import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import Billing from '@/pages/Billing'
// ── Mock data ──────────────────────────────────────────────────
const mockPlans = [
{
id: 'plan-free', name: 'free', display_name: '免费版',
description: '基础功能', price_cents: 0, currency: 'CNY',
interval: 'month',
features: {}, limits: { max_relay_requests_monthly: 100, max_hand_executions_monthly: 10, max_pipeline_runs_monthly: 5 },
is_default: true, sort_order: 0, status: 'active',
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
},
{
id: 'plan-pro', name: 'pro', display_name: '专业版',
description: '高级功能', price_cents: 9900, currency: 'CNY',
interval: 'month',
features: {}, limits: { max_relay_requests_monthly: 1000, max_hand_executions_monthly: 100, max_pipeline_runs_monthly: 50 },
is_default: false, sort_order: 1, status: 'active',
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
},
{
id: 'plan-team', name: 'team', display_name: '团队版',
description: '团队协作', price_cents: 29900, currency: 'CNY',
interval: 'month',
features: {}, limits: { max_relay_requests_monthly: 10000, max_hand_executions_monthly: 500, max_pipeline_runs_monthly: 200 },
is_default: false, sort_order: 2, status: 'active',
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
},
]
const mockSubscription = {
plan: mockPlans[0],
subscription: null,
usage: {
id: 'usage-001', account_id: 'acc-001',
period_start: '2026-04-01T00:00:00Z', period_end: '2026-04-30T23:59:59Z',
input_tokens: 5000, output_tokens: 12000,
relay_requests: 42, hand_executions: 3, pipeline_runs: 1,
max_input_tokens: null, max_output_tokens: null,
max_relay_requests: 100, max_hand_executions: 10, max_pipeline_runs: 5,
created_at: '2026-04-01T00:00:00Z', updated_at: '2026-04-07T12:00:00Z',
},
}
const server = setupServer()
beforeEach(() => {
server.listen({ onUnhandledRequest: 'bypass' })
})
afterEach(() => {
server.close()
})
function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
return render(
{ui}
,
)
}
function setupBillingHandlers(overrides: Record = {}) {
server.use(
http.get('*/api/v1/billing/plans', () => {
return HttpResponse.json(overrides.plans ?? mockPlans)
}),
http.get('*/api/v1/billing/subscription', () => {
return HttpResponse.json(overrides.subscription ?? mockSubscription)
}),
)
}
describe('Billing', () => {
it('renders page title', () => {
setupBillingHandlers()
renderWithProviders()
expect(screen.getByText('计费管理')).toBeInTheDocument()
})
it('shows loading state', async () => {
server.use(
http.get('*/api/v1/billing/plans', async () => {
await new Promise(resolve => setTimeout(resolve, 500))
return HttpResponse.json(mockPlans)
}),
http.get('*/api/v1/billing/subscription', async () => {
await new Promise(resolve => setTimeout(resolve, 500))
return HttpResponse.json(mockSubscription)
}),
)
renderWithProviders()
expect(document.querySelector('.ant-spin')).toBeTruthy()
})
it('displays all three plan cards', async () => {
setupBillingHandlers()
renderWithProviders()
await waitFor(() => {
expect(screen.getByText('免费版')).toBeInTheDocument()
})
expect(screen.getByText('专业版')).toBeInTheDocument()
expect(screen.getByText('团队版')).toBeInTheDocument()
})
it('displays plan prices', async () => {
setupBillingHandlers()
renderWithProviders()
await waitFor(() => {
// Free plan: ¥0
expect(screen.getByText('¥0')).toBeInTheDocument()
})
// Pro plan: ¥99, Team plan: ¥299
expect(screen.getByText('¥99')).toBeInTheDocument()
expect(screen.getByText('¥299')).toBeInTheDocument()
})
it('displays per-month interval', async () => {
setupBillingHandlers()
renderWithProviders()
await waitFor(() => {
// All plans are monthly, so "/月" should appear multiple times
const monthLabels = screen.getAllByText('/月')
expect(monthLabels.length).toBeGreaterThanOrEqual(3)
})
})
it('displays plan limits', async () => {
setupBillingHandlers()
renderWithProviders()
await waitFor(() => {
// Free plan limits
expect(screen.getByText('中转请求: 100 次/月')).toBeInTheDocument()
})
expect(screen.getByText('Hand 执行: 10 次/月')).toBeInTheDocument()
expect(screen.getByText('Pipeline 运行: 5 次/月')).toBeInTheDocument()
})
it('shows 当前计划 badge on free plan', async () => {
setupBillingHandlers()
renderWithProviders()
await waitFor(() => {
// "当前计划" appears on the badge AND the disabled button for free plan
const allCurrentPlan = screen.getAllByText('当前计划')
expect(allCurrentPlan.length).toBeGreaterThanOrEqual(1)
})
})
it('renders pro and team plan cards with buttons', async () => {
setupBillingHandlers()
renderWithProviders()
await waitFor(() => {
expect(screen.getByText('专业版')).toBeInTheDocument()
})
// Non-current plans should have clickable buttons (not disabled "当前计划")
expect(screen.getByText('团队版')).toBeInTheDocument()
// Free plan is current → its button shows "当前计划" and is disabled
const allButtons = screen.getAllByRole('button')
const disabledButtons = allButtons.filter(btn => btn.hasAttribute('disabled'))
expect(disabledButtons.length).toBeGreaterThanOrEqual(1) // at least free plan button
})
it('shows 当前用量 section', async () => {
setupBillingHandlers()
renderWithProviders()
await waitFor(() => {
expect(screen.getByText('当前用量')).toBeInTheDocument()
})
})
it('displays usage bars with correct values', async () => {
setupBillingHandlers()
renderWithProviders()
await waitFor(() => {
// relay_requests: 42 / 100
expect(screen.getByText('中转请求')).toBeInTheDocument()
})
expect(screen.getByText('Hand 执行')).toBeInTheDocument()
expect(screen.getByText('Pipeline 运行')).toBeInTheDocument()
})
it('shows error state on plans API failure', async () => {
server.use(
http.get('*/api/v1/billing/plans', () => {
return HttpResponse.json(
{ error: 'internal_error', message: '数据库错误' },
{ status: 500 },
)
}),
http.get('*/api/v1/billing/subscription', () => {
return HttpResponse.json(mockSubscription)
}),
)
renderWithProviders()
await waitFor(() => {
expect(screen.getByText('加载失败')).toBeInTheDocument()
})
})
it('renders without subscription data', async () => {
setupBillingHandlers({
subscription: {
plan: null,
subscription: null,
usage: null,
},
})
renderWithProviders()
await waitFor(() => {
expect(screen.getByText('免费版')).toBeInTheDocument()
})
// No usage section when usage is null
expect(screen.queryByText('当前用量')).not.toBeInTheDocument()
})
it('shows 选择计划 heading', async () => {
setupBillingHandlers()
renderWithProviders()
await waitFor(() => {
expect(screen.getByText('选择计划')).toBeInTheDocument()
})
})
})