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 TIMEOUT_MS = 120_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())`, ); } 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, ); });