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]>
806 lines
24 KiB
TypeScript
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);
|
|
});
|
|
});
|