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 { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js"; import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.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 { projectChangeSets } from "../src/db/schema/project-change-sets.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 { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js"; import { deserializeProjectCommand } from "../src/domain/models/project-command.model.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: "Test project" }, { id: "project-2", name: "Other project" }, ]) .run(); const boards = new DistributionBoardFixtureRepository(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(circuits) .values([ { id: "circuit-1", circuitListId: board.id, sectionId: section.id, equipmentIdentifier: "-1F1", sortOrder: 10, }, { id: "circuit-2", circuitListId: foreignBoard.id, sectionId: foreignSection.id, equipmentIdentifier: "-1F1", sortOrder: 10, }, ]) .run(); context.db .insert(projectDevices) .values([ { id: "project-device-1", projectId: "project-1", name: "Leuchte", displayName: "Leuchte", quantity: 1, powerPerUnit: 0.1, simultaneityFactor: 1, }, { id: "project-device-2", 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", floorId: null, roomNumber: "001", roomName: "Büro", }, { id: "room-2", projectId: "project-2", floorId: null, roomNumber: "999", roomName: "Fremdraum", }, ]) .run(); context.db .insert(circuitDeviceRows) .values([ { id: "row-1", circuitId: "circuit-1", linkedProjectDeviceId: "project-device-1", sortOrder: 10, name: "Leuchte", displayName: "Leuchte lokal", quantity: 1, powerPerUnit: 0.1, simultaneityFactor: 1, }, { id: "row-2", circuitId: "circuit-2", sortOrder: 10, name: "Fremdzeile", displayName: "Fremdzeile", quantity: 1, powerPerUnit: 0.2, simultaneityFactor: 1, }, ]) .run(); return context; } function getRow(context: DatabaseContext) { const row = context.db .select() .from(circuitDeviceRows) .where(eq(circuitDeviceRows.id, "row-1")) .get(); assert.ok(row); return row; } describe("circuit device-row project-command repository", () => { it("commits the row update, tracked override, inverse and revision together", () => { const context = createTestDatabase(); try { const store = new CircuitDeviceRowProjectCommandRepository(context.db); const executed = store.executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", description: "Gerätezeile bearbeiten", command: createCircuitDeviceRowUpdateProjectCommand("row-1", { quantity: 2, roomId: "room-1", remark: null, }), }); const row = getRow(context); assert.equal(row.quantity, 2); assert.equal(row.roomId, "room-1"); assert.equal(row.remark, null); assert.equal(row.overriddenFields, "[\"quantity\"]"); assert.equal(executed.revision.revisionNumber, 1); assert.deepEqual(executed.inverse.payload, { rowId: "row-1", changes: [ { field: "quantity", value: 1 }, { field: "roomId", value: null }, { field: "remark", value: null }, { field: "overriddenFields", value: null }, ], }); const changeSet = context.db.select().from(projectChangeSets).get(); assert.ok(changeSet); const storedForward = deserializeProjectCommand( changeSet.forwardPayloadJson ); assert.deepEqual(storedForward, { schemaVersion: 1, type: "circuit-device-row.update", payload: { rowId: "row-1", changes: [ { field: "quantity", value: 2 }, { field: "roomId", value: "room-1" }, { field: "remark", value: null }, { field: "overriddenFields", value: "[\"quantity\"]" }, ], }, }); } finally { context.close(); } }); it("applies the generated inverse and restores override metadata", () => { const context = createTestDatabase(); try { const store = new CircuitDeviceRowProjectCommandRepository(context.db); const forward = store.executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitDeviceRowUpdateProjectCommand("row-1", { quantity: 3, }), }); const undone = store.executeUpdate({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: forward.revision.changeSetId, command: forward.inverse, }); const row = getRow(context); assert.equal(row.quantity, 1); assert.equal(row.overriddenFields, null); assert.equal(undone.revision.revisionNumber, 2); assert.deepEqual( context.db .select({ source: projectRevisions.source }) .from(projectRevisions) .all() .map((revision) => revision.source), ["user", "undo"] ); } finally { context.close(); } }); it("rolls back the row and override update for a stale revision", () => { const context = createTestDatabase(); try { const store = new CircuitDeviceRowProjectCommandRepository(context.db); store.executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitDeviceRowUpdateProjectCommand("row-1", { quantity: 2, }), }); assert.throws( () => store.executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitDeviceRowUpdateProjectCommand("row-1", { quantity: 4, }), }), (error) => error instanceof ProjectRevisionConflictError && error.actualRevision === 1 ); const row = getRow(context); assert.equal(row.quantity, 2); assert.equal(row.overriddenFields, "[\"quantity\"]"); assert.equal(context.db.select().from(projectRevisions).all().length, 1); } finally { context.close(); } }); it("rolls back the row update when late history persistence fails", () => { const context = createTestDatabase(); try { context.sqlite.exec(` CREATE TRIGGER fail_device_row_command_change_set BEFORE INSERT ON project_change_sets BEGIN SELECT RAISE(ABORT, 'forced device-row history failure'); END; `); const store = new CircuitDeviceRowProjectCommandRepository(context.db); assert.throws( () => store.executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitDeviceRowUpdateProjectCommand("row-1", { quantity: 5, }), }), /forced device-row history failure/ ); const row = getRow(context); assert.equal(row.quantity, 1); assert.equal(row.overriddenFields, null); assert.equal( context.db .select() .from(projects) .where(eq(projects.id, "project-1")) .get()?.currentRevision, 0 ); assert.equal(context.db.select().from(projectChangeSets).all().length, 0); } finally { context.close(); } }); it("rejects cross-project rows, devices and rooms without history", () => { const context = createTestDatabase(); try { const store = new CircuitDeviceRowProjectCommandRepository(context.db); assert.throws( () => store.executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitDeviceRowUpdateProjectCommand("row-2", { quantity: 2, }), }), /does not belong to project/ ); assert.throws( () => store.executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitDeviceRowUpdateProjectCommand("row-1", { linkedProjectDeviceId: "project-device-2", }), }), /Invalid linked project device/ ); assert.throws( () => store.executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitDeviceRowUpdateProjectCommand("row-1", { roomId: "room-2", }), }), /Invalid room/ ); assert.equal(getRow(context).linkedProjectDeviceId, "project-device-1"); assert.equal(getRow(context).roomId, null); assert.equal(context.db.select().from(projectRevisions).all().length, 0); } finally { context.close(); } }); });