mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
aecf6f91b1
commit
2265763160
2 files changed
+1302
No files matched your search
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user