feat(launch): security and correctness fixes for 2026-05-01 launch

Day-of-launch deep-dive audit and remediation. 11 issues fixed across
security, correctness, and reliability. Tests: 698 → 758 passing
(+60), 0 failing, 2 skipped.

CRITICAL (security)

Fix #1 — HELCIM_API_TOKEN removed from runtimeConfig.public; dead
useHelcim.js deleted. Production token MUST BE ROTATED post-deploy
(was previously exposed in window.__NUXT__ payload).

Fix #2 — /api/helcim/customer gated with origin check + per-IP/email
rate limit + magic-link email verification (replaces unauthenticated
setAuthCookie). Adds payment-bridge token for paid-tier signup so
users can complete Helcim checkout before email verify. New utils:
server/utils/{magicLink,rateLimit}.js. UX: signup success copy now
prompts user to check email.

Fix #3 — /api/events/[id]/payment deleted (dead code with unauth
member-spoof bypass — processHelcimPayment was a permanent stub).
Removes processHelcimPayment export and eventPaymentSchema.

Fix #4 — /api/helcim/initialize-payment re-derives ticket amount
server-side via calculateTicketPrice and calculateSeriesTicketPrice.
Adds new series_ticket metadata type (was being shoved through
event_ticket with seriesId in metadata.eventId).

Fix #5 — /api/helcim/customer upgrades existing status:guest members
in place rather than rejecting with 409. Lowercases email at lookup;
preserves _id so prior event registrations stay linked.

HIGH (correctness / reliability)

Fix #6 — Daily reconciliation cron via Netlify scheduled function
(@daily). New: netlify.toml, netlify/functions/reconcile-payments.mjs,
server/api/internal/reconcile-payments.post.js. Shared-secret auth
via NUXT_RECONCILE_TOKEN env var. Inline 3-retry exponential backoff
on Helcim transactions API.

Fix #7 — validateBeforeSave: false on event subdoc saves (waitlist
endpoints) to dodge legacy location validators.

Fix #8 — /api/series/[id]/tickets/purchase always upserts a guest
Member when caller is unauthenticated, mirrors event-ticket flow
byte-for-byte. SeriesPassPurchase.vue adds guest-account hint and
client auth refresh on signedIn:true response.

Fix #9 — /api/members/cancel-subscription leaves status active per
ratified bylaws (was pending_payment). Adds lastCancelledAt audit
field on Member model. Indirectly fixes false-positive
detectStuckPendingPayment admin alert for cancelled members.

Fix #10 — /api/auth/verify uses validateBody with strict() Zod schema
(verifyMagicLinkSchema, max 2000 chars).

Fix #11 — 8 vitest cases for cancel-subscription handler (was
uncovered).

Specs and audit at docs/superpowers/specs/2026-04-25-fix-*.md and
docs/superpowers/plans/2026-04-25-launch-readiness-fixes.md.
LAUNCH_READINESS.md updated with new test count, 3 deploy-time
tasks (rotate Helcim token, set NUXT_RECONCILE_TOKEN, verify
Netlify scheduled function), and Fixed-2026-04-25 fix log.
This commit is contained in:
Jennie Robinson Faber 2026-04-25 18:42:36 +01:00
parent 0f2f1d1cbf
commit 208638e374
37 changed files with 1980 additions and 340 deletions

View file

