chore(tests): replace source-grep tests with handler tests

This commit is contained in:
Jennie Robinson Faber 2026-04-27 11:13:35 +01:00
parent 00073ec52c
commit bafe24b778
3 changed files with 299 additions and 161 deletions

View file

@ -1,49 +1,139 @@
import { describe, it, expect } from 'vitest'
import { readFileSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, it, expect, vi, beforeEach } from 'vitest'
const eventsDir = resolve(import.meta.dirname, '../../../server/api/events/[id]')
import Event from '../../../server/models/event.js'
import Member from '../../../server/models/member.js'
import { waitlistSchema, waitlistDeleteSchema } from '../../../server/utils/schemas.js'
import waitlistPostHandler from '../../../server/api/events/[id]/waitlist.post.js'
import waitlistDeleteHandler from '../../../server/api/events/[id]/waitlist.delete.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
describe('waitlist.post.js bypasses validators on event.save()', () => {
const source = readFileSync(resolve(eventsDir, 'waitlist.post.js'), 'utf-8')
vi.mock('../../../server/models/event.js', () => ({
default: { findOne: vi.fn() }
}))
vi.mock('../../../server/models/member.js', () => ({
default: { findOne: vi.fn() }
}))
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
it('calls eventData.save with validateBeforeSave: false', () => {
expect(source).toContain('eventData.save({ validateBeforeSave: false })')
})
vi.stubGlobal('waitlistSchema', waitlistSchema)
vi.stubGlobal('waitlistDeleteSchema', waitlistDeleteSchema)
it('does not contain a bare eventData.save() call', () => {
expect(source).not.toMatch(/eventData\.save\(\s*\)/)
})
})
// Override the global validateBody stub so the route actually parses against
// the schema it passed in.
vi.stubGlobal('validateBody', vi.fn(async (event, schema) => {
const body = await readBody(event)
return schema.parse(body)
}))
describe('waitlist.delete.js bypasses validators on event.save()', () => {
const source = readFileSync(resolve(eventsDir, 'waitlist.delete.js'), 'utf-8')
it('calls eventData.save with validateBeforeSave: false', () => {
expect(source).toContain('eventData.save({ validateBeforeSave: false })')
})
it('does not contain a bare eventData.save() call', () => {
expect(source).not.toMatch(/eventData\.save\(\s*\)/)
})
})
// payment.post.js cases are handled by Fix #3 (file deletion).
// If the file still exists, it should also pass the validators bypass.
describe.skipIf(!existsSync(resolve(eventsDir, 'payment.post.js')))(
'payment.post.js bypasses validators on event.save()',
() => {
const source = existsSync(resolve(eventsDir, 'payment.post.js'))
? readFileSync(resolve(eventsDir, 'payment.post.js'), 'utf-8')
: ''
it('has exactly two eventData.save({ validateBeforeSave: false }) calls', () => {
const matches = source.match(/eventData\.save\(\{\s*validateBeforeSave:\s*false\s*\}\)/g) || []
expect(matches.length).toBe(2)
})
it('does not contain a bare eventData.save() call', () => {
expect(source).not.toMatch(/eventData\.save\(\s*\)/)
})
/**
* Build a mock Event document whose `save()` simulates the legacy validator
* problem we're protecting against: when called WITHOUT `validateBeforeSave:
* false` it throws (mimicking a stale `location` validator failing on
* unrelated writes). When called WITH `validateBeforeSave: false` it resolves
* normally. The route is correct iff it bypasses validators.
*/
function makeMockEvent(overrides = {}) {
const doc = {
_id: 'event-1',
slug: 'event-slug',
tickets: {
waitlist: {
enabled: true,
maxSize: 10,
entries: [],
},
},
registrations: [],
...overrides,
}
)
doc.save = vi.fn(async (options) => {
if (!options || options.validateBeforeSave !== false) {
const err = new Error('Validation failed: location: legacy field invalid')
err.name = 'ValidationError'
throw err
}
return doc
})
return doc
}
function buildPostEvent(body) {
const ev = createMockEvent({
method: 'POST',
path: '/api/events/event-slug/waitlist',
body,
})
ev.context = { params: { id: 'event-slug' } }
return ev
}
function buildDeleteEvent(body) {
const ev = createMockEvent({
method: 'DELETE',
path: '/api/events/event-slug/waitlist',
body,
})
ev.context = { params: { id: 'event-slug' } }
return ev
}
describe('POST /api/events/[id]/waitlist — bypasses save validators', () => {
beforeEach(() => {
vi.clearAllMocks()
Member.findOne.mockResolvedValue(null)
})
it('save() succeeds because the route passes { validateBeforeSave: false }', async () => {
const mockEvent = makeMockEvent()
Event.findOne.mockResolvedValue(mockEvent)
const result = await waitlistPostHandler(buildPostEvent({
name: 'Waiter',
email: 'wait@example.com',
}))
expect(result.success).toBe(true)
expect(mockEvent.save).toHaveBeenCalledTimes(1)
expect(mockEvent.save).toHaveBeenCalledWith({ validateBeforeSave: false })
// Entry was actually appended.
expect(mockEvent.tickets.waitlist.entries).toHaveLength(1)
expect(mockEvent.tickets.waitlist.entries[0].email).toBe('wait@example.com')
})
})
describe('DELETE /api/events/[id]/waitlist — bypasses save validators', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('save() succeeds because the route passes { validateBeforeSave: false }', async () => {
const mockEvent = makeMockEvent({
tickets: {
waitlist: {
enabled: true,
maxSize: 10,
entries: [
{
name: 'Waiter',
email: 'wait@example.com',
membershipLevel: 'non-member',
addedAt: new Date(),
notified: false,
},
],
},
},
})
Event.findOne.mockResolvedValue(mockEvent)
const result = await waitlistDeleteHandler(buildDeleteEvent({
email: 'wait@example.com',
}))
expect(result.success).toBe(true)
expect(mockEvent.save).toHaveBeenCalledTimes(1)
expect(mockEvent.save).toHaveBeenCalledWith({ validateBeforeSave: false })
// Entry was actually removed.
expect(mockEvent.tickets.waitlist.entries).toHaveLength(0)
})
})