From 8eff500e9ddf5a21998620f9b7007a3b933121e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 10:06:14 +0200 Subject: [PATCH] test(e2e): prove a switched-off module leaves every surface it owns A journey on its own account switches mood and medications on, proves the Mood nav entry, the Mood and Medications pills, the mood trends slot and both add-menu entries are there, switches both off through the module route and proves each is gone, the trends row fills from pulse, /insights/mood shows the module notice at the same URL, and no chart on the way paints its error state. --- e2e/modules-off.spec.ts | 169 +++++++++++++++++++++++++++++++++++ e2e/setup/global-setup.ts | 45 ++++++++++ e2e/setup/modules-fixture.ts | 51 +++++++++++ playwright.config.ts | 5 ++ 4 files changed, 270 insertions(+) create mode 100644 e2e/modules-off.spec.ts create mode 100644 e2e/setup/modules-fixture.ts diff --git a/e2e/modules-off.spec.ts b/e2e/modules-off.spec.ts new file mode 100644 index 000000000..ceb79df73 --- /dev/null +++ b/e2e/modules-off.spec.ts @@ -0,0 +1,169 @@ +/** + * A module switched off disappears everywhere, and a page of it answers with + * the notice instead of errors. + * + * One journey on its own account (`E2E_MODULES`): mood and medications are + * switched on, the surfaces they own are proven present (the positive control + * every "is gone" assertion below leans on), then both are switched off + * through the real module route and the same surfaces are proven gone: + * + * - the Mood nav entry, + * - the Mood and Medications pills in the Insights tab strip, + * - the mood slot in the Insights trends row (the row fills from the next + * metric instead), + * - Mood and Medication in the add menu, + * - `/insights/mood` renders the module notice, not the page, + * + * and no chart on the overview paints its error state. + * + * Every assertion is addressed to a stable `data-*` attribute, never to copy. + * The journey moves its account's module switches, so it runs in one project + * and serially (see `playwright.config.ts`). + */ +import type { Page } from "@playwright/test"; + +import { MODULES_STORAGE_STATE_PATH } from "./setup/global-setup"; +import { modulesSeedState } from "./setup/modules-fixture"; +import { expect, test } from "./setup/test"; + +test.use({ storageState: MODULES_STORAGE_STATE_PATH }); +test.describe.configure({ mode: "serial" }); + +/** A same-origin JSON write from the page, so the session cookie rides along. */ +async function send( + page: Page, + method: "POST" | "PATCH", + path: string, + body: unknown, +): Promise { + return page.evaluate( + async ({ method, path, body }) => { + const res = await fetch(path, { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return res.status; + }, + { method, path, body }, + ); +} + +async function setModules(page: Page, on: boolean): Promise { + expect( + await send(page, "PATCH", "/api/auth/me/modules", { + mood: on, + medications: on, + }), + ).toBe(200); +} + +/** One mood entry and one medication, created once, through the app. */ +async function seed(page: Page): Promise { + const state = await modulesSeedState(); + if (state.moodEntries === 0) { + expect( + await send(page, "POST", "/api/mood-entries", { + mood: "GUT", + moodLoggedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + }), + ).toBe(201); + } + if (state.medications === 0) { + expect( + await send(page, "POST", "/api/medications", { + name: "Module journey", + dose: "5 mg", + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }), + ).toBe(201); + } +} + +const moodPill = '[data-slot="insights-tab-strip-pill"][href="/insights/mood"]'; +const medsPill = + '[data-slot="insights-tab-strip-pill"][href="/insights/medications"]'; +const trendCard = (metric: string) => + `[data-slot="trends-row-card"][data-metric="${metric}"]`; + +async function openCapturePicker(page: Page): Promise { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/"); + const capture = page.locator('[data-testid="bottom-nav-capture"]'); + await expect(capture).toBeVisible({ timeout: 15_000 }); + await capture.click(); + await expect( + page.locator('[data-testid="capture-picker-measurement"]'), + ).toBeVisible(); +} + +test("mood and medications, switched off, leave every surface they own", async ({ + page, +}) => { + test.setTimeout(120_000); + await page.goto("/"); + await setModules(page, true); + await seed(page); + + // ── Positive control: with both modules on, each surface is there. ── + await page.goto("/insights"); + await expect(page.locator(moodPill)).toBeVisible({ timeout: 20_000 }); + await expect(page.locator(medsPill)).toBeVisible(); + await expect(page.locator(trendCard("mood"))).toHaveCount(1); + await expect(page.locator('[data-tour-id="nav-mood"]')).toHaveCount(1); + + await openCapturePicker(page); + await expect(page.locator('[data-testid="capture-picker-mood"]')).toHaveCount( + 1, + ); + await expect( + page.locator('[data-testid="capture-picker-medication"]'), + ).toHaveCount(1); + await page.setViewportSize({ width: 1280, height: 720 }); + + // ── Switch both off through the real route. ── + await setModules(page, false); + + // Insights overview: no Mood or Medications pill, no mood trend slot, the + // row still fills three slots from the next metric, and nothing errors. + await page.goto("/insights"); + await expect( + page.locator('[data-slot="insights-tab-strip-pill"][href="/insights"]'), + ).toBeVisible({ timeout: 20_000 }); + await expect(page.locator('[data-slot="trends-row"]')).toBeVisible(); + await expect(page.locator(moodPill)).toHaveCount(0); + await expect(page.locator(medsPill)).toHaveCount(0); + await expect(page.locator(trendCard("mood"))).toHaveCount(0); + await expect(page.locator(trendCard("pulse"))).toHaveCount(1); + await expect(page.locator('[data-slot="chart-error-state"]')).toHaveCount(0); + + // The nav entry is gone; Insights itself is not a module's and stays. + await expect(page.locator('[data-tour-id="nav-mood"]')).toHaveCount(0); + await expect(page.locator('[data-tour-id="nav-insights"]')).toHaveCount(1); + + // A direct visit: the notice, named for mood and for the record's own + // switch (so it offers Settings), at the same URL, never the page. + await page.goto("/insights/mood"); + await expect(page).toHaveURL(/\/insights\/mood$/); + const notice = page.locator( + '[data-slot="module-disabled-notice"][data-module="mood"]', + ); + await expect(notice).toBeVisible({ timeout: 15_000 }); + await expect(notice).toHaveAttribute("data-module-access", "disabled"); + await expect( + page.locator('[data-slot="module-off-open-settings"]'), + ).toBeVisible(); + await expect(page.locator('[data-slot="chart-error-state"]')).toHaveCount(0); + + // The add menu offers a measurement and nothing the modules owned. + await openCapturePicker(page); + await expect(page.locator('[data-testid="capture-picker-mood"]')).toHaveCount( + 0, + ); + await expect( + page.locator('[data-testid="capture-picker-medication"]'), + ).toHaveCount(0); + + // Leave the account as the next run expects to find it. + await setModules(page, true); +}); diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 63dad8a79..50791808c 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -145,6 +145,26 @@ export const E2E_MEDICATION = { role: "USER", } as const; +/** + * The module-surfaces journey's own account (`modules-off.spec.ts`). + * + * It switches mood and medications off and on again, and those switches are + * read by every surface of the account. On the shared account a parallel spec + * reading a mood chart or the add menu would see them flip underneath it, so + * the journey gets a record nobody else reads. + */ +export const E2E_MODULES = { + email: "e2e-modules@healthlog.test", + username: "e2e-modules", + password: "Qm4!Vr8tK2wZp7Ls", + role: "USER", +} as const; + +export const MODULES_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateModules.json", +); + /** * The notification-dispatch journey's own account. * @@ -724,6 +744,28 @@ export default async function globalSetup(config: FullConfig): Promise { ], ); + // The module-surfaces journey's account. Its module switches are set by + // `e2e/setup/modules-fixture.ts` before every test. + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true) + 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`, + [ + cuid(), + E2E_MODULES.username, + E2E_MODULES.email, + await hashPassword(E2E_MODULES.password), + now, + ], + ); + // The notification-dispatch journey's account. Seeded like the others; // its channels, devices, ledger rows and preferences are reset by // `e2e/setup/notification-fixture.ts` before every test, because the @@ -1257,6 +1299,9 @@ export default async function globalSetup(config: FullConfig): Promise { // login — see `E2E_NOTIFY` for why the ledger counts need one. await capture(E2E_NOTIFY, NOTIFY_STORAGE_STATE_PATH); + // The module-surfaces journey's jar — see `E2E_MODULES`. + await capture(E2E_MODULES, MODULES_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); diff --git a/e2e/setup/modules-fixture.ts b/e2e/setup/modules-fixture.ts new file mode 100644 index 000000000..7dfb8aedf --- /dev/null +++ b/e2e/setup/modules-fixture.ts @@ -0,0 +1,51 @@ +/** + * Fixture for the module-surfaces journey (`e2e/modules-off.spec.ts`). + * + * The journey runs on its own account (`E2E_MODULES`). This reads what that + * account holds straight from Postgres; every WRITE goes through the app + * (the module switch and the seed rows), so the caches the app keeps are + * evicted by the app's own paths rather than read stale. + */ +import pg from "pg"; + +import { E2E_MODULES } from "./global-setup"; + +/** Rows the journey needs so the surfaces it hides would otherwise paint. */ +export interface ModulesSeedState { + moodEntries: number; + medications: number; +} + +export async function modulesSeedState(): Promise { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("[modules-fixture] DATABASE_URL is not set"); + const pool = new pg.Pool({ connectionString: url }); + try { + const user = await pool.query<{ id: string }>( + "SELECT id FROM users WHERE username = $1", + [E2E_MODULES.username], + ); + const userId = user.rows[0]?.id; + if (!userId) { + throw new Error( + "[modules-fixture] modules account not seeded — global-setup must run first", + ); + } + const [mood, meds] = await Promise.all([ + pool.query<{ n: string }>( + "SELECT count(*)::text AS n FROM mood_entries WHERE user_id = $1", + [userId], + ), + pool.query<{ n: string }>( + "SELECT count(*)::text AS n FROM medications WHERE user_id = $1 AND active = true", + [userId], + ), + ]); + return { + moodEntries: Number(mood.rows[0]?.n ?? 0), + medications: Number(meds.rows[0]?.n ?? 0), + }; + } finally { + await pool.end(); + } +} diff --git a/playwright.config.ts b/playwright.config.ts index 3d4e78b96..57cb11be7 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -246,6 +246,11 @@ export default defineConfig({ // share the five-per-fifteen-minutes budget on `auth:register`, so // the second one meets a rate limit rather than the form. "invite-registration.spec.ts", + // The module-surfaces journey switches its account's mood and + // medications modules off and on; a second project would flip them + // under the first mid-assertion. It sets the mobile width itself for + // the add menu, so it runs in one project. + "modules-off.spec.ts", ], use: { // Pixel 5 — Chromium-based mobile profile so CI only needs