diff --git a/e2e/onboarding-ai-offer.spec.ts b/e2e/onboarding-ai-offer.spec.ts new file mode 100644 index 000000000..0e7bb948c --- /dev/null +++ b/e2e/onboarding-ai-offer.spec.ts @@ -0,0 +1,170 @@ +/** + * v1.38.19 (wave D) — the AI offer at the end of the setup flow. + * + * The screen used to promise that insights "work for you right now" the + * moment the operator had configured a key. It never knew whether the key + * answered, and it never mentioned the consent receipt the egress actually + * needs — so the person who believed it met a `consent.ai.required` refusal + * on their first briefing. On 2026-09-11 the operator's own instance was + * answering HTTP 500 from its OAuth proxy while the screen kept promising. + * + * Two runs, one account, and the only difference between them is a row in + * `provider_health`: + * + * a success inside the freshness window → the offer, and one tap grants + * a dead credential inside its cooldown → "not answering", and no button + * + * What is deliberately absent: any call to a provider. The flow's rule holds + * — nothing in it talks to one — and a probe here would bill the operator for + * every registration on the instance. + * + * Serial, and it restores the instance state it seeded: the tri-state folds + * across accounts by design, so its inputs are instance-wide. + */ +import { + E2E_SETUP_AI_OFFER, + SETUP_AI_OFFER_STORAGE_STATE_PATH, +} from "./setup/global-setup"; +import { + clearAiConsent, + clearOperatorProvider, + seedOperatorProvider, + seedSharedProviderResult, +} from "./setup/ai-offer-fixture"; +import { resetSetupFlow } from "./setup/setup-flow-fixture"; +import { expect, test } from "./setup/test"; +import { + answer, + answerEverything, + complete, + expectScreen, +} from "./setup-flow-helpers"; + +const OFFER = '[data-slot="onboarding-ai-offer"]'; +const GRANT = '[data-slot="onboarding-ai-offer-grant"]'; +const UNAVAILABLE = '[data-slot="onboarding-ai-unavailable"]'; +const SHARED_KEY = '[data-slot="onboarding-ai-shared-key"]'; + +/** Walk the account to the done screen through the API, then open it. */ +async function openDoneScreen(page: import("@playwright/test").Page) { + await page.goto("/onboarding"); + await answerEverything(page); + // Q6 applies because the answers name glucose and weight; the flow does not + // complete while a screen it owes is unanswered. + await answer(page, { + step: "units", + units: { glucoseUnit: "mmol/L", unitPreference: "metric" }, + }); + await complete(page); + await answer(page, { step: "first-result", status: "skipped" }); + await page.goto("/onboarding/done"); + await expectScreen(page, "done"); + // The panel itself is unconditional; the variant inside it is not. + await expect(page.locator('[data-slot="onboarding-ai-panel"]')).toBeVisible(); +} + +// One account, one instance-wide ledger. Two workers driving this at once +// would each read the other's seed. +test.describe.configure({ mode: "serial" }); + +test.describe("the setup flow's AI offer follows the shared provider's health", () => { + test.use({ storageState: SETUP_AI_OFFER_STORAGE_STATE_PATH }); + + test.beforeEach(async () => { + await resetSetupFlow(E2E_SETUP_AI_OFFER.username); + await clearAiConsent(E2E_SETUP_AI_OFFER.username); + await seedOperatorProvider(); + }); + + test.afterAll(async () => { + await clearOperatorProvider(); + await clearAiConsent(E2E_SETUP_AI_OFFER.username); + }); + + test("offers one tap when the shared provider worked recently, and the tap leaves a receipt", async ({ + page, + }) => { + test.setTimeout(90_000); + await seedSharedProviderResult(E2E_SETUP_AI_OFFER.username, { + result: "ok", + okMinutesAgo: 30, + }); + + // No receipt before the tap — otherwise the assertion after it proves + // nothing about the button. + const before = await page.request.get( + "/api/consent/ai/latest?kind=ai_full", + ); + expect(before.status()).toBe(200); + expect( + ((await before.json()) as { data: { receipt: unknown } }).data.receipt, + ).toBeNull(); + + await openDoneScreen(page); + + await expect(page.locator(OFFER)).toBeVisible({ timeout: 15_000 }); + await expect(page.locator(UNAVAILABLE)).toHaveCount(0); + + // No provider was called to decide this. The flow reads a projection. + const probes: string[] = []; + page.on("request", (req) => { + const path = new URL(req.url()).pathname; + if (path === "/api/ai/test" || path.startsWith("/api/insights/")) + probes.push(path); + }); + + await page.locator(GRANT).click(); + + // The line the person reads swaps to the granted one. + await expect(page.locator(SHARED_KEY)).toBeVisible({ timeout: 15_000 }); + await expect(page.locator(GRANT)).toHaveCount(0); + expect(probes, "the offer must not trigger a provider call").toEqual([]); + + const after = await page.request.get("/api/consent/ai/latest?kind=ai_full"); + const receipt = ( + (await after.json()) as { + data: { receipt: { kind: string; revokedAt: string | null } | null }; + } + ).data.receipt; + expect(receipt).not.toBeNull(); + expect(receipt!.kind).toBe("ai_full"); + expect(receipt!.revokedAt).toBeNull(); + }); + + test("says the provider is not answering while its credential is benched, and offers no button", async ({ + page, + }) => { + test.setTimeout(90_000); + await seedSharedProviderResult(E2E_SETUP_AI_OFFER.username, { + result: "auth_failed", + }); + + await openDoneScreen(page); + + await expect(page.locator(UNAVAILABLE)).toBeVisible({ timeout: 15_000 }); + await expect(page.locator(GRANT)).toHaveCount(0); + await expect(page.locator(OFFER)).toHaveCount(0); + // The unproven promise is not shown to somebody who has not consented. + await expect(page.locator(SHARED_KEY)).toHaveCount(0); + + // The rest of the panel is untouched: the flow stays complete without it. + await expect( + page.locator('[data-slot="onboarding-ai-keyless"]'), + ).toBeVisible(); + await expect( + page.locator('[data-slot="onboarding-open-dashboard"]'), + ).toBeVisible(); + + // The grant route itself stays open — it is the user's own consent act + // and is reachable from Settings at any time. What this case pins is the + // decision the screen was handed, so read it back from the source. + const status = await page.request.get("/api/user/ai-provider"); + const data = ( + (await status.json()) as { + data: { serverProviderHealth: string; serverProviderOffer: boolean }; + } + ).data; + expect(data.serverProviderHealth).toBe("unhealthy"); + expect(data.serverProviderOffer).toBe(false); + }); +}); diff --git a/e2e/setup/ai-offer-fixture.ts b/e2e/setup/ai-offer-fixture.ts new file mode 100644 index 000000000..a78bf00d9 --- /dev/null +++ b/e2e/setup/ai-offer-fixture.ts @@ -0,0 +1,132 @@ +/** + * v1.38.19 (wave D) — the instance state the shared-provider offer reads. + * + * Two rows decide what the setup flow's last screen may say, and neither has + * a product endpoint that writes them the way this journey needs: + * + * `app_settings.admin_ai_key_encrypted` — presence alone makes the operator + * the origin that would serve a user with no provider of their own + * (`managedBy: "server"`). Nothing decrypts it on this path, so the value + * below is a placeholder ciphertext and not a credential of any kind. + * + * `provider_health` — the ledger the tri-state folds. It is written only as + * a side effect of a real completion, which this suite must never make, so + * the journey states the outcome it wants to reason about instead. + * + * Both are INSTANCE-wide by nature: the projection deliberately folds across + * accounts, because a fresh account's own ledger is empty by construction. + * That is why the spec runs serially and puts both back afterwards. + * + * Seeded through `pg`, the same transport `global-setup.ts` and the other + * fixtures use. + */ +import pg from "pg"; + +/** Not a key. Presence is the only thing read on this path. */ +const PLACEHOLDER_ADMIN_KEY = "e2e-admin-key-presence-only"; + +function connect(): pg.Pool { + const url = process.env.DATABASE_URL; + if (!url) { + throw new Error("[ai-offer-fixture] DATABASE_URL is not set"); + } + return new pg.Pool({ connectionString: url }); +} + +async function userId(pool: pg.Pool, username: string): Promise { + const { rows } = await pool.query<{ id: string }>( + `SELECT id FROM users WHERE username = $1`, + [username], + ); + const id = rows[0]?.id; + if (!id) throw new Error(`[ai-offer-fixture] no account named ${username}`); + return id; +} + +/** The operator has configured a shared provider on this instance. */ +export async function seedOperatorProvider(): Promise { + const pool = connect(); + try { + await pool.query( + `INSERT INTO app_settings (id, admin_ai_key_encrypted) + VALUES ('singleton', $1) + ON CONFLICT (id) DO UPDATE SET admin_ai_key_encrypted = EXCLUDED.admin_ai_key_encrypted`, + [PLACEHOLDER_ADMIN_KEY], + ); + } finally { + await pool.end(); + } +} + +/** Put the instance back: no operator key, no ledger rows for either tag. */ +export async function clearOperatorProvider(): Promise { + const pool = connect(); + try { + await pool.query( + `UPDATE app_settings SET admin_ai_key_encrypted = NULL WHERE id = 'singleton'`, + ); + await pool.query( + `DELETE FROM provider_health + WHERE provider_type IN ('admin-openai', 'admin-codex')`, + ); + } finally { + await pool.end(); + } +} + +/** + * State one outcome for the operator's OpenAI provider. + * + * `ok` — it served somebody `okMinutesAgo` minutes ago. + * `auth_failed` — its credential is dead and benched for another hour, + * which is the shape the operator's own instance was in on + * 2026-09-11 (a 500 from the OAuth proxy, then a 401). + */ +export async function seedSharedProviderResult( + username: string, + outcome: { result: "ok"; okMinutesAgo: number } | { result: "auth_failed" }, +): Promise { + const pool = connect(); + try { + const id = await userId(pool, username); + await pool.query( + `DELETE FROM provider_health + WHERE provider_type IN ('admin-openai', 'admin-codex')`, + ); + if (outcome.result === "ok") { + await pool.query( + `INSERT INTO provider_health + (id, user_id, provider_type, last_result, consecutive_failures, + last_ok_at, updated_at) + VALUES ($1, $2, 'admin-openai', 'ok', 0, + NOW() - ($3 || ' minutes')::interval, NOW())`, + [`e2e-ph-${id}`, id, String(outcome.okMinutesAgo)], + ); + } else { + await pool.query( + `INSERT INTO provider_health + (id, user_id, provider_type, last_result, last_status, + consecutive_failures, last_failure_at, next_retry_at, updated_at) + VALUES ($1, $2, 'admin-openai', 'auth_failed', 401, 1, + NOW(), NOW() + interval '1 hour', NOW())`, + [`e2e-ph-${id}`, id], + ); + } + } finally { + await pool.end(); + } +} + +/** No receipt on file — the state a fresh account reaches the screen in. */ +export async function clearAiConsent(username: string): Promise { + const pool = connect(); + try { + const id = await userId(pool, username); + await pool.query(`DELETE FROM consent_receipts WHERE user_id = $1`, [id]); + await pool.query(`DELETE FROM rate_limits WHERE key = 'consent:' || $1`, [ + id, + ]); + } finally { + await pool.end(); + } +} diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 13bfccc33..c3c47b567 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -487,6 +487,21 @@ export const E2E_SETUP_LOCALE = { role: "USER", } as const; +/** + * v1.38.19 (wave D) — the shared-provider offer's own account. + * + * Its journey seeds INSTANCE-wide state (the operator key's presence and the + * provider-health ledger the tri-state folds) and mints a consent receipt on + * the account, so it cannot share one with a journey that asserts a module + * map or a tile order. + */ +export const E2E_SETUP_AI_OFFER = { + email: "e2e-setup-ai-offer@healthlog.test", + username: "e2e-setup-ai-offer", + password: "Ao6!Zd3fB9qMv2Rk", + role: "USER", +} as const; + export const SETUP_BP_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateSetupBp.json", @@ -511,6 +526,10 @@ export const SETUP_LOCALE_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateSetupLocale.json", ); +export const SETUP_AI_OFFER_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateSetupAiOffer.json", +); const SETUP_FLOW_ACCOUNTS = [ E2E_SETUP_BP, @@ -519,6 +538,7 @@ const SETUP_FLOW_ACCOUNTS = [ E2E_SETUP_CHILD, E2E_SETUP_A11Y, E2E_SETUP_LOCALE, + E2E_SETUP_AI_OFFER, ] as const; async function hashPassword(password: string): Promise { @@ -1261,6 +1281,7 @@ export default async function globalSetup(config: FullConfig): Promise { await capture(E2E_SETUP_VISIT, SETUP_VISIT_STORAGE_STATE_PATH); await capture(E2E_SETUP_A11Y, SETUP_A11Y_STORAGE_STATE_PATH); await capture(E2E_SETUP_LOCALE, SETUP_LOCALE_STORAGE_STATE_PATH); + await capture(E2E_SETUP_AI_OFFER, SETUP_AI_OFFER_STORAGE_STATE_PATH); await capture(E2E_SETUP_CHILD, SETUP_CHILD_STORAGE_STATE_PATH); await pool.query( `UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`,