File
Blob: tests/e2e/specs/invite-flow.spec.ts
| 1 | import { readFileSync } from "node:fs"; |
| 2 | |
| 3 | import { expect, test } from "@playwright/test"; |
| 4 | |
| 5 | import { E2E_CONTEXT_PATH_ENV, type PersistedE2eContext } from "../global-setup"; |
| 6 | |
| 7 | const loadContext = (): PersistedE2eContext => { |
| 8 | const path = process.env[E2E_CONTEXT_PATH_ENV]; |
| 9 | if (!path) throw new Error(`${E2E_CONTEXT_PATH_ENV} is not set; global-setup didn't run.`); |
| 10 | return JSON.parse(readFileSync(path, "utf8")) as PersistedE2eContext; |
| 11 | }; |
| 12 | |
| 13 | const loadSetupState = () => ({ invite: loadContext().invite }); |
| 14 | |
| 15 | test.describe("invite → signup → sign-out → sign-in", () => { |
| 16 | // Sign-out lands on the landing page; the header pill flip is the |
| 17 | // confirmation. There's no dedicated /sign-out route any more. |
| 18 | test("walks a new user through the invite-driven signup and back through sign-in", async ({ page }) => { |
| 19 | const { invite } = loadSetupState(); |
| 20 | |
| 21 | // 1. Land on the invite acceptance page. Email is locked because the |
| 22 | // invite was minted with a specific address — UI guards against |
| 23 | // sign-up under a different email than the one invited. |
| 24 | await page.goto(`/invite/${encodeURIComponent(invite.token)}`); |
| 25 | await expect(page.getByRole("heading", { name: /accept your invite/i })).toBeVisible(); |
| 26 | |
| 27 | const emailInput = page.getByLabel("Email"); |
| 28 | await expect(emailInput).toHaveValue(invite.email); |
| 29 | await expect(emailInput).toBeDisabled(); |
| 30 | |
| 31 | await page.getByLabel("Name").fill("E2E Tester"); |
| 32 | await page.getByLabel("Password").fill(invite.password); |
| 33 | |
| 34 | // Turnstile test keys (1x00…AA) always pass, but the widget still has |
| 35 | // to mount before the submit button becomes enabled. Wait on the |
| 36 | // button instead of polling the iframe state. |
| 37 | const submit = page.getByRole("button", { name: /accept invite/i }); |
| 38 | await expect(submit).toBeEnabled({ timeout: 15_000 }); |
| 39 | await submit.click(); |
| 40 | |
| 41 | // 2. Successful acceptance lands on /account. invite-accept calls |
| 42 | // `session.refetch()` before navigating so the nanostore is fresh — |
| 43 | // don't poll networkidle here, Turnstile keeps that signal busy. |
| 44 | await page.waitForURL(/\/account$/); |
| 45 | await expect(page.getByRole("heading", { name: /your account/i })).toBeVisible({ timeout: 15_000 }); |
| 46 | await expect(page.getByText(invite.email)).toBeVisible(); |
| 47 | |
| 48 | // 3. Sign out via the header button, then verify we're not signed in. |
| 49 | // The control is a <button> in header.tsx (not a link), and the |
| 50 | // label sits in a tooltip / aria-label rather than visible text on |
| 51 | // narrow viewports — match by accessible name to cover both modes. |
| 52 | const [signOutResp] = await Promise.all([ |
| 53 | page.waitForResponse((r) => r.url().includes("/api/auth/sign-out")), |
| 54 | page.getByRole("button", { name: /sign out/i }).click(), |
| 55 | ]); |
| 56 | expect(signOutResp.status()).toBe(200); |
| 57 | // After sign-out we land on the landing page (no dedicated /sign-out |
| 58 | // route — the header pill flip is the confirmation). |
| 59 | await page.waitForURL((url) => url.pathname === "/"); |
| 60 | // The session cookie must be gone after Max-Age=0 takes effect; if it |
| 61 | // isn't, the next page.goto won't render the sign-in form. |
| 62 | const cookiesAfterSignOut = await page.context().cookies(); |
| 63 | const lingering = cookiesAfterSignOut.find((c) => c.name === "better-auth.session_token"); |
| 64 | expect(lingering).toBeUndefined(); |
| 65 | await expect(page.getByRole("link", { name: /sign in/i }).first()).toBeVisible(); |
| 66 | |
| 67 | // 4. Re-login through /sign-in with the credentials we just set up. |
| 68 | await page.goto("/sign-in"); |
| 69 | await page.getByLabel("Email").fill(invite.email); |
| 70 | await page.getByLabel("Password").fill(invite.password); |
| 71 | |
| 72 | const signIn = page.getByRole("button", { name: /sign in/i }); |
| 73 | await expect(signIn).toBeEnabled({ timeout: 15_000 }); |
| 74 | const [signInResp] = await Promise.all([ |
| 75 | page.waitForResponse((r) => r.url().includes("/api/sign-in")), |
| 76 | signIn.click(), |
| 77 | ]); |
| 78 | expect(signInResp.status()).toBe(200); |
| 79 | |
| 80 | // 5. Lands signed-in — account page is reachable and shows the email |
| 81 | // we registered with. |
| 82 | await page.waitForURL(/\/account$/); |
| 83 | await expect(page.getByText(invite.email)).toBeVisible(); |
| 84 | }); |
| 85 | }); |