sub2api/frontend/src/views/admin/__tests__/UsageView.spec.ts
DaydreamCoding fe8952733a fix(usage): 管理端错误请求页过滤与分列完善
- 错误请求标签补传 model/account_id/group_id 过滤(此前 loadAdminErrors 丢弃),admin handler
  读取 model 查询参数走精确匹配
- 错误表格拆成 用户/API Key/账号 三独立列(上游行也显示用户),补 api_key_name/api_key_deleted,
  已删除 key 显示红色「已删除」标记;i18n keyDeletedBadge 补入 errorLog 命名空间

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:00:57 +08:00

355 lines
11 KiB
TypeScript

import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import UsageView from '../UsageView.vue'
const { list, getStats, getSnapshotV2, getById, getModelStats, listErrorLogs } = vi.hoisted(() => {
vi.stubGlobal('localStorage', {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
})
return {
list: vi.fn(),
getStats: vi.fn(),
getSnapshotV2: vi.fn(),
getById: vi.fn(),
getModelStats: vi.fn(),
listErrorLogs: vi.fn(),
}
})
const messages: Record<string, string> = {
'admin.dashboard.timeRange': 'Time Range',
'admin.dashboard.day': 'Day',
'admin.dashboard.hour': 'Hour',
'admin.usage.failedToLoadUser': 'Failed to load user',
}
const formatLocalDate = (date: Date): string => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
vi.mock('@/api/admin', () => ({
adminAPI: {
usage: {
list,
getStats,
},
dashboard: {
getSnapshotV2,
getModelStats,
},
users: {
getById,
},
},
}))
vi.mock('@/api/admin/usage', () => ({
adminUsageAPI: {
list: vi.fn(),
},
}))
vi.mock('@/api/admin/ops', () => ({
listErrorLogs,
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError: vi.fn(),
showWarning: vi.fn(),
showSuccess: vi.fn(),
showInfo: vi.fn(),
}),
}))
vi.mock('@/utils/format', () => ({
formatReasoningEffort: (value: string | null | undefined) => value ?? '-',
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => messages[key] ?? key,
}),
}
})
vi.mock('vue-router', () => ({
useRoute: () => ({
query: {}
})
}))
const AppLayoutStub = { template: '<div><slot /></div>' }
const UsageFiltersStub = { template: '<div><slot name="after-reset" /></div>' }
const UsageTableStub = {
emits: ['userClick'],
template: '<div data-test="usage-table"><button class="user-click" @click="$emit(\'userClick\', 2)">user</button></div>',
}
const ModelDistributionChartStub = {
props: ['metric'],
emits: ['update:metric'],
template: `
<div data-test="model-chart">
<span class="metric">{{ metric }}</span>
<button class="switch-metric" @click="$emit('update:metric', 'actual_cost')">switch</button>
</div>
`,
}
const GroupDistributionChartStub = {
props: ['metric'],
emits: ['update:metric'],
template: `
<div data-test="group-chart">
<span class="metric">{{ metric }}</span>
<button class="switch-metric" @click="$emit('update:metric', 'actual_cost')">switch</button>
</div>
`,
}
describe('admin UsageView distribution metric toggles', () => {
beforeEach(() => {
vi.useFakeTimers()
list.mockReset()
getStats.mockReset()
getSnapshotV2.mockReset()
getById.mockReset()
getModelStats.mockReset()
list.mockResolvedValue({
items: [],
total: 0,
pages: 0,
})
getStats.mockResolvedValue({
total_requests: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_tokens: 0,
total_tokens: 0,
total_cost: 0,
total_actual_cost: 0,
average_duration_ms: 0,
})
getSnapshotV2.mockResolvedValue({
trend: [],
models: [],
groups: [],
})
getModelStats.mockResolvedValue({ models: [] })
})
afterEach(() => {
vi.useRealTimers()
})
it('keeps previous model stats visible during refresh until new data arrives', async () => {
// 首次加载返回 A
getModelStats.mockResolvedValueOnce({ models: [{ model: 'A', total_tokens: 10 }] })
const wrapper = mount(UsageView, {
global: { stubs: {
AppLayout: AppLayoutStub, UsageStatsCards: true, UsageFilters: UsageFiltersStub,
UsageTable: true, UsageExportProgress: true, UsageCleanupDialog: true,
UserBalanceHistoryModal: true, AuditLogModal: true, Pagination: true, Select: true,
DateRangePicker: true, Icon: true, TokenUsageTrend: true,
ModelDistributionChart: ModelDistributionChartStub, GroupDistributionChart: GroupDistributionChartStub,
EndpointDistributionChart: true,
} },
})
vi.advanceTimersByTime(120)
await flushPromises()
expect((wrapper.vm as any).requestedModelStats).toEqual([{ model: 'A', total_tokens: 10 }])
// 刷新:让第二次 getModelStats 处于 pending,断言旧数据 A 仍在(不被清空成 [])
let resolveSecond: (v: any) => void = () => {}
getModelStats.mockReturnValueOnce(new Promise((res) => { resolveSecond = res }))
;(wrapper.vm as any).refreshData()
await flushPromises()
expect((wrapper.vm as any).requestedModelStats).toEqual([{ model: 'A', total_tokens: 10 }])
// 新数据到达后替换为 B
resolveSecond({ models: [{ model: 'B', total_tokens: 20 }] })
await flushPromises()
expect((wrapper.vm as any).requestedModelStats).toEqual([{ model: 'B', total_tokens: 20 }])
})
it('keeps model and group metric toggles independent without refetching chart data', async () => {
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
UsageStatsCards: true,
UsageFilters: UsageFiltersStub,
UsageTable: true,
UsageExportProgress: true,
UsageCleanupDialog: true,
UserBalanceHistoryModal: true,
Pagination: true,
Select: true,
DateRangePicker: true,
Icon: true,
TokenUsageTrend: true,
ModelDistributionChart: ModelDistributionChartStub,
GroupDistributionChart: GroupDistributionChartStub,
},
},
})
vi.advanceTimersByTime(120)
await flushPromises()
expect(getSnapshotV2).toHaveBeenCalledTimes(1)
const now = new Date()
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)
expect(getSnapshotV2).toHaveBeenCalledWith(expect.objectContaining({
start_date: formatLocalDate(yesterday),
end_date: formatLocalDate(now),
granularity: 'hour'
}))
const modelChart = wrapper.find('[data-test="model-chart"]')
const groupChart = wrapper.find('[data-test="group-chart"]')
expect(modelChart.find('.metric').text()).toBe('tokens')
expect(groupChart.find('.metric').text()).toBe('tokens')
await modelChart.find('.switch-metric').trigger('click')
await flushPromises()
expect(modelChart.find('.metric').text()).toBe('actual_cost')
expect(groupChart.find('.metric').text()).toBe('tokens')
expect(getSnapshotV2).toHaveBeenCalledTimes(1)
await groupChart.find('.switch-metric').trigger('click')
await flushPromises()
expect(modelChart.find('.metric').text()).toBe('actual_cost')
expect(groupChart.find('.metric').text()).toBe('actual_cost')
expect(getSnapshotV2).toHaveBeenCalledTimes(1)
})
})
describe('admin UsageView handleUserClick', () => {
beforeEach(() => {
vi.useFakeTimers()
list.mockReset()
getStats.mockReset()
getSnapshotV2.mockReset()
getById.mockReset()
list.mockResolvedValue({ items: [], total: 0, pages: 0 })
getStats.mockResolvedValue({
total_requests: 0, total_input_tokens: 0, total_output_tokens: 0,
total_cache_tokens: 0, total_tokens: 0, total_cost: 0, total_actual_cost: 0, average_duration_ms: 0,
})
getSnapshotV2.mockResolvedValue({ trend: [], models: [], groups: [] })
})
afterEach(() => {
vi.useRealTimers()
})
it('opens user via include_deleted when clicking a usage row user', async () => {
getById.mockResolvedValue({ id: 2, email: 'd@test.com', deleted_at: '2026-05-28T00:00:00Z' })
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
UsageStatsCards: true,
UsageFilters: UsageFiltersStub,
UsageTable: UsageTableStub,
UsageExportProgress: true,
UsageCleanupDialog: true,
UserBalanceHistoryModal: true,
AuditLogModal: true,
Pagination: true,
Select: true,
DateRangePicker: true,
Icon: true,
TokenUsageTrend: true,
ModelDistributionChart: true,
GroupDistributionChart: true,
EndpointDistributionChart: true,
},
},
})
vi.advanceTimersByTime(120)
await flushPromises()
await wrapper.find('[data-test="usage-table"] .user-click').trigger('click')
await flushPromises()
expect(getById).toHaveBeenCalledWith(2, true)
})
})
describe('admin UsageView errors tab filter forwarding', () => {
beforeEach(() => {
vi.useFakeTimers()
list.mockReset()
getStats.mockReset()
getSnapshotV2.mockReset()
getModelStats.mockReset()
listErrorLogs.mockReset()
list.mockResolvedValue({ items: [], total: 0, pages: 0 })
getStats.mockResolvedValue({
total_requests: 0, total_input_tokens: 0, total_output_tokens: 0,
total_cache_tokens: 0, total_tokens: 0, total_cost: 0, total_actual_cost: 0, average_duration_ms: 0,
})
getSnapshotV2.mockResolvedValue({ trend: [], models: [], groups: [] })
getModelStats.mockResolvedValue({ models: [] })
listErrorLogs.mockResolvedValue({ items: [], total: 0, pages: 0 })
})
afterEach(() => {
vi.useRealTimers()
})
it('forwards model/account_id/group_id to listErrorLogs on the errors tab', async () => {
const wrapper = mount(UsageView, {
global: { stubs: {
AppLayout: AppLayoutStub, UsageStatsCards: true, UsageFilters: UsageFiltersStub,
UsageTable: true, UsageExportProgress: true, UsageCleanupDialog: true,
UserBalanceHistoryModal: true, AuditLogModal: true, Pagination: true, Select: true,
DateRangePicker: true, Icon: true, TokenUsageTrend: true,
ModelDistributionChart: true, GroupDistributionChart: true, EndpointDistributionChart: true,
OpsErrorLogTable: true, OpsErrorDetailModal: true,
} },
})
vi.advanceTimersByTime(120)
await flushPromises()
// 模拟用户在过滤器里选择了模型/账户/分组
const vm = wrapper.vm as any
vm.filters.model = 'gpt-5.3-codex'
vm.filters.account_id = 7
vm.filters.group_id = 3
await flushPromises()
// 切换到「错误请求」标签(第二个 .tab 按钮)触发 loadAdminErrors
const tabs = wrapper.findAll('button.tab')
await tabs[1].trigger('click')
await flushPromises()
expect(listErrorLogs).toHaveBeenCalledWith(expect.objectContaining({
view: 'all',
model: 'gpt-5.3-codex',
account_id: 7,
group_id: 3,
}))
})
})