From c72b81fa66bc5723506638676d91eefd84ab9163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 17:02:44 +0200 Subject: [PATCH] test(api): read every published list with a row in it The GET contract run counted 40 operations whose bodies held only empty arrays, and an empty array parses against any item schema. The seed now links the visit, lab, condition, document and dose to each other, adds a procedure, a second and third lab reading, nutrients, a category, the list order, courses, a rescheduled medication, a history fact, Coach preferences, a plan and a fact, a reminder event, an invite, a delegate's visit, a sign-in, a personal record, a trusted device, a marker alias, a symptom on a cycle and an illness day, part-way import jobs and a chat about the document. The run requires every operation to answer at least once with a filled list, apart from eleven named exceptions, and a floor of filled operations. The first finding is fixed: the document chat list returns fenced, attachments and documentTitle, now declared. --- docs/api/openapi.yaml | 30 +- src/lib/openapi/routes/documents.ts | 33 +- tests/integration/contract-seed.ts | 303 +++++++++++++++++- .../integration/openapi-get-contract.test.ts | 47 +++ 4 files changed, 394 insertions(+), 19 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 795ae7379..91bb91870 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -75720,7 +75720,7 @@ components: conversations: type: array items: - $ref: "#/components/schemas/DocumentChatConversation" + $ref: "#/components/schemas/DocumentChatListItem" nextCursor: type: - string @@ -75729,7 +75729,7 @@ components: - conversations - nextCursor additionalProperties: false - DocumentChatConversation: + DocumentChatListItem: type: object properties: id: @@ -75742,12 +75742,38 @@ components: type: string messageCount: type: number + fenced: + type: boolean + description: "Always true here: a chat about a document is a fenced thread (no tools, no health snapshot)." + attachments: + description: The documents the thread is about, as they are now. + type: array + items: + type: object + properties: + documentId: + type: string + title: + description: The attached document's title, falling back to its filename; null when it has neither. + type: + - string + - "null" + required: + - documentId + - title + additionalProperties: false + documentTitle: + description: The first attached document's title, for a one-line badge; null when none is attached any more. + type: + - string + - "null" required: - id - title - createdAt - updatedAt - messageCount + - fenced additionalProperties: false DocumentReindexEnvelope: type: object diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index 35fb5495d..0808f1686 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -118,9 +118,40 @@ const documentChatDetailSchema = documentChatConversationSchema }) .meta({ id: "DocumentChatDetail" }); +/** One thread in the document's rail: the conversation row the Coach rail reads. */ +const documentChatListItemSchema = documentChatConversationSchema + .extend({ + fenced: z + .boolean() + .describe( + "Always true here: a chat about a document is a fenced thread (no tools, no health snapshot).", + ), + attachments: z + .array( + z.object({ + documentId: z.string(), + title: z + .string() + .nullable() + .describe( + "The attached document's title, falling back to its filename; null when it has neither.", + ), + }), + ) + .optional() + .describe("The documents the thread is about, as they are now."), + documentTitle: z + .string() + .nullish() + .describe( + "The first attached document's title, for a one-line badge; null when none is attached any more.", + ), + }) + .meta({ id: "DocumentChatListItem" }); + const documentChatListSchema = z .object({ - conversations: z.array(documentChatConversationSchema), + conversations: z.array(documentChatListItemSchema), nextCursor: z.string().nullable(), }) .meta({ id: "DocumentChatList" }); diff --git a/tests/integration/contract-seed.ts b/tests/integration/contract-seed.ts index e878b9d48..c297e90c2 100644 --- a/tests/integration/contract-seed.ts +++ b/tests/integration/contract-seed.ts @@ -467,15 +467,16 @@ export async function seedContractAccount( actorUserId: CONTRACT_USER_ID, medicationId: ids.medication, payload: {}, + // Part-way through, so the progress lists carry an entry each. progress: { - processed: 0, + processed: 2, total: 3, - imported: 0, - skippedByReason: {}, - skipDetails: [], + imported: 1, + skippedByReason: { already_recorded: 1 }, + skipDetails: [{ line: 2, reason: "already_recorded" }], skippedDetailsOmitted: 0, - touchedDays: [], - rollupProcessed: 0, + touchedDays: [dayKey(daysAgo(3))], + rollupProcessed: 1, }, }, }); @@ -485,15 +486,16 @@ export async function seedContractAccount( recordUserId: CONTRACT_USER_ID, actorUserId: CONTRACT_USER_ID, payload: {}, + // Part-way through, so the progress lists carry an entry each. progress: { - processed: 0, + processed: 2, total: 3, - imported: 0, - skippedByReason: {}, - skipDetails: [], + imported: 1, + skippedByReason: { already_recorded: 1 }, + skipDetails: [{ line: 2, reason: "already_recorded" }], skippedDetailsOmitted: 0, - touchedDays: [], - rollupProcessed: 0, + touchedDays: [dayKey(daysAgo(3))], + rollupProcessed: 1, }, }, }); @@ -509,9 +511,22 @@ export async function seedContractAccount( { id: ids.medication }, ); } - await write("cycle-symptom", "POST", "api/cycle/symptoms/custom", { - label: "Contract symptom", - }); + const cycleSymptom = await write( + "cycle-symptom", + "POST", + "api/cycle/symptoms/custom", + { label: "Contract symptom" }, + ); + // The day carries the account's own symptom, so the day log's symptom + // list is not empty. + if (typeof cycleSymptom?.key === "string") { + await write("cycle-day-symptom", "POST", "api/cycle/day-logs", { + date: dayKey(daysAgo(3)), + loggedAt: daysAgo(3, 10).toISOString(), + flow: "MEDIUM", + symptoms: [{ key: cycleSymptom.key, severity: 2 }], + }); + } await write("device", "POST", "api/devices", { token: "contract-device-token", bundleId: "dev.healthlog.contract", @@ -582,6 +597,19 @@ export async function seedContractAccount( } if (ids.illness) { + // A logged day with a catalogue symptom (the catalogue is migration + // data), so the day's symptom list is not empty. + await write( + "illness-day-symptom", + "POST", + "api/illness/episodes/[id]/day-logs", + { + date: dayKey(daysAgo(2)), + functionalImpact: 1, + symptoms: [{ key: "cough", severity: 2 }], + }, + { id: ids.illness }, + ); await write( "illness-day", "POST", @@ -667,6 +695,20 @@ export async function seedContractAccount( }); if (ingested.kind === "stored") ids.document = ingested.document.id; else failures.push(`document: ingest answered ${ingested.kind}`); + // A chat about that document, as the document sheet starts one. + if (ids.document) { + const documentChat = await createConversation({ + userId: CONTRACT_USER_ID, + title: "About the letter", + documentScoped: true, + attachmentIds: [ids.document], + }); + await appendMessage({ + conversationId: documentChat.id, + role: "user", + content: "What does the letter say?", + }); + } // ── An AI run ────────────────────────────────────────────────────────── const { createAiRun } = await import("@/lib/documents/ai-runs/store"); @@ -715,6 +757,235 @@ export async function seedContractAccount( }); ids.restoreJob = restore.id; + // ── Rows behind the lists a detail or a summary carries ────────────── + // An array that comes back empty parses against any item schema, so each + // list the run reads gets at least one row (see EMPTY_ALLOWED in the run + // for the ones that cannot have one here, with the reason). + if (ids.encounter) { + const visit = { id: ids.encounter }; + const links: Array<[string, string | undefined]> = [ + ["labResult", ids.lab], + ["conditionEpisode", ids.illness], + ["document", ids.document], + ["vaccination", ids.vaccination], + ["medication", ids.medication], + ]; + for (const [targetKind, targetId] of links) { + if (!targetId) continue; + await write( + `encounter-link-${targetKind}`, + "POST", + "api/encounters/[id]/links", + { targetKind, targetIds: [targetId] }, + visit, + ); + } + } + if (ids.lab && ids.document) { + await write( + "lab-link-document", + "POST", + "api/labs/[id]/links", + { targetKind: "document", targetIds: [ids.document] }, + { id: ids.lab }, + ); + } + await write("procedure", "POST", "api/encounters", { + kind: "PROCEDURE", + occurredAt: daysAgo(25).toISOString(), + reason: "Knee arthroscopy", + bodySite: "Knee", + laterality: "LEFT", + }); + // A second reading of the same analyte, so the lab change summary has a + // pair to compare. + await write("lab-second", "POST", "api/labs", { + analyte: "HbA1c", + value: 5.8, + unit: "%", + takenAt: daysAgo(5).toISOString(), + }); + await write("lab-third", "POST", "api/labs", { + analyte: "HbA1c", + value: 5.6, + unit: "%", + takenAt: daysAgo(20).toISOString(), + }); + await write("nutrients", "POST", "api/nutrients/batch", { + entries: [1, 2, 3].map((n) => ({ + day: dayKey(daysAgo(n)), + nutrient: "vitamin_c", + unit: "mg", + amount: 80 + n, + })), + }); + await write("medication-category", "POST", "api/medications/categories", { + label: "Contract category", + }); + if (ids.medication) { + await write("medication-layout", "PUT", "api/medications/layout", { + version: 1, + order: [ids.medication], + }); + // An ended course and the current one. The route refuses a second + // course while the implicit open one runs, so both go in as rows. + await prisma.medicationCourse.createMany({ + data: [ + { + medicationId: ids.medication, + userId: CONTRACT_USER_ID, + startsOn: new Date(`${dayKey(daysAgo(400))}T00:00:00.000Z`), + endsOn: new Date(`${dayKey(daysAgo(380))}T00:00:00.000Z`), + }, + { + medicationId: ids.medication, + userId: CONTRACT_USER_ID, + startsOn: new Date(`${dayKey(daysAgo(60))}T00:00:00.000Z`), + endsOn: null, + }, + ], + }); + } + // A schedule change archives the previous schedule as a revision. On a + // medication of its own, so the main one's history stays as seeded. + const rescheduled = await write( + "medication-rescheduled", + "POST", + "api/medications", + { + name: "Contract rescheduled", + dose: "5 mg", + schedules: [ + { + time: "08:00", + windowStart: "07:00", + windowEnd: "10:00", + daysOfWeek: [0, 1, 2, 3, 4, 5, 6], + }, + ], + }, + ); + keep("rescheduledMedication", rescheduled); + if (ids.rescheduledMedication) { + await write( + "medication-reschedule", + "PUT", + "api/medications/[id]", + { + name: "Contract rescheduled", + dose: "5 mg", + schedules: [ + { + time: "09:00", + windowStart: "08:00", + windowEnd: "11:00", + daysOfWeek: [0, 1, 2, 3, 4, 5, 6], + }, + ], + }, + { id: ids.rescheduledMedication }, + ); + } + await write("anamnesis-fact", "POST", "api/anamnesis/facts", { + kind: "SMOKING_STATUS", + value: "FORMER", + validFrom: dayKey(daysAgo(400)), + }); + await write("coach-prefs", "PUT", "api/auth/me/coach-prefs", { + excludeMetrics: ["weight"], + }); + await write( + "injection-site-prefs", + "PATCH", + "api/auth/me/injection-site-prefs", + { + globalExcludedInjectionSites: ["ABDOMEN_LEFT"], + }, + ); + await write("invite", "POST", "api/admin/invites", { expiresInDays: 7 }); + const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec"); + await prisma.coachPlan.create({ + data: { + userId: CONTRACT_USER_ID, + metric: "SLEEP", + ifCueEncrypted: encryptToBytes("it is 22:30"), + thenActionEncrypted: encryptToBytes("put the phone away"), + status: "active", + }, + }); + await prisma.coachFact.create({ + data: { + userId: CONTRACT_USER_ID, + factEncrypted: encryptToBytes("Works night shifts twice a month"), + category: "context", + confidence: 0.9, + source: "user", + }, + }); + if (ids.reminder) { + await prisma.measurementReminderEvent.create({ + data: { + userId: CONTRACT_USER_ID, + reminderId: ids.reminder, + kind: "SATISFIED", + occurredAt: daysAgo(1, 7), + onTime: true, + source: "auto_measurement", + }, + }); + } + // A sign-in, as the security activity lists it. + await prisma.auditLog.create({ + data: { + userId: CONTRACT_USER_ID, + actorUserId: CONTRACT_USER_ID, + action: "auth.login", + createdAt: daysAgo(1), + }, + }); + await prisma.personalRecord.create({ + data: { + userId: CONTRACT_USER_ID, + metricType: "ACTIVITY_STEPS", + direction: "MAX", + value: 18_204, + unit: "steps", + achievedAt: daysAgo(6), + }, + }); + // A delegate's visit to this record, as the activity list shows it. + await prisma.auditLog.create({ + data: { + userId: CONTRACT_USER_ID, + actorUserId: MANAGED_PROFILE_ID, + action: "sharing.record.accessed", + details: JSON.stringify({ accesses: 2 }), + createdAt: daysAgo(2), + }, + }); + // A remembered device. A passkey or a security key stays out: either one + // turns the export and backup reads into a re-proof the seeded session + // cannot give (EMPTY_ALLOWED in the run). + await prisma.trustedDevice.create({ + data: { + userId: CONTRACT_USER_ID, + tokenHash: "contract-trusted-device-hash", + label: "Phone", + expiresAt: new Date(Date.now() + 20 * DAY), + }, + }); + if (ids.biomarker) { + await prisma.biomarkerAlias.create({ + data: { + userId: CONTRACT_USER_ID, + biomarkerId: ids.biomarker, + name: "Contract alias", + nameFolded: "contract alias", + source: "MANUAL", + }, + }); + } + // Fixed keys for the parameters that are not row ids. ids.today = dayKey(daysAgo(1)); ids.paperless = "paperless"; @@ -774,7 +1045,7 @@ const PATH_PARAM_SOURCES: Record> = { "/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/medications/{id}/schedule-revisions": { id: "rescheduledMedication" }, "/api/allergies/{id}": { id: "allergy" }, "/api/family-history/{id}": { id: "familyHistory" }, "/api/practitioners/{id}": { id: "practitioner" }, diff --git a/tests/integration/openapi-get-contract.test.ts b/tests/integration/openapi-get-contract.test.ts index 573fbe253..47fffdb99 100644 --- a/tests/integration/openapi-get-contract.test.ts +++ b/tests/integration/openapi-get-contract.test.ts @@ -116,6 +116,41 @@ const NOT_CALLED: Record = { /** The least number of operations that must actually be called and parsed. */ const CHECKED_FLOOR = 235; +/** + * Operations whose every list may come back empty here, with the reason. An + * empty list parses against any item schema, so every other operation must + * answer at least once with a list that has a row in it; the seed + * (`contract-seed.ts`) writes one for each. As for `NOT_CALLED`, a reason is + * about the operation: a list a fixture could fill is a row to seed. + */ +const EMPTY_ALLOWED: Record = { + "/api/auth/me/mfa": + "The list is the account's security keys, and an enrolled second factor turns the export and backup reads this run also calls into a re-proof a seeded session cannot give.", + "/api/auth/passkeys": + "The same: a passkey on the account puts the export and backup reads behind a re-proof.", + "/api/coach/about-me/questions": + "The questions are written by the account's AI provider; the run has none.", + "/api/insights/medication-compliance-status": + "The per-medication summaries are written by the account's AI provider; with none the published answer is the empty list beside `hasProvider: false`.", + "/api/insights/chat/{id}/messages/{messageId}/results": + "Result tables are stored by a model turn that called tools, in its own sealed format; the run has no provider.", + "/api/cycle/insights": + "A phase row only survives a false-discovery-rate test over many cycles of readings, which no handful of rows passes.", + "/api/insights/health-status": + "A deviation or shift is reported only once a reading leaves the account's own long-run baseline, which needs months of history.", + "/api/insights/glp1-timeline": + "Entries exist only for a GLP-1 medication, whose dose phases would change what the main medication's reads answer.", + "/api/documents/sources": + "Lists connections to the operator's document archive, which is environment configuration; the run configures none.", + "/api/environment/travel/suggestions": + "Suggestions come from the account's location history in an external Dawarich instance the run does not reach.", + "/api/mcp/connections": + "A connection is minted only by the OAuth authorization flow of a registered agent client.", +}; + +/** The least number of operations that answer with a list that has a row. */ +const FILLED_FLOOR = 228; + /** * The queries each operation is called with. An operation with a required * parameter needs an entry; an operation whose optional parameters select a @@ -346,6 +381,8 @@ function onlyEmptyArrays(value: unknown): boolean { } const emptyBodies: string[] = []; +/** Operations that answered at least once with a row in a list (or no list). */ +const filled = new Set(); /** Call one operation once and report what disagrees with its schema. */ async function check( @@ -407,6 +444,7 @@ async function check( ); } if (onlyEmptyArrays(body)) emptyBodies.push(op.path); + else filled.add(op.path); const parsed = schema.safeParse(body); if (!parsed.success) { return { @@ -465,6 +503,15 @@ describe("every published GET answers what its schema says", () => { expect(findings).toEqual([]); expect(checked).toBeGreaterThanOrEqual(CHECKED_FLOOR); + + // Every list a schema promises is read with a row in it at least once, + // apart from the reasoned exceptions; an exception that now has a row + // is stale and goes. + const onlyEmpty = [...new Set(emptyBodies)] + .filter((path) => !filled.has(path)) + .sort(); + expect(onlyEmpty).toEqual(Object.keys(EMPTY_ALLOWED).sort()); + expect(filled.size).toBeGreaterThanOrEqual(FILLED_FLOOR); }, 600_000); it("leaves no exception that the run could have called", async () => {