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; // 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; let prisma: PrismaService; let backup: BackupService; const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; const own: Record> = Object.fromEntries( SECTIONS_CHILD_FIRST.map((section) => [section, new Set()]), ) as Record>; let userId: string; let exported: Record; const remember = (section: Section, row: T): T => { own[section].add(row.id); return row; }; function ownRows(bundle: BackupBundle): Record { return Object.fromEntries( SECTIONS_CHILD_FIRST.map((section) => [ section, ((bundle.data[section] ?? []) as Row[]).filter((row) => own[section].has(identity(row)), ), ]), ) as Record; } 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; }; 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; }; 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('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); }); });