Files
aiturk-hermes-ide/apps/desktop/src/app/settings/billing/index.test.tsx
T

734 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render as renderUi, screen, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider, useI18n } from '@/i18n'
import { renderWithEnglish as render } from '../test-locale'
import { formatMoney } from './billing-amounts'
import {
billingDevFixtures,
loggedOutBillingState,
loggedOutSubscriptionState,
okBilling,
okSubscription,
postTrainBillingState,
postTrainSubscriptionState,
todayBillingState,
todaySubscriptionState
} from './fixtures.test-util'
import { BillingSettings } from './index'
const apiMocks = vi.hoisted(() => ({
charge: vi.fn(),
chargeStatus: vi.fn(),
fetchBillingState: vi.fn(),
fetchSubscriptionState: vi.fn(),
openExternal: vi.fn(),
previewSubscriptionChange: vi.fn(),
resumeSubscription: vi.fn(),
scheduleSubscriptionChange: vi.fn(),
stepUp: vi.fn(),
updateAutoReload: vi.fn()
}))
vi.mock('./api', () => ({
// Pass-through provider — the mocked useBillingApi ignores any override anyway.
BillingApiProvider: ({ children }: { children: ReactNode }) => children,
useBillingApi: () => ({
charge: apiMocks.charge,
chargeStatus: apiMocks.chargeStatus,
fetchBillingState: apiMocks.fetchBillingState,
fetchSubscriptionState: apiMocks.fetchSubscriptionState,
previewSubscriptionChange: apiMocks.previewSubscriptionChange,
resumeSubscription: apiMocks.resumeSubscription,
scheduleSubscriptionChange: apiMocks.scheduleSubscriptionChange,
stepUp: apiMocks.stepUp,
updateAutoReload: apiMocks.updateAutoReload
})
}))
function renderBilling(initialEntries: string[] = ['/settings?tab=billing']) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<MemoryRouter initialEntries={initialEntries}>
<QueryClientProvider client={client}>
<BillingSettings />
</QueryClientProvider>
</MemoryRouter>
)
return client
}
beforeEach(() => {
apiMocks.fetchBillingState.mockResolvedValue(okBilling(todayBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(todaySubscriptionState))
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {
openExternal: apiMocks.openExternal
}
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('BillingSettings', () => {
it('renders the deployed-today payload with buy controls hidden and usage rows visible', async () => {
renderBilling()
expect(await screen.findByText('$996.47')).toBeTruthy()
expect(screen.getByText('Ultra · $200/mo')).toBeTruthy()
expect(screen.getByText('Visa •••• 3206')).toBeTruthy()
expect(
screen.getByText(
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page."
)
).toBeTruthy()
expect(screen.queryByRole('button', { name: '$100' })).toBeNull()
expect(screen.getByText('Charges $10 automatically when your balance falls below $5.')).toBeTruthy()
expect(screen.getByText('$120 of $220 left')).toBeTruthy()
expect(screen.getByText('$876.47')).toBeTruthy()
expect(screen.getByText('$10 of $100 used').classList.contains('tabular-nums')).toBe(true)
expect(screen.getByText('Default ceiling')).toBeTruthy()
})
it('renders the post-train payload with enabled buy controls and card provenance', async () => {
apiMocks.fetchBillingState.mockResolvedValue(okBilling(postTrainBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(postTrainSubscriptionState))
renderBilling()
expect(await screen.findByText('$142.50')).toBeTruthy()
expect(screen.getByText('Visa •••• 4242 - subscription card')).toBeTruthy()
expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(false)
expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(false)
expect(screen.getByRole('button', { name: '$100' }).hasAttribute('disabled')).toBe(false)
expect(screen.getByRole('spinbutton', { name: 'Custom credit amount' })).toBeTruthy()
expect(screen.getByRole('button', { name: /^Buy$/ }).hasAttribute('disabled')).toBe(false)
})
it('disables buy controls when no card is on file', async () => {
const fixture = billingDevFixtures['no-card']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling()
// No card → the payment row collapses to a single "Add payment method" link.
expect(await screen.findByRole('button', { name: /Add payment method/ })).toBeTruthy()
expect(screen.queryByText('No card on file')).toBeNull()
expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '$100' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('spinbutton', { name: 'Custom credit amount' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: /^Buy$/ }).hasAttribute('disabled')).toBe(true)
fireEvent.click(screen.getByRole('button', { name: /^Buy$/ }))
expect(apiMocks.charge).not.toHaveBeenCalled()
})
it('saves enabled auto-refill edits and refreshes billing state', async () => {
const client = renderBilling()
const invalidate = vi.spyOn(client, 'invalidateQueries')
apiMocks.updateAutoReload.mockResolvedValue({ data: { ok: true }, ok: true })
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }))
fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' }), {
target: { value: '15' }
})
fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill reload-to amount' }), {
target: { value: '20' }
})
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() =>
expect(apiMocks.updateAutoReload).toHaveBeenCalledWith({
enabled: true,
reload_to_usd: '20',
threshold_usd: '15'
})
)
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'state'] }))
expect(await screen.findByText('Auto-refill updated.')).toBeTruthy()
})
it('rejects auto-refill amounts outside the billing bounds', async () => {
renderBilling()
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }))
fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' }), {
target: { value: '7.50' }
})
expect(screen.getByText(`Threshold: minimum is ${formatMoney(10)}.`)).toBeTruthy()
expect(screen.getByRole('button', { name: 'Save' }).hasAttribute('disabled')).toBe(true)
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(apiMocks.updateAutoReload).not.toHaveBeenCalled()
})
it('renders the enabled auto-refill row without crashing when the card is null', async () => {
apiMocks.fetchBillingState.mockResolvedValue(
okBilling({ ...todayBillingState, auto_reload: { ...todayBillingState.auto_reload, card: null } })
)
renderBilling()
expect(await screen.findByText('Charges $10 automatically when your balance falls below $5.')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Manage' })).toBeTruthy()
})
it('requires inline confirmation before disabling auto-refill', async () => {
renderBilling()
apiMocks.updateAutoReload.mockResolvedValue({ data: { ok: true }, ok: true })
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }))
fireEvent.click(screen.getByRole('button', { name: 'Disable' }))
expect(screen.getByText('Turn off auto-refill?')).toBeTruthy()
expect(apiMocks.updateAutoReload).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Turn off' }))
// The gateway requires threshold + top_up_amount even to disable, so the current
// amounts ride along (todayBillingState: threshold $5, reload-to $10).
await waitFor(() =>
expect(apiMocks.updateAutoReload).toHaveBeenCalledWith({
enabled: false,
reload_to_usd: '10',
threshold_usd: '5'
})
)
})
it('opens auto-refill edit without a validation error even when the saved config is below the minimum', async () => {
// todayBillingState: threshold $5 with min_usd $10 — invalid, but opening
// Manage must stay silent until the user edits or attempts to save (spec §9).
renderBilling()
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }))
expect(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' })).toBeTruthy()
expect(screen.queryByText(`Threshold: minimum is ${formatMoney(10)}.`)).toBeNull()
// Save is disabled because the prefilled config is invalid — but no error yet.
expect(screen.getByRole('button', { name: 'Save' }).hasAttribute('disabled')).toBe(true)
})
it('navigates to the in-app plans grid from the plan card and back', async () => {
const fixture = billingDevFixtures['free-personal']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling()
fireEvent.click(await screen.findByRole('button', { name: 'View plans' }))
expect(await screen.findByText('Plans')).toBeTruthy()
// No subscription → the free tier is the inert current plan, the three paid
// tiers are "Choose ↗" upgrades (no "subscribe to Free").
expect(screen.getByText('Current plan')).toBeTruthy()
expect(screen.getAllByRole('button', { name: /Choose/ }).length).toBe(3)
expect(screen.queryByRole('button', { name: 'Downgrade' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Back to billing' }))
expect(await screen.findByRole('button', { name: 'View plans' })).toBeTruthy()
})
it('renders the current marker and an actionable downgrade when deep-linked to the plans grid', async () => {
const fixture = billingDevFixtures['subscriber-personal']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling(['/settings?tab=billing&bview=plans'])
expect(await screen.findByText('Current plan')).toBeTruthy()
// Free sits below Plus → an in-app (enabled) "Downgrade" button, not disabled.
expect(screen.getByRole('button', { name: 'Downgrade' }).hasAttribute('disabled')).toBe(false)
// Super + Ultra are upgrades.
expect(screen.getAllByRole('button', { name: /Choose/ }).length).toBe(2)
})
it('falls back to overview (no live Choose grid) when a team deep-links bview=plans', async () => {
// Default beforeEach uses todaySubscriptionState (context: 'team') — no in-app
// plans capability, so the URL must not surface a grid of Choose buttons.
renderBilling(['/settings?tab=billing&bview=plans'])
expect(await screen.findByText('Payment & credits')).toBeTruthy()
expect(screen.queryByText('Plans')).toBeNull()
expect(screen.queryByRole('button', { name: /Choose/ })).toBeNull()
})
it('falls back to overview when a non-changer personal account deep-links bview=plans', async () => {
apiMocks.fetchSubscriptionState.mockResolvedValue(
okSubscription({ ...todaySubscriptionState, can_change_plan: false, context: 'personal' })
)
renderBilling(['/settings?tab=billing&bview=plans'])
expect(await screen.findByText('Payment & credits')).toBeTruthy()
expect(screen.queryByText('Plans')).toBeNull()
expect(screen.queryByRole('button', { name: /Choose/ })).toBeNull()
})
it('runs an in-app downgrade: preview → confirm → schedule with the tier id → refetch → overview', async () => {
const fixture = billingDevFixtures['subscriber-personal']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: {
effect: 'scheduled',
effective_at: '2026-08-15T00:00:00Z',
monthly_credits_delta: '-88',
ok: true,
target_tier_name: 'Free'
},
ok: true
})
apiMocks.scheduleSubscriptionChange.mockResolvedValue({ data: { ok: true }, ok: true })
const client = renderBilling(['/settings?tab=billing&bview=plans'])
const invalidate = vi.spyOn(client, 'invalidateQueries')
fireEvent.click(await screen.findByRole('button', { name: 'Downgrade' }))
await waitFor(() => expect(apiMocks.previewSubscriptionChange).toHaveBeenCalledWith('cltier000free0000personal'))
expect(await screen.findByText(/No charge now/)).toBeTruthy()
// Credits delta renders as signed dollars, not the raw wire string "-88".
expect(screen.getByText(/Monthly credits change: \$88\/mo\./)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Confirm downgrade' }))
await waitFor(() => expect(apiMocks.scheduleSubscriptionChange).toHaveBeenCalledWith('cltier000free0000personal'))
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'subscription'] }))
// Scheduled → back on the overview.
expect(await screen.findByText('Payment & credits')).toBeTruthy()
expect(screen.queryByText('Plans')).toBeNull()
})
it('surfaces the step-up affordance when scheduling a downgrade needs approval', async () => {
const fixture = billingDevFixtures['subscriber-personal']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: { effect: 'scheduled', effective_at: '2026-08-15T00:00:00Z', ok: true, target_tier_name: 'Free' },
ok: true
})
apiMocks.scheduleSubscriptionChange.mockResolvedValue({
ok: false,
refusal: { kind: 'insufficient_scope', message: 'billing:manage required' }
})
renderBilling(['/settings?tab=billing&bview=plans'])
fireEvent.click(await screen.findByRole('button', { name: 'Downgrade' }))
fireEvent.click(await screen.findByRole('button', { name: 'Confirm downgrade' }))
expect(await screen.findByText('Remote Spending needs approval:')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Verify to continue' })).toBeTruthy()
// The failed schedule offers a retry in place.
expect(screen.getByRole('button', { name: 'Try again' })).toBeTruthy()
expect(apiMocks.scheduleSubscriptionChange).toHaveBeenCalledTimes(1)
})
it('undoes a scheduled downgrade from the plan card via resume', async () => {
const fixture = billingDevFixtures['pending-downgrade']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
apiMocks.resumeSubscription.mockResolvedValue({ data: { ok: true }, ok: true })
const client = renderBilling()
const invalidate = vi.spyOn(client, 'invalidateQueries')
expect(await screen.findByText('Changes to Free on Aug 15.')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Undo' }))
await waitFor(() => expect(apiMocks.resumeSubscription).toHaveBeenCalledTimes(1))
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'subscription'] }))
})
it('undoes a scheduled cancellation from the plan card via resume', async () => {
const fixture = billingDevFixtures['pending-cancellation']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
apiMocks.resumeSubscription.mockResolvedValue({ data: { ok: true }, ok: true })
renderBilling()
expect(await screen.findByText('Cancels on Aug 15.')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Undo' }))
await waitFor(() => expect(apiMocks.resumeSubscription).toHaveBeenCalledTimes(1))
})
it('locks out the other downgrade tiles and Back while a schedule is in flight', async () => {
// Current = Ultra so Free/Plus/Super are all downgrades (three tiles).
apiMocks.fetchBillingState.mockResolvedValue(billingDevFixtures['subscriber-personal'].billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(
okSubscription({
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: { ...todaySubscriptionState.current, tier_id: 't_ultra', tier_name: 'Ultra' },
tiers: [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0.1',
name: 'Free',
tier_id: 't_free',
tier_order: 0
},
{
dollars_per_month_display: '$20',
is_current: false,
is_enabled: true,
monthly_credits: '22',
name: 'Plus',
tier_id: 't_plus',
tier_order: 1
},
{
dollars_per_month_display: '$100',
is_current: false,
is_enabled: true,
monthly_credits: '110',
name: 'Super',
tier_id: 't_super',
tier_order: 2
},
{
dollars_per_month_display: '$200',
is_current: true,
is_enabled: true,
monthly_credits: '220',
name: 'Ultra',
tier_id: 't_ultra',
tier_order: 3
}
]
})
)
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: { effect: 'scheduled', effective_at: '2026-08-15T00:00:00Z', ok: true, target_tier_name: 'Free' },
ok: true
})
let settleSchedule: (value: unknown) => void = () => {}
apiMocks.scheduleSubscriptionChange.mockReturnValue(
new Promise(resolve => {
settleSchedule = resolve
})
)
renderBilling(['/settings?tab=billing&bview=plans'])
const downgrades = await screen.findAllByRole('button', { name: 'Downgrade' })
expect(downgrades.length).toBe(3)
fireEvent.click(downgrades[0])
fireEvent.click(await screen.findByRole('button', { name: 'Confirm downgrade' }))
// Scheduling in flight → the two remaining tiles + Back are disabled.
await waitFor(() => {
const remaining = screen.getAllByRole('button', { name: 'Downgrade' })
expect(remaining.length).toBe(2)
expect(remaining.every(btn => btn.hasAttribute('disabled'))).toBe(true)
})
expect(screen.getByRole('button', { name: 'Back to billing' }).hasAttribute('disabled')).toBe(true)
settleSchedule({ data: { ok: true }, ok: true })
})
it('disables Undo while the resume is in flight', async () => {
const fixture = billingDevFixtures['pending-downgrade']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
let settleResume: (value: unknown) => void = () => {}
apiMocks.resumeSubscription.mockReturnValue(
new Promise(resolve => {
settleResume = resolve
})
)
renderBilling()
fireEvent.click(await screen.findByRole('button', { name: 'Undo' }))
await waitFor(() => expect(screen.getByRole('button', { name: 'Undoing…' }).hasAttribute('disabled')).toBe(true))
settleResume({ data: { ok: true }, ok: true })
})
it('moves focus into the confirm panel (role=status) when a downgrade opens', async () => {
const fixture = billingDevFixtures['subscriber-personal']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: { effect: 'scheduled', effective_at: '2026-08-15T00:00:00Z', ok: true, target_tier_name: 'Free' },
ok: true
})
renderBilling(['/settings?tab=billing&bview=plans'])
fireEvent.click(await screen.findByRole('button', { name: 'Downgrade' }))
const panel = await screen.findByRole('status')
expect(panel.getAttribute('aria-live')).toBe('polite')
expect(panel).toBe(panel.ownerDocument.activeElement)
})
it('keeps the auto-refill edit form mounted so the row height is reserved before editing', async () => {
renderBilling()
await screen.findByRole('button', { name: 'Manage' })
// Not editing: the inputs are already in the DOM (height reserved) but aria-hidden,
// so the accessible query finds nothing while the hidden-inclusive query does.
expect(screen.queryByRole('spinbutton', { name: 'Auto-refill threshold' })).toBeNull()
expect(screen.getByRole('spinbutton', { name: 'Auto-refill threshold', hidden: true })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Manage' }))
// Editing reveals the same reserved input.
expect(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' })).toBeTruthy()
})
it('renders auto-refill mutation refusals and step-up affordance', async () => {
renderBilling()
apiMocks.updateAutoReload.mockResolvedValue({
ok: false,
refusal: {
kind: 'insufficient_scope',
message: 'billing:manage required'
}
})
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }))
fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' }), {
target: { value: '15' }
})
fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill reload-to amount' }), {
target: { value: '20' }
})
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findByText('Remote Spending needs approval:')).toBeTruthy()
expect(screen.getByText('This needs Remote Spending allowed. Start a top-up to allow it, then retry.')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Verify to continue' })).toBeTruthy()
})
it('keeps disabled auto-refill portal-only with no enable control', async () => {
apiMocks.fetchBillingState.mockResolvedValue(okBilling(postTrainBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(postTrainSubscriptionState))
renderBilling()
expect((await screen.findAllByText('Off')).length).toBeGreaterThan(0)
expect(screen.getByText('Turn on auto-refill from the portal')).toBeTruthy()
expect(screen.queryByRole('button', { name: /enable/i })).toBeNull()
expect(screen.queryByRole('button', { name: 'Manage' })).toBeNull()
})
it('disables buy controls while polling and renders the settled outcome', async () => {
let settleStatus: (value: unknown) => void = () => {}
const statusPromise = new Promise(resolve => {
settleStatus = resolve
})
apiMocks.fetchBillingState.mockResolvedValue(okBilling(postTrainBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(postTrainSubscriptionState))
apiMocks.charge.mockResolvedValue({
data: {
charge_id: 'ch_123',
ok: true
},
idempotencyKey: 'key-1',
ok: true
})
apiMocks.chargeStatus.mockReturnValue(statusPromise)
renderBilling()
fireEvent.click(await screen.findByRole('button', { name: /^Buy$/ }))
expect(await screen.findByText('Processing… checking settlement')).toBeTruthy()
expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('spinbutton', { name: 'Custom credit amount' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: /^Buy$/ }).hasAttribute('disabled')).toBe(true)
settleStatus({
data: {
amount_usd: '25',
ok: true,
status: 'settled'
},
ok: true
})
await waitFor(() => expect(screen.getByText(`${formatMoney(25)} added. Balance is refreshing.`)).toBeTruthy())
})
it('renders logged-out as a connect card without normal account rows', async () => {
apiMocks.fetchBillingState.mockResolvedValue(okBilling(loggedOutBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(loggedOutSubscriptionState))
renderBilling()
expect(await screen.findByText('Connect your Nous account')).toBeTruthy()
expect(screen.getByText('Run /portal in the TUI or open the Nous portal to connect your account.')).toBeTruthy()
expect(screen.queryByText('Payment method')).toBeNull()
expect(screen.queryByText('Usage')).toBeNull()
})
it('renders danger value text for overdrawn subscription credits', async () => {
const fixture = billingDevFixtures['empty-overdrawn']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling()
expect((await screen.findByText('$0 of $220 left · $0.79 over')).classList.contains('text-destructive')).toBe(true)
const subscriptionTrack = screen.getByRole('progressbar', { name: 'Subscription credits remaining' })
// Plain shared primitive track (no bespoke dither/tinted chrome); the
// over-limit signal rides the destructive fill instead.
expect(subscriptionTrack.classList.contains('dither')).toBe(false)
expect(subscriptionTrack.classList.contains('bg-muted')).toBe(true)
expect(subscriptionTrack.querySelector('.bg-destructive')).toBeTruthy()
})
it('renders an empty neutral usage track when a row has no bar data', async () => {
const fixture = billingDevFixtures['no-subscription']
apiMocks.fetchBillingState.mockResolvedValue(
okBilling({
...todayBillingState,
monthly_cap: {
...todayBillingState.monthly_cap,
spent_display: '$0',
spent_this_month_usd: '0'
}
})
)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling()
await screen.findByText('Subscription credits')
const subscriptionTrack = screen.getByRole('progressbar', { name: 'Subscription credits usage' })
expect(subscriptionTrack.getAttribute('aria-valuenow')).toBe('0')
expect(subscriptionTrack.classList.contains('text-destructive')).toBe(false)
// Empty tracks are the plain shared primitive now — no hatched placeholder.
expect(subscriptionTrack.classList.contains('dither')).toBe(false)
expect(subscriptionTrack.classList.contains('bg-muted')).toBe(true)
const monthlyCapTrack = screen.getByRole('progressbar', { name: 'Monthly spend cap used' })
expect(monthlyCapTrack.getAttribute('aria-valuenow')).toBe('0')
expect(monthlyCapTrack.classList.contains('dither')).toBe(false)
expect(monthlyCapTrack.classList.contains('bg-muted')).toBe(true)
})
it('shows a warn notice that names the no-card blocker with a portal link', async () => {
const fixture = billingDevFixtures['no-card']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling()
expect(await screen.findByText('No payment method on file')).toBeTruthy()
expect(
screen.getByText(
'Buying top-up credits and auto-refill stay disabled until a card is on file. Add one on the portal.'
)
).toBeTruthy()
expect(screen.getByRole('button', { name: /Add card/ })).toBeTruthy()
})
it('does not show the no-card notice when a card is on file', async () => {
renderBilling()
await screen.findByText('$996.47')
expect(screen.queryByText('No payment method on file')).toBeNull()
})
it('polls billing on an interval without a manual refresh control', async () => {
renderBilling()
await screen.findByText('$120 of $220 left')
// The manual refresh affordance is gone — the queries poll on their own.
expect(screen.queryByRole('button', { name: 'Refresh' })).toBeNull()
expect(screen.queryByText(/Updated/)).toBeNull()
})
})
function BillingLocaleSwitch() {
const { setLocale } = useI18n()
return <button onClick={() => void setLocale('tr')}>Türkçe</button>
}
it('switches loaded billing content to Turkish and preserves auto-refill request values', async () => {
apiMocks.updateAutoReload.mockResolvedValue({ data: { ok: true }, ok: true })
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
renderUi(
<I18nProvider configClient={null} initialLocale="en">
<BillingLocaleSwitch />
<MemoryRouter initialEntries={['/settings?tab=billing']}>
<QueryClientProvider client={client}>
<BillingSettings />
</QueryClientProvider>
</MemoryRouter>
</I18nProvider>
)
await screen.findByText('Buy credits now')
fireEvent.click(screen.getByRole('button', { name: 'Türkçe' }))
expect(await screen.findByText('Şimdi kredi satın al')).toBeTruthy()
expect(screen.queryByText('Buy credits now')).toBeNull()
expect(screen.getByText('Bakiye')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Yönet' }))
fireEvent.change(screen.getByRole('spinbutton', { name: 'Otomatik yükleme eşiği' }), { target: { value: '15' } })
fireEvent.change(screen.getByRole('spinbutton', { name: 'Otomatik yükleme tutarı' }), { target: { value: '20' } })
fireEvent.click(screen.getByRole('button', { name: 'Kaydet' }))
await waitFor(() =>
expect(apiMocks.updateAutoReload).toHaveBeenCalledWith({ enabled: true, threshold_usd: '15', reload_to_usd: '20' })
)
expect(await screen.findByText('Otomatik yükleme güncellendi.')).toBeTruthy()
expect(apiMocks.charge).not.toHaveBeenCalled()
})