// ============================================================ // Relay 页面测试 // ============================================================ 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 Relay from '@/pages/Relay' // ── Mock data ──────────────────────────────────────────────── const mockRelayTasks = { items: [ { id: 'task-001-abcdef', account_id: 'acc-001', provider_id: 'prov-001', model_id: 'gpt-4o', status: 'completed', priority: 0, attempt_count: 1, max_attempts: 3, input_tokens: 1500, output_tokens: 800, error_message: null, queued_at: '2026-03-30T10:00:00Z', started_at: '2026-03-30T10:00:01Z', completed_at: '2026-03-30T10:00:05Z', created_at: '2026-03-30T10:00:00Z', }, { id: 'task-002-ghijkl', account_id: 'acc-002', provider_id: 'prov-002', model_id: 'claude-3.5-sonnet', status: 'failed', priority: 0, attempt_count: 3, max_attempts: 3, input_tokens: 2000, output_tokens: 0, error_message: 'Rate limit exceeded', queued_at: '2026-03-30T09:00:00Z', started_at: '2026-03-30T09:00:01Z', completed_at: '2026-03-30T09:01:00Z', created_at: '2026-03-30T09:00:00Z', }, { id: 'task-003-mnopqr', account_id: 'acc-001', provider_id: 'prov-001', model_id: 'gpt-4o-mini', status: 'queued', priority: 1, attempt_count: 0, max_attempts: 3, input_tokens: 0, output_tokens: 0, error_message: null, queued_at: '2026-03-30T11:00:00Z', started_at: null, completed_at: null, created_at: '2026-03-30T11:00:00Z', }, ], total: 3, page: 1, page_size: 20, } // ── MSW server ─────────────────────────────────────────────── const server = setupServer() beforeEach(() => { server.listen({ onUnhandledRequest: 'bypass' }) }) afterEach(() => { server.close() }) // ── Helper: render with QueryClient ────────────────────────── function renderWithProviders(ui: React.ReactElement) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, }, }) return render( {ui} , ) } // ── Tests ──────────────────────────────────────────────────── describe('Relay page', () => { it('renders page header', async () => { server.use( http.get('*/api/v1/relay/tasks', () => { return HttpResponse.json(mockRelayTasks) }), ) renderWithProviders() expect(screen.getByText('中转任务')).toBeInTheDocument() expect(screen.getByText('查看和管理 AI 模型中转请求')).toBeInTheDocument() }) it('fetches and displays relay tasks', async () => { server.use( http.get('*/api/v1/relay/tasks', () => { return HttpResponse.json(mockRelayTasks) }), ) renderWithProviders() await waitFor(() => { expect(screen.getByText('已完成')).toBeInTheDocument() }) expect(screen.getByText('失败')).toBeInTheDocument() expect(screen.getByText('排队中')).toBeInTheDocument() }) it('shows loading spinner while fetching', async () => { server.use( http.get('*/api/v1/relay/tasks', async () => { await new Promise((resolve) => setTimeout(resolve, 500)) return HttpResponse.json(mockRelayTasks) }), ) renderWithProviders() // Ant Design Spin component renders a .ant-spin element const spinner = document.querySelector('.ant-spin') expect(spinner).toBeTruthy() // Wait for loading to complete so afterEach cleanup is clean await waitFor(() => { expect(screen.getByText('已完成')).toBeInTheDocument() }) }) it('shows ErrorState on API failure with retry button', async () => { server.use( http.get('*/api/v1/relay/tasks', () => { return HttpResponse.json( { error: 'internal_error', message: '服务器内部错误' }, { status: 500 }, ) }), ) renderWithProviders() await waitFor(() => { expect(screen.getByText('服务器内部错误')).toBeInTheDocument() }) // Ant Design Button splits two-character text with a space: "重 试" const retryButton = screen.getByRole('button', { name: /重.?试/ }) expect(retryButton).toBeInTheDocument() }) it('renders status as colored tag', async () => { server.use( http.get('*/api/v1/relay/tasks', () => { return HttpResponse.json(mockRelayTasks) }), ) renderWithProviders() await waitFor(() => { expect(screen.getByText('已完成')).toBeInTheDocument() }) // Verify the status tags have correct Ant Design color classes const completedTag = screen.getByText('已完成').closest('.ant-tag') expect(completedTag).toBeTruthy() // statusColors.completed = 'green' expect(completedTag?.className).toMatch(/green/) const failedTag = screen.getByText('失败').closest('.ant-tag') expect(failedTag).toBeTruthy() // statusColors.failed = 'red' expect(failedTag?.className).toMatch(/red/) }) it('renders model_id column', async () => { server.use( http.get('*/api/v1/relay/tasks', () => { return HttpResponse.json(mockRelayTasks) }), ) renderWithProviders() await waitFor(() => { expect(screen.getByText('已完成')).toBeInTheDocument() }) expect(screen.getByText('gpt-4o')).toBeInTheDocument() expect(screen.getByText('claude-3.5-sonnet')).toBeInTheDocument() expect(screen.getByText('gpt-4o-mini')).toBeInTheDocument() }) it('renders token count column', async () => { server.use( http.get('*/api/v1/relay/tasks', () => { return HttpResponse.json(mockRelayTasks) }), ) renderWithProviders() await waitFor(() => { expect(screen.getByText('已完成')).toBeInTheDocument() }) // Token (入/出): 1,500 / 800 expect(screen.getByText(/1,500 \/ 800/)).toBeInTheDocument() // 2,000 / 0 expect(screen.getByText(/2,000 \/ 0/)).toBeInTheDocument() }) })