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 { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.repository.js"; import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.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 { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; import { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-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(); context.db .insert(projectDevices) .values([ { id: "project-device-1", projectId: "project-1", name: "Luminaire", displayName: "Office lighting", phaseType: "single_phase", connectionKind: "fixed", costGroup: "440", category: "lighting", quantity: 4, powerPerUnit: 0.04, simultaneityFactor: 0.8, cosPhi: 0.95, remark: "DALI", voltageV: 230, }, { id: "project-device-foreign", projectId: "project-2", name: "Foreign device", displayName: "Foreign device", phaseType: "three_phase", quantity: 1, powerPerUnit: 1, simultaneityFactor: 1, }, ]) .run(); return context; } function getProjectDevice( context: DatabaseContext, projectDeviceId = "project-device-1" ) { const device = context.db .select() .from(projectDevices) .where(eq(projectDevices.id, projectDeviceId)) .get(); assert.ok(device); return device; } describe("project-device project-command repository", () => { it("updates canonical fields and supports persisted undo and redo", () => { const context = createTestDatabase(); try { const store = new ProjectDeviceProjectCommandRepository( context.db ); const command = createProjectDeviceUpdateProjectCommand( "project-device-1", { displayName: "Flurbeleuchtung", phaseType: "three_phase", connectionKind: null, powerPerUnit: 0.05, cosPhi: null, voltageV: 400, } ); const updated = store.executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", command, }); assert.deepEqual( { displayName: getProjectDevice(context).displayName, phaseType: getProjectDevice(context).phaseType, connectionKind: getProjectDevice(context).connectionKind, powerPerUnit: getProjectDevice(context).powerPerUnit, cosPhi: getProjectDevice(context).cosPhi, voltageV: getProjectDevice(context).voltageV, }, { displayName: "Flurbeleuchtung", phaseType: "three_phase", connectionKind: null, powerPerUnit: 0.05, cosPhi: null, voltageV: 400, } ); store.executeUpdate({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: updated.revision.changeSetId, command: updated.inverse, }); assert.deepEqual( { displayName: getProjectDevice(context).displayName, phaseType: getProjectDevice(context).phaseType, connectionKind: getProjectDevice(context).connectionKind, powerPerUnit: getProjectDevice(context).powerPerUnit, cosPhi: getProjectDevice(context).cosPhi, voltageV: getProjectDevice(context).voltageV, }, { displayName: "Office lighting", phaseType: "single_phase", connectionKind: "fixed", powerPerUnit: 0.04, cosPhi: 0.95, voltageV: 230, } ); store.executeUpdate({ projectId: "project-1", expectedRevision: 2, source: "redo", historyTargetChangeSetId: updated.revision.changeSetId, command, }); assert.deepEqual( new ProjectHistoryRepository(context.db).getState( "project-1" ), { projectId: "project-1", currentRevision: 3, undoDepth: 1, redoDepth: 0, undoChangeSetId: updated.revision.changeSetId, redoChangeSetId: null, } ); } finally { context.close(); } }); it("rejects project devices from another project", () => { const context = createTestDatabase(); try { const store = new ProjectDeviceProjectCommandRepository( context.db ); assert.throws( () => store.executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", command: createProjectDeviceUpdateProjectCommand( "project-device-foreign", { displayName: "Not allowed" } ), }), /does not belong to project/ ); assert.equal( getProjectDevice( context, "project-device-foreign" ).displayName, "Foreign device" ); assert.equal( context.db.select().from(projectRevisions).all().length, 0 ); } finally { context.close(); } }); it("rolls back the update for a stale project revision", () => { const context = createTestDatabase(); try { const store = new ProjectDeviceProjectCommandRepository( context.db ); store.executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", command: createProjectDeviceUpdateProjectCommand( "project-device-1", { displayName: "First change" } ), }); assert.throws( () => store.executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", command: createProjectDeviceUpdateProjectCommand( "project-device-1", { displayName: "Stale change" } ), }), (error) => error instanceof ProjectRevisionConflictError && error.actualRevision === 1 ); assert.equal( getProjectDevice(context).displayName, "First change" ); assert.equal( context.db.select().from(projectRevisions).all().length, 1 ); } finally { context.close(); } }); it("rolls back the update after a late history failure", () => { const context = createTestDatabase(); try { context.sqlite.exec(` CREATE TRIGGER fail_project_device_history BEFORE INSERT ON project_history_stack_entries BEGIN SELECT RAISE(ABORT, 'forced project-device history failure'); END; `); const store = new ProjectDeviceProjectCommandRepository( context.db ); assert.throws( () => store.executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", command: createProjectDeviceUpdateProjectCommand( "project-device-1", { displayName: "Must roll back", quantity: 9, } ), }), /forced project-device history failure/ ); assert.equal( getProjectDevice(context).displayName, "Office lighting" ); assert.equal(getProjectDevice(context).quantity, 4); assert.equal( context.db.select().from(projectRevisions).all().length, 0 ); } finally { context.close(); } }); });