mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
163 lines
5.6 KiB
TypeScript
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`,
|
|
);
|
|
}
|