feat: Backend-Modul fuer vollstaendiges Daten-Backup (Export/Import)

Neues Modul src/backup/ fuer Backups und Datenuebertragung zwischen
Deployments: GET /backup/export liefert ein JSON-Bundle mit allen
fachlichen Daten (Bienenstaende bis Etikettenvorlagen, ~30 Tabellen),
POST /backup/import spielt so ein Bundle wieder ein.

Bewusst NICHT enthalten: User/Rollen/Rechte (werden ueber die
Benutzerverwaltung gepflegt, Permission-Zeilen werden ohnehin bei
jedem Start aus dem Code neu synchronisiert), Audit-Log (unveraenderliche
Historie), Notifications (fluechtig), Dokumente (nur die DB-Zeile waere
gesichert, nicht die tatsaechliche Datei auf der Festplatte).

Import laeuft in einer Transaktion, Tabellen in Abhaengigkeitsreihenfolge
(z.B. Bienenstand vor Volk, Volk vor Durchsicht). Selbstreferenzen
(Koenigin -> Mutterkoenigin) werden in zwei Durchgaengen aufgeloest,
damit die Reihenfolge im Export keine Rolle spielt. userId-Felder, die
im Zielsystem nicht existieren (z.B. Uebertragung auf ein anderes
Deployment), werden auf den ausfuehrenden Admin umgebogen statt die
Fremdschluessel-Pruefung scheitern zu lassen. AutomationRule wird ueber
triggerType statt id abgeglichen, da diese Zeilen code-fest sind.

Neue Berechtigungen backup:export/backup:import. 8 neue Unit-Tests
fuer die Sonderfaelle (Selbstreferenz, userId-Remapping,
AutomationRule-Abgleich, Audit-Eintrag).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-03 10:22:15 +02:00
1 parent 390ec32f50
commit 2f223b6941
6 files changed
+896

No files matched your search

@@ -0,0 +1,243 @@
import { BadRequestException } from '@nestjs/common';
import { BackupService } from './backup.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
const ENTITY_KEYS = [
'apiary',
'queen',
'colony',
'colonyApiaryHistory',
'colonyLineage',
'queenEvaluation',
'queenColonyHistory',
'inspection',
'taskTemplate',
'task',
'varroaMeasurement',
'swarmControlCheck',
'treatment',
'feeding',
'harvest',
'harvestColony',
'extraction',
'extractionHarvest',
'storageContainer',
'honeyBatch',
'honeyMeasurement',
'honeyBatchRelocation',
'stockItem',
'stockMovement',
'product',
'labelTemplate',
'bottlingBatch',
'equipment',
'webhookSubscription',
'automationRule',
'siteSettings',
] as const;
describe('BackupService', () => {
let service: BackupService;
let prisma: Record<string, { findMany: jest.Mock }> & {
user: { findMany: jest.Mock };
$transaction: jest.Mock;
};
let tx: Record<
string,
{ upsert: jest.Mock; update?: jest.Mock }
>;
let auditLog: { record: jest.Mock };
beforeEach(() => {
tx = {};
for (const key of ENTITY_KEYS) {
tx[key] = { upsert: jest.fn().mockResolvedValue({}) };
}
tx.queen.update = jest.fn().mockResolvedValue({});
prisma = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'user-1' }]) },
$transaction: jest.fn(
(callback: (txArg: typeof tx) => unknown) => callback(tx),
),
} as never;
for (const key of ENTITY_KEYS) {
prisma[key] = { findMany: jest.fn().mockResolvedValue([]) };
}
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
service = new BackupService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
});
describe('exportAll', () => {
it('returns a versioned bundle with every entity key', async () => {
const bundle = await service.exportAll();
expect(bundle.version).toBe(1);
expect(typeof bundle.exportedAt).toBe('string');
for (const key of ENTITY_KEYS) {
expect(bundle.data).toHaveProperty(key);
}
});
});
describe('importAll', () => {
it('rejects a bundle with no data object', async () => {
await expect(
service.importAll({ version: 1 } as never, 'admin-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it('links a self-referencing motherQueenId only after every queen exists', async () => {
await service.importAll(
{
version: 1,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
queen: [
{ id: 'queen-daughter', motherQueenId: 'queen-mother' },
{ id: 'queen-mother', motherQueenId: null },
],
},
},
'admin-1',
);
// First pass: both queens upserted with motherQueenId stripped, so a
// daughter appearing before her mother in the array never violates
// the foreign key.
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();
}
// Second pass: only the daughter's real motherQueenId is patched back.
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 () => {
prisma.user.findMany.mockResolvedValue([{ id: 'known-user' }]);
await service.importAll(
{
version: 1,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
inspection: [
{ id: 'inspection-1', colonyId: 'colony-1', userId: 'ghost-user' },
],
},
},
'admin-1',
);
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 () => {
prisma.user.findMany.mockResolvedValue([{ id: 'known-user' }]);
await service.importAll(
{
version: 1,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
inspection: [
{ id: 'inspection-1', colonyId: 'colony-1', userId: 'known-user' },
],
},
},
'admin-1',
);
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: 1,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
stockMovement: [
{ id: 'movement-1', stockItemId: 'stock-1', userId: null },
],
},
},
'admin-1',
);
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: 1,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
automationRule: [
{
id: 'source-db-id',
triggerType: 'VARROA_THRESHOLD',
active: true,
},
],
},
},
'admin-1',
);
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('records an audit log entry with the per-entity import counts', async () => {
await service.importAll(
{
version: 1,
exportedAt: '2026-01-01T00:00:00.000Z',
data: { apiary: [{ id: 'apiary-1', name: 'Stand Wald' }] },
},
'admin-1',
);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Backup',
action: 'CREATE',
userId: 'admin-1',
afterData: expect.objectContaining({ apiary: 1 }),
}),
);
});
});
});