mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
525 lines
21 KiB
TypeScript
525 lines
21 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|