feat: add testing infrastructure — Vitest, Playwright, CI, git hooks
Some checks are pending
Test / vitest (push) Waiting to run
Test / playwright (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions

Add comprehensive testing covering 420 unit/handler tests across 24 Vitest
files, 9 Playwright E2E specs, accessibility scans, and visual regression.
Includes GitHub Actions CI, Husky pre-push hook, and TESTING.md docs.
This commit is contained in:
Jennie Robinson Faber 2026-04-04 16:07:21 +01:00
parent 036af95e00
commit 1e30ba23cd
35 changed files with 3637 additions and 5 deletions

94
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,94 @@
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
vitest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test:run
playwright:
runs-on: ubuntu-latest
needs: vitest
services:
mongo:
image: mongo:7
ports:
- 27017:27017
env:
MONGODB_URI: mongodb://localhost:27017/ghostguild-test
JWT_SECRET: ci-test-jwt-secret
NUXT_PUBLIC_COMING_SOON: 'false'
NODE_ENV: development
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run build
- name: Start server
run: node .output/server/index.mjs &
env:
PORT: 3000
- name: Wait for server
run: npx wait-on http://localhost:3000 --timeout 30000
- run: npx playwright test --ignore-snapshots
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: |
playwright-report/
e2e/test-results/
retention-days: 7
visual:
runs-on: ubuntu-latest
needs: vitest
continue-on-error: true
services:
mongo:
image: mongo:7
ports:
- 27017:27017
env:
MONGODB_URI: mongodb://localhost:27017/ghostguild-test
JWT_SECRET: ci-test-jwt-secret
NUXT_PUBLIC_COMING_SOON: 'false'
NODE_ENV: development
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run build
- name: Start server
run: node .output/server/index.mjs &
env:
PORT: 3000
- name: Wait for server
run: npx wait-on http://localhost:3000 --timeout 30000
- run: npx playwright test e2e/visual/
- uses: actions/upload-artifact@v4
if: failure()
with:
name: visual-diffs
path: e2e/test-results/
retention-days: 7

4
.gitignore vendored
View file

@ -25,3 +25,7 @@ logs
.env.* .env.*
!.env.example !.env.example
scripts/*.js scripts/*.js
# Playwright
e2e/test-results/
playwright-report/

1
.husky/pre-push Normal file
View file

@ -0,0 +1 @@
npm run test:run

View file

@ -12,11 +12,15 @@ Ghost Guild is a membership community platform for game developers exploring coo
npm run dev # Start dev server at http://localhost:3000 npm run dev # Start dev server at http://localhost:3000
npm run build # Production build npm run build # Production build
npm run preview # Preview production build npm run preview # Preview production build
npm run test:run # Vitest single run (pre-push hook)
npm run test:e2e # Playwright E2E (needs dev server + MongoDB)
npm run test:a11y # Accessibility scans
npm run test:all # Vitest + Playwright
``` ```
**Dev helpers:** `GET /api/dev/test-login` — creates a test admin user and sets auth cookie (dev only, blocked in production). Navigate to this URL to access admin pages during development. **Dev helpers:** `GET /api/dev/test-login` — creates a test admin user and sets auth cookie (dev only, blocked in production). Navigate to this URL to access admin pages during development.
No test framework is currently configured. **Testing:** Vitest for unit/handler tests (`tests/`), Playwright for E2E (`e2e/`). Husky pre-push hook runs Vitest. See `TESTING.md` for details.
## Architecture ## Architecture

90
TESTING.md Normal file
View file

@ -0,0 +1,90 @@
# Testing
## Quick Reference
```bash
npm test # Vitest watch mode
npm run test:run # Vitest single run (used by pre-push hook)
npm run test:e2e # Playwright E2E tests
npm run test:e2e:ui # Playwright with interactive UI
npm run test:a11y # Accessibility scans (axe-core)
npm run test:visual # Visual regression screenshots
npm run test:all # Vitest + Playwright together
```
## Vitest (Unit / Handler Tests)
Tests live in `tests/` mirroring the source structure. Two vitest projects:
- **server** (`tests/server/`) — Node environment, `setup.js` stubs Nitro auto-imports
- **client** (`tests/client/`) — jsdom environment for composables
### Test patterns
**Behavioral tests** mock models/services with `vi.mock()`, import the handler, and call it with `createMockEvent()`:
```js
vi.mock('../../../server/models/member.js', () => ({ default: { findOne: vi.fn() } }))
import handler from '../../../server/api/some/route.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
```
**Source inspection tests** use `readFileSync` to verify structural properties (auth guards, import order) without executing handlers.
### Nitro auto-imports in tests
Handlers use Nitro auto-imports for `requireAuth`, `requireAdmin`, `validateBody`, and schemas. These are stubbed as globals in `tests/server/setup.js`. Individual tests can configure their behavior with `.mockResolvedValue()` etc.
## Playwright (E2E Tests)
Tests live in `e2e/`. Requires a running dev server and MongoDB.
### Auth helpers
`e2e/helpers/auth.js` provides `loginAsAdmin(page)` and `loginAsMember(page, email)` using the dev login endpoints. These set real JWT cookies.
`e2e/helpers/fixtures.js` extends Playwright's `test` with `adminPage` and `memberPage` fixtures.
### Running locally
```bash
# Start dev server (Playwright config does this automatically)
npm run dev
# Run tests
npm run test:e2e
# Interactive mode
npm run test:e2e:ui
```
### Visual regression
Baselines stored in `e2e/__screenshots__/`. Generate or update:
```bash
npm run test:visual:update
```
Note: Linux CI and macOS local produce different renders. Generate baselines in CI with `--update-snapshots`, then commit.
## Pre-Push Hook
Husky runs `npm run test:run` (Vitest only) before `git push`. All mocked, runs in ~1s. Playwright is not included — too slow and requires MongoDB.
## CI (GitHub Actions)
`.github/workflows/test.yml` defines three jobs:
1. **vitest** — runs on every push/PR to main
2. **playwright** — runs after vitest passes, uses MongoDB service container
3. **visual** — runs after vitest, `continue-on-error: true` during stabilization
CI uses dummy secrets (`JWT_SECRET: ci-test-jwt-secret`). No real Helcim/Resend/Slack tokens — those paths are mocked at Vitest level, and E2E skips the Helcim iframe.
## Known Limitations
- **Helcim iframe** — cannot be tested in E2E (cross-origin). Payment flows are tested at the Vitest handler level. E2E tests stop before the iframe opens.
- **Email delivery** — mocked at Vitest level. E2E verifies form submission, not actual email.
- **Slack invitations** — mocked at Vitest level.
- **Visual baseline OS drift** — generate baselines in CI, not locally.

112
e2e/a11y.spec.js Normal file
View file

@ -0,0 +1,112 @@
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
import { loginAsAdmin } from './helpers/auth.js'
const publicPages = [
{ name: 'Home', path: '/' },
{ name: 'Join', path: '/join' },
{ name: 'Events', path: '/events' },
{ name: 'Coming Soon', path: '/coming-soon' },
]
const memberPages = [
{ name: 'Member Dashboard', path: '/member/dashboard' },
{ name: 'Member Profile', path: '/member/profile' },
]
const adminPages = [
{ name: 'Admin Members', path: '/admin/members' },
{ name: 'Admin Events Create', path: '/admin/events/create' },
]
test.describe('accessibility — public pages', () => {
for (const { name, path } of publicPages) {
test(`${name} has no critical a11y violations`, async ({ page }) => {
await page.goto(path)
await page.waitForLoadState('networkidle')
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze()
const critical = results.violations.filter(
(v) => v.impact === 'critical' || v.impact === 'serious'
)
expect(critical, `${name} has critical/serious a11y issues`).toEqual([])
})
}
})
test.describe('accessibility — member pages', () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page)
})
for (const { name, path } of memberPages) {
test(`${name} has no critical a11y violations`, async ({ page }) => {
await page.goto(path)
await page.waitForLoadState('networkidle')
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze()
const critical = results.violations.filter(
(v) => v.impact === 'critical' || v.impact === 'serious'
)
expect(critical, `${name} has critical/serious a11y issues`).toEqual([])
})
}
})
test.describe('accessibility — admin pages', () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page)
})
for (const { name, path } of adminPages) {
test(`${name} has no critical a11y violations`, async ({ page }) => {
await page.goto(path)
await page.waitForLoadState('networkidle')
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze()
const critical = results.violations.filter(
(v) => v.impact === 'critical' || v.impact === 'serious'
)
expect(critical, `${name} has critical/serious a11y issues`).toEqual([])
})
}
})
test.describe('keyboard navigation', () => {
test('tab through join form fields in order', async ({ page }) => {
await page.goto('/join')
await page.waitForLoadState('networkidle')
// Focus the name field and tab through
await page.locator('#join-name').focus()
expect(await page.locator('#join-name').evaluate((el) => el === document.activeElement)).toBe(true)
await page.keyboard.press('Tab')
// Email field should receive focus next
expect(await page.locator('#join-email').evaluate((el) => el === document.activeElement)).toBe(true)
})
test('escape closes login modal', async ({ page }) => {
await page.goto('/member/dashboard')
// Wait for login modal to appear
const modal = page.locator('text=Sign in to continue').or(page.locator('text=Sign in to your dashboard'))
await expect(modal.first()).toBeVisible({ timeout: 10000 })
await page.keyboard.press('Escape')
// Modal should close
await expect(modal.first()).not.toBeVisible({ timeout: 5000 })
})
})

55
e2e/admin-events.spec.js Normal file
View file

@ -0,0 +1,55 @@
import { test, expect } from './helpers/fixtures.js'
test.describe('Admin events list', () => {
test('events list loads for admin', async ({ adminPage }) => {
await adminPage.goto('/admin/events')
await expect(adminPage.locator('h1')).toContainText('Events')
})
test('create event button present', async ({ adminPage }) => {
await adminPage.goto('/admin/events')
await expect(
adminPage.getByRole('link', { name: 'Create Event' })
).toBeVisible()
})
})
test.describe('Admin events create', () => {
test('create event page loads', async ({ adminPage }) => {
await adminPage.goto('/admin/events/create')
await expect(adminPage.locator('h1')).toContainText('Create Event')
})
test('create event form has required fields', async ({ adminPage }) => {
await adminPage.goto('/admin/events/create')
// Title input
await expect(
adminPage.getByPlaceholder('Enter a clear, descriptive event title')
).toBeVisible()
// Description textarea
await expect(
adminPage.getByPlaceholder(
'Provide a clear description of what attendees can expect from this event'
)
).toBeVisible()
// Event type select (USelect renders a button with the selected value)
await expect(adminPage.getByText('Event Type')).toBeVisible()
})
})
test.describe('Admin events access control', () => {
test('non-admin redirect', async ({ page }) => {
await page.goto('/admin/events')
// Admin middleware redirects unauthenticated users to /
await page.waitForURL((url) => !url.pathname.startsWith('/admin'))
expect(page.url()).not.toContain('/admin/events')
})
})

48
e2e/admin-members.spec.js Normal file
View file

@ -0,0 +1,48 @@
import { test, expect } from './helpers/fixtures.js'
test.describe('Admin members page', () => {
test('members list loads for admin', async ({ adminPage }) => {
await adminPage.goto('/admin/members')
await expect(adminPage.locator('h1')).toHaveText('Members')
await expect(adminPage.getByText('Manage members, contributions, and access')).toBeVisible()
})
test('search bar works', async ({ adminPage }) => {
await adminPage.goto('/admin/members')
const searchInput = adminPage.getByPlaceholder('Search members...')
await expect(searchInput).toBeVisible()
await searchInput.fill('nonexistent-query-xyz')
// Page should not crash -- either shows filtered results or the empty state
await expect(
adminPage.locator('table').or(adminPage.getByText('No members found matching your criteria'))
).toBeVisible()
})
test('non-admin redirect', async ({ browser }) => {
const context = await browser.newContext()
const page = await context.newPage()
await page.goto('/admin/members')
// Admin middleware redirects non-admin users to / or /members
await page.waitForURL((url) => !url.pathname.startsWith('/admin'))
expect(page.url()).not.toContain('/admin/members')
await context.close()
})
test('add member button opens modal', async ({ adminPage }) => {
await adminPage.goto('/admin/members')
await adminPage.getByRole('button', { name: 'Add Member' }).click()
// Modal should appear with the form heading and fields
await expect(adminPage.getByText('Add New Member')).toBeVisible()
await expect(adminPage.getByPlaceholder('Full name')).toBeVisible()
await expect(adminPage.getByPlaceholder('email@example.com')).toBeVisible()
})
})

54
e2e/auth.spec.js Normal file
View file

@ -0,0 +1,54 @@
import { test, expect } from '@playwright/test'
import { loginAsAdmin, loginAsMember } from './helpers/auth.js'
test.describe('Authentication flows', () => {
test('protected page shows login modal when logged out', async ({ page }) => {
// Navigate to a protected member page without being logged in
await page.goto('/member/dashboard')
// The auth middleware aborts navigation and shows the login modal
// Look for the modal title and email input
await expect(page.getByText('Sign in to continue')).toBeVisible()
await expect(page.locator('input[type="email"]')).toBeVisible()
await expect(page.getByRole('button', { name: 'Send magic link' })).toBeVisible()
})
test('admin login and redirect', async ({ page }) => {
await loginAsAdmin(page)
// loginAsAdmin waits for /admin URL
await expect(page).toHaveURL(/\/admin/)
// Admin layout should show admin sidebar content
await expect(page.locator('.sidebar-nav').getByText('Members')).toBeVisible()
await expect(page.locator('.admin-tag')).toBeVisible()
})
test('member login and redirect', async ({ page }) => {
await loginAsMember(page, 'test-admin@ghostguild.dev')
// loginAsMember waits for /member/ URL
await expect(page).toHaveURL(/\/member\//)
})
test('logout clears auth', async ({ page }) => {
// Login as admin first
await loginAsAdmin(page)
await expect(page).toHaveURL(/\/admin/)
// Click the "Sign out" link in the sidebar meta area
await page.locator('.sidebar-meta a').filter({ hasText: 'Sign out' }).click()
// Should redirect to home after logout
await page.waitForURL('/')
// Verify the auth-token cookie is cleared
const cookies = await page.context().cookies()
const authCookie = cookies.find((c) => c.name === 'auth-token')
expect(!authCookie || authCookie.value === '').toBeTruthy()
// Navigating to a protected page should show the login modal
await page.goto('/member/dashboard')
await expect(page.getByText('Sign in to continue')).toBeVisible()
})
})

41
e2e/coming-soon.spec.js Normal file
View file

@ -0,0 +1,41 @@
import { test, expect } from '@playwright/test'
test.describe('coming-soon page', () => {
test('renders with heading and login form', async ({ page }) => {
await page.goto('/coming-soon')
await expect(page.locator('h1')).toContainText('Ghost Guild')
await expect(page.locator('input[type="email"]')).toBeVisible()
await expect(page.getByRole('button', { name: 'Send Magic Link' })).toBeVisible()
})
test('shows "Coming Soon" text for unauthenticated visitors', async ({ page }) => {
await page.goto('/coming-soon')
await expect(page.getByText('Coming Soon')).toBeVisible()
})
})
test.describe('public routes accessible when gate is off', () => {
test('home page loads', async ({ page }) => {
await page.goto('/')
// Should not redirect to /coming-soon
expect(page.url()).not.toContain('/coming-soon')
await expect(page.getByText('Ghost Guild')).toBeVisible()
})
test('events page loads', async ({ page }) => {
await page.goto('/events')
expect(page.url()).not.toContain('/coming-soon')
await expect(page.locator('h1')).toContainText('Events')
})
test('join page loads', async ({ page }) => {
await page.goto('/join')
expect(page.url()).not.toContain('/coming-soon')
await expect(page.locator('h1')).toContainText('Join Ghost Guild')
})
})

64
e2e/events.spec.js Normal file
View file

@ -0,0 +1,64 @@
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')
const checkbox = page.locator('input[type="checkbox"]')
await expect(checkbox).toBeVisible()
await expect(page.locator('text=Show past events')).toBeVisible()
await checkbox.check()
await expect(checkbox).toBeChecked()
// 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')
const workshopsBtn = page.locator('.filter-bar button', { hasText: 'Workshops' })
await workshopsBtn.click()
await expect(workshopsBtn).toHaveClass(/active/)
})
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('input[type="checkbox"]').check()
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()
})
})

21
e2e/helpers/auth.js Normal file
View file

@ -0,0 +1,21 @@
/**
* Login helpers using dev endpoints.
* These set real httpOnly JWT cookies so all middleware works naturally.
*/
/**
* Login as admin via the dev test-login endpoint.
* Creates a test admin user if none exists.
*/
export async function loginAsAdmin(page) {
await page.goto('/api/dev/test-login')
await page.waitForURL('**/admin**')
}
/**
* Login as a specific member by email via the dev member-login endpoint.
*/
export async function loginAsMember(page, email) {
await page.goto(`/api/dev/member-login?email=${encodeURIComponent(email)}`)
await page.waitForURL('**/member/**')
}

23
e2e/helpers/fixtures.js Normal file
View file

@ -0,0 +1,23 @@
import { test as base } from '@playwright/test'
import { loginAsAdmin, loginAsMember } from './auth.js'
/**
* Extended test fixtures with pre-authenticated pages.
*/
export const test = base.extend({
adminPage: async ({ page }, use) => {
await loginAsAdmin(page)
await use(page)
},
memberPage: async ({ browser }, use) => {
// Uses a default test member — tests needing a specific member
// should use loginAsMember directly
const context = await browser.newContext()
const page = await context.newPage()
await loginAsMember(page, 'test-admin@ghostguild.dev')
await use(page)
await context.close()
},
})
export { expect } from '@playwright/test'

83
e2e/join-flow.spec.js Normal file
View file

@ -0,0 +1,83 @@
import { test, expect } from '@playwright/test'
test.describe('Join page — member signup flow', () => {
test('join form loads with all fields', async ({ page }) => {
await page.goto('/join')
await expect(page.locator('#join-name')).toBeVisible()
await expect(page.locator('#join-email')).toBeVisible()
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('#join-contribution')).toBeVisible()
await expect(page.locator('.form-submit')).toBeVisible()
})
test('submit button disabled when form incomplete', async ({ page }) => {
await page.goto('/join')
// Clear name and email — circle defaults to community, contribution defaults to $15
await page.locator('#join-name').fill('')
await page.locator('#join-email').fill('')
// Button should be disabled with empty required fields
await expect(page.locator('.form-submit')).toBeDisabled()
// Fill only name — still incomplete
await page.locator('#join-name').fill('Test User')
await expect(page.locator('.form-submit')).toBeDisabled()
// Fill email too — now all fields are populated and button should be enabled
await page.locator('#join-email').fill('incomplete-test@example.com')
await expect(page.locator('.form-submit')).toBeEnabled()
})
test('fill and submit free tier', async ({ page }) => {
const uniqueEmail = `test-e2e-${Date.now()}@example.com`
await page.goto('/join')
await page.locator('#join-name').fill('E2E Test User')
await page.locator('#join-email').fill(uniqueEmail)
await page.locator('#circle-community').check({ force: true })
await page.locator('#join-contribution').selectOption('0')
await expect(page.locator('.form-submit')).toBeEnabled()
await page.locator('.form-submit').click()
// Free tier skips payment (step 2) and goes to confirmation (step 3)
// or redirects to /welcome. Wait for either outcome.
await expect(
page.getByText('Welcome to Ghost Guild!').or(page.locator('.success-box'))
).toBeVisible({ timeout: 15000 })
})
test('duplicate email shows error', async ({ page }) => {
// First submission — create a member
const duplicateEmail = `test-e2e-dup-${Date.now()}@example.com`
await page.goto('/join')
await page.locator('#join-name').fill('Dup Test User')
await page.locator('#join-email').fill(duplicateEmail)
await page.locator('#circle-community').check({ force: true })
await page.locator('#join-contribution').selectOption('0')
await page.locator('.form-submit').click()
// Wait for first submission to succeed
await expect(
page.getByText('Welcome to Ghost Guild!').or(page.locator('.success-box'))
).toBeVisible({ timeout: 15000 })
// Navigate back and try to register the same email again
await page.goto('/join')
await page.locator('#join-name').fill('Dup Test User Again')
await page.locator('#join-email').fill(duplicateEmail)
await page.locator('#circle-community').check({ force: true })
await page.locator('#join-contribution').selectOption('0')
await page.locator('.form-submit').click()
// Should show an error about the email already existing
await expect(page.locator('.error-box')).toBeVisible({ timeout: 10000 })
await expect(page.locator('.error-box')).toContainText(/already/i)
})
})

