diff --git a/e2e/doctor-report-delegate.spec.ts b/e2e/doctor-report-delegate.spec.ts new file mode 100644 index 000000000..204734eea --- /dev/null +++ b/e2e/doctor-report-delegate.spec.ts @@ -0,0 +1,271 @@ +/** + * The doctor report is not something a READ-level delegate can generate. + * + * `POST /api/export/health-record` resolves `requireRecordAuth("manage", + * "record")`: a prepared artefact from a declared selection is the one export an + * invited MANAGER reaches, and READ is below that line. That is the statement + * this spec is about, and it is the ROUTE half that carries it: 403 inside the + * record the delegate may only read, 200 inside the one it manages. + * + * The UI half proves something narrower, and the file says so rather than + * implying the route's boundary twice. `/settings/gesundheitsakte` is a + * PERSONAL destination: inside SOMEBODY ELSE's record the shell answers it with + * the "not part of shared access" panel at every adult grant level — READ, + * WRITE and MANAGE alike — so the export card is never rendered for a delegate + * to press. That is a property of the destination, not of the level, and the + * spec asserts it at BOTH levels for exactly that reason: the same two + * absences inside the MANAGE record, where the route says 200, are what stops + * "the control is not offered" from being a check that cannot fail. + * + * Why both halves at all. A missing button proves nothing about the server, and + * a 403 proves nothing about what the person is shown; the failure mode this + * release family keeps rediscovering is a control that renders and then 403s. + * + * The positive control is the same delegate, the same payload and the same + * switched-record plumbing against the record it holds a MANAGE grant on. That + * isolates the one variable the refusal is supposed to be about: if the payload + * were malformed, the session unfenced or the switch broken, the MANAGE arm + * would refuse too and the file would go red instead of passing for the wrong + * reason. + * + * Both requests carry the record-context headers the shipped client sends. A + * raw `fetch` without them is refused by the record-session fence with the SAME + * status and the SAME error code as an insufficient grant — so an unfenced + * request would make the refusal below pass for the wrong reason. The context + * is read from `/api/auth/me`, which is where a browser gets it too. + * + * Its own cookie jar, like every spec that moves a session's record selector: + * the switch is stamped on the session row, so sharing a jar would switch the + * record under whichever spec happened to be mid-navigation. + */ +import type { Page } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { + E2E_LEVEL_RECORDS, + REPORT_DELEGATE_STORAGE_STATE_PATH, +} from "./setup/test-helpers"; + +/** A selection the route accepts, so a refusal is about the grant and nothing + * else. One leaf is enough: an empty list is its own 422. */ +const SELECTION = { v: 2, leaves: ["WEIGHT"] } as const; + +interface ReportAttempt { + status: number; + contentType: string; + errorCode: string | null; + magic: string; +} + +async function openSwitcher(page: Page): Promise { + await page.getByRole("button", { name: "User menu" }).first().click(); + await page.locator('[data-slot="account-switcher-trigger"]').click(); + await expect( + page.locator('[data-slot="account-switcher-menu"]'), + ).toBeVisible(); +} + +/** + * Run `act` and wait for the document it replaces to be gone — the switch is a + * hard navigation, and a banner assertion alone settles nothing. Same helper + * shape the v1.37/v1.38 sharing journeys use, for the reason written there. + */ +async function withDocumentReplacement( + page: Page, + act: () => Promise, +): Promise { + await page.evaluate(() => { + (window as Window & { __hlNavToken?: true }).__hlNavToken = true; + }); + await act(); + await page.waitForFunction( + () => + (window as Window & { __hlNavToken?: true }).__hlNavToken === undefined, + undefined, + { timeout: 30_000 }, + ); +} + +/** Land on the delegate's own record, with the shell live. */ +async function openOwnRecord(page: Page): Promise { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect( + page.getByRole("button", { name: "User menu" }).first(), + ).toBeVisible({ + timeout: 30_000, + }); + await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount( + 0, + { timeout: 30_000 }, + ); +} + +async function enterRecord(page: Page, username: string): Promise { + await openSwitcher(page); + const entry = page.locator( + `[data-slot="account-switcher-entry"][data-account-username="${username}"]`, + ); + await expect(entry).toHaveCount(1); + await withDocumentReplacement(page, () => entry.click()); + await expect(page.locator('[data-slot="shared-record-banner"]')).toBeVisible({ + timeout: 30_000, + }); +} + +async function leaveRecord(page: Page): Promise { + await withDocumentReplacement(page, () => + page.locator('[data-slot="shared-record-banner-exit"]').click(), + ); + await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount( + 0, + { timeout: 30_000 }, + ); +} + +/** + * Open the health-record settings page and wait for the shell to decide. + * + * Inside somebody else's record the shell answers this destination with the + * "not part of shared access" panel rather than the section, so the panel is + * the settled state to gate on — and the thing whose presence says the export + * card was never offered. + */ +async function openGesundheitsakte(page: Page): Promise { + await page.goto("/settings/gesundheitsakte", { + waitUntil: "domcontentloaded", + }); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toBeVisible({ timeout: 30_000 }); +} + +/** + * Ask the route for a PDF from inside the page, carrying the record context the + * shipped client carries. + */ +async function attemptReport( + page: Page, + selection: { v: number; leaves: readonly string[] }, +): Promise { + return page.evaluate(async (chosen) => { + const me = (await (await fetch("/api/auth/me")).json()) as { + data: { recordSession: { epoch: number; scope: string | null } | null }; + }; + const context = me.data.recordSession; + const headers: Record = { + "Content-Type": "application/json", + }; + if (context) { + headers["x-healthlog-record-epoch"] = String(context.epoch); + headers["x-healthlog-record-scope"] = context.scope ?? "self"; + } + + const res = await fetch("/api/export/health-record", { + method: "POST", + headers, + body: JSON.stringify({ + format: "pdf", + locale: "en", + range: { days: 30 }, + includeCharts: false, + selection: chosen, + }), + }); + + const contentType = res.headers.get("content-type") ?? ""; + const buffer = await res.arrayBuffer(); + const head = String.fromCharCode(...new Uint8Array(buffer.slice(0, 5))); + + let errorCode: string | null = null; + if (contentType.includes("json")) { + try { + const payload = JSON.parse(new TextDecoder().decode(buffer)) as { + meta?: { errorCode?: string }; + }; + errorCode = payload.meta?.errorCode ?? null; + } catch { + errorCode = null; + } + } + + return { status: res.status, contentType, errorCode, magic: head }; + }, selection); +} + +test.describe.serial("a read-level delegate and the doctor report", () => { + test.use({ storageState: REPORT_DELEGATE_STORAGE_STATE_PATH }); + + const readRecord = E2E_LEVEL_RECORDS.find( + (record) => record.access === "READ" && record.recordKind === "shared", + ); + if (!readRecord) throw new Error("the READ-level record fixture is missing"); + + const manageRecord = E2E_LEVEL_RECORDS.find( + (record) => record.access === "MANAGE" && record.recordKind === "shared", + ); + if (!manageRecord) { + throw new Error("the MANAGE-level record fixture is missing"); + } + + test.afterEach(async ({ page }) => { + // Hand the session back to its own record even when an assertion above + // failed: this jar is this spec's, but a switched row left behind would + // make its own next run start somewhere it did not choose. + await page.request + .post("/api/account/switch", { data: { accountId: null } }) + .catch(() => {}); + }); + + test("cannot generate on a record it may only read, and is not offered the control", async ({ + page, + }) => { + test.setTimeout(120_000); + + await openOwnRecord(page); + + // Positive control: a record the same delegate MANAGES. Everything except + // the access level is identical to the refusal below. + await enterRecord(page, manageRecord.username); + await expect( + page.locator('[data-slot="shared-record-banner"]'), + ).toHaveAttribute("data-access-level", "manage"); + const managed = await attemptReport(page, SELECTION); + expect(managed.status).toBe(200); + expect(managed.contentType).toContain("application/pdf"); + expect(managed.magic).toBe("%PDF-"); + + // The destination is shut here too, and this is the arm that proves it is + // the DESTINATION doing it: the route answered 200 for this very record a + // line ago, so the two absences below cannot be the grant level talking. + await openGesundheitsakte(page); + await expect(page.getByTestId("health-record-export-panel")).toHaveCount(0); + await expect(page.getByTestId("health-record-generate")).toHaveCount(0); + + await leaveRecord(page); + + await enterRecord(page, readRecord.username); + const banner = page.locator('[data-slot="shared-record-banner"]'); + await expect(banner).toHaveAttribute("data-record-kind", "shared"); + // The banner carries the PRESENTATION of the grant, and a READ grant + // presents as "view" — one level below the "view-and-add" a WRITE grant + // shows and two below the "manage" this route requires. + await expect(banner).toHaveAttribute("data-access-level", "view"); + + // The same closed door, one level down. Paired with the MANAGE arm above it + // says the export card is offered inside NOBODY's record but your own; on + // its own it would say nothing about READ. + await openGesundheitsakte(page); + await expect(page.getByTestId("health-record-export-panel")).toHaveCount(0); + await expect(page.getByTestId("health-record-generate")).toHaveCount(0); + + // The level-sensitive statement: the same request, the same headers and the + // same payload that answered 200 inside the managed record are refused + // here, and nothing but the grant changed. + const shared = await attemptReport(page, SELECTION); + expect(shared.status).toBe(403); + expect(shared.errorCode).toBe("sharing.access.denied"); + expect(shared.contentType).toContain("json"); + + await leaveRecord(page); + }); +}); diff --git a/e2e/doctor-report.spec.ts b/e2e/doctor-report.spec.ts new file mode 100644 index 000000000..c11fdee63 --- /dev/null +++ b/e2e/doctor-report.spec.ts @@ -0,0 +1,524 @@ +/** + * The doctor report, generated in a browser and read back as a real PDF. + * + * The surface had unit coverage for the renderer and route coverage for the + * envelope, and nothing that pressed the button. What that leaves untested is + * the part a self-hoster actually meets: a person with a few weeks of readings + * opens Settings → Gesundheitsakte, picks a window, presses Generate, and gets + * a document a practice can file. Every link in that chain — the panel's own + * `fetch`, the selection it sends, the aggregator's window, the renderer, the + * download — is proved here once, end to end. + * + * What the assertions are about: + * + * 1. The response IS a PDF: `application/pdf`, a `%PDF` magic, a byte length + * no empty document could reach, and a `Content-Length` that agrees with + * the bytes. + * 2. The document says what it contains. The bytes are parsed back to text, + * so the period line, the blood-pressure section and the medication the + * account carries are read out of the artefact rather than inferred from a + * 200. + * 3. The window travelled. The period line names two dates, and their span is + * the window the select offered — not the server's default. + * + * And the refusing control, in the same file because it is the same button: a + * window with nothing in it still produces a valid, readable PDF that names the + * period and simply carries no blood-pressure section. An empty report is a + * true statement about an empty window; a truncated or unopenable file is not. + * + * Its own account, and not the shared one: generating a report WRITES. The + * route remembers the scope and the practice name on the account row, so the + * panel a later visit opens is the collapsed "repeat run" state — which is + * exactly the state `settings-export.spec.ts` asserts the opposite of, against + * the shared account, in nine places. See `E2E_REPORT_OWNER`. + * + * The fixture is seeded through the PAGE's own `fetch` under the session the + * spec is already authenticated as — not through a second Playwright request + * context — so the rows are written by exactly the credential that later reads + * them back. The ROWS are idempotent: a re-post of the same reading collides on + * the measurement's natural key and answers 409, so `--repeat-each` and a + * second local run write nothing twice. The export QUOTA is not idempotent — + * every export route shares one 10-per-hour bucket keyed on the actor, and it + * outlives the run — so `global-setup` clears this account's bucket before each + * run rather than the file pretending the press is free. + * + * The delegate half of the story — a READ-level delegate cannot generate the + * owner's report — lives in `doctor-report-delegate.spec.ts`, which needs its + * own session row because it moves the record selector. + */ +import { readFile } from "node:fs/promises"; + +import type { Page, Response } from "@playwright/test"; +import { PDFParse } from "pdf-parse"; + +import { REPORT_OWNER_STORAGE_STATE_PATH } from "./setup/global-setup"; +import { expect, test } from "./setup/test"; + +/** The allergy and the drug the report has to name. Both plain ASCII: the + * PDF's built-in Helvetica is WinAnsi-encoded and the assertions read the + * extracted text back, so a fixture that needs transliteration would be + * testing the sanitiser rather than the report. */ +const ALLERGY_SUBSTANCE = "Amoxicillin"; +const MEDICATION_NAME = "Ramipril"; +const MEDICATION_DOSE = "5 mg"; + +/** Day offsets for the seeded readings — a few weeks of history, inside the + * 30-day window the spec selects and well inside the 90-day default, so a + * window that failed to travel would still find them. */ +const READING_DAY_OFFSETS = [2, 5, 9, 13, 17, 21] as const; + +/** The window the spec chooses in the panel. Deliberately NOT the 90-day + * fallback the panel opens with, so the period line proves a choice. */ +const RANGE_DAYS = 30; + +/** The section heading the PDF prints only when both blood-pressure series + * have readings in the window. Quoted without its em dash: the expectation is + * about the section being there, not about how a parser renders punctuation. */ +const BP_SECTION = "ESH classification"; + +/** The refusing control's window: twenty days, starting four hundred days back. + * Far outside every seeded reading and well inside the route's 1..730-day span + * bounds, and named so the period line can be asserted against it rather than + * the report being taken on trust. */ +const EMPTY_WINDOW_START_DAYS_BACK = 400; +const EMPTY_WINDOW_DAYS = 20; + +/** Byte floor for "a real document". A cover page with charts is far above + * this; a truncated stream or an error body is far below it. */ +const MIN_PDF_BYTES = 10_000; + +interface SeedOutcome { + measurements: number[]; + allergy: number; + medication: number; + emergency: number; +} + +/** + * Write the record this report is about, through the page's own `fetch`. + * + * Each per-day POST carries the day's systolic, diastolic and weight as one + * array body — the route's batch arm — so a repeat run collides as a whole day + * and answers 409 rather than half-inserting. The allergy and the medication + * are read before they are written, because neither has a natural key that + * would make a second create a no-op. + * + * The emergency profile matters more than it looks: the drug list reaches the + * PDF through the emergency sheet, and that sheet only renders once the profile + * holds something. One blood type is the smallest honest way to say so. + */ +async function seedReportFixture(page: Page): Promise { + return page.evaluate( + async (fixture) => { + const send = async ( + method: string, + path: string, + body: unknown, + ): Promise => { + const res = await fetch(path, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return res.status; + }; + + const readList = async (path: string): Promise => { + const res = await fetch(path); + if (!res.ok) return []; + const payload = (await res.json()) as { data: unknown }; + return Array.isArray(payload.data) ? payload.data : []; + }; + + /** 09:00 UTC on the day `offset` days back — a fixed instant, so a + * second run inside the same day reproduces the same natural key. */ + const instantFor = (offset: number): string => { + const day = new Date(); + day.setUTCDate(day.getUTCDate() - offset); + return `${day.toISOString().slice(0, 10)}T09:00:00.000Z`; + }; + + const measurements: number[] = []; + for (const [index, offset] of fixture.dayOffsets.entries()) { + const measuredAt = instantFor(offset); + measurements.push( + await send("POST", "/api/measurements", [ + { + type: "BLOOD_PRESSURE_SYS", + value: 118 + index, + measuredAt, + }, + { + type: "BLOOD_PRESSURE_DIA", + value: 74 + index, + measuredAt, + }, + { + type: "WEIGHT", + value: 82.4 - index * 0.2, + measuredAt, + }, + ]), + ); + } + + const allergies = (await readList("/api/allergies")) as Array<{ + substance?: string; + }>; + const allergy = allergies.some( + (row) => row.substance === fixture.allergySubstance, + ) + ? 200 + : await send("POST", "/api/allergies", { + substance: fixture.allergySubstance, + category: "MEDICATION", + type: "ALLERGY", + severity: "MODERATE", + }); + + const drugs = (await readList("/api/medications")) as Array<{ + name?: string; + }>; + const medication = drugs.some( + (row) => row.name === fixture.medicationName, + ) + ? 200 + : await send("POST", "/api/medications", { + name: fixture.medicationName, + dose: fixture.medicationDose, + // No schedule: an as-needed drug is active indefinitely and needs + // no dose ledger, which keeps the fixture about the drug LIST. + asNeeded: true, + }); + + const emergency = await send("PATCH", "/api/anamnesis/emergency", { + bloodType: "O_POS", + }); + + return { measurements, allergy, medication, emergency }; + }, + { + dayOffsets: [...READING_DAY_OFFSETS], + allergySubstance: ALLERGY_SUBSTANCE, + medicationName: MEDICATION_NAME, + medicationDose: MEDICATION_DOSE, + }, + ); +} + +/** + * Open the panel and wait until the client boundary that owns it is live. + * + * `GesundheitsakteSection` carries the export panel and the sharing card in one + * boundary, and the sharing card's query only runs once that boundary has + * hydrated — so its request is the signal that the Generate button is wired + * rather than merely painted. Same gate `settings-export.spec.ts` uses, for the + * same reason. + */ +async function openReportPanel(page: Page): Promise { + const boundaryHydrated = page.waitForResponse( + (response) => + response.request().method() === "GET" && + new URL(response.url()).pathname === "/api/share-links", + ); + await page.goto("/settings/gesundheitsakte", { + waitUntil: "domcontentloaded", + }); + await boundaryHydrated; + await expect(page.getByTestId("health-record-export-panel")).toBeVisible({ + timeout: 30_000, + }); +} + +/** + * Put the panel into a known state: PDF format, the standard scope. + * + * The account carries a saved selection the moment it has generated once, so + * the panel a second run opens is the "repeat run" state with the picker + * collapsed. Both states are handled by opening the disclosure when it is shut + * and then applying the named template, which is also the only thing in the + * product that fills a selection — so the scope this report is generated under + * is a scope somebody pressed a button for, exactly as the panel intends. + */ +async function chooseStandardPdfScope(page: Page): Promise { + const panel = page.getByTestId("health-record-export-panel"); + + const formats = panel.locator('[role="radiogroup"] [role="radio"]'); + // PDF is the first of the three formats the panel offers. + await formats.first().click(); + await expect(formats.first()).toHaveAttribute("aria-checked", "true"); + + const picker = page.getByTestId("health-record-included-data-panel"); + if ((await picker.count()) === 0) { + await page.getByTestId("health-record-included-data-toggle").click(); + } + await expect(picker).toBeVisible(); + + await page.getByTestId("report-apply-standard-export").click(); + await expect(page.getByTestId("health-record-generate")).toBeEnabled(); +} + +interface GeneratedReport { + status: number; + contentType: string; + contentLength: string | undefined; + bytes: Buffer; + text: string; + filename: string; +} + +/** + * Press Generate and take the artefact apart. + * + * Both ends of the press are observed: the response carries the bytes the + * assertions are about, and the download event is the state the person sees. + * Waiting on the response ALONE would pass on a build that produced a perfect + * PDF and never handed it to the browser. + */ +async function generateReport(page: Page): Promise { + const responseArrived = page.waitForResponse( + (response) => + response.request().method() === "POST" && + new URL(response.url()).pathname === "/api/export/health-record", + { timeout: 60_000 }, + ); + const downloadStarted = page.waitForEvent("download", { timeout: 60_000 }); + + const generate = page.getByTestId("health-record-generate"); + await generate.click(); + + const response = await responseArrived; + const download = await downloadStarted; + + // The bytes come off the DOWNLOAD, not off the response, because the saved + // file is the artefact the person ends up with: a build that produced a + // perfect response and saved something else would still be caught. + const saved = await download.path(); + expect(saved, "the panel saved the generated report").toBeTruthy(); + const bytes = await readFile(saved!); + + // The control comes back, and nothing on the panel is complaining. The error + // paragraph is the panel's only failure surface, so its absence is the + // statement that the press succeeded rather than failed quietly. + await expect(generate).toBeEnabled({ timeout: 30_000 }); + await expect( + page.getByTestId("health-record-export-panel").getByRole("alert"), + ).toHaveCount(0); + + // Asserted BEFORE the parse: a parser fed an error envelope reports "not a + // PDF", which is a true statement about the bytes and a useless one about + // what went wrong. The status and the content type say that. + expect(response.status(), await failureDetail(response)).toBe(200); + expect(response.headers()["content-type"] ?? "").toContain("application/pdf"); + expect(bytes.byteLength).toBeGreaterThan(MIN_PDF_BYTES); + + const parser = new PDFParse({ data: new Uint8Array(bytes) }); + let text: string; + try { + text = (await parser.getText()).text; + } finally { + await parser.destroy(); + } + + return { + status: response.status(), + contentType: response.headers()["content-type"] ?? "", + contentLength: response.headers()["content-length"], + bytes, + text, + filename: download.suggestedFilename(), + }; +} + +/** What the route said, when it did not say "PDF". */ +async function failureDetail(response: Response): Promise { + try { + return (await response.text()).slice(0, 400); + } catch { + return ""; + } +} + +/** Every assertion that is about "this is a PDF", said once. */ +function expectRealPdf(report: GeneratedReport): void { + expect(report.status).toBe(200); + expect(report.contentType).toContain("application/pdf"); + expect(report.bytes.subarray(0, 5).toString("latin1")).toBe("%PDF-"); + expect(report.bytes.byteLength).toBeGreaterThan(MIN_PDF_BYTES); + // The route declares the length it sent; a disagreement means the body was + // truncated on the way out, which a magic-number check alone would miss. + expect(report.contentLength).toBe(String(report.bytes.byteLength)); + // The name the CLIENT anchor invents, not the route's `Content-Disposition` + // — the two differ by a word today. This says the panel saved a PDF, and it + // is not contract coverage of the header. + expect(report.filename).toMatch(/\.pdf$/); +} + +/** The three orders `DateFormatPreference` pins a rendering to. */ +type DateOrder = "DMY" | "MDY" | "YMD"; + +/** + * Which field of the period line is the month, read off the account. + * + * The rendering is the account's `dateFormat` preference, not a constant: DMY + * prints `10.09.2026`, YMD prints `2026-09-10`, and en-GB's MM/DD-shaped + * `10/09/2026` is a DMY date that an MDY reader silently mis-parses into a + * nonsense span. So the preference is read rather than assumed. + * + * `AUTO` resolves through the active locale and is deliberately refused here: + * the fixture pins `MDY` (see `E2E_REPORT_OWNER` in `global-setup.ts`), so an + * `AUTO` account is a drifted fixture and should say so rather than be guessed + * at. + */ +async function readDateOrder(page: Page): Promise { + const preference = await page.evaluate(async () => { + const res = await fetch("/api/auth/me"); + const payload = (await res.json()) as { data: { dateFormat?: string } }; + return payload.data.dateFormat ?? "AUTO"; + }); + expect( + ["DMY", "MDY", "YMD"], + "the account pins an explicit date order for the period line", + ).toContain(preference); + return preference as DateOrder; +} + +/** + * The period line's two dates, as a span in days. + * + * The line is rendered in the account's date order and its timezone, so the + * dates are read structurally and compared as a SPAN. A span is timezone-proof + * in a way an absolute date is not: both ends move together. + * + * The separator is captured once and back-referenced, so `10.09.2026`, + * `09/10/2026` and `2026-09-10` all parse and none of them can be read as the + * other two. + */ +function periodSpanDays(text: string, order: DateOrder): number { + const match = + /Reporting period:\s*(\d{2,4})([./-])(\d{2})\2(\d{2,4})\s*\D{1,3}\s*(\d{2,4})\2(\d{2})\2(\d{2,4})/.exec( + text, + ); + expect(match, "the report prints a reporting period with two dates").not.toBe( + null, + ); + const start = utcMidnight(match![1], match![3], match![4], order); + const end = utcMidnight(match![5], match![6], match![7], order); + return Math.round((end - start) / 86_400_000); +} + +function utcMidnight( + first: string, + second: string, + third: string, + order: DateOrder, +): number { + const [a, b, c] = [Number(first), Number(second), Number(third)]; + const [year, month, day] = + order === "YMD" ? [a, b, c] : order === "DMY" ? [c, b, a] : [c, a, b]; + return Date.UTC(year, month - 1, day); +} + +test.describe.serial("the doctor report", () => { + // This journey's own account owns the readings, the allergy and the drug, and + // the page's own `fetch` writes them under its session. + test.use({ storageState: REPORT_OWNER_STORAGE_STATE_PATH }); + + test("generates a PDF that names the period, the vitals and the medication", async ({ + page, + }) => { + // A production build rendering a charted PDF over a seeded window is well + // past the 30 s default, and a timeout here would report a failure that + // says nothing about the report. + test.setTimeout(120_000); + + await openReportPanel(page); + const seeded = await seedReportFixture(page); + // 201 on a first run, 409 on a repeat — both mean the readings are there. + for (const status of seeded.measurements) { + expect([201, 409]).toContain(status); + } + expect([200, 201]).toContain(seeded.allergy); + expect([200, 201]).toContain(seeded.medication); + expect(seeded.emergency).toBe(200); + + // Reload so the panel is looking at the record that now exists. + await openReportPanel(page); + await chooseStandardPdfScope(page); + + // The period: a window the panel offers, and not the one it defaults to. + await page.locator("#hr-range").selectOption({ value: String(RANGE_DAYS) }); + await expect(page.locator("#hr-range")).toHaveValue(String(RANGE_DAYS)); + + const report = await generateReport(page); + expectRealPdf(report); + + // The document says what it is about. + expect(report.text).toContain("Reporting period"); + const span = periodSpanDays(report.text, await readDateOrder(page)); + expect(span).toBeGreaterThanOrEqual(RANGE_DAYS - 1); + expect(span).toBeLessThanOrEqual(RANGE_DAYS + 1); + + // The blood-pressure section only prints when both series carried readings + // in the window, so its presence is the seeded history arriving. + expect(report.text).toContain(BP_SECTION); + // And the record the clinician reads first: the drug and the allergy. + expect(report.text).toContain(MEDICATION_NAME); + expect(report.text).toContain(ALLERGY_SUBSTANCE); + }); + + test("a window with no readings is an empty report, not a broken one", async ({ + page, + }) => { + test.setTimeout(120_000); + + await openReportPanel(page); + await chooseStandardPdfScope(page); + + // A custom window a year back: inside the route's span bounds, and holding + // none of this account's readings. + await page.locator("#hr-range").selectOption({ value: "custom" }); + const panel = page.getByTestId("health-record-export-panel"); + const dateFields = panel.locator( + '[data-slot="date-field"] input[type="text"]', + ); + await expect(dateFields).toHaveCount(2); + + const dayBack = (offset: number): string => { + const day = new Date(); + day.setUTCDate(day.getUTCDate() - offset); + return day.toISOString().slice(0, 10); + }; + // The field commits a clean ISO string whatever the locale's field order is. + await dateFields.first().fill(dayBack(EMPTY_WINDOW_START_DAYS_BACK)); + await dateFields.first().press("Enter"); + await dateFields + .nth(1) + .fill(dayBack(EMPTY_WINDOW_START_DAYS_BACK - EMPTY_WINDOW_DAYS)); + await dateFields.nth(1).press("Enter"); + + const report = await generateReport(page); + // Still a document: the empty window is answered with a report, not with a + // truncated file and not with an error the panel swallows. + expectRealPdf(report); + expect(report.text).toContain("Reporting period"); + + // And it is the window that was typed. Two silent fallbacks sit under this + // line — the panel sends `{ days }` when either date field is empty, and + // the route falls back to the 90-day default when a bound fails to parse, + // neither of them a 422. Both land on a window that HOLDS the seeded + // readings, so without this the absence below would be the fixture's luck. + const emptySpan = periodSpanDays(report.text, await readDateOrder(page)); + expect(emptySpan).toBeGreaterThanOrEqual(EMPTY_WINDOW_DAYS - 1); + expect(emptySpan).toBeLessThanOrEqual(EMPTY_WINDOW_DAYS + 1); + + // Nothing was measured in it, so the section that needs readings is gone. + expect(report.text).not.toContain(BP_SECTION); + + // The positive control for that absence: the reference data that is NOT + // window-scoped is still in the document. Without this, a renderer that + // produced a blank page would pass the line above. + expect(report.text).toContain(MEDICATION_NAME); + expect(report.text).toContain(ALLERGY_SUBSTANCE); + }); +}); diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 904250778..9e9f8fe01 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -120,6 +120,31 @@ export const E2E_SCOPE_DELEGATE = { role: "USER", } as const; +/** + * The doctor-report journey's own account. + * + * Generating a report WRITES: the route remembers the scope and the practice + * name on the account row, and the panel a later visit opens is therefore the + * "repeat run" state with the picker collapsed. `settings-export.spec.ts` + * asserts the opposite — an expanded picker, an empty summary, a disabled + * button — against the shared account, so a journey that generated there would + * turn nine of its assertions red the moment the two files happened to run in + * the wrong order, and keep them red on every later run against the same + * database. + * + * Its own account also keeps the seeded readings, the emergency profile and + * the hourly export bucket off the account sixty other specs read. + * + * `date_format` is pinned rather than left on AUTO: the spec reads the period + * line out of the rendered PDF and has to know which field is the month. + */ +export const E2E_REPORT_OWNER = { + email: "e2e-report-owner@healthlog.test", + username: "e2e-report-owner", + password: "Hd6!Tw3pV9nQ2xLb", + role: "USER", +} as const; + /** One non-personal target record per closed sharing domain. */ export const E2E_SCOPE_RECORDS = [ { @@ -275,6 +300,26 @@ export const CROSS_TAB_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateCrossTab.json", ); +/** + * The doctor-report delegate journey's jar. Its own session row, for the reason + * every switching spec has one: it opens somebody else's record to prove the + * report refuses a READ-level grant, and two specs driving one row from two + * workers would test their interference rather than the refusal. + */ +export const REPORT_DELEGATE_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateReportDelegate.json", +); + +/** + * The doctor-report journey's jar. Its own account, so what generating a report + * stamps on the account row cannot reach the shared one — see `E2E_REPORT_OWNER`. + */ +export const REPORT_OWNER_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateReportOwner.json", +); + /** * The banner-geometry spec's jar, and the reason it exists is a release-shaped * one rather than a load-shaped one. @@ -382,7 +427,9 @@ export default async function globalSetup(config: FullConfig): Promise { insights_cached_text = NULL, onboarding_completed_at = EXCLUDED.onboarding_completed_at, onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, - module_preferences_json = EXCLUDED.module_preferences_json`, + module_preferences_json = EXCLUDED.module_preferences_json, + report_selection_json = NULL, + last_report_practice_name = NULL`, [ cuid(), E2E_USER.username, @@ -459,6 +506,40 @@ export default async function globalSetup(config: FullConfig): Promise { ], ); + // The doctor-report journey's account. Seeded like the shared one — a + // record with a height, a date of birth and a gender, so the report has a + // patient header to print — and with the two report columns cleared, which + // is what puts the panel back into its first-run state on every run. + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + height_cm, date_of_birth, gender, date_format, + onboarding_completed_at, onboarding_tour_completed) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, + 180, $6, 'MALE', 'MDY', + $5, true) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at, + height_cm = EXCLUDED.height_cm, + date_of_birth = EXCLUDED.date_of_birth, + gender = EXCLUDED.gender, + date_format = EXCLUDED.date_format, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + report_selection_json = NULL, + last_report_practice_name = NULL`, + [ + cuid(), + E2E_REPORT_OWNER.username, + E2E_REPORT_OWNER.email, + await hashPassword(E2E_REPORT_OWNER.password), + now, + dob, + ], + ); + // The scoped-record journey uses a dedicated delegate so its preseeded // grants cannot affect the invitation lifecycle exercised by the sharing // journey above. Its own module preferences are explicitly off: a target @@ -569,7 +650,9 @@ export default async function globalSetup(config: FullConfig): Promise { managed_profile_at = EXCLUDED.managed_profile_at, onboarding_completed_at = EXCLUDED.onboarding_completed_at, onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, - module_preferences_json = EXCLUDED.module_preferences_json`, + module_preferences_json = EXCLUDED.module_preferences_json, + report_selection_json = NULL, + last_report_practice_name = NULL`, [ cuid(), record.username, @@ -629,6 +712,20 @@ export default async function globalSetup(config: FullConfig): Promise { [E2E_USER.username], ); + // The export bucket, for the same reason and with the same shape. Every + // export route shares one 10-per-hour bucket keyed on the ACTOR, the + // counter outlives a run by a full hour, and the doctor-report journey + // spends two of it per pass — so a fourth run inside the hour would meet a + // 429 and report a missing artefact rather than a spent quota. Only the two + // accounts that generate a report are cleared. + await pool.query( + `DELETE FROM rate_limits + WHERE key IN ( + SELECT 'export:' || id::text FROM users WHERE username = ANY($1) + )`, + [[E2E_REPORT_OWNER.username, E2E_SCOPE_DELEGATE.username]], + ); + // Opt the seed account into cycle tracking. The user is seeded as // `MALE`, and v1.18.0 resolves the `cycle` module through // `isCycleEnabled(gender, CycleProfile)` — NULL `cycleTrackingEnabled` @@ -698,7 +795,7 @@ export default async function globalSetup(config: FullConfig): Promise { WHERE key LIKE 'sharing:%'`, ); - // The login bucket, for the same reason. This setup signs in TWELVE times now + // The login bucket, for the same reason. This setup signs in FOURTEEN times now // (the shared jar, the owner, and one jar apiece for every spec that moves // a session's record selector), and the ceiling is five attempts per IP per // quarter-hour — so two local runs in a row would otherwise end with a 429 @@ -778,7 +875,7 @@ export default async function globalSetup(config: FullConfig): Promise { * Log one account in, clearing the login bucket first. * * The ceiling is FIVE attempts per IP per quarter-hour and this setup now - * signs in twelve times, so clearing once before the batch is no longer + * signs in fourteen times, so clearing once before the batch is no longer * enough — the sixth would be answered by the fixture's own 429 rather * than by the product. Only the auth surfaces' buckets are touched, and * only between logins this setup is itself performing. @@ -820,6 +917,11 @@ export default async function globalSetup(config: FullConfig): Promise { await capture(E2E_SCOPE_DELEGATE, FENCE_OFFLINE_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, CROSS_TAB_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SEAM_BANNERS_STORAGE_STATE_PATH); + await capture(E2E_SCOPE_DELEGATE, REPORT_DELEGATE_STORAGE_STATE_PATH); + + // The doctor-report journey's own account, for the reason written on + // E2E_REPORT_OWNER: generating a report writes to the account row. + await capture(E2E_REPORT_OWNER, REPORT_OWNER_STORAGE_STATE_PATH); // v1.37.0 — the guardian's jar, and only then its second factor. // diff --git a/e2e/setup/test-helpers.ts b/e2e/setup/test-helpers.ts index 024f9199e..6d9f4c65d 100644 --- a/e2e/setup/test-helpers.ts +++ b/e2e/setup/test-helpers.ts @@ -25,5 +25,7 @@ export { FENCE_STORAGE_STATE_PATH, FENCE_OFFLINE_STORAGE_STATE_PATH, CROSS_TAB_STORAGE_STATE_PATH, + REPORT_DELEGATE_STORAGE_STATE_PATH, + REPORT_OWNER_STORAGE_STATE_PATH, GUARDIAN_STORAGE_STATE_PATH, } from "./global-setup"; diff --git a/playwright.config.ts b/playwright.config.ts index a8d751009..4911f5074 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -169,6 +169,17 @@ export default defineConfig({ // to a question neither test asked. It proves flows through stable // data-slots, not a mobile layout, so it runs in one project. "medication-compliance-journey.spec.ts", + // The doctor-report journey seeds a few weeks of readings into its own + // account, presses Generate and reads the PDF back off the download. + // Two projects doing that in parallel would leave a second set of rows + // in the window one of them is asserting an empty report for, and would + // spend the shared hourly export bucket twice over. The panel's mobile + // layout is covered by `settings-export.spec.ts`, which measures its + // tap targets there; this journey is about the artefact. + "doctor-report.spec.ts", + // Its delegate half MOVES the session's record selector, so it runs in + // exactly one project for the reason written above the fence specs. + "doctor-report-delegate.spec.ts", // Runs only in the service-worker project. "v137-record-session-fence-offline.spec.ts", // The Apple Health import journey uploads an archive into the one