1855 lines
61 KiB
JavaScript
1855 lines
61 KiB
JavaScript
jest.mock('../src/models/redis', () => ({
|
|
getClient: jest.fn(),
|
|
getApiKey: jest.fn()
|
|
}))
|
|
|
|
jest.mock('../src/services/claudeRelayConfigService', () => ({
|
|
getConfig: jest.fn()
|
|
}))
|
|
|
|
jest.mock('../src/utils/logger', () => ({
|
|
warn: jest.fn(),
|
|
debug: jest.fn(),
|
|
error: jest.fn(),
|
|
info: jest.fn(),
|
|
start: jest.fn()
|
|
}))
|
|
|
|
jest.mock('../src/services/account/claudeAccountService', () => ({ getAccount: jest.fn() }))
|
|
jest.mock('../src/services/account/claudeConsoleAccountService', () => ({ getAccount: jest.fn() }))
|
|
jest.mock('../src/services/account/ccrAccountService', () => ({ getAccount: jest.fn() }))
|
|
jest.mock('../src/services/account/geminiAccountService', () => ({ getAccount: jest.fn() }))
|
|
jest.mock('../src/services/account/geminiApiAccountService', () => ({ getAccount: jest.fn() }))
|
|
jest.mock('../src/services/account/openaiAccountService', () => ({ getAccount: jest.fn() }))
|
|
jest.mock('../src/services/account/openaiResponsesAccountService', () => ({
|
|
getAccount: jest.fn()
|
|
}))
|
|
jest.mock('../src/services/account/azureOpenaiAccountService', () => ({ getAccount: jest.fn() }))
|
|
jest.mock('../src/services/account/droidAccountService', () => ({ getAccount: jest.fn() }))
|
|
jest.mock('../src/services/account/bedrockAccountService', () => ({ getAccount: jest.fn() }))
|
|
jest.mock('../src/utils/costCalculator', () => ({
|
|
calculateCost: jest.fn()
|
|
}))
|
|
|
|
const redis = require('../src/models/redis')
|
|
const claudeRelayConfigService = require('../src/services/claudeRelayConfigService')
|
|
const claudeAccountService = require('../src/services/account/claudeAccountService')
|
|
const claudeConsoleAccountService = require('../src/services/account/claudeConsoleAccountService')
|
|
const openaiAccountService = require('../src/services/account/openaiAccountService')
|
|
const bedrockAccountService = require('../src/services/account/bedrockAccountService')
|
|
const CostCalculator = require('../src/utils/costCalculator')
|
|
const requestDetailService = require('../src/services/requestDetailService')
|
|
|
|
describe('requestDetailService', () => {
|
|
beforeEach(() => {
|
|
jest.resetAllMocks()
|
|
jest.useFakeTimers().setSystemTime(Date.parse('2026-04-07T18:00:00.000Z'))
|
|
})
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers()
|
|
})
|
|
|
|
test('captureRequestDetail stores normalized request detail records when enabled', async () => {
|
|
const exec = jest.fn().mockResolvedValue([])
|
|
const multi = {
|
|
set: jest.fn().mockReturnThis(),
|
|
zadd: jest.fn().mockReturnThis(),
|
|
expire: jest.fn().mockReturnThis(),
|
|
exec
|
|
}
|
|
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
redis.getClient.mockReturnValue({ multi: jest.fn(() => multi) })
|
|
|
|
const result = await requestDetailService.captureRequestDetail({
|
|
requestId: 'req_capture_1',
|
|
timestamp: '2026-04-07T12:00:00.000Z',
|
|
endpoint: '/openai/v1/responses',
|
|
method: 'POST',
|
|
statusCode: 200,
|
|
apiKeyId: 'key_1',
|
|
accountId: 'acct_1',
|
|
accountType: 'openai',
|
|
model: 'gpt-5.4',
|
|
inputTokens: 10,
|
|
outputTokens: 4,
|
|
cacheReadTokens: 3,
|
|
cacheCreateTokens: 2,
|
|
cost: 0.123456,
|
|
requestBody: {
|
|
apiKey: 'super-secret',
|
|
model: 'gpt-5.4',
|
|
reasoning: {
|
|
effort: 'medium'
|
|
},
|
|
prompt: 'hello'
|
|
}
|
|
})
|
|
|
|
expect(result).toEqual({ captured: true, requestId: 'req_capture_1' })
|
|
expect(multi.set).toHaveBeenCalled()
|
|
expect(multi.set).toHaveBeenCalledWith(
|
|
'request_detail:item:req_capture_1',
|
|
expect.any(String),
|
|
'EX',
|
|
21600
|
|
)
|
|
const storedPayload = JSON.parse(multi.set.mock.calls[0][1])
|
|
expect(storedPayload.requestBodySnapshot.apiKey).toContain('***')
|
|
expect(storedPayload.endpoint).toBe('/openai/v1/responses')
|
|
expect(storedPayload.reasoningDisplay).toBe('medium')
|
|
expect(storedPayload.reasoningSource).toBe('reasoning.effort')
|
|
expect(multi.zadd).toHaveBeenCalled()
|
|
expect(exec).toHaveBeenCalled()
|
|
})
|
|
|
|
test('listRequestDetails applies openai cache display flags and openai hit-rate formula', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
redis.getApiKey.mockResolvedValue({ name: 'Primary Key' })
|
|
openaiAccountService.getAccount.mockResolvedValue({ name: 'OpenAI Main' })
|
|
|
|
const redisClient = {
|
|
zrangebyscore: jest.fn().mockResolvedValue(['req_1', '1775563200000']),
|
|
mget: jest.fn().mockResolvedValue([
|
|
JSON.stringify({
|
|
requestId: 'req_1',
|
|
timestamp: '2026-04-07T12:00:00.000Z',
|
|
endpoint: '/openai/v1/responses',
|
|
method: 'POST',
|
|
apiKeyId: 'key_1',
|
|
accountId: 'acct_1',
|
|
accountType: 'openai',
|
|
model: 'gpt-5.4',
|
|
inputTokens: 100,
|
|
outputTokens: 50,
|
|
cacheReadTokens: 60,
|
|
cacheCreateTokens: 0,
|
|
totalTokens: 210,
|
|
cost: 0.5,
|
|
durationMs: 1200,
|
|
requestBodySnapshot: { model: 'gpt-5.4' }
|
|
})
|
|
])
|
|
}
|
|
|
|
redis.getClient.mockReturnValue(redisClient)
|
|
|
|
const result = await requestDetailService.listRequestDetails({
|
|
apiKeyId: 'key_1',
|
|
model: 'gpt-5.4',
|
|
keyword: 'primary',
|
|
startDate: '2026-04-07T00:00:00.000Z',
|
|
endDate: '2026-04-07T23:59:59.000Z'
|
|
})
|
|
|
|
expect(result.records).toHaveLength(1)
|
|
expect(result.records[0].apiKeyName).toBe('Primary Key')
|
|
expect(result.records[0].accountName).toBe('OpenAI Main')
|
|
expect(result.records[0].requestBodySnapshot).toBeUndefined()
|
|
expect(result.records[0].isOpenAIRelated).toBe(true)
|
|
expect(result.records[0].cacheCreateNotApplicable).toBe(true)
|
|
expect(result.retentionHours).toBe(6)
|
|
expect(result.summary.totalRequests).toBe(1)
|
|
expect(result.summary.cacheCreateTokens).toBe(0)
|
|
expect(result.summary.cacheCreateNotApplicable).toBe(true)
|
|
expect(result.summary.cacheHitNumerator).toBe(60)
|
|
expect(result.summary.cacheHitDenominator).toBe(160)
|
|
expect(result.summary.cacheHitRate).toBe(37.5)
|
|
expect(result.availableFilters.models).toEqual(['gpt-5.4'])
|
|
expect(result.filters.hasCustomDateRange).toBe(true)
|
|
})
|
|
|
|
test('listRequestDetails aggregates mixed openai and non-openai cache metrics correctly', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
redis.getApiKey.mockImplementation(async (keyId) => ({ name: `Key ${keyId}` }))
|
|
openaiAccountService.getAccount.mockImplementation(async (accountId) =>
|
|
accountId === 'acct_1' ? { name: 'OpenAI Main' } : null
|
|
)
|
|
claudeAccountService.getAccount.mockImplementation(async (accountId) =>
|
|
accountId === 'acct_2' ? { name: 'Claude Main' } : null
|
|
)
|
|
|
|
redis.getClient.mockReturnValue({
|
|
zrangebyscore: jest
|
|
.fn()
|
|
.mockResolvedValue(['req_openai', '1775563200000', 'req_claude', '1775566800000']),
|
|
mget: jest.fn().mockResolvedValue([
|
|
JSON.stringify({
|
|
requestId: 'req_openai',
|
|
timestamp: '2026-04-07T12:00:00.000Z',
|
|
endpoint: '/openai/v1/responses',
|
|
method: 'POST',
|
|
apiKeyId: 'key_1',
|
|
accountId: 'acct_1',
|
|
accountType: 'openai',
|
|
model: 'gpt-5.4',
|
|
inputTokens: 100,
|
|
outputTokens: 20,
|
|
cacheReadTokens: 60,
|
|
cacheCreateTokens: 0,
|
|
totalTokens: 180,
|
|
cost: 0.3,
|
|
durationMs: 500
|
|
}),
|
|
JSON.stringify({
|
|
requestId: 'req_claude',
|
|
timestamp: '2026-04-07T13:00:00.000Z',
|
|
endpoint: '/v1/messages',
|
|
method: 'POST',
|
|
apiKeyId: 'key_2',
|
|
accountId: 'acct_2',
|
|
accountType: 'claude',
|
|
model: 'claude-sonnet-4-6',
|
|
inputTokens: 90,
|
|
outputTokens: 30,
|
|
cacheReadTokens: 30,
|
|
cacheCreateTokens: 30,
|
|
totalTokens: 180,
|
|
cost: 0.2,
|
|
durationMs: 700
|
|
})
|
|
])
|
|
})
|
|
|
|
const result = await requestDetailService.listRequestDetails({
|
|
startDate: '2026-04-07T00:00:00.000Z',
|
|
endDate: '2026-04-07T23:59:59.000Z'
|
|
})
|
|
|
|
expect(result.records).toHaveLength(2)
|
|
expect(result.summary.totalRequests).toBe(2)
|
|
expect(result.summary.cacheCreateNotApplicable).toBe(false)
|
|
expect(result.summary.cacheCreateTokens).toBe(30)
|
|
expect(result.summary.cacheHitNumerator).toBe(90)
|
|
expect(result.summary.cacheHitDenominator).toBe(310)
|
|
expect(result.summary.cacheHitFormula).toBe(
|
|
'cacheReadTokens / (inputTokens + cacheReadTokens + cacheCreateTokens)'
|
|
)
|
|
expect(result.summary.cacheHitRate).toBe(29.03)
|
|
})
|
|
|
|
test('listRequestDetails treats azure-openai cache hits as openai-style metrics', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
redis.getApiKey.mockResolvedValue({ name: 'Azure Key' })
|
|
|
|
redis.getClient.mockReturnValue({
|
|
zrangebyscore: jest.fn().mockResolvedValue(['req_azure', '1775563200000']),
|
|
mget: jest.fn().mockResolvedValue([
|
|
JSON.stringify({
|
|
requestId: 'req_azure',
|
|
timestamp: '2026-04-07T12:00:00.000Z',
|
|
endpoint: '/azure/chat/completions',
|
|
method: 'POST',
|
|
apiKeyId: 'key_azure',
|
|
accountId: 'acct_azure',
|
|
accountType: 'azure-openai',
|
|
model: 'gpt-4o',
|
|
inputTokens: 100,
|
|
outputTokens: 20,
|
|
cacheReadTokens: 60,
|
|
cacheCreateTokens: 0,
|
|
totalTokens: 180,
|
|
cost: 0.3,
|
|
durationMs: 500
|
|
})
|
|
])
|
|
})
|
|
|
|
const result = await requestDetailService.listRequestDetails({
|
|
startDate: '2026-04-07T00:00:00.000Z',
|
|
endDate: '2026-04-07T23:59:59.000Z'
|
|
})
|
|
|
|
expect(result.records).toHaveLength(1)
|
|
expect(result.records[0].isOpenAIRelated).toBe(true)
|
|
expect(result.records[0].cacheCreateNotApplicable).toBe(true)
|
|
expect(result.records[0].cacheHitRate).toBe(37.5)
|
|
expect(result.summary.cacheCreateTokens).toBe(0)
|
|
expect(result.summary.cacheHitRate).toBe(37.5)
|
|
})
|
|
|
|
test('reads retained zero-cost unknown model records with recomputed display cost', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
redis.getApiKey.mockResolvedValue({ name: 'Mimo Key' })
|
|
claudeConsoleAccountService.getAccount.mockResolvedValue({ name: 'Claude Console' })
|
|
CostCalculator.calculateCost.mockReturnValue({
|
|
costs: {
|
|
input: 0.051618,
|
|
output: 0.000765,
|
|
cacheCreate: 0,
|
|
cacheWrite: 0,
|
|
cacheRead: 0.0006144,
|
|
total: 0.0529974
|
|
},
|
|
debug: {
|
|
usedFallbackPricing: true,
|
|
pricingSource: 'unknown-fallback'
|
|
},
|
|
usingDynamicPricing: false
|
|
})
|
|
|
|
const storedRecord = JSON.stringify({
|
|
requestId: 'req_mimo',
|
|
timestamp: '2026-04-07T12:00:00.000Z',
|
|
endpoint: '/api/v1/messages',
|
|
method: 'POST',
|
|
apiKeyId: 'key_mimo',
|
|
accountId: 'acct_mimo',
|
|
accountType: 'claude-console',
|
|
model: 'mimo-v2.5-pro',
|
|
inputTokens: 17206,
|
|
outputTokens: 51,
|
|
cacheReadTokens: 2048,
|
|
cacheCreateTokens: 0,
|
|
totalTokens: 19305,
|
|
cost: 0,
|
|
realCost: 0,
|
|
realCostBreakdown: {
|
|
input: 0,
|
|
output: 0,
|
|
cacheCreate: 0,
|
|
cacheRead: 0
|
|
},
|
|
durationMs: 5662
|
|
})
|
|
const client = {
|
|
zrangebyscore: jest.fn().mockResolvedValue(['req_mimo', '1775563200000']),
|
|
mget: jest.fn().mockResolvedValue([storedRecord]),
|
|
get: jest.fn().mockResolvedValue(storedRecord),
|
|
set: jest.fn().mockResolvedValue('OK')
|
|
}
|
|
redis.getClient.mockReturnValue(client)
|
|
|
|
const result = await requestDetailService.listRequestDetails({
|
|
startDate: '2026-04-07T00:00:00.000Z',
|
|
endDate: '2026-04-07T23:59:59.000Z'
|
|
})
|
|
const detail = await requestDetailService.getRequestDetail('req_mimo')
|
|
|
|
expect(result.records).toHaveLength(1)
|
|
expect(result.records[0].cacheHitRate).toBe(10.64)
|
|
expect(result.records[0].cacheHitNumerator).toBe(2048)
|
|
expect(result.records[0].cacheHitDenominator).toBe(19254)
|
|
expect(result.records[0].cost).toBe(0.052997)
|
|
expect(result.records[0].realCost).toBe(0.052997)
|
|
expect(result.records[0].costRecomputed).toBe(true)
|
|
expect(result.records[0].usedFallbackPricing).toBe(true)
|
|
expect(result.records[0].pricingSource).toBe('unknown-fallback')
|
|
expect(result.records[0].realCostBreakdown).toEqual(
|
|
expect.objectContaining({
|
|
input: 0.051618,
|
|
output: 0.000765,
|
|
cacheRead: 0.0006144,
|
|
total: 0.0529974
|
|
})
|
|
)
|
|
expect(result.summary.cacheHitRate).toBe(10.64)
|
|
expect(result.summary.totalCost).toBe(0.052997)
|
|
expect(detail.record.cost).toBe(0.052997)
|
|
expect(detail.record.costRecomputed).toBe(true)
|
|
expect(client.set).not.toHaveBeenCalledWith(
|
|
'request_detail:item:req_mimo',
|
|
expect.any(String),
|
|
expect.anything()
|
|
)
|
|
})
|
|
|
|
test('listRequestDetails still exposes retained data when capture is disabled', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: false,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: false
|
|
})
|
|
|
|
redis.getApiKey.mockResolvedValue({ name: 'Primary Key' })
|
|
openaiAccountService.getAccount.mockResolvedValue({ name: 'OpenAI Main' })
|
|
|
|
redis.getClient.mockReturnValue({
|
|
zrangebyscore: jest.fn().mockResolvedValue(['req_1', '1775563200000']),
|
|
mget: jest.fn().mockResolvedValue([
|
|
JSON.stringify({
|
|
requestId: 'req_1',
|
|
timestamp: '2026-04-07T12:00:00.000Z',
|
|
endpoint: '/v1/messages',
|
|
method: 'POST',
|
|
apiKeyId: 'key_1',
|
|
accountId: 'acct_1',
|
|
accountType: 'openai',
|
|
model: 'gpt-5.4',
|
|
inputTokens: 100,
|
|
outputTokens: 50,
|
|
cacheReadTokens: 60,
|
|
cacheCreateTokens: 40,
|
|
totalTokens: 250,
|
|
cost: 0.5,
|
|
durationMs: 1200
|
|
})
|
|
])
|
|
})
|
|
|
|
const result = await requestDetailService.listRequestDetails({
|
|
startDate: '2026-04-07T00:00:00.000Z',
|
|
endDate: '2026-04-07T23:59:59.000Z'
|
|
})
|
|
|
|
expect(result.captureEnabled).toBe(false)
|
|
expect(result.retentionHours).toBe(6)
|
|
expect(result.records).toHaveLength(1)
|
|
expect(result.records[0].apiKeyName).toBe('Primary Key')
|
|
})
|
|
|
|
test('listRequestDetails derives reasoning from legacy preview-only records', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
redis.getApiKey.mockResolvedValue({ name: 'Primary Key' })
|
|
openaiAccountService.getAccount.mockResolvedValue({ name: 'OpenAI Main' })
|
|
|
|
redis.getClient.mockReturnValue({
|
|
zrangebyscore: jest.fn().mockResolvedValue(['req_preview', '1775563200000']),
|
|
mget: jest.fn().mockResolvedValue([
|
|
JSON.stringify({
|
|
requestId: 'req_preview',
|
|
timestamp: '2026-04-07T12:00:00.000Z',
|
|
endpoint: '/openai/v1/responses',
|
|
method: 'POST',
|
|
apiKeyId: 'key_1',
|
|
accountId: 'acct_1',
|
|
accountType: 'openai',
|
|
model: 'gpt-5.4',
|
|
inputTokens: 100,
|
|
outputTokens: 50,
|
|
cacheReadTokens: 60,
|
|
cacheCreateTokens: 0,
|
|
totalTokens: 210,
|
|
cost: 0.5,
|
|
durationMs: 1200,
|
|
requestBodySnapshot: {
|
|
summary: 'request body snapshot truncated',
|
|
originalChars: 18000,
|
|
maxChars: 12000,
|
|
preview:
|
|
'{"model":"gpt-5.4","reasoning":{"effort":"high","summary":"auto"},"input":"...[42 chars]'
|
|
}
|
|
})
|
|
])
|
|
})
|
|
|
|
const result = await requestDetailService.listRequestDetails({
|
|
startDate: '2026-04-07T00:00:00.000Z',
|
|
endDate: '2026-04-07T23:59:59.000Z'
|
|
})
|
|
|
|
expect(result.records).toHaveLength(1)
|
|
expect(result.records[0].reasoningDisplay).toBe('high')
|
|
expect(result.records[0].reasoningSource).toBe('reasoning.effort')
|
|
})
|
|
|
|
test('captureRequestDetail omits requestBodySnapshot when body preview is disabled', async () => {
|
|
const exec = jest.fn().mockResolvedValue([])
|
|
const multi = {
|
|
set: jest.fn().mockReturnThis(),
|
|
zadd: jest.fn().mockReturnThis(),
|
|
expire: jest.fn().mockReturnThis(),
|
|
exec
|
|
}
|
|
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: false
|
|
})
|
|
redis.getClient.mockReturnValue({ multi: jest.fn(() => multi) })
|
|
|
|
await requestDetailService.captureRequestDetail({
|
|
requestId: 'req_capture_no_preview',
|
|
timestamp: '2026-04-07T12:00:00.000Z',
|
|
endpoint: '/openai/v1/responses',
|
|
method: 'POST',
|
|
model: 'gpt-5.4',
|
|
requestBody: {
|
|
model: 'gpt-5.4',
|
|
reasoning: {
|
|
effort: 'high'
|
|
},
|
|
input: 'hello world'
|
|
}
|
|
})
|
|
|
|
const storedPayload = JSON.parse(multi.set.mock.calls[0][1])
|
|
expect(storedPayload.requestBodySnapshot).toBeUndefined()
|
|
expect(storedPayload.reasoningDisplay).toBe('high')
|
|
expect(storedPayload.reasoningSource).toBe('reasoning.effort')
|
|
})
|
|
|
|
test('getRequestBodyPreviewStats counts stored snapshots', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: false
|
|
})
|
|
|
|
redis.getClient.mockReturnValue({
|
|
scan: jest
|
|
.fn()
|
|
.mockResolvedValueOnce([
|
|
'0',
|
|
['request_detail:item:req_1', 'request_detail:item:req_2', 'request_detail:item:req_3']
|
|
]),
|
|
mget: jest.fn().mockResolvedValue([
|
|
JSON.stringify({ requestId: 'req_1', requestBodySnapshot: { model: 'gpt-5.4' } }),
|
|
JSON.stringify({ requestId: 'req_2', model: 'gpt-5.4' }),
|
|
JSON.stringify({
|
|
requestId: 'req_3',
|
|
requestBodySnapshot: {
|
|
preview: '{"model":"gpt-5.4"}'
|
|
}
|
|
})
|
|
])
|
|
})
|
|
|
|
const result = await requestDetailService.getRequestBodyPreviewStats()
|
|
|
|
expect(result.bodyPreviewEnabled).toBe(false)
|
|
expect(result.snapshotCount).toBe(2)
|
|
expect(result.hasSnapshots).toBe(true)
|
|
})
|
|
|
|
test('getRequestDetail returns null for records outside retention window', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
redis.getClient.mockReturnValue({
|
|
get: jest.fn().mockResolvedValue(
|
|
JSON.stringify({
|
|
requestId: 'req_old',
|
|
timestamp: '2026-04-07T10:00:00.000Z',
|
|
endpoint: '/v1/messages',
|
|
method: 'POST',
|
|
apiKeyId: 'key_1',
|
|
accountId: 'acct_1',
|
|
accountType: 'claude',
|
|
model: 'claude-sonnet-4-6',
|
|
inputTokens: 100,
|
|
outputTokens: 50,
|
|
cost: 0.5,
|
|
durationMs: 1200
|
|
})
|
|
)
|
|
})
|
|
|
|
const result = await requestDetailService.getRequestDetail('req_old')
|
|
|
|
expect(result.record).toBeNull()
|
|
expect(result.retentionHours).toBe(6)
|
|
expect(result.captureEnabled).toBe(true)
|
|
})
|
|
|
|
test('getRequestDetail returns record within retention window', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
claudeAccountService.getAccount.mockResolvedValue({ name: 'Claude Main' })
|
|
redis.getApiKey.mockResolvedValue({ name: 'Primary Key' })
|
|
|
|
redis.getClient.mockReturnValue({
|
|
get: jest.fn().mockResolvedValue(
|
|
JSON.stringify({
|
|
requestId: 'req_recent',
|
|
timestamp: '2026-04-07T14:00:00.000Z',
|
|
endpoint: '/v1/messages',
|
|
method: 'POST',
|
|
apiKeyId: 'key_1',
|
|
accountId: 'acct_1',
|
|
accountType: 'claude',
|
|
model: 'claude-sonnet-4-6',
|
|
inputTokens: 100,
|
|
outputTokens: 50,
|
|
cost: 0.5,
|
|
durationMs: 1200
|
|
})
|
|
)
|
|
})
|
|
|
|
const result = await requestDetailService.getRequestDetail('req_recent')
|
|
|
|
expect(result.record).not.toBeNull()
|
|
expect(result.record.requestId).toBe('req_recent')
|
|
expect(result.record.apiKeyName).toBe('Primary Key')
|
|
})
|
|
|
|
test('getRequestDetail recovers missing timestamp from retention-window day index', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
redis.getClient.mockReturnValue({
|
|
zscore: jest.fn().mockResolvedValue('1775570400000'),
|
|
get: jest.fn().mockResolvedValue(
|
|
JSON.stringify({
|
|
requestId: 'req_no_ts',
|
|
endpoint: '/v1/messages',
|
|
method: 'POST'
|
|
})
|
|
)
|
|
})
|
|
|
|
const result = await requestDetailService.getRequestDetail('req_no_ts')
|
|
|
|
expect(result.record).not.toBeNull()
|
|
expect(result.record.requestId).toBe('req_no_ts')
|
|
expect(result.record.timestamp).toBe('2026-04-07T14:00:00.000Z')
|
|
})
|
|
|
|
test('getRequestDetail recovers unparseable timestamp from retention-window day index', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
redis.getClient.mockReturnValue({
|
|
zscore: jest.fn().mockResolvedValue('1775570400000'),
|
|
get: jest.fn().mockResolvedValue(
|
|
JSON.stringify({
|
|
requestId: 'req_bad_ts',
|
|
timestamp: 'invalid-date',
|
|
endpoint: '/v1/messages',
|
|
method: 'POST'
|
|
})
|
|
)
|
|
})
|
|
|
|
const result = await requestDetailService.getRequestDetail('req_bad_ts')
|
|
|
|
expect(result.record).not.toBeNull()
|
|
expect(result.record.requestId).toBe('req_bad_ts')
|
|
expect(result.record.timestamp).toBe('2026-04-07T14:00:00.000Z')
|
|
})
|
|
|
|
test('getRequestDetail hides legacy records without a recoverable in-window timestamp', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
redis.getClient.mockReturnValue({
|
|
zscore: jest.fn().mockResolvedValue('1775556000000'),
|
|
get: jest.fn().mockResolvedValue(
|
|
JSON.stringify({
|
|
requestId: 'req_legacy_old',
|
|
endpoint: '/v1/messages',
|
|
method: 'POST'
|
|
})
|
|
)
|
|
})
|
|
|
|
const result = await requestDetailService.getRequestDetail('req_legacy_old')
|
|
|
|
expect(result.record).toBeNull()
|
|
})
|
|
|
|
test('resolves bedrock account name from { success, data } wrapper', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
redis.getApiKey.mockResolvedValue({ name: 'Bedrock Key' })
|
|
bedrockAccountService.getAccount.mockResolvedValue({
|
|
success: true,
|
|
data: { name: 'My Bedrock Account' }
|
|
})
|
|
|
|
redis.getClient.mockReturnValue({
|
|
zrangebyscore: jest.fn().mockResolvedValue(['req_bedrock', '1775563200000']),
|
|
mget: jest.fn().mockResolvedValue([
|
|
JSON.stringify({
|
|
requestId: 'req_bedrock',
|
|
timestamp: '2026-04-07T12:00:00.000Z',
|
|
endpoint: '/v1/messages',
|
|
method: 'POST',
|
|
apiKeyId: 'key_bedrock',
|
|
accountId: 'acct_bedrock',
|
|
accountType: 'bedrock',
|
|
model: 'anthropic.claude-sonnet-4-6',
|
|
inputTokens: 100,
|
|
outputTokens: 50,
|
|
cost: 0.5,
|
|
durationMs: 1200
|
|
})
|
|
])
|
|
})
|
|
|
|
const result = await requestDetailService.listRequestDetails({
|
|
startDate: '2026-04-07T00:00:00.000Z',
|
|
endDate: '2026-04-07T23:59:59.000Z'
|
|
})
|
|
|
|
expect(result.records).toHaveLength(1)
|
|
expect(result.records[0].accountName).toBe('My Bedrock Account')
|
|
expect(result.records[0].accountTypeName).toBe('AWS Bedrock')
|
|
})
|
|
|
|
test('handles bedrock { success: false } wrapper gracefully', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
redis.getApiKey.mockResolvedValue({ name: 'Some Key' })
|
|
claudeAccountService.getAccount.mockResolvedValue(null)
|
|
openaiAccountService.getAccount.mockResolvedValue(null)
|
|
bedrockAccountService.getAccount.mockResolvedValue({
|
|
success: false,
|
|
error: 'Account not found'
|
|
})
|
|
|
|
redis.getClient.mockReturnValue({
|
|
zrangebyscore: jest.fn().mockResolvedValue(['req_missing', '1775563200000']),
|
|
mget: jest.fn().mockResolvedValue([
|
|
JSON.stringify({
|
|
requestId: 'req_missing',
|
|
timestamp: '2026-04-07T12:00:00.000Z',
|
|
endpoint: '/v1/messages',
|
|
method: 'POST',
|
|
apiKeyId: 'key_1',
|
|
accountId: 'acct_gone',
|
|
accountType: 'bedrock',
|
|
model: 'anthropic.claude-sonnet-4-6',
|
|
inputTokens: 100,
|
|
outputTokens: 50,
|
|
cost: 0.5,
|
|
durationMs: 1200
|
|
})
|
|
])
|
|
})
|
|
|
|
const result = await requestDetailService.listRequestDetails({
|
|
startDate: '2026-04-07T00:00:00.000Z',
|
|
endDate: '2026-04-07T23:59:59.000Z'
|
|
})
|
|
|
|
expect(result.records).toHaveLength(1)
|
|
expect(result.records[0].accountName).toBe('acct_gone')
|
|
expect(result.records[0].accountTypeName).toBe('AWS Bedrock')
|
|
})
|
|
|
|
test('listRequestDetails without keyword uses deferred enrichment for page records only', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
redis.getApiKey.mockResolvedValue({ name: 'Primary Key' })
|
|
openaiAccountService.getAccount.mockResolvedValue({ name: 'OpenAI Main' })
|
|
|
|
const recordsByKey = {}
|
|
const pointerEntries = []
|
|
for (let i = 0; i < 5; i++) {
|
|
const ts = 1775563200000 + i * 3600000
|
|
pointerEntries.push(`req_${i}`, String(ts))
|
|
recordsByKey[`request_detail:item:req_${i}`] = JSON.stringify({
|
|
requestId: `req_${i}`,
|
|
timestamp: new Date(ts).toISOString(),
|
|
endpoint: '/openai/v1/responses',
|
|
method: 'POST',
|
|
apiKeyId: 'key_1',
|
|
accountId: 'acct_1',
|
|
accountType: 'openai',
|
|
model: 'gpt-5.4',
|
|
inputTokens: 100,
|
|
outputTokens: 50,
|
|
cacheReadTokens: 0,
|
|
cacheCreateTokens: 0,
|
|
totalTokens: 150,
|
|
cost: 0.5,
|
|
durationMs: 1200
|
|
})
|
|
}
|
|
|
|
redis.getClient.mockReturnValue({
|
|
zrangebyscore: jest.fn().mockResolvedValue(pointerEntries),
|
|
mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null))
|
|
})
|
|
|
|
const result = await requestDetailService.listRequestDetails({
|
|
startDate: '2026-04-07T00:00:00.000Z',
|
|
endDate: '2026-04-08T23:59:59.000Z',
|
|
pageSize: 2,
|
|
page: 1
|
|
})
|
|
|
|
expect(result.records).toHaveLength(2)
|
|
expect(result.pagination.totalRecords).toBe(5)
|
|
expect(result.records[0].apiKeyName).toBe('Primary Key')
|
|
expect(result.records[0].accountName).toBe('OpenAI Main')
|
|
expect(result.availableFilters.models).toEqual(['gpt-5.4'])
|
|
expect(result.summary.totalRequests).toBe(5)
|
|
})
|
|
|
|
test('listRequestDetails creates a snapshot and reuses it across pages', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
redis.getApiKey.mockResolvedValue({ name: 'Primary Key' })
|
|
openaiAccountService.getAccount.mockResolvedValue({ name: 'OpenAI Main' })
|
|
|
|
const recordsByKey = {}
|
|
const pointerEntries = []
|
|
for (let i = 0; i < 4; i++) {
|
|
const ts = 1775563200000 + i * 3600000
|
|
pointerEntries.push(`req_${i}`, String(ts))
|
|
recordsByKey[`request_detail:item:req_${i}`] = JSON.stringify({
|
|
requestId: `req_${i}`,
|
|
timestamp: new Date(ts).toISOString(),
|
|
endpoint: '/openai/v1/responses',
|
|
method: 'POST',
|
|
apiKeyId: 'key_1',
|
|
accountId: 'acct_1',
|
|
accountType: 'openai',
|
|
model: 'gpt-5.4',
|
|
inputTokens: 100,
|
|
outputTokens: 50,
|
|
totalTokens: 150,
|
|
cost: 0.5,
|
|
durationMs: 1200
|
|
})
|
|
}
|
|
|
|
const snapshots = new Map()
|
|
const client = {
|
|
zrangebyscore: jest.fn().mockResolvedValue(pointerEntries),
|
|
mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null)),
|
|
set: jest.fn(async (key, value) => {
|
|
snapshots.set(key, value)
|
|
return 'OK'
|
|
}),
|
|
get: jest.fn(async (key) => snapshots.get(key) || null),
|
|
expire: jest.fn().mockResolvedValue(1)
|
|
}
|
|
redis.getClient.mockReturnValue(client)
|
|
|
|
const firstPage = await requestDetailService.listRequestDetails({
|
|
startDate: '2026-04-07T00:00:00.000Z',
|
|
endDate: '2026-04-08T23:59:59.000Z',
|
|
pageSize: 2,
|
|
page: 1
|
|
})
|
|
|
|
expect(firstPage.snapshotId).toBeTruthy()
|
|
expect(firstPage.records.map((record) => record.requestId)).toEqual(['req_3', 'req_2'])
|
|
expect(client.set).toHaveBeenCalledWith(
|
|
expect.stringMatching(/^request_detail:query_snapshot:/),
|
|
expect.any(String),
|
|
'EX',
|
|
30
|
|
)
|
|
|
|
const secondPage = await requestDetailService.listRequestDetails({
|
|
startDate: '2026-04-07T00:00:00.000Z',
|
|
endDate: '2026-04-08T23:59:59.000Z',
|
|
pageSize: 2,
|
|
page: 2,
|
|
snapshotId: firstPage.snapshotId
|
|
})
|
|
|
|
expect(secondPage.snapshotId).toBe(firstPage.snapshotId)
|
|
expect(secondPage.records.map((record) => record.requestId)).toEqual(['req_1', 'req_0'])
|
|
expect(client.zrangebyscore).toHaveBeenCalledTimes(1)
|
|
expect(client.get).toHaveBeenCalledWith(`request_detail:query_snapshot:${firstPage.snapshotId}`)
|
|
expect(client.expire).toHaveBeenCalledWith(
|
|
`request_detail:query_snapshot:${firstPage.snapshotId}`,
|
|
30
|
|
)
|
|
})
|
|
|
|
test('listRequestDetails rebuilds the snapshot when filters change', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
redis.getApiKey.mockImplementation(async (keyId) => ({ name: `Key ${keyId}` }))
|
|
openaiAccountService.getAccount.mockResolvedValue({ name: 'OpenAI Main' })
|
|
|
|
const recordsByKey = {
|
|
'request_detail:item:req_1': JSON.stringify({
|
|
requestId: 'req_1',
|
|
timestamp: '2026-04-07T12:00:00.000Z',
|
|
endpoint: '/openai/v1/responses',
|
|
method: 'POST',
|
|
apiKeyId: 'key_1',
|
|
accountId: 'acct_1',
|
|
accountType: 'openai',
|
|
model: 'gpt-5.4',
|
|
inputTokens: 10,
|
|
outputTokens: 5,
|
|
totalTokens: 15,
|
|
cost: 0.1,
|
|
durationMs: 400
|
|
}),
|
|
'request_detail:item:req_2': JSON.stringify({
|
|
requestId: 'req_2',
|
|
timestamp: '2026-04-07T13:00:00.000Z',
|
|
endpoint: '/openai/v1/responses',
|
|
method: 'POST',
|
|
apiKeyId: 'key_2',
|
|
accountId: 'acct_1',
|
|
accountType: 'openai',
|
|
model: 'gpt-5.4',
|
|
inputTokens: 20,
|
|
outputTokens: 5,
|
|
totalTokens: 25,
|
|
cost: 0.2,
|
|
durationMs: 500
|
|
})
|
|
}
|
|
|
|
const snapshots = new Map()
|
|
const client = {
|
|
zrangebyscore: jest
|
|
.fn()
|
|
.mockResolvedValue(['req_1', '1775563200000', 'req_2', '1775566800000']),
|
|
mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null)),
|
|
set: jest.fn(async (key, value) => {
|
|
snapshots.set(key, value)
|
|
return 'OK'
|
|
}),
|
|
get: jest.fn(async (key) => snapshots.get(key) || null),
|
|
expire: jest.fn().mockResolvedValue(1)
|
|
}
|
|
redis.getClient.mockReturnValue(client)
|
|
|
|
const firstQuery = await requestDetailService.listRequestDetails({
|
|
startDate: '2026-04-07T00:00:00.000Z',
|
|
endDate: '2026-04-07T23:59:59.000Z',
|
|
apiKeyId: 'key_1'
|
|
})
|
|
|
|
const secondQuery = await requestDetailService.listRequestDetails({
|
|
startDate: '2026-04-07T00:00:00.000Z',
|
|
endDate: '2026-04-07T23:59:59.000Z',
|
|
apiKeyId: 'key_2',
|
|
snapshotId: firstQuery.snapshotId
|
|
})
|
|
|
|
expect(client.zrangebyscore).toHaveBeenCalledTimes(2)
|
|
expect(secondQuery.records).toHaveLength(1)
|
|
expect(secondQuery.records[0].apiKeyId).toBe('key_2')
|
|
expect(secondQuery.snapshotId).toBeTruthy()
|
|
expect(secondQuery.snapshotId).not.toBe(firstQuery.snapshotId)
|
|
})
|
|
|
|
test('listRequestDetails rejects stale snapshot when explicit date range changes', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
redis.getApiKey.mockResolvedValue({ name: 'Key' })
|
|
openaiAccountService.getAccount.mockResolvedValue({ name: 'Acct' })
|
|
|
|
const recordsByKey = {}
|
|
const pointerEntries = []
|
|
for (let i = 0; i < 2; i++) {
|
|
const ts = Date.now() - (1 - i) * 3600000
|
|
pointerEntries.push(`req_${i}`, String(ts))
|
|
recordsByKey[`request_detail:item:req_${i}`] = JSON.stringify({
|
|
requestId: `req_${i}`,
|
|
timestamp: new Date(ts).toISOString(),
|
|
endpoint: '/v1/messages',
|
|
method: 'POST',
|
|
apiKeyId: 'key_1',
|
|
accountId: 'acct_1',
|
|
accountType: 'claude',
|
|
model: 'claude-sonnet-4-20250514',
|
|
inputTokens: 10,
|
|
outputTokens: 5,
|
|
totalTokens: 15,
|
|
cost: 0.1,
|
|
durationMs: 300
|
|
})
|
|
}
|
|
|
|
const snapshots = new Map()
|
|
const client = {
|
|
zrangebyscore: jest.fn().mockResolvedValue(pointerEntries),
|
|
mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null)),
|
|
set: jest.fn(async (key, value) => {
|
|
snapshots.set(key, value)
|
|
return 'OK'
|
|
}),
|
|
get: jest.fn(async (key) => snapshots.get(key) || null),
|
|
expire: jest.fn().mockResolvedValue(1)
|
|
}
|
|
redis.getClient.mockReturnValue(client)
|
|
|
|
const now = Date.now()
|
|
const rangeA = {
|
|
startDate: new Date(now - 5 * 3600000).toISOString(),
|
|
endDate: new Date(now - 3 * 3600000).toISOString()
|
|
}
|
|
const rangeB = {
|
|
startDate: new Date(now - 2 * 3600000).toISOString(),
|
|
endDate: new Date(now - 1 * 3600000).toISOString()
|
|
}
|
|
|
|
const firstQuery = await requestDetailService.listRequestDetails(rangeA)
|
|
|
|
expect(firstQuery.snapshotId).toBeTruthy()
|
|
|
|
// Same non-date filters, different date range, stale snapshotId —
|
|
// must NOT reuse the old snapshot.
|
|
const secondQuery = await requestDetailService.listRequestDetails({
|
|
...rangeB,
|
|
snapshotId: firstQuery.snapshotId
|
|
})
|
|
|
|
expect(client.zrangebyscore).toHaveBeenCalledTimes(2)
|
|
expect(secondQuery.snapshotId).toBeTruthy()
|
|
expect(secondQuery.snapshotId).not.toBe(firstQuery.snapshotId)
|
|
})
|
|
|
|
test('listRequestDetails reuses snapshot for startDate-only queries after time advances', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
redis.getApiKey.mockResolvedValue({ name: 'Key' })
|
|
openaiAccountService.getAccount.mockResolvedValue({ name: 'Acct' })
|
|
|
|
const recordsByKey = {}
|
|
const pointerEntries = []
|
|
for (let i = 0; i < 2; i++) {
|
|
const ts = Date.now() - (1 - i) * 3600000
|
|
pointerEntries.push(`req_${i}`, String(ts))
|
|
recordsByKey[`request_detail:item:req_${i}`] = JSON.stringify({
|
|
requestId: `req_${i}`,
|
|
timestamp: new Date(ts).toISOString(),
|
|
endpoint: '/v1/messages',
|
|
method: 'POST',
|
|
apiKeyId: 'key_1',
|
|
accountId: 'acct_1',
|
|
accountType: 'claude',
|
|
model: 'claude-sonnet-4-20250514',
|
|
inputTokens: 10,
|
|
outputTokens: 5,
|
|
totalTokens: 15,
|
|
cost: 0.1,
|
|
durationMs: 300
|
|
})
|
|
}
|
|
|
|
const snapshots = new Map()
|
|
const client = {
|
|
zrangebyscore: jest.fn().mockResolvedValue(pointerEntries),
|
|
mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null)),
|
|
set: jest.fn(async (key, value) => {
|
|
snapshots.set(key, value)
|
|
return 'OK'
|
|
}),
|
|
get: jest.fn(async (key) => snapshots.get(key) || null),
|
|
expire: jest.fn().mockResolvedValue(1)
|
|
}
|
|
redis.getClient.mockReturnValue(client)
|
|
|
|
// Only startDate, no endDate — the start is clipped to retentionStart
|
|
// and would drift without moving-boundary-aware snapshot matching.
|
|
const startOnly = {
|
|
startDate: '2020-01-01T00:00:00.000Z',
|
|
pageSize: 1
|
|
}
|
|
|
|
const firstQuery = await requestDetailService.listRequestDetails(startOnly)
|
|
expect(firstQuery.snapshotId).toBeTruthy()
|
|
|
|
jest.advanceTimersByTime(10000)
|
|
|
|
// Page 2 with the same startDate-only filter after time advances —
|
|
// the snapshot should still be reused.
|
|
const secondQuery = await requestDetailService.listRequestDetails({
|
|
...startOnly,
|
|
snapshotId: firstQuery.snapshotId,
|
|
page: 2
|
|
})
|
|
|
|
expect(client.zrangebyscore).toHaveBeenCalledTimes(1)
|
|
expect(secondQuery.snapshotId).toBe(firstQuery.snapshotId)
|
|
})
|
|
|
|
test('listRequestDetails reuses snapshot for endDate-only future queries after time advances', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
redis.getApiKey.mockResolvedValue({ name: 'Key' })
|
|
openaiAccountService.getAccount.mockResolvedValue({ name: 'Acct' })
|
|
|
|
const recordsByKey = {}
|
|
const pointerEntries = []
|
|
for (let i = 0; i < 2; i++) {
|
|
const ts = Date.now() - (1 - i) * 3600000
|
|
pointerEntries.push(`req_${i}`, String(ts))
|
|
recordsByKey[`request_detail:item:req_${i}`] = JSON.stringify({
|
|
requestId: `req_${i}`,
|
|
timestamp: new Date(ts).toISOString(),
|
|
endpoint: '/v1/messages',
|
|
method: 'POST',
|
|
apiKeyId: 'key_1',
|
|
accountId: 'acct_1',
|
|
accountType: 'claude',
|
|
model: 'claude-sonnet-4-20250514',
|
|
inputTokens: 10,
|
|
outputTokens: 5,
|
|
totalTokens: 15,
|
|
cost: 0.1,
|
|
durationMs: 300
|
|
})
|
|
}
|
|
|
|
const snapshots = new Map()
|
|
const client = {
|
|
zrangebyscore: jest.fn().mockResolvedValue(pointerEntries),
|
|
mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null)),
|
|
set: jest.fn(async (key, value) => {
|
|
snapshots.set(key, value)
|
|
return 'OK'
|
|
}),
|
|
get: jest.fn(async (key) => snapshots.get(key) || null),
|
|
expire: jest.fn().mockResolvedValue(1)
|
|
}
|
|
redis.getClient.mockReturnValue(client)
|
|
|
|
const endOnly = {
|
|
endDate: new Date(Date.now() + 3600000).toISOString(),
|
|
pageSize: 1
|
|
}
|
|
|
|
const firstQuery = await requestDetailService.listRequestDetails(endOnly)
|
|
expect(firstQuery.snapshotId).toBeTruthy()
|
|
|
|
jest.advanceTimersByTime(10000)
|
|
|
|
const secondQuery = await requestDetailService.listRequestDetails({
|
|
...endOnly,
|
|
snapshotId: firstQuery.snapshotId,
|
|
page: 2
|
|
})
|
|
|
|
expect(client.zrangebyscore).toHaveBeenCalledTimes(1)
|
|
expect(secondQuery.snapshotId).toBe(firstQuery.snapshotId)
|
|
})
|
|
|
|
test('listRequestDetails invalidates snapshot when retentionHours changes', async () => {
|
|
const makeConfig = (hours) => ({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: hours,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
claudeRelayConfigService.getConfig.mockResolvedValue(makeConfig(6))
|
|
|
|
redis.getApiKey.mockResolvedValue({ name: 'Key' })
|
|
openaiAccountService.getAccount.mockResolvedValue({ name: 'Acct' })
|
|
|
|
const recordsByKey = {}
|
|
const pointerEntries = []
|
|
for (let i = 0; i < 4; i++) {
|
|
const ts = Date.now() - (3 - i) * 3600000
|
|
pointerEntries.push(`req_${i}`, String(ts))
|
|
recordsByKey[`request_detail:item:req_${i}`] = JSON.stringify({
|
|
requestId: `req_${i}`,
|
|
timestamp: new Date(ts).toISOString(),
|
|
endpoint: '/v1/messages',
|
|
method: 'POST',
|
|
apiKeyId: 'key_1',
|
|
accountId: 'acct_1',
|
|
accountType: 'claude',
|
|
model: 'claude-sonnet-4-20250514',
|
|
inputTokens: 10,
|
|
outputTokens: 5,
|
|
totalTokens: 15,
|
|
cost: 0.1,
|
|
durationMs: 300
|
|
})
|
|
}
|
|
|
|
const snapshots = new Map()
|
|
const client = {
|
|
zrangebyscore: jest.fn().mockResolvedValue(pointerEntries),
|
|
mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null)),
|
|
set: jest.fn(async (key, value) => {
|
|
snapshots.set(key, value)
|
|
return 'OK'
|
|
}),
|
|
get: jest.fn(async (key) => snapshots.get(key) || null),
|
|
expire: jest.fn().mockResolvedValue(1)
|
|
}
|
|
redis.getClient.mockReturnValue(client)
|
|
|
|
// Page 1 with retentionHours=6
|
|
const firstQuery = await requestDetailService.listRequestDetails({
|
|
pageSize: 2,
|
|
page: 1
|
|
})
|
|
expect(firstQuery.snapshotId).toBeTruthy()
|
|
|
|
// Admin changes retention from 6 to 2 while snapshot is alive
|
|
claudeRelayConfigService.getConfig.mockResolvedValue(makeConfig(2))
|
|
|
|
// Page 2 with stale snapshotId — must NOT reuse old snapshot
|
|
const secondQuery = await requestDetailService.listRequestDetails({
|
|
pageSize: 2,
|
|
page: 2,
|
|
snapshotId: firstQuery.snapshotId
|
|
})
|
|
|
|
expect(client.zrangebyscore).toHaveBeenCalledTimes(2)
|
|
expect(secondQuery.snapshotId).not.toBe(firstQuery.snapshotId)
|
|
})
|
|
|
|
test('listRequestDetails normalizes whitespace in structured filter values', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
redis.getApiKey.mockResolvedValue({ name: 'Key' })
|
|
openaiAccountService.getAccount.mockResolvedValue({ name: 'Acct' })
|
|
|
|
const ts = Date.now() - 3600000
|
|
const pointerEntries = ['req_0', String(ts)]
|
|
const recordsByKey = {
|
|
'request_detail:item:req_0': JSON.stringify({
|
|
requestId: 'req_0',
|
|
timestamp: new Date(ts).toISOString(),
|
|
endpoint: '/v1/messages',
|
|
method: 'POST',
|
|
apiKeyId: 'key_1',
|
|
accountId: 'acct_1',
|
|
accountType: 'claude',
|
|
model: 'claude-sonnet-4-20250514',
|
|
inputTokens: 10,
|
|
outputTokens: 5,
|
|
totalTokens: 15,
|
|
cost: 0.1,
|
|
durationMs: 300
|
|
})
|
|
}
|
|
|
|
const client = {
|
|
zrangebyscore: jest.fn().mockResolvedValue(pointerEntries),
|
|
mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null)),
|
|
set: jest.fn().mockResolvedValue('OK'),
|
|
get: jest.fn().mockResolvedValue(null),
|
|
expire: jest.fn().mockResolvedValue(1)
|
|
}
|
|
redis.getClient.mockReturnValue(client)
|
|
|
|
// Filter with leading/trailing whitespace must still match records
|
|
const result = await requestDetailService.listRequestDetails({
|
|
apiKeyId: ' key_1 '
|
|
})
|
|
|
|
expect(result.pagination.totalRecords).toBe(1)
|
|
expect(result.records).toHaveLength(1)
|
|
expect(result.filters.apiKeyId).toBe('key_1')
|
|
})
|
|
|
|
test('listRequestDetails skips snapshot creation when result count exceeds the limit', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
const client = {
|
|
set: jest.fn()
|
|
}
|
|
redis.getClient.mockReturnValue(client)
|
|
|
|
const buildQuerySpy = jest
|
|
.spyOn(requestDetailService, '_buildListQueryData')
|
|
.mockResolvedValue({
|
|
hasSourceRecords: true,
|
|
matchedPointers: Array.from({ length: 25001 }, (_, index) => ({
|
|
requestId: `req_${index}`,
|
|
timestampMs: 1775563200000 + index
|
|
})),
|
|
availableFilters: {
|
|
apiKeys: [],
|
|
accounts: [],
|
|
models: [],
|
|
endpoints: [],
|
|
dateRange: {
|
|
earliest: null,
|
|
latest: null
|
|
}
|
|
},
|
|
summary: {
|
|
totalRequests: 25001,
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheReadTokens: 0,
|
|
cacheCreateTokens: 0,
|
|
totalCost: 0,
|
|
avgDurationMs: 0,
|
|
cacheHitRate: 0,
|
|
cacheCreateNotApplicable: false
|
|
}
|
|
})
|
|
const buildPageSpy = jest.spyOn(requestDetailService, '_buildPageRecords').mockResolvedValue([])
|
|
|
|
try {
|
|
const result = await requestDetailService.listRequestDetails({
|
|
startDate: '2026-04-07T00:00:00.000Z',
|
|
endDate: '2026-04-07T23:59:59.000Z'
|
|
})
|
|
|
|
expect(result.snapshotId).toBeNull()
|
|
expect(client.set).not.toHaveBeenCalled()
|
|
} finally {
|
|
buildQuerySpy.mockRestore()
|
|
buildPageSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
test('listRequestDetails skips snapshot creation when payload size exceeds the limit', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
const client = {
|
|
set: jest.fn()
|
|
}
|
|
redis.getClient.mockReturnValue(client)
|
|
|
|
const buildQuerySpy = jest
|
|
.spyOn(requestDetailService, '_buildListQueryData')
|
|
.mockResolvedValue({
|
|
hasSourceRecords: true,
|
|
matchedPointers: [{ requestId: 'req_1', timestampMs: 1775563200000 }],
|
|
availableFilters: {
|
|
apiKeys: [],
|
|
accounts: [],
|
|
models: [`model_${'x'.repeat(2 * 1024 * 1024)}`],
|
|
endpoints: [],
|
|
dateRange: {
|
|
earliest: null,
|
|
latest: null
|
|
}
|
|
},
|
|
summary: {
|
|
totalRequests: 1,
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheReadTokens: 0,
|
|
cacheCreateTokens: 0,
|
|
totalCost: 0,
|
|
avgDurationMs: 0,
|
|
cacheHitRate: 0,
|
|
cacheCreateNotApplicable: false
|
|
}
|
|
})
|
|
const buildPageSpy = jest.spyOn(requestDetailService, '_buildPageRecords').mockResolvedValue([])
|
|
|
|
try {
|
|
const result = await requestDetailService.listRequestDetails({
|
|
startDate: '2026-04-07T00:00:00.000Z',
|
|
endDate: '2026-04-07T23:59:59.000Z'
|
|
})
|
|
|
|
expect(result.snapshotId).toBeNull()
|
|
expect(client.set).not.toHaveBeenCalled()
|
|
} finally {
|
|
buildQuerySpy.mockRestore()
|
|
buildPageSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
test('listRequestDetails degrades gracefully when snapshot write fails', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
redis.getApiKey.mockResolvedValue({ name: 'Primary Key' })
|
|
openaiAccountService.getAccount.mockResolvedValue({ name: 'OpenAI Main' })
|
|
|
|
const ts = 1775563200000
|
|
const client = {
|
|
zrangebyscore: jest.fn().mockResolvedValue(['req_1', String(ts)]),
|
|
mget: jest.fn().mockResolvedValue([
|
|
JSON.stringify({
|
|
requestId: 'req_1',
|
|
timestamp: new Date(ts).toISOString(),
|
|
endpoint: '/openai/v1/responses',
|
|
method: 'POST',
|
|
apiKeyId: 'key_1',
|
|
accountId: 'acct_1',
|
|
accountType: 'openai',
|
|
model: 'gpt-5.4',
|
|
inputTokens: 10,
|
|
outputTokens: 5,
|
|
totalTokens: 15,
|
|
cost: 0.1,
|
|
durationMs: 400
|
|
})
|
|
]),
|
|
set: jest.fn().mockRejectedValue(new Error('READONLY'))
|
|
}
|
|
redis.getClient.mockReturnValue(client)
|
|
|
|
const result = await requestDetailService.listRequestDetails({
|
|
startDate: '2026-04-07T00:00:00.000Z',
|
|
endDate: '2026-04-07T23:59:59.000Z'
|
|
})
|
|
|
|
expect(result.snapshotId).toBeNull()
|
|
expect(result.records).toHaveLength(1)
|
|
expect(result.records[0].requestId).toBe('req_1')
|
|
})
|
|
|
|
test('listRequestDetails falls back to full query when snapshot read fails', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
redis.getApiKey.mockResolvedValue({ name: 'Primary Key' })
|
|
openaiAccountService.getAccount.mockResolvedValue({ name: 'OpenAI Main' })
|
|
|
|
const recordsByKey = {}
|
|
const pointerEntries = []
|
|
for (let i = 0; i < 3; i++) {
|
|
const ts = 1775563200000 + i * 3600000
|
|
pointerEntries.push(`req_${i}`, String(ts))
|
|
recordsByKey[`request_detail:item:req_${i}`] = JSON.stringify({
|
|
requestId: `req_${i}`,
|
|
timestamp: new Date(ts).toISOString(),
|
|
endpoint: '/openai/v1/responses',
|
|
method: 'POST',
|
|
apiKeyId: 'key_1',
|
|
accountId: 'acct_1',
|
|
accountType: 'openai',
|
|
model: 'gpt-5.4',
|
|
inputTokens: 10,
|
|
outputTokens: 5,
|
|
totalTokens: 15,
|
|
cost: 0.1,
|
|
durationMs: 400
|
|
})
|
|
}
|
|
|
|
const client = {
|
|
zrangebyscore: jest.fn().mockResolvedValue(pointerEntries),
|
|
mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null)),
|
|
get: jest.fn().mockRejectedValue(new Error('ETIMEDOUT')),
|
|
set: jest.fn().mockResolvedValue('OK')
|
|
}
|
|
redis.getClient.mockReturnValue(client)
|
|
|
|
const result = await requestDetailService.listRequestDetails({
|
|
startDate: '2026-04-07T00:00:00.000Z',
|
|
endDate: '2026-04-08T23:59:59.000Z',
|
|
snapshotId: 'rds_stale_id'
|
|
})
|
|
|
|
expect(result.records).toHaveLength(3)
|
|
expect(client.get).toHaveBeenCalledWith('request_detail:query_snapshot:rds_stale_id')
|
|
// Falls back to full query, so zrangebyscore must have been called
|
|
expect(client.zrangebyscore).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
test('listRequestDetails still returns snapshot data when TTL renewal fails', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
redis.getApiKey.mockResolvedValue({ name: 'Primary Key' })
|
|
openaiAccountService.getAccount.mockResolvedValue({ name: 'OpenAI Main' })
|
|
|
|
const recordsByKey = {}
|
|
const pointerEntries = []
|
|
for (let i = 0; i < 4; i++) {
|
|
const ts = 1775563200000 + i * 3600000
|
|
pointerEntries.push(`req_${i}`, String(ts))
|
|
recordsByKey[`request_detail:item:req_${i}`] = JSON.stringify({
|
|
requestId: `req_${i}`,
|
|
timestamp: new Date(ts).toISOString(),
|
|
endpoint: '/openai/v1/responses',
|
|
method: 'POST',
|
|
apiKeyId: 'key_1',
|
|
accountId: 'acct_1',
|
|
accountType: 'openai',
|
|
model: 'gpt-5.4',
|
|
inputTokens: 100,
|
|
outputTokens: 50,
|
|
totalTokens: 150,
|
|
cost: 0.5,
|
|
durationMs: 1200
|
|
})
|
|
}
|
|
|
|
const snapshots = new Map()
|
|
const client = {
|
|
zrangebyscore: jest.fn().mockResolvedValue(pointerEntries),
|
|
mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null)),
|
|
set: jest.fn(async (key, value) => {
|
|
snapshots.set(key, value)
|
|
return 'OK'
|
|
}),
|
|
get: jest.fn(async (key) => snapshots.get(key) || null),
|
|
expire: jest.fn().mockRejectedValue(new Error('NOPERM'))
|
|
}
|
|
redis.getClient.mockReturnValue(client)
|
|
|
|
const firstPage = await requestDetailService.listRequestDetails({
|
|
startDate: '2026-04-07T00:00:00.000Z',
|
|
endDate: '2026-04-08T23:59:59.000Z',
|
|
pageSize: 2,
|
|
page: 1
|
|
})
|
|
|
|
expect(firstPage.snapshotId).toBeTruthy()
|
|
|
|
const secondPage = await requestDetailService.listRequestDetails({
|
|
startDate: '2026-04-07T00:00:00.000Z',
|
|
endDate: '2026-04-08T23:59:59.000Z',
|
|
pageSize: 2,
|
|
page: 2,
|
|
snapshotId: firstPage.snapshotId
|
|
})
|
|
|
|
expect(secondPage.snapshotId).toBe(firstPage.snapshotId)
|
|
expect(secondPage.records.map((record) => record.requestId)).toEqual(['req_1', 'req_0'])
|
|
// Snapshot was reused despite expire failure — no full re-query
|
|
expect(client.zrangebyscore).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
test('listRequestDetails reuses snapshot after time-window trimming', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
redis.getApiKey.mockResolvedValue({ name: 'Primary Key' })
|
|
openaiAccountService.getAccount.mockResolvedValue({ name: 'OpenAI Main' })
|
|
|
|
const recordsByKey = {}
|
|
const pointerEntries = []
|
|
for (let i = 0; i < 4; i++) {
|
|
const ts = 1775563200000 + i * 3600000
|
|
pointerEntries.push(`req_${i}`, String(ts))
|
|
recordsByKey[`request_detail:item:req_${i}`] = JSON.stringify({
|
|
requestId: `req_${i}`,
|
|
timestamp: new Date(ts).toISOString(),
|
|
endpoint: '/openai/v1/responses',
|
|
method: 'POST',
|
|
apiKeyId: 'key_1',
|
|
accountId: 'acct_1',
|
|
accountType: 'openai',
|
|
model: 'gpt-5.4',
|
|
inputTokens: 100,
|
|
outputTokens: 50,
|
|
totalTokens: 150,
|
|
cost: 0.5,
|
|
durationMs: 1200
|
|
})
|
|
}
|
|
|
|
const snapshots = new Map()
|
|
const client = {
|
|
zrangebyscore: jest.fn().mockResolvedValue(pointerEntries),
|
|
mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null)),
|
|
set: jest.fn(async (key, value) => {
|
|
snapshots.set(key, value)
|
|
return 'OK'
|
|
}),
|
|
get: jest.fn(async (key) => snapshots.get(key) || null),
|
|
expire: jest.fn().mockResolvedValue(1)
|
|
}
|
|
redis.getClient.mockReturnValue(client)
|
|
|
|
// First request with startDate far before retention window — will be trimmed
|
|
const firstPage = await requestDetailService.listRequestDetails({
|
|
startDate: '2020-01-01T00:00:00.000Z',
|
|
endDate: '2026-04-08T23:59:59.000Z',
|
|
pageSize: 2,
|
|
page: 1
|
|
})
|
|
|
|
expect(firstPage.snapshotId).toBeTruthy()
|
|
// The response echoes back the trimmed start date
|
|
const trimmedStart = firstPage.filters.startDate
|
|
|
|
jest.advanceTimersByTime(10000)
|
|
|
|
// Second request uses trimmed dates (as the frontend would after syncResponseState)
|
|
const secondPage = await requestDetailService.listRequestDetails({
|
|
startDate: trimmedStart,
|
|
endDate: firstPage.filters.endDate,
|
|
pageSize: 2,
|
|
page: 2,
|
|
snapshotId: firstPage.snapshotId
|
|
})
|
|
|
|
expect(secondPage.snapshotId).toBe(firstPage.snapshotId)
|
|
// Full query should run only once
|
|
expect(client.zrangebyscore).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
test('listRequestDetails reuses snapshot for default time window without explicit dates', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
redis.getApiKey.mockResolvedValue({ name: 'Primary Key' })
|
|
openaiAccountService.getAccount.mockResolvedValue({ name: 'OpenAI Main' })
|
|
|
|
const recordsByKey = {}
|
|
const pointerEntries = []
|
|
for (let i = 0; i < 4; i++) {
|
|
const ts = Date.now() - (3 - i) * 3600000
|
|
pointerEntries.push(`req_${i}`, String(ts))
|
|
recordsByKey[`request_detail:item:req_${i}`] = JSON.stringify({
|
|
requestId: `req_${i}`,
|
|
timestamp: new Date(ts).toISOString(),
|
|
endpoint: '/openai/v1/responses',
|
|
method: 'POST',
|
|
apiKeyId: 'key_1',
|
|
accountId: 'acct_1',
|
|
accountType: 'openai',
|
|
model: 'gpt-5.4',
|
|
inputTokens: 100,
|
|
outputTokens: 50,
|
|
totalTokens: 150,
|
|
cost: 0.5,
|
|
durationMs: 1200
|
|
})
|
|
}
|
|
|
|
const snapshots = new Map()
|
|
const client = {
|
|
zrangebyscore: jest.fn().mockResolvedValue(pointerEntries),
|
|
mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null)),
|
|
set: jest.fn(async (key, value) => {
|
|
snapshots.set(key, value)
|
|
return 'OK'
|
|
}),
|
|
get: jest.fn(async (key) => snapshots.get(key) || null),
|
|
expire: jest.fn().mockResolvedValue(1)
|
|
}
|
|
redis.getClient.mockReturnValue(client)
|
|
|
|
// First page — no startDate / endDate (default rolling window)
|
|
const firstPage = await requestDetailService.listRequestDetails({
|
|
pageSize: 2,
|
|
page: 1
|
|
})
|
|
|
|
expect(firstPage.snapshotId).toBeTruthy()
|
|
|
|
jest.advanceTimersByTime(10000)
|
|
|
|
// Second page — still no dates, only snapshotId; the server's new Date()
|
|
// will produce different effective timestamps, but the snapshot should
|
|
// still be reused because dates are excluded from the filter signature.
|
|
const secondPage = await requestDetailService.listRequestDetails({
|
|
pageSize: 2,
|
|
page: 2,
|
|
snapshotId: firstPage.snapshotId
|
|
})
|
|
|
|
expect(secondPage.snapshotId).toBe(firstPage.snapshotId)
|
|
expect(client.zrangebyscore).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
test('listRequestDetails clamps out-of-range pages without rerunning the full query', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
redis.getApiKey.mockResolvedValue({ name: 'Primary Key' })
|
|
openaiAccountService.getAccount.mockResolvedValue({ name: 'OpenAI Main' })
|
|
|
|
const recordsByKey = {}
|
|
const pointerEntries = []
|
|
for (let i = 0; i < 3; i++) {
|
|
const ts = 1775563200000 + i * 3600000
|
|
pointerEntries.push(`req_${i}`, String(ts))
|
|
recordsByKey[`request_detail:item:req_${i}`] = JSON.stringify({
|
|
requestId: `req_${i}`,
|
|
timestamp: new Date(ts).toISOString(),
|
|
endpoint: '/openai/v1/responses',
|
|
method: 'POST',
|
|
apiKeyId: 'key_1',
|
|
accountId: 'acct_1',
|
|
accountType: 'openai',
|
|
model: 'gpt-5.4',
|
|
inputTokens: 10,
|
|
outputTokens: 5,
|
|
totalTokens: 15,
|
|
cost: 0.1,
|
|
durationMs: 400
|
|
})
|
|
}
|
|
|
|
const client = {
|
|
zrangebyscore: jest.fn().mockResolvedValue(pointerEntries),
|
|
mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null)),
|
|
set: jest.fn().mockResolvedValue('OK')
|
|
}
|
|
redis.getClient.mockReturnValue(client)
|
|
|
|
const result = await requestDetailService.listRequestDetails({
|
|
startDate: '2026-04-07T00:00:00.000Z',
|
|
endDate: '2026-04-08T23:59:59.000Z',
|
|
pageSize: 2,
|
|
page: 9
|
|
})
|
|
|
|
expect(result.pagination.currentPage).toBe(2)
|
|
expect(result.pagination.totalPages).toBe(2)
|
|
expect(result.records.map((record) => record.requestId)).toEqual(['req_0'])
|
|
expect(client.zrangebyscore).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
test('listRequestDetails backfills invalid timestamps from day-index scores', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: true
|
|
})
|
|
|
|
redis.getClient.mockReturnValue({
|
|
zrangebyscore: jest.fn().mockResolvedValue(['req_invalid_ts', '1775563200000']),
|
|
mget: jest.fn().mockResolvedValue([
|
|
JSON.stringify({
|
|
requestId: 'req_invalid_ts',
|
|
timestamp: 'invalid-date',
|
|
endpoint: '/v1/messages',
|
|
method: 'POST',
|
|
model: 'claude-sonnet-4-6'
|
|
})
|
|
])
|
|
})
|
|
|
|
const result = await requestDetailService.listRequestDetails({
|
|
startDate: '2026-04-07T00:00:00.000Z',
|
|
endDate: '2026-04-07T23:59:59.000Z'
|
|
})
|
|
|
|
expect(result.records).toHaveLength(1)
|
|
expect(result.records[0].timestamp).toBe('2026-04-07T12:00:00.000Z')
|
|
expect(result.availableFilters.dateRange.earliest).toBe('2026-04-07T12:00:00.000Z')
|
|
})
|
|
|
|
test('purgeRequestBodySnapshots removes snapshots while keeping records', async () => {
|
|
claudeRelayConfigService.getConfig.mockResolvedValue({
|
|
requestDetailCaptureEnabled: true,
|
|
requestDetailRetentionHours: 6,
|
|
requestDetailBodyPreviewEnabled: false
|
|
})
|
|
|
|
const exec = jest.fn().mockResolvedValue([])
|
|
const pipeline = {
|
|
set: jest.fn().mockReturnThis(),
|
|
exec
|
|
}
|
|
const client = {
|
|
scan: jest
|
|
.fn()
|
|
.mockResolvedValueOnce(['0', ['request_detail:item:req_1', 'request_detail:item:req_2']]),
|
|
mget: jest.fn().mockResolvedValue([
|
|
JSON.stringify({
|
|
requestId: 'req_1',
|
|
model: 'gpt-5.4',
|
|
requestBodySnapshot: { model: 'gpt-5.4' }
|
|
}),
|
|
JSON.stringify({
|
|
requestId: 'req_2',
|
|
model: 'claude-sonnet-4-6'
|
|
})
|
|
]),
|
|
pipeline: jest.fn(() => pipeline)
|
|
}
|
|
redis.getClient.mockReturnValue(client)
|
|
|
|
const result = await requestDetailService.purgeRequestBodySnapshots()
|
|
|
|
expect(result.updatedRecords).toBe(1)
|
|
expect(pipeline.set).toHaveBeenCalledWith(
|
|
'request_detail:item:req_1',
|
|
JSON.stringify({
|
|
requestId: 'req_1',
|
|
model: 'gpt-5.4'
|
|
}),
|
|
'KEEPTTL'
|
|
)
|
|
expect(exec).toHaveBeenCalled()
|
|
})
|
|
})
|