forked from jappel/leistungsbilanz-ts
- The command repository rejects user plans that link objects missing in the latest import, so the generic command route cannot bypass the rule. - The plan schema limits the total number of objects to 10,000. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
214 lines
11 KiB
TypeScript
214 lines
11 KiB
TypeScript
/**
|
|
* HTTP boundary of the Revit assignment plan route against createApp() with
|
|
* an in-memory database: authentication, strict input and foreign objects.
|
|
*/
|
|
import assert from "node:assert/strict";
|
|
import type { AddressInfo } from "node:net";
|
|
import { after, before, describe, it } from "node:test";
|
|
import config, { WRITE_HEADER, signIn } from "./security/security.config.ts";
|
|
|
|
type Headers = Record<string, string>;
|
|
|
|
describe("Revit assignment plan route", () => {
|
|
let server: import("node:http").Server;
|
|
let baseUrl = "";
|
|
let user: Headers = {};
|
|
|
|
async function call(method: string, path: string, headers: Headers, body?: unknown) {
|
|
return fetch(baseUrl + path, {
|
|
method,
|
|
headers: { ...headers, "content-type": "application/json" },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
async function createProjectWithBoard(name: string) {
|
|
const project = (await (await call("POST", "/api/projects", user, { name })).json()) as { id: string };
|
|
const created = await call("POST", `/api/projects/${project.id}/distribution-boards`, user, {
|
|
expectedRevision: 0, name: "UV-EG", floorId: null, supplyType: "AV",
|
|
});
|
|
assert.equal(created.status, 201, await created.clone().text());
|
|
const [circuitList] = (await (await call("GET", `/api/projects/${project.id}/circuit-lists`, user)).json()) as Array<{ id: string }>;
|
|
return { projectId: project.id, circuitListId: circuitList!.id };
|
|
}
|
|
|
|
function plan(circuitListId: string, objectId = "unknown-object") {
|
|
return {
|
|
expectedRevision: 1,
|
|
circuitListId,
|
|
newCircuits: [],
|
|
rows: [{ kind: "new-row", circuitId: "unknown-circuit", objectIds: [objectId] }],
|
|
confirmedConflictObjectIds: [],
|
|
confirmedOverLimitCircuitKeys: [],
|
|
};
|
|
}
|
|
|
|
before(async () => {
|
|
const app = await config.createApp();
|
|
server = app.listen(0, "127.0.0.1");
|
|
await new Promise((resolve) => server.once("listening", resolve));
|
|
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
user = await signIn(baseUrl, "planer-a");
|
|
});
|
|
|
|
after(() => new Promise<void>((resolve) => server.close(() => resolve())));
|
|
|
|
async function seedSockets(projectId: string, boardId: string, count: number) {
|
|
const { db } = await import("../src/db/client.js");
|
|
const { externalModelConfigurations } = await import("../src/db/schema/external-model-configurations.js");
|
|
const { externalModelSources } = await import("../src/db/schema/external-model-sources.js");
|
|
const { externalImportBatches } = await import("../src/db/schema/external-import-batches.js");
|
|
const { externalModelObjects } = await import("../src/db/schema/external-model-objects.js");
|
|
const { createDefaultExternalModelConfiguration } = await import(
|
|
"../src/external-model/configuration/external-model-configuration.js"
|
|
);
|
|
const deviceTypeId = "10000000-0000-4000-8000-000000000001";
|
|
db.insert(externalModelConfigurations).values({
|
|
id: `config-${projectId}`,
|
|
projectId,
|
|
configuration: {
|
|
...createDefaultExternalModelConfiguration(),
|
|
deviceTypes: [{
|
|
id: deviceTypeId, name: "Steckdose", category: "single_phase", isSocket: true,
|
|
defaults: { powerPerUnitW: 200, simultaneityFactor: 1, cosPhi: null, costGroup: null },
|
|
}],
|
|
familyTypeRules: [],
|
|
},
|
|
}).run();
|
|
db.insert(externalModelSources).values({ id: `source-${projectId}`, projectId, name: "Revit", sourceType: "revit_xlsx" }).run();
|
|
db.insert(externalImportBatches).values({
|
|
id: `batch-${projectId}`, projectId, sourceId: `source-${projectId}`, importKind: "initial",
|
|
importedAtIso: "2026-10-30T10:00:00.000Z", fileName: "revit.xlsx", sha256: "b".repeat(64), objectCount: count,
|
|
}).run();
|
|
const ids = Array.from({ length: count }, (_, index) => `${projectId}-socket-${index + 1}`);
|
|
db.insert(externalModelObjects).values(ids.map((id, index) => ({
|
|
id, projectId, sourceId: `source-${projectId}`,
|
|
worksharingId: `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}-00000001`,
|
|
ifcGuid: null, deviceTypeId, lastSeenImportBatchId: `batch-${projectId}`, lastAcceptedImportBatchId: `batch-${projectId}`,
|
|
acceptedSourceValues: {
|
|
rowNumber: index + 3, roomNumber: null, roomName: null, familyAndType: "Steckdose: Doppel", selectionMarker: "SD",
|
|
socketCount: "2", sourceRemark: null, circuitCombined: null, boardName: null, circuitIdentifier: null,
|
|
designation: null, electricalPowerW: null, voltageV: null, currentA: null,
|
|
},
|
|
planningValues: {
|
|
displayName: "Steckdose", category: "single_phase" as const, connectionKind: null, effectiveQuantity: 2,
|
|
powerPerUnitW: 200, simultaneityFactor: 1, cosPhi: null, costGroup: null, remark: null,
|
|
},
|
|
overriddenFields: [], externalRoomMappingId: null, distributionBoardId: boardId,
|
|
linkedProjectDeviceId: null, circuitDeviceRowId: null, presenceStatus: "present" as const,
|
|
}))).run();
|
|
return ids;
|
|
}
|
|
|
|
it("applies a split plan with the socket limit, one undo step and stable BMKs", async () => {
|
|
const { projectId, circuitListId } = await createProjectWithBoard("Steckdosen");
|
|
const tree = (await (await call("GET", `/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`, user)).json()) as {
|
|
distributionBoardId: string;
|
|
sections: Array<{ id: string; category?: string; prefix: string; circuits: Array<{ id: string; equipmentIdentifier: string; deviceRows: Array<{ quantity: number; manualQuantity: number }> }> }>;
|
|
};
|
|
const section = tree.sections.find((entry) => entry.category === "single_phase")!;
|
|
const objectIds = await seedSockets(projectId, tree.distributionBoardId, 6);
|
|
const settings = await call("PUT", `/api/projects/${projectId}`, user, {
|
|
expectedRevision: 1, name: "Steckdosen", internalProjectNumber: null, externalProjectNumber: null,
|
|
buildingOwner: null, description: null, isPublicBuilding: false, singlePhaseVoltageV: 230,
|
|
threePhaseVoltageV: 400, enabledDistributionBoardSupplyTypes: ["AV", "SV", "EV", "USV", "MSR", "SiBe"],
|
|
maxSocketsPerCircuit: 8,
|
|
});
|
|
assert.equal(settings.status, 200, await settings.clone().text());
|
|
|
|
const path = `/api/projects/${projectId}/external-model/objects/assignment-plan`;
|
|
const request = {
|
|
expectedRevision: 2,
|
|
circuitListId,
|
|
newCircuits: [{ key: "a", sectionId: section.id }, { key: "b", sectionId: section.id }],
|
|
rows: [
|
|
{ kind: "new-row-in-new-circuit", newCircuitKey: "a", objectIds: objectIds.slice(0, 4) },
|
|
{ kind: "new-row-in-new-circuit", newCircuitKey: "b", objectIds: objectIds.slice(4, 6) },
|
|
],
|
|
confirmedConflictObjectIds: [],
|
|
confirmedOverLimitCircuitKeys: [],
|
|
};
|
|
const overLimit = await call("POST", path, user, {
|
|
...request,
|
|
rows: [{ kind: "new-row-in-new-circuit", newCircuitKey: "a", objectIds: objectIds.slice(0, 5) }],
|
|
newCircuits: [{ key: "a", sectionId: section.id }],
|
|
});
|
|
assert.equal(overLimit.status, 400);
|
|
assert.match(await overLimit.text(), /Höchstwert von 8 Steckdosen/);
|
|
|
|
const applied = await call("POST", path, user, request);
|
|
assert.equal(applied.status, 200, await applied.clone().text());
|
|
const afterTree = (await (await call("GET", `/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`, user)).json()) as typeof tree;
|
|
const created = afterTree.sections.find((entry) => entry.id === section.id)!.circuits;
|
|
assert.deepEqual(created.map((circuit) => circuit.equipmentIdentifier), [`${section.prefix}1`, `${section.prefix}2`]);
|
|
assert.deepEqual(created.map((circuit) => circuit.deviceRows.map((row) => [row.quantity, row.manualQuantity])), [[[8, 0]], [[4, 0]]]);
|
|
|
|
const objects = (await (await call("GET", `/api/projects/${projectId}/external-model/objects`, user)).json()) as {
|
|
maxSocketsPerCircuit: number | null; socketDeviceTypeIds: string[]; objects: Array<{ circuitDeviceRowId: string | null }>;
|
|
};
|
|
assert.equal(objects.maxSocketsPerCircuit, 8);
|
|
assert.equal(objects.socketDeviceTypeIds.length, 1);
|
|
assert.ok(objects.objects.every((object) => object.circuitDeviceRowId !== null));
|
|
|
|
const undone = await call("POST", `/api/projects/${projectId}/history/undo`, user, { expectedRevision: 3 });
|
|
assert.equal(undone.status, 200, await undone.clone().text());
|
|
const undoneTree = (await (await call("GET", `/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`, user)).json()) as typeof tree;
|
|
assert.equal(undoneTree.sections.find((entry) => entry.id === section.id)!.circuits.length, 0);
|
|
const redone = await call("POST", `/api/projects/${projectId}/history/redo`, user, { expectedRevision: 4 });
|
|
assert.equal(redone.status, 200, await redone.clone().text());
|
|
const redoneTree = (await (await call("GET", `/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`, user)).json()) as typeof tree;
|
|
assert.deepEqual(
|
|
redoneTree.sections.find((entry) => entry.id === section.id)!.circuits.map((circuit) => circuit.id),
|
|
created.map((circuit) => circuit.id)
|
|
);
|
|
});
|
|
|
|
it("rejects requests without a session", async () => {
|
|
const { projectId, circuitListId } = await createProjectWithBoard("Ohne Login");
|
|
const response = await call(
|
|
"POST",
|
|
`/api/projects/${projectId}/external-model/objects/assignment-plan`,
|
|
WRITE_HEADER,
|
|
plan(circuitListId)
|
|
);
|
|
assert.equal(response.status, 401);
|
|
});
|
|
|
|
it("rejects invalid input and unknown fields", async () => {
|
|
const { projectId, circuitListId } = await createProjectWithBoard("Ungültig");
|
|
const path = `/api/projects/${projectId}/external-model/objects/assignment-plan`;
|
|
assert.equal((await call("POST", path, user, {})).status, 400);
|
|
assert.equal((await call("POST", path, user, { ...plan(circuitListId), extra: true })).status, 400);
|
|
assert.equal(
|
|
(await call("POST", path, user, { ...plan(circuitListId), rows: [{ kind: "row", rowId: "x", objectIds: ["a"] }] })).status,
|
|
400
|
|
);
|
|
assert.equal((await call("POST", path, user, { ...plan(circuitListId), rows: [] })).status, 400);
|
|
const tooManyObjects = Array.from({ length: 10 }, (_, row) => ({
|
|
kind: "new-row",
|
|
circuitId: "c",
|
|
objectIds: Array.from({ length: 1001 }, (_, index) => `o-${row}-${index}`),
|
|
}));
|
|
const oversized = await call("POST", path, user, { ...plan(circuitListId), rows: tooManyObjects });
|
|
assert.equal(oversized.status, 400);
|
|
assert.match(await oversized.text(), /höchstens 10000 Revit-Objekte/);
|
|
});
|
|
|
|
it("rejects a circuit list of another project and unknown objects", async () => {
|
|
const own = await createProjectWithBoard("Eigenes Projekt");
|
|
const foreign = await createProjectWithBoard("Fremdes Projekt");
|
|
const path = `/api/projects/${own.projectId}/external-model/objects/assignment-plan`;
|
|
const foreignList = await call("POST", path, user, plan(foreign.circuitListId));
|
|
assert.equal(foreignList.status, 404);
|
|
const unknownObject = await call("POST", path, user, plan(own.circuitListId));
|
|
assert.equal(unknownObject.status, 400);
|
|
assert.match(await unknownObject.text(), /Revit-Objekt wurde nicht gefunden/);
|
|
const missingProject = await call(
|
|
"POST",
|
|
"/api/projects/unknown-project/external-model/objects/assignment-plan",
|
|
user,
|
|
plan(own.circuitListId)
|
|
);
|
|
assert.equal(missingProject.status, 404);
|
|
});
|
|
});
|