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").
194 lines
7.2 KiB
JavaScript
194 lines
7.2 KiB
JavaScript
import { test, expect } from '@playwright/test'
|
|
|
|
test.describe('Events list page', () => {
|
|
test('events list loads', async ({ page }) => {
|
|
await page.goto('/events')
|
|
await expect(page.locator('h1', { hasText: 'Events' })).toBeVisible()
|
|
})
|
|
|
|
test('filter bar has type filters', async ({ page }) => {
|
|
await page.goto('/events')
|
|
const filterBar = page.locator('.filter-bar')
|
|
await expect(filterBar).toBeVisible()
|
|
|
|
for (const label of ['All', 'Workshops', 'Community', 'Social', 'Showcase']) {
|
|
await expect(filterBar.locator('button', { hasText: label })).toBeVisible()
|
|
}
|
|
})
|
|
|
|
test('past events toggle exists and can be checked', async ({ page }) => {
|
|
await page.goto('/events')
|
|
await page.waitForLoadState('networkidle')
|
|
const toggle = page.locator('.past-toggle')
|
|
await expect(toggle).toBeVisible()
|
|
await expect(toggle).toContainText('Show past events')
|
|
|
|
await toggle.click()
|
|
await expect(toggle).toHaveClass(/active/)
|
|
|
|
// Page should still render without errors after toggling
|
|
await expect(page.locator('h1', { hasText: 'Events' })).toBeVisible()
|
|
})
|
|
|
|
test('clicking a filter button activates it', async ({ page }) => {
|
|
await page.goto('/events')
|
|
await page.waitForLoadState('networkidle')
|
|
// Wait for Vue hydration — the "All" filter should have the active class once reactive
|
|
const allBtn = page.locator('.filter-btn', { hasText: 'All' })
|
|
await expect(allBtn).toHaveClass(/active/, { timeout: 10000 })
|
|
const workshopsBtn = page.locator('.filter-bar button', { hasText: 'Workshops' })
|
|
await workshopsBtn.click()
|
|
await expect(workshopsBtn).toHaveClass(/active/, { timeout: 5000 })
|
|
})
|
|
|
|
test('event links navigate to detail page', async ({ page }) => {
|
|
await page.goto('/events')
|
|
|
|
// Check the past events toggle so we see all events
|
|
await page.locator('.past-toggle').click()
|
|
|
|
const eventLinks = page.locator('.event-row a')
|
|
const count = await eventLinks.count()
|
|
|
|
if (count === 0) {
|
|
// No events in the database — just verify the empty state renders
|
|
await expect(page.locator('.empty', { hasText: 'No events found' })).toBeVisible()
|
|
return
|
|
}
|
|
|
|
// Click the first event link and verify navigation
|
|
const firstLink = eventLinks.first()
|
|
const href = await firstLink.getAttribute('href')
|
|
await firstLink.click()
|
|
await page.waitForURL(/\/events\//)
|
|
|
|
expect(page.url()).toContain('/events/')
|
|
// Detail page should have an h1 with the event title
|
|
await expect(page.locator('h1')).toBeVisible()
|
|
})
|
|
})
|
|
|
|
async function navigateToFirstEventDetail(page) {
|
|
await page.goto('/events')
|
|
await page.locator('.past-toggle').click()
|
|
await page.waitForLoadState('networkidle')
|
|
const eventLinks = page.locator('.event-row a')
|
|
const count = await eventLinks.count()
|
|
if (count === 0) return null
|
|
const href = await eventLinks.first().getAttribute('href')
|
|
return href
|
|
}
|
|
|
|
test.describe('Event detail — ticket gating', () => {
|
|
test('series-pass-required shows pass-required notice instead of buy button', async ({ page }) => {
|
|
const href = await navigateToFirstEventDetail(page)
|
|
test.skip(!href, 'No events in dev DB to navigate against')
|
|
|
|
await page.route('**/api/events/*/tickets/available**', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
available: false,
|
|
reason: 'series_pass_required',
|
|
requiresSeriesPass: true,
|
|
series: { id: 'series-stub', slug: 'series-stub', title: 'Stub Series' }
|
|
})
|
|
})
|
|
})
|
|
await page.route('**/api/events/*/check-series-access**', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ requiresSeriesPass: false })
|
|
})
|
|
})
|
|
|
|
await page.locator(`.event-row a[href="${href}"]`).first().click()
|
|
await page.waitForURL(`**${href}`)
|
|
|
|
const ticketPanel = page.locator('.event-ticket-purchase')
|
|
await expect(ticketPanel.locator('.ticket-status', { hasText: 'Series Pass Required' })).toBeVisible()
|
|
await expect(ticketPanel.locator('button', { hasText: /Pay |Register for this event|Complete Registration/ })).toHaveCount(0)
|
|
await expect(ticketPanel.locator('a[href="/series/series-stub"] button')).toBeVisible()
|
|
})
|
|
|
|
test('memberSavings line is hidden for anonymous viewers', async ({ page }) => {
|
|
const href = await navigateToFirstEventDetail(page)
|
|
test.skip(!href, 'No events in dev DB to navigate against')
|
|
|
|
await page.route('**/api/events/*/tickets/available**', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
available: true,
|
|
alreadyRegistered: false,
|
|
isFree: false,
|
|
isMember: false,
|
|
name: 'General Admission',
|
|
formattedPrice: '$25.00',
|
|
remaining: 10,
|
|
memberSavings: 0,
|
|
publicTicket: null
|
|
})
|
|
})
|
|
})
|
|
|
|
await page.locator(`.event-row a[href="${href}"]`).first().click()
|
|
await page.waitForURL(`**${href}`)
|
|
|
|
const ticketCard = page.locator('.ticket-card')
|
|
await expect(ticketCard).toBeVisible()
|
|
await expect(page.locator('.ticket-savings')).toHaveCount(0)
|
|
await expect(page.locator('text=/save .* as a member/i')).toHaveCount(0)
|
|
})
|
|
|
|
test('memberSavings line is shown when API reports savings', async ({ page }) => {
|
|
const href = await navigateToFirstEventDetail(page)
|
|
test.skip(!href, 'No events in dev DB to navigate against')
|
|
|
|
await page.route('**/api/events/*/tickets/available**', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
available: true,
|
|
alreadyRegistered: false,
|
|
isFree: false,
|
|
isMember: true,
|
|
name: 'Member Ticket',
|
|
formattedPrice: '$10.00',
|
|
remaining: 10,
|
|
memberSavings: 15,
|
|
publicTicket: { formattedPrice: '$25.00' }
|
|
})
|
|
})
|
|
})
|
|
|
|
await page.locator(`.event-row a[href="${href}"]`).first().click()
|
|
await page.waitForURL(`**${href}`)
|
|
|
|
const savings = page.locator('.ticket-savings')
|
|
await expect(savings).toBeVisible()
|
|
await expect(savings).toContainText(/save/i)
|
|
})
|
|
|
|
test.skip('hidden event returns 404', async () => {
|
|
// Skipped: hidden-event gating happens during SSR useFetch in [slug].vue,
|
|
// which page.route cannot intercept. Verifying this gate requires either
|
|
// seeding a hidden event in the dev DB or a server-side mock layer.
|
|
})
|
|
|
|
test.skip('past-deadline event shows registration-closed copy', async () => {
|
|
// Skipped: when the available endpoint returns reason
|
|
// "Registration deadline has passed", the current UI surfaces it as the
|
|
// generic "Event Sold Out" panel — there is no distinct "Registration
|
|
// closed" string to assert against without changing the component.
|
|
})
|
|
|
|
test.skip('member with paid registration cannot self-cancel', async () => {
|
|
// Skipped: requires seeding an authed member with a paid registration in
|
|
// the DB, which is out of scope for API-level mocking.
|
|
})
|
|
})
|