Files
sub2api/backend/internal/service/channel_monitor_quota_fetcher_test.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

682 lines
25 KiB
Go
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.
//go:build unit
package service
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/stretchr/testify/require"
)
// --- fetcher 依赖 stub ---
type stubMonitorUsageSource struct {
usage *UsageInfo
err error
// block 非 nil 时 GetUsageForAccount 阻塞在该 channel 上,用于并发/singleflight 测试。
block chan struct{}
mu sync.Mutex
calls int
lastCtx context.Context
lastAccount *Account
}
func (s *stubMonitorUsageSource) GetUsageForAccount(ctx context.Context, account *Account, force ...bool) (*UsageInfo, error) {
s.mu.Lock()
s.calls++
s.lastCtx = ctx
s.lastAccount = account
s.mu.Unlock()
if s.block != nil {
<-s.block
}
return s.usage, s.err
}
func (s *stubMonitorUsageSource) getCalls() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.calls
}
func (s *stubMonitorUsageSource) getLastAccount() *Account {
s.mu.Lock()
defer s.mu.Unlock()
return s.lastAccount
}
type stubMonitorCNQuotaSource struct {
result *CNProviderQuotaProbeResult
err error
calls int
lastAccount *Account
}
func (s *stubMonitorCNQuotaSource) QueryUsageForAccount(ctx context.Context, account *Account) (*CNProviderQuotaProbeResult, error) {
s.calls++
s.lastAccount = account
return s.result, s.err
}
type stubMonitorCNBalanceSource struct {
result *CNProviderBalanceResult
err error
calls int
lastAccount *Account
}
func (s *stubMonitorCNBalanceSource) QueryBalanceForAccount(ctx context.Context, account *Account) (*CNProviderBalanceResult, error) {
s.calls++
s.lastAccount = account
return s.result, s.err
}
type stubMonitorAccountSource struct {
accounts map[int64]*Account
err error
calls int
}
func (s *stubMonitorAccountSource) GetByID(ctx context.Context, id int64) (*Account, error) {
s.calls++
if s.err != nil {
return nil, s.err
}
return s.accounts[id], nil
}
func newQuotaFetcherTestSetup(t *testing.T) (*ChannelMonitorQuotaFetcher, *stubMonitorUsageSource, *stubMonitorCNQuotaSource, *stubMonitorCNBalanceSource, *stubMonitorAccountSource) {
t.Helper()
usage := &stubMonitorUsageSource{}
cnQuota := &stubMonitorCNQuotaSource{}
cnBalance := &stubMonitorCNBalanceSource{}
accounts := &stubMonitorAccountSource{accounts: make(map[int64]*Account)}
fetcher := &ChannelMonitorQuotaFetcher{
usage: usage,
cnQuota: cnQuota,
cnBalance: cnBalance,
accounts: accounts,
balanceThreshold: monitorBalanceThreshold(nil),
cache: make(map[int64]monitorQuotaCacheEntry),
}
return fetcher, usage, cnQuota, cnBalance, accounts
}
// --- 分派 ---
func TestQuotaFetcher_OverseasAccountUsesUsageService(t *testing.T) {
fetcher, usage, _, cnQuota, accounts := newQuotaFetcherTestSetup(t)
accounts.accounts[7] = &Account{ID: 7, Platform: domain.PlatformAnthropic}
resets := time.Now().Add(2 * time.Hour).UTC()
usage.usage = &UsageInfo{
FiveHour: &UsageProgress{Utilization: 42.5, UsedRequests: 17, LimitRequests: 40, ResetsAt: &resets},
SevenDay: &UsageProgress{Utilization: 10},
SubscriptionTier: "PRO",
}
snapshot := fetcher.Fetch(context.Background(), 7)
require.True(t, snapshot.Success)
require.Equal(t, "usage", snapshot.Source)
require.Equal(t, "PRO", snapshot.PlanLevel)
require.False(t, snapshot.CredentialInvalid)
require.Empty(t, snapshot.Error)
require.Len(t, snapshot.Tiers, 2)
fiveHour := snapshot.Tiers[0]
require.Equal(t, "5h", fiveHour.Window)
require.Empty(t, fiveHour.Label)
require.InDelta(t, 42.5, fiveHour.UsedPercent, 0.001)
require.Equal(t, float64(17), fiveHour.Used)
require.Equal(t, float64(40), fiveHour.Limit)
require.NotEmpty(t, fiveHour.ResetAt)
require.Equal(t, "7d", snapshot.Tiers[1].Window)
require.Equal(t, 1, usage.getCalls())
require.Equal(t, 0, cnQuota.calls)
}
func TestQuotaFetcher_CodingPlanAccountUsesCNQuota(t *testing.T) {
fetcher, _, cnQuota, cnBalance, accounts := newQuotaFetcherTestSetup(t)
accounts.accounts[9] = &Account{
ID: 9,
Platform: domain.PlatformKimi,
Credentials: map[string]any{"account_mode": AccountModeCoding},
}
cnQuota.result = &CNProviderQuotaProbeResult{
Success: true,
CredentialValid: true,
PlanLevel: "",
Tiers: []CNQuotaTier{
{Window: "5h", UsedPercent: 33.3, ResetAt: "2026-08-18T06:00:00Z"},
{Window: "weekly", UsedPercent: 12},
},
}
snapshot := fetcher.Fetch(context.Background(), 9)
require.True(t, snapshot.Success)
require.Equal(t, "cn_quota", snapshot.Source)
require.Len(t, snapshot.Tiers, 2)
require.Equal(t, "5h", snapshot.Tiers[0].Window)
require.InDelta(t, 33.3, snapshot.Tiers[0].UsedPercent, 0.001)
require.Equal(t, "weekly", snapshot.Tiers[1].Window)
require.Equal(t, 1, cnQuota.calls)
require.Equal(t, 0, cnBalance.calls)
}
func TestQuotaFetcher_PayGAccountUsesCNBalance(t *testing.T) {
fetcher, _, _, cnBalance, accounts := newQuotaFetcherTestSetup(t)
accounts.accounts[11] = &Account{
ID: 11,
Platform: domain.PlatformDeepseek,
Credentials: map[string]any{"account_mode": AccountModePayG},
}
cnBalance.result = &CNProviderBalanceResult{
Success: true,
Available: true,
Balance: 12.34,
Currency: "CNY",
Balances: []CNProviderBalanceEntry{
{Currency: "CNY", Balance: 12.34},
{Currency: "USD", Balance: 1.5},
},
}
snapshot := fetcher.Fetch(context.Background(), 11)
require.True(t, snapshot.Success)
require.Equal(t, "cn_balance", snapshot.Source)
require.NotNil(t, snapshot.Balance)
require.InDelta(t, 12.34, *snapshot.Balance, 0.001)
require.Equal(t, "CNY", snapshot.Currency)
require.Len(t, snapshot.Balances, 2)
require.Equal(t, "USD", snapshot.Balances[1].Currency)
require.False(t, snapshot.BalanceLow)
require.Empty(t, snapshot.Error)
}
// P2-6fetchUncached 只 GetByID 一次,已加载的 account 指针直传数据源,
// 三条路由都不能让下游重载账号。
func TestQuotaFetcher_LoadsAccountOnceAndPassesItThrough(t *testing.T) {
t.Run("overseas usage", func(t *testing.T) {
fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t)
acc := &Account{ID: 21, Platform: domain.PlatformAnthropic}
accounts.accounts[21] = acc
usage.usage = &UsageInfo{}
fetcher.Fetch(context.Background(), 21)
require.Equal(t, 1, accounts.calls)
require.Same(t, acc, usage.getLastAccount())
require.Equal(t, 1, usage.getCalls())
})
t.Run("cn coding plan", func(t *testing.T) {
fetcher, _, cnQuota, _, accounts := newQuotaFetcherTestSetup(t)
acc := &Account{ID: 22, Platform: domain.PlatformKimi, Credentials: map[string]any{"account_mode": AccountModeCoding}}
accounts.accounts[22] = acc
cnQuota.result = &CNProviderQuotaProbeResult{Success: true}
fetcher.Fetch(context.Background(), 22)
require.Equal(t, 1, accounts.calls)
require.Same(t, acc, cnQuota.lastAccount)
require.Equal(t, 1, cnQuota.calls)
})
t.Run("cn payg", func(t *testing.T) {
fetcher, _, _, cnBalance, accounts := newQuotaFetcherTestSetup(t)
acc := &Account{ID: 23, Platform: domain.PlatformDeepseek, Credentials: map[string]any{"account_mode": AccountModePayG}}
accounts.accounts[23] = acc
cnBalance.result = &CNProviderBalanceResult{Success: true, Available: true, Balance: 1, Currency: "CNY"}
fetcher.Fetch(context.Background(), 23)
require.Equal(t, 1, accounts.calls)
require.Same(t, acc, cnBalance.lastAccount)
require.Equal(t, 1, cnBalance.calls)
})
}
// --- 失败路径(Fetch 永不返回 error ---
func TestQuotaFetcher_AccountMissingYieldsLinkedAccountSnapshot(t *testing.T) {
fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t)
accounts.err = errors.New("not found")
snapshot := fetcher.Fetch(context.Background(), 404)
require.False(t, snapshot.Success)
require.Equal(t, "linked account not found", snapshot.Error)
require.Equal(t, 0, usage.getCalls()) // 未走到数据源
}
func TestQuotaFetcher_UsageAuthErrorMarksCredentialInvalid(t *testing.T) {
fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t)
accounts.accounts[3] = &Account{ID: 3, Platform: domain.PlatformOpenAI}
usage.err = errors.New("API returned 401: unauthorized")
snapshot := fetcher.Fetch(context.Background(), 3)
require.False(t, snapshot.Success)
require.True(t, snapshot.CredentialInvalid)
require.Contains(t, snapshot.Error, "401")
}
// 值通道失败:antigravity/grok 等平台 err==nil 但错误降级在 UsageInfo 字段里,
// 必须识别为失败快照,否则会被误判为 operational。
func TestQuotaFetcher_UsageValueChannelFailureYieldsFailureSnapshot(t *testing.T) {
fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t)
// 凭据失效(401 语义)→ failed。
accounts.accounts[3] = &Account{ID: 3, Platform: domain.PlatformAnthropic}
usage.usage = &UsageInfo{Error: "usage API error: HTTP 401", ErrorCode: errorCodeUnauthenticated, NeedsReauth: true}
snapshot := fetcher.Fetch(context.Background(), 3)
require.False(t, snapshot.Success)
require.True(t, snapshot.CredentialInvalid)
require.Contains(t, snapshot.Error, "401")
require.Equal(t, MonitorStatusFailed, deriveQuotaCheckResult(snapshot, "quota", time.Now()).Status)
// 限流等非凭据失败 → error(而非 operational)。
accounts.accounts[13] = &Account{ID: 13, Platform: domain.PlatformAnthropic}
usage.usage = &UsageInfo{Error: "usage API error: HTTP 429", ErrorCode: errorCodeRateLimited}
snapshot = fetcher.Fetch(context.Background(), 13)
require.False(t, snapshot.Success)
require.False(t, snapshot.CredentialInvalid)
require.Contains(t, snapshot.Error, "429")
require.Equal(t, MonitorStatusError, deriveQuotaCheckResult(snapshot, "quota", time.Now()).Status)
// grok 已知未知态(尚未观测到计费/限流头)不算失败。
accounts.accounts[14] = &Account{ID: 14, Platform: domain.PlatformGrok}
usage.usage = &UsageInfo{ErrorCode: "quota_unknown", Error: "Grok quota is unknown until billing is probed"}
snapshot = fetcher.Fetch(context.Background(), 14)
require.True(t, snapshot.Success)
require.Empty(t, snapshot.Error)
require.Empty(t, snapshot.Tiers)
require.Equal(t, MonitorStatusOperational, deriveQuotaCheckResult(snapshot, "quota", time.Now()).Status)
}
func TestUsageFailureInfo_ClassificationMatrix(t *testing.T) {
cases := []struct {
name string
usage *UsageInfo
failed bool
credentialInvalid bool
msg string
}{
{name: "nil usage", usage: nil},
{name: "healthy empty", usage: &UsageInfo{}},
{name: "error text only", usage: &UsageInfo{Error: "boom"}, failed: true, msg: "boom"},
{name: "needs reauth", usage: &UsageInfo{NeedsReauth: true}, failed: true, credentialInvalid: true, msg: "usage fetch failed"},
{name: "banned", usage: &UsageInfo{IsBanned: true}, failed: true, credentialInvalid: true, msg: "usage fetch failed"},
{name: "forbidden with reason", usage: &UsageInfo{IsForbidden: true, ForbiddenReason: "usage limited"}, failed: true, credentialInvalid: true, msg: "usage limited"},
{name: "error code unauthenticated", usage: &UsageInfo{ErrorCode: errorCodeUnauthenticated}, failed: true, credentialInvalid: true, msg: errorCodeUnauthenticated},
{name: "error code forbidden", usage: &UsageInfo{ErrorCode: errorCodeForbidden}, failed: true, credentialInvalid: true, msg: errorCodeForbidden},
{name: "error code rate limited", usage: &UsageInfo{ErrorCode: errorCodeRateLimited}, failed: true, msg: errorCodeRateLimited},
{name: "error code network error", usage: &UsageInfo{ErrorCode: errorCodeNetworkError}, failed: true, msg: errorCodeNetworkError},
{name: "grok quota unknown exempted", usage: &UsageInfo{ErrorCode: "quota_unknown", Error: "Grok quota is unknown until billing is probed"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
failed, credentialInvalid, msg := usageFailureInfo(tc.usage)
require.Equal(t, tc.failed, failed)
require.Equal(t, tc.credentialInvalid, credentialInvalid)
if tc.msg != "" {
require.Equal(t, tc.msg, msg)
}
})
}
}
// 凭据失效只认 401/403(与 fetchCNBalance 口径一致):CN quota 服务的
// CredentialValid 仅成功路径置 true500/429/智谱业务错误须推导为 error 而非 failed。
func TestQuotaFetcher_CNQuotaCredentialInvalidByStatusCode(t *testing.T) {
cases := []struct {
name string
accountID int64
statusCode int
credentialBad bool
expectedStatus string
}{
{name: "401 unauthorized", accountID: 5, statusCode: 401, credentialBad: true, expectedStatus: MonitorStatusFailed},
{name: "403 forbidden", accountID: 15, statusCode: 403, credentialBad: true, expectedStatus: MonitorStatusFailed},
{name: "500 server error", accountID: 16, statusCode: 500, expectedStatus: MonitorStatusError},
{name: "429 rate limited", accountID: 17, statusCode: 429, expectedStatus: MonitorStatusError},
// 智谱 2xx 但业务级失败:StatusCode=200,非凭据问题。
{name: "200 business error", accountID: 18, statusCode: 200, expectedStatus: MonitorStatusError},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fetcher, _, cnQuota, _, accounts := newQuotaFetcherTestSetup(t)
accounts.accounts[tc.accountID] = &Account{
ID: tc.accountID,
Platform: domain.PlatformZhipu,
Credentials: map[string]any{"account_mode": AccountModeCoding},
}
cnQuota.result = &CNProviderQuotaProbeResult{
Success: false,
StatusCode: tc.statusCode,
Error: "api key expired",
}
snapshot := fetcher.Fetch(context.Background(), tc.accountID)
require.False(t, snapshot.Success)
require.Equal(t, tc.credentialBad, snapshot.CredentialInvalid)
require.Equal(t, tc.expectedStatus, deriveQuotaCheckResult(snapshot, "quota", time.Now()).Status)
})
}
}
func TestQuotaFetcher_CNBalanceHTTP403MarksCredentialInvalid(t *testing.T) {
fetcher, _, _, cnBalance, accounts := newQuotaFetcherTestSetup(t)
accounts.accounts[6] = &Account{ID: 6, Platform: domain.PlatformKimi}
cnBalance.result = &CNProviderBalanceResult{Success: false, StatusCode: 403, Error: "forbidden"}
snapshot := fetcher.Fetch(context.Background(), 6)
require.False(t, snapshot.Success)
require.True(t, snapshot.CredentialInvalid)
}
// 余额告警口径与账号停调(CNProviderBalanceCheckService.checkOne)一致:
// 上游标记不可用或全部币种低于阈值 → BalanceLow → degraded;任一币种达标即健康。
func TestQuotaFetcher_CNBalanceLowMarksDegraded(t *testing.T) {
cases := []struct {
name string
accountID int64
result *CNProviderBalanceResult
balanceLow bool
wantStatus string
wantMessage string
}{
{
// 审查例:余额 5/阈值 10 的账号调度器已停调,监控不能仍绿灯。
name: "balance below threshold",
accountID: 21,
result: &CNProviderBalanceResult{Success: true, Available: true, Balance: 5, Currency: "CNY"},
balanceLow: true,
wantStatus: MonitorStatusDegraded, wantMessage: "balance low: 5 CNY",
},
{
name: "upstream marked unavailable",
accountID: 22,
result: &CNProviderBalanceResult{Success: true, Available: false, Balance: 20, Currency: "CNY"},
balanceLow: true,
wantStatus: MonitorStatusDegraded, wantMessage: "balance low: 20 CNY",
},
{
// deepseek 双币种:任一币种(USD 20)达标即健康。
name: "any currency above threshold is healthy",
accountID: 23,
result: &CNProviderBalanceResult{
Success: true, Available: true, Balance: 5, Currency: "CNY",
Balances: []CNProviderBalanceEntry{{Currency: "CNY", Balance: 5}, {Currency: "USD", Balance: 20}},
},
wantStatus: MonitorStatusOperational,
},
{
name: "single currency above threshold",
accountID: 24,
result: &CNProviderBalanceResult{Success: true, Available: true, Balance: 20, Currency: "CNY"},
wantStatus: MonitorStatusOperational,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fetcher, _, _, cnBalance, accounts := newQuotaFetcherTestSetup(t)
fetcher.balanceThreshold = 10
accounts.accounts[tc.accountID] = &Account{
ID: tc.accountID,
Platform: domain.PlatformKimi,
Credentials: map[string]any{"account_mode": AccountModePayG},
}
cnBalance.result = tc.result
snapshot := fetcher.Fetch(context.Background(), tc.accountID)
require.True(t, snapshot.Success)
require.Equal(t, tc.balanceLow, snapshot.BalanceLow)
res := deriveQuotaCheckResult(snapshot, "quota", time.Now())
require.Equal(t, tc.wantStatus, res.Status)
if tc.wantMessage != "" {
require.Contains(t, res.Message, tc.wantMessage)
} else {
require.Empty(t, res.Message)
}
})
}
}
func TestNewChannelMonitorQuotaFetcher_ThresholdFromConfig(t *testing.T) {
require.InDelta(t, 0.5, NewChannelMonitorQuotaFetcher(nil, nil, nil, nil, nil).balanceThreshold, 0.0001)
cfg10 := &config.Config{Gateway: config.GatewayConfig{CNProviders: config.GatewayCNProvidersConfig{BalanceThreshold: 10}}}
require.InDelta(t, 10, NewChannelMonitorQuotaFetcher(nil, nil, nil, nil, cfg10).balanceThreshold, 0.0001)
// 非正值(含显式 0)回退默认,避免 0 阈值下「余额=0 也不告警」。
cfg0 := &config.Config{Gateway: config.GatewayConfig{CNProviders: config.GatewayCNProvidersConfig{BalanceThreshold: 0}}}
require.InDelta(t, 0.5, NewChannelMonitorQuotaFetcher(nil, nil, nil, nil, cfg0).balanceThreshold, 0.0001)
}
func TestQuotaFetcher_NilDependenciesProduceErrorSnapshots(t *testing.T) {
// fetcher 本体为 nil:直接降级为错误快照,不 panic。
var nilFetcher *ChannelMonitorQuotaFetcher
snapshot := nilFetcher.Fetch(context.Background(), 1)
require.False(t, snapshot.Success)
require.Equal(t, "quota fetcher is not configured", snapshot.Error)
// 数据源缺失:账号能加载,但对应服务未注入。
fetcher, _, _, _, accounts := newQuotaFetcherTestSetup(t)
fetcher.usage = nil
accounts.accounts[2] = &Account{ID: 2, Platform: domain.PlatformOpenAI}
snapshot = fetcher.Fetch(context.Background(), 2)
require.False(t, snapshot.Success)
require.Contains(t, snapshot.Error, "not configured")
}
// --- TTL 缓存 ---
func TestQuotaFetcher_CachesSuccessSnapshotPerAccount(t *testing.T) {
fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t)
accounts.accounts[8] = &Account{ID: 8, Platform: domain.PlatformOpenAI}
usage.usage = &UsageInfo{FiveHour: &UsageProgress{Utilization: 10}}
for i := 0; i < 3; i++ {
snapshot := fetcher.Fetch(context.Background(), 8)
require.True(t, snapshot.Success)
}
require.Equal(t, 1, usage.getCalls(), "success snapshots should be served from cache")
// 缓存过期后重新拉取。
fetcher.mu.Lock()
entry := fetcher.cache[8]
entry.expiry = time.Now().Add(-time.Second)
fetcher.cache[8] = entry
fetcher.mu.Unlock()
_ = fetcher.Fetch(context.Background(), 8)
require.Equal(t, 2, usage.getCalls())
}
func TestQuotaFetcher_CachesFailureSnapshotWithShortTTL(t *testing.T) {
fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t)
accounts.accounts[4] = &Account{ID: 4, Platform: domain.PlatformOpenAI}
usage.err = errors.New("boom")
for i := 0; i < 2; i++ {
snapshot := fetcher.Fetch(context.Background(), 4)
require.False(t, snapshot.Success)
}
require.Equal(t, 1, usage.getCalls(), "failure snapshots should be served from the short negative cache")
// 失败快照的 TTL 是负缓存时长(而非成功 TTL)。
fetcher.mu.Lock()
entry := fetcher.cache[4]
require.WithinDuration(t, entry.snapshot.FetchedAt.Add(monitorQuotaErrorCacheTTL), entry.expiry, time.Second)
entry.expiry = time.Now().Add(-time.Second)
fetcher.cache[4] = entry
fetcher.mu.Unlock()
_ = fetcher.Fetch(context.Background(), 4)
require.Equal(t, 2, usage.getCalls(), "expired negative cache should refetch")
}
func TestQuotaFetcher_ConcurrentFetchesShareSingleFlight(t *testing.T) {
fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t)
accounts.accounts[12] = &Account{ID: 12, Platform: domain.PlatformOpenAI}
usage.usage = &UsageInfo{FiveHour: &UsageProgress{Utilization: 10}}
usage.block = make(chan struct{})
var wg sync.WaitGroup
snapshots := make([]*domain.MonitorQuotaSnapshot, 5)
for i := 0; i < 5; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
snapshots[idx] = fetcher.Fetch(context.Background(), 12)
}(i)
}
// 上游被 block 卡住时,5 个并发 Fetch 应只产生 1 次真实查询。
require.Eventually(t, func() bool { return usage.getCalls() == 1 },
5*time.Second, 10*time.Millisecond)
close(usage.block)
wg.Wait()
for _, snapshot := range snapshots {
require.NotNil(t, snapshot)
require.True(t, snapshot.Success)
}
require.Equal(t, 1, usage.getCalls())
// 成功快照已缓存:再取一次仍不打上游。
_ = fetcher.Fetch(context.Background(), 12)
require.Equal(t, 1, usage.getCalls())
}
// --- UsageInfo → tiers 归一 ---
func TestUsageQuotaTiers_MapsAllWindowKinds(t *testing.T) {
limit := int64(1000)
remaining := int64(400)
resetUnix := int64(1777283883)
usage := &UsageInfo{
FiveHour: &UsageProgress{Utilization: 50},
SevenDay: &UsageProgress{Utilization: 60},
SevenDaySonnet: &UsageProgress{Utilization: 70},
SevenDayFable: &UsageProgress{Utilization: 80},
ThirtyDay: &UsageProgress{Utilization: 20},
GeminiSharedDaily: &UsageProgress{Utilization: 11},
GeminiProDaily: &UsageProgress{Utilization: 22},
GeminiFlashDaily: &UsageProgress{Utilization: 33},
GrokRequestQuota: &xai.QuotaWindow{Limit: &limit, Remaining: &remaining, ResetUnix: &resetUnix},
GrokTokenQuota: &xai.QuotaWindow{Limit: &limit, Remaining: &remaining, ResetAt: "2026-08-19T00:00:00Z"},
AntigravityQuota: map[string]*AntigravityModelQuota{
"gemini-3-pro": {Utilization: 45},
"gemini-3-flash": {Utilization: 55},
},
}
tiers := usageQuotaTiers(usage)
// 5h/7d/7d-sonnet/7d-fable/30d + gemini×3 + grok×2 + antigravity×2
require.Len(t, tiers, 12)
byKey := make(map[string]domain.MonitorQuotaTier, len(tiers))
for _, tier := range tiers {
key := tier.Window
if tier.Label != "" {
key = tier.Window + "/" + tier.Label
}
byKey[key] = tier
}
require.Contains(t, byKey, "5h")
require.Contains(t, byKey, "7d")
require.Contains(t, byKey, "7d-sonnet")
require.Contains(t, byKey, "7d-fable")
require.Contains(t, byKey, "30d")
require.Contains(t, byKey, "daily/shared")
require.Contains(t, byKey, "daily/pro")
require.Contains(t, byKey, "daily/flash")
require.Contains(t, byKey, "daily/requests")
require.Contains(t, byKey, "daily/tokens")
require.Contains(t, byKey, "total/gemini-3-pro")
require.Contains(t, byKey, "total/gemini-3-flash")
// grok requests 窗口:used = limit - remaining,百分比 60%。
requests := byKey["daily/requests"]
require.Equal(t, float64(600), requests.Used)
require.Equal(t, float64(1000), requests.Limit)
require.InDelta(t, 60.0, requests.UsedPercent, 0.001)
require.NotEmpty(t, requests.ResetAt, "ResetUnix should fall back to RFC3339")
tokens := byKey["daily/tokens"]
require.Equal(t, "2026-08-19T00:00:00Z", tokens.ResetAt)
}
func TestUsageQuotaTiers_NilAndEmptyInputs(t *testing.T) {
require.Nil(t, usageQuotaTiers(nil))
require.Nil(t, usageQuotaTiers(&UsageInfo{}))
// Grok 窗口 limit<=0 时跳过,避免除零。
var zero int64
tiers := usageQuotaTiers(&UsageInfo{
GrokRequestQuota: &xai.QuotaWindow{Limit: &zero, Remaining: &zero},
})
require.Nil(t, tiers)
}
// --- 状态推导 ---
func TestDeriveQuotaCheckResult_StatusMatrix(t *testing.T) {
now := time.Now()
healthy := &domain.MonitorQuotaSnapshot{Success: true, Tiers: []domain.MonitorQuotaTier{{Window: "5h", UsedPercent: 40}}}
res := deriveQuotaCheckResult(healthy, "quota", now)
require.Equal(t, MonitorStatusOperational, res.Status)
require.Equal(t, "quota", res.Model)
require.Empty(t, res.Message)
highUsage := &domain.MonitorQuotaSnapshot{Success: true, Tiers: []domain.MonitorQuotaTier{
{Window: "5h", UsedPercent: 30},
{Window: "daily", Label: "pro", UsedPercent: 95},
}}
res = deriveQuotaCheckResult(highUsage, "quota", now)
require.Equal(t, MonitorStatusDegraded, res.Status)
require.Contains(t, res.Message, "pro/daily")
require.Contains(t, res.Message, "95.0%")
balance := -0.5
lowBalance := &domain.MonitorQuotaSnapshot{Success: true, BalanceLow: true, Balance: &balance, Currency: "CNY"}
res = deriveQuotaCheckResult(lowBalance, "quota", now)
require.Equal(t, MonitorStatusDegraded, res.Status)
require.Contains(t, res.Message, "balance low")
invalid := &domain.MonitorQuotaSnapshot{Success: false, CredentialInvalid: true, Error: "401 unauthorized"}
res = deriveQuotaCheckResult(invalid, "quota", now)
require.Equal(t, MonitorStatusFailed, res.Status)
unlinked := &domain.MonitorQuotaSnapshot{Success: false, Error: "linked account not found"}
res = deriveQuotaCheckResult(unlinked, "quota", now)
require.Equal(t, MonitorStatusDegraded, res.Status)
other := &domain.MonitorQuotaSnapshot{Success: false, Error: "connection refused"}
res = deriveQuotaCheckResult(other, "quota", now)
require.Equal(t, MonitorStatusError, res.Status)
require.Equal(t, "connection refused", res.Message)
res = deriveQuotaCheckResult(nil, "quota", now)
require.Equal(t, MonitorStatusError, res.Status)
}