Files
Aether/frontend/src/features/usage/composables/__tests__/useUsageData.spec.ts
T

720 lines
21 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
const {
getAllUsageRecordsMock,
getAllUsageRecordTotalMock,
getUsageStatsMock,
getUsageByModelMock,
getUsageByProviderMock,
getUsageByApiFormatMock,
meGetUsageMock,
} = vi.hoisted(() => ({
getAllUsageRecordsMock: vi.fn(),
getAllUsageRecordTotalMock: vi.fn(),
getUsageStatsMock: vi.fn(),
getUsageByModelMock: vi.fn(),
getUsageByProviderMock: vi.fn(),
getUsageByApiFormatMock: vi.fn(),
meGetUsageMock: vi.fn(),
}))
vi.mock('@/api/usage', () => ({
usageApi: {
getAllUsageRecords: getAllUsageRecordsMock,
getAllUsageRecordTotal: getAllUsageRecordTotalMock,
getUsageStats: getUsageStatsMock,
getUsageByModel: getUsageByModelMock,
getUsageByProvider: getUsageByProviderMock,
getUsageByApiFormat: getUsageByApiFormatMock,
},
}))
vi.mock('@/api/me', () => ({
meApi: {
getUsage: meGetUsageMock,
},
}))
vi.mock('@/utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
http: vi.fn(),
performance: vi.fn(),
},
}))
import { useUsageData } from '../useUsageData'
import type { UsageRecord } from '../../types'
function buildUsageRecord(overrides: Partial<UsageRecord> = {}): UsageRecord {
return {
id: 'usage-1',
model: 'gpt-5',
input_tokens: 10,
output_tokens: 5,
total_tokens: 15,
cost: 0.01,
is_stream: false,
created_at: '2026-05-01T00:00:00Z',
status: 'completed',
...overrides,
}
}
function createDeferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
async function flushMicrotasks() {
await Promise.resolve()
await Promise.resolve()
}
describe('useUsageData', () => {
beforeEach(() => {
vi.clearAllMocks()
getAllUsageRecordsMock.mockResolvedValue({
records: [buildUsageRecord()],
total: 1,
limit: 20,
offset: 0,
})
getAllUsageRecordTotalMock.mockResolvedValue(1)
getUsageStatsMock.mockRejectedValue({
response: { status: 500 },
message: 'stats failed',
})
getUsageByModelMock.mockResolvedValue([])
getUsageByProviderMock.mockResolvedValue([])
getUsageByApiFormatMock.mockResolvedValue([])
meGetUsageMock.mockResolvedValue({})
})
it('keeps admin records when stats refresh fails', async () => {
const isAdminPage = ref(true)
const { loadRecords, loadStats, currentRecords, totalRecords } = useUsageData({ isAdminPage })
const dateRange = { preset: 'last7days', tz_offset_minutes: 0 }
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
expect(currentRecords.value).toHaveLength(1)
expect(totalRecords.value).toBe(1)
await loadStats(dateRange)
expect(currentRecords.value).toHaveLength(1)
expect(currentRecords.value[0]).toMatchObject({
id: 'usage-1',
model: 'gpt-5',
})
expect(totalRecords.value).toBe(1)
})
it('keeps locally resolved failure fields when a stale active record refreshes', async () => {
const isAdminPage = ref(true)
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
const dateRange = { preset: 'today', tz_offset_minutes: 0 }
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'failed',
status_code: 524,
error_message: 'error code: 524',
response_time_ms: 125_000,
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'pending',
status_code: undefined,
error_message: undefined,
response_time_ms: null,
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
expect(currentRecords.value[0]).toMatchObject({
status: 'failed',
status_code: 524,
error_message: 'error code: 524',
response_time_ms: 125_000,
})
})
it('clears a failed candidate Cyber snapshot when the final candidate completes', async () => {
const isAdminPage = ref(true)
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
const dateRange = { preset: 'today', tz_offset_minutes: 0 }
const cyberMessage = 'This content was flagged for possible cybersecurity risk. https://chatgpt.com/cyber'
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'failed',
status_code: 400,
error_message: cyberMessage,
updated_at: '2026-07-17T00:00:01Z',
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'completed',
status_code: 200,
error_message: undefined,
updated_at: '2026-07-17T00:00:02Z',
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
expect(currentRecords.value[0]).toMatchObject({
status: 'completed',
status_code: 200,
updated_at: '2026-07-17T00:00:02Z',
})
expect(currentRecords.value[0]?.error_message).toBeUndefined()
})
it('rejects an older same-rank terminal snapshot as a unit', async () => {
const isAdminPage = ref(true)
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
const dateRange = { preset: 'today', tz_offset_minutes: 0 }
const cyberMessage = 'This content was flagged for possible cybersecurity risk. https://chatgpt.com/cyber'
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'completed',
status_code: 200,
error_message: undefined,
updated_at: '2026-07-17T00:00:02Z',
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'failed',
status_code: 400,
error_message: cyberMessage,
updated_at: '2026-07-17T00:00:01Z',
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
expect(currentRecords.value[0]).toMatchObject({
status: 'completed',
status_code: 200,
updated_at: '2026-07-17T00:00:02Z',
})
expect(currentRecords.value[0]?.error_message).toBeUndefined()
})
it('keeps live response duration and its anchor atomic across stale refreshes', async () => {
const isAdminPage = ref(true)
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
const dateRange = { preset: 'today', tz_offset_minutes: 0 }
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'streaming',
response_time_ms: 5500,
response_time_updated_at: '2026-07-17T12:00:06Z',
first_byte_time_ms: 2000,
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'streaming',
response_time_ms: 5000,
response_time_updated_at: '2026-07-17T12:00:07Z',
first_byte_time_ms: 1500,
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
expect(currentRecords.value[0]).toMatchObject({
status: 'streaming',
response_time_ms: 5500,
response_time_updated_at: '2026-07-17T12:00:06Z',
first_byte_time_ms: 2000,
})
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'completed',
response_time_ms: 5200,
response_time_updated_at: '2026-07-17T12:00:07Z',
first_byte_time_ms: 1800,
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
expect(currentRecords.value[0]).toMatchObject({
status: 'completed',
response_time_ms: 5200,
response_time_updated_at: '2026-07-17T12:00:07Z',
first_byte_time_ms: 2000,
})
})
it('preserves client/detail metrics but clears stale final-provider facts from the next list snapshot', async () => {
const isAdminPage = ref(true)
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
const dateRange = { preset: 'today', tz_offset_minutes: 0 }
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'completed',
input_tokens: 0,
effective_input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
cache_creation_input_tokens: 0,
cache_creation_ephemeral_5m_input_tokens: 0,
cache_creation_ephemeral_1h_input_tokens: 0,
cache_read_input_tokens: 0,
cost: 0,
actual_cost: 0,
response_time_ms: null,
first_byte_time_ms: null,
is_stream: false,
upstream_is_stream: false,
client_requested_stream: false,
client_is_stream: false,
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
Object.assign(currentRecords.value[0], {
input_tokens: 1138,
effective_input_tokens: 1138,
output_tokens: 244,
total_tokens: 81126,
cache_creation_input_tokens: 17,
cache_creation_ephemeral_5m_input_tokens: 5,
cache_creation_ephemeral_1h_input_tokens: 12,
cache_read_input_tokens: 79744,
cost: 0.052882,
actual_cost: 0.052882,
response_time_ms: 5570,
first_byte_time_ms: 1600,
is_stream: true,
upstream_is_stream: true,
client_requested_stream: true,
client_is_stream: true,
api_format: 'openai:responses',
endpoint_api_format: 'openai:responses',
has_format_conversion: false,
has_retry: true,
target_model: 'gpt-5.5',
request_type: 'compact',
requested_reasoning_effort: 'xhigh',
reasoning_effort: 'max',
service_tier: 'priority',
actual_service_tier: 'default',
})
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'completed',
input_tokens: 0,
effective_input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
cache_creation_input_tokens: 0,
cache_creation_ephemeral_5m_input_tokens: 0,
cache_creation_ephemeral_1h_input_tokens: 0,
cache_read_input_tokens: 0,
cost: 0,
actual_cost: 0,
response_time_ms: null,
first_byte_time_ms: null,
is_stream: false,
upstream_is_stream: false,
client_requested_stream: false,
client_is_stream: false,
api_format: undefined,
endpoint_api_format: undefined,
has_format_conversion: undefined,
has_retry: false,
target_model: undefined,
request_type: null,
requested_reasoning_effort: null,
reasoning_effort: undefined,
service_tier: undefined,
actual_service_tier: undefined,
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
expect(currentRecords.value[0]).toMatchObject({
status: 'completed',
input_tokens: 1138,
effective_input_tokens: 1138,
output_tokens: 244,
total_tokens: 81126,
cache_creation_input_tokens: 17,
cache_creation_ephemeral_5m_input_tokens: 5,
cache_creation_ephemeral_1h_input_tokens: 12,
cache_read_input_tokens: 79744,
cost: 0.052882,
actual_cost: 0.052882,
response_time_ms: 5570,
first_byte_time_ms: 1600,
is_stream: true,
upstream_is_stream: true,
client_requested_stream: true,
client_is_stream: true,
api_format: 'openai:responses',
endpoint_api_format: 'openai:responses',
has_format_conversion: false,
has_retry: true,
target_model: null,
request_type: 'compact',
requested_reasoning_effort: 'xhigh',
reasoning_effort: null,
service_tier: null,
actual_service_tier: null,
})
})
it('allows finalized list metrics to replace larger detail estimates', async () => {
const isAdminPage = ref(true)
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
const dateRange = { preset: 'today', tz_offset_minutes: 0 }
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'completed',
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
cost: 0.09,
actual_cost: 0.09,
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'completed',
input_tokens: 1100,
output_tokens: 250,
total_tokens: 1350,
cost: 0.07,
actual_cost: 0.07,
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
expect(currentRecords.value[0]).toMatchObject({
input_tokens: 1100,
output_tokens: 250,
total_tokens: 1350,
cost: 0.07,
actual_cost: 0.07,
})
})
it('refreshes exact admin record totals after an estimated first page', async () => {
const isAdminPage = ref(true)
const { loadRecords, totalRecords } = useUsageData({ isAdminPage })
const dateRange = { preset: 'last7days', tz_offset_minutes: 0 }
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord()],
total: 21,
total_is_estimated: true,
limit: 20,
offset: 0,
})
getAllUsageRecordTotalMock.mockResolvedValueOnce(122101)
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
expect(getAllUsageRecordsMock).toHaveBeenCalledWith(expect.objectContaining({
include_total: false,
}))
await Promise.resolve()
await Promise.resolve()
expect(getAllUsageRecordTotalMock).toHaveBeenCalledWith(expect.objectContaining({
preset: 'last7days',
tz_offset_minutes: 0,
}))
expect(totalRecords.value).toBe(122101)
})
it('keeps the exact admin record total while a later page returns an estimate', async () => {
const isAdminPage = ref(true)
const { loadRecords, totalRecords } = useUsageData({ isAdminPage })
const dateRange = { preset: 'last7days', tz_offset_minutes: 0 }
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord()],
total: 21,
total_is_estimated: true,
limit: 20,
offset: 0,
})
getAllUsageRecordTotalMock.mockResolvedValueOnce(8650)
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
await flushMicrotasks()
expect(totalRecords.value).toBe(8650)
const exactTotal = createDeferred<number>()
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({ id: 'usage-2' })],
total: 41,
total_is_estimated: true,
limit: 20,
offset: 20,
})
getAllUsageRecordTotalMock.mockReturnValueOnce(exactTotal.promise)
await loadRecords({ page: 2, pageSize: 20 }, undefined, dateRange)
expect(totalRecords.value).toBe(8650)
exactTotal.resolve(8651)
await flushMicrotasks()
expect(totalRecords.value).toBe(8651)
})
it('continues loading admin breakdowns when the summary request fails', async () => {
const isAdminPage = ref(true)
const {
loadStats,
stats,
modelStats,
providerStats,
apiFormatStats,
availableModels,
availableProviders,
} = useUsageData({ isAdminPage })
const dateRange = { preset: 'last7days', tz_offset_minutes: 0 }
getUsageStatsMock.mockRejectedValueOnce({
response: { status: 500 },
message: 'summary failed',
})
getUsageByModelMock.mockResolvedValueOnce([
{
model: 'gpt-5',
request_count: 3,
total_tokens: 300,
total_cost: 1.23,
},
])
getUsageByProviderMock.mockResolvedValueOnce([
{
provider_id: 'provider-openai',
provider_key: 'provider-openai',
provider_identity_source: 'provider_id',
provider: 'OpenAI',
request_count: 3,
total_tokens: 300,
total_cost: 1.23,
actual_cost: 1.5,
avg_response_time_ms: 1250,
success_rate: 1,
},
])
getUsageByApiFormatMock.mockResolvedValueOnce([
{
api_format: 'openai:chat',
request_count: 3,
total_tokens: 300,
total_cost: 1.23,
actual_cost: 1.5,
avg_response_time_ms: 1250,
},
])
const hadFailure = await loadStats(dateRange)
expect(hadFailure).toBe(true)
expect(stats.value).toMatchObject({
total_requests: 0,
total_tokens: 0,
total_cost: 0,
})
expect(modelStats.value).toHaveLength(1)
expect(providerStats.value).toHaveLength(1)
expect(providerStats.value[0]).toMatchObject({
providerId: 'provider-openai',
providerKey: 'provider-openai',
providerIdentitySource: 'provider_id',
})
expect(apiFormatStats.value).toHaveLength(1)
expect(availableModels.value).toEqual(['gpt-5'])
expect(availableProviders.value).toEqual(['OpenAI'])
})
it('filters placeholder providers from admin provider stats', async () => {
const isAdminPage = ref(true)
const { loadStats, providerStats, availableProviders } = useUsageData({ isAdminPage })
const dateRange = { preset: 'last7days', tz_offset_minutes: 0 }
getUsageStatsMock.mockResolvedValueOnce({
total_requests: 4,
total_tokens: 400,
total_cost: 1,
avg_response_time: 0,
})
getUsageByProviderMock.mockResolvedValueOnce([
{
provider: 'OpenAI',
request_count: 3,
total_tokens: 300,
total_cost: 1.23,
actual_cost: 1.5,
avg_response_time_ms: 1250,
success_rate: 100,
},
{
provider: 'Unknown',
request_count: 1,
total_tokens: 100,
total_cost: 0,
actual_cost: 0,
avg_response_time_ms: 0,
success_rate: 100,
},
{
provider: 'unknow',
request_count: 1,
total_tokens: 100,
total_cost: 0,
actual_cost: 0,
avg_response_time_ms: 0,
success_rate: 100,
},
{
provider: 'pending',
request_count: 1,
total_tokens: 100,
total_cost: 0,
actual_cost: 0,
avg_response_time_ms: 0,
success_rate: 100,
},
])
await loadStats(dateRange)
expect(providerStats.value.map(item => item.provider)).toEqual(['OpenAI'])
expect(availableProviders.value).toEqual(['OpenAI'])
})
it('keeps previous admin provider stats when a background refresh fails', async () => {
const isAdminPage = ref(true)
const { loadStats, providerStats, availableProviders } = useUsageData({ isAdminPage })
const dateRange = { preset: 'last7days', tz_offset_minutes: 0 }
getUsageStatsMock.mockResolvedValueOnce({
total_requests: 3,
total_tokens: 300,
total_cost: 1,
avg_response_time: 0,
})
getUsageByProviderMock.mockResolvedValueOnce([
{
provider: 'OpenAI',
request_count: 3,
total_tokens: 300,
total_cost: 1.23,
actual_cost: 1.5,
avg_response_time_ms: 1250,
success_rate: 100,
},
])
await loadStats(dateRange)
getUsageStatsMock.mockResolvedValueOnce({
total_requests: 4,
total_tokens: 400,
total_cost: 2,
avg_response_time: 0,
})
getUsageByProviderMock.mockRejectedValueOnce({
response: { status: 500 },
message: 'provider aggregation failed',
})
const hadFailure = await loadStats(dateRange, { preserveOnFailure: true })
expect(hadFailure).toBe(true)
expect(providerStats.value).toHaveLength(1)
expect(providerStats.value[0]).toMatchObject({
provider: 'OpenAI',
requests: 3,
})
expect(availableProviders.value).toEqual(['OpenAI'])
})
})