diff --git a/e2e/a11y.spec.js b/e2e/a11y.spec.js index a81f901..cbb7caa 100644 --- a/e2e/a11y.spec.js +++ b/e2e/a11y.spec.js @@ -7,16 +7,20 @@ const publicPages = [ { name: "Join", path: "/join" }, { name: "Events", path: "/events" }, { name: "Coming Soon", path: "/coming-soon" }, + { name: "Accept Invite", path: "/accept-invite" }, ]; const memberPages = [ { name: "Member Dashboard", path: "/member/dashboard" }, { name: "Member Profile", path: "/member/profile" }, + { name: "Member Account", path: "/member/account" }, + { name: "Board", path: "/board" }, ]; const adminPages = [ { name: "Admin Members", path: "/admin/members" }, { name: "Admin Events Create", path: "/admin/events/create" }, + { name: "Admin Pre-Registrants", path: "/admin/pre-registrants" }, ]; test.describe("accessibility — public pages", () => { diff --git a/e2e/accept-invite.spec.js b/e2e/accept-invite.spec.js new file mode 100644 index 0000000..c834a70 --- /dev/null +++ b/e2e/accept-invite.spec.js @@ -0,0 +1,170 @@ +import { test, expect } from '@playwright/test' + +const FAKE_TOKEN = 'fake-invite-token-for-e2e' +const FAKE_PREREG_ID = '000000000000000000000001' + +async function mockVerifyOk(page, overrides = {}) { + await page.route('**/api/invite/verify', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + preRegistrationId: FAKE_PREREG_ID, + name: overrides.name ?? 'Pre Registered User', + email: overrides.email ?? `prereg-${Date.now()}@example.com`, + city: overrides.city ?? 'Vancouver, BC', + }), + }) + }) +} + +async function mockAcceptFree(page) { + await page.route('**/api/invite/accept', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + requiresPayment: false, + redirectUrl: '/member/dashboard', + member: { + id: 'mem-1', + email: 'prereg@example.com', + name: 'Pre Registered User', + circle: 'community', + contributionAmount: 0, + status: 'active', + }, + }), + }) + }) + await page.route('**/api/auth/status', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + authenticated: true, + member: { id: 'mem-1', name: 'Pre Registered User', status: 'active' }, + status: 'active', + }), + }) + }) +} + +async function gotoAcceptInvite(page) { + await page.goto(`/accept-invite#${FAKE_TOKEN}`) +} + +test.describe('Accept Invite — pre-registrant signup', () => { + test('verifies invitation and shows form fields', async ({ page }) => { + await mockVerifyOk(page, { name: 'Ada Lovelace', email: 'ada@example.com' }) + await gotoAcceptInvite(page) + + await expect(page.locator('#accept-name')).toBeVisible() + await expect(page.locator('#accept-name')).toHaveValue('Ada Lovelace') + await expect(page.locator('#accept-email')).toHaveValue('ada@example.com') + await expect(page.locator('#circle-community')).toBeAttached() + await expect(page.locator('#circle-founder')).toBeAttached() + await expect(page.locator('#circle-practitioner')).toBeAttached() + await expect(page.locator('#accept-cadence-monthly')).toBeAttached() + await expect(page.locator('#accept-cadence-annual')).toBeAttached() + await expect(page.locator('#accept-contribution')).toBeVisible() + await expect(page.locator('.contribution-preset-chip').first()).toBeVisible() + await expect(page.locator('.form-submit')).toBeVisible() + }) + + test('shows error when no token in URL hash', async ({ page }) => { + await page.goto('/accept-invite') + await expect(page.getByRole('heading', { name: 'Invitation Error' })).toBeVisible() + await expect(page.locator('.error-box')).toContainText(/No invitation token/) + }) + + test('shows error when token verification fails', async ({ page }) => { + await page.route('**/api/invite/verify', async (route) => { + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ statusCode: 401, statusMessage: 'Invalid or expired invitation link' }), + }) + }) + await gotoAcceptInvite(page) + await expect(page.getByRole('heading', { name: 'Invitation Error' })).toBeVisible() + await expect(page.locator('.error-box')).toContainText(/Invalid or expired/) + }) + + test('submit disabled until name + agreement filled', async ({ page }) => { + await mockVerifyOk(page, { name: '' }) + await gotoAcceptInvite(page) + + await expect(page.locator('#accept-name')).toBeVisible() + await expect(page.locator('.form-submit')).toBeDisabled() + + await page.locator('#accept-name').fill('New Member') + await expect(page.locator('.form-submit')).toBeDisabled() + + await page.getByRole('checkbox', { name: /Community Guidelines/ }).check() + await expect(page.locator('.form-submit')).toBeEnabled() + }) + + test('cadence toggle updates billing summary total', async ({ page }) => { + await mockVerifyOk(page) + await gotoAcceptInvite(page) + + await expect(page.locator('#accept-contribution')).toBeVisible() + await page.locator('#accept-contribution').fill('10') + + await page.locator('label[for="accept-cadence-monthly"]').click() + await expect(page.locator('.billing-summary')).toContainText('$10 today') + + await page.locator('label[for="accept-cadence-annual"]').click() + await expect(page.locator('.billing-summary')).toContainText('$120 today') + await expect(page.locator('.billing-summary')).toContainText('$10/month') + }) + + test('preset chip sets contribution amount', async ({ page }) => { + await mockVerifyOk(page) + await gotoAcceptInvite(page) + + await expect(page.locator('.contribution-preset-chip').first()).toBeVisible() + const chip = page.locator('.contribution-preset-chip').nth(1) + const chipText = await chip.textContent() + const expected = chipText.replace(/[^0-9]/g, '') + + await chip.click() + await expect(page.locator('#accept-contribution')).toHaveValue(expected) + }) + + test('free tier happy path shows welcome state', async ({ page }) => { + await mockVerifyOk(page, { name: 'Free Tester', email: `free-${Date.now()}@example.com` }) + await mockAcceptFree(page) + await gotoAcceptInvite(page) + + await expect(page.locator('#accept-name')).toHaveValue('Free Tester') + await page.locator('#circle-community').check({ force: true }) + await page.locator('#accept-contribution').fill('0') + await page.getByRole('checkbox', { name: /Community Guidelines/ }).check() + + await expect(page.locator('.form-submit')).toBeEnabled() + await expect(page.locator('.form-submit')).toContainText(/Accept Invitation/) + + await page.locator('.form-submit').click() + + await expect( + page.getByRole('heading', { name: 'Welcome to Ghost Guild!' }) + ).toBeVisible({ timeout: 15000 }) + }) + + test('paid tier submit button copy switches to Continue to Payment', async ({ page }) => { + await mockVerifyOk(page) + await gotoAcceptInvite(page) + + await page.locator('#accept-contribution').fill('10') + await page.getByRole('checkbox', { name: /Community Guidelines/ }).check() + await expect(page.locator('.form-submit')).toContainText(/Continue to Payment/) + }) + + // Skipped: full paid-tier submission requires intercepting HelcimPay.js modal + // (external script loads an iframe and posts a message back to verifyPayment). + // Feasible but out of scope for this initial coverage pass. + test.skip('paid tier full flow with mocked HelcimPay', async () => {}) +}) diff --git a/e2e/admin-events.spec.js b/e2e/admin-events.spec.js index 0fba9d0..4acd617 100644 --- a/e2e/admin-events.spec.js +++ b/e2e/admin-events.spec.js @@ -53,3 +53,116 @@ test.describe('Admin events access control', () => { expect(page.url()).not.toContain('/admin/events') }) }) + +test.describe('Admin events CRUD', () => { + test('create, edit, and delete an event', async ({ adminPage }) => { + const suffix = Date.now().toString().slice(-6) + const title = `e2e-event-${suffix}` + const editedTitle = `e2e-event-${suffix}-edited` + + // Re-prime the auth cookie immediately before this multi-step flow. + // The shared test-admin account's tokenVersion is bumped whenever + // auth.spec.js's logout test runs in parallel, which would otherwise + // surface mid-flow as "Session has been revoked" on the first POST. + const loginRes = await adminPage.context().request.get('/api/dev/test-login', { maxRedirects: 0 }) + if (loginRes.status() !== 302) { + throw new Error(`Failed to refresh admin session: ${loginRes.status()}`) + } + + // --- Create --- + await adminPage.goto('/admin/events/create') + await expect(adminPage.locator('h1')).toContainText('Create Event') + // Ensure Vue has hydrated (initial $fetch for series/tags has resolved) + // before interacting — under cross-file load, hydration can lag and a + // pre-hydration submit will native-POST against an empty form. + await adminPage.waitForLoadState('networkidle') + + await adminPage + .getByPlaceholder('Enter a clear, descriptive event title') + .fill(title) + + await adminPage + .getByPlaceholder( + 'Provide a clear description of what attendees can expect from this event' + ) + .fill('e2e test event description') + + await adminPage + .getByPlaceholder('e.g., https://zoom.us/j/123... or #channel-name') + .fill('https://example.com/zoom') + + const startInput = adminPage.getByPlaceholder( + "e.g., 'tomorrow at 3pm', 'next Friday at 9am'" + ) + await startInput.fill('next Tuesday at 3pm') + await startInput.blur() + + const endInput = adminPage.getByPlaceholder( + "e.g., 'tomorrow at 5pm', 'next Friday at 11am'" + ) + await endInput.fill('next Tuesday at 5pm') + await endInput.blur() + + await adminPage.getByRole('button', { name: 'Create Event' }).click() + + // The form posts via $fetch and then auto-redirects after a 1.5s setTimeout. + // Under cross-file load that auto-redirect can race against waitForURL. + // Wait for the surfaced success/error state, fail fast on error, then + // navigate explicitly so subsequent assertions are deterministic. + await expect( + adminPage.locator('.success-box').or(adminPage.locator('.error-box')) + ).toBeVisible({ timeout: 15000 }) + await expect(adminPage.locator('.success-box')).toBeVisible() + await adminPage.goto('/admin/events') + await adminPage.waitForLoadState('networkidle') + + // Filter to just our event — orphan rows from prior failed runs can push + // the new row off page 1 of the paginated list. + await adminPage.getByPlaceholder('Search events...').fill(title) + const row = adminPage.locator('tr', { hasText: title }) + await expect(row).toBeVisible({ timeout: 10000 }) + + // --- Edit --- + // Find the event ID from the row's "View" link (href is /events/), + // and use the row's Edit button. Pair the click with waitForURL so we don't + // miss the navigation event under load. + await Promise.all([ + adminPage.waitForURL(/\/admin\/events\/create\?edit=/, { timeout: 15000 }), + row.getByRole('button', { name: 'Edit' }).click(), + ]) + await expect(adminPage.locator('h1')).toContainText('Edit Event') + + const titleInput = adminPage.getByPlaceholder( + 'Enter a clear, descriptive event title' + ) + await titleInput.fill(editedTitle) + + await adminPage.getByRole('button', { name: 'Update Event' }).click() + + await expect( + adminPage.locator('.success-box').or(adminPage.locator('.error-box')) + ).toBeVisible({ timeout: 15000 }) + await expect(adminPage.locator('.success-box')).toBeVisible() + await adminPage.goto('/admin/events') + await adminPage.waitForLoadState('networkidle') + + // Filter to the edited event's unique title for the same pagination reason. + await adminPage.getByPlaceholder('Search events...').fill(editedTitle) + const editedRow = adminPage.locator('tr', { hasText: editedTitle }) + await expect(editedRow).toBeVisible({ timeout: 10000 }) + + // --- Delete (custom modal, not browser dialog) --- + await editedRow.getByRole('button', { name: 'Del' }).click() + await expect( + adminPage.getByRole('heading', { name: 'Delete Event' }) + ).toBeVisible() + await adminPage + .locator('.modal') + .getByRole('button', { name: 'Delete' }) + .click() + + await expect( + adminPage.locator('tr', { hasText: editedTitle }) + ).toHaveCount(0, { timeout: 10000 }) + }) +}) diff --git a/e2e/admin-members.spec.js b/e2e/admin-members.spec.js index 88aedce..e7b11d3 100644 --- a/e2e/admin-members.spec.js +++ b/e2e/admin-members.spec.js @@ -66,4 +66,68 @@ test.describe("Admin members page", () => { adminPage.getByPlaceholder("email@example.com"), ).toBeVisible(); }); + + test("create member, status select reflects STATUS_LABELS, change persists, detail page renders", async ({ adminPage }) => { + const stamp = Date.now(); + const memberName = `E2E Member ${stamp}`; + const memberEmail = `e2e-member-${stamp}@example.test`; + + await adminPage.goto("/admin/members"); + await adminPage.waitForLoadState("networkidle"); + await expect(adminPage.locator("h1")).toHaveText("Members"); + + await adminPage.getByRole("button", { name: "Add Member" }).click(); + await adminPage.getByPlaceholder("Full name").fill(memberName); + await adminPage.getByPlaceholder("email@example.com").fill(memberEmail); + await adminPage.getByRole("button", { name: "Create Member" }).click(); + + // Verify the new member shows up via search + const searchInput = adminPage.getByPlaceholder("Search members..."); + await expect(searchInput).toBeVisible({ timeout: 10000 }); + await searchInput.fill(memberEmail); + + const memberRow = adminPage.locator("tr", { hasText: memberEmail }); + await expect(memberRow).toBeVisible({ timeout: 10000 }); + await expect(memberRow.getByText(memberName)).toBeVisible(); + + // Open the edit modal for this member, where the STATUS_LABELS-driven