mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
The coach positive control navigates to the dashboard to prove the launcher renders for a delegate in their own record. It left the browser there, and the next step clicks Accept, which lives on the access page. The click waited out its timeout against a page that does not carry the button. An assertion inserted mid-journey has to restore the state it borrowed.
433 lines
19 KiB
TypeScript
433 lines
19 KiB
TypeScript
/**
|
|
* Account sharing, end to end: invite → accept → switch → banner → revoke.
|
|
*
|
|
* Two browser contexts, two real accounts, one real database. The owner
|
|
* invites, the delegate accepts and opens the record, and the owner takes it
|
|
* back — each step through the shipped UI rather than through the API, because
|
|
* the half of this feature that can go wrong is the half a person touches.
|
|
*
|
|
* ## The stale-paint check
|
|
*
|
|
* The assertion this spec exists for is a NEGATIVE one, and it is deliberately
|
|
* not "the new record's data arrives". A check that waits for the new data
|
|
* proves nothing about what was on screen while it was arriving, and the
|
|
* failure being guarded against is exactly that in-between frame: a dashboard
|
|
* hydrated from the previous record's IndexedDB snapshot, painted under a
|
|
* banner naming somebody else.
|
|
*
|
|
* So an init script installs a MutationObserver before any page script runs,
|
|
* on every navigation including the full reload the switch performs, and it
|
|
* latches the moment the delegate's own marker weight appears in the document.
|
|
* The spec then reads the latch. Nothing about it is sampled or timed.
|
|
*
|
|
* The observer's positive control runs first: with the same script installed,
|
|
* the delegate's own measurements page must trip the latch. Without that, "the
|
|
* marker never appeared" and "the observer never worked" are the same sentence.
|
|
*
|
|
* ### How much this assertion can be made to fail, measured rather than assumed
|
|
*
|
|
* Three controls stand behind it, and they were removed one at a time and
|
|
* together against a real build to see which of them this spec can actually
|
|
* detect. The honest answer is: none of them individually, and that is worth
|
|
* writing down rather than leaving as an implication.
|
|
*
|
|
* - Delete the switch-time cache wipe, the persister's restore guard and the
|
|
* record-scoped query hash — all three at once — and this spec stays
|
|
* GREEN. The reason is a rule that predates this feature: the restore
|
|
* refuses to clobber a query the live cache already holds, and the
|
|
* dashboard's own query is created during render, before any effect runs.
|
|
* So the IndexedDB snapshot never reaches the screen on this route at all.
|
|
* - Delete the full reload and the spec goes RED, but on the banner line
|
|
* rather than the marker line: without a reload the account payload never
|
|
* re-resolves and the banner never paints, so the run stops before the
|
|
* latch is read.
|
|
*
|
|
* What that means, plainly: this spec is a regression net over the whole
|
|
* journey, not a proof of any single control. The controls are proven where
|
|
* they can be broken and observed — `query-persister-shared-record.test.ts`
|
|
* for the two disk guards, `record-scope.test.ts` for the cache dimension —
|
|
* and each of those was verified red before being kept.
|
|
*
|
|
* ## What this spec cannot cover, stated rather than implied
|
|
*
|
|
* - **The service worker.** The Playwright context runs with
|
|
* `serviceWorkers: "block"` (see `playwright.config.ts`), so the worker's
|
|
* `healthlog-data-*` cache — the other layer that survives a reload — is not
|
|
* exercised here at all. The switch wipes it through the same helper the
|
|
* IndexedDB snapshot goes through, and that helper is covered in
|
|
* `src/lib/pwa/__tests__/query-persister-shared-record.test.ts`.
|
|
* - **The owner's data appearing.** No route declares the delegable mode yet,
|
|
* so every read inside a shared record is refused by design. That is the
|
|
* current, correct state of the tree; the pages a delegate can open show
|
|
* their refusal state and, crucially, still show nothing of the delegate's
|
|
* own. The positive half of that assertion belongs with the route migration.
|
|
*/
|
|
import type { BrowserContext, Page } from "@playwright/test";
|
|
|
|
import { expect, test } from "./setup/test";
|
|
import {
|
|
DELEGATE_STORAGE_STATE_PATH,
|
|
E2E_DELEGATE_MARKER_KG,
|
|
E2E_OWNER,
|
|
E2E_USER,
|
|
OWNER_STORAGE_STATE_PATH,
|
|
} from "./setup/test-helpers";
|
|
|
|
const DELEGATE_MARKER = String(E2E_DELEGATE_MARKER_KG);
|
|
|
|
/**
|
|
* Latch the first appearance of a string anywhere in the document, from before
|
|
* the first script of every page load.
|
|
*
|
|
* A MutationObserver rather than a poll: a poll samples, and the frame this is
|
|
* looking for can be one paint long. `characterData` is on because a hydration
|
|
* pass can fill an existing text node in place rather than inserting one.
|
|
*/
|
|
async function watchForText(page: Page, needle: string): Promise<void> {
|
|
await page.addInitScript((text: string) => {
|
|
const w = window as unknown as { __sawText?: boolean };
|
|
w.__sawText = false;
|
|
const seen = () => document.body?.innerText?.includes(text) ?? false;
|
|
const check = () => {
|
|
if (seen()) w.__sawText = true;
|
|
};
|
|
const start = () => {
|
|
check();
|
|
new MutationObserver(check).observe(document.documentElement, {
|
|
childList: true,
|
|
subtree: true,
|
|
characterData: true,
|
|
});
|
|
};
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", start);
|
|
} else {
|
|
start();
|
|
}
|
|
}, needle);
|
|
}
|
|
|
|
async function sawText(page: Page): Promise<boolean> {
|
|
return page.evaluate(
|
|
() => (window as unknown as { __sawText?: boolean }).__sawText === true,
|
|
);
|
|
}
|
|
|
|
/** Open the user menu and the switcher inside it. */
|
|
async function openSwitcher(page: Page): Promise<void> {
|
|
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();
|
|
}
|
|
|
|
test.describe("account sharing", () => {
|
|
// One journey, in order: each step is the precondition of the next, so
|
|
// splitting them into independent tests would mean re-running the invitation
|
|
// three times against a rate-limited endpoint.
|
|
test.describe.configure({ mode: "serial" });
|
|
|
|
// The `page` fixture throughout is the DELEGATE. The owner gets an explicit
|
|
// context below, because a grant needs two signed-in sides at once and the
|
|
// login endpoint is IP-rate-limited.
|
|
//
|
|
// The delegate's jar is this journey's OWN, not the shared one every other
|
|
// authenticated spec reads, and that is load-bearing rather than tidy. The
|
|
// switch is stamped on the session row (see "the nav offers only what
|
|
// sharing covers" below, which relies on exactly that), so switching the
|
|
// shared jar would switch it for every spec holding the same cookie — and
|
|
// the suite runs fully parallel. The damage lands somewhere else entirely:
|
|
// a settings surface painting "Not part of shared access", an axe scan
|
|
// reading a banner its spec never asked for. Same account, different
|
|
// session, no reach outside this file.
|
|
test.use({ storageState: DELEGATE_STORAGE_STATE_PATH });
|
|
|
|
// Desktop only, and for a reason that is about the fixture rather than the
|
|
// layout: this journey mutates shared rows — one grant between two seeded
|
|
// accounts — so two projects running it at once race each other, and the
|
|
// second one's invitation is refused as a duplicate of the first's. Running
|
|
// it once keeps every step's precondition true. The mobile bar reads the
|
|
// same destination model as the sidebar, and that agreement is a unit
|
|
// contract (`nav-model-shared-record.test.ts`) rather than something two
|
|
// browser runs of the same script would establish.
|
|
test.beforeEach(({}, testInfo) => {
|
|
test.skip(
|
|
testInfo.project.name !== "chromium-desktop",
|
|
"shared-fixture journey — runs once",
|
|
);
|
|
});
|
|
|
|
let ownerContext: BrowserContext;
|
|
let ownerPage: Page;
|
|
|
|
test.beforeAll(async ({ browser }) => {
|
|
ownerContext = await browser.newContext({
|
|
storageState: OWNER_STORAGE_STATE_PATH,
|
|
});
|
|
ownerPage = await ownerContext.newPage();
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
await ownerContext.close();
|
|
});
|
|
|
|
/** A `YYYY-MM-DD` day the date input accepts, N days from now. */
|
|
function isoDayFromNow(days: number): string {
|
|
const d = new Date();
|
|
d.setDate(d.getDate() + days);
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
}
|
|
|
|
test("the owner invites, and the invitation confers nothing yet", async () => {
|
|
await ownerPage.goto("/settings/access");
|
|
|
|
// The submit button is disabled until the controlled input holds a value,
|
|
// so a `fill()` that lands before React has attached its listeners leaves
|
|
// the form looking filled and behaving empty. Retry the pair until the
|
|
// button agrees, rather than waiting a fixed time and hoping.
|
|
const identifier = ownerPage.locator(
|
|
'[data-slot="grant-invite-identifier"]',
|
|
);
|
|
const submit = ownerPage.locator('[data-slot="grant-invite-submit"]');
|
|
await expect(async () => {
|
|
await identifier.fill(E2E_USER.username);
|
|
await expect(submit).toBeEnabled({ timeout: 1000 });
|
|
}).toPass({ timeout: 15_000 });
|
|
|
|
// The lapse date, through the real form and onto the real wire.
|
|
//
|
|
// `expiresAt` was accepted, stored and annotated on by the route from the
|
|
// day the grant model shipped, while the card sent a hardcoded `null`. The
|
|
// component test cannot see that: it renders the control and checks the
|
|
// date mapping, and BOTH still pass with the card wired back to `null`.
|
|
// Only reading the posted body proves the value a person typed is the
|
|
// value the server receives, which is the half that was missing.
|
|
const lapse = ownerPage.locator('[data-slot="grant-invite-expires"]');
|
|
await expect(lapse).toBeVisible();
|
|
const chosenDay = isoDayFromNow(30);
|
|
await lapse.fill(chosenDay);
|
|
|
|
// The level, through the real control and onto the real wire.
|
|
//
|
|
// Same class of gap as the lapse date above, and the reason it is checked
|
|
// here rather than in a component test is worth stating: the project's
|
|
// component convention is SSR-only, so no test under `src/` can run this
|
|
// form's submit path at all. Wire the radio back to a constant and every
|
|
// one of those stays green. Only the posted body can tell the level a
|
|
// person picked from the level the card sends.
|
|
const writeOption = ownerPage.locator(
|
|
'[data-slot="grant-invite-access-option"][data-access="WRITE"]',
|
|
);
|
|
await expect(writeOption).toBeVisible();
|
|
await writeOption.click();
|
|
await expect(writeOption).toHaveAttribute("data-selected", "true");
|
|
|
|
const invitePost = ownerPage.waitForRequest(
|
|
(req) =>
|
|
req.method() === "POST" && req.url().endsWith("/api/account/grants"),
|
|
);
|
|
await submit.click();
|
|
const posted = JSON.parse((await invitePost).postData() ?? "{}") as {
|
|
expiresAt?: string | null;
|
|
access?: string;
|
|
};
|
|
expect(
|
|
posted.expiresAt,
|
|
"the invitation must carry the day the owner chose",
|
|
).toBeTruthy();
|
|
// End of the chosen day, not the midnight that starts it.
|
|
expect(posted.expiresAt).toContain(chosenDay.slice(0, 8));
|
|
expect(
|
|
posted.access,
|
|
"the invitation must carry the level the owner chose",
|
|
).toBe("WRITE");
|
|
|
|
const row = ownerPage
|
|
.locator('[data-slot="grants-given-list"] [data-slot="grant-row"]')
|
|
.first();
|
|
await expect(row).toBeVisible();
|
|
// Pending, not active. The state is the server's answer, rendered.
|
|
await expect(row).toHaveAttribute("data-grant-state", "PENDING");
|
|
// And the level came back from the database on the list read, which is the
|
|
// far end of the same wire: form → POST → row → GET → attribute.
|
|
await expect(row).toHaveAttribute("data-grant-access", "WRITE");
|
|
});
|
|
|
|
test("the delegate is offered nothing until they accept", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/settings/access");
|
|
|
|
const row = page
|
|
.locator('[data-slot="grants-received-list"] [data-slot="grant-row"]')
|
|
.first();
|
|
await expect(row).toBeVisible();
|
|
await expect(row).toHaveAttribute("data-grant-state", "PENDING");
|
|
// The delegate's half of the consent: what they are being offered, and
|
|
// what accepting it will mean, on the screen where they accept it.
|
|
await expect(row).toHaveAttribute("data-grant-access", "WRITE");
|
|
await expect(
|
|
row.locator('[data-slot="grant-write-consent"]'),
|
|
).toBeVisible();
|
|
|
|
// No switcher yet: `accountAccess.canSwitch` is false while the only
|
|
// grant is pending, and the menu binds that boolean.
|
|
await page.getByRole("button", { name: "User menu" }).first().click();
|
|
await expect(
|
|
page.locator('[data-slot="account-switcher-trigger"]'),
|
|
).toHaveCount(0);
|
|
await page.keyboard.press("Escape");
|
|
|
|
// The positive control for the shell-door assertion further down: the
|
|
// delegate, in their OWN record, is offered the Coach launcher like anyone
|
|
// else. Without this line the later "it is gone" check would also pass on
|
|
// a build where the launcher had stopped rendering for everybody.
|
|
await page.goto("/");
|
|
await expect(page.locator('[data-slot="coach-fab"]')).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
|
|
// Back to where the rest of this journey happens. The check above leaves
|
|
// the browser on the dashboard, and Accept lives on the access page.
|
|
await page.goto("/settings/access");
|
|
await expect(row).toBeVisible();
|
|
|
|
await page.locator('[data-slot="grant-accept"]').click();
|
|
await expect(row).toHaveAttribute("data-grant-state", "ACTIVE");
|
|
});
|
|
|
|
test("the switcher appears once the grant is live", async ({ page }) => {
|
|
await page.goto("/settings/access");
|
|
await openSwitcher(page);
|
|
|
|
const entry = page.locator('[data-slot="account-switcher-entry"]');
|
|
await expect(entry).toHaveCount(1);
|
|
await expect(entry).toContainText(E2E_OWNER.username);
|
|
// The way home is in the same menu as the way in.
|
|
await expect(
|
|
page.locator('[data-slot="account-switcher-own"]'),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("switching shows the banner and never the delegate's own data", async ({
|
|
page,
|
|
}) => {
|
|
await watchForText(page, DELEGATE_MARKER);
|
|
|
|
// Positive control. If the observer cannot see the marker on the page
|
|
// that definitely shows it, its silence later means nothing.
|
|
await page.goto("/measurements");
|
|
await expect(page.getByText(DELEGATE_MARKER).first()).toBeVisible();
|
|
expect(await sawText(page)).toBe(true);
|
|
|
|
// Let the offline persister flush the delegate's own dashboard to
|
|
// IndexedDB (1 s debounce). That snapshot is the stale-paint source this
|
|
// whole assertion is aimed at — without it the check would pass against a
|
|
// disk that was empty anyway.
|
|
await page.goto("/");
|
|
await page.waitForTimeout(2000);
|
|
|
|
await openSwitcher(page);
|
|
await page.locator('[data-slot="account-switcher-entry"]').click();
|
|
|
|
// Wait for CONTENT, and for content the assertion is not about: the
|
|
// banner naming the owner. A skeleton does not satisfy it, and it says
|
|
// nothing about the marker either way.
|
|
const banner = page.locator('[data-slot="shared-record-banner"]');
|
|
await expect(banner).toBeVisible();
|
|
await expect(banner).toContainText(E2E_OWNER.username);
|
|
|
|
// The document showing the banner has never shown the delegate's own
|
|
// number. The latch was armed by that document's own init script, so this
|
|
// covers its whole life rather than the instant it is read. See the
|
|
// header for what this line can and cannot be made to fail on.
|
|
expect(await sawText(page)).toBe(false);
|
|
|
|
// And it stays false across a navigation inside the record.
|
|
await page.goto("/measurements");
|
|
await expect(
|
|
page.locator('[data-slot="shared-record-banner"]'),
|
|
).toBeVisible();
|
|
expect(await sawText(page)).toBe(false);
|
|
});
|
|
|
|
test("the nav offers only what sharing covers", async ({ page }) => {
|
|
// A fresh page fixture, and still inside the record: the switch lives on
|
|
// the session row rather than in the tab, so every tab of that browser is
|
|
// switched at once. That is the property this navigation quietly proves.
|
|
await page.goto("/");
|
|
await expect(
|
|
page.locator('[data-slot="shared-record-banner"]'),
|
|
).toBeVisible();
|
|
|
|
// The record itself is still there…
|
|
await expect(page.locator('a[href="/measurements"]').first()).toBeVisible();
|
|
// …and everything that configures the account around it is not.
|
|
await expect(page.locator('a[href="/settings/account"]')).toHaveCount(0);
|
|
await expect(page.locator('a[href="/coach"]')).toHaveCount(0);
|
|
await expect(page.locator('a[href="/insights"]')).toHaveCount(0);
|
|
|
|
// And neither is the door that stood beside the removed entries. The Coach
|
|
// launcher hangs off the shell rather than off a route, so dropping the
|
|
// nav entry left it floating over every page in the record — one tap from
|
|
// a drawer whose every action resolves `requireAuth()` and refuses.
|
|
await expect(page.locator('[data-slot="coach-fab"]')).toHaveCount(0);
|
|
});
|
|
|
|
test("a surface sharing does not cover says so", async ({ page }) => {
|
|
// Reached by bookmark: the nav entry is gone, the URL still resolves.
|
|
await page.goto("/settings/account");
|
|
await expect(
|
|
page.locator('[data-slot="shared-record-unavailable"]'),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.locator('[data-slot="shared-record-unavailable-leave"]'),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("revoking from the owner's browser drops the delegate out", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await expect(
|
|
page.locator('[data-slot="shared-record-banner"]'),
|
|
).toBeVisible();
|
|
|
|
// The owner ends it from their own session, in their own context.
|
|
await ownerPage.goto("/settings/access");
|
|
await ownerPage.locator('[data-slot="grant-revoke"]').first().click();
|
|
await ownerPage
|
|
.getByRole("button", { name: "End access", exact: true })
|
|
.last()
|
|
.click();
|
|
await expect(
|
|
ownerPage
|
|
.locator('[data-slot="grants-given-list"] [data-slot="grant-row"]')
|
|
.first(),
|
|
).toHaveAttribute("data-grant-state", "REVOKED");
|
|
|
|
// The delegate's next navigation lands back in their own account rather
|
|
// than on a wall of refusals: the revoke cleared their acting session in
|
|
// the same transaction that stamped the row.
|
|
await page.goto("/measurements");
|
|
await expect(
|
|
page.locator('[data-slot="shared-record-banner"]'),
|
|
).toHaveCount(0);
|
|
// Their own record, back — which is also the proof that the page did not
|
|
// simply fail to load.
|
|
await expect(page.getByText(DELEGATE_MARKER).first()).toBeVisible();
|
|
});
|
|
|
|
test("the ended grant stays on the record as history", async () => {
|
|
await ownerPage.goto("/settings/access");
|
|
const row = ownerPage
|
|
.locator('[data-slot="grants-given-list"] [data-slot="grant-row"]')
|
|
.first();
|
|
// Revocation never deletes. "Who had access, from when to when, and who
|
|
// ended it" is the question the panel exists to answer.
|
|
await expect(row).toHaveAttribute("data-grant-state", "REVOKED");
|
|
await expect(row).toContainText("You ended it");
|
|
});
|
|
});
|