test(export): pin the selection controls against going inert again

Two layers, because the defect had two halves and each needs its own kind of
proof.

The structural guard walks the panel's control shape, folds every control
through the wire schema and the resolver the route uses, and fails when
flipping one changes nothing in the resolved selection. It also checks the
panel renders a row for every control it declares, and that every resolved
flag is read downstream, either by gating measurement types or by name in the
aggregator. That is the link nobody had written: the dead switches were dead
for several releases and no test connected "the panel renders this" to "the
export honours this".

The behavioural half runs the real export route and reads what comes out. The
previously-dead measurement switches are asserted against the FHIR bundle and
the PDF table, with a positive control beside each exclusion so nothing passes
on an empty fixture. The screening totals are asserted absent for a caller
that names other sections and for a caller that sends no sections at all, then
present when asked for by name. Allergies and family history are asserted
absent from the bundle and never read from the database, and the package case
opens the zip and checks the bundle inside it agrees with the PDF beside it.

Mutation-checked: reverting the allergy gate, the medication-list gate, the
screener entries or the measurement entries turns the matching cases red and
leaves the rest green; removing a control's fold entry or its aggregator entry
turns the guard red with the control named.

The remaining changes update fixtures the new shapes touch: prefs objects now
resolve the added flags from the defaults, and the share-link fixtures drop
the retired FHIR scope, with a case pinning that the create route now refuses
those keys instead of ignoring them.
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 00:45:08 +02:00
1 parent 68b9903966
commit f90219784b
11 files changed
+533 -58

No files matched your search

@@ -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<string>(
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);
});
});
@@ -99,10 +99,13 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => {
const res = await (PUT as (r: Request) => Promise<Response>)(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 },
});
});
@@ -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<typeof import("@/lib/tz/resolver")>();
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<string, unknown>,
): Promise<string[]> {
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<string, unknown>,
): Promise<string[]> {
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
+20 -22
View File
@@ -64,8 +64,6 @@ function validBody(overrides: Record<string, unknown> = {}) {
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<string, unknown> = {}) {
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);
@@ -94,8 +94,6 @@ function resolvedContext() {
rangeStart: new Date(),
rangeEnd: null,
sectionsJson: {},
resourceTypes: [],
allowFhirApi: false,
expiresAt: new Date(Date.now() + 86_400_000),
};
}
@@ -159,8 +159,6 @@ beforeEach(() => {
rangeStart: new Date(),
rangeEnd: null,
sectionsJson: {},
resourceTypes: [],
allowFhirApi: false,
expiresAt: new Date(Date.now() + 3_600_000),
} as never);
});
@@ -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,
});
@@ -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("<SharingSection> — 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)", () => {
@@ -39,8 +39,6 @@ function liveRow(overrides: Record<string, unknown> = {}) {
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");
@@ -45,8 +45,6 @@ function ctx(overrides: Partial<ShareContext> = {}): 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;
@@ -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", () => {