View file

@ -0,0 +1,35 @@
import { test, expect } from './helpers/fixtures.js'
test.describe('Member dashboard', () => {
test('dashboard loads for authenticated user', async ({ adminPage }) => {
await adminPage.goto('/member/dashboard')
await expect(adminPage.getByText('Welcome back')).toBeVisible({ timeout: 10000 })
})
test('shows navigation links', async ({ adminPage }) => {
await adminPage.goto('/member/dashboard')
// Wait for dashboard content to render
await expect(adminPage.getByText('Welcome back')).toBeVisible({ timeout: 10000 })
// Verify quick action links are present
await expect(adminPage.getByText('Update your profile')).toBeVisible()
await expect(adminPage.getByText('Browse members')).toBeVisible()
await expect(adminPage.getByText('Manage account')).toBeVisible()
})
test('unauthenticated shows sign-in prompt', async ({ browser }) => {
const context = await browser.newContext()
const page = await context.newPage()
await page.goto('/member/dashboard')
// Should show the sign-in required message or a login modal
await expect(
page.getByText('Sign in required').or(page.getByText('Sign in to your dashboard'))
).toBeVisible({ timeout: 10000 })
await context.close()
})
})

View file

@ -0,0 +1,51 @@
import { test, expect } from './helpers/fixtures.js'
test.describe('Member profile page', () => {
test('profile page loads', async ({ adminPage }) => {
await adminPage.goto('/member/profile')
await expect(adminPage.getByText('Edit Profile')).toBeVisible()
await expect(adminPage.getByText('How you appear to other members')).toBeVisible()
})
test('form fields are present', async ({ adminPage }) => {
await adminPage.goto('/member/profile')
// Name input
await expect(adminPage.locator('input[placeholder="Your name"]')).toBeVisible()
// Bio textarea
await expect(adminPage.locator('textarea[placeholder*="Share your background"]')).toBeVisible()
// Save button
await expect(adminPage.getByRole('button', { name: 'Save Profile' })).toBeVisible()
})
test('bio field accepts input', async ({ adminPage }) => {
await adminPage.goto('/member/profile')
const bio = adminPage.locator('textarea[placeholder*="Share your background"]')
const saveBtn = adminPage.getByRole('button', { name: 'Save Profile' })
// Save button should start disabled (no changes yet)
await expect(saveBtn).toBeDisabled()
// Clear and type new text
await bio.clear()
await bio.fill('Game designer exploring cooperative structures')
// Save button should now be enabled
await expect(saveBtn).toBeEnabled()
})
test('pronouns field editable', async ({ adminPage }) => {
await adminPage.goto('/member/profile')
const pronouns = adminPage.locator('input[placeholder="e.g., she/her, they/them"]')
await expect(pronouns).toBeVisible()
await pronouns.clear()
await pronouns.fill('they/them')
await expect(pronouns).toHaveValue('they/them')
})
})

123
e2e/updates.spec.js Normal file
View file

@ -0,0 +1,123 @@
import { test, expect } from './helpers/fixtures.js'
test.describe('My Updates page', () => {
test('authenticated user sees the my-updates page', async ({ adminPage }) => {
await adminPage.goto('/member/my-updates')
await expect(adminPage.locator('h1', { hasText: 'My Updates' })).toBeVisible({
timeout: 10000,
})
})
test('authenticated user sees the new update link', async ({ adminPage }) => {
await adminPage.goto('/member/my-updates')
// Wait for ClientOnly content to hydrate
await expect(adminPage.locator('h1', { hasText: 'My Updates' })).toBeVisible({
timeout: 10000,
})
// The page shows either the "+ New Update" button (stats row) or
// the "+ Post Your First Update" link (empty state) — both go to /updates/new
const newUpdateLink = adminPage.locator('a[href="/updates/new"]')
await expect(newUpdateLink.first()).toBeVisible({ timeout: 10000 })
})
test('unauthenticated user sees sign-in prompt', async ({ browser }) => {
const context = await browser.newContext()
const page = await context.newPage()
await page.goto('/member/my-updates')
await expect(
page
.getByText('Sign in required')
.or(page.getByText('Sign in to view your updates'))
).toBeVisible({ timeout: 10000 })
await context.close()
})
})
test.describe('New Update page', () => {
test('loads the new update form', async ({ adminPage }) => {
await adminPage.goto('/updates/new')
await expect(adminPage.locator('h1', { hasText: 'New Update' })).toBeVisible({
timeout: 10000,
})
// Form elements are present
await expect(adminPage.locator('textarea')).toBeVisible()
await expect(adminPage.locator('select')).toBeVisible()
// Submit button exists and starts disabled (empty textarea)
const submitBtn = adminPage.locator('button[type="submit"]')
await expect(submitBtn).toBeVisible()
await expect(submitBtn).toBeDisabled()
})
test('submit button enables when content is entered', async ({ adminPage }) => {
await adminPage.goto('/updates/new')
await expect(adminPage.locator('h1', { hasText: 'New Update' })).toBeVisible({
timeout: 10000,
})
const textarea = adminPage.locator('textarea')
const submitBtn = adminPage.locator('button[type="submit"]')
await expect(submitBtn).toBeDisabled()
await textarea.fill('Test update content')
await expect(submitBtn).toBeEnabled()
})
test('privacy selector defaults to members and has all options', async ({ adminPage }) => {
await adminPage.goto('/updates/new')
await expect(adminPage.locator('h1', { hasText: 'New Update' })).toBeVisible({
timeout: 10000,
})
const select = adminPage.locator('select')
await expect(select).toHaveValue('members')
// Verify all three privacy options exist
await expect(select.locator('option[value="members"]')).toBeAttached()
await expect(select.locator('option[value="public"]')).toBeAttached()
await expect(select.locator('option[value="private"]')).toBeAttached()
})
test('cancel link navigates back to my-updates', async ({ adminPage }) => {
await adminPage.goto('/updates/new')
await expect(adminPage.locator('h1', { hasText: 'New Update' })).toBeVisible({
timeout: 10000,
})
const cancelLink = adminPage.locator('a', { hasText: 'Cancel' })
await expect(cancelLink).toHaveAttribute('href', '/member/my-updates')
})
test('back link points to my-updates', async ({ adminPage }) => {
await adminPage.goto('/updates/new')
const backLink = adminPage.locator('.back-link a')
await expect(backLink).toBeVisible({ timeout: 10000 })
await expect(backLink).toHaveAttribute('href', '/member/my-updates')
})
})
test.describe('Updates API (public access)', () => {
test('public updates endpoint returns data', async ({ page }) => {
const response = await page.request.get('/api/updates')
expect(response.ok()).toBe(true)
const data = await response.json()
expect(data).toHaveProperty('updates')
expect(data).toHaveProperty('total')
expect(data).toHaveProperty('hasMore')
expect(Array.isArray(data.updates)).toBe(true)
})
})

