import { ScratchDatabase, createScratchDatabase, } from './utils/migration-harness'; import { INVARIANT_SQL } from './utils/honey-ledger'; const PHENOLOGY = '20261015100000_t4_phenology'; const HONEY_LEDGER = '20261015100100_t4_honey_ledger'; const PRODUCT_SHELF_LIFE = '20261015100200_t4_product_shelf_life'; const TIMEOUT_MS = 240_000; async function seedUser(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())`, ); } async function seedHoney( db: ScratchDatabase, batches: { id: string; amount: number | null }[], ): Promise { await seedUser(db); await db.query( `INSERT INTO "Extraction" ("id", "date", "userId") VALUES ('ext-1', now(), 'user-1')`, ); for (const batch of batches) { await db.query( `INSERT INTO "HoneyBatch" ("id", "batchNumber", "extractionId", "amount", "updatedAt") VALUES ($1, $1, 'ext-1', $2, now())`, [batch.id, batch.amount], ); } } async function seedBottling( db: ScratchDatabase, id: string, honeyBatchId: string, totalAmount: number, createdAt: string, ): Promise { await db.query( `INSERT INTO "BottlingBatch" ("id", "honeyBatchId", "date", "jarSize", "jarCount", "totalAmount", "bestBeforeDate", "lotNumber", "createdAt") VALUES ($1, $2, $4, 500, 10, $3, '2028-01-01', $1, $4)`, [id, honeyBatchId, totalAmount, createdAt], ); } describe('T4 schema III migrations (e2e)', () => { let db: ScratchDatabase | null = null; afterEach(async () => { if (db) { await db.drop(); db = null; } }); it( 'legacy bottlings keep amount = initialAmount + movements', async () => { db = await createScratchDatabase(HONEY_LEDGER); await seedHoney(db, [{ id: 'hb-1', amount: 20 }]); await seedBottling(db, 'bt-1', 'hb-1', 7.5, '2026-08-01T10:00:00Z'); await seedBottling(db, 'bt-2', 'hb-1', 2.5, '2026-08-02T10:00:00Z'); await db.migrateThrough(HONEY_LEDGER); expect( await db.query('SELECT "initialAmount", "amount" FROM "HoneyBatch"'), ).toEqual([{ initialAmount: 30, amount: 20 }]); const movements = await db.query<{ type: string; quantity: number; honeyBatchId: string; sourceBottlingBatchId: string; notes: string; sameTime: boolean; }>( `SELECT m."type"::text AS type, m."quantity", m."honeyBatchId", m."sourceBottlingBatchId", m."notes", (m."createdAt" = b."createdAt") AS "sameTime" FROM "BatchMovement" m JOIN "BottlingBatch" b ON b."id" = m."sourceBottlingBatchId" ORDER BY m."quantity"`, ); expect(movements).toEqual([ { type: 'BOTTLING', quantity: -7500, honeyBatchId: 'hb-1', sourceBottlingBatchId: 'bt-1', notes: 'Übernahme aus Abfüllung (T4)', sameTime: true, }, { type: 'BOTTLING', quantity: -2500, honeyBatchId: 'hb-1', sourceBottlingBatchId: 'bt-2', notes: 'Übernahme aus Abfüllung (T4)', sameTime: true, }, ]); expect(await db.query(INVARIANT_SQL)).toEqual([]); }, TIMEOUT_MS, ); it( 'a batch without amount gets no initialAmount and no booking', async () => { db = await createScratchDatabase(HONEY_LEDGER); await seedHoney(db, [{ id: 'hb-1', amount: null }]); await seedBottling(db, 'bt-1', 'hb-1', 5, '2026-08-01T10:00:00Z'); await db.migrateThrough(HONEY_LEDGER); expect( await db.query('SELECT "initialAmount" FROM "HoneyBatch"'), ).toEqual([{ initialAmount: null }]); expect(await db.query('SELECT 1 FROM "BatchMovement"')).toEqual([]); }, TIMEOUT_MS, ); it( 'a batch without bottlings gets initialAmount = amount and no booking', async () => { db = await createScratchDatabase(HONEY_LEDGER); await seedHoney(db, [{ id: 'hb-1', amount: 12.345 }]); await db.migrateThrough(HONEY_LEDGER); expect( await db.query('SELECT "initialAmount" FROM "HoneyBatch"'), ).toEqual([{ initialAmount: 12.345 }]); expect(await db.query('SELECT 1 FROM "BatchMovement"')).toEqual([]); }, TIMEOUT_MS, ); it( 'a movement belongs to exactly one of the four batch kinds', async () => { db = await createScratchDatabase(HONEY_LEDGER); await seedHoney(db, [{ id: 'hb-1', amount: 20 }]); await seedBottling(db, 'bt-1', 'hb-1', 5, '2026-08-01T10:00:00Z'); await db.migrateThrough(HONEY_LEDGER); await db.query( `INSERT INTO "BatchMovement" ("id", "honeyBatchId", "type", "quantity") VALUES ('m-ok', 'hb-1', 'CORRECTION', 100)`, ); await expect( db.query( `INSERT INTO "BatchMovement" ("id", "honeyBatchId", "bottlingBatchId", "type", "quantity") VALUES ('m-two', 'hb-1', 'bt-1', 'CORRECTION', 100)`, ), ).rejects.toThrow(); await expect( db.query( `INSERT INTO "BatchMovement" ("id", "type", "quantity") VALUES ('m-none', 'CORRECTION', 100)`, ), ).rejects.toThrow(); }, TIMEOUT_MS, ); it( 'a bottling delete keeps its movements and clears the back reference', async () => { db = await createScratchDatabase(HONEY_LEDGER); await seedHoney(db, [{ id: 'hb-1', amount: 20 }]); await seedBottling(db, 'bt-1', 'hb-1', 5, '2026-08-01T10:00:00Z'); await db.migrateThrough(HONEY_LEDGER); await db.query(`DELETE FROM "BottlingBatch" WHERE "id" = 'bt-1'`); expect( await db.query( `SELECT "sourceBottlingBatchId" FROM "BatchMovement" WHERE "honeyBatchId" = 'hb-1'`, ), ).toEqual([{ sourceBottlingBatchId: null }]); }, TIMEOUT_MS, ); it( 'rounds each booking half up to the gram and keeps initialAmount consistent with it', async () => { db = await createScratchDatabase(HONEY_LEDGER); await seedHoney(db, [{ id: 'hb-1', amount: 1 }]); await seedBottling(db, 'bt-1', 'hb-1', 0.0125, '2026-08-01T10:00:00Z'); await db.migrateThrough(HONEY_LEDGER); expect(await db.query('SELECT "quantity" FROM "BatchMovement"')).toEqual([ { quantity: -13 }, ]); expect(await db.query(INVARIANT_SQL)).toEqual([]); }, TIMEOUT_MS, ); it( 'a mixed run keeps the invariant for every batch', async () => { db = await createScratchDatabase(HONEY_LEDGER); await seedHoney(db, [ { id: 'hb-1', amount: 20.0004 }, { id: 'hb-2', amount: null }, { id: 'hb-3', amount: 3.3333 }, { id: 'hb-4', amount: 8 }, ]); await seedBottling(db, 'bt-1', 'hb-1', 0.0125, '2026-08-01T10:00:00Z'); await seedBottling(db, 'bt-2', 'hb-1', 4.4446, '2026-08-02T10:00:00Z'); await seedBottling(db, 'bt-3', 'hb-1', 0.6667, '2026-08-03T10:00:00Z'); await seedBottling(db, 'bt-4', 'hb-2', 2, '2026-08-04T10:00:00Z'); await seedBottling(db, 'bt-5', 'hb-3', 1.1111, '2026-08-05T10:00:00Z'); await db.migrateThrough(HONEY_LEDGER); expect(await db.query(INVARIANT_SQL)).toEqual([]); expect( await db.query( 'SELECT count(*)::int AS n FROM "BatchMovement" WHERE "honeyBatchId" IS NOT NULL', ), ).toEqual([{ n: 4 }]); }, TIMEOUT_MS, ); it( 'a movement that already belongs to a bottling survives the CHECK and is not double-booked', async () => { db = await createScratchDatabase(HONEY_LEDGER); await seedHoney(db, [{ id: 'hb-1', amount: 20 }]); await seedBottling(db, 'bt-1', 'hb-1', 5, '2026-08-01T10:00:00Z'); await db.query( `INSERT INTO "BatchMovement" ("id", "bottlingBatchId", "type", "quantity") VALUES ('m-shop', 'bt-1', 'SHOP_SALE', -3)`, ); await db.migrateThrough(HONEY_LEDGER); expect( await db.query( `SELECT "id", "honeyBatchId" FROM "BatchMovement" WHERE "bottlingBatchId" = 'bt-1'`, ), ).toEqual([{ id: 'm-shop', honeyBatchId: null }]); expect( await db.query( `SELECT "quantity" FROM "BatchMovement" WHERE "honeyBatchId" = 'hb-1'`, ), ).toEqual([{ quantity: -5000 }]); }, TIMEOUT_MS, ); it( 'a bottling without a user migrates with a null userId', async () => { db = await createScratchDatabase(HONEY_LEDGER); await seedHoney(db, [{ id: 'hb-1', amount: 20 }]); await seedBottling(db, 'bt-1', 'hb-1', 5, '2026-08-01T10:00:00Z'); await db.migrateThrough(HONEY_LEDGER); expect(await db.query('SELECT "userId" FROM "BatchMovement"')).toEqual([ { userId: null }, ]); }, TIMEOUT_MS, ); it( 'indexes phenology reports by apiary, phase and date', async () => { db = await createScratchDatabase(PHENOLOGY); await db.migrateThrough(PHENOLOGY); const rows = await db.query<{ indexdef: string }>( `SELECT indexdef FROM pg_indexes WHERE schemaname = current_schema() AND tablename = 'PhenologyReport' AND indexname = 'PhenologyReport_apiaryId_phase_date_idx'`, ); expect(rows).toHaveLength(1); }, TIMEOUT_MS, ); it( 'phenology rights go to non-system roles: read with colonies:read, create/update with inspections:create, system roles get all', async () => { db = await createScratchDatabase(PHENOLOGY); for (const [id, isSystem] of [ ['r-reader', false], ['r-keeper', false], ['r-none', false], ['r-system', true], ] as const) { await db.query( `INSERT INTO "Role" ("id", "key", "name", "isSystem", "updatedAt") VALUES ($1, $1, $1, $2, now())`, [id, isSystem], ); } // colonies:read and inspections:create come from earlier migrations. const grant = (roleId: string, key: string) => db!.query( `INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt") SELECT $1, "id", now() FROM "Permission" WHERE "key" = $2`, [roleId, key], ); await grant('r-reader', 'colonies:read'); await grant('r-keeper', 'colonies:read'); await grant('r-keeper', 'inspections:create'); await grant('r-system', 'colonies:read'); await grant('r-system', 'inspections:create'); await db.migrateThrough(PHENOLOGY); expect( await db.query( `SELECT p."key", p."groupDe", p."sortOrder" FROM "Permission" p WHERE p."resource" = 'phenology' ORDER BY p."sortOrder"`, ), ).toEqual([ { key: 'phenology:read', groupDe: 'Pflanzenentwicklung', sortOrder: 860, }, { key: 'phenology:create', groupDe: 'Pflanzenentwicklung', sortOrder: 861, }, { key: 'phenology:update', groupDe: 'Pflanzenentwicklung', sortOrder: 862, }, ]); expect( await db.query( `SELECT rp."roleId", p."key" FROM "RolePermission" rp JOIN "Permission" p ON p."id" = rp."permissionId" WHERE p."resource" = 'phenology' AND rp."roleId" LIKE 'r-%' ORDER BY rp."roleId", p."key"`, ), ).toEqual([ { roleId: 'r-keeper', key: 'phenology:create' }, { roleId: 'r-keeper', key: 'phenology:read' }, { roleId: 'r-keeper', key: 'phenology:update' }, { roleId: 'r-reader', key: 'phenology:read' }, { roleId: 'r-system', key: 'phenology:create' }, { roleId: 'r-system', key: 'phenology:read' }, { roleId: 'r-system', key: 'phenology:update' }, ]); }, TIMEOUT_MS, ); it( 'Product.shelfLifeMonths is nullable and has no default', async () => { db = await createScratchDatabase(PRODUCT_SHELF_LIFE); await db.query( `INSERT INTO "Product" ("id", "name", "updatedAt") VALUES ('p-1', 'Honig', now())`, ); await db.migrateThrough(PRODUCT_SHELF_LIFE); expect(await db.query('SELECT "shelfLifeMonths" FROM "Product"')).toEqual( [{ shelfLifeMonths: null }], ); }, TIMEOUT_MS, ); });