From f42934487d977cb4263637649d772cbd625601e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 18:30:38 +0200 Subject: [PATCH] test(export): prove the scope holds at the artefact, not just at the switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five layers, because the defect had five places to hide. The catalogue guard checks what the compiler cannot state: every leaf resolves a label a human can read, every group resolves one, the fenced tier stays last, and the shipped template intersects the fence at nothing. The selection guard pins the three minting functions against each other. A stored blob this build cannot read resolves to the empty selection, never to a default set — that is what makes the revoked legacy links revocable rather than silently re-scoped. A request naming an id the server does not know is refused by name rather than dropped, because dropping it would narrow a scope the caller did express. The egress guard freezes the set of aggregator callers with the source of each one's selection written down. The required brand-typed parameter already stops a new path compiling without an answer; this stops one being added without a human writing the answer down, which is the moment a reviewer asks the question. The behavioural sweep aggregates every leaf in and out against a fixture that carries a row for each, and asserts the payload differs. It is parametrised over the catalogue, so a leaf added later is covered the day it is added. Its positive control asserts the all-on payload is genuinely populated first, so a "no difference" result means the gate is inert and not that the account is empty. Beside it: an unmapped measurement type is excluded rather than passed through, no table behind an unchosen leaf is queried at all, and an empty selection produces an empty record. The artefact layer runs the real route and reads what comes out — the FHIR bundle, the PDF text and the zip's contents — with a positive control beside every exclusion. Mutation-checked: making the gate admit everything reddens the sweep for every leaf; passing an unmapped type through reddens the fail-closed case; dropping the mood query's condition reddens the zero-read case; returning the BMI unconditionally reddens the sweep naming BODY_MASS_INDEX; adding a fenced leaf to the template reddens the purity guard; removing the panel's seeding block reddens the first-run and repeat-run cases; dropping the insurance refusal reddens the share-link case; adding a caller outside the frozen list reddens the egress guard naming the file. The panel suite grew past the practice name it was written for and is renamed for what it now covers: the three states, the fenced tier having no group control, and the scope line naming its sensitive inclusions. Integration coverage moves to the leaf model, including the case this release exists for: an account with data in every domain and a caller that named nothing gets an empty record. The OpenAPI contract catches up with the routes — the required selection, the refused keys, the share summary's new fields, the moved `Patient/$everything`, the Coverage search and the five typed reads. --- docs/api/openapi.yaml | 423 ++++-- ...doctor-report-control-gating-guard.test.ts | 250 ++-- .../report-selection-egress-guard.test.ts | 83 ++ .../health-record/__tests__/route.test.ts | 295 +++-- src/app/api/fhir/Coverage/[id]/route.ts | 50 + src/app/api/fhir/Coverage/route.ts | 55 + .../MedicationAdministration/[id]/route.ts | 53 + .../fhir/MedicationAdministration/route.ts | 38 +- .../fhir/MedicationStatement/[id]/route.ts | 53 + src/app/api/fhir/MedicationStatement/route.ts | 33 +- src/app/api/fhir/Observation/[id]/route.ts | 48 + src/app/api/fhir/Observation/route.ts | 37 +- .../fhir/{ => Patient}/$everything/route.ts | 56 +- src/app/api/fhir/Patient/[id]/route.ts | 48 + src/app/api/fhir/Patient/route.ts | 38 +- .../capability-and-everything.test.ts | 360 ++++++ .../api/fhir/__tests__/module-gate.test.ts | 84 +- src/app/api/fhir/__tests__/route.test.ts | 1 + src/app/api/fhir/metadata/route.ts | 77 +- .../meta/capabilities/__tests__/route.test.ts | 24 +- .../api/share-links/__tests__/route.test.ts | 132 +- .../__tests__/clinician-view.test.tsx | 15 +- .../__tests__/disable-coach-card.test.tsx | 1 + ...ealth-record-export-practice-name.test.tsx | 97 -- .../__tests__/modules-section.test.tsx | 1 + .../__tests__/report-selection-panel.test.tsx | 234 ++++ .../__tests__/share-link-create-form.test.tsx | 11 +- .../__tests__/time-format-select.test.tsx | 1 + .../__tests__/unit-preference-select.test.tsx | 1 + .../report-selection/selection-panel.tsx | 22 +- .../doctor-report-module-backstop.test.ts | 13 +- .../__tests__/doctor-report-pdf-core.test.ts | 53 +- .../measurement-type-enum-coverage.test.ts | 191 +-- .../__tests__/share-view-data.test.ts | 167 +-- .../clinician-share/resolve-share-token.ts | 13 +- .../__tests__/leaf-gating-sweep.test.ts | 403 ++++++ .../__tests__/coach-user-disable.test.tsx | 1 + src/lib/fhir/__tests__/build-bundle.test.ts | 70 +- src/lib/fhir/__tests__/conformance.test.ts | 516 ++++++++ .../__tests__/rest-module-backstop.test.ts | 8 + src/lib/fhir/build-bundle.ts | 261 +++- src/lib/fhir/resources.ts | 1143 ----------------- src/lib/fhir/resources/common.ts | 137 ++ src/lib/fhir/resources/conditions.ts | 124 ++ src/lib/fhir/resources/coverage.ts | 52 + src/lib/fhir/resources/cycle.ts | 146 +++ src/lib/fhir/resources/glucose.ts | 171 +++ src/lib/fhir/resources/index.ts | 38 + src/lib/fhir/resources/labs.ts | 113 ++ src/lib/fhir/resources/medications.ts | 224 ++++ src/lib/fhir/resources/observation.ts | 283 ++++ src/lib/fhir/resources/patient.ts | 65 + src/lib/fhir/rest.ts | 99 +- src/lib/fhir/types.ts | 55 +- src/lib/fhir/ucum.ts | 92 ++ src/lib/mcp/__tests__/prompts.test.ts | 3 + .../mcp/__tests__/resource-templates.test.ts | 6 + src/lib/mcp/__tests__/resources.test.ts | 3 + src/lib/openapi/routes/health-record.ts | 195 ++- .../__tests__/catalogue-guard.test.ts | 114 ++ .../__tests__/panel-state.test.ts | 89 ++ .../__tests__/selection.test.ts | 99 ++ src/lib/report-selection/saved-profile.ts | 6 +- .../fixtures/expected-measurement-loinc.json | 12 + src/lib/signals/registry.ts | 23 +- .../__tests__/doctor-report-prefs.test.ts | 126 -- .../__tests__/health-record-export.test.ts | 182 ++- .../doctor-report-dense-data.test.ts | 28 +- .../doctor-report-sections.test.ts | 149 ++- .../medication-administrations.test.ts | 11 +- 70 files changed, 5646 insertions(+), 2459 deletions(-) create mode 100644 src/__tests__/report-selection-egress-guard.test.ts create mode 100644 src/app/api/fhir/Coverage/[id]/route.ts create mode 100644 src/app/api/fhir/Coverage/route.ts create mode 100644 src/app/api/fhir/MedicationAdministration/[id]/route.ts create mode 100644 src/app/api/fhir/MedicationStatement/[id]/route.ts create mode 100644 src/app/api/fhir/Observation/[id]/route.ts rename src/app/api/fhir/{ => Patient}/$everything/route.ts (58%) create mode 100644 src/app/api/fhir/Patient/[id]/route.ts create mode 100644 src/app/api/fhir/__tests__/capability-and-everything.test.ts delete mode 100644 src/components/settings/__tests__/health-record-export-practice-name.test.tsx create mode 100644 src/components/settings/__tests__/report-selection-panel.test.tsx create mode 100644 src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts create mode 100644 src/lib/fhir/__tests__/conformance.test.ts delete mode 100644 src/lib/fhir/resources.ts create mode 100644 src/lib/fhir/resources/common.ts create mode 100644 src/lib/fhir/resources/conditions.ts create mode 100644 src/lib/fhir/resources/coverage.ts create mode 100644 src/lib/fhir/resources/cycle.ts create mode 100644 src/lib/fhir/resources/glucose.ts create mode 100644 src/lib/fhir/resources/index.ts create mode 100644 src/lib/fhir/resources/labs.ts create mode 100644 src/lib/fhir/resources/medications.ts create mode 100644 src/lib/fhir/resources/observation.ts create mode 100644 src/lib/fhir/resources/patient.ts create mode 100644 src/lib/fhir/ucum.ts create mode 100644 src/lib/report-selection/__tests__/catalogue-guard.test.ts create mode 100644 src/lib/report-selection/__tests__/panel-state.test.ts create mode 100644 src/lib/report-selection/__tests__/selection.test.ts delete mode 100644 src/lib/validations/__tests__/doctor-report-prefs.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2889c47f5..5f2d5e945 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -871,10 +871,12 @@ paths: tags: - Export summary: Generate a health-record export (PDF / FHIR / package) - description: "v1.7.0 flagship export. Returns the doctor-handover artefact in the requested `format`: `pdf` → - application/pdf, `fhir` → application/fhir+json (HL7 FHIR R4 document Bundle), `package` → application/zip (PDF - + FHIR + README). Auth via cookie or Bearer; shared `export:` rate bucket (10/h). Strict validation: - unknown keys 422." + description: "Returns the doctor-handover artefact in the requested `format`: `pdf` → application/pdf, `fhir` → + application/fhir+json (HL7 FHIR R4 document Bundle), `package` → application/zip (PDF + FHIR + README). + `selection` is REQUIRED and carries the leaf inclusion list — membership is inclusion, absence is exclusion, and + there is no server-side default. An unknown leaf id is a 422 that names it; the retired grouped `sections` shape + is a 422 too, because accepting it would mean choosing a scope the caller did not express. Auth via cookie or + Bearer; shared `export:` rate bucket (10/h). Strict validation: unknown keys 422." requestBody: required: true content: @@ -904,65 +906,23 @@ paths: minimum: 1 maximum: 365 additionalProperties: false - sections: + selection: type: object properties: - vitals: - type: object - properties: - weight: - type: boolean - bp: - type: boolean - pulse: - type: boolean - oxygenSaturation: - type: boolean - bodyFat: - type: boolean - bodyComposition: - type: boolean - cardioFitness: - type: object - properties: - restingHeartRate: - type: boolean - hrv: - type: boolean - vo2max: - type: boolean - activity: - type: object - properties: - steps: - type: boolean - distance: - type: boolean - sleep: - type: boolean - glucose: - type: boolean - medications: - type: object - properties: - list: - type: boolean - compliance: - type: boolean - mood: - type: boolean - bmi: - type: boolean - cycle: - type: boolean - labs: - type: boolean - allergies: - type: boolean - familyHistory: - type: boolean - mentalHealthScreeners: - type: boolean + v: + type: number + const: 2 + leaves: + maxItems: 90 + type: array + items: + type: string + minLength: 1 + maxLength: 64 + required: + - v + - leaves + additionalProperties: false locale: type: string enum: @@ -977,12 +937,11 @@ paths: maxLength: 120 includeCharts: type: boolean - includeAiSummary: - type: boolean germanAtc: type: boolean required: - format + - selection additionalProperties: false responses: "200": @@ -1010,14 +969,13 @@ paths: - Export summary: Create a clinician share link (v1.11.0) description: "Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY - ONCE in the response. v1.18.7 also mints a passphrase second factor (returned once as `passphrase`, with - `shareUrl` and a `qrUrl` carrying the passphrase in the URL fragment); a leaked URL without the passphrase - cannot open the record. Every scope column (window, sections, FHIR resource types, API toggle) is frozen - write-once. v1.28 accepts an optional `documentIds` array (bounded ≤50): each id is validated as the caller's - own live document and frozen onto the link at create (a foreign/deleted id → 422); the summary DTO then reports - `documentCount`. A documents-only share (empty report sections + non-empty `documentIds`) is valid. `expiresAt` - is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). - Strict: unknown keys 422." + ONCE in the response, together with a passphrase second factor and a `qrUrl` carrying that passphrase in the URL + fragment; a leaked URL without the passphrase cannot open the record. The window and the `selection` are frozen + write-once — there is no widen path. `selection` omitted means an empty scope, not a default one, and the + `INSURANCE` leaf is refused with a 422 because the share view never decrypts the insurance number. An optional + `documentIds` array (bounded ≤50) is validated as the caller's own live documents and frozen onto the link; a + documents-only share (no report scope, non-empty `documentIds`) is valid. `expiresAt` is required and capped at + 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). Strict: unknown keys 422." requestBody: required: true content: @@ -1039,65 +997,23 @@ paths: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" - sections: + selection: type: object properties: - vitals: - type: object - properties: - weight: - type: boolean - bp: - type: boolean - pulse: - type: boolean - oxygenSaturation: - type: boolean - bodyFat: - type: boolean - bodyComposition: - type: boolean - cardioFitness: - type: object - properties: - restingHeartRate: - type: boolean - hrv: - type: boolean - vo2max: - type: boolean - activity: - type: object - properties: - steps: - type: boolean - distance: - type: boolean - sleep: - type: boolean - glucose: - type: boolean - medications: - type: object - properties: - list: - type: boolean - compliance: - type: boolean - mood: - type: boolean - bmi: - type: boolean - cycle: - type: boolean - labs: - type: boolean - allergies: - type: boolean - familyHistory: - type: boolean - mentalHealthScreeners: - type: boolean + v: + type: number + const: 2 + leaves: + maxItems: 90 + type: array + items: + type: string + minLength: 1 + maxLength: 64 + required: + - v + - leaves + additionalProperties: false documentIds: maxItems: 50 type: array @@ -1272,8 +1188,9 @@ paths: - FHIR summary: FHIR R4 CapabilityStatement (v1.11.0) description: "Read-only FHIR R4 capability statement for the REST face. Declares the served resource types (Patient, - Observation, MedicationStatement, MedicationAdministration), the `$everything` operation, and the - `application/fhir+json` format. Auth: `fhir:read` scope (cookie sessions also pass)." + Coverage, Observation, MedicationStatement, MedicationAdministration), their `read` and `search-type` + interactions, the `Patient/$everything` operation, and the `application/fhir+json` format. Every advertised + interaction has a route. Auth: `fhir:read` scope (cookie sessions also pass)." responses: "200": description: CapabilityStatement (application/fhir+json). @@ -1393,14 +1310,204 @@ paths: "401": *a1 "422": *a3 "429": *a4 - /api/fhir/$everything: + /api/fhir/Coverage: + get: + tags: + - FHIR + summary: FHIR R4 Coverage search + description: "Read-only `searchset` Bundle of the caller's own Coverage resource, when an insurer is recorded and the + insurance leaf is in the saved selection. Auth: `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / + `_offset`. `userId` is narrowed from auth." + parameters: + - in: query + name: _count + schema: + type: number + - in: query + name: _offset + schema: + type: number + responses: + "200": + description: searchset Bundle (application/fhir+json). + content: + application/fhir+json: + schema: + type: string + format: binary + "401": *a1 + "422": *a3 + "429": *a4 + /api/fhir/Patient/{id}: + get: + tags: + - FHIR + summary: FHIR R4 Patient read + description: "Read-only `Patient` by id, out of the caller's own record. An id belonging to someone else is + indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` + scope; `userId` is narrowed from auth." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The resource (application/fhir+json). + content: + application/fhir+json: + schema: + type: string + format: binary + "401": *a1 + "404": + description: OperationOutcome (application/fhir+json). + content: + application/fhir+json: + schema: + type: string + format: binary + "422": *a3 + "429": *a4 + /api/fhir/Coverage/{id}: + get: + tags: + - FHIR + summary: FHIR R4 Coverage read + description: "Read-only `Coverage` by id, out of the caller's own record. An id belonging to someone else is + indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` + scope; `userId` is narrowed from auth." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The resource (application/fhir+json). + content: + application/fhir+json: + schema: + type: string + format: binary + "401": *a1 + "404": + description: OperationOutcome (application/fhir+json). + content: + application/fhir+json: + schema: + type: string + format: binary + "422": *a3 + "429": *a4 + /api/fhir/Observation/{id}: + get: + tags: + - FHIR + summary: FHIR R4 Observation read + description: "Read-only `Observation` by id, out of the caller's own record. An id belonging to someone else is + indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` + scope; `userId` is narrowed from auth." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The resource (application/fhir+json). + content: + application/fhir+json: + schema: + type: string + format: binary + "401": *a1 + "404": + description: OperationOutcome (application/fhir+json). + content: + application/fhir+json: + schema: + type: string + format: binary + "422": *a3 + "429": *a4 + /api/fhir/MedicationStatement/{id}: + get: + tags: + - FHIR + summary: FHIR R4 MedicationStatement read + description: "Read-only `MedicationStatement` by id, out of the caller's own record. An id belonging to someone else is + indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` + scope; `userId` is narrowed from auth." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The resource (application/fhir+json). + content: + application/fhir+json: + schema: + type: string + format: binary + "401": *a1 + "404": + description: OperationOutcome (application/fhir+json). + content: + application/fhir+json: + schema: + type: string + format: binary + "422": *a3 + "429": *a4 + /api/fhir/MedicationAdministration/{id}: + get: + tags: + - FHIR + summary: FHIR R4 MedicationAdministration read + description: "Read-only `MedicationAdministration` by id, out of the caller's own record. An id belonging to someone + else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: + `fhir:read` scope; `userId` is narrowed from auth." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The resource (application/fhir+json). + content: + application/fhir+json: + schema: + type: string + format: binary + "401": *a1 + "404": + description: OperationOutcome (application/fhir+json). + content: + application/fhir+json: + schema: + type: string + format: binary + "422": *a3 + "429": *a4 + /api/fhir/Patient/$everything: get: tags: - FHIR summary: FHIR R4 $everything (v1.11.0) - description: "Read-only `$everything` operation: every resource in the caller's own record (Patient, Coverage, - Observations, MedicationStatements, MedicationAdministrations) in one `searchset` Bundle. Auth: `fhir:read` - scope. Offset paging via `_count` (≤200) / `_offset`." + description: "Read-only `Patient/$everything`: every resource the document bundle carries — Composition, Device, + Patient, Coverage, Observations (including the cycle ones), MedicationStatements, MedicationAdministrations, + Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport — flattened into one + `searchset` Bundle. Scoped to the owner's SAVED report selection; an account that never saved one gets nothing. + Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`." parameters: - in: query name: _count @@ -15670,23 +15777,35 @@ components: minimum: -9007199254740991 maximum: 9007199254740991 description: Maximum lifetime of a share link, in days. No never-expiring share. - resourceTypes: + reportDownload: type: array items: type: string - description: FHIR resource types a share link may be scoped to serve. - sections: + description: Machine formats a share link serves as a download under its token, behind the same unlock as the page. + selectionVersion: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Version of the leaf-selection grammar this build speaks. + groups: type: array items: type: string - description: Scopeable report sections a share link may toggle. + description: Selection group ids, in presentation order. + leaves: + type: array + items: + type: string + description: Every leaf id a report selection may carry. Membership is inclusion; absence is exclusion. required: - supported - maxDays - - resourceTypes - - sections + - reportDownload + - selectionVersion + - groups + - leaves additionalProperties: false - description: Clinician share-link surface descriptor (v1.11). + description: Clinician share-link surface descriptor. required: - apiContractVersion - derivedMetricIds @@ -15719,6 +15838,14 @@ components: type: boolean documentCount: type: number + needsReselection: + type: boolean + allowFhirApi: + type: boolean + resourceTypes: + type: array + items: + type: string expiresAt: type: string createdAt: @@ -15755,6 +15882,9 @@ components: - rangeEnd - protected - documentCount + - needsReselection + - allowFhirApi + - resourceTypes - expiresAt - createdAt - revokedAt @@ -15803,6 +15933,14 @@ components: type: boolean documentCount: type: number + needsReselection: + type: boolean + allowFhirApi: + type: boolean + resourceTypes: + type: array + items: + type: string expiresAt: type: string createdAt: @@ -15826,6 +15964,9 @@ components: - rangeEnd - protected - documentCount + - needsReselection + - allowFhirApi + - resourceTypes - expiresAt - createdAt - revokedAt diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts index b7c476dff..0aa41fec0 100644 --- a/src/__tests__/doctor-report-control-gating-guard.test.ts +++ b/src/__tests__/doctor-report-control-gating-guard.test.ts @@ -1,187 +1,149 @@ /** - * Structural guard: a rendered export control with no gating path fails the - * build. + * Structural guard: a rendered scope control with no gating path fails the + * build, and a gating path with no control fails it too. * - * The health-record export panel shipped twenty data-selection switches, of - * which eight changed nothing at all. `toDoctorReportPrefs` folded the grouped - * wire shape down to twelve flat flags and dropped the rest on the floor, so a - * user who switched off SpO₂, body fat, body composition, resting heart rate, - * HRV, VO₂max, steps or distance exported them anyway — in the PDF, in the FHIR - * bundle, in the package, in a clinician share link and over MCP. Nothing - * failed; the control simply lied, and it lied for several releases because no - * test connected "the panel renders this" to "the export honours this". + * The export panel once shipped twenty data switches, eight of which changed + * nothing at all — the wire fold read twelve of the keys it accepted and + * dropped the rest on the floor. Nothing failed; the control simply lied, and + * it lied for several releases because no test connected "the panel renders + * this" to "the export honours this". * - * This file is that connection, in three links that each break independently: + * The selection model removed the fold, so the shape that defect lived in is + * gone. This file is the same connection retargeted at the leaf catalogue, + * which is now the one thing the picker, the aggregator, the PDF and the share + * view all read: * - * 1. Every control the panel holds state for survives the wire schema. A key - * the schema strips can never be honoured, however carefully the fold is - * written. - * 2. Flipping exactly one control changes the resolved `DoctorReportPrefs` - * the aggregator consumes. This is the assertion that would have caught - * all eight dead switches on the day they were added. - * 3. Every flag in the resolved shape is read downstream — either as a - * per-measurement-type entry in the selection gates or by name in the - * aggregator body. A flag nothing reads is the same defect one layer down. - * - * It also checks the panel actually renders every control it declares, so the - * split between the control shape and its rendering cannot quietly leave a - * declared control with no row (or a row with no gate). + * 1. Every leaf the catalogue declares is reachable in the picker — the + * picker renders from the catalogue, so the check is that the catalogue + * places each leaf in exactly one group and the picker renders each group. + * 2. Flipping exactly one leaf changes the resolved selection. This is the + * assertion that would have caught all eight dead switches. + * 3. Every leaf is honoured somewhere downstream: as a measurement type the + * gate strips, or by name in the aggregator's own body. * * What it does NOT prove: that the excluded data is actually absent from a - * rendered artefact. That is a behavioural property and it is covered where the - * artefacts are built — `src/app/api/export/health-record/__tests__/route.test.ts` - * asserts the PDF, the FHIR bundle and the zip for the same selections. This - * guard is the cheap structural half that runs on every commit and cannot be - * satisfied by writing a control that looks plausible. + * rendered artefact. That is behavioural and is covered where the artefacts are + * built — `src/app/api/export/health-record/__tests__/route.test.ts` opens the + * real PDF, the real bundle and the real zip. This guard is the cheap + * structural half that cannot be satisfied by writing a plausible-looking + * control. + * + * Mutation checks are recorded per assertion below. */ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { - exportSectionsSchema, - toDoctorReportPrefs, -} from "@/lib/validations/health-record-export"; -import { - DEFAULT_DOCTOR_REPORT_PREFS, - type DoctorReportPrefs, -} from "@/lib/validations/doctor-report-prefs"; -import { - DEFAULT_SECTIONS, - buildSelectionSections, - type SectionState, -} from "@/components/settings/health-record-export-controls"; -import { MEASUREMENT_TYPE_SECTION } from "@/lib/doctor-report-selection"; + ALL_LEAF_IDS, + MEASUREMENT_LEAF_GROUP, + REPORT_GROUPS, + STRUCTURED_LEAF_GROUP, + isStructuredLeafId, + leafGroup, + type ReportLeafId, +} from "@/lib/report-selection/catalogue"; +import { selectionFromLeaves } from "@/lib/report-selection/selection"; const REPO_ROOT = join(__dirname, "..", ".."); -const PANEL_PATH = join( +const COLLECT_PATH = join( + REPO_ROOT, + "src", + "lib", + "doctor-report", + "collect.ts", +); +const PICKER_PATH = join( REPO_ROOT, "src", "components", "settings", - "health-record-export-panel.tsx", + "report-selection", + "report-scope-picker.tsx", ); -const AGGREGATOR_PATH = join(REPO_ROOT, "src", "lib", "doctor-report-data.ts"); -const panelSource = readFileSync(PANEL_PATH, "utf8"); -const aggregatorSource = readFileSync(AGGREGATOR_PATH, "utf8"); +const collectSource = readFileSync(COLLECT_PATH, "utf8"); +const pickerSource = readFileSync(PICKER_PATH, "utf8"); -/** Strip comments so a key mentioned only in prose does not count as read. */ +/** Strip comments so a leaf named only in prose does not count as read. */ function stripComments(source: string): string { return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); } -/** - * The control set, taken from the panel's own starting values rather than from - * a list this test maintains — a list would drift exactly the way the fold - * drifted. `DEFAULT_SECTIONS` is typed `SectionState`, so TypeScript already - * forces it to name every control, and every rendered row must bind one of - * them. "Declared here" and "rendered" are therefore the same set. - */ -const CONTROL_KEYS = Object.keys(DEFAULT_SECTIONS) as (keyof SectionState)[]; - -/** All controls on, as the panel's state shape. */ -function allOn(): SectionState { - return Object.fromEntries( - CONTROL_KEYS.map((k) => [k, true]), - ) as unknown as SectionState; -} - -/** - * The exact path a selection takes in production: panel state → grouped wire - * shape → the route's Zod parse (which strips anything the schema does not - * declare) → the flat prefs the aggregator consumes. - */ -function resolve(state: SectionState): DoctorReportPrefs { - return toDoctorReportPrefs( - exportSectionsSchema.parse(buildSelectionSections(state)), - ); -} - -describe("export control gating — structural guard", () => { - it("reads a plausible control set", () => { - // Sanity floor: if the control shape silently degraded, every assertion - // below would pass vacuously. - expect(CONTROL_KEYS.length).toBeGreaterThanOrEqual(20); - expect(CONTROL_KEYS).toContain("bp"); - expect(CONTROL_KEYS).toContain("mentalHealthScreeners"); +describe("report scope gating — structural guard", () => { + it("reads a plausible catalogue", () => { + // Sanity floor: a silently degraded catalogue would pass everything below + // vacuously. + expect(ALL_LEAF_IDS.length).toBe(90); + expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77); + expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(13); }); - it("renders a row for every control it declares", () => { - const unrendered = CONTROL_KEYS.filter( - (key) => !panelSource.includes(`sections.${key}`), - ); - expect( - unrendered, - `These controls are declared in the selection shape but the panel binds ` + - `no row to them, so the user has no way to reach them.`, - ).toEqual([]); - }); - - it("keeps every rendered control inside the wire schema", () => { - const wire = buildSelectionSections(allOn()); - // A round trip through the schema must not lose anything the panel sends. - expect(exportSectionsSchema.parse(wire)).toEqual(wire); - }); - - it("makes flipping any single control change the resolved selection", () => { - const baseline = resolve(allOn()); - const inert: string[] = []; - - for (const key of CONTROL_KEYS) { - const flipped = resolve({ ...allOn(), [key]: false }); - if (JSON.stringify(flipped) === JSON.stringify(baseline)) { - inert.push(key); + it("places every leaf in exactly one group", () => { + const seen = new Map(); + for (const group of REPORT_GROUPS) { + for (const leaf of group.leaves) { + seen.set(leaf, (seen.get(leaf) ?? 0) + 1); } } + const duplicated = [...seen.entries()].filter(([, n]) => n > 1); + const missing = ALL_LEAF_IDS.filter((leaf) => !seen.has(leaf)); + expect(duplicated, "a leaf appears in more than one group").toEqual([]); + expect(missing, "a leaf is in no group and so is unreachable").toEqual([]); + }); + it("renders every group the catalogue declares", () => { + // The picker maps over `REPORT_GROUPS` and splits the fenced tier out by + // id, so this is the check that the split cannot silently drop a group. + expect(pickerSource).toContain("REPORT_GROUPS.map"); + expect(pickerSource).toContain("SENSITIVE_GROUP_ID"); + }); + + it("makes flipping any single leaf change the resolved selection", () => { + const all = selectionFromLeaves(ALL_LEAF_IDS); + const inert: ReportLeafId[] = []; + for (const leaf of ALL_LEAF_IDS) { + const without = selectionFromLeaves( + ALL_LEAF_IDS.filter((l) => l !== leaf), + ); + if ( + without.has(leaf) || + without.leaves.length !== all.leaves.length - 1 + ) { + inert.push(leaf); + } + } expect( inert, - `These panel controls are rendered but change nothing in the resolved ` + - `export selection. Give each one a flag in DoctorReportPrefs and a ` + - `gating path in doctor-report-data.ts, or remove the control.`, + "these leaves cannot be individually withheld, so their control would lie", ).toEqual([]); }); - it("has something downstream read every flag in the resolved selection", () => { - // A flag is honoured either by gating measurement types or by name in the - // aggregator body (the structured sections: labs, allergies, the - // medication block, the glucose panel). - const mappedFlags = new Set( - Object.values(MEASUREMENT_TYPE_SECTION), + it("has something downstream read every leaf", () => { + // A leaf is honoured either as a measurement type — the gate strips those + // by set membership over the whole enum — or by name in the aggregator. + const body = stripComments(collectSource); + const unread = ALL_LEAF_IDS.filter( + (leaf) => isStructuredLeafId(leaf) && !body.includes(`"${leaf}"`), ); - const body = stripComments(aggregatorSource); - - const unread = Object.keys(DEFAULT_DOCTOR_REPORT_PREFS).filter( - (key) => !mappedFlags.has(key) && !body.includes(`sections.${key}`), - ); - expect( unread, - `These resolved selection flags are never read downstream, so switching ` + - `them off would change nothing that leaves the instance.`, + "these structured leaves are never read in the aggregator, so switching " + + "them off would change nothing that leaves the instance", ).toEqual([]); }); - it("keeps the sensitive selections off unless asked for by name", () => { - // Mood, cycle and the screening instruments must never ride an absent key: - // the mental-health module went default-ON in v1.29.1 and silently made - // PHQ-9 and GAD-7 totals part of a default export. - const fromNothing = toDoctorReportPrefs(undefined); - expect(fromNothing.mood).toBe(false); - expect(fromNothing.cycle).toBe(false); - expect(fromNothing.mentalHealthScreeners).toBe(false); - - // A partial selection that names other things must not flip them on. - const partial = toDoctorReportPrefs({ labs: true, vitals: { bp: true } }); - expect(partial.mood).toBe(false); - expect(partial.cycle).toBe(false); - expect(partial.mentalHealthScreeners).toBe(false); - - // Only an explicit `true` does. - expect( - toDoctorReportPrefs({ mentalHealthScreeners: true }) - .mentalHealthScreeners, - ).toBe(true); + it("keeps every sensitive leaf in the fenced tier", () => { + for (const leaf of [ + "MOOD", + "CYCLE", + "FAMILY_HISTORY", + "PHQ9_SCORE", + "GAD7_SCORE", + "WHO5_SCORE", + "SCI_SCORE", + ] as ReportLeafId[]) { + expect(leafGroup(leaf), `${leaf} must be fenced`).toBe("sensitive"); + } }); }); diff --git a/src/__tests__/report-selection-egress-guard.test.ts b/src/__tests__/report-selection-egress-guard.test.ts new file mode 100644 index 000000000..0601165d1 --- /dev/null +++ b/src/__tests__/report-selection-egress-guard.test.ts @@ -0,0 +1,83 @@ +/** + * Structural guard: every caller of the doctor-report aggregator is named here + * with the source of its selection. + * + * The aggregator's third parameter is required and brand-typed, so a new + * egress path cannot compile without naming a selection source. This is the + * second half of that: it cannot be ADDED without a human writing down which + * source, in this list, which is the moment a reviewer asks the consent + * question. Modelled on `bearer-scope-enforcement-guard.test.ts`. + * + * Adding a caller fails the build until the list is updated deliberately. + * + * Mutation check: add a `collectDoctorReportData(` call anywhere under `src/` + * that is not in the list → "the set of aggregator callers is frozen" goes red + * naming the file. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +const SRC = join(__dirname, ".."); +const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]); + +/** + * Every production call site, with the source of its selection. A path here + * that no longer calls the aggregator is as much a failure as one that does + * and is missing — a stale entry is how a list stops being read. + */ +const CALLERS: Record = { + "app/api/export/health-record/route.ts": + "the request body's `selection` field, minted through selectionFromRequest", + "lib/clinician-share/share-view-data.ts": + "the share link's frozen sectionsJson, minted through selectionFromStoredBlob", + "lib/fhir/rest.ts": "the owner's saved profile (resolveSavedSelection)", + "lib/mcp/resources.ts": "the owner's saved profile (resolveSavedSelection)", + "lib/mcp/prompts.ts": "the owner's saved profile (resolveSavedSelection)", +}; + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + if (SKIP_DIRS.has(name)) continue; + const p = join(dir, name); + if (statSync(p).isDirectory()) walk(p, out); + else if (name.endsWith(".ts") || name.endsWith(".tsx")) out.push(p); + } + return out; +} + +/** Strip comments so a mention in prose is not read as a call. */ +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); +} + +describe("doctor-report aggregator egress", () => { + it("keeps the set of aggregator callers frozen", () => { + const found: string[] = []; + for (const file of walk(SRC)) { + const rel = file.slice(SRC.length + 1).replace(/\\/g, "/"); + // The aggregator's own module and its public surface are not callers. + if (rel === "lib/doctor-report/collect.ts") continue; + if (rel === "lib/doctor-report-data.ts") continue; + const body = stripComments(readFileSync(file, "utf8")); + if (body.includes("collectDoctorReportData(")) found.push(rel); + } + + const expected = Object.keys(CALLERS).sort(); + expect( + found.sort(), + "Every surface that assembles the whole record has to name where its " + + "selection came from. Add the file to CALLERS with its source, or " + + "remove the call.", + ).toEqual(expected); + }); + + it("names a real selection source for each caller", () => { + for (const [path, source] of Object.entries(CALLERS)) { + expect( + source.length, + `${path} has no stated selection source`, + ).toBeGreaterThan(10); + } + }); +}); diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index e5815ce7b..f8ab6cc74 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -1,11 +1,16 @@ /** - * v1.7.0 — POST /api/export/health-record unit coverage. + * POST /api/export/health-record unit coverage. * * Mock-based (no testcontainers): pins the route contract — strict Zod - * rejection paths (422 via returnAllZodIssues), rate-limit (429), the - * three format outputs (PDF magic bytes, application/fhir+json valid - * Bundle, application/zip), and that the user is narrowed from the - * session (never the body). + * rejection paths (422 via returnAllZodIssues), rate-limit (429), the three + * format outputs (PDF magic bytes, application/fhir+json valid Bundle, + * application/zip), and that the user is narrowed from the session, never the + * body. + * + * The second half is the artefact-level proof that the selection means the + * same thing in every format: each case runs the real route end to end and + * reads what comes out, with a positive control beside each exclusion so + * nothing can pass on an empty fixture. */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; @@ -24,6 +29,23 @@ vi.mock("@/lib/db", () => ({ familyHistoryEntry: { findMany: vi.fn() }, user: { findUnique: vi.fn(), update: vi.fn() }, }, + toJson: (value: unknown) => value, +})); +// The cycle summary has its own aggregation behind it; this suite is about the +// route, so it is stubbed to a fixed shape. Whether it is CALLED at all is the +// property that matters here, and the aggregator sweep asserts that directly. +vi.mock("@/lib/cycle/export-data", () => ({ + buildCycleExportSummary: vi.fn(async () => ({ + lastPeriodStart: "2026-02-01", + recentCycles: [ + { startDate: "2026-02-01", lengthDays: 28, periodLengthDays: 5 }, + ], + observedCycleCount: 1, + averageCycleLengthDays: 28, + cycleLengthVariabilityDays: null, + averagePeriodLengthDays: 5, + currentPhase: "FOLLICULAR", + })), })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); @@ -57,6 +79,7 @@ vi.mock("@/lib/modules/gate", async (importOriginal) => { }); import { prisma } from "@/lib/db"; +import { ALL_LEAF_IDS } from "@/lib/report-selection/catalogue"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; import { @@ -69,6 +92,16 @@ const SESSION_OK = { user: { id: "user-1", email: "test@example.com", role: "USER" }, } as const; +/** A selection body carrying exactly these leaves. */ +function sel(...leaves: string[]) { + return { v: 2, leaves }; +} + +/** Everything the catalogue knows — the positive-control scope. */ +function allLeaves() { + return { v: 2, leaves: [...ALL_LEAF_IDS] }; +} + function mkReq( body: unknown, headers: Record = {}, @@ -129,19 +162,21 @@ beforeEach(() => { describe("POST /api/export/health-record — validation", () => { it("rejects an unknown format with 422", async () => { const { POST } = await import("../route"); - const res = await POST(mkReq({ format: "xml" })); + const res = await POST(mkReq({ format: "xml", selection: sel() })); expect(res.status).toBe(422); }); it("rejects a missing format with 422", async () => { const { POST } = await import("../route"); - const res = await POST(mkReq({})); + const res = await POST(mkReq({ selection: sel() })); expect(res.status).toBe(422); }); it("rejects a userId smuggled into the body with 422", async () => { const { POST } = await import("../route"); - const res = await POST(mkReq({ format: "pdf", userId: "user-2" })); + const res = await POST( + mkReq({ format: "pdf", selection: sel(), userId: "user-2" }), + ); expect(res.status).toBe(422); }); @@ -155,7 +190,7 @@ describe("POST /api/export/health-record — validation", () => { }), } as never); const { POST } = await import("../route"); - const res = await POST(mkReq({ format: "fhir" })); + const res = await POST(mkReq({ format: "fhir", selection: sel() })); expect(res.status).toBe(403); expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "doctorReport"); }); @@ -167,7 +202,7 @@ describe("POST /api/export/health-record — validation", () => { resetAt: Date.now() + 3_600_000, } as never); const { POST } = await import("../route"); - const res = await POST(mkReq({ format: "fhir" })); + const res = await POST(mkReq({ format: "fhir", selection: sel() })); expect(res.status).toBe(429); }); }); @@ -175,7 +210,7 @@ describe("POST /api/export/health-record — validation", () => { describe("POST /api/export/health-record — outputs", () => { it("format=fhir returns a valid FHIR document Bundle", async () => { const { POST } = await import("../route"); - const res = await POST(mkReq({ format: "fhir" })); + const res = await POST(mkReq({ format: "fhir", selection: allLeaves() })); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toContain("application/fhir+json"); const bundle = await res.json(); @@ -186,7 +221,7 @@ describe("POST /api/export/health-record — outputs", () => { it("format=pdf returns a PDF with the %PDF- magic bytes", async () => { const { POST } = await import("../route"); - const res = await POST(mkReq({ format: "pdf" })); + const res = await POST(mkReq({ format: "pdf", selection: allLeaves() })); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toBe("application/pdf"); const buf = Buffer.from(await res.arrayBuffer()); @@ -195,7 +230,9 @@ describe("POST /api/export/health-record — outputs", () => { it("format=package returns a zip", async () => { const { POST } = await import("../route"); - const res = await POST(mkReq({ format: "package" })); + const res = await POST( + mkReq({ format: "package", selection: allLeaves() }), + ); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toBe("application/zip"); const buf = Buffer.from(await res.arrayBuffer()); @@ -206,7 +243,12 @@ describe("POST /api/export/health-record — outputs", () => { it("renders the PDF in the explicit selection locale", async () => { const { POST } = await import("../route"); const res = await POST( - mkReq({ format: "pdf", locale: "de", practiceName: "Sample Practice" }), + mkReq({ + format: "pdf", + locale: "de", + selection: allLeaves(), + practiceName: "Sample Practice", + }), ); const text = await pdfText(res); // German cover label. @@ -217,7 +259,11 @@ describe("POST /api/export/health-record — outputs", () => { const { POST } = await import("../route"); const res = await POST( mkReq( - { format: "pdf", practiceName: "Sample Practice" }, + { + format: "pdf", + selection: allLeaves(), + practiceName: "Sample Practice", + }, { // Browser default English, but the in-app cookie says German — the // cookie must win over Accept-Language. @@ -235,7 +281,11 @@ describe("POST /api/export/health-record — outputs", () => { const { POST } = await import("../route"); const res = await POST( mkReq( - { format: "pdf", practiceName: "Sample Practice" }, + { + format: "pdf", + selection: allLeaves(), + practiceName: "Sample Practice", + }, { "accept-language": "en-US,en;q=0.9" }, ), ); @@ -245,10 +295,12 @@ describe("POST /api/export/health-record — outputs", () => { it("scopes the aggregator measurement read to the session user", async () => { const { POST } = await import("../route"); - await POST(mkReq({ format: "fhir", userId: "user-2" } as never)); + await POST( + mkReq({ format: "fhir", selection: sel(), userId: "user-2" } as never), + ); // userId smuggle is rejected above; here we confirm the read uses the // session user, not anything from the body, for a clean payload. - await POST(mkReq({ format: "fhir" })); + await POST(mkReq({ format: "fhir", selection: allLeaves() })); expect(prisma.measurement.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ userId: "user-1", deletedAt: null }), @@ -335,11 +387,12 @@ describe("POST /api/export/health-record — the selection reaches every format" } /** Resource types present in the bundle a given selection produces. */ - async function bundleResourceTypes( - sections: Record, - ): Promise { + async function bundleResourceTypes(selection: { + v: number; + leaves: string[]; + }): Promise { const { POST } = await import("../route"); - const res = await POST(mkReq({ format: "fhir", sections })); + const res = await POST(mkReq({ format: "fhir", selection })); expect(res.status).toBe(200); const bundle = (await res.json()) as { entry: { resource: { resourceType: string; code?: unknown } }[]; @@ -348,11 +401,12 @@ describe("POST /api/export/health-record — the selection reaches every format" } /** The LOINC/HealthKit codes of every Observation a selection produces. */ - async function observationCodes( - sections: Record, - ): Promise { + async function observationCodes(selection: { + v: number; + leaves: string[]; + }): Promise { const { POST } = await import("../route"); - const res = await POST(mkReq({ format: "fhir", sections })); + const res = await POST(mkReq({ format: "fhir", selection })); expect(res.status).toBe(200); const bundle = (await res.json()) as { entry: { @@ -382,36 +436,35 @@ describe("POST /api/export/health-record — the selection reaches every format" measurement("WALKING_RUNNING_DISTANCE", 6200), ] as never); - // Positive control: with every switch on, all eight are emitted. Without - // this the exclusion assertion below could pass on an empty bundle. - const on = await observationCodes({ - vitals: { oxygenSaturation: true, bodyFat: true, bodyComposition: true }, - cardioFitness: { restingHeartRate: true, hrv: true, vo2max: true }, - activity: { steps: true, distance: true }, - }); + // Positive control: with all eight leaves chosen, all eight are emitted. + // Without this the exclusion assertion below could pass on an empty bundle. + const on = await observationCodes( + sel( + "BODY_FAT", + "OXYGEN_SATURATION", + "BONE_MASS", + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "VO2_MAX", + "ACTIVITY_STEPS", + "WALKING_RUNNING_DISTANCE", + ), + ); expect(on.length).toBeGreaterThanOrEqual(8); - const off = await observationCodes({ - vitals: { - oxygenSaturation: false, - bodyFat: false, - bodyComposition: false, - }, - cardioFitness: { restingHeartRate: false, hrv: false, vo2max: false }, - activity: { steps: false, distance: false }, - }); + // A selection naming something else entirely carries none of them — + // absence of a leaf is exclusion, not a default. + const off = await observationCodes(sel("PULSE")); expect(off).toEqual([]); }); it("keeps the deselected measurement rows out of the query in the first place", async () => { - await bundleResourceTypes({ - vitals: { bodyFat: false }, - cardioFitness: { vo2max: false }, - }); + await bundleResourceTypes(sel("PULSE")); const lastCall = vi.mocked(prisma.measurement.findMany).mock.calls.at(-1)!; const where = lastCall[0]!.where as { type?: { notIn?: string[] } }; expect(where.type?.notIn).toContain("BODY_FAT"); expect(where.type?.notIn).toContain("VO2_MAX"); + expect(where.type?.notIn).not.toContain("PULSE"); }); it("omits the deselected vitals from the PDF table", async () => { @@ -426,22 +479,22 @@ describe("POST /api/export/health-record — the selection reaches every format" mkReq({ format: "pdf", locale: "en", - sections: { vitals: { bodyFat: true, oxygenSaturation: true } }, + selection: sel("BODY_FAT", "OXYGEN_SATURATION"), }), ), ); - expect(withBoth).toContain("Body fat"); + expect(withBoth).toContain("Body Fat"); const without = await pdfText( await POST( mkReq({ format: "pdf", locale: "en", - sections: { vitals: { bodyFat: false, oxygenSaturation: false } }, + selection: sel("PULSE"), }), ), ); - expect(without).not.toContain("Body fat"); + expect(without).not.toContain("Body Fat"); }); it("holds the mental-health screening totals back unless they are asked for by name", async () => { @@ -450,12 +503,13 @@ describe("POST /api/export/health-record — the selection reaches every format" measurement("GAD7_SCORE", 8), ] as never); - // The module is on (the default since v1.29.1) and the caller sends no - // screening key: the totals must stay in the database. - expect(await observationCodes({ labs: true })).toEqual([]); - // And an omitted `sections` object entirely — the MCP / share-link case. + // The module is on (the default since v1.29.1) and the caller names other + // leaves: the totals must stay in the database. + expect(await observationCodes(sel("LAB_RESULTS"))).toEqual([]); + // And an empty selection — the "I said nothing" case, which used to mean + // "give me everything". const { POST } = await import("../route"); - const res = await POST(mkReq({ format: "fhir" })); + const res = await POST(mkReq({ format: "fhir", selection: sel() })); const bundle = (await res.json()) as { entry: { resource: { resourceType: string } }[]; }; @@ -463,8 +517,8 @@ describe("POST /api/export/health-record — the selection reaches every format" bundle.entry.filter((e) => e.resource.resourceType === "Observation"), ).toEqual([]); - // Asked for explicitly, they are emitted — the data is withheld, not lost. - const asked = await observationCodes({ mentalHealthScreeners: true }); + // Asked for by name, they are emitted — the data is withheld, not lost. + const asked = await observationCodes(sel("PHQ9_SCORE", "GAD7_SCORE")); expect(asked).toContain("44261-6"); expect(asked).toContain("70274-6"); }); @@ -474,11 +528,11 @@ describe("POST /api/export/health-record — the selection reaches every format" medicationRow(), ] as never); + expect(await bundleResourceTypes(sel("MEDICATION_LIST"))).toContain( + "MedicationStatement", + ); expect( - await bundleResourceTypes({ medications: { list: true } }), - ).toContain("MedicationStatement"); - expect( - await bundleResourceTypes({ medications: { list: false } }), + await bundleResourceTypes(sel("MEDICATION_COMPLIANCE")), ).not.toContain("MedicationStatement"); }); @@ -490,23 +544,17 @@ describe("POST /api/export/health-record — the selection reaches every format" familyHistoryRow(), ] as never); - const on = await bundleResourceTypes({ - allergies: true, - familyHistory: true, - }); + const on = await bundleResourceTypes(sel("ALLERGIES", "FAMILY_HISTORY")); expect(on).toContain("AllergyIntolerance"); expect(on).toContain("FamilyMemberHistory"); - const off = await bundleResourceTypes({ - allergies: false, - familyHistory: false, - }); + const off = await bundleResourceTypes(sel("PULSE")); expect(off).not.toContain("AllergyIntolerance"); expect(off).not.toContain("FamilyMemberHistory"); }); it("never reads the deselected records from the database", async () => { - await bundleResourceTypes({ allergies: false, familyHistory: false }); + await bundleResourceTypes(sel("PULSE")); expect(prisma.allergy.findMany).not.toHaveBeenCalled(); expect(prisma.familyHistoryEntry.findMany).not.toHaveBeenCalled(); }); @@ -521,10 +569,7 @@ describe("POST /api/export/health-record — the selection reaches every format" const { POST } = await import("../route"); const res = await POST( - mkReq({ - format: "package", - sections: { allergies: false, familyHistory: false }, - }), + mkReq({ format: "package", selection: sel("PULSE") }), ); expect(res.status).toBe(200); const files = unzipSync(new Uint8Array(await res.arrayBuffer())); @@ -539,53 +584,111 @@ describe("POST /api/export/health-record — the selection reaches every format" }); /** - * The practice name is a remembered preference (`User.lastReportPracticeName`), - * so a monthly report does not ask for the clinic name again every time. The - * write happens only once the artefact exists — a generation that throws must - * not overwrite the remembered value with a half-typed one. + * What the route remembers: the practice name and the scope the owner chose, + * so the panel opens carrying both next time and the surfaces that cannot ask + * a human have a real act to replay. * - * Mutation check: drop the `rememberPracticeName` call in the route and the - * first two cases go red; move it above the generation and the - * failed-generation case goes red. + * The write happens only once the artefact exists — a generation that throws + * must not overwrite the remembered values with a half-typed name or a scope + * that produced nothing. + * + * Mutation checks: drop the `rememberChoices` call and the first cases go red; + * move it above the generation and the failed-generation case goes red; drop + * `reportSelectionJson` from the update and "remembers the chosen scope" goes + * red. */ -describe("POST /api/export/health-record — remembered practice name", () => { +describe("POST /api/export/health-record — what the route remembers", () => { it("persists the sanitised practice name after a PDF export", async () => { const { POST } = await import("../route"); const res = await POST( - mkReq({ format: "pdf", practiceName: " Sample Practice " }), + mkReq({ + format: "pdf", + selection: sel("WEIGHT"), + practiceName: " Sample Practice ", + }), ); expect(res.status).toBe(200); expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: "user-1" }, - data: { lastReportPracticeName: "Sample Practice" }, + data: expect.objectContaining({ + lastReportPracticeName: "Sample Practice", + }), }); }); + it("remembers the chosen scope, in catalogue order", async () => { + const { POST } = await import("../route"); + const res = await POST( + mkReq({ + format: "fhir", + selection: sel("LAB_RESULTS", "WEIGHT"), + range: { days: 180 }, + includeCharts: false, + }), + ); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: expect.objectContaining({ + reportSelectionJson: { + v: 2, + leaves: ["WEIGHT", "LAB_RESULTS"], + format: "fhir", + rangeDays: 180, + includeCharts: false, + }, + }), + }); + }); + + it("refuses a leaf id this build does not know, and remembers nothing", async () => { + const { POST } = await import("../route"); + const res = await POST( + mkReq({ format: "pdf", selection: sel("SOMETHING_NEW") }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("SOMETHING_NEW"); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + it("persists the practice name after a FHIR export", async () => { const { POST } = await import("../route"); const res = await POST( - mkReq({ format: "fhir", practiceName: "Sample Practice" }), + mkReq({ + format: "fhir", + selection: sel("WEIGHT"), + practiceName: "Sample Practice", + }), ); expect(res.status).toBe(200); expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: "user-1" }, - data: { lastReportPracticeName: "Sample Practice" }, + data: expect.objectContaining({ + lastReportPracticeName: "Sample Practice", + }), }); }); it("persists null when the user cleared the practice name", async () => { const { POST } = await import("../route"); - const res = await POST(mkReq({ format: "pdf" })); + const res = await POST(mkReq({ format: "pdf", selection: sel("WEIGHT") })); expect(res.status).toBe(200); expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: "user-1" }, - data: { lastReportPracticeName: null }, + data: expect.objectContaining({ lastReportPracticeName: null }), }); }); it("writes nothing when the selection is rejected", async () => { const { POST } = await import("../route"); - const res = await POST(mkReq({ format: "xml", practiceName: "Sample" })); + const res = await POST( + mkReq({ + format: "xml", + selection: sel("WEIGHT"), + practiceName: "Sample", + }), + ); expect(res.status).toBe(422); expect(prisma.user.update).not.toHaveBeenCalled(); }); @@ -596,7 +699,11 @@ describe("POST /api/export/health-record — remembered practice name", () => { ); const { POST } = await import("../route"); const res = await POST( - mkReq({ format: "pdf", practiceName: "Sample Practice" }), + mkReq({ + format: "pdf", + selection: sel("WEIGHT"), + practiceName: "Sample Practice", + }), ); expect(res.status).toBe(500); expect(prisma.user.update).not.toHaveBeenCalled(); @@ -604,7 +711,13 @@ describe("POST /api/export/health-record — remembered practice name", () => { it("narrows the update to the session user, never the body", async () => { const { POST } = await import("../route"); - await POST(mkReq({ format: "fhir", practiceName: "Sample Practice" })); + await POST( + mkReq({ + format: "fhir", + selection: sel("WEIGHT"), + practiceName: "Sample Practice", + }), + ); expect(prisma.user.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: "user-1" } }), ); diff --git a/src/app/api/fhir/Coverage/[id]/route.ts b/src/app/api/fhir/Coverage/[id]/route.ts new file mode 100644 index 000000000..e721c709c --- /dev/null +++ b/src/app/api/fhir/Coverage/[id]/route.ts @@ -0,0 +1,50 @@ +/** + * GET /api/fhir/Coverage/{id} — FHIR R4 `read` of one Coverage from the + * caller's own record. + * + * The id space is the export's own (`coverage-1`), stable for a given record and + * reporting window. A `read` is a filter over the same emitter the search + * route pages, so a resource can never differ between the two faces; an id the + * caller's record does not hold is a `not-found` OperationOutcome. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { coverageResource } from "@/lib/fhir/resources"; +import { + FHIR_READ_SCOPE, + loadFhirContext, + operationOutcome, + readResponse, +} from "@/lib/fhir/rest"; + +export const GET = apiHandler( + async (_request: NextRequest, ctx: { params: Promise<{ id: string }> }) => { + const { user } = await requireAuth(FHIR_READ_SCOPE); + annotate({ action: { name: "fhir.coverage.read" } }); + + // REFUSE, NOT OMIT. The FHIR face serves the SAME whole-record aggregate as + // `/api/export/health-record`, right down to the decrypted insurance number + // on the Patient resource, so it gates on the same `doctorReport` module. + // There is no partial answer that is still a truthful FHIR Bundle, and no + // background client depends on this draining to stay consistent. The 403 + // `module.disabled` envelope (rather than an OperationOutcome) keeps the + // errorCode clients already branch on for a disabled module. + const gate = await requireModuleEnabled(user.id, "doctorReport"); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000); + if (!rl.allowed) { + return operationOutcome(429, "throttled", "Rate limit exceeded"); + } + const { id } = await ctx.params; + const context = await loadFhirContext(user.id); + annotate({ meta: { resourceId: id } }); + return readResponse([coverageResource(context.data, context.identity)].filter( + (resource) => resource !== null, + ), id); + }, +); diff --git a/src/app/api/fhir/Coverage/route.ts b/src/app/api/fhir/Coverage/route.ts new file mode 100644 index 000000000..ce45cbc0f --- /dev/null +++ b/src/app/api/fhir/Coverage/route.ts @@ -0,0 +1,55 @@ +/** + * GET /api/fhir/Coverage — FHIR R4 `searchset` of the caller's own insurance Coverage. + * + * Present only when an insurer is known — R4 requires a payor, and a record + * that carries only a member id has none, so the search is empty rather than + * carrying a Coverage that names nobody. + * + * Read-only. `userId` is narrowed from `requireAuth`; the shared emitters in + * `@/lib/fhir/resources` are the single source of the coding, so this face and + * the document export can never describe the same record differently. Offset + * paging via `_count` (clamped ≤200) / `_offset`. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { coverageResource } from "@/lib/fhir/resources"; +import { + FHIR_READ_SCOPE, + loadFhirContext, + operationOutcome, + parsePaging, + searchsetResponse, +} from "@/lib/fhir/rest"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(FHIR_READ_SCOPE); + annotate({ action: { name: "fhir.coverage.search" } }); + + // REFUSE, NOT OMIT. The FHIR face serves the SAME whole-record aggregate as + // `/api/export/health-record`, right down to the decrypted insurance number + // on the Patient resource, so it gates on the same `doctorReport` module. + // There is no partial answer that is still a truthful FHIR Bundle, and no + // background client depends on this draining to stay consistent. The 403 + // `module.disabled` envelope (rather than an OperationOutcome) keeps the + // errorCode clients already branch on for a disabled module. + const gate = await requireModuleEnabled(user.id, "doctorReport"); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000); + if (!rl.allowed) { + return operationOutcome(429, "throttled", "Rate limit exceeded"); + } + const { count, offset } = parsePaging(request.nextUrl.searchParams); + const context = await loadFhirContext(user.id); + + const all = [coverageResource(context.data, context.identity)].filter( + (resource) => resource !== null, + ); + const page = all.slice(offset, offset + count); + annotate({ meta: { total: all.length, count, offset } }); + return searchsetResponse(request.nextUrl, page, all.length, count, offset); +}); diff --git a/src/app/api/fhir/MedicationAdministration/[id]/route.ts b/src/app/api/fhir/MedicationAdministration/[id]/route.ts new file mode 100644 index 000000000..5725ef1dc --- /dev/null +++ b/src/app/api/fhir/MedicationAdministration/[id]/route.ts @@ -0,0 +1,53 @@ +/** + * GET /api/fhir/MedicationAdministration/{id} — FHIR R4 `read` of one MedicationAdministration from the + * caller's own record. + * + * The id space is the export's own (`medadmin-7`), stable for a given record and + * reporting window. A `read` is a filter over the same emitter the search + * route pages, so a resource can never differ between the two faces; an id the + * caller's record does not hold is a `not-found` OperationOutcome. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { medicationAdministrationsFromReportData } from "@/lib/fhir/resources"; +import { + FHIR_READ_SCOPE, + loadFhirContext, + operationOutcome, + readResponse, +} from "@/lib/fhir/rest"; + +export const GET = apiHandler( + async (_request: NextRequest, ctx: { params: Promise<{ id: string }> }) => { + const { user } = await requireAuth(FHIR_READ_SCOPE); + annotate({ action: { name: "fhir.medicationadministration.read" } }); + + // REFUSE, NOT OMIT. The FHIR face serves the SAME whole-record aggregate as + // `/api/export/health-record`, right down to the decrypted insurance number + // on the Patient resource, so it gates on the same `doctorReport` module. + // There is no partial answer that is still a truthful FHIR Bundle, and no + // background client depends on this draining to stay consistent. The 403 + // `module.disabled` envelope (rather than an OperationOutcome) keeps the + // errorCode clients already branch on for a disabled module. + const gate = await requireModuleEnabled(user.id, "doctorReport"); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000); + if (!rl.allowed) { + return operationOutcome(429, "throttled", "Rate limit exceeded"); + } + const { id } = await ctx.params; + const context = await loadFhirContext(user.id); + annotate({ meta: { resourceId: id } }); + return readResponse( + medicationAdministrationsFromReportData(context.data, { + germanAtc: context.germanAtc, + }), + id, + ); + }, +); diff --git a/src/app/api/fhir/MedicationAdministration/route.ts b/src/app/api/fhir/MedicationAdministration/route.ts index 747b8f71b..361d1eed6 100644 --- a/src/app/api/fhir/MedicationAdministration/route.ts +++ b/src/app/api/fhir/MedicationAdministration/route.ts @@ -1,10 +1,12 @@ /** - * GET /api/fhir/MedicationAdministration — FHIR R4 `searchset` of the caller's - * own acted intakes (`completed` taken / `not-done` skip), with the same - * ATC/RxNorm + dose/route/site coding the document export carries. + * GET /api/fhir/MedicationAdministration — FHIR R4 `searchset` of the caller's own acted + * intakes (`completed` taken / `not-done` skip), with the same ATC/RxNorm + + * dose/route/site coding the document export carries. * - * Read-only. `userId` narrowed from `requireAuth`; shared emitter is the - * single source of the coding. Offset paging via `_count` (≤200) / `_offset`. + * Read-only. `userId` is narrowed from `requireAuth`; the shared emitters in + * `@/lib/fhir/resources` are the single source of the coding, so this face and + * the document export can never describe the same record differently. Offset + * paging via `_count` (clamped ≤200) / `_offset`. */ import { NextRequest } from "next/server"; @@ -25,18 +27,13 @@ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(FHIR_READ_SCOPE); annotate({ action: { name: "fhir.medicationadministration.search" } }); - // v1.30 — the FHIR REST face serves the SAME whole-record aggregate as - // `/api/export/health-record` (the doctor-report builder), right down to - // the decrypted insurance number on the Patient resource. That export - // gates on the `doctorReport` module; this surface did not, so the module - // could be off and `/api/fhir/*` still handed out the full record. - // - // REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is - // a whole-record export, not an incremental feed: there is no partial - // answer that is still a truthful FHIR Bundle, and no background client - // depends on it draining to stay consistent. Mirroring the sibling export's - // 403 `module.disabled` envelope (rather than an OperationOutcome) keeps - // the errorCode the clients already branch on for a disabled module. + // REFUSE, NOT OMIT. The FHIR face serves the SAME whole-record aggregate as + // `/api/export/health-record`, right down to the decrypted insurance number + // on the Patient resource, so it gates on the same `doctorReport` module. + // There is no partial answer that is still a truthful FHIR Bundle, and no + // background client depends on this draining to stay consistent. The 403 + // `module.disabled` envelope (rather than an OperationOutcome) keeps the + // errorCode clients already branch on for a disabled module. const gate = await requireModuleEnabled(user.id, "doctorReport"); if (!gate.enabled) return gate.response; @@ -44,11 +41,12 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!rl.allowed) { return operationOutcome(429, "throttled", "Rate limit exceeded"); } - const { count, offset } = parsePaging(request.nextUrl.searchParams); - const { data, germanAtc } = await loadFhirContext(user.id); + const context = await loadFhirContext(user.id); - const all = medicationAdministrationsFromReportData(data, { germanAtc }); + const all = medicationAdministrationsFromReportData(context.data, { + germanAtc: context.germanAtc, + }); const page = all.slice(offset, offset + count); annotate({ meta: { total: all.length, count, offset } }); return searchsetResponse(request.nextUrl, page, all.length, count, offset); diff --git a/src/app/api/fhir/MedicationStatement/[id]/route.ts b/src/app/api/fhir/MedicationStatement/[id]/route.ts new file mode 100644 index 000000000..d3366f236 --- /dev/null +++ b/src/app/api/fhir/MedicationStatement/[id]/route.ts @@ -0,0 +1,53 @@ +/** + * GET /api/fhir/MedicationStatement/{id} — FHIR R4 `read` of one MedicationStatement from the + * caller's own record. + * + * The id space is the export's own (`med-2`), stable for a given record and + * reporting window. A `read` is a filter over the same emitter the search + * route pages, so a resource can never differ between the two faces; an id the + * caller's record does not hold is a `not-found` OperationOutcome. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { medicationStatementsFromReportData } from "@/lib/fhir/resources"; +import { + FHIR_READ_SCOPE, + loadFhirContext, + operationOutcome, + readResponse, +} from "@/lib/fhir/rest"; + +export const GET = apiHandler( + async (_request: NextRequest, ctx: { params: Promise<{ id: string }> }) => { + const { user } = await requireAuth(FHIR_READ_SCOPE); + annotate({ action: { name: "fhir.medicationstatement.read" } }); + + // REFUSE, NOT OMIT. The FHIR face serves the SAME whole-record aggregate as + // `/api/export/health-record`, right down to the decrypted insurance number + // on the Patient resource, so it gates on the same `doctorReport` module. + // There is no partial answer that is still a truthful FHIR Bundle, and no + // background client depends on this draining to stay consistent. The 403 + // `module.disabled` envelope (rather than an OperationOutcome) keeps the + // errorCode clients already branch on for a disabled module. + const gate = await requireModuleEnabled(user.id, "doctorReport"); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000); + if (!rl.allowed) { + return operationOutcome(429, "throttled", "Rate limit exceeded"); + } + const { id } = await ctx.params; + const context = await loadFhirContext(user.id); + annotate({ meta: { resourceId: id } }); + return readResponse( + medicationStatementsFromReportData(context.data, { + germanAtc: context.germanAtc, + }), + id, + ); + }, +); diff --git a/src/app/api/fhir/MedicationStatement/route.ts b/src/app/api/fhir/MedicationStatement/route.ts index b7565735d..bd205854c 100644 --- a/src/app/api/fhir/MedicationStatement/route.ts +++ b/src/app/api/fhir/MedicationStatement/route.ts @@ -3,9 +3,10 @@ * active-medication statements (one per active medication, with additive * ATC/RxNorm codings). * - * Read-only. `userId` narrowed from `requireAuth`; shared emitter is the - * single source of the drug coding. Offset paging via `_count` (≤200) / - * `_offset`. + * Read-only. `userId` is narrowed from `requireAuth`; the shared emitters in + * `@/lib/fhir/resources` are the single source of the coding, so this face and + * the document export can never describe the same record differently. Offset + * paging via `_count` (clamped ≤200) / `_offset`. */ import { NextRequest } from "next/server"; @@ -26,18 +27,13 @@ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(FHIR_READ_SCOPE); annotate({ action: { name: "fhir.medicationstatement.search" } }); - // v1.30 — the FHIR REST face serves the SAME whole-record aggregate as - // `/api/export/health-record` (the doctor-report builder), right down to - // the decrypted insurance number on the Patient resource. That export - // gates on the `doctorReport` module; this surface did not, so the module - // could be off and `/api/fhir/*` still handed out the full record. - // - // REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is - // a whole-record export, not an incremental feed: there is no partial - // answer that is still a truthful FHIR Bundle, and no background client - // depends on it draining to stay consistent. Mirroring the sibling export's - // 403 `module.disabled` envelope (rather than an OperationOutcome) keeps - // the errorCode the clients already branch on for a disabled module. + // REFUSE, NOT OMIT. The FHIR face serves the SAME whole-record aggregate as + // `/api/export/health-record`, right down to the decrypted insurance number + // on the Patient resource, so it gates on the same `doctorReport` module. + // There is no partial answer that is still a truthful FHIR Bundle, and no + // background client depends on this draining to stay consistent. The 403 + // `module.disabled` envelope (rather than an OperationOutcome) keeps the + // errorCode clients already branch on for a disabled module. const gate = await requireModuleEnabled(user.id, "doctorReport"); if (!gate.enabled) return gate.response; @@ -45,11 +41,12 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!rl.allowed) { return operationOutcome(429, "throttled", "Rate limit exceeded"); } - const { count, offset } = parsePaging(request.nextUrl.searchParams); - const { data, germanAtc } = await loadFhirContext(user.id); + const context = await loadFhirContext(user.id); - const all = medicationStatementsFromReportData(data, { germanAtc }); + const all = medicationStatementsFromReportData(context.data, { + germanAtc: context.germanAtc, + }); const page = all.slice(offset, offset + count); annotate({ meta: { total: all.length, count, offset } }); return searchsetResponse(request.nextUrl, page, all.length, count, offset); diff --git a/src/app/api/fhir/Observation/[id]/route.ts b/src/app/api/fhir/Observation/[id]/route.ts new file mode 100644 index 000000000..45de4c5e6 --- /dev/null +++ b/src/app/api/fhir/Observation/[id]/route.ts @@ -0,0 +1,48 @@ +/** + * GET /api/fhir/Observation/{id} — FHIR R4 `read` of one Observation from the + * caller's own record. + * + * The id space is the export's own (`obs-3`), stable for a given record and + * reporting window. A `read` is a filter over the same emitter the search + * route pages, so a resource can never differ between the two faces; an id the + * caller's record does not hold is a `not-found` OperationOutcome. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { observationsFromReportData } from "@/lib/fhir/resources"; +import { + FHIR_READ_SCOPE, + loadFhirContext, + operationOutcome, + readResponse, +} from "@/lib/fhir/rest"; + +export const GET = apiHandler( + async (_request: NextRequest, ctx: { params: Promise<{ id: string }> }) => { + const { user } = await requireAuth(FHIR_READ_SCOPE); + annotate({ action: { name: "fhir.observation.read" } }); + + // REFUSE, NOT OMIT. The FHIR face serves the SAME whole-record aggregate as + // `/api/export/health-record`, right down to the decrypted insurance number + // on the Patient resource, so it gates on the same `doctorReport` module. + // There is no partial answer that is still a truthful FHIR Bundle, and no + // background client depends on this draining to stay consistent. The 403 + // `module.disabled` envelope (rather than an OperationOutcome) keeps the + // errorCode clients already branch on for a disabled module. + const gate = await requireModuleEnabled(user.id, "doctorReport"); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000); + if (!rl.allowed) { + return operationOutcome(429, "throttled", "Rate limit exceeded"); + } + const { id } = await ctx.params; + const context = await loadFhirContext(user.id); + annotate({ meta: { resourceId: id } }); + return readResponse(observationsFromReportData(context.data), id); + }, +); diff --git a/src/app/api/fhir/Observation/route.ts b/src/app/api/fhir/Observation/route.ts index 22760e71b..ab52fcc54 100644 --- a/src/app/api/fhir/Observation/route.ts +++ b/src/app/api/fhir/Observation/route.ts @@ -1,11 +1,12 @@ /** - * GET /api/fhir/Observation — FHIR R4 `searchset` of the caller's own - * Observations (vitals / activity / lab / survey), one latest reading per - * type plus the BP panel, BMI, glucose, adherence, mood and wellness scores. + * GET /api/fhir/Observation — FHIR R4 `searchset` of the caller's own Observations + * (vitals / activity / lab / survey): one latest reading per type plus the BP + * panel, BMI, glucose, adherence, mood and the wellness composites. * - * Read-only. `userId` is narrowed from `requireAuth`; the shared emitter is - * the single source of the LOINC/UCUM coding. Offset paging via `_count` - * (clamped ≤200) / `_offset`. + * Read-only. `userId` is narrowed from `requireAuth`; the shared emitters in + * `@/lib/fhir/resources` are the single source of the coding, so this face and + * the document export can never describe the same record differently. Offset + * paging via `_count` (clamped ≤200) / `_offset`. */ import { NextRequest } from "next/server"; @@ -26,18 +27,13 @@ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(FHIR_READ_SCOPE); annotate({ action: { name: "fhir.observation.search" } }); - // v1.30 — the FHIR REST face serves the SAME whole-record aggregate as - // `/api/export/health-record` (the doctor-report builder), right down to - // the decrypted insurance number on the Patient resource. That export - // gates on the `doctorReport` module; this surface did not, so the module - // could be off and `/api/fhir/*` still handed out the full record. - // - // REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is - // a whole-record export, not an incremental feed: there is no partial - // answer that is still a truthful FHIR Bundle, and no background client - // depends on it draining to stay consistent. Mirroring the sibling export's - // 403 `module.disabled` envelope (rather than an OperationOutcome) keeps - // the errorCode the clients already branch on for a disabled module. + // REFUSE, NOT OMIT. The FHIR face serves the SAME whole-record aggregate as + // `/api/export/health-record`, right down to the decrypted insurance number + // on the Patient resource, so it gates on the same `doctorReport` module. + // There is no partial answer that is still a truthful FHIR Bundle, and no + // background client depends on this draining to stay consistent. The 403 + // `module.disabled` envelope (rather than an OperationOutcome) keeps the + // errorCode clients already branch on for a disabled module. const gate = await requireModuleEnabled(user.id, "doctorReport"); if (!gate.enabled) return gate.response; @@ -45,11 +41,10 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!rl.allowed) { return operationOutcome(429, "throttled", "Rate limit exceeded"); } - const { count, offset } = parsePaging(request.nextUrl.searchParams); - const { data, identity, germanAtc } = await loadFhirContext(user.id); + const context = await loadFhirContext(user.id); - const all = observationsFromReportData(data, identity, { germanAtc }); + const all = observationsFromReportData(context.data); const page = all.slice(offset, offset + count); annotate({ meta: { total: all.length, count, offset } }); return searchsetResponse(request.nextUrl, page, all.length, count, offset); diff --git a/src/app/api/fhir/$everything/route.ts b/src/app/api/fhir/Patient/$everything/route.ts similarity index 58% rename from src/app/api/fhir/$everything/route.ts rename to src/app/api/fhir/Patient/$everything/route.ts index e42a49b80..df9318b9f 100644 --- a/src/app/api/fhir/$everything/route.ts +++ b/src/app/api/fhir/Patient/$everything/route.ts @@ -1,11 +1,19 @@ /** - * GET /api/fhir/$everything — FHIR R4 `$everything` operation. + * GET /api/fhir/Patient/$everything — FHIR R4 `Patient-everything`. * - * Returns every resource in the caller's own record — Patient, Coverage, - * Observations, MedicationStatements, MedicationAdministrations — in one - * `searchset` Bundle, in the canonical document order. Read-only; `userId` - * narrowed from `requireAuth`. Offset paging applies across the flattened - * resource list via `_count` (clamped ≤200) / `_offset`. + * Returns every resource in the caller's own record in one `searchset` Bundle: + * the Composition, the authoring Device, Patient, Coverage, every Observation + * (vitals / glucose / labs / adherence / mood / wellness AND the cycle set), + * MedicationStatements, MedicationAdministrations, Conditions, Encounters, + * AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport. + * + * The set is the document export's, flattened — same builder, same order, same + * `urn:uuid` entry identities, so a reference between two resources still + * resolves inside the response and the operation can never quietly carry less + * than the document does. + * + * Read-only; `userId` narrowed from `requireAuth`. Offset paging applies across + * the flattened resource list via `_count` (clamped ≤200) / `_offset`. */ import { NextRequest } from "next/server"; @@ -13,20 +21,13 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; -import { - coverageResource, - medicationAdministrationsFromReportData, - medicationStatementsFromReportData, - observationsFromReportData, - patientResource, -} from "@/lib/fhir/resources"; -import type { FhirResource } from "@/lib/fhir/types"; +import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle"; import { FHIR_READ_SCOPE, loadFhirContext, operationOutcome, parsePaging, - searchsetResponse, + searchsetResponseFromEntries, } from "@/lib/fhir/rest"; export const GET = apiHandler(async (request: NextRequest) => { @@ -54,16 +55,23 @@ export const GET = apiHandler(async (request: NextRequest) => { } const { count, offset } = parsePaging(request.nextUrl.searchParams); - const { data, identity, germanAtc } = await loadFhirContext(user.id); - - const all: FhirResource[] = [patientResource(data, identity)]; - const coverage = coverageResource(data, identity); - if (coverage) all.push(coverage); - all.push(...observationsFromReportData(data, identity, { germanAtc })); - all.push(...medicationStatementsFromReportData(data, { germanAtc })); - all.push(...medicationAdministrationsFromReportData(data, { germanAtc })); + const { data, identity, germanAtc, records } = await loadFhirContext(user.id); + const document = buildFhirDocumentBundle( + data, + identity, + undefined, + { germanAtc }, + records, + ); + const all = document.entry; const page = all.slice(offset, offset + count); annotate({ meta: { total: all.length, count, offset } }); - return searchsetResponse(request.nextUrl, page, all.length, count, offset); + return searchsetResponseFromEntries( + request.nextUrl, + page, + all.length, + count, + offset, + ); }); diff --git a/src/app/api/fhir/Patient/[id]/route.ts b/src/app/api/fhir/Patient/[id]/route.ts new file mode 100644 index 000000000..6e5ef3a3c --- /dev/null +++ b/src/app/api/fhir/Patient/[id]/route.ts @@ -0,0 +1,48 @@ +/** + * GET /api/fhir/Patient/{id} — FHIR R4 `read` of one Patient from the + * caller's own record. + * + * The id space is the export's own (`patient-1`), stable for a given record and + * reporting window. A `read` is a filter over the same emitter the search + * route pages, so a resource can never differ between the two faces; an id the + * caller's record does not hold is a `not-found` OperationOutcome. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { patientResource } from "@/lib/fhir/resources"; +import { + FHIR_READ_SCOPE, + loadFhirContext, + operationOutcome, + readResponse, +} from "@/lib/fhir/rest"; + +export const GET = apiHandler( + async (_request: NextRequest, ctx: { params: Promise<{ id: string }> }) => { + const { user } = await requireAuth(FHIR_READ_SCOPE); + annotate({ action: { name: "fhir.patient.read" } }); + + // REFUSE, NOT OMIT. The FHIR face serves the SAME whole-record aggregate as + // `/api/export/health-record`, right down to the decrypted insurance number + // on the Patient resource, so it gates on the same `doctorReport` module. + // There is no partial answer that is still a truthful FHIR Bundle, and no + // background client depends on this draining to stay consistent. The 403 + // `module.disabled` envelope (rather than an OperationOutcome) keeps the + // errorCode clients already branch on for a disabled module. + const gate = await requireModuleEnabled(user.id, "doctorReport"); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000); + if (!rl.allowed) { + return operationOutcome(429, "throttled", "Rate limit exceeded"); + } + const { id } = await ctx.params; + const context = await loadFhirContext(user.id); + annotate({ meta: { resourceId: id } }); + return readResponse([patientResource(context.data, context.identity)], id); + }, +); diff --git a/src/app/api/fhir/Patient/route.ts b/src/app/api/fhir/Patient/route.ts index c6e2f3b74..dcf03b802 100644 --- a/src/app/api/fhir/Patient/route.ts +++ b/src/app/api/fhir/Patient/route.ts @@ -1,10 +1,14 @@ /** - * GET /api/fhir/Patient — FHIR R4 `searchset` of the caller's own Patient. + * GET /api/fhir/Patient — FHIR R4 `searchset` of the caller's own Patient resource. * - * Read-only. A FHIR `Patient` search always yields exactly the authenticated - * user's single Patient resource (the `userId` is narrowed from `requireAuth`; - * there is no cross-user search). Returned as a one-entry `searchset` Bundle - * so a generic FHIR client can page it like any other resource type. + * A Patient search always yields exactly the authenticated user's single + * Patient (there is no cross-user search); it is returned as a one-entry + * Bundle so a generic FHIR client can page it like any other resource type. + * + * Read-only. `userId` is narrowed from `requireAuth`; the shared emitters in + * `@/lib/fhir/resources` are the single source of the coding, so this face and + * the document export can never describe the same record differently. Offset + * paging via `_count` (clamped ≤200) / `_offset`. */ import { NextRequest } from "next/server"; @@ -25,18 +29,13 @@ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(FHIR_READ_SCOPE); annotate({ action: { name: "fhir.patient.search" } }); - // v1.30 — the FHIR REST face serves the SAME whole-record aggregate as - // `/api/export/health-record` (the doctor-report builder), right down to - // the decrypted insurance number on the Patient resource. That export - // gates on the `doctorReport` module; this surface did not, so the module - // could be off and `/api/fhir/*` still handed out the full record. - // - // REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is - // a whole-record export, not an incremental feed: there is no partial - // answer that is still a truthful FHIR Bundle, and no background client - // depends on it draining to stay consistent. Mirroring the sibling export's - // 403 `module.disabled` envelope (rather than an OperationOutcome) keeps - // the errorCode the clients already branch on for a disabled module. + // REFUSE, NOT OMIT. The FHIR face serves the SAME whole-record aggregate as + // `/api/export/health-record`, right down to the decrypted insurance number + // on the Patient resource, so it gates on the same `doctorReport` module. + // There is no partial answer that is still a truthful FHIR Bundle, and no + // background client depends on this draining to stay consistent. The 403 + // `module.disabled` envelope (rather than an OperationOutcome) keeps the + // errorCode clients already branch on for a disabled module. const gate = await requireModuleEnabled(user.id, "doctorReport"); if (!gate.enabled) return gate.response; @@ -44,11 +43,10 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!rl.allowed) { return operationOutcome(429, "throttled", "Rate limit exceeded"); } - const { count, offset } = parsePaging(request.nextUrl.searchParams); - const { data, identity } = await loadFhirContext(user.id); + const context = await loadFhirContext(user.id); - const all = [patientResource(data, identity)]; + const all = [patientResource(context.data, context.identity)]; const page = all.slice(offset, offset + count); annotate({ meta: { total: all.length, count, offset } }); return searchsetResponse(request.nextUrl, page, all.length, count, offset); diff --git a/src/app/api/fhir/__tests__/capability-and-everything.test.ts b/src/app/api/fhir/__tests__/capability-and-everything.test.ts new file mode 100644 index 000000000..22f1f077f --- /dev/null +++ b/src/app/api/fhir/__tests__/capability-and-everything.test.ts @@ -0,0 +1,360 @@ +/** + * The CapabilityStatement must describe a face that exists, and + * `Patient/$everything` must return the whole record. + * + * A statement is a promise a generic FHIR client acts on: it will issue a + * `read` because the statement said `read` is there. So the advertised set is + * checked against the route tree on disk, not against a second list. And the + * whole-record operation is checked against the document export it mirrors — + * "everything" that quietly omits a resource family is the kind of gap only a + * clinician notices, when the family is the one they needed. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { existsSync } from "node:fs"; +import path from "node:path"; + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(), + isModuleEnabled: vi.fn(), +})); +vi.mock("@/lib/fhir/rest", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadFhirContext: vi.fn() }; +}); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET as metadataGet } from "../metadata/route"; +import { GET as everythingGet } from "../Patient/$everything/route"; +import { GET as observationRead } from "../Observation/[id]/route"; +import { GET as patientRead } from "../Patient/[id]/route"; +import { GET as coverageRead } from "../Coverage/[id]/route"; +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { loadFhirContext, FHIR_REST_RESOURCE_TYPES } from "@/lib/fhir/rest"; +import { requireModuleEnabled, isModuleEnabled } from "@/lib/modules/gate"; +import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle"; +import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; +import type { DoctorReportData } from "@/lib/doctor-report-data"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +const FIXED_NOW = new Date("2026-05-03T12:00:00.000Z"); + +const DATA = { + period: { + days: 90, + since: "2026-02-02T00:00:00.000Z", + start: "2026-02-02T00:00:00.000Z", + end: "2026-05-03T12:00:00.000Z", + }, + patient: { + username: "sample-user", + dateOfBirth: "1985-06-15T00:00:00.000Z", + gender: "MALE", + heightCm: 182, + fullName: "Sample Patient", + insurerName: "Example Insurer", + insurerIkNumber: "101234567", + }, + practiceName: null, + measurements: { + WEIGHT: [{ value: 79.5, measuredAt: "2026-04-30T08:00:00.000Z" }], + }, + stats: {}, + glucoseStats: {}, + glucoseRanges: {}, + glucoseClinical: computeGlucoseClinicalMetrics([], { now: FIXED_NOW }), + glucoseUnit: "mg/dL", + bmi: 24.1, + compliance: {}, + medications: [{ name: "Example Drug", dose: "5mg", schedules: [] }], + medicationAdministrations: [ + { + medicationName: "Example Drug", + effectiveAt: "2026-04-30T08:00:00.000Z", + status: "completed", + doseText: "5mg", + dose: { value: 5, unit: "mg" }, + injectionSite: null, + atcCode: null, + rxNormCode: null, + deliveryForm: "ORAL", + }, + ], + illnessEpisodes: [ + { + label: "Erkältung", + type: "INFECTION", + lifecycle: "ACUTE", + onsetAt: "2026-04-01T00:00:00.000Z", + resolvedAt: "2026-04-10T00:00:00.000Z", + }, + ], + cycle: { + lastPeriodStart: "2026-04-20", + recentCycles: [], + observedCycleCount: 3, + averageCycleLengthDays: 27.5, + cycleLengthVariabilityDays: 0.5, + averagePeriodLengthDays: 5, + currentPhase: "LUTEAL", + }, + mood: { avg: 3.8, count: 20 }, + glp1: null, +} as unknown as DoctorReportData; + +const RECORDS = { + allergies: [ + { + id: "a1", + substance: "Penicillin", + category: "MEDICATION", + type: "ALLERGY", + status: "ACTIVE", + severity: "SEVERE", + reaction: "Rash", + onsetAt: null, + note: null, + }, + ], + familyHistory: [ + { + id: "f1", + relationship: "MOTHER", + condition: "Hypertension", + ageAtOnset: 50, + note: null, + }, + ], +} as never; + +function req(pathname: string): NextRequest { + return new NextRequest(`http://localhost${pathname}`); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + count: 1, + resetAt: Date.now(), + } as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); + vi.mocked(isModuleEnabled).mockResolvedValue(true); + vi.mocked(loadFhirContext).mockResolvedValue({ + data: DATA, + identity: { insuranceNumber: "A123456780" }, + germanAtc: false, + records: RECORDS, + }); +}); + +interface CapabilityStatement { + resourceType: string; + implementation?: { description: string; url: string }; + software?: { name: string; version: string }; + description?: string; + rest: Array<{ + resource: Array<{ + type: string; + interaction?: Array<{ code: string }>; + operation?: Array<{ name: string }>; + }>; + }>; +} + +describe("GET /api/fhir/metadata — CapabilityStatement", () => { + it("describes the instance it speaks for (cpb-2)", async () => { + const res = await metadataGet(req("/api/fhir/metadata")); + const body = (await res.json()) as CapabilityStatement; + expect(body.resourceType).toBe("CapabilityStatement"); + expect(body.description?.length).toBeGreaterThan(0); + expect(body.software?.name).toBe("HealthLog"); + expect(body.software?.version.length).toBeGreaterThan(0); + expect(body.implementation?.description.length).toBeGreaterThan(0); + expect(body.implementation?.url).toBe("http://localhost/api/fhir"); + }); + + it("describes Patient exactly once, carrying the operation (cpb-9)", async () => { + const res = await metadataGet(req("/api/fhir/metadata")); + const body = (await res.json()) as CapabilityStatement; + const resources = body.rest[0].resource; + const types = resources.map((r) => r.type); + expect(types.filter((t) => t === "Patient")).toHaveLength(1); + expect(new Set(types).size).toBe(types.length); + const patient = resources.find((r) => r.type === "Patient"); + expect(patient?.operation?.[0].name).toBe("everything"); + expect(patient?.interaction?.map((i) => i.code)).toEqual([ + "read", + "search-type", + ]); + }); + + it("advertises no interaction without a route behind it", async () => { + const res = await metadataGet(req("/api/fhir/metadata")); + const body = (await res.json()) as CapabilityStatement; + const root = path.join(process.cwd(), "src/app/api/fhir"); + + for (const resource of body.rest[0].resource) { + for (const { code } of resource.interaction ?? []) { + const file = + code === "read" + ? path.join(root, resource.type, "[id]", "route.ts") + : path.join(root, resource.type, "route.ts"); + expect(existsSync(file), `${resource.type} ${code} → ${file}`).toBe( + true, + ); + } + for (const { name } of resource.operation ?? []) { + const file = path.join(root, resource.type, `$${name}`, "route.ts"); + expect(existsSync(file), `${resource.type} $${name} → ${file}`).toBe( + true, + ); + } + } + // …and the catalogue the statement derives from is the routed set. + expect(body.rest[0].resource.map((r) => r.type)).toEqual([ + ...FHIR_REST_RESOURCE_TYPES, + ]); + }); + + it("advertises no write interaction", async () => { + const res = await metadataGet(req("/api/fhir/metadata")); + const body = (await res.json()) as CapabilityStatement; + const codes = body.rest[0].resource.flatMap((r) => + (r.interaction ?? []).map((i) => i.code), + ); + for (const code of codes) { + expect(["read", "search-type"]).toContain(code); + } + }); +}); + +describe("GET /api/fhir/{type}/{id} — read", () => { + it("serves the resource the searchset fullUrl points at", async () => { + const res = await observationRead(req("/api/fhir/Observation/obs-1"), { + params: Promise.resolve({ id: "obs-1" }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/fhir+json"); + const body = (await res.json()) as { resourceType: string; id: string }; + expect(body.resourceType).toBe("Observation"); + expect(body.id).toBe("obs-1"); + }); + + it("answers an id the record does not hold with a not-found OperationOutcome", async () => { + const res = await observationRead(req("/api/fhir/Observation/obs-999"), { + params: Promise.resolve({ id: "obs-999" }), + }); + expect(res.status).toBe(404); + const body = (await res.json()) as { + resourceType: string; + issue: Array<{ code: string }>; + }; + expect(body.resourceType).toBe("OperationOutcome"); + expect(body.issue[0].code).toBe("not-found"); + }); + + it("reads the Patient", async () => { + const res = await patientRead(req("/api/fhir/Patient/patient-1"), { + params: Promise.resolve({ id: "patient-1" }), + }); + const body = (await res.json()) as { resourceType: string }; + expect(body.resourceType).toBe("Patient"); + }); + + it("reads the Coverage", async () => { + const res = await coverageRead(req("/api/fhir/Coverage/coverage-1"), { + params: Promise.resolve({ id: "coverage-1" }), + }); + const body = (await res.json()) as { resourceType: string }; + expect(body.resourceType).toBe("Coverage"); + }); +}); + +describe("GET /api/fhir/Patient/$everything", () => { + it("returns every resource family the document bundle carries", async () => { + const res = await everythingGet( + req("/api/fhir/Patient/$everything?_count=200"), + ); + expect(res.status).toBe(200); + const bundle = (await res.json()) as { + type: string; + total: number; + entry: Array<{ fullUrl: string; resource: { resourceType: string } }>; + }; + expect(bundle.type).toBe("searchset"); + + const document = buildFhirDocumentBundle( + DATA, + { insuranceNumber: "A123456780" }, + FIXED_NOW, + { germanAtc: false }, + RECORDS, + ); + const expected = new Set( + document.entry.map((e) => e.resource.resourceType), + ); + const served = new Set(bundle.entry.map((e) => e.resource.resourceType)); + expect(served).toEqual(expected); + expect(bundle.total).toBe(document.entry.length); + + // The families the operation used to drop. + for (const type of [ + "Composition", + "Device", + "Condition", + "Encounter", + "AllergyIntolerance", + "FamilyMemberHistory", + "DiagnosticReport", + "Coverage", + ]) { + expect(served.has(type), `missing ${type}`).toBe(true); + } + // …including the cycle Observations, which share a resourceType with the + // vitals and would hide inside the set check above. + const ids = bundle.entry.map( + (e) => (e.resource as unknown as { id: string }).id, + ); + expect(ids.some((id) => id.startsWith("obs-cycle-"))).toBe(true); + }); + + it("keeps the entry identities the references resolve against", async () => { + const res = await everythingGet( + req("/api/fhir/Patient/$everything?_count=200"), + ); + const bundle = (await res.json()) as { + entry: Array<{ + fullUrl: string; + search: { mode: string }; + resource: { subject?: { reference?: string } }; + }>; + }; + const urls = new Set(bundle.entry.map((e) => e.fullUrl)); + for (const entry of bundle.entry) { + expect(entry.fullUrl.startsWith("urn:uuid:")).toBe(true); + expect(entry.search.mode).toBe("match"); + const subject = entry.resource.subject?.reference; + if (subject) expect(urls.has(subject)).toBe(true); + } + }); +}); diff --git a/src/app/api/fhir/__tests__/module-gate.test.ts b/src/app/api/fhir/__tests__/module-gate.test.ts index f869a3e75..f56c71cbb 100644 --- a/src/app/api/fhir/__tests__/module-gate.test.ts +++ b/src/app/api/fhir/__tests__/module-gate.test.ts @@ -47,9 +47,12 @@ vi.mock("@/lib/fhir/rest", async (importOriginal) => { }); vi.mock("@/lib/fhir/resources", () => ({ GERMAN_ATC_DEFAULT_LOCALES: ["de"], - patientResource: vi.fn(() => ({ resourceType: "Patient", id: "p-1" })), + PATIENT_RESOURCE_ID: "patient-1", + patientResource: vi.fn(() => ({ resourceType: "Patient", id: "patient-1" })), coverageResource: vi.fn(() => null), observationsFromReportData: vi.fn(() => []), + cycleObservationsFromReportData: vi.fn(() => []), + conditionsFromReportData: vi.fn(() => ({ conditions: [], encounters: [] })), medicationStatementsFromReportData: vi.fn(() => []), medicationAdministrationsFromReportData: vi.fn(() => []), })); @@ -63,10 +66,16 @@ vi.mock("next/headers", () => ({ })); import { GET as patientGet } from "../Patient/route"; +import { GET as coverageGet } from "../Coverage/route"; +import { GET as patientRead } from "../Patient/[id]/route"; +import { GET as coverageRead } from "../Coverage/[id]/route"; +import { GET as observationRead } from "../Observation/[id]/route"; +import { GET as medStatementRead } from "../MedicationStatement/[id]/route"; +import { GET as medAdminRead } from "../MedicationAdministration/[id]/route"; import { GET as observationGet } from "../Observation/route"; import { GET as medStatementGet } from "../MedicationStatement/route"; import { GET as medAdminGet } from "../MedicationAdministration/route"; -import { GET as everythingGet } from "../$everything/route"; +import { GET as everythingGet } from "../Patient/$everything/route"; import { GET as metadataGet } from "../metadata/route"; import { getSession } from "@/lib/auth/session"; @@ -95,15 +104,42 @@ const SESSION_OK = { user: { id: "user-1", username: "tester", role: "USER" as const }, }; -/** Every data route under `/api/fhir`, by the path a caller would hit. */ +/** + * Every data route under `/api/fhir`, by the path a caller would hit. The + * `/{type}/{id}` reads are here too: a route that 403s on search and serves on + * read would leak the same record through the other door. + */ const DATA_ROUTES: ReadonlyArray< [string, (req: NextRequest) => Promise] > = [ ["Patient", patientGet], + ["Coverage", coverageGet], ["Observation", observationGet], ["MedicationStatement", medStatementGet], ["MedicationAdministration", medAdminGet], - ["$everything", everythingGet], + ["Patient/$everything", everythingGet], +]; + +/** + * The `/{type}/{id}` reads. Same gate, different success shape (a bare + * resource, not a Bundle) — a route that 403s on search and serves on read + * would leak the same record through the other door. + */ +function idCtx(id: string) { + return { params: Promise.resolve({ id }) }; +} + +const READ_ROUTES: ReadonlyArray< + [string, (req: NextRequest) => Promise] +> = [ + ["Patient/patient-1", (req) => patientRead(req, idCtx("patient-1"))], + ["Coverage/coverage-1", (req) => coverageRead(req, idCtx("coverage-1"))], + ["Observation/obs-1", (req) => observationRead(req, idCtx("obs-1"))], + ["MedicationStatement/med-1", (req) => medStatementRead(req, idCtx("med-1"))], + [ + "MedicationAdministration/medadmin-1", + (req) => medAdminRead(req, idCtx("medadmin-1")), + ], ]; function req(path: string): NextRequest { @@ -124,9 +160,18 @@ beforeEach(() => { Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as never, ); vi.mocked(loadFhirContext).mockResolvedValue({ - data: {} as never, + data: { + period: { + days: 90, + since: "2026-02-02T00:00:00.000Z", + start: "2026-02-02T00:00:00.000Z", + end: "2026-05-03T00:00:00.000Z", + }, + patient: {}, + } as never, identity: { insuranceNumber: "A123456789" }, germanAtc: false, + records: {}, }); }); @@ -168,10 +213,37 @@ describe("/api/fhir/* — doctorReport module gate", () => { }); }); + describe.each(READ_ROUTES)("GET /api/fhir/%s", (path, handler) => { + it("403s with module.disabled when the module is off", async () => { + setDoctorReportModule(false); + + const res = await handler(req(path)); + expect(res.status).toBe(403); + const body = (await res.json()) as { + meta?: { errorCode?: string; module?: string }; + }; + expect(body.meta?.errorCode).toBe(MODULE_DISABLED_ERROR_CODE); + expect(body.meta?.module).toBe("doctorReport"); + expect(loadFhirContext).not.toHaveBeenCalled(); + expect(JSON.stringify(body)).not.toContain("A123456789"); + }); + + it("reaches the record when the module is on", async () => { + setDoctorReportModule(true); + + const res = await handler(req(path)); + // The emitters are stubbed empty here, so the honest answer is a + // `not-found` OperationOutcome — what matters is that the gate did not + // refuse and the aggregate was assembled. + expect(res.status).not.toBe(403); + expect(loadFhirContext).toHaveBeenCalledWith("user-1"); + }); + }); + it("leaves the static CapabilityStatement reachable while the module is off", async () => { // Server metadata, no user data — the one FHIR route that stays open. setDoctorReportModule(false); - const res = await metadataGet(); + const res = await metadataGet(req("metadata")); expect(res.status).toBe(200); const body = (await res.json()) as { resourceType: string }; expect(body.resourceType).toBe("CapabilityStatement"); diff --git a/src/app/api/fhir/__tests__/route.test.ts b/src/app/api/fhir/__tests__/route.test.ts index 0a061e8da..a5e585096 100644 --- a/src/app/api/fhir/__tests__/route.test.ts +++ b/src/app/api/fhir/__tests__/route.test.ts @@ -78,6 +78,7 @@ beforeEach(() => { data: {} as never, identity: { insuranceNumber: null }, germanAtc: false, + records: {}, }); }); diff --git a/src/app/api/fhir/metadata/route.ts b/src/app/api/fhir/metadata/route.ts index 341a6c2fc..2bd4587fb 100644 --- a/src/app/api/fhir/metadata/route.ts +++ b/src/app/api/fhir/metadata/route.ts @@ -1,12 +1,22 @@ /** * GET /api/fhir/metadata — FHIR R4 `CapabilityStatement`. * - * Declares the read-only REST face: the resource types served, the search - * parameters honoured (`_count`, `_offset`), the `$everything` operation, and - * the `application/fhir+json` format. Static — no per-user data — but still - * gated behind the `fhir:read` scope so the whole `/api/fhir` tree answers + * Declares the read-only REST face: the resource types served, the `read` + + * `search-type` interactions on each, the search parameters honoured + * (`_count`, `_offset`), the `Patient-everything` operation, and the + * `application/fhir+json` format. Static — no per-user data — but still gated + * behind the `fhir:read` scope so the whole `/api/fhir` tree answers * uniformly. Read-only: no write interactions are advertised. + * + * Every entry here is backed by a route. `FHIR_REST_RESOURCE_TYPES` is the one + * catalogue the resource entries derive from, and each type in it has both a + * `/{type}` search route and a `/{type}/{id}` read route; the operation is + * routed at `/api/fhir/Patient/$everything`. A statement that advertises an + * interaction a client cannot reach is worse than one that stays quiet. */ +import type { NextRequest } from "next/server"; + +import packageJson from "../../../../../package.json"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { @@ -16,15 +26,32 @@ import { fhirJsonResponse, } from "@/lib/fhir/rest"; -export const GET = apiHandler(async () => { +export const GET = apiHandler(async (request: NextRequest) => { await requireAuth(FHIR_READ_SCOPE); annotate({ action: { name: "fhir.metadata.read" } }); + // Same resolution order as `/api/version`: the image build arg wins so a + // deployed instance names its release, with the package version as the + // local-dev fallback. + const version = + process.env.NEXT_PUBLIC_APP_VERSION?.trim() || packageJson.version; + const capability = { resourceType: "CapabilityStatement", status: "active", date: new Date().toISOString(), kind: "instance", + description: + "Read-only HL7 FHIR R4 face over a single HealthLog account's own health record.", + software: { name: "HealthLog", version }, + // `cpb-2` — a `kind: "instance"` statement SHALL describe the instance it + // speaks for. The URL is the deployment answering this request, not a + // hardcoded host: a self-hosted instance is reachable at its own origin + // and nowhere else. + implementation: { + description: "HealthLog self-hosted instance", + url: new URL("/api/fhir", request.nextUrl.origin).toString(), + }, fhirVersion: "4.0.1", format: ["application/fhir+json"], rest: [ @@ -32,26 +59,28 @@ export const GET = apiHandler(async () => { mode: "server", documentation: "Read-only access to the authenticated user's own health record.", - resource: [ - ...FHIR_REST_RESOURCE_TYPES.map((type) => ({ - type, - interaction: [{ code: "read" }, { code: "search-type" }], - searchParam: FHIR_SEARCH_PARAMS.map((name) => ({ - name, - type: "number", - })), + // `cpb-9` — one entry per resource type. The Patient entry carries the + // `$everything` operation alongside its interactions rather than + // appearing a second time. + resource: FHIR_REST_RESOURCE_TYPES.map((type) => ({ + type, + interaction: [{ code: "read" }, { code: "search-type" }], + searchParam: FHIR_SEARCH_PARAMS.map((name) => ({ + name, + type: "number", })), - { - type: "Patient", - operation: [ - { - name: "everything", - definition: - "http://hl7.org/fhir/OperationDefinition/Patient-everything", - }, - ], - }, - ], + ...(type === "Patient" + ? { + operation: [ + { + name: "everything", + definition: + "http://hl7.org/fhir/OperationDefinition/Patient-everything", + }, + ], + } + : {}), + })), }, ], }; diff --git a/src/app/api/meta/capabilities/__tests__/route.test.ts b/src/app/api/meta/capabilities/__tests__/route.test.ts index 0f3f4e5c3..970ca750a 100644 --- a/src/app/api/meta/capabilities/__tests__/route.test.ts +++ b/src/app/api/meta/capabilities/__tests__/route.test.ts @@ -59,7 +59,10 @@ import { FHIR_SEARCH_PARAMS, } from "@/lib/fhir/rest"; import { SHARE_LINK_MAX_DAYS } from "@/lib/validations/clinician-share-link"; -import { exportSectionsSchema } from "@/lib/validations/health-record-export"; +import { + ALL_LEAF_IDS, + REPORT_GROUP_ORDER, +} from "@/lib/report-selection/catalogue"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -92,8 +95,10 @@ type CapabilitiesBody = { share: { supported: boolean; maxDays: number; - fhirApi: boolean; - sections: string[]; + reportDownload: string[]; + selectionVersion: number; + groups: string[]; + leaves: string[]; }; }; }; @@ -175,11 +180,14 @@ describe("GET /api/meta/capabilities — drift guards", () => { const body = (await res.json()) as CapabilitiesBody; expect(body.data.share.supported).toBe(true); expect(body.data.share.maxDays).toBe(SHARE_LINK_MAX_DAYS); - // The share serves the rendered record view only; no `/api/fhir/*` route - // honours a share token yet, so the share→FHIR face is advertised off. - expect(body.data.share.fhirApi).toBe(false); - expect([...body.data.share.sections].sort()).toEqual( - Object.keys(exportSectionsSchema.shape).sort(), + // The record IS reachable in machine form now — as a download under the + // token, not as a bearer API. The old `fhirApi: false` described a face + // that was never built. + expect(body.data.share.reportDownload).toEqual(["fhir", "pdf"]); + expect(body.data.share.selectionVersion).toBe(2); + expect(body.data.share.groups).toEqual([...REPORT_GROUP_ORDER]); + expect([...body.data.share.leaves].sort()).toEqual( + [...ALL_LEAF_IDS].sort(), ); }); diff --git a/src/app/api/share-links/__tests__/route.test.ts b/src/app/api/share-links/__tests__/route.test.ts index 57ae2e92b..c38b61431 100644 --- a/src/app/api/share-links/__tests__/route.test.ts +++ b/src/app/api/share-links/__tests__/route.test.ts @@ -41,7 +41,10 @@ vi.mock("next/headers", () => ({ import { POST, GET } from "../route"; import { DELETE } from "../[id]/route"; -import { EMPTY_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs"; +import { + selectionToBlob, + selectionFromLeaves, +} from "@/lib/report-selection/selection"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -81,6 +84,8 @@ function storedRow(overrides: Record = {}) { revokedAt: null, lastAccessAt: null, accessCount: 0, + documentOnly: false, + sectionsJson: selectionToBlob(selectionFromLeaves(["WEIGHT"])), _count: { documents: 0 }, ...overrides, }; @@ -277,7 +282,7 @@ describe("POST /api/share-links — create", () => { postReq( validBody({ documentOnly: true, - sections: { vitals: { bp: true }, labs: true }, + selection: { v: 2, leaves: ["BLOOD_PRESSURE_SYS", "LAB_RESULTS"] }, documentIds: ["doc-a"], }), ), @@ -289,9 +294,9 @@ describe("POST /api/share-links — create", () => { // The retired FHIR scope columns are never written at all. expect(createArg.data).not.toHaveProperty("resourceTypes"); expect(createArg.data).not.toHaveProperty("allowFhirApi"); - // Sections persist as an explicit all-OFF blob (distinct from `{}` = full - // record defaults). The clinician view reads this as "no report". - expect(createArg.data.sectionsJson).toEqual(EMPTY_DOCTOR_REPORT_PREFS); + // The scope persists as an explicit EMPTY leaf list. The clinician view + // reads that as "no report" and never calls the aggregator. + expect(createArg.data.sectionsJson).toEqual({ v: 2, leaves: [] }); // v1.28.16 — the frozen documents-only flag is persisted so the view path // gates on the column, not on inferring "all sections off". This is what a // future report section can never re-open. @@ -362,3 +367,120 @@ describe("DELETE /api/share-links/[id] — revoke", () => { expect(res.status).toBe(404); }); }); + +/** + * The frozen scope, and the two things a share link may never carry. + * + * Mutation checks: + * - drop the `SHARE_LINK_FORBIDDEN_LEAVES` check in the route → "refuses the + * insurance leaf by name" goes red. + * - drop `RETIRED_FHIR_SCOPE_FIELDS` from the create response → "keeps the + * retired scope fields on the response" goes red. + * - make `needsReselection` always false → "flags a link whose frozen scope + * predates the selection model" goes red. + */ +describe("POST /api/share-links — the frozen scope", () => { + it("persists exactly the leaves the owner chose", async () => { + vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue( + storedRow() as never, + ); + const res = await POST( + postReq( + validBody({ + selection: { v: 2, leaves: ["LAB_RESULTS", "WEIGHT"] }, + }), + ), + ); + expect(res.status).toBe(201); + const createArg = vi.mocked(prisma.clinicianShareLink.create).mock + .calls[0][0]; + // Canonical order, not the caller's. + expect(createArg.data.sectionsJson).toEqual({ + v: 2, + leaves: ["WEIGHT", "LAB_RESULTS"], + }); + }); + + it("refuses the insurance leaf by name", async () => { + // The share view has never decrypted the insurance number. Refusing the + // leaf at create makes that structural: there is no path by which a link + // could later carry it. + const res = await POST( + postReq(validBody({ selection: { v: 2, leaves: ["INSURANCE"] } })), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("INSURANCE"); + expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled(); + }); + + it("refuses a leaf id this build does not know", async () => { + const res = await POST( + postReq(validBody({ selection: { v: 2, leaves: ["SOMETHING_NEW"] } })), + ); + expect(res.status).toBe(422); + expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled(); + }); + + it("mints an empty scope when the body names none", async () => { + vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue( + storedRow() as never, + ); + const res = await POST(postReq(validBody())); + expect(res.status).toBe(201); + const createArg = vi.mocked(prisma.clinicianShareLink.create).mock + .calls[0][0]; + expect(createArg.data.sectionsJson).toEqual({ v: 2, leaves: [] }); + }); + + it("keeps the retired scope fields on the response", async () => { + // Response-only, serving constants. The request refuses them and the + // columns are gone; they stay here until the native client confirms it + // does not decode them. + vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue( + storedRow() as never, + ); + const res = await POST( + postReq(validBody({ selection: { v: 2, leaves: ["WEIGHT"] } })), + ); + const body = (await res.json()) as { + data: { allowFhirApi: boolean; resourceTypes: string[] }; + }; + expect(body.data.allowFhirApi).toBe(false); + expect(body.data.resourceTypes).toEqual([]); + }); +}); + +describe("GET /api/share-links — the pick-again state", () => { + it("flags a link whose frozen scope predates the selection model", async () => { + vi.mocked(prisma.clinicianShareLink.findMany).mockResolvedValue([ + storedRow({ + id: "legacy", + revokedAt: new Date(), + sectionsJson: { bp: true, weight: true, pulse: true }, + }), + storedRow({ id: "current" }), + storedRow({ id: "docs", documentOnly: true, sectionsJson: {} }), + ] as never); + const res = await GET(); + const body = (await res.json()) as { + data: { + shareLinks: Array<{ + id: string; + needsReselection: boolean; + allowFhirApi: boolean; + resourceTypes: string[]; + }>; + }; + }; + const byId = new Map(body.data.shareLinks.map((l) => [l.id, l])); + expect(byId.get("legacy")!.needsReselection).toBe(true); + expect(byId.get("current")!.needsReselection).toBe(false); + // A documents-only link carried no report scope to begin with, so it was + // never revoked and is not asked to pick again. + expect(byId.get("docs")!.needsReselection).toBe(false); + // The retired fields ride the list response too. + expect(byId.get("current")!.allowFhirApi).toBe(false); + expect(byId.get("current")!.resourceTypes).toEqual([]); + }); +}); diff --git a/src/components/clinician/__tests__/clinician-view.test.tsx b/src/components/clinician/__tests__/clinician-view.test.tsx index f77f86bab..2650ffd25 100644 --- a/src/components/clinician/__tests__/clinician-view.test.tsx +++ b/src/components/clinician/__tests__/clinician-view.test.tsx @@ -11,7 +11,8 @@ import { renderToStaticMarkup } from "react-dom/server"; import { ClinicianView } from "../clinician-view"; import { getServerTranslator } from "@/lib/i18n/server-translator"; -import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs"; +import { selectionFromLeaves } from "@/lib/report-selection/selection"; +import { ALL_LEAF_IDS } from "@/lib/report-selection/catalogue"; import type { DoctorReportData } from "@/lib/doctor-report-data"; import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; @@ -76,7 +77,7 @@ function render( label="Cardiology clinic" expiresAt="2026-03-01T00:00:00.000Z" report={report} - sections={{ ...DEFAULT_DOCTOR_REPORT_PREFS }} + selection={selectionFromLeaves(ALL_LEAF_IDS)} {...extra} />, ); @@ -100,10 +101,12 @@ describe("", () => { it("renders clinical vitals from the scoped report", () => { const html = render(makeReport()); - expect(html).toContain("Vital signs"); - // The measurement-type enum renders as its localised label, not the raw - // enum string. - expect(html).toContain("Body weight"); + // The measurement rows are grouped the way the selection panel groups + // them, so the page reads in the same order as the PDF. + expect(html).toContain("Body measurements"); + // The measurement-type enum renders as the SAME localised label the rest + // of the app uses, not the raw enum string and not a second vocabulary. + expect(html).toContain(">Weight<"); expect(html).not.toContain("WEIGHT"); }); diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx index b6b326e09..696a66755 100644 --- a/src/components/settings/__tests__/disable-coach-card.test.tsx +++ b/src/components/settings/__tests__/disable-coach-card.test.tsx @@ -75,6 +75,7 @@ function buildUser(disableCoach: boolean): AuthUser { insurerIkNumber: null, insuranceNumber: null, lastReportPracticeName: null, + reportSelection: null, cycleTrackingEnabled: false, modules: {}, }; diff --git a/src/components/settings/__tests__/health-record-export-practice-name.test.tsx b/src/components/settings/__tests__/health-record-export-practice-name.test.tsx deleted file mode 100644 index 24c5c298e..000000000 --- a/src/components/settings/__tests__/health-record-export-practice-name.test.tsx +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Settings → Gesundheitsakte: the practice name is remembered between reports. - * - * `User.lastReportPracticeName` is persisted by the export route after a - * successful generation and rides the `/api/auth/me` payload. This suite pins - * the read half: the panel opens pre-filled with the last-used name instead of - * asking for the clinic name again on every report. - * - * SSR-only, per the project convention for this surface (node environment, - * no `@testing-library/react`). The seed runs during render — a `useState` - * initializer would read `undefined` while `useAuth` is still resolving and - * stick at empty — so the static markup already carries the seeded value. - * - * Mutation check: revert the seeding block in the panel and the first case - * goes red. - */ -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import type { AuthUser } from "@/hooks/use-auth"; - -const authSpy = vi.fn<() => { user: AuthUser | null }>(); -vi.mock("@/hooks/use-auth", async () => { - const actual = - await vi.importActual( - "@/hooks/use-auth", - ); - return { ...actual, useAuth: () => authSpy() }; -}); - -vi.mock("@tanstack/react-query", () => ({ - useQueryClient: () => ({ invalidateQueries: vi.fn() }), -})); - -import { I18nProvider } from "@/lib/i18n/context"; -import { HealthRecordExportPanel } from "../health-record-export-panel"; - -function buildUser(lastReportPracticeName: string | null): AuthUser { - return { - id: "user-1", - username: "user", - email: null, - role: "USER", - heightCm: null, - dateOfBirth: null, - gender: null, - timezone: "Europe/Berlin", - onboardingCompletedAt: null, - onboardingTourCompleted: true, - onboardingTourProgress: null, - avatarUrl: null, - glucoseUnit: null, - unitPreference: "metric", - timeFormat: "AUTO", - dateFormat: "AUTO", - disableCoach: false, - fullName: null, - insurerName: null, - insurerIkNumber: null, - insuranceNumber: null, - lastReportPracticeName, - cycleTrackingEnabled: false, - modules: {}, - } as AuthUser; -} - -function renderPanel(user: AuthUser | null): string { - authSpy.mockReturnValue({ user }); - return renderToStaticMarkup( - - - , - ); -} - -function practiceInputValue(html: string): string | null { - const input = html.match(/]*id="hr-practice"[^>]*>/)?.[0]; - if (!input) return null; - return input.match(/value="([^"]*)"/)?.[1] ?? ""; -} - -describe(" — remembered practice name", () => { - it("pre-fills the input with the last-used practice name", () => { - const html = renderPanel(buildUser("Sample Practice")); - expect(practiceInputValue(html)).toBe("Sample Practice"); - }); - - it("leaves the input empty when no name was ever remembered", () => { - const html = renderPanel(buildUser(null)); - expect(practiceInputValue(html)).toBe(""); - }); - - it("leaves the input empty while the auth payload is still resolving", () => { - const html = renderPanel(null); - expect(practiceInputValue(html)).toBe(""); - }); -}); diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx index a77475675..c30bad4bd 100644 --- a/src/components/settings/__tests__/modules-section.test.tsx +++ b/src/components/settings/__tests__/modules-section.test.tsx @@ -108,6 +108,7 @@ function buildUser( insurerIkNumber: null, insuranceNumber: null, lastReportPracticeName: null, + reportSelection: null, cycleTrackingEnabled: false, modules, moduleAvailability: {}, diff --git a/src/components/settings/__tests__/report-selection-panel.test.tsx b/src/components/settings/__tests__/report-selection-panel.test.tsx new file mode 100644 index 000000000..830e1762d --- /dev/null +++ b/src/components/settings/__tests__/report-selection-panel.test.tsx @@ -0,0 +1,234 @@ +/** + * Settings → Gesundheitsakte: what the export panel opens with. + * + * Two things are seeded from `/api/auth/me` during render — the practice name + * and the saved selection. The seed runs during render because a `useState` + * initialiser would read `undefined` while `useAuth` is still resolving and + * stick, and it is gated on the user id so a late re-resolve never clobbers + * edits in flight. + * + * The three panel states are the load-bearing part. First run must open the + * picker EXPANDED with the named template applied and the fenced tier visibly + * empty, so the user presses Generate having seen twelve named groups; a + * repeat run must open collapsed carrying the owner's own last scope. A + * template silently applied behind a collapsed disclosure would be opt-out + * wearing a hat. + * + * SSR-only, per the project convention for this surface. + * + * Mutation checks: + * - remove the seeding block → "pre-fills the input" and "restores the saved + * selection" go red. + * - start `pickerOpen` at `false` → "opens expanded on the first run" goes + * red. + * - add a fenced leaf to `STANDARD_TEMPLATE_LEAVES` → "checks no fenced leaf + * on the first run" goes red. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import type { AuthUser } from "@/hooks/use-auth"; + +const authSpy = vi.fn<() => { user: AuthUser | null }>(); +vi.mock("@/hooks/use-auth", async () => { + const actual = + await vi.importActual( + "@/hooks/use-auth", + ); + return { ...actual, useAuth: () => authSpy() }; +}); + +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { HealthRecordExportPanel } from "../report-selection/selection-panel"; + +function buildUser(lastReportPracticeName: string | null): AuthUser { + return { + id: "user-1", + username: "user", + email: null, + role: "USER", + heightCm: null, + dateOfBirth: null, + gender: null, + timezone: "Europe/Berlin", + onboardingCompletedAt: null, + onboardingTourCompleted: true, + onboardingTourProgress: null, + avatarUrl: null, + glucoseUnit: null, + unitPreference: "metric", + timeFormat: "AUTO", + dateFormat: "AUTO", + disableCoach: false, + fullName: null, + insurerName: null, + insurerIkNumber: null, + insuranceNumber: null, + lastReportPracticeName, + reportSelection: null, + cycleTrackingEnabled: false, + modules: {}, + } as AuthUser; +} + +function withSavedSelection(user: AuthUser, leaves: string[]): AuthUser { + return { + ...user, + reportSelection: { + v: 2, + leaves, + format: "fhir", + rangeDays: 180, + includeCharts: false, + }, + } as AuthUser; +} + +/** + * Whether a leaf row renders as checked. Only the fenced tier and an expanded + * group render leaf rows at all — a collapsed group shows its count chip + * instead, which is the point of the two-interaction repeat path. + */ +function leafChecked(html: string, leaf: string): boolean { + const row = html.match( + new RegExp(`data-testid="report-leaf-${leaf}"[\\s\\S]*?`), + )?.[0]; + if (!row) return false; + return /data-state="checked"/.test(row); +} + +/** The "n/total" chip a collapsed group row carries. */ +function groupChip(html: string, group: string): string | null { + const row = html.match( + new RegExp(`data-testid="report-group-row-${group}"[\\s\\S]*?`), + )?.[0]; + return row?.match(/data-slot="tag-chip"[^>]*>([^<]*) + + , + ); +} + +function practiceInputValue(html: string): string | null { + const input = html.match(/]*id="hr-practice"[^>]*>/)?.[0]; + if (!input) return null; + return input.match(/value="([^"]*)"/)?.[1] ?? ""; +} + +describe(" — remembered practice name", () => { + it("pre-fills the input with the last-used practice name", () => { + const html = renderPanel(buildUser("Sample Practice")); + expect(practiceInputValue(html)).toBe("Sample Practice"); + }); + + it("leaves the input empty when no name was ever remembered", () => { + const html = renderPanel(buildUser(null)); + expect(practiceInputValue(html)).toBe(""); + }); + + it("leaves the input empty while the auth payload is still resolving", () => { + const html = renderPanel(null); + expect(practiceInputValue(html)).toBe(""); + }); +}); + +describe(" — the first run", () => { + it("opens the picker expanded and names the template it applied", () => { + const html = renderPanel(buildUser(null)); + expect(html).toContain('data-testid="health-record-included-data-panel"'); + expect(html).toContain('data-testid="report-scope-picker"'); + expect(html).toContain("Standard doctor's report"); + }); + + it("counts the template's leaves per group, and no others", () => { + const html = renderPanel(buildUser(null)); + // Identity: both leaves. Vitals: the three classic ones out of seven. + // Body: weight and BMI out of twelve. Nothing at all from the groups the + // template leaves alone. + expect(groupChip(html, "identity")).toBe("2/2"); + expect(groupChip(html, "vitals")).toBe("3/7"); + expect(groupChip(html, "body")).toBe("2/12"); + expect(groupChip(html, "labs")).toBe("1/1"); + expect(groupChip(html, "medications")).toBe("2/4"); + expect(groupChip(html, "history")).toBe("2/2"); + expect(groupChip(html, "cardio")).toBe("0/14"); + expect(groupChip(html, "activity")).toBe("0/8"); + expect(groupChip(html, "sleepRecovery")).toBe("0/16"); + expect(groupChip(html, "mobility")).toBe("0/11"); + expect(groupChip(html, "environment")).toBe("0/4"); + }); + + it("leaves every fenced leaf unchecked and renders the tier", () => { + const html = renderPanel(buildUser(null)); + expect(html).toContain('data-testid="report-sensitive-tier"'); + for (const leaf of [ + "MOOD", + "CYCLE", + "FAMILY_HISTORY", + "PHQ9_SCORE", + "GAD7_SCORE", + "WHO5_SCORE", + "SCI_SCORE", + ]) { + expect(leafChecked(html, leaf), leaf).toBe(false); + } + }); + + it("gives the fenced tier no group checkbox", () => { + const html = renderPanel(buildUser(null)); + expect(html).toContain('data-testid="report-group-check-vitals"'); + expect(html).not.toContain('data-testid="report-group-check-sensitive"'); + }); + + it("states the scope in one line", () => { + const html = renderPanel(buildUser(null)); + expect(html).toContain('data-testid="report-scope-summary"'); + expect(html).toContain("14 entries from 7 areas"); + }); +}); + +describe(" — a repeat run", () => { + it("restores the saved selection and collapses the picker", () => { + const html = renderPanel( + withSavedSelection(buildUser("Sample Practice"), [ + "RESTING_HEART_RATE", + "MOOD", + ]), + ); + expect(html).not.toContain( + 'data-testid="health-record-included-data-panel"', + ); + // The scope line still says what will be in the document, and it names the + // fenced inclusion rather than folding it into the count. + expect(html).toContain("2 entries from 2 areas"); + expect(html).toContain("incl. Mood"); + }); + + it("restores the saved format and range", () => { + const html = renderPanel(withSavedSelection(buildUser(null), ["WEIGHT"])); + // FHIR was saved, so the practice-name line (PDF only) is not rendered. + expect(practiceInputValue(html)).toBeNull(); + expect(html).toContain('