diff --git a/admin-v2/src/pages/Knowledge.tsx b/admin-v2/src/pages/Knowledge.tsx
index 6a95a06..07a8496 100644
--- a/admin-v2/src/pages/Knowledge.tsx
+++ b/admin-v2/src/pages/Knowledge.tsx
@@ -183,6 +183,7 @@ function CategoriesPanel() {
.map((c) => (
{c.name}
))}
+
@@ -454,6 +455,7 @@ function ItemsPanel() {
},
]}
/>
+
)
}
diff --git a/admin-v2/tests/pages/Billing.test.tsx b/admin-v2/tests/pages/Billing.test.tsx
new file mode 100644
index 0000000..e86268c
--- /dev/null
+++ b/admin-v2/tests/pages/Billing.test.tsx
@@ -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(
+
+ {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()
+ })
+ })
+})
diff --git a/admin-v2/tests/pages/ConfigSync.test.tsx b/admin-v2/tests/pages/ConfigSync.test.tsx
new file mode 100644
index 0000000..5617791
--- /dev/null
+++ b/admin-v2/tests/pages/ConfigSync.test.tsx
@@ -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(
+
+ {ui}
+ ,
+ )
+}
+
+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()
+ 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()
+ 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()
+
+ // 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ await waitFor(() => {
+ expect(screen.getByText('ๆไฝ')).toBeInTheDocument()
+ })
+ expect(screen.getByText('ๅฎขๆท็ซฏๆ็บน')).toBeInTheDocument()
+ expect(screen.getByText('้
็ฝฎ้ฎ')).toBeInTheDocument()
+ })
+})
diff --git a/admin-v2/tests/pages/Knowledge.test.tsx b/admin-v2/tests/pages/Knowledge.test.tsx
new file mode 100644
index 0000000..7b5348e
--- /dev/null
+++ b/admin-v2/tests/pages/Knowledge.test.tsx
@@ -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(
+
+ {ui}
+ ,
+ )
+}
+
+function setupKnowledgeHandlers(overrides: Record = {}) {
+ 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()
+
+ 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()
+
+ await waitFor(() => {
+ expect(screen.getByText('API ่ฎค่ฏๆๅ')).toBeInTheDocument()
+ })
+ expect(screen.getByText('็ฉๅ
ทๅธๅบ่ถๅฟ 2026')).toBeInTheDocument()
+ })
+
+ it('displays item status with Chinese labels', async () => {
+ setupKnowledgeHandlers()
+ renderWithProviders()
+
+ 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()
+
+ 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()
+
+ await waitFor(() => {
+ const empties = screen.getAllByText('ๆๆ ๆฐๆฎ')
+ expect(empties.length).toBeGreaterThanOrEqual(1)
+ })
+ })
+
+ // โโ Categories Tab โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ it('switches to categories tab and displays categories', async () => {
+ setupKnowledgeHandlers()
+ renderWithProviders()
+
+ 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()
+
+ // 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()
+
+ 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()
+
+ 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()
+
+ 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()
+ expect(document.querySelector('.ant-spin')).toBeTruthy()
+ })
+
+ // โโ Item Tags โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ it('displays item tags in table', async () => {
+ setupKnowledgeHandlers()
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByText('api')).toBeInTheDocument()
+ })
+ expect(screen.getByText('auth')).toBeInTheDocument()
+ expect(screen.getByText('market')).toBeInTheDocument()
+ })
+})
diff --git a/admin-v2/tests/pages/Roles.test.tsx b/admin-v2/tests/pages/Roles.test.tsx
new file mode 100644
index 0000000..8d9ac47
--- /dev/null
+++ b/admin-v2/tests/pages/Roles.test.tsx
@@ -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(
+
+ {ui}
+ ,
+ )
+}
+
+function setupRolesHandlers(overrides: Record = {}) {
+ 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()
+ expect(screen.getByText('่ง่ฒไธๆ้')).toBeInTheDocument()
+ })
+
+ it('displays tabs', () => {
+ setupRolesHandlers()
+ renderWithProviders()
+ // Tabs use label spans with icons
+ expect(screen.getByText('่ง่ฒ')).toBeInTheDocument()
+ expect(screen.getByText('ๆ้ๆจกๆฟ')).toBeInTheDocument()
+ })
+
+ it('displays roles in default tab', async () => {
+ setupRolesHandlers()
+ renderWithProviders()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ await waitFor(() => {
+ expect(screen.getByText('ๆฐๅปบ่ง่ฒ')).toBeInTheDocument()
+ })
+ })
+
+ it('renders ๆไฝ column for role rows', async () => {
+ setupRolesHandlers()
+ renderWithProviders()
+
+ 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()
+
+ await waitFor(() => {
+ const empties = screen.getAllByText('ๆๆ ๆฐๆฎ')
+ expect(empties.length).toBeGreaterThanOrEqual(1)
+ })
+ })
+
+ it('switches to templates tab and displays templates', async () => {
+ setupRolesHandlers()
+ renderWithProviders()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ await waitFor(() => {
+ // ProTable shows ๆๆ ๆฐๆฎ when data fetch fails
+ const empties = screen.getAllByText('ๆๆ ๆฐๆฎ')
+ expect(empties.length).toBeGreaterThanOrEqual(1)
+ })
+ })
+})
diff --git a/admin-v2/tests/pages/ScheduledTasks.test.tsx b/admin-v2/tests/pages/ScheduledTasks.test.tsx
new file mode 100644
index 0000000..d096809
--- /dev/null
+++ b/admin-v2/tests/pages/ScheduledTasks.test.tsx
@@ -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(
+
+ {ui}
+ ,
+ )
+}
+
+describe('ScheduledTasks', () => {
+ it('renders page header', () => {
+ server.use(
+ http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json([])),
+ )
+ renderWithProviders()
+ 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()
+ expect(document.querySelector('.ant-spin')).toBeTruthy()
+ })
+
+ it('displays tasks in table', async () => {
+ server.use(
+ http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)),
+ )
+ renderWithProviders()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ await waitFor(() => {
+ expect(screen.getByText('ๅ ่ฝฝๅคฑ่ดฅ')).toBeInTheDocument()
+ })
+ })
+
+ it('has ๆฐๅปบไปปๅก button', async () => {
+ server.use(
+ http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json([])),
+ )
+ renderWithProviders()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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/)
+ })
+})
diff --git a/admin-v2/tests/setup.ts b/admin-v2/tests/setup.ts
index a569cfc..d25c738 100644
--- a/admin-v2/tests/setup.ts
+++ b/admin-v2/tests/setup.ts
@@ -40,4 +40,11 @@ beforeAll(() => {
return {} as CSSStyleDeclaration
}
}
+
+ // Ant Design ProTable / rc-virtual-list require ResizeObserver
+ global.ResizeObserver = class ResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
})