Typecheck tests against current types

tsx runs tests without type checking and tsconfig.json covers only src,
so 71 type errors had accumulated unnoticed. tsconfig.tests.json now
checks every test and helper, and npm run typecheck includes it.

Fixtures gain the fields the DTOs have grown since they were written
(manualQuantity, circuit apparent power/cos phi, downstream board loads,
section totals, building, cover and board info fields). Repository
lookups that return "row ?? null" now declare the null in their type,
which their callers already handle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Julian Appel 2026-10-29 13:17:45 +01:00
parent a8cc7eca5e
commit 7222398628
37 changed files with 166 additions and 56 deletions

View file

@ -742,6 +742,7 @@ For a normal code change run the relevant focused tests plus:
- `npm run build:api` - `npm run build:api`
- `npm run build:web` - `npm run build:web`
- `npm run typecheck:scripts` - `npm run typecheck:scripts`
- `npm run typecheck:tests`
- `npx tsc --noEmit -p tsconfig.next.json` - `npx tsc --noEmit -p tsconfig.next.json`
- `make prepush` before every push, full `make check` before deploy and weekly (see Tests & Security Baseline) - `make prepush` before every push, full `make check` before deploy and weekly (see Tests & Security Baseline)

View file

@ -147,6 +147,7 @@ npm test
npm run build:api npm run build:api
npm run build:web npm run build:web
npm run typecheck:scripts npm run typecheck:scripts
npm run typecheck:tests
npx tsc --noEmit -p tsconfig.next.json npx tsc --noEmit -p tsconfig.next.json
``` ```

View file

@ -602,6 +602,8 @@ Oberfläche: `/projects/:projectId/grobbilanz`. Details:
- `npm run db:backup` erzeugt ein konsistentes und verifiziertes Online-Backup. - `npm run db:backup` erzeugt ein konsistentes und verifiziertes Online-Backup.
- `npm run typecheck:scripts` prüft die TypeScript-Wartungsskripte, ohne Code zu - `npm run typecheck:scripts` prüft die TypeScript-Wartungsskripte, ohne Code zu
erzeugen. erzeugen.
- `npm run typecheck:tests` prüft alle Tests und Testhilfen gegen die aktuellen
Typen; `tsx` führt Tests sonst ohne Typprüfung aus.
- Die Baseline ersetzt alle vorigen Entwicklungsmigrationen. Datenbanken, - Die Baseline ersetzt alle vorigen Entwicklungsmigrationen. Datenbanken,
Snapshots und persistierte Commands aus Vor-Baseline-Ständen werden bewusst Snapshots und persistierte Commands aus Vor-Baseline-Ständen werden bewusst
nicht unterstützt. Ab der ersten veröffentlichten Version sind angewendete nicht unterstützt. Ab der ersten veröffentlichten Version sind angewendete

View file

@ -82,6 +82,7 @@ npm test
npm run build:api npm run build:api
npm run build:web npm run build:web
npm run typecheck:scripts npm run typecheck:scripts
npm run typecheck:tests
npx tsc --noEmit -p tsconfig.next.json npx tsc --noEmit -p tsconfig.next.json
git diff --check git diff --check
``` ```

View file

@ -20,6 +20,7 @@
"build:api": "tsc -p tsconfig.json", "build:api": "tsc -p tsconfig.json",
"build:web": "next build", "build:web": "next build",
"typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json", "typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json",
"typecheck:tests": "tsc --noEmit -p tsconfig.tests.json",
"start": "node dist/server/index.js", "start": "node dist/server/index.js",
"test": "node scripts/run-tests.mjs", "test": "node scripts/run-tests.mjs",
"test:watch": "node scripts/run-tests.mjs --watch", "test:watch": "node scripts/run-tests.mjs --watch",
@ -29,7 +30,7 @@
"db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js", "db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js",
"revit:verify-reference": "tsx scripts/verify-revit-reference-xlsx.ts", "revit:verify-reference": "tsx scripts/verify-revit-reference-xlsx.ts",
"lint": "eslint .", "lint": "eslint .",
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.next.json && tsc --noEmit -p tsconfig.scripts.json" "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.next.json && tsc --noEmit -p tsconfig.scripts.json && tsc --noEmit -p tsconfig.tests.json"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",

View file

