test(admin-v2): Phase 2 frontend tests — 61 tests for 5 pages
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

- Billing (13 tests): plan cards, prices, limits, usage bars, payment flow
- ScheduledTasks (16 tests): CRUD table, schedule/target types, color tags
- Knowledge (12 tests): 4 tabs, items/categories/search/analytics panels
- Roles (12 tests): roles + permission templates tabs
- ConfigSync (8 tests): sync log viewer with action labels

Fix: Knowledge.tsx missing </Select> and </Modal> closing tags (JSX parse error)
Fix: tests/setup.ts added ResizeObserver mock for ProTable compatibility
This commit is contained in:
iven
2026-04-07 16:06:47 +08:00
parent 7de486bfca
commit 803464b492
7 changed files with 1200 additions and 0 deletions

View File

@@ -0,0 +1,244 @@
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(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
}
function setupBillingHandlers(overrides: Record<string, unknown> = {}) {
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(<Billing />)
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(<Billing />)
expect(document.querySelector('.ant-spin')).toBeTruthy()
})
it('displays all three plan cards', async () => {
setupBillingHandlers()
renderWithProviders(<Billing />)
await waitFor(() => {
expect(screen.getByText('免费版')).toBeInTheDocument()
})
expect(screen.getByText('专业版')).toBeInTheDocument()
expect(screen.getByText('团队版')).toBeInTheDocument()
})
it('displays plan prices', async () => {
setupBillingHandlers()
renderWithProviders(<Billing />)
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(<Billing />)
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(<Billing />)
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(<Billing />)
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(<Billing />)
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(<Billing />)
await waitFor(() => {
expect(screen.getByText('当前用量')).toBeInTheDocument()
})
})
it('displays usage bars with correct values', async () => {
setupBillingHandlers()
renderWithProviders(<Billing />)
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(<Billing />)
await waitFor(() => {
expect(screen.getByText('加载失败')).toBeInTheDocument()
})
})
it('renders without subscription data', async () => {
setupBillingHandlers({
subscription: {
plan: null,
subscription: null,
usage: null,
},
})
renderWithProviders(<Billing />)
await waitFor(() => {
expect(screen.getByText('免费版')).toBeInTheDocument()
})
// No usage section when usage is null
expect(screen.queryByText('当前用量')).not.toBeInTheDocument()
})
it('shows 选择计划 heading', async () => {
setupBillingHandlers()
renderWithProviders(<Billing />)
await waitFor(() => {
expect(screen.getByText('选择计划')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,153 @@
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 ConfigSync from '@/pages/ConfigSync'
const mockSyncLogs = {
items: [
{
id: 1,
account_id: 'acc-001',
client_fingerprint: 'fp-abc123def456',
action: 'push',
config_keys: 'model_config,prompt_config',
client_values: '{"model":"gpt-4"}',
saas_values: '{"model":"gpt-3.5"}',
resolution: 'client_wins',
created_at: '2026-04-07T10:30:00Z',
},
{
id: 2,
account_id: 'acc-002',
client_fingerprint: 'fp-xyz789',
action: 'pull',
config_keys: 'privacy_settings',
client_values: null,
saas_values: '{"analytics":true}',
resolution: null,
created_at: '2026-04-06T08:00:00Z',
},
],
total: 2,
page: 1,
page_size: 20,
}
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(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
}
describe('ConfigSync', () => {
it('renders page title', () => {
server.use(
http.get('*/api/v1/config/sync-logs', () => {
return HttpResponse.json({ items: [], total: 0, page: 1, page_size: 20 })
}),
)
renderWithProviders(<ConfigSync />)
expect(screen.getByText('配置同步日志')).toBeInTheDocument()
})
it('shows loading state', async () => {
server.use(
http.get('*/api/v1/config/sync-logs', async () => {
await new Promise(resolve => setTimeout(resolve, 500))
return HttpResponse.json(mockSyncLogs)
}),
)
renderWithProviders(<ConfigSync />)
expect(document.querySelector('.ant-spin')).toBeTruthy()
})
it('displays sync logs with Chinese action labels', async () => {
server.use(
http.get('*/api/v1/config/sync-logs', () => {
return HttpResponse.json(mockSyncLogs)
}),
)
renderWithProviders(<ConfigSync />)
// Action labels are mapped to Chinese: push → 推送, pull → 拉取
await waitFor(() => {
expect(screen.getByText('推送')).toBeInTheDocument()
})
expect(screen.getByText('拉取')).toBeInTheDocument()
})
it('displays config keys for each log', async () => {
server.use(
http.get('*/api/v1/config/sync-logs', () => {
return HttpResponse.json(mockSyncLogs)
}),
)
renderWithProviders(<ConfigSync />)
await waitFor(() => {
expect(screen.getByText('model_config,prompt_config')).toBeInTheDocument()
})
})
it('displays resolution column', async () => {
server.use(
http.get('*/api/v1/config/sync-logs', () => {
return HttpResponse.json(mockSyncLogs)
}),
)
renderWithProviders(<ConfigSync />)
await waitFor(() => {
expect(screen.getByText('client_wins')).toBeInTheDocument()
})
})
it('color-codes action tags', async () => {
server.use(
http.get('*/api/v1/config/sync-logs', () => {
return HttpResponse.json(mockSyncLogs)
}),
)
renderWithProviders(<ConfigSync />)
await waitFor(() => {
const pushTag = screen.getByText('推送').closest('.ant-tag')
expect(pushTag?.className).toMatch(/blue/)
})
const pullTag = screen.getByText('拉取').closest('.ant-tag')
expect(pullTag?.className).toMatch(/cyan/)
})
it('renders table column headers', async () => {
server.use(
http.get('*/api/v1/config/sync-logs', () => {
return HttpResponse.json(mockSyncLogs)
}),
)
renderWithProviders(<ConfigSync />)
await waitFor(() => {
expect(screen.getByText('操作')).toBeInTheDocument()
})
expect(screen.getByText('客户端指纹')).toBeInTheDocument()
expect(screen.getByText('配置键')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,299 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import Knowledge from '@/pages/Knowledge'
// ── Mock data ──────────────────────────────────────────────────
const mockCategories = [
{
id: 'cat-001', name: '技术文档', description: '技术相关文档',
parent_id: null, icon: '📚', sort_order: 0, item_count: 5,
children: [], created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
},
{
id: 'cat-002', name: '行业知识', description: '行业相关知识',
parent_id: null, icon: '🏭', sort_order: 1, item_count: 3,
children: [], created_at: '2026-01-15T00:00:00Z', updated_at: '2026-01-15T00:00:00Z',
},
]
const mockItems = {
items: [
{
id: 'item-001', category_id: 'cat-001', title: 'API 认证指南',
content: 'JWT 认证流程说明...', keywords: ['认证', 'JWT'],
related_questions: [], priority: 5, status: 'active',
version: 2, source: 'manual', tags: ['api', 'auth'],
created_by: 'admin', created_at: '2026-02-01T00:00:00Z', updated_at: '2026-03-01T00:00:00Z',
},
{
id: 'item-002', category_id: 'cat-002', title: '玩具市场趋势 2026',
content: '2026 年玩具行业趋势分析...', keywords: ['市场', '趋势'],
related_questions: [], priority: 3, status: 'active',
version: 1, source: 'import', tags: ['market'],
created_by: 'admin', created_at: '2026-03-01T00:00:00Z', updated_at: '2026-03-01T00:00:00Z',
},
],
total: 2, page: 1, page_size: 20,
}
const mockOverview = {
total_items: 8, active_items: 6, total_categories: 2,
weekly_new_items: 1, total_references: 45, avg_reference_per_item: 5.6,
hit_rate: 0.78, injection_rate: 0.65, positive_feedback_rate: 0.92,
stale_items_count: 1,
}
const mockTrends = { trends: [{ date: '2026-04-01', new_items: 2, references: 10, queries: 25 }] }
const mockTopItems = { items: [] }
const mockQuality = { metrics: [] }
const mockGaps = { gaps: [] }
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(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
}
function setupKnowledgeHandlers(overrides: Record<string, unknown> = {}) {
server.use(
http.get('*/api/v1/knowledge/categories', () => {
return HttpResponse.json(overrides.categories ?? mockCategories)
}),
http.get('*/api/v1/knowledge/items', () => {
return HttpResponse.json(overrides.items ?? mockItems)
}),
http.get('*/api/v1/knowledge/analytics/overview', () => {
return HttpResponse.json(overrides.overview ?? mockOverview)
}),
http.get('*/api/v1/knowledge/analytics/trends', () => {
return HttpResponse.json(overrides.trends ?? mockTrends)
}),
http.get('*/api/v1/knowledge/analytics/top-items', () => {
return HttpResponse.json(overrides.topItems ?? mockTopItems)
}),
http.get('*/api/v1/knowledge/analytics/quality', () => {
return HttpResponse.json(overrides.quality ?? mockQuality)
}),
http.get('*/api/v1/knowledge/analytics/gaps', () => {
return HttpResponse.json(overrides.gaps ?? mockGaps)
}),
)
}
describe('Knowledge', () => {
// ── Tab structure ─────────────────────────────────────────────
it('renders all tab labels', () => {
setupKnowledgeHandlers()
renderWithProviders(<Knowledge />)
expect(screen.getByText('知识条目')).toBeInTheDocument()
expect(screen.getByText('分类管理')).toBeInTheDocument()
expect(screen.getByText('搜索')).toBeInTheDocument()
expect(screen.getByText('分析看板')).toBeInTheDocument()
})
// ── Items Tab (default) ──────────────────────────────────────
it('displays items in default tab', async () => {
setupKnowledgeHandlers()
renderWithProviders(<Knowledge />)
await waitFor(() => {
expect(screen.getByText('API 认证指南')).toBeInTheDocument()
})
expect(screen.getByText('玩具市场趋势 2026')).toBeInTheDocument()
})
it('displays item status with Chinese labels', async () => {
setupKnowledgeHandlers()
renderWithProviders(<Knowledge />)
await waitFor(() => {
// status "active" is displayed as "活跃" via statusLabels mapping
const activeLabels = screen.getAllByText('活跃')
expect(activeLabels.length).toBeGreaterThanOrEqual(2)
})
})
it('displays item version column', async () => {
setupKnowledgeHandlers()
renderWithProviders(<Knowledge />)
await waitFor(() => {
expect(screen.getByText('API 认证指南')).toBeInTheDocument()
})
// Version numbers in the table
expect(screen.getByText('2')).toBeInTheDocument()
})
it('shows empty state when no items', async () => {
setupKnowledgeHandlers({
items: { items: [], total: 0, page: 1, page_size: 20 },
})
renderWithProviders(<Knowledge />)
await waitFor(() => {
const empties = screen.getAllByText('暂无数据')
expect(empties.length).toBeGreaterThanOrEqual(1)
})
})
// ── Categories Tab ───────────────────────────────────────────
it('switches to categories tab and displays categories', async () => {
setupKnowledgeHandlers()
renderWithProviders(<Knowledge />)
await waitFor(() => {
expect(screen.getByText('API 认证指南')).toBeInTheDocument()
})
// Find the tab button (not the panel heading) and click it
const categoryTabs = screen.getAllByText('分类管理')
await act(async () => {
fireEvent.click(categoryTabs[0])
})
// Wait for the categories panel to render its heading and tree
await waitFor(() => {
// "新建分类" button should appear in the CategoriesPanel toolbar
expect(screen.getByText('新建分类')).toBeInTheDocument()
}, { timeout: 3000 })
// Category names rendered via Tree component inside spans with icon prefix
// Use stringContaining since the text includes icon emoji prefix
expect(screen.getByText((content) => content.includes('技术文档'))).toBeInTheDocument()
expect(screen.getByText((content) => content.includes('行业知识'))).toBeInTheDocument()
})
it('shows empty categories state', async () => {
setupKnowledgeHandlers({ categories: [] })
renderWithProviders(<Knowledge />)
// Wait for items tab to load first
await waitFor(() => {
expect(screen.getByText('API 认证指南')).toBeInTheDocument()
})
// Switch to categories tab
const categoryTabs = screen.getAllByText('分类管理')
await act(async () => {
fireEvent.click(categoryTabs[0])
})
// The CategoriesPanel should show "暂无分类,请新建一个" for empty state
await waitFor(() => {
expect(screen.getByText('暂无分类,请新建一个')).toBeInTheDocument()
}, { timeout: 3000 })
})
// ── Analytics Tab ────────────────────────────────────────────
it('switches to analytics tab and shows overview stats', async () => {
setupKnowledgeHandlers()
renderWithProviders(<Knowledge />)
const analyticsTab = screen.getByText('分析看板')
await act(async () => {
fireEvent.click(analyticsTab)
})
await waitFor(() => {
expect(screen.getByText('总条目数')).toBeInTheDocument()
})
expect(screen.getByText('活跃条目')).toBeInTheDocument()
expect(screen.getByText('分类数')).toBeInTheDocument()
expect(screen.getByText('本周新增')).toBeInTheDocument()
})
it('displays analytics numbers', async () => {
setupKnowledgeHandlers()
renderWithProviders(<Knowledge />)
const analyticsTab = screen.getByText('分析看板')
await act(async () => {
fireEvent.click(analyticsTab)
})
await waitFor(() => {
// total_items: 8
expect(screen.getByText('8')).toBeInTheDocument()
})
})
// ── Error Handling ───────────────────────────────────────────
it('shows empty on API failure', async () => {
server.use(
http.get('*/api/v1/knowledge/categories', () => {
return HttpResponse.json(
{ error: 'internal_error', message: '数据库错误' },
{ status: 500 },
)
}),
http.get('*/api/v1/knowledge/items', () => {
return HttpResponse.json(
{ error: 'internal_error', message: '数据库错误' },
{ status: 500 },
)
}),
)
renderWithProviders(<Knowledge />)
await waitFor(() => {
const empties = screen.getAllByText('暂无数据')
expect(empties.length).toBeGreaterThanOrEqual(1)
})
})
// ── Loading State ────────────────────────────────────────────
it('shows loading spinner', async () => {
server.use(
http.get('*/api/v1/knowledge/categories', async () => {
await new Promise(resolve => setTimeout(resolve, 500))
return HttpResponse.json(mockCategories)
}),
http.get('*/api/v1/knowledge/items', async () => {
await new Promise(resolve => setTimeout(resolve, 500))
return HttpResponse.json(mockItems)
}),
)
renderWithProviders(<Knowledge />)
expect(document.querySelector('.ant-spin')).toBeTruthy()
})
// ── Item Tags ────────────────────────────────────────────────
it('displays item tags in table', async () => {
setupKnowledgeHandlers()
renderWithProviders(<Knowledge />)
await waitFor(() => {
expect(screen.getByText('api')).toBeInTheDocument()
})
expect(screen.getByText('auth')).toBeInTheDocument()
expect(screen.getByText('market')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,227 @@
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 Roles from '@/pages/Roles'
// ── Mock data ──────────────────────────────────────────────────
const mockRoles = [
{
id: 'role-admin', name: 'admin', description: '管理员',
permissions: ['admin:full'], account_count: 2,
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
},
{
id: 'role-user', name: 'user', description: '普通用户',
permissions: ['model:read', 'relay:use', 'config:read', 'prompt:read'],
account_count: 15,
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
},
]
const mockTemplates = [
{
id: 'tpl-read-only', name: '只读模板', description: '仅查看权限',
permissions: ['model:read', 'config:read'],
created_at: '2026-02-01T00:00:00Z', updated_at: '2026-02-01T00:00:00Z',
},
{
id: 'tpl-operator', name: '操作员模板', description: '操作权限',
permissions: ['model:read', 'relay:use', 'config:read', 'prompt:read', 'hand:use'],
created_at: '2026-02-15T00:00:00Z', updated_at: '2026-02-15T00: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(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
}
function setupRolesHandlers(overrides: Record<string, unknown> = {}) {
server.use(
http.get('*/api/v1/roles', () => {
return HttpResponse.json(overrides.roles ?? mockRoles)
}),
http.get('*/api/v1/permission-templates', () => {
return HttpResponse.json(overrides.templates ?? mockTemplates)
}),
)
}
describe('Roles', () => {
it('renders page title', () => {
setupRolesHandlers()
renderWithProviders(<Roles />)
expect(screen.getByText('角色与权限')).toBeInTheDocument()
})
it('displays tabs', () => {
setupRolesHandlers()
renderWithProviders(<Roles />)
// Tabs use label spans with icons
expect(screen.getByText('角色')).toBeInTheDocument()
expect(screen.getByText('权限模板')).toBeInTheDocument()
})
it('displays roles in default tab', async () => {
setupRolesHandlers()
renderWithProviders(<Roles />)
await waitFor(() => {
expect(screen.getByText('admin')).toBeInTheDocument()
})
expect(screen.getByText('user')).toBeInTheDocument()
expect(screen.getByText('管理员')).toBeInTheDocument()
expect(screen.getByText('普通用户')).toBeInTheDocument()
})
it('displays permissions count tags', async () => {
setupRolesHandlers()
renderWithProviders(<Roles />)
await waitFor(() => {
// "1 项" for admin (1 permission), "4 项" for user (4 permissions)
expect(screen.getByText('1 项')).toBeInTheDocument()
expect(screen.getByText('4 项')).toBeInTheDocument()
})
})
it('displays account count column', async () => {
setupRolesHandlers()
renderWithProviders(<Roles />)
await waitFor(() => {
expect(screen.getByText('admin')).toBeInTheDocument()
})
// account_count: admin=2, user=15
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.getByText('15')).toBeInTheDocument()
})
it('has 新建角色 button', async () => {
setupRolesHandlers()
renderWithProviders(<Roles />)
await waitFor(() => {
expect(screen.getByText('新建角色')).toBeInTheDocument()
})
})
it('renders 操作 column for role rows', async () => {
setupRolesHandlers()
renderWithProviders(<Roles />)
await waitFor(() => {
expect(screen.getByText('admin')).toBeInTheDocument()
})
// 操作 column header should exist
expect(screen.getByText('操作')).toBeInTheDocument()
})
it('shows empty roles state', async () => {
setupRolesHandlers({ roles: [], templates: mockTemplates })
renderWithProviders(<Roles />)
await waitFor(() => {
const empties = screen.getAllByText('暂无数据')
expect(empties.length).toBeGreaterThanOrEqual(1)
})
})
it('switches to templates tab and displays templates', async () => {
setupRolesHandlers()
renderWithProviders(<Roles />)
await waitFor(() => {
expect(screen.getByText('admin')).toBeInTheDocument()
})
// Click 权限模板 tab
screen.getByText('权限模板').click()
await waitFor(() => {
expect(screen.getByText('只读模板')).toBeInTheDocument()
})
expect(screen.getByText('操作员模板')).toBeInTheDocument()
})
it('displays template permission counts', async () => {
setupRolesHandlers()
renderWithProviders(<Roles />)
screen.getByText('权限模板').click()
await waitFor(() => {
// read-only: 2 permissions, operator: 5 permissions
expect(screen.getByText('2 项')).toBeInTheDocument()
})
})
it('shows empty templates state', async () => {
setupRolesHandlers({ roles: mockRoles, templates: [] })
renderWithProviders(<Roles />)
await waitFor(() => {
expect(screen.getByText('admin')).toBeInTheDocument()
})
screen.getByText('权限模板').click()
await waitFor(() => {
const empties = screen.getAllByText('暂无数据')
expect(empties.length).toBeGreaterThanOrEqual(1)
})
})
it('has 新建模板 button in templates tab', async () => {
setupRolesHandlers()
renderWithProviders(<Roles />)
screen.getByText('权限模板').click()
await waitFor(() => {
expect(screen.getByText('新建模板')).toBeInTheDocument()
})
})
it('shows empty on roles API failure', async () => {
server.use(
http.get('*/api/v1/roles', () => {
return HttpResponse.json(
{ error: 'internal_error', message: '数据库错误' },
{ status: 500 },
)
}),
http.get('*/api/v1/permission-templates', () => {
return HttpResponse.json(mockTemplates)
}),
)
renderWithProviders(<Roles />)
await waitFor(() => {
// ProTable shows 暂无数据 when data fetch fails
const empties = screen.getAllByText('暂无数据')
expect(empties.length).toBeGreaterThanOrEqual(1)
})
})
})

View File

@@ -0,0 +1,268 @@
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 ScheduledTasks from '@/pages/ScheduledTasks'
// ── Mock data ──────────────────────────────────────────────────
const mockTasks = [
{
id: 'task-001',
name: '每日早报',
schedule: '0 8 * * *',
schedule_type: 'cron',
target: { type: 'agent', id: 'daily-news' },
enabled: true,
description: '每天早上8点推送新闻',
last_run: '2026-04-07T08:00:00Z',
next_run: '2026-04-08T08:00:00Z',
run_count: 30,
last_result: null,
last_error: null,
last_duration_ms: 1500,
created_at: '2026-03-01T00:00:00Z',
},
{
id: 'task-002',
name: '定时采集',
schedule: '30m',
schedule_type: 'interval',
target: { type: 'hand', id: 'collector' },
enabled: false,
description: null,
last_run: null,
next_run: null,
run_count: 0,
last_result: null,
last_error: null,
last_duration_ms: null,
created_at: '2026-04-01T00: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(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
}
describe('ScheduledTasks', () => {
it('renders page header', () => {
server.use(
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json([])),
)
renderWithProviders(<ScheduledTasks />)
expect(screen.getByText('定时任务')).toBeInTheDocument()
})
it('shows loading spinner', async () => {
server.use(
http.get('*/api/v1/scheduler/tasks', async () => {
await new Promise(resolve => setTimeout(resolve, 500))
return HttpResponse.json(mockTasks)
}),
)
renderWithProviders(<ScheduledTasks />)
expect(document.querySelector('.ant-spin')).toBeTruthy()
})
it('displays tasks in table', async () => {
server.use(
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)),
)
renderWithProviders(<ScheduledTasks />)
await waitFor(() => {
expect(screen.getByText('每日早报')).toBeInTheDocument()
})
expect(screen.getByText('定时采集')).toBeInTheDocument()
expect(screen.getByText('0 8 * * *')).toBeInTheDocument()
expect(screen.getByText('30m')).toBeInTheDocument()
})
it('displays Chinese schedule type labels', async () => {
server.use(
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)),
)
renderWithProviders(<ScheduledTasks />)
await waitFor(() => {
// schedule_type labels: cron → "Cron", interval → "间隔"
expect(screen.getByText('Cron')).toBeInTheDocument()
})
expect(screen.getByText('间隔')).toBeInTheDocument()
})
it('displays target type with English labels', async () => {
server.use(
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)),
)
renderWithProviders(<ScheduledTasks />)
await waitFor(() => {
// target type labels: agent → "Agent", hand → "Hand"
expect(screen.getByText('Agent')).toBeInTheDocument()
})
expect(screen.getByText('Hand')).toBeInTheDocument()
})
it('displays target IDs', async () => {
server.use(
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)),
)
renderWithProviders(<ScheduledTasks />)
await waitFor(() => {
expect(screen.getByText('daily-news')).toBeInTheDocument()
})
expect(screen.getByText('collector')).toBeInTheDocument()
})
it('displays enabled switches', async () => {
server.use(
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)),
)
renderWithProviders(<ScheduledTasks />)
await waitFor(() => {
const switches = document.querySelectorAll('.ant-switch')
expect(switches.length).toBeGreaterThanOrEqual(2)
})
})
it('displays run count', async () => {
server.use(
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)),
)
renderWithProviders(<ScheduledTasks />)
await waitFor(() => {
expect(screen.getByText('每日早报')).toBeInTheDocument()
})
// run_count: 30 is displayed in tabular-nums span
expect(screen.getByText('30')).toBeInTheDocument()
})
it('shows last error in red when present', async () => {
const tasksWithError = [{ ...mockTasks[0], last_error: 'Connection timeout' }]
server.use(
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(tasksWithError)),
)
renderWithProviders(<ScheduledTasks />)
await waitFor(() => {
const errorEl = screen.getByText('Connection timeout')
expect(errorEl).toHaveClass('text-red-500')
})
})
it('shows dash for null last error', async () => {
server.use(
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)),
)
renderWithProviders(<ScheduledTasks />)
await waitFor(() => {
expect(screen.getByText('每日早报')).toBeInTheDocument()
})
})
it('shows empty state when no tasks', async () => {
server.use(
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json([])),
)
renderWithProviders(<ScheduledTasks />)
await waitFor(() => {
const empties = screen.getAllByText('暂无数据')
expect(empties.length).toBeGreaterThanOrEqual(1)
})
})
it('shows error state on API failure', async () => {
server.use(
http.get('*/api/v1/scheduler/tasks', () => {
return HttpResponse.json(
{ error: 'internal_error', message: '数据库错误' },
{ status: 500 },
)
}),
)
renderWithProviders(<ScheduledTasks />)
await waitFor(() => {
expect(screen.getByText('加载失败')).toBeInTheDocument()
})
})
it('has 新建任务 button', async () => {
server.use(
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json([])),
)
renderWithProviders(<ScheduledTasks />)
await waitFor(() => {
expect(screen.getByText('新建任务')).toBeInTheDocument()
})
})
it('renders action column with buttons', async () => {
server.use(
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)),
)
renderWithProviders(<ScheduledTasks />)
await waitFor(() => {
expect(screen.getByText('每日早报')).toBeInTheDocument()
})
// 操作 column header should be rendered
expect(screen.getByText('操作')).toBeInTheDocument()
})
it('color-codes schedule type tags', async () => {
server.use(
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)),
)
renderWithProviders(<ScheduledTasks />)
await waitFor(() => {
const cronTag = screen.getByText('Cron').closest('.ant-tag')
expect(cronTag?.className).toMatch(/blue/)
})
const intervalTag = screen.getByText('间隔').closest('.ant-tag')
expect(intervalTag?.className).toMatch(/green/)
})
it('color-codes target type tags', async () => {
server.use(
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)),
)
renderWithProviders(<ScheduledTasks />)
await waitFor(() => {
const agentTag = screen.getByText('Agent').closest('.ant-tag')
expect(agentTag?.className).toMatch(/purple/)
})
const handTag = screen.getByText('Hand').closest('.ant-tag')
expect(handTag?.className).toMatch(/cyan/)
})
})

View File

@@ -40,4 +40,11 @@ beforeAll(() => {
return {} as CSSStyleDeclaration
}
}
// Ant Design ProTable / rc-virtual-list require ResizeObserver
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
})