Files
HealthLog/e2e/glucose-journey.spec.ts
Marc-André Bombeck 7405e41119 test(e2e): read the glucose journey's list filtered to glucose
The journey counted glucose rows on the unfiltered list, which shows
the account's newest page of every type. Other specs keep writing
readings to the same account, so on a database a few runs old the
reading typed for yesterday fell off that page and the count read one
short. The list now opens with `?type=BLOOD_GLUCOSE`, so it holds
exactly the rows the spec owns.
2026-10-23 20:40:31 +02:00

425 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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(".", "[.,]"));
}
/**
* The list, filtered to blood glucose. Unfiltered it shows the account's
* newest page of every type, and other specs keep writing readings to the
* same account, so on a database a few runs old a reading typed for
* "yesterday" falls off that page and the count reads one short.
*/
async function openList(page: Page): Promise<void> {
await page.goto("/measurements?type=BLOOD_GLUCOSE");
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<void> {
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<void> {
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<number> {
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<void> {
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)));
});
});