Files
HealthLog/tests/integration/openapi-get-contract.test.ts
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
2026-11-03 20:32:35 +01:00

538 lines
19 KiB
TypeScript

/**
* Every published GET, called for real and read against the schema that
* publishes it.
*
* `openapi-response-shape.test.ts` asked this question of two responses, the
* two that had already been caught drifting. Everything else in the gate
* proves one end at a time: `openapi:check` holds the YAML to the registry,
* the route-coverage guard holds the registry to the route files, and neither
* ever looks at a body. A schema that disagrees with what its handler sends
* passes all of them, and the native client — which decodes against the
* contract, not against the handler — is the first thing to notice.
*
* So the operation list here is derived from the registry, not written down:
* every path with a `get` whose 2xx response publishes an `application/json`
* schema. Each is called through its route module's own `GET` export as one
* seeded account, and the body is parsed with the published schema. Two
* failures count:
*
* - the parse fails: a required field is missing or has the wrong type;
* - the parse drops something: Zod strips undeclared keys rather than
* refusing them, so the parsed value is compared back to the raw body and
* any key that did not survive is a field the server sends and the
* contract does not declare. Loose objects (`meta` on the error envelope,
* a few deliberately open maps) keep unknown keys by design, so this half
* does not reach inside them.
*
* An operation that cannot be called meaningfully in-process sits in
* {@link NOT_CALLED} with its reason. The list is the exception, not the
* selection: a new GET is checked the day it is registered, and the floor at
* the bottom fails if the checked set ever shrinks to something that proves
* little.
*/
import { NextRequest } from "next/server";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { beforeAll, describe, expect, it, vi } from "vitest";
import type { z } from "zod";
import { assertRecordContext, cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { seedContractAccount, type ContractFixture } from "./contract-seed";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
// The health route reports the worker it shares the process with. There is
// none in a test process, which is the published 503; a running one is the
// 200 the schema describes.
vi.mock("@/lib/jobs/worker-status", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/jobs/worker-status")>();
return {
...actual,
getWorkerStatus: () => ({
...actual.getWorkerStatus(),
running: true,
lastHeartbeat: new Date().toISOString(),
}),
};
});
// No request leaves the process. An operation that needs the network to
// answer belongs in NOT_CALLED with that reason; one that merely tries and
// copes with the failure is checked on the path it takes when it cannot.
vi.mock("@/lib/safe-fetch", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/safe-fetch")>();
return {
...actual,
safeFetch: vi.fn(async () => {
throw new actual.SafeFetchError("offline in the contract run", "network");
}),
};
});
/**
* Operations that are published and not called, each with the reason.
*
* A reason has to be about the operation, not about the fixture: "needs a
* seeded X" is a fixture to write, not an exception to take.
*/
const NOT_CALLED: Record<string, string> = {
"/api/insights/generate":
"Generates a briefing with the account's AI provider on the request path; with no provider it answers the published 422, and with one it would call the model.",
"/api/environment/geocode":
"A pass-through to the operator's geocoding service; the body is the service's answer.",
"/api/dawarich/places":
"Reads places from the account's own Dawarich instance; unconnected it answers the published 409.",
"/api/documents/sources/{system}/tags":
"Reads tags from the operator's document archive; with none configured it answers the published 404.",
"/api/documents/sources/{system}/search":
"Searches the operator's document archive; with none configured it answers the published 404.",
};
/** The least number of operations that must actually be called and parsed. */
const CHECKED_FLOOR = 235;
/**
* Operations whose every list may come back empty here, with the reason. An
* empty list parses against any item schema, so every other operation must
* answer at least once with a list that has a row in it; the seed
* (`contract-seed.ts`) writes one for each. As for `NOT_CALLED`, a reason is
* about the operation: a list a fixture could fill is a row to seed.
*/
const EMPTY_ALLOWED: Record<string, string> = {
"/api/auth/me/mfa":
"The list is the account's security keys, and an enrolled second factor turns the export and backup reads this run also calls into a re-proof a seeded session cannot give.",
"/api/auth/passkeys":
"The same: a passkey on the account puts the export and backup reads behind a re-proof.",
"/api/coach/about-me/questions":
"The questions are written by the account's AI provider; the run has none.",
"/api/insights/medication-compliance-status":
"The per-medication summaries are written by the account's AI provider; with none the published answer is the empty list beside `hasProvider: false`.",
"/api/insights/chat/{id}/messages/{messageId}/results":
"Result tables are stored by a model turn that called tools, in its own sealed format; the run has no provider.",
"/api/cycle/insights":
"A phase row only survives a false-discovery-rate test over many cycles of readings, which no handful of rows passes.",
"/api/insights/health-status":
"A deviation or shift is reported only once a reading leaves the account's own long-run baseline, which needs months of history.",
"/api/insights/glp1-timeline":
"Entries exist only for a GLP-1 medication, whose dose phases would change what the main medication's reads answer.",
"/api/documents/sources":
"Lists connections to the operator's document archive, which is environment configuration; the run configures none.",
"/api/environment/travel/suggestions":
"Suggestions come from the account's location history in an external Dawarich instance the run does not reach.",
"/api/mcp/connections":
"A connection is minted only by the OAuth authorization flow of a registered agent client.",
};
/** The least number of operations that answer with a list that has a row. */
const FILLED_FLOOR = 228;
/**
* The queries each operation is called with. An operation with a required
* parameter needs an entry; an operation whose optional parameters select a
* different body shape gets one variant per shape, because each is a separate
* promise the schema makes.
*/
function queriesFor(
path: string,
fx: ContractFixture,
): Array<Record<string, string>> {
const from = new Date(Date.now() - 60 * 86_400_000).toISOString();
const to = new Date().toISOString();
const day = (n: number) =>
new Date(Date.now() - n * 86_400_000).toISOString().slice(0, 10);
switch (path) {
case "/api/cycle/day-logs":
return [{ date: day(3) }];
case "/api/measurements":
return [{}, { groupBy: "day", type: "WEIGHT" }, { dayKey: day(2) }];
case "/api/measurements/series-batch":
return [{ types: "WEIGHT,PULSE,ACTIVITY_STEPS", from, to }];
case "/api/measurements/series":
return [
{ kind: "weight" },
{ kind: "bloodPressure" },
{ kind: "sleep" },
{ kind: "glucose" },
];
case "/api/medications/intake":
return [{ scope: "today" }, { scope: "compliance" }];
case "/api/insights/coach-read":
return [{ metric: "WEIGHT" }];
case "/api/insights/narrative":
return [{ period: "week" }];
case "/api/analytics/range":
return [{ type: "WEIGHT", range: "30d" }];
case "/api/insights/metric-status":
return [{ metric: "RESTING_HEART_RATE" }];
case "/api/insights/biomarker-assessment":
return [{ biomarkerId: fx.id("biomarker") }];
case "/api/insights/derived":
return [
{ metric: "BMI" },
{ metric: "VITALS_BASELINE", type: "RESTING_HEART_RATE" },
{ metric: "HEALTH_SCORE" },
];
case "/api/insights/derived/batch":
return [{ metrics: "BMI,HRV_BALANCE,VITALS_BASELINE:PULSE" }];
case "/api/insights/score-history":
return [{ score: "HEALTH_SCORE", days: "30" }];
case "/api/mood/linked-context":
return [{ date: day(2) }];
case "/api/encounters/suggest":
case "/api/vaccinations/suggest":
return [{ anchor: new Date(Date.now() - 30 * 86_400_000).toISOString() }];
case "/api/nutrients/daily":
return [{ nutrient: "vitamin_d" }];
case "/api/day/index":
return [{ from: day(14), to: day(0) }];
case "/api/measurements/provenance":
return [
{ type: "ACTIVITY_STEPS", date: day(2) },
{ type: "WEIGHT", date: day(2) },
];
case "/api/measurements/provenance/compare":
return [
{ type: "ACTIVITY_STEPS", from: day(14), to: day(0) },
{
type: "ACTIVITY_STEPS",
from: day(14),
to: day(0),
a: "APPLE_HEALTH",
b: "MANUAL",
},
];
case "/api/documents/inbound/source":
return [{ sourceSystem: "PAPERLESS", sourceId: "4711" }];
case "/api/documents/sources/{system}/search":
return [{ q: "letter" }];
default:
return [{}];
}
}
/**
* Operations served to a guardian inside a managed profile rather than to an
* account on its own record. They are called with the session switched into
* the seeded managed profile.
*/
const AS_GUARDIAN = new Set([
"/api/record-settings",
"/api/record-settings/integrations",
"/api/record-settings/{family}",
]);
interface PublishedGet {
path: string;
statuses: Map<number, z.ZodType>;
requiredQuery: string[];
}
type Operation = {
responses?: Record<
string,
{ content?: Record<string, { schema?: z.ZodType }> }
>;
requestParams?: { query?: z.ZodType };
};
function requiredQueryKeys(schema: z.ZodType | undefined): string[] {
if (!schema) return [];
const shape = (schema as unknown as { shape?: Record<string, z.ZodType> })
.shape;
if (!shape) return [];
return Object.entries(shape)
.filter(([, field]) => !field.safeParse(undefined).success)
.map(([key]) => key);
}
async function publishedGets(): Promise<PublishedGet[]> {
const { openApiPaths } = await import("@/lib/openapi/routes");
const out: PublishedGet[] = [];
for (const [path, item] of Object.entries(
openApiPaths as Record<string, { get?: Operation }>,
)) {
const op = item.get;
if (!op?.responses) continue;
const statuses = new Map<number, z.ZodType>();
for (const [code, response] of Object.entries(op.responses)) {
const status = Number(code);
if (!(status >= 200 && status < 300)) continue;
const schema = response.content?.["application/json"]?.schema;
if (schema) statuses.set(status, schema);
}
if (statuses.size === 0) continue;
out.push({
path,
statuses,
requiredQuery: requiredQueryKeys(op.requestParams?.query),
});
}
return out;
}
function routeFile(path: string): string {
return join(
process.cwd(),
"src/app",
path.replace(/\{([^}]+)\}/g, "[$1]"),
"route.ts",
);
}
/**
* Every place the parsed value differs from the raw body: a key the parse
* dropped (undeclared on the wire) or a value it rewrote.
*/
function strippedOrRewritten(raw: unknown, parsed: unknown, at = ""): string[] {
if (raw === null || typeof raw !== "object") {
return Object.is(raw, parsed) ? [] : [`${at || "."}: rewritten`];
}
if (Array.isArray(raw)) {
if (!Array.isArray(parsed)) return [`${at || "."}: rewritten`];
const out: string[] = [];
// One element of each array is enough to name the field; reporting the
// same undeclared key a thousand times buries the next finding.
const seen = new Set<string>();
raw.forEach((item, i) => {
for (const finding of strippedOrRewritten(item, parsed[i], `${at}[]`)) {
if (!seen.has(finding)) {
seen.add(finding);
out.push(finding);
}
}
});
return out;
}
if (parsed === null || typeof parsed !== "object") {
return [`${at || "."}: rewritten`];
}
const out: string[] = [];
const p = parsed as Record<string, unknown>;
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (!(key in p)) {
out.push(`${at}.${key}: undeclared`);
continue;
}
out.push(...strippedOrRewritten(value, p[key], `${at}.${key}`));
}
return out;
}
interface Finding {
path: string;
problem: string;
}
let fixture: ContractFixture;
beforeAll(async () => {
// Web Push configured, so the VAPID key route answers with a key.
const { generateVAPIDKeys } = await import("web-push");
const keys = generateVAPIDKeys();
vi.stubEnv("VAPID_PUBLIC_KEY", keys.publicKey);
vi.stubEnv("VAPID_PRIVATE_KEY", keys.privateKey);
vi.stubEnv("VAPID_SUBJECT", "mailto:operator@example.test");
const prisma = getPrismaClient();
const { setGlobalBoss } = await import("@/lib/jobs/boss-instance");
// A producer is present, as in a running web process; nothing is sent.
setGlobalBoss({ send: async () => null } as never);
await truncateAllTables(prisma);
cookieJar.clear();
headerJar.clear();
fixture = await seedContractAccount(prisma);
}, 300_000);
/** Put the cookie (and the record assertion) in place for one call. */
function enter(path: string): void {
headerJar.clear();
if (AS_GUARDIAN.has(path)) {
cookieJar.set("healthlog_session", fixture.guardianSessionId);
assertRecordContext(fixture.guardianRecordEpoch, fixture.managedProfileId);
} else {
cookieJar.set("healthlog_session", fixture.sessionId);
}
}
/** Whether a body carries arrays and every one of them is empty. */
function onlyEmptyArrays(value: unknown): boolean {
let arrays = 0;
let filled = 0;
const walk = (v: unknown) => {
if (Array.isArray(v)) {
arrays += 1;
if (v.length > 0) filled += 1;
v.forEach(walk);
} else if (v && typeof v === "object") {
Object.values(v).forEach(walk);
}
};
walk(value);
return arrays > 0 && filled === 0;
}
const emptyBodies: string[] = [];
/** Operations that answered at least once with a row in a list (or no list). */
const filled = new Set<string>();
/** Call one operation once and report what disagrees with its schema. */
async function check(
op: PublishedGet,
query: Record<string, string>,
): Promise<{ called: boolean; findings: string[] }> {
const params = fixture.pathParams(op.path);
if (params === null) {
return { called: false, findings: ["no fixture for path params"] };
}
const missing = op.requiredQuery.filter((key) => !(key in query));
if (missing.length > 0) {
return {
called: false,
findings: [`no fixture for required query ${missing.join(", ")}`],
};
}
let concrete = op.path;
for (const [name, value] of Object.entries(params)) {
concrete = concrete.replace(`{${name}}`, encodeURIComponent(value));
}
const url = new URL(`http://localhost${concrete}`);
for (const [key, value] of Object.entries(query)) {
url.searchParams.set(key, value);
}
const mod = (await import(/* @vite-ignore */ routeFile(op.path))) as {
GET?: (req: NextRequest, ctx: unknown) => Promise<Response>;
};
if (!mod.GET) return { called: false, findings: ["route exports no GET"] };
enter(op.path);
let res: Response;
try {
res = await mod.GET(new NextRequest(url), {
params: Promise.resolve(params),
});
} catch (error) {
return { called: false, findings: [`threw: ${(error as Error).message}`] };
}
const schema = op.statuses.get(res.status);
if (!schema) {
const text = await res.text();
return {
called: false,
findings: [
`status ${res.status} is not a published 2xx: ${text.slice(0, 300)}`,
],
};
}
const body: unknown = await res.json();
if (process.env.CONTRACT_DUMP?.split(",").includes(op.path)) {
const { appendFileSync } = await import("node:fs");
appendFileSync(
`${process.env.CONTRACT_REPORT}.bodies`,
`${op.path}\n${JSON.stringify(body, null, 1).slice(0, 20000)}\n\n`,
);
}
if (onlyEmptyArrays(body)) emptyBodies.push(op.path);
else filled.add(op.path);
const parsed = schema.safeParse(body);
if (!parsed.success) {
return {
called: true,
findings: parsed.error.issues
.slice(0, 8)
.map(
(issue) =>
`${issue.path.join(".") || "."}: ${issue.code} ${issue.message}`,
),
};
}
return { called: true, findings: strippedOrRewritten(body, parsed.data) };
}
describe("every published GET answers what its schema says", () => {
it("parses each response against the registered schema and drops nothing", async () => {
const operations = await publishedGets();
if (process.env.CONTRACT_REPORT) {
const { rmSync } = await import("node:fs");
rmSync(process.env.CONTRACT_REPORT, { force: true });
}
const findings: Finding[] = fixture.seedFailures.map((problem) => ({
path: "(seed)",
problem,
}));
let checked = 0;
for (const op of operations) {
if (op.path in NOT_CALLED) continue;
if (!existsSync(routeFile(op.path))) {
findings.push({ path: op.path, problem: "no route file" });
continue;
}
let calledOnce = false;
for (const query of queriesFor(op.path, fixture)) {
const label = Object.keys(query).length
? `${op.path}?${new URLSearchParams(query).toString()}`
: op.path;
const result = await check(op, query);
calledOnce ||= result.called;
for (const problem of result.findings) {
findings.push({ path: label, problem });
}
}
if (calledOnce) checked += 1;
}
if (process.env.CONTRACT_REPORT) {
const { writeFileSync } = await import("node:fs");
writeFileSync(
process.env.CONTRACT_REPORT,
JSON.stringify({ checked, findings, emptyBodies }, null, 2),
);
}
expect(findings).toEqual([]);
expect(checked).toBeGreaterThanOrEqual(CHECKED_FLOOR);
// Every list a schema promises is read with a row in it at least once,
// apart from the reasoned exceptions; an exception that now has a row
// is stale and goes.
const onlyEmpty = [...new Set(emptyBodies)]
.filter((path) => !filled.has(path))
.sort();
expect(onlyEmpty).toEqual(Object.keys(EMPTY_ALLOWED).sort());
expect(filled.size).toBeGreaterThanOrEqual(FILLED_FLOOR);
}, 600_000);
it("leaves no exception that the run could have called", async () => {
const published = new Set((await publishedGets()).map((op) => op.path));
// An exception for an operation that is no longer published is a stale
// entry that would quietly excuse its successor under the same path.
expect(
Object.keys(NOT_CALLED).filter((path) => !published.has(path)),
).toEqual([]);
});
});