mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Merge the doctor-report end-to-end journeys
This commit is contained in:
commit
a792ac09b1
5 files changed
+914
-4
No files matched your search
@@ -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<void> {
|
||||
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<void>,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<ReportAttempt> {
|
||||
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<string, string> = {
|
||||
"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);
|
||||
});
|
||||
});
|
||||
@@ -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<SeedOutcome> {
|
||||
return page.evaluate(
|
||||
async (fixture) => {
|
||||
const send = async (
|
||||
method: string,
|
||||
path: string,
|
||||
body: unknown,
|
||||
): Promise<number> => {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return res.status;
|
||||
};
|
||||
|
||||
const readList = async (path: string): Promise<unknown[]> => {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<GeneratedReport> {
|
||||
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<string> {
|
||||
try {
|
||||
return (await response.text()).slice(0, 400);
|
||||
} catch {
|
||||
return "<no readable body>";
|
||||
}
|
||||
}
|
||||
|
||||
/** 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<DateOrder> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
+106
-4
@@ -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<void> {
|
||||
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<void> {
|
||||
],
|
||||
);
|
||||
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
[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<void> {
|
||||
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<void> {
|
||||
* 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<void> {
|
||||
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.
|
||||
//
|
||||
|
||||
@@ -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";
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user