mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 08:22:34 +00:00
Merge pull request #2007 from PMExtra/feature/user-usage-admin-parity
feat: align user usage analytics with admin
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,26 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import UsageView from '../UsageView.vue'
|
||||
|
||||
const { query, getStatsByDateRange, list, showError, showWarning, showSuccess, showInfo } = vi.hoisted(() => ({
|
||||
const {
|
||||
query,
|
||||
getStats,
|
||||
getDashboardModels,
|
||||
getDashboardSnapshotV2,
|
||||
list,
|
||||
getAvailable,
|
||||
showError,
|
||||
showWarning,
|
||||
showSuccess,
|
||||
showInfo,
|
||||
} = vi.hoisted(() => ({
|
||||
query: vi.fn(),
|
||||
getStatsByDateRange: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
getDashboardModels: vi.fn(),
|
||||
getDashboardSnapshotV2: vi.fn(),
|
||||
list: vi.fn(),
|
||||
getAvailable: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
showWarning: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
@@ -15,62 +28,55 @@ const { query, getStatsByDateRange, list, showError, showWarning, showSuccess, s
|
||||
}))
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
'usage.costDetails': 'Cost Breakdown',
|
||||
'admin.usage.inputCost': 'Input Cost',
|
||||
'admin.usage.outputCost': 'Output Cost',
|
||||
'admin.usage.cacheCreationCost': 'Cache Creation Cost',
|
||||
'admin.usage.cacheReadCost': 'Cache Read Cost',
|
||||
'usage.inputTokenPrice': 'Input price',
|
||||
'usage.outputTokenPrice': 'Output price',
|
||||
'usage.perMillionTokens': '/ 1M tokens',
|
||||
'usage.serviceTier': 'Service tier',
|
||||
'usage.serviceTierPriority': 'Fast',
|
||||
'usage.serviceTierFlex': 'Flex',
|
||||
'usage.serviceTierStandard': 'Standard',
|
||||
'usage.rate': 'Rate',
|
||||
'usage.original': 'Original',
|
||||
'usage.billed': 'Billed',
|
||||
'usage.allApiKeys': 'All API Keys',
|
||||
'usage.apiKeyFilter': 'API Key',
|
||||
'usage.model': 'Model',
|
||||
'usage.reasoningEffort': 'Reasoning Effort',
|
||||
'usage.type': 'Type',
|
||||
'usage.tokens': 'Tokens',
|
||||
'usage.cost': 'Cost',
|
||||
'usage.firstToken': 'First Token',
|
||||
'usage.duration': 'Duration',
|
||||
'usage.time': 'Time',
|
||||
'usage.userAgent': 'User Agent',
|
||||
'usage.imageUnit': ' images',
|
||||
'usage.imageCount': 'Image count',
|
||||
'usage.imageBillingSize': 'Billing size',
|
||||
'usage.imageInputSize': 'Input size',
|
||||
'usage.imageOutputSize': 'Output size',
|
||||
'usage.imageSizeSource': 'Size source',
|
||||
'usage.imageSizeBreakdown': 'Size breakdown',
|
||||
'usage.imageSizeSourceOutput': 'Upstream output',
|
||||
'usage.imageSizeSourceInput': 'Request input',
|
||||
'usage.imageSizeSourceDefault': 'Default billing tier',
|
||||
'usage.imageSizeSourceLegacy': 'Legacy record',
|
||||
'usage.imageSizeSourceMissing': 'Not recorded',
|
||||
'usage.imageSizeNotRecorded': 'not recorded',
|
||||
'usage.imageSizeLegacyUnstandardized': 'legacy unstandardized',
|
||||
'usage.imageSizeUnknown': 'unknown',
|
||||
'usage.imageUnitPrice': 'Per-image price',
|
||||
'usage.imageTotalPrice': 'Image total price',
|
||||
'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.apiKeyFilter': 'API Key',
|
||||
'usage.model': 'Model',
|
||||
'usage.type': 'Type',
|
||||
'usage.ws': 'WS',
|
||||
'usage.stream': 'Stream',
|
||||
'usage.sync': 'Sync',
|
||||
'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,
|
||||
getStatsByDateRange,
|
||||
getStats,
|
||||
getDashboardModels,
|
||||
getDashboardSnapshotV2,
|
||||
},
|
||||
keysAPI: {
|
||||
list,
|
||||
},
|
||||
userGroupsAPI: {
|
||||
getAvailable,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
@@ -87,178 +93,131 @@ vi.mock('vue-i18n', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
const AppLayoutStub = { template: '<div><slot /></div>' }
|
||||
const TablePageLayoutStub = {
|
||||
template: '<div><slot name="actions" /><slot name="filters" /><slot name="table" /><slot /></div>',
|
||||
}
|
||||
const DataTableStub = {
|
||||
props: ['data'],
|
||||
template: `
|
||||
<div>
|
||||
<div v-for="row in data" :key="row.request_id">
|
||||
<slot name="cell-billing_mode" :row="row" />
|
||||
<slot name="cell-tokens" :row="row" />
|
||||
<slot name="cell-cost" :row="row" />
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
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,
|
||||
}
|
||||
|
||||
describe('user UsageView tooltip', () => {
|
||||
function mountUsageView() {
|
||||
return mount(UsageView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: simpleStub,
|
||||
Pagination: true,
|
||||
Select: true,
|
||||
DateRangePicker: true,
|
||||
Icon: true,
|
||||
UsageStatsCards: chartStub,
|
||||
UsageTable: chartStub,
|
||||
ModelDistributionChart: chartStub,
|
||||
GroupDistributionChart: chartStub,
|
||||
EndpointDistributionChart: chartStub,
|
||||
TokenUsageTrend: chartStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('user UsageView', () => {
|
||||
beforeEach(() => {
|
||||
query.mockReset()
|
||||
getStatsByDateRange.mockReset()
|
||||
getStats.mockReset()
|
||||
getDashboardModels.mockReset()
|
||||
getDashboardSnapshotV2.mockReset()
|
||||
list.mockReset()
|
||||
getAvailable.mockReset()
|
||||
showError.mockReset()
|
||||
showWarning.mockReset()
|
||||
showSuccess.mockReset()
|
||||
showInfo.mockReset()
|
||||
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 20,
|
||||
left: 20,
|
||||
right: 120,
|
||||
bottom: 40,
|
||||
width: 100,
|
||||
height: 20,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
|
||||
;(globalThis as any).ResizeObserver = class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
}
|
||||
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: [],
|
||||
})
|
||||
list.mockResolvedValue({ items: [{ id: 1, name: 'demo-key' }] })
|
||||
getAvailable.mockResolvedValue([{ id: 1, name: 'default' }])
|
||||
})
|
||||
|
||||
it('shows fast service tier and unit prices in user tooltip', async () => {
|
||||
query.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
request_id: 'req-user-1',
|
||||
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,
|
||||
cache_read_cost: 0.069568,
|
||||
input_tokens: 4057,
|
||||
output_tokens: 101,
|
||||
cache_creation_tokens: 0,
|
||||
cache_read_tokens: 278272,
|
||||
cache_creation_5m_tokens: 0,
|
||||
cache_creation_1h_tokens: 0,
|
||||
image_count: 0,
|
||||
image_size: null,
|
||||
first_token_ms: null,
|
||||
duration_ms: 1,
|
||||
created_at: '2026-03-08T00:00:00Z',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
pages: 1,
|
||||
})
|
||||
getStatsByDateRange.mockResolvedValue({
|
||||
total_requests: 1,
|
||||
total_tokens: 100,
|
||||
total_cost: 0.1,
|
||||
avg_duration_ms: 1,
|
||||
})
|
||||
list.mockResolvedValue({ items: [] })
|
||||
|
||||
const wrapper = mount(UsageView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: AppLayoutStub,
|
||||
TablePageLayout: TablePageLayoutStub,
|
||||
Pagination: true,
|
||||
EmptyState: true,
|
||||
Select: true,
|
||||
DateRangePicker: true,
|
||||
DataTable: DataTableStub,
|
||||
Icon: true,
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
it('loads logs, stats, model stats, and snapshot on first render', async () => {
|
||||
mountUsageView()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
const setupState = (wrapper.vm as any).$?.setupState
|
||||
setupState.tooltipData = {
|
||||
request_id: 'req-user-1',
|
||||
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,
|
||||
cache_read_cost: 0.069568,
|
||||
input_tokens: 4057,
|
||||
output_tokens: 101,
|
||||
}
|
||||
setupState.tooltipVisible = true
|
||||
await nextTick()
|
||||
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('Service tier')
|
||||
expect(text).toContain('Fast')
|
||||
expect(text).toContain('Rate')
|
||||
expect(text).toContain('1.00x')
|
||||
expect(text).toContain('Billed')
|
||||
expect(text).toContain('$0.092883')
|
||||
expect(text).toContain('$5.0000 / 1M tokens')
|
||||
expect(text).toContain('$30.0000 / 1M tokens')
|
||||
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).toHaveBeenCalledWith(1, 100)
|
||||
expect(getAvailable).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('exports csv with input and output unit price columns', async () => {
|
||||
const exportedLogs = [
|
||||
{
|
||||
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,
|
||||
api_key: { name: 'demo-key' },
|
||||
},
|
||||
]
|
||||
|
||||
query.mockResolvedValue({
|
||||
items: exportedLogs,
|
||||
total: 1,
|
||||
pages: 1,
|
||||
})
|
||||
getStatsByDateRange.mockResolvedValue({
|
||||
total_requests: 1,
|
||||
total_tokens: 100,
|
||||
total_cost: 0.1,
|
||||
avg_duration_ms: 1,
|
||||
})
|
||||
list.mockResolvedValue({ items: [] })
|
||||
it('exports csv with current filters and without admin-only fields', async () => {
|
||||
const wrapper = mountUsageView()
|
||||
await flushPromises()
|
||||
|
||||
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) => {
|
||||
@@ -268,146 +227,38 @@ describe('user UsageView tooltip', () => {
|
||||
window.URL.revokeObjectURL = vi.fn(() => {}) as typeof window.URL.revokeObjectURL
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
|
||||
const wrapper = mount(UsageView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: AppLayoutStub,
|
||||
TablePageLayout: TablePageLayoutStub,
|
||||
Pagination: true,
|
||||
EmptyState: true,
|
||||
Select: true,
|
||||
DateRangePicker: true,
|
||||
DataTable: DataTableStub,
|
||||
Icon: true,
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const setupState = (wrapper.vm as any).$?.setupState
|
||||
await setupState.exportToCSV()
|
||||
await (wrapper.vm as any).exportToCSV()
|
||||
|
||||
expect(exportedBlob).not.toBeNull()
|
||||
const hasSortedExportQuery = query.mock.calls.some((call) => {
|
||||
const params = call[0] as Record<string, unknown> | undefined
|
||||
const config = call[1]
|
||||
return (
|
||||
params?.page_size === 100 &&
|
||||
params?.sort_by === 'created_at' &&
|
||||
params?.sort_order === 'desc' &&
|
||||
config === undefined
|
||||
)
|
||||
})
|
||||
expect(hasSortedExportQuery).toBe(true)
|
||||
expect(query).toHaveBeenCalledWith(expect.objectContaining({
|
||||
page_size: 100,
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc',
|
||||
}))
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
expect(showSuccess).toHaveBeenCalled()
|
||||
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('exports historical image rows with image billing mode derived from image_count', async () => {
|
||||
const exportedLogs = [
|
||||
{
|
||||
request_id: 'req-user-export-legacy-image',
|
||||
actual_cost: 0.2,
|
||||
total_cost: 0.2,
|
||||
rate_multiplier: 1,
|
||||
service_tier: null,
|
||||
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,
|
||||
cache_creation_5m_tokens: 0,
|
||||
cache_creation_1h_tokens: 0,
|
||||
image_count: 1,
|
||||
image_size: null,
|
||||
billing_mode: null,
|
||||
first_token_ms: null,
|
||||
duration_ms: 345,
|
||||
created_at: '2026-03-08T00:00:00Z',
|
||||
model: 'gpt-image-2',
|
||||
reasoning_effort: null,
|
||||
api_key: { name: 'demo-key' },
|
||||
},
|
||||
]
|
||||
|
||||
query.mockResolvedValue({
|
||||
items: exportedLogs,
|
||||
total: 1,
|
||||
pages: 1,
|
||||
})
|
||||
getStatsByDateRange.mockResolvedValue({
|
||||
total_requests: 1,
|
||||
total_tokens: 0,
|
||||
total_cost: 0.2,
|
||||
avg_duration_ms: 1,
|
||||
})
|
||||
list.mockResolvedValue({ items: [] })
|
||||
|
||||
let exportedBlob: Blob | null = null
|
||||
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(() => {})
|
||||
|
||||
const wrapper = mount(UsageView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: AppLayoutStub,
|
||||
TablePageLayout: TablePageLayoutStub,
|
||||
Pagination: true,
|
||||
EmptyState: true,
|
||||
Select: true,
|
||||
DateRangePicker: true,
|
||||
DataTable: DataTableStub,
|
||||
Icon: true,
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const setupState = (wrapper.vm as any).$?.setupState
|
||||
await setupState.exportToCSV()
|
||||
|
||||
expect(exportedBlob).not.toBeNull()
|
||||
const csv = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result))
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsText(exportedBlob as Blob)
|
||||
})
|
||||
expect(csv).toContain('Billing Mode')
|
||||
expect(csv).toContain('Image')
|
||||
expect(csv).not.toContain(',Token,0,0,0,0,')
|
||||
|
||||
window.URL.createObjectURL = originalCreateObjectURL
|
||||
window.URL.revokeObjectURL = originalRevokeObjectURL
|
||||
clickSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('does not display a 2K fallback for historical image rows with missing size', async () => {
|
||||
query.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
request_id: 'req-user-legacy-missing-image',
|
||||
...usageLog,
|
||||
request_id: 'req-user-export-legacy-image',
|
||||
actual_cost: 0.2,
|
||||
total_cost: 0.2,
|
||||
rate_multiplier: 1,
|
||||
service_tier: null,
|
||||
input_cost: 0,
|
||||
output_cost: 0,
|
||||
cache_creation_cost: 0,
|
||||
@@ -416,125 +267,40 @@ describe('user UsageView tooltip', () => {
|
||||
output_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_5m_tokens: 0,
|
||||
cache_creation_1h_tokens: 0,
|
||||
image_count: 1,
|
||||
image_size: null,
|
||||
image_input_size: null,
|
||||
image_output_size: null,
|
||||
image_size_source: null,
|
||||
image_size_breakdown: null,
|
||||
billing_mode: null,
|
||||
first_token_ms: null,
|
||||
duration_ms: 1,
|
||||
created_at: '2026-03-08T00:00:00Z',
|
||||
model: 'gpt-image-2',
|
||||
billing_mode: null,
|
||||
ip_address: null,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
pages: 1,
|
||||
})
|
||||
getStatsByDateRange.mockResolvedValue({
|
||||
total_requests: 1,
|
||||
total_tokens: 0,
|
||||
total_cost: 0.2,
|
||||
avg_duration_ms: 1,
|
||||
})
|
||||
list.mockResolvedValue({ items: [] })
|
||||
|
||||
const wrapper = mount(UsageView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: AppLayoutStub,
|
||||
TablePageLayout: TablePageLayoutStub,
|
||||
Pagination: true,
|
||||
EmptyState: true,
|
||||
Select: true,
|
||||
DateRangePicker: true,
|
||||
DataTable: DataTableStub,
|
||||
Icon: true,
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('Image')
|
||||
expect(text).toContain('not recorded')
|
||||
expect(text).not.toContain('(2K)')
|
||||
})
|
||||
|
||||
it('shows image billing metadata in the user cost tooltip', async () => {
|
||||
query.mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
pages: 0,
|
||||
})
|
||||
getStatsByDateRange.mockResolvedValue({
|
||||
total_requests: 0,
|
||||
total_tokens: 0,
|
||||
total_cost: 0,
|
||||
avg_duration_ms: 0,
|
||||
})
|
||||
list.mockResolvedValue({ items: [] })
|
||||
|
||||
const wrapper = mount(UsageView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: AppLayoutStub,
|
||||
TablePageLayout: TablePageLayoutStub,
|
||||
Pagination: true,
|
||||
EmptyState: true,
|
||||
Select: true,
|
||||
DateRangePicker: true,
|
||||
DataTable: DataTableStub,
|
||||
Icon: true,
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = mountUsageView()
|
||||
await flushPromises()
|
||||
|
||||
const setupState = (wrapper.vm as any).$?.setupState
|
||||
setupState.tooltipData = {
|
||||
request_id: 'req-user-output-image',
|
||||
actual_cost: 0.8,
|
||||
total_cost: 0.8,
|
||||
rate_multiplier: 1,
|
||||
service_tier: null,
|
||||
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,
|
||||
billing_mode: null,
|
||||
image_count: 2,
|
||||
image_size: '4K',
|
||||
image_input_size: '1024x1024',
|
||||
image_output_size: '3840x2160',
|
||||
image_size_source: 'output',
|
||||
image_size_breakdown: { '4K': 2 },
|
||||
}
|
||||
setupState.tooltipVisible = true
|
||||
await nextTick()
|
||||
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(() => {})
|
||||
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('Image count')
|
||||
expect(text).toContain('Billing size')
|
||||
expect(text).toContain('4K')
|
||||
expect(text).toContain('Size source')
|
||||
expect(text).toContain('Upstream output')
|
||||
expect(text).toContain('Input size')
|
||||
expect(text).toContain('1024x1024')
|
||||
expect(text).toContain('Output size')
|
||||
expect(text).toContain('3840x2160')
|
||||
expect(text).toContain('4K x 2')
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user