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