73
e2e/visual/pages.spec.js Normal file
View file

@ -0,0 +1,73 @@
import { test, expect } from '@playwright/test'
import { loginAsAdmin } from '../helpers/auth.js'
const viewports = {
desktop: { width: 1280, height: 720 },
mobile: { width: 375, height: 667 },
}
const publicPages = [
{ name: 'home', path: '/' },
{ name: 'join', path: '/join' },
{ name: 'events', path: '/events' },
{ name: 'coming-soon', path: '/coming-soon' },
]
const authenticatedPages = [
{ name: 'member-dashboard', path: '/member/dashboard' },
{ name: 'member-profile', path: '/member/profile' },
{ name: 'admin-members', path: '/admin/members' },
{ name: 'admin-events-create', path: '/admin/events/create' },
]
// Wait for fonts and images to load before taking screenshots
async function waitForStable(page) {
await page.waitForLoadState('networkidle')
// Wait for web fonts to load
await page.evaluate(() => document.fonts.ready)
}
test.describe('visual regression — public pages', () => {
for (const { name, path } of publicPages) {
for (const [viewportName, viewport] of Object.entries(viewports)) {
test(`${name}${viewportName}`, async ({ page }) => {
await page.setViewportSize(viewport)
await page.goto(path)
await waitForStable(page)
await expect(page).toHaveScreenshot(`${name}-${viewportName}.png`, {
maxDiffPixelRatio: 0.01,
mask: [
// Mask dynamic content like dates and counts
page.locator('.event-date'),
page.locator('.event-count'),
page.locator('time'),
],
})
})
}
}
})
test.describe('visual regression — authenticated pages', () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page)
})
for (const { name, path } of authenticatedPages) {
test(`${name} — desktop`, async ({ page }) => {
await page.setViewportSize(viewports.desktop)
await page.goto(path)
await waitForStable(page)
await expect(page).toHaveScreenshot(`${name}-desktop.png`, {
maxDiffPixelRatio: 0.01,
mask: [
page.locator('.event-date'),
page.locator('time'),
page.locator('.member-since'),
],
})
})
}
})

105
package-lock.json generated
View file

@ -32,10 +32,13 @@
"zod": "^4.1.3" "zod": "^4.1.3"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "^4.11.1",
"@nuxt/test-utils": "^4.0.0", "@nuxt/test-utils": "^4.0.0",
"@playwright/test": "^1.59.1",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/oidc-provider": "^9.5.0", "@types/oidc-provider": "^9.5.0",
"husky": "^9.1.7",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"vitest": "^4.0.18" "vitest": "^4.0.18"
} }
@ -142,6 +145,19 @@
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@axe-core/playwright": {
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.1.tgz",
"integrity": "sha512-mKEfoUIB1MkVTht0BGZFXtSAEKXMJoDkyV5YZ9jbBmZCcWDz71tegNsdTkIN8zc/yMi5Gm2kx7Z5YQ9PfWNAWw==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"axe-core": "~4.11.1"
},
"peerDependencies": {
"playwright-core": ">= 1.0.0"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@ -4385,6 +4401,22 @@
"integrity": "sha512-fz0NOYUEYXtg1TBaPEEvtcBq3FfmLFuTe1VZw4M8sTWX129br5dguu3M15+plOQnc181ShYe67RfwhKgK89VnA==", "integrity": "sha512-fz0NOYUEYXtg1TBaPEEvtcBq3FfmLFuTe1VZw4M8sTWX129br5dguu3M15+plOQnc181ShYe67RfwhKgK89VnA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@polka/url": { "node_modules/@polka/url": {
"version": "1.0.0-next.29", "version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@ -7765,6 +7797,16 @@
"postcss": "^8.1.0" "postcss": "^8.1.0"
} }
}, },
"node_modules/axe-core": {
"version": "4.11.2",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.2.tgz",
"integrity": "sha512-byD6KPdvo72y/wj2T/4zGEvvlis+PsZsn/yPS3pEO+sFpcrqRpX/TJCxvVaEsNeMrfQbCr7w163YqoD9IYwHXw==",
"dev": true,
"license": "MPL-2.0",
"engines": {
"node": ">=4"
}
},
"node_modules/axios": { "node_modules/axios": {
"version": "1.13.5", "version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
@ -10933,6 +10975,22 @@
"node": ">=16.17.0" "node": ">=16.17.0"
} }
}, },
"node_modules/husky": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
"dev": true,
"license": "MIT",
"bin": {
"husky": "bin.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.4.24", "version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@ -13713,6 +13771,53 @@
"pathe": "^2.0.3" "pathe": "^2.0.3"
} }
}, },
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pluralize": { "node_modules/pluralize": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",

View file

@ -9,7 +9,15 @@
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare", "postinstall": "nuxt prepare",
"test": "vitest", "test": "vitest",
"test:run": "vitest run" "test:run": "vitest run",
"test:e2e": "npx playwright test",
"test:e2e:ui": "npx playwright test --ui",
"test:e2e:headed": "npx playwright test --headed",
"test:visual": "npx playwright test e2e/visual/",
"test:visual:update": "npx playwright test e2e/visual/ --update-snapshots",
"test:a11y": "npx playwright test e2e/a11y.spec.js",
"test:all": "npm run test:run && npx playwright test",
"prepare": "husky"
}, },
"dependencies": { "dependencies": {
"@cloudinary/vue": "^1.13.3", "@cloudinary/vue": "^1.13.3",
@ -37,10 +45,13 @@
"zod": "^4.1.3" "zod": "^4.1.3"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "^4.11.1",
"@nuxt/test-utils": "^4.0.0", "@nuxt/test-utils": "^4.0.0",
"@playwright/test": "^1.59.1",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/oidc-provider": "^9.5.0", "@types/oidc-provider": "^9.5.0",
"husky": "^9.1.7",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"vitest": "^4.0.18" "vitest": "^4.0.18"
} }

31
playwright.config.js Normal file
View file

@ -0,0 +1,31 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
outputDir: 'e2e/test-results',
snapshotDir: 'e2e/__screenshots__',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { browserName: 'chromium' },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
env: {
NUXT_PUBLIC_COMING_SOON: 'false',
NODE_ENV: 'development',
},
},
})

View file

@ -0,0 +1,218 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref, computed } from 'vue'
import { MEMBER_STATUSES, MEMBER_STATUS_CONFIG, useMemberStatus } from '../../../app/composables/useMemberStatus.js'
// Stub Vue's computed as a global (Nuxt auto-import)
vi.stubGlobal('computed', computed)
// Shared reactive ref for controlling member status in tests
const memberData = ref({ status: 'active' })
vi.stubGlobal('useAuth', () => ({ memberData }))
describe('MEMBER_STATUSES', () => {
it('has all four status keys', () => {
expect(Object.keys(MEMBER_STATUSES)).toEqual([
'PENDING_PAYMENT', 'ACTIVE', 'SUSPENDED', 'CANCELLED',
])
})
it('maps to expected string values', () => {
expect(MEMBER_STATUSES.PENDING_PAYMENT).toBe('pending_payment')
expect(MEMBER_STATUSES.ACTIVE).toBe('active')
expect(MEMBER_STATUSES.SUSPENDED).toBe('suspended')
expect(MEMBER_STATUSES.CANCELLED).toBe('cancelled')
})
})
describe('MEMBER_STATUS_CONFIG', () => {
const requiredFields = ['label', 'color', 'canRSVP', 'canAccessMembers', 'canPeerSupport']
it('has config for every status value', () => {
for (const status of Object.values(MEMBER_STATUSES)) {
expect(MEMBER_STATUS_CONFIG).toHaveProperty(status)
}
})
it('each config has required fields', () => {
for (const [key, config] of Object.entries(MEMBER_STATUS_CONFIG)) {
for (const field of requiredFields) {
expect(config, `${key} missing ${field}`).toHaveProperty(field)
}
}
})
it('active has full permissions', () => {
const cfg = MEMBER_STATUS_CONFIG.active
expect(cfg.canRSVP).toBe(true)
expect(cfg.canAccessMembers).toBe(true)
expect(cfg.canPeerSupport).toBe(true)
})
it('pending_payment can access members but not RSVP or peer support', () => {
const cfg = MEMBER_STATUS_CONFIG.pending_payment
expect(cfg.canRSVP).toBe(false)
expect(cfg.canAccessMembers).toBe(true)
expect(cfg.canPeerSupport).toBe(false)
})
it('suspended has all permissions false', () => {
const cfg = MEMBER_STATUS_CONFIG.suspended
expect(cfg.canRSVP).toBe(false)
expect(cfg.canAccessMembers).toBe(false)
expect(cfg.canPeerSupport).toBe(false)
})
it('cancelled has all permissions false', () => {
const cfg = MEMBER_STATUS_CONFIG.cancelled
expect(cfg.canRSVP).toBe(false)
expect(cfg.canAccessMembers).toBe(false)
expect(cfg.canPeerSupport).toBe(false)
})
})
describe('useMemberStatus composable', () => {
beforeEach(() => {
memberData.value = { status: 'active' }
})
describe('status detection', () => {
it('defaults to pending_payment when memberData has no status', () => {
memberData.value = {}
const { status } = useMemberStatus()
expect(status.value).toBe('pending_payment')
})
it('defaults to pending_payment when memberData is null', () => {
memberData.value = null
const { status } = useMemberStatus()
expect(status.value).toBe('pending_payment')
})
it('isActive is true when status is active', () => {
memberData.value = { status: 'active' }
const { isActive } = useMemberStatus()
expect(isActive.value).toBe(true)
})
it('isActive is false when status is not active', () => {
memberData.value = { status: 'suspended' }
const { isActive, isInactive } = useMemberStatus()
expect(isActive.value).toBe(false)
expect(isInactive.value).toBe(true)
})
})
describe('permissions', () => {
it('canRSVP is true when active', () => {
memberData.value = { status: 'active' }
const { canRSVP } = useMemberStatus()
expect(canRSVP.value).toBe(true)
})
it('canRSVP is false when pending_payment', () => {
memberData.value = { status: 'pending_payment' }
const { canRSVP } = useMemberStatus()
expect(canRSVP.value).toBe(false)
})
it('canAccessMembers is true for active and pending_payment', () => {
for (const status of ['active', 'pending_payment']) {
memberData.value = { status }
const { canAccessMembers } = useMemberStatus()
expect(canAccessMembers.value, `expected true for ${status}`).toBe(true)
}
})
it('canAccessMembers is false for suspended and cancelled', () => {
for (const status of ['suspended', 'cancelled']) {
memberData.value = { status }
const { canAccessMembers } = useMemberStatus()
expect(canAccessMembers.value, `expected false for ${status}`).toBe(false)
}
})
})
describe('getNextAction', () => {
it('returns Complete Payment for pending_payment', () => {
memberData.value = { status: 'pending_payment' }
const { getNextAction } = useMemberStatus()
const action = getNextAction()
expect(action.label).toBe('Complete Payment')
expect(action.link).toBe('/member/profile#account')
})
it('returns Reactivate Membership for cancelled', () => {
memberData.value = { status: 'cancelled' }
const { getNextAction } = useMemberStatus()
const action = getNextAction()
expect(action.label).toBe('Reactivate Membership')
expect(action.link).toBe('/member/profile#account')
})
it('returns Contact Support for suspended', () => {
memberData.value = { status: 'suspended' }
const { getNextAction } = useMemberStatus()
const action = getNextAction()
expect(action.label).toBe('Contact Support')
expect(action.link).toBe('mailto:support@ghostguild.org')
})
it('returns null for active', () => {
memberData.value = { status: 'active' }
const { getNextAction } = useMemberStatus()
expect(getNextAction()).toBeNull()
})
})
describe('getBannerMessage', () => {
it('returns payment message for pending_payment', () => {
memberData.value = { status: 'pending_payment' }
const { getBannerMessage } = useMemberStatus()
expect(getBannerMessage()).toContain('pending payment')
})
it('returns suspended message for suspended', () => {
memberData.value = { status: 'suspended' }
const { getBannerMessage } = useMemberStatus()
expect(getBannerMessage()).toContain('suspended')
})
it('returns cancelled message for cancelled', () => {
memberData.value = { status: 'cancelled' }
const { getBannerMessage } = useMemberStatus()
expect(getBannerMessage()).toContain('cancelled')
})
it('returns null for active', () => {
memberData.value = { status: 'active' }
const { getBannerMessage } = useMemberStatus()
expect(getBannerMessage()).toBeNull()
})
})
describe('getRSVPMessage', () => {
it('returns payment message for pending_payment', () => {
memberData.value = { status: 'pending_payment' }
const { getRSVPMessage } = useMemberStatus()
expect(getRSVPMessage()).toContain('payment')
})
it('returns restriction message for suspended', () => {
memberData.value = { status: 'suspended' }
const { getRSVPMessage } = useMemberStatus()
expect(getRSVPMessage()).toContain('reactivate')
})
it('returns restriction message for cancelled', () => {
memberData.value = { status: 'cancelled' }
const { getRSVPMessage } = useMemberStatus()
expect(getRSVPMessage()).toContain('reactivate')
})
it('returns null for active', () => {
memberData.value = { status: 'active' }
const { getRSVPMessage } = useMemberStatus()
expect(getRSVPMessage()).toBeNull()
})
})
})

