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 }) }) })