Backend - Backup covers every Prisma model (bundle v3); a guard test fails for any new model that is neither backed up nor explicitly excluded. Unsealed bundles may only add rows to 27 legacy master/field tables and never overwrite; counters, ledgers, settings, roles etc. need a sealed file. Shared import limit BACKUP_MAX_IMPORT_BYTES (default 50 MB, max 256 MB) with warnings in log and UI; replaced document files are removed after commit. - Post-commit side effects (automation, calendar sync, webhooks) run via runAfterCommit and never turn a committed write into a 500, so retries with the same Idempotency-Key no longer create duplicates. - Calendar outbox is multi-replica safe: per-entry claims with stale takeover, revision-based settle, new Graph event only on 404/410, transactionId on create, 30 s Graph timeout, URL-encoded ids, GUID validation for groupId, "run now" in background and resets FAILED. - Automation tasks are deduplicated under a row lock on the rule. - Update DTOs follow one null rule: omitted = unchanged, null clears a nullable field, null on a required field is a 400. - Varroa mites/day is recomputed on correction; withdrawal status is checked against the harvest date (?at=), bounded colonyIds; inspection create is audited; updates re-enqueue calendar entries. - Dashboard, statistics and apiary/colony guards treat WEAK, CONSPICUOUS and QUEENLESS colonies as living. - Prisma errors and body-parser errors are logged without query values or request bodies. - Migration 20261012100000_calendar_sync_claims; schema index name aligned with the existing database object. Frontend - Sticky save bar sticks again (no scroll container ancestors, card uses overflow-clip), safe-area insets, scroll-padding, FAB hidden on forms, sticky desktop sidebar. - Inspections record yes / no / not checked instead of silent defaults. - All living colonies are selectable; shared RecordSelect, EnumSelect, ChoiceGroup and TriStateField replace per-page copies; selects show names instead of ids; labels point at real controls. - Emptied optional fields are sent as null (emptyToNull) so every field can be cleared; dates default to the local day and are sent via toApiDate. - Harvest withdrawal warning uses the harvest date; backup page warns when a backup outgrows the import limit and lists skipped tables. Build and docs - Docker builds fail unless lint, typecheck and unit tests pass. - ARCHITECTURE.md updated (assumptions 9-12, 19-21, section 8) and spec docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md for the backbone (api/worker/job roles, domain event outbox) and the feature roadmap T3-T7. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
442 lines
13 KiB
TypeScript
442 lines
13 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',
|
|
'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 }));
|
|
}
|
|
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 () => {
|
|
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'),
|
|
),
|
|
}).toEqual({
|
|
productionBatch: 1,
|
|
rawMaterialLot: 1,
|
|
productionInput: 1,
|
|
shopStockRequest: 1,
|
|
batchMovement: 3,
|
|
honeyBatchContainerAllocation: 1,
|
|
automationSequence: 1,
|
|
calendarSyncOutboxEntry: 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('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);
|
|
});
|
|
});
|