View file

@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const adminDir = resolve(import.meta.dirname, '../../../server/api/admin')
// All admin routes grouped by directory
const adminRoutes = {
'admin/': [
'dashboard.get.js',
'events.get.js',
'events.post.js',
'members.get.js',
'members.post.js',
'series.get.js',
'series.post.js',
'series.put.js'
],
'admin/events/': [
'events/[id].delete.js',
'events/[id].get.js',
'events/[id].put.js'
],
'admin/members/': [
'members/[id].put.js',
'members/[id]/role.patch.js',
'members/import.post.js',
'members/invite.post.js'
],
'admin/series/': [
'series/[id].delete.js',
'series/[id].put.js',
'series/tickets.put.js'
]
}
// Business logic markers that must appear after requireAdmin
const businessLogicPatterns = [
'readBody(event)',
'validateBody(event',
'fetch(',
'connectDB()',
'Member.find',
'Member.findOne',
'Member.findById',
'Member.countDocuments',
'Event.find',
'Event.findOne',
'Event.findById',
'Event.countDocuments',
'Series.find',
'Series.findOne',
'Series.findById'
]
describe('Admin endpoint auth guards', () => {
for (const [group, files] of Object.entries(adminRoutes)) {
describe(group, () => {
for (const file of files) {
describe(file, () => {
const source = readFileSync(resolve(adminDir, file), 'utf-8')
it('calls requireAdmin', () => {
expect(source).toContain('requireAdmin(event)')
})
it('calls requireAdmin before any business logic', () => {
const adminIndex = source.indexOf('requireAdmin(event)')
expect(adminIndex).toBeGreaterThan(-1)
for (const pattern of businessLogicPatterns) {
const patternIndex = source.indexOf(pattern)
if (patternIndex > -1) {
expect(
adminIndex,
`requireAdmin must appear before ${pattern} in ${file}`
).toBeLessThan(patternIndex)
}
}
})
})
}
})
}
})

View file

@ -0,0 +1,165 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
vi.mock('../../../server/models/member.js', () => ({
default: { findByIdAndUpdate: vi.fn() }
}))
vi.mock('../../../server/utils/mongoose.js', () => ({
connectDB: vi.fn()
}))
vi.mock('../../../server/utils/validateBody.js', () => ({
validateBody: vi.fn()
}))
vi.mock('../../../server/utils/schemas.js', () => ({
adminRoleUpdateSchema: {}
}))
import handler from '../../../server/api/admin/members/[id]/role.patch.js'
import Member from '../../../server/models/member.js'
import { validateBody } from '../../../server/utils/validateBody.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
describe('admin role PATCH endpoint', () => {
beforeEach(() => {
vi.clearAllMocks()
requireAdmin.mockResolvedValue({
_id: { toString: () => 'admin-123' }
})
validateBody.mockResolvedValue({ role: 'member' })
vi.stubGlobal('getRouterParam', vi.fn().mockReturnValue('target-member-id'))
})
describe('source inspection', () => {
const source = readFileSync(
resolve(import.meta.dirname, '../../../server/api/admin/members/[id]/role.patch.js'),
'utf-8'
)
it('calls requireAdmin before validateBody', () => {
const adminIndex = source.indexOf('requireAdmin(event)')
const validateIndex = source.indexOf('validateBody(event')
expect(adminIndex).toBeGreaterThan(-1)
expect(validateIndex).toBeGreaterThan(-1)
expect(adminIndex).toBeLessThan(validateIndex)
})
})
describe('auth', () => {
it('rejects non-admin users', async () => {
requireAdmin.mockRejectedValue(
createError({ statusCode: 403, statusMessage: 'Forbidden' })
)
const event = createMockEvent({
method: 'PATCH',
path: '/api/admin/members/target-member-id/role',
body: { role: 'member' }
})
await expect(handler(event)).rejects.toMatchObject({ statusCode: 403 })
})
})
describe('self-demotion', () => {
it('returns 400 when admin tries to remove their own admin role', async () => {
requireAdmin.mockResolvedValue({
_id: { toString: () => 'admin-123' }
})
validateBody.mockResolvedValue({ role: 'member' })
vi.stubGlobal('getRouterParam', vi.fn().mockReturnValue('admin-123'))
const event = createMockEvent({
method: 'PATCH',
path: '/api/admin/members/admin-123/role',
body: { role: 'member' }
})
await expect(handler(event)).rejects.toMatchObject({
statusCode: 400,
statusMessage: 'You cannot remove your own admin role.'
})
})
})
describe('validation', () => {
it('rejects invalid role via validateBody', async () => {
validateBody.mockRejectedValue(
createError({ statusCode: 400, statusMessage: 'Invalid role' })
)
const event = createMockEvent({
method: 'PATCH',
path: '/api/admin/members/target-member-id/role',
body: { role: 'superadmin' }
})
await expect(handler(event)).rejects.toMatchObject({ statusCode: 400 })
})
})
describe('member not found', () => {
it('returns 404 when member does not exist', async () => {
Member.findByIdAndUpdate.mockResolvedValue(null)
const event = createMockEvent({
method: 'PATCH',
path: '/api/admin/members/nonexistent-id/role',
body: { role: 'member' }
})
await expect(handler(event)).rejects.toMatchObject({
statusCode: 404,
statusMessage: 'Member not found.'
})
})
})
describe('successful role changes', () => {
it('promotes a member to admin', async () => {
validateBody.mockResolvedValue({ role: 'admin' })
const updatedMember = { _id: 'target-member-id', role: 'admin', name: 'Test User' }
Member.findByIdAndUpdate.mockResolvedValue(updatedMember)
const event = createMockEvent({
method: 'PATCH',
path: '/api/admin/members/target-member-id/role',
body: { role: 'admin' }
})
const result = await handler(event)
expect(result).toEqual({ success: true, member: updatedMember })
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
'target-member-id',
{ role: 'admin' },
{ new: true }
)
})
it('demotes a member to regular role', async () => {
validateBody.mockResolvedValue({ role: 'member' })
const updatedMember = { _id: 'target-member-id', role: 'member', name: 'Test User' }
Member.findByIdAndUpdate.mockResolvedValue(updatedMember)
const event = createMockEvent({
method: 'PATCH',
path: '/api/admin/members/target-member-id/role',
body: { role: 'member' }
})
const result = await handler(event)
expect(result).toEqual({ success: true, member: updatedMember })
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
'target-member-id',
{ role: 'member' },
{ new: true }
)
})
})
})

View file

