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:
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 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user