mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
Merge the medication adherence end-to-end journey
This commit is contained in:
commit
89ec49cd42
10 files changed
+954
-8
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user