Files
aiturk-hermes-ide/ui-tui/src/__tests__/billingStepUp.test.tsx
T

200 lines
6.1 KiB
TypeScript

import { PassThrough } from 'stream'
import { renderSync } from '@hermes/ink'
import React from 'react'
import { describe, expect, it, vi } from 'vitest'
// Stub useInput so the overlay doesn't enter raw mode under renderSync.
vi.mock('@hermes/ink', async importOriginal => {
const mod = await importOriginal()
return { ...mod, useInput: () => {} }
})
import type { BillingOverlayState } from '../app/interfaces.js'
import { BillingOverlay } from '../components/billingOverlay.js'
import type { BillingStateResponse } from '../gatewayTypes.js'
import { stripAnsi } from '../lib/text.js'
import { DEFAULT_THEME } from '../theme.js'
const t = DEFAULT_THEME
function render(overlay: BillingOverlayState): string {
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
let output = ''
Object.assign(stdout, { columns: 100, isTTY: false, rows: 40 })
Object.assign(stdin, { isTTY: false })
Object.assign(stderr, { isTTY: false })
stdout.on('data', chunk => {
output += chunk.toString()
})
const instance = renderSync(
React.createElement(BillingOverlay, {
onClose: () => {},
onPatch: () => {},
overlay,
t
}),
{
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
}
)
instance.unmount()
instance.cleanup()
return stripAnsi(output)
}
const billState = (overrides: Partial<BillingStateResponse> = {}): BillingStateResponse =>
({
auto_reload: null,
balance_display: '$12.00',
balance_usd: '12',
can_charge: true,
card: { brand: 'visa', last4: '4242', masked: 'visa ····4242' },
charge_presets: ['25', '50'],
charge_presets_display: ['$25', '$50'],
cli_billing_enabled: true,
is_admin: true,
logged_in: true,
max_usd: '1000',
min_usd: '10',
monthly_cap: null,
ok: true,
org_name: 'Acme',
portal_url: 'https://portal/billing',
role: 'OWNER',
...overrides
}) as BillingStateResponse
const ctx = {
applyAutoReload: vi.fn(() => Promise.resolve(true)),
charge: vi.fn(() => Promise.resolve('submitted' as const)),
openPortal: vi.fn(),
refreshState: vi.fn(() => Promise.resolve(null)),
requestRemoteSpending: vi.fn(() => Promise.resolve(true)),
sys: vi.fn(),
validate: vi.fn((raw: string) => ({ amount: raw }))
}
const overlay = (screen: BillingOverlayState['screen']): BillingOverlayState => ({
ctx,
pendingCharge: { amount: '100' },
screen,
state: billState()
})
describe('BillingOverlay — step-up screen (Allow Remote Spending)', () => {
it('renders the one-time-setup prompt with the held amount, never leaking the raw scope', () => {
const out = render(overlay('stepup'))
expect(out).toContain('One-time setup')
expect(out).toContain('Allow Remote Spending')
expect(out).toContain('$100') // resumes the held purchase
expect(out).toContain('Not now')
expect(out).not.toContain('billing:manage')
})
})
describe('BillingOverlay — overview (reordered, dollars)', () => {
it('leads with balance in the title, Add funds first, no "credits"', () => {
const out = render(overlay('overview'))
expect(out).toContain('Top up · balance $12.00') // balance in the title
expect(out).toContain('Add funds') // buy action, renamed
expect(out).toContain('Auto-reload')
expect(out).toContain('Manage on portal')
expect(out.toLowerCase()).not.toContain('credits') // dollars only
// No standalone "Allow Remote Spending" item — discovered at pay time.
expect(out).not.toContain('Allow Remote Spending')
})
it('renders the two-bar dollar usage when a usage model is present', () => {
const withUsage: BillingOverlayState = {
...overlay('overview'),
state: {
...billState(),
usage: {
available: true,
status: 'healthy',
plan_name: 'Plus',
has_topup: true,
plan_bar: {
kind: 'plan',
remaining_display: '$14.00',
total_display: '$20.00',
spent_display: '$6.00',
pct_used: 30,
fill_fraction: 0.7
},
topup_bar: {
kind: 'topup',
remaining_display: '$12.00',
total_display: '$12.00',
spent_display: '$0.00',
pct_used: null,
fill_fraction: 1
}
}
}
}
const out = render(withUsage)
expect(out).toContain('$14.00 left of $20.00')
expect(out).toContain('30% used')
expect(out).toContain('never expires')
})
})
describe('BillingOverlay — auto-reload card divergence', () => {
const autoReload = (card: NonNullable<BillingStateResponse['auto_reload']>['card']) => ({
card,
enabled: true,
reload_to_display: '$100',
reload_to_usd: '100',
threshold_display: '$20',
threshold_usd: '20'
})
it('warns when auto-reload charges a distinct card and offers the portal hand-off', () => {
const out = render({
...overlay('autoreload'),
state: billState({
auto_reload: autoReload({ kind: 'distinct', payment_method_id: 'pm_other', brand: 'Visa', last4: '9999' })
})
})
expect(out).toContain('Auto-refill is charging Visa ••9999 — not your card on file')
expect(out).toContain('authorize Nous Research to charge Visa ••9999')
expect(out).toContain('Use your card on file — manage on portal')
})
it('uses generic distinct-card copy when metadata is unresolved', () => {
const out = render({
...overlay('autoreload'),
state: billState({
auto_reload: autoReload({ kind: 'distinct', payment_method_id: 'pm_other', brand: null, last4: null })
})
})
expect(out).toContain('Auto-refill is charging a different card — not your card on file')
})
it.each(['canonical', 'none'] as const)('does not warn for a %s auto-reload card', kind => {
const out = render({
...overlay('autoreload'),
state: billState({ auto_reload: autoReload({ kind }) })
})
expect(out).not.toContain('not your card on file')
expect(out).not.toContain('Use your card on file — manage on portal')
})
})