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 { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js"; import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js"; import { projectChangeSets } from "../src/db/schema/project-change-sets.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuits } from "../src/db/schema/circuits.js"; import { projects } from "../src/db/schema/projects.js"; import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js"; import { deserializeProjectCommand } from "../src/domain/models/project-command.model.js"; interface TestFixture { context: DatabaseContext; circuitListId: string; sectionId: string; foreignSectionId: string; } function createTestFixture(): 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 repository = new DistributionBoardFixtureRepository(context.db); const board = repository.createWithCircuitListAndDefaultSections( "project-1", "UV-01" ); const foreignBoard = repository.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(circuits) .values([ { id: "circuit-1", circuitListId: board.id, sectionId: section.id, equipmentIdentifier: "-1F1", displayName: "Licht Bestand", sortOrder: 10, protectionRatedCurrent: 10, voltage: 230, controlRequirement: "none", isReserve: 0, }, { id: "circuit-2", circuitListId: board.id, sectionId: section.id, equipmentIdentifier: "-1F2", displayName: "Zweiter Stromkreis", sortOrder: 20, isReserve: 0, }, ]) .run(); return { context, circuitListId: board.id, sectionId: section.id, foreignSectionId: foreignSection.id, }; } function getCircuit(context: DatabaseContext) { const circuit = context.db .select() .from(circuits) .where(eq(circuits.id, "circuit-1")) .get(); assert.ok(circuit); return circuit; } describe("circuit project-command repository", () => { it("commits a circuit update, inverse command and revision together", () => { const fixture = createTestFixture(); try { const store = new CircuitProjectCommandRepository(fixture.context.db); const command = createCircuitUpdateProjectCommand("circuit-1", { displayName: null, protectionRatedCurrent: 16, voltage: 400, controlRequirement: "DALI", isReserve: true, }); const executed = store.executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", description: "Stromkreis bearbeiten", command, }); const circuit = getCircuit(fixture.context); assert.equal(circuit.displayName, null); assert.equal(circuit.protectionRatedCurrent, 16); assert.equal(circuit.voltage, 230); assert.equal(circuit.controlRequirement, "DALI"); assert.equal(circuit.isReserve, 1); assert.equal(executed.revision.revisionNumber, 1); assert.deepEqual(executed.inverse.payload, { circuitId: "circuit-1", changes: [ { field: "displayName", value: "Licht Bestand" }, { field: "protectionRatedCurrent", value: 10 }, { field: "controlRequirement", value: "none" }, { field: "isReserve", value: false }, ], }); const changeSet = fixture.context.db .select() .from(projectChangeSets) .get(); assert.ok(changeSet); assert.deepEqual( deserializeProjectCommand(changeSet.forwardPayloadJson), createCircuitUpdateProjectCommand("circuit-1", { displayName: null, protectionRatedCurrent: 16, controlRequirement: "DALI", isReserve: true, }) ); assert.deepEqual( deserializeProjectCommand(changeSet.inversePayloadJson), executed.inverse ); assert.equal( fixture.context.db .select() .from(projects) .where(eq(projects.id, "project-1")) .get()?.currentRevision, 1 ); } finally { fixture.context.close(); } }); it("can apply the generated inverse as a new auditable revision", () => { const fixture = createTestFixture(); try { const store = new CircuitProjectCommandRepository(fixture.context.db); const forward = store.executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitUpdateProjectCommand("circuit-1", { displayName: "Licht Neu", protectionRatedCurrent: 16, }), }); const undone = store.executeUpdate({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: forward.revision.changeSetId, description: "Stromkreisänderung rückgängig machen", command: forward.inverse, }); const circuit = getCircuit(fixture.context); assert.equal(circuit.displayName, "Licht Bestand"); assert.equal(circuit.protectionRatedCurrent, 10); assert.equal(undone.revision.revisionNumber, 2); assert.deepEqual( fixture.context.db .select({ source: projectRevisions.source }) .from(projectRevisions) .all() .map((revision) => revision.source), ["user", "undo"] ); } finally { fixture.context.close(); } }); it("rolls back the circuit update when the expected revision is stale", () => { const fixture = createTestFixture(); try { const store = new CircuitProjectCommandRepository(fixture.context.db); store.executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitUpdateProjectCommand("circuit-1", { displayName: "Erste Änderung", }), }); assert.throws( () => store.executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitUpdateProjectCommand("circuit-1", { displayName: "Veraltete Änderung", }), }), (error) => error instanceof ProjectRevisionConflictError && error.actualRevision === 1 ); assert.equal(getCircuit(fixture.context).displayName, "Erste Änderung"); assert.equal(fixture.context.db.select().from(projectRevisions).all().length, 1); assert.equal(fixture.context.db.select().from(projectChangeSets).all().length, 1); } finally { fixture.context.close(); } }); it("rolls back the circuit update when late history persistence fails", () => { const fixture = createTestFixture(); try { fixture.context.sqlite.exec(` CREATE TRIGGER fail_circuit_command_change_set BEFORE INSERT ON project_change_sets BEGIN SELECT RAISE(ABORT, 'forced command history failure'); END; `); const store = new CircuitProjectCommandRepository(fixture.context.db); assert.throws( () => store.executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitUpdateProjectCommand("circuit-1", { displayName: "Darf nicht bleiben", }), }), /forced command history failure/ ); assert.equal(getCircuit(fixture.context).displayName, "Licht Bestand"); assert.equal( fixture.context.db .select() .from(projects) .where(eq(projects.id, "project-1")) .get()?.currentRevision, 0 ); assert.equal(fixture.context.db.select().from(projectRevisions).all().length, 0); assert.equal(fixture.context.db.select().from(projectChangeSets).all().length, 0); } finally { fixture.context.close(); } }); it("rejects foreign sections and duplicate equipment identifiers before history", () => { const fixture = createTestFixture(); try { const store = new CircuitProjectCommandRepository(fixture.context.db); assert.throws( () => store.executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitUpdateProjectCommand("circuit-1", { sectionId: fixture.foreignSectionId, }), }), /Section does not belong/ ); assert.throws( () => store.executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitUpdateProjectCommand("circuit-1", { equipmentIdentifier: "-1F2", }), }), /Duplicate equipmentIdentifier/ ); const circuit = getCircuit(fixture.context); assert.equal(circuit.sectionId, fixture.sectionId); assert.equal(circuit.equipmentIdentifier, "-1F1"); assert.equal(fixture.context.db.select().from(projectRevisions).all().length, 0); } finally { fixture.context.close(); } }); });