Files
HealthLog/e2e/v138-managed-record-capabilities.spec.ts
Marc-André Bombeck 4a02b78f5a test(e2e): take the mood empty state's add control in the managed-profile journey
Since an empty mood list carries the one add control and the header drops
its own, the journey clicked a header button that unmounted as the list
resolved. Start the profile without entries, assert the empty-state add,
then the header add once an entry exists. Both carry a data-slot.
2026-10-09 00:41:54 +02:00

256 lines
10 KiB
TypeScript

/**
* v1.38.12 — the controls a guardian sees inside a managed profile (#939).
*
* A person acting on a managed profile holds a MANAGE grant, and the server
* has accepted their mood entries, screeners, visits, allergies and lab edits
* since v1.37.0. The interface withheld every one of those controls anyway,
* because the client answered `canManage: false` for every shared record. It
* answers per section now, from the two lists `GET /api/auth/me` publishes.
*
* Two pages, one each way:
*
* - Mood is a section whose create answers at MANAGE, so the add control
* is there and an entry can be added — asserted on the POST's status, not
* on the paint, because the paint is what lied before.
* - Documents is the section with no delegated write route, so the upload
* control is absent and the page says why, in a sentence, where the
* control would have been.
*
* A third journey reaches the profile's Settings from the navigation (#939).
* The Settings shell has always listed a managed profile's own configuration,
* and the section gate has always admitted it, but no navigation led there:
* the utility entries disappeared for every shared record. It walks the
* desktop sidebar and the mobile user menu to the Modules card.
*
* Modelled on `v137-sharing-managed-profiles.spec.ts`; the switch helpers are
* the same ones, for the same reasons written there.
*/
import type { Page } from "@playwright/test";
import pg from "pg";
import { expect, test } from "./setup/test";
import {
E2E_LEVEL_RECORDS,
SCOPE_CAPABILITIES_STORAGE_STATE_PATH,
} from "./setup/test-helpers";
async function openSwitcher(page: Page) {
await page.getByRole("button", { name: "User menu" }).first().click();
await page.locator('[data-slot="account-switcher-trigger"]').click();
await expect(
page.locator('[data-slot="account-switcher-menu"]'),
).toBeVisible();
}
/**
* Run `act` and wait for the document it replaces to be gone. See the
* v1.37.0 spec for why waiting on the banner alone settles nothing.
*/
async function withDocumentReplacement(
page: Page,
act: () => Promise<void>,
): Promise<void> {
await page.evaluate(() => {
(window as Window & { __hlNavToken?: true }).__hlNavToken = true;
});
await act();
await page.waitForFunction(
() =>
(window as Window & { __hlNavToken?: true }).__hlNavToken === undefined,
undefined,
{ timeout: 30_000 },
);
}
async function openRecord(page: Page, username: string) {
await page.goto("/");
await openSwitcher(page);
const entry = page.locator(
`[data-slot="account-switcher-entry"][data-account-username="${username}"]`,
);
await expect(entry).toHaveCount(1);
await withDocumentReplacement(page, () => entry.click());
await expect(page.locator('[data-slot="shared-record-banner"]')).toBeVisible({
timeout: 30_000,
});
}
/**
* Leave the record and prove the own-record shell is back.
*
* Leaving replaces the document, and the protected shell renders its
* hydration gate — no nav, no banner, no page body — until `/api/auth/me`
* resolves. "The banner is gone" is true of that gate as well, so on its own
* it is satisfied by the document on its way out and is not a wait at all.
* The top bar only exists past the gate, so it is the anchor the absence
* hangs on.
*/
async function leaveRecord(page: Page) {
await withDocumentReplacement(page, () =>
page.locator('[data-slot="shared-record-banner-exit"]').click(),
);
await expect(
page.locator('[data-slot="record-scope-hydration-gate"]'),
).toHaveCount(0, { timeout: 30_000 });
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible({
timeout: 30_000,
});
await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount(
0,
{ timeout: 30_000 },
);
}
test.describe.serial("a guardian's controls inside a managed profile", () => {
// Its own session row: this journey moves the record selector, and the
// scoped-sharing journey drives the same account from another worker.
test.use({ storageState: SCOPE_CAPABILITIES_STORAGE_STATE_PATH });
const managed = E2E_LEVEL_RECORDS.find(
(record) => record.recordKind === "managed",
);
if (!managed) throw new Error("managed record fixture is missing");
test("offers the mood add control and lands the entry", async ({ page }) => {
// The profile starts every run without mood entries. The form stamps an
// entry to the minute and `(user, date, moodLoggedAt)` is unique, so a
// second run inside the same minute (a retry, a repeat) met a 409 for the
// entry the first one wrote. An empty record also fixes which control the
// page offers: the empty state's add, not the header's.
const dbUrl = process.env.DATABASE_URL;
test.skip(!dbUrl, "DATABASE_URL is required to reset the profile's mood");
const pool = new pg.Pool({ connectionString: dbUrl });
try {
await pool.query(
`DELETE FROM mood_entries
WHERE user_id = (SELECT id FROM users WHERE username = $1)`,
[managed.username],
);
} finally {
await pool.end();
}
await openRecord(page, managed.username);
const banner = page.locator('[data-slot="shared-record-banner"]');
await expect(banner).toHaveAttribute("data-record-kind", "managed");
await expect(banner).toHaveAttribute("data-access-level", "manage");
await page.goto("/mood");
// Since v1.42 an empty list carries the one add control in its empty
// state and the header drops its own; the header's returns once there is
// an entry to list. Both are asserted on their data-slots.
const addFirst = page.locator('[data-slot="mood-add-first"]');
const addHeader = page.locator('[data-slot="mood-add-entry"]');
await expect(addFirst).toBeVisible({ timeout: 30_000 });
await expect(addHeader).toHaveCount(0);
await addFirst.click();
const face = page.locator('[data-slot="mood-face"][data-mood="GUT"]');
await expect(face).toBeVisible();
await face.click();
const posted = page.waitForResponse(
(response) =>
response.request().method() === "POST" &&
/\/api\/mood-entries(\?|$)/.test(response.url()),
{ timeout: 30_000 },
);
await page.getByRole("button", { name: "Save", exact: true }).click();
const response = await posted;
// The route, not the paint: a control that renders and 403s is the exact
// failure this release ends.
expect(response.status()).toBe(201);
await expect(page.locator('[data-slot="mood-rows"]').first()).toBeVisible({
timeout: 30_000,
});
await expect(addHeader).toBeVisible();
await expect(addFirst).toHaveCount(0);
await leaveRecord(page);
});
test("reaches the profile's Modules settings from the navigation on both widths", async ({
page,
}) => {
await openRecord(page, managed.username);
const banner = page.locator('[data-slot="shared-record-banner"]');
await expect(banner).toHaveAttribute("data-record-kind", "managed");
await expect(banner).toHaveAttribute("data-access-level", "manage");
const accountId = await banner.getAttribute("data-account-id");
expect(accountId).toBeTruthy();
// The banner renders from the same `/api/auth/me` answer the navigation
// reads, and the shell paints nothing before it resolves. Waiting on it
// above is what makes every presence and absence below a statement about
// the loaded shell rather than about the hydration gate.
const sidebar = page.locator('aside[aria-label="Sidebar"]');
const settings = sidebar.locator('[data-slot="nav-settings-link"]');
await expect(settings).toHaveCount(1);
await expect(settings).toHaveAttribute("href", "/settings/account");
await expect(sidebar.locator('a[href="/notifications"]')).toHaveCount(0);
await settings.click();
await expect(page).toHaveURL(/\/settings\/account$/);
await expect(
page.locator('[data-record-settings-family="profile"]'),
).toHaveAttribute("data-record-id", accountId as string, {
timeout: 30_000,
});
await page
.locator('a[href="/settings/modules"]')
.filter({ visible: true })
.first()
.click();
await expect(page).toHaveURL(/\/settings\/modules$/);
const modules = page.locator(
'[data-record-settings-family="modules"][aria-busy="false"]',
);
await expect(modules).toBeVisible({ timeout: 30_000 });
await expect(modules).toHaveAttribute(
"data-record-id",
accountId as string,
);
// The phone layout: the utilities live in the top-bar user menu there.
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/");
await expect(banner).toBeVisible({ timeout: 30_000 });
const topBar = page.locator('[data-slot="top-bar"]');
await expect(topBar).toBeVisible();
await topBar.getByRole("button", { name: "User menu" }).click();
const menu = page.getByRole("menu");
const menuSettings = menu.locator('[data-slot="nav-settings-link"]');
await expect(menuSettings).toHaveAttribute("href", "/settings/account");
// The menu is open and populated, so this absence is the menu's answer.
await expect(menu.locator('a[href="/notifications"]')).toHaveCount(0);
await menuSettings.click();
await expect(page).toHaveURL(/\/settings\/account$/);
await expect(
page.locator('[data-record-settings-family="profile"]'),
).toHaveAttribute("data-record-id", accountId as string, {
timeout: 30_000,
});
await page.setViewportSize({ width: 1280, height: 720 });
await leaveRecord(page);
});
test("withholds the vault's upload control and says why", async ({
page,
}) => {
await openRecord(page, managed.username);
await page.goto("/documents");
const note = page.locator('[data-slot="documents-owner-only"]');
await expect(note).toBeVisible({ timeout: 30_000 });
await expect(note).toHaveText(
"Documents can only be added by the record owner.",
);
await expect(page.getByRole("button", { name: "Upload" })).toHaveCount(0);
await expect(page.locator('input[type="file"]')).toHaveCount(0);
await leaveRecord(page);
});
});