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