import crypto from "node:crypto"; import path from "node:path"; import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { eq } from "drizzle-orm"; import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { createDatabaseContext, type DatabaseContext, } from "../src/db/database-context.js"; import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js"; import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js"; import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js"; import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js"; import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js"; import { deleteEstimateBalance, insertEstimateBalance, readEstimateBalances, } from "../src/db/repositories/estimate-balance.persistence.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitLists } from "../src/db/schema/circuit-lists.js"; import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuits } from "../src/db/schema/circuits.js"; import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js"; import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js"; import { distributionBoards } from "../src/db/schema/distribution-boards.js"; import { buildings } from "../src/db/schema/buildings.js"; import { floors } from "../src/db/schema/floors.js"; import { projectDevices } from "../src/db/schema/project-devices.js"; import { projectSnapshots } from "../src/db/schema/project-snapshots.js"; import { projects } from "../src/db/schema/projects.js"; import { rooms } from "../src/db/schema/rooms.js"; import { deserializeProjectStateSnapshot } from "../src/domain/models/project-state-snapshot.model.js"; import { projectStateSnapshotSchemaVersion } from "../src/domain/models/project-state-snapshot.model.js"; import { createProjectStateRestoreCommand } from "../src/domain/models/project-state-restore-command.model.js"; import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; import { ProjectSnapshotNameConflictError } from "../src/domain/errors/project-snapshot-name-conflict.error.js"; import { parseEstimateBalanceSnapshot } from "../src/estimate-balance/domain/estimate-balance-command.model.js"; function estimateBalance(projectId: string, suffix = "") { return parseEstimateBalanceSnapshot({ id: `estimate${suffix}`, projectId, name: `Vorplanung${suffix}`, workStage: "LPH 2", dateIso: "2026-09-27", reserveAvPercent: 10, reserveSvPercent: 5, sortOrder: 0, groups: [ { id: `estimate-group${suffix}`, costGroup: "440", title: "Starkstromanlagen", editor: "Planung", sortOrder: 0, rows: [ { id: `estimate-row${suffix}`, sortOrder: 0, supply: "ELT", name: "Beleuchtung", quantity: 100, unit: "area", connection: "fixed", powerPerUnitW: 12, simultaneityFactor: 0.8, cosPhi: 0.95, safetySupply: false, remark: null, }, ], }, ], }); } function createTestDatabase(): DatabaseContext { const context = createDatabaseContext(":memory:"); migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations"), }); context.db .insert(projects) .values({ id: "project-1", name: "Snapshot-Projekt", singlePhaseVoltageV: 230, threePhaseVoltageV: 400, }) .run(); const board = new DistributionBoardFixtureRepository( context.db ).createWithCircuitListAndDefaultSections("project-1", "UV-01"); const list = context.db .select() .from(circuitLists) .where(eq(circuitLists.distributionBoardId, board.id)) .get(); assert.ok(list); const section = context.db .select() .from(circuitSections) .where(eq(circuitSections.circuitListId, list.id)) .get(); assert.ok(section); context.db .insert(buildings) .values({ id: "building-1", projectId: "project-1", name: "Haus A", sortOrder: 0, }) .run(); context.db .insert(floors) .values({ id: "floor-1", projectId: "project-1", name: "EG", sortOrder: 10, buildingId: "building-1", }) .run(); context.db .update(distributionBoards) .set({ floorId: "floor-1", supplyType: "SV", simultaneityFactor: 0.65, }) .where(eq(distributionBoards.id, board.id)) .run(); context.db .insert(rooms) .values({ id: "room-1", projectId: "project-1", floorId: "floor-1", roomNumber: "001", roomName: "Technik", }) .run(); context.db .insert(projectDevices) .values({ id: "device-1", projectId: "project-1", name: "Pumpe", displayName: "Pumpe", phaseType: "three_phase", quantity: 1, powerPerUnit: 2.5, simultaneityFactor: 0.8, cosPhi: 0.9, voltageV: 400, }) .run(); context.db .insert(circuits) .values({ id: "circuit-1", circuitListId: list.id, sectionId: section.id, equipmentIdentifier: "-1F1", displayName: "Technik", sortOrder: 10, voltage: 230, isReserve: 0, }) .run(); context.db .insert(circuitDeviceRows) .values({ id: "row-1", circuitId: "circuit-1", linkedProjectDeviceId: "device-1", roomId: "room-1", sortOrder: 10, name: "Pumpe", displayName: "Pumpe lokal", phaseType: "three_phase", quantity: 1, powerPerUnit: 2.5, simultaneityFactor: 0.8, cosPhi: 0.9, }) .run(); context.db .insert(circuitProtectionDevices) .values({ circuitId: "circuit-1", type: "LS", ratedCurrentA: 10, fuseUtilizationCategory: null, tripCharacteristic: "B", rcdType: null, ratedResidualCurrentMa: null, }) .run(); context.db .insert(distributionBoardComponents) .values({ id: "component-1", circuitListId: list.id, sectionId: section.id, equipmentIdentifier: "-1Q1.0", name: "Gruppen-FI", role: "group_residual_current_protection", placement: "group", sortOrder: 10, }) .run(); context.db .insert(distributionBoardComponentProtectionDevices) .values({ componentId: "component-1", type: "FI", ratedCurrentA: 40, fuseUtilizationCategory: null, tripCharacteristic: null, rcdType: "A", ratedResidualCurrentMa: 30, }) .run(); return context; } describe("project snapshot repository", () => { it("captures, hashes and lists a complete named logical snapshot", () => { const context = createTestDatabase(); try { const repository = new ProjectSnapshotRepository(context.db); const created = repository.createNamed({ projectId: "project-1", expectedRevision: 0, name: " Vor Ausschreibung ", description: " Sicherer Stand ", actorId: "planner-1", }); assert.ok(created); assert.equal(created.sourceRevision, 0); assert.equal(created.schemaVersion, projectStateSnapshotSchemaVersion); assert.equal(created.name, "Vor Ausschreibung"); assert.equal(created.description, "Sicherer Stand"); assert.equal(created.createdByActorId, "planner-1"); assert.match(created.payloadSha256, /^[a-f0-9]{64}$/); assert.equal( context.db .select({ currentRevision: projects.currentRevision }) .from(projects) .where(eq(projects.id, "project-1")) .get()?.currentRevision, 0 ); const persisted = context.db .select() .from(projectSnapshots) .where(eq(projectSnapshots.id, created.id)) .get(); assert.ok(persisted); assert.equal( crypto .createHash("sha256") .update(persisted.payloadJson) .digest("hex"), persisted.payloadSha256 ); const state = deserializeProjectStateSnapshot( persisted.payloadJson ); assert.equal(state.project.id, "project-1"); assert.equal(state.distributionBoards.length, 1); assert.equal(state.distributionBoards[0].floorId, "floor-1"); assert.equal(state.distributionBoards[0].supplyType, "SV"); assert.equal( state.distributionBoards[0].simultaneityFactor, 0.65 ); assert.equal(state.circuitLists.length, 1); assert.equal( state.circuitSections.length, context.db.select().from(circuitSections).all().length ); assert.equal(state.circuits.length, 1); assert.equal(state.circuitProtectionDevices.length, 1); assert.equal( state.distributionBoardComponents.length, context.db .select() .from(distributionBoardComponents) .all().length ); assert.equal( state.distributionBoardComponentProtectionDevices.length, 1 ); assert.equal(state.circuits[0].deviceRows.length, 1); assert.equal( state.circuits[0].deviceRows[0].linkedProjectDeviceId, "device-1" ); assert.equal(state.projectDevices.length, 1); assert.equal(state.buildings.length, 1); assert.equal(state.floors.length, 1); assert.equal(state.floors[0].buildingId, "building-1"); assert.equal(state.rooms.length, 1); assert.deepEqual(repository.listByProject("project-1"), [ created, ]); } finally { context.close(); } }); it("rejects stale revisions and duplicate names without partial rows", () => { const context = createTestDatabase(); try { const repository = new ProjectSnapshotRepository(context.db); assert.throws( () => repository.createNamed({ projectId: "project-1", expectedRevision: 1, name: "Stale", }), ProjectRevisionConflictError ); const created = repository.createNamed({ projectId: "project-1", expectedRevision: 0, name: "Freigabe", }); assert.ok(created); assert.throws( () => repository.createNamed({ projectId: "project-1", expectedRevision: 0, name: " Freigabe ", }), ProjectSnapshotNameConflictError ); assert.equal( context.db.select().from(projectSnapshots).all().length, 1 ); } finally { context.close(); } }); it("distinguishes unknown projects from empty snapshot lists", () => { const context = createTestDatabase(); try { const repository = new ProjectSnapshotRepository(context.db); assert.deepEqual(repository.listByProject("project-1"), []); assert.equal(repository.listByProject("missing"), null); assert.equal( repository.createNamed({ projectId: "missing", expectedRevision: 0, name: "Unbekannt", }), null ); } finally { context.close(); } }); it("verifies stored payload integrity before preparing a restore", () => { const context = createTestDatabase(); try { const repository = new ProjectSnapshotRepository(context.db); const snapshot = repository.createNamed({ projectId: "project-1", expectedRevision: 0, name: "Freigabe", }); assert.ok(snapshot); const prepared = repository.prepareRestore( "project-1", snapshot.id ); assert.ok(prepared); assert.equal( prepared.command.payload.targetState.project.id, "project-1" ); const currentPayload = JSON.parse( context.db .select({ payloadJson: projectSnapshots.payloadJson }) .from(projectSnapshots) .where(eq(projectSnapshots.id, snapshot.id)) .get()!.payloadJson ) as Record & { project: Record; }; currentPayload.schemaVersion = 99; const unsupportedPayloadJson = JSON.stringify(currentPayload); context.db .update(projectSnapshots) .set({ schemaVersion: 99, payloadJson: unsupportedPayloadJson, payloadSha256: crypto .createHash("sha256") .update(unsupportedPayloadJson) .digest("hex"), }) .where(eq(projectSnapshots.id, snapshot.id)) .run(); assert.throws( () => repository.prepareRestore("project-1", snapshot.id), /schema version is not supported/ ); context.db .update(projectSnapshots) .set({ payloadJson: `${JSON.stringify({})}\n` }) .where(eq(projectSnapshots.id, snapshot.id)) .run(); assert.throws( () => repository.prepareRestore("project-1", snapshot.id), /checksum verification failed/ ); assert.equal( repository.prepareRestore("project-1", "missing"), null ); } finally { context.close(); } }); it("restores snapshots stored with an older supported schema version", () => { const context = createTestDatabase(); try { const repository = new ProjectSnapshotRepository(context.db); const snapshot = repository.createNamed({ projectId: "project-1", expectedRevision: 0, name: "Vor dem Update", }); assert.ok(snapshot); // Rewrite the stored payload into the previous (version 5) shape, as it // exists for snapshots created before the current version. const payload = JSON.parse( context.db .select({ payloadJson: projectSnapshots.payloadJson }) .from(projectSnapshots) .where(eq(projectSnapshots.id, snapshot.id)) .get()!.payloadJson ) as { schemaVersion: number; project: Record; distributionBoards: Array>; buildings?: unknown; floors: Array>; }; payload.schemaVersion = 5; delete payload.buildings; for (const floor of payload.floors) { delete floor.buildingId; } for (const field of ["siteAddress", "workStage", "projectLead", "projectEditor"]) { delete payload.project[field]; } for (const board of payload.distributionBoards) { for (const field of ["boardNumber", "location", "fedFrom", "remark"]) { delete board[field]; } } const payloadJson = JSON.stringify(payload); context.db .update(projectSnapshots) .set({ schemaVersion: 5, payloadJson, payloadSha256: crypto .createHash("sha256") .update(payloadJson) .digest("hex"), }) .where(eq(projectSnapshots.id, snapshot.id)) .run(); const prepared = repository.prepareRestore("project-1", snapshot.id); assert.ok(prepared); const targetState = prepared.command.payload.targetState; assert.equal(targetState.schemaVersion, projectStateSnapshotSchemaVersion); assert.ok(targetState.distributionBoards.length > 0); assert.ok( targetState.distributionBoards.every( (board) => board.boardNumber === null && board.fedFrom === null ) ); // Version 5 predates buildings: the upgraded state has none. assert.deepEqual(targetState.buildings, []); assert.ok(targetState.floors.every((floor) => floor.buildingId === null)); } finally { context.close(); } }); }); describe("portable project transfer", () => { it("keeps estimate balances outside normal snapshot restore", () => { const context = createTestDatabase(); try { insertEstimateBalance(context.db, estimateBalance("project-1")); const current = readProjectStateSnapshot(context.db, "project-1"); assert.ok(current); new ProjectStateRestoreCommandRepository(context.db).execute({ projectId: "project-1", expectedRevision: 0, source: "restore", command: createProjectStateRestoreCommand(current.payloadSha256, { ...current.state, project: { ...current.state.project, name: "Restored project" }, }), }); assert.deepEqual( readEstimateBalances(context.db, "project-1").map((entry) => entry.id), ["estimate"] ); } finally { context.close(); } }); it("exports and atomically duplicates a complete project with new ids", () => { const context = createTestDatabase(); try { context.db.update(projects).set({ siteAddress: "Musterstraße 1", workStage: "LPH 5", projectLead: "Planung", projectEditor: "Test" }).where(eq(projects.id, "project-1")).run(); context.db.update(distributionBoards).set({ boardNumber: "UV-01", location: "Technik", fedFrom: "HAK", remark: "Prüfstand" }).where(eq(distributionBoards.projectId, "project-1")).run(); insertEstimateBalance(context.db, estimateBalance("project-1")); const repository = new ProjectTransferRepository(context.db); const transfer = repository.exportProject("project-1"); assert.ok(transfer); assert.equal(transfer.formatVersion, 2); assert.equal(transfer.estimateBalances.length, 1); const duplicate = repository.importDuplicate(transfer); assert.notEqual(duplicate.projectId, "project-1"); assert.equal(duplicate.name, "Snapshot-Projekt (Kopie)"); const source = readProjectStateSnapshot(context.db, "project-1"); const copied = readProjectStateSnapshot( context.db, duplicate.projectId ); assert.ok(source); assert.ok(copied); assert.equal(copied.currentRevision, 0); assert.equal(copied.state.project.siteAddress, source.state.project.siteAddress); assert.equal(copied.state.project.workStage, "LPH 5"); assert.equal(copied.state.project.projectLead, "Planung"); assert.equal(copied.state.project.projectEditor, "Test"); for (const field of ["boardNumber", "location", "fedFrom", "remark"] as const) { assert.equal(copied.state.distributionBoards[0][field], source.state.distributionBoards[0][field]); } assert.equal(copied.state.circuits.length, source.state.circuits.length); assert.notEqual( copied.state.circuits[0].id, source.state.circuits[0].id ); assert.equal(copied.state.distributionBoardComponents.length, 3); assert.equal( copied.state.circuitProtectionDevices[0].circuitId, copied.state.circuits[0].id ); assert.equal( copied.state.distributionBoardComponents.some( (component) => component.id === copied.state .distributionBoardComponentProtectionDevices[0] .componentId ), true ); assert.notEqual( copied.state.distributionBoardComponentProtectionDevices[0] .componentId, source.state.distributionBoardComponentProtectionDevices[0] .componentId ); assert.equal( copied.state.circuits[0].deviceRows[0].linkedProjectDeviceId, copied.state.projectDevices[0].id ); assert.equal( copied.state.circuits[0].deviceRows[0].roomId, copied.state.rooms[0].id ); assert.equal( copied.state.distributionBoards[0].floorId, copied.state.floors[0].id ); assert.notEqual(copied.state.buildings[0].id, "building-1"); assert.equal( copied.state.floors[0].buildingId, copied.state.buildings[0].id ); assert.equal( copied.state.distributionBoards[0].supplyType, "SV" ); assert.equal( copied.state.distributionBoards[0].simultaneityFactor, 0.65 ); const copiedBalances = readEstimateBalances(context.db, duplicate.projectId); assert.equal(copiedBalances.length, 1); assert.notEqual(copiedBalances[0].id, transfer.estimateBalances[0].id); assert.notEqual( copiedBalances[0].groups[0].id, transfer.estimateBalances[0].groups[0].id ); assert.notEqual( copiedBalances[0].groups[0].rows[0].id, transfer.estimateBalances[0].groups[0].rows[0].id ); assert.equal(copiedBalances[0].groups[0].rows[0].name, "Beleuchtung"); } finally { context.close(); } }); it("prepares same-project replacement and rejects damaged payloads", () => { const context = createTestDatabase(); try { const repository = new ProjectTransferRepository(context.db); const transfer = repository.exportProject("project-1"); assert.ok(transfer); const prepared = repository.prepareReplace("project-1", transfer); assert.ok(prepared); assert.equal( prepared.command.payload.targetState.project.id, "project-1" ); assert.throws( () => repository.importDuplicate({ ...transfer, payloadSha256: "0".repeat(64), }), /checksum verification failed/ ); assert.throws( () => repository.importDuplicate({ ...transfer, estimateBalances: [estimateBalance("project-1", "-tampered")], }), /checksum verification failed/ ); assert.equal( context.db.select().from(projects).all().length, 1 ); } finally { context.close(); } }); it("imports legacy transfer version 1 with no estimate balances", () => { const context = createTestDatabase(); try { const snapshot = readProjectStateSnapshot(context.db, "project-1"); assert.ok(snapshot); const legacyTransfer = { format: "leistungsbilanz.project", formatVersion: 1, exportedAtIso: "2026-09-27T12:00:00.000Z", payloadSha256: snapshot.payloadSha256, projectState: snapshot.state, }; const duplicate = new ProjectTransferRepository(context.db).importDuplicate( legacyTransfer ); assert.deepEqual( readEstimateBalances(context.db, duplicate.projectId), [] ); } finally { context.close(); } }); it("replaces exported estimate balances atomically and restores them on undo", () => { const context = createTestDatabase(); try { insertEstimateBalance(context.db, estimateBalance("project-1")); const transferRepository = new ProjectTransferRepository(context.db); const transfer = transferRepository.exportProject("project-1"); assert.ok(transfer); deleteEstimateBalance(context.db, "project-1", "estimate"); insertEstimateBalance(context.db, estimateBalance("project-1", "-changed")); const prepared = transferRepository.prepareReplace("project-1", transfer); assert.ok(prepared); const restored = new ProjectStateRestoreCommandRepository(context.db).execute({ projectId: "project-1", expectedRevision: 0, source: "restore", command: prepared.command, }); assert.deepEqual( readEstimateBalances(context.db, "project-1").map((entry) => entry.id), ["estimate"] ); new ProjectStateRestoreCommandRepository(context.db).execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: restored.revision.changeSetId, command: restored.inverse, }); assert.deepEqual( readEstimateBalances(context.db, "project-1").map((entry) => entry.id), ["estimate-changed"] ); } finally { context.close(); } }); it("rolls back a duplicate when a late project-state insert fails", () => { const context = createTestDatabase(); try { insertEstimateBalance(context.db, estimateBalance("project-1")); const repository = new ProjectTransferRepository(context.db); const transfer = repository.exportProject("project-1"); assert.ok(transfer); context.sqlite.exec(` CREATE TRIGGER fail_imported_estimate_row BEFORE INSERT ON estimate_rows BEGIN SELECT RAISE(ABORT, 'forced project transfer failure'); END; `); assert.throws( () => repository.importDuplicate(transfer), /forced project transfer failure/ ); assert.equal( context.db.select().from(projects).all().length, 1 ); } finally { context.close(); } }); });