mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Next 16.4.5 drops content-length, content-encoding and transfer-encoding from every route handler response, so the header the export route sets never reaches the browser. A truncated body still loses its %%EOF trailer, and that is what the spec now looks for.
562 lines
23 KiB
TypeScript
562 lines
23 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 { readPdfText } from "@/lib/documents/pdfjs";
|
|
|
|
import {
|
|
E2E_REPORT_OWNER,
|
|
REPORT_OWNER_STORAGE_STATE_PATH,
|
|
} from "./setup/global-setup";
|
|
import {
|
|
completeReproofWithPassword,
|
|
useStaleSession,
|
|
} from "./setup/recent-proof";
|
|
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;
|
|
|
|
/** Where the drug's course starts: before the empty control's window opens,
|
|
* so the drug is ongoing across every window this file asks for. */
|
|
const MEDICATION_COURSE_START_DAYS_BACK = EMPTY_WINDOW_START_DAYS_BACK + 30;
|
|
|
|
/** The UTC calendar day `offset` days back, as `YYYY-MM-DD`. */
|
|
function isoDayBack(offset: number): string {
|
|
const day = new Date();
|
|
day.setUTCDate(day.getUTCDate() - offset);
|
|
return day.toISOString().slice(0, 10);
|
|
}
|
|
|
|
/** 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,
|
|
// The report lists a medication only for a window its course had
|
|
// begun by, so a drug entered today is rightly absent from a report
|
|
// for last year. The course therefore starts before the empty
|
|
// control's window: an ongoing drug the patient was already taking
|
|
// then belongs in both reports.
|
|
startsOn: fixture.medicationStartsOn,
|
|
});
|
|
|
|
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,
|
|
medicationStartsOn: isoDayBack(MEDICATION_COURSE_START_DAYS_BACK),
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
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> {
|
|
// The first press is refused for a recent proof (the session is a stale one,
|
|
// see the `beforeEach`); the report is the answer to the retry that follows
|
|
// the re-proof, so the refusal is not the response waited for.
|
|
const responseArrived = page.waitForResponse(
|
|
(response) =>
|
|
response.request().method() === "POST" &&
|
|
new URL(response.url()).pathname === "/api/export/health-record" &&
|
|
response.status() !== 401,
|
|
{ timeout: 60_000 },
|
|
);
|
|
const downloadStarted = page.waitForEvent("download", { timeout: 60_000 });
|
|
|
|
const generate = page.getByTestId("health-record-generate");
|
|
await generate.click();
|
|
await completeReproofWithPassword(page, E2E_REPORT_OWNER.password);
|
|
|
|
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 text = await readPdfText(new Uint8Array(bytes));
|
|
|
|
return {
|
|
status: response.status(),
|
|
contentType: response.headers()["content-type"] ?? "",
|
|
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);
|
|
// A body truncated on the way out keeps its header but loses the trailer,
|
|
// which a magic-number check alone would miss. The route's Content-Length
|
|
// cannot say this: Next drops that header from every route response since
|
|
// 16.4.5, so the bytes have to.
|
|
expect(report.bytes.subarray(-1024).toString("latin1").trimEnd()).toMatch(
|
|
/%%EOF$/,
|
|
);
|
|
// 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 });
|
|
|
|
// Generating the report exports the whole record, which asks for a sign-in
|
|
// or re-proof within five minutes. The shared jar is fresh only early in a
|
|
// run, so each test starts from a session signed in ten minutes ago and
|
|
// answers the re-proof dialog the way the owner would.
|
|
let endStaleSession: (() => Promise<void>) | null = null;
|
|
test.beforeEach(async ({ page }) => {
|
|
endStaleSession = await useStaleSession(page, E2E_REPORT_OWNER.username);
|
|
});
|
|
test.afterEach(async () => {
|
|
await endStaleSession?.();
|
|
endStaleSession = null;
|
|
});
|
|
|
|
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);
|
|
|
|
// The field commits a clean ISO string whatever the locale's field order is.
|
|
await dateFields.first().fill(isoDayBack(EMPTY_WINDOW_START_DAYS_BACK));
|
|
await dateFields.first().press("Enter");
|
|
await dateFields
|
|
.nth(1)
|
|
.fill(isoDayBack(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: what the window does not empty
|
|
// is still in the document. The allergy is not window-scoped at all, and
|
|
// the drug's course began before the window and never ended, so it was
|
|
// being taken then. 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);
|
|
});
|
|
});
|