chore(tests): replace source-grep tests with handler tests
This commit is contained in:
parent
00073ec52c
commit
bafe24b778
3 changed files with 299 additions and 161 deletions
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue