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") });
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(); }
});

View file

@ -410,25 +410,39 @@ describe("circuit device-row structure project-command repository", () => {
try {
const store =
new CircuitDeviceRowStructureProjectCommandRepository(context.db);
for (const snapshot of [
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" }),
]) {
assert.throws(() =>
/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);

View file

@ -568,26 +568,39 @@ describe("circuit structure project-command repository", () => {
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
const invalidSnapshots = [
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: [
@ -596,6 +609,9 @@ describe("circuit structure project-command repository", () => {
}),
],
}),
/Invalid linked project device id/,
],
[
createCircuitSnapshot(fixture, {
id: "foreign-room",
deviceRows: [
@ -604,15 +620,19 @@ describe("circuit structure project-command repository", () => {
}),
],
}),
/Invalid room id/,
],
];
for (const snapshot of invalidSnapshots) {
assert.throws(() =>
for (const [snapshot, expectedError] of invalidSnapshots) {
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitInsertProjectCommand(snapshot),
})
}),
expectedError
);
}
assert.equal(

View file

@ -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),
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<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);
mutate(balance);
assert.throws(() => parseEstimateBalanceSnapshot(balance));
assert.throws(() => parseEstimateBalanceSnapshot(balance), zodIssues(expectedIssue));
}
assert.equal(
createEstimateBalanceRequestSchema.safeParse({

View file

@ -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(() =>
assert.throws(
() =>
assertProjectFloorInsertProjectCommand({
schemaVersion: 1,
type: "project-floor.insert",
payload: { floor: { ...legacyFloor, buildingId: null } },
})
}),
/Project-floor snapshot is invalid/
);
assert.throws(() =>
assert.throws(
() =>
assertProjectFloorInsertProjectCommand({
schemaVersion: 2,
type: "project-floor.insert",
payload: { floor: legacyFloor },
})
}),
/Project-floor snapshot is invalid/
);
} finally {
context.close();

View file

@ -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(() =>
for (const [payload, expectedError] of invalidPayloads) {
assert.throws(
() =>
serializeProjectCommand({
schemaVersion: 1,
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 { 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<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", () => {
@ -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<string, unknown>).description;
assert.throws(() => parseProjectStateSnapshot(outdated));
assert.throws(() =>
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<string, unknown>)
.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(() =>
assert.throws(
() =>
createNamedProjectSnapshotSchema.parse({
expectedRevision: -1,
name: "",
unexpected: true,
})
}),
zodIssues(
["expectedRevision", "too_small"],
["name", "too_small"],
["", "unrecognized_keys", "unexpected"]
)
);
assert.throws(() =>
assert.throws(
() =>
createNamedProjectSnapshotSchema.parse({
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;
};
}