- 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]>
391 lines
12 KiB
TypeScript
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,
|
|
);
|
|
});
|