@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../../../server/models/member.js', () => ({ vi.mock('../../../server/models/member.js', () => ({
default: { findOne: vi.fn() } default: { findOne: vi.fn(), findByIdAndUpdate: vi.fn() }
})) }))
vi.mock('../../../server/utils/mongoose.js', () => ({ vi.mock('../../../server/utils/mongoose.js', () => ({

View file

@ -0,0 +1,221 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../../../server/models/member.js', () => ({
default: { findById: vi.fn(), findByIdAndUpdate: vi.fn() }
}))
vi.mock('../../../server/utils/mongoose.js', () => ({
connectDB: vi.fn()
}))
vi.mock('jsonwebtoken', () => ({
default: {
verify: vi.fn(),
sign: vi.fn().mockReturnValue('mock-session-token')
}
}))
import jwt from 'jsonwebtoken'
import Member from '../../../server/models/member.js'
import verifyHandler from '../../../server/api/auth/verify.post.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
const baseMember = {
_id: 'member-123',
email: 'test@example.com',
status: 'active',
role: 'member',
magicLinkJti: 'jti-abc',
magicLinkJtiUsed: false,
tokenVersion: 1
}
describe('auth verify endpoint', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('rejects missing token with 400', async () => {
const event = createMockEvent({
method: 'POST',
path: '/api/auth/verify',
body: {}
})
await expect(verifyHandler(event)).rejects.toMatchObject({
statusCode: 400,
statusMessage: 'Token is required'
})
})
it('rejects invalid JWT with 401', async () => {
jwt.verify.mockImplementation(() => { throw new Error('invalid') })
const event = createMockEvent({
method: 'POST',
path: '/api/auth/verify',
body: { token: 'bad-token' }
})
await expect(verifyHandler(event)).rejects.toMatchObject({
statusCode: 401,
statusMessage: 'Invalid or expired token'
})
})
it('rejects when member not found with 401', async () => {
jwt.verify.mockReturnValue({ memberId: 'nonexistent', jti: 'jti-abc' })
Member.findById.mockResolvedValue(null)
const event = createMockEvent({
method: 'POST',
path: '/api/auth/verify',
body: { token: 'valid-token' }
})
await expect(verifyHandler(event)).rejects.toMatchObject({
statusCode: 401,
statusMessage: 'Invalid or expired token'
})
})
it('rejects suspended member with 403', async () => {
jwt.verify.mockReturnValue({ memberId: 'member-123', jti: 'jti-abc' })
Member.findById.mockResolvedValue({ ...baseMember, status: 'suspended' })
const event = createMockEvent({
method: 'POST',
path: '/api/auth/verify',
body: { token: 'valid-token' }
})
await expect(verifyHandler(event)).rejects.toMatchObject({
statusCode: 403,
statusMessage: 'Account is suspended'
})
})
it('rejects cancelled member with 403', async () => {
jwt.verify.mockReturnValue({ memberId: 'member-123', jti: 'jti-abc' })
Member.findById.mockResolvedValue({ ...baseMember, status: 'cancelled' })
const event = createMockEvent({
method: 'POST',
path: '/api/auth/verify',
body: { token: 'valid-token' }
})
await expect(verifyHandler(event)).rejects.toMatchObject({
statusCode: 403,
statusMessage: 'Account is cancelled'
})
})
it('rejects JTI mismatch with 401', async () => {
jwt.verify.mockReturnValue({ memberId: 'member-123', jti: 'wrong-jti' })
Member.findById.mockResolvedValue({ ...baseMember })
const event = createMockEvent({
method: 'POST',
path: '/api/auth/verify',
body: { token: 'valid-token' }
})
await expect(verifyHandler(event)).rejects.toMatchObject({
statusCode: 401,
statusMessage: 'Invalid or expired token'
})
})
it('rejects already-used JTI with 401', async () => {
jwt.verify.mockReturnValue({ memberId: 'member-123', jti: 'jti-abc' })
Member.findById.mockResolvedValue({ ...baseMember, magicLinkJtiUsed: true })
const event = createMockEvent({
method: 'POST',
path: '/api/auth/verify',
body: { token: 'valid-token' }
})
await expect(verifyHandler(event)).rejects.toMatchObject({
statusCode: 401,
statusMessage: 'Invalid or expired token'
})
})
it('burns token atomically via findByIdAndUpdate', async () => {
jwt.verify.mockReturnValue({ memberId: 'member-123', jti: 'jti-abc' })
Member.findById.mockResolvedValue({ ...baseMember })
Member.findByIdAndUpdate.mockResolvedValue({})
const event = createMockEvent({
method: 'POST',
path: '/api/auth/verify',
body: { token: 'valid-token' }
})
await verifyHandler(event)
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
'member-123',
{ $set: { magicLinkJtiUsed: true, lastLogin: expect.any(Date) } },
{ runValidators: false }
)
})
it('sets httpOnly session cookie with correct attributes', async () => {
jwt.verify.mockReturnValue({ memberId: 'member-123', jti: 'jti-abc' })
Member.findById.mockResolvedValue({ ...baseMember })
Member.findByIdAndUpdate.mockResolvedValue({})
const event = createMockEvent({
method: 'POST',
path: '/api/auth/verify',
body: { token: 'valid-token' }
})
await verifyHandler(event)
const setCookieHeader = event._testSetHeaders['set-cookie']
expect(setCookieHeader).toBeDefined()
const cookie = Array.isArray(setCookieHeader) ? setCookieHeader.join('; ') : setCookieHeader
expect(cookie).toContain('auth-token=mock-session-token')
expect(cookie).toContain('HttpOnly')
expect(cookie).toContain('SameSite=Lax')
expect(cookie).toContain('Path=/')
expect(cookie).toContain('Max-Age=604800')
})
it('returns admin redirect for admin role', async () => {
jwt.verify.mockReturnValue({ memberId: 'member-123', jti: 'jti-abc' })
Member.findById.mockResolvedValue({ ...baseMember, role: 'admin' })
Member.findByIdAndUpdate.mockResolvedValue({})
const event = createMockEvent({
method: 'POST',
path: '/api/auth/verify',
body: { token: 'valid-token' }
})
const result = await verifyHandler(event)
expect(result).toEqual({ success: true, redirectUrl: '/admin' })
})
it('returns member redirect for non-admin role', async () => {
jwt.verify.mockReturnValue({ memberId: 'member-123', jti: 'jti-abc' })
Member.findById.mockResolvedValue({ ...baseMember })
Member.findByIdAndUpdate.mockResolvedValue({})
const event = createMockEvent({
method: 'POST',
path: '/api/auth/verify',
body: { token: 'valid-token' }
})
const result = await verifyHandler(event)
expect(result).toEqual({ success: true, redirectUrl: '/member/dashboard' })
})
})

View file

@ -0,0 +1,168 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
vi.mock('../../../server/models/member.js', () => ({
default: { findOne: vi.fn(), create: vi.fn() }
}))
vi.mock('../../../server/utils/mongoose.js', () => ({
connectDB: vi.fn()
}))
vi.mock('jsonwebtoken', () => ({
default: { sign: vi.fn().mockReturnValue('mock-token') }
}))
import Member from '../../../server/models/member.js'
import testLoginHandler from '../../../server/api/dev/test-login.get.js'
import memberLoginHandler from '../../../server/api/dev/member-login.get.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
const mockMember = {
_id: 'member-123',
email: 'test-admin@ghostguild.dev',
name: 'Test Admin',
circle: 'founder',
role: 'admin',
status: 'active'
}
describe('dev endpoints', () => {
let originalNodeEnv
beforeEach(() => {
originalNodeEnv = process.env.NODE_ENV
process.env.NODE_ENV = 'development'
vi.clearAllMocks()
})
afterEach(() => {
process.env.NODE_ENV = originalNodeEnv
})
// ── Source inspection ──────────────────────────────────────────
describe('source inspection', () => {
const devDir = resolve(import.meta.dirname, '../../../server/api/dev')
it('test-login.get.js first conditional is NODE_ENV production check', () => {
const source = readFileSync(resolve(devDir, 'test-login.get.js'), 'utf-8')
const handlerBody = source.slice(source.indexOf('defineEventHandler'))
const firstIf = handlerBody.match(/if\s*\([^)]+\)/)?.[0]
expect(firstIf).toContain("process.env.NODE_ENV === 'production'")
})
it('member-login.get.js first conditional is NODE_ENV production check', () => {
const source = readFileSync(resolve(devDir, 'member-login.get.js'), 'utf-8')
const handlerBody = source.slice(source.indexOf('defineEventHandler'))
const firstIf = handlerBody.match(/if\s*\([^)]+\)/)?.[0]
expect(firstIf).toContain("process.env.NODE_ENV === 'production'")
})
})
// ── test-login.get.js ──────────────────────────────────────────
describe('test-login.get.js', () => {
it('returns 404 in production', async () => {
process.env.NODE_ENV = 'production'
const event = createMockEvent({ method: 'GET', path: '/api/dev/test-login' })
await expect(testLoginHandler(event)).rejects.toMatchObject({
statusCode: 404
})
})
it('creates admin user when none exists', async () => {
Member.findOne.mockResolvedValue(null)
Member.create.mockResolvedValue(mockMember)
const event = createMockEvent({ method: 'GET', path: '/api/dev/test-login' })
await testLoginHandler(event)
expect(Member.findOne).toHaveBeenCalledWith({ email: 'test-admin@ghostguild.dev' })
expect(Member.create).toHaveBeenCalledWith(
expect.objectContaining({
email: 'test-admin@ghostguild.dev',
role: 'admin',
circle: 'founder'
})
)
})
it('uses existing admin when found', async () => {
Member.findOne.mockResolvedValue(mockMember)
const event = createMockEvent({ method: 'GET', path: '/api/dev/test-login' })
await testLoginHandler(event)
expect(Member.findOne).toHaveBeenCalledWith({ email: 'test-admin@ghostguild.dev' })
expect(Member.create).not.toHaveBeenCalled()
})
it('sets auth cookie', async () => {
Member.findOne.mockResolvedValue(mockMember)
const event = createMockEvent({ method: 'GET', path: '/api/dev/test-login' })
await testLoginHandler(event)
const cookieHeader = event._testSetHeaders['set-cookie']
expect(cookieHeader).toBeDefined()
expect(cookieHeader).toContain('auth-token=mock-token')
})
})
// ── member-login.get.js ────────────────────────────────────────
describe('member-login.get.js', () => {
it('returns 404 in production', async () => {
process.env.NODE_ENV = 'production'
const event = createMockEvent({ method: 'GET', path: '/api/dev/member-login' })
await expect(memberLoginHandler(event)).rejects.toMatchObject({
statusCode: 404
})
})
it('returns 400 when email query param is missing', async () => {
const event = createMockEvent({ method: 'GET', path: '/api/dev/member-login' })
await expect(memberLoginHandler(event)).rejects.toMatchObject({
statusCode: 400
})
})
it('returns 404 when member not found', async () => {
Member.findOne.mockResolvedValue(null)
const event = createMockEvent({
method: 'GET',
path: '/api/dev/member-login?email=nobody@example.com'
})
await expect(memberLoginHandler(event)).rejects.toMatchObject({
statusCode: 404
})
})
it('sets auth cookie for found member', async () => {
const foundMember = {
_id: 'member-456',
email: 'test@example.com',
name: 'Test User',
status: 'active'
}
Member.findOne.mockResolvedValue(foundMember)
const event = createMockEvent({
method: 'GET',
path: '/api/dev/member-login?email=test@example.com'
})
await memberLoginHandler(event)
const cookieHeader = event._testSetHeaders['set-cookie']
expect(cookieHeader).toBeDefined()
expect(cookieHeader).toContain('auth-token=mock-token')
})
})
})

View file

@ -0,0 +1,89 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const eventsDir = resolve(import.meta.dirname, '../../../server/api/events/[id]')
describe('register.post.js', () => {
const source = readFileSync(resolve(eventsDir, 'register.post.js'), 'utf-8')
it('uses validateBody for input validation', () => {
expect(source).toContain('validateBody(event')
})
it('checks for duplicate registration with case-insensitive email', () => {
expect(source).toContain('email.toLowerCase()')
})
it('checks membersOnly restriction', () => {
expect(source).toContain('membersOnly')
})
it('checks capacity via maxAttendees', () => {
expect(source).toContain('maxAttendees')
})
it('does not let email failure block registration', () => {
// The await call (not the import) must be wrapped in try/catch
const emailCallIndex = source.indexOf('await sendEventRegistrationEmail')
expect(emailCallIndex).toBeGreaterThan(-1)
// A try block immediately precedes the email call
const precedingSource = source.slice(0, emailCallIndex)
const lastTryIndex = precedingSource.lastIndexOf('try')
expect(lastTryIndex).toBeGreaterThan(-1)
// The catch after the email call should log but not re-throw
const afterEmail = source.slice(emailCallIndex)
const catchBlock = afterEmail.match(/catch\s*\(\w+\)\s*\{[^}]*\}/s)
expect(catchBlock).not.toBeNull()
expect(catchBlock[0]).toContain('console.error')
})
})
describe('guest-register.post.js', () => {
const source = readFileSync(resolve(eventsDir, 'guest-register.post.js'), 'utf-8')
it('uses validateBody for input validation', () => {
expect(source).toContain('validateBody(event')
})
it('checks membersOnly restriction with 403', () => {
expect(source).toContain('membersOnly')
expect(source).toContain('403')
})
it('checks payment requirement with 402', () => {
expect(source).toContain('paymentRequired')
expect(source).toContain('402')
})
it('checks capacity via maxAttendees', () => {
expect(source).toContain('maxAttendees')
})
it('does not require auth', () => {
expect(source).not.toContain('requireAuth')
})
})
describe('cancel-registration.post.js', () => {
const source = readFileSync(resolve(eventsDir, 'cancel-registration.post.js'), 'utf-8')
it('uses validateBody for input validation', () => {
expect(source).toContain('validateBody(event')
})
it('finds registration by email', () => {
expect(source).toContain('email.toLowerCase()')
})
it('notifies waitlist after cancellation', () => {
expect(source).toContain('waitlist')
expect(source).toContain('sendWaitlistNotificationEmail')
})
it('does not require auth', () => {
expect(source).not.toContain('requireAuth')
})
})

View file

@ -0,0 +1,201 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn() }))
vi.mock('../../../server/utils/validateBody.js', () => ({ validateBody: vi.fn() }))
vi.mock('../../../server/utils/schemas.js', () => ({ paymentVerifySchema: {} }))
import { requireAuth } from '../../../server/utils/auth.js'
import { validateBody as importedValidateBody } from '../../../server/utils/validateBody.js'
import initPaymentHandler from '../../../server/api/helcim/initialize-payment.post.js'
import verifyPaymentHandler from '../../../server/api/helcim/verify-payment.post.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
// helcimInitializePaymentSchema is a Nitro auto-import used by validateBody
vi.stubGlobal('helcimInitializePaymentSchema', {})
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
describe('initialize-payment endpoint', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
mockFetch.mockReset()
})
it('skips auth for event_ticket type', async () => {
const body = {
amount: 25,
metadata: { type: 'event_ticket', eventTitle: 'Test Event', eventId: 'evt-1' }
}
globalThis.validateBody.mockResolvedValue(body)
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ checkoutToken: 'ct-123', secretToken: 'st-456' })
})
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/initialize-payment',
body
})
await initPaymentHandler(event)
expect(requireAuth).not.toHaveBeenCalled()
})
it('requires auth for non-event_ticket types', async () => {
const body = { amount: 0, customerCode: 'code-1' }
globalThis.validateBody.mockResolvedValue(body)
requireAuth.mockResolvedValue(undefined)
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ checkoutToken: 'ct-123', secretToken: 'st-456' })
})
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/initialize-payment',
body
})
await initPaymentHandler(event)
expect(requireAuth).toHaveBeenCalledWith(event)
})
it('returns checkoutToken and secretToken on success', async () => {
const body = {
amount: 10,
metadata: { type: 'event_ticket', eventTitle: 'Workshop', eventId: 'evt-2' }
}
globalThis.validateBody.mockResolvedValue(body)
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ checkoutToken: 'ct-abc', secretToken: 'st-xyz' })
})
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/initialize-payment',
body
})
const result = await initPaymentHandler(event)
expect(result).toEqual({
success: true,
checkoutToken: 'ct-abc',
secretToken: 'st-xyz'
})
})
})
describe('verify-payment endpoint', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
mockFetch.mockReset()
})
it('requires auth', async () => {
requireAuth.mockRejectedValue(
createError({ statusCode: 401, statusMessage: 'Unauthorized' })
)
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/verify-payment',
body: { customerId: 'cust-1', cardToken: 'tok-1' }
})
await expect(verifyPaymentHandler(event)).rejects.toMatchObject({
statusCode: 401,
statusMessage: 'Unauthorized'
})
expect(requireAuth).toHaveBeenCalledWith(event)
})
it('validates with paymentVerifySchema', async () => {
const body = { customerId: 'cust-1', cardToken: 'tok-1' }
requireAuth.mockResolvedValue(undefined)
importedValidateBody.mockResolvedValue(body)
mockFetch.mockResolvedValue({
ok: true,
json: async () => [{ cardToken: 'tok-1' }]
})
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/verify-payment',
body
})
await verifyPaymentHandler(event)
expect(importedValidateBody).toHaveBeenCalledWith(event, expect.any(Object))
})
it('returns success when card token found', async () => {
const body = { customerId: 'cust-1', cardToken: 'tok-match' }
requireAuth.mockResolvedValue(undefined)
importedValidateBody.mockResolvedValue(body)
mockFetch.mockResolvedValue({
ok: true,
json: async () => [
{ cardToken: 'tok-other' },
{ cardToken: 'tok-match' }
]
})
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/verify-payment',
body
})
const result = await verifyPaymentHandler(event)
expect(result).toEqual({
success: true,
cardToken: 'tok-match',
message: 'Payment verified with Helcim'
})
})
it('returns 400 when card token not found', async () => {
const body = { customerId: 'cust-1', cardToken: 'tok-missing' }
requireAuth.mockResolvedValue(undefined)
importedValidateBody.mockResolvedValue(body)
mockFetch.mockResolvedValue({
ok: true,
json: async () => [
{ cardToken: 'tok-aaa' },
{ cardToken: 'tok-bbb' }
]
})
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/verify-payment',
body
})
await expect(verifyPaymentHandler(event)).rejects.toMatchObject({
statusCode: 400,
statusMessage: 'Payment method not found or does not belong to this customer'
})
})
})

