mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
468 lines
16 KiB
TypeScript
468 lines
16 KiB
TypeScript
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,
|
||
getProxies,
|
||
testAPIKeys,
|
||
showError,
|
||
showSuccess,
|
||
} = vi.hoisted(() => ({
|
||
getConfig: vi.fn(),
|
||
updateConfig: vi.fn(),
|
||
getStatus: vi.fn(),
|
||
listLogs: vi.fn(),
|
||
getGroups: vi.fn(),
|
||
getProxies: vi.fn(),
|
||
testAPIKeys: vi.fn(),
|
||
showError: vi.fn(),
|
||
showSuccess: vi.fn(),
|
||
}))
|
||
|
||
vi.mock('@/api/admin', () => ({
|
||
adminAPI: {
|
||
riskControl: {
|
||
getConfig,
|
||
updateConfig,
|
||
getStatus,
|
||
listLogs,
|
||
testAPIKeys,
|
||
deleteFlaggedHash: vi.fn(),
|
||
clearFlaggedHashes: vi.fn(),
|
||
unbanUser: vi.fn(),
|
||
},
|
||
groups: {
|
||
getAll: getGroups,
|
||
},
|
||
proxies: {
|
||
getAll: getProxies,
|
||
},
|
||
},
|
||
}))
|
||
|
||
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',
|
||
proxy_id: null,
|
||
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()
|
||
testAPIKeys.mockReset()
|
||
|
||
getConfig.mockResolvedValue(baseConfig())
|
||
getStatus.mockResolvedValue(runtimeStatus())
|
||
listLogs.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20, pages: 1 })
|
||
getGroups.mockResolvedValue([])
|
||
getProxies.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('preserves both engine drafts and saves thresholds independently', async () => {
|
||
const wrapper = mount(RiskControlView, { global: { stubs: { AppLayout: AppLayoutStub, BaseDialog: BaseDialogStub, Icon: true, Select: true, Toggle: true, Pagination: true, ModelWhitelistSelector: ModelWhitelistSelectorStub, ProxySelector: true } } })
|
||
await flushPromises()
|
||
await findButtonByText(wrapper, 'admin.riskControl.openSettings').trigger('click')
|
||
await wrapper.get('[data-test="audit-base-url"]').setValue('https://openai-edited.example')
|
||
await findButtonByText(wrapper, 'admin.riskControl.tabs.riskThresholds').trigger('click')
|
||
await wrapper.get('[data-test="risk-threshold-sexual"]').setValue('73')
|
||
await findButtonByText(wrapper, 'admin.riskControl.tabs.basic').trigger('click')
|
||
const engine = () => wrapper.findComponent('[data-test="audit-engine-select"]')
|
||
engine().vm.$emit('update:modelValue', 'typesafe')
|
||
await flushPromises()
|
||
expect((wrapper.get('[data-test="audit-model"]').element as HTMLInputElement).value).toBe('jev-latest')
|
||
await wrapper.get('[data-test="audit-base-url"]').setValue('https://typesafe-edited.example')
|
||
await findButtonByText(wrapper, 'admin.riskControl.tabs.riskThresholds').trigger('click')
|
||
expect((wrapper.get('[data-test="risk-threshold-sexual"]').element as HTMLInputElement).value).toBe('80')
|
||
await wrapper.get('[data-test="risk-threshold-sexual"]').setValue('91')
|
||
await findButtonByText(wrapper, 'admin.riskControl.tabs.basic').trigger('click')
|
||
engine().vm.$emit('update:modelValue', 'openai')
|
||
await flushPromises()
|
||
expect((wrapper.get('[data-test="audit-base-url"]').element as HTMLInputElement).value).toBe('https://openai-edited.example')
|
||
await findButtonByText(wrapper, 'admin.riskControl.saveConfig').trigger('click')
|
||
await flushPromises()
|
||
expect(updateConfig).toHaveBeenCalledWith(expect.objectContaining({ engine: 'openai', engine_configs: {
|
||
openai: expect.objectContaining({ base_url: 'https://openai-edited.example', thresholds: expect.objectContaining({ sexual: 0.73 }) }),
|
||
typesafe: expect.objectContaining({ base_url: 'https://typesafe-edited.example', thresholds: expect.objectContaining({ sexual: 0.91 }) }),
|
||
} }))
|
||
expect(showError).not.toHaveBeenCalled()
|
||
wrapper.unmount()
|
||
})
|
||
|
||
it('tests the draft engine without switching the active engine', async () => {
|
||
testAPIKeys.mockResolvedValue({ items: [{ status: 'ok', configured: false }], image_count: 0 })
|
||
const wrapper = mount(RiskControlView, { global: { stubs: { AppLayout: AppLayoutStub, BaseDialog: BaseDialogStub, Icon: true, Select: true, Toggle: true, Pagination: true, ModelWhitelistSelector: ModelWhitelistSelectorStub, ProxySelector: true } } })
|
||
await flushPromises()
|
||
await findButtonByText(wrapper, 'admin.riskControl.openSettings').trigger('click')
|
||
wrapper.findComponent('[data-test="audit-engine-select"]').vm.$emit('update:modelValue', 'typesafe')
|
||
await flushPromises()
|
||
await wrapper.get('textarea[autocomplete="new-password"]').setValue('test-only-key')
|
||
await findButtonByText(wrapper, 'admin.riskControl.testInputApiKeys').trigger('click')
|
||
await flushPromises()
|
||
expect(testAPIKeys).toHaveBeenCalledWith(expect.objectContaining({ engine: 'typesafe', api_keys: ['test-only-key'], model: 'jev-latest', thresholds: expect.objectContaining({ sexual: 0.8 }) }))
|
||
expect(updateConfig).not.toHaveBeenCalled()
|
||
expect(wrapper.get('[data-test="active-audit-engine"]').text()).not.toContain('TypeSafe AI')
|
||
wrapper.unmount()
|
||
})
|
||
|
||
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,
|
||
ProxySelector: true,
|
||
},
|
||
},
|
||
})
|
||
|
||
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,
|
||
ProxySelector: true,
|
||
},
|
||
},
|
||
})
|
||
|
||
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,
|
||
ProxySelector: true,
|
||
},
|
||
},
|
||
})
|
||
|
||
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,
|
||
ProxySelector: true,
|
||
},
|
||
},
|
||
})
|
||
|
||
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',
|
||
]))
|
||
})
|
||
})
|