mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
219 lines
7.8 KiB
JavaScript
219 lines
7.8 KiB
JavaScript
// Mock logger,避免测试输出污染控制台
|
||
jest.mock('../src/utils/logger', () => ({
|
||
debug: jest.fn(),
|
||
info: jest.fn(),
|
||
warn: jest.fn(),
|
||
error: jest.fn()
|
||
}))
|
||
|
||
const accountBalanceServiceModule = require('../src/services/accountBalanceService')
|
||
|
||
const { AccountBalanceService } = accountBalanceServiceModule
|
||
|
||
describe('AccountBalanceService', () => {
|
||
const originalBalanceScriptEnabled = process.env.BALANCE_SCRIPT_ENABLED
|
||
|
||
afterEach(() => {
|
||
if (originalBalanceScriptEnabled === undefined) {
|
||
delete process.env.BALANCE_SCRIPT_ENABLED
|
||
} else {
|
||
process.env.BALANCE_SCRIPT_ENABLED = originalBalanceScriptEnabled
|
||
}
|
||
})
|
||
|
||
const mockLogger = {
|
||
debug: jest.fn(),
|
||
info: jest.fn(),
|
||
warn: jest.fn(),
|
||
error: jest.fn()
|
||
}
|
||
|
||
const buildMockRedis = () => ({
|
||
getLocalBalance: jest.fn().mockResolvedValue(null),
|
||
setLocalBalance: jest.fn().mockResolvedValue(undefined),
|
||
getAccountBalance: jest.fn().mockResolvedValue(null),
|
||
setAccountBalance: jest.fn().mockResolvedValue(undefined),
|
||
deleteAccountBalance: jest.fn().mockResolvedValue(undefined),
|
||
getBalanceScriptConfig: jest.fn().mockResolvedValue(null),
|
||
getAccountUsageStats: jest.fn().mockResolvedValue({
|
||
total: { requests: 10 },
|
||
daily: { requests: 2, cost: 20 },
|
||
monthly: { requests: 5 }
|
||
}),
|
||
getDateInTimezone: (date) => new Date(date.getTime() + 8 * 3600 * 1000)
|
||
})
|
||
|
||
it('should normalize platform aliases', () => {
|
||
const service = new AccountBalanceService({ redis: buildMockRedis(), logger: mockLogger })
|
||
expect(service.normalizePlatform('claude-official')).toBe('claude')
|
||
expect(service.normalizePlatform('azure-openai')).toBe('azure_openai')
|
||
expect(service.normalizePlatform('gemini-api')).toBe('gemini-api')
|
||
})
|
||
|
||
it('should build local quota/balance from dailyQuota and local dailyCost', async () => {
|
||
const mockRedis = buildMockRedis()
|
||
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||
|
||
service._computeMonthlyCost = jest.fn().mockResolvedValue(30)
|
||
service._computeTotalCost = jest.fn().mockResolvedValue(123.45)
|
||
|
||
const account = { id: 'acct-1', name: 'A', dailyQuota: '100', quotaResetTime: '00:00' }
|
||
const result = await service._getAccountBalanceForAccount(account, 'claude-console', {
|
||
queryApi: false,
|
||
useCache: true
|
||
})
|
||
|
||
expect(result.success).toBe(true)
|
||
expect(result.data.source).toBe('local')
|
||
expect(result.data.balance.amount).toBeCloseTo(80, 6)
|
||
expect(result.data.quota.percentage).toBeCloseTo(20, 6)
|
||
expect(result.data.statistics.totalCost).toBeCloseTo(123.45, 6)
|
||
expect(mockRedis.setLocalBalance).toHaveBeenCalled()
|
||
})
|
||
|
||
it('should use cached balance when account has no dailyQuota', async () => {
|
||
const mockRedis = buildMockRedis()
|
||
mockRedis.getAccountBalance.mockResolvedValue({
|
||
status: 'success',
|
||
balance: 12.34,
|
||
currency: 'USD',
|
||
quota: null,
|
||
errorMessage: '',
|
||
lastRefreshAt: '2025-01-01T00:00:00Z',
|
||
ttlSeconds: 120
|
||
})
|
||
|
||
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
|
||
service._computeTotalCost = jest.fn().mockResolvedValue(0)
|
||
|
||
const account = { id: 'acct-2', name: 'B' }
|
||
const result = await service._getAccountBalanceForAccount(account, 'openai', {
|
||
queryApi: false,
|
||
useCache: true
|
||
})
|
||
|
||
expect(result.data.source).toBe('cache')
|
||
expect(result.data.balance.amount).toBeCloseTo(12.34, 6)
|
||
expect(result.data.lastRefreshAt).toBe('2025-01-01T00:00:00Z')
|
||
})
|
||
|
||
it('should not cache provider errors and fallback to local when queryApi=true', async () => {
|
||
const mockRedis = buildMockRedis()
|
||
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||
|
||
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
|
||
service._computeTotalCost = jest.fn().mockResolvedValue(0)
|
||
|
||
service.registerProvider('openai', {
|
||
queryBalance: () => {
|
||
throw new Error('boom')
|
||
}
|
||
})
|
||
|
||
const account = { id: 'acct-3', name: 'C' }
|
||
const result = await service._getAccountBalanceForAccount(account, 'openai', {
|
||
queryApi: true,
|
||
useCache: false
|
||
})
|
||
|
||
expect(mockRedis.setAccountBalance).not.toHaveBeenCalled()
|
||
expect(result.data.source).toBe('local')
|
||
expect(result.data.status).toBe('error')
|
||
expect(result.data.error).toBe('boom')
|
||
})
|
||
|
||
it('should ignore script config when balance script is disabled', async () => {
|
||
process.env.BALANCE_SCRIPT_ENABLED = 'false'
|
||
|
||
const mockRedis = buildMockRedis()
|
||
mockRedis.getBalanceScriptConfig.mockResolvedValue({
|
||
scriptBody: '({ request: { url: "http://example.com" }, extractor: function(){ return {} } })'
|
||
})
|
||
|
||
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
|
||
service._computeTotalCost = jest.fn().mockResolvedValue(0)
|
||
|
||
const provider = { queryBalance: jest.fn().mockResolvedValue({ balance: 1, currency: 'USD' }) }
|
||
service.registerProvider('openai', provider)
|
||
|
||
const scriptSpy = jest.spyOn(service, '_getBalanceFromScript')
|
||
|
||
const account = { id: 'acct-script-off', name: 'S' }
|
||
const result = await service._getAccountBalanceForAccount(account, 'openai', {
|
||
queryApi: true,
|
||
useCache: false
|
||
})
|
||
|
||
expect(provider.queryBalance).toHaveBeenCalled()
|
||
expect(scriptSpy).not.toHaveBeenCalled()
|
||
expect(result.data.source).toBe('api')
|
||
})
|
||
|
||
it('should prefer script when configured and enabled', async () => {
|
||
process.env.BALANCE_SCRIPT_ENABLED = 'true'
|
||
|
||
const mockRedis = buildMockRedis()
|
||
mockRedis.getBalanceScriptConfig.mockResolvedValue({
|
||
scriptBody: '({ request: { url: "http://example.com" }, extractor: function(){ return {} } })'
|
||
})
|
||
|
||
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
|
||
service._computeTotalCost = jest.fn().mockResolvedValue(0)
|
||
|
||
const provider = { queryBalance: jest.fn().mockResolvedValue({ balance: 2, currency: 'USD' }) }
|
||
service.registerProvider('openai', provider)
|
||
|
||
jest.spyOn(service, '_getBalanceFromScript').mockResolvedValue({
|
||
status: 'success',
|
||
balance: 3,
|
||
currency: 'USD',
|
||
quota: null,
|
||
queryMethod: 'script',
|
||
rawData: { ok: true },
|
||
lastRefreshAt: '2025-01-01T00:00:00Z',
|
||
errorMessage: ''
|
||
})
|
||
|
||
const account = { id: 'acct-script-on', name: 'T' }
|
||
const result = await service._getAccountBalanceForAccount(account, 'openai', {
|
||
queryApi: true,
|
||
useCache: false
|
||
})
|
||
|
||
expect(provider.queryBalance).not.toHaveBeenCalled()
|
||
expect(result.data.source).toBe('api')
|
||
expect(result.data.balance.amount).toBeCloseTo(3, 6)
|
||
expect(result.data.lastRefreshAt).toBe('2025-01-01T00:00:00Z')
|
||
})
|
||
|
||
it('should count low balance once per account in summary', async () => {
|
||
const mockRedis = buildMockRedis()
|
||
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||
|
||
service.getSupportedPlatforms = () => ['claude-console']
|
||
service.getAllAccountsByPlatform = async () => [{ id: 'acct-4', name: 'D' }]
|
||
service._getAccountBalanceForAccount = async () => ({
|
||
success: true,
|
||
data: {
|
||
accountId: 'acct-4',
|
||
platform: 'claude-console',
|
||
balance: { amount: 5, currency: 'USD', formattedAmount: '$5.00' },
|
||
quota: { percentage: 95 },
|
||
statistics: { totalCost: 1 },
|
||
source: 'local',
|
||
lastRefreshAt: '2025-01-01T00:00:00Z',
|
||
cacheExpiresAt: null,
|
||
status: 'success',
|
||
error: null
|
||
}
|
||
})
|
||
|
||
const summary = await service.getBalanceSummary()
|
||
expect(summary.lowBalanceCount).toBe(1)
|
||
expect(summary.platforms['claude-console'].lowBalanceCount).toBe(1)
|
||
})
|
||
})
|