From a8cc7eca5e89b9a44081a5420871ebddd90ad530 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Thu, 29 Oct 2026 12:27:46 +0100 Subject: [PATCH] Tighten rejection tests to their expected errors Every assert.throws now names the error it expects, so a broken fixture or an unrelated failure can no longer satisfy a rejection test. Zod rejections are matched by issue path and code via tests/support/zod-assertions.ts. Restore snapshot coverage lost with the release baseline: invalid board floor, room floor and circuit section references and a reserve state that contradicts the device rows are rejected again under test. Co-Authored-By: Claude Opus 5.5 --- tests/batched-insert.persistence.test.ts | 5 +- ...ructure-project-command.repository.test.ts | 52 +++++--- ...ructure-project-command.repository.test.ts | 108 ++++++++++------- tests/estimate-balance.test.ts | 30 +++-- tests/project-building-structure.test.ts | 28 +++-- tests/project-command.model.test.ts | 26 ++-- tests/project-state-snapshot.test.ts | 114 ++++++++++++++---- tests/support/zod-assertions.ts | 28 +++++ 8 files changed, 269 insertions(+), 122 deletions(-) create mode 100644 tests/support/zod-assertions.ts diff --git a/tests/batched-insert.persistence.test.ts b/tests/batched-insert.persistence.test.ts index e7b2127..2955a8f 100644 --- a/tests/batched-insert.persistence.test.ts +++ b/tests/batched-insert.persistence.test.ts @@ -13,7 +13,10 @@ describe("batched SQLite inserts", () => { migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); const values = Array.from({ length: 401 }, (_, index) => ({ id: `project-${index}`, name: `Projekt ${index}` })); values[400] = { id: "project-0", name: "Duplikat" }; - assert.throws(() => context.db.transaction((tx) => insertManyInBatches(tx, projects, values))); + assert.throws( + () => context.db.transaction((tx) => insertManyInBatches(tx, projects, values)), + /UNIQUE constraint failed: projects\.id/ + ); assert.equal(context.db.select().from(projects).all().length, 0); } finally { context.close(); } }); diff --git a/tests/circuit-device-row-structure-project-command.repository.test.ts b/tests/circuit-device-row-structure-project-command.repository.test.ts index 318ef02..7beee4a 100644 --- a/tests/circuit-device-row-structure-project-command.repository.test.ts +++ b/tests/circuit-device-row-structure-project-command.repository.test.ts @@ -410,25 +410,39 @@ describe("circuit device-row structure project-command repository", () => { try { const store = new CircuitDeviceRowStructureProjectCommandRepository(context.db); - for (const snapshot of [ - createInsertSnapshot({ circuitId: "circuit-foreign" }), - createInsertSnapshot({ - id: "row-foreign-device", - linkedProjectDeviceId: "device-foreign", - }), - createInsertSnapshot({ - id: "row-foreign-room", - roomId: "room-foreign", - }), - createInsertSnapshot({ id: "row-1" }), - ]) { - assert.throws(() => - store.execute({ - projectId: "project-1", - expectedRevision: 0, - source: "user", - command: createCircuitDeviceRowInsertProjectCommand(snapshot), - }) + for (const [snapshot, expectedError] of [ + [ + createInsertSnapshot({ circuitId: "circuit-foreign" }), + /Circuit does not belong to project/, + ], + [ + createInsertSnapshot({ + id: "row-foreign-device", + linkedProjectDeviceId: "device-foreign", + }), + /Invalid linked project device id/, + ], + [ + createInsertSnapshot({ + id: "row-foreign-room", + roomId: "room-foreign", + }), + /Invalid room id/, + ], + [ + createInsertSnapshot({ id: "row-1" }), + /Circuit device-row id already exists/, + ], + ] as const) { + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitDeviceRowInsertProjectCommand(snapshot), + }), + expectedError ); } assert.equal(context.db.select().from(projectRevisions).all().length, 0); diff --git a/tests/circuit-structure-project-command.repository.test.ts b/tests/circuit-structure-project-command.repository.test.ts index 8985ad0..626c708 100644 --- a/tests/circuit-structure-project-command.repository.test.ts +++ b/tests/circuit-structure-project-command.repository.test.ts @@ -568,51 +568,71 @@ describe("circuit structure project-command repository", () => { const store = new CircuitStructureProjectCommandRepository( fixture.context.db ); - const invalidSnapshots = [ - createCircuitSnapshot(fixture, { - id: "foreign-list", - circuitListId: fixture.foreignCircuitListId, - sectionId: fixture.foreignSectionId, - }), - createCircuitSnapshot(fixture, { - id: "foreign-section", - sectionId: fixture.foreignSectionId, - }), - createCircuitSnapshot(fixture, { - id: "duplicate-bmk", - equipmentIdentifier: "-1F1", - }), - createCircuitSnapshot(fixture, { - id: "duplicate-row", - deviceRows: [ - createRow("duplicate-row", "row-existing-1", 10), - ], - }), - createCircuitSnapshot(fixture, { - id: "foreign-device", - deviceRows: [ - createRow("foreign-device", "row-foreign-device", 10, { - linkedProjectDeviceId: "device-foreign", - }), - ], - }), - createCircuitSnapshot(fixture, { - id: "foreign-room", - deviceRows: [ - createRow("foreign-room", "row-foreign-room", 10, { - roomId: "room-foreign", - }), - ], - }), + const invalidSnapshots: Array<[CircuitSnapshot, RegExp]> = [ + [ + createCircuitSnapshot(fixture, { + id: "foreign-list", + circuitListId: fixture.foreignCircuitListId, + sectionId: fixture.foreignSectionId, + }), + /Circuit list does not belong to project/, + ], + [ + createCircuitSnapshot(fixture, { + id: "foreign-section", + sectionId: fixture.foreignSectionId, + }), + /Section does not belong to circuit list/, + ], + [ + createCircuitSnapshot(fixture, { + id: "duplicate-bmk", + equipmentIdentifier: "-1F1", + }), + /Duplicate equipmentIdentifier in circuit list/, + ], + [ + createCircuitSnapshot(fixture, { + id: "duplicate-row", + deviceRows: [ + createRow("duplicate-row", "row-existing-1", 10), + ], + }), + /Circuit device-row id already exists/, + ], + [ + createCircuitSnapshot(fixture, { + id: "foreign-device", + deviceRows: [ + createRow("foreign-device", "row-foreign-device", 10, { + linkedProjectDeviceId: "device-foreign", + }), + ], + }), + /Invalid linked project device id/, + ], + [ + createCircuitSnapshot(fixture, { + id: "foreign-room", + deviceRows: [ + createRow("foreign-room", "row-foreign-room", 10, { + roomId: "room-foreign", + }), + ], + }), + /Invalid room id/, + ], ]; - for (const snapshot of invalidSnapshots) { - assert.throws(() => - store.execute({ - projectId: "project-1", - expectedRevision: 0, - source: "user", - command: createCircuitInsertProjectCommand(snapshot), - }) + for (const [snapshot, expectedError] of invalidSnapshots) { + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitInsertProjectCommand(snapshot), + }), + expectedError ); } assert.equal( diff --git a/tests/estimate-balance.test.ts b/tests/estimate-balance.test.ts index 1463fe4..a850bcb 100644 --- a/tests/estimate-balance.test.ts +++ b/tests/estimate-balance.test.ts @@ -35,6 +35,7 @@ import { type EstimateBalanceSnapshot, type EstimateRowSnapshot, } from "../src/shared/estimate-balance/estimate-balance.js"; +import { zodIssues, type ExpectedZodIssue } from "./support/zod-assertions.js"; const round2 = (value: number) => roundEstimateValue(value, 2); let nextId = 0; @@ -236,22 +237,25 @@ describe("estimate balance snapshots and commands", () => { it("validates snapshots strictly", () => { const valid = templateExample(); assert.equal(parseEstimateBalanceSnapshot(valid).groups[4]!.rows[3]!.sortOrder, 3); - const invalid: Array<(balance: EstimateBalanceSnapshot) => void> = [ - (balance) => (balance.groups[0]!.rows[0]!.cosPhi = 0), - (balance) => (balance.groups[0]!.rows[0]!.simultaneityFactor = 1.5), - (balance) => (balance.groups[0]!.rows[0]!.quantity = -1), - (balance) => (balance.groups[0]!.rows[0]!.name = " Leerzeichen "), - (balance) => (balance.groups[0]!.rows[0]!.supply = ""), - (balance) => (balance.groups[0]!.title = ""), - (balance) => (balance.dateIso = "30.06.2023"), - (balance) => (balance.reserveAvPercent = 101), - (balance) => (balance.groups[1]!.rows[0]!.id = balance.groups[0]!.rows[0]!.id), - (balance) => ((balance.groups[0]!.rows[0] as unknown as Record).extra = 1), + const invalid: Array<[(balance: EstimateBalanceSnapshot) => void, ExpectedZodIssue]> = [ + [(balance) => (balance.groups[0]!.rows[0]!.cosPhi = 0), ["groups.0.rows.0.cosPhi", "too_small"]], + [(balance) => (balance.groups[0]!.rows[0]!.simultaneityFactor = 1.5), ["groups.0.rows.0.simultaneityFactor", "too_big"]], + [(balance) => (balance.groups[0]!.rows[0]!.quantity = -1), ["groups.0.rows.0.quantity", "too_small"]], + [(balance) => (balance.groups[0]!.rows[0]!.name = " Leerzeichen "), ["groups.0.rows.0.name", "custom"]], + [(balance) => (balance.groups[0]!.rows[0]!.supply = ""), ["groups.0.rows.0.supply", "custom"]], + [(balance) => (balance.groups[0]!.title = ""), ["groups.0.title", "custom"]], + [(balance) => (balance.dateIso = "30.06.2023"), ["dateIso", "invalid_format"]], + [(balance) => (balance.reserveAvPercent = 101), ["reserveAvPercent", "too_big"]], + [(balance) => (balance.groups[1]!.rows[0]!.id = balance.groups[0]!.rows[0]!.id), ["groups", "custom"]], + [ + (balance) => ((balance.groups[0]!.rows[0] as unknown as Record).extra = 1), + ["groups.0.rows.0", "unrecognized_keys", "extra"], + ], ]; - for (const mutate of invalid) { + for (const [mutate, expectedIssue] of invalid) { const balance = structuredClone(valid); mutate(balance); - assert.throws(() => parseEstimateBalanceSnapshot(balance)); + assert.throws(() => parseEstimateBalanceSnapshot(balance), zodIssues(expectedIssue)); } assert.equal( createEstimateBalanceRequestSchema.safeParse({ diff --git a/tests/project-building-structure.test.ts b/tests/project-building-structure.test.ts index 2c9ccdf..6be875a 100644 --- a/tests/project-building-structure.test.ts +++ b/tests/project-building-structure.test.ts @@ -253,19 +253,23 @@ describe("project building structure", () => { assert.equal(getFloor(context, legacyFloor.id)?.name, "EG"); // A version 1 snapshot must not carry buildingId, a version 2 one must. - assert.throws(() => - assertProjectFloorInsertProjectCommand({ - schemaVersion: 1, - type: "project-floor.insert", - payload: { floor: { ...legacyFloor, buildingId: null } }, - }) + assert.throws( + () => + assertProjectFloorInsertProjectCommand({ + schemaVersion: 1, + type: "project-floor.insert", + payload: { floor: { ...legacyFloor, buildingId: null } }, + }), + /Project-floor snapshot is invalid/ ); - assert.throws(() => - assertProjectFloorInsertProjectCommand({ - schemaVersion: 2, - type: "project-floor.insert", - payload: { floor: legacyFloor }, - }) + assert.throws( + () => + assertProjectFloorInsertProjectCommand({ + schemaVersion: 2, + type: "project-floor.insert", + payload: { floor: legacyFloor }, + }), + /Project-floor snapshot is invalid/ ); } finally { context.close(); diff --git a/tests/project-command.model.test.ts b/tests/project-command.model.test.ts index 15ba857..d6cf082 100644 --- a/tests/project-command.model.test.ts +++ b/tests/project-command.model.test.ts @@ -74,20 +74,22 @@ describe("serialized project commands", () => { }); it("rejects values that JSON would lose or silently change", () => { - const invalidPayloads: unknown[] = [ - { value: undefined }, - { value: Number.NaN }, - { value: Number.POSITIVE_INFINITY }, - { value: new Date() }, + const invalidPayloads: Array<[unknown, RegExp]> = [ + [{ value: undefined }, /must contain JSON values only/], + [{ value: Number.NaN }, /numbers must be finite/], + [{ value: Number.POSITIVE_INFINITY }, /numbers must be finite/], + [{ value: new Date() }, /must contain plain objects only/], ]; - for (const payload of invalidPayloads) { - assert.throws(() => - serializeProjectCommand({ - schemaVersion: 1, - type: "test.invalid", - payload: payload as never, - }) + for (const [payload, expectedError] of invalidPayloads) { + assert.throws( + () => + serializeProjectCommand({ + schemaVersion: 1, + type: "test.invalid", + payload: payload as never, + }), + expectedError ); } }); diff --git a/tests/project-state-snapshot.test.ts b/tests/project-state-snapshot.test.ts index 1ae460f..4d99f8e 100644 --- a/tests/project-state-snapshot.test.ts +++ b/tests/project-state-snapshot.test.ts @@ -10,6 +10,7 @@ import { remapProjectState } from "../src/domain/models/project-transfer.model.j import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js"; import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js"; import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js"; +import { zodIssues } from "./support/zod-assertions.js"; /** Removes the fields introduced by snapshot schema version 9. */ function withoutVersionNineFields< @@ -425,11 +426,20 @@ describe("project state snapshot model", () => { const mislabeled = protectedSnapshot(); mislabeled.schemaVersion = 5 as typeof mislabeled.schemaVersion; - assert.throws(() => parseProjectStateSnapshot(mislabeled)); + assert.throws( + () => parseProjectStateSnapshot(mislabeled), + zodIssues( + ["project", "unrecognized_keys", "workStage"], + ["distributionBoards.0", "unrecognized_keys", "boardNumber"] + ) + ); const missingCover = structuredClone(protectedSnapshot()); delete (missingCover.project as Record).workStage; - assert.throws(() => parseProjectStateSnapshot(missingCover)); + assert.throws( + () => parseProjectStateSnapshot(missingCover), + zodIssues(["project.workStage", "invalid_type"]) + ); }); it("upgrades snapshot version 4 with the existing quantity as manual share", () => { @@ -468,12 +478,57 @@ describe("project state snapshot model", () => { it("rejects malformed baseline and unknown snapshot shapes", () => { const outdated = structuredClone(minimalSnapshot()); delete (outdated.project as Record).description; - assert.throws(() => parseProjectStateSnapshot(outdated)); - assert.throws(() => - parseProjectStateSnapshot({ - ...minimalSnapshot(), - schemaVersion: projectStateSnapshotSchemaVersion + 1, - }) + assert.throws( + () => parseProjectStateSnapshot(outdated), + zodIssues(["project.description", "invalid_type"]) + ); + assert.throws( + () => + parseProjectStateSnapshot({ + ...minimalSnapshot(), + schemaVersion: projectStateSnapshotSchemaVersion + 1, + }), + zodIssues(["schemaVersion", "invalid_value"]) + ); + }); + + it("rejects invalid structure references", () => { + const boardFloor = protectedSnapshot(); + (boardFloor.distributionBoards[0] as { floorId: string | null }).floorId = + "missing"; + assert.throws( + () => parseProjectStateSnapshot(boardFloor), + /distribution board floor reference is invalid/ + ); + + const roomFloor = protectedSnapshot(); + roomFloor.rooms.push({ + id: "room-1", + projectId: "project-1", + floorId: "missing", + roomNumber: "001", + roomName: "Technik", + areaM2: null, + }); + assert.throws( + () => parseProjectStateSnapshot(roomFloor), + /room floor reference is invalid/ + ); + + const circuitSection = protectedSnapshot(); + circuitSection.circuits[0]!.sectionId = "missing"; + assert.throws( + () => parseProjectStateSnapshot(circuitSection), + /circuit section reference is invalid/ + ); + }); + + it("rejects circuits whose reserve state contradicts their device rows", () => { + const withoutRows = protectedSnapshot(); + withoutRows.circuits[0]!.isReserve = false; + assert.throws( + () => parseProjectStateSnapshot(withoutRows), + /reserve state must match its device rows/ ); }); @@ -580,7 +635,10 @@ describe("project state snapshot model", () => { const floorWithoutBuildingField = structuredClone(snapshot); delete (floorWithoutBuildingField.floors[1] as Record) .buildingId; - assert.throws(() => parseProjectStateSnapshot(floorWithoutBuildingField)); + assert.throws( + () => parseProjectStateSnapshot(floorWithoutBuildingField), + zodIssues(["floors.1.buildingId", "invalid_type"]) + ); }); it("keeps room areas and upgrades snapshot version 7 with an empty area", () => { @@ -633,7 +691,13 @@ describe("project state snapshot model", () => { const mislabeled = structuredClone(snapshot); mislabeled.schemaVersion = 6 as typeof mislabeled.schemaVersion; - assert.throws(() => parseProjectStateSnapshot(mislabeled)); + assert.throws( + () => parseProjectStateSnapshot(mislabeled), + zodIssues( + ["", "unrecognized_keys", "buildings"], + ["floors.0", "unrecognized_keys", "buildingId"] + ) + ); }); }); @@ -654,18 +718,26 @@ describe("named project snapshot request", () => { }); it("rejects stale-shaped and excessive input", () => { - assert.throws(() => - createNamedProjectSnapshotSchema.parse({ - expectedRevision: -1, - name: "", - unexpected: true, - }) + assert.throws( + () => + createNamedProjectSnapshotSchema.parse({ + expectedRevision: -1, + name: "", + unexpected: true, + }), + zodIssues( + ["expectedRevision", "too_small"], + ["name", "too_small"], + ["", "unrecognized_keys", "unexpected"] + ) ); - assert.throws(() => - createNamedProjectSnapshotSchema.parse({ - expectedRevision: 0, - name: "x".repeat(201), - }) + assert.throws( + () => + createNamedProjectSnapshotSchema.parse({ + expectedRevision: 0, + name: "x".repeat(201), + }), + zodIssues(["name", "too_big"]) ); }); }); diff --git a/tests/support/zod-assertions.ts b/tests/support/zod-assertions.ts new file mode 100644 index 0000000..e925d5a --- /dev/null +++ b/tests/support/zod-assertions.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import { ZodError } from "zod"; + +/** One expected Zod issue: dotted path ("" for the root), issue code and, for unrecognized keys, the key. */ +export type ExpectedZodIssue = [path: string, code: string, key?: string]; + +/** + * Validation function for `assert.throws` that requires a ZodError reporting + * every expected issue. Any other error, such as a TypeError from a broken + * fixture, fails the assertion instead of passing as "some error". + */ +export function zodIssues(...expected: ExpectedZodIssue[]) { + return (error: unknown) => { + assert.ok(error instanceof ZodError, `expected ZodError, got ${String(error)}`); + for (const [path, code, key] of expected) { + assert.ok( + error.issues.some( + (issue) => + issue.path.join(".") === path && + issue.code === code && + (key === undefined || ("keys" in issue && issue.keys.includes(key))) + ), + `missing ${code}${key ? ` (${key})` : ""} at "${path}": ${error.message}` + ); + } + return true; + }; +}