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 { 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', 'mig@example.com', '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 { 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 { 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 { 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', 'x@example.com', '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, ); });