From 2265763160bd075b8f543403218f9f67540da2ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 13:16:22 +0200 Subject: [PATCH] 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. --- tests/integration/contract-seed.ts | 824 ++++++++++++++++++ .../integration/openapi-get-contract.test.ts | 478 ++++++++++ 2 files changed, 1302 insertions(+) create mode 100644 tests/integration/contract-seed.ts create mode 100644 tests/integration/openapi-get-contract.test.ts diff --git a/tests/integration/contract-seed.ts b/tests/integration/contract-seed.ts new file mode 100644 index 000000000..e878b9d48 --- /dev/null +++ b/tests/integration/contract-seed.ts @@ -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 | 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; + +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; + query?: Record; + } = {}, +): 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 { + 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 = {}; + 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, + ): Promise | 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; + } + 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 | 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 | 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<>endobj\n2 0 obj<>endobj\ntrailer<>\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 = {}; + 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> = { + "/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" }, +}; diff --git a/tests/integration/openapi-get-contract.test.ts b/tests/integration/openapi-get-contract.test.ts new file mode 100644 index 000000000..573fbe253 --- /dev/null +++ b/tests/integration/openapi-get-contract.test.ts @@ -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(); + 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(); + 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 = { + "/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> { + 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; + requiredQuery: string[]; +} + +type Operation = { + responses?: Record< + string, + { content?: Record } + >; + requestParams?: { query?: z.ZodType }; +}; + +function requiredQueryKeys(schema: z.ZodType | undefined): string[] { + if (!schema) return []; + const shape = (schema as unknown as { shape?: Record }) + .shape; + if (!shape) return []; + return Object.entries(shape) + .filter(([, field]) => !field.safeParse(undefined).success) + .map(([key]) => key); +} + +async function publishedGets(): Promise { + const { openApiPaths } = await import("@/lib/openapi/routes"); + const out: PublishedGet[] = []; + for (const [path, item] of Object.entries( + openApiPaths as Record, + )) { + const op = item.get; + if (!op?.responses) continue; + const statuses = new Map(); + 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(); + 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; + for (const [key, value] of Object.entries(raw as Record)) { + 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, +): 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; + }; + 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([]); + }); +});