diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c041dbb --- /dev/null +++ b/.github/workflows/test.yml @@ -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 diff --git a/.gitignore b/.gitignore index b0bd776..15f2a76 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,7 @@ logs .env.* !.env.example scripts/*.js + +# Playwright +e2e/test-results/ +playwright-report/ diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..42c7238 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1 @@ +npm run test:run diff --git a/CLAUDE.md b/CLAUDE.md index ff59a89..3b1685d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 build # 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. -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 diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..32a2200 --- /dev/null +++ b/TESTING.md @@ -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. diff --git a/e2e/a11y.spec.js b/e2e/a11y.spec.js new file mode 100644 index 0000000..a7eb639 --- /dev/null +++ b/e2e/a11y.spec.js @@ -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 }) + }) +}) diff --git a/e2e/admin-events.spec.js b/e2e/admin-events.spec.js new file mode 100644 index 0000000..0fba9d0 --- /dev/null +++ b/e2e/admin-events.spec.js @@ -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') + }) +}) diff --git a/e2e/admin-members.spec.js b/e2e/admin-members.spec.js new file mode 100644 index 0000000..71bcb8b --- /dev/null +++ b/e2e/admin-members.spec.js @@ -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() + }) +}) diff --git a/e2e/auth.spec.js b/e2e/auth.spec.js new file mode 100644 index 0000000..6637cff --- /dev/null +++ b/e2e/auth.spec.js @@ -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() + }) +}) diff --git a/e2e/coming-soon.spec.js b/e2e/coming-soon.spec.js new file mode 100644 index 0000000..283bc5d --- /dev/null +++ b/e2e/coming-soon.spec.js @@ -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') + }) +}) diff --git a/e2e/events.spec.js b/e2e/events.spec.js new file mode 100644 index 0000000..7039ec2 --- /dev/null +++ b/e2e/events.spec.js @@ -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() + }) +}) diff --git a/e2e/helpers/auth.js b/e2e/helpers/auth.js new file mode 100644 index 0000000..7e4b6b4 --- /dev/null +++ b/e2e/helpers/auth.js @@ -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/**') +} diff --git a/e2e/helpers/fixtures.js b/e2e/helpers/fixtures.js new file mode 100644 index 0000000..93c05f6 --- /dev/null +++ b/e2e/helpers/fixtures.js @@ -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' diff --git a/e2e/join-flow.spec.js b/e2e/join-flow.spec.js new file mode 100644 index 0000000..aa63a2b --- /dev/null +++ b/e2e/join-flow.spec.js @@ -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) + }) +}) diff --git a/e2e/member-dashboard.spec.js b/e2e/member-dashboard.spec.js new file mode 100644 index 0000000..848c591 --- /dev/null +++ b/e2e/member-dashboard.spec.js @@ -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() + }) +}) diff --git a/e2e/member-profile.spec.js b/e2e/member-profile.spec.js new file mode 100644 index 0000000..06f9841 --- /dev/null +++ b/e2e/member-profile.spec.js @@ -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') + }) +}) diff --git a/e2e/updates.spec.js b/e2e/updates.spec.js new file mode 100644 index 0000000..21df40d --- /dev/null +++ b/e2e/updates.spec.js @@ -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) + }) +}) diff --git a/e2e/visual/pages.spec.js b/e2e/visual/pages.spec.js new file mode 100644 index 0000000..d00c458 --- /dev/null +++ b/e2e/visual/pages.spec.js @@ -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'), + ], + }) + }) + } +}) diff --git a/package-lock.json b/package-lock.json index 63183ff..1d0196c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,10 +32,13 @@ "zod": "^4.1.3" }, "devDependencies": { + "@axe-core/playwright": "^4.11.1", "@nuxt/test-utils": "^4.0.0", + "@playwright/test": "^1.59.1", "@tailwindcss/typography": "^0.5.19", "@types/jsonwebtoken": "^9.0.10", "@types/oidc-provider": "^9.5.0", + "husky": "^9.1.7", "jsdom": "^28.1.0", "vitest": "^4.0.18" } @@ -142,6 +145,19 @@ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", "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": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -4385,6 +4401,22 @@ "integrity": "sha512-fz0NOYUEYXtg1TBaPEEvtcBq3FfmLFuTe1VZw4M8sTWX129br5dguu3M15+plOQnc181ShYe67RfwhKgK89VnA==", "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": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -7765,6 +7797,16 @@ "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": { "version": "1.13.5", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", @@ -10933,6 +10975,22 @@ "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": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -13713,6 +13771,53 @@ "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": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", diff --git a/package.json b/package.json index 3661871..1f86d57 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,15 @@ "preview": "nuxt preview", "postinstall": "nuxt prepare", "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": { "@cloudinary/vue": "^1.13.3", @@ -37,10 +45,13 @@ "zod": "^4.1.3" }, "devDependencies": { + "@axe-core/playwright": "^4.11.1", "@nuxt/test-utils": "^4.0.0", + "@playwright/test": "^1.59.1", "@tailwindcss/typography": "^0.5.19", "@types/jsonwebtoken": "^9.0.10", "@types/oidc-provider": "^9.5.0", + "husky": "^9.1.7", "jsdom": "^28.1.0", "vitest": "^4.0.18" } diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..fa7b12a --- /dev/null +++ b/playwright.config.js @@ -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', + }, + }, +}) diff --git a/tests/client/composables/useMemberStatus.test.js b/tests/client/composables/useMemberStatus.test.js new file mode 100644 index 0000000..f2070b7 --- /dev/null +++ b/tests/client/composables/useMemberStatus.test.js @@ -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() + }) + }) +}) diff --git a/tests/server/api/admin-auth-guards.test.js b/tests/server/api/admin-auth-guards.test.js new file mode 100644 index 0000000..84074b6 --- /dev/null +++ b/tests/server/api/admin-auth-guards.test.js @@ -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) + } + } + }) + }) + } + }) + } +}) diff --git a/tests/server/api/admin-role-patch.test.js b/tests/server/api/admin-role-patch.test.js new file mode 100644 index 0000000..f8b64f7 --- /dev/null +++ b/tests/server/api/admin-role-patch.test.js @@ -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 } + ) + }) + }) +}) diff --git a/tests/server/api/auth-login.test.js b/tests/server/api/auth-login.test.js index 828240b..9e98c4b 100644 --- a/tests/server/api/auth-login.test.js +++ b/tests/server/api/auth-login.test.js @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('../../../server/models/member.js', () => ({ - default: { findOne: vi.fn() } + default: { findOne: vi.fn(), findByIdAndUpdate: vi.fn() } })) vi.mock('../../../server/utils/mongoose.js', () => ({ diff --git a/tests/server/api/auth-verify.test.js b/tests/server/api/auth-verify.test.js new file mode 100644 index 0000000..a16234d --- /dev/null +++ b/tests/server/api/auth-verify.test.js @@ -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' }) + }) +}) diff --git a/tests/server/api/dev-endpoints.test.js b/tests/server/api/dev-endpoints.test.js new file mode 100644 index 0000000..dbfeb99 --- /dev/null +++ b/tests/server/api/dev-endpoints.test.js @@ -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') + }) + }) +}) diff --git a/tests/server/api/event-registration.test.js b/tests/server/api/event-registration.test.js new file mode 100644 index 0000000..4ae7f25 --- /dev/null +++ b/tests/server/api/event-registration.test.js @@ -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') + }) +}) diff --git a/tests/server/api/helcim-payment.test.js b/tests/server/api/helcim-payment.test.js new file mode 100644 index 0000000..2423d9b --- /dev/null +++ b/tests/server/api/helcim-payment.test.js @@ -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' + }) + }) +}) diff --git a/tests/server/api/helcim-subscription.test.js b/tests/server/api/helcim-subscription.test.js new file mode 100644 index 0000000..6fc36d7 --- /dev/null +++ b/tests/server/api/helcim-subscription.test.js @@ -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', {}) + }) +}) diff --git a/tests/server/api/members-create.test.js b/tests/server/api/members-create.test.js new file mode 100644 index 0000000..c4a5fb5 --- /dev/null +++ b/tests/server/api/members-create.test.js @@ -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() + }) +}) diff --git a/tests/server/api/updates-auth.test.js b/tests/server/api/updates-auth.test.js new file mode 100644 index 0000000..1a54b72 --- /dev/null +++ b/tests/server/api/updates-auth.test.js @@ -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') + }) + }) +}) diff --git a/tests/server/api/upload-image.test.js b/tests/server/api/upload-image.test.js new file mode 100644 index 0000000..abcb361 --- /dev/null +++ b/tests/server/api/upload-image.test.js @@ -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) + } + }) +}) diff --git a/tests/server/setup.js b/tests/server/setup.js index 37575c2..3c01933 100644 --- a/tests/server/setup.js +++ b/tests/server/setup.js @@ -11,7 +11,8 @@ import { defineEventHandler, readBody, getQuery, - getRouterParam + getRouterParam, + sendRedirect } from 'h3' // 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('getQuery', getQuery) vi.stubGlobal('getRouterParam', getRouterParam) +vi.stubGlobal('sendRedirect', sendRedirect) 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))) diff --git a/tests/server/utils/tickets.test.js b/tests/server/utils/tickets.test.js new file mode 100644 index 0000000..53ede55 --- /dev/null +++ b/tests/server/utils/tickets.test.js @@ -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() + }) +})