- Schema III: per-user notifications (broadcast fan-out migration, category, link, requiredPermissions filter), notification preferences and settings endpoints, calendar withdrawal switch, occupiedFrames - Suggestions API: list/summary with permission visibility and paging, accept/dismiss/reopen incl. bulk, SuggestionView on all responses - Varroa traffic light by season and method, young colonies, treatment effect window, sample-based percentage; shared withdrawal-window helper on Berlin calendar days; GET /varroa-measurements/thresholds - ColonySplit and QueenAssigned domain events with calendar and webhook handlers - Outbox claims compare against now() rounded to timestamp(3); fixes the flaky e2e claims of rows written with SQL now() Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
475 lines
15 KiB
TypeScript
475 lines
15 KiB
TypeScript
import {
|
|
ScratchDatabase,
|
|
createScratchDatabase,
|
|
} from './utils/migration-harness';
|
|
|
|
const FEED_VOCABULARY = '20261014100200_t4_feed_vocabulary';
|
|
const SPLIT_QUEEN_SOURCE = '20261014100300_t4_split_queen_source';
|
|
const VARROA_SAMPLE_SIZE = '20261014100400_t4_varroa_sample_size';
|
|
const NOTIFICATION_FANOUT = '20261014100500_t4_notification_fanout';
|
|
const OCCUPIED_FRAMES = '20261014100700_t4_occupied_frames';
|
|
const NOTIFICATION_SETTINGS = '20261014100800_t4_notification_settings';
|
|
|
|
const TIMEOUT_MS = 240_000;
|
|
|
|
interface InspectionSeed {
|
|
id: string;
|
|
createdAt: string;
|
|
feedStatus: string | null;
|
|
notes: string | null;
|
|
}
|
|
|
|
/** Minimal FK targets, written as raw SQL (Prisma ids and updatedAt have no DB default). */
|
|
async function seedColony(db: ScratchDatabase): Promise<void> {
|
|
await db.query(
|
|
`INSERT INTO "Role" ("id", "key", "name", "updatedAt")
|
|
VALUES ('role-1', 'MIG_TEST', 'Migration test', now())`,
|
|
);
|
|
await db.query(
|
|
`INSERT INTO "User" ("id", "email", "passwordHash", "name", "roleId", "updatedAt")
|
|
VALUES ('user-1', '[email protected]', 'x', 'Mig', 'role-1', now())`,
|
|
);
|
|
await db.query(
|
|
`INSERT INTO "Apiary" ("id", "name", "updatedAt")
|
|
VALUES ('apiary-1', 'Stand', now())`,
|
|
);
|
|
await db.query(
|
|
`INSERT INTO "Colony" ("id", "code", "apiaryId", "colonyType", "updatedAt")
|
|
VALUES ('colony-1', 'V-1', 'apiary-1', 'PRODUCTION_COLONY', now())`,
|
|
);
|
|
}
|
|
|
|
/** Two active users and one inactive one, plus the role they need. */
|
|
async function seedUsers(db: ScratchDatabase): Promise<void> {
|
|
await db.query(
|
|
`INSERT INTO "Role" ("id", "key", "name", "updatedAt")
|
|
VALUES ('role-1', 'MIG_TEST', 'Migration test', now())`,
|
|
);
|
|
for (const [id, active] of [
|
|
['user-a', true],
|
|
['user-b', true],
|
|
['user-c', false],
|
|
] as const) {
|
|
await db.query(
|
|
`INSERT INTO "User" ("id", "email", "passwordHash", "name", "roleId", "isActive", "updatedAt")
|
|
VALUES ($1, $2, 'x', $1, 'role-1', $3, now())`,
|
|
[id, `${id}@example.com`, active],
|
|
);
|
|
}
|
|
}
|
|
|
|
async function seedNotification(
|
|
db: ScratchDatabase,
|
|
row: {
|
|
id: string;
|
|
userId: string | null;
|
|
relatedType: string | null;
|
|
read?: boolean;
|
|
createdAt?: string;
|
|
},
|
|
): Promise<void> {
|
|
await db.query(
|
|
`INSERT INTO "Notification" ("id", "userId", "priority", "message", "relatedType", "read", "createdAt")
|
|
VALUES ($1, $2, 'WARNUNG', 'm', $3, $4, $5)`,
|
|
[
|
|
row.id,
|
|
row.userId,
|
|
row.relatedType,
|
|
row.read ?? false,
|
|
row.createdAt ?? '2026-05-01T10:00:00Z',
|
|
],
|
|
);
|
|
}
|
|
|
|
async function seedInspections(
|
|
db: ScratchDatabase,
|
|
seeds: InspectionSeed[],
|
|
): Promise<void> {
|
|
for (const seed of seeds) {
|
|
await db.query(
|
|
`INSERT INTO "Inspection" ("id", "colonyId", "userId", "date", "feedStatus", "notes", "createdAt")
|
|
VALUES ($1, 'colony-1', 'user-1', '2026-05-01', $2, $3, $4)`,
|
|
[seed.id, seed.feedStatus, seed.notes, seed.createdAt],
|
|
);
|
|
}
|
|
}
|
|
|
|
describe('T4 schema II migrations (e2e)', () => {
|
|
let db: ScratchDatabase | null = null;
|
|
|
|
afterEach(async () => {
|
|
if (db) {
|
|
await db.drop();
|
|
db = null;
|
|
}
|
|
});
|
|
|
|
it(
|
|
'free-text feed moves into notes and known words map to the enum',
|
|
async () => {
|
|
db = await createScratchDatabase(FEED_VOCABULARY);
|
|
await seedColony(db);
|
|
await seedInspections(db, [
|
|
{
|
|
id: 'i-1',
|
|
createdAt: '2026-05-01T10:00:00Z',
|
|
feedStatus: 'niedrig',
|
|
notes: null,
|
|
},
|
|
{
|
|
id: 'i-2',
|
|
createdAt: '2026-05-01T11:00:00Z',
|
|
feedStatus: 'halb voll',
|
|
notes: null,
|
|
},
|
|
{
|
|
id: 'i-3',
|
|
createdAt: '2026-05-01T12:00:00Z',
|
|
feedStatus: 'halb voll',
|
|
notes: 'x',
|
|
},
|
|
]);
|
|
|
|
await db.migrateThrough(FEED_VOCABULARY);
|
|
|
|
expect(
|
|
await db.query(
|
|
'SELECT "feedStatus", notes FROM "Inspection" ORDER BY "createdAt"',
|
|
),
|
|
).toEqual([
|
|
{ feedStatus: 'WEAK', notes: null },
|
|
{ feedStatus: null, notes: 'Futter (alt): halb voll' },
|
|
{ feedStatus: null, notes: 'x\nFutter (alt): halb voll' },
|
|
]);
|
|
},
|
|
TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
'leaves no inspection whose old feed text was lost',
|
|
async () => {
|
|
db = await createScratchDatabase(FEED_VOCABULARY);
|
|
await seedColony(db);
|
|
const rawSeeds: [string | null, string | null][] = [
|
|
[' Ausreichend ', null],
|
|
['KRITISCH', 'Hinweis'],
|
|
['Niedrig', ''],
|
|
['sehr viel', 'a'],
|
|
[' Wabe voll ', null],
|
|
['', 'leer'],
|
|
[' ', null],
|
|
[null, 'ohne'],
|
|
['\tniedrig', null],
|
|
['\r\n Halb\tvoll\n', null],
|
|
];
|
|
const seeds: InspectionSeed[] = rawSeeds.map(
|
|
([feedStatus, notes], index) => ({
|
|
id: `i-${index}`,
|
|
createdAt: `2026-05-01T0${index}:00:00Z`,
|
|
feedStatus,
|
|
notes,
|
|
}),
|
|
);
|
|
await seedInspections(db, seeds);
|
|
|
|
await db.migrateThrough(FEED_VOCABULARY);
|
|
|
|
const rows = await db.query<{
|
|
id: string;
|
|
feedStatus: string | null;
|
|
notes: string | null;
|
|
}>('SELECT id, "feedStatus", notes FROM "Inspection" ORDER BY id');
|
|
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
|
|
// Spec 4.12: every old text ends up as an enum value or in the notes.
|
|
const known = ['ausreichend', 'niedrig', 'kritisch'];
|
|
for (const seed of seeds) {
|
|
const text = seed.feedStatus?.trim() ?? '';
|
|
if (text === '') {
|
|
continue;
|
|
}
|
|
const row = byId.get(seed.id)!;
|
|
if (known.includes(text.toLowerCase())) {
|
|
expect(row.feedStatus).not.toBeNull();
|
|
} else {
|
|
expect(row.feedStatus).toBeNull();
|
|
expect(row.notes).toContain(`Futter (alt): ${text}`);
|
|
}
|
|
}
|
|
expect(
|
|
rows.filter(
|
|
(row) => row.feedStatus === null && (row.notes ?? '') === '',
|
|
),
|
|
).toEqual([
|
|
// Only the blank text with no notes remains empty - nothing was lost.
|
|
{ id: 'i-6', feedStatus: null, notes: null },
|
|
]);
|
|
expect(byId.get('i-1')?.notes).toBe('Hinweis');
|
|
expect(byId.get('i-3')?.notes).toBe('a\nFutter (alt): sehr viel');
|
|
expect(byId.get('i-5')?.notes).toBe('leer');
|
|
expect(byId.get('i-7')?.notes).toBe('ohne');
|
|
expect(byId.get('i-8')).toMatchObject({
|
|
feedStatus: 'WEAK',
|
|
notes: null,
|
|
});
|
|
expect(byId.get('i-9')).toMatchObject({
|
|
feedStatus: null,
|
|
notes: 'Futter (alt): Halb\tvoll',
|
|
});
|
|
},
|
|
TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
'adds the optional split queen source and varroa sample size without touching old rows',
|
|
async () => {
|
|
db = await createScratchDatabase(SPLIT_QUEEN_SOURCE);
|
|
await seedColony(db);
|
|
await db.query(
|
|
`INSERT INTO "Colony" ("id", "code", "apiaryId", "colonyType", "updatedAt")
|
|
VALUES ('colony-2', 'V-2', 'apiary-1', 'NUCLEUS_COLONY', now())`,
|
|
);
|
|
await db.query(
|
|
`INSERT INTO "ColonyLineage" ("id", "type", "fromColonyId", "toColonyId", "date")
|
|
VALUES ('l-1', 'SPLIT', 'colony-1', 'colony-2', '2026-05-01')`,
|
|
);
|
|
await db.query(
|
|
`INSERT INTO "VarroaMeasurement" ("id", "colonyId", "userId", "date", "method", "miteCount")
|
|
VALUES ('v-1', 'colony-1', 'user-1', '2026-05-02', 'WASHING_METHOD', 4)`,
|
|
);
|
|
|
|
await db.migrateThrough(SPLIT_QUEEN_SOURCE);
|
|
await db.migrateThrough(VARROA_SAMPLE_SIZE);
|
|
|
|
expect(
|
|
await db.query('SELECT "queenSource" FROM "ColonyLineage"'),
|
|
).toEqual([{ queenSource: null }]);
|
|
expect(
|
|
await db.query(
|
|
'SELECT "sampleBees", "miteCount" FROM "VarroaMeasurement"',
|
|
),
|
|
).toEqual([{ sampleBees: null, miteCount: 4 }]);
|
|
await db.query(
|
|
`UPDATE "ColonyLineage" SET "queenSource" = 'RAISE_OWN' WHERE id = 'l-1'`,
|
|
);
|
|
await expect(
|
|
db.query(
|
|
`UPDATE "ColonyLineage" SET "queenSource" = 'BOUGHT' WHERE id = 'l-1'`,
|
|
),
|
|
).rejects.toThrow();
|
|
},
|
|
TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
'broadcasts are copied per active user with their read flag',
|
|
async () => {
|
|
db = await createScratchDatabase(NOTIFICATION_FANOUT);
|
|
await seedUsers(db);
|
|
await seedNotification(db, {
|
|
id: 'broadcast',
|
|
userId: null,
|
|
relatedType: 'COLONY',
|
|
read: true,
|
|
createdAt: '2026-04-01T08:00:00Z',
|
|
});
|
|
await seedNotification(db, {
|
|
id: 'personal',
|
|
userId: 'user-c',
|
|
relatedType: 'COLONY',
|
|
});
|
|
|
|
const [{ createdAt }] = await db.query<{ createdAt: string }>(
|
|
`SELECT "createdAt"::text AS "createdAt" FROM "Notification" WHERE "id" = 'broadcast'`,
|
|
);
|
|
|
|
await db.migrateThrough(NOTIFICATION_FANOUT);
|
|
|
|
const rows = await db.query<{
|
|
userId: string;
|
|
read: boolean;
|
|
createdAt: string;
|
|
}>(
|
|
`SELECT "userId", "read", "createdAt"::text AS "createdAt"
|
|
FROM "Notification" `,
|
|
);
|
|
rows.sort((a, b) => (a.userId < b.userId ? -1 : 1));
|
|
expect(rows.map(({ userId, read }) => ({ userId, read }))).toEqual([
|
|
{ userId: 'user-a', read: true },
|
|
{ userId: 'user-b', read: true },
|
|
{ userId: 'user-c', read: false },
|
|
]);
|
|
expect(rows[0].createdAt).toBe(createdAt);
|
|
expect(rows[1].createdAt).toBe(createdAt);
|
|
expect(
|
|
await db.query(
|
|
`SELECT is_nullable FROM information_schema.columns
|
|
WHERE table_schema = current_schema() AND table_name = 'Notification' AND column_name = 'userId'`,
|
|
),
|
|
).toEqual([{ is_nullable: 'NO' }]);
|
|
},
|
|
TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
'drops broadcasts when no user is active and keeps the personal rows',
|
|
async () => {
|
|
db = await createScratchDatabase(NOTIFICATION_FANOUT);
|
|
await db.query(
|
|
`INSERT INTO "Role" ("id", "key", "name", "updatedAt")
|
|
VALUES ('role-1', 'MIG_TEST', 'Migration test', now())`,
|
|
);
|
|
await db.query(
|
|
`INSERT INTO "User" ("id", "email", "passwordHash", "name", "roleId", "isActive", "updatedAt")
|
|
VALUES ('user-x', '[email protected]', 'x', 'x', 'role-1', false, now())`,
|
|
);
|
|
await seedNotification(db, {
|
|
id: 'broadcast',
|
|
userId: null,
|
|
relatedType: null,
|
|
});
|
|
await seedNotification(db, {
|
|
id: 'personal',
|
|
userId: 'user-x',
|
|
relatedType: null,
|
|
});
|
|
|
|
await db.migrateThrough(NOTIFICATION_FANOUT);
|
|
|
|
expect(await db.query('SELECT "id" FROM "Notification"')).toEqual([
|
|
{ id: 'personal' },
|
|
]);
|
|
},
|
|
TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
'copies carry no dedupeKey, old rows get empty requiredPermissions, and the preference defaults hold',
|
|
async () => {
|
|
db = await createScratchDatabase(NOTIFICATION_FANOUT);
|
|
await seedUsers(db);
|
|
await seedNotification(db, {
|
|
id: 'broadcast',
|
|
userId: null,
|
|
relatedType: 'COLONY',
|
|
});
|
|
await seedNotification(db, {
|
|
id: 'personal',
|
|
userId: 'user-a',
|
|
relatedType: 'COLONY',
|
|
});
|
|
|
|
await db.migrateThrough(NOTIFICATION_SETTINGS);
|
|
|
|
expect(
|
|
await db.query(
|
|
`SELECT count(*)::int AS "rows",
|
|
count("dedupeKey")::int AS "keys",
|
|
count(*) FILTER (WHERE "requiredPermissions" = '{}')::int AS "emptyPermissions",
|
|
count("link")::int AS "links"
|
|
FROM "Notification"`,
|
|
),
|
|
).toEqual([{ rows: 3, keys: 0, emptyPermissions: 3, links: 0 }]);
|
|
|
|
await db.query(
|
|
`INSERT INTO "NotificationPreference" ("userId", "updatedAt") VALUES ('user-a', now())`,
|
|
);
|
|
expect(
|
|
await db.query(
|
|
`SELECT "taskDueEnabled", "digestEnabled", "digestTime", "quietFrom", "quietUntil",
|
|
"telegramEnabled", "telegramCategories"::text AS "telegramCategories", "telegramDetail"
|
|
FROM "NotificationPreference"`,
|
|
),
|
|
).toEqual([
|
|
{
|
|
taskDueEnabled: true,
|
|
digestEnabled: true,
|
|
digestTime: '06:30',
|
|
quietFrom: '21:00',
|
|
quietUntil: '06:30',
|
|
telegramEnabled: false,
|
|
telegramCategories: '{TASK_DUE,DIGEST}',
|
|
telegramDetail: true,
|
|
},
|
|
]);
|
|
},
|
|
TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
'legacy job-failure rows (relatedType JobRun) and rows without relatedType get SYSTEM, all others SUGGESTION, before NOT NULL',
|
|
async () => {
|
|
db = await createScratchDatabase(NOTIFICATION_FANOUT);
|
|
await seedUsers(db);
|
|
await seedNotification(db, {
|
|
id: 'n-job',
|
|
userId: 'user-a',
|
|
relatedType: 'JobRun',
|
|
});
|
|
await seedNotification(db, {
|
|
id: 'n-none',
|
|
userId: 'user-a',
|
|
relatedType: null,
|
|
});
|
|
await seedNotification(db, {
|
|
id: 'n-colony',
|
|
userId: 'user-a',
|
|
relatedType: 'COLONY',
|
|
});
|
|
await seedNotification(db, {
|
|
id: 'n-broadcast',
|
|
userId: null,
|
|
relatedType: 'JobRun',
|
|
});
|
|
|
|
await db.migrateThrough(NOTIFICATION_SETTINGS);
|
|
|
|
expect(
|
|
await db.query(
|
|
`SELECT "id", "category" FROM "Notification" WHERE "id" LIKE 'n-%' ORDER BY "id"`,
|
|
),
|
|
).toEqual([
|
|
{ id: 'n-colony', category: 'SUGGESTION' },
|
|
{ id: 'n-job', category: 'SYSTEM' },
|
|
{ id: 'n-none', category: 'SYSTEM' },
|
|
]);
|
|
// The broadcast copies carry relatedType JobRun too.
|
|
expect(
|
|
await db.query(
|
|
`SELECT DISTINCT "category" FROM "Notification" WHERE "relatedType" = 'JobRun' AND "id" NOT LIKE 'n-%'`,
|
|
),
|
|
).toEqual([{ category: 'SYSTEM' }]);
|
|
expect(
|
|
await db.query(
|
|
`SELECT is_nullable FROM information_schema.columns
|
|
WHERE table_schema = current_schema() AND table_name = 'Notification' AND column_name = 'category'`,
|
|
),
|
|
).toEqual([{ is_nullable: 'NO' }]);
|
|
},
|
|
TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
'occupied frames migration leaves strength untouched',
|
|
async () => {
|
|
db = await createScratchDatabase(OCCUPIED_FRAMES);
|
|
await seedColony(db);
|
|
await db.query(`UPDATE "Colony" SET "strength" = 7`);
|
|
|
|
await db.migrateThrough(OCCUPIED_FRAMES);
|
|
|
|
expect(
|
|
await db.query('SELECT "strength", "occupiedFrames" FROM "Colony"'),
|
|
).toEqual([{ strength: 7, occupiedFrames: null }]);
|
|
await db.query(`UPDATE "Colony" SET "occupiedFrames" = 40`);
|
|
await expect(
|
|
db.query(`UPDATE "Colony" SET "occupiedFrames" = 41`),
|
|
).rejects.toThrow();
|
|
await expect(
|
|
db.query(`UPDATE "Colony" SET "occupiedFrames" = -1`),
|
|
).rejects.toThrow();
|
|
},
|
|
TIMEOUT_MS,
|
|
);
|
|
});
|