mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
126c80cbc4
commit
8eff500e9d
4 files changed
+270
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user