Files
ApiCore/ApiCore-Backend/test/backup.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 b48fb157c8
Backend Image / build-and-push (push) Successful in 5m5s
Frontend Image / build-and-push (push) Successful in 11m2s
feat: hive setup data model and API (Beute L1)
Each colony can now hold its hive setup (Ist-Zustand) as validated
JSON with an append-only change log:

- catalog: hive systems and part types with dimensions, placement
  rules and state fields; Liebig-Zander 10 seeded with fixed ids
  (owner's equipment incl. Bausperre, Futterzarge, Nassenheider)
- pure domain module (operations, validator, derived facts) with
  shared JSON fixtures the frontend runs too
- API: catalog (hive-systems:manage), setup create/change/history/
  state at a day (colonies:read/update), 409 HIVE_CONFLICT with the
  current state, idempotent writes, HiveChanged event
- inspections can carry hive ops saved in the same transaction
- backup export/import of the new tables (backup wins per colony)
- docs: ARCHITECTURE section 13, Annahmen 95-100

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

806 lines
24 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { AutomationService } from '../src/automation/automation.service';
import { BackupBundle, BackupService } from '../src/backup/backup.service';
import { createTestApp } from './utils/create-test-app';
type Row = Record<string, unknown>;
// Fixtures plus whole-database exports of the shared test database - well
// beyond the 5 s default when other specs run alongside.
const ROUND_TRIP_TIMEOUT_MS = 60_000;
// Wipe order: every row before the rows it references.
const SECTIONS_CHILD_FIRST = [
'batchMovement',
'productionInput',
'shopStockRequest',
'productionBatch',
'rawMaterialLot',
'bottlingBatch',
'honeyBatchContainerAllocation',
'honeyBatch',
'storageContainer',
'extraction',
'harvest',
'veterinaryMedicineMovement',
'treatment',
'veterinaryMedicineLot',
'veterinaryMedicine',
'complianceAcknowledgement',
'authorityNotice',
'complianceSettings',
'colony',
'apiaryRestriction',
'apiary',
'product',
'automationSequence',
'calendarSyncOutboxEntry',
] as const;
type Section = (typeof SECTIONS_CHILD_FIRST)[number];
// AutomationSequence has no id of its own.
function identity(row: Row): string {
return typeof row.id === 'string'
? row.id
: `${String(row.triggerType)}|${String(row.scope)}`;
}
/**
* Export -> loss -> import against the real database: production, raw
* material, stock ledger, number sequence and calendar-sync rows come back
* exactly as exported, which also proves the import writes every parent
* before the rows that reference it. Only this spec's own rows are wiped and
* re-imported, so specs running alongside never see their data rewritten.
*/
describe('Backup round trip (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
let backup: BackupService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const own: Record<Section, Set<string>> = Object.fromEntries(
SECTIONS_CHILD_FIRST.map((section) => [section, new Set<string>()]),
) as Record<Section, Set<string>>;
let userId: string;
let exported: Record<Section, Row[]>;
const remember = <T extends { id: string }>(section: Section, row: T): T => {
own[section].add(row.id);
return row;
};
function ownRows(bundle: BackupBundle): Record<Section, Row[]> {
return Object.fromEntries(
SECTIONS_CHILD_FIRST.map((section) => [
section,
((bundle.data[section] ?? []) as Row[]).filter((row) =>
own[section].has(identity(row)),
),
]),
) as Record<Section, Row[]>;
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
backup = app.get(BackupService);
// Makes sure the number-format rules exist (the sequence references one).
await app.get(AutomationService).findAll();
const user = await prisma.user.create({
data: {
email: `backup-${suffix}@example.com`,
passwordHash: 'not-used-for-login',
name: 'Backup',
role: { connect: { key: 'BEEKEEPER' } },
},
});
userId = user.id;
const day = new Date('2026-07-01T00:00:00.000Z');
const apiary = remember(
'apiary',
await prisma.apiary.create({ data: { name: `Stand ${suffix}` } }),
);
const harvest = remember(
'harvest',
await prisma.harvest.create({
data: { apiaryId: apiary.id, userId, date: day },
}),
);
const extraction = remember(
'extraction',
await prisma.extraction.create({ data: { date: day, userId } }),
);
const container = remember(
'storageContainer',
await prisma.storageContainer.create({ data: { code: `E-${suffix}` } }),
);
const honeyBatch = remember(
'honeyBatch',
await prisma.honeyBatch.create({
data: { batchNumber: `H-${suffix}`, extractionId: extraction.id },
}),
);
remember(
'honeyBatchContainerAllocation',
await prisma.honeyBatchContainerAllocation.create({
data: {
honeyBatchId: honeyBatch.id,
storageContainerId: container.id,
amount: 25,
},
}),
);
const honey = remember(
'product',
await prisma.product.create({ data: { name: `Honig ${suffix}` } }),
);
const candle = remember(
'product',
await prisma.product.create({
data: {
name: `Kerze ${suffix}`,
kind: 'MANUFACTURED',
shopCategory: 'KERZEN',
},
}),
);
const bottling = remember(
'bottlingBatch',
await prisma.bottlingBatch.create({
data: {
honeyBatchId: honeyBatch.id,
productId: honey.id,
date: day,
jarSize: 500,
jarCount: 10,
totalAmount: 5,
bestBeforeDate: new Date('2028-07-01T00:00:00.000Z'),
lotNumber: `L-${suffix}`,
userId,
},
}),
);
const wax = remember(
'rawMaterialLot',
await prisma.rawMaterialLot.create({
data: {
kind: 'BEESWAX',
lotNumber: `W-${suffix}`,
date: day,
quantity: 1000,
apiaryId: apiary.id,
harvestId: harvest.id,
userId,
},
}),
);
const production = remember(
'productionBatch',
await prisma.productionBatch.create({
data: {
productId: candle.id,
lotNumber: `K-${suffix}`,
date: day,
quantity: 12,
userId,
},
}),
);
remember(
'productionInput',
await prisma.productionInput.create({
data: {
productionBatchId: production.id,
rawMaterialLotId: wax.id,
quantity: 300,
},
}),
);
const shopRequest = remember(
'shopStockRequest',
await prisma.shopStockRequest.create({
data: {
externalId: `order-${suffix}`,
kind: 'SALE',
bottlingBatchId: bottling.id,
quantity: 2,
apiKeyId: 'key-not-in-backup',
},
}),
);
for (const data of [
{
bottlingBatchId: bottling.id,
type: 'SHOP_SALE' as const,
quantity: -2,
shopRequestId: shopRequest.id,
apiKeyId: 'key-not-in-backup',
},
{
rawMaterialLotId: wax.id,
type: 'CONSUMPTION' as const,
quantity: -300,
userId,
},
{
productionBatchId: production.id,
type: 'WITHDRAWAL' as const,
quantity: -1,
reason: 'EIGENBEDARF' as const,
userId,
},
]) {
remember('batchMovement', await prisma.batchMovement.create({ data }));
}
// T6: catalogue, pack, a treatment booked from it, the ledger rows, the
// authority notice, the restriction and the acknowledgement.
const colony = remember(
'colony',
await prisma.colony.create({
data: {
code: `B-${suffix}`,
apiaryId: apiary.id,
colonyType: 'PRODUCTION_COLONY',
},
}),
);
const medicine = remember(
'veterinaryMedicine',
await prisma.veterinaryMedicine.create({
data: { name: `Mittel ${suffix}`, unit: 'ML', withdrawalPeriodDays: 0 },
}),
);
const medicineLot = remember(
'veterinaryMedicineLot',
await prisma.veterinaryMedicineLot.create({
data: {
medicineId: medicine.id,
purchasedOn: day,
quantity: 100,
supplier: 'Lieferant',
userId,
},
}),
);
const treatment = remember(
'treatment',
await prisma.treatment.create({
data: {
colonyId: colony.id,
userId,
date: day,
product: `Mittel ${suffix}`,
medicineId: medicine.id,
medicineLotId: medicineLot.id,
doseQuantity: 5,
},
}),
);
remember(
'veterinaryMedicineMovement',
await prisma.veterinaryMedicineMovement.create({
data: {
lotId: medicineLot.id,
type: 'TREATMENT',
quantity: -5,
treatmentId: treatment.id,
userId,
},
}),
);
remember(
'veterinaryMedicineMovement',
await prisma.veterinaryMedicineMovement.create({
data: {
lotId: medicineLot.id,
type: 'DISPOSAL',
quantity: -2,
reason: 'Abgelaufen',
userId,
},
}),
);
remember(
'apiaryRestriction',
await prisma.apiaryRestriction.create({
data: {
apiaryId: apiary.id,
disease: 'Test',
startsOn: day,
createdByUserId: userId,
},
}),
);
remember(
'authorityNotice',
await prisma.authorityNotice.create({
data: {
kind: 'TSK_REPORT',
year: 2098,
referenceDate: day,
countedAt: day,
snapshot: { version: 1, totalLiving: 1 },
totalColonies: 1,
openKey: `TSK_REPORT:2098:${suffix}`,
createdByUserId: userId,
},
}),
);
remember(
'complianceAcknowledgement',
await prisma.complianceAcknowledgement.create({
data: {
itemKey: 'backup-test',
subjectId: suffix,
fingerprint: 'abc',
note: 'Geprüft und in Ordnung',
userId,
},
}),
);
remember(
'complianceSettings',
await prisma.complianceSettings.create({
data: { id: `backup-${suffix}`, registrationNumber: 'R-1' },
}),
);
const sequence = await prisma.automationSequence.create({
data: {
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
scope: `backup-${suffix}`,
value: 41,
},
});
own.automationSequence.add(identity(sequence));
remember(
'calendarSyncOutboxEntry',
await prisma.calendarSyncOutboxEntry.create({
data: {
sourceType: 'TASK',
sourceId: `backup-${suffix}`,
status: 'SYNCED',
graphEventId: `graph-${suffix}`,
},
}),
);
}, ROUND_TRIP_TIMEOUT_MS);
afterAll(async () => {
// The T6 rows reference users and colonies of this spec: remove them,
// children first, so the shared database does not collect compliance data.
for (const section of [
'veterinaryMedicineMovement',
'treatment',
'veterinaryMedicineLot',
'veterinaryMedicine',
'complianceAcknowledgement',
'authorityNotice',
'complianceSettings',
'apiaryRestriction',
] as const) {
const delegate = prisma[section] as unknown as {
deleteMany(args: { where: { id: { in: string[] } } }): Promise<unknown>;
};
await delegate.deleteMany({ where: { id: { in: [...own[section]] } } });
}
await app.close();
});
it(
'exports the production, raw material and stock ledger tables',
async () => {
exported = ownRows(await backup.exportAll());
for (const section of SECTIONS_CHILD_FIRST) {
expect({ section, count: exported[section].length }).toEqual({
section,
count: own[section].size,
});
}
},
ROUND_TRIP_TIMEOUT_MS,
);
it(
'restores every one of those rows exactly after they were lost',
async () => {
for (const section of SECTIONS_CHILD_FIRST) {
if (section === 'automationSequence') {
await prisma.automationSequence.deleteMany({
where: { scope: `backup-${suffix}` },
});
continue;
}
const delegate = prisma[section] as unknown as {
deleteMany(args: {
where: { id: { in: string[] } };
}): Promise<unknown>;
};
await delegate.deleteMany({ where: { id: { in: [...own[section]] } } });
}
expect(
await prisma.batchMovement.count({
where: { id: { in: [...own.batchMovement] } },
}),
).toBe(0);
await backup.importAll(
{
version: 3,
exportedAt: new Date().toISOString(),
data: exported,
},
userId,
{ trusted: true },
);
const where = (section: Section) => ({
where: { id: { in: [...own[section]] } },
});
expect({
productionBatch: await prisma.productionBatch.count(
where('productionBatch'),
),
rawMaterialLot: await prisma.rawMaterialLot.count(
where('rawMaterialLot'),
),
productionInput: await prisma.productionInput.count(
where('productionInput'),
),
shopStockRequest: await prisma.shopStockRequest.count(
where('shopStockRequest'),
),
batchMovement: await prisma.batchMovement.count(where('batchMovement')),
honeyBatchContainerAllocation:
await prisma.honeyBatchContainerAllocation.count(
where('honeyBatchContainerAllocation'),
),
automationSequence: await prisma.automationSequence.count({
where: { scope: `backup-${suffix}` },
}),
calendarSyncOutboxEntry: await prisma.calendarSyncOutboxEntry.count(
where('calendarSyncOutboxEntry'),
),
veterinaryMedicine: await prisma.veterinaryMedicine.count(
where('veterinaryMedicine'),
),
veterinaryMedicineLot: await prisma.veterinaryMedicineLot.count(
where('veterinaryMedicineLot'),
),
treatmentWithPack: await prisma.treatment.count({
...where('treatment'),
where: {
id: { in: [...own.treatment] },
medicineLotId: { not: null },
},
}),
veterinaryMedicineMovement:
await prisma.veterinaryMedicineMovement.count(
where('veterinaryMedicineMovement'),
),
apiaryRestriction: await prisma.apiaryRestriction.count(
where('apiaryRestriction'),
),
authorityNotice: await prisma.authorityNotice.count(
where('authorityNotice'),
),
complianceAcknowledgement: await prisma.complianceAcknowledgement.count(
where('complianceAcknowledgement'),
),
complianceSettings: await prisma.complianceSettings.count(
where('complianceSettings'),
),
}).toEqual({
productionBatch: 1,
rawMaterialLot: 1,
productionInput: 1,
shopStockRequest: 1,
batchMovement: 3,
honeyBatchContainerAllocation: 1,
automationSequence: 1,
calendarSyncOutboxEntry: 1,
veterinaryMedicine: 1,
veterinaryMedicineLot: 1,
treatmentWithPack: 1,
veterinaryMedicineMovement: 2,
apiaryRestriction: 1,
authorityNotice: 1,
complianceAcknowledgement: 1,
complianceSettings: 1,
});
expect(ownRows(await backup.exportAll())).toEqual(exported);
},
ROUND_TRIP_TIMEOUT_MS,
);
it('adds rows from an unsealed file but never rewrites existing ones or takes ledger entries', async () => {
const existing = await prisma.apiary.create({
data: { name: `Bestand ${suffix}` },
});
const addedId = `apiary-added-${suffix}`;
const forgedId = `forged-movement-${suffix}`;
const summary = await backup.importFile(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
apiary: [
{ id: existing.id, name: 'Überschrieben' },
{ id: addedId, name: `Ergänzt ${suffix}` },
],
batchMovement: [
{
id: forgedId,
productionBatchId: [...own.productionBatch][0],
type: 'CORRECTION',
quantity: 1000,
},
],
},
},
userId,
);
expect(summary).toEqual(
expect.objectContaining({ sealed: false, skipped: ['batchMovement'] }),
);
expect(
(await prisma.apiary.findUniqueOrThrow({ where: { id: existing.id } }))
.name,
).toBe(`Bestand ${suffix}`);
expect(
(await prisma.apiary.findUniqueOrThrow({ where: { id: addedId } })).name,
).toBe(`Ergänzt ${suffix}`);
expect(
await prisma.batchMovement.findUnique({ where: { id: forgedId } }),
).toBeNull();
});
it('takes no T6 compliance table from an unsealed file and reports them', async () => {
const medicineId = `medicine-forged-${suffix}`;
const noticeId = `notice-forged-${suffix}`;
const summary = await backup.importFile(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
veterinaryMedicine: [
{ id: medicineId, name: 'Gefälscht', unit: 'ML' },
],
authorityNotice: [
{
id: noticeId,
kind: 'REGISTRATION',
referenceDate: '2026-01-01T00:00:00.000Z',
countedAt: '2026-01-01T00:00:00.000Z',
snapshot: {},
totalColonies: 1,
},
],
},
},
userId,
);
expect([...(summary.skipped ?? [])].sort()).toEqual([
'authorityNotice',
'veterinaryMedicine',
]);
expect(
await prisma.veterinaryMedicine.findUnique({ where: { id: medicineId } }),
).toBeNull();
expect(
await prisma.authorityNotice.findUnique({ where: { id: noticeId } }),
).toBeNull();
});
it(
'restores hive setup and history into a fresh install with the seeded catalog',
async () => {
const systemId = 'hive-system-zander-liebig-10';
const day = new Date('2026-07-02T00:00:00.000Z');
const state = { schemaVersion: 1, stack: [{ id: 'p1', kind: 'FLOOR' }] };
const apiary = await prisma.apiary.create({
data: { name: `Hive-Stand ${suffix}` },
});
const colony = await prisma.colony.create({
data: {
code: `HV-${suffix}`,
apiaryId: apiary.id,
colonyType: 'PRODUCTION_COLONY',
},
});
const inspection = await prisma.inspection.create({
data: { colonyId: colony.id, userId, date: day },
});
await prisma.hiveSetup.create({
data: { colonyId: colony.id, systemId, version: 2, state },
});
await prisma.hiveChange.createMany({
data: [
{
colonyId: colony.id,
version: 1,
occurredOn: day,
source: 'INIT',
userId,
ops: [],
stateAfter: state,
},
{
colonyId: colony.id,
version: 2,
occurredOn: day,
source: 'INSPECTION',
inspectionId: inspection.id,
userId,
ops: [{ op: 'SET_NOTE' }],
stateAfter: state,
note: 'Beute',
},
],
});
const catalogCounts = async () => ({
systems: await prisma.hiveSystem.count(),
partTypes: await prisma.hivePartType.count(),
});
const catalogBefore = await catalogCounts();
expect(catalogBefore.systems).toBeGreaterThan(0);
expect(catalogBefore.partTypes).toBeGreaterThan(0);
const hiveRows = async () => ({
setup: await prisma.hiveSetup.findUnique({
where: { colonyId: colony.id },
}),
changes: await prisma.hiveChange.findMany({
where: { colonyId: colony.id },
orderBy: { version: 'asc' },
}),
});
const before = await hiveRows();
try {
const bundle = await backup.exportAll();
const ownColonyRows = (bundle.data.colony as Row[]).filter(
(row) => row.id === colony.id,
);
const exportedHive = {
hiveSystem: bundle.data.hiveSystem as Row[],
hivePartType: bundle.data.hivePartType as Row[],
colony: ownColonyRows,
inspection: (bundle.data.inspection as Row[]).filter(
(row) => row.id === inspection.id,
),
hiveSetup: (bundle.data.hiveSetup as Row[]).filter(
(row) => row.colonyId === colony.id,
),
hiveChange: (bundle.data.hiveChange as Row[]).filter(
(row) => row.colonyId === colony.id,
),
};
expect(exportedHive.hiveSetup).toHaveLength(1);
expect(exportedHive.hiveChange).toHaveLength(2);
expect(exportedHive.hiveSystem.some((row) => row.id === systemId)).toBe(
true,
);
// The colony is lost; the catalog rows stay, as a fresh install has
// them from the migration.
await prisma.hiveChange.deleteMany({ where: { colonyId: colony.id } });
await prisma.hiveSetup.delete({ where: { colonyId: colony.id } });
await prisma.inspection.delete({ where: { id: inspection.id } });
await prisma.colony.delete({ where: { id: colony.id } });
const summary = await backup.importAll(
{
version: 3,
exportedAt: new Date().toISOString(),
data: exportedHive,
},
userId,
{ trusted: true },
);
expect(summary.imported).toMatchObject({
hiveSetup: 1,
hiveChange: 2,
});
expect(await hiveRows()).toEqual(before);
expect(await catalogCounts()).toEqual(catalogBefore);
// A second restore over an existing, diverged setup: the backup wins.
await prisma.hiveSetup.update({
where: { colonyId: colony.id },
data: { version: 9 },
});
await prisma.hiveChange.create({
data: {
colonyId: colony.id,
version: 9,
occurredOn: day,
source: 'CARD',
userId,
ops: [],
stateAfter: state,
},
});
await backup.importAll(
{
version: 3,
exportedAt: new Date().toISOString(),
data: exportedHive,
},
userId,
{ trusted: true },
);
expect(await hiveRows()).toEqual(before);
expect(await catalogCounts()).toEqual(catalogBefore);
} finally {
await prisma.hiveChange.deleteMany({ where: { colonyId: colony.id } });
await prisma.hiveSetup.deleteMany({ where: { colonyId: colony.id } });
await prisma.inspection.deleteMany({ where: { colonyId: colony.id } });
await prisma.colony.deleteMany({ where: { id: colony.id } });
await prisma.apiary.deleteMany({ where: { id: apiary.id } });
}
},
ROUND_TRIP_TIMEOUT_MS,
);
it('removes the file a restored document row replaced', async () => {
const directory = path.join(
path.resolve(process.env.DOCUMENTS_STORAGE_PATH ?? './uploads'),
'colony',
);
await fs.mkdir(directory, { recursive: true });
const oldFile = path.join(directory, `backup-e2e-${suffix}.pdf`);
await fs.writeFile(oldFile, '%PDF-1.4 old');
const document = await prisma.document.create({
data: {
entityType: 'COLONY',
entityId: `colony-${suffix}`,
fileName: 'alt.pdf',
mimeType: 'application/pdf',
sizeBytes: 12,
storagePath: oldFile,
},
});
await backup.importAll(
{
version: 3,
exportedAt: new Date().toISOString(),
data: {
document: [
{
id: document.id,
entityType: 'COLONY',
entityId: `colony-${suffix}`,
fileName: 'neu.pdf',
mimeType: 'application/pdf',
sizeBytes: 12,
contentBase64: Buffer.from('%PDF-1.4 new').toString('base64'),
},
],
},
},
userId,
{ trusted: true },
);
const restored = await prisma.document.findUniqueOrThrow({
where: { id: document.id },
});
expect(restored.storagePath).not.toBe(oldFile);
await expect(fs.readFile(restored.storagePath, 'utf8')).resolves.toBe(
'%PDF-1.4 new',
);
await expect(fs.access(oldFile)).rejects.toThrow();
await fs.unlink(restored.storagePath);
});
});