@ -1,5 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
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'
vi.mock('../../../server/models/member.js', () => ({
default: { findById: vi.fn(), findByIdAndUpdate: vi.fn() }
}))
@ -15,11 +20,6 @@ vi.mock('jsonwebtoken', () => ({
}
}))
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',
@ -44,10 +44,94 @@ describe('auth verify endpoint', () => {
await expect(verifyHandler(event)).rejects.toMatchObject({
statusCode: 400,
statusMessage: 'Token is required'
statusMessage: 'Validation failed'
})
})
it('rejects non-string number token with 400 before jwt.verify', async () => {
const event = createMockEvent({
method: 'POST',
path: '/api/auth/verify',
body: { token: 123 }
})
await expect(verifyHandler(event)).rejects.toMatchObject({
statusCode: 400,
statusMessage: 'Validation failed'
})
expect(jwt.verify).not.toHaveBeenCalled()
})
it('rejects non-string boolean token with 400 before jwt.verify', async () => {
const event = createMockEvent({
method: 'POST',
path: '/api/auth/verify',
body: { token: true }
})
await expect(verifyHandler(event)).rejects.toMatchObject({
statusCode: 400,
statusMessage: 'Validation failed'
})
expect(jwt.verify).not.toHaveBeenCalled()
})
it('rejects empty string token with 400 before jwt.verify', async () => {
const event = createMockEvent({
method: 'POST',
path: '/api/auth/verify',
body: { token: '' }
})
await expect(verifyHandler(event)).rejects.toMatchObject({
statusCode: 400,
statusMessage: 'Validation failed'
})
expect(jwt.verify).not.toHaveBeenCalled()
})
it('rejects oversized token (>2000 chars) with 400 before jwt.verify', async () => {
const event = createMockEvent({
method: 'POST',
path: '/api/auth/verify',
body: { token: 'x'.repeat(2001) }
})
await expect(verifyHandler(event)).rejects.toMatchObject({
statusCode: 400,
statusMessage: 'Validation failed'
})
expect(jwt.verify).not.toHaveBeenCalled()
})
it('rejects null body with 400 before jwt.verify', async () => {
const event = createMockEvent({
method: 'POST',
path: '/api/auth/verify',
body: null
})
await expect(verifyHandler(event)).rejects.toMatchObject({
statusCode: 400,
statusMessage: 'Validation failed'
})
expect(jwt.verify).not.toHaveBeenCalled()
})
it('rejects unknown extra keys with 400 before jwt.verify (strict mode)', async () => {
const event = createMockEvent({
method: 'POST',
path: '/api/auth/verify',
body: { token: 'valid-token', email: 'extra@example.com' }
})
await expect(verifyHandler(event)).rejects.toMatchObject({
statusCode: 400,
statusMessage: 'Validation failed'
})
expect(jwt.verify).not.toHaveBeenCalled()
})
it('rejects invalid JWT with 401', async () => {
jwt.verify.mockImplementation(() => { throw new Error('invalid') })

View file

@ -0,0 +1,201 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import Member from '../../../server/models/member.js'
import { cancelHelcimSubscription } from '../../../server/utils/helcim.js'
import handler from '../../../server/api/members/cancel-subscription.post.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
vi.mock('../../../server/models/member.js', () => ({
default: { findByIdAndUpdate: vi.fn() }
}))
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn() }))
vi.mock('../../../server/utils/helcim.js', () => ({
cancelHelcimSubscription: vi.fn(),
}))
// Nitro auto-imports — stub as globals
const logActivityMock = vi.fn()
vi.stubGlobal('logActivity', logActivityMock)
function setMember(mockMember) {
globalThis.requireAuth = vi.fn().mockResolvedValue(mockMember)
}
function buildEvent() {
return createMockEvent({
method: 'POST',
path: '/api/members/cancel-subscription',
body: {},
})
}
describe('cancel-subscription endpoint', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('happy path: active paid member → cancels Helcim, drops to free tier (status stays active), returns success', async () => {
const mockMember = {
_id: 'member-1',
status: 'active',
contributionAmount: 15,
helcimSubscriptionId: 'sub-1',
}
setMember(mockMember)
cancelHelcimSubscription.mockResolvedValue({})
Member.findByIdAndUpdate.mockResolvedValue({})
const result = await handler(buildEvent())
expect(cancelHelcimSubscription).toHaveBeenCalledWith('sub-1')
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
'member-1',
expect.objectContaining({
$set: expect.objectContaining({
status: 'active',
contributionAmount: 0,
helcimSubscriptionId: null,
paymentMethod: 'none',
}),
$unset: expect.objectContaining({ nextBillingDate: 1 }),
}),
{ runValidators: false }
)
// Per Fix #9: lastCancelledAt must be set as a Date
const updateArg = Member.findByIdAndUpdate.mock.calls[0][1]
expect(updateArg.$set.lastCancelledAt).toBeInstanceOf(Date)
expect(result).toEqual(expect.objectContaining({
success: true,
message: 'Subscription cancelled successfully',
status: 'active',
contributionAmount: 0,
}))
})
it('persists subscriptionEndDate as a Date instance', async () => {
setMember({
_id: 'member-2',
status: 'active',
contributionAmount: 5,
helcimSubscriptionId: 'sub-2',
})
cancelHelcimSubscription.mockResolvedValue({})
Member.findByIdAndUpdate.mockResolvedValue({})
await handler(buildEvent())
const call = Member.findByIdAndUpdate.mock.calls[0]
expect(call[1].$set.subscriptionEndDate).toBeInstanceOf(Date)
})
it('emits subscription_cancelled activity log entry', async () => {
setMember({
_id: 'member-3',
status: 'active',
contributionAmount: 15,
helcimSubscriptionId: 'sub-3',
})
cancelHelcimSubscription.mockResolvedValue({})
Member.findByIdAndUpdate.mockResolvedValue({})
await handler(buildEvent())
expect(logActivityMock).toHaveBeenCalledWith(
'member-3',
'subscription_cancelled',
expect.objectContaining({ effectiveDate: expect.any(String) })
)
})
it('free-tier member (contributionAmount=0): no Helcim call, no DB write, returns no-op success', async () => {
const mockMember = {
_id: 'member-4',
status: 'active',
contributionAmount: 0,
helcimSubscriptionId: null,
}
setMember(mockMember)
const result = await handler(buildEvent())
expect(cancelHelcimSubscription).not.toHaveBeenCalled()
expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
expect(logActivityMock).not.toHaveBeenCalled()
expect(result).toEqual({
success: true,
message: 'No active subscription to cancel',
status: 'active',
contributionAmount: 0,
})
})
it('member without helcimSubscriptionId (data inconsistency): no Helcim call, no DB write', async () => {
setMember({
_id: 'member-5',
status: 'active',
contributionAmount: 15,
helcimSubscriptionId: null,
})
const result = await handler(buildEvent())
expect(cancelHelcimSubscription).not.toHaveBeenCalled()
expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
expect(result.success).toBe(true)
expect(result.message).toBe('No active subscription to cancel')
})
it('Helcim cancel failure: swallows error, still updates Member to free tier (status stays active)', async () => {
setMember({
_id: 'member-6',
status: 'active',
contributionAmount: 15,
helcimSubscriptionId: 'sub-6',
})
cancelHelcimSubscription.mockRejectedValue(new Error('Helcim 503'))
Member.findByIdAndUpdate.mockResolvedValue({})
const result = await handler(buildEvent())
expect(cancelHelcimSubscription).toHaveBeenCalledWith('sub-6')
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
'member-6',
expect.objectContaining({
$set: expect.objectContaining({ status: 'active', helcimSubscriptionId: null }),
}),
{ runValidators: false }
)
expect(result.success).toBe(true)
expect(result.status).toBe('active')
})
it('unauthenticated: requireAuth throws → handler rejects with that statusCode', async () => {
globalThis.requireAuth = vi.fn().mockRejectedValue(
Object.assign(new Error('Unauthorized'), { statusCode: 401 })
)
await expect(handler(buildEvent())).rejects.toMatchObject({
statusCode: 401,
})
expect(cancelHelcimSubscription).not.toHaveBeenCalled()
expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
})
it('Mongo update failure: rejects with 500', async () => {
setMember({
_id: 'member-7',
status: 'active',
contributionAmount: 15,
helcimSubscriptionId: 'sub-7',
})
cancelHelcimSubscription.mockResolvedValue({})
Member.findByIdAndUpdate.mockRejectedValue(new Error('Mongo down'))
await expect(handler(buildEvent())).rejects.toMatchObject({
statusCode: 500,
statusMessage: 'Mongo down',
})
})
})

View file

@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest'
import { readFileSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'
const eventsDir = resolve(import.meta.dirname, '../../../server/api/events/[id]')
describe('waitlist.post.js bypasses validators on event.save()', () => {
const source = readFileSync(resolve(eventsDir, 'waitlist.post.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*\)/)
})
})
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*\)/)
})
}
)

View file

@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest'
import { existsSync } from 'node:fs'
import { resolve } from 'node:path'
/**
* Regression: `events/[id]/payment.post.js` was deleted because its
* unauthenticated POST allowed any caller to spam-register an existing
* member to any paid event by supplying their email. See
* docs/superpowers/specs/2026-04-25-fix-3.md.
*
* With the route file gone, Nitro's filesystem router will not register
* a handler at `/api/events/{id}/payment`, so a POST returns 404 the
* spam-register attack surface no longer exists at the network layer.
*/
describe('events/[id]/payment route deletion', () => {
it('the payment.post.js route file no longer exists', () => {
const routePath = resolve(
import.meta.dirname,
'../../../../server/api/events/[id]/payment.post.js'
)
expect(existsSync(routePath)).toBe(false)
})
it('the secure replacement at tickets/purchase.post.js still exists', () => {
const replacementPath = resolve(
import.meta.dirname,
'../../../../server/api/events/[id]/tickets/purchase.post.js'
)
expect(existsSync(replacementPath)).toBe(true)
})
})

View file

@ -0,0 +1,385 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import Member from '../../../server/models/member.js'
import { createHelcimCustomer } from '../../../server/utils/helcim.js'
import { sendMagicLink } from '../../../server/utils/magicLink.js'
import { setAuthCookie, setPaymentBridgeCookie } from '../../../server/utils/auth.js'
import customerHandler from '../../../server/api/helcim/customer.post.js'
import { resetRateLimit } from '../../../server/utils/rateLimit.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
// --- Mocks ---
vi.mock('../../../server/models/member.js', () => ({
default: { findOne: vi.fn(), create: vi.fn(), findByIdAndUpdate: vi.fn() }
}))
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
vi.mock('../../../server/utils/helcim.js', () => ({
createHelcimCustomer: vi.fn()
}))
vi.mock('../../../server/utils/magicLink.js', () => ({
sendMagicLink: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('../../../server/utils/auth.js', () => ({
setAuthCookie: vi.fn(),
setPaymentBridgeCookie: vi.fn()
}))
// helcimCustomerSchema is auto-imported in the handler — stub it to a passthrough
vi.stubGlobal('helcimCustomerSchema', {})
// Helper to build a same-origin request body+headers
const ALLOWED_ORIGIN = 'https://ghostguild.test'
function build(opts = {}) {
const {
origin = ALLOWED_ORIGIN,
remoteAddress = '127.0.0.1',
body = {
name: 'Test User',
email: 'test@example.com',
circle: 'community',
contributionAmount: 0,
agreedToGuidelines: true
}
} = opts
const headers = {}
if (origin !== null) headers.origin = origin
return createMockEvent({
method: 'POST',
path: '/api/helcim/customer',
body,
headers,
remoteAddress
})
}
describe('POST /api/helcim/customer', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.BASE_URL = ALLOWED_ORIGIN
resetRateLimit()
Member.findOne.mockResolvedValue(null)
Member.create.mockImplementation(async (doc) => ({ _id: 'mem-1', ...doc }))
Member.findByIdAndUpdate.mockImplementation(async (id, update) => ({
_id: id,
...(update?.$set || {})
}))
createHelcimCustomer.mockResolvedValue({ id: 'cust-1', customerCode: 'CUST-1' })
})
describe('origin check', () => {
it('rejects requests with missing Origin header', async () => {
const event = build({ origin: null })
await expect(customerHandler(event)).rejects.toMatchObject({
statusCode: 403,
statusMessage: 'Invalid origin'
})
expect(Member.create).not.toHaveBeenCalled()
})
it('rejects requests with foreign Origin header', async () => {
const event = build({ origin: 'https://attacker.example' })
await expect(customerHandler(event)).rejects.toMatchObject({
statusCode: 403,
statusMessage: 'Invalid origin'
})
expect(Member.create).not.toHaveBeenCalled()
})
it('accepts requests with matching Origin header', async () => {
const event = build()
const result = await customerHandler(event)
expect(result.success).toBe(true)
})
})
describe('rate limiting', () => {
it('rate-limits a single IP after 5 signup attempts', async () => {
// 5 calls succeed (each with a unique email so we don't hit email limit
// and don't hit the dedupe 409)
for (let i = 0; i < 5; i++) {
Member.findOne.mockResolvedValueOnce(null)
const event = build({
remoteAddress: '10.0.0.1',
body: {
name: 'User',
email: `u${i}@example.com`,
circle: 'community',
contributionAmount: 0,
agreedToGuidelines: true
}
})
await customerHandler(event)
}
// 6th call returns 429
const event = build({
remoteAddress: '10.0.0.1',
body: {
name: 'User',
email: 'u6@example.com',
circle: 'community',
contributionAmount: 0,
agreedToGuidelines: true
}
})
await expect(customerHandler(event)).rejects.toMatchObject({
statusCode: 429
})
})
it('rate-limits a single email after 3 signup attempts (different IPs)', async () => {
const email = 'shared@example.com'
for (let i = 0; i < 3; i++) {
Member.findOne.mockResolvedValueOnce(null)
const event = build({
remoteAddress: `10.0.0.${i + 10}`,
body: {
name: 'User',
email,
circle: 'community',
contributionAmount: 0,
agreedToGuidelines: true
}
})
await customerHandler(event)
}
// 4th call returns 429
const event = build({
remoteAddress: '10.0.0.99',
body: {
name: 'User',
email,
circle: 'community',
contributionAmount: 0,
agreedToGuidelines: true
}
})
await expect(customerHandler(event)).rejects.toMatchObject({
statusCode: 429
})
})
})
describe('existing-member dedupe (Fix #5)', () => {
it('brand-new email succeeds and creates a pending_payment member', async () => {
Member.findOne.mockResolvedValue(null)
const event = build({
body: {
name: 'Brand New',
email: 'brandnew@example.com',
circle: 'community',
contributionAmount: 0,
agreedToGuidelines: true
}
})
const result = await customerHandler(event)
expect(result.success).toBe(true)
expect(result.customerId).toBe('cust-1')
expect(result.member.status).toBe('pending_payment')
expect(Member.create).toHaveBeenCalledTimes(1)
expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
})
it('returns 409 for an existing active member', async () => {
Member.findOne.mockResolvedValue({
_id: 'mem-active',
email: 'active@example.com',
status: 'active'
})
const event = build({
body: {
name: 'Active User',
email: 'active@example.com',
circle: 'community',
contributionAmount: 0,
agreedToGuidelines: true
}
})
await expect(customerHandler(event)).rejects.toMatchObject({
statusCode: 409,
statusMessage: 'A member with this email already exists'
})
expect(Member.create).not.toHaveBeenCalled()
expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
expect(createHelcimCustomer).not.toHaveBeenCalled()
})
it('returns 409 for an existing pending_payment member (re-submit guard)', async () => {
Member.findOne.mockResolvedValue({
_id: 'mem-pending',
email: 'pending@example.com',
status: 'pending_payment'
})
const event = build({
body: {
name: 'Pending User',
email: 'pending@example.com',
circle: 'community',
contributionAmount: 0,
agreedToGuidelines: true
}
})
await expect(customerHandler(event)).rejects.toMatchObject({
statusCode: 409
})
expect(Member.create).not.toHaveBeenCalled()
expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
expect(createHelcimCustomer).not.toHaveBeenCalled()
})
it('returns 409 for suspended and cancelled members', async () => {
for (const status of ['suspended', 'cancelled']) {
vi.clearAllMocks()
resetRateLimit()
Member.findOne.mockResolvedValue({ _id: `mem-${status}`, status })
const event = build({
body: {
name: 'User',
email: `${status}@example.com`,
circle: 'community',
contributionAmount: 0,
agreedToGuidelines: true
}
})
await expect(customerHandler(event)).rejects.toMatchObject({
statusCode: 409
})
expect(Member.create).not.toHaveBeenCalled()
expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
expect(createHelcimCustomer).not.toHaveBeenCalled()
}
})
it('upgrades an existing guest member in place (preserves _id)', async () => {
const guestId = 'guest-1'
Member.findOne.mockResolvedValue({
_id: guestId,
email: 'guest@example.com',
name: 'Old Guest Name',
circle: 'community',
contributionAmount: 0,
status: 'guest'
})
const event = build({
body: {
name: 'New Member Name',
email: 'guest@example.com',
circle: 'founder',
contributionAmount: 25,
agreedToGuidelines: true
}
})
const result = await customerHandler(event)
// No new member doc created — existing guest is reused.
expect(Member.create).not.toHaveBeenCalled()
// Helcim customer created for the upgraded member.
expect(createHelcimCustomer).toHaveBeenCalledWith({
customerType: 'PERSON',
contactName: 'New Member Name',
email: 'guest@example.com'
})
// findByIdAndUpdate called with guest's _id (preservation) and the form fields.
expect(Member.findByIdAndUpdate).toHaveBeenCalledTimes(1)
const [updateId, updatePayload, updateOpts] = Member.findByIdAndUpdate.mock.calls[0]
expect(updateId).toBe(guestId)
expect(updatePayload.$set).toMatchObject({
name: 'New Member Name',
circle: 'founder',
contributionAmount: 25,
helcimCustomerId: 'cust-1',
status: 'pending_payment'
})
expect(updatePayload.$set['agreement.acceptedAt']).toBeInstanceOf(Date)
expect(updateOpts).toMatchObject({ new: true, runValidators: false })
// Magic link still issued, paid-tier bridge cookie still set.
expect(sendMagicLink).toHaveBeenCalledWith(
'guest@example.com',
expect.objectContaining({ subject: 'Verify your Ghost Guild signup' })
)
expect(setPaymentBridgeCookie).toHaveBeenCalled()
expect(setAuthCookie).not.toHaveBeenCalled()
// Response shape mirrors new-signup case AND surfaces the preserved _id.
expect(result.success).toBe(true)
expect(result.member.id).toBe(guestId)
expect(result.member.status).toBe('pending_payment')
expect(result.customerId).toBe('cust-1')
})
it('lowercases mixed-case email at the existence lookup', async () => {
Member.findOne.mockResolvedValue({
_id: 'guest-mixed',
email: 'foo@example.com',
name: 'Existing Guest',
circle: 'community',
contributionAmount: 0,
status: 'guest'
})
const event = build({
body: {
name: 'Foo Bar',
email: 'Foo@Example.com',
circle: 'community',
contributionAmount: 0,
agreedToGuidelines: true
}
})
await customerHandler(event)
expect(Member.findOne).toHaveBeenCalledWith({ email: 'foo@example.com' })
expect(Member.findByIdAndUpdate).toHaveBeenCalledTimes(1)
expect(Member.create).not.toHaveBeenCalled()
})
})
describe('no auth cookie + magic link', () => {
it('does not set auth-token cookie on free-tier signup', async () => {
const event = build()
await customerHandler(event)
expect(setAuthCookie).not.toHaveBeenCalled()
const cookieHeader = event._testSetHeaders['set-cookie']
const cookies = Array.isArray(cookieHeader) ? cookieHeader.join(';') : (cookieHeader || '')
expect(cookies).not.toContain('auth-token=')
})
it('sends a magic link to the new member email', async () => {
const event = build({
body: {
name: 'New User',
email: 'newuser@example.com',
circle: 'community',
contributionAmount: 0,
agreedToGuidelines: true
}
})
await customerHandler(event)
expect(sendMagicLink).toHaveBeenCalledWith(
'newuser@example.com',
expect.objectContaining({ subject: 'Verify your Ghost Guild signup' })
)
})
it('sets a payment-bridge cookie on paid-tier signup so checkout can proceed', async () => {
const event = build({
body: {
name: 'Paid User',
email: 'paid@example.com',
circle: 'community',
contributionAmount: 25,
agreedToGuidelines: true
}
})
await customerHandler(event)
expect(setPaymentBridgeCookie).toHaveBeenCalled()
expect(sendMagicLink).toHaveBeenCalledWith(
'paid@example.com',
expect.objectContaining({ subject: 'Verify your Ghost Guild signup' })
)
// still no full session cookie
expect(setAuthCookie).not.toHaveBeenCalled()
})
})
})

View file

@ -1,14 +1,23 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { requireAuth } from '../../../server/utils/auth.js'
import { requireAuth, getOptionalMember } from '../../../server/utils/auth.js'
import { validateBody as importedValidateBody } from '../../../server/utils/validateBody.js'
import { loadPublicEvent } from '../../../server/utils/loadEvent.js'
import Member from '../../../server/models/member.js'
import Series from '../../../server/models/series.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'
vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn() }))
vi.mock('../../../server/utils/auth.js', () => ({
requireAuth: vi.fn(),
getOptionalMember: vi.fn()
}))
vi.mock('../../../server/utils/validateBody.js', () => ({ validateBody: vi.fn() }))
vi.mock('../../../server/utils/schemas.js', () => ({ paymentVerifySchema: {} }))
vi.mock('../../../server/utils/loadEvent.js', () => ({ loadPublicEvent: vi.fn() }))
vi.mock('../../../server/models/member.js', () => ({ default: { findOne: vi.fn() } }))
vi.mock('../../../server/models/series.js', () => ({ default: { findOne: vi.fn() } }))
// helcimInitializePaymentSchema is a Nitro auto-import used by validateBody
vi.stubGlobal('helcimInitializePaymentSchema', {})
@ -19,6 +28,9 @@ vi.stubGlobal('fetch', mockFetch)
describe('initialize-payment endpoint', () => {
beforeEach(() => {
vi.clearAllMocks()
getOptionalMember.mockResolvedValue(null)
Member.findOne.mockResolvedValue(null)
Series.findOne.mockResolvedValue(null)
})
afterEach(() => {
@ -31,6 +43,11 @@ describe('initialize-payment endpoint', () => {
metadata: { type: 'event_ticket', eventTitle: 'Test Event', eventId: 'evt-1' }
}
globalThis.validateBody.mockResolvedValue(body)
loadPublicEvent.mockResolvedValue({
_id: 'evt-1',
title: 'Test Event',
tickets: { enabled: true, public: { available: true, price: 2500 } }
})
mockFetch.mockResolvedValue({
ok: true,
@ -75,6 +92,11 @@ describe('initialize-payment endpoint', () => {
metadata: { type: 'event_ticket', eventTitle: 'Workshop', eventId: 'evt-2' }
}
globalThis.validateBody.mockResolvedValue(body)
loadPublicEvent.mockResolvedValue({
_id: 'evt-2',
title: 'Workshop',
tickets: { enabled: true, public: { available: true, price: 1500 } }
})
mockFetch.mockResolvedValue({
ok: true,
@ -92,9 +114,152 @@ describe('initialize-payment endpoint', () => {
expect(result).toEqual({
success: true,
checkoutToken: 'ct-abc',
secretToken: 'st-xyz'
secretToken: 'st-xyz',
amount: 1500
})
})
it('ignores client-supplied amount and re-derives event_ticket price server-side', async () => {
const body = {
amount: 1, // tampered low value
metadata: { type: 'event_ticket', eventId: 'evt-x' }
}
globalThis.validateBody.mockResolvedValue(body)
loadPublicEvent.mockResolvedValue({
_id: 'evt-x',
title: 'Pricey Workshop',
tickets: { enabled: true, public: { available: true, price: 5000 } }
})
mockFetch.mockResolvedValue({
ok: true,
text: async () => JSON.stringify({ checkoutToken: 'ct-1', secretToken: 'st-1' })
})
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/initialize-payment',
body
})
const result = await initPaymentHandler(event)
// Verify the fetch to Helcim was called with the server-derived amount, not body.amount
expect(mockFetch).toHaveBeenCalledTimes(1)
const [, init] = mockFetch.mock.calls[0]
const sentBody = JSON.parse(init.body)
expect(sentBody.amount).toBe(5000)
expect(sentBody.amount).not.toBe(1)
expect(sentBody.paymentType).toBe('purchase')
expect(result.amount).toBe(5000)
})
it('returns 400 when event_ticket metadata is missing eventId', async () => {
const body = { amount: 25, metadata: { type: 'event_ticket' } }
globalThis.validateBody.mockResolvedValue(body)
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/initialize-payment',
body
})
await expect(initPaymentHandler(event)).rejects.toMatchObject({
statusCode: 400
})
})
it('returns 400 when series_ticket metadata is missing seriesId', async () => {
const body = { amount: 50, metadata: { type: 'series_ticket' } }
globalThis.validateBody.mockResolvedValue(body)
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/initialize-payment',
body
})
await expect(initPaymentHandler(event)).rejects.toMatchObject({
statusCode: 400
})
})
it('re-derives series_ticket price via Series.findOne + calculateSeriesTicketPrice', async () => {
const body = {
amount: 100, // tampered
metadata: { type: 'series_ticket', seriesId: 'ser-x' }
}
globalThis.validateBody.mockResolvedValue(body)
Series.findOne.mockResolvedValue({
_id: 'ser-x',
title: 'Coop Foundations',
tickets: { enabled: true, public: { available: true, price: 7500 } }
})
mockFetch.mockResolvedValue({
ok: true,
text: async () => JSON.stringify({ checkoutToken: 'ct-s', secretToken: 'st-s' })
})
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/initialize-payment',
body
})
const result = await initPaymentHandler(event)
expect(mockFetch).toHaveBeenCalledTimes(1)
const [, init] = mockFetch.mock.calls[0]
const sentBody = JSON.parse(init.body)
expect(sentBody.amount).toBe(7500)
expect(sentBody.paymentType).toBe('purchase')
expect(result.amount).toBe(7500)
expect(Series.findOne).toHaveBeenCalled()
})
it('uses member pricing when metadata.email matches an active member', async () => {
const body = {
amount: 5000,
metadata: { type: 'event_ticket', eventId: 'evt-m', email: '[email protected]' }
}
globalThis.validateBody.mockResolvedValue(body)
Member.findOne.mockResolvedValue({
_id: 'm-1',
email: '[email protected]',
status: 'active',
circle: 'community'
})
loadPublicEvent.mockResolvedValue({
_id: 'evt-m',
title: 'Member Event',
tickets: {
enabled: true,
member: { available: true, isFree: true },
public: { available: true, price: 5000 }
}
})
mockFetch.mockResolvedValue({
ok: true,
text: async () => JSON.stringify({ checkoutToken: 'ct-m', secretToken: 'st-m' })
})
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/initialize-payment',
body
})
const result = await initPaymentHandler(event)
expect(mockFetch).toHaveBeenCalledTimes(1)
const [, init] = mockFetch.mock.calls[0]
const sentBody = JSON.parse(init.body)
expect(sentBody.amount).toBe(0)
expect(sentBody.paymentType).toBe('verify')
expect(result.amount).toBe(0)
})
})
describe('verify-payment endpoint', () => {

View file

@ -12,7 +12,10 @@ vi.mock('../../../server/models/member.js', () => ({
default: { findOneAndUpdate: vi.fn(), findOne: vi.fn() }
}))
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn() }))
vi.mock('../../../server/utils/auth.js', () => ({
requireAuth: vi.fn(),
getPaymentBridgeMember: vi.fn().mockResolvedValue(null)
}))
vi.mock('../../../server/utils/slack.ts', () => ({
getSlackService: vi.fn().mockReturnValue(null)
}))

