import { describe, it, expect, vi, beforeEach } from 'vitest' import Member from '../../../server/models/member.js' import { requiresPayment, getHelcimPlanId, } from '../../../server/config/contributions.js' import { updateHelcimSubscription, getHelcimCustomer, listHelcimCustomerCards, createHelcimSubscription, cancelHelcimSubscription, } from '../../../server/utils/helcim.js' import handler from '../../../server/api/members/update-contribution.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/config/contributions.js', () => ({ requiresPayment: vi.fn(), getHelcimPlanId: vi.fn(), })) vi.mock('../../../server/utils/helcim.js', () => ({ getHelcimCustomer: vi.fn(), listHelcimCustomerCards: vi.fn(), createHelcimSubscription: vi.fn(), updateHelcimSubscription: vi.fn(), cancelHelcimSubscription: vi.fn(), generateIdempotencyKey: vi.fn().mockReturnValue('idem-key-123'), })) // Nitro auto-imports vi.stubGlobal('updateContributionSchema', {}) describe('update-contribution endpoint — Case 3 (paid→paid)', () => { beforeEach(() => { vi.clearAllMocks() // Both tiers require payment for all Case 3 tests requiresPayment.mockReturnValue(true) }) // Helper: set requireAuth global to resolve with the given member function setMember(mockMember) { globalThis.requireAuth = vi.fn().mockResolvedValue(mockMember) } it('monthly $5 → $15: calls updateHelcimSubscription with recurringAmount and updates member', async () => { const mockMember = { _id: 'member-1', contributionAmount: 5, helcimSubscriptionId: 'sub-1', billingCadence: 'monthly', } setMember(mockMember) updateHelcimSubscription.mockResolvedValue({ id: 'sub-1', status: 'active' }) Member.findByIdAndUpdate.mockResolvedValue({}) const event = createMockEvent({ method: 'POST', path: '/api/members/update-contribution', body: { contributionAmount: 15 }, }) const result = await handler(event) expect(updateHelcimSubscription).toHaveBeenCalledWith('sub-1', { recurringAmount: 15 }) expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( 'member-1', { $set: { contributionAmount: 15 } }, { runValidators: false } ) expect(result.success).toBe(true) expect(result.message).toBe('Successfully updated contribution level') }) it('annual $5 → $15: calls updateHelcimSubscription with recurringAmount 180', async () => { const mockMember = { _id: 'member-2', contributionAmount: 5, helcimSubscriptionId: 'sub-2', billingCadence: 'annual', } setMember(mockMember) updateHelcimSubscription.mockResolvedValue({ id: 'sub-2', status: 'active' }) Member.findByIdAndUpdate.mockResolvedValue({}) const event = createMockEvent({ method: 'POST', path: '/api/members/update-contribution', body: { contributionAmount: 15, cadence: 'annual' }, }) const result = await handler(event) expect(updateHelcimSubscription).toHaveBeenCalledWith('sub-2', { recurringAmount: 180 }) expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( 'member-2', { $set: { contributionAmount: 15 } }, { runValidators: false } ) expect(result.success).toBe(true) }) it('annual $15 → $50: calls updateHelcimSubscription with recurringAmount 600', async () => { const mockMember = { _id: 'member-3', contributionAmount: 15, helcimSubscriptionId: 'sub-3', billingCadence: 'annual', } setMember(mockMember) updateHelcimSubscription.mockResolvedValue({ id: 'sub-3', status: 'active' }) Member.findByIdAndUpdate.mockResolvedValue({}) const event = createMockEvent({ method: 'POST', path: '/api/members/update-contribution', body: { contributionAmount: 50, cadence: 'annual' }, }) await handler(event) expect(updateHelcimSubscription).toHaveBeenCalledWith('sub-3', { recurringAmount: 600 }) }) it('cadence mismatch: monthly member + body cadence annual → 400, no Helcim call, no DB write', async () => { const mockMember = { _id: 'member-4', contributionAmount: 5, helcimSubscriptionId: 'sub-4', billingCadence: 'monthly', } setMember(mockMember) const event = createMockEvent({ method: 'POST', path: '/api/members/update-contribution', body: { contributionAmount: 15, cadence: 'annual' }, }) await expect(handler(event)).rejects.toMatchObject({ statusCode: 400, statusMessage: 'Cadence switch not supported on existing subscription', }) expect(updateHelcimSubscription).not.toHaveBeenCalled() expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() }) it('Helcim PATCH failure → 500, member NOT updated', async () => { const mockMember = { _id: 'member-5', contributionAmount: 5, helcimSubscriptionId: 'sub-5', billingCadence: 'monthly', } setMember(mockMember) updateHelcimSubscription.mockRejectedValue(new Error('Helcim 400')) const event = createMockEvent({ method: 'POST', path: '/api/members/update-contribution', body: { contributionAmount: 15 }, }) await expect(handler(event)).rejects.toMatchObject({ statusCode: 500, statusMessage: 'Subscription update failed', }) expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() }) it('no helcimSubscriptionId → 400 with requiresPaymentSetup, no Helcim call', async () => { const mockMember = { _id: 'member-6', contributionAmount: 5, helcimSubscriptionId: null, billingCadence: 'monthly', } setMember(mockMember) const event = createMockEvent({ method: 'POST', path: '/api/members/update-contribution', body: { contributionAmount: 15 }, }) await expect(handler(event)).rejects.toMatchObject({ statusCode: 400, data: { requiresPaymentSetup: true }, }) expect(updateHelcimSubscription).not.toHaveBeenCalled() expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() }) }) describe('update-contribution endpoint — Case 1 (free→paid)', () => { beforeEach(() => { vi.clearAllMocks() // old amount = 0 (free), new amount > 0 (paid) requiresPayment.mockImplementation((amount) => amount !== 0) }) function setMember(mockMember) { globalThis.requireAuth = vi.fn().mockResolvedValue(mockMember) } const freeMember = { _id: 'member-c1', contributionAmount: 0, helcimCustomerId: 'cust-1', } it('monthly: calls createHelcimSubscription with monthly plan id and recurringAmount 15, persists billingCadence monthly', async () => { setMember(freeMember) getHelcimPlanId.mockReturnValue('111') getHelcimCustomer.mockResolvedValue({ customerCode: 'code-1' }) listHelcimCustomerCards.mockResolvedValue([{ id: 'card-1' }]) createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-new', status: 'active', nextBillingDate: '2026-05-18' }] }) Member.findByIdAndUpdate.mockResolvedValue({}) const event = createMockEvent({ method: 'POST', path: '/api/members/update-contribution', body: { contributionAmount: 15, cadence: 'monthly' }, }) const result = await handler(event) expect(createHelcimSubscription).toHaveBeenCalledWith( expect.objectContaining({ paymentPlanId: 111, recurringAmount: 15 }), 'idem-key-123' ) expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( 'member-c1', { $set: expect.objectContaining({ billingCadence: 'monthly', contributionAmount: 15, helcimSubscriptionId: 'sub-new' }) }, { runValidators: false } ) expect(result.success).toBe(true) expect(result.message).toBe('Successfully upgraded to paid tier') }) it('annual: calls createHelcimSubscription with annual plan id and recurringAmount 180, persists billingCadence annual', async () => { setMember(freeMember) getHelcimPlanId.mockReturnValue('222') getHelcimCustomer.mockResolvedValue({ customerCode: 'code-1' }) listHelcimCustomerCards.mockResolvedValue([{ id: 'card-1' }]) createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-annual', status: 'active', nextBillingDate: '2027-04-18' }] }) Member.findByIdAndUpdate.mockResolvedValue({}) const event = createMockEvent({ method: 'POST', path: '/api/members/update-contribution', body: { contributionAmount: 15, cadence: 'annual' }, }) const result = await handler(event) expect(createHelcimSubscription).toHaveBeenCalledWith( expect.objectContaining({ paymentPlanId: 222, recurringAmount: 180 }), 'idem-key-123' ) expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( 'member-c1', { $set: expect.objectContaining({ billingCadence: 'annual', contributionAmount: 15, helcimSubscriptionId: 'sub-annual' }) }, { runValidators: false } ) expect(result.success).toBe(true) }) it('missing plan id env → 500, createHelcimSubscription NOT called, member NOT updated', async () => { setMember(freeMember) getHelcimPlanId.mockReturnValue(null) const event = createMockEvent({ method: 'POST', path: '/api/members/update-contribution', body: { contributionAmount: 15, cadence: 'monthly' }, }) await expect(handler(event)).rejects.toMatchObject({ statusCode: 500, statusMessage: 'Monthly plan id not configured', }) expect(createHelcimSubscription).not.toHaveBeenCalled() expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() }) }) describe('update-contribution endpoint — Case 2 (paid→free)', () => { beforeEach(() => { vi.clearAllMocks() // old amount > 0 (paid), new amount = 0 (free) requiresPayment.mockImplementation((amount) => amount !== 0) }) function setMember(mockMember) { globalThis.requireAuth = vi.fn().mockResolvedValue(mockMember) } it('cancels subscription, resets billingCadence to monthly, clears helcimSubscriptionId', async () => { const mockMember = { _id: 'member-c2', contributionAmount: 15, helcimSubscriptionId: 'sub-1', billingCadence: 'monthly', } setMember(mockMember) cancelHelcimSubscription.mockResolvedValue({}) Member.findByIdAndUpdate.mockResolvedValue({}) const event = createMockEvent({ method: 'POST', path: '/api/members/update-contribution', body: { contributionAmount: 0 }, }) const result = await handler(event) expect(cancelHelcimSubscription).toHaveBeenCalledWith('sub-1') expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( 'member-c2', { $set: expect.objectContaining({ contributionAmount: 0, helcimSubscriptionId: null, paymentMethod: 'none', billingCadence: 'monthly', }) }, { runValidators: false } ) expect(result.success).toBe(true) expect(result.message).toBe('Successfully downgraded to free tier') }) })