Files
ApiCore/ApiCore-Backend/src/backup/backup.service.spec.ts
T
Nick.RosenloecherandClaude Opus 5.5 6a915aad9c
Backend E2E / e2e (push) Failing after 7m2s
Backend Image / build-and-push (push) Successful in 8m20s
Manifests / validate (push) Successful in 33s
Frontend Image / build-and-push (push) Failing after 21m23s
feat: Auswertung der Sprachnotizen mit Claude (T7 L4 Tasks 14, 15)
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]>
2026-10-16 15:38:15 +02:00

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(),
);
});
});
});