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
This commit is contained in:
@@ -40,30 +40,31 @@ func (s *AntigravityGatewayService) checkAccountCredits(
|
|||||||
return true // 出错时假设有积分,不阻断
|
return true // 出错时假设有积分,不阻断
|
||||||
}
|
}
|
||||||
|
|
||||||
if usageInfo == nil || len(usageInfo.AICredits) == 0 {
|
hasCredits := hasEnoughCredits(usageInfo)
|
||||||
|
if !hasCredits {
|
||||||
logger.LegacyPrintf("service.antigravity_gateway",
|
logger.LegacyPrintf("service.antigravity_gateway",
|
||||||
"check_credits: account=%d has_credits=false amount=0 (no credits field)",
|
"check_credits: account=%d has_credits=false", account.ID)
|
||||||
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, credit := range usageInfo.AICredits {
|
for _, credit := range info.AICredits {
|
||||||
if credit.CreditType == "GOOGLE_ONE_AI" {
|
if credit.CreditType == "GOOGLE_ONE_AI" {
|
||||||
minimum := credit.MinimumBalance
|
minimum := credit.MinimumBalance
|
||||||
if minimum <= 0 {
|
if minimum <= 0 {
|
||||||
minimum = 5
|
minimum = 5
|
||||||
}
|
}
|
||||||
hasCredits := credit.Amount >= minimum
|
return 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LegacyPrintf("service.antigravity_gateway",
|
|
||||||
"check_credits: account=%d has_credits=false (no GOOGLE_ONE_AI credit)",
|
|
||||||
account.ID)
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -548,3 +548,105 @@ func TestClearCreditsExhausted(t *testing.T) {
|
|||||||
require.True(t, exists, "普通模型限流应保留")
|
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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1887,3 +1887,127 @@ func TestReplaceModelInBody_InvalidJSON(t *testing.T) {
|
|||||||
result2 := ReplaceModelInBody(arrayBody, "new-model")
|
result2 := ReplaceModelInBody(arrayBody, "new-model")
|
||||||
require.Equal(t, arrayBody, result2)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -585,3 +585,79 @@ func TestGetRequestTierPriceByContext_ExactBoundary(t *testing.T) {
|
|||||||
price2 := r.GetRequestTierPriceByContext(resolved, 128001)
|
price2 := r.GetRequestTierPriceByContext(resolved, 128001)
|
||||||
require.InDelta(t, 0.10, price2, 1e-12)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user