View file

@ -0,0 +1,269 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import Member from '../../../server/models/member.js'
import Payment from '../../../server/models/payment.js'
import { listHelcimCustomerTransactions } from '../../../server/utils/helcim.js'
import { upsertPaymentFromHelcim } from '../../../server/utils/payments.js'
import reconcileHandler from '../../../server/api/internal/reconcile-payments.post.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
vi.mock('../../../server/models/member.js', () => ({
default: { find: vi.fn() }
}))
vi.mock('../../../server/models/payment.js', () => ({
default: { findOne: vi.fn() }
}))
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
vi.mock('../../../server/utils/helcim.js', () => ({
listHelcimCustomerTransactions: vi.fn()
}))
vi.mock('../../../server/utils/payments.js', () => ({
upsertPaymentFromHelcim: vi.fn()
}))
// Override useRuntimeConfig from setup.js for these tests
const RECONCILE_TOKEN = 'test-reconcile-secret-32-characters-long-xx'
beforeEach(() => {
vi.stubGlobal('useRuntimeConfig', () => ({
jwtSecret: 'test-jwt-secret',
helcimApiToken: 'test-helcim-token',
reconcileToken: RECONCILE_TOKEN
}))
})
function leanResolver(value) {
return { lean: vi.fn().mockResolvedValue(value) }
}
describe('POST /api/internal/reconcile-payments', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('rejects requests without the shared-secret token', async () => {
const event = createMockEvent({
method: 'POST',
path: '/api/internal/reconcile-payments'
})
await expect(reconcileHandler(event)).rejects.toMatchObject({
statusCode: 401,
statusMessage: 'Unauthorized'
})
})
it('rejects requests with the wrong token', async () => {
const event = createMockEvent({
method: 'POST',
path: '/api/internal/reconcile-payments',
headers: { 'x-reconcile-token': 'not-the-right-secret' }
})
await expect(reconcileHandler(event)).rejects.toMatchObject({
statusCode: 401,
statusMessage: 'Unauthorized'
})
})
it('rejects when reconcileToken is not configured on the server', async () => {
vi.stubGlobal('useRuntimeConfig', () => ({
jwtSecret: 'test-jwt-secret',
helcimApiToken: 'test-helcim-token',
reconcileToken: ''
}))
const event = createMockEvent({
method: 'POST',
path: '/api/internal/reconcile-payments',
headers: { 'x-reconcile-token': 'anything' }
})
await expect(reconcileHandler(event)).rejects.toMatchObject({
statusCode: 401
})
})
it('queries members with helcimCustomerId and returns a summary', async () => {
Member.find.mockReturnValue(leanResolver([
{
_id: 'm1',
email: 'a@example.com',
helcimCustomerId: 'cust-1',
helcimSubscriptionId: 'sub-1',
billingCadence: 'monthly'
}
]))
listHelcimCustomerTransactions.mockResolvedValue([
{ id: 'tx-paid', status: 'paid', amount: 10, currency: 'CAD' },
{ id: 'tx-other', status: 'other', amount: 0, currency: 'CAD' },
{ id: 'tx-failed', status: 'failed', amount: 10, currency: 'CAD' }
])
upsertPaymentFromHelcim.mockResolvedValueOnce({ created: true, payment: { _id: 'p1' } })
upsertPaymentFromHelcim.mockResolvedValueOnce({ created: false, payment: { _id: 'p2' } })
const event = createMockEvent({
method: 'POST',
path: '/api/internal/reconcile-payments',
headers: { 'x-reconcile-token': RECONCILE_TOKEN }
})
const result = await reconcileHandler(event)
// Verify the query shape: filter by helcimCustomerId existence + projection
expect(Member.find).toHaveBeenCalledWith(
{ helcimCustomerId: { $exists: true, $ne: null } },
expect.objectContaining({
helcimCustomerId: 1,
helcimSubscriptionId: 1,
billingCadence: 1
})
)
// upsert called for paid + failed (status='other' is skipped)
expect(upsertPaymentFromHelcim).toHaveBeenCalledTimes(2)
expect(result).toMatchObject({
membersScanned: 1,
txExamined: 3,
created: 1,
existed: 1,
skipped: 1,
memberErrors: 0,
apply: true
})
})
it('does NOT pass sendConfirmation: true (no duplicate confirmation emails)', async () => {
Member.find.mockReturnValue(leanResolver([
{ _id: 'm1', helcimCustomerId: 'cust-1' }
]))
listHelcimCustomerTransactions.mockResolvedValue([
{ id: 'tx-paid', status: 'paid', amount: 10, currency: 'CAD' }
])
upsertPaymentFromHelcim.mockResolvedValue({ created: true, payment: { _id: 'p1' } })
const event = createMockEvent({
method: 'POST',
path: '/api/internal/reconcile-payments',
headers: { 'x-reconcile-token': RECONCILE_TOKEN }
})
await reconcileHandler(event)
// The cron must not pass sendConfirmation. Either no opts or sendConfirmation falsy.
const opts = upsertPaymentFromHelcim.mock.calls[0][2]
if (opts) {
expect(opts.sendConfirmation).toBeFalsy()
}
})
it('continues iterating when listHelcimCustomerTransactions throws for one member', async () => {
Member.find.mockReturnValue(leanResolver([
{ _id: 'm1', helcimCustomerId: 'cust-1' },
{ _id: 'm2', helcimCustomerId: 'cust-2' },
{ _id: 'm3', helcimCustomerId: 'cust-3' }
]))
// m1 succeeds first try, m2 fails all 3 retries, m3 succeeds first try.
listHelcimCustomerTransactions
.mockResolvedValueOnce([{ id: 'tx1', status: 'paid', amount: 5 }])
.mockRejectedValueOnce(new Error('helcim 503'))
.mockRejectedValueOnce(new Error('helcim 503'))
.mockRejectedValueOnce(new Error('helcim 503'))
.mockResolvedValueOnce([{ id: 'tx3', status: 'paid', amount: 7 }])
upsertPaymentFromHelcim.mockResolvedValue({ created: true, payment: { _id: 'p' } })
vi.useFakeTimers()
const event = createMockEvent({
method: 'POST',
path: '/api/internal/reconcile-payments',
headers: { 'x-reconcile-token': RECONCILE_TOKEN }
})
const promise = reconcileHandler(event)
// Drain m2's exponential backoff (250 + 500 + 1000ms)
await vi.advanceTimersByTimeAsync(2000)
const result = await promise
expect(result.membersScanned).toBe(3)
expect(result.memberErrors).toBe(1)
expect(result.created).toBe(2) // m1 + m3 succeeded
vi.useRealTimers()
})
it('retries transient Helcim errors with exponential backoff (3 attempts)', async () => {
vi.useFakeTimers()
Member.find.mockReturnValue(leanResolver([
{ _id: 'm1', helcimCustomerId: 'cust-1' }
]))
listHelcimCustomerTransactions
.mockRejectedValueOnce(new Error('boom 1'))
.mockRejectedValueOnce(new Error('boom 2'))
.mockResolvedValueOnce([{ id: 'tx-paid', status: 'paid', amount: 9 }])
upsertPaymentFromHelcim.mockResolvedValue({ created: true, payment: { _id: 'p1' } })
const event = createMockEvent({
method: 'POST',
path: '/api/internal/reconcile-payments',
headers: { 'x-reconcile-token': RECONCILE_TOKEN }
})
const promise = reconcileHandler(event)
// Advance through the 250ms + 500ms backoff windows
await vi.advanceTimersByTimeAsync(250)
await vi.advanceTimersByTimeAsync(500)
const result = await promise
expect(listHelcimCustomerTransactions).toHaveBeenCalledTimes(3)
expect(result.memberErrors).toBe(0)
expect(result.created).toBe(1)
vi.useRealTimers()
})
it('counts memberErrors when all 3 retry attempts fail', async () => {
vi.useFakeTimers()
Member.find.mockReturnValue(leanResolver([
{ _id: 'm1', helcimCustomerId: 'cust-1' }
]))
listHelcimCustomerTransactions.mockRejectedValue(new Error('persistent 503'))
const event = createMockEvent({
method: 'POST',
path: '/api/internal/reconcile-payments',
headers: { 'x-reconcile-token': RECONCILE_TOKEN }
})
const promise = reconcileHandler(event)
await vi.advanceTimersByTimeAsync(250)
await vi.advanceTimersByTimeAsync(500)
await vi.advanceTimersByTimeAsync(1000)
const result = await promise
expect(listHelcimCustomerTransactions).toHaveBeenCalledTimes(3)
expect(result.memberErrors).toBe(1)
vi.useRealTimers()
})
it('honors ?apply=false dry-run mode (Payment.findOne, no upsert)', async () => {
Member.find.mockReturnValue(leanResolver([
{ _id: 'm1', helcimCustomerId: 'cust-1' }
]))
listHelcimCustomerTransactions.mockResolvedValue([
{ id: 'tx-existing', status: 'paid', amount: 10 },
{ id: 'tx-new', status: 'paid', amount: 12 }
])
Payment.findOne
.mockResolvedValueOnce({ _id: 'p-existing' })
.mockResolvedValueOnce(null)
const event = createMockEvent({
method: 'POST',
path: '/api/internal/reconcile-payments?apply=false',
headers: { 'x-reconcile-token': RECONCILE_TOKEN }
})
const result = await reconcileHandler(event)
expect(upsertPaymentFromHelcim).not.toHaveBeenCalled()
expect(Payment.findOne).toHaveBeenCalledTimes(2)
expect(result).toMatchObject({
apply: false,
created: 1, // would-create
existed: 1
})
})
})

