/** * Distribution-board export/import: pure import preparation plus the HTTP * round trip from one project into another against createApp() with an * in-memory database and synthetic accounts. */ import assert from "node:assert/strict"; import type { AddressInfo } from "node:net"; import { after, before, describe, it } from "node:test"; import type { DistributionBoardSubtreeSnapshot } from "../src/domain/models/distribution-board-subtree-snapshot.model.js"; import { createDistributionBoardTransferEnvelope, parseDistributionBoardTransferEnvelope, prepareDistributionBoardImport, } from "../src/domain/models/distribution-board-transfer.model.js"; import config, { WRITE_HEADER, signIn } from "./security/security.config.ts"; type Headers = Record; function sourceBoard(): DistributionBoardSubtreeSnapshot { return { distributionBoard: { id: "board-1", projectId: "project-source", name: "UV-EG", floorId: "floor-1", supplyType: "SV", simultaneityFactor: 0.8, feedingDistributionBoardId: "board-main", feedingCircuitId: null, boardNumber: "UV-1", location: "Technik", fedFrom: null, remark: null, }, circuitList: { id: "board-1", projectId: "project-source", distributionBoardId: "board-1", name: "UV-EG Stromkreisliste", }, sections: [ { id: "section-1", circuitListId: "board-1", key: "single_phase", displayName: "Einphasig", prefix: "-2F1.", sortOrder: 20, category: "single_phase", groupNumber: 1, }, ], components: [], circuits: [ { id: "circuit-1", circuitListId: "board-1", sectionId: "section-1", equipmentIdentifier: "-2F1.1", displayName: "Steckdose", sortOrder: 10, cableType: "NYM-J", cableCrossSection: "3x2,5", cableLength: 20, rcdAssignment: null, terminalDesignation: null, voltage: 230, controlRequirement: null, status: null, isReserve: false, remark: null, protectionDevice: { circuitId: "circuit-1", type: "LS", ratedCurrentA: 16, fuseUtilizationCategory: null, tripCharacteristic: "B", rcdType: null, ratedResidualCurrentMa: null, }, deviceRows: [ { id: "row-1", circuitId: "circuit-1", linkedProjectDeviceId: "device-1", sortOrder: 10, name: "Steckdose", displayName: "Steckdose", phaseType: "single_phase", connectionKind: null, costGroup: null, category: null, level: null, roomId: "room-1", roomNumberSnapshot: "0.01", roomNameSnapshot: "Büro", quantity: 5, manualQuantity: 2, powerPerUnit: 0.2, simultaneityFactor: 1, cosPhi: 1, remark: null, overriddenFields: "[\"displayName\"]", }, ], }, ], }; } describe("distribution-board transfer model", () => { it("imports with new ids and without source-project references", () => { let nextId = 0; const envelope = createDistributionBoardTransferEnvelope( sourceBoard(), "Quelle", "2026-10-29T10:00:00.000Z" ); const { snapshot, adjustments } = prepareDistributionBoardImport( parseDistributionBoardTransferEnvelope(JSON.parse(JSON.stringify(envelope))), { projectId: "project-target", name: "UV-EG neu", voltageSettings: { singlePhaseVoltageV: 220, threePhaseVoltageV: 380 }, enabledSupplyTypes: ["AV"], }, () => `new-${++nextId}` ); const board = snapshot.distributionBoard; assert.equal(board.projectId, "project-target"); assert.equal(board.name, "UV-EG neu"); assert.equal(board.boardNumber, "UV-1"); assert.equal(board.simultaneityFactor, 0.8); assert.deepEqual( [board.floorId, board.supplyType, board.feedingDistributionBoardId], [null, null, null] ); assert.equal(snapshot.circuitList.projectId, "project-target"); const [circuit] = snapshot.circuits; assert.ok(circuit.id.startsWith("new-")); assert.equal(circuit.equipmentIdentifier, "-2F1.1"); assert.equal(circuit.cableCrossSection, "3x2,5"); assert.equal(circuit.voltage, 220); assert.equal(circuit.protectionDevice?.circuitId, circuit.id); assert.equal(circuit.protectionDevice?.ratedCurrentA, 16); const [row] = circuit.deviceRows; assert.ok(row.id.startsWith("new-")); assert.deepEqual( { linkedProjectDeviceId: row.linkedProjectDeviceId, overriddenFields: row.overriddenFields, roomId: row.roomId, roomNumberSnapshot: row.roomNumberSnapshot, quantity: row.quantity, manualQuantity: row.manualQuantity, }, { linkedProjectDeviceId: null, overriddenFields: null, roomId: null, roomNumberSnapshot: "0.01", quantity: 2, manualQuantity: 2, } ); assert.equal(adjustments.length, 6); }); it("rejects foreign files, unknown fields and broken boards in German", () => { const envelope = createDistributionBoardTransferEnvelope( sourceBoard(), "Quelle", "2026-10-29T10:00:00.000Z" ); assert.throws( () => parseDistributionBoardTransferEnvelope({ format: "leistungsbilanz.project" }), /keine Verteiler-Exportdatei/ ); assert.throws( () => parseDistributionBoardTransferEnvelope({ ...envelope, extra: 1 }), /unbekannte Felder/ ); assert.throws( () => parseDistributionBoardTransferEnvelope({ ...envelope, board: { ...envelope.board, circuits: "kaputt" }, }), /Verteiler in der Exportdatei ist ungültig/ ); }); }); describe("distribution-board transfer routes", () => { 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 json(response: Response, status: number): Promise { const text = await response.text(); assert.equal(response.status, status, text); return JSON.parse(text) as T; } async function createProject(name: string) { return json<{ id: string }>(await call("POST", "/api/projects", user, { name }), 201); } async function getRevision(projectId: string) { return ( await json<{ currentRevision: number }>( await call("GET", `/api/projects/${projectId}/history`, user), 200 ) ).currentRevision; } async function loadTree(projectId: string) { const [circuitList] = await json>( await call("GET", `/api/projects/${projectId}/circuit-lists`, user), 200 ); return json<{ sections: Array<{ id: string; key: string; circuits: Array<{ id: string; equipmentIdentifier: string; cableType?: string; deviceRows: Array<{ id: string; powerPerUnit: number }>; }>; }>; }>( await call("GET", `/api/projects/${projectId}/circuit-lists/${circuitList.id}/tree`, user), 200 ); } 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((resolve) => server.close(() => resolve()))); it("exports a board and imports it into another project as one undoable revision", async () => { const source = await createProject("Quelle"); const created = await json<{ distributionBoard: { id: string } }>( await call("POST", `/api/projects/${source.id}/distribution-boards`, user, { expectedRevision: 0, name: "UV-EG", floorId: null, supplyType: "AV", }), 201 ); const section = (await loadTree(source.id)).sections.find( (entry) => entry.key === "single_phase" ); assert.ok(section); const [circuitList] = await json>( await call("GET", `/api/projects/${source.id}/circuit-lists`, user), 200 ); await json( await call("POST", `/api/projects/${source.id}/commands`, user, { expectedRevision: 1, command: { schemaVersion: 1, type: "circuit.insert", payload: { circuit: { ...sourceBoard().circuits[0], id: "source-circuit", circuitListId: circuitList.id, sectionId: section.id, cableType: null, protectionDevice: null, deviceRows: [ { ...sourceBoard().circuits[0].deviceRows[0], id: "source-row", circuitId: "source-circuit", linkedProjectDeviceId: null, roomId: null, overriddenFields: null, quantity: 2, }, ], }, }, }, }), 200 ); // Cell edits of several entities go through the bulk command. await json( await call("POST", `/api/projects/${source.id}/commands`, user, { expectedRevision: 2, command: { schemaVersion: 1, type: "circuit-list.bulk-update", payload: { updates: [ { schemaVersion: 1, type: "circuit.update", payload: { circuitId: "source-circuit", changes: [{ field: "cableType", value: "NYM-J" }], }, }, { schemaVersion: 1, type: "circuit-device-row.update", payload: { rowId: "source-row", changes: [{ field: "powerPerUnit", value: 0.5 }], }, }, ], }, }, }), 200 ); const exported = await call( "GET", `/api/projects/${source.id}/distribution-boards/${created.distributionBoard.id}/export`, user ); assert.match( exported.headers.get("content-disposition") ?? "", /UV-EG\.verteiler\.json/ ); const transfer = await json<{ format: string }>(exported, 200); assert.equal(transfer.format, "leistungsbilanz.distribution-board"); const target = await createProject("Ziel"); const imported = await json<{ adjustments: string[]; distributionBoard: { name: string } }>( await call("POST", `/api/projects/${target.id}/distribution-boards/import`, user, { expectedRevision: 0, name: "UV-EG importiert", transfer, }), 201 ); assert.equal(imported.distributionBoard.name, "UV-EG importiert"); assert.deepEqual(imported.adjustments, []); const importedCircuit = (await loadTree(target.id)).sections .flatMap((entry) => entry.circuits) .find((circuit) => circuit.equipmentIdentifier === "-2F1.1"); assert.ok(importedCircuit); assert.notEqual(importedCircuit.id, "source-circuit"); assert.equal(importedCircuit.cableType, "NYM-J"); assert.equal(importedCircuit.deviceRows[0].powerPerUnit, 0.5); assert.equal(await getRevision(target.id), 1); await json( await call("POST", `/api/projects/${target.id}/history/undo`, user, { expectedRevision: 1, }), 200 ); assert.deepEqual( await json(await call("GET", `/api/projects/${target.id}/distribution-boards`, user), 200), [] ); }); it("rejects unknown boards, invalid files, stale revisions and anonymous requests", async () => { const project = await createProject("Fehlerfälle"); assert.equal( ( await call( "GET", `/api/projects/${project.id}/distribution-boards/unbekannt/export`, user ) ).status, 404 ); const importPath = `/api/projects/${project.id}/distribution-boards/import`; const invalid = await call("POST", importPath, user, { expectedRevision: 0, transfer: { format: "leistungsbilanz.project" }, }); assert.equal(invalid.status, 400); assert.match((await invalid.json()).error, /keine Verteiler-Exportdatei/); assert.equal( (await call("POST", importPath, user, { expectedRevision: 0, transfer: {}, extra: 1 })).status, 400 ); const envelope = createDistributionBoardTransferEnvelope( sourceBoard(), "Quelle", "2026-10-29T10:00:00.000Z" ); assert.equal( (await call("POST", importPath, user, { expectedRevision: 7, transfer: envelope })).status, 409 ); assert.equal( (await call("POST", importPath, WRITE_HEADER, { expectedRevision: 0, transfer: envelope })).status, 401 ); assert.equal( ( await call( "POST", "/api/projects/unbekannt/distribution-boards/import", user, { expectedRevision: 0, transfer: envelope } ) ).status, 404 ); }); });