View file

@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
vi.mock('../../../server/models/member.js', () => ({
default: { findOneAndUpdate: vi.fn() }
}))
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn() }))
vi.mock('../../../server/utils/slack.ts', () => ({
getSlackService: vi.fn().mockReturnValue(null)
}))
vi.mock('../../../server/config/contributions.js', () => ({
requiresPayment: vi.fn(),
getHelcimPlanId: vi.fn(),
getContributionTierByValue: vi.fn()
}))
import Member from '../../../server/models/member.js'
import { requireAuth } from '../../../server/utils/auth.js'
import { requiresPayment, getHelcimPlanId, getContributionTierByValue } from '../../../server/config/contributions.js'
import subscriptionHandler from '../../../server/api/helcim/subscription.post.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
// helcimSubscriptionSchema is a Nitro auto-import used by validateBody
vi.stubGlobal('helcimSubscriptionSchema', {})
describe('helcim subscription endpoint', () => {
const savedFetch = globalThis.fetch
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
// Restore fetch in case a test stubbed it
globalThis.fetch = savedFetch
})
it('requires auth', async () => {
requireAuth.mockRejectedValue(
createError({ statusCode: 401, statusMessage: 'Unauthorized' })
)
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/subscription',
body: { customerId: 'cust-1', contributionTier: '0', customerCode: 'code-1' }
})
await expect(subscriptionHandler(event)).rejects.toMatchObject({
statusCode: 401,
statusMessage: 'Unauthorized'
})
expect(requireAuth).toHaveBeenCalledWith(event)
})
it('free tier skips Helcim and activates member', async () => {
requireAuth.mockResolvedValue(undefined)
requiresPayment.mockReturnValue(false)
const mockMember = {
_id: 'member-1',
email: 'test@example.com',
name: 'Test',
circle: 'community',
contributionTier: '0',
status: 'active',
save: vi.fn()
}
Member.findOneAndUpdate.mockResolvedValue(mockMember)
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/subscription',
body: { customerId: 'cust-1', contributionTier: '0', customerCode: 'code-1' }
})
const result = await subscriptionHandler(event)
expect(result.success).toBe(true)
expect(result.subscription).toBeNull()
expect(result.member).toEqual({
id: 'member-1',
email: 'test@example.com',
name: 'Test',
circle: 'community',
contributionTier: '0',
status: 'active'
})
expect(Member.findOneAndUpdate).toHaveBeenCalledWith(
{ helcimCustomerId: 'cust-1' },
expect.objectContaining({ status: 'active', contributionTier: '0' }),
{ new: true }
)
})
it('paid tier without cardToken returns 400', async () => {
requireAuth.mockResolvedValue(undefined)
requiresPayment.mockReturnValue(true)
getHelcimPlanId.mockReturnValue('plan-123')
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/subscription',
body: { customerId: 'cust-1', contributionTier: '15', customerCode: 'code-1' }
})
await expect(subscriptionHandler(event)).rejects.toMatchObject({
statusCode: 400,
statusMessage: 'Payment information is required for this contribution tier'
})
})
it('Helcim API failure still activates member', async () => {
requireAuth.mockResolvedValue(undefined)
requiresPayment.mockReturnValue(true)
getHelcimPlanId.mockReturnValue('plan-123')
getContributionTierByValue.mockReturnValue({ amount: '15' })
const mockMember = {
_id: 'member-2',
email: 'paid@example.com',
name: 'Paid User',
circle: 'founder',
contributionTier: '15',
status: 'active',
save: vi.fn()
}
Member.findOneAndUpdate.mockResolvedValue(mockMember)
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')))
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/subscription',
body: {
customerId: 'cust-1',
contributionTier: '15',
customerCode: 'code-1',
cardToken: 'tok-123'
}
})
const result = await subscriptionHandler(event)
expect(result.success).toBe(true)
expect(result.warning).toBeTruthy()
expect(result.member.status).toBe('active')
expect(Member.findOneAndUpdate).toHaveBeenCalledWith(
{ helcimCustomerId: 'cust-1' },
expect.objectContaining({ status: 'active', contributionTier: '15' }),
{ new: true }
)
vi.unstubAllGlobals()
// Re-stub the schema global after unstubAllGlobals
vi.stubGlobal('helcimSubscriptionSchema', {})
})
})

View file

@ -0,0 +1,109 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../../../server/models/member.js', () => {
const mockSave = vi.fn().mockResolvedValue(undefined)
function MockMember(data) {
Object.assign(this, data)
this._id = 'new-member-123'
this.status = data.status || 'pending_payment'
this.save = mockSave
}
MockMember.findOne = vi.fn()
MockMember.findByIdAndUpdate = vi.fn()
MockMember._mockSave = mockSave
return { default: MockMember }
})
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
vi.mock('../../../server/utils/validateBody.js', () => ({ validateBody: vi.fn() }))
vi.mock('../../../server/utils/schemas.js', () => ({ memberCreateSchema: {} }))
vi.mock('../../../server/utils/slack.ts', () => ({
getSlackService: vi.fn().mockReturnValue(null)
}))
vi.mock('../../../server/utils/resend.js', () => ({
sendWelcomeEmail: vi.fn().mockResolvedValue(undefined)
}))
import Member from '../../../server/models/member.js'
import { validateBody } from '../../../server/utils/validateBody.js'
import { sendWelcomeEmail } from '../../../server/utils/resend.js'
import createHandler from '../../../server/api/members/create.post.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
describe('members/create.post handler', () => {
beforeEach(() => {
vi.clearAllMocks()
Member.findOne.mockResolvedValue(null)
Member._mockSave.mockResolvedValue(undefined)
validateBody.mockResolvedValue({
email: 'new@example.com',
name: 'New Member',
circle: 'community',
contributionTier: '0'
})
})
it('validates request body via memberCreateSchema', async () => {
const event = createMockEvent({ method: 'POST', path: '/api/members/create' })
await createHandler(event)
expect(validateBody).toHaveBeenCalledOnce()
expect(validateBody).toHaveBeenCalledWith(event, expect.any(Object))
})
it('rejects duplicate email with 409', async () => {
Member.findOne.mockResolvedValue({ _id: 'existing-123', email: 'new@example.com' })
const event = createMockEvent({ method: 'POST', path: '/api/members/create' })
await expect(createHandler(event)).rejects.toMatchObject({
statusCode: 409,
statusMessage: 'A member with this email already exists'
})
})
it('does not expose helcimCustomerId or role in response', async () => {
validateBody.mockResolvedValue({
email: 'new@example.com',
name: 'New Member',
circle: 'community',
contributionTier: '0',
helcimCustomerId: 'cust-999',
role: 'admin'
})
const event = createMockEvent({ method: 'POST', path: '/api/members/create' })
const result = await createHandler(event)
expect(result.member).not.toHaveProperty('helcimCustomerId')
expect(result.member).not.toHaveProperty('role')
expect(result.success).toBe(true)
expect(result.member).toEqual({
id: 'new-member-123',
email: 'new@example.com',
name: 'New Member',
circle: 'community',
contributionTier: '0',
status: 'pending_payment'
})
})
it('succeeds when Slack service is unavailable', async () => {
const event = createMockEvent({ method: 'POST', path: '/api/members/create' })
const result = await createHandler(event)
expect(result.success).toBe(true)
expect(result.member.email).toBe('new@example.com')
})
it('succeeds when welcome email fails', async () => {
sendWelcomeEmail.mockRejectedValue(new Error('email service down'))
const event = createMockEvent({ method: 'POST', path: '/api/members/create' })
const result = await createHandler(event)
expect(result.success).toBe(true)
expect(result.member.email).toBe('new@example.com')
expect(sendWelcomeEmail).toHaveBeenCalledOnce()
})
})

View file

