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 { CircuitDeviceRowMoveProjectCommandRepository } from "../src/db/repositories/circuit-device-row-move-project-command.repository.js"; import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js"; import { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-structure-project-command.repository.js"; import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js"; import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js"; import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js"; import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js"; import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js"; import { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.repository.js"; import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.repository.js"; import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js"; import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js"; import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js"; import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js"; import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitLists } from "../src/db/schema/circuit-lists.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuits } from "../src/db/schema/circuits.js"; import { consumers } from "../src/db/schema/consumers.js"; import { floors } from "../src/db/schema/floors.js"; import { legacyConsumerCircuitMigrations } from "../src/db/schema/legacy-consumer-circuit-migrations.js"; import { legacyConsumerMigrationReports } from "../src/db/schema/legacy-consumer-migration-report.js"; import { projectDevices } from "../src/db/schema/project-devices.js"; import { projectRevisions } from "../src/db/schema/project-revisions.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 { ProjectStateConflictError } from "../src/domain/errors/project-state-conflict.error.js"; import { ProjectCommandService } from "../src/domain/services/project-command.service.js"; 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: "Ausgangsprojekt" }) .run(); const board = new DistributionBoardRepository( 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(floors) .values({ id: "floor-1", projectId: "project-1", name: "EG", sortOrder: 10, }) .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, }) .run(); context.db .insert(circuits) .values({ id: "circuit-1", circuitListId: list.id, sectionId: section.id, equipmentIdentifier: "-1F1", displayName: "Ausgang", sortOrder: 10, 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", quantity: 1, powerPerUnit: 2.5, simultaneityFactor: 0.8, }) .run(); return context; } function createService(context: DatabaseContext) { return new ProjectCommandService( new CircuitProjectCommandRepository(context.db), new CircuitDeviceRowProjectCommandRepository(context.db), new CircuitDeviceRowStructureProjectCommandRepository(context.db), new CircuitDeviceRowMoveProjectCommandRepository(context.db), new CircuitStructureProjectCommandRepository(context.db), new DistributionBoardStructureProjectCommandRepository(context.db), new CircuitSectionReorderProjectCommandRepository(context.db), new CircuitSectionRenumberProjectCommandRepository(context.db), new ProjectDeviceStructureProjectCommandRepository(context.db), new ProjectDeviceProjectCommandRepository(context.db), new ProjectDeviceRowSyncProjectCommandRepository(context.db), new ProjectSettingsProjectCommandRepository(context.db), new ProjectStateRestoreCommandRepository(context.db), new ProjectHistoryRepository(context.db) ); } function changeCompleteProjectState(context: DatabaseContext) { context.db .update(projects) .set({ name: "Geänderter Stand", singlePhaseVoltageV: 240, threePhaseVoltageV: 415, }) .where(eq(projects.id, "project-1")) .run(); context.db .update(circuits) .set({ displayName: "Geänderter Stromkreis" }) .where(eq(circuits.id, "circuit-1")) .run(); context.db .insert(floors) .values({ id: "floor-2", projectId: "project-1", name: "OG", sortOrder: 20, }) .run(); context.db .insert(rooms) .values({ id: "room-2", projectId: "project-1", floorId: "floor-2", roomNumber: "101", roomName: "Büro", }) .run(); context.db .insert(projectDevices) .values({ id: "device-2", projectId: "project-1", name: "Leuchte", displayName: "Leuchte", phaseType: "single_phase", quantity: 2, powerPerUnit: 0.05, simultaneityFactor: 1, }) .run(); new DistributionBoardRepository( context.db ).createWithCircuitListAndDefaultSections("project-1", "UV-02"); } function seedUpgradeOnlyData(context: DatabaseContext) { const circuit = context.db .select() .from(circuits) .where(eq(circuits.id, "circuit-1")) .get(); assert.ok(circuit); context.db .insert(consumers) .values({ id: "consumer-1", projectId: "project-1", distributionBoardId: context.db.select().from(circuitLists).get()! .distributionBoardId, circuitListId: circuit.circuitListId, projectDeviceId: "device-1", roomId: "room-1", name: "Legacy-Pumpe", quantity: 1, installedPowerPerUnitKw: 2.5, demandFactor: 0.8, }) .run(); context.db .insert(legacyConsumerCircuitMigrations) .values({ consumerId: "consumer-1", circuitId: "circuit-1", circuitDeviceRowId: "row-1", circuitListId: circuit.circuitListId, createdAtIso: "2026-07-25T00:00:00.000Z", }) .run(); context.db .insert(legacyConsumerMigrationReports) .values({ id: "report-1", circuitListId: circuit.circuitListId, legacyConsumerCount: 1, createdCircuitCount: 1, createdDeviceRowCount: 1, duplicateGroupedCount: 0, generatedIdentifierCount: 0, unassignedRowCount: 0, warningsJson: "[]", generatedIdentifiersJson: "[]", duplicateGroupsJson: "[]", createdAtIso: "2026-07-25T00:00:00.000Z", }) .run(); } describe("project state restore command", () => { it("restores a complete snapshot as a revision and supports undo and redo", () => { const context = createTestDatabase(); try { const snapshots = new ProjectSnapshotRepository(context.db); seedUpgradeOnlyData(context); const snapshot = snapshots.createNamed({ projectId: "project-1", expectedRevision: 0, name: "Freigabe", }); assert.ok(snapshot); const original = readProjectStateSnapshot(context.db, "project-1"); assert.ok(original); changeCompleteProjectState(context); const changed = readProjectStateSnapshot(context.db, "project-1"); assert.ok(changed); assert.notEqual(changed.payloadSha256, original.payloadSha256); const prepared = snapshots.prepareRestore( "project-1", snapshot.id ); assert.ok(prepared); const service = createService(context); assert.throws( () => service.executeUser({ projectId: "project-1", expectedRevision: 0, command: prepared.command, }), /stored server snapshot/ ); const restored = service.executeRestore({ projectId: "project-1", expectedRevision: 0, description: "Snapshot wiederherstellen", command: prepared.command, }); assert.equal(restored.revision.revisionNumber, 1); assert.equal(restored.history.undoDepth, 1); assert.equal( readProjectStateSnapshot(context.db, "project-1")?.payloadSha256, original.payloadSha256 ); assert.equal( context.db.select().from(projectSnapshots).all().length, 1 ); assert.equal( context.db .select() .from(legacyConsumerCircuitMigrations) .all().length, 1 ); assert.equal( context.db .select() .from(legacyConsumerMigrationReports) .all().length, 1 ); assert.equal( context.db .select({ roomId: consumers.roomId }) .from(consumers) .where(eq(consumers.id, "consumer-1")) .get()?.roomId, "room-1" ); const undone = service.undo({ projectId: "project-1", expectedRevision: 1, }); assert.equal(undone.revision.revisionNumber, 2); assert.equal(undone.history.redoDepth, 1); assert.equal( readProjectStateSnapshot(context.db, "project-1")?.payloadSha256, changed.payloadSha256 ); const redone = service.redo({ projectId: "project-1", expectedRevision: 2, }); assert.equal(redone.revision.revisionNumber, 3); assert.equal( readProjectStateSnapshot(context.db, "project-1")?.payloadSha256, original.payloadSha256 ); assert.deepEqual( context.db .select({ source: projectRevisions.source }) .from(projectRevisions) .all() .map((entry) => entry.source), ["restore", "undo", "redo"] ); } finally { context.close(); } }); it("rejects a prepared restore after an unversioned state change", () => { const context = createTestDatabase(); try { const snapshots = new ProjectSnapshotRepository(context.db); const snapshot = snapshots.createNamed({ projectId: "project-1", expectedRevision: 0, name: "Freigabe", }); assert.ok(snapshot); changeCompleteProjectState(context); const prepared = snapshots.prepareRestore( "project-1", snapshot.id ); assert.ok(prepared); context.db .update(projects) .set({ name: "Noch neuer" }) .where(eq(projects.id, "project-1")) .run(); assert.throws( () => createService(context).executeRestore({ projectId: "project-1", expectedRevision: 0, command: prepared.command, }), ProjectStateConflictError ); assert.equal( context.db.select().from(projectRevisions).all().length, 0 ); assert.equal( context.db .select({ name: projects.name }) .from(projects) .get()?.name, "Noch neuer" ); } finally { context.close(); } }); it("rolls back the complete replacement after a late history failure", () => { const context = createTestDatabase(); try { const snapshots = new ProjectSnapshotRepository(context.db); const snapshot = snapshots.createNamed({ projectId: "project-1", expectedRevision: 0, name: "Freigabe", }); assert.ok(snapshot); changeCompleteProjectState(context); const changed = readProjectStateSnapshot(context.db, "project-1"); const prepared = snapshots.prepareRestore( "project-1", snapshot.id ); assert.ok(changed); assert.ok(prepared); context.sqlite.exec(` CREATE TRIGGER fail_restore_history BEFORE INSERT ON project_history_stack_entries BEGIN SELECT RAISE(ABORT, 'forced restore history failure'); END; `); assert.throws( () => createService(context).executeRestore({ projectId: "project-1", expectedRevision: 0, command: prepared.command, }), /forced restore history failure/ ); assert.equal( readProjectStateSnapshot(context.db, "project-1")?.payloadSha256, changed.payloadSha256 ); assert.equal( context.db .select({ currentRevision: projects.currentRevision }) .from(projects) .get()?.currentRevision, 0 ); assert.equal( context.db.select().from(projectRevisions).all().length, 0 ); } finally { context.close(); } }); });