import crypto from "node:crypto"; import path from "node:path"; import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { createDatabaseContext } from "../src/db/database-context.js"; import { ExternalInitialImportProjectCommandRepository } from "../src/db/repositories/external-initial-import-project-command.repository.js"; import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js"; import { externalCsvConfigurations } from "../src/db/schema/external-csv-configurations.js"; import { projects } from "../src/db/schema/projects.js"; import { createEmptyExternalModelState, createExternalInitialImportProjectCommand, } from "../src/domain/models/external-initial-import-project-command.model.js"; import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js"; import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js"; import type { ExternalModelStateSnapshot } from "../src/external-model/domain/external-model-contracts.js"; import { createExternalRoomKey, projectInitialExternalObject, } from "../src/external-model/domain/external-model-matching.js"; import { externalCsvTestConfiguration, quotedRevitCsv, utf8Bytes, } from "./fixtures/revit-csv-fixtures.js"; function createTestContext() { const context = createDatabaseContext(":memory:"); migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run(); const configuration = configuredCsv(); context.db.insert(externalCsvConfigurations).values({ id: "config-1", projectId: "project-1", configurationVersion: 1, configuration, }).run(); return { context, configuration }; } function configuredCsv() { const configuration = structuredClone(externalCsvTestConfiguration); configuration.familyTypeRules.push( { exactFamilyAndType: "Steckdose: Doppelsteckdose", internalDeviceType: "Steckdose", connectionKind: "socket", category: "single_phase", quantityRule: { kind: "mapped-column" }, displayNameSuggestion: { kind: "selection-marker" }, }, { exactFamilyAndType: "Steckdose: Standard", internalDeviceType: "Steckdose", connectionKind: "socket", category: "single_phase", quantityRule: { kind: "fixed", quantity: 1 }, displayNameSuggestion: { kind: "selection-marker" }, } ); return configuration; } function initialState( configuration: ReturnType ): ExternalModelStateSnapshot { const bytes = utf8Bytes(quotedRevitCsv, true); const preview = createExternalCsvPreview({ fileName: "revit.csv", bytes, configuration, }); const roomKeys = [...new Set(preview.objects.map((object) => createExternalRoomKey(object.roomNumber, object.roomName) ).filter((key): key is string => key !== null))]; const roomMappings = roomKeys.map((key, index) => { const object = preview.objects.find( (candidate) => createExternalRoomKey(candidate.roomNumber, candidate.roomName) === key )!; return { id: `mapping-${index + 1}`, projectId: "project-1", sourceId: "source-1", normalizedSourceRoomKey: key, sourceFloorName: null, sourceRoomNumber: object.roomNumber, sourceRoomName: object.roomName, roomId: null, defaultDistributionBoardId: null, }; }); const mappingByKey = new Map( roomMappings.map((mapping) => [mapping.normalizedSourceRoomKey, mapping.id]) ); return { source: { id: "source-1", projectId: "project-1", name: "Revit-Gesamtmodell", sourceType: "revit_csv", }, importBatches: [{ id: "batch-1", projectId: "project-1", sourceId: "source-1", importKind: "initial", importedAtIso: "2026-08-02T16:00:00.000Z", fileName: "revit.csv", sha256: crypto.createHash("sha256").update(bytes).digest("hex"), appliedProjectRevision: 1, configurationVersion: 1, configurationSnapshot: configuration, originalContentBase64: Buffer.from(bytes).toString("base64"), document: parseExternalCsv(bytes, configuration), }], roomMappings, objects: preview.objects.map((candidate, index) => { const projected = projectInitialExternalObject(candidate, configuration); const roomKey = createExternalRoomKey(candidate.roomNumber, candidate.roomName); return { id: `object-${index + 1}`, projectId: "project-1", sourceId: "source-1", ifcGuid: projected.ifcGuid, lastSeenImportBatchId: "batch-1", lastAcceptedImportBatchId: "batch-1", acceptedSourceValues: projected.sourceValues, planningValues: projected.planningValues, overriddenFields: [], externalRoomMappingId: roomKey === null ? null : mappingByKey.get(roomKey)!, distributionBoardId: null, linkedProjectDeviceId: null, circuitDeviceRowId: null, presenceStatus: "present", }; }), }; } describe("external initial import project command", () => { it("commits the complete import and supports persisted undo and redo", () => { const { context, configuration } = createTestContext(); try { const target = initialState(configuration); const repository = new ExternalInitialImportProjectCommandRepository(context.db); const inserted = repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", description: "Revit-Erstimport übernehmen", command: createExternalInitialImportProjectCommand( createEmptyExternalModelState(), target ), }); assert.equal(inserted.revision.revisionNumber, 1); assert.equal( new ExternalModelStateRepository(context.db).getByProject("project-1") .state.objects.length, 2 ); assert.ok( new ExternalModelStateRepository(context.db).getByProject("project-1") .state.objects.every((object) => object.circuitDeviceRowId === null) ); const undone = repository.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: inserted.revision.changeSetId, command: inserted.inverse, }); assert.equal(undone.revision.revisionNumber, 2); assert.equal( new ExternalModelStateRepository(context.db).getByProject("project-1") .state.source, null ); repository.execute({ projectId: "project-1", expectedRevision: 2, source: "redo", historyTargetChangeSetId: inserted.revision.changeSetId, command: undone.inverse, }); const redone = new ExternalModelStateRepository(context.db) .getByProject("project-1").state; assert.deepEqual(redone.objects.map((object) => object.id), ["object-1", "object-2"]); assert.equal(redone.importBatches[0].configurationVersion, 1); } finally { context.close(); } }); it("rejects changed state, checksum drift and row assignment", () => { const { context, configuration } = createTestContext(); try { const target = initialState(configuration); const repository = new ExternalInitialImportProjectCommandRepository(context.db); const assigned = structuredClone(target); assigned.objects[0].circuitDeviceRowId = "row-1"; assert.throws( () => createExternalInitialImportProjectCommand( createEmptyExternalModelState(), assigned ), /must not assign circuit device rows/ ); const invalidHash = structuredClone(target); invalidHash.importBatches[0].sha256 = "b".repeat(64); assert.throws( () => repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createExternalInitialImportProjectCommand( createEmptyExternalModelState(), invalidHash ), }), /checksum is invalid/ ); assert.equal( new ExternalModelStateRepository(context.db).getByProject("project-1") .state.source, null ); } finally { context.close(); } }); it("rolls back all external rows when late history persistence fails", () => { const { context, configuration } = createTestContext(); try { context.sqlite.exec(` CREATE TRIGGER fail_external_import_history BEFORE INSERT ON project_change_sets BEGIN SELECT RAISE(ABORT, 'late history failure'); END; `); const repository = new ExternalInitialImportProjectCommandRepository(context.db); assert.throws( () => repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createExternalInitialImportProjectCommand( createEmptyExternalModelState(), initialState(configuration) ), }), /late history failure/ ); assert.equal( new ExternalModelStateRepository(context.db).getByProject("project-1") .state.source, null ); assert.equal( context.db.select({ revision: projects.currentRevision }).from(projects).get()?.revision, 0 ); } finally { context.close(); } }); });