/** * 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 ``. 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(); 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 ``; if (typeof node === "string") { if (GENERATED_ID.test(node)) { let label = ids.get(node); if (!label) { label = ``; ids.set(node, label); } return label; } if (ISO_INSTANT.test(node)) { const t = Date.parse(node); if (t >= since && t <= until) return ""; } 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 = {}; 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 { await expect(`${JSON.stringify(value, null, 2)}\n`).toMatchFileSnapshot( `./__snapshots__/${name}.json.snap`, ); }