Task 14: Port StructuredModel mit Anthropic-Adapter (@anthropic-ai/sdk 0.131.0 exakt, Lieferkette geprüft), fester Endpunkt https://api.anthropic.com (Umgebungs-Header werden entfernt), feste Systemregeln ohne Datum/Volk/Person, Transkript als Daten in Tags, strukturierte Ausgabe per JSON-Schema mit effort low, serverseitiger Fallback per VOICE_SERVER_FALLBACK abschaltbar. Jeder Vorschlag wird geprüft: bekanntes Feld, Wert in den Formulargrenzen, Zitat steht wörtlich im Gesagten (Dezimalzahl als ein Wort), Korrektur gewinnt. Netzwächter: nur die Worker-Verdrahtung erreicht den Adapter (transitiv geprüft). Task 15: Worker-Schleife voice-extractions (LISTEN + 15-s-Abfrage, ein Lauf zugleich, höchstens 20 Notizen je Lauf), Modellaufruf außerhalb jeder Transaktion mit 240-s-Obergrenze, Ergebnis versiegelt mit Ereignis nur bei gehaltenem Claim; ohne Schlüssel EXTRACT_DISABLED ohne Versuch, Tageslimit zählt jeden bezahlten Aufruf über das neue Journal VoiceExtractionCall (nur IDs, Modell, Tokens, Ergebnis; 90 Tage, nicht im Backup). Nur der Worker-Container erhält ANTHROPIC_API_KEY aus dem Secret apicore-anthropic (optional); API- und Job-Rollen warnen, wenn der Schlüssel bei ihnen gesetzt ist. Gates: Backend lint, typecheck, 4589 Unit-Tests, build; e2e 104 Suites (nur der Wetter-Test gegen die echte API schwankt); Manifest-Policy 55. Refs #27 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2098 lines
66 KiB
TypeScript
2098 lines
66 KiB
TypeScript
import { BadRequestException, Logger } from '@nestjs/common';
|
|
import * as path from 'node:path';
|
|
import { Prisma } from '../../generated/prisma';
|
|
import {
|
|
BackupBundle,
|
|
BackupService,
|
|
LEGACY_UNSEALED_TABLES,
|
|
MODELS_EXCLUDED_FROM_BACKUP,
|
|
} from './backup.service';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { AuditLogService } from '../audit/audit-log.service';
|
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
|
|
|
jest.mock('node:fs/promises', () => ({
|
|
readFile: jest.fn(),
|
|
writeFile: jest.fn(),
|
|
mkdir: jest.fn(),
|
|
unlink: jest.fn(),
|
|
}));
|
|
|
|
import * as fs from 'node:fs/promises';
|
|
|
|
const BUSINESS_ENTITY_KEYS = [
|
|
'apiary',
|
|
'queen',
|
|
'colony',
|
|
'colonyApiaryHistory',
|
|
'colonyLineage',
|
|
'queenEvaluation',
|
|
'queenColonyHistory',
|
|
'inspection',
|
|
'taskTemplate',
|
|
'suggestion',
|
|
'task',
|
|
'varroaMeasurement',
|
|
'swarmControlCheck',
|
|
'winterCheck',
|
|
'phenologyReport',
|
|
'treatment',
|
|
'feeding',
|
|
'harvest',
|
|
'harvestColony',
|
|
'extraction',
|
|
'extractionHarvest',
|
|
'storageContainer',
|
|
'honeyBatch',
|
|
'honeyMeasurement',
|
|
'honeyBatchRelocation',
|
|
'honeyBatchContainerAllocation',
|
|
'stockItem',
|
|
'stockMovement',
|
|
'product',
|
|
'labelTemplate',
|
|
'bottlingBatch',
|
|
'rawMaterialLot',
|
|
'productionBatch',
|
|
'productionInput',
|
|
'shopStockRequest',
|
|
'batchMovement',
|
|
'equipment',
|
|
'webhookSubscription',
|
|
'automationRule',
|
|
'automationSequence',
|
|
'siteSettings',
|
|
'backupSettings',
|
|
'calendarSyncSettings',
|
|
'calendarSyncOutboxEntry',
|
|
'veterinaryMedicine',
|
|
'veterinaryMedicineLot',
|
|
'veterinaryMedicineMovement',
|
|
'complianceSettings',
|
|
'authorityNotice',
|
|
'apiaryRestriction',
|
|
'complianceAcknowledgement',
|
|
] as const;
|
|
|
|
const T6_MODELS = [
|
|
'VeterinaryMedicine',
|
|
'VeterinaryMedicineLot',
|
|
'VeterinaryMedicineMovement',
|
|
'ComplianceSettings',
|
|
'AuthorityNotice',
|
|
'ApiaryRestriction',
|
|
'ComplianceAcknowledgement',
|
|
] as const;
|
|
|
|
const ALL_ENTITY_KEYS = [
|
|
'role',
|
|
'user',
|
|
'rolePermission',
|
|
...BUSINESS_ENTITY_KEYS,
|
|
'document',
|
|
'notification',
|
|
'notificationPreference',
|
|
'auditLog',
|
|
] as const;
|
|
|
|
// Prisma delegate name of a model: `productionBatch` for ProductionBatch.
|
|
function modelKey(model: string): string {
|
|
return model.charAt(0).toLowerCase() + model.slice(1);
|
|
}
|
|
|
|
// Every model the schema has - so a table added later is mocked too and the
|
|
// coverage guard below reports it instead of crashing on a missing mock.
|
|
const MODEL_KEYS = Object.values(Prisma.ModelName).map(modelKey);
|
|
|
|
type Row = Record<string, unknown>;
|
|
|
|
describe('BackupService', () => {
|
|
let service: BackupService;
|
|
let prisma: Record<string, { findMany: jest.Mock }> & {
|
|
user: { findMany: jest.Mock };
|
|
rolePermission: { findMany: jest.Mock };
|
|
document: { findMany: jest.Mock; count: jest.Mock };
|
|
$transaction: jest.Mock;
|
|
};
|
|
let tx: Record<
|
|
string,
|
|
{
|
|
upsert: jest.Mock;
|
|
update?: jest.Mock;
|
|
create?: jest.Mock;
|
|
deleteMany?: jest.Mock;
|
|
}
|
|
> & {
|
|
permission: { findUnique: jest.Mock };
|
|
user: { upsert: jest.Mock; findMany: jest.Mock };
|
|
queen: { upsert: jest.Mock; update: jest.Mock; findMany: jest.Mock };
|
|
document: { upsert: jest.Mock; findUnique: jest.Mock };
|
|
};
|
|
let auditLog: { record: jest.Mock };
|
|
let permissionResolver: { invalidateAll: jest.Mock };
|
|
const TRUSTED = { trusted: true };
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
|
|
tx = {} as never;
|
|
for (const key of MODEL_KEYS) {
|
|
tx[key] = {
|
|
upsert: jest.fn().mockResolvedValue({ id: `${key}-upserted` }),
|
|
};
|
|
}
|
|
tx.notification = {
|
|
upsert: jest.fn().mockResolvedValue({ id: 'notification-upserted' }),
|
|
findUnique: jest.fn().mockResolvedValue(null),
|
|
} as never;
|
|
(tx as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest
|
|
.fn()
|
|
.mockResolvedValue([]);
|
|
for (const key of ['hiveChange', 'hiveSetup']) {
|
|
tx[key].create = jest.fn().mockResolvedValue({});
|
|
tx[key].deleteMany = jest.fn().mockResolvedValue({ count: 0 });
|
|
}
|
|
tx.queen.update = jest.fn().mockResolvedValue({});
|
|
tx.queen.findMany = jest.fn().mockResolvedValue([]);
|
|
tx.document.findUnique = jest.fn().mockResolvedValue(null);
|
|
tx.permission = {
|
|
findUnique: jest.fn().mockResolvedValue({ id: 'permission-1' }),
|
|
};
|
|
tx.user.findMany = jest.fn().mockResolvedValue([{ id: 'known-user' }]);
|
|
(tx.role as unknown as { findMany: jest.Mock }).findMany = jest
|
|
.fn()
|
|
.mockResolvedValue([]);
|
|
|
|
prisma = {
|
|
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
|
|
callback(tx),
|
|
),
|
|
} as never;
|
|
for (const key of MODEL_KEYS) {
|
|
prisma[key] = { findMany: jest.fn().mockResolvedValue([]) };
|
|
}
|
|
prisma.document.count = jest.fn().mockResolvedValue(0);
|
|
|
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
|
|
|
permissionResolver = { invalidateAll: jest.fn() };
|
|
service = new BackupService(
|
|
prisma as unknown as PrismaService,
|
|
auditLog as unknown as AuditLogService,
|
|
permissionResolver as unknown as PermissionResolverService,
|
|
);
|
|
});
|
|
|
|
describe('exportAll', () => {
|
|
it('returns a version 3 bundle with every entity key, including role/user/document', async () => {
|
|
const bundle = await service.exportAll();
|
|
|
|
expect(bundle.version).toBe(3);
|
|
expect(typeof bundle.exportedAt).toBe('string');
|
|
for (const key of ALL_ENTITY_KEYS) {
|
|
expect(bundle.data).toHaveProperty(key);
|
|
}
|
|
});
|
|
|
|
it('denormalizes user.roleId into roleKey and strips roleId', async () => {
|
|
prisma.user.findMany.mockResolvedValue([
|
|
{
|
|
id: 'user-1',
|
|
email: '[email protected]',
|
|
roleId: 'role-db-id',
|
|
role: { key: 'ADMIN' },
|
|
},
|
|
]);
|
|
|
|
const bundle = await service.exportAll();
|
|
|
|
expect(bundle.data.user).toEqual([
|
|
{ id: 'user-1', email: '[email protected]', roleKey: 'ADMIN' },
|
|
]);
|
|
});
|
|
|
|
it('denormalizes rolePermission into roleKey/resource/action', async () => {
|
|
prisma.rolePermission.findMany.mockResolvedValue([
|
|
{
|
|
role: { key: 'ADMIN' },
|
|
permission: { resource: 'users', action: 'read' },
|
|
},
|
|
]);
|
|
|
|
const bundle = await service.exportAll();
|
|
|
|
expect(bundle.data.rolePermission).toEqual([
|
|
{ roleKey: 'ADMIN', resource: 'users', action: 'read' },
|
|
]);
|
|
});
|
|
|
|
it('embeds document content as base64 and falls back to null when the file is missing', async () => {
|
|
prisma.document.findMany.mockResolvedValue([
|
|
{
|
|
id: 'doc-1',
|
|
storagePath: '/uploads/ok.pdf',
|
|
mimeType: 'application/pdf',
|
|
},
|
|
{
|
|
id: 'doc-2',
|
|
storagePath: '/uploads/missing.pdf',
|
|
mimeType: 'application/pdf',
|
|
},
|
|
]);
|
|
(fs.readFile as jest.Mock).mockImplementation((filePath: string) =>
|
|
filePath === '/uploads/ok.pdf'
|
|
? Promise.resolve(Buffer.from('hello'))
|
|
: Promise.reject(new Error('ENOENT')),
|
|
);
|
|
|
|
const bundle = await service.exportAll();
|
|
|
|
const documents = bundle.data.document as {
|
|
id: string;
|
|
contentBase64: string | null;
|
|
}[];
|
|
expect(documents.find((d) => d.id === 'doc-1')?.contentBase64).toBe(
|
|
Buffer.from('hello').toString('base64'),
|
|
);
|
|
expect(documents.find((d) => d.id === 'doc-2')?.contentBase64).toBeNull();
|
|
});
|
|
|
|
it('exports and restores Inspection.broodFrames', async () => {
|
|
const row = {
|
|
id: 'inspection-1',
|
|
colonyId: 'colony-1',
|
|
userId: 'known-user',
|
|
broodFrames: 5,
|
|
};
|
|
prisma.inspection.findMany.mockResolvedValue([row]);
|
|
|
|
const bundle = await service.exportAll();
|
|
const exported = bundle.data.inspection as { broodFrames: number }[];
|
|
expect(exported[0]?.broodFrames).toBe(5);
|
|
|
|
await service.importAll(bundle, 'admin-1', TRUSTED);
|
|
|
|
const [[call]] = tx.inspection.upsert.mock.calls as [
|
|
[{ create: { broodFrames: number } }],
|
|
];
|
|
expect(call.create.broodFrames).toBe(5);
|
|
});
|
|
|
|
it('maps a legacy free-text feedStatus of an old bundle on import', async () => {
|
|
const base = { colonyId: 'colony-1', userId: 'known-user' };
|
|
const bundle = {
|
|
version: 3,
|
|
data: {
|
|
inspection: [
|
|
{ ...base, id: 'i-1', feedStatus: ' Niedrig ', notes: null },
|
|
{ ...base, id: 'i-2', feedStatus: 'halb voll', notes: 'x' },
|
|
{ ...base, id: 'i-3', feedStatus: 'CRITICAL', notes: null },
|
|
{ ...base, id: 'i-4', feedStatus: '', notes: 'y' },
|
|
],
|
|
},
|
|
};
|
|
|
|
await service.importAll(bundle as never, 'admin-1', TRUSTED);
|
|
|
|
const created = (
|
|
tx.inspection.upsert.mock.calls as [
|
|
[{ where: { id: string }; create: Record<string, unknown> }],
|
|
]
|
|
).map(([call]) => call.create);
|
|
expect(created).toEqual([
|
|
expect.objectContaining({ id: 'i-1', feedStatus: 'WEAK', notes: null }),
|
|
expect.objectContaining({
|
|
id: 'i-2',
|
|
feedStatus: null,
|
|
notes: 'x\nFutter (alt): halb voll',
|
|
}),
|
|
expect.objectContaining({
|
|
id: 'i-3',
|
|
feedStatus: 'CRITICAL',
|
|
notes: null,
|
|
}),
|
|
expect.objectContaining({ id: 'i-4', feedStatus: null, notes: 'y' }),
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('legacy feedStatus of unexpected type', () => {
|
|
it('keeps a non-string feedStatus in the notes as JSON instead of dropping it', async () => {
|
|
const base = { colonyId: 'colony-1', userId: 'known-user' };
|
|
const bundle = {
|
|
version: 3,
|
|
data: {
|
|
inspection: [
|
|
{ ...base, id: 'i-1', feedStatus: 3, notes: null },
|
|
{ ...base, id: 'i-2', feedStatus: { level: 'low' }, notes: 'x' },
|
|
{ ...base, id: 'i-3', feedStatus: null, notes: 'y' },
|
|
],
|
|
},
|
|
};
|
|
|
|
await service.importAll(bundle as never, 'admin-1', TRUSTED);
|
|
|
|
const created = (
|
|
tx.inspection.upsert.mock.calls as [
|
|
[{ create: Record<string, unknown> }],
|
|
]
|
|
).map(([call]) => call.create);
|
|
expect(created).toEqual([
|
|
expect.objectContaining({
|
|
feedStatus: null,
|
|
notes: 'Futter (alt): 3',
|
|
}),
|
|
expect.objectContaining({
|
|
feedStatus: null,
|
|
notes: 'x\nFutter (alt): {"level":"low"}',
|
|
}),
|
|
expect.objectContaining({ feedStatus: null, notes: 'y' }),
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('suggestions', () => {
|
|
it('exports and restores suggestions with their decision before tasks', async () => {
|
|
const suggestion = {
|
|
id: 'suggestion-1',
|
|
colonyId: 'colony-1',
|
|
status: 'ACCEPTED',
|
|
dedupeKey: 'INSPECTION_INTERVAL:colony-1:insp-9',
|
|
decidedAt: '2026-10-14T08:00:00.000Z',
|
|
decidedByUserId: 'known-user',
|
|
decisionNote: 'Passt',
|
|
};
|
|
const orphanDecision = {
|
|
...suggestion,
|
|
id: 'suggestion-2',
|
|
dedupeKey: null,
|
|
decidedByUserId: 'deleted-user',
|
|
};
|
|
const open = {
|
|
...suggestion,
|
|
id: 'suggestion-3',
|
|
status: 'OPEN',
|
|
decidedAt: null,
|
|
decidedByUserId: null,
|
|
decisionNote: null,
|
|
};
|
|
prisma.suggestion.findMany.mockResolvedValue([
|
|
suggestion,
|
|
orphanDecision,
|
|
open,
|
|
]);
|
|
prisma.colony.findMany.mockResolvedValue([{ id: 'colony-1' }]);
|
|
prisma.task.findMany.mockResolvedValue([
|
|
{ id: 'task-1', sourceSuggestionId: 'suggestion-1' },
|
|
]);
|
|
|
|
const bundle = await service.exportAll();
|
|
expect(bundle.data.suggestion).toEqual([
|
|
suggestion,
|
|
orphanDecision,
|
|
open,
|
|
]);
|
|
|
|
const summary = await service.importAll(bundle, 'admin-1', TRUSTED);
|
|
|
|
expect(summary.imported.suggestion).toBe(3);
|
|
const creates = (
|
|
tx.suggestion.upsert.mock.calls as [
|
|
{ create: { id: string; decidedByUserId: string | null } },
|
|
][]
|
|
).map(([call]) => call.create);
|
|
// A decider that is gone resolves like any other user id; no decision
|
|
// stays without one.
|
|
expect(creates.map((row) => row.decidedByUserId)).toEqual([
|
|
'known-user',
|
|
'admin-1',
|
|
null,
|
|
]);
|
|
const suggestionOrder = tx.suggestion.upsert.mock.invocationCallOrder[0];
|
|
const taskOrder = tx.task.upsert.mock.invocationCallOrder[0];
|
|
expect(suggestionOrder).toBeLessThan(taskOrder);
|
|
const colonyOrder = tx.colony.upsert.mock.invocationCallOrder[0];
|
|
expect(colonyOrder).toBeLessThan(suggestionOrder);
|
|
});
|
|
|
|
it('does not restore suggestions from an unsealed file', async () => {
|
|
await service.importAll(
|
|
{
|
|
version: 3,
|
|
exportedAt: '2026-10-14T00:00:00.000Z',
|
|
data: { suggestion: [{ id: 'suggestion-1' }] },
|
|
},
|
|
'admin-1',
|
|
{ trusted: false },
|
|
);
|
|
|
|
expect(tx.suggestion.upsert).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('importAll', () => {
|
|
it('rejects a bundle with no data object', async () => {
|
|
await expect(
|
|
service.importAll({ version: 2 } as never, 'admin-1', TRUSTED),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upserts a role by key and drops the source id on update', async () => {
|
|
await service.importAll(
|
|
{
|
|
version: 2,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: { role: [{ id: 'source-id', key: 'ADMIN', name: 'Admin' }] },
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
|
|
const [[call]] = tx.role.upsert.mock.calls as [
|
|
[{ where: { key: string }; update: Record<string, unknown> }],
|
|
];
|
|
expect(call.where).toEqual({ key: 'ADMIN' });
|
|
expect(call.update).not.toHaveProperty('id');
|
|
});
|
|
|
|
it('resolves a user roleKey to the freshly upserted role id', async () => {
|
|
tx.role.upsert = jest.fn().mockResolvedValue({ id: 'role-new-id' });
|
|
|
|
await service.importAll(
|
|
{
|
|
version: 2,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
role: [{ key: 'ADMIN', name: 'Admin' }],
|
|
user: [{ id: 'user-1', email: '[email protected]', roleKey: 'ADMIN' }],
|
|
},
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
|
|
const [[call]] = tx.user.upsert.mock.calls as [
|
|
[{ create: { roleId: string } }],
|
|
];
|
|
expect(call.create.roleId).toBe('role-new-id');
|
|
});
|
|
|
|
it('throws when a user references a role key not present in the bundle', async () => {
|
|
await expect(
|
|
service.importAll(
|
|
{
|
|
version: 2,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
user: [{ id: 'user-1', email: '[email protected]', roleKey: 'UNKNOWN' }],
|
|
},
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it('resolves rolePermission via permission.findUnique by resource/action and skips unknown permissions', async () => {
|
|
tx.permission.findUnique = jest
|
|
.fn()
|
|
.mockResolvedValueOnce({ id: 'perm-1' })
|
|
.mockResolvedValueOnce(null);
|
|
|
|
await service.importAll(
|
|
{
|
|
version: 2,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
role: [{ key: 'ADMIN', name: 'Admin' }],
|
|
rolePermission: [
|
|
{ roleKey: 'ADMIN', resource: 'users', action: 'read' },
|
|
{ roleKey: 'ADMIN', resource: 'ghost', action: 'read' },
|
|
],
|
|
},
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
|
|
expect(tx.rolePermission.upsert).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('links a self-referencing motherQueenId only after every queen exists', async () => {
|
|
await service.importAll(
|
|
{
|
|
version: 2,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
queen: [
|
|
{ id: 'queen-daughter', motherQueenId: 'queen-mother' },
|
|
{ id: 'queen-mother', motherQueenId: null },
|
|
],
|
|
},
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
|
|
expect(tx.queen.upsert).toHaveBeenCalledTimes(2);
|
|
for (const call of tx.queen.upsert.mock.calls as {
|
|
create: { motherQueenId: string | null };
|
|
}[][]) {
|
|
expect(call[0].create.motherQueenId).toBeNull();
|
|
}
|
|
expect(tx.queen.update).toHaveBeenCalledTimes(1);
|
|
expect(tx.queen.update).toHaveBeenCalledWith({
|
|
where: { id: 'queen-daughter' },
|
|
data: { motherQueenId: 'queen-mother' },
|
|
});
|
|
});
|
|
|
|
it('remaps a required userId that does not exist in the target database to the importing admin', async () => {
|
|
tx.user.findMany.mockResolvedValue([{ id: 'known-user' }]);
|
|
|
|
await service.importAll(
|
|
{
|
|
version: 2,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
inspection: [
|
|
{
|
|
id: 'inspection-1',
|
|
colonyId: 'colony-1',
|
|
userId: 'ghost-user',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
|
|
const [[call]] = tx.inspection.upsert.mock.calls as [
|
|
[{ create: { userId: string } }],
|
|
];
|
|
expect(call.create.userId).toBe('admin-1');
|
|
});
|
|
|
|
it('keeps a required userId that does exist in the target database', async () => {
|
|
tx.user.findMany.mockResolvedValue([{ id: 'known-user' }]);
|
|
|
|
await service.importAll(
|
|
{
|
|
version: 2,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
inspection: [
|
|
{
|
|
id: 'inspection-1',
|
|
colonyId: 'colony-1',
|
|
userId: 'known-user',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
|
|
const [[call]] = tx.inspection.upsert.mock.calls as [
|
|
[{ create: { userId: string } }],
|
|
];
|
|
expect(call.create.userId).toBe('known-user');
|
|
});
|
|
|
|
it('leaves an optional userId as null rather than substituting the admin', async () => {
|
|
await service.importAll(
|
|
{
|
|
version: 2,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
stockMovement: [
|
|
{ id: 'movement-1', stockItemId: 'stock-1', userId: null },
|
|
],
|
|
},
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
|
|
const [[call]] = tx.stockMovement.upsert.mock.calls as [
|
|
[{ create: { userId: string | null } }],
|
|
];
|
|
expect(call.create.userId).toBeNull();
|
|
});
|
|
|
|
it('upserts automationRule by triggerType, not id, and drops the source id', async () => {
|
|
await service.importAll(
|
|
{
|
|
version: 2,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
automationRule: [
|
|
{
|
|
id: 'source-db-id',
|
|
triggerType: 'VARROA_THRESHOLD',
|
|
active: true,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
|
|
const [[call]] = tx.automationRule.upsert.mock.calls as [
|
|
[
|
|
{
|
|
where: { triggerType: string };
|
|
create: Record<string, unknown>;
|
|
},
|
|
],
|
|
];
|
|
expect(call.where).toEqual({ triggerType: 'VARROA_THRESHOLD' });
|
|
expect(call.create).not.toHaveProperty('id');
|
|
});
|
|
|
|
it('decodes document contentBase64, writes it to a fresh path, and skips rows with no content', async () => {
|
|
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
|
|
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
|
|
|
await service.importAll(
|
|
{
|
|
version: 2,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
document: [
|
|
{
|
|
id: 'doc-1',
|
|
entityType: 'COLONY',
|
|
mimeType: 'application/pdf',
|
|
uploadedByUserId: 'known-user',
|
|
contentBase64: Buffer.from('%PDF-1.4 hello').toString('base64'),
|
|
},
|
|
{
|
|
id: 'doc-2',
|
|
entityType: 'COLONY',
|
|
mimeType: 'application/pdf',
|
|
uploadedByUserId: null,
|
|
contentBase64: null,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
|
|
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
|
expect(tx.document.upsert).toHaveBeenCalledTimes(1);
|
|
const [[call]] = tx.document.upsert.mock.calls as [
|
|
[{ create: { storagePath: string; contentBase64?: unknown } }],
|
|
];
|
|
expect(call.create.storagePath).toMatch(/colony/);
|
|
expect(call.create).not.toHaveProperty('contentBase64');
|
|
});
|
|
|
|
it('records an audit log entry with the per-entity import counts', async () => {
|
|
await service.importAll(
|
|
{
|
|
version: 2,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: { apiary: [{ id: 'apiary-1', name: 'Stand Wald' }] },
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
|
|
expect(auditLog.record).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
entityType: 'Backup',
|
|
action: 'CREATE',
|
|
userId: 'admin-1',
|
|
afterData: expect.objectContaining({
|
|
imported: expect.objectContaining({ apiary: 1 }) as unknown,
|
|
}) as unknown,
|
|
}),
|
|
);
|
|
expect(permissionResolver.invalidateAll).toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses a document whose entityType would leave the storage root', async () => {
|
|
await expect(
|
|
service.importAll(
|
|
{
|
|
version: 2,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
document: [
|
|
{
|
|
id: 'doc-evil',
|
|
entityType: '../../app/dist',
|
|
mimeType: 'application/pdf',
|
|
contentBase64: Buffer.from('%PDF-1.4').toString('base64'),
|
|
},
|
|
],
|
|
},
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(fs.mkdir).not.toHaveBeenCalled();
|
|
expect(fs.writeFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses a document whose bytes do not match its declared type', async () => {
|
|
await expect(
|
|
service.importAll(
|
|
{
|
|
version: 2,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
document: [
|
|
{
|
|
id: 'doc-html',
|
|
entityType: 'COLONY',
|
|
mimeType: 'application/pdf',
|
|
contentBase64: Buffer.from('<script>').toString('base64'),
|
|
},
|
|
],
|
|
},
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(fs.writeFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('never rewrites audit entries that already exist', async () => {
|
|
await service.importAll(
|
|
{
|
|
version: 2,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: { auditLog: [{ id: 'audit-1', action: 'LOGIN' }] },
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
const [[call]] = tx.auditLog.upsert.mock.calls as [
|
|
[{ update: Record<string, unknown> }],
|
|
];
|
|
expect(call.update).toEqual({});
|
|
});
|
|
|
|
it('takes no users, roles, webhooks or audit entries from an unsealed file', async () => {
|
|
const summary = await service.importAll(
|
|
{
|
|
version: 2,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
role: [{ key: 'ADMIN', name: 'Admin' }],
|
|
user: [
|
|
{
|
|
id: 'admin-victim',
|
|
email: '[email protected]',
|
|
passwordHash: 'attacker-hash',
|
|
roleKey: 'ADMIN',
|
|
},
|
|
],
|
|
webhookSubscription: [{ id: 'hook-1', url: 'https://evil' }],
|
|
auditLog: [{ id: 'audit-1' }],
|
|
apiary: [{ id: 'apiary-1', name: 'Stand Wald' }],
|
|
},
|
|
},
|
|
'admin-1',
|
|
{ trusted: false },
|
|
);
|
|
|
|
expect(tx.role.upsert).not.toHaveBeenCalled();
|
|
expect(tx.user.upsert).not.toHaveBeenCalled();
|
|
expect(tx.webhookSubscription.upsert).not.toHaveBeenCalled();
|
|
expect(tx.auditLog.upsert).not.toHaveBeenCalled();
|
|
expect(tx.apiary.upsert).toHaveBeenCalledTimes(1);
|
|
expect(summary.skipped).toEqual(
|
|
expect.arrayContaining(['role', 'user', 'webhookSubscription']),
|
|
);
|
|
});
|
|
|
|
it('restores production, raw material and stock ledger rows by id, remapping their optional userId', async () => {
|
|
await service.importAll(
|
|
{
|
|
version: 3,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
rawMaterialLot: [{ id: 'lot-1', userId: 'ghost-user' }],
|
|
productionBatch: [
|
|
{ id: 'batch-1', productId: 'product-1', userId: 'known-user' },
|
|
],
|
|
productionInput: [
|
|
{
|
|
id: 'input-1',
|
|
productionBatchId: 'batch-1',
|
|
rawMaterialLotId: 'lot-1',
|
|
},
|
|
],
|
|
shopStockRequest: [{ id: 'request-1', externalId: 'order-1' }],
|
|
batchMovement: [
|
|
{
|
|
id: 'movement-1',
|
|
productionBatchId: 'batch-1',
|
|
shopRequestId: 'request-1',
|
|
userId: null,
|
|
},
|
|
],
|
|
honeyBatchContainerAllocation: [
|
|
{
|
|
id: 'allocation-1',
|
|
honeyBatchId: 'honey-1',
|
|
storageContainerId: 'container-1',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
|
|
const firstCall = (key: string) =>
|
|
(
|
|
tx[key].upsert.mock.calls as [
|
|
{
|
|
where: Record<string, unknown>;
|
|
create: Record<string, unknown>;
|
|
},
|
|
][]
|
|
)[0][0];
|
|
expect(firstCall('rawMaterialLot').where).toEqual({ id: 'lot-1' });
|
|
expect(firstCall('rawMaterialLot').create.userId).toBe('admin-1');
|
|
expect(firstCall('productionBatch').create.userId).toBe('known-user');
|
|
expect(firstCall('batchMovement').create.userId).toBeNull();
|
|
expect(firstCall('productionInput').where).toEqual({ id: 'input-1' });
|
|
expect(firstCall('shopStockRequest').where).toEqual({ id: 'request-1' });
|
|
expect(firstCall('honeyBatchContainerAllocation').where).toEqual({
|
|
id: 'allocation-1',
|
|
});
|
|
});
|
|
|
|
it('writes every parent row before the rows that reference it', async () => {
|
|
const one = (id: string) => [{ id }];
|
|
await service.importAll(
|
|
{
|
|
version: 3,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
apiary: one('apiary-1'),
|
|
harvest: one('harvest-1'),
|
|
storageContainer: one('container-1'),
|
|
honeyBatch: one('honey-1'),
|
|
honeyBatchContainerAllocation: one('allocation-1'),
|
|
product: one('product-1'),
|
|
bottlingBatch: one('bottling-1'),
|
|
rawMaterialLot: one('lot-1'),
|
|
productionBatch: one('batch-1'),
|
|
productionInput: one('input-1'),
|
|
shopStockRequest: one('request-1'),
|
|
batchMovement: one('movement-1'),
|
|
automationRule: [{ triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT' }],
|
|
automationSequence: [
|
|
{
|
|
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
|
|
scope: 'BEESWAX',
|
|
value: 7,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
|
|
const order = (key: string) => tx[key].upsert.mock.invocationCallOrder[0];
|
|
const dependencies: [parent: string, child: string][] = [
|
|
['storageContainer', 'honeyBatchContainerAllocation'],
|
|
['honeyBatch', 'honeyBatchContainerAllocation'],
|
|
['apiary', 'rawMaterialLot'],
|
|
['harvest', 'rawMaterialLot'],
|
|
['product', 'productionBatch'],
|
|
['productionBatch', 'productionInput'],
|
|
['rawMaterialLot', 'productionInput'],
|
|
['bottlingBatch', 'batchMovement'],
|
|
['productionBatch', 'batchMovement'],
|
|
['rawMaterialLot', 'batchMovement'],
|
|
['shopStockRequest', 'batchMovement'],
|
|
['automationRule', 'automationSequence'],
|
|
];
|
|
for (const [parent, child] of dependencies) {
|
|
expect({ parent, child, before: order(parent) < order(child) }).toEqual(
|
|
{ parent, child, before: true },
|
|
);
|
|
}
|
|
});
|
|
|
|
it('upserts automationSequence by triggerType and scope', async () => {
|
|
await service.importAll(
|
|
{
|
|
version: 3,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
automationSequence: [
|
|
{
|
|
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
|
|
scope: 'BEESWAX',
|
|
value: 7,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
|
|
const [[call]] = tx.automationSequence.upsert.mock.calls as [
|
|
[{ where: unknown; create: Record<string, unknown> }],
|
|
];
|
|
expect(call.where).toEqual({
|
|
triggerType_scope: {
|
|
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
|
|
scope: 'BEESWAX',
|
|
},
|
|
});
|
|
expect(call.create.value).toBe(7);
|
|
});
|
|
|
|
it('upserts a calendar outbox entry by its source and keeps the target row id', async () => {
|
|
await service.importAll(
|
|
{
|
|
version: 3,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
calendarSyncOutboxEntry: [
|
|
{
|
|
id: 'entry-source-id',
|
|
sourceType: 'TASK',
|
|
sourceId: 'task-1',
|
|
graphEventId: 'graph-event-1',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
|
|
const [[call]] = tx.calendarSyncOutboxEntry.upsert.mock.calls as [
|
|
[{ where: unknown; update: Record<string, unknown> }],
|
|
];
|
|
expect(call.where).toEqual({
|
|
sourceType_sourceId: { sourceType: 'TASK', sourceId: 'task-1' },
|
|
});
|
|
expect(call.update).not.toHaveProperty('id');
|
|
expect(call.update.graphEventId).toBe('graph-event-1');
|
|
});
|
|
|
|
it('upserts the backup and calendar settings singletons by id without rewriting it', async () => {
|
|
await service.importAll(
|
|
{
|
|
version: 3,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
backupSettings: [{ id: 'singleton', retentionCount: 30 }],
|
|
calendarSyncSettings: [{ id: 'singleton', enabled: true }],
|
|
},
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
|
|
for (const key of ['backupSettings', 'calendarSyncSettings']) {
|
|
const [[call]] = tx[key].upsert.mock.calls as [
|
|
[{ where: unknown; update: Record<string, unknown> }],
|
|
];
|
|
expect(call.where).toEqual({ id: 'singleton' });
|
|
expect(call.update).not.toHaveProperty('id');
|
|
}
|
|
});
|
|
|
|
it('takes no calendar sync or backup schedule from an unsealed file', async () => {
|
|
const summary = await service.importAll(
|
|
{
|
|
version: 2,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
backupSettings: [{ id: 'singleton', retentionCount: 1 }],
|
|
calendarSyncSettings: [{ id: 'singleton', groupId: 'evil' }],
|
|
calendarSyncOutboxEntry: [
|
|
{ id: 'entry-1', sourceType: 'TASK', sourceId: 'task-1' },
|
|
],
|
|
},
|
|
},
|
|
'admin-1',
|
|
{ trusted: false },
|
|
);
|
|
|
|
expect(tx.backupSettings.upsert).not.toHaveBeenCalled();
|
|
expect(tx.calendarSyncSettings.upsert).not.toHaveBeenCalled();
|
|
expect(tx.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
|
|
expect(summary.skipped).toEqual(
|
|
expect.arrayContaining([
|
|
'backupSettings',
|
|
'calendarSyncSettings',
|
|
'calendarSyncOutboxEntry',
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('restores an older bundle that lacks the newer tables as if they were empty', async () => {
|
|
const summary = await service.importAll(
|
|
{
|
|
version: 2,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: { apiary: [{ id: 'apiary-1', name: 'Stand Wald' }] },
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
|
|
expect(summary.imported).toEqual(
|
|
expect.objectContaining({
|
|
apiary: 1,
|
|
productionBatch: 0,
|
|
rawMaterialLot: 0,
|
|
batchMovement: 0,
|
|
automationSequence: 0,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
// A file without the seal cannot be proven to come from ApiCore: it may
|
|
// only add plain master and field records, nothing else.
|
|
describe('unsealed file', () => {
|
|
const UNSEALED = { trusted: false };
|
|
const SEALED_ONLY_SAMPLE: Record<string, Row[]> = {
|
|
automationRule: [{ triggerType: 'HONEY_BATCH_NUMBER_FORMAT' }],
|
|
automationSequence: [
|
|
{ triggerType: 'HONEY_BATCH_NUMBER_FORMAT', scope: 'x', value: 0 },
|
|
],
|
|
stockItem: [{ id: 'stock-1' }],
|
|
stockMovement: [{ id: 'movement-1' }],
|
|
batchMovement: [{ id: 'ledger-1' }],
|
|
shopStockRequest: [{ id: 'request-1', externalId: 'order-1' }],
|
|
rawMaterialLot: [{ id: 'lot-1' }],
|
|
productionBatch: [{ id: 'batch-1' }],
|
|
productionInput: [{ id: 'input-1' }],
|
|
honeyBatchContainerAllocation: [{ id: 'allocation-1' }],
|
|
siteSettings: [{ id: 'singleton', holderName: 'Fake' }],
|
|
document: [
|
|
{
|
|
id: 'doc-1',
|
|
entityType: 'COLONY',
|
|
mimeType: 'application/pdf',
|
|
contentBase64: Buffer.from('%PDF-1.4').toString('base64'),
|
|
},
|
|
],
|
|
notification: [{ id: 'note-1', message: 'Bitte hier anmelden' }],
|
|
};
|
|
|
|
it('takes no counters, ledgers, shop requests, settings, documents or notifications from it', async () => {
|
|
const summary = await service.importAll(
|
|
{
|
|
version: 2,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
...SEALED_ONLY_SAMPLE,
|
|
apiary: [{ id: 'apiary-1', name: 'Stand Wald' }],
|
|
},
|
|
},
|
|
'admin-1',
|
|
UNSEALED,
|
|
);
|
|
|
|
for (const key of Object.keys(SEALED_ONLY_SAMPLE)) {
|
|
expect({ key, calls: tx[key].upsert.mock.calls.length }).toEqual({
|
|
key,
|
|
calls: 0,
|
|
});
|
|
}
|
|
expect(fs.writeFile).not.toHaveBeenCalled();
|
|
expect(tx.apiary.upsert).toHaveBeenCalledTimes(1);
|
|
expect([...summary.skipped].sort()).toEqual(
|
|
Object.keys(SEALED_ONLY_SAMPLE).sort(),
|
|
);
|
|
});
|
|
|
|
it('allows only plain master and field records - no table added later', () => {
|
|
expect([...LEGACY_UNSEALED_TABLES].sort()).toEqual(
|
|
[
|
|
'apiary',
|
|
'queen',
|
|
'colony',
|
|
'colonyApiaryHistory',
|
|
'colonyLineage',
|
|
'queenEvaluation',
|
|
'queenColonyHistory',
|
|
'inspection',
|
|
'taskTemplate',
|
|
'task',
|
|
'varroaMeasurement',
|
|
'swarmControlCheck',
|
|
'winterCheck',
|
|
'treatment',
|
|
'feeding',
|
|
'harvest',
|
|
'harvestColony',
|
|
'extraction',
|
|
'extractionHarvest',
|
|
'storageContainer',
|
|
'honeyBatch',
|
|
'honeyMeasurement',
|
|
'honeyBatchRelocation',
|
|
'product',
|
|
'labelTemplate',
|
|
'bottlingBatch',
|
|
'equipment',
|
|
].sort(),
|
|
);
|
|
});
|
|
|
|
it('adds the records it carries but never rewrites ones that already exist', async () => {
|
|
const data = Object.fromEntries(
|
|
LEGACY_UNSEALED_TABLES.map((key) => [
|
|
key,
|
|
[{ id: `${key}-1`, notes: 'aus der Datei' }],
|
|
]),
|
|
);
|
|
|
|
await service.importAll(
|
|
{ version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data },
|
|
'admin-1',
|
|
UNSEALED,
|
|
);
|
|
|
|
for (const key of LEGACY_UNSEALED_TABLES) {
|
|
const [[call]] = tx[key].upsert.mock.calls as [
|
|
[{ create: Row; update: Row }],
|
|
];
|
|
expect({ key, update: call.update }).toEqual({ key, update: {} });
|
|
expect(call.create.notes).toBe('aus der Datei');
|
|
}
|
|
});
|
|
|
|
it('does not re-link the mother line of a queen that was already there', async () => {
|
|
tx.queen.findMany.mockResolvedValue([{ id: 'queen-existing' }]);
|
|
|
|
await service.importAll(
|
|
{
|
|
version: 2,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
queen: [
|
|
{ id: 'queen-existing', motherQueenId: 'queen-mother' },
|
|
{ id: 'queen-new', motherQueenId: 'queen-mother' },
|
|
{ id: 'queen-mother', motherQueenId: null },
|
|
],
|
|
},
|
|
},
|
|
'admin-1',
|
|
UNSEALED,
|
|
);
|
|
|
|
expect(tx.queen.update).toHaveBeenCalledTimes(1);
|
|
expect(tx.queen.update).toHaveBeenCalledWith({
|
|
where: { id: 'queen-new' },
|
|
data: { motherQueenId: 'queen-mother' },
|
|
});
|
|
});
|
|
|
|
it('reports the import as unsealed and lists no unknown sections as skipped', async () => {
|
|
const summary = await service.importAll(
|
|
{
|
|
version: 2,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: { notATable: [{ id: 'x' }], apiary: [{ id: 'apiary-1' }] },
|
|
},
|
|
'admin-1',
|
|
UNSEALED,
|
|
);
|
|
|
|
expect(summary.sealed).toBe(false);
|
|
expect(summary.skipped).toEqual([]);
|
|
});
|
|
|
|
it('still restores everything, overwriting existing rows, from a sealed file', async () => {
|
|
const summary = await service.importAll(
|
|
{
|
|
version: 3,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
apiary: [{ id: 'apiary-1', name: 'Stand Wald' }],
|
|
stockMovement: [{ id: 'movement-1', userId: null }],
|
|
},
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
|
|
expect(summary.sealed).toBe(true);
|
|
expect(summary.skipped).toEqual([]);
|
|
const [[call]] = tx.apiary.upsert.mock.calls as [[{ update: Row }]];
|
|
expect(call.update).toEqual({ id: 'apiary-1', name: 'Stand Wald' });
|
|
expect(tx.stockMovement.upsert).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
// Restoring a document writes its content to a new file; the file the row
|
|
// pointed at before must not be left behind on disk.
|
|
describe('replaced document files', () => {
|
|
const storageRoot = path.resolve(
|
|
process.env.DOCUMENTS_STORAGE_PATH ?? './uploads',
|
|
);
|
|
const oldFile = path.join(storageRoot, 'colony', 'old.pdf');
|
|
const bundle = {
|
|
version: 3,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
document: [
|
|
{
|
|
id: 'doc-1',
|
|
entityType: 'COLONY',
|
|
mimeType: 'application/pdf',
|
|
contentBase64: Buffer.from('%PDF-1.4 new').toString('base64'),
|
|
},
|
|
],
|
|
},
|
|
};
|
|
let warn: jest.SpyInstance;
|
|
|
|
beforeEach(() => {
|
|
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
|
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
|
|
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
|
(fs.unlink as jest.Mock).mockResolvedValue(undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it('deletes the file a restored row replaced, only after the import committed', async () => {
|
|
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
|
|
|
|
await service.importAll(bundle, 'admin-1', TRUSTED);
|
|
|
|
expect(fs.unlink).toHaveBeenCalledWith(oldFile);
|
|
expect(
|
|
(fs.unlink as jest.Mock).mock.invocationCallOrder[0],
|
|
).toBeGreaterThan(tx.document.upsert.mock.invocationCallOrder[0]);
|
|
expect(prisma.document.count).toHaveBeenCalledWith({
|
|
where: { storagePath: oldFile },
|
|
});
|
|
});
|
|
|
|
it('deletes nothing when the import fails', async () => {
|
|
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
|
|
tx.notification.upsert.mockRejectedValue(new Error('db down'));
|
|
|
|
await expect(
|
|
service.importAll(
|
|
{
|
|
...bundle,
|
|
data: { ...bundle.data, notification: [{ id: 'note-1' }] },
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
),
|
|
).rejects.toThrow('db down');
|
|
expect(fs.unlink).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps a replaced file outside the documents root and logs it', async () => {
|
|
tx.document.findUnique.mockResolvedValue({ storagePath: '/etc/passwd' });
|
|
|
|
await service.importAll(bundle, 'admin-1', TRUSTED);
|
|
|
|
expect(fs.unlink).not.toHaveBeenCalled();
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('keeps a replaced file that another document row still uses', async () => {
|
|
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
|
|
prisma.document.count.mockResolvedValue(1);
|
|
|
|
await service.importAll(bundle, 'admin-1', TRUSTED);
|
|
|
|
expect(fs.unlink).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('completes the import when a replaced file cannot be deleted, and logs it', async () => {
|
|
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
|
|
(fs.unlink as jest.Mock).mockRejectedValue(new Error('EBUSY'));
|
|
|
|
await expect(
|
|
service.importAll(bundle, 'admin-1', TRUSTED),
|
|
).resolves.toEqual(expect.objectContaining({ sealed: true }));
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('deletes nothing for a document that is new to this database', async () => {
|
|
await service.importAll(bundle, 'admin-1', TRUSTED);
|
|
|
|
expect(fs.unlink).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('notifications', () => {
|
|
const note = (overrides: Row = {}): Row => ({
|
|
id: 'n1',
|
|
userId: 'u1',
|
|
priority: 'INFO',
|
|
category: 'SUGGESTION',
|
|
message: 'm',
|
|
relatedType: 'COLONY',
|
|
relatedId: 'c1',
|
|
read: true,
|
|
createdAt: '2026-01-01T00:00:00.000Z',
|
|
...overrides,
|
|
});
|
|
const without = (row: Row, ...keys: string[]): Row =>
|
|
Object.fromEntries(
|
|
Object.entries(row).filter(([key]) => !keys.includes(key)),
|
|
);
|
|
const importNotes = (rows: Row[]) =>
|
|
service.importAll(
|
|
{
|
|
version: 3,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: { notification: rows },
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
const written = () =>
|
|
(
|
|
tx.notification.upsert.mock.calls as [
|
|
{ where: { id: string }; create: Row },
|
|
][]
|
|
).map(([arg]) => arg.create);
|
|
|
|
beforeEach(() => {
|
|
tx.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]);
|
|
});
|
|
|
|
it('restore keeps the userId of a notification and skips a row whose user does not exist in the target', async () => {
|
|
const summary = await importNotes([
|
|
note({ id: 'n1', userId: 'u2' }),
|
|
note({ id: 'n2', userId: 'gone' }),
|
|
]);
|
|
|
|
expect(written().map((row) => [row.id, row.userId])).toEqual([
|
|
['n1', 'u2'],
|
|
]);
|
|
expect(summary.imported.notification).toBe(1);
|
|
expect(summary.imported.notificationSkipped).toBe(1);
|
|
});
|
|
|
|
it('skips and counts a row whose dedupeKey already exists under another id', async () => {
|
|
(
|
|
tx.notification as unknown as { findUnique: jest.Mock }
|
|
).findUnique.mockImplementation(
|
|
({ where }: { where: { dedupeKey: string } }) =>
|
|
Promise.resolve(
|
|
where.dedupeKey === 'k:u1' ? { id: 'other-id' } : null,
|
|
),
|
|
);
|
|
|
|
const summary = await importNotes([
|
|
note({ id: 'n1', dedupeKey: 'k:u1' }),
|
|
note({ id: 'n2', dedupeKey: 'k:u2' }),
|
|
// Same id as the stored row: a plain re-restore, written as before.
|
|
note({ id: 'other-id', dedupeKey: 'k:u1' }),
|
|
]);
|
|
|
|
expect(written().map((row) => row.id)).toEqual(['n2', 'other-id']);
|
|
expect(summary.imported.notification).toBe(2);
|
|
expect(summary.imported.notificationSkipped).toBe(1);
|
|
});
|
|
|
|
it('a repeated restore of broadcast copies does not reset their read flags', async () => {
|
|
const { userId: _u, category: _c, ...old } = note({ id: 'b' });
|
|
void _u;
|
|
void _c;
|
|
await importNotes([old]);
|
|
|
|
const calls = tx.notification.upsert.mock.calls as [{ update: Row }][];
|
|
expect(calls).toHaveLength(2);
|
|
expect(calls.every(([arg]) => Object.keys(arg.update).length === 0)).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it('never writes a notification without a userId', async () => {
|
|
await importNotes([
|
|
note({ userId: null }),
|
|
note({ id: 'n3', userId: 'gone' }),
|
|
]);
|
|
|
|
expect(written().every((row) => typeof row.userId === 'string')).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it('an old bundle notification without userId is copied per active user: category SYSTEM for relatedType JobRun or none, SUGGESTION otherwise', async () => {
|
|
const old = (id: string, relatedType: string | null): Row => {
|
|
return without(note({ id, relatedType }), 'userId', 'category');
|
|
};
|
|
|
|
await importNotes([
|
|
old('a', 'JobRun'),
|
|
old('b', null),
|
|
old('c', 'COLONY'),
|
|
]);
|
|
|
|
const rows = written().map((row) => [row.userId, row.category, row.read]);
|
|
expect(rows).toEqual([
|
|
['u1', 'SYSTEM', true],
|
|
['u2', 'SYSTEM', true],
|
|
['u1', 'SYSTEM', true],
|
|
['u2', 'SYSTEM', true],
|
|
['u1', 'SUGGESTION', true],
|
|
['u2', 'SUGGESTION', true],
|
|
]);
|
|
expect(new Set(written().map((row) => row.id)).size).toBe(6);
|
|
});
|
|
|
|
it('fills the category of an old row that has a userId but none', async () => {
|
|
await importNotes([without(note({ relatedType: 'JobRun' }), 'category')]);
|
|
expect(written()[0]).toMatchObject({ userId: 'u1', category: 'SYSTEM' });
|
|
});
|
|
|
|
it('exports and restores the notification preferences of the users', async () => {
|
|
prisma.notificationPreference.findMany.mockResolvedValue([
|
|
{ userId: 'u1', digestTime: '07:00' },
|
|
{ userId: 'gone', digestTime: '08:00' },
|
|
]);
|
|
const bundle = await service.exportAll();
|
|
expect(bundle.data.notificationPreference).toHaveLength(2);
|
|
|
|
await service.importAll(bundle, 'admin-1', TRUSTED);
|
|
|
|
const calls = tx.notificationPreference.upsert.mock.calls as [
|
|
{ where: { userId: string } },
|
|
][];
|
|
expect(calls.map(([arg]) => arg.where.userId)).toEqual(['u1']);
|
|
});
|
|
});
|
|
|
|
describe('phenology and honey ledger', () => {
|
|
const importData = (data: Record<string, Row[]>, trusted = true) =>
|
|
service.importAll(
|
|
{ version: 3, exportedAt: '2026-01-01T00:00:00.000Z', data },
|
|
'admin-1',
|
|
{ trusted },
|
|
);
|
|
const created = (key: string): Row[] =>
|
|
(tx[key].upsert.mock.calls as [{ create: Row }][]).map(
|
|
([arg]) => arg.create,
|
|
);
|
|
|
|
it('backs up PhenologyReport and excludes ApiaryPhenology and ApiaryWeatherDay with reasons', async () => {
|
|
prisma.phenologyReport.findMany.mockResolvedValue([
|
|
{ id: 'p1', phase: 'KIRSCHE', userId: 'known-user' },
|
|
]);
|
|
|
|
const bundle = await service.exportAll();
|
|
|
|
expect(bundle.data.phenologyReport).toHaveLength(1);
|
|
expect(bundle.data).not.toHaveProperty('apiaryPhenology');
|
|
expect(bundle.data).not.toHaveProperty('apiaryWeatherDay');
|
|
expect(MODELS_EXCLUDED_FROM_BACKUP.ApiaryPhenology).toMatch(
|
|
/recomputed by daily-evaluation/,
|
|
);
|
|
expect(MODELS_EXCLUDED_FROM_BACKUP.ApiaryWeatherDay).toMatch(
|
|
/refetched by the worker/,
|
|
);
|
|
expect(MODELS_EXCLUDED_FROM_BACKUP).not.toHaveProperty('PhenologyReport');
|
|
});
|
|
|
|
it('excludes the Telegram models and the delivery queue from the backup, each with a reason', async () => {
|
|
const bundle = await service.exportAll();
|
|
|
|
for (const [model, key] of [
|
|
['TelegramLink', 'telegramLink'],
|
|
['TelegramLinkToken', 'telegramLinkToken'],
|
|
['NotificationDelivery', 'notificationDelivery'],
|
|
['TelegramPollState', 'telegramPollState'],
|
|
] as const) {
|
|
expect(bundle.data).not.toHaveProperty(key);
|
|
expect(MODELS_EXCLUDED_FROM_BACKUP[model]).toEqual(
|
|
expect.stringMatching(/.{30,}/),
|
|
);
|
|
}
|
|
expect(MODELS_EXCLUDED_FROM_BACKUP.TelegramLink).toMatch(/re-link/);
|
|
expect(MODELS_EXCLUDED_FROM_BACKUP.NotificationDelivery).toMatch(
|
|
/resend old messages/,
|
|
);
|
|
});
|
|
|
|
it('excludes VoiceCapture and VoiceCaptureAudio with a reason', async () => {
|
|
const bundle = await service.exportAll();
|
|
|
|
for (const [model, key] of [
|
|
['VoiceCapture', 'voiceCapture'],
|
|
['VoiceCaptureAudio', 'voiceCaptureAudio'],
|
|
] as const) {
|
|
expect(bundle.data).not.toHaveProperty(key);
|
|
expect(MODELS_EXCLUDED_FROM_BACKUP[model]).toMatch(
|
|
/ephemeral, personal data, not deliverable after a restore/,
|
|
);
|
|
}
|
|
});
|
|
|
|
it('excludes the VoiceExtractionCall ledger with a reason', async () => {
|
|
const bundle = await service.exportAll();
|
|
|
|
expect(bundle.data).not.toHaveProperty('voiceExtractionCall');
|
|
expect(MODELS_EXCLUDED_FROM_BACKUP.VoiceExtractionCall).toMatch(
|
|
/Operational ledger/,
|
|
);
|
|
});
|
|
|
|
it('restores phenology reports after the apiaries, remapping a user that does not exist', async () => {
|
|
await importData({
|
|
apiary: [{ id: 'apiary-1' }],
|
|
phenologyReport: [
|
|
{ id: 'p1', apiaryId: 'apiary-1', userId: 'known-user' },
|
|
{ id: 'p2', apiaryId: null, userId: 'ghost' },
|
|
],
|
|
});
|
|
|
|
expect(created('phenologyReport').map((row) => row.userId)).toEqual([
|
|
'known-user',
|
|
'admin-1',
|
|
]);
|
|
expect(tx.apiary.upsert.mock.invocationCallOrder[0]).toBeLessThan(
|
|
tx.phenologyReport.upsert.mock.invocationCallOrder[0],
|
|
);
|
|
});
|
|
|
|
it('an old bundle without initialAmount is recalculated like migration 6', async () => {
|
|
await importData({
|
|
honeyBatch: [
|
|
{ id: 'hb-1', amount: 20 },
|
|
{ id: 'hb-2', amount: null },
|
|
{ id: 'hb-3', amount: 5 },
|
|
],
|
|
bottlingBatch: [
|
|
{
|
|
id: 'bt-1',
|
|
honeyBatchId: 'hb-1',
|
|
totalAmount: 7.5,
|
|
createdAt: '2026-08-01T10:00:00.000Z',
|
|
},
|
|
{
|
|
id: 'bt-2',
|
|
honeyBatchId: 'hb-1',
|
|
totalAmount: 2.0004,
|
|
createdAt: '2026-08-02T10:00:00.000Z',
|
|
},
|
|
{ id: 'bt-3', honeyBatchId: 'hb-2', totalAmount: 1 },
|
|
],
|
|
});
|
|
|
|
expect(
|
|
created('honeyBatch').map((row) => [row.id, row.initialAmount]),
|
|
).toEqual([
|
|
['hb-1', 29.5],
|
|
['hb-2', undefined],
|
|
['hb-3', 5],
|
|
]);
|
|
expect(created('batchMovement')).toEqual([
|
|
expect.objectContaining({
|
|
honeyBatchId: 'hb-1',
|
|
sourceBottlingBatchId: 'bt-1',
|
|
type: 'BOTTLING',
|
|
quantity: -7500,
|
|
createdAt: '2026-08-01T10:00:00.000Z',
|
|
notes: 'Übernahme aus Abfüllung (T4)',
|
|
}),
|
|
expect.objectContaining({
|
|
honeyBatchId: 'hb-1',
|
|
sourceBottlingBatchId: 'bt-2',
|
|
type: 'BOTTLING',
|
|
quantity: -2000,
|
|
}),
|
|
]);
|
|
// After the batch and the bottling it points at.
|
|
expect(tx.bottlingBatch.upsert.mock.invocationCallOrder[0]).toBeLessThan(
|
|
tx.batchMovement.upsert.mock.invocationCallOrder[0],
|
|
);
|
|
});
|
|
|
|
it('leaves a bundle that carries initialAmount untouched', async () => {
|
|
await importData({
|
|
honeyBatch: [{ id: 'hb-1', amount: 20, initialAmount: 30 }],
|
|
bottlingBatch: [{ id: 'bt-1', honeyBatchId: 'hb-1', totalAmount: 10 }],
|
|
batchMovement: [
|
|
{
|
|
id: 'm1',
|
|
honeyBatchId: 'hb-1',
|
|
type: 'BOTTLING',
|
|
quantity: -10000,
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(created('honeyBatch')[0].initialAmount).toBe(30);
|
|
expect(created('batchMovement').map((row) => row.id)).toEqual(['m1']);
|
|
});
|
|
|
|
it('sets initialAmount from an unsealed file but books no movements', async () => {
|
|
await importData(
|
|
{
|
|
honeyBatch: [{ id: 'hb-new', amount: 20 }],
|
|
bottlingBatch: [
|
|
{ id: 'bt-2', honeyBatchId: 'hb-new', totalAmount: 5 },
|
|
],
|
|
},
|
|
false,
|
|
);
|
|
|
|
expect(created('honeyBatch')[0].initialAmount).toBe(25);
|
|
expect(tx.batchMovement.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reports honey batches that break the invariant after the import, without failing it', async () => {
|
|
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
|
|
(tx as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest
|
|
.fn()
|
|
.mockResolvedValue([{ id: 'hb-1' }]);
|
|
|
|
const summary = await importData({
|
|
honeyBatch: [{ id: 'hb-1', amount: 20, initialAmount: 30 }],
|
|
});
|
|
|
|
expect(summary.imported.honeyLedgerViolations).toBe(1);
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hb-1'));
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it('adds no violation count when the ledger is consistent', async () => {
|
|
(tx as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest
|
|
.fn()
|
|
.mockResolvedValue([]);
|
|
|
|
const summary = await importData({
|
|
honeyBatch: [{ id: 'hb-1', amount: 20, initialAmount: 20 }],
|
|
});
|
|
|
|
expect(summary.imported).not.toHaveProperty('honeyLedgerViolations');
|
|
});
|
|
});
|
|
|
|
describe('T6 compliance tables', () => {
|
|
const T6_KEYS = T6_MODELS.map(modelKey);
|
|
|
|
it('no T6 model is excluded from the backup or listed as legacy-unsealed', async () => {
|
|
const bundle = await service.exportAll();
|
|
|
|
for (const model of T6_MODELS) {
|
|
expect(MODELS_EXCLUDED_FROM_BACKUP).not.toHaveProperty(model);
|
|
expect(bundle.data).toHaveProperty(modelKey(model));
|
|
}
|
|
for (const key of T6_KEYS) {
|
|
expect(LEGACY_UNSEALED_TABLES as readonly string[]).not.toContain(key);
|
|
}
|
|
});
|
|
|
|
it('restores medicine, lot, treatment and movement in foreign-key order', async () => {
|
|
const one = (id: string) => [{ id }];
|
|
await service.importAll(
|
|
{
|
|
version: 3,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
apiary: one('apiary-1'),
|
|
apiaryRestriction: one('restriction-1'),
|
|
veterinaryMedicineMovement: one('movement-1'),
|
|
treatment: one('treatment-1'),
|
|
veterinaryMedicineLot: one('lot-1'),
|
|
veterinaryMedicine: one('medicine-1'),
|
|
},
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
|
|
const order = (key: string) => tx[key].upsert.mock.invocationCallOrder[0];
|
|
const dependencies: [parent: string, child: string][] = [
|
|
['veterinaryMedicine', 'veterinaryMedicineLot'],
|
|
['veterinaryMedicineLot', 'treatment'],
|
|
['veterinaryMedicine', 'treatment'],
|
|
['treatment', 'veterinaryMedicineMovement'],
|
|
['veterinaryMedicineLot', 'veterinaryMedicineMovement'],
|
|
['apiary', 'apiaryRestriction'],
|
|
];
|
|
for (const [parent, child] of dependencies) {
|
|
expect({ parent, child, before: order(parent) < order(child) }).toEqual(
|
|
{ parent, child, before: true },
|
|
);
|
|
}
|
|
});
|
|
|
|
it('remaps the user columns of the T6 rows like every other table', async () => {
|
|
await service.importAll(
|
|
{
|
|
version: 3,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
veterinaryMedicineLot: [
|
|
{ id: 'lot-1', userId: 'gone-user' },
|
|
{ id: 'lot-2', userId: 'known-user' },
|
|
{ id: 'lot-3', userId: null },
|
|
],
|
|
veterinaryMedicineMovement: [{ id: 'movement-1', userId: 'gone' }],
|
|
authorityNotice: [{ id: 'notice-1', createdByUserId: 'gone' }],
|
|
apiaryRestriction: [{ id: 'restriction-1', createdByUserId: null }],
|
|
complianceAcknowledgement: [
|
|
{ id: 'ack-1', userId: 'gone-user', revokedByUserId: 'gone' },
|
|
],
|
|
},
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
|
|
const created = (key: string, call = 0) =>
|
|
(tx[key].upsert.mock.calls[call] as [{ create: Row }])[0].create;
|
|
expect(created('veterinaryMedicineLot', 0).userId).toBe('admin-1');
|
|
expect(created('veterinaryMedicineLot', 1).userId).toBe('known-user');
|
|
expect(created('veterinaryMedicineLot', 2).userId).toBeNull();
|
|
expect(created('veterinaryMedicineMovement').userId).toBe('admin-1');
|
|
expect(created('authorityNotice').createdByUserId).toBe('admin-1');
|
|
expect(created('apiaryRestriction').createdByUserId).toBeNull();
|
|
expect(created('complianceAcknowledgement').userId).toBe('admin-1');
|
|
});
|
|
|
|
it('upserts the compliance settings singleton by id', async () => {
|
|
await service.importAll(
|
|
{
|
|
version: 3,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
complianceSettings: [
|
|
{ id: 'singleton', registrationNumber: 'X-1' },
|
|
],
|
|
},
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
|
|
const [[call]] = tx.complianceSettings.upsert.mock.calls as [
|
|
[{ where: Row; update: Row }],
|
|
];
|
|
expect(call.where).toEqual({ id: 'singleton' });
|
|
expect(call.update).toEqual({ registrationNumber: 'X-1' });
|
|
});
|
|
|
|
it('skips a notice whose open draft key is held by another row here and counts it', async () => {
|
|
(
|
|
tx as unknown as { authorityNotice: { findUnique: jest.Mock } }
|
|
).authorityNotice.findUnique = jest
|
|
.fn()
|
|
.mockResolvedValueOnce({ id: 'other-draft' })
|
|
.mockResolvedValueOnce({ id: 'notice-2' });
|
|
|
|
const summary = await service.importAll(
|
|
{
|
|
version: 3,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
authorityNotice: [
|
|
{ id: 'notice-1', openKey: 'HERD_CHANGE' },
|
|
{ id: 'notice-2', openKey: 'TSK_REPORT:2027' },
|
|
{ id: 'notice-3', openKey: null },
|
|
],
|
|
},
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
|
|
expect(tx.authorityNotice.upsert).toHaveBeenCalledTimes(2);
|
|
expect(summary.imported.authorityNotice).toBe(2);
|
|
expect(summary.imported.authorityNoticeSkipped).toBe(1);
|
|
});
|
|
|
|
it('an unsealed legacy file skips every T6 table and reports it', async () => {
|
|
const data = Object.fromEntries(
|
|
T6_KEYS.map((key) => [key, [{ id: `${key}-1` }]]),
|
|
);
|
|
|
|
const summary = await service.importAll(
|
|
{ version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data },
|
|
'admin-1',
|
|
{ trusted: false },
|
|
);
|
|
|
|
for (const key of T6_KEYS) {
|
|
expect({ key, calls: tx[key].upsert.mock.calls.length }).toEqual({
|
|
key,
|
|
calls: 0,
|
|
});
|
|
}
|
|
expect([...summary.skipped].sort()).toEqual([...T6_KEYS].sort());
|
|
});
|
|
|
|
it('an unsealed file keeps the pack link off its treatments', async () => {
|
|
await service.importAll(
|
|
{
|
|
version: 2,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
treatment: [
|
|
{
|
|
id: 'treatment-1',
|
|
product: 'Mittel',
|
|
medicineId: 'medicine-1',
|
|
medicineLotId: 'lot-1',
|
|
doseQuantity: 5,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
'admin-1',
|
|
{ trusted: false },
|
|
);
|
|
|
|
const [[call]] = tx.treatment.upsert.mock.calls as [[{ create: Row }]];
|
|
expect(call.create).not.toHaveProperty('medicineId');
|
|
expect(call.create).not.toHaveProperty('medicineLotId');
|
|
expect(call.create).not.toHaveProperty('doseQuantity');
|
|
expect(call.create.product).toBe('Mittel');
|
|
});
|
|
|
|
it('counts and logs the treatments whose pack link an unsealed file lost', async () => {
|
|
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
|
|
|
|
const summary = await service.importAll(
|
|
{
|
|
version: 2,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
treatment: [
|
|
{ id: 't1', medicineLotId: 'lot-1' },
|
|
{ id: 't2', doseQuantity: 3 },
|
|
{ id: 't3', product: 'ohne Verknüpfung', medicineId: null },
|
|
],
|
|
},
|
|
},
|
|
'admin-1',
|
|
{ trusted: false },
|
|
);
|
|
|
|
expect(summary.imported.treatmentMedicineLinksDropped).toBe(2);
|
|
expect(warn).toHaveBeenCalledWith(
|
|
expect.stringContaining('2 Behandlung'),
|
|
);
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it('adds no link counter when a sealed file keeps the links', async () => {
|
|
const summary = await service.importAll(
|
|
{
|
|
version: 3,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: { treatment: [{ id: 't1', medicineLotId: 'lot-1' }] },
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
|
|
expect(summary.imported).not.toHaveProperty(
|
|
'treatmentMedicineLinksDropped',
|
|
);
|
|
});
|
|
|
|
it('leaves out the documents of a skipped notice and lists its id', async () => {
|
|
(
|
|
tx as unknown as { authorityNotice: { findUnique: jest.Mock } }
|
|
).authorityNotice.findUnique = jest
|
|
.fn()
|
|
.mockResolvedValue({ id: 'other-draft' });
|
|
const pdf = Buffer.from('%PDF-1.4').toString('base64');
|
|
const doc = (id: string, entityType: string, entityId: string) => ({
|
|
id,
|
|
entityType,
|
|
entityId,
|
|
mimeType: 'application/pdf',
|
|
contentBase64: pdf,
|
|
});
|
|
|
|
const summary = await service.importAll(
|
|
{
|
|
version: 3,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
authorityNotice: [
|
|
{ id: 'notice-1', openKey: 'HERD_CHANGE' },
|
|
{ id: 'notice-2', openKey: null },
|
|
],
|
|
document: [
|
|
doc('d1', 'AUTHORITY_NOTICE', 'notice-1'),
|
|
doc('d2', 'AUTHORITY_NOTICE', 'notice-2'),
|
|
doc('d3', 'COLONY', 'notice-1'),
|
|
],
|
|
},
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
|
|
expect(summary.skippedNoticeIds).toEqual(['notice-1']);
|
|
expect(summary.imported.authorityNoticeDocumentsSkipped).toBe(1);
|
|
expect(summary.imported.document).toBe(2);
|
|
const calls = tx.document.upsert.mock.calls as [
|
|
{ where: { id: string } },
|
|
][];
|
|
const ids = calls.map(([arg]) => arg.where.id);
|
|
expect(ids).toEqual(['d2', 'd3']);
|
|
});
|
|
|
|
it('a sealed file keeps the pack link of its treatments', async () => {
|
|
await service.importAll(
|
|
{
|
|
version: 3,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
treatment: [
|
|
{ id: 'treatment-1', medicineLotId: 'lot-1', doseQuantity: 5 },
|
|
],
|
|
},
|
|
},
|
|
'admin-1',
|
|
TRUSTED,
|
|
);
|
|
|
|
const [[call]] = tx.treatment.upsert.mock.calls as [[{ create: Row }]];
|
|
expect(call.create).toMatchObject({
|
|
medicineLotId: 'lot-1',
|
|
doseQuantity: 5,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('importAll hive models', () => {
|
|
const hiveBundle = (): BackupBundle => ({
|
|
version: 3,
|
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
data: {
|
|
hiveSystem: [{ id: 'hive-system-1', name: 'System' }],
|
|
hivePartType: [{ id: 'hive-part-1', systemId: 'hive-system-1' }],
|
|
colony: [{ id: 'colony-1' }, { id: 'colony-2' }],
|
|
hiveSetup: [
|
|
{ colonyId: 'colony-1', systemId: 'hive-system-1', version: 2 },
|
|
{ colonyId: 'colony-other', systemId: 'hive-system-1', version: 1 },
|
|
],
|
|
hiveChange: [
|
|
{ id: 'change-1', colonyId: 'colony-1', userId: 'known-user' },
|
|
{ id: 'change-2', colonyId: 'colony-1', userId: 'gone-user' },
|
|
{ id: 'change-3', colonyId: 'colony-other', userId: 'known-user' },
|
|
],
|
|
},
|
|
});
|
|
|
|
it('upserts the catalog by id so seeded rows are neither duplicated nor rejected', async () => {
|
|
await service.importAll(hiveBundle(), 'admin-1', TRUSTED);
|
|
|
|
expect(tx.hiveSystem.upsert).toHaveBeenCalledWith(
|
|
expect.objectContaining({ where: { id: 'hive-system-1' } }),
|
|
);
|
|
expect(tx.hivePartType.upsert).toHaveBeenCalledWith(
|
|
expect.objectContaining({ where: { id: 'hive-part-1' } }),
|
|
);
|
|
});
|
|
|
|
it('replaces setup and history of each restored colony, remapping the history user', async () => {
|
|
const summary = await service.importAll(hiveBundle(), 'admin-1', TRUSTED);
|
|
|
|
expect(tx.hiveChange.deleteMany).toHaveBeenCalledWith({
|
|
where: { colonyId: { in: ['colony-1'] } },
|
|
});
|
|
expect(tx.hiveSetup.deleteMany).toHaveBeenCalledWith({
|
|
where: { colonyId: { in: ['colony-1'] } },
|
|
});
|
|
expect(tx.hiveSetup.create).toHaveBeenCalledTimes(1);
|
|
const created = tx.hiveChange.create!.mock.calls.map(
|
|
([arg]: [{ data: { id: string; userId: string } }]) => arg.data,
|
|
);
|
|
expect(created).toEqual([
|
|
expect.objectContaining({ id: 'change-1', userId: 'known-user' }),
|
|
expect.objectContaining({ id: 'change-2', userId: 'admin-1' }),
|
|
]);
|
|
expect(summary.imported).toMatchObject({
|
|
hiveSystem: 1,
|
|
hivePartType: 1,
|
|
hiveSetup: 2,
|
|
hiveChange: 3,
|
|
});
|
|
});
|
|
|
|
it('takes no hive table from an unsealed file and reports them', async () => {
|
|
const summary = await service.importAll(hiveBundle(), 'admin-1', {
|
|
trusted: false,
|
|
});
|
|
|
|
expect(tx.hiveSetup.create).not.toHaveBeenCalled();
|
|
expect(tx.hiveSystem.upsert).not.toHaveBeenCalled();
|
|
expect(summary.skipped).toEqual(
|
|
expect.arrayContaining([
|
|
'hiveSystem',
|
|
'hivePartType',
|
|
'hiveSetup',
|
|
'hiveChange',
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
|
|
// Drift guard: a model added to the schema has to be backed up or listed
|
|
// in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing.
|
|
describe('model coverage', () => {
|
|
it('backs up every Prisma model or names why it is left out', async () => {
|
|
const bundle = await service.exportAll();
|
|
|
|
const uncovered = Object.values(Prisma.ModelName).filter(
|
|
(model) =>
|
|
!(modelKey(model) in bundle.data) &&
|
|
!(model in MODELS_EXCLUDED_FROM_BACKUP),
|
|
);
|
|
|
|
expect(uncovered).toEqual([]);
|
|
});
|
|
|
|
it('gives every excluded model a reason and never exports it anyway', async () => {
|
|
const bundle = await service.exportAll();
|
|
|
|
for (const [model, reason] of Object.entries(
|
|
MODELS_EXCLUDED_FROM_BACKUP,
|
|
)) {
|
|
expect(bundle.data).not.toHaveProperty(modelKey(model));
|
|
expect(reason?.trim().length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it('restores every table it exports', async () => {
|
|
const bundle = await service.exportAll();
|
|
|
|
const summary = await service.importAll(bundle, 'admin-1', TRUSTED);
|
|
|
|
expect(Object.keys(summary.imported).sort()).toEqual(
|
|
Object.keys(bundle.data).sort(),
|
|
);
|
|
});
|
|
});
|
|
});
|