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:
parent
cc4eefcb86
commit
a8cc7eca5e
8 changed files with 269 additions and 122 deletions
|
|
@ -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(); }
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
28
tests/support/zod-assertions.ts
Normal file
28
tests/support/zod-assertions.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue