From 7b06a235e1941f5580ee3d3f0cd95bc96b3c80ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 03:27:02 +0200 Subject: [PATCH 1/5] test(e2e): cover doctor-report generation end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doctor report had unit coverage for the renderer and route coverage for the envelope, and nothing that pressed the button. Two browser journeys close that. `doctor-report.spec.ts` seeds a few weeks of blood pressure and weight, one allergy and one medication through the page's own `fetch`, picks a window in the export panel and generates. The saved download is asserted as a real artefact — `%PDF` magic, a `Content-Length` that agrees with the bytes, a length no empty document reaches — and then parsed back to text, so the period line, the blood-pressure section and the drug list are read out of the document rather than inferred from a 200. The period is checked as a span, so the assertion survives the account's timezone. Its refusing control is a window with no readings: still a valid, readable report that names the period and carries no blood-pressure section, with the reference data that is not window-scoped as the control against a blank page passing. `doctor-report-delegate.spec.ts` covers the access boundary the route draws at MANAGE. A delegate inside a record it may only read is not offered the export card at all, and the route refuses the request that card would have sent with `sharing.access.denied`. The positive control is the same delegate, the same payload and the same switched-record path against a record it manages, so the one variable between pass and refusal is the access level. Both requests carry the record-context headers the shipped client sends: without them the fence refuses with the same status and the same error code, which would make the refusal pass for the wrong reason. Seeding is idempotent — a re-posted reading collides on its natural key and answers 409 — so a second local run and a repeated run both stay green. The delegate journey takes its own cookie jar because it moves the session's record selector, and both specs run in one project. --- e2e/doctor-report-delegate.spec.ts | 252 ++++++++++++++++ e2e/doctor-report.spec.ts | 453 +++++++++++++++++++++++++++++ e2e/setup/global-setup.ts | 12 + e2e/setup/test-helpers.ts | 1 + playwright.config.ts | 11 + 5 files changed, 729 insertions(+) create mode 100644 e2e/doctor-report-delegate.spec.ts create mode 100644 e2e/doctor-report.spec.ts diff --git a/e2e/doctor-report-delegate.spec.ts b/e2e/doctor-report-delegate.spec.ts new file mode 100644 index 000000000..a5a6d8c64 --- /dev/null +++ b/e2e/doctor-report-delegate.spec.ts @@ -0,0 +1,252 @@ +/** + * 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. This spec proves both + * halves of that refusal in a browser — the control is not offered, and the + * route refuses the request anyway. + * + * Why both. 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. So the settings + * page is asserted to carry the shared-record refusal panel instead of the + * export card, and the route is asserted separately, from the same session. + * + * 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-"); + 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 control is not offered: the health-record page inside a shared record + // is the refusal panel, not the export card. + await openGesundheitsakte(page); + await expect(page.getByTestId("health-record-export-panel")).toHaveCount(0); + await expect(page.getByTestId("health-record-generate")).toHaveCount(0); + + // And the route refuses the request the absent control would have sent. + 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..38e2c76c2 --- /dev/null +++ b/e2e/doctor-report.spec.ts @@ -0,0 +1,453 @@ +/** + * 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. + * + * 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. Seeding is idempotent: a re-post of the same reading collides on + * the measurement's natural key and answers 409, which is why `--repeat-each` + * and a second local run both stay green. + * + * 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 { 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"; + +/** 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. The panel reads the + // response into a Blob, and a body a page has already consumed is not one + // Playwright can hand back — `response.body()` answers zero bytes here. The + // saved file is also the better subject: it is the artefact the person ends + // up with, so 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)); + expect(report.filename).toMatch(/\.pdf$/); +} + +/** + * The two dates the period line names, as UTC midnights. + * + * The line is rendered in the account's locale (English → MM/DD/YYYY) 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. + */ +function periodSpanDays(text: string): number { + const match = + /Reporting period:\s*(\d{2})\/(\d{2})\/(\d{4})\s*\D{1,3}\s*(\d{2})\/(\d{2})\/(\d{4})/.exec( + text, + ); + expect(match, "the report prints a reporting period with two dates").not.toBe( + null, + ); + const [, sm, sd, sy, em, ed, ey] = match!; + const start = Date.UTC(Number(sy), Number(sm) - 1, Number(sd)); + const end = Date.UTC(Number(ey), Number(em) - 1, Number(ed)); + return Math.round((end - start) / 86_400_000); +} + +test.describe.serial("the doctor report", () => { + // The seeded account owns the readings, the allergy and the drug, and the + // page's own `fetch` writes them under its session. + test.use({ storageState: 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); + 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(400)); + await dateFields.first().press("Enter"); + await dateFields.nth(1).fill(dayBack(380)); + 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"); + + // 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 14f0343ad..969ebdbde 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -250,6 +250,17 @@ 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 banner-geometry spec's jar, and the reason it exists is a release-shaped * one rather than a load-shaped one. @@ -760,6 +771,7 @@ 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); // 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..ac69785ff 100644 --- a/e2e/setup/test-helpers.ts +++ b/e2e/setup/test-helpers.ts @@ -25,5 +25,6 @@ export { FENCE_STORAGE_STATE_PATH, FENCE_OFFLINE_STORAGE_STATE_PATH, CROSS_TAB_STORAGE_STATE_PATH, + REPORT_DELEGATE_STORAGE_STATE_PATH, GUARDIAN_STORAGE_STATE_PATH, } from "./global-setup"; diff --git a/playwright.config.ts b/playwright.config.ts index 928caf004..43064c4a2 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -152,6 +152,17 @@ export default defineConfig({ // through stable data-slots, not a mobile layout, so it runs in one // project. "vaccinations.spec.ts", + // The doctor-report journey seeds a few weeks of readings into the one + // shared 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 mobile emulation emits no `download` event for the + // anchor click the panel makes, so the artefact would be unreachable + // there anyway. + "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", ], From 33f5bed802e548937204f619274f6c869a49f812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 03:48:37 +0200 Subject: [PATCH 2/5] test(e2e): give the doctor-report journey its own account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generating a report writes: the route remembers the scope and the practice name on the account row, and the export panel a later visit opens is therefore the collapsed "repeat run" state. The journey was generating on the shared fixture account, which is the account `settings-export.spec.ts` asserts the first-run state against in nine places — an expanded picker, an empty summary, a disabled button. The two files run in an unconstrained order under two workers, so the first symptom was an order-dependent red in CI; against a persistent local database the flip never cleared and the red was permanent. The journey now seeds, generates and reads back on `e2e-report-owner`, with its own cookie jar. The shared account and the whole-record fixtures also get `report_selection_json` and `last_report_practice_name` cleared on every seed, which repairs a database an earlier run already dirtied and keeps a delegate's scope choice from being left on a record owner's row. The account's date order is pinned rather than left to the locale, and its export bucket is purged beside the share-link and login buckets: the 10-per-hour ceiling is shared by every export route, keys on the actor and outlives a run by an hour, so repeated local runs would otherwise meet a 429 the spec reports as a missing artefact. --- e2e/doctor-report.spec.ts | 23 +++++--- e2e/setup/global-setup.ts | 109 ++++++++++++++++++++++++++++++++++---- e2e/setup/test-helpers.ts | 1 + 3 files changed, 117 insertions(+), 16 deletions(-) diff --git a/e2e/doctor-report.spec.ts b/e2e/doctor-report.spec.ts index 38e2c76c2..ea999e41d 100644 --- a/e2e/doctor-report.spec.ts +++ b/e2e/doctor-report.spec.ts @@ -26,12 +26,21 @@ * 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. Seeding is idempotent: a re-post of the same reading collides on - * the measurement's natural key and answers 409, which is why `--repeat-each` - * and a second local run both stay green. + * 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 @@ -42,7 +51,7 @@ import { readFile } from "node:fs/promises"; import type { Page, Response } from "@playwright/test"; import { PDFParse } from "pdf-parse"; -import { STORAGE_STATE_PATH } from "./setup/global-setup"; +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 @@ -360,9 +369,9 @@ function periodSpanDays(text: string): number { } test.describe.serial("the doctor report", () => { - // The seeded account owns the readings, the allergy and the drug, and the - // page's own `fetch` writes them under its session. - test.use({ storageState: STORAGE_STATE_PATH }); + // 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, diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 969ebdbde..b5e7866a6 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -101,6 +101,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 = [ { @@ -261,6 +286,15 @@ export const REPORT_DELEGATE_STORAGE_STATE_PATH = resolve( "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. @@ -368,7 +402,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, @@ -419,6 +455,40 @@ export default async function globalSetup(config: FullConfig): Promise { [E2E_GUARDIAN.username], ); + // 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 @@ -529,7 +599,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, @@ -589,6 +661,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` @@ -658,12 +744,13 @@ export default async function globalSetup(config: FullConfig): Promise { WHERE key LIKE 'sharing:%'`, ); - // The login bucket, for the same reason. This setup signs in TEN 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 - // from the fixture rather than a failure from the product. Only the auth - // surfaces' own buckets are cleared; nothing else in the table is touched. + // The login bucket, for the same reason. This setup signs in THIRTEEN + // times now (the shared jar, the owner, the report 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 from the fixture rather than a failure + // from the product. Only the auth surfaces' own buckets are cleared; + // nothing else in the table is touched. await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`); // One marker weight per record. Re-seeded by delete-then-insert so a @@ -733,7 +820,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 ten times, so clearing once before the batch is no longer + * signs in thirteen 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. @@ -773,6 +860,10 @@ export default async function globalSetup(config: FullConfig): Promise { 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. // // Order is the whole of it. `requireFreshMfa` refuses an account with no diff --git a/e2e/setup/test-helpers.ts b/e2e/setup/test-helpers.ts index ac69785ff..6d9f4c65d 100644 --- a/e2e/setup/test-helpers.ts +++ b/e2e/setup/test-helpers.ts @@ -26,5 +26,6 @@ export { 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"; From 19bee96ad29665928190f6bca37c827816b63fc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 03:48:47 +0200 Subject: [PATCH 3/5] test(e2e): prove the report card is closed by the destination, not the level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delegate spec asserted the absence of the export card inside a READ-level record and presented that as the UI half of the MANAGE boundary. It is not. `/settings/gesundheitsakte` is a personal destination, and the shell answers it with the "not part of shared access" panel at every adult grant level — read, write and manage alike — so the same two absences would pass unchanged inside the record the delegate manages. The assertion could not fail for the reason it named. Both arms now open the page. Inside the managed record, where the route answers 200 for the same request, the card is still absent: that is what makes the destination the cause. The level-sensitive statement stays where it belongs, on the route's 403, and the file header now says which half proves what. --- e2e/doctor-report-delegate.spec.ts | 41 ++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/e2e/doctor-report-delegate.spec.ts b/e2e/doctor-report-delegate.spec.ts index a5a6d8c64..204734eea 100644 --- a/e2e/doctor-report-delegate.spec.ts +++ b/e2e/doctor-report-delegate.spec.ts @@ -3,15 +3,23 @@ * * `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. This spec proves both - * halves of that refusal in a browser — the control is not offered, and the - * route refuses the request anyway. + * 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. * - * Why both. 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. So the settings - * page is asserted to carry the shared-record refusal panel instead of the - * export card, and the route is asserted separately, from the same session. + * 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 @@ -225,6 +233,14 @@ test.describe.serial("a read-level delegate and the doctor report", () => { 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); @@ -235,13 +251,16 @@ test.describe.serial("a read-level delegate and the doctor report", () => { // shows and two below the "manage" this route requires. await expect(banner).toHaveAttribute("data-access-level", "view"); - // The control is not offered: the health-record page inside a shared record - // is the refusal panel, not the export card. + // 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); - // And the route refuses the request the absent control would have sent. + // 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"); From 094eb13b5f1c7bb6a744c9ce85f901571a18416c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 03:49:21 +0200 Subject: [PATCH 4/5] test(e2e): read the report's period line in the account's date order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The period assertion hardcoded a slash-separated month-first rendering. The field order comes from the account's date-format preference, so a DMY account prints dots and fails with a message claiming the report carries no period at all, while an en-GB rendering matches, swaps the two fields and computes a nonsense span in silence. The order is now read off the account and the separator is captured once and back-referenced, so all three renderings parse and none can be read as another. `AUTO` is refused rather than guessed at: the fixture pins an explicit order, and an account that lost it should say so. The empty-window control also asserts the span it got. It had two silent fallbacks under it — the panel sends a day count when a date field is empty, and the route falls back to its 90-day default on a bound it cannot parse, neither a 422 — and both landed on a window that holds the seeded readings, so the missing-section assertion below was passing on the fixture's luck. --- e2e/doctor-report.spec.ts | 86 +++++++++++++++++++++++++++++++++------ 1 file changed, 74 insertions(+), 12 deletions(-) diff --git a/e2e/doctor-report.spec.ts b/e2e/doctor-report.spec.ts index ea999e41d..ae5e34216 100644 --- a/e2e/doctor-report.spec.ts +++ b/e2e/doctor-report.spec.ts @@ -76,6 +76,13 @@ const RANGE_DAYS = 30; * 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; @@ -347,27 +354,71 @@ function expectRealPdf(report: GeneratedReport): void { expect(report.filename).toMatch(/\.pdf$/); } +/** The three orders `DateFormatPreference` pins a rendering to. */ +type DateOrder = "DMY" | "MDY" | "YMD"; + /** - * The two dates the period line names, as UTC midnights. + * Which field of the period line is the month, read off the account. * - * The line is rendered in the account's locale (English → MM/DD/YYYY) 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 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. */ -function periodSpanDays(text: string): number { +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})\/(\d{2})\/(\d{4})\s*\D{1,3}\s*(\d{2})\/(\d{2})\/(\d{4})/.exec( + /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 [, sm, sd, sy, em, ed, ey] = match!; - const start = Date.UTC(Number(sy), Number(sm) - 1, Number(sd)); - const end = Date.UTC(Number(ey), Number(em) - 1, Number(ed)); + 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. @@ -404,7 +455,7 @@ test.describe.serial("the doctor report", () => { // The document says what it is about. expect(report.text).toContain("Reporting period"); - const span = periodSpanDays(report.text); + const span = periodSpanDays(report.text, await readDateOrder(page)); expect(span).toBeGreaterThanOrEqual(RANGE_DAYS - 1); expect(span).toBeLessThanOrEqual(RANGE_DAYS + 1); @@ -439,9 +490,11 @@ test.describe.serial("the doctor report", () => { 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(400)); + await dateFields.first().fill(dayBack(EMPTY_WINDOW_START_DAYS_BACK)); await dateFields.first().press("Enter"); - await dateFields.nth(1).fill(dayBack(380)); + 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); @@ -450,6 +503,15 @@ test.describe.serial("the doctor report", () => { 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); From 923b8328914ce9d2c53cfef9068f1add9706d90a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 03:49:32 +0200 Subject: [PATCH 5/5] test(e2e): correct three claims the report journey's comments make MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mobile exclusion carried an inherited second reason — that mobile emulation emits no download event for the panel's anchor click. The precedent it came from contradicts itself: the JSON example-download test in `settings-export.spec.ts` asserts a real download event in the mobile project, and that spec also measures this very panel's tap targets there. The honest statement is that the panel's mobile layout is covered elsewhere and this journey is about the artefact. The bytes are read off the download because the saved file is what the person ends up with, not because a consumed body reads back empty — Playwright buffers the response independently of the page. And the filename assertion covers the name the client anchor invents, not the route's `Content-Disposition`; the two differ by a word, so it is not contract coverage of the header. --- e2e/doctor-report.spec.ts | 12 ++++++------ playwright.config.ts | 14 +++++++------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/e2e/doctor-report.spec.ts b/e2e/doctor-report.spec.ts index ae5e34216..c11fdee63 100644 --- a/e2e/doctor-report.spec.ts +++ b/e2e/doctor-report.spec.ts @@ -290,12 +290,9 @@ async function generateReport(page: Page): Promise { const response = await responseArrived; const download = await downloadStarted; - // The bytes come off the DOWNLOAD, not off the response. The panel reads the - // response into a Blob, and a body a page has already consumed is not one - // Playwright can hand back — `response.body()` answers zero bytes here. The - // saved file is also the better subject: it is the artefact the person ends - // up with, so a build that produced a perfect response and saved something - // else would still be caught. + // 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!); @@ -351,6 +348,9 @@ function expectRealPdf(report: GeneratedReport): void { // 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$/); } diff --git a/playwright.config.ts b/playwright.config.ts index 43064c4a2..ae8df2f73 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -152,13 +152,13 @@ export default defineConfig({ // through stable data-slots, not a mobile layout, so it runs in one // project. "vaccinations.spec.ts", - // The doctor-report journey seeds a few weeks of readings into the one - // shared 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 mobile emulation emits no `download` event for the - // anchor click the panel makes, so the artefact would be unreachable - // there anyway. + // 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.