diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts new file mode 100644 index 000000000..b7c476dff --- /dev/null +++ b/src/__tests__/doctor-report-control-gating-guard.test.ts @@ -0,0 +1,187 @@ +/** + * Structural guard: a rendered export control with no gating path fails the + * build. + * + * 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". + * + * This file is that connection, in three links that each break independently: + * + * 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). + * + * 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. + */ +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"; + +const REPO_ROOT = join(__dirname, "..", ".."); +const PANEL_PATH = join( + REPO_ROOT, + "src", + "components", + "settings", + "health-record-export-panel.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"); + +/** Strip comments so a key mentioned 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"); + }); + + 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); + } + } + + 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.`, + ).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), + ); + 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.`, + ).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); + }); +}); diff --git a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts index b5c5a5584..d993076dc 100644 --- a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts @@ -99,10 +99,13 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => { const res = await (PUT as (r: Request) => Promise)(mkPut(body)); expect(res.status).toBe(200); const env = (await res.json()) as { data: typeof body }; - expect(env.data).toEqual(body); + // The canonical shape is the full prefs object: keys the body omits fill + // from the defaults rather than being dropped. + const canonical = { ...DEFAULT_DOCTOR_REPORT_PREFS, ...body }; + expect(env.data).toEqual(canonical); expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: "user-1" }, - data: { doctorReportPrefsJson: body }, + data: { doctorReportPrefsJson: canonical }, }); }); @@ -134,10 +137,15 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => { ); expect(res.status).toBe(200); const env = (await res.json()) as { data: typeof current }; - expect(env.data).toEqual({ ...current, mood: false }); + const merged = { + ...DEFAULT_DOCTOR_REPORT_PREFS, + ...current, + mood: false, + }; + expect(env.data).toEqual(merged); expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: "user-1" }, - data: { doctorReportPrefsJson: { ...current, mood: false } }, + data: { doctorReportPrefsJson: merged }, }); }); 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 2edf0ab3c..e5815ce7b 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -10,6 +10,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; import { PDFParse } from "pdf-parse"; +import { unzipSync } from "fflate"; vi.mock("@/lib/db", () => ({ prisma: { @@ -31,9 +32,15 @@ vi.mock("@/lib/auth/audit", () => ({ })); vi.mock("@/lib/geo", () => ({ lookupIpLocation: vi.fn() })); vi.mock("@/lib/logging/transports", () => ({ emitStructuredLog: vi.fn() })); -vi.mock("@/lib/tz/resolver", () => ({ - resolveUserTimezone: vi.fn().mockResolvedValue("Europe/Berlin"), -})); +// Partial: the aggregator's canonical-source collapse calls `userDayKey` from +// the same module, so a total mock breaks any fixture that carries rows. +vi.mock("@/lib/tz/resolver", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveUserTimezone: vi.fn().mockResolvedValue("Europe/Berlin"), + }; +}); // v1.18.0 — the health-record aggregator resolves the per-user module map // so a disabled data-domain module never reaches the export. Stub the gate @@ -250,6 +257,287 @@ describe("POST /api/export/health-record — outputs", () => { }); }); +/** + * The selection has to mean the same thing in every artefact. + * + * Three defects met here. The export panel rendered eight measurement switches + * that the grouped-to-flat fold discarded, so deselecting body fat or VO₂max + * exported them anyway. The medication switch documented itself as excluding + * all medication data while the drug list and the per-dose ledger went out + * unconditionally. And the route fetched allergies and family history without + * consulting the toggles at all, so `sections.allergies = false` produced a PDF + * without an allergy section and a FHIR bundle carrying every one of them — in + * the `package` format, inside the same zip. + * + * Each case below runs the real route end to end and reads the produced + * artefact. Mutation check: revert any single gate and its case goes red while + * the positive control beside it stays green, so none of them can pass by + * asserting on an empty fixture. + */ +describe("POST /api/export/health-record — the selection reaches every format", () => { + const T0 = new Date("2026-02-01T08:00:00.000Z"); + + function measurement(type: string, value: number) { + return { + type, + value, + measuredAt: T0, + source: "MANUAL", + deviceType: null, + sleepStage: null, + glucoseContext: null, + }; + } + + function allergyRow() { + return { + id: "allergy-1", + substance: "Penicillin", + category: "MEDICATION", + type: "ALLERGY", + severity: "SEVERE", + status: "ACTIVE", + onsetAt: null, + reactionEncrypted: null, + notesEncrypted: null, + createdAt: T0, + updatedAt: T0, + }; + } + + function familyHistoryRow() { + return { + id: "fmh-1", + relationship: "MOTHER", + condition: "Hypertension", + ageAtOnset: 52, + notesEncrypted: null, + createdAt: T0, + updatedAt: T0, + }; + } + + function medicationRow() { + return { + id: "med-1", + name: "Sample medication", + dose: "5 mg", + atcCode: null, + rxNormCode: null, + deliveryForm: null, + active: true, + schedules: [], + scheduleRevisions: [], + pauseEras: [], + doseChanges: [], + intakeEvents: [], + }; + } + + /** Resource types present in the bundle a given selection produces. */ + async function bundleResourceTypes( + sections: Record, + ): Promise { + const { POST } = await import("../route"); + const res = await POST(mkReq({ format: "fhir", sections })); + expect(res.status).toBe(200); + const bundle = (await res.json()) as { + entry: { resource: { resourceType: string; code?: unknown } }[]; + }; + return bundle.entry.map((e) => e.resource.resourceType); + } + + /** The LOINC/HealthKit codes of every Observation a selection produces. */ + async function observationCodes( + sections: Record, + ): Promise { + const { POST } = await import("../route"); + const res = await POST(mkReq({ format: "fhir", sections })); + expect(res.status).toBe(200); + const bundle = (await res.json()) as { + entry: { + resource: { + resourceType: string; + code?: { coding?: { code: string }[]; text?: string }; + }; + }[]; + }; + return bundle.entry + .filter((e) => e.resource.resourceType === "Observation") + .flatMap((e) => [ + ...(e.resource.code?.coding?.map((c) => c.code) ?? []), + e.resource.code?.text ?? "", + ]); + } + + it("honours every previously-dead measurement switch in the FHIR bundle", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + measurement("BODY_FAT", 21), + measurement("OXYGEN_SATURATION", 97), + measurement("BONE_MASS", 3.1), + measurement("RESTING_HEART_RATE", 54), + measurement("HEART_RATE_VARIABILITY", 61), + measurement("VO2_MAX", 44), + measurement("ACTIVITY_STEPS", 8400), + 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 }, + }); + 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 }, + }); + 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 }, + }); + 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"); + }); + + it("omits the deselected vitals from the PDF table", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + measurement("BODY_FAT", 21), + measurement("OXYGEN_SATURATION", 97), + ] as never); + const { POST } = await import("../route"); + + const withBoth = await pdfText( + await POST( + mkReq({ + format: "pdf", + locale: "en", + sections: { vitals: { bodyFat: true, oxygenSaturation: true } }, + }), + ), + ); + expect(withBoth).toContain("Body fat"); + + const without = await pdfText( + await POST( + mkReq({ + format: "pdf", + locale: "en", + sections: { vitals: { bodyFat: false, oxygenSaturation: false } }, + }), + ), + ); + expect(without).not.toContain("Body fat"); + }); + + it("holds the mental-health screening totals back unless they are asked for by name", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + measurement("PHQ9_SCORE", 11), + 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. + const { POST } = await import("../route"); + const res = await POST(mkReq({ format: "fhir" })); + const bundle = (await res.json()) as { + entry: { resource: { resourceType: string } }[]; + }; + expect( + 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 }); + expect(asked).toContain("44261-6"); + expect(asked).toContain("70274-6"); + }); + + it("lets the medication switch withhold the medication list", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + medicationRow(), + ] as never); + + expect( + await bundleResourceTypes({ medications: { list: true } }), + ).toContain("MedicationStatement"); + expect( + await bundleResourceTypes({ medications: { list: false } }), + ).not.toContain("MedicationStatement"); + }); + + it("keeps allergies and family history out of the FHIR bundle when deselected", async () => { + vi.mocked(prisma.allergy.findMany).mockResolvedValue([ + allergyRow(), + ] as never); + vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([ + familyHistoryRow(), + ] as never); + + const on = await bundleResourceTypes({ + allergies: true, + familyHistory: true, + }); + expect(on).toContain("AllergyIntolerance"); + expect(on).toContain("FamilyMemberHistory"); + + const off = await bundleResourceTypes({ + allergies: false, + familyHistory: false, + }); + 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 }); + expect(prisma.allergy.findMany).not.toHaveBeenCalled(); + expect(prisma.familyHistoryEntry.findMany).not.toHaveBeenCalled(); + }); + + it("ships a package whose bundle agrees with its PDF", async () => { + vi.mocked(prisma.allergy.findMany).mockResolvedValue([ + allergyRow(), + ] as never); + vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([ + familyHistoryRow(), + ] as never); + + const { POST } = await import("../route"); + const res = await POST( + mkReq({ + format: "package", + sections: { allergies: false, familyHistory: false }, + }), + ); + expect(res.status).toBe(200); + const files = unzipSync(new Uint8Array(await res.arrayBuffer())); + const bundle = JSON.parse( + new TextDecoder().decode(files["bundle.json"]!), + ) as { entry: { resource: { resourceType: string } }[] }; + const types = bundle.entry.map((e) => e.resource.resourceType); + // The compliant PDF and the violating bundle used to travel together. + expect(types).not.toContain("AllergyIntolerance"); + expect(types).not.toContain("FamilyMemberHistory"); + }); +}); + /** * The practice name is a remembered preference (`User.lastReportPracticeName`), * so a monthly report does not ask for the clinic name again every time. The diff --git a/src/app/api/share-links/__tests__/route.test.ts b/src/app/api/share-links/__tests__/route.test.ts index e040c6b6e..57ae2e92b 100644 --- a/src/app/api/share-links/__tests__/route.test.ts +++ b/src/app/api/share-links/__tests__/route.test.ts @@ -64,8 +64,6 @@ function validBody(overrides: Record = {}) { label: "Cardiology clinic", rangeStart: "2026-01-01T00:00:00Z", rangeEnd: null, - resourceTypes: ["Observation"], - allowFhirApi: true, expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), ...overrides, }; @@ -77,8 +75,6 @@ function storedRow(overrides: Record = {}) { label: "Cardiology clinic", rangeStart: new Date("2026-01-01T00:00:00Z"), rangeEnd: null, - resourceTypes: ["Observation"], - allowFhirApi: true, passphraseHash: "hash(STORED)", expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), createdAt: new Date(), @@ -241,22 +237,27 @@ describe("POST /api/share-links — create", () => { expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled(); }); + it("rejects the retired FHIR scope keys with 422", async () => { + // The share link has never served FHIR against its token. The keys + // are gone from the create schema rather than accepted and ignored, so a + // stale client is told plainly instead of believing it scoped something. + const res = await POST( + postReq( + validBody({ allowFhirApi: true, resourceTypes: ["Observation"] }), + ), + ); + expect(res.status).toBe(422); + expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled(); + }); + it("allows a documents-only share (empty report sections)", async () => { vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([ { id: "doc-a" }, ] as never); vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue( - storedRow({ resourceTypes: [], _count: { documents: 1 } }) as never, - ); - const res = await POST( - postReq( - validBody({ - resourceTypes: [], - allowFhirApi: false, - documentIds: ["doc-a"], - }), - ), + storedRow({ _count: { documents: 1 } }) as never, ); + const res = await POST(postReq(validBody({ documentIds: ["doc-a"] }))); expect(res.status).toBe(201); const body = (await res.json()) as { data: { documentCount: number } }; expect(body.data.documentCount).toBe(1); @@ -267,18 +268,15 @@ describe("POST /api/share-links — create", () => { { id: "doc-a" }, ] as never); vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue( - storedRow({ resourceTypes: [], _count: { documents: 1 } }) as never, + storedRow({ _count: { documents: 1 } }) as never, ); // A hostile / stale client tries to widen a document link into a record - // share: FHIR on, resource types set, sections requested. The server must - // ignore every one of them. + // share by requesting sections. The server must ignore them. const res = await POST( postReq( validBody({ documentOnly: true, - allowFhirApi: true, - resourceTypes: ["Observation", "Patient"], sections: { vitals: { bp: true }, labs: true }, documentIds: ["doc-a"], }), @@ -288,9 +286,9 @@ describe("POST /api/share-links — create", () => { const createArg = vi.mocked(prisma.clinicianShareLink.create).mock .calls[0][0]; - // Empty FHIR scope, FHIR API off — no health surface at all. - expect(createArg.data.resourceTypes).toEqual([]); - expect(createArg.data.allowFhirApi).toBe(false); + // 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); diff --git a/src/app/c/[token]/__tests__/page.test.tsx b/src/app/c/[token]/__tests__/page.test.tsx index 55b121b09..35416057d 100644 --- a/src/app/c/[token]/__tests__/page.test.tsx +++ b/src/app/c/[token]/__tests__/page.test.tsx @@ -94,8 +94,6 @@ function resolvedContext() { rangeStart: new Date(), rangeEnd: null, sectionsJson: {}, - resourceTypes: [], - allowFhirApi: false, expiresAt: new Date(Date.now() + 86_400_000), }; } diff --git a/src/app/c/[token]/d/[id]/__tests__/route.test.ts b/src/app/c/[token]/d/[id]/__tests__/route.test.ts index 96072c5c7..93adec25a 100644 --- a/src/app/c/[token]/d/[id]/__tests__/route.test.ts +++ b/src/app/c/[token]/d/[id]/__tests__/route.test.ts @@ -159,8 +159,6 @@ beforeEach(() => { rangeStart: new Date(), rangeEnd: null, sectionsJson: {}, - resourceTypes: [], - allowFhirApi: false, expiresAt: new Date(Date.now() + 3_600_000), } as never); }); diff --git a/src/components/settings/__tests__/share-link-create-form.test.tsx b/src/components/settings/__tests__/share-link-create-form.test.tsx index 707c63ca1..96e17aef8 100644 --- a/src/components/settings/__tests__/share-link-create-form.test.tsx +++ b/src/components/settings/__tests__/share-link-create-form.test.tsx @@ -112,19 +112,17 @@ describe("buildShareLinkCreatePayload — scope contract", () => { expiryDays: 30, }; - it("document-only: empty report scope, FHIR off, and the launched doc always rides along", () => { + it("document-only: empty report scope and the launched doc always rides along", () => { const payload = buildShareLinkCreatePayload({ ...common, - // Even if the caller state still holds record-scope values, they must be - // dropped: a document link never carries a health scope. - allowFhirApi: true, - resourceTypes: ["Patient", "Observation"], documentIds: ["doc-1"], documentOnly: true, }); - expect(payload.resourceTypes).toEqual([]); - expect(payload.allowFhirApi).toBe(false); expect(payload.documentOnly).toBe(true); + // The retired FHIR scope keys are gone from the wire entirely, + // so a share link can no longer advertise a face that was never served. + expect(payload).not.toHaveProperty("resourceTypes"); + expect(payload).not.toHaveProperty("allowFhirApi"); // No report sections are ever sent for a document link. expect(payload).not.toHaveProperty("sections"); // The launched document is always in the created link (pre-attach fix). @@ -132,15 +130,15 @@ describe("buildShareLinkCreatePayload — scope contract", () => { expect(payload.label).toBe("Blood panel 2026"); }); - it("record share: keeps the full scope and omits documentIds when none picked", () => { + it("record share: keeps the window scope and omits documentIds when none picked", () => { const payload = buildShareLinkCreatePayload({ ...common, - allowFhirApi: false, - resourceTypes: ["Patient", "Observation"], documentIds: [], documentOnly: false, }); - expect(payload.resourceTypes).toEqual(["Patient", "Observation"]); + expect(payload.rangeEnd).toBeNull(); + expect(payload).not.toHaveProperty("resourceTypes"); + expect(payload).not.toHaveProperty("allowFhirApi"); expect(payload).not.toHaveProperty("documentIds"); expect(payload).not.toHaveProperty("documentOnly"); }); @@ -148,8 +146,6 @@ describe("buildShareLinkCreatePayload — scope contract", () => { it("record share: attaches the picked documents when the owner chose some", () => { const payload = buildShareLinkCreatePayload({ ...common, - allowFhirApi: false, - resourceTypes: ["Patient", "Observation"], documentIds: ["a", "b"], documentOnly: false, }); diff --git a/src/components/settings/__tests__/sharing-section.test.tsx b/src/components/settings/__tests__/sharing-section.test.tsx index 81a5171f4..7cadc1354 100644 --- a/src/components/settings/__tests__/sharing-section.test.tsx +++ b/src/components/settings/__tests__/sharing-section.test.tsx @@ -33,8 +33,6 @@ const FIXTURE_LINKS = [ label: "Cardiology referral", rangeStart: "2026-01-01T00:00:00.000Z", rangeEnd: null, - resourceTypes: ["Patient", "Observation"], - allowFhirApi: true, documentCount: 2, expiresAt: "2099-01-01T00:00:00.000Z", createdAt: "2026-06-01T00:00:00.000Z", @@ -48,8 +46,6 @@ const FIXTURE_LINKS = [ label: "Old GP link", rangeStart: "2026-01-01T00:00:00.000Z", rangeEnd: null, - resourceTypes: [], - allowFhirApi: false, documentCount: 0, expiresAt: "2099-01-01T00:00:00.000Z", createdAt: "2026-05-01T00:00:00.000Z", @@ -100,8 +96,9 @@ describe(" — owner share-link surface (C7)", () => { // The list response shape carries no token; the markup must not surface // an `hls_` string anywhere. expect(html).not.toContain("hls_"); - // The FHIR badge surfaces for the FHIR-enabled link. - expect(html).toContain("FHIR"); + // No FHIR badge: the link has never served FHIR, so advertising + // it on the owner's list was the last place the promise still showed. + expect(html).not.toContain("FHIR"); }); it("folds revoked links into the inactive list (not the active list)", () => { diff --git a/src/lib/clinician-share/__tests__/resolve-share-token.test.ts b/src/lib/clinician-share/__tests__/resolve-share-token.test.ts index 814683dc1..c7252dd65 100644 --- a/src/lib/clinician-share/__tests__/resolve-share-token.test.ts +++ b/src/lib/clinician-share/__tests__/resolve-share-token.test.ts @@ -39,8 +39,6 @@ function liveRow(overrides: Record = {}) { rangeStart: new Date("2026-01-01T00:00:00.000Z"), rangeEnd: null, sectionsJson: { bp: true }, - resourceTypes: ["Observation"], - allowFhirApi: false, expiresAt: new Date(Date.now() + 86_400_000), revokedAt: null, ...overrides, @@ -59,7 +57,9 @@ describe("resolveShareToken", () => { expect(ctx).not.toBeNull(); expect(ctx?.ownerUserId).toBe("owner-1"); expect(ctx?.shareLinkId).toBe("link-1"); - expect(ctx?.resourceTypes).toEqual(["Observation"]); + // The retired FHIR scope no longer rides the resolved context. + expect(ctx).not.toHaveProperty("resourceTypes"); + expect(ctx).not.toHaveProperty("allowFhirApi"); // The context must carry ONLY the owner scope — never a session/user/role. expect(ctx).not.toHaveProperty("session"); expect(ctx).not.toHaveProperty("user"); diff --git a/src/lib/clinician-share/__tests__/share-view-data.test.ts b/src/lib/clinician-share/__tests__/share-view-data.test.ts index 5eac1b659..2317f375f 100644 --- a/src/lib/clinician-share/__tests__/share-view-data.test.ts +++ b/src/lib/clinician-share/__tests__/share-view-data.test.ts @@ -45,8 +45,6 @@ function ctx(overrides: Partial = {}): ShareContext { rangeStart: new Date("2026-01-01T00:00:00Z"), rangeEnd: new Date("2026-02-01T00:00:00Z"), sectionsJson: { mood: false }, - resourceTypes: [], - allowFhirApi: false, expiresAt: new Date(Date.now() + 86_400_000), ...overrides, } as ShareContext; diff --git a/src/lib/validations/__tests__/doctor-report-prefs.test.ts b/src/lib/validations/__tests__/doctor-report-prefs.test.ts index c3abd34fc..e8073ab02 100644 --- a/src/lib/validations/__tests__/doctor-report-prefs.test.ts +++ b/src/lib/validations/__tests__/doctor-report-prefs.test.ts @@ -109,7 +109,14 @@ describe("resolveDoctorReportPrefs", () => { familyHistory: false, }; const out = resolveDoctorReportPrefs(current, { mood: false }); - expect(out).toEqual({ ...current, mood: false }); + // Keys the persisted row predates (this change added the measurement-group, + // medication-list and screener flags) fill from the defaults, so the + // resolved shape is always complete for the aggregator. + expect(out).toEqual({ + ...DEFAULT_DOCTOR_REPORT_PREFS, + ...current, + mood: false, + }); }); it("layers a partial update over defaults when current is null", () => {