From 094eb13b5f1c7bb6a744c9ce85f901571a18416c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 03:49:21 +0200 Subject: [PATCH] test(e2e): read the report's period line in the account's date order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The period assertion hardcoded a slash-separated month-first rendering. The field order comes from the account's date-format preference, so a DMY account prints dots and fails with a message claiming the report carries no period at all, while an en-GB rendering matches, swaps the two fields and computes a nonsense span in silence. The order is now read off the account and the separator is captured once and back-referenced, so all three renderings parse and none can be read as another. `AUTO` is refused rather than guessed at: the fixture pins an explicit order, and an account that lost it should say so. The empty-window control also asserts the span it got. It had two silent fallbacks under it — the panel sends a day count when a date field is empty, and the route falls back to its 90-day default on a bound it cannot parse, neither a 422 — and both landed on a window that holds the seeded readings, so the missing-section assertion below was passing on the fixture's luck. --- e2e/doctor-report.spec.ts | 86 +++++++++++++++++++++++++++++++++------ 1 file changed, 74 insertions(+), 12 deletions(-) diff --git a/e2e/doctor-report.spec.ts b/e2e/doctor-report.spec.ts index ea999e41d..ae5e34216 100644 --- a/e2e/doctor-report.spec.ts +++ b/e2e/doctor-report.spec.ts @@ -76,6 +76,13 @@ const RANGE_DAYS = 30; * about the section being there, not about how a parser renders punctuation. */ const BP_SECTION = "ESH classification"; +/** The refusing control's window: twenty days, starting four hundred days back. + * Far outside every seeded reading and well inside the route's 1..730-day span + * bounds, and named so the period line can be asserted against it rather than + * the report being taken on trust. */ +const EMPTY_WINDOW_START_DAYS_BACK = 400; +const EMPTY_WINDOW_DAYS = 20; + /** Byte floor for "a real document". A cover page with charts is far above * this; a truncated stream or an error body is far below it. */ const MIN_PDF_BYTES = 10_000; @@ -347,27 +354,71 @@ function expectRealPdf(report: GeneratedReport): void { expect(report.filename).toMatch(/\.pdf$/); } +/** The three orders `DateFormatPreference` pins a rendering to. */ +type DateOrder = "DMY" | "MDY" | "YMD"; + /** - * The two dates the period line names, as UTC midnights. + * Which field of the period line is the month, read off the account. * - * The line is rendered in the account's locale (English → MM/DD/YYYY) and its - * timezone, so the dates are read structurally and compared as a SPAN. A span - * is timezone-proof in a way an absolute date is not: both ends move together. + * The rendering is the account's `dateFormat` preference, not a constant: DMY + * prints `10.09.2026`, YMD prints `2026-09-10`, and en-GB's MM/DD-shaped + * `10/09/2026` is a DMY date that an MDY reader silently mis-parses into a + * nonsense span. So the preference is read rather than assumed. + * + * `AUTO` resolves through the active locale and is deliberately refused here: + * the fixture pins `MDY` (see `E2E_REPORT_OWNER` in `global-setup.ts`), so an + * `AUTO` account is a drifted fixture and should say so rather than be guessed + * at. */ -function periodSpanDays(text: string): number { +async function readDateOrder(page: Page): Promise { + const preference = await page.evaluate(async () => { + const res = await fetch("/api/auth/me"); + const payload = (await res.json()) as { data: { dateFormat?: string } }; + return payload.data.dateFormat ?? "AUTO"; + }); + expect( + ["DMY", "MDY", "YMD"], + "the account pins an explicit date order for the period line", + ).toContain(preference); + return preference as DateOrder; +} + +/** + * The period line's two dates, as a span in days. + * + * The line is rendered in the account's date order and its timezone, so the + * dates are read structurally and compared as a SPAN. A span is timezone-proof + * in a way an absolute date is not: both ends move together. + * + * The separator is captured once and back-referenced, so `10.09.2026`, + * `09/10/2026` and `2026-09-10` all parse and none of them can be read as the + * other two. + */ +function periodSpanDays(text: string, order: DateOrder): number { const match = - /Reporting period:\s*(\d{2})\/(\d{2})\/(\d{4})\s*\D{1,3}\s*(\d{2})\/(\d{2})\/(\d{4})/.exec( + /Reporting period:\s*(\d{2,4})([./-])(\d{2})\2(\d{2,4})\s*\D{1,3}\s*(\d{2,4})\2(\d{2})\2(\d{2,4})/.exec( text, ); expect(match, "the report prints a reporting period with two dates").not.toBe( null, ); - const [, sm, sd, sy, em, ed, ey] = match!; - const start = Date.UTC(Number(sy), Number(sm) - 1, Number(sd)); - const end = Date.UTC(Number(ey), Number(em) - 1, Number(ed)); + const start = utcMidnight(match![1], match![3], match![4], order); + const end = utcMidnight(match![5], match![6], match![7], order); return Math.round((end - start) / 86_400_000); } +function utcMidnight( + first: string, + second: string, + third: string, + order: DateOrder, +): number { + const [a, b, c] = [Number(first), Number(second), Number(third)]; + const [year, month, day] = + order === "YMD" ? [a, b, c] : order === "DMY" ? [c, b, a] : [c, a, b]; + return Date.UTC(year, month - 1, day); +} + test.describe.serial("the doctor report", () => { // This journey's own account owns the readings, the allergy and the drug, and // the page's own `fetch` writes them under its session. @@ -404,7 +455,7 @@ test.describe.serial("the doctor report", () => { // The document says what it is about. expect(report.text).toContain("Reporting period"); - const span = periodSpanDays(report.text); + const span = periodSpanDays(report.text, await readDateOrder(page)); expect(span).toBeGreaterThanOrEqual(RANGE_DAYS - 1); expect(span).toBeLessThanOrEqual(RANGE_DAYS + 1); @@ -439,9 +490,11 @@ test.describe.serial("the doctor report", () => { return day.toISOString().slice(0, 10); }; // The field commits a clean ISO string whatever the locale's field order is. - await dateFields.first().fill(dayBack(400)); + await dateFields.first().fill(dayBack(EMPTY_WINDOW_START_DAYS_BACK)); await dateFields.first().press("Enter"); - await dateFields.nth(1).fill(dayBack(380)); + await dateFields + .nth(1) + .fill(dayBack(EMPTY_WINDOW_START_DAYS_BACK - EMPTY_WINDOW_DAYS)); await dateFields.nth(1).press("Enter"); const report = await generateReport(page); @@ -450,6 +503,15 @@ test.describe.serial("the doctor report", () => { expectRealPdf(report); expect(report.text).toContain("Reporting period"); + // And it is the window that was typed. Two silent fallbacks sit under this + // line — the panel sends `{ days }` when either date field is empty, and + // the route falls back to the 90-day default when a bound fails to parse, + // neither of them a 422. Both land on a window that HOLDS the seeded + // readings, so without this the absence below would be the fixture's luck. + const emptySpan = periodSpanDays(report.text, await readDateOrder(page)); + expect(emptySpan).toBeGreaterThanOrEqual(EMPTY_WINDOW_DAYS - 1); + expect(emptySpan).toBeLessThanOrEqual(EMPTY_WINDOW_DAYS + 1); + // Nothing was measured in it, so the section that needs readings is gone. expect(report.text).not.toContain(BP_SECTION);