Files
aiturk-hermes-ide/apps/desktop/src/app/settings/billing/use-charge-poller.test.ts
T

239 lines
6.5 KiB
TypeScript

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook } from '@testing-library/react'
import { createElement, type PropsWithChildren } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n'
import type { BillingResult } from './api'
import type { BillingChargeStatusResponse } from './types'
const apiMocks = vi.hoisted(() => ({
charge: vi.fn(),
chargeStatus: vi.fn()
}))
vi.mock('./api', () => ({
useBillingApi: () => ({
charge: apiMocks.charge,
chargeStatus: apiMocks.chargeStatus
})
}))
import { CHARGE_POLL_CAP_MS, pollChargeSettlement, useChargeFlow } from './use-charge-poller'
const status = (overrides: Partial<BillingChargeStatusResponse> = {}): BillingResult<BillingChargeStatusResponse> => ({
data: {
ok: true,
status: 'pending',
...overrides
},
ok: true
})
const refusal = (
kind: string,
overrides: Partial<Extract<BillingResult<BillingChargeStatusResponse>, { ok: false }>['refusal']> = {}
): BillingResult<BillingChargeStatusResponse> => ({
ok: false,
refusal: {
kind,
message: kind,
...overrides
}
})
function controlledClock() {
let current = 0
const waits: number[] = []
return {
now: () => current,
sleep: vi.fn(async (ms: number) => {
waits.push(ms)
current += ms
}),
waits
}
}
function wrapper({ children }: PropsWithChildren) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return createElement(I18nProvider, {
configClient: null,
initialLocale: 'en',
children: createElement(QueryClientProvider, { client }, children)
})
}
beforeEach(() => {
apiMocks.charge.mockReset()
apiMocks.chargeStatus.mockReset()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('pollChargeSettlement', () => {
it('settles after pending polls', async () => {
const clock = controlledClock()
const api = {
chargeStatus: vi
.fn()
.mockResolvedValueOnce(status())
.mockResolvedValueOnce(status())
.mockResolvedValueOnce(status({ amount_usd: '25', status: 'settled' }))
}
const outcome = await pollChargeSettlement(api, 'ch_123', clock)
expect(outcome).toMatchObject({ amountUsd: '25', kind: 'success' })
expect(api.chargeStatus).toHaveBeenCalledTimes(3)
expect(clock.waits).toEqual([2000, 2000])
})
it('returns a failed outcome with the charge failure reason', async () => {
const clock = controlledClock()
const api = {
chargeStatus: vi.fn().mockResolvedValue(status({ reason: 'card_declined', status: 'failed' }))
}
const outcome = await pollChargeSettlement(api, 'ch_123', clock)
expect(outcome).toMatchObject({
kind: 'failure',
message: 'Your card was declined. Try another card on the portal.',
title: 'Charge failed'
})
expect(clock.waits).toEqual([])
})
it('backs off on rate limits, honors retryAfter, and keeps polling', async () => {
const clock = controlledClock()
const api = {
chargeStatus: vi
.fn()
.mockResolvedValueOnce(refusal('rate_limited', { retryAfter: 7 }))
.mockResolvedValueOnce(status({ amount_usd: '50', status: 'settled' }))
}
const outcome = await pollChargeSettlement(api, 'ch_123', clock)
expect(outcome).toMatchObject({ amountUsd: '50', kind: 'success' })
expect(clock.waits).toEqual([7000])
})
it('backs off when Stripe is unavailable and keeps polling', async () => {
const clock = controlledClock()
const api = {
chargeStatus: vi
.fn()
.mockResolvedValueOnce(refusal('stripe_unavailable', { retryAfter: 3 }))
.mockResolvedValueOnce(status({ amount_usd: '50', status: 'settled' }))
}
const outcome = await pollChargeSettlement(api, 'ch_123', clock)
expect(outcome).toMatchObject({ amountUsd: '50', kind: 'success' })
expect(clock.waits).toEqual([3000])
})
it('caps pending polling at 5 minutes as an ambiguous outcome', async () => {
const clock = controlledClock()
const api = {
chargeStatus: vi.fn().mockResolvedValue(status())
}
const outcome = await pollChargeSettlement(api, 'ch_123', {
...clock,
portalUrl: 'https://portal.nousresearch.com/billing'
})
expect(outcome).toEqual({
kind: 'ambiguous',
message: 'Charge may still settle. Check the portal before retrying.',
portalUrl: 'https://portal.nousresearch.com/billing',
title: 'Still processing after 5 minutes'
})
expect(clock.waits.reduce((total, ms) => total + ms, 0)).toBe(CHARGE_POLL_CAP_MS)
})
it('treats auth revocation while polling as ambiguous', async () => {
const clock = controlledClock()
const api = {
chargeStatus: vi.fn().mockResolvedValue(
refusal('session_revoked', {
message: 'Your session was logged out.',
portalUrl: 'https://portal.nousresearch.com/billing'
})
)
}
const outcome = await pollChargeSettlement(api, 'ch_123', clock)
expect(outcome).toMatchObject({
kind: 'ambiguous',
portalUrl: 'https://portal.nousresearch.com/billing',
title: 'Charge outcome unconfirmed'
})
})
})
describe('useChargeFlow', () => {
it('turns a charge refusal into an immediate outcome without polling', async () => {
apiMocks.charge.mockResolvedValue({
idempotencyKey: 'key-1',
ok: false,
refusal: {
kind: 'no_payment_method',
message: 'No saved card.',
portalUrl: 'https://portal.nousresearch.com/billing'
}
})
const { result } = renderHook(() => useChargeFlow(), { wrapper })
await act(async () => {
await result.current.start('25')
})
expect(result.current.phase).toBe('done')
expect(result.current.outcome).toMatchObject({
kind: 'failure',
title: 'No saved card'
})
expect(apiMocks.chargeStatus).not.toHaveBeenCalled()
})
it('reuses one idempotency key when retrying a failed-to-send charge', async () => {
apiMocks.charge.mockResolvedValue({
idempotencyKey: 'key-1',
ok: false,
refusal: {
kind: 'transport',
message: 'connection closed'
}
})
const { result } = renderHook(() => useChargeFlow(), { wrapper })
await act(async () => {
await result.current.start('25')
})
await act(async () => {
await result.current.start('25')
})
expect(apiMocks.charge).toHaveBeenNthCalledWith(1, '25', undefined)
expect(apiMocks.charge).toHaveBeenNthCalledWith(2, '25', 'key-1')
})
})