feat: add testing infrastructure — Vitest, Playwright, CI, git hooks
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:
parent
036af95e00
commit
1e30ba23cd
35 changed files with 3637 additions and 5 deletions
94
.github/workflows/test.yml
vendored
Normal file
94
.github/workflows/test.yml
vendored
Normal 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
4
.gitignore
vendored
|
|
@ -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
1
.husky/pre-push
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
npm run test:run
|
||||||
|
|
@ -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
90
TESTING.md
Normal 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
112
e2e/a11y.spec.js
Normal 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
55
e2e/admin-events.spec.js
Normal 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
48
e2e/admin-members.spec.js
Normal 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
54
e2e/auth.spec.js
Normal 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
41
e2e/coming-soon.spec.js
Normal 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
64
e2e/events.spec.js
Normal 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
21
e2e/helpers/auth.js
Normal 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
23
e2e/helpers/fixtures.js
Normal 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
83
e2e/join-flow.spec.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
35
e2e/member-dashboard.spec.js
Normal file
35
e2e/member-dashboard.spec.js
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
51
e2e/member-profile.spec.js
Normal file
51
e2e/member-profile.spec.js
Normal 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
123
e2e/updates.spec.js
Normal 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
73
e2e/visual/pages.spec.js
Normal 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
105
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
13
package.json
13
package.json
|
|
@ -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
31
playwright.config.js
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
218
tests/client/composables/useMemberStatus.test.js
Normal file
218
tests/client/composables/useMemberStatus.test.js
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
85
tests/server/api/admin-auth-guards.test.js
Normal file
85
tests/server/api/admin-auth-guards.test.js
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
165
tests/server/api/admin-role-patch.test.js
Normal file
165
tests/server/api/admin-role-patch.test.js
Normal 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 }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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', () => ({
|
||||||
|
|
|
||||||
221
tests/server/api/auth-verify.test.js
Normal file
221
tests/server/api/auth-verify.test.js
Normal 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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
168
tests/server/api/dev-endpoints.test.js
Normal file
168
tests/server/api/dev-endpoints.test.js
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
89
tests/server/api/event-registration.test.js
Normal file
89
tests/server/api/event-registration.test.js
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
201
tests/server/api/helcim-payment.test.js
Normal file
201
tests/server/api/helcim-payment.test.js
Normal 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'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
159
tests/server/api/helcim-subscription.test.js
Normal file
159
tests/server/api/helcim-subscription.test.js
Normal 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', {})
|
||||||
|
})
|
||||||
|
})
|
||||||
109
tests/server/api/members-create.test.js
Normal file
109
tests/server/api/members-create.test.js
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
112
tests/server/api/updates-auth.test.js
Normal file
112
tests/server/api/updates-auth.test.js
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
38
tests/server/api/upload-image.test.js
Normal file
38
tests/server/api/upload-image.test.js
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)))
|
||||||
|
|
|
||||||
936
tests/server/utils/tickets.test.js
Normal file
936
tests/server/utils/tickets.test.js
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue