| import { beforeEach, describe, expect, it, vi } from 'vitest' |
| import { defineComponent, h } from 'vue' |
| import { flushPromises, mount } from '@vue/test-utils' |
| import type { DOMWrapper, VueWrapper } from '@vue/test-utils' |
|
|
| import RiskControlView from '../RiskControlView.vue' |
| import type { ContentModerationConfig, UpdateContentModerationConfig } from '@/api/admin/riskControl' |
|
|
| const { |
| getConfig, |
| updateConfig, |
| getStatus, |
| listLogs, |
| getGroups, |
| showError, |
| showSuccess, |
| } = vi.hoisted(() => ({ |
| getConfig: vi.fn(), |
| updateConfig: vi.fn(), |
| getStatus: vi.fn(), |
| listLogs: vi.fn(), |
| getGroups: vi.fn(), |
| showError: vi.fn(), |
| showSuccess: vi.fn(), |
| })) |
|
|
| vi.mock('@/api/admin', () => ({ |
| adminAPI: { |
| riskControl: { |
| getConfig, |
| updateConfig, |
| getStatus, |
| listLogs, |
| testAPIKeys: vi.fn(), |
| deleteFlaggedHash: vi.fn(), |
| clearFlaggedHashes: vi.fn(), |
| unbanUser: vi.fn(), |
| }, |
| groups: { |
| getAll: getGroups, |
| }, |
| }, |
| })) |
|
|
| vi.mock('@/stores/app', () => ({ |
| useAppStore: () => ({ |
| showError, |
| showSuccess, |
| }), |
| })) |
|
|
| vi.mock('@/utils/apiError', () => ({ |
| extractApiErrorMessage: (_err: unknown, fallback: string) => fallback, |
| })) |
|
|
| vi.mock('vue-i18n', async () => { |
| const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n') |
| return { |
| ...actual, |
| useI18n: () => ({ |
| t: (key: string, params?: Record<string, string | number>) => { |
| if (key === 'admin.riskControl.preBlockAPIKeyLoadSummary') { |
| return `同步并发 ${params?.active} / 可用 Key ${params?.available},累计 ${params?.total} 次,worker:${params?.workerActive} / ${params?.workerTotal}` |
| } |
| return key.replace(/\{(\w+)\}/g, (_, token) => String(params?.[token] ?? `{${token}}`)) |
| }, |
| }), |
| } |
| }) |
|
|
| const baseConfig = (): ContentModerationConfig => ({ |
| enabled: true, |
| mode: 'pre_block', |
| base_url: 'https://api.openai.com', |
| model: 'omni-moderation-latest', |
| api_key_configured: false, |
| api_key_masked: '', |
| api_key_count: 0, |
| api_key_masks: [], |
| api_key_statuses: [], |
| timeout_ms: 3000, |
| sample_rate: 100, |
| all_groups: true, |
| group_ids: [], |
| record_non_hits: false, |
| worker_count: 4, |
| queue_size: 32768, |
| block_status: 403, |
| block_message: '内容审计命中风险规则,请调整输入后重试', |
| email_on_hit: true, |
| auto_ban_enabled: true, |
| ban_threshold: 10, |
| violation_window_hours: 720, |
| retry_count: 2, |
| hit_retention_days: 180, |
| non_hit_retention_days: 3, |
| pre_hash_check_enabled: false, |
| blocked_keywords: [], |
| keyword_blocking_mode: 'keyword_and_api', |
| thresholds: { |
| harassment: 0.98, |
| sexual: 0.65, |
| }, |
| model_filter: { |
| type: 'all', |
| models: [], |
| }, |
| }) |
|
|
| const runtimeStatus = () => ({ |
| enabled: true, |
| risk_control_enabled: true, |
| mode: 'pre_block', |
| worker_count: 4, |
| max_workers: 32, |
| active_workers: 0, |
| idle_workers: 4, |
| queue_size: 32768, |
| queue_length: 0, |
| queue_usage_percent: 0, |
| enqueued: 0, |
| dropped: 0, |
| processed: 0, |
| errors: 0, |
| pre_block_active: 0, |
| pre_block_checked: 0, |
| pre_block_allowed: 0, |
| pre_block_blocked: 0, |
| pre_block_errors: 0, |
| pre_block_avg_latency_ms: 0, |
| pre_block_api_key_active: 0, |
| pre_block_api_key_available_count: 0, |
| pre_block_api_key_total_calls: 0, |
| pre_block_api_key_loads: [], |
| api_key_statuses: [], |
| flagged_hash_count: 0, |
| last_cleanup_deleted_hit: 0, |
| last_cleanup_deleted_non_hit: 0, |
| }) |
|
|
| const AppLayoutStub = { template: '<div><slot /></div>' } |
| const BaseDialogStub = defineComponent({ |
| props: { |
| show: { |
| type: Boolean, |
| default: false, |
| }, |
| }, |
| template: '<div v-if="show"><slot /><slot name="footer" /></div>', |
| }) |
| const ModelWhitelistSelectorStub = defineComponent({ |
| props: { |
| modelValue: { |
| type: Array, |
| default: () => [], |
| }, |
| }, |
| emits: ['update:modelValue'], |
| setup(props, { emit }) { |
| const onInput = (event: Event) => { |
| const value = (event.target as HTMLInputElement).value |
| emit( |
| 'update:modelValue', |
| value |
| .split(/[,\n]/) |
| .map((item) => item.trim()) |
| .filter(Boolean) |
| ) |
| } |
| return () => |
| h('input', { |
| 'data-test': 'model-filter-input', |
| value: (props.modelValue as string[]).join('\n'), |
| onInput, |
| }) |
| }, |
| }) |
|
|
| function findButtonByText(wrapper: VueWrapper, text: string): DOMWrapper<HTMLButtonElement> { |
| const button = wrapper.findAll<HTMLButtonElement>('button').find((item) => item.text().includes(text)) |
| if (!button) { |
| throw new Error(`button not found: ${text}`) |
| } |
| return button |
| } |
|
|
| describe('admin RiskControlView', () => { |
| beforeEach(() => { |
| getConfig.mockReset() |
| updateConfig.mockReset() |
| getStatus.mockReset() |
| listLogs.mockReset() |
| getGroups.mockReset() |
| showError.mockReset() |
| showSuccess.mockReset() |
|
|
| getConfig.mockResolvedValue(baseConfig()) |
| getStatus.mockResolvedValue(runtimeStatus()) |
| listLogs.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20, pages: 1 }) |
| getGroups.mockResolvedValue([]) |
| updateConfig.mockImplementation(async (payload: UpdateContentModerationConfig) => ({ |
| ...baseConfig(), |
| ...payload, |
| model_filter: payload.model_filter ?? baseConfig().model_filter, |
| api_key_configured: false, |
| api_key_masked: '', |
| api_key_count: 0, |
| api_key_masks: [], |
| api_key_statuses: [], |
| })) |
| }) |
|
|
| it('saves the selected model filter mode and models', async () => { |
| const wrapper = mount(RiskControlView, { |
| global: { |
| stubs: { |
| AppLayout: AppLayoutStub, |
| BaseDialog: BaseDialogStub, |
| Icon: true, |
| Select: true, |
| Toggle: true, |
| Pagination: true, |
| ModelWhitelistSelector: ModelWhitelistSelectorStub, |
| }, |
| }, |
| }) |
|
|
| await flushPromises() |
|
|
| await findButtonByText(wrapper, 'admin.riskControl.openSettings').trigger('click') |
| await findButtonByText(wrapper, 'admin.riskControl.tabs.scope').trigger('click') |
| await findButtonByText(wrapper, 'admin.riskControl.modelFilterInclude').trigger('click') |
| await wrapper.get('[data-test="model-filter-input"]').setValue('gpt-5.5, gpt-5.4') |
| await findButtonByText(wrapper, 'admin.riskControl.saveConfig').trigger('click') |
| await flushPromises() |
|
|
| expect(updateConfig).toHaveBeenCalledWith(expect.objectContaining({ |
| model_filter: { |
| type: 'include', |
| models: ['gpt-5.5', 'gpt-5.4'], |
| }, |
| })) |
| expect(showError).not.toHaveBeenCalled() |
| }) |
|
|
| it('submits edited risk control thresholds when saving moderation config', async () => { |
| const wrapper = mount(RiskControlView, { |
| global: { |
| stubs: { |
| AppLayout: AppLayoutStub, |
| BaseDialog: BaseDialogStub, |
| Icon: true, |
| Select: true, |
| Toggle: true, |
| Pagination: true, |
| ModelWhitelistSelector: ModelWhitelistSelectorStub, |
| }, |
| }, |
| }) |
|
|
| await flushPromises() |
|
|
| await findButtonByText(wrapper, 'admin.riskControl.openSettings').trigger('click') |
| await findButtonByText(wrapper, 'admin.riskControl.tabs.riskThresholds').trigger('click') |
| await wrapper.get('[data-test="risk-threshold-sexual"]').setValue('72') |
| await wrapper.get('[data-test="risk-threshold-harassment"]').setValue('99') |
| await findButtonByText(wrapper, 'admin.riskControl.saveConfig').trigger('click') |
| await flushPromises() |
|
|
| expect(updateConfig).toHaveBeenCalledWith(expect.objectContaining({ |
| thresholds: expect.objectContaining({ |
| sexual: 0.72, |
| harassment: 0.99, |
| }), |
| })) |
| expect(showError).not.toHaveBeenCalled() |
| }) |
|
|
| it('describes worker runtime as async audit and pre-block record processing', async () => { |
| getStatus.mockResolvedValue({ |
| ...runtimeStatus(), |
| mode: 'observe', |
| processed: 12, |
| queue_length: 2, |
| }) |
|
|
| const wrapper = mount(RiskControlView, { |
| global: { |
| stubs: { |
| AppLayout: AppLayoutStub, |
| BaseDialog: BaseDialogStub, |
| Icon: true, |
| Select: true, |
| Toggle: true, |
| Pagination: true, |
| ModelWhitelistSelector: ModelWhitelistSelectorStub, |
| }, |
| }, |
| }) |
|
|
| await flushPromises() |
|
|
| expect(wrapper.text()).toContain('admin.riskControl.workerStatusHint') |
| expect(wrapper.text()).not.toContain('admin.riskControl.preBlockSyncStatus') |
| expect(wrapper.text()).toContain('admin.riskControl.records') |
| expect(wrapper.text()).toContain('12') |
| expect(wrapper.text()).toContain('2 / 32,768') |
| }) |
|
|
| it('shows pre-block synchronous moderation metrics separately from worker queue', async () => { |
| getStatus.mockResolvedValue({ |
| ...runtimeStatus(), |
| pre_block_active: 2, |
| pre_block_checked: 128, |
| pre_block_allowed: 120, |
| pre_block_blocked: 8, |
| pre_block_errors: 1, |
| pre_block_avg_latency_ms: 86, |
| pre_block_api_key_active: 2, |
| pre_block_api_key_available_count: 2, |
| pre_block_api_key_total_calls: 128, |
| active_workers: 3, |
| worker_count: 7, |
| pre_block_api_key_loads: [ |
| { |
| index: 0, |
| key_hash: 'hash-one', |
| masked: 'sk-...one', |
| status: 'ok', |
| active: 1, |
| total: 72, |
| success: 70, |
| errors: 2, |
| avg_latency_ms: 84, |
| last_latency_ms: 80, |
| last_http_status: 200, |
| }, |
| { |
| index: 1, |
| key_hash: 'hash-two', |
| masked: 'sk-...two', |
| status: 'ok', |
| active: 1, |
| total: 56, |
| success: 56, |
| errors: 0, |
| avg_latency_ms: 90, |
| last_latency_ms: 92, |
| last_http_status: 200, |
| }, |
| ], |
| }) |
|
|
| const wrapper = mount(RiskControlView, { |
| global: { |
| stubs: { |
| AppLayout: AppLayoutStub, |
| BaseDialog: BaseDialogStub, |
| Icon: true, |
| Select: true, |
| Toggle: true, |
| Pagination: true, |
| ModelWhitelistSelector: ModelWhitelistSelectorStub, |
| }, |
| }, |
| }) |
|
|
| await flushPromises() |
|
|
| expect(wrapper.text()).toContain('admin.riskControl.preBlockSyncStatus') |
| expect(wrapper.text()).toContain('admin.riskControl.preBlockSyncHint') |
| expect(wrapper.text()).not.toContain('admin.riskControl.workerStatus') |
| expect(wrapper.text()).toContain('admin.riskControl.records') |
| expect(wrapper.text()).toContain('128') |
| expect(wrapper.text()).toContain('120') |
| expect(wrapper.text()).toContain('8') |
| expect(wrapper.text()).toContain('86 ms') |
| expect(wrapper.text()).toContain('admin.riskControl.preBlockAPIKeyLoad') |
| expect(wrapper.text()).toContain('sk-...one') |
| expect(wrapper.text()).toContain('sk-...two') |
| expect(wrapper.text()).toContain('72') |
| expect(wrapper.text()).toContain('56') |
| expect(wrapper.text()).toContain('同步并发 2 / 可用 Key 2,累计 128 次,worker:3 / 7') |
|
|
| const runtimeCards = wrapper.get('[data-test="pre-block-runtime-cards"]') |
| const syncCard = wrapper.get('[data-test="pre-block-sync-card"]') |
| const apiKeyLoadCard = wrapper.get('[data-test="pre-block-api-key-load-card"]') |
|
|
| expect(runtimeCards.classes()).toEqual(expect.arrayContaining([ |
| 'grid', |
| 'grid-cols-1', |
| 'xl:grid-cols-[minmax(0,520px)_minmax(0,1fr)]', |
| ])) |
| expect(syncCard.element.parentElement).toBe(runtimeCards.element) |
| expect(apiKeyLoadCard.element.parentElement).toBe(runtimeCards.element) |
| expect(syncCard.classes()).toContain('card') |
| expect(apiKeyLoadCard.classes()).toContain('card') |
| expect(syncCard.get('h2').text()).toBe('admin.riskControl.preBlockSyncStatus') |
| expect(syncCard.text()).toContain('admin.riskControl.preBlockSyncHint') |
| expect(apiKeyLoadCard.get('h2').text()).toBe('admin.riskControl.preBlockAPIKeyLoad') |
| expect(apiKeyLoadCard.text()).toContain('admin.riskControl.preBlockAPIKeyLoadHint') |
| expect(wrapper.get('[data-test="pre-block-api-key-load-list"]').classes()).toEqual(expect.arrayContaining([ |
| 'max-h-[280px]', |
| 'overflow-y-auto', |
| ])) |
| }) |
| }) |
|
|