274 lines
9.4 KiB
TypeScript
274 lines
9.4 KiB
TypeScript
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<typeof configuredCsv>
|
|
): 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();
|
|
}
|
|
});
|
|
});
|