ghostguild-org/tests/server/api/helcim-update-card.test.js
Jennie Robinson Faber 101d6a231b feat(billing): add update-card API route with rollback + status gate
POST /api/helcim/update-card updates the customer's default card, then
best-effort patches the active subscription payment method. Status-gated
to {active, pending_payment}; verifies the submitted cardToken is
attached to the member's helcimCustomerId via listHelcimCustomerCards.
On subscription PATCH 5xx we revert the customer default to the prior
card token; 4xx (schema rejection — cardToken is not a documented
subscription PATCH field) is tolerated since the customer default is
the authoritative billing driver.
2026-04-19 16:29:23 +01:00

196 lines
7.8 KiB
JavaScript

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