Files
leistungsbilanz-ts/tests/external-initial-import-project-command.repository.test.ts
T

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();
}
});
});