test(api): parse every published GET against its schema

The operation list is derived from the registry: every path with a get
whose 2xx publishes an application/json schema. Each is called through its
route module as one seeded account and the body is parsed with that schema;
a missing required field and a field the parse strips both fail. Operations
that need an external service sit in a reasoned exception list, and a floor
on the number of checked operations keeps the set from shrinking quietly.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 13:16:22 +02:00
1 parent aecf6f91b1
commit 2265763160
2 files changed
+1302

No files matched your search

+824
View File
@@ -0,0 +1,824 @@
/**
* One account with something in every section, for the GET contract run.
*
* Rows go in through the app's own write routes wherever one exists, so the
* fixture carries exactly what a client would have created — encrypted
* columns encrypted, derived columns derived — rather than what a hand-written
* Prisma literal guesses. Prisma is used directly only where no route writes
* the row (imported recordings, job ledgers, conversations).
*
* A list that comes back empty parses against almost any schema, so the point
* of the seed is that the lists the contract run reads are not empty.
*/
import { NextRequest } from "next/server";
import { join } from "node:path";
import type { PrismaClient } from "@/generated/prisma/client";
export interface ContractFixture {
userId: string;
sessionId: string;
/** A second session of the same account, switched into the managed profile. */
guardianSessionId: string;
guardianRecordEpoch: number;
managedProfileId: string;
/** Writes that did not land; the contract run reports them first. */
seedFailures: string[];
/** The path parameters for a published path, or null when none is seeded. */
pathParams(path: string): Record<string, string> | null;
/** A seeded id by name, for query fixtures. */
id(name: string): string;
}
export const CONTRACT_USER_ID = "user-openapi-get-contract";
const MANAGED_PROFILE_ID = "user-openapi-get-contract-managed";
type Handler = (req: NextRequest, ctx: unknown) => Promise<Response>;
function routeFile(path: string): string {
return join(process.cwd(), "src/app", path, "route.ts");
}
/**
* Call one route handler in-process, as the seeded session.
*
* `path` is the file-system path under `src/app` (`api/allergies`,
* `api/medications/[id]/courses`); `params` fills its dynamic segments.
*/
export async function callRoute(
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
path: string,
options: {
body?: unknown;
params?: Record<string, string>;
query?: Record<string, string>;
} = {},
): Promise<{ status: number; body: unknown }> {
const mod = (await import(/* @vite-ignore */ routeFile(path))) as Record<
string,
Handler | undefined
>;
const handler = mod[method];
if (!handler) throw new Error(`${path} exports no ${method}`);
let urlPath = `/${path}`;
for (const [name, value] of Object.entries(options.params ?? {})) {
urlPath = urlPath.replace(`[${name}]`, encodeURIComponent(value));
}
const url = new URL(`http://localhost${urlPath}`);
for (const [key, value] of Object.entries(options.query ?? {})) {
url.searchParams.set(key, value);
}
const init: { method: string; body?: string; headers?: HeadersInit } = {
method,
};
if (options.body !== undefined) {
init.body = JSON.stringify(options.body);
init.headers = { "content-type": "application/json" };
}
const res = await handler(new NextRequest(url, init), {
params: Promise.resolve(options.params ?? {}),
});
const text = await res.text();
let body: unknown = text;
try {
body = JSON.parse(text);
} catch {
// not JSON; keep the text for the failure message
}
return { status: res.status, body };
}
const DAY = 24 * 60 * 60 * 1000;
function daysAgo(n: number, hour = 8): Date {
const d = new Date(Date.now() - n * DAY);
d.setUTCHours(hour, 0, 0, 0);
return d;
}
function dayKey(d: Date): string {
return d.toISOString().slice(0, 10);
}
export async function seedContractAccount(
prisma: PrismaClient,
): Promise<ContractFixture> {
await prisma.user.create({
data: {
id: CONTRACT_USER_ID,
username: "openapi-get-contract",
email: "openapi-get-contract@example.test",
gender: "FEMALE",
timezone: "UTC",
role: "ADMIN",
dateOfBirth: new Date("1985-04-12T00:00:00.000Z"),
heightCm: 170,
},
});
const session = await prisma.session.create({
data: {
userId: CONTRACT_USER_ID,
expiresAt: new Date(Date.now() + 6 * 60 * 60 * 1000),
},
});
const { cookieJar } = await import("./mock-next-headers");
cookieJar.set("healthlog_session", session.id);
const ids: Record<string, string> = {};
const failures: string[] = [];
/** Write through a route; on success return `data`, on failure note it. */
async function write(
label: string,
method: "POST" | "PUT" | "PATCH",
path: string,
body: unknown,
params?: Record<string, string>,
): Promise<Record<string, unknown> | null> {
try {
const res = await callRoute(method, path, { body, params });
if (res.status >= 200 && res.status < 300) {
const data = (res.body as { data?: unknown }).data;
return (data ?? {}) as Record<string, unknown>;
}
failures.push(
`${label}: ${res.status} ${JSON.stringify(res.body).slice(0, 600)}`,
);
} catch (error) {
failures.push(`${label}: threw ${(error as Error).message}`);
}
return null;
}
function keep(name: string, data: Record<string, unknown> | null, at = "id") {
const value = data?.[at];
if (typeof value === "string") ids[name] = value;
}
// ── Modules that are off by default ────────────────────────────────────
await write("modules", "PATCH", "api/auth/me/modules", {
nutrients: true,
timeline: true,
});
// ── Measurements: ninety days of the common series ─────────────────────
const rows: Array<{
type: string;
value: number;
unit: string;
measuredAt: Date;
glucoseContext?: string;
}> = [];
for (let n = 0; n < 90; n++) {
rows.push(
{
type: "WEIGHT",
value: 72 + Math.sin(n / 7),
unit: "kg",
measuredAt: daysAgo(n, 7),
},
{
type: "BLOOD_PRESSURE_SYS",
value: 122 + (n % 9),
unit: "mmHg",
measuredAt: daysAgo(n, 8),
},
{
type: "BLOOD_PRESSURE_DIA",
value: 79 + (n % 5),
unit: "mmHg",
measuredAt: daysAgo(n, 8),
},
{
type: "PULSE",
value: 64 + (n % 7),
unit: "bpm",
measuredAt: daysAgo(n, 8),
},
{
type: "RESTING_HEART_RATE",
value: 58 + (n % 4),
unit: "bpm",
measuredAt: daysAgo(n, 6),
},
{
type: "HEART_RATE_VARIABILITY",
value: 40 + (n % 11),
unit: "ms",
measuredAt: daysAgo(n, 6),
},
{
type: "ACTIVITY_STEPS",
value: 6000 + ((n * 977) % 5000),
unit: "count",
measuredAt: daysAgo(n, 20),
},
{
type: "SLEEP_DURATION",
value: 400 + (n % 60),
unit: "min",
measuredAt: daysAgo(n, 6),
},
{
type: "BODY_FAT",
value: 24 + (n % 3) / 2,
unit: "%",
measuredAt: daysAgo(n, 7),
},
{
type: "OXYGEN_SATURATION",
value: 97,
unit: "%",
measuredAt: daysAgo(n, 6),
},
{
type: "BLOOD_GLUCOSE",
value: 92 + (n % 13),
unit: "mg/dL",
measuredAt: daysAgo(n, 9),
glucoseContext: "FASTING",
},
);
}
await prisma.measurement.createMany({
data: rows.map((row, i) => ({
userId: CONTRACT_USER_ID,
type: row.type as never,
value: row.value,
unit: row.unit,
measuredAt: row.measuredAt,
source: "MANUAL",
externalId: `contract-${i}`,
...(row.glucoseContext
? { glucoseContext: row.glucoseContext as never }
: {}),
})),
});
const firstMeasurement = await prisma.measurement.findFirst({
where: { userId: CONTRACT_USER_ID, type: "WEIGHT" },
orderBy: { measuredAt: "desc" },
});
if (firstMeasurement) ids.measurement = firstMeasurement.id;
// ── Mood ───────────────────────────────────────────────────────────────
const binaryTag = await prisma.moodTag.findFirst({
where: { userId: null, kind: { not: "RATED" } },
});
const ratedTag = await prisma.moodTag.findFirst({
where: { userId: null, kind: "RATED" },
});
for (let n = 0; n < 20; n++) {
const mood = await write("mood", "POST", "api/mood-entries", {
date: dayKey(daysAgo(n + 1)),
mood: ["GUT", "OKAY", "SUPER_GUT", "SCHLECHT"][n % 4],
moodLoggedAt: daysAgo(n + 1, 21).toISOString(),
note: n === 0 ? "A contract note" : undefined,
...(n === 0 && binaryTag ? { tagKeys: [binaryTag.key] } : {}),
...(n === 0 && ratedTag
? { ratedFactors: [{ key: ratedTag.key, rating: 3 }] }
: {}),
});
if (n === 0) keep("mood", mood);
}
// ── Medication ─────────────────────────────────────────────────────────
const medication = await write("medication", "POST", "api/medications", {
name: "Contract compound",
dose: "10 mg",
schedules: [
{
time: "08:00",
windowStart: "07:00",
windowEnd: "10:00",
daysOfWeek: [0, 1, 2, 3, 4, 5, 6],
},
],
});
keep("medication", medication);
// ── Records ────────────────────────────────────────────────────────────
keep(
"allergy",
await write("allergy", "POST", "api/allergies", {
substance: "Pollen",
category: "ENVIRONMENT",
type: "ALLERGY",
status: "ACTIVE",
reaction: "Sneezing",
}),
);
keep(
"familyHistory",
await write("family-history", "POST", "api/family-history", {
relationship: "MOTHER",
condition: "Hypertension",
}),
);
keep(
"practitioner",
await write("practitioner", "POST", "api/practitioners", {
name: "Dr. Example",
specialty: "General practice",
}),
);
keep(
"encounter",
await write("encounter", "POST", "api/encounters", {
kind: "ROUTINE",
occurredAt: daysAgo(30).toISOString(),
reason: "Check-up",
}),
);
keep(
"vaccination",
await write("vaccination", "POST", "api/vaccinations", {
vaccineName: "Tetanus",
occurredAt: daysAgo(200).toISOString(),
}),
);
keep(
"illness",
await write("illness", "POST", "api/illness/episodes", {
label: "Cold",
type: "INFECTION",
onsetAt: daysAgo(14).toISOString(),
}),
);
const assessment = await write(
"assessment",
"POST",
"api/mental-health/assessments",
{ instrument: "PHQ9", items: [0, 1, 0, 1, 0, 0, 1, 0, 0] },
);
keep(
"assessment",
(assessment?.assessment as Record<string, unknown> | undefined) ?? null,
);
keep(
"customMetric",
await write("custom-metric", "POST", "api/custom-metrics", {
name: "Contract metric",
unit: "pts",
}),
);
if (ids.customMetric) {
await write(
"custom-metric-entry",
"POST",
"api/custom-metrics/[id]/entries",
{ value: 3, measuredAt: daysAgo(1).toISOString() },
{ id: ids.customMetric },
);
}
keep(
"lab",
await write("lab", "POST", "api/labs", {
analyte: "HbA1c",
value: 5.4,
unit: "%",
takenAt: daysAgo(40).toISOString(),
}),
);
keep(
"reminder",
await write("reminder", "POST", "api/measurement-reminders", {
type: "WEIGHT",
label: "Morning weight",
time: "07:30",
intervalDays: 1,
}),
);
await write("cycle-day", "POST", "api/cycle/day-logs", {
date: dayKey(daysAgo(3)),
loggedAt: daysAgo(3, 9).toISOString(),
flow: "MEDIUM",
});
await write("symptom-definition", "POST", "api/symptoms/definitions", {
label: "Headache",
});
const definitions = await callRoute("GET", "api/symptoms/definitions");
const definition = (definitions.body as { data?: unknown }).data as
Array<{ id: string }> | { definitions?: Array<{ id: string }> } | undefined;
const definitionId = Array.isArray(definition)
? definition[0]?.id
: definition?.definitions?.[0]?.id;
if (definitionId) {
await write("symptom", "POST", "api/symptoms/events", {
definitionId,
occurredAt: daysAgo(2).toISOString(),
intensity: 4,
});
} else {
failures.push(
`symptom: no definition ${JSON.stringify(definitions.body).slice(0, 300)}`,
);
}
await write("life-event", "POST", "api/life-events", {
title: "Moved house",
category: "HOME",
startDate: dayKey(daysAgo(60)),
precision: "DAY",
});
// Creating a managed profile asks for a fresh second factor, which a seeded
// session cannot give; the profile and its MANAGE grant go in directly.
await prisma.user.create({
data: {
id: MANAGED_PROFILE_ID,
username: "openapi-get-contract-managed",
email: null,
role: "USER",
managedProfileAt: new Date(),
},
});
await prisma.accountGrant.create({
data: {
grantorId: MANAGED_PROFILE_ID,
granteeId: CONTRACT_USER_ID,
access: "MANAGE",
acceptedAt: new Date(Date.now() - 60_000),
},
});
ids.managedProfile = MANAGED_PROFILE_ID;
// ── Medication detail ──────────────────────────────────────────────────
if (ids.medication) {
const medication = { id: ids.medication };
await write(
"side-effect",
"POST",
"api/medications/[id]/side-effects",
{ entry: "NAUSEA", severity: 2, occurredAt: daysAgo(5).toISOString() },
medication,
);
await prisma.medicationIntakeEvent.createMany({
data: Array.from({ length: 14 }, (_, n) => ({
userId: CONTRACT_USER_ID,
medicationId: ids.medication,
scheduledFor: daysAgo(n + 1, 8),
takenAt: n % 5 === 0 ? null : daysAgo(n + 1, 8),
skipped: n % 5 === 0,
source: "WEB" as const,
})),
});
const intakeJob = await prisma.medicationIntakeImportJob.create({
data: {
recordUserId: CONTRACT_USER_ID,
actorUserId: CONTRACT_USER_ID,
medicationId: ids.medication,
payload: {},
progress: {
processed: 0,
total: 3,
imported: 0,
skippedByReason: {},
skipDetails: [],
skippedDetailsOmitted: 0,
touchedDays: [],
rollupProcessed: 0,
},
},
});
ids.intakeImportJob = intakeJob.id;
const doseJob = await prisma.medicationIntakeImportJob.create({
data: {
recordUserId: CONTRACT_USER_ID,
actorUserId: CONTRACT_USER_ID,
payload: {},
progress: {
processed: 0,
total: 3,
imported: 0,
skippedByReason: {},
skipDetails: [],
skippedDetailsOmitted: 0,
touchedDays: [],
rollupProcessed: 0,
},
},
});
ids.doseHistoryImportJob = doseJob.id;
}
if (ids.medication) {
await write(
"inventory",
"POST",
"api/medications/[id]/inventory",
{ unitsTotal: 30, containerType: "BLISTER" },
{ id: ids.medication },
);
}
await write("cycle-symptom", "POST", "api/cycle/symptoms/custom", {
label: "Contract symptom",
});
await write("device", "POST", "api/devices", {
token: "contract-device-token",
bundleId: "dev.healthlog.contract",
apnsToken: "a".repeat(64),
apnsEnvironment: "sandbox",
appVersion: "1.0",
model: "iPhone",
});
await write("share-link", "POST", "api/share-links", {
label: "For the practice",
rangeStart: daysAgo(60).toISOString(),
expiresAt: new Date(Date.now() + 7 * 86_400_000).toISOString(),
});
await write("mcp-token", "POST", "api/mcp/tokens", {
name: "Contract connector",
scope: "read",
});
// A device's irregular-rhythm notification: the EVENT row the rhythm
// timeline reads.
await prisma.measurement.create({
data: {
userId: CONTRACT_USER_ID,
type: "IRREGULAR_RHYTHM_NOTIFICATION",
value: 1,
unit: "count",
measuredAt: daysAgo(6, 3),
source: "APPLE_HEALTH",
externalId: "contract-rhythm-1",
rhythmClassification: "IRREGULAR",
},
});
// ── Workouts ───────────────────────────────────────────────────────────
const workouts = await write("workouts", "POST", "api/workouts/batch", {
workouts: [1, 3, 6].map((n) => ({
sportType: "running",
startedAt: daysAgo(n, 17).toISOString(),
endedAt: new Date(daysAgo(n, 17).getTime() + 40 * 60_000).toISOString(),
totalEnergyKcal: 420,
totalDistanceM: 6500,
avgHeartRate: 148,
externalId: `contract-workout-${n}`,
})),
});
void workouts;
const workout = await prisma.workout.findFirst({
where: { userId: CONTRACT_USER_ID },
});
if (workout) ids.workout = workout.id;
// ── Biomarker, a lab against it ────────────────────────────────────────
keep(
"biomarker",
await write("biomarker", "POST", "api/biomarkers", {
name: "Contract marker",
unit: "mg/dL",
lowerBound: 10,
upperBound: 50,
}),
);
if (ids.biomarker) {
await write("lab-on-biomarker", "POST", "api/labs", {
biomarkerId: ids.biomarker,
value: 31,
unit: "mg/dL",
takenAt: daysAgo(20).toISOString(),
});
}
if (ids.illness) {
await write(
"illness-day",
"POST",
"api/illness/episodes/[id]/day-logs",
{ date: dayKey(daysAgo(13)), functionalImpact: 2, feverC: 38.1 },
{ id: ids.illness },
);
}
await write("custom-vaccine", "POST", "api/vaccinations/custom", {
name: "Contract vaccine",
components: ["tetanus"],
});
await write("coach-reminder", "POST", "api/coach/reminders", {
note: "Check weight trend",
});
// ── ECG: a recording through the persistence path the importers use ───
const { persistEcgRecording } = await import("@/lib/ecg/persist-recording");
const ecg = await persistEcgRecording(
{
userId: CONTRACT_USER_ID,
source: "APPLE_HEALTH",
externalRecordingId: "contract-ecg-1",
recordedAt: daysAgo(4, 10),
samples: Array.from({ length: 512 }, (_, i) =>
Math.round(Math.sin(i / 8) * 400),
),
samplingFrequency: 512,
averageHeartRate: 66,
rhythmClassification: "NOT_DETECTED",
},
prisma,
);
ids.ecg = ecg.id;
// ── A Coach conversation with an exchange in it ────────────────────────
const { createConversation, appendMessage } =
await import("@/lib/ai/coach/persistence");
const conversation = await createConversation({
userId: CONTRACT_USER_ID,
title: "Contract conversation",
});
ids.conversation = conversation.id;
await appendMessage({
conversationId: conversation.id,
role: "user",
content: "How is my blood pressure?",
});
const answer = await appendMessage({
conversationId: conversation.id,
role: "assistant",
content: "It has been steady over the last month.",
});
ids.message = answer.id;
// ── A document, through the same ingest the upload route calls ────────
const { ingestDocument } = await import("@/lib/documents/ingest");
const pdf = Buffer.from(
"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Kids[]/Count 0>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF\n",
);
const ingested = await ingestDocument({
userId: CONTRACT_USER_ID,
scoped: false,
ipAddress: null,
bytes: pdf,
filename: "contract.pdf",
title: "Contract letter",
kind: null,
documentDate: dayKey(daysAgo(10)),
episodeIds: [],
encounterIds: [],
sourceSystem: "PAPERLESS",
sourceId: "4711",
sourceInstance: null,
aiDeferred: true,
sourceKeyChecked: false,
limits: { maxFileBytes: 10 * 1024 * 1024, quotaBytes: 100 * 1024 * 1024 },
documentAi: async () => ({
available: false,
reason: "no_provider",
onDeviceAllowed: false,
}),
});
if (ingested.kind === "stored") ids.document = ingested.document.id;
else failures.push(`document: ingest answered ${ingested.kind}`);
// ── An AI run ──────────────────────────────────────────────────────────
const { createAiRun } = await import("@/lib/documents/ai-runs/store");
if (ids.document) {
ids.aiRun = await createAiRun({
userId: CONTRACT_USER_ID,
kind: "DOCUMENT_INDEX",
documentId: ids.document,
params: { mode: "vision", mime: "application/pdf" },
});
}
// ── Import ledger rows ─────────────────────────────────────────────────
const importJob = await prisma.importJob.create({
data: {
userId: CONTRACT_USER_ID,
uploadBytes: 2048,
status: "completed",
completedAt: daysAgo(1),
},
});
ids.importJob = importJob.id;
// ── A stored backup of this very account, and a restore job for it ─────
const { storeBackupBlob } = await import("@/lib/export/store-backup-blob");
const { streamFullBackupJson } =
await import("@/lib/export/full-backup-stream");
const stored = await storeBackupBlob(
prisma,
{ userId: CONTRACT_USER_ID, type: "WEEKLY_AUTO", preview: () => null },
async (out) => {
await streamFullBackupJson(prisma, CONTRACT_USER_ID, out, {
purpose: "disaster-recovery",
});
},
);
ids.backup = stored.id;
const restore = await prisma.backupRestoreJob.create({
data: {
userId: CONTRACT_USER_ID,
actorUserId: CONTRACT_USER_ID,
backupId: stored.id,
backupDigest: "contract-digest",
status: "completed",
},
});
ids.restoreJob = restore.id;
// Fixed keys for the parameters that are not row ids.
ids.today = dayKey(daysAgo(1));
ids.paperless = "paperless";
ids.profileFamily = "profile";
const guardianSession = await prisma.session.create({
data: {
userId: CONTRACT_USER_ID,
expiresAt: new Date(Date.now() + 6 * 60 * 60 * 1000),
actingAsUserId: MANAGED_PROFILE_ID,
recordEpoch: 1,
},
});
return {
userId: CONTRACT_USER_ID,
sessionId: session.id,
guardianSessionId: guardianSession.id,
guardianRecordEpoch: guardianSession.recordEpoch,
managedProfileId: MANAGED_PROFILE_ID,
seedFailures: failures,
id(name) {
const value = ids[name];
if (value === undefined) throw new Error(`no seeded ${name}`);
return value;
},
pathParams(path) {
const names = [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
if (names.length === 0) return {};
const value = PATH_PARAM_SOURCES[path];
if (!value) return null;
const out: Record<string, string> = {};
for (const name of names) {
const source = value[name];
const resolved = source ? ids[source] : undefined;
if (resolved === undefined) return null;
out[name] = resolved;
}
return out;
},
};
}
/** Which seeded id fills which path parameter. */
const PATH_PARAM_SOURCES: Record<string, Record<string, string>> = {
"/api/measurements/{id}": { id: "measurement" },
"/api/mood-entries/{id}": { id: "mood" },
"/api/medications/{id}": { id: "medication" },
"/api/medications/{id}/glp1": { id: "medication" },
"/api/medications/{id}/api-endpoint": { id: "medication" },
"/api/medications/{id}/phase-config": { id: "medication" },
"/api/medications/{id}/side-effects": { id: "medication" },
"/api/medications/{id}/courses": { id: "medication" },
"/api/medications/{id}/intake": { id: "medication" },
"/api/medications/{id}/inventory": { id: "medication" },
"/api/medications/{id}/cadence": { id: "medication" },
"/api/medications/{id}/compliance": { id: "medication" },
"/api/medications/{id}/efficacy": { id: "medication" },
"/api/medications/{id}/dose-history": { id: "medication" },
"/api/medications/{id}/schedule-revisions": { id: "medication" },
"/api/allergies/{id}": { id: "allergy" },
"/api/family-history/{id}": { id: "familyHistory" },
"/api/practitioners/{id}": { id: "practitioner" },
"/api/encounters/{id}": { id: "encounter" },
"/api/vaccinations/{id}": { id: "vaccination" },
"/api/illness/episodes/{id}": { id: "illness" },
"/api/illness/episodes/{id}/day-logs": { id: "illness" },
"/api/illness/episodes/{id}/correlation": { id: "illness" },
"/api/mental-health/assessments/{id}": { id: "assessment" },
"/api/custom-metrics/{id}": { id: "customMetric" },
"/api/custom-metrics/{id}/entries": { id: "customMetric" },
"/api/labs/{id}": { id: "lab" },
"/api/measurement-reminders/{id}": { id: "reminder" },
"/api/measurement-reminders/{id}/history": { id: "reminder" },
"/api/managed-profiles/{id}": { id: "managedProfile" },
"/api/managed-profiles/{id}/guardians": { id: "managedProfile" },
"/api/managed-profiles/{id}/handover": { id: "managedProfile" },
"/api/workouts/{id}": { id: "workout" },
"/api/biomarkers/{id}": { id: "biomarker" },
"/api/insights/ecg/{id}": { id: "ecg" },
"/api/insights/chat/{id}": { id: "conversation" },
"/api/insights/chat/{id}/messages/{messageId}/results": {
id: "conversation",
messageId: "message",
},
"/api/insights/chat/{id}/messages/{messageId}/trail": {
id: "conversation",
messageId: "message",
},
"/api/documents/inbound/{id}": { id: "document" },
"/api/documents/inbound/{id}/chat": { id: "document" },
"/api/ai-runs/{id}": { id: "aiRun" },
"/api/import/apple-health-export/{jobId}/status": { jobId: "importJob" },
"/api/medications/{id}/intake/import/{jobId}/status": {
id: "medication",
jobId: "intakeImportJob",
},
"/api/medications/intake/dose-history-import/{jobId}/status": {
jobId: "doseHistoryImportJob",
},
"/api/admin/backups/{id}/download": { id: "backup" },
"/api/admin/backups/restores/{jobId}": { jobId: "restoreJob" },
"/api/documents/sources/{system}/tags": { system: "paperless" },
"/api/documents/sources/{system}/search": { system: "paperless" },
"/api/day/{date}": { date: "today" },
"/api/record-settings/{family}": { family: "profileFamily" },
};
@@ -0,0 +1,478 @@
/**
* 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;
/**
* 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/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[] = [];
/** 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);
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);
}, 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([]);
});
});