import { describe, it, expect, vi, beforeEach } from 'vitest' import { requireAuth } from '../../../server/utils/auth.js' import { validateBody } from '../../../server/utils/validateBody.js' import { listHelcimCustomerCards, updateHelcimCustomerDefaultPaymentMethod, updateHelcimSubscriptionPaymentMethod } from '../../../server/utils/helcim.js' import { logActivity } from '../../../server/utils/activityLog.js' import updateCardHandler from '../../../server/api/helcim/update-card.post.js' import { createMockEvent } from '../helpers/createMockEvent.js' vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn() })) vi.mock('../../../server/utils/validateBody.js', () => ({ validateBody: vi.fn() })) vi.mock('../../../server/utils/schemas.js', () => ({ helcimUpdateCardSchema: {} })) vi.mock('../../../server/utils/helcim.js', () => ({ listHelcimCustomerCards: vi.fn(), updateHelcimCustomerDefaultPaymentMethod: vi.fn(), updateHelcimSubscriptionPaymentMethod: vi.fn() })) vi.mock('../../../server/utils/activityLog.js', () => ({ logActivity: vi.fn() })) const activeMember = () => ({ _id: 'member-1', status: 'active', helcimCustomerId: 9876, helcimSubscriptionId: 5555 }) const cardsList = () => ([ { cardToken: 'tok-old', id: 1, default: true }, { cardToken: 'tok-new', id: 2 } ]) const newEvent = () => createMockEvent({ method: 'POST', path: '/api/helcim/update-card', body: { cardToken: 'tok-new' } }) describe('helcim update-card endpoint', () => { beforeEach(() => { vi.clearAllMocks() validateBody.mockResolvedValue({ cardToken: 'tok-new' }) }) it('happy path: updates customer default + subscription, logs activity, returns success', async () => { requireAuth.mockResolvedValue(activeMember()) listHelcimCustomerCards.mockResolvedValue(cardsList()) updateHelcimCustomerDefaultPaymentMethod.mockResolvedValue({}) updateHelcimSubscriptionPaymentMethod.mockResolvedValue({}) const result = await updateCardHandler(newEvent()) expect(result).toEqual({ success: true }) expect(updateHelcimCustomerDefaultPaymentMethod).toHaveBeenCalledTimes(1) expect(updateHelcimCustomerDefaultPaymentMethod).toHaveBeenCalledWith(9876, 'tok-new') expect(updateHelcimSubscriptionPaymentMethod).toHaveBeenCalledWith(5555, 'tok-new') expect(logActivity).toHaveBeenCalledWith( 'member-1', 'billing_card_updated', {}, { visibility: 'member' } ) }) it('returns 401 when unauthenticated', async () => { requireAuth.mockRejectedValue( createError({ statusCode: 401, statusMessage: 'Unauthorized' }) ) await expect(updateCardHandler(newEvent())).rejects.toMatchObject({ statusCode: 401 }) expect(listHelcimCustomerCards).not.toHaveBeenCalled() expect(updateHelcimCustomerDefaultPaymentMethod).not.toHaveBeenCalled() expect(updateHelcimSubscriptionPaymentMethod).not.toHaveBeenCalled() }) it('returns 403 when member status is not active/pending_payment', async () => { requireAuth.mockResolvedValue({ ...activeMember(), status: 'cancelled' }) await expect(updateCardHandler(newEvent())).rejects.toMatchObject({ statusCode: 403 }) expect(listHelcimCustomerCards).not.toHaveBeenCalled() expect(updateHelcimCustomerDefaultPaymentMethod).not.toHaveBeenCalled() expect(updateHelcimSubscriptionPaymentMethod).not.toHaveBeenCalled() }) it('returns 400 when member is missing helcimCustomerId', async () => { requireAuth.mockResolvedValue({ ...activeMember(), helcimCustomerId: null }) await expect(updateCardHandler(newEvent())).rejects.toMatchObject({ statusCode: 400 }) expect(listHelcimCustomerCards).not.toHaveBeenCalled() expect(updateHelcimCustomerDefaultPaymentMethod).not.toHaveBeenCalled() }) it('returns 400 when member is missing helcimSubscriptionId', async () => { requireAuth.mockResolvedValue({ ...activeMember(), helcimSubscriptionId: null }) await expect(updateCardHandler(newEvent())).rejects.toMatchObject({ statusCode: 400 }) expect(listHelcimCustomerCards).not.toHaveBeenCalled() }) it('returns 403 when cardToken is not attached to the customer', async () => { requireAuth.mockResolvedValue(activeMember()) listHelcimCustomerCards.mockResolvedValue([ { cardToken: 'tok-old', id: 1, default: true } ]) await expect(updateCardHandler(newEvent())).rejects.toMatchObject({ statusCode: 403 }) expect(updateHelcimCustomerDefaultPaymentMethod).not.toHaveBeenCalled() expect(updateHelcimSubscriptionPaymentMethod).not.toHaveBeenCalled() }) it('returns 500 and does NOT call subscription update when customer default update throws', async () => { requireAuth.mockResolvedValue(activeMember()) listHelcimCustomerCards.mockResolvedValue(cardsList()) updateHelcimCustomerDefaultPaymentMethod.mockRejectedValue( createError({ statusCode: 500, statusMessage: 'Helcim down' }) ) await expect(updateCardHandler(newEvent())).rejects.toMatchObject({ statusCode: 500 }) expect(updateHelcimCustomerDefaultPaymentMethod).toHaveBeenCalledTimes(1) expect(updateHelcimSubscriptionPaymentMethod).not.toHaveBeenCalled() expect(logActivity).not.toHaveBeenCalled() }) it('partial failure with 5xx: reverts customer default with prior token and returns 500', async () => { requireAuth.mockResolvedValue(activeMember()) listHelcimCustomerCards.mockResolvedValue(cardsList()) updateHelcimCustomerDefaultPaymentMethod.mockResolvedValue({}) updateHelcimSubscriptionPaymentMethod.mockRejectedValue( createError({ statusCode: 502, statusMessage: 'Bad gateway' }) ) await expect(updateCardHandler(newEvent())).rejects.toMatchObject({ statusCode: 500 }) expect(updateHelcimCustomerDefaultPaymentMethod).toHaveBeenCalledTimes(2) expect(updateHelcimCustomerDefaultPaymentMethod).toHaveBeenNthCalledWith(1, 9876, 'tok-new') expect(updateHelcimCustomerDefaultPaymentMethod).toHaveBeenNthCalledWith(2, 9876, 'tok-old') expect(logActivity).not.toHaveBeenCalled() }) it('subscription PATCH 4xx is tolerated: returns success without revert', async () => { requireAuth.mockResolvedValue(activeMember()) listHelcimCustomerCards.mockResolvedValue(cardsList()) updateHelcimCustomerDefaultPaymentMethod.mockResolvedValue({}) updateHelcimSubscriptionPaymentMethod.mockRejectedValue( createError({ statusCode: 400, statusMessage: 'Unknown field: cardToken' }) ) const result = await updateCardHandler(newEvent()) expect(result).toEqual({ success: true }) // customer default was set once, no revert expect(updateHelcimCustomerDefaultPaymentMethod).toHaveBeenCalledTimes(1) expect(logActivity).toHaveBeenCalledWith( 'member-1', 'billing_card_updated', {}, { visibility: 'member' } ) }) it('revert-also-fails: returns 500 and inconsistency is logged', async () => { requireAuth.mockResolvedValue(activeMember()) listHelcimCustomerCards.mockResolvedValue(cardsList()) // First call (set new default) succeeds; second call (revert) fails updateHelcimCustomerDefaultPaymentMethod .mockResolvedValueOnce({}) .mockRejectedValueOnce(createError({ statusCode: 500, statusMessage: 'still down' })) updateHelcimSubscriptionPaymentMethod.mockRejectedValue( createError({ statusCode: 503, statusMessage: 'Service unavailable' }) ) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) await expect(updateCardHandler(newEvent())).rejects.toMatchObject({ statusCode: 500 }) expect(updateHelcimCustomerDefaultPaymentMethod).toHaveBeenCalledTimes(2) const loggedInconsistency = errorSpy.mock.calls.some(([msg]) => typeof msg === 'string' && msg.includes('INCONSISTENT STATE') ) expect(loggedInconsistency).toBe(true) expect(logActivity).not.toHaveBeenCalled() errorSpy.mockRestore() }) })