New specs (4):
- accept-invite: pre-registrant flow happy path + cadence/preset UX
- admin-pre-registrants: list, filter, action gating, redirect
- admin-series: list, create, edit (delete skipped — button no-ops)
- admin-site-content: list whitelist, edit + roundtrip on /
Extended specs (6):
- join-flow: cadence ×12 math, guidance label, paid-tier success
- events: series-pass-required, member-savings gating
- admin-events: full CRUD via /admin/events/create?edit=<id>
- admin-members: add-member submit, status select, detail nav
- a11y: add /accept-invite, /member/account, /board, /admin/pre-registrants
- wave-slack-onboarding: 9 of 16 scaffold tests now passing
Cross-file isolation hardening:
- admin-events CRUD: refresh auth cookie (auth.spec.js logout test
bumps tokenVersion on the shared admin), wait for hydration
before form fill, search by unique title to dodge pagination.
- board: switch memberPage from shared admin to dedicated seeded
member to avoid the same tokenVersion race.
- wave-slack §6.4: create dedicated test member, filter by email
before clicking, removing the "first row" anchor.
Also fixed board heading drift ("Board" → "Bulletin Board").
170 lines
6.5 KiB
JavaScript
170 lines
6.5 KiB
JavaScript
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 () => {})
|
|
})
|