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

+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();
}