@ -10,7 +10,7 @@ export class CircuitListRepository {
return db.select().from(circuitLists).where(eq(circuitLists.projectId, projectId)); return db.select().from(circuitLists).where(eq(circuitLists.projectId, projectId));
} }
async findById(projectId: string, circuitListId: string) { async findById(projectId: string, circuitListId: string): Promise<typeof circuitLists.$inferSelect | null> {
const db = this.database; const db = this.database;
const [row] = await db const [row] = await db
.select() .select()

View file

@ -15,7 +15,7 @@ export function createDefaultCircuitSectionValues(circuitListId: string) {
export class CircuitSectionRepository { export class CircuitSectionRepository {
constructor(private readonly database: AppDatabase) {} constructor(private readonly database: AppDatabase) {}
async findById(sectionId: string) { async findById(sectionId: string): Promise<typeof circuitSections.$inferSelect | null> {
const db = this.database; const db = this.database;
const [row] = await db.select().from(circuitSections).where(eq(circuitSections.id, sectionId)).limit(1); const [row] = await db.select().from(circuitSections).where(eq(circuitSections.id, sectionId)).limit(1);
return row ?? null; return row ?? null;

View file

@ -53,7 +53,7 @@ export class GlobalDeviceRepository {
.where(eq(globalDevices.id, globalDeviceId)); .where(eq(globalDevices.id, globalDeviceId));
} }
async findById(globalDeviceId: string) { async findById(globalDeviceId: string): Promise<typeof globalDevices.$inferSelect | null> {
const db = this.database; const db = this.database;
const [row] = await db.select().from(globalDevices).where(eq(globalDevices.id, globalDeviceId)).limit(1); const [row] = await db.select().from(globalDevices).where(eq(globalDevices.id, globalDeviceId)).limit(1);
return row ?? null; return row ?? null;

View file

@ -41,7 +41,7 @@ export class ProjectRepository {
return project; return project;
} }
async findById(projectId: string) { async findById(projectId: string): Promise<typeof projects.$inferSelect | null> {
const db = this.database; const db = this.database;
const [row] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1); const [row] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1);
return row ?? null; return row ?? null;

View file

@ -32,13 +32,13 @@ export class UserRepository implements UserStore {
return rows.length; return rows.length;
} }
async findById(userId: string) { async findById(userId: string): Promise<typeof users.$inferSelect | null> {
const db = this.database; const db = this.database;
const [row] = await db.select().from(users).where(eq(users.id, userId)).limit(1); const [row] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
return row ?? null; return row ?? null;
} }
async findByUsername(username: string) { async findByUsername(username: string): Promise<typeof users.$inferSelect | null> {
const db = this.database; const db = this.database;
const [row] = await db const [row] = await db
.select() .select()

View file

@ -160,16 +160,17 @@ describe("same-origin guard for writes", () => {
function run(method: string, fetchSite?: string, includeApplicationHeader = true) { function run(method: string, fetchSite?: string, includeApplicationHeader = true) {
let status: number | undefined; let status: number | undefined;
let passed = false; let passed = false;
const req = { const headers: Record<string, string> = {
method,
headers: {
...(fetchSite ? { "sec-fetch-site": fetchSite } : {}), ...(fetchSite ? { "sec-fetch-site": fetchSite } : {}),
...(includeApplicationHeader ...(includeApplicationHeader
? { [WRITE_REQUEST_HEADER_NAME.toLowerCase()]: WRITE_REQUEST_HEADER_VALUE } ? { [WRITE_REQUEST_HEADER_NAME.toLowerCase()]: WRITE_REQUEST_HEADER_VALUE }
: {}), : {}),
}, };
const req = {
method,
headers,
get(name: string) { get(name: string) {
return this.headers[name.toLowerCase() as keyof typeof this.headers]; return headers[name.toLowerCase()];
}, },
} as unknown as Request; } as unknown as Request;
const res = { const res = {

View file

@ -18,6 +18,7 @@ function row(
name: id, name: id,
displayName: id, displayName: id,
quantity: 1, quantity: 1,
manualQuantity: 1,
powerPerUnit: 0.1, powerPerUnit: 0.1,
simultaneityFactor: 1, simultaneityFactor: 1,
rowTotalPower: 0.1, rowTotalPower: 0.1,
@ -36,7 +37,10 @@ function circuit(
sortOrder: 10, sortOrder: 10,
isReserve: deviceRows.length === 0, isReserve: deviceRows.length === 0,
circuitTotalPower: 0, circuitTotalPower: 0,
circuitApparentPower: 0,
circuitCosPhi: 1,
deviceRows, deviceRows,
downstreamBoardLoads: [],
}; };
} }

View file

@ -13,12 +13,18 @@ import type {
function tree(currentRevision: number): CircuitTreeResponseDto { function tree(currentRevision: number): CircuitTreeResponseDto {
return { return {
id: "list-1", circuitListId: "list-1",
projectId: "project-1",
distributionBoardId: "board-1", distributionBoardId: "board-1",
name: "Liste", distributionBoardName: "UV 1",
currentRevision, currentRevision,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
distributionBoardSimultaneityFactor: 1,
distributionBoardTotalPower: 0,
distributionBoardTotalPowerWithSimultaneityFactor: 0,
headerComponents: [],
sections: [], sections: [],
footerComponents: [],
}; };
} }

View file

@ -27,6 +27,7 @@ const device: CircuitTreeDeviceRowDto = {
roomNumberSnapshot: "1.01", roomNumberSnapshot: "1.01",
roomNameSnapshot: "Office", roomNameSnapshot: "Office",
quantity: 2, quantity: 2,
manualQuantity: 2,
powerPerUnit: 0.05, powerPerUnit: 0.05,
simultaneityFactor: 0.8, simultaneityFactor: 0.8,
rowTotalPower: 0.08, rowTotalPower: 0.08,
@ -53,7 +54,10 @@ const circuit: CircuitTreeCircuitDto = {
isReserve: false, isReserve: false,
remark: "Circuit remark", remark: "Circuit remark",
circuitTotalPower: 0.08, circuitTotalPower: 0.08,
circuitApparentPower: 0.08,
circuitCosPhi: 1,
deviceRows: [device], deviceRows: [device],
downstreamBoardLoads: [],
}; };
describe("circuit grid model", () => { describe("circuit grid model", () => {

View file

@ -25,6 +25,7 @@ function makeDevice(
displayName, displayName,
roomNumberSnapshot, roomNumberSnapshot,
quantity: 1, quantity: 1,
manualQuantity: 1,
powerPerUnit: 0.1, powerPerUnit: 0.1,
simultaneityFactor: 1, simultaneityFactor: 1,
rowTotalPower: 0.1, rowTotalPower: 0.1,
@ -77,6 +78,8 @@ const sections: CircuitTreeSectionDto[] = [
displayName: "Lighting", displayName: "Lighting",
prefix: "-1F", prefix: "-1F",
sortOrder: 10, sortOrder: 10,
sectionTotalPower: 0.3,
components: [],
circuits: [compactCircuit, multiDeviceCircuit, reserveCircuit], circuits: [compactCircuit, multiDeviceCircuit, reserveCircuit],
}, },
]; ];
@ -90,6 +93,8 @@ describe("circuit grid projection", () => {
displayName: "Lighting", displayName: "Lighting",
prefix: "-1F", prefix: "-1F",
sortOrder: 10, sortOrder: 10,
sectionTotalPower: 0,
components: [],
circuits: [], circuits: [],
}, },
{ {
@ -98,6 +103,8 @@ describe("circuit grid projection", () => {
displayName: "Single-phase circuits", displayName: "Single-phase circuits",
prefix: "-2F", prefix: "-2F",
sortOrder: 20, sortOrder: 20,
sectionTotalPower: 0,
components: [],
circuits: [], circuits: [],
}, },
]; ];

View file

@ -138,7 +138,10 @@ describe("circuit group numbering", () => {
sortOrder: 10, sortOrder: 10,
isReserve: false, isReserve: false,
circuitTotalPower: 0, circuitTotalPower: 0,
circuitApparentPower: 0,
circuitCosPhi: 1,
deviceRows: [], deviceRows: [],
downstreamBoardLoads: [],
}, },
], ],
components: [ components: [
@ -235,7 +238,10 @@ describe("circuit group numbering", () => {
sortOrder: 10, sortOrder: 10,
isReserve: false, isReserve: false,
circuitTotalPower: 0, circuitTotalPower: 0,
circuitApparentPower: 0,
circuitCosPhi: 1,
deviceRows: [], deviceRows: [],
downstreamBoardLoads: [],
}, },
], ],
}; };
@ -250,7 +256,10 @@ describe("circuit group numbering", () => {
sortOrder: 20, sortOrder: 20,
isReserve: false, isReserve: false,
circuitTotalPower: 0, circuitTotalPower: 0,
circuitApparentPower: 0,
circuitCosPhi: 1,
deviceRows: [], deviceRows: [],
downstreamBoardLoads: [],
}, },
], ],
}; };
@ -328,6 +337,9 @@ describe("circuit group numbering", () => {
voltage: 230, voltage: 230,
isReserve: false, isReserve: false,
circuitTotalPower: 0.1, circuitTotalPower: 0.1,
circuitApparentPower: 0.1 / 0.95,
circuitCosPhi: 0.95,
downstreamBoardLoads: [],
protectionDevice: { protectionDevice: {
type: "LS" as const, type: "LS" as const,
ratedCurrentA: 10, ratedCurrentA: 10,
@ -342,6 +354,7 @@ describe("circuit group numbering", () => {
displayName: "Leuchte Flur", displayName: "Leuchte Flur",
phaseType: "single_phase", phaseType: "single_phase",
quantity: 2, quantity: 2,
manualQuantity: 2,
powerPerUnit: 0.05, powerPerUnit: 0.05,
simultaneityFactor: 1, simultaneityFactor: 1,
cosPhi: 0.95, cosPhi: 0.95,

View file

@ -24,7 +24,10 @@ function circuit(
sortOrder, sortOrder,
isReserve: true, isReserve: true,
circuitTotalPower: 0, circuitTotalPower: 0,
circuitApparentPower: 0,
circuitCosPhi: 1,
deviceRows: [], deviceRows: [],
downstreamBoardLoads: [],
}; };
} }
@ -39,6 +42,8 @@ function section(
displayName: id, displayName: id,
prefix, prefix,
sortOrder: 10, sortOrder: 10,
sectionTotalPower: 0,
components: [],
circuits: circuits.map((entry) => ({ circuits: circuits.map((entry) => ({
...entry, ...entry,
sectionId: id, sectionId: id,

View file

@ -24,7 +24,10 @@ function circuit(
sortOrder, sortOrder,
isReserve: true, isReserve: true,
circuitTotalPower: 0, circuitTotalPower: 0,
circuitApparentPower: 0,
circuitCosPhi: 1,
deviceRows: [], deviceRows: [],
downstreamBoardLoads: [],
}; };
} }

View file

@ -42,7 +42,10 @@ function circuit(
sortOrder: 10, sortOrder: 10,
isReserve: true, isReserve: true,
circuitTotalPower: 0, circuitTotalPower: 0,
circuitApparentPower: 0,
circuitCosPhi: 1,
deviceRows: [], deviceRows: [],
downstreamBoardLoads: [],
}; };
} }
@ -124,6 +127,7 @@ describe("circuit structure projection", () => {
name: "Leuchte", name: "Leuchte",
displayName: "Leuchte", displayName: "Leuchte",
quantity: 1, quantity: 1,
manualQuantity: 1,
powerPerUnit: 0.1, powerPerUnit: 0.1,
simultaneityFactor: 1, simultaneityFactor: 1,
rowTotalPower: 0.1, rowTotalPower: 0.1,

View file

@ -122,6 +122,7 @@ describe("application repository database contexts", () => {
quantity: 1, quantity: 1,
installedPowerPerUnitKw: 0.2, installedPowerPerUnitKw: 0.2,
demandFactor: 1, demandFactor: 1,
phaseCount: 1,
}); });
assert.equal((await firstGlobalDevices.list()).length, 1); assert.equal((await firstGlobalDevices.list()).length, 1);

View file

@ -208,7 +208,8 @@ describe("distribution-board feeder-circuit project command", () => {
const undo = history.getNextCommand("project-1", "undo"); const undo = history.getNextCommand("project-1", "undo");
assert.ok(undo); assert.ok(undo);
assertDistributionBoardFeederCircuitProjectCommand(undo.command); const undoCommand = undo.command;
assertDistributionBoardFeederCircuitProjectCommand(undoCommand);
assert.throws( assert.throws(
() => () =>
repository.executeFeederCircuit({ repository.executeFeederCircuit({
@ -216,7 +217,7 @@ describe("distribution-board feeder-circuit project command", () => {
expectedRevision: 3, expectedRevision: 3,
source: "undo", source: "undo",
historyTargetChangeSetId: undo.changeSetId, historyTargetChangeSetId: undo.changeSetId,
command: undo.command, command: undoCommand,
}), }),
/changed before history removal/ /changed before history removal/
); );

View file

@ -21,6 +21,10 @@ function createSnapshot(): DistributionBoardSubtreeSnapshot {
simultaneityFactor: 0.8, simultaneityFactor: 0.8,
feedingDistributionBoardId: "board-upstream", feedingDistributionBoardId: "board-upstream",
feedingCircuitId: "circuit-upstream", feedingCircuitId: "circuit-upstream",
boardNumber: null,
location: null,
fedFrom: null,
remark: null,
}, },
circuitList: { circuitList: {
id: "list-1", id: "list-1",

View file

@ -1,7 +1,6 @@
import path from "node:path"; import path from "node:path";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import ExcelJS from "exceljs";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext } from "../src/db/database-context.js"; import { createDatabaseContext } from "../src/db/database-context.js";
@ -26,7 +25,7 @@ import { buildExternalXlsxFollowUpTarget } from "../src/external-model/applicati
import { createExternalXlsxInitialImportPlan } from "../src/external-model/application/external-xlsx-initial-import.js"; import { createExternalXlsxInitialImportPlan } from "../src/external-model/application/external-xlsx-initial-import.js";
import { buildExternalXlsxInitialImportTarget } from "../src/external-model/application/external-xlsx-initial-import-target.js"; import { buildExternalXlsxInitialImportTarget } from "../src/external-model/application/external-xlsx-initial-import-target.js";
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js"; import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
import { createRevitXlsxFixture, externalXlsxTestConfiguration, firstWorksharingId } from "./fixtures/revit-xlsx-fixtures.js"; import { createRevitXlsxFixture, externalXlsxTestConfiguration, loadXlsxWorkbook, firstWorksharingId } from "./fixtures/revit-xlsx-fixtures.js";
const deviceTypeA = "10000000-0000-4000-8000-000000000001"; const deviceTypeA = "10000000-0000-4000-8000-000000000001";
const deviceTypeB = "10000000-0000-4000-8000-000000000002"; const deviceTypeB = "10000000-0000-4000-8000-000000000002";
@ -54,7 +53,7 @@ describe("external follow-up import project command", () => {
const importedFirst = new ExternalModelStateRepository(context.db).getByProject("project-1").state.objects.find((object) => object.worksharingId === firstWorksharingId)!; const importedFirst = new ExternalModelStateRepository(context.db).getByProject("project-1").state.objects.find((object) => object.worksharingId === firstWorksharingId)!;
context.db.update(externalModelObjects).set({ circuitDeviceRowId: "row-1" }).where(eq(externalModelObjects.id, importedFirst.id)).run(); context.db.update(externalModelObjects).set({ circuitDeviceRowId: "row-1" }).where(eq(externalModelObjects.id, importedFirst.id)).run();
const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(initialBytes); workbook.worksheets[0]!.getCell(3, 6).value = 3; const workbook = await loadXlsxWorkbook(initialBytes); workbook.worksheets[0]!.getCell(3, 6).value = 3;
const followBytes = new Uint8Array(await workbook.xlsx.writeBuffer()); const followBytes = new Uint8Array(await workbook.xlsx.writeBuffer());
const current = new ExternalModelStateRepository(context.db).getByProject("project-1").state; const current = new ExternalModelStateRepository(context.db).getByProject("project-1").state;
const returnValues = new Map(current.objects.map((object) => [object.id, { circuitCombined: object.acceptedSourceValues.circuitCombined, boardName: object.acceptedSourceValues.boardName, circuitIdentifier: object.acceptedSourceValues.circuitIdentifier, designation: object.acceptedSourceValues.designation, electricalPowerW: object.acceptedSourceValues.electricalPowerW, voltageV: object.acceptedSourceValues.voltageV, currentA: object.acceptedSourceValues.currentA }])); const returnValues = new Map(current.objects.map((object) => [object.id, { circuitCombined: object.acceptedSourceValues.circuitCombined, boardName: object.acceptedSourceValues.boardName, circuitIdentifier: object.acceptedSourceValues.circuitIdentifier, designation: object.acceptedSourceValues.designation, electricalPowerW: object.acceptedSourceValues.electricalPowerW, voltageV: object.acceptedSourceValues.voltageV, currentA: object.acceptedSourceValues.currentA }]));

View file

@ -78,7 +78,7 @@ describe("Linear XLSX external model foundation", () => {
`); `);
insert.run("o1", lowerId); insert.run("o1", lowerId);
insert.run("o2", upperId); insert.run("o2", upperId);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM external_model_objects").get()!.count, 2); assert.equal(context.sqlite.prepare<[], { count: number }>("SELECT count(*) AS count FROM external_model_objects").get()!.count, 2);
assert.throws(() => insert.run("o3", lowerId), /UNIQUE constraint failed/); assert.throws(() => insert.run("o3", lowerId), /UNIQUE constraint failed/);
} finally { } finally {
context.close(); context.close();

View file

@ -1,13 +1,12 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import ExcelJS from "exceljs";
import { createExternalXlsxFollowUpImportPlan } from "../src/external-model/application/external-xlsx-follow-up-import.js"; import { createExternalXlsxFollowUpImportPlan } from "../src/external-model/application/external-xlsx-follow-up-import.js";
import { buildExternalXlsxFollowUpTarget } from "../src/external-model/application/external-xlsx-follow-up-target.js"; import { buildExternalXlsxFollowUpTarget } from "../src/external-model/application/external-xlsx-follow-up-target.js";
import { createExternalXlsxInitialImportPlan } from "../src/external-model/application/external-xlsx-initial-import.js"; import { createExternalXlsxInitialImportPlan } from "../src/external-model/application/external-xlsx-initial-import.js";
import { buildExternalXlsxInitialImportTarget } from "../src/external-model/application/external-xlsx-initial-import-target.js"; import { buildExternalXlsxInitialImportTarget } from "../src/external-model/application/external-xlsx-initial-import-target.js";
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js"; import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
import type { ExternalXlsxReturnValues } from "../src/external-model/xlsx/external-xlsx-contracts.js"; import type { ExternalXlsxReturnValues } from "../src/external-model/xlsx/external-xlsx-contracts.js";
import { createRevitXlsxFixture, externalXlsxTestConfiguration, firstWorksharingId } from "./fixtures/revit-xlsx-fixtures.js"; import { createRevitXlsxFixture, externalXlsxTestConfiguration, loadXlsxWorkbook, firstWorksharingId } from "./fixtures/revit-xlsx-fixtures.js";
const deviceTypeA = "10000000-0000-4000-8000-000000000001"; const deviceTypeA = "10000000-0000-4000-8000-000000000001";
const deviceTypeB = "10000000-0000-4000-8000-000000000002"; const deviceTypeB = "10000000-0000-4000-8000-000000000002";
@ -26,7 +25,7 @@ async function initialState() {
} }
async function changedWorkbook(firstId = firstWorksharingId) { async function changedWorkbook(firstId = firstWorksharingId) {
const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(await createRevitXlsxFixture()); const workbook = await loadXlsxWorkbook(await createRevitXlsxFixture());
const sheet = workbook.worksheets[0]!; const sheet = workbook.worksheets[0]!;
sheet.getCell(3, 1).value = firstId; sheet.getCell(3, 6).value = 3; sheet.getCell(3, 1).value = firstId; sheet.getCell(3, 6).value = 3;
sheet.getCell(5, 1).value = newWorksharingId; sheet.getCell(5, 2).value = "Leuchte neu"; sheet.getCell(5, 5).value = "Leuchte: Decke"; sheet.getCell(5, 6).value = 1; sheet.getCell(5, 1).value = newWorksharingId; sheet.getCell(5, 2).value = "Leuchte neu"; sheet.getCell(5, 5).value = "Leuchte: Decke"; sheet.getCell(5, 6).value = 1;

View file

@ -172,10 +172,11 @@ describe("external XLSX transport", () => {
assert.equal(worksheet.getCell("P4").value, "bleibt"); assert.equal(worksheet.getCell("P4").value, "bleibt");
assert.equal(worksheet.getCell("L3").numFmt, "0.00"); assert.equal(worksheet.getCell("L3").numFmt, "0.00");
assert.equal(worksheet.getColumn(1).width, 12); assert.equal(worksheet.getColumn(1).width, 12);
const worksharingNote = worksheet.getCell("A1").note;
assert.equal( assert.equal(
typeof worksheet.getCell("A1").note === "string" typeof worksharingNote === "string"
? worksheet.getCell("A1").note ? worksharingNote
: worksheet.getCell("A1").note?.texts?.[0]?.text, : worksharingNote?.texts?.[0]?.text,
"Kommentar Worksharing-ID" "Kommentar Worksharing-ID"
); );
const commentedCells: string[] = []; const commentedCells: string[] = [];

View file

@ -12,6 +12,19 @@ export const secondWorksharingId =
export const externalXlsxTestConfiguration: ExternalXlsxTransportConfiguration = export const externalXlsxTestConfiguration: ExternalXlsxTransportConfiguration =
createDefaultExternalXlsxTransportConfiguration(); createDefaultExternalXlsxTransportConfiguration();
/**
* Loads workbook bytes for editing in a test. ExcelJS declares its own Buffer
* type, which Node 24's generic Buffer does not satisfy (see loadWorkbook in
* external-xlsx-transport.ts).
*/
export async function loadXlsxWorkbook(bytes: Uint8Array) {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(
Buffer.from(bytes) as unknown as Parameters<typeof workbook.xlsx.load>[0]
);
return workbook;
}
export async function createRevitXlsxFixture(options?: { export async function createRevitXlsxFixture(options?: {
duplicateWorksharingId?: boolean; duplicateWorksharingId?: boolean;
secondWorksharingId?: string; secondWorksharingId?: string;

View file

@ -53,8 +53,8 @@ it("replaces legacy CSV state and resets linked row quantities to their manual s
(context.sqlite.prepare("SELECT quantity FROM circuit_device_rows WHERE id = 'r'").get() as { quantity: number }).quantity, (context.sqlite.prepare("SELECT quantity FROM circuit_device_rows WHERE id = 'r'").get() as { quantity: number }).quantity,
2 2
); );
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM external_model_objects").get()!.count, 0); assert.equal(context.sqlite.prepare<[], { count: number }>("SELECT count(*) AS count FROM external_model_objects").get()!.count, 0);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM external_model_configurations").get()!.count, 0); assert.equal(context.sqlite.prepare<[], { count: number }>("SELECT count(*) AS count FROM external_model_configurations").get()!.count, 0);
assert.throws(() => context.sqlite.prepare("SELECT * FROM external_csv_configurations").all(), /no such table/); assert.throws(() => context.sqlite.prepare("SELECT * FROM external_csv_configurations").all(), /no such table/);
const columns = context.sqlite.prepare("PRAGMA table_info(external_model_objects)").all() as Array<{ name: string; notnull: number }>; const columns = context.sqlite.prepare("PRAGMA table_info(external_model_objects)").all() as Array<{ name: string; notnull: number }>;

View file

@ -61,8 +61,8 @@ function device(
} }
const floors: FloorDto[] = [ const floors: FloorDto[] = [
{ id: "floor-og", projectId: "project-1", name: "1. OG", sortOrder: 2 }, { id: "floor-og", projectId: "project-1", name: "1. OG", sortOrder: 2, buildingId: null },
{ id: "floor-eg", projectId: "project-1", name: "EG", sortOrder: 1 }, { id: "floor-eg", projectId: "project-1", name: "EG", sortOrder: 1, buildingId: null },
]; ];
const floorNameById = new Map(floors.map((floor) => [floor.id, floor.name])); const floorNameById = new Map(floors.map((floor) => [floor.id, floor.name]));

View file

@ -54,12 +54,12 @@ it("upgrades a populated pre-print database through the current schema without c
assert.equal(board.feeding_circuit_id, null); assert.equal(board.feeding_circuit_id, null);
assert.equal(floor.building_id, null); assert.equal(floor.building_id, null);
assert.equal(room.area_m2, null); assert.equal(room.area_m2, null);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM buildings").get()!.count, 0); assert.equal(context.sqlite.prepare<[], { count: number }>("SELECT count(*) AS count FROM buildings").get()!.count, 0);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM document_branding_assets").get()!.count, 0); assert.equal(context.sqlite.prepare<[], { count: number }>("SELECT count(*) AS count FROM document_branding_assets").get()!.count, 0);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM document_branding_settings").get()!.count, 0); assert.equal(context.sqlite.prepare<[], { count: number }>("SELECT count(*) AS count FROM document_branding_settings").get()!.count, 0);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM estimate_balances").get()!.count, 0); assert.equal(context.sqlite.prepare<[], { count: number }>("SELECT count(*) AS count FROM estimate_balances").get()!.count, 0);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM estimate_groups").get()!.count, 0); assert.equal(context.sqlite.prepare<[], { count: number }>("SELECT count(*) AS count FROM estimate_groups").get()!.count, 0);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM estimate_rows").get()!.count, 0); assert.equal(context.sqlite.prepare<[], { count: number }>("SELECT count(*) AS count FROM estimate_rows").get()!.count, 0);
assert.deepEqual(context.sqlite.pragma("foreign_key_check"), []); assert.deepEqual(context.sqlite.pragma("foreign_key_check"), []);
assert.equal(context.sqlite.pragma("integrity_check", { simple: true }), "ok"); assert.equal(context.sqlite.pragma("integrity_check", { simple: true }), "ok");
migrate(context.db, { migrationsFolder: source }); migrate(context.db, { migrationsFolder: source });

View file

@ -63,11 +63,13 @@ function getSettings(context: DatabaseContext) {
.get(); .get();
} }
function settings( type ProjectSettingsInput = Parameters<
overrides: Partial<Parameters<
typeof createProjectSettingsUpdateProjectCommand typeof createProjectSettingsUpdateProjectCommand
>[0]> = {} >[0];
) {
function settings(
overrides: Partial<ProjectSettingsInput> = {}
): ProjectSettingsInput {
return { return {
name: "Testprojekt", name: "Testprojekt",
internalProjectNumber: "INT-1", internalProjectNumber: "INT-1",

View file

@ -7,6 +7,7 @@ import {
serializeProjectStateSnapshot, serializeProjectStateSnapshot,
} from "../src/domain/models/project-state-snapshot.model.js"; } from "../src/domain/models/project-state-snapshot.model.js";
import { remapProjectState } from "../src/domain/models/project-transfer.model.js"; import { remapProjectState } from "../src/domain/models/project-transfer.model.js";
import type { CircuitGroupCategory } from "../src/shared/constants/circuit-group.js";
import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js"; import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js";
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js"; import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js"; import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
@ -156,7 +157,7 @@ function protectedSnapshot() {
displayName: "Beleuchtung 1", displayName: "Beleuchtung 1",
prefix: "-1F1.", prefix: "-1F1.",
sortOrder: 10, sortOrder: 10,
category: "lighting" as const, category: "lighting" as CircuitGroupCategory,
groupNumber: 1, groupNumber: 1,
}, },
], ],
@ -370,7 +371,7 @@ describe("project state snapshot model", () => {
it("rejects legacy CSV configuration and imported state with a German migration error", () => { it("rejects legacy CSV configuration and imported state with a German migration error", () => {
const configured = withoutVersionNineFields(structuredClone(minimalSnapshot())); const configured = withoutVersionNineFields(structuredClone(minimalSnapshot()));
configured.schemaVersion = 9 as typeof configured.schemaVersion; configured.schemaVersion = 9 as typeof configured.schemaVersion;
configured.externalCsvConfiguration = { (configured as Record<string, unknown>).externalCsvConfiguration = {
id: "legacy", id: "legacy",
projectId: "project-1", projectId: "project-1",
configurationVersion: 1, configurationVersion: 1,
@ -380,7 +381,7 @@ describe("project state snapshot model", () => {
const imported = withoutVersionNineFields(structuredClone(minimalSnapshot())); const imported = withoutVersionNineFields(structuredClone(minimalSnapshot()));
imported.schemaVersion = 9 as typeof imported.schemaVersion; imported.schemaVersion = 9 as typeof imported.schemaVersion;
imported.externalModel.source = { (imported.externalModel as Record<string, unknown>).source = {
id: "legacy-source", id: "legacy-source",
projectId: "project-1", projectId: "project-1",
name: "Legacy", name: "Legacy",
@ -446,7 +447,7 @@ describe("project state snapshot model", () => {
const previous = withoutVersionSixFields(protectedSnapshot()); const previous = withoutVersionSixFields(protectedSnapshot());
previous.schemaVersion = 4 as typeof previous.schemaVersion; previous.schemaVersion = 4 as typeof previous.schemaVersion;
previous.circuits[0]!.isReserve = false; previous.circuits[0]!.isReserve = false;
previous.circuits[0]!.deviceRows = [{ (previous.circuits[0] as Record<string, unknown>).deviceRows = [{
id: "row-1", id: "row-1",
circuitId: "circuit-1", circuitId: "circuit-1",
linkedProjectDeviceId: null, linkedProjectDeviceId: null,

View file

@ -285,6 +285,10 @@ describe("project settings presentation", () => {
"MSR", "MSR",
"SiBe", "SiBe",
], ],
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
currentRevision: 0, currentRevision: 0,
}, },
usedDistributionBoardSupplyTypes: ["SiBe"], usedDistributionBoardSupplyTypes: ["SiBe"],

View file

@ -4,7 +4,6 @@ import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import ExcelJS from "exceljs";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext } from "../src/db/database-context.js"; import { createDatabaseContext } from "../src/db/database-context.js";
@ -39,7 +38,7 @@ import { createExternalXlsxInitialImportPlan } from "../src/external-model/appli
import { projectExternalXlsxReturn } from "../src/external-model/application/external-xlsx-return-projection.js"; import { projectExternalXlsxReturn } from "../src/external-model/application/external-xlsx-return-projection.js";
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js"; import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
import { createExternalXlsxReturn } from "../src/external-model/xlsx/external-xlsx-transport.js"; import { createExternalXlsxReturn } from "../src/external-model/xlsx/external-xlsx-transport.js";
import { createRevitXlsxFixture, externalXlsxTestConfiguration, firstWorksharingId, secondWorksharingId } from "./fixtures/revit-xlsx-fixtures.js"; import { createRevitXlsxFixture, externalXlsxTestConfiguration, loadXlsxWorkbook, firstWorksharingId, secondWorksharingId } from "./fixtures/revit-xlsx-fixtures.js";
const projectId = "project-e2e"; const projectId = "project-e2e";
const boardId = "board-e2e"; const boardId = "board-e2e";
@ -80,7 +79,7 @@ describe("Linear XLSX end-to-end", () => {
const projected = state.objects.map((object) => projectExternalXlsxReturn({ object, row: object.circuitDeviceRowId ? { rowId, displayName: "Steckdose", powerPerUnitKw: 0.125, cosPhi: 0.9, phaseType: "single_phase", category: "single_phase", circuitIdentifier: "-2F1.1", circuitVoltageV: 230, distributionBoardName: "UV 1" } : null, circuitMode: configuration.circuitMode, singlePhaseVoltageV: 230, threePhaseVoltageV: 400 })); const projected = state.objects.map((object) => projectExternalXlsxReturn({ object, row: object.circuitDeviceRowId ? { rowId, displayName: "Steckdose", powerPerUnitKw: 0.125, cosPhi: 0.9, phaseType: "single_phase", category: "single_phase", circuitIdentifier: "-2F1.1", circuitVoltageV: 230, distributionBoardName: "UV 1" } : null, circuitMode: configuration.circuitMode, singlePhaseVoltageV: 230, threePhaseVoltageV: 400 }));
const latestBatch = state.importBatches.at(-1)!; const latestBatch = state.importBatches.at(-1)!;
const returned = await createExternalXlsxReturn({ fileName: latestBatch.fileName, bytes: Buffer.from(latestBatch.originalContentBase64!, "base64"), configuration, selectedWorksharingIds: projected.map((entry) => entry.worksharingId), updates: projected.map((entry) => ({ worksharingId: entry.worksharingId, values: entry.values })) }); const returned = await createExternalXlsxReturn({ fileName: latestBatch.fileName, bytes: Buffer.from(latestBatch.originalContentBase64!, "base64"), configuration, selectedWorksharingIds: projected.map((entry) => entry.worksharingId), updates: projected.map((entry) => ({ worksharingId: entry.worksharingId, values: entry.values })) });
const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(returned.bytes); workbook.worksheets[0]!.getCell(3, 6).value = 3; const workbook = await loadXlsxWorkbook(returned.bytes); workbook.worksheets[0]!.getCell(3, 6).value = 3;
const followBytes = new Uint8Array(await workbook.xlsx.writeBuffer()); const followBytes = new Uint8Array(await workbook.xlsx.writeBuffer());
const returnValues = new Map(projected.map((entry) => [entry.objectId, entry.values])); const returnValues = new Map(projected.map((entry) => [entry.objectId, entry.values]));
const follow = await createExternalXlsxFollowUpImportPlan({ fileName: "follow.xlsx", bytes: followBytes, configuration, catalogs: emptyCatalogs(), currentState: state, currentReturnValuesByObjectId: returnValues }); const follow = await createExternalXlsxFollowUpImportPlan({ fileName: "follow.xlsx", bytes: followBytes, configuration, catalogs: emptyCatalogs(), currentState: state, currentReturnValuesByObjectId: returnValues });
@ -139,5 +138,5 @@ function seedProject(database: ReturnType<typeof createDatabaseContext>["db"]) {
database.insert(circuitDeviceRows).values({ id: rowId, circuitId, sortOrder: 10, name: "Steckdose", displayName: "Steckdose", phaseType: "single_phase", category: "single_phase", quantity: 1, manualQuantity: 1, powerPerUnit: 0.125, simultaneityFactor: 1, cosPhi: 0.9 }).run(); database.insert(circuitDeviceRows).values({ id: rowId, circuitId, sortOrder: 10, name: "Steckdose", displayName: "Steckdose", phaseType: "single_phase", category: "single_phase", quantity: 1, manualQuantity: 1, powerPerUnit: 0.125, simultaneityFactor: 1, cosPhi: 0.9 }).run();
} }
function currentRow(database: ReturnType<typeof createDatabaseContext>["db"]) { return toCircuitDeviceRowSnapshot(database.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)).get()!); } function currentRow(database: ReturnType<typeof createDatabaseContext>["db"]) { return toCircuitDeviceRowSnapshot(database.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)).get()!); }
function emptyCatalogs() { return { floors: [], rooms: [], distributionBoards: [{ id: boardId, name: "UV 1" }], projectDevices: [] }; } function emptyCatalogs() { return { floors: [], rooms: [], distributionBoards: [{ id: boardId, name: "UV 1", floorId: null }], projectDevices: [] }; }
function nextId(prefix: string, sequence: number) { return `${prefix}0000000-0000-4000-8000-${String(sequence).padStart(12, "0")}`; } function nextId(prefix: string, sequence: number) { return `${prefix}0000000-0000-4000-8000-${String(sequence).padStart(12, "0")}`; }

View file

@ -6,8 +6,12 @@
* Die App wird nur DYNAMISCH in createApp() importiert, damit das Routen-Inventar greift. * Die App wird nur DYNAMISCH in createApp() importiert, damit das Routen-Inventar greift.
* Testdaten sind synthetisch; die Datenbank liegt nur im Speicher dieses Testprozesses. * Testdaten sind synthetisch; die Datenbank liegt nur im Speicher dieses Testprozesses.
*/ */
import path from "node:path";
import type { Express } from "express"; import type { Express } from "express";
import type { collectRoutes } from "./route-inventory.ts";
// Express 5 typisiert app.router nur als Router; zur Laufzeit trägt er den
// Layer-Stack, den das generische Routen-Inventar durchläuft.
type InventoriedExpress = Express & Parameters<typeof collectRoutes>[0];
type Headers = Record<string, string>; type Headers = Record<string, string>;
type Case = { method: string; path: string; body?: unknown }; type Case = { method: string; path: string; body?: unknown };
@ -46,22 +50,22 @@ export async function signIn(baseUrl: string, username: string): Promise<Headers
} }
const config = { const config = {
async createApp(): Promise<Express> { async createApp(): Promise<InventoriedExpress> {
process.env.LB_DATABASE_PATH = ":memory:"; process.env.LB_DATABASE_PATH = ":memory:";
process.env.LOG_LEVEL ??= "error"; process.env.LOG_LEVEL ??= "error";
delete process.env.AUTH_BOOTSTRAP_USERNAME; delete process.env.AUTH_BOOTSTRAP_USERNAME;
delete process.env.AUTH_BOOTSTRAP_PASSWORT; delete process.env.AUTH_BOOTSTRAP_PASSWORT;
const { migrate } = await import("drizzle-orm/better-sqlite3/migrator");
const { db } = await import("../../src/db/client.js"); const { db } = await import("../../src/db/client.js");
migrate(db, { migrationsFolder: path.resolve("src", "db", "migrations") }); const { migrateDatabase } = await import("../support/migrate-database.js");
migrateDatabase(db);
const { userService } = await import("../../src/server/composition/auth.js"); const { userService } = await import("../../src/server/composition/auth.js");
for (const username of TEST_USERS) { for (const username of TEST_USERS) {
await userService.createUser({ username, name: username, role: "user", initialPassword: START_PASSWORD }); await userService.createUser({ username, name: username, role: "user", initialPassword: START_PASSWORD });
} }
const { createApp } = await import("../../src/server/app.js"); const { createApp } = await import("../../src/server/app.js");
return createApp(); return createApp() as InventoriedExpress;
}, },
// Ohne Login erreichbar: Healthcheck und die offenen Pfade von createAuthGuard. // Ohne Login erreichbar: Healthcheck und die offenen Pfade von createAuthGuard.

View file

@ -0,0 +1,11 @@
import path from "node:path";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import type { AppDatabase } from "../../src/db/database-context.js";
/**
* Applies all migrations. ES-module tests (tests/security) use this helper so
* the migrator resolves the same Drizzle declarations as the application.
*/
export function migrateDatabase(database: AppDatabase): void {
migrate(database, { migrationsFolder: path.resolve("src", "db", "migrations") });
}

18
tsconfig.tests.json Normal file
View file

@ -0,0 +1,18 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": ".",
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"lib": ["DOM", "DOM.Iterable", "ES2022"]
},
"include": ["tests/**/*.ts", "src/**/*.ts"],
"exclude": [
"node_modules",
"dist",
"src/proxy.ts",
"src/instrumentation.ts",
"src/instrumentation-node.ts"
]
}