test(e2e): cover the medication-adherence journey end to end

The percentage on a medication card is the number this product is for, and
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. The join was the part
nobody checked.

Three flows against the seeded account, serial and in one project because
they read counts back off a cabinet they own:

  - a twice-daily medication created through the real wizard, one dose taken
    and one skipped from the card, and the figure read back on the card, in
    the intake history and on the dashboard tile;
  - a dose recorded against a schedule that does not exist, refused, with the
    rate exactly what it was before;
  - a plan due on two weekdays whose other five days stay out of the
    denominator, with a daily twin given the identical doses as the control —
    without it, "100 %" would also be what an engine that counted no days at
    all would say.

The dose times are derived from the clock rather than picked from the
wizard's suggestions: the card offers one dose, so with fixed times there is
always an hour at which that dose belongs to yesterday or tomorrow, and the
day the write lands on decides what the tile reads. Each medication is aged a
few days as well — compliance is reconstructed from the creation stamp
forward, so a medication created a second ago reads the empty-set 100 % and
no take can move it.

Four surfaces gain data attributes so the journey addresses controls and
figures rather than translated copy: the card's take / skip buttons, the
card's two adherence rates, the intake-history row status, and the dashboard
tile's most recent day, which existed only as bar geometry. Presentation is
unchanged; the compliance block's fixed-geometry guard normalises the two new
rate attributes alongside the ones it already did.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 01:46:13 +02:00
1 parent 1d372df3a0
commit 617beb40dc
8 files changed
+761 -5

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
/**
* Account hygiene and row-ageing for the medication-adherence journey.
*
* `medication-compliance-journey.spec.ts` asserts RATES — a percentage over
* the seeded account's own doses — and the dashboard tile sums the expected
* doses of every active medication the 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.
* `resetMedications` is the delete-then-count guarantee, keyed by the seeded
* user's id; every other e2e spec that touches `/api/medications` stubs the
* route, so nothing else has live rows here to lose.
*
* 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_USER } 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_USER.username],
);
const id = res.rows[0]?.id;
if (!id) {
throw new Error(
"[medication-fixture] e2e user not seeded — global-setup must run first",
);
}
return id;
}
/**
* Drop the seeded e2e user's medications. Schedules, schedule revisions,
* pause eras and intake events all cascade off `medications`, so the one
* delete clears the whole cabinet. Safe to call from every test's
* `beforeEach`.
*/
export async function resetMedications(): Promise<void> {
const db = pool();
try {
const userId = await getUserId(db);
await db.query("DELETE FROM medications WHERE user_id = $1", [userId]);
} finally {
await db.end();
}
}
/**
* Move a medication's creation stamp (and its course start) `days` into the
* past, so the compliance engine expands the schedule over those days and the
* doses nobody logged read as missed.
*
* 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,
days: number,
flushCaches: () => Promise<void>,
): Promise<void> {
const db = pool();
try {
const userId = await getUserId(db);
const res = await db.query(
`UPDATE medications
SET created_at = now() - make_interval(days => $1),
starts_on = (CURRENT_DATE - make_interval(days => $1))::date
WHERE id = $2 AND user_id = $3`,
[days, 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();
}