mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
The two provenance reads take required query parameters and had no entry, so the run could not call them. Give them a day and a range on the steps, seed a second steps source for the last week so the pick and the comparison have two sources to explain, and save a labs list order so the module list layout read answers with a row.
542 lines
20 KiB
TypeScript
542 lines
20 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(),
|
|
}),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
// 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([]);
|
|
});
|
|
});
|