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)
}
})
})