Files
ApiCore/ApiCore-Backend/test/honey-ledger-migration.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 22382d53a2 feat: T4b wave 7 - schema for phenology, honey movement ledger, shelf life
- Phenology reports, apiary phenology state and weather days with
  permissions for system roles
- Honey movement ledger: BOTTLING movements and initialAmount backfilled
  from existing bottlings with the same gram rounding; the migration
  reports counts and aborts if the ledger invariant fails
- Shelf life and event catalog columns; backup export/import of the new
  tables, old bundles keep importing, invariant check after imports
- e2e: test timeout 20 s (app start in hooks exceeded 5 s under load)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 19:19:32 +02:00

391 lines
12 KiB
TypeScript

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<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())`,
);
}
async function seedHoney(
db: ScratchDatabase,
batches: { id: string; amount: number | null }[],
): Promise<void> {
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<void> {
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,
);
});