Files
claude-relay-service/tests/accountBalanceService.test.js
atoz03 f6ed420401 feat(admin): 新增账户余额/配额查询与展示
- 新增 accountBalanceService 与多 Provider 适配(Claude/Claude Console/OpenAI Responses/通用)
  - Redis 增加余额查询结果与本地统计缓存读写
  - 管理端新增 /admin/accounts/balance 相关接口与汇总接口,并在应用启动时注册 Provider
  - 后台前端新增余额组件与 Dashboard 余额/配额汇总、低余额/高使用提示
  - 补充 accountBalanceService 单元测试
2025-12-20 01:15:33 +08:00

143 lines
5.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 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),
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 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).toHaveBeenCalled()
expect(result.data.source).toBe('local')
expect(result.data.status).toBe('error')
expect(result.data.error).toBe('boom')
})
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)
})
})