Files
HealthLog/e2e/setup-flow-connect.spec.ts
T
Marc-André Bombeck dfa5b9a5e0 test(e2e): the connect journey asserts after the status resolves
No journey reached the connect arm: bp answers `manual`, visit answers
`file`, and the child path ends at confirm. The a11y sweep opened the screen
and asserted the connect card was visible — which it was, for the few hundred
milliseconds before the status envelope resolved, so the sweep passed through
the defect rather than catching it.

The new journey answers Q4 with a wearable and asserts only after
`GET /api/integrations/status` has settled: nothing is claimed for an account
with no connection and no result is stamped on its behalf, and a connection
seeded as delivering is acknowledged rather than offered. The a11y sweep now
waits for the tile the screen settled on instead of the frame before it.
2026-09-11 09:31:59 +02:00

178 lines
6.3 KiB
TypeScript

/**
* v1.38.19 (wave B / C1, C2) — the setup flow, path 5: a wearable on Q4.
*
* The connect arm is the only place in the flow that renders a claim about
* real account state, and until this file no journey reached it: bp answers
* `manual`, visit answers `file`, and the child path ends at confirm. The
* a11y sweep does open the screen, but asserted the connect card was visible
* — which it was, for the few hundred milliseconds before the status query
* resolved. So the sweep passed THROUGH the defect it should have caught.
*
* Both cases here assert AFTER `GET /api/integrations/status` has settled,
* which is the only moment at which the screen's claim means anything.
*
* Runs in one project (see `playwright.config.ts`): it resets and mutates its
* own account.
*/
import pg from "pg";
import {
E2E_SETUP_CONNECT,
SETUP_CONNECT_STORAGE_STATE_PATH,
} from "./setup/global-setup";
import { resetSetupFlow } from "./setup/setup-flow-fixture";
import type { Page, Response } from "@playwright/test";
import { expect, test } from "./setup/test";
import { answer, expectScreen, readMe } from "./setup-flow-helpers";
function pool(): pg.Pool {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("[setup-flow-connect] DATABASE_URL is not set");
return new pg.Pool({ connectionString: url });
}
/**
* Put the account's Oura back to "never connected". The journey's second case
* writes a token column and a ledger row, neither of which `resetSetupFlow`
* knows about — it resets the FLOW, and a leftover connection would make the
* first case assert against the second case's state.
*/
async function clearOura(): Promise<void> {
const db = pool();
try {
const { rows } = await db.query<{ id: string }>(
`SELECT id FROM users WHERE username = $1`,
[E2E_SETUP_CONNECT.username],
);
const id = rows[0]?.id;
if (!id) throw new Error("[setup-flow-connect] no account");
await db.query(
`UPDATE users SET oura_access_token_encrypted = NULL WHERE id = $1`,
[id],
);
await db.query(
`DELETE FROM integration_statuses WHERE user_id = $1 AND integration = 'oura'`,
[id],
);
} finally {
await db.end();
}
}
/**
* A connection that has been delivering for an hour: a stored token (the
* envelope reads `connected` off the column's presence alone) and a ledger
* row whose last success is recent enough for the `fresh` verdict.
*/
async function seedDeliveringOura(): Promise<void> {
const db = pool();
try {
const { rows } = await db.query<{ id: string }>(
`SELECT id FROM users WHERE username = $1`,
[E2E_SETUP_CONNECT.username],
);
const id = rows[0]?.id;
if (!id) throw new Error("[setup-flow-connect] no account");
await db.query(
`UPDATE users SET oura_access_token_encrypted = 'e2e-placeholder' WHERE id = $1`,
[id],
);
await db.query(
`INSERT INTO integration_statuses
(id, user_id, integration, state, last_success_at, last_attempt_at, created_at, updated_at)
VALUES ($1, $2, 'oura', 'connected', NOW(), NOW(), NOW(), NOW())
ON CONFLICT (user_id, integration) DO UPDATE
SET state = 'connected',
last_success_at = NOW(),
last_attempt_at = NOW(),
updated_at = NOW()`,
[`e2e-connect-oura-${Date.now()}`, id],
);
} finally {
await db.end();
}
}
/** Walk the questions through the API, then open the screen under test. */
async function reachFirstResult(page: Page): Promise<void> {
await answer(page, { step: "who", recordTarget: "me" });
await answer(page, { step: "areas", areas: ["sleep"] });
await answer(page, { step: "medication", medication: "no" });
await answer(page, { step: "sources", sources: ["oura"] });
await answer(page, { step: "visit", visit: "no" });
const settled = page.waitForResponse(
(res: Response) =>
res.url().includes("/api/integrations/status") && res.status() === 200,
);
await page.goto("/onboarding/confirm");
await expectScreen(page, "confirm");
await page.locator('[data-slot="onboarding-next"]').click();
await expectScreen(page, "first-result");
// Every assertion below is about what the screen says once it KNOWS.
await settled;
}
test.describe("setup flow — a wearable on Q4", () => {
test.use({ storageState: SETUP_CONNECT_STORAGE_STATE_PATH });
test.beforeEach(async () => {
await resetSetupFlow(E2E_SETUP_CONNECT.username);
await clearOura();
});
test.afterAll(async () => {
await clearOura();
});
test("never claims a connection the account does not have", async ({
page,
}) => {
test.setTimeout(90_000);
await page.goto("/onboarding");
await reachFirstResult(page);
// The tile the screen settled on: either the connect card, or — on an
// instance with no Oura app configured — the credentials note. Never a
// result: nothing was connected.
const tile = page.locator("[data-connect-slot]");
await expect(tile).toBeVisible({ timeout: 15_000 });
const slot = await tile.getAttribute("data-connect-slot");
expect(["connect", "credentials"]).toContain(slot);
await expect(tile).toHaveAttribute("data-connect-state", "disconnected");
await expect(
page.locator('[data-slot="onboarding-first-result-done"]'),
).toHaveCount(0);
// And the ledger was not stamped on the person's behalf.
const me = await readMe(page);
expect(me.onboarding.firstResult?.completedAt ?? null).toBeNull();
});
test("acknowledges a connection that is already delivering", async ({
page,
}) => {
test.setTimeout(90_000);
await seedDeliveringOura();
await page.goto("/onboarding");
await reachFirstResult(page);
const tile = page.locator('[data-connect-slot="result"]');
await expect(tile).toBeVisible({ timeout: 15_000 });
await expect(tile).toHaveAttribute("data-connect-state", "fresh");
// The heading is the acknowledgment, not an offer: the connect CTA that
// used to sit under "Oura verbinden" is gone.
await expect(
page.locator('[data-slot="onboarding-task-connect"]'),
).toHaveCount(0);
// A working connection is a result the flow may record.
await expect
.poll(async () => (await readMe(page)).onboarding.firstResult?.task, {
timeout: 15_000,
})
.toBe("connect-source");
});
});