Merge the glucose end-to-end journey

This commit is contained in:
Marc-André Bombeck committed 2026-09-10 01:07:00 +02:00
commit 80bcf75786
6 files changed
+533 -4

No files matched your search

+418
View File
@@ -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<void> {
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<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)));
});
});
+6 -1
View File
@@ -675,7 +675,12 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
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],
);
}
+67
View File
@@ -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<T>(fn: (pool: pg.Pool) => Promise<T>): Promise<T> {
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<string> {
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<void> {
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,
]);
});
}
+8
View File
@@ -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",
],
@@ -605,7 +605,14 @@ export function MeasurementForm({
</SelectTrigger>
<SelectContent>
{GLUCOSE_CONTEXTS.map((ctx) => (
<SelectItem key={ctx.value} value={ctx.value}>
<SelectItem
key={ctx.value}
value={ctx.value}
// The option carries its enum alongside the translated
// label, so an option can be chosen by which meal time it
// means rather than by the words the current locale prints.
data-glucose-context={ctx.value}
>
{t(ctx.labelKey)}
</SelectItem>
))}
@@ -1161,6 +1161,18 @@ export function MeasurementList({
<Fragment key={m.id}>
<TableRow
data-state={isSelected ? "selected" : undefined}
// A row is addressable by what it holds, not by
// the copy it paints: the type is an enum and the
// unit is a symbol, so a browser journey can pin
// "this glucose reading now reads in mmol/L"
// without matching translated text that also
// renders at a different size on the card list
// below.
data-testid="measurement-row"
data-measurement-type={m.type}
data-display-unit={
isGrouped ? m.unit : rowDisplay(m).unit
}
>
<TableCell className="pl-4">
{/* Grouped/synthetic rows aren't individually
@@ -1197,7 +1209,10 @@ export function MeasurementList({
</button>
</Badge>
</TableCell>
<TableCell className="font-semibold tabular-nums">
<TableCell
className="font-semibold tabular-nums"
data-slot="measurement-row-value"
>
{/* 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({
<ListRow
key={m.id}
data-state={isSelected ? "selected" : undefined}
// Same addressing contract as the desktop row above.
data-testid="measurement-row"
data-measurement-type={m.type}
data-display-unit={
isGrouped ? m.unit : rowDisplay(m).unit
}
className="bg-card border-border data-[state=selected]:border-primary/60 data-[state=selected]:bg-primary/5"
>
<div className="flex items-center justify-between">
@@ -1412,7 +1433,10 @@ export function MeasurementList({
{t(TYPE_LABEL_KEYS[m.type])}
</Badge>
)}
<span className="font-semibold tabular-nums">
<span
className="font-semibold tabular-nums"
data-slot="measurement-row-value"
>
{/* v1.4.39.3 — mirror the desktop table:
grouped daily aggregates stay integer,
non-grouped single readings render with