import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' 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(), 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', {}) const mockFetch = vi.fn() vi.stubGlobal('fetch', mockFetch) describe('initialize-payment endpoint', () => { beforeEach(() => { vi.clearAllMocks() getOptionalMember.mockResolvedValue(null) Member.findOne.mockResolvedValue(null) Series.findOne.mockResolvedValue(null) }) 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) loadPublicEvent.mockResolvedValue({ _id: 'evt-1', title: 'Test Event', tickets: { enabled: true, public: { available: true, price: 2500 } } }) mockFetch.mockResolvedValue({ ok: true, text: async () => JSON.stringify({ 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, text: async () => JSON.stringify({ 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) loadPublicEvent.mockResolvedValue({ _id: 'evt-2', title: 'Workshop', tickets: { enabled: true, public: { available: true, price: 1500 } } }) mockFetch.mockResolvedValue({ ok: true, text: async () => JSON.stringify({ 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', 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', () => { 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, text: async () => JSON.stringify([{ 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, text: async () => JSON.stringify([ { 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, text: async () => JSON.stringify([ { 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' }) }) })