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 AppDatabase } from "../src/db/database-context.js"; import { ExternalObjectRowAssignmentProjectCommandRepository } from "../src/db/repositories/external-object-row-assignment-project-command.repository.js"; import { ExternalObjectNewRowProjectCommandRepository } from "../src/db/repositories/external-object-new-row-project-command.repository.js"; import { ExternalObjectNewCircuitProjectCommandRepository } from "../src/db/repositories/external-object-new-circuit-project-command.repository.js"; import { toCircuitDeviceRowSnapshot } from "../src/db/repositories/circuit-device-row-structure.persistence.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitLists } from "../src/db/schema/circuit-lists.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuits } from "../src/db/schema/circuits.js"; import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js"; import { distributionBoards } from "../src/db/schema/distribution-boards.js"; import { externalImportBatches } from "../src/db/schema/external-import-batches.js"; import { externalModelObjects } from "../src/db/schema/external-model-objects.js"; import { externalModelSources } from "../src/db/schema/external-model-sources.js"; import { externalRoomMappings } from "../src/db/schema/external-room-mappings.js"; import { floors } from "../src/db/schema/floors.js"; import { projects } from "../src/db/schema/projects.js"; import { rooms } from "../src/db/schema/rooms.js"; import { createExternalObjectRowAssignmentProjectCommand } from "../src/domain/models/external-object-row-assignment-project-command.model.js"; import { createExternalObjectNewRowProjectCommand, externalObjectAssignToNewRowCommandType, } from "../src/domain/models/external-object-new-row-project-command.model.js"; import { createExternalObjectNewCircuitProjectCommand, externalObjectAssignToNewCircuitCommandType, } from "../src/domain/models/external-object-new-circuit-project-command.model.js"; import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js"; import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js"; function createFixture(initialObjectRowId: string | null = "row-1") { const context = createDatabaseContext(":memory:"); migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); const database = context.db; database.insert(projects).values({ id: "project-1", name: "Projekt" }).run(); database.insert(floors).values({ id: "floor-1", projectId: "project-1", name: "EG", sortOrder: 10 }).run(); database.insert(rooms).values({ id: "room-1", projectId: "project-1", floorId: "floor-1", roomNumber: "101", roomName: "Büro" }).run(); database.insert(distributionBoards).values({ id: "board-1", projectId: "project-1", name: "UV 1" }).run(); database.insert(circuitLists).values({ id: "list-1", projectId: "project-1", distributionBoardId: "board-1", name: "Liste" }).run(); database.insert(circuitSections).values({ id: "section-1", circuitListId: "list-1", key: "single-1", displayName: "1-phasig", prefix: "-2F1.", sortOrder: 10, category: "single_phase", groupNumber: 1 }).run(); for (const index of [1, 2]) { database.insert(circuits).values({ id: `circuit-${index}`, circuitListId: "list-1", sectionId: "section-1", equipmentIdentifier: `-2F1.${index}`, sortOrder: index * 10, voltage: 230 }).run(); database.insert(circuitDeviceRows).values({ id: `row-${index}`, circuitId: `circuit-${index}`, sortOrder: 10, name: "socket", displayName: "Steckdose", phaseType: "single_phase", connectionKind: "socket", category: "single_phase", roomId: "room-1", roomNumberSnapshot: "101", roomNameSnapshot: "Büro", quantity: index === 1 ? (initialObjectRowId === "row-1" ? 3 : 1) : 0, manualQuantity: index === 1 ? 1 : 0, powerPerUnit: 0.12, simultaneityFactor: 1, }).run(); } database.insert(externalModelSources).values({ id: "source-1", projectId: "project-1", name: "Revit", sourceType: "revit_csv" }).run(); database.insert(externalImportBatches).values({ id: "batch-1", projectId: "project-1", sourceId: "source-1", importKind: "initial", importedAtIso: "2026-08-02T16:00:00.000Z", fileName: "revit.csv", sha256: "a".repeat(64), appliedProjectRevision: 0, configurationVersion: 1, configurationSnapshot: externalCsvTestConfiguration, originalBytes: Buffer.from("test"), document: { delimiter: ";", encoding: "utf-8", headers: [], rows: [] }, }).run(); database.insert(externalRoomMappings).values({ id: "mapping-1", projectId: "project-1", sourceId: "source-1", normalizedSourceRoomKey: "number:101", sourceFloorName: "EG", sourceRoomNumber: "101", sourceRoomName: "Büro", roomId: "room-1", defaultDistributionBoardId: "board-1", }).run(); database.insert(externalModelObjects).values(objectSnapshot(initialObjectRowId)).run(); return context; } function objectSnapshot(circuitDeviceRowId: string | null): ExternalModelObjectSnapshot { return { id: "object-1", projectId: "project-1", sourceId: "source-1", ifcGuid: "ifc-1", lastSeenImportBatchId: "batch-1", lastAcceptedImportBatchId: "batch-1", acceptedSourceValues: { rowNumber: 2, roomNumber: "101", roomName: "Büro", familyAndType: "Steckdose: Standard", selectionMarker: "Steckdose", circuitIdentifier: "-2F1.1", power: "120", quantity: "2", additionalSourceValues: {}, }, planningValues: { displayName: "Steckdose", internalDeviceType: "socket", category: "single_phase", connectionKind: "socket", effectiveQuantity: 2, powerPerUnitW: 120, simultaneityFactor: 1, cosPhi: null, costGroup: null, remark: null, }, overriddenFields: [], externalRoomMappingId: "mapping-1", distributionBoardId: "board-1", linkedProjectDeviceId: null, circuitDeviceRowId, presenceStatus: "present", }; } function row(database: AppDatabase, id: string) { return toCircuitDeviceRowSnapshot( database.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, id)).get()! ); } function moveCommand(database: AppDatabase, confirmedConflictObjectIds: string[] = []) { const row1 = row(database, "row-1"); const row2 = row(database, "row-2"); const object = objectSnapshot("row-1"); return createExternalObjectRowAssignmentProjectCommand({ rows: [ { expected: row1, target: { ...row1, quantity: 1 } }, { expected: row2, target: { ...row2, quantity: 2 } }, ], objects: [{ expected: object, target: { ...object, circuitDeviceRowId: "row-2" } }], confirmedConflictObjectIds, }); } describe("external object row assignment project command", () => { it("moves an object atomically and preserves exact quantities through undo and redo", () => { const context = createFixture(); try { const repository = new ExternalObjectRowAssignmentProjectCommandRepository(context.db); const moved = repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: moveCommand(context.db) }); assert.equal(row(context.db, "row-1").quantity, 1); assert.equal(row(context.db, "row-2").quantity, 2); assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-2"); const undone = repository.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: moved.revision.changeSetId, command: moved.inverse }); assert.equal(row(context.db, "row-1").quantity, 3); assert.equal(row(context.db, "row-2").quantity, 0); assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-1"); repository.execute({ projectId: "project-1", expectedRevision: 2, source: "redo", historyTargetChangeSetId: moved.revision.changeSetId, command: undone.inverse }); assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-2"); } finally { context.close(); } }); it("detaches the last object but keeps the now-manual row", () => { const context = createFixture(); try { const currentRow = row(context.db, "row-1"); const object = objectSnapshot("row-1"); const command = createExternalObjectRowAssignmentProjectCommand({ rows: [{ expected: currentRow, target: { ...currentRow, quantity: 1 } }], objects: [{ expected: object, target: { ...object, circuitDeviceRowId: null } }], confirmedConflictObjectIds: [], }); new ExternalObjectRowAssignmentProjectCommandRepository(context.db).execute({ projectId: "project-1", expectedRevision: 0, source: "user", command, }); assert.equal(row(context.db, "row-1").quantity, 1); assert.equal(row(context.db, "row-1").manualQuantity, 1); assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null); } finally { context.close(); } }); it("requires explicit confirmation for differing planning values", () => { const context = createFixture(); try { context.db.update(circuitDeviceRows).set({ displayName: "Abweichend" }).where(eq(circuitDeviceRows.id, "row-2")).run(); const repository = new ExternalObjectRowAssignmentProjectCommandRepository(context.db); assert.throws( () => repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: moveCommand(context.db) }), /explicit conflict confirmation/ ); repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: moveCommand(context.db, ["object-1"]) }); assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-2"); } finally { context.close(); } }); it("rolls back row quantities and links when history persistence fails", () => { const context = createFixture(); try { context.sqlite.exec(` CREATE TRIGGER fail_assignment_history BEFORE INSERT ON project_change_sets BEGIN SELECT RAISE(ABORT, 'late history failure'); END; `); const repository = new ExternalObjectRowAssignmentProjectCommandRepository(context.db); assert.throws( () => repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: moveCommand(context.db) }), /late history failure/ ); assert.equal(row(context.db, "row-1").quantity, 3); assert.equal(row(context.db, "row-2").quantity, 0); assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-1"); } finally { context.close(); } }); }); describe("external object new-row project command", () => { it("creates an external-only row and supports exact persisted undo and redo", () => { const context = createFixture(null); try { const base = row(context.db, "row-2"); const newRow = { ...base, id: "row-new", quantity: 2, manualQuantity: 0, sortOrder: 20, }; const object = objectSnapshot(null); const command = createExternalObjectNewRowProjectCommand( externalObjectAssignToNewRowCommandType, { row: newRow, objects: [{ expected: object, target: { ...object, circuitDeviceRowId: "row-new" }, }], confirmedConflictObjectIds: [], } ); const repository = new ExternalObjectNewRowProjectCommandRepository(context.db); const inserted = repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command, }); assert.equal(row(context.db, "row-new").manualQuantity, 0); assert.equal(row(context.db, "row-new").quantity, 2); assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-new"); assert.throws( () => repository.execute({ projectId: "project-1", expectedRevision: 1, source: "user", command: inserted.inverse, }), /only be removed through project history/ ); const undone = repository.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: inserted.revision.changeSetId, command: inserted.inverse, }); assert.equal( context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-new")).get(), undefined ); assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null); repository.execute({ projectId: "project-1", expectedRevision: 2, source: "redo", historyTargetChangeSetId: inserted.revision.changeSetId, command: undone.inverse, }); assert.equal(row(context.db, "row-new").quantity, 2); } finally { context.close(); } }); it("rolls back the row and object link when history persistence fails", () => { const context = createFixture(null); try { context.sqlite.exec(` CREATE TRIGGER fail_new_row_history BEFORE INSERT ON project_change_sets BEGIN SELECT RAISE(ABORT, 'late history failure'); END; `); const base = row(context.db, "row-2"); const object = objectSnapshot(null); const command = createExternalObjectNewRowProjectCommand( externalObjectAssignToNewRowCommandType, { row: { ...base, id: "row-new", quantity: 2, manualQuantity: 0, sortOrder: 20 }, objects: [{ expected: object, target: { ...object, circuitDeviceRowId: "row-new" } }], confirmedConflictObjectIds: [], } ); assert.throws( () => new ExternalObjectNewRowProjectCommandRepository(context.db).execute({ projectId: "project-1", expectedRevision: 0, source: "user", command, }), /late history failure/ ); assert.equal( context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-new")).get(), undefined ); assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null); } finally { context.close(); } }); it("keeps the created row when it changed after assignment", () => { const context = createFixture(null); try { const base = row(context.db, "row-2"); const object = objectSnapshot(null); const repository = new ExternalObjectNewRowProjectCommandRepository(context.db); const inserted = repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createExternalObjectNewRowProjectCommand( externalObjectAssignToNewRowCommandType, { row: { ...base, id: "row-new", quantity: 2, manualQuantity: 0, sortOrder: 20 }, objects: [{ expected: object, target: { ...object, circuitDeviceRowId: "row-new" } }], confirmedConflictObjectIds: [], } ), }); context.db.update(circuitDeviceRows).set({ displayName: "Geändert" }) .where(eq(circuitDeviceRows.id, "row-new")).run(); assert.throws( () => repository.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: inserted.revision.changeSetId, command: inserted.inverse, }), /changed before history removal/ ); assert.equal(row(context.db, "row-new").displayName, "Geändert"); assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-new"); } finally { context.close(); } }); }); function newExternalCircuit(database: AppDatabase) { const sourceRow = row(database, "row-2"); return { id: "circuit-new", circuitListId: "list-1", sectionId: "section-1", equipmentIdentifier: "-2F1.3", displayName: "Steckdose", sortOrder: 30, cableType: null, cableCrossSection: null, cableLength: null, rcdAssignment: null, terminalDesignation: null, voltage: 230, controlRequirement: null, status: null, isReserve: false, remark: null, deviceRows: [{ ...sourceRow, id: "row-new", circuitId: "circuit-new", quantity: 2, manualQuantity: 0, }], protectionDevice: { circuitId: "circuit-new", type: "FI_LS" as const, ratedCurrentA: 16, fuseUtilizationCategory: null, tripCharacteristic: "B" as const, rcdType: "A" as const, ratedResidualCurrentMa: 30, }, }; } describe("external object new-circuit project command", () => { it("creates circuit, protection, row and links as one undoable revision", () => { const context = createFixture(null); try { const object = objectSnapshot(null); const circuit = newExternalCircuit(context.db); const command = createExternalObjectNewCircuitProjectCommand( externalObjectAssignToNewCircuitCommandType, { circuit, objects: [{ expected: object, target: { ...object, circuitDeviceRowId: "row-new" }, }], confirmedConflictObjectIds: [], } ); const repository = new ExternalObjectNewCircuitProjectCommandRepository(context.db); const inserted = repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command, }); assert.equal( context.db.select().from(circuits).where(eq(circuits.id, "circuit-new")).get()! .equipmentIdentifier, "-2F1.3" ); assert.equal(row(context.db, "row-new").manualQuantity, 0); assert.equal( context.db.select().from(circuitProtectionDevices) .where(eq(circuitProtectionDevices.circuitId, "circuit-new")).get()!.type, "FI_LS" ); assert.equal( context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-new" ); assert.throws( () => repository.execute({ projectId: "project-1", expectedRevision: 1, source: "user", command: inserted.inverse, }), /only be removed through project history/ ); const undone = repository.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: inserted.revision.changeSetId, command: inserted.inverse, }); assert.equal( context.db.select().from(circuits).where(eq(circuits.id, "circuit-new")).get(), undefined ); assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null); repository.execute({ projectId: "project-1", expectedRevision: 2, source: "redo", historyTargetChangeSetId: inserted.revision.changeSetId, command: undone.inverse, }); assert.equal( context.db.select().from(circuits).where(eq(circuits.id, "circuit-new")).get()! .equipmentIdentifier, "-2F1.3" ); } finally { context.close(); } }); it("rejects a preplanned BMK that does not belong to the target group", () => { const context = createFixture(null); try { const object = objectSnapshot(null); const circuit = { ...newExternalCircuit(context.db), equipmentIdentifier: "-1F1.3", }; assert.throws( () => new ExternalObjectNewCircuitProjectCommandRepository(context.db).execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createExternalObjectNewCircuitProjectCommand( externalObjectAssignToNewCircuitCommandType, { circuit, objects: [{ expected: object, target: { ...object, circuitDeviceRowId: "row-new" }, }], confirmedConflictObjectIds: [], } ), }), /equipment identifier does not match target group/ ); assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null); } finally { context.close(); } }); it("rolls back the complete circuit subtree when history persistence fails", () => { const context = createFixture(null); try { context.sqlite.exec(` CREATE TRIGGER fail_new_circuit_history BEFORE INSERT ON project_change_sets BEGIN SELECT RAISE(ABORT, 'late history failure'); END; `); const object = objectSnapshot(null); assert.throws( () => new ExternalObjectNewCircuitProjectCommandRepository(context.db).execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createExternalObjectNewCircuitProjectCommand( externalObjectAssignToNewCircuitCommandType, { circuit: newExternalCircuit(context.db), objects: [{ expected: object, target: { ...object, circuitDeviceRowId: "row-new" }, }], confirmedConflictObjectIds: [], } ), }), /late history failure/ ); assert.equal( context.db.select().from(circuits).where(eq(circuits.id, "circuit-new")).get(), undefined ); assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null); } finally { context.close(); } }); });