Merge the medication adherence end-to-end journey

This commit is contained in:
Marc-André Bombeck committed 2026-09-10 04:20:01 +02:00
commit 89ec49cd42
10 files changed
+954 -8

No files matched your search

+670
View File
@@ -0,0 +1,670 @@
/**
* Medication adherence, proven end to end.
*
* The percentage on a medication card is the number this product is for, and
* until now nothing in the browser suite walked the path that produces it: the
* wizard specs stub `/api/medications` and stop at the POST body, and the
* compliance engine's own tests are unit tests over pure functions. So the one
* thing nobody checked was the join — that a tap on "Genommen" reaches the
* engine, and that the engine's answer reaches every surface that shows it.
*
* Three flows:
*
* 1. Journey — a twice-daily medication created through the real wizard, one
* dose taken and one skipped from the card, and the resulting figure read
* back on the card, in the intake history and on the dashboard tile.
* 2. Refusal — an intake against a medication this account does not own, and
* one against a slot its schedule does not have, are both refused and the
* rate is exactly what it was before.
* 3. Cadence — a medication due on two weekdays does not count the other
* five as missed. Its daily twin, given the identical doses, does.
*
* Every assertion addresses a `data-slot` / `data-*` attribute rather than
* viewport text: the copy is i18n-driven and the wizard runs in German, so
* addressing a control by its label would assert the translation.
*
* The flows write rows and read counts back off them, so they run on their own
* account (`E2E_MEDICATION`) — the rate a card shows is a percentage over one
* cabinet, and a sibling spec mounting `/medications` in the other worker
* would otherwise be spending the same per-actor read allowance and sitting in
* the same dashboard denominator. The file still runs serial and in a single
* project (see `playwright.config.ts`), because its three tests share that one
* cabinet with each other.
*
* Why the medications are aged (`ageMedication`): compliance is reconstructed
* from the medication's creation stamp forward, so a medication created a
* second ago has expected no dose yet and reads the empty-set 100 %. A number
* that cannot move is a number this spec cannot check, so each flow puts a few
* days of unlogged history behind its medication first — which is also the
* only state in which "missed" means anything.
*/
import type { Page } from "@playwright/test";
import { MEDICATION_STORAGE_STATE_PATH } from "./setup/global-setup";
import {
ageMedication,
resetMedications,
type MedicationAgeStamps,
} from "./setup/medication-fixture";
import { expect, test } from "./setup/test";
import {
clickNext,
clickSave,
expectStep,
fillStep1Name,
fillStep3Dose,
openCreateWizard,
pickCadenceRow,
pickTreatmentRow,
} from "./medications-wizard-helpers";
/** One `{ data, error }` envelope as the browser sees it. */
interface ApiResult<T> {
status: number;
data: T | null;
}
/** The slice of `GET /api/medications/{id}/compliance` these flows read. */
interface CompliancePayload {
compliance7: {
totalExpected: number;
taken: number;
skipped: number;
missed: number;
rate: number;
};
complianceDisplay: {
shortDays: number;
short: { rate: number; taken: number; expected: number; missed: number };
};
}
/**
* Call the API the way the app does: from the page's own `fetch`, on the
* page's origin, with the session cookie the browser already holds. A
* request issued from the test process would carry a different jar and would
* not exercise the same-origin path every client write takes.
*/
async function api<T>(
page: Page,
method: "GET" | "POST" | "PUT" | "DELETE",
path: string,
body?: unknown,
): Promise<ApiResult<T>> {
return page.evaluate(
async ({ method, path, body }) => {
const res = await fetch(path, {
method,
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
// Read the body as text first: a refusal is allowed to answer with no
// body at all, and a bare `res.json()` would then throw a parse error
// over the status code the assertion is actually about.
const raw = await res.text();
const envelope = raw ? (JSON.parse(raw) as { data: unknown }) : null;
return { status: res.status, data: (envelope?.data ?? null) as never };
},
{ method, path, body },
);
}
/** One schedule entry as `POST /api/medications` accepts it. */
interface SeedSchedule {
windowStart: string;
windowEnd: string;
timesOfDay: string[];
rrule: string;
}
/** Create a medication through the page's own fetch. Returns its id. */
async function seedMedication(
page: Page,
input: {
name: string;
dose: string;
startsOn: string;
schedule: SeedSchedule;
},
): Promise<string> {
const created = await api<{ id: string }>(page, "POST", "/api/medications", {
name: input.name,
dose: input.dose,
startsOn: input.startsOn,
schedules: [input.schedule],
});
expect(created.status, `create ${input.name}`).toBe(201);
const id = created.data?.id;
expect(id, `created ${input.name} carries an id`).toBeTruthy();
return id as string;
}
/**
* The two stamps `ageMedication` writes, computed in the browser's clock.
*
* Deliberately not in SQL: `CURRENT_DATE` resolves in the database session's
* zone, the plan dates every flow sends are computed here, and between
* midnight and 02:00 Berlin those are different calendar days.
*/
async function ageStamps(
page: Page,
days: number,
): Promise<MedicationAgeStamps> {
return page.evaluate((days) => {
const pad = (n: number) => String(n).padStart(2, "0");
const at = new Date();
at.setDate(at.getDate() - days);
return {
createdAt: at.toISOString(),
startsOn: `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}`,
};
}, days);
}
/**
* The write `ageMedication` uses to flush the server's memoised compliance
* cells after it has edited the row underneath them: an ordinary update that
* re-sends the dose the medication already has.
*/
function cacheFlusher(page: Page, medicationId: string, dose: string) {
return async () => {
const saved = await api<{ id: string }>(
page,
"PUT",
`/api/medications/${medicationId}`,
{ dose },
);
expect(saved.status, "cache-flushing update").toBe(200);
};
}
/** The compliance block on a medication card, as the card renders it. */
function complianceBlock(page: Page, medicationId: string) {
return page
.locator(`[data-medication-id="${medicationId}"]`)
.locator('[data-slot="medication-card-compliance"]')
.first();
}
/**
* Read the card's short-window adherence percentage.
*
* The rate's presence is the gate: the skeleton and the quiet error fallback
* reserve the same footprint as the loaded bars and carry no rates, so an
* attribute that is there is a figure the card really painted. Twenty seconds
* is the list's own load-plus-fetch budget and nothing more — the account is
* this spec's own, so a card with no figure on it is a defect rather than a
* neighbour's spent read allowance.
*/
async function readCardRate(page: Page, medicationId: string): Promise<number> {
const block = complianceBlock(page, medicationId);
await expect(
block,
"the card never painted an adherence figure",
).toHaveAttribute("data-rate-short", /^\d+$/, { timeout: 20_000 });
return Number(await block.getAttribute("data-rate-short"));
}
/** The dashboard tile's most recent day, as the tile itself computed it. */
async function readTileLatestRate(page: Page): Promise<number> {
const tile = page.locator('[data-slot="medication-compliance-chart"]');
await expect(tile).toBeVisible({ timeout: 15_000 });
await expect(tile).toHaveAttribute("data-latest-rate", /^\d+$/, {
timeout: 15_000,
});
return Number(await tile.getAttribute("data-latest-rate"));
}
/**
* Open a surface the reset and the seeding calls can run from.
*
* Both go out as the page's own `fetch`, so the page has to be on the app's
* origin first. Deliberately NOT the medications list: the reset that follows
* deletes rows, and a list mount would first fetch compliance for every one of
* them.
*/
async function openSeedingSurface(page: Page): Promise<void> {
await page.goto("/profile");
// The shell's own main region, not `main` — the profile page nests a second
// one inside it and a bare tag selector is ambiguous there.
await expect(page.locator("#main-content")).toBeVisible({ timeout: 15_000 });
}
/**
* The two dose times the journey's medication runs on: now, and twelve hours
* from now.
*
* They are derived from the clock rather than picked from the wizard's
* suggestion chips, and that is the whole reason the flow is stable. The card
* offers exactly one dose — the open window, else the next one due — so with
* fixed times there is always an hour of the day at which the dose it offers
* belongs to yesterday or to tomorrow, and the day the write lands on decides
* what the dashboard tile reads. A slot anchored at "now" is inside its own
* on-time window by construction, so the take is always today's, whenever the
* suite runs. Its twin twelve hours away is the second dose of the day.
*/
async function clockAnchoredDoseTimes(
page: Page,
): Promise<{ open: string; other: string }> {
return page.evaluate(() => {
const pad = (n: number) => String(n).padStart(2, "0");
const now = new Date();
return {
open: `${pad(now.getHours())}:${pad(now.getMinutes())}`,
other: `${pad((now.getHours() + 12) % 24)}:${pad(now.getMinutes())}`,
};
});
}
/**
* Add one `HH:mm` to the wizard's times-of-day chips.
*
* The Enter press is the commit, not a flourish: the field's overlay input
* holds what is being typed and only hands it to the form on Enter or on blur
* (`src/components/ui/time-field.tsx:175-184`). Until then the draft is empty,
* and the add button is `disabled` on exactly that
* (`times-of-day-chips.tsx:267`) — so a fill followed straight by a click
* waits out the actionability timeout on a button that never enables.
*/
async function addDoseTime(page: Page, time: string): Promise<void> {
const chips = page.locator('[data-slot="times-of-day-chips"]');
const draft = chips.getByTestId("times-of-day-draft-input");
await draft.fill(time);
await draft.press("Enter");
await chips.locator('[data-slot="times-of-day-add"]').click();
await expect(
chips.locator(`[data-slot="times-of-day-chip"][data-time="${time}"]`),
).toBeVisible();
}
/**
* Walk the create wizard for a twice-daily medication and return its id.
* Reuses the cadence specs' step helpers; unlike those specs it leaves
* `/api/medications` unstubbed, so the medication is really written.
*/
async function createTwiceDailyMedication(
page: Page,
name: string,
): Promise<{ id: string; times: { open: string; other: string } }> {
await openCreateWizard(page);
const times = await clockAnchoredDoseTimes(page);
await fillStep1Name(page, { name });
await clickNext(page);
await expectStep(page, 2);
await pickTreatmentRow(page, "other");
await clickNext(page);
await expectStep(page, 3);
await fillStep3Dose(page, { amount: "500" });
await clickNext(page);
await expectStep(page, 4);
await clickNext(page);
await expectStep(page, 5, 8);
await pickCadenceRow(page, "daily");
// Picking a daily cadence compresses the path from eight steps to seven.
await expectStep(page, 5, 7);
await clickNext(page);
// Times of day — the step arrives with 08:00 already on it. Add the two the
// journey needs, then drop 08:00 unless it is one of them, so the plan is
// exactly twice daily.
await expectStep(page, 6, 7);
const chips = page.locator('[data-slot="times-of-day-chips"]');
await addDoseTime(page, times.open);
await addDoseTime(page, times.other);
if (times.open !== "08:00" && times.other !== "08:00") {
await chips
.locator('[data-slot="times-of-day-chip"][data-time="08:00"]')
.locator('[data-slot="times-of-day-chip-remove"]')
.click();
}
await expect(chips.locator('[data-slot="times-of-day-chip"]')).toHaveCount(2);
await clickNext(page);
await expectStep(page, 7, 7);
await clickSave(page);
await page.waitForURL(/\/medications\/[^/?]+$/, { timeout: 15_000 });
const id = new URL(page.url()).pathname.split("/").pop();
expect(id, "the create flow lands on the new medication").toBeTruthy();
return { id: id as string, times };
}
// Serial: the three tests share one cabinet, and two of them clearing it at
// once would each undo the other's setup.
test.describe.configure({ mode: "serial" });
test.describe("medication adherence journey", () => {
test.use({ storageState: MEDICATION_STORAGE_STATE_PATH });
test.beforeEach(async ({ page }) => {
// Own every row the rates count. The dashboard tile sums the expected
// doses of every active medication on the account, so a leftover from a
// previous run would sit in its denominator. The removal goes out as the
// app's own DELETE so the server's memoised medication cells go with it.
await openSeedingSurface(page);
await resetMedications(async (id) => {
const removed = await api(page, "DELETE", `/api/medications/${id}`);
expect(removed.status, "clearing a leftover medication").toBe(200);
});
});
test("a taken and a skipped dose move the card, the history and the dashboard tile", async ({
page,
}) => {
// The wizard is a seven-step dialog with per-step validation gating Next;
// on a loaded runner that settle earns the tripled timeout.
test.slow();
// The tile reads THE LATEST day, and a day boundary crossed mid-flow moves
// that day out from under both tile assertions: a fresh day has two doses
// scheduled, none taken, and answers 0 where 50 is asserted. The flow is a
// couple of minutes long, so declining to start in the last ten of the day
// is enough. The clock is the browser's, which the config pins to the zone
// the compliance engine falls back to.
const minutesLeftInDay = await page.evaluate(
() => 24 * 60 - (new Date().getHours() * 60 + new Date().getMinutes()),
);
test.skip(
minutesLeftInDay < 10,
"starts inside the last ten minutes of the day — the dashboard tile's latest day would change under the flow",
);
const { id: medicationId } = await createTwiceDailyMedication(
page,
"E2E Adhärenz",
);
// Four days of plan behind it, none of it logged: the engine expects two
// doses a day and nobody took any, so the starting rate is a real 0 %.
await ageMedication(
medicationId,
await ageStamps(page, 4),
cacheFlusher(page, medicationId, "500 mg"),
);
// --- before ------------------------------------------------------------
await page.goto("/");
expect(await readTileLatestRate(page)).toBe(0);
await page.goto("/medications");
expect(await readCardRate(page, medicationId)).toBe(0);
// --- the two gestures --------------------------------------------------
const card = page.locator(`[data-medication-id="${medicationId}"]`);
const intakePath = `/api/medications/${medicationId}/intake`;
const takeWritten = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === intakePath &&
res.request().method() === "POST",
);
await card.locator('[data-slot="medication-intake-take"]').click();
expect((await takeWritten).status(), "the take is written").toBe(201);
// The skip must land on the OTHER dose, so it has to wait for the card to
// re-read its schedule: the card binds each write to the slot it is
// currently showing, and a skip fired against a stale render would flip
// the dose just taken instead of recording a second one. The rate leaving
// zero is the card's own confirmation that the refetch landed.
await expect
.poll(
() =>
complianceBlock(page, medicationId).getAttribute("data-rate-short"),
{
timeout: 30_000,
message: "the card never re-read its schedule after the take",
},
)
.toMatch(/^[1-9]\d*$/);
const skipWritten = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === intakePath &&
res.request().method() === "POST",
);
await card.locator('[data-slot="medication-intake-skip"]').click();
expect((await skipWritten).status(), "the skip is written").toBe(201);
// --- the card agrees with the engine -----------------------------------
const payload = await api<CompliancePayload>(
page,
"GET",
`/api/medications/${medicationId}/compliance`,
);
expect(payload.status).toBe(200);
const compliance = payload.data as CompliancePayload;
// One dose taken, one deliberately skipped, and the four days of plan
// nobody logged still counting against the rate.
expect(compliance.compliance7.taken).toBe(1);
expect(compliance.compliance7.missed).toBeGreaterThanOrEqual(6);
expect(compliance.complianceDisplay.shortDays).toBe(7);
expect(compliance.complianceDisplay.short.taken).toBe(1);
// A skip is a decision, not a failure: it never enters the denominator.
// So the rate has moved off zero and is nowhere near a hundred — one dose
// taken against at least six missed.
const rateAfter = await readCardRate(page, medicationId);
expect(rateAfter).toBe(compliance.complianceDisplay.short.rate);
expect(rateAfter).toBeGreaterThan(0);
expect(rateAfter).toBeLessThanOrEqual(20);
// --- the intake history shows both events, each as what it was ----------
// The 90-day ledger beside it is a view of the SCHEDULE and stops at now,
// so a dose skipped ahead of its window is not in it. The intake history
// is the view of what was recorded, and both gestures belong there.
await page.goto(`/medications/${medicationId}?tab=verlauf`);
await page
.locator('[data-slot="medication-verlauf-view-toggle-all"]')
.click();
const history = page.locator('[data-slot="intake-history-row"]');
await expect(history).toHaveCount(2, { timeout: 15_000 });
await expect(
page.locator('[data-slot="intake-history-row"][data-status="taken"]'),
).toHaveCount(1);
await expect(
page.locator('[data-slot="intake-history-row"][data-status="skipped"]'),
).toHaveCount(1);
// --- and the dashboard tile, which answers the skip differently ---------
// Today expected two doses; one was taken and one deliberately skipped.
//
// The two surfaces do not agree about that skip, and this pins the
// difference rather than claiming it away. The tile's denominator is the
// day's expected slots, which a skip does not leave, and its numerator
// counts only rows carrying a `takenAt`
// (`src/lib/analytics/schedule-anchored-compliance.ts:141-164`), so it
// reads 50. The card's engine — `src/lib/analytics/compliance.ts`, the
// documented single source of the percentage — drops a skip from the
// denominator outright (`src/lib/analytics/compliance/ledger.ts:43-47`),
// which is what the counts asserted above already show. The number below
// is the one the server's daily buckets really produce for today; the
// card's is the authoritative answer about adherence.
await page.goto("/");
expect(await readTileLatestRate(page)).toBe(50);
});
test("an intake against an unowned medication, and one against a slot the schedule does not have, are both refused", async ({
page,
}) => {
// Two full list loads either side of the refusals, plus a seed and an age;
// on a loaded runner that settle earns the tripled timeout.
test.slow();
const stamps = await ageStamps(page, 4);
// An instant the schedule below does not have a slot for. Computed here,
// before the first navigation, so the whole test body reads as one setup
// step followed by assertions on it.
const unscheduled = await page.evaluate(() => {
const at = new Date();
at.setHours(3, 17, 0, 0);
return at.toISOString();
});
const medicationId = await seedMedication(page, {
name: "E2E Ablehnung",
dose: "10 mg",
startsOn: stamps.startsOn,
schedule: {
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00", "22:00"],
rrule: "FREQ=DAILY",
},
});
await ageMedication(
medicationId,
stamps,
cacheFlusher(page, medicationId, "10 mg"),
);
await page.goto("/medications");
const before = await readCardRate(page, medicationId);
// A medication id nothing on this account owns. The privacy gate answers
// before the body is even read, so the 404 is about ownership and says
// nothing about the medication existing elsewhere.
const unowned = await api(
page,
"POST",
"/api/medications/med_e2e_does_not_exist/intake",
{ skipped: false },
);
expect(unowned.status, "an unowned medication is refused").toBe(404);
// And the schedule's own boundary: this medication doses at 08:00 and
// 22:00, so 03:17 is not one of its slots and the pin is refused rather
// than silently recorded as an ad-hoc dose.
const offSlot = await api(
page,
"POST",
`/api/medications/${medicationId}/intake`,
{
skipped: false,
forceSlotInstant: unscheduled,
},
);
expect(offSlot.status, "an instant that is not a slot is refused").toBe(
422,
);
// Nothing was written, so nothing moved — including on the engine's own
// read, which neither refused call had an opportunity to invalidate.
const payload = await api<CompliancePayload>(
page,
"GET",
`/api/medications/${medicationId}/compliance`,
);
expect(payload.status).toBe(200);
expect((payload.data as CompliancePayload).compliance7.taken).toBe(0);
await page.goto("/medications");
expect(await readCardRate(page, medicationId)).toBe(before);
});
test("a two-weekday plan does not count the other five days as missed", async ({
page,
}) => {
// Two medications seeded, aged and dosed three times apiece before the
// first assertion; on a loaded runner that earns the tripled timeout.
test.slow();
// Both dose days are behind us and neither is today, so the plan's whole
// realised history is closed and the verdicts do not depend on the hour
// the suite runs at. The 08:00 dose eight days back falls on the same
// weekday as yesterday's, which is what keeps the longer window's answer
// the same as the short one's.
const plan = await page.evaluate(() => {
const TOKENS = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"];
const at8 = (daysAgo: number) => {
const d = new Date();
d.setDate(d.getDate() - daysAgo);
d.setHours(8, 0, 0, 0);
return d;
};
const iso = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const doseDays = [at8(8), at8(2), at8(1)];
return {
byday: [TOKENS[at8(2).getDay()], TOKENS[at8(1).getDay()]].join(","),
startsOn: iso(at8(8)),
slots: doseDays.map((d) => d.toISOString()),
};
});
const weekdayId = await seedMedication(page, {
name: "E2E Wochentage",
dose: "5 mg",
startsOn: plan.startsOn,
schedule: {
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00"],
rrule: `FREQ=WEEKLY;BYDAY=${plan.byday}`,
},
});
// The control: the same doses on the same days, on a plan that expects one
// every day. Without it "100 %" proves nothing — an engine that counted no
// days at all would also read 100 %.
const dailyId = await seedMedication(page, {
name: "E2E Täglich",
dose: "5 mg",
startsOn: plan.startsOn,
schedule: {
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00"],
rrule: "FREQ=DAILY",
},
});
const stamps = await ageStamps(page, 8);
for (const id of [weekdayId, dailyId]) {
await ageMedication(id, stamps, cacheFlusher(page, id, "5 mg"));
for (const slot of plan.slots) {
const written = await api(
page,
"POST",
`/api/medications/${id}/intake`,
{
skipped: false,
scheduledFor: slot,
takenAt: slot,
},
);
expect(written.status, "a backdated dose is written").toBe(201);
}
}
const read = async (id: string) => {
const res = await api<CompliancePayload>(
page,
"GET",
`/api/medications/${id}/compliance`,
);
expect(res.status).toBe(200);
return (res.data as CompliancePayload).compliance7;
};
const weekday = await read(weekdayId);
const daily = await read(dailyId);
// Two dose days in the trailing week, both taken, none missed. If the five
// non-dose weekdays reached the denominator this would read seven.
expect(weekday.totalExpected).toBe(2);
expect(weekday.taken).toBe(2);
expect(weekday.missed).toBe(0);
expect(weekday.rate).toBe(100);
// The daily twin, given exactly the same doses, is missing the rest.
expect(daily.totalExpected).toBeGreaterThanOrEqual(6);
expect(daily.missed).toBeGreaterThanOrEqual(4);
expect(daily.rate).toBeLessThan(100);
// And the two cards say the same thing side by side.
await page.goto("/medications");
expect(await readCardRate(page, weekdayId)).toBe(100);
expect(await readCardRate(page, dailyId)).toBeLessThan(100);
});
});
+57 -2
View File
@@ -93,6 +93,25 @@ export const E2E_GUARDIAN = {
role: "USER",
} as const;
/**
* The medication-adherence journey's own account.
*
* The batched compliance read is capped at thirty calls a minute per ACTOR,
* and the journey spends that allowance twice over: it writes doses and then
* reads the resulting rates back off the same cabinet. On the shared account
* the allowance is also being spent by whichever sibling happens to be
* mounting `/medications` in the other worker, and the cabinet itself is
* shared with them — so the journey's `beforeEach` clear would take rows
* somebody else seeded with it. One account of its own makes the rate-limit
* bucket, the cabinet and the dashboard tile's denominator private at once.
*/
export const E2E_MEDICATION = {
email: "e2e-medication@healthlog.test",
username: "e2e-medication",
password: "Hj6!Tw2pV9sQ4nDx",
role: "USER",
} as const;
/** A separate delegate session for the eight scoped-record browser journeys. */
export const E2E_SCOPE_DELEGATE = {
email: "e2e-scope-delegate@healthlog.test",
@@ -209,6 +228,12 @@ export const SCOPE_CAPABILITIES_STORAGE_STATE_PATH = resolve(
"e2e/setup/storageStateScopeCapabilities.json",
);
/** The adherence journey's jar — see `E2E_MEDICATION` for why it is its own. */
export const MEDICATION_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateMedication.json",
);
/**
* v1.37.0 — one jar per SWITCHING spec, for the reason spelled out at
* `DELEGATE_STORAGE_STATE_PATH` above and now applied to the scope delegate as
@@ -408,6 +433,32 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
[E2E_GUARDIAN.username],
);
// The adherence journey's account. Modules are pinned rather than left to
// the defaults: the dashboard's medication tile is one of the surfaces the
// journey reads, and it only paints while the module is on.
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed,
module_preferences_json)
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, $6::jsonb)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
updated_at = EXCLUDED.updated_at,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
module_preferences_json = EXCLUDED.module_preferences_json`,
[
cuid(),
E2E_MEDICATION.username,
E2E_MEDICATION.email,
await hashPassword(E2E_MEDICATION.password),
now,
JSON.stringify({ medications: true }),
],
);
// The scoped-record journey uses a dedicated delegate so its preseeded
// grants cannot affect the invitation lifecycle exercised by the sharing
// journey above. Its own module preferences are explicitly off: a target
@@ -647,7 +698,7 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
WHERE key LIKE 'sharing:%'`,
);
// The login bucket, for the same reason. This setup signs in TEN times now
// The login bucket, for the same reason. This setup signs in TWELVE times now
// (the shared jar, the owner, and one jar apiece for every spec that moves
// a session's record selector), and the ceiling is five attempts per IP per
// quarter-hour — so two local runs in a row would otherwise end with a 429
@@ -727,7 +778,7 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
* Log one account in, clearing the login bucket first.
*
* The ceiling is FIVE attempts per IP per quarter-hour and this setup now
* signs in ten times, so clearing once before the batch is no longer
* signs in twelve times, so clearing once before the batch is no longer
* enough — the sixth would be answered by the fixture's own 429 rather
* than by the product. Only the auth surfaces' buckets are touched, and
* only between logins this setup is itself performing.
@@ -753,6 +804,10 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
// for what happens when the journey switches the shared row instead.
await capture(E2E_USER, DELEGATE_STORAGE_STATE_PATH);
// The adherence journey's jar. Its own account, so nothing it writes and
// nothing it clears is visible to the shared one.
await capture(E2E_MEDICATION, MEDICATION_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH);
+130
View File
@@ -0,0 +1,130 @@
/**
* Account hygiene and row-ageing for the medication-adherence journey.
*
* `medication-compliance-journey.spec.ts` asserts RATES — a percentage over
* one account's own doses — and the dashboard tile sums the expected doses of
* every active medication that account holds. Both are counts, so the spec has
* to own the rows it counts against: a medication left by an earlier run or a
* Playwright retry would sit in the denominator of every verdict. Everything
* here is keyed on `E2E_MEDICATION`, the journey's own account, so neither the
* clearing nor the ageing can be felt by a spec running in the other worker.
*
* The ageing is the other half. Compliance is reconstructed over
* `[max(medication.createdAt, now − window), now]` — a medication created a
* second ago has expected no dose yet, so its rate is the empty-set 100 % and
* no take can move it. A journey that wants to watch a number change needs a
* medication that has been in the cabinet for a few days, and the only honest
* way to have one inside a test is to age the row.
*/
import pg from "pg";
import { E2E_MEDICATION } from "./global-setup";
function pool(): pg.Pool {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("[medication-fixture] DATABASE_URL is not set");
return new pg.Pool({ connectionString: url });
}
async function getUserId(db: pg.Pool): Promise<string> {
const res = await db.query<{ id: string }>(
"SELECT id FROM users WHERE username = $1",
[E2E_MEDICATION.username],
);
const id = res.rows[0]?.id;
if (!id) {
throw new Error(
"[medication-fixture] medication account not seeded — global-setup must run first",
);
}
return id;
}
/**
* Clear the journey account's cabinet — through the app, not around it.
*
* The rows are enumerated in SQL because the list route serves a memoised
* shape and a row that cache has not caught up with would hide from it. Every
* removal then goes out as the caller's own `DELETE /api/medications/{id}`,
* which is the only path that evicts the three 15-minute per-user cells
* medication state lives in (`caches.medications`,
* `caches.medicationCompliance`, `caches.medicationsIntake`). A raw
* `DELETE FROM medications` reaches none of them, and the next read would
* answer out of a cache still naming rows that no longer exist — which is
* exactly the read this helper exists to make trustworthy. Schedules, schedule
* revisions, pause eras and intake events cascade off the row either way.
*
* Safe to call from every test's `beforeEach`.
*/
export async function resetMedications(
deleteMedication: (id: string) => Promise<void>,
): Promise<void> {
const db = pool();
let ids: string[];
try {
const userId = await getUserId(db);
const res = await db.query<{ id: string }>(
"SELECT id FROM medications WHERE user_id = $1",
[userId],
);
ids = res.rows.map((row) => row.id);
} finally {
await db.end();
}
for (const id of ids) await deleteMedication(id);
}
/** The two stamps {@link ageMedication} writes onto the row. */
export interface MedicationAgeStamps {
/** The instant the row should claim as its creation, as an ISO string. */
createdAt: string;
/** The `YYYY-MM-DD` the course should claim as its start. */
startsOn: string;
}
/**
* Move a medication's creation stamp and its course start into the past, so
* the compliance engine expands the schedule over those days and the doses
* nobody logged read as missed.
*
* Both stamps come from the caller rather than from the database clock, and
* that is deliberate: `CURRENT_DATE` resolves in the database session's zone,
* which on CI is the Postgres container's UTC, while the plan dates the spec
* sends are computed in the browser's pinned `Europe/Berlin`. Between midnight
* and 02:00 Berlin the two name different calendar days, and the row would
* quietly start one day earlier than its caller asked for. `starts_on` is
* OVERWRITTEN, not adjusted — whatever the create call sent is replaced by
* what is passed here.
*
* The row is edited directly, which no cache invalidation can see: the
* per-medication compliance payload and the dashboard's daily buckets are
* both memoised server-side for 15 minutes, and the detail page the create
* flow lands on has already warmed them. The caller therefore passes a
* writer — one ordinary API write against the medication — and this helper
* uses it to flush those cells through the app's own invalidation path.
*/
export async function ageMedication(
medicationId: string,
stamps: MedicationAgeStamps,
flushCaches: () => Promise<void>,
): Promise<void> {
const db = pool();
try {
const userId = await getUserId(db);
const res = await db.query(
`UPDATE medications
SET created_at = $1::timestamptz,
starts_on = $2::date
WHERE id = $3 AND user_id = $4`,
[stamps.createdAt, stamps.startsOn, medicationId, userId],
);
if (res.rowCount !== 1) {
throw new Error(
`[medication-fixture] expected to age exactly one medication, updated ${res.rowCount}`,
);
}
} finally {
await db.end();
}
await flushCaches();
}
+9
View File
@@ -160,6 +160,15 @@ export default defineConfig({
// 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",
// The adherence journey walks the wizard, records doses on its own
// account and then reads RATES back off it — and the dashboard tile
// sums the expected doses of every active medication that account
// holds. The account keeps siblings out; two PROJECTS running this
// same file in parallel would still write the one cabinet and each
// sit in the other's denominator, so the percentages would be answers
// to a question neither test asked. It proves flows through stable
// data-slots, not a mobile layout, so it runs in one project.
"medication-compliance-journey.spec.ts",
// Runs only in the service-worker project.
"v137-record-session-fence-offline.spec.ts",
// The Apple Health import journey uploads an archive into the one
@@ -15,8 +15,16 @@ import {
* aggregation helper produces the correct rates.
*/
/**
* The daily points the mocked query serves. Mutable so one test can hand the
* chart a loaded window; every other test leaves it empty.
*/
const queryState = vi.hoisted(() => ({
data: [] as { date: string; scheduled: number; taken: number }[],
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: [], isLoading: false }),
useQuery: () => ({ data: queryState.data, isLoading: false }),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
getQueryData: () => undefined,
@@ -195,4 +203,24 @@ describe("<MedicationComplianceChart>", () => {
const html = render(<MedicationComplianceChart />);
expect(html).not.toContain('data-slot="medication-trend-chip"');
});
/**
* The tile's adherence number exists only as bar geometry, so the root's
* `data-latest-rate` is the one way to read it back — and the browser suite
* was its only reader, which put a production attribute's shape three
* minutes and a whole server away from the gate that should own it. Pinned
* here: the value is the LAST day's rate, as an integer.
*/
it("carries the last day's rate on the root as data-latest-rate", () => {
queryState.data = [
{ date: "2026-05-01", scheduled: 2, taken: 2 }, // 100 %
{ date: "2026-05-02", scheduled: 4, taken: 1 }, // 25 %
];
try {
const html = render(<MedicationComplianceChart />);
expect(html).toContain('data-latest-rate="25"');
} finally {
queryState.data = [];
}
});
});
@@ -393,10 +393,19 @@ export function MedicationComplianceChart({
// explicit user opt-in — silence would feel like a bug).
const hasData = chartData.length > 0;
// The tile's adherence number exists only as bar geometry, so nothing on
// this surface can be read back except by measuring pixels. The most
// recent day's rate — the one figure a reader takes off the tile — rides
// the root as a plain data attribute so the browser journey can assert the
// dashboard and the medication card agree on the same dose. Presentation
// is untouched; the value is the one `chartData` already carries.
const latestRate = hasData ? chartData[chartData.length - 1].rate : null;
return (
<div
className="bg-card border-border rounded-xl border p-4 md:p-6"
data-slot="medication-compliance-chart"
data-latest-rate={latestRate ?? undefined}
>
{/* v1.4.19 A2 — header layout split into title row + controls
row on small screens. Pre-fix the trend chip + 4 range tabs +
@@ -609,7 +609,8 @@ describe("compliance bars — fixed geometry (v1.16.6)", () => {
);
// Strip text nodes; the remaining tag/class skeleton must be identical
// between a 0% and a 100% card (transform style varies with the value
// by design, so it is normalised too).
// by design, so it is normalised too — as are the two rate attributes the
// block publishes, which are the value and not the geometry).
const skeleton = (html: string) =>
html
.replace(/>[^<]+</g, "><")
@@ -617,6 +618,8 @@ describe("compliance bars — fixed geometry (v1.16.6)", () => {
.replace(/aria-valuenow="\d+"/g, 'aria-valuenow="N"')
.replace(/data-state="[^"]*"/g, 'data-state="S"')
.replace(/data-value="[^"]*"/g, 'data-value="V"')
.replace(/data-rate-short="[^"]*"/g, 'data-rate-short="R"')
.replace(/data-rate-long="[^"]*"/g, 'data-rate-long="R"')
.replace(/data-max="[^"]*"/g, 'data-max="M"');
expect(skeleton(a)).toBe(skeleton(b));
});
@@ -107,8 +107,10 @@ export function MedicationComplianceBars({
// v1.12.2 — route the rate through the locale number formatter and round
// so a non-integer rate (e.g. 33.333) never leaks raw into the caption,
// matching how every other percentage renders.
const shortPct = fmt.number(Math.round(rate7));
const longPct = fmt.number(Math.round(rate30));
const shortRounded = Math.round(rate7);
const longRounded = Math.round(rate30);
const shortPct = fmt.number(shortRounded);
const longPct = fmt.number(longRounded);
// v1.16.6 — both rows carry an identical, FIXED geometry: the label line
// is pinned to one text line (`h-5` + `truncate`) and the bar to `h-2`,
@@ -117,7 +119,21 @@ export function MedicationComplianceBars({
// is colour / text only — never geometry; the two bars on a card (and
// across a grid row) always align.
return (
<div className="space-y-2.5">
// Both rates ride the block as plain data attributes. The percentages
// themselves are locale-formatted text inside a fixed-height caption, and
// the bar beside them is a shadcn `Progress` that keeps its value for the
// indicator transform and never forwards it to the primitive — so the one
// number this card exists to show has, until now, been readable only by
// parsing a translated string. The attributes carry the ROUNDED value the
// caption renders, so a reader and an assertion see the same figure.
// Presentation is untouched; the loading and error variants below stay
// free of them, which is what makes their presence mean "loaded".
<div
className="space-y-2.5"
data-slot="medication-card-compliance"
data-rate-short={shortRounded}
data-rate-long={longRounded}
>
<div className="space-y-1.5">
<div className="flex h-5 items-center justify-between text-sm">
<span className="text-muted-foreground flex min-w-0 items-center gap-1">
@@ -22,6 +22,11 @@ interface MedicationIntakeActionsProps {
*
* Extracted from the generic and GLP-1 cards so the action row is shared
* structurally rather than kept byte-equivalent by hand.
*
* Both buttons carry a `data-slot`. They are the only controls on the card
* that write, and their labels are translated copy — an e2e journey
* addressing them by text would be asserting the translation, not the
* control.
*/
export function MedicationIntakeActions({
intakeLoading,
@@ -37,6 +42,7 @@ export function MedicationIntakeActions({
return (
<div className="flex gap-2">
<Button
data-slot="medication-intake-take"
className="min-h-11 flex-1"
onClick={() => onRecordIntake(false)}
disabled={!!intakeLoading}
@@ -49,6 +55,7 @@ export function MedicationIntakeActions({
{t("medications.taken")}
</Button>
<Button
data-slot="medication-intake-skip"
variant="outline"
className="min-h-11"
onClick={() => onRecordIntake(true)}
@@ -155,6 +155,19 @@ const DEFAULT_PAGE_SIZE = 25;
*/
const STATUS_FILTER = "completed";
/**
* The row's outcome as a stable token, carried on both layouts' rows as
* `data-status`. The visible outcome is a translated badge that renders at a
* different size on each layout, so it is not a thing an end-to-end journey
* can address; the branch itself is one line and already computed twice.
* "unknown" cannot occur while `STATUS_FILTER` holds — it is what a malformed
* row would say rather than quietly reading as taken.
*/
function rowStatus(isTaken: boolean, isSkipped: boolean): string {
if (isTaken) return "taken";
return isSkipped ? "skipped" : "unknown";
}
export function IntakeHistoryListV2({
medicationId,
pageSize = DEFAULT_PAGE_SIZE,
@@ -346,7 +359,11 @@ export function IntakeHistoryListV2({
const isSelected =
selection?.selected.has(event.id) ?? false;
return (
<TableRow key={event.id}>
<TableRow
key={event.id}
data-slot="intake-history-row"
data-status={rowStatus(isTaken, isSkipped)}
>
{showSelection && (
<TableCell className="w-8">
<Checkbox
@@ -441,6 +458,8 @@ export function IntakeHistoryListV2({
return (
<ListRow
key={event.id}
data-slot="intake-history-row"
data-status={rowStatus(isTaken, isSkipped)}
data-state={isSelected ? "selected" : undefined}
className="bg-card border-border data-[state=selected]:border-primary/60 data-[state=selected]:bg-primary/5"
>