Files
sub2api/frontend/src/views/user/__tests__/UsageView.spec.ts
T

598 lines
21 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import UsageView from '../UsageView.vue'
import Select, { type SelectOption } from '@/components/common/Select.vue'
import DateRangePicker from '@/components/common/DateRangePicker.vue'
import UsageTable from '@/components/admin/usage/UsageTable.vue'
const {
query,
getStats,
getDashboardModels,
getDashboardSnapshotV2,
listMyErrorRequests,
list,
getAvailable,
showError,
showWarning,
showSuccess,
showInfo,
} = vi.hoisted(() => ({
query: vi.fn(),
getStats: vi.fn(),
getDashboardModels: vi.fn(),
getDashboardSnapshotV2: vi.fn(),
listMyErrorRequests: vi.fn(),
list: vi.fn(),
getAvailable: vi.fn(),
showError: vi.fn(),
showWarning: vi.fn(),
showSuccess: vi.fn(),
showInfo: vi.fn(),
}))
const messages: Record<string, string> = {
'admin.dashboard.timeRange': 'Time range',
'admin.dashboard.granularity': 'Granularity',
'admin.dashboard.day': 'Day',
'admin.dashboard.hour': 'Hour',
'admin.users.columnSettings': 'Columns',
'admin.usage.group': 'Group',
'admin.usage.billingType': 'Billing type',
'admin.usage.billingMode': 'Billing mode',
'admin.usage.allTypes': 'All types',
'admin.usage.allBillingTypes': 'All billing types',
'admin.usage.billingTypeBalance': 'Balance',
'admin.usage.billingTypeSubscription': 'Subscription',
'admin.usage.allBillingModes': 'All billing modes',
'admin.usage.billingModeToken': 'Token',
'admin.usage.billingModePerRequest': 'Per request',
'admin.usage.billingModeImage': 'Image',
'admin.usage.allGroups': 'All groups',
'admin.usage.allModels': 'All models',
'usage.allApiKeys': 'All API Keys',
'usage.errors.allKeys': 'All API Keys',
'usage.tabs.usage': 'Usage records',
'usage.tabs.errors': 'Error records',
'usage.apiKeyFilter': 'API Key',
'usage.model': 'Model',
'usage.type': 'Type',
'usage.ws': 'WS',
'usage.stream': 'Stream',
'usage.sync': 'Sync',
'usage.compactionFilter': 'Request Kind',
'usage.allCompactionTypes': 'All Requests',
'usage.compactionOnly': 'Compaction Only',
'usage.exporting': 'Exporting',
'usage.exportCsv': 'Export CSV',
'usage.failedToLoad': 'Failed to load',
'usage.noDataToExport': 'No data',
'usage.preparingExport': 'Preparing export',
'usage.exportSuccess': 'Export success',
'usage.exportFailed': 'Export failed',
'common.refresh': 'Refresh',
'common.reset': 'Reset',
}
vi.mock('@/api', () => ({
usageAPI: {
query,
getStats,
getDashboardModels,
getDashboardSnapshotV2,
listMyErrorRequests,
},
keysAPI: {
list,
},
userGroupsAPI: {
getAvailable,
},
}))
const appStoreState = vi.hoisted(() => ({
cachedPublicSettings: { allow_user_view_error_requests: true } as Record<string, unknown>,
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError, showWarning, showSuccess, showInfo,
get cachedPublicSettings() {
return appStoreState.cachedPublicSettings
},
}),
}))
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,
}),
}
})
const simpleStub = { template: '<div><slot /></div>' }
const chartStub = { template: '<div />' }
const usageLog = {
id: 1,
request_id: 'req-user-export',
actual_cost: 0.092883,
total_cost: 0.092883,
rate_multiplier: 1,
service_tier: 'priority',
input_cost: 0.020285,
output_cost: 0.00303,
cache_creation_cost: 0.000001,
cache_read_cost: 0.069568,
input_tokens: 4057,
output_tokens: 101,
cache_creation_tokens: 4,
cache_read_tokens: 278272,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
image_count: 0,
image_size: null,
first_token_ms: 12,
duration_ms: 345,
created_at: '2026-03-08T00:00:00Z',
model: 'gpt-5.4',
reasoning_effort: null,
ip_address: '203.0.113.10',
api_key: { name: 'demo-key' },
billing_mode: 'token',
request_type: 'sync',
stream: false,
native_compaction_v2: false,
}
function mountUsageView() {
return mount(UsageView, {
global: {
stubs: {
AppLayout: simpleStub,
Pagination: true,
Select: true,
DateRangePicker: true,
Icon: true,
UsageStatsCards: chartStub,
UsageTable: chartStub,
UserErrorRequestsTable: chartStub,
ModelDistributionChart: chartStub,
GroupDistributionChart: chartStub,
EndpointDistributionChart: chartStub,
TokenUsageTrend: chartStub,
},
},
})
}
describe('user UsageView', () => {
beforeEach(() => {
query.mockReset()
getStats.mockReset()
getDashboardModels.mockReset()
getDashboardSnapshotV2.mockReset()
listMyErrorRequests.mockReset()
list.mockReset()
getAvailable.mockReset()
showError.mockReset()
showWarning.mockReset()
showSuccess.mockReset()
showInfo.mockReset()
query.mockResolvedValue({ items: [usageLog], total: 1, pages: 1 })
getStats.mockResolvedValue({
total_requests: 1,
total_input_tokens: 10,
total_output_tokens: 20,
total_cache_tokens: 0,
total_tokens: 30,
total_cost: 0.1,
total_actual_cost: 0.08,
average_duration_ms: 12,
endpoints: [],
upstream_endpoints: [],
endpoint_paths: [],
})
getDashboardModels.mockResolvedValue({
models: [{ model: 'gpt-5.4', requests: 1, input_tokens: 10, output_tokens: 20, cache_creation_tokens: 0, cache_read_tokens: 0, total_tokens: 30, cost: 0.1, actual_cost: 0.08 }],
start_date: '2026-03-08',
end_date: '2026-03-08',
})
getDashboardSnapshotV2.mockResolvedValue({
generated_at: '2026-03-08T00:00:00Z',
start_date: '2026-03-08',
end_date: '2026-03-08',
granularity: 'hour',
trend: [],
groups: [],
})
listMyErrorRequests.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20, pages: 0 })
list.mockResolvedValue({ items: [{ id: 1, name: 'demo-key' }], total: 1, page: 1, page_size: 100, pages: 1 })
getAvailable.mockResolvedValue([{ id: 1, name: 'default' }])
})
it('loads logs, stats, model stats, and snapshot on first render', async () => {
mountUsageView()
await flushPromises()
expect(query).toHaveBeenCalled()
expect(getStats).toHaveBeenCalled()
expect(getDashboardModels).toHaveBeenCalled()
expect(getDashboardSnapshotV2).toHaveBeenCalledWith(expect.objectContaining({
include_trend: true,
include_model_stats: false,
include_group_stats: true,
}))
expect(list).toHaveBeenCalledTimes(1)
expect(list).toHaveBeenCalledWith(1, 100)
expect(getAvailable).toHaveBeenCalled()
})
it('includes API keys after the first page in both record filters and queries by the selected key', async () => {
const firstPageKeys = Array.from({ length: 100 }, (_, index) => ({
id: index + 1,
name: `key-${index + 1}`,
}))
const laterKey = { id: 101, name: 'key-from-second-page' }
list
.mockResolvedValueOnce({ items: firstPageKeys, total: 101, page: 1, page_size: 100, pages: 2 })
.mockResolvedValueOnce({ items: [laterKey], total: 101, page: 2, page_size: 100, pages: 2 })
const wrapper = mountUsageView()
await flushPromises()
expect(list.mock.calls).toEqual([[1, 100], [2, 100]])
const usageKeySelect = wrapper.findAllComponents(Select).find((select) =>
select.props('options').some((option: SelectOption) => option.label === 'All API Keys')
)!
expect(usageKeySelect.props('options')).toHaveLength(102)
expect(usageKeySelect.props('options')).toContainEqual({ value: laterKey.id, label: laterKey.name })
query.mockClear()
usageKeySelect.vm.$emit('update:modelValue', laterKey.id)
usageKeySelect.vm.$emit('change', laterKey.id)
await flushPromises()
expect(query).toHaveBeenCalledWith(
expect.objectContaining({ api_key_id: laterKey.id, page: 1 }),
expect.anything()
)
await wrapper.findAll('button').find((button) => button.text() === 'Error records')!.trigger('click')
await flushPromises()
const errorKeySelect = wrapper.findAllComponents(Select).find((select) =>
select.props('options').some((option: SelectOption) => option.label === 'All API Keys')
)!
expect(errorKeySelect.props('options')).toHaveLength(102)
expect(errorKeySelect.props('options')).toContainEqual({ value: laterKey.id, label: laterKey.name })
listMyErrorRequests.mockClear()
errorKeySelect.vm.$emit('update:modelValue', laterKey.id)
errorKeySelect.vm.$emit('change', laterKey.id)
await flushPromises()
expect(listMyErrorRequests).toHaveBeenCalledWith(
expect.objectContaining({ api_key_id: laterKey.id, page: 1 })
)
expect(list).toHaveBeenCalledTimes(2)
wrapper.unmount()
})
it('does not request another API key page when the user has no keys', async () => {
list.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 100, pages: 0 })
const wrapper = mountUsageView()
await flushPromises()
expect(list.mock.calls).toEqual([[1, 100]])
const keySelect = wrapper.findAllComponents(Select).find((select) =>
select.props('options').some((option: SelectOption) => option.label === 'All API Keys')
)!
expect(keySelect.props('options')).toEqual([{ value: null, label: 'All API Keys' }])
expect(getAvailable).toHaveBeenCalled()
wrapper.unmount()
})
it('stops loading API keys when a later page is empty despite an outdated page count', async () => {
const firstPageKeys = Array.from({ length: 100 }, (_, index) => ({
id: index + 1,
name: `key-${index + 1}`,
}))
list
.mockResolvedValueOnce({ items: firstPageKeys, total: 201, page: 1, page_size: 100, pages: 3 })
.mockResolvedValueOnce({ items: [], total: 201, page: 2, page_size: 100, pages: 3 })
const wrapper = mountUsageView()
await flushPromises()
expect(list.mock.calls).toEqual([[1, 100], [2, 100]])
const keySelect = wrapper.findAllComponents(Select).find((select) =>
select.props('options').some((option: SelectOption) => option.label === 'All API Keys')
)!
expect(keySelect.props('options')).toHaveLength(101)
expect(keySelect.props('options')).toContainEqual({ value: 100, label: 'key-100' })
wrapper.unmount()
})
it('propagates and resets the native compaction filter across page requests', async () => {
const wrapper = mountUsageView()
await flushPromises()
expect((wrapper.vm as any).compactionOptions).toEqual([
{ value: null, label: 'All Requests' },
{ value: true, label: 'Compaction Only' },
])
query.mockClear()
getStats.mockClear()
getDashboardModels.mockClear()
getDashboardSnapshotV2.mockClear()
;(wrapper.vm as any).filters.native_compaction_v2 = true
;(wrapper.vm as any).applyFilters()
await flushPromises()
expect(query).toHaveBeenCalledWith(
expect.objectContaining({ native_compaction_v2: true }),
expect.anything()
)
expect(getStats).toHaveBeenCalledWith(expect.objectContaining({ native_compaction_v2: true }))
expect(getDashboardModels).toHaveBeenCalledWith(expect.objectContaining({ native_compaction_v2: true }))
expect(getDashboardSnapshotV2).toHaveBeenCalledWith(expect.objectContaining({ native_compaction_v2: true }))
query.mockClear()
getStats.mockClear()
getDashboardModels.mockClear()
getDashboardSnapshotV2.mockClear()
;(wrapper.vm as any).resetFilters()
await flushPromises()
expect((wrapper.vm as any).filters.native_compaction_v2).toBeNull()
expect(query).toHaveBeenCalledWith(
expect.objectContaining({ native_compaction_v2: null }),
expect.anything()
)
expect(getStats).toHaveBeenCalledWith(expect.objectContaining({ native_compaction_v2: null }))
expect(getDashboardModels).toHaveBeenCalledWith(expect.objectContaining({ native_compaction_v2: null }))
expect(getDashboardSnapshotV2).toHaveBeenCalledWith(expect.objectContaining({ native_compaction_v2: null }))
})
it('exports csv with current filters and without admin-only fields', async () => {
const wrapper = mountUsageView()
await flushPromises()
;(wrapper.vm as any).filters.native_compaction_v2 = true
let exportedBlob: Blob | null = null
let csvContent = ''
const OriginalBlob = globalThis.Blob
vi.stubGlobal('Blob', vi.fn((parts: BlobPart[], options?: BlobPropertyBag) => {
csvContent = parts.map((part) => String(part)).join('')
return new OriginalBlob(parts, options)
}))
const originalCreateObjectURL = window.URL.createObjectURL
const originalRevokeObjectURL = window.URL.revokeObjectURL
window.URL.createObjectURL = vi.fn((blob: Blob | MediaSource) => {
exportedBlob = blob as Blob
return 'blob:usage-export'
}) as typeof window.URL.createObjectURL
window.URL.revokeObjectURL = vi.fn(() => {}) as typeof window.URL.revokeObjectURL
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
await (wrapper.vm as any).exportToCSV()
expect(exportedBlob).not.toBeNull()
expect(query).toHaveBeenCalledWith(expect.objectContaining({
page_size: 100,
sort_by: 'created_at',
sort_order: 'desc',
native_compaction_v2: true,
}))
expect(clickSpy).toHaveBeenCalled()
expect(showSuccess).toHaveBeenCalled()
expect(csvContent.startsWith('\uFEFF')).toBe(true)
expect(csvContent.slice(1)).toBe([
'Time,API Key Name,Model,Reasoning Effort,Inbound Endpoint,IP Address,Type,Billing Mode,Input Tokens,Output Tokens,Cache Read Tokens,Cache Creation Tokens,Rate Multiplier,Billed Cost,Original Cost,First Token (ms),Duration (ms)',
'2026-03-08T00:00:00Z,demo-key,gpt-5.4,-,,203.0.113.10,Sync,Token,4057,101,278272,4,1,0.09288300,0.09288300,12,345',
].join('\n'))
expect(csvContent).toContain('IP Address')
expect(csvContent).toContain('203.0.113.10')
expect(csvContent).toContain('Billed Cost')
expect(csvContent).toContain('Original Cost')
expect(csvContent).not.toContain('Upstream Endpoint')
expect(csvContent).not.toContain('account_cost')
expect(csvContent).not.toContain('account_rate_multiplier')
window.URL.createObjectURL = originalCreateObjectURL
window.URL.revokeObjectURL = originalRevokeObjectURL
vi.unstubAllGlobals()
clickSpy.mockRestore()
})
it('keeps formula-injection protection for dangerous exported values', async () => {
query.mockResolvedValue({
items: [{ ...usageLog, api_key: { name: '-1+1' } }],
total: 1,
pages: 1,
})
const wrapper = mountUsageView()
await flushPromises()
let csvContent = ''
const OriginalBlob = globalThis.Blob
vi.stubGlobal('Blob', vi.fn((parts: BlobPart[], options?: BlobPropertyBag) => {
csvContent = parts.map((part) => String(part)).join('')
return new OriginalBlob(parts, options)
}))
const originalCreateObjectURL = window.URL.createObjectURL
const originalRevokeObjectURL = window.URL.revokeObjectURL
window.URL.createObjectURL = vi.fn(() => 'blob:usage-export') as typeof window.URL.createObjectURL
window.URL.revokeObjectURL = vi.fn(() => {}) as typeof window.URL.revokeObjectURL
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
await (wrapper.vm as any).exportToCSV()
expect(csvContent).toContain(',"\'-1+1",gpt-5.4,-,')
expect(showSuccess).toHaveBeenCalled()
window.URL.createObjectURL = originalCreateObjectURL
window.URL.revokeObjectURL = originalRevokeObjectURL
vi.unstubAllGlobals()
clickSpy.mockRestore()
wrapper.unmount()
})
it('keeps the initial filters, sort, and filename while exporting multiple pages', async () => {
const pageResponse = { items: [usageLog], total: 101, pages: 2 }
query.mockResolvedValue(pageResponse)
const wrapper = mountUsageView()
await flushPromises()
const datePicker = wrapper.findComponent(DateRangePicker)
datePicker.vm.$emit('change', { startDate: '2026-03-01', endDate: '2026-03-08', preset: null })
await flushPromises()
let resolveFirstPage!: (value: typeof pageResponse) => void
const firstPage = new Promise<typeof pageResponse>((resolve) => { resolveFirstPage = resolve })
query.mockClear()
query.mockImplementation((params, options) =>
!options && params.page === 1 ? firstPage : Promise.resolve(pageResponse)
)
const originalCreateObjectURL = window.URL.createObjectURL
const originalRevokeObjectURL = window.URL.revokeObjectURL
window.URL.createObjectURL = vi.fn(() => 'blob:usage-export')
window.URL.revokeObjectURL = vi.fn()
let filename = ''
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function () {
filename = this.download
})
try {
await wrapper.findAll('button').find((button) => button.text() === 'Export CSV')!.trigger('click')
const initialParams = { ...query.mock.calls[0][0] }
expect(initialParams).toMatchObject({
page: 1, page_size: 100, start_date: '2026-03-01', end_date: '2026-03-08',
sort_by: 'created_at', sort_order: 'desc',
})
const keySelect = wrapper.findAllComponents(Select).find((select) =>
select.props('options').some((option: SelectOption) => option.label === 'All API Keys')
)!
keySelect.vm.$emit('update:modelValue', 1)
keySelect.vm.$emit('change', 1)
datePicker.vm.$emit('change', { startDate: '2026-04-01', endDate: '2026-04-08', preset: null })
wrapper.findComponent(UsageTable).vm.$emit('sort', 'actual_cost', 'asc')
await flushPromises()
expect(query).toHaveBeenCalledWith(expect.objectContaining({
api_key_id: 1, start_date: '2026-04-01', end_date: '2026-04-08',
sort_by: 'actual_cost', sort_order: 'asc',
}), expect.anything())
resolveFirstPage(pageResponse)
await flushPromises()
const exportCalls = query.mock.calls.filter((call) => call.length === 1)
expect.soft(exportCalls).toEqual([[initialParams], [{ ...initialParams, page: 2 }]])
expect.soft(filename).toBe('usage_2026-03-01_to_2026-03-08.csv')
expect(showSuccess).toHaveBeenCalledWith('Export success')
expect(showError).not.toHaveBeenCalled()
} finally {
window.URL.createObjectURL = originalCreateObjectURL
window.URL.revokeObjectURL = originalRevokeObjectURL
clickSpy.mockRestore()
wrapper.unmount()
}
})
it('exports historical image rows with image billing mode derived from image_count', async () => {
query.mockResolvedValue({
items: [
{
...usageLog,
request_id: 'req-user-export-legacy-image',
actual_cost: 0.2,
total_cost: 0.2,
input_cost: 0,
output_cost: 0,
cache_creation_cost: 0,
cache_read_cost: 0,
input_tokens: 0,
output_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
image_count: 1,
model: 'gpt-image-2',
billing_mode: null,
ip_address: null,
},
],
total: 1,
pages: 1,
})
const wrapper = mountUsageView()
await flushPromises()
let csvContent = ''
const OriginalBlob = globalThis.Blob
vi.stubGlobal('Blob', vi.fn((parts: BlobPart[], options?: BlobPropertyBag) => {
csvContent = parts.map((part) => String(part)).join('')
return new OriginalBlob(parts, options)
}))
const originalCreateObjectURL = window.URL.createObjectURL
const originalRevokeObjectURL = window.URL.revokeObjectURL
window.URL.createObjectURL = vi.fn(() => 'blob:usage-export') as typeof window.URL.createObjectURL
window.URL.revokeObjectURL = vi.fn(() => {}) as typeof window.URL.revokeObjectURL
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
await (wrapper.vm as any).exportToCSV()
expect(csvContent).toContain('Billing Mode')
expect(csvContent).toContain('Image')
expect(csvContent).not.toContain(',Token,0,0,0,0,')
window.URL.createObjectURL = originalCreateObjectURL
window.URL.revokeObjectURL = originalRevokeObjectURL
vi.unstubAllGlobals()
clickSpy.mockRestore()
})
})
describe('UsageView subscription feature flag', () => {
afterEach(() => {
appStoreState.cachedPublicSettings = { allow_user_view_error_requests: true }
})
function billingTypeSelect(wrapper: ReturnType<typeof mountUsageView>) {
return wrapper.findAllComponents(Select).find((select) =>
select.props('options').some((option: SelectOption) => option.label === 'Subscription')
)
}
it('offers the balance / subscription billing-type filter by default', async () => {
const wrapper = mountUsageView()
await flushPromises()
expect(billingTypeSelect(wrapper)).toBeDefined()
expect(wrapper.text()).toContain('Billing type')
wrapper.unmount()
})
it('hides the billing-type filter entirely when subscriptions are disabled', async () => {
appStoreState.cachedPublicSettings = { allow_user_view_error_requests: true, subscription_enabled: false }
const wrapper = mountUsageView()
await flushPromises()
expect(billingTypeSelect(wrapper)).toBeUndefined()
expect(wrapper.text()).not.toContain('Billing type')
wrapper.unmount()
})
})