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 <noreply@anthropic.com>
This commit is contained in:
Julian Appel 2026-10-29 12:27:46 +01:00
parent cc4eefcb86
commit a8cc7eca5e
8 changed files with 269 additions and 122 deletions

View file

@ -13,7 +13,10 @@ describe("batched SQLite inserts", () => {
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
const values = Array.from({ length: 401 }, (_, index) => ({ id: `project-${index}`, name: `Projekt ${index}` })); const values = Array.from({ length: 401 }, (_, index) => ({ id: `project-${index}`, name: `Projekt ${index}` }));
values[400] = { id: "project-0", name: "Duplikat" }; 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); assert.equal(context.db.select().from(projects).all().length, 0);
} finally { context.close(); } } finally { context.close(); }
}); });

View file

@ -410,25 +410,39 @@ describe("circuit device-row structure project-command repository", () => {
try { try {
const store = const store =
new CircuitDeviceRowStructureProjectCommandRepository(context.db); new CircuitDeviceRowStructureProjectCommandRepository(context.db);
for (const snapshot of [ for (const [snapshot, expectedError] of [
createInsertSnapshot({ circuitId: "circuit-foreign" }), [
createInsertSnapshot({ createInsertSnapshot({ circuitId: "circuit-foreign" }),
id: "row-foreign-device", /Circuit does not belong to project/,
linkedProjectDeviceId: "device-foreign", ],
}), [
createInsertSnapshot({ createInsertSnapshot({
id: "row-foreign-room", id: "row-foreign-device",
roomId: "room-foreign", linkedProjectDeviceId: "device-foreign",
}), }),
createInsertSnapshot({ id: "row-1" }), /Invalid linked project device id/,
]) { ],
assert.throws(() => [
store.execute({ createInsertSnapshot({
projectId: "project-1", id: "row-foreign-room",
expectedRevision: 0, roomId: "room-foreign",
source: "user", }),
command: createCircuitDeviceRowInsertProjectCommand(snapshot), /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); assert.equal(context.db.select().from(projectRevisions).all().length, 0);

View file

@ -568,51 +568,71 @@ describe("circuit structure project-command repository", () => {
const store = new CircuitStructureProjectCommandRepository( const store = new CircuitStructureProjectCommandRepository(
fixture.context.db fixture.context.db
); );
const invalidSnapshots = [ const invalidSnapshots: Array<[CircuitSnapshot, RegExp]> = [
createCircuitSnapshot(fixture, { [
id: "foreign-list", createCircuitSnapshot(fixture, {
circuitListId: fixture.foreignCircuitListId, id: "foreign-list",
sectionId: fixture.foreignSectionId, circuitListId: fixture.foreignCircuitListId,
}), sectionId: fixture.foreignSectionId,
createCircuitSnapshot(fixture, { }),
id: "foreign-section", /Circuit list does not belong to project/,
sectionId: fixture.foreignSectionId, ],
}), [
createCircuitSnapshot(fixture, { createCircuitSnapshot(fixture, {
id: "duplicate-bmk", id: "foreign-section",
equipmentIdentifier: "-1F1", sectionId: fixture.foreignSectionId,
}), }),
createCircuitSnapshot(fixture, { /Section does not belong to circuit list/,
id: "duplicate-row", ],
deviceRows: [ [
createRow("duplicate-row", "row-existing-1", 10), createCircuitSnapshot(fixture, {
], id: "duplicate-bmk",
}), equipmentIdentifier: "-1F1",
createCircuitSnapshot(fixture, { }),
id: "foreign-device", /Duplicate equipmentIdentifier in circuit list/,
deviceRows: [ ],
createRow("foreign-device", "row-foreign-device", 10, { [
linkedProjectDeviceId: "device-foreign", createCircuitSnapshot(fixture, {
}), id: "duplicate-row",
], deviceRows: [
}), createRow("duplicate-row", "row-existing-1", 10),
createCircuitSnapshot(fixture, { ],
id: "foreign-room", }),
deviceRows: [ /Circuit device-row id already exists/,
createRow("foreign-room", "row-foreign-room", 10, { ],
roomId: "room-foreign", [
}), 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) { for (const [snapshot, expectedError] of invalidSnapshots) {
assert.throws(() => assert.throws(
store.execute({ () =>
projectId: "project-1", store.execute({
expectedRevision: 0, projectId: "project-1",
source: "user", expectedRevision: 0,
command: createCircuitInsertProjectCommand(snapshot), source: "user",
}) command: createCircuitInsertProjectCommand(snapshot),
}),
expectedError
); );
} }
assert.equal( assert.equal(

View file

@ -35,6 +35,7 @@ import {
type EstimateBalanceSnapshot, type EstimateBalanceSnapshot,
type EstimateRowSnapshot, type EstimateRowSnapshot,
} from "../src/shared/estimate-balance/estimate-balance.js"; } from "../src/shared/estimate-balance/estimate-balance.js";
import { zodIssues, type ExpectedZodIssue } from "./support/zod-assertions.js";
const round2 = (value: number) => roundEstimateValue(value, 2); const round2 = (value: number) => roundEstimateValue(value, 2);
let nextId = 0; let nextId = 0;
@ -236,22 +237,25 @@ describe("estimate balance snapshots and commands", () => {
it("validates snapshots strictly", () => { it("validates snapshots strictly", () => {
const valid = templateExample(); const valid = templateExample();
assert.equal(parseEstimateBalanceSnapshot(valid).groups[4]!.rows[3]!.sortOrder, 3); assert.equal(parseEstimateBalanceSnapshot(valid).groups[4]!.rows[3]!.sortOrder, 3);
const invalid: Array<(balance: EstimateBalanceSnapshot) => void> = [ const invalid: Array<[(balance: EstimateBalanceSnapshot) => void, ExpectedZodIssue]> = [
(balance) => (balance.groups[0]!.rows[0]!.cosPhi = 0), [(balance) => (balance.groups[0]!.rows[0]!.cosPhi = 0), ["groups.0.rows.0.cosPhi", "too_small"]],
(balance) => (balance.groups[0]!.rows[0]!.simultaneityFactor = 1.5), [(balance) => (balance.groups[0]!.rows[0]!.simultaneityFactor = 1.5), ["groups.0.rows.0.simultaneityFactor", "too_big"]],
(balance) => (balance.groups[0]!.rows[0]!.quantity = -1), [(balance) => (balance.groups[0]!.rows[0]!.quantity = -1), ["groups.0.rows.0.quantity", "too_small"]],
(balance) => (balance.groups[0]!.rows[0]!.name = " Leerzeichen "), [(balance) => (balance.groups[0]!.rows[0]!.name = " Leerzeichen "), ["groups.0.rows.0.name", "custom"]],
(balance) => (balance.groups[0]!.rows[0]!.supply = ""), [(balance) => (balance.groups[0]!.rows[0]!.supply = ""), ["groups.0.rows.0.supply", "custom"]],
(balance) => (balance.groups[0]!.title = ""), [(balance) => (balance.groups[0]!.title = ""), ["groups.0.title", "custom"]],
(balance) => (balance.dateIso = "30.06.2023"), [(balance) => (balance.dateIso = "30.06.2023"), ["dateIso", "invalid_format"]],
(balance) => (balance.reserveAvPercent = 101), [(balance) => (balance.reserveAvPercent = 101), ["reserveAvPercent", "too_big"]],
(balance) => (balance.groups[1]!.rows[0]!.id = balance.groups[0]!.rows[0]!.id), [(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<string, unknown>).extra = 1), [
(balance) => ((balance.groups[0]!.rows[0] as unknown as Record<string, unknown>).extra = 1),
["groups.0.rows.0", "unrecognized_keys", "extra"],
],
]; ];
for (const mutate of invalid) { for (const [mutate, expectedIssue] of invalid) {
const balance = structuredClone(valid); const balance = structuredClone(valid);
mutate(balance); mutate(balance);
assert.throws(() => parseEstimateBalanceSnapshot(balance)); assert.throws(() => parseEstimateBalanceSnapshot(balance), zodIssues(expectedIssue));
} }
assert.equal( assert.equal(
createEstimateBalanceRequestSchema.safeParse({ createEstimateBalanceRequestSchema.safeParse({

View file

@ -253,19 +253,23 @@ describe("project building structure", () => {
assert.equal(getFloor(context, legacyFloor.id)?.name, "EG"); assert.equal(getFloor(context, legacyFloor.id)?.name, "EG");
// A version 1 snapshot must not carry buildingId, a version 2 one must. // A version 1 snapshot must not carry buildingId, a version 2 one must.
assert.throws(() => assert.throws(
assertProjectFloorInsertProjectCommand({ () =>
schemaVersion: 1, assertProjectFloorInsertProjectCommand({
type: "project-floor.insert", schemaVersion: 1,
payload: { floor: { ...legacyFloor, buildingId: null } }, type: "project-floor.insert",
}) payload: { floor: { ...legacyFloor, buildingId: null } },
}),
/Project-floor snapshot is invalid/
); );
assert.throws(() => assert.throws(
assertProjectFloorInsertProjectCommand({ () =>
schemaVersion: 2, assertProjectFloorInsertProjectCommand({
type: "project-floor.insert", schemaVersion: 2,
payload: { floor: legacyFloor }, type: "project-floor.insert",
}) payload: { floor: legacyFloor },
}),
/Project-floor snapshot is invalid/
); );
} finally { } finally {
context.close(); context.close();

View file

@ -74,20 +74,22 @@ describe("serialized project commands", () => {
}); });
it("rejects values that JSON would lose or silently change", () => { it("rejects values that JSON would lose or silently change", () => {
const invalidPayloads: unknown[] = [ const invalidPayloads: Array<[unknown, RegExp]> = [
{ value: undefined }, [{ value: undefined }, /must contain JSON values only/],
{ value: Number.NaN }, [{ value: Number.NaN }, /numbers must be finite/],
{ value: Number.POSITIVE_INFINITY }, [{ value: Number.POSITIVE_INFINITY }, /numbers must be finite/],
{ value: new Date() }, [{ value: new Date() }, /must contain plain objects only/],
]; ];
for (const payload of invalidPayloads) { for (const [payload, expectedError] of invalidPayloads) {
assert.throws(() => assert.throws(
serializeProjectCommand({ () =>
schemaVersion: 1, serializeProjectCommand({
type: "test.invalid", schemaVersion: 1,
payload: payload as never, type: "test.invalid",
}) payload: payload as never,
}),
expectedError
); );
} }
}); });

View file

@ -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 { 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";
import { zodIssues } from "./support/zod-assertions.js";
/** Removes the fields introduced by snapshot schema version 9. */ /** Removes the fields introduced by snapshot schema version 9. */
function withoutVersionNineFields< function withoutVersionNineFields<
@ -425,11 +426,20 @@ describe("project state snapshot model", () => {
const mislabeled = protectedSnapshot(); const mislabeled = protectedSnapshot();
mislabeled.schemaVersion = 5 as typeof mislabeled.schemaVersion; 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()); const missingCover = structuredClone(protectedSnapshot());
delete (missingCover.project as Record<string, unknown>).workStage; delete (missingCover.project as Record<string, unknown>).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", () => { 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", () => { it("rejects malformed baseline and unknown snapshot shapes", () => {
const outdated = structuredClone(minimalSnapshot()); const outdated = structuredClone(minimalSnapshot());
delete (outdated.project as Record<string, unknown>).description; delete (outdated.project as Record<string, unknown>).description;
assert.throws(() => parseProjectStateSnapshot(outdated)); assert.throws(
assert.throws(() => () => parseProjectStateSnapshot(outdated),
parseProjectStateSnapshot({ zodIssues(["project.description", "invalid_type"])
...minimalSnapshot(), );
schemaVersion: projectStateSnapshotSchemaVersion + 1, 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); const floorWithoutBuildingField = structuredClone(snapshot);
delete (floorWithoutBuildingField.floors[1] as Record<string, unknown>) delete (floorWithoutBuildingField.floors[1] as Record<string, unknown>)
.buildingId; .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", () => { 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); const mislabeled = structuredClone(snapshot);
mislabeled.schemaVersion = 6 as typeof mislabeled.schemaVersion; 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", () => { it("rejects stale-shaped and excessive input", () => {
assert.throws(() => assert.throws(
createNamedProjectSnapshotSchema.parse({ () =>
expectedRevision: -1, createNamedProjectSnapshotSchema.parse({
name: "", expectedRevision: -1,
unexpected: true, name: "",
}) unexpected: true,
}),
zodIssues(
["expectedRevision", "too_small"],
["name", "too_small"],
["", "unrecognized_keys", "unexpected"]
)
); );
assert.throws(() => assert.throws(
createNamedProjectSnapshotSchema.parse({ () =>
expectedRevision: 0, createNamedProjectSnapshotSchema.parse({
name: "x".repeat(201), expectedRevision: 0,
}) name: "x".repeat(201),
}),
zodIssues(["name", "too_big"])
); );
}); });
}); });

View file

@ -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;
};
}