View file

@ -0,0 +1,98 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const seriesDir = resolve(import.meta.dirname, '../../../server/api/series/[id]')
describe('series tickets/purchase.post.js — guest account upsert (Fix #8)', () => {
const source = readFileSync(resolve(seriesDir, 'tickets/purchase.post.js'), 'utf-8')
it('uses validateBody with seriesTicketPurchaseSchema', () => {
expect(source).toContain('validateBody(event, seriesTicketPurchaseSchema)')
})
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')
})
})
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/)
})
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)
})
})

View file

@ -12,7 +12,6 @@ import {
waitlistDeleteSchema,
cancelRegistrationSchema,
checkRegistrationSchema,
eventPaymentSchema,
updateContributionSchema,
seriesTicketPurchaseSchema,
seriesTicketEligibilitySchema,
@ -312,25 +311,6 @@ describe('waitlistSchema', () => {
})
})
describe('eventPaymentSchema', () => {
it('accepts valid payment data', () => {
const result = eventPaymentSchema.safeParse({
name: 'Payer',
email: 'payer@example.com',
paymentToken: 'tok_abc123'
})
expect(result.success).toBe(true)
})
it('rejects missing paymentToken', () => {
const result = eventPaymentSchema.safeParse({
name: 'Payer',
email: 'payer@example.com'
})
expect(result.success).toBe(false)
})
})
// --- Member schemas ---
describe('updateContributionSchema', () => {
@ -557,7 +537,6 @@ describe('validateBody migration coverage', () => {
'events/[id]/waitlist.delete.js',
'events/[id]/cancel-registration.post.js',
'events/[id]/check-registration.post.js',
'events/[id]/payment.post.js',
'members/update-contribution.post.js',
'series/[id]/tickets/purchase.post.js',
'series/[id]/tickets/check-eligibility.post.js',