import path from "node:path"; import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { asc, eq } from "drizzle-orm"; import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { createDatabaseContext, type DatabaseContext, } from "../src/db/database-context.js"; import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js"; import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuits } from "../src/db/schema/circuits.js"; import { projectDevices } from "../src/db/schema/project-devices.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projects } from "../src/db/schema/projects.js"; import { rooms } from "../src/db/schema/rooms.js"; import type { CircuitDeviceRowSnapshot } from "../src/domain/models/circuit-device-row-structure-project-command.model.js"; import { createCircuitDeleteProjectCommand, createCircuitInsertProjectCommand, type CircuitSnapshot, } from "../src/domain/models/circuit-structure-project-command.model.js"; interface TestFixture { context: DatabaseContext; circuitListId: string; sectionId: string; foreignCircuitListId: string; foreignSectionId: string; } function createTestDatabase(): TestFixture { const context = createDatabaseContext(":memory:"); migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations"), }); context.db .insert(projects) .values([ { id: "project-1", name: "Test project" }, { id: "project-2", name: "Other project" }, ]) .run(); const boards = new DistributionBoardRepository(context.db); const board = boards.createWithCircuitListAndDefaultSections( "project-1", "UV-01" ); const foreignBoard = boards.createWithCircuitListAndDefaultSections( "project-2", "UV-02" ); const section = context.db .select() .from(circuitSections) .where(eq(circuitSections.circuitListId, board.id)) .get(); const foreignSection = context.db .select() .from(circuitSections) .where(eq(circuitSections.circuitListId, foreignBoard.id)) .get(); assert.ok(section); assert.ok(foreignSection); context.db .insert(projectDevices) .values([ { id: "device-1", projectId: "project-1", name: "Leuchte", displayName: "Leuchte", quantity: 1, powerPerUnit: 0.1, simultaneityFactor: 1, }, { id: "device-foreign", projectId: "project-2", name: "Fremdgerät", displayName: "Fremdgerät", quantity: 1, powerPerUnit: 0.2, simultaneityFactor: 1, }, ]) .run(); context.db .insert(rooms) .values([ { id: "room-1", projectId: "project-1", roomNumber: "001", roomName: "Büro", }, { id: "room-foreign", projectId: "project-2", roomNumber: "999", roomName: "Fremdraum", }, ]) .run(); context.db .insert(circuits) .values({ id: "circuit-existing", circuitListId: board.id, sectionId: section.id, equipmentIdentifier: "-1F1", displayName: "Bestand", sortOrder: 10, protectionType: "LS", protectionRatedCurrent: 16, protectionCharacteristic: "B", cableType: "NYM-J", cableCrossSection: "3x1,5", cableLength: 18.5, rcdAssignment: "FI-1", terminalDesignation: "X1", voltage: 230, controlRequirement: "DALI", status: "planned", isReserve: 0, remark: "Vollständig", }) .run(); context.db .insert(circuitDeviceRows) .values([ { id: "row-existing-1", circuitId: "circuit-existing", linkedProjectDeviceId: "device-1", legacyConsumerId: "legacy-1", sortOrder: 10, name: "Leuchte A", displayName: "Leuchte A lokal", phaseType: "single_phase", category: "lighting", roomId: "room-1", roomNumberSnapshot: "001", roomNameSnapshot: "Büro", quantity: 2, powerPerUnit: 0.1, simultaneityFactor: 0.8, cosPhi: 0.9, overriddenFields: "[\"displayName\"]", }, { id: "row-existing-2", circuitId: "circuit-existing", sortOrder: 20, name: "Leuchte B", displayName: "Leuchte B", quantity: 1, powerPerUnit: 0.2, simultaneityFactor: 1, }, ]) .run(); return { context, circuitListId: board.id, sectionId: section.id, foreignCircuitListId: foreignBoard.id, foreignSectionId: foreignSection.id, }; } function createRow( circuitId: string, id: string, sortOrder: number, overrides: Partial = {} ): CircuitDeviceRowSnapshot { return { id, circuitId, linkedProjectDeviceId: null, legacyConsumerId: null, sortOrder, name: `Gerät ${id}`, displayName: `Gerät ${id}`, phaseType: "single_phase", connectionKind: null, costGroup: null, category: null, level: null, roomId: null, roomNumberSnapshot: null, roomNameSnapshot: null, quantity: 1, powerPerUnit: 0.1, simultaneityFactor: 1, cosPhi: null, remark: null, overriddenFields: null, ...overrides, }; } function createCircuitSnapshot( fixture: TestFixture, overrides: Partial = {} ): CircuitSnapshot { const id = overrides.id ?? "circuit-new"; const deviceRows = overrides.deviceRows ?? [ createRow(id, "row-new-1", 10, { linkedProjectDeviceId: "device-1", roomId: "room-1", }), createRow(id, "row-new-2", 20), ]; return { id, circuitListId: fixture.circuitListId, sectionId: fixture.sectionId, equipmentIdentifier: "-1F2", displayName: "Neuer Stromkreis", sortOrder: 20, protectionType: null, protectionRatedCurrent: null, protectionCharacteristic: null, cableType: null, cableCrossSection: null, cableLength: null, rcdAssignment: null, terminalDesignation: null, voltage: 230, controlRequirement: null, status: null, isReserve: deviceRows.length === 0, remark: null, deviceRows, ...overrides, }; } function getCircuit( context: DatabaseContext, circuitId: string ) { return context.db .select() .from(circuits) .where(eq(circuits.id, circuitId)) .get(); } function getRows(context: DatabaseContext, circuitId: string) { return context.db .select() .from(circuitDeviceRows) .where(eq(circuitDeviceRows.circuitId, circuitId)) .orderBy( asc(circuitDeviceRows.sortOrder), asc(circuitDeviceRows.id) ) .all(); } describe("circuit structure project-command repository", () => { it("inserts a multi-device circuit and supports persisted undo and redo", () => { const fixture = createTestDatabase(); try { const store = new CircuitStructureProjectCommandRepository( fixture.context.db ); const command = createCircuitInsertProjectCommand( createCircuitSnapshot(fixture) ); const inserted = store.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command, }); assert.equal(getCircuit(fixture.context, "circuit-new")?.isReserve, 0); assert.deepEqual( getRows(fixture.context, "circuit-new").map((row) => row.id), ["row-new-1", "row-new-2"] ); store.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: inserted.revision.changeSetId, command: inserted.inverse, }); assert.equal(getCircuit(fixture.context, "circuit-new"), undefined); assert.equal(getRows(fixture.context, "circuit-new").length, 0); store.execute({ projectId: "project-1", expectedRevision: 2, source: "redo", historyTargetChangeSetId: inserted.revision.changeSetId, command, }); assert.deepEqual( getRows(fixture.context, "circuit-new").map((row) => row.id), ["row-new-1", "row-new-2"] ); assert.deepEqual( new ProjectHistoryRepository(fixture.context.db).getState( "project-1" ), { projectId: "project-1", currentRevision: 3, undoDepth: 1, redoDepth: 0, undoChangeSetId: inserted.revision.changeSetId, redoChangeSetId: null, } ); } finally { fixture.context.close(); } }); it("inserts and removes an empty reserve circuit", () => { const fixture = createTestDatabase(); try { const store = new CircuitStructureProjectCommandRepository( fixture.context.db ); const inserted = store.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitInsertProjectCommand( createCircuitSnapshot(fixture, { id: "circuit-reserve", equipmentIdentifier: "-1F3", deviceRows: [], isReserve: true, }) ), }); assert.equal( getCircuit(fixture.context, "circuit-reserve")?.isReserve, 1 ); store.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: inserted.revision.changeSetId, command: inserted.inverse, }); assert.equal( getCircuit(fixture.context, "circuit-reserve"), undefined ); } finally { fixture.context.close(); } }); it("deletes and restores a complete multi-device circuit block", () => { const fixture = createTestDatabase(); try { const beforeCircuit = getCircuit( fixture.context, "circuit-existing" ); const beforeRows = getRows(fixture.context, "circuit-existing"); assert.ok(beforeCircuit); const store = new CircuitStructureProjectCommandRepository( fixture.context.db ); const deleted = store.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitDeleteProjectCommand( "circuit-existing", fixture.circuitListId ), }); assert.equal( getCircuit(fixture.context, "circuit-existing"), undefined ); assert.equal(getRows(fixture.context, "circuit-existing").length, 0); store.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: deleted.revision.changeSetId, command: deleted.inverse, }); assert.deepEqual( getCircuit(fixture.context, "circuit-existing"), beforeCircuit ); assert.deepEqual( getRows(fixture.context, "circuit-existing"), beforeRows ); } finally { fixture.context.close(); } }); it("rolls back insert and delete for late history failures", () => { const fixture = createTestDatabase(); try { fixture.context.sqlite.exec(` CREATE TRIGGER fail_circuit_structure_history BEFORE INSERT ON project_history_stack_entries BEGIN SELECT RAISE(ABORT, 'forced circuit structure history failure'); END; `); const store = new CircuitStructureProjectCommandRepository( fixture.context.db ); assert.throws( () => store.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitInsertProjectCommand( createCircuitSnapshot(fixture) ), }), /forced circuit structure history failure/ ); assert.equal(getCircuit(fixture.context, "circuit-new"), undefined); assert.throws( () => store.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitDeleteProjectCommand( "circuit-existing", fixture.circuitListId ), }), /forced circuit structure history failure/ ); assert.ok(getCircuit(fixture.context, "circuit-existing")); assert.equal(getRows(fixture.context, "circuit-existing").length, 2); assert.equal( fixture.context.db.select().from(projectRevisions).all().length, 0 ); } finally { fixture.context.close(); } }); it("rolls back deletion for a stale expected revision", () => { const fixture = createTestDatabase(); try { const store = new CircuitStructureProjectCommandRepository( fixture.context.db ); assert.throws( () => store.execute({ projectId: "project-1", expectedRevision: 1, source: "user", command: createCircuitDeleteProjectCommand( "circuit-existing", fixture.circuitListId ), }), /at revision 0, expected 1/ ); assert.ok(getCircuit(fixture.context, "circuit-existing")); assert.equal(getRows(fixture.context, "circuit-existing").length, 2); } finally { fixture.context.close(); } }); it("rejects invalid ownership, identifiers, references and row ids", () => { const fixture = createTestDatabase(); try { const store = new CircuitStructureProjectCommandRepository( fixture.context.db ); const invalidSnapshots = [ createCircuitSnapshot(fixture, { id: "foreign-list", circuitListId: fixture.foreignCircuitListId, sectionId: fixture.foreignSectionId, }), createCircuitSnapshot(fixture, { id: "foreign-section", sectionId: fixture.foreignSectionId, }), createCircuitSnapshot(fixture, { id: "duplicate-bmk", equipmentIdentifier: "-1F1", }), createCircuitSnapshot(fixture, { id: "duplicate-row", deviceRows: [ createRow("duplicate-row", "row-existing-1", 10), ], }), createCircuitSnapshot(fixture, { id: "foreign-device", deviceRows: [ createRow("foreign-device", "row-foreign-device", 10, { linkedProjectDeviceId: "device-foreign", }), ], }), createCircuitSnapshot(fixture, { id: "foreign-room", deviceRows: [ createRow("foreign-room", "row-foreign-room", 10, { roomId: "room-foreign", }), ], }), createCircuitSnapshot(fixture, { id: "legacy-row", deviceRows: [ createRow("legacy-row", "row-legacy", 10, { legacyConsumerId: "legacy-user-value", }), ], }), ]; for (const snapshot of invalidSnapshots) { assert.throws(() => store.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitInsertProjectCommand(snapshot), }) ); } assert.equal( fixture.context.db.select().from(projectRevisions).all().length, 0 ); } finally { fixture.context.close(); } }); });