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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 10:06:14 +02:00
1 parent 126c80cbc4
commit 8eff500e9d
4 files changed
+270

No files matched your search

+169
View File
@@ -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<number> {
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<void> {
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<void> {
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<void> {
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);
});
+45
View File
@@ -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<void> {
],
);
// 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<void> {
// 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);
+51
View File
@@ -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<ModulesSeedState> {
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();
}
}
+5
View File
@@ -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