From 1de3681c81349fb30428e0ee5ca2ecad25a8ec53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 01:01:11 +0200 Subject: [PATCH] test(e2e): cover the blood-glucose journey across a unit switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The glucose display unit had thirty-odd readers and, until recently, no control to set it. Nothing in the browser suite walks the path a person actually takes: capture a reading in mg/dL, switch the account to mmol/L, capture a second one, and read both back — each converted, none of them reinterpreted — in the list and on the dashboard strip. The journey covers that end to end, plus the boundary: a value past the plausible ceiling is refused by the server, the capture keeps what was typed, and the list is exactly what it was. One reading arrives with no meal-time context, the way a meter export or a device sync delivers one. It reads back correctly in the list; the tile it should paint does not exist yet (#943), so the tile assertion is skipped by issue number rather than softened into something that passes. Three supporting changes: - The list rows carry what they hold — type and resolved display unit on the row, a slot on the value cell — so the journey pins "this reading now reads in mmol/L" structurally instead of matching translated copy that also paints through a different subtree on the card list. The meal-time options carry their enum for the same reason. - The sharing-marker weight rows were seeded by handing a Date to a `timestamp without time zone` column, which stamps them in the host's zone. On a machine east of UTC that put them hours in the future, where every fold whose window ends "now" skips them, and the type stayed uncovered — which keeps the snapshot's thick phase cold and the whole tile strip short. Seed the instant as UTC, the shape the app's own writes use. - The file mutates one shared account and reads global verdicts off it, so it runs in a single project alongside the visits and immunization journeys. --- e2e/glucose-journey.spec.ts | 418 ++++++++++++++++++ e2e/setup/global-setup.ts | 7 +- e2e/setup/glucose-fixture.ts | 67 +++ playwright.config.ts | 8 + .../measurements/measurement-form.tsx | 9 +- .../measurements/measurement-list.tsx | 28 +- 6 files changed, 533 insertions(+), 4 deletions(-) create mode 100644 e2e/glucose-journey.spec.ts create mode 100644 e2e/setup/glucose-fixture.ts diff --git a/e2e/glucose-journey.spec.ts b/e2e/glucose-journey.spec.ts new file mode 100644 index 000000000..b8f545b22 --- /dev/null +++ b/e2e/glucose-journey.spec.ts @@ -0,0 +1,418 @@ +/** + * Blood glucose, from the first reading to the tile — one journey. + * + * The flow this file is checked against, in one script: + * + * 1. a reading arrives with no meal-time context, the way a meter export or + * a device sync delivers one; + * 2. a person captures a reading by hand in mg/dL; + * 3. the list shows both in mg/dL; + * 4. they switch the account's display unit to mmol/L in settings; + * 5. the capture form now ASKS in mmol/L, and a reading typed there is + * stored canonically — the list shows all three in mmol/L, each one + * converted, none of them reinterpreted; + * 6. the dashboard paints the blood-glucose tile in the same unit. + * + * Refusing control: a value outside the accepted mg/dL band is refused by the + * server, the capture stays open with an error, and the list is exactly what + * it was. A journey that only ever writes acceptable numbers proves the happy + * path and nothing about the boundary. + * + * Every assertion addresses a stable attribute — `data-testid`, + * `data-measurement-type`, `data-display-unit`, `data-slot`, a placeholder — + * never rendered copy: the labels are i18n-driven, and the same rows paint + * through a different subtree on the card list. + * + * The flows mutate the one seeded account (its glucose rows AND its display + * unit), so — like `visits.spec.ts` and `vaccinations.spec.ts` — this file + * runs in a single project (see `playwright.config.ts`), runs serial, and + * resets both before each test. + */ +import type { Page } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { resetGlucose } from "./setup/glucose-fixture"; +import { expect, test } from "./setup/test"; + +/** Every blood-glucose row the list is currently painting. */ +const GLUCOSE_ROWS = + '[data-testid="measurement-row"][data-measurement-type="BLOOD_GLUCOSE"]'; + +/** The list has finished its read — rows, or the honest empty state. */ +const LIST_SETTLED = + '[data-slot="measurement-rows"], [data-slot="empty-state"]'; + +/** + * Canonical mg/dL in, display mmol/L out (18.0182 mg/dL per mmol/L, rounded + * to one fraction digit). Written out rather than imported so the spec pins + * the numbers a reader sees instead of re-running the app's own arithmetic + * and agreeing with itself. + */ +const READINGS = { + /** Delivered with no meal-time context. */ + untagged: { mgdl: 112, mmol: "6.2" }, + /** Typed by hand while the account reads mg/dL. */ + typedAsMgdl: { mgdl: 95, mmol: "5.3" }, + /** Typed by hand once the account reads mmol/L; stored as 130 mg/dL. */ + typedAsMmol: { typed: "7.2", mmol: "7.2" }, +} as const; + +/** A locale-tolerant matcher for a decimal the formatter may render "5,3". */ +function decimal(value: string): RegExp { + return new RegExp(value.replace(".", "[.,]")); +} + +async function openList(page: Page): Promise { + await page.goto("/measurements"); + await expect(page.locator(LIST_SETTLED).first()).toBeVisible({ + timeout: 30_000, + }); +} + +/** + * Deliver a reading the way a meter export does: through the batch ingest, + * which is the one write surface that accepts a glucose row with no + * meal-time context. The hand-entry schema requires one — a person at the + * reading knows the answer — so this row cannot be produced by the form, and + * producing it any other way would be testing a fixture rather than a + * contract. + * + * The POST rides the PAGE's own `fetch`, not a shared Playwright request + * context: a pooled context can hand a POST to a keep-alive socket the server + * has already closed, and the retry lands as a second row. + */ +async function deliverUntaggedReading(page: Page, mgdl: number): Promise { + const result = await page.evaluate(async (value: number) => { + const at = new Date().toISOString(); + const res = await fetch("/api/measurements/batch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + entries: [ + { + hkIdentifier: "HKQuantityTypeIdentifierBloodGlucose", + value, + unit: "mg/dL", + startDate: at, + endDate: at, + externalId: `e2e-glucose-untagged-${at}`, + source: "MANUAL", + }, + ], + }), + }); + return { + status: res.status, + body: (await res.json()) as { data?: { inserted?: number } }, + }; + }, mgdl); + + expect(result.status, "the batch ingest accepted the untagged row").toBe(200); + expect(result.body.data?.inserted).toBe(1); +} + +/** + * Fold the account's measurement rollups, synchronously, through the app's own + * operator endpoint. + * + * The dashboard's glucose tiles ride the snapshot's THICK phase, and that + * phase refuses to run until the rollup tier is warm for the types the account + * holds — a cold tier would drop the whole strip into a live-SQL fallback and + * make every tile wait on the slowest read. Rows seeded straight into Postgres + * by `global-setup` are never folded (nothing hooks a raw INSERT), so on a + * freshly created database the phase is cold, `extras` comes back null, and + * the tile a person would see on their own instance is simply absent here. + * + * That is a property of the harness, not of the feature, so the journey warms + * the tier rather than asserting around it: one call, awaited, before the + * dashboard read. + */ +async function warmRollupTier(page: Page): Promise { + const result = await page.evaluate(async () => { + const me = (await (await fetch("/api/auth/me")).json()) as { + data: { id: string }; + }; + const res = await fetch("/api/admin/rollups/recompute", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId: me.data.id }), + }); + return { + status: res.status, + body: (await res.json()) as { data?: { rowsUpserted?: number } }, + }; + }); + + expect(result.status, "the rollup fold ran").toBe(200); + expect(result.body.data?.rowsUpserted ?? 0).toBeGreaterThan(0); +} + +/** The ISO day before the one the capture form seeded itself with. */ +function previousDay(isoDay: string): string { + const day = new Date(`${isoDay}T00:00:00.000Z`); + day.setUTCDate(day.getUTCDate() - 1); + return day.toISOString().slice(0, 10); +} + +/** + * Drive the capture sheet through the `?add=` deep link the Insights actions + * already use, fill the single value field, and save. Returns the status the + * server answered the write with, so a caller can assert the refusal without + * reading it back off the copy in the banner. + */ +async function captureReading( + page: Page, + opts: { + typed: string; + context?: string; + expectPlaceholder?: string; + /** Backdate to `HH:mm` on the day before the form's own default. */ + yesterdayAt?: string; + }, +): Promise { + await page.goto("/measurements?add=BLOOD_GLUCOSE"); + + const sheet = page.locator('[data-slot="responsive-sheet-content"]'); + await expect(sheet).toBeVisible({ timeout: 30_000 }); + + const valueField = sheet.locator("#value"); + await expect(valueField).toBeVisible(); + if (opts.expectPlaceholder) { + // The placeholder is the form saying which unit it is asking in, in an + // attribute rather than in copy. + await expect(valueField).toHaveAttribute( + "placeholder", + opts.expectPlaceholder, + ); + } + await valueField.fill(opts.typed); + + if (opts.yesterdayAt) { + // Two readings that share a minute share a row identity — `(user, type, + // measuredAt)` is unique and the second one is refused — so a journey + // that captures twice has to place them, exactly as a person would. + // Yesterday rather than an earlier hour today: any hour is in the past on + // a past day, so the picker's no-future clamp cannot pull the instant + // back onto the same minute as the other reading whatever the hour the + // suite happens to run at. + const dateField = page.locator( + '[data-slot="date-time-field"] [data-slot="date-field"]', + ); + const seededDay = await dateField + .locator('input[type="hidden"]') + .inputValue(); + const day = previousDay(seededDay); + // The overlay parses a clean ISO string ahead of the account's own + // date-format preference, so the spec does not have to know it. + await dateField.locator('input[type="text"]').fill(day); + await dateField.locator('input[type="text"]').blur(); + await expect(dateField.locator('input[type="hidden"]')).toHaveValue(day); + + const timeField = page.locator( + '[data-slot="date-time-field"] [data-slot="time-field"]', + ); + await timeField.locator('input[type="text"]').fill(opts.yesterdayAt); + await timeField.locator('input[type="text"]').blur(); + await expect(timeField.locator('input[type="hidden"]')).toHaveValue( + opts.yesterdayAt, + ); + } + + if (opts.context) { + // The meal-time context defaults to fasting; anything else is a choice + // the person makes, addressed by the enum the option carries. + await sheet.locator("#glucose-context").click(); + const option = page.locator( + `[data-slot="select-item"][data-glucose-context="${opts.context}"]`, + ); + await expect(option).toBeVisible(); + await option.click(); + await expect(option).toHaveCount(0); + } + + const write = page.waitForResponse( + (res) => + new URL(res.url()).pathname === "/api/measurements" && + res.request().method() === "POST", + ); + await sheet + .locator('[data-slot="responsive-sheet-footer"] button[type="submit"]') + .click(); + return (await write).status(); +} + +/** Switch the account's blood-glucose display unit through settings. */ +async function chooseGlucoseUnit(page: Page, unit: string): Promise { + await page.goto("/settings/account"); + + const select = page.getByTestId("settings-glucose-unit-select"); + await expect(select).toBeVisible({ timeout: 30_000 }); + // The control disables itself until the account payload has landed; a + // change dispatched before that is dropped by its own guard. + await expect(select).toBeEnabled(); + + const saved = page.waitForResponse( + (res) => + new URL(res.url()).pathname === "/api/auth/me/glucose-unit" && + res.request().method() === "PATCH", + ); + await select.selectOption(unit); + expect((await saved).status()).toBe(200); + await expect(select).toHaveValue(unit); +} + +test.beforeEach(async () => { + // Own every row the counts below read, and undo the switch the journey + // itself performs — the unit is a column on the shared account, so a run + // that left it on mmol/L would start the next one past the half it means + // to prove. + await resetGlucose(); +}); + +// Serial and single-account: the reset above would race a sibling worker, and +// two tests clearing the same rows would each undo the other's setup. +test.describe.configure({ mode: "serial" }); + +test.describe("blood glucose", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + // The dashboard is the most expensive page in the product to render cold, + // and this journey ends there after four navigations. The budget is the + // page's; the assertions stay exactly as tight as they were. + test.setTimeout(120_000); + + test("readings captured either side of a unit switch all read in the chosen unit", async ({ + page, + }) => { + await openList(page); + await deliverUntaggedReading(page, READINGS.untagged.mgdl); + await warmRollupTier(page); + + // Hand entry, while the account still reads mg/dL. + expect( + await captureReading(page, { + typed: String(READINGS.typedAsMgdl.mgdl), + yesterdayAt: "07:30", + expectPlaceholder: "95", + }), + ).toBe(201); + + await openList(page); + const rows = page.locator(GLUCOSE_ROWS); + await expect(rows).toHaveCount(2); + await expect(rows.first()).toHaveAttribute("data-display-unit", "mg/dL"); + await expect( + page.locator(`${GLUCOSE_ROWS}[data-display-unit="mg/dL"]`), + ).toHaveCount(2); + + // The switch. + await chooseGlucoseUnit(page, "mmol/L"); + + // The form now ASKS in mmol/L — its placeholder says so — and what it + // stores is still canonical mg/dL, which is what the read-back proves. + // Bedtime rather than fasting, so each hand-entered reading owns a tile + // and each tile's "latest" names exactly one of them — a strip where two + // readings share a bucket would prove only that one of them won. + expect( + await captureReading(page, { + typed: READINGS.typedAsMmol.typed, + context: "BEDTIME", + expectPlaceholder: "5.3", + }), + ).toBe(201); + + await openList(page); + await expect(rows).toHaveCount(3); + await expect( + page.locator(`${GLUCOSE_ROWS}[data-display-unit="mmol/L"]`), + ).toHaveCount(3); + + // Each reading converted, none reinterpreted: 112 → 6.2, 95 → 5.3, and + // the 7.2 typed in mmol/L comes back as the 7.2 it was. + for (const expected of [ + READINGS.untagged.mmol, + READINGS.typedAsMgdl.mmol, + READINGS.typedAsMmol.mmol, + ]) { + await expect( + rows + .locator('[data-slot="measurement-row-value"]') + .filter({ hasText: decimal(expected) }), + ).toHaveCount(1); + } + + // The dashboard reads the same account preference: one tile per meal-time + // context, each carrying its own reading in mmol/L. + await page.goto("/"); + for (const [tileId, value] of [ + ["glucose-FASTING", READINGS.typedAsMgdl.mmol], + ["glucose-BEDTIME", READINGS.typedAsMmol.mmol], + ] as const) { + const tile = page.locator( + `[data-slot="dashboard-tile-link"][data-tile-id="${tileId}"]`, + ); + await expect(tile).toBeVisible({ timeout: 60_000 }); + await expect(tile.locator('[data-slot="trend-card-value"]')).toHaveText( + decimal(value), + ); + } + }); + + test("a reading with no meal-time context gets a tile of its own", async ({ + page, + }) => { + test.skip( + true, + "#943 — tile eligibility filters the four named meal-time contexts " + + "against the per-context summaries, so an account whose source records " + + "no meal time matches none of them and the strip is silently short one " + + "card. The fix names the untagged bucket and walks it with the rest; it " + + "is not on main yet. Unskip with it, do not weaken the assertion — a " + + "check that cannot fail is worse than none.", + ); + + await openList(page); + await deliverUntaggedReading(page, READINGS.untagged.mgdl); + await warmRollupTier(page); + + await page.goto("/"); + const tile = page.locator( + '[data-slot="dashboard-tile-link"][data-tile-id="glucose-UNSPECIFIED"]', + ); + await expect(tile).toBeVisible({ timeout: 60_000 }); + await expect(tile.locator('[data-slot="trend-card-value"]')).toHaveText( + decimal(String(READINGS.untagged.mgdl)), + ); + }); + + test("a value outside the accepted band is refused and the list is unchanged", async ({ + page, + }) => { + await openList(page); + await deliverUntaggedReading(page, READINGS.untagged.mgdl); + + await openList(page); + await expect(page.locator(GLUCOSE_ROWS)).toHaveCount(1); + + // 900 mg/dL is past the plausible ceiling (20–800). The field carries no + // min/max of its own, so the refusal is the server's and this is a real + // round trip, not a browser-side block. + expect(await captureReading(page, { typed: "900" })).toBe(422); + + // The capture stays open, carrying the error and the entered value: a + // refusal must not cost the person what they typed. + const sheet = page.locator('[data-slot="responsive-sheet-content"]'); + await expect(sheet.getByRole("alert")).toBeVisible(); + await expect(sheet).toBeVisible(); + await expect(sheet.locator("#value")).toHaveValue("900"); + + // And the list behind it is exactly what it was. + await openList(page); + const rows = page.locator(GLUCOSE_ROWS); + await expect(rows).toHaveCount(1); + await expect(rows.first()).toHaveAttribute("data-display-unit", "mg/dL"); + await expect( + rows.locator('[data-slot="measurement-row-value"]'), + ).toHaveText(decimal(String(READINGS.untagged.mgdl))); + }); +}); diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 14f0343ad..9ee0f42ec 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -675,7 +675,12 @@ export default async function globalSetup(config: FullConfig): Promise { SELECT $1, u.id, 'WEIGHT', $2, 'kg', 'MANUAL', $3, 'e2e-sharing-marker', $3, $3 FROM users u WHERE u.username = $4`, - [cuid(), kg, now, username], + // `measured_at` is `timestamp without time zone` and the driver + // serialises a `Date` in the HOST's zone, so a developer machine east + // of UTC seeded this row hours into the FUTURE — where every fold + // whose window ends "now" skips it, and the type stays uncovered. + // Send the instant as UTC, the same shape the app's own writes take. + [cuid(), kg, now.toISOString(), username], ); } diff --git a/e2e/setup/glucose-fixture.ts b/e2e/setup/glucose-fixture.ts new file mode 100644 index 000000000..6c1748995 --- /dev/null +++ b/e2e/setup/glucose-fixture.ts @@ -0,0 +1,67 @@ +/** + * Account hygiene for the blood-glucose journey. + * + * `glucose-unit-journey.spec.ts` counts rows and reads a display unit off + * them, and both verdicts are global: "the list holds three glucose readings, + * all of them in mmol/L" is only true of an account whose glucose history the + * test itself wrote. A reading left by an earlier run, by a Playwright retry, + * or by a sibling worker would add a fourth row and a stale unit, and the + * assertions would read somebody else's data. + * + * The display unit needs the same treatment, and for a sharper reason: the + * journey's whole subject is switching it, so the spec ENDS with the shared + * account on mmol/L. Left there, the next run's mg/dL half would start from + * the answer it is trying to reach — a test that can only pass once is worse + * than no test. `resetGlucose()` puts the column back to NULL (the default + * mg/dL resolution) before every test. + * + * Deletes rather than soft-deletes: the list route filters on `deleted_at IS + * NULL`, but the dashboard tile and the analytics summaries are the other half + * of this journey and a soft-deleted row would still have to be reasoned + * about. These are fixture rows, so they go. + */ +import pg from "pg"; + +import { E2E_USER } from "./global-setup"; + +async function withPool(fn: (pool: pg.Pool) => Promise): Promise { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("[glucose-fixture] DATABASE_URL is not set"); + const pool = new pg.Pool({ connectionString: url }); + try { + return await fn(pool); + } finally { + await pool.end(); + } +} + +async function getUserId(pool: pg.Pool): Promise { + const res = await pool.query<{ id: string }>( + "SELECT id FROM users WHERE username = $1", + [E2E_USER.username], + ); + const id = res.rows[0]?.id; + if (!id) { + throw new Error( + "[glucose-fixture] e2e user not seeded — global-setup must run first", + ); + } + return id; +} + +/** + * Clear the seeded account's blood-glucose history and put its display unit + * back to the default. Safe to call from every test's `beforeEach`. + */ +export async function resetGlucose(): Promise { + await withPool(async (pool) => { + const userId = await getUserId(pool); + await pool.query( + "DELETE FROM measurements WHERE user_id = $1 AND type = 'BLOOD_GLUCOSE'", + [userId], + ); + await pool.query("UPDATE users SET glucose_unit = NULL WHERE id = $1", [ + userId, + ]); + }); +} diff --git a/playwright.config.ts b/playwright.config.ts index 928caf004..fb750fd73 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -152,6 +152,14 @@ export default defineConfig({ // through stable data-slots, not a mobile layout, so it runs in one // project. "vaccinations.spec.ts", + // The blood-glucose journey writes readings to the one shared account + // and then reads GLOBAL verdicts off it — three rows in the list, one + // tile per meal-time context — while also moving that account's + // display-unit column. Two projects mutating it in parallel would + // leave a fourth row in the count and could flip the unit under the + // other one mid-navigation. It proves a flow through stable + // data-slots, not a mobile layout, so it runs in one project. + "glucose-journey.spec.ts", // Runs only in the service-worker project. "v137-record-session-fence-offline.spec.ts", ], diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index 40ea77036..5cc7e23e0 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -605,7 +605,14 @@ export function MeasurementForm({ {GLUCOSE_CONTEXTS.map((ctx) => ( - + {t(ctx.labelKey)} ))} diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 4ffd2d8be..210aad325 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -1161,6 +1161,18 @@ export function MeasurementList({ {/* Grouped/synthetic rows aren't individually @@ -1197,7 +1209,10 @@ export function MeasurementList({ - + {/* v1.4.39.3 — non-grouped rows render the stored value with its native precision (`fmt.number` honours up to 3 fraction @@ -1358,6 +1373,12 @@ export function MeasurementList({
@@ -1412,7 +1433,10 @@ export function MeasurementList({ {t(TYPE_LABEL_KEYS[m.type])} )} - + {/* v1.4.39.3 — mirror the desktop table: grouped daily aggregates stay integer, non-grouped single readings render with