@ -0,0 +1,112 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const updatesDir = resolve(import.meta.dirname, '../../../server/api/updates')
describe('Updates API auth guards', () => {
describe('index.post.js (create)', () => {
const source = readFileSync(resolve(updatesDir, 'index.post.js'), 'utf-8')
it('requires auth via requireAuth(event)', () => {
expect(source).toContain('requireAuth(event)')
})
it('does not wrap requireAuth in a try/catch (auth is mandatory)', () => {
// The public GET routes wrap requireAuth in try/catch to make it optional.
// The create route must NOT do that — auth failure should halt the request.
const lines = source.split('\n')
const authLine = lines.findIndex(l => l.includes('requireAuth(event)'))
expect(authLine).toBeGreaterThan(-1)
// Check the line before requireAuth is not a try {
const preceding = lines.slice(Math.max(0, authLine - 2), authLine).join(' ')
expect(preceding).not.toMatch(/try\s*\{/)
})
})
describe('[id].patch.js (edit)', () => {
const source = readFileSync(resolve(updatesDir, '[id].patch.js'), 'utf-8')
it('requires auth via requireAuth(event)', () => {
expect(source).toContain('requireAuth(event)')
})
it('does not wrap requireAuth in a try/catch (auth is mandatory)', () => {
const lines = source.split('\n')
const authLine = lines.findIndex(l => l.includes('requireAuth(event)'))
expect(authLine).toBeGreaterThan(-1)
const preceding = lines.slice(Math.max(0, authLine - 2), authLine).join(' ')
expect(preceding).not.toMatch(/try\s*\{/)
})
it('verifies ownership by comparing update.author with authenticated member ID', () => {
expect(source).toContain('update.author.toString() !== memberId')
})
it('throws 403 when user is not the author', () => {
expect(source).toContain('statusCode: 403')
})
})
describe('[id].delete.js (delete)', () => {
const source = readFileSync(resolve(updatesDir, '[id].delete.js'), 'utf-8')
it('requires auth via requireAuth(event)', () => {
expect(source).toContain('requireAuth(event)')
})
it('does not wrap requireAuth in a try/catch (auth is mandatory)', () => {
const lines = source.split('\n')
const authLine = lines.findIndex(l => l.includes('requireAuth(event)'))
expect(authLine).toBeGreaterThan(-1)
const preceding = lines.slice(Math.max(0, authLine - 2), authLine).join(' ')
expect(preceding).not.toMatch(/try\s*\{/)
})
it('verifies ownership by comparing update.author with authenticated member ID', () => {
expect(source).toContain('update.author.toString() !== memberId')
})
it('throws 403 when user is not the author', () => {
expect(source).toContain('statusCode: 403')
})
})
describe('index.get.js (list — public)', () => {
const source = readFileSync(resolve(updatesDir, 'index.get.js'), 'utf-8')
it('does NOT enforce requireAuth (public access allowed)', () => {
// The route uses requireAuth inside a try/catch so unauthenticated
// users can still access it — auth failure is caught and ignored.
const lines = source.split('\n')
const authLine = lines.findIndex(l => l.includes('requireAuth(event)'))
// If requireAuth is present, it must be wrapped in try/catch
if (authLine > -1) {
const preceding = lines.slice(Math.max(0, authLine - 2), authLine).join(' ')
expect(preceding).toMatch(/try\s*\{/)
}
// Either way, the route must not throw on unauthenticated access
})
it('does not call requireAdmin', () => {
expect(source).not.toContain('requireAdmin')
})
})
describe('[id].get.js (get — public)', () => {
const source = readFileSync(resolve(updatesDir, '[id].get.js'), 'utf-8')
it('does NOT enforce requireAuth (public access allowed)', () => {
const lines = source.split('\n')
const authLine = lines.findIndex(l => l.includes('requireAuth(event)'))
if (authLine > -1) {
const preceding = lines.slice(Math.max(0, authLine - 2), authLine).join(' ')
expect(preceding).toMatch(/try\s*\{/)
}
})
it('does not call requireAdmin', () => {
expect(source).not.toContain('requireAdmin')
})
})
})

View file

@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const source = readFileSync(
resolve(import.meta.dirname, '../../../server/api/upload/image.post.js'),
'utf-8'
)
describe('upload/image.post.js source inspection', () => {
it('requires auth', () => {
expect(source).toContain('requireAuth(event)')
})
it('calls requireAuth before file processing', () => {
const authIndex = source.indexOf('requireAuth(event)')
const multipartIndex = source.indexOf('readMultipartFormData(event)')
expect(authIndex).toBeGreaterThan(-1)
expect(multipartIndex).toBeGreaterThan(-1)
expect(authIndex).toBeLessThan(multipartIndex)
})
it('validates file type is an image', () => {
expect(source).toContain("startsWith('image/')")
})
it('validates file size with a 10MB limit', () => {
expect(source).toMatch(/10\s*\*\s*1024\s*\*\s*1024/)
})
it('only allows specific image formats', () => {
expect(source).toContain('allowed_formats')
for (const fmt of ['jpg', 'png', 'webp', 'gif']) {
expect(source).toContain(fmt)
}
})
})

View file

@ -11,7 +11,8 @@ import {
defineEventHandler, defineEventHandler,
readBody, readBody,
getQuery, getQuery,
getRouterParam getRouterParam,
sendRedirect
} from 'h3' } from 'h3'
// Register real h3 functions as globals so server code that relies on // Register real h3 functions as globals so server code that relies on
@ -28,7 +29,14 @@ vi.stubGlobal('defineEventHandler', defineEventHandler)
vi.stubGlobal('readBody', readBody) vi.stubGlobal('readBody', readBody)
vi.stubGlobal('getQuery', getQuery) vi.stubGlobal('getQuery', getQuery)
vi.stubGlobal('getRouterParam', getRouterParam) vi.stubGlobal('getRouterParam', getRouterParam)
vi.stubGlobal('sendRedirect', sendRedirect)
vi.stubGlobal('useRuntimeConfig', () => ({ vi.stubGlobal('useRuntimeConfig', () => ({
jwtSecret: 'test-jwt-secret' jwtSecret: 'test-jwt-secret',
helcimApiToken: 'test-helcim-token'
})) }))
// Stubs for Nitro auto-imported server/utils (used by handlers that don't explicitly import them)
vi.stubGlobal('requireAuth', vi.fn())
vi.stubGlobal('requireAdmin', vi.fn())
vi.stubGlobal('validateBody', vi.fn(async (event) => readBody(event)))

View file

@ -0,0 +1,936 @@
import { describe, it, expect } from 'vitest'
import {
calculateTicketPrice,
checkTicketAvailability,
validateTicketPurchase,
formatPrice,
calculateSeriesTicketPrice,
checkSeriesTicketAvailability,
validateSeriesTicketPurchase,
checkUserSeriesPass
} from '../../../server/utils/tickets.js'
// ---------------------------------------------------------------------------
// Helpers — build minimal event / series / member stubs
// ---------------------------------------------------------------------------
const futureDate = () => new Date(Date.now() + 86400000).toISOString()
const pastDate = () => new Date(Date.now() - 86400000).toISOString()
const baseMember = (overrides = {}) => ({
email: 'member@example.com',
circle: 'community',
...overrides
})
const legacyFreeEvent = (overrides = {}) => ({
startDate: futureDate(),
registrations: [],
...overrides
})
const legacyPaidEvent = (overrides = {}) => ({
startDate: futureDate(),
registrations: [],
pricing: { paymentRequired: true, isFree: false, publicPrice: 25, currency: 'CAD' },
...overrides
})
const ticketedEvent = (overrides = {}) => ({
startDate: futureDate(),
registrations: [],
tickets: {
enabled: true,
currency: 'CAD',
capacity: { total: 100 },
member: {
available: true,
price: 10,
isFree: false,
name: 'Member Ticket',
description: 'For members'
},
public: {
available: true,
price: 30,
quantity: 50,
sold: 0,
reserved: 0,
name: 'General Admission',
description: 'Public ticket'
},
waitlist: { enabled: false },
...overrides.tickets
},
...overrides
})
const ticketedSeries = (overrides = {}) => ({
isActive: true,
registrations: [],
tickets: {
enabled: true,
currency: 'CAD',
capacity: { total: 50 },
member: {
available: true,
price: 20,
isFree: false,
name: 'Member Series Pass',
description: 'For members'
},
public: {
available: true,
price: 60,
quantity: 30,
sold: 0,
reserved: 0,
name: 'Series Pass',
description: 'Public series pass'
},
waitlist: { enabled: false },
...overrides.tickets
},
...overrides
})
// ===========================================================================
// calculateTicketPrice
// ===========================================================================
describe('calculateTicketPrice', () => {
describe('legacy pricing (tickets not enabled)', () => {
it('returns free guest ticket for events without payment requirement', () => {
const event = legacyFreeEvent()
const result = calculateTicketPrice(event)
expect(result).toEqual({
ticketType: 'guest',
price: 0,
currency: 'CAD',
isEarlyBird: false,
isFree: true
})
})
it('returns free ticket for member on paid event', () => {
const event = legacyPaidEvent()
const member = baseMember()
const result = calculateTicketPrice(event, member)
expect(result.ticketType).toBe('member')
expect(result.price).toBe(0)
expect(result.isFree).toBe(true)
})
it('returns public price for non-member on paid event', () => {
const event = legacyPaidEvent()
const result = calculateTicketPrice(event, null)
expect(result.ticketType).toBe('public')
expect(result.price).toBe(25)
expect(result.isFree).toBe(false)
})
it('returns guest/free when pricing.isFree is true even if paymentRequired', () => {
const event = legacyFreeEvent({
pricing: { paymentRequired: true, isFree: true, publicPrice: 10 }
})
const result = calculateTicketPrice(event)
expect(result.ticketType).toBe('guest')
expect(result.price).toBe(0)
expect(result.isFree).toBe(true)
})
})
describe('member ticket pricing', () => {
it('returns base member price when isFree is false', () => {
const event = ticketedEvent()
const member = baseMember()
const result = calculateTicketPrice(event, member)
expect(result.ticketType).toBe('member')
expect(result.price).toBe(10)
expect(result.isFree).toBe(false)
expect(result.name).toBe('Member Ticket')
expect(result.description).toBe('For members')
})
it('returns price 0 when member ticket isFree is true', () => {
const event = ticketedEvent({
tickets: {
enabled: true,
currency: 'CAD',
member: { available: true, price: 10, isFree: true },
public: { available: true, price: 30 }
}
})
const member = baseMember()
const result = calculateTicketPrice(event, member)
expect(result.price).toBe(0)
expect(result.isFree).toBe(true)
})
it('applies circle override price', () => {
const event = ticketedEvent({
tickets: {
enabled: true,
currency: 'CAD',
member: {
available: true,
price: 10,
isFree: false,
circleOverrides: {
founder: { price: 5 }
}
},
public: { available: true, price: 30 }
}
})
const member = baseMember({ circle: 'founder' })
const result = calculateTicketPrice(event, member)
expect(result.price).toBe(5)
expect(result.isFree).toBe(false)
})
it('applies circle override isFree', () => {
const event = ticketedEvent({
tickets: {
enabled: true,
currency: 'CAD',
member: {
available: true,
price: 10,
isFree: false,
circleOverrides: {
practitioner: { isFree: true }
}
},
public: { available: true, price: 30 }
}
})
const member = baseMember({ circle: 'practitioner' })
const result = calculateTicketPrice(event, member)
expect(result.price).toBe(0)
expect(result.isFree).toBe(true)
})
it('ignores circle override for a different circle', () => {
const event = ticketedEvent({
tickets: {
enabled: true,
currency: 'CAD',
member: {
available: true,
price: 10,
isFree: false,
circleOverrides: {
practitioner: { isFree: true }
}
},
public: { available: true, price: 30 }
}
})
const member = baseMember({ circle: 'community' })
const result = calculateTicketPrice(event, member)
expect(result.price).toBe(10)
expect(result.isFree).toBe(false)
})
})
describe('public ticket pricing', () => {
it('returns base public price when no member provided', () => {
const event = ticketedEvent()
const result = calculateTicketPrice(event, null)
expect(result.ticketType).toBe('public')
expect(result.price).toBe(30)
expect(result.isEarlyBird).toBe(false)
expect(result.isFree).toBe(false)
expect(result.name).toBe('General Admission')
})
it('returns early bird price before deadline', () => {
const event = ticketedEvent({
tickets: {
enabled: true,
currency: 'CAD',
member: { available: false },
public: {
available: true,
price: 30,
earlyBirdPrice: 20,
earlyBirdDeadline: futureDate()
}
}
})
const result = calculateTicketPrice(event, null)
expect(result.price).toBe(20)
expect(result.isEarlyBird).toBe(true)
expect(result.isFree).toBe(false)
})
it('returns regular price after early bird deadline', () => {
const event = ticketedEvent({
tickets: {
enabled: true,
currency: 'CAD',
member: { available: false },
public: {
available: true,
price: 30,
earlyBirdPrice: 20,
earlyBirdDeadline: pastDate()
}
}
})
const result = calculateTicketPrice(event, null)
expect(result.price).toBe(30)
expect(result.isEarlyBird).toBe(false)
})
it('returns isFree true when public price is 0', () => {
const event = ticketedEvent({
tickets: {
enabled: true,
currency: 'CAD',
member: { available: false },
public: { available: true, price: 0 }
}
})
const result = calculateTicketPrice(event, null)
expect(result.price).toBe(0)
expect(result.isFree).toBe(true)
})
})
describe('no tickets available', () => {
it('returns null when member tickets unavailable and no public tickets', () => {
const event = ticketedEvent({
tickets: {
enabled: true,
member: { available: false },
public: { available: false }
}
})
const result = calculateTicketPrice(event, null)
expect(result).toBeNull()
})
it('returns null for non-member when only member tickets available', () => {
const event = ticketedEvent({
tickets: {
enabled: true,
currency: 'CAD',
member: { available: true, price: 10, isFree: false },
public: { available: false }
}
})
const result = calculateTicketPrice(event, null)
expect(result).toBeNull()
})
})
})
// ===========================================================================
// checkTicketAvailability
// ===========================================================================
describe('checkTicketAvailability', () => {
describe('legacy (tickets not enabled)', () => {
it('returns available with null remaining when no maxAttendees', () => {
const event = legacyFreeEvent()
const result = checkTicketAvailability(event)
expect(result).toEqual({
available: true,
remaining: null,
waitlistAvailable: false
})
})
it('returns available with remaining count when under capacity', () => {
const event = legacyFreeEvent({
maxAttendees: 10,
registrations: [{ email: 'a@b.com' }, { email: 'c@d.com' }]
})
const result = checkTicketAvailability(event)
expect(result.available).toBe(true)
expect(result.remaining).toBe(8)
})
it('returns unavailable at capacity', () => {
const event = legacyFreeEvent({
maxAttendees: 2,
registrations: [{ email: 'a@b.com' }, { email: 'c@d.com' }]
})
const result = checkTicketAvailability(event)
expect(result.available).toBe(false)
expect(result.remaining).toBe(0)
})
})
describe('ticketed events — overall capacity', () => {
it('returns unavailable when total capacity exceeded', () => {
const event = ticketedEvent()
event.tickets.capacity.total = 2
event.registrations = [{ email: 'a@b.com' }, { email: 'c@d.com' }]
const result = checkTicketAvailability(event, 'public')
expect(result.available).toBe(false)
expect(result.remaining).toBe(0)
})
it('respects waitlist flag when capacity exceeded', () => {
const event = ticketedEvent()
event.tickets.capacity.total = 1
event.tickets.waitlist = { enabled: true }
event.registrations = [{ email: 'a@b.com' }]
const result = checkTicketAvailability(event, 'public')
expect(result.available).toBe(false)
expect(result.waitlistAvailable).toBe(true)
})
})
describe('ticketed events — public tickets', () => {
it('returns correct remaining for quantity-limited public tickets', () => {
const event = ticketedEvent()
event.tickets.public.quantity = 10
event.tickets.public.sold = 3
event.tickets.public.reserved = 2
const result = checkTicketAvailability(event, 'public')
expect(result.available).toBe(true)
expect(result.remaining).toBe(5)
})
it('returns unavailable when public tickets sold out', () => {
const event = ticketedEvent()
event.tickets.public.quantity = 5
event.tickets.public.sold = 5
event.tickets.public.reserved = 0
const result = checkTicketAvailability(event, 'public')
expect(result.available).toBe(false)
expect(result.remaining).toBe(0)
})
it('returns unlimited when no quantity set on public tickets', () => {
const event = ticketedEvent()
delete event.tickets.public.quantity
const result = checkTicketAvailability(event, 'public')
expect(result.available).toBe(true)
expect(result.remaining).toBeNull()
})
})
describe('ticketed events — member tickets', () => {
it('returns available with capacity remaining', () => {
const event = ticketedEvent()
event.tickets.capacity.total = 10
event.registrations = [{ email: 'a@b.com' }]
const result = checkTicketAvailability(event, 'member')
expect(result.available).toBe(true)
expect(result.remaining).toBe(9)
})
it('returns unlimited when no total capacity set', () => {
const event = ticketedEvent()
delete event.tickets.capacity
const result = checkTicketAvailability(event, 'member')
expect(result.available).toBe(true)
expect(result.remaining).toBeNull()
})
})
describe('edge cases', () => {
it('returns unavailable for unknown ticket type', () => {
const event = ticketedEvent()
const result = checkTicketAvailability(event, 'vip')
expect(result.available).toBe(false)
expect(result.remaining).toBe(0)
})
})
})
// ===========================================================================
// validateTicketPurchase
// ===========================================================================
describe('validateTicketPurchase', () => {
const validUser = { email: 'user@example.com', name: 'Test User', member: null }
const memberUser = { email: 'member@example.com', name: 'Member', member: baseMember() }
it('rejects cancelled event', () => {
const event = ticketedEvent({ isCancelled: true })
const result = validateTicketPurchase(event, validUser)
expect(result.valid).toBe(false)
expect(result.reason).toBe('Event has been cancelled')
})
it('rejects past event', () => {
const event = ticketedEvent({ startDate: pastDate() })
const result = validateTicketPurchase(event, validUser)
expect(result.valid).toBe(false)
expect(result.reason).toBe('Event has already started')
})
it('rejects when registration deadline has passed', () => {
const event = ticketedEvent({ registrationDeadline: pastDate() })
const result = validateTicketPurchase(event, validUser)
expect(result.valid).toBe(false)
expect(result.reason).toBe('Registration deadline has passed')
})
it('rejects already registered user', () => {
const event = ticketedEvent({
registrations: [{ email: 'user@example.com', cancelledAt: null }]
})
const result = validateTicketPurchase(event, validUser)
expect(result.valid).toBe(false)
expect(result.reason).toBe('You are already registered for this event')
})
it('allows user whose previous registration was cancelled', () => {
const event = ticketedEvent({
registrations: [{ email: 'user@example.com', cancelledAt: new Date() }]
})
const result = validateTicketPurchase(event, validUser)
expect(result.valid).toBe(true)
})
it('rejects non-member for members-only event', () => {
const event = ticketedEvent({ membersOnly: true })
const result = validateTicketPurchase(event, validUser)
expect(result.valid).toBe(false)
expect(result.reason).toContain('members only')
})
it('allows member for members-only event', () => {
const event = ticketedEvent({ membersOnly: true })
const result = validateTicketPurchase(event, memberUser)
expect(result.valid).toBe(true)
expect(result.ticketInfo).toBeDefined()
expect(result.availability).toBeDefined()
})
it('rejects when no tickets available for user status', () => {
const event = ticketedEvent({
tickets: {
enabled: true,
member: { available: false },
public: { available: false }
}
})
const result = validateTicketPurchase(event, validUser)
expect(result.valid).toBe(false)
expect(result.reason).toContain('No tickets available')
})
it('rejects when sold out with waitlist info', () => {
const event = ticketedEvent()
event.tickets.public.quantity = 1
event.tickets.public.sold = 1
event.tickets.waitlist = { enabled: true }
const result = validateTicketPurchase(event, validUser)
expect(result.valid).toBe(false)
expect(result.reason).toBe('Event is sold out')
expect(result.waitlistAvailable).toBe(true)
})
it('returns valid with ticket info and availability for good purchase', () => {
const event = ticketedEvent()
const result = validateTicketPurchase(event, validUser)
expect(result.valid).toBe(true)
expect(result.ticketInfo.ticketType).toBe('public')
expect(result.ticketInfo.price).toBe(30)
expect(result.availability.available).toBe(true)
})
it('is case-insensitive on email match for duplicate check', () => {
const event = ticketedEvent({
registrations: [{ email: 'USER@EXAMPLE.COM', cancelledAt: null }]
})
const result = validateTicketPurchase(event, validUser)
expect(result.valid).toBe(false)
expect(result.reason).toBe('You are already registered for this event')
})
})
// ===========================================================================
// formatPrice
// ===========================================================================
describe('formatPrice', () => {
it('returns "Free" for zero price', () => {
expect(formatPrice(0)).toBe('Free')
})
it('formats CAD price by default', () => {
const result = formatPrice(25)
expect(result).toContain('25.00')
})
it('formats decimal prices', () => {
const result = formatPrice(9.99)
expect(result).toContain('9.99')
})
it('respects USD currency', () => {
const result = formatPrice(25, 'USD')
expect(result).toContain('25.00')
// US$ or $ prefix depending on locale — just confirm it formats
expect(result).toMatch(/\$/)
})
})
// ===========================================================================
// calculateSeriesTicketPrice
// ===========================================================================
describe('calculateSeriesTicketPrice', () => {
it('returns free guest ticket when tickets not enabled', () => {
const series = { tickets: { enabled: false } }
const result = calculateSeriesTicketPrice(series)
expect(result).toEqual({
ticketType: 'guest',
price: 0,
currency: 'CAD',
isEarlyBird: false,
isFree: true
})
})
it('returns member price for authenticated member', () => {
const series = ticketedSeries()
const member = baseMember()
const result = calculateSeriesTicketPrice(series, member)
expect(result.ticketType).toBe('member')
expect(result.price).toBe(20)
expect(result.isFree).toBe(false)
expect(result.name).toBe('Member Series Pass')
})
it('applies circle override to series member price', () => {
const series = ticketedSeries({
tickets: {
enabled: true,
currency: 'CAD',
member: {
available: true,
price: 20,
isFree: false,
circleOverrides: { founder: { price: 10 } }
},
public: { available: true, price: 60 }
}
})
const member = baseMember({ circle: 'founder' })
const result = calculateSeriesTicketPrice(series, member)
expect(result.price).toBe(10)
})
it('returns public price for non-member', () => {
const series = ticketedSeries()
const result = calculateSeriesTicketPrice(series, null)
expect(result.ticketType).toBe('public')
expect(result.price).toBe(60)
})
it('applies early bird pricing before deadline', () => {
const series = ticketedSeries({
tickets: {
enabled: true,
currency: 'CAD',
member: { available: false },
public: {
available: true,
price: 60,
earlyBirdPrice: 40,
earlyBirdDeadline: futureDate()
}
}
})
const result = calculateSeriesTicketPrice(series, null)
expect(result.price).toBe(40)
expect(result.isEarlyBird).toBe(true)
})
it('returns null when no tickets available for user', () => {
const series = ticketedSeries({
tickets: {
enabled: true,
member: { available: false },
public: { available: false }
}
})
const result = calculateSeriesTicketPrice(series, null)
expect(result).toBeNull()
})
})
// ===========================================================================
// checkSeriesTicketAvailability
// ===========================================================================
describe('checkSeriesTicketAvailability', () => {
it('returns unavailable when tickets not enabled', () => {
const series = { tickets: { enabled: false } }
const result = checkSeriesTicketAvailability(series)
expect(result.available).toBe(false)
})
it('returns available with remaining for public tickets', () => {
const series = ticketedSeries()
series.tickets.public.quantity = 30
series.tickets.public.sold = 10
series.tickets.public.reserved = 5
const result = checkSeriesTicketAvailability(series, 'public')
expect(result.available).toBe(true)
expect(result.remaining).toBe(15)
})
it('returns unavailable when public tickets sold out', () => {
const series = ticketedSeries()
series.tickets.public.quantity = 5
series.tickets.public.sold = 5
const result = checkSeriesTicketAvailability(series, 'public')
expect(result.available).toBe(false)
expect(result.remaining).toBe(0)
})
it('returns unavailable when total capacity reached', () => {
const series = ticketedSeries()
series.tickets.capacity.total = 2
series.registrations = [
{ email: 'a@b.com' },
{ email: 'c@d.com' }
]
const result = checkSeriesTicketAvailability(series, 'member')
expect(result.available).toBe(false)
expect(result.remaining).toBe(0)
})
it('excludes cancelled registrations from count', () => {
const series = ticketedSeries()
series.tickets.capacity.total = 2
series.registrations = [
{ email: 'a@b.com', cancelledAt: null },
{ email: 'c@d.com', cancelledAt: new Date() }
]
const result = checkSeriesTicketAvailability(series, 'member')
expect(result.available).toBe(true)
expect(result.remaining).toBe(1)
})
it('returns unlimited for member tickets with no capacity', () => {
const series = ticketedSeries()
delete series.tickets.capacity
const result = checkSeriesTicketAvailability(series, 'member')
expect(result.available).toBe(true)
expect(result.remaining).toBeNull()
})
})
// ===========================================================================
// validateSeriesTicketPurchase
// ===========================================================================
describe('validateSeriesTicketPurchase', () => {
const validUser = { email: 'user@example.com', name: 'Test User', member: null }
const memberUser = { email: 'member@example.com', name: 'Member', member: baseMember() }
it('rejects inactive series', () => {
const series = ticketedSeries({ isActive: false })
const result = validateSeriesTicketPurchase(series, validUser)
expect(result.valid).toBe(false)
expect(result.reason).toBe('This series is not currently available')
})
it('rejects already registered user', () => {
const series = ticketedSeries({
registrations: [{ email: 'user@example.com', cancelledAt: null }]
})
const result = validateSeriesTicketPurchase(series, validUser)
expect(result.valid).toBe(false)
expect(result.reason).toBe('You already have a pass for this series')
})
it('allows user whose previous registration was cancelled', () => {
const series = ticketedSeries({
registrations: [{ email: 'user@example.com', cancelledAt: new Date() }]
})
const result = validateSeriesTicketPurchase(series, validUser)
expect(result.valid).toBe(true)
})
it('rejects when no tickets available for user status', () => {
const series = ticketedSeries({
tickets: {
enabled: true,
member: { available: false },
public: { available: false }
}
})
const result = validateSeriesTicketPurchase(series, validUser)
expect(result.valid).toBe(false)
expect(result.reason).toContain('No series passes available')
})
it('rejects when sold out', () => {
const series = ticketedSeries()
series.tickets.public.quantity = 1
series.tickets.public.sold = 1
series.tickets.waitlist = { enabled: true }
const result = validateSeriesTicketPurchase(series, validUser)
expect(result.valid).toBe(false)
expect(result.reason).toBe('Series passes are sold out')
expect(result.waitlistAvailable).toBe(true)
})
it('returns valid with ticket info for good purchase', () => {
const series = ticketedSeries()
const result = validateSeriesTicketPurchase(series, validUser)
expect(result.valid).toBe(true)
expect(result.ticketInfo.ticketType).toBe('public')
expect(result.ticketInfo.price).toBe(60)
expect(result.availability.available).toBe(true)
})
it('returns member ticket info for authenticated member', () => {
const series = ticketedSeries()
const result = validateSeriesTicketPurchase(series, memberUser)
expect(result.valid).toBe(true)
expect(result.ticketInfo.ticketType).toBe('member')
expect(result.ticketInfo.price).toBe(20)
})
})
// ===========================================================================
// checkUserSeriesPass
// ===========================================================================
describe('checkUserSeriesPass', () => {
it('returns hasPass true when user has active registration', () => {
const series = {
registrations: [
{ email: 'user@example.com', cancelledAt: null, paymentStatus: 'completed' }
]
}
const result = checkUserSeriesPass(series, 'user@example.com')
expect(result.hasPass).toBe(true)
expect(result.registration).toBeDefined()
expect(result.registration.email).toBe('user@example.com')
})
it('returns hasPass false when no registration exists', () => {
const series = { registrations: [] }
const result = checkUserSeriesPass(series, 'user@example.com')
expect(result.hasPass).toBe(false)
expect(result.registration).toBeNull()
})
it('returns hasPass false when registration is cancelled', () => {
const series = {
registrations: [
{ email: 'user@example.com', cancelledAt: new Date(), paymentStatus: 'completed' }
]
}
const result = checkUserSeriesPass(series, 'user@example.com')
expect(result.hasPass).toBe(false)
})
it('returns hasPass false when payment failed', () => {
const series = {
registrations: [
{ email: 'user@example.com', cancelledAt: null, paymentStatus: 'failed' }
]
}
const result = checkUserSeriesPass(series, 'user@example.com')
expect(result.hasPass).toBe(false)
})
it('is case-insensitive on email', () => {
const series = {
registrations: [
{ email: 'USER@EXAMPLE.COM', cancelledAt: null, paymentStatus: 'completed' }
]
}
const result = checkUserSeriesPass(series, 'user@example.com')
expect(result.hasPass).toBe(true)
})
it('handles missing registrations array', () => {
const series = {}
const result = checkUserSeriesPass(series, 'user@example.com')
expect(result.hasPass).toBe(false)
expect(result.registration).toBeNull()
})
})