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 { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.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 { createProjectDeviceRowSyncProjectCommand, type ProjectDeviceSyncRowSnapshot, } from "../src/domain/models/project-device-row-sync-project-command.model.js"; interface TestFixture { context: DatabaseContext; } 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: "project-device-1", projectId: "project-1", name: "Luminaire", displayName: "Office lighting", phaseType: "single_phase", quantity: 4, powerPerUnit: 0.04, simultaneityFactor: 0.8, cosPhi: 0.95, remark: "DALI", }, { id: "project-device-foreign", projectId: "project-2", name: "Foreign device", displayName: "Foreign device", phaseType: "single_phase", quantity: 1, powerPerUnit: 0.1, simultaneityFactor: 1, }, ]) .run(); context.db .insert(circuits) .values([ { id: "circuit-1", circuitListId: board.id, sectionId: section.id, equipmentIdentifier: "-1F1", sortOrder: 10, }, { id: "circuit-foreign", circuitListId: foreignBoard.id, sectionId: foreignSection.id, equipmentIdentifier: "-1F1", sortOrder: 10, }, ]) .run(); context.db .insert(circuitDeviceRows) .values([ { id: "row-1", circuitId: "circuit-1", linkedProjectDeviceId: "project-device-1", sortOrder: 10, name: "Old luminaire", displayName: "Local row 1", phaseType: "single_phase", category: "lighting", quantity: 1, powerPerUnit: 0.03, simultaneityFactor: 1, overriddenFields: '["displayName","quantity"]', }, { id: "row-2", circuitId: "circuit-1", linkedProjectDeviceId: "project-device-1", sortOrder: 20, name: "Old luminaire", displayName: "Local row 2", phaseType: "single_phase", category: "lighting", quantity: 2, powerPerUnit: 0.03, simultaneityFactor: 1, overriddenFields: '["displayName"]', }, { id: "row-foreign", circuitId: "circuit-foreign", linkedProjectDeviceId: "project-device-foreign", sortOrder: 10, name: "Foreign device", displayName: "Foreign device", phaseType: "single_phase", quantity: 1, powerPerUnit: 0.1, simultaneityFactor: 1, }, ]) .run(); return { context }; } function getRow(context: DatabaseContext, rowId: string) { const row = context.db .select() .from(circuitDeviceRows) .where(eq(circuitDeviceRows.id, rowId)) .get(); assert.ok(row); return row; } function snapshot( context: DatabaseContext, rowId: string ): ProjectDeviceSyncRowSnapshot { const row = getRow(context, rowId); return { linkedProjectDeviceId: row.linkedProjectDeviceId, name: row.name, displayName: row.displayName, phaseType: row.phaseType, connectionKind: row.connectionKind, costGroup: row.costGroup, category: row.category, quantity: row.quantity, powerPerUnit: row.powerPerUnit, simultaneityFactor: row.simultaneityFactor, cosPhi: row.cosPhi, remark: row.remark, overriddenFields: row.overriddenFields, }; } function syncCommand(context: DatabaseContext) { return createProjectDeviceRowSyncProjectCommand( "project-device-1", "synchronize", ["row-1", "row-2"].map((rowId) => { const expected = snapshot(context, rowId); return { rowId, expected, target: { ...expected, name: "Luminaire", displayName: "Office lighting", quantity: 4, powerPerUnit: 0.04, simultaneityFactor: 0.8, cosPhi: 0.95, remark: "DALI", overriddenFields: null, }, }; }) ); } function listOwnRows(context: DatabaseContext) { return context.db .select() .from(circuitDeviceRows) .where(eq(circuitDeviceRows.circuitId, "circuit-1")) .orderBy(asc(circuitDeviceRows.id)) .all(); } describe("project-device row sync project-command repository", () => { it("synchronizes multiple rows and supports persisted undo and redo", () => { const fixture = createTestDatabase(); try { const store = new ProjectDeviceRowSyncProjectCommandRepository( fixture.context.db ); const command = syncCommand(fixture.context); const synchronized = store.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command, }); assert.deepEqual( listOwnRows(fixture.context).map((row) => [ row.displayName, row.quantity, row.overriddenFields, row.sortOrder, ]), [ ["Office lighting", 4, null, 10], ["Office lighting", 4, null, 20], ] ); store.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: synchronized.revision.changeSetId, command: synchronized.inverse, }); assert.deepEqual( listOwnRows(fixture.context).map((row) => [ row.displayName, row.quantity, row.overriddenFields, ]), [ ["Local row 1", 1, '["displayName","quantity"]'], ["Local row 2", 2, '["displayName"]'], ] ); store.execute({ projectId: "project-1", expectedRevision: 2, source: "redo", historyTargetChangeSetId: synchronized.revision.changeSetId, command, }); assert.deepEqual( new ProjectHistoryRepository(fixture.context.db).getState( "project-1" ), { projectId: "project-1", currentRevision: 3, undoDepth: 1, redoDepth: 0, undoChangeSetId: synchronized.revision.changeSetId, redoChangeSetId: null, } ); } finally { fixture.context.close(); } }); it("disconnects and reconnects rows without changing local values", () => { const fixture = createTestDatabase(); try { const store = new ProjectDeviceRowSyncProjectCommandRepository( fixture.context.db ); const before = listOwnRows(fixture.context); const command = createProjectDeviceRowSyncProjectCommand( "project-device-1", "disconnect", ["row-1", "row-2"].map((rowId) => { const expected = snapshot(fixture.context, rowId); return { rowId, expected, target: { ...expected, linkedProjectDeviceId: null, }, }; }) ); const disconnected = store.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command, }); assert.deepEqual( listOwnRows(fixture.context).map((row) => [ row.linkedProjectDeviceId, row.displayName, row.quantity, ]), before.map((row) => [null, row.displayName, row.quantity]) ); store.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: disconnected.revision.changeSetId, command: disconnected.inverse, }); assert.deepEqual( listOwnRows(fixture.context).map( (row) => row.linkedProjectDeviceId ), ["project-device-1", "project-device-1"] ); } finally { fixture.context.close(); } }); it("rejects stale snapshots and cross-project device or row access", () => { const fixture = createTestDatabase(); try { const store = new ProjectDeviceRowSyncProjectCommandRepository( fixture.context.db ); const staleCommand = syncCommand(fixture.context); fixture.context.db .update(circuitDeviceRows) .set({ displayName: "Changed elsewhere" }) .where(eq(circuitDeviceRows.id, "row-1")) .run(); assert.throws( () => store.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: staleCommand, }), /changed before sync execution/ ); const expected = snapshot(fixture.context, "row-foreign"); assert.throws( () => store.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createProjectDeviceRowSyncProjectCommand( "project-device-foreign", "disconnect", [ { rowId: "row-foreign", expected, target: { ...expected, linkedProjectDeviceId: null, }, }, ] ), }), /does not belong to project/ ); assert.throws( () => store.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createProjectDeviceRowSyncProjectCommand( "project-device-1", "disconnect", [ { rowId: "row-foreign", expected: { ...expected, linkedProjectDeviceId: "project-device-1", }, target: { ...expected, linkedProjectDeviceId: null, }, }, ] ), }), /rows do not belong to project/ ); assert.equal( fixture.context.db.select().from(projectRevisions).all() .length, 0 ); } finally { fixture.context.close(); } }); it("rolls back every row and revision after a late history failure", () => { const fixture = createTestDatabase(); try { fixture.context.sqlite.exec(` CREATE TRIGGER fail_sync_history BEFORE INSERT ON project_history_stack_entries BEGIN SELECT RAISE(ABORT, 'forced sync history failure'); END; `); const store = new ProjectDeviceRowSyncProjectCommandRepository( fixture.context.db ); assert.throws( () => store.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: syncCommand(fixture.context), }), /forced sync history failure/ ); assert.deepEqual( listOwnRows(fixture.context).map((row) => [ row.displayName, row.quantity, ]), [ ["Local row 1", 1], ["Local row 2", 2], ] ); assert.equal( fixture.context.db.select().from(projectRevisions).all() .length, 0 ); } finally { fixture.context.close(); } }); it("rolls back synchronized rows for a stale project revision", () => { const fixture = createTestDatabase(); try { const store = new ProjectDeviceRowSyncProjectCommandRepository( fixture.context.db ); assert.throws( () => store.execute({ projectId: "project-1", expectedRevision: 1, source: "user", command: syncCommand(fixture.context), }), /at revision 0, expected 1/ ); assert.deepEqual( listOwnRows(fixture.context).map((row) => [ row.displayName, row.quantity, ]), [ ["Local row 1", 1], ["Local row 2", 2], ] ); } finally { fixture.context.close(); } }); });