Files
wei-shaw--claude-relay-service/tests/requestDetailService.test.js
T
2026-07-13 12:48:39 +08:00

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()
})
})