From 5534347328e021e6cc8543271715f0d8ec02fefa Mon Sep 17 00:00:00 2001 From: erio Date: Wed, 1 Apr 2026 22:41:39 +0800 Subject: [PATCH] test: add unit tests for channel platform matching, interval validation, credits check - TestIsPlatformPricingMatch: 12 cases covering all platform combinations - TestMatchingPlatforms: 4 cases for platform expansion - TestGetChannelModelPricing_AntigravityCrossPlatform: antigravity sees anthropic pricing - TestGetChannelModelPricing_AnthropicCannotSeeAntigravityPricing: no reverse leakage - TestResolveChannelMapping_AntigravityCrossPlatform: antigravity uses anthropic mapping - TestFilterValidIntervals: 8 cases for empty interval filtering - TestHasEnoughCredits: 10 cases for credits balance threshold logic - Extract hasEnoughCredits() pure function for testability --- .../service/antigravity_credits_overages.go | 25 ++-- .../antigravity_credits_overages_test.go | 102 ++++++++++++++ .../internal/service/channel_service_test.go | 124 ++++++++++++++++++ .../service/model_pricing_resolver_test.go | 76 +++++++++++ 4 files changed, 315 insertions(+), 12 deletions(-) diff --git a/backend/internal/service/antigravity_credits_overages.go b/backend/internal/service/antigravity_credits_overages.go index 56725900..1cfc9504 100644 --- a/backend/internal/service/antigravity_credits_overages.go +++ b/backend/internal/service/antigravity_credits_overages.go @@ -40,30 +40,31 @@ func (s *AntigravityGatewayService) checkAccountCredits( return true // 出错时假设有积分,不阻断 } - if usageInfo == nil || len(usageInfo.AICredits) == 0 { + hasCredits := hasEnoughCredits(usageInfo) + if !hasCredits { logger.LegacyPrintf("service.antigravity_gateway", - "check_credits: account=%d has_credits=false amount=0 (no credits field)", - account.ID) + "check_credits: account=%d has_credits=false", account.ID) + } + return hasCredits +} + +// hasEnoughCredits 检查 UsageInfo 中是否有足够的 GOOGLE_ONE_AI 积分。 +// 返回 true 表示积分可用,false 表示积分不足或无积分信息。 +func hasEnoughCredits(info *UsageInfo) bool { + if info == nil || len(info.AICredits) == 0 { return false } - for _, credit := range usageInfo.AICredits { + for _, credit := range info.AICredits { if credit.CreditType == "GOOGLE_ONE_AI" { minimum := credit.MinimumBalance if minimum <= 0 { minimum = 5 } - hasCredits := credit.Amount >= minimum - logger.LegacyPrintf("service.antigravity_gateway", - "check_credits: account=%d has_credits=%t amount=%.0f minimum=%.0f", - account.ID, hasCredits, credit.Amount, minimum) - return hasCredits + return credit.Amount >= minimum } } - logger.LegacyPrintf("service.antigravity_gateway", - "check_credits: account=%d has_credits=false (no GOOGLE_ONE_AI credit)", - account.ID) return false } diff --git a/backend/internal/service/antigravity_credits_overages_test.go b/backend/internal/service/antigravity_credits_overages_test.go index 1676787e..fc849b9c 100644 --- a/backend/internal/service/antigravity_credits_overages_test.go +++ b/backend/internal/service/antigravity_credits_overages_test.go @@ -548,3 +548,105 @@ func TestClearCreditsExhausted(t *testing.T) { require.True(t, exists, "普通模型限流应保留") }) } + +// =========================================================================== +// hasEnoughCredits — standalone credits balance check +// =========================================================================== + +func TestHasEnoughCredits(t *testing.T) { + tests := []struct { + name string + info *UsageInfo + want bool + }{ + { + name: "nil UsageInfo", + info: nil, + want: false, + }, + { + name: "empty AICredits list", + info: &UsageInfo{AICredits: []AICredit{}}, + want: false, + }, + { + name: "GOOGLE_ONE_AI with enough credits (amount=18778, minimum=50)", + info: &UsageInfo{ + AICredits: []AICredit{ + {CreditType: "GOOGLE_ONE_AI", Amount: 18778, MinimumBalance: 50}, + }, + }, + want: true, + }, + { + name: "GOOGLE_ONE_AI below minimum (amount=3, minimum=5)", + info: &UsageInfo{ + AICredits: []AICredit{ + {CreditType: "GOOGLE_ONE_AI", Amount: 3, MinimumBalance: 5}, + }, + }, + want: false, + }, + { + name: "GOOGLE_ONE_AI with zero MinimumBalance defaults to 5, amount=6", + info: &UsageInfo{ + AICredits: []AICredit{ + {CreditType: "GOOGLE_ONE_AI", Amount: 6, MinimumBalance: 0}, + }, + }, + want: true, + }, + { + name: "GOOGLE_ONE_AI with zero MinimumBalance defaults to 5, amount=4", + info: &UsageInfo{ + AICredits: []AICredit{ + {CreditType: "GOOGLE_ONE_AI", Amount: 4, MinimumBalance: 0}, + }, + }, + want: false, + }, + { + name: "GOOGLE_ONE_AI exactly at minimum (amount=5, minimum=5)", + info: &UsageInfo{ + AICredits: []AICredit{ + {CreditType: "GOOGLE_ONE_AI", Amount: 5, MinimumBalance: 5}, + }, + }, + want: true, + }, + { + name: "no GOOGLE_ONE_AI credit type", + info: &UsageInfo{ + AICredits: []AICredit{ + {CreditType: "OTHER_CREDIT", Amount: 10000, MinimumBalance: 5}, + }, + }, + want: false, + }, + { + name: "multiple credits, GOOGLE_ONE_AI present with enough", + info: &UsageInfo{ + AICredits: []AICredit{ + {CreditType: "OTHER_CREDIT", Amount: 0, MinimumBalance: 5}, + {CreditType: "GOOGLE_ONE_AI", Amount: 100, MinimumBalance: 10}, + }, + }, + want: true, + }, + { + name: "negative MinimumBalance defaults to 5", + info: &UsageInfo{ + AICredits: []AICredit{ + {CreditType: "GOOGLE_ONE_AI", Amount: 6, MinimumBalance: -1}, + }, + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, hasEnoughCredits(tt.info)) + }) + } +} diff --git a/backend/internal/service/channel_service_test.go b/backend/internal/service/channel_service_test.go index 4926fd88..0cb16b2b 100644 --- a/backend/internal/service/channel_service_test.go +++ b/backend/internal/service/channel_service_test.go @@ -1887,3 +1887,127 @@ func TestReplaceModelInBody_InvalidJSON(t *testing.T) { result2 := ReplaceModelInBody(arrayBody, "new-model") require.Equal(t, arrayBody, result2) } + +// =========================================================================== +// 7. isPlatformPricingMatch +// =========================================================================== + +func TestIsPlatformPricingMatch(t *testing.T) { + tests := []struct { + name string + groupPlatform string + pricingPlatform string + want bool + }{ + {"antigravity matches anthropic", PlatformAntigravity, PlatformAnthropic, true}, + {"antigravity matches gemini", PlatformAntigravity, PlatformGemini, true}, + {"antigravity matches antigravity", PlatformAntigravity, PlatformAntigravity, true}, + {"antigravity does NOT match openai", PlatformAntigravity, PlatformOpenAI, false}, + {"anthropic matches anthropic", PlatformAnthropic, PlatformAnthropic, true}, + {"anthropic does NOT match antigravity", PlatformAnthropic, PlatformAntigravity, false}, + {"anthropic does NOT match gemini", PlatformAnthropic, PlatformGemini, false}, + {"gemini matches gemini", PlatformGemini, PlatformGemini, true}, + {"gemini does NOT match antigravity", PlatformGemini, PlatformAntigravity, false}, + {"gemini does NOT match anthropic", PlatformGemini, PlatformAnthropic, false}, + {"empty string matches nothing", "", PlatformAnthropic, false}, + {"empty string matches empty", "", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, isPlatformPricingMatch(tt.groupPlatform, tt.pricingPlatform)) + }) + } +} + +// =========================================================================== +// 8. matchingPlatforms +// =========================================================================== + +func TestMatchingPlatforms(t *testing.T) { + tests := []struct { + name string + groupPlatform string + want []string + }{ + {"antigravity returns all three", PlatformAntigravity, []string{PlatformAntigravity, PlatformAnthropic, PlatformGemini}}, + {"anthropic returns itself", PlatformAnthropic, []string{PlatformAnthropic}}, + {"gemini returns itself", PlatformGemini, []string{PlatformGemini}}, + {"openai returns itself", PlatformOpenAI, []string{PlatformOpenAI}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := matchingPlatforms(tt.groupPlatform) + require.Equal(t, tt.want, result) + }) + } +} + +// =========================================================================== +// 9. Antigravity cross-platform channel pricing +// =========================================================================== + +func TestGetChannelModelPricing_AntigravityCrossPlatform(t *testing.T) { + // Channel has anthropic pricing for claude-opus-4-6. + // Group 10 is antigravity — should see the anthropic pricing. + ch := Channel{ + ID: 1, + Status: StatusActive, + GroupIDs: []int64{10}, + ModelPricing: []ChannelModelPricing{ + {ID: 100, Platform: PlatformAnthropic, Models: []string{"claude-opus-4-6"}, InputPrice: testPtrFloat64(15e-6)}, + }, + } + repo := makeStandardRepo(ch, map[int64]string{10: PlatformAntigravity}) + svc := newTestChannelService(repo) + + result := svc.GetChannelModelPricing(context.Background(), 10, "claude-opus-4-6") + require.NotNil(t, result, "antigravity group should see anthropic pricing") + require.Equal(t, int64(100), result.ID) + require.InDelta(t, 15e-6, *result.InputPrice, 1e-12) +} + +func TestGetChannelModelPricing_AnthropicCannotSeeAntigravityPricing(t *testing.T) { + // Channel has antigravity-platform pricing for claude-opus-4-6. + // Group 10 is anthropic — should NOT see antigravity pricing (no cross-platform leakage). + ch := Channel{ + ID: 1, + Status: StatusActive, + GroupIDs: []int64{10}, + ModelPricing: []ChannelModelPricing{ + {ID: 100, Platform: PlatformAntigravity, Models: []string{"claude-opus-4-6"}, InputPrice: testPtrFloat64(15e-6)}, + }, + } + repo := makeStandardRepo(ch, map[int64]string{10: PlatformAnthropic}) + svc := newTestChannelService(repo) + + result := svc.GetChannelModelPricing(context.Background(), 10, "claude-opus-4-6") + require.Nil(t, result, "anthropic group should NOT see antigravity-platform pricing") +} + +// =========================================================================== +// 10. Antigravity cross-platform model mapping +// =========================================================================== + +func TestResolveChannelMapping_AntigravityCrossPlatform(t *testing.T) { + // Channel has anthropic model mapping: claude-opus-4-5 → claude-opus-4-6. + // Group 10 is antigravity — should apply the anthropic mapping. + ch := Channel{ + ID: 1, + Status: StatusActive, + GroupIDs: []int64{10}, + ModelMapping: map[string]map[string]string{ + PlatformAnthropic: { + "claude-opus-4-5": "claude-opus-4-6", + }, + }, + } + repo := makeStandardRepo(ch, map[int64]string{10: PlatformAntigravity}) + svc := newTestChannelService(repo) + + result := svc.ResolveChannelMapping(context.Background(), 10, "claude-opus-4-5") + require.True(t, result.Mapped, "antigravity group should apply anthropic mapping") + require.Equal(t, "claude-opus-4-6", result.MappedModel) + require.Equal(t, int64(1), result.ChannelID) +} diff --git a/backend/internal/service/model_pricing_resolver_test.go b/backend/internal/service/model_pricing_resolver_test.go index 74a7c3e2..905c4df6 100644 --- a/backend/internal/service/model_pricing_resolver_test.go +++ b/backend/internal/service/model_pricing_resolver_test.go @@ -585,3 +585,79 @@ func TestGetRequestTierPriceByContext_ExactBoundary(t *testing.T) { price2 := r.GetRequestTierPriceByContext(resolved, 128001) require.InDelta(t, 0.10, price2, 1e-12) } + +// =========================================================================== +// 8. filterValidIntervals +// =========================================================================== + +func TestFilterValidIntervals(t *testing.T) { + tests := []struct { + name string + intervals []PricingInterval + wantLen int + }{ + { + name: "empty list", + intervals: nil, + wantLen: 0, + }, + { + name: "all-nil interval filtered out", + intervals: []PricingInterval{ + {MinTokens: 0, MaxTokens: testPtrInt(128000)}, + }, + wantLen: 0, + }, + { + name: "interval with only InputPrice kept", + intervals: []PricingInterval{ + {MinTokens: 0, MaxTokens: testPtrInt(128000), InputPrice: testPtrFloat64(1e-6)}, + }, + wantLen: 1, + }, + { + name: "interval with only OutputPrice kept", + intervals: []PricingInterval{ + {MinTokens: 0, MaxTokens: testPtrInt(128000), OutputPrice: testPtrFloat64(2e-6)}, + }, + wantLen: 1, + }, + { + name: "interval with only CacheWritePrice kept", + intervals: []PricingInterval{ + {MinTokens: 0, CacheWritePrice: testPtrFloat64(3e-6)}, + }, + wantLen: 1, + }, + { + name: "interval with only CacheReadPrice kept", + intervals: []PricingInterval{ + {MinTokens: 0, CacheReadPrice: testPtrFloat64(0.5e-6)}, + }, + wantLen: 1, + }, + { + name: "interval with only PerRequestPrice kept", + intervals: []PricingInterval{ + {TierLabel: "1K", PerRequestPrice: testPtrFloat64(0.04)}, + }, + wantLen: 1, + }, + { + name: "mixed valid and invalid", + intervals: []PricingInterval{ + {MinTokens: 0, MaxTokens: testPtrInt(128000), InputPrice: testPtrFloat64(1e-6)}, + {MinTokens: 128000, MaxTokens: nil}, // all-nil → filtered out + {MinTokens: 256000, OutputPrice: testPtrFloat64(5e-6)}, + }, + wantLen: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := filterValidIntervals(tt.intervals) + require.Len(t, result, tt.wantLen) + }) + } +}