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,98 +1,177 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, it, expect, vi, beforeEach } from 'vitest'
const seriesDir = resolve(import.meta.dirname, '../../../server/api/series/[id]')
import Member from '../../../server/models/member.js'
import Series from '../../../server/models/series.js'
import Event from '../../../server/models/event.js'
import {
validateSeriesTicketPurchase,
completeSeriesTicketPurchase,
registerForAllSeriesEvents,
hasMemberAccess,
} from '../../../server/utils/tickets.js'
import { sendSeriesPassConfirmation } from '../../../server/utils/resend.js'
import { seriesTicketPurchaseSchema } from '../../../server/utils/schemas.js'
import handler from '../../../server/api/series/[id]/tickets/purchase.post.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
describe('series tickets/purchase.post.js — guest account upsert (Fix #8)', () => {
const source = readFileSync(resolve(seriesDir, 'tickets/purchase.post.js'), 'utf-8')
vi.mock('../../../server/models/member.js', () => ({
default: { findOne: vi.fn(), findOneAndUpdate: vi.fn() }
}))
vi.mock('../../../server/models/series.js', () => ({
default: { findOne: vi.fn() }
}))
vi.mock('../../../server/models/event.js', () => ({
default: {
find: vi.fn(() => ({ sort: vi.fn().mockResolvedValue([]) }))
}
}))
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
vi.mock('../../../server/utils/tickets.js', () => ({
validateSeriesTicketPurchase: vi.fn(),
calculateSeriesTicketPrice: vi.fn(),
reserveSeriesTicket: vi.fn(),
releaseSeriesTicket: vi.fn(),
completeSeriesTicketPurchase: vi.fn().mockResolvedValue(undefined),
registerForAllSeriesEvents: vi.fn().mockResolvedValue([]),
hasMemberAccess: vi.fn(() => false),
}))
vi.mock('../../../server/utils/resend.js', () => ({
sendSeriesPassConfirmation: vi.fn().mockResolvedValue(undefined),
}))
it('uses validateBody with seriesTicketPurchaseSchema', () => {
expect(source).toContain('validateBody(event, seriesTicketPurchaseSchema)')
// Auto-imports the handler relies on but the global setup doesn't stub.
const setAuthCookieMock = vi.fn()
vi.stubGlobal('setAuthCookie', setAuthCookieMock)
vi.stubGlobal('seriesTicketPurchaseSchema', seriesTicketPurchaseSchema)
// Capture schema passed to validateBody so we can prove the route validates
// against seriesTicketPurchaseSchema specifically.
const validateBodyCalls = []
const validateBodyMock = vi.fn(async (event, schema) => {
validateBodyCalls.push(schema)
// Mirror real behavior: parse body via the schema so invalid bodies still throw.
const body = await readBody(event)
return schema.parse(body)
})
vi.stubGlobal('validateBody', validateBodyMock)
function buildEvent(body) {
const ev = createMockEvent({
method: 'POST',
path: '/api/series/series-1/tickets/purchase',
body,
})
ev.context = { params: { id: 'series-1' } }
return ev
}
it('Case 1 (free) + Case 2 (paid): upserts a guest Member when unauthenticated buyer provides name+email', () => {
// Mirror event endpoint upsert pattern; ALWAYS-CREATE-GUEST (no opt-in
// checkbox), so guard is `if (!member)` rather than `if (!member && body.createAccount)`.
expect(source).toContain('findOneAndUpdate')
expect(source).toContain('$setOnInsert')
expect(source).toContain('status: "guest"')
expect(source).toContain('upsert: true')
expect(source).toContain('circle: "community"')
expect(source).toContain('contributionAmount: 0')
// ALWAYS-CREATE — must NOT gate on a createAccount flag
expect(source).not.toContain('body.createAccount')
})
it('Case 3 (idempotency): upsert pattern handles concurrent same-email registrations atomically', () => {
// findOneAndUpdate with $setOnInsert + upsert:true is the idempotent pattern;
// email has a unique index. No duplicate Member doc created on retry.
expect(source).toMatch(/findOneAndUpdate\(\s*\{\s*email:/)
expect(source).toContain('upsert: true')
expect(source).toContain('new: true')
expect(source).toContain('setDefaultsOnInsert: true')
})
it('Case 4 (existing real member): does not auto-login real members entered via public form', () => {
// Auto-login only for newly-created accounts and existing guests.
// Real members (active/pending_payment) get requiresSignIn: true instead.
expect(source).toContain('accountCreated || member.status === "guest"')
expect(source).toContain('requiresSignIn = true')
})
it('Case 5 (authenticated guest): sets auth cookie on signedIn:true response', () => {
// setAuthCookie fires for both new accounts and returning guests.
expect(source).toContain('setAuthCookie(event, member)')
expect(source).toContain('signedIn = true')
})
it('Case 6 (missing fields): relies on schema validation to reject missing name/email', () => {
// No new validation logic added — existing seriesTicketPurchaseSchema
// already requires name+email; validateBody throws 400 if missing.
expect(source).toContain('validateBody(event, seriesTicketPurchaseSchema)')
})
it('includes accountCreated, signedIn, and requiresSignIn in response (parity with event endpoint)', () => {
expect(source).toContain('accountCreated,')
expect(source).toContain('signedIn,')
expect(source).toContain('requiresSignIn,')
})
it('still uses hasMemberAccess to gate member pricing (guest/suspended/cancelled treated as non-members)', () => {
expect(source).toContain('hasMemberAccess(member)')
})
it('preserves try/catch around requireAuth so unauthenticated callers fall through', () => {
// Required for unauth guest-purchase flow to work at all.
expect(source).toMatch(/try\s*\{[^}]*requireAuth\(event\)[^}]*\}\s*catch/s)
})
it('does not block purchase when confirmation email fails', () => {
const emailCallIndex = source.indexOf('await sendSeriesPassConfirmation')
expect(emailCallIndex).toBeGreaterThan(-1)
const afterEmail = source.slice(emailCallIndex)
const catchBlock = afterEmail.match(/catch\s*\(\w+\)\s*\{[^}]*\}/s)
expect(catchBlock).not.toBeNull()
expect(catchBlock[0]).toContain('console.error')
})
const baseSeries = () => ({
_id: 'series-1',
id: 'series-1',
slug: 'series-slug',
title: 'Test Series',
description: 'desc',
type: 'workshop',
registrations: [],
})
describe('SeriesPassPurchase.vue — client auth refresh (Fix #8)', () => {
const source = readFileSync(
resolve(import.meta.dirname, '../../../app/components/SeriesPassPurchase.vue'),
'utf-8'
)
it('refreshes client auth state via useAuth().checkMemberStatus() when server reports signedIn', () => {
expect(source).toContain('useAuth().checkMemberStatus()')
expect(source).toMatch(/purchaseResponse\?\.signedIn/)
describe('POST /api/series/[id]/tickets/purchase — guest upsert + auth cookie', () => {
beforeEach(() => {
vi.clearAllMocks()
validateBodyCalls.length = 0
// Default: unauthenticated buyer
globalThis.requireAuth = vi.fn().mockRejectedValue(
Object.assign(new Error('Unauthorized'), { statusCode: 401 })
)
Series.findOne.mockResolvedValue(baseSeries())
Member.findOne.mockResolvedValue(null)
validateSeriesTicketPurchase.mockReturnValue({
valid: true,
ticketInfo: {
ticketType: 'public',
price: 0,
currency: 'CAD',
isFree: true,
},
})
})
it('shows a one-line guest-account hint under the form (no checkbox)', () => {
// Per ALWAYS-CREATE-GUEST decision: hint only, no UI control.
expect(source).toMatch(/free guest account/i)
// Make sure no checkbox was added by mistake.
expect(source).not.toMatch(/createAccount/)
expect(source).not.toMatch(/<input[^>]*type="checkbox"/i)
it('upserts a guest Member with $setOnInsert + upsert:true when buyer has no account', async () => {
Member.findOneAndUpdate.mockResolvedValue({
_id: 'new-member-1',
email: 'guest@example.com',
status: 'guest',
})
await handler(buildEvent({
name: 'Guest Buyer',
email: 'guest@example.com',
ticketType: 'public',
}))
expect(Member.findOneAndUpdate).toHaveBeenCalledTimes(1)
const [filter, update, options] = Member.findOneAndUpdate.mock.calls[0]
expect(filter).toEqual({ email: 'guest@example.com' })
expect(update).toEqual({
$setOnInsert: {
email: 'guest@example.com',
name: 'Guest Buyer',
circle: 'community',
contributionAmount: 0,
status: 'guest',
},
})
expect(options).toEqual({
upsert: true,
new: true,
setDefaultsOnInsert: true,
})
})
it('sets the auth cookie for newly-created guest accounts', async () => {
const newMember = {
_id: 'new-member-2',
email: 'newbie@example.com',
status: 'guest',
}
Member.findOneAndUpdate.mockResolvedValue(newMember)
const result = await handler(buildEvent({
name: 'Newbie',
email: 'newbie@example.com',
ticketType: 'public',
}))
expect(setAuthCookieMock).toHaveBeenCalledTimes(1)
expect(setAuthCookieMock.mock.calls[0][1]).toBe(newMember)
expect(result.signedIn).toBe(true)
expect(result.accountCreated).toBe(true)
expect(result.requiresSignIn).toBe(false)
})
it('validates input via seriesTicketPurchaseSchema', async () => {
Member.findOneAndUpdate.mockResolvedValue({
_id: 'm',
email: 'a@b.com',
status: 'guest',
})
await handler(buildEvent({
name: 'A',
email: 'a@b.com',
ticketType: 'public',
}))
expect(validateBodyMock).toHaveBeenCalled()
expect(validateBodyCalls[0]).toBe(seriesTicketPurchaseSchema)
})
it('rejects invalid body (no name) via schema validation', async () => {
await expect(
handler(buildEvent({ email: 'a@b.com', ticketType: 'public' }))
).rejects.toBeDefined()
expect(Member.findOneAndUpdate).not.toHaveBeenCalled()
expect(setAuthCookieMock).not.toHaveBeenCalled()
})
})