diff --git a/src/components/layout/AppHeader/UserActions.tsx b/src/components/layout/AppHeader/UserActions.tsx index 51a19123..b023b989 100644 --- a/src/components/layout/AppHeader/UserActions.tsx +++ b/src/components/layout/AppHeader/UserActions.tsx @@ -40,6 +40,7 @@ export function UserActions({ ); diff --git a/tests/README.md b/tests/README.md index 655957ae..50f91a01 100644 --- a/tests/README.md +++ b/tests/README.md @@ -75,9 +75,20 @@ The tests use the following environment variables: - `TEST_SUPABASE_URL`: Local Supabase URL (default: `http://localhost:54321`) - `TEST_SUPABASE_ANON_KEY`: Local Supabase anon key -- `PLAYWRIGHT_BASE_URL`: App base URL (default: `http://localhost:5173`) -- `TEST_USER_EMAIL`: Test user email (default: `test@example.com`) -- `TEST_USER_PASSWORD`: Test user password (default: `testpassword123`) +- `PLAYWRIGHT_BASE_URL`: App base URL (default: `http://localhost:8080`) +- `TEST_USER_EMAIL`: Base email used for the passwordless test voter (default: `e2e-voter`) +- `TEST_USER_EMAIL_DOMAIN`: Domain appended to the base email (default: `example.com`) +- `TEST_MAILPIT_URL`: Local Supabase's Mailpit inbox API, used to read OTP codes (default: `http://127.0.0.1:54324`) + +### Authentication in tests + +The app only supports passwordless sign-in (magic link + 6-digit OTP), so +`TestHelpers.signIn()` drives the real flow end-to-end: it submits an email, +polls the local Supabase stack's Mailpit inbox (`TEST_MAILPIT_URL`) for the +OTP email, extracts the code, and completes it. Each call defaults to a +per-process email (`generateTestEmail()`) so parallel workers never race over +the same Mailpit inbox or OTP code. New accounts get the onboarding dialog's +username step auto-completed so it doesn't block later interactions. ### Test Data @@ -154,11 +165,16 @@ await testHelpers.takeScreenshot("test-name"); - Filtering functionality - Artist detail navigation - Empty state handling +4. **Voting** (`voting.spec.ts`) + - Casting each vote type (Must Go / Interested / Won't Go) and reflecting the selection + - Votes persisting across a reload + - Changing a vote updates the selection and vote counts instead of adding a second vote + - Removing a vote returns the set to the unvoted state + - Unauthenticated vote attempts surface the sign-in prompt instead of recording a vote ### Planned Tests - User registration -- Voting functionality - Group management - Schedule viewing - Admin features diff --git a/tests/config/test-env.ts b/tests/config/test-env.ts index 17698335..e4331b86 100644 --- a/tests/config/test-env.ts +++ b/tests/config/test-env.ts @@ -1,6 +1,10 @@ // Test environment configuration export const TEST_CONFIG = { - // Test user credentials - TEST_USER_EMAIL: process.env.TEST_USER_EMAIL || "test@example.com", - TEST_USER_PASSWORD: process.env.TEST_USER_PASSWORD || "testpassword123", + // Base email for the passwordless test voter. Each signIn() call appends a + // per-worker suffix so parallel workers never race over the same OTP inbox. + TEST_USER_EMAIL_BASE: process.env.TEST_USER_EMAIL || "e2e-voter", + TEST_USER_EMAIL_DOMAIN: process.env.TEST_USER_EMAIL_DOMAIN || "example.com", + // Mailpit is the local Supabase stack's email testing service. Its REST + // API is used to read the OTP delivered to the test voter's inbox. + MAILPIT_URL: process.env.TEST_MAILPIT_URL || "http://127.0.0.1:54324", }; diff --git a/tests/e2e/voting.spec.ts b/tests/e2e/voting.spec.ts new file mode 100644 index 00000000..8b6dcb20 --- /dev/null +++ b/tests/e2e/voting.spec.ts @@ -0,0 +1,168 @@ +import { + test, + expect, + type BrowserContext, + type Locator, + type Page, +} from "@playwright/test"; +import { TestHelpers } from "../utils/test-helpers"; + +// Seeded via supabase/seed.sql: "Test festival" edition "Boom Festival 2025". +const EDITION_SETS_PATH = "/festivals/test/editions/2025/sets"; + +// Each scenario below targets a distinct seeded set so that parallel runs +// (and parallel Playwright projects) never race over the same votes row. +test.describe("Voting on a set", () => { + let context: BrowserContext; + let page: Page; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + await new TestHelpers(page).signIn(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test("casts a Must Go vote and reflects the selected state", async () => { + const setCard = await goToSet(page, "Maya Jane Coles"); + const mustGo = voteButton(setCard, "mustGo"); + + await mustGo.click(); + + await expect(mustGo).toHaveAttribute("aria-pressed", "true"); + await expect(voteButton(setCard, "interested")).toHaveAttribute( + "aria-pressed", + "false", + ); + await expect(voteButton(setCard, "wontGo")).toHaveAttribute( + "aria-pressed", + "false", + ); + }); + + test("casts an Interested vote and reflects the selected state", async () => { + const setCard = await goToSet(page, "Ben Böhmer"); + const interested = voteButton(setCard, "interested"); + + await interested.click(); + + await expect(interested).toHaveAttribute("aria-pressed", "true"); + }); + + test("casts a Won't Go vote and reflects the selected state", async () => { + const setCard = await goToSet(page, "Kiara Scuro"); + const wontGo = voteButton(setCard, "wontGo"); + + await wontGo.click(); + + await expect(wontGo).toHaveAttribute("aria-pressed", "true"); + }); + + test("persists a vote across a reload", async () => { + const setCard = await goToSet(page, "Nils Frahm"); + const mustGo = voteButton(setCard, "mustGo"); + + await mustGo.click(); + await expect(mustGo).toHaveAttribute("aria-pressed", "true"); + + await page.reload(); + await page.waitForLoadState("networkidle"); + + const setCardAfterReload = page + .getByTestId("artist-item") + .filter({ hasText: "Nils Frahm" }); + await expect(voteButton(setCardAfterReload, "mustGo")).toHaveAttribute( + "aria-pressed", + "true", + ); + }); + + test("changes a vote from one type to another instead of adding a second vote", async () => { + const setCard = await goToSet(page, "Charlotte de Witte"); + const mustGo = voteButton(setCard, "mustGo"); + const interested = voteButton(setCard, "interested"); + const mustGoCount = voteCount(setCard, "mustGo"); + const interestedCount = voteCount(setCard, "interested"); + + await mustGo.click(); + await expect(mustGo).toHaveAttribute("aria-pressed", "true"); + + const mustGoCountAfterFirstVote = await readCount(mustGoCount); + const interestedCountBeforeSwitch = await readCount(interestedCount); + + await interested.click(); + + await expect(interested).toHaveAttribute("aria-pressed", "true"); + await expect(mustGo).toHaveAttribute("aria-pressed", "false"); + await expect(mustGoCount).toHaveText(String(mustGoCountAfterFirstVote - 1)); + await expect(interestedCount).toHaveText( + String(interestedCountBeforeSwitch + 1), + ); + }); + + test("removes a vote and returns the set to the unvoted state", async () => { + const setCard = await goToSet(page, "Four Tet"); + const mustGo = voteButton(setCard, "mustGo"); + const mustGoCount = voteCount(setCard, "mustGo"); + + await mustGo.click(); + await expect(mustGo).toHaveAttribute("aria-pressed", "true"); + const countAfterVote = await readCount(mustGoCount); + + await mustGo.click(); + + await expect(mustGo).toHaveAttribute("aria-pressed", "false"); + await expect(mustGoCount).toHaveText(String(countAfterVote - 1)); + }); +}); + +test.describe("Voting without authentication", () => { + test("an unauthenticated vote attempt surfaces the sign-in prompt", async ({ + page, + }) => { + const testHelpers = new TestHelpers(page); + await testHelpers.navigateTo(EDITION_SETS_PATH); + + const setCard = page + .getByTestId("artist-item") + .filter({ hasText: "Moderat" }); + const mustGo = voteButton(setCard, "mustGo"); + + await mustGo.click(); + + await expect(page.getByRole("dialog")).toBeVisible(); + await expect(mustGo).toHaveAttribute("aria-pressed", "false"); + }); +}); + +async function goToSet(page: Page, setName: string): Promise { + await page.goto(EDITION_SETS_PATH); + await page.waitForLoadState("networkidle"); + + const setCard = page.getByTestId("artist-item").filter({ hasText: setName }); + await expect(setCard).toBeVisible(); + return setCard; +} + +function voteButton( + setCard: Locator, + voteType: "mustGo" | "interested" | "wontGo", +): Locator { + return setCard.locator(`[data-testid="vote-button-${voteType}"]:visible`); +} + +function voteCount( + setCard: Locator, + voteType: "mustGo" | "interested" | "wontGo", +): Locator { + return setCard.locator(`[data-testid="vote-count-${voteType}"]:visible`); +} + +async function readCount(locator: Locator): Promise { + const text = (await locator.textContent()) ?? ""; + const match = text.match(/\d+/); + return match ? parseInt(match[0], 10) : 0; +} diff --git a/tests/utils/test-helpers.ts b/tests/utils/test-helpers.ts index 7e2a2ee0..356969f4 100644 --- a/tests/utils/test-helpers.ts +++ b/tests/utils/test-helpers.ts @@ -1,77 +1,86 @@ import { Page, expect } from "@playwright/test"; import { TEST_CONFIG } from "../config/test-env"; +interface MailpitMessageSummary { + ID: string; + To: Array<{ Address: string }>; +} + +interface MailpitMessage { + Text?: string; + HTML?: string; +} + export class TestHelpers { constructor(private page: Page) {} /** - * Sign in with test credentials + * Sign in via the app's real magic-link + OTP flow, reading the one-time + * code from the local Supabase stack's Mailpit inbox. Completes onboarding + * (username step) for brand-new voters so it doesn't block later + * interactions. Returns the email that was signed in. */ - async signIn( - email = TEST_CONFIG.TEST_USER_EMAIL, - password = TEST_CONFIG.TEST_USER_PASSWORD, - ) { + async signIn(email = generateTestEmail()) { await this.page.goto("/"); - // Click sign in button - const signInButton = this.page - .getByRole("button", { name: /sign in/i }) - .or(this.page.getByRole("link", { name: /sign in/i })) - .or(this.page.getByText(/sign in/i)); - - await signInButton.click(); + await this.page.getByTestId("sign-in-button").click(); - // Wait for auth dialog const authDialog = this.page.getByRole("dialog"); await expect(authDialog).toBeVisible(); - // Fill in credentials - const emailInput = this.page - .getByLabel(/email/i) - .or(this.page.getByPlaceholder(/email/i)); - const passwordInput = this.page - .getByLabel(/password/i) - .or(this.page.getByPlaceholder(/password/i)); - - await emailInput.fill(email); - await passwordInput.fill(password); - - // Submit form - const submitButton = this.page - .getByRole("button", { name: /sign in/i }) - .or(this.page.getByRole("button", { name: /login/i })); - - await submitButton.click(); - - // Wait for successful sign in (user menu or avatar should appear) - await expect( - this.page - .getByRole("button", { name: /user/i }) - .or(this.page.locator('[data-testid="user-avatar"]')), - ).toBeVisible({ timeout: 10000 }); + await this.page.getByLabel(/^email$/i).fill(email); + await this.page.getByRole("button", { name: /send magic link/i }).click(); + + const otp = await fetchOtpCode(email); + + await this.page.locator("input[data-input-otp]").fill(otp); + await this.page.getByRole("button", { name: /verify code/i }).click(); + + await expect(this.page.getByTestId("user-menu-trigger")).toBeVisible({ + timeout: 15000, + }); + + await this.completeOnboardingIfPresent(); + + return email; + } + + /** + * Fills in and submits the username step of the onboarding dialog (shown + * for brand-new accounts) and skips the rest, so it stops blocking the + * page. No-ops if onboarding isn't showing. + */ + async completeOnboardingIfPresent() { + const onboardingDialog = this.page.getByRole("dialog", { + name: /welcome to upline/i, + }); + + const appeared = await onboardingDialog + .waitFor({ state: "visible", timeout: 5000 }) + .then(() => true) + .catch(() => false); + + if (!appeared) return; + + await this.page.getByLabel(/username/i).fill(`e2e-voter-${Date.now()}`); + await this.page.getByRole("button", { name: /continue/i }).click(); + + await this.page.getByRole("button", { name: /^skip$/i }).click(); + await expect(onboardingDialog).not.toBeVisible(); } /** * Sign out */ async signOut() { - const userMenu = this.page - .getByRole("button", { name: /user/i }) - .or(this.page.locator('[data-testid="user-avatar"]')); + const userMenu = this.page.getByTestId("user-menu-trigger"); if (await userMenu.isVisible()) { await userMenu.click(); - const signOutButton = this.page - .getByRole("button", { name: /sign out/i }) - .or(this.page.getByText(/sign out/i)); - - await signOutButton.click(); + await this.page.getByRole("menuitem", { name: /sign out/i }).click(); - // Wait for sign out to complete - await expect( - this.page.getByRole("button", { name: /sign in/i }), - ).toBeVisible(); + await expect(this.page.getByTestId("sign-in-button")).toBeVisible(); } } @@ -86,11 +95,7 @@ export class TestHelpers { * Check if user is authenticated */ async isAuthenticated(): Promise { - const userMenu = this.page - .getByRole("button", { name: /user/i }) - .or(this.page.locator('[data-testid="user-avatar"]')); - - return await userMenu.isVisible(); + return await this.page.getByTestId("user-menu-trigger").isVisible(); } /** @@ -115,3 +120,67 @@ export class TestHelpers { await this.page.screenshot({ path: `tests/screenshots/${name}.png` }); } } + +/** + * Builds an email unique to this worker so parallel test runs never share + * (and race over) the same Mailpit inbox or the same underlying auth user. + */ +export function generateTestEmail(suffix: string | number = process.pid) { + return `${TEST_CONFIG.TEST_USER_EMAIL_BASE}-${suffix}@${TEST_CONFIG.TEST_USER_EMAIL_DOMAIN}`; +} + +/** + * Polls the local Supabase stack's Mailpit inbox for the most recent email + * sent to `email` and extracts the 6-digit OTP from its body. + */ +async function fetchOtpCode(email: string): Promise { + const deadline = Date.now() + 20000; + + while (Date.now() < deadline) { + const summary = await findLatestMessageTo(email); + + if (summary) { + const code = await extractOtpFromMessage(summary.ID); + if (code) return code; + } + + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + throw new Error(`Timed out waiting for an OTP email to ${email} in Mailpit`); +} + +async function findLatestMessageTo( + email: string, +): Promise { + const response = await fetch( + `${TEST_CONFIG.MAILPIT_URL}/api/v1/messages?limit=50`, + ); + + if (!response.ok) return undefined; + + const data = (await response.json()) as { messages: MailpitMessageSummary[] }; + + return data.messages?.find((message) => + message.To?.some((to) => to.Address.toLowerCase() === email.toLowerCase()), + ); +} + +async function extractOtpFromMessage( + messageId: string, +): Promise { + const response = await fetch( + `${TEST_CONFIG.MAILPIT_URL}/api/v1/message/${messageId}`, + ); + + if (!response.ok) return undefined; + + const message = (await response.json()) as MailpitMessage; + const body = message.Text || message.HTML || ""; + + const otpMatch = body.match(/otp-code[^>]*>\s*(\d{6})/i); + if (otpMatch) return otpMatch[1]; + + const genericMatch = body.match(/\b(\d{6})\b/); + return genericMatch?.[1]; +}