Files
HealthLog/tests/kit/parity.ts
T
Marc-André Bombeck 8d48733c6a test(measurements): pin the measurement list and create before moving them into services
A parity walk over GET and POST /api/measurements: every create arm (single, combined, named sleep stage) with its duplicate and refusal, a bridge token, a delegated write, and every list mode. Sealed bytes are recorded by length, since each write carries a fresh nonce.
2026-10-23 11:47:09 +02:00

163 lines
5.6 KiB
TypeScript

/**
* Response parity for a route whose code moves: record what the route
* answers, stores and reports before the move, and compare after it.
*
* A parity test drives a route through `callRoute` across the cases it
* distinguishes, and pins three things per case in a committed snapshot
* under `tests/integration/routes/parity/__snapshots__/`:
*
* - the response (status, envelope);
* - what the request left in the database, read back by the test;
* - the wide event the request emitted: its action and meta, which is what
* the operator dashboards read.
*
* The snapshot is written on the code before the move, committed on its own,
* and must come out byte-identical after it. Values that differ between two
* runs of the same code (generated ids, server timestamps, request ids,
* durations) are replaced by stable placeholders first, so the comparison
* sees only what the code decides. Ids are numbered by first appearance,
* so two references to the same row still read as the same placeholder.
*/
import { expect } from "vitest";
import type { WideEvent } from "@/lib/logging/types";
/** Generated identifiers: Prisma `cuid()` values and UUIDs. */
const GENERATED_ID =
/^(c[a-z0-9]{24}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?Z$/;
/**
* Keys whose values the clock or a random source decides on every run. Their
* presence is kept, their value is not.
*/
const VOLATILE_KEYS = new Set([
"requestId",
"request_id",
"createdAt",
"updatedAt",
"syncVersion",
"duration_ms",
"durationMs",
"timestamp",
"lastUsedAt",
]);
export interface StableOptions {
/**
* Instants inside `[since, until]` are server clock readings (a row's
* `createdAt`, a stamp the route wrote) and read as `<now>`. Instants the
* test sent, which lie outside that window, are kept as they are.
*/
since: Date;
until?: Date;
/** Further keys to blank, for a route with a volatile field of its own. */
volatileKeys?: readonly string[];
}
/** A copy of `value` with the run-dependent parts replaced. */
export function stable(value: unknown, options: StableOptions): unknown {
const ids = new Map<string, string>();
const until = (options.until ?? new Date(Date.now() + 60_000)).getTime();
const since = options.since.getTime();
const volatile = new Set([...VOLATILE_KEYS, ...(options.volatileKeys ?? [])]);
const walk = (node: unknown, key: string | null): unknown => {
if (
key !== null &&
volatile.has(key) &&
node !== null &&
node !== undefined
) {
return `<${key}>`;
}
if (node instanceof Date) node = node.toISOString();
// Sealed columns carry a fresh nonce on every write: keep the length.
if (node instanceof Uint8Array) return `<bytes:${node.length}>`;
if (typeof node === "string") {
if (GENERATED_ID.test(node)) {
let label = ids.get(node);
if (!label) {
label = `<id:${ids.size + 1}>`;
ids.set(node, label);
}
return label;
}
if (ISO_INSTANT.test(node)) {
const t = Date.parse(node);
if (t >= since && t <= until) return "<now>";
}
return node;
}
if (typeof node === "bigint") return `${node}n`;
if (Array.isArray(node)) return node.map((item) => walk(item, null));
if (node && typeof node === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(node)) out[k] = walk(v, k);
return out;
}
return node;
};
return walk(value, null);
}
/**
* The wide events requests emitted, collected in order. Install with
* `vi.mock("@/lib/logging/transports", wideEventRecorder.mockFactory)` in
* the test file, then read `wideEventRecorder.take()` after each call.
*/
export const wideEventRecorder = {
events: [] as WideEvent[],
/** The events since the last call, emptied. */
take(): WideEvent[] {
return this.events.splice(0, this.events.length);
},
};
/**
* Calls the route handed to a queue or another fire-and-forget tail (a job
* enqueue, a data-arrival emit), recorded in order with their arguments. A
* parity test replaces those collaborators with recorders: the snapshot then
* shows what the route asked for, and the request's wide event no longer
* depends on whether a tail it did not await happened to finish first.
*/
export const effectRecorder = {
calls: [] as Array<{ effect: string; args: unknown[] }>,
record(effect: string, args: unknown[]): void {
this.calls.push({ effect, args });
},
/** The calls since the last take, emptied. */
take(): Array<{ effect: string; args: unknown[] }> {
return this.calls.splice(0, this.calls.length);
},
};
/** What of a wide event a parity snapshot keeps: what the code decided. */
export function eventFacts(event: WideEvent): unknown {
return {
status: event.http?.status ?? null,
action: event.action ?? null,
meta: event.meta ?? null,
warnings: event.warnings ?? null,
error: event.error
? { type: event.error.type, message: event.error.message }
: null,
};
}
/**
* Compare `value` with the committed snapshot `name`, a file under
* `__snapshots__/` beside the calling test (parity tests live in
* `tests/integration/routes/parity/`). A missing snapshot is written outside
* CI (the recording run); in CI a missing one fails, and a present one must
* match everywhere.
*/
export async function expectParity(
name: string,
value: unknown,
): Promise<void> {
await expect(`${JSON.stringify(value, null, 2)}\n`).toMatchFileSnapshot(
`./__snapshots__/${name}.json.snap`,
);
}