test(e2e): expand coverage and harden cross-file isolation

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").
This commit is contained in:
Jennie Robinson Faber 2026-04-30 22:26:11 +01:00
parent 03dfdab20e
commit 8dd55ccc09
11 changed files with 1077 additions and 89 deletions

View file

@ -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/<slug-or-id>),
// 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 })
})
})