| import { describe, expect, it, vi, beforeEach } 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(() => ({ |
| query: vi.fn(), |
| getStatsByDateRange: vi.fn(), |
| list: vi.fn(), |
| showError: vi.fn(), |
| showWarning: vi.fn(), |
| showSuccess: vi.fn(), |
| showInfo: vi.fn(), |
| })) |
|
|
| 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', |
| } |
|
|
| vi.mock('@/api', () => ({ |
| usageAPI: { |
| query, |
| getStatsByDateRange, |
| }, |
| keysAPI: { |
| list, |
| }, |
| })) |
|
|
| vi.mock('@/stores/app', () => ({ |
| useAppStore: () => ({ showError, showWarning, showSuccess, showInfo }), |
| })) |
|
|
| 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 AppLayoutStub = { template: '<div><slot /></div>' } |
| const TablePageLayoutStub = { |
| template: '<div><slot name="actions" /><slot name="filters" /><slot /></div>', |
| } |
|
|
| describe('user UsageView tooltip', () => { |
| beforeEach(() => { |
| query.mockReset() |
| getStatsByDateRange.mockReset() |
| list.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() {} |
| } |
| }) |
|
|
| 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, |
| Icon: true, |
| Teleport: true, |
| }, |
| }, |
| }) |
|
|
| 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') |
| }) |
|
|
| 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: [] }) |
|
|
| 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, |
| Icon: true, |
| Teleport: true, |
| }, |
| }, |
| }) |
|
|
| await flushPromises() |
|
|
| const setupState = (wrapper.vm as any).$?.setupState |
| await setupState.exportToCSV() |
|
|
| expect(exportedBlob).not.toBeNull() |
| expect(clickSpy).toHaveBeenCalled() |
| expect(showSuccess).toHaveBeenCalled() |
|
|
| window.URL.createObjectURL = originalCreateObjectURL |
| window.URL.revokeObjectURL = originalRevokeObjectURL |
| clickSpy.mockRestore() |
| }) |
| }) |
|
|