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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,7 +11,8 @@ import {
defineEventHandler,
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)))

View file

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