test: add unit tests for billing, websearch, and notify systems
Billing (25 tests): - CalculateCostUnified: nil resolver fallback, token/per_request/image modes - GetModelPricingWithChannel: nil/partial/full channel overrides - resolveAccountStatsCost: four-level priority chain integration tests WebSearch (18 tests): - PopulateWebSearchUsage: nil input, manager states, QuotaLimit nil/*int64 - ResetWebSearchUsage: nil manager error - Manager.ResetUsage: nil Redis - shouldEmulateWebSearch: full decision chain (8 scenarios) Notify (36 tests): - ParseNotifyEmails/MarshalNotifyEmails: old/new format, roundtrip - crossedDownward: boundary values, threshold semantics - checkQuotaDimCrossings: mixed dimensions, disabled/zero skip
This commit is contained in:
@@ -3,7 +3,9 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -527,3 +529,243 @@ func TestTryModelFilePricing_WithCacheTokens(t *testing.T) {
|
||||
// = 0.1 + 0.1 + 0.6 + 0.15 = 0.95
|
||||
require.InDelta(t, 0.95, *result, 1e-12)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveAccountStatsCost — integration tests covering the 4-level priority chain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestResolveAccountStatsCost_NilChannelService(t *testing.T) {
|
||||
result := resolveAccountStatsCost(
|
||||
context.Background(),
|
||||
nil, // channelService is nil
|
||||
newTestBillingServiceWithPrices(map[string]*ModelPricing{}),
|
||||
1, 1, "claude-sonnet-4",
|
||||
UsageTokens{InputTokens: 100}, 1, 0.5,
|
||||
)
|
||||
require.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestResolveAccountStatsCost_EmptyUpstreamModel(t *testing.T) {
|
||||
cs := newTestChannelServiceForStats(t, &Channel{
|
||||
ID: 1,
|
||||
Status: StatusActive,
|
||||
}, 1, "")
|
||||
|
||||
result := resolveAccountStatsCost(
|
||||
context.Background(),
|
||||
cs,
|
||||
newTestBillingServiceWithPrices(map[string]*ModelPricing{}),
|
||||
1, 1, "", // empty upstream model
|
||||
UsageTokens{InputTokens: 100}, 1, 0.5,
|
||||
)
|
||||
require.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestResolveAccountStatsCost_GetChannelForGroupReturnsNil(t *testing.T) {
|
||||
// Group 99 is NOT in the cache, so GetChannelForGroup returns nil
|
||||
cs := newTestChannelServiceForStats(t, &Channel{
|
||||
ID: 1,
|
||||
Status: StatusActive,
|
||||
}, 1, "")
|
||||
|
||||
result := resolveAccountStatsCost(
|
||||
context.Background(),
|
||||
cs,
|
||||
newTestBillingServiceWithPrices(map[string]*ModelPricing{}),
|
||||
1, 99, "claude-sonnet-4", // groupID 99 has no channel
|
||||
UsageTokens{InputTokens: 100}, 1, 0.5,
|
||||
)
|
||||
require.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestResolveAccountStatsCost_HitsCustomRule(t *testing.T) {
|
||||
channel := &Channel{
|
||||
ID: 1,
|
||||
Status: StatusActive,
|
||||
AccountStatsPricingRules: []AccountStatsPricingRule{
|
||||
{
|
||||
GroupIDs: []int64{10},
|
||||
Pricing: []ChannelModelPricing{
|
||||
{
|
||||
ID: 100,
|
||||
Models: []string{"claude-sonnet-4"},
|
||||
InputPrice: testPtrFloat64(0.01),
|
||||
OutputPrice: testPtrFloat64(0.02),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
cs := newTestChannelServiceForStats(t, channel, 10, "anthropic")
|
||||
|
||||
tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
|
||||
|
||||
result := resolveAccountStatsCost(
|
||||
context.Background(),
|
||||
cs, nil, // billingService not needed when custom rule hits
|
||||
1, 10, "claude-sonnet-4",
|
||||
tokens, 1, 999.0, // totalCost ignored because custom rule hits
|
||||
)
|
||||
require.NotNil(t, result)
|
||||
// 100*0.01 + 50*0.02 = 1.0 + 1.0 = 2.0
|
||||
require.InDelta(t, 2.0, *result, 1e-12)
|
||||
}
|
||||
|
||||
func TestResolveAccountStatsCost_ApplyPricingToAccountStats_UsesTotalCost(t *testing.T) {
|
||||
channel := &Channel{
|
||||
ID: 1,
|
||||
Status: StatusActive,
|
||||
ApplyPricingToAccountStats: true,
|
||||
// No custom rules
|
||||
}
|
||||
cs := newTestChannelServiceForStats(t, channel, 10, "anthropic")
|
||||
|
||||
tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
|
||||
|
||||
result := resolveAccountStatsCost(
|
||||
context.Background(),
|
||||
cs, nil,
|
||||
1, 10, "claude-sonnet-4",
|
||||
tokens, 1, 0.75, // totalCost = 0.75
|
||||
)
|
||||
require.NotNil(t, result)
|
||||
require.InDelta(t, 0.75, *result, 1e-12)
|
||||
}
|
||||
|
||||
func TestResolveAccountStatsCost_ApplyPricingToAccountStats_ZeroTotalCost_ReturnsNil(t *testing.T) {
|
||||
channel := &Channel{
|
||||
ID: 1,
|
||||
Status: StatusActive,
|
||||
ApplyPricingToAccountStats: true,
|
||||
}
|
||||
cs := newTestChannelServiceForStats(t, channel, 10, "anthropic")
|
||||
|
||||
result := resolveAccountStatsCost(
|
||||
context.Background(),
|
||||
cs, nil,
|
||||
1, 10, "claude-sonnet-4",
|
||||
UsageTokens{}, 1, 0.0, // totalCost = 0
|
||||
)
|
||||
require.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestResolveAccountStatsCost_FallsBackToLiteLLM(t *testing.T) {
|
||||
channel := &Channel{
|
||||
ID: 1,
|
||||
Status: StatusActive,
|
||||
ApplyPricingToAccountStats: false, // not enabled
|
||||
// No custom rules
|
||||
}
|
||||
cs := newTestChannelServiceForStats(t, channel, 10, "anthropic")
|
||||
|
||||
bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
|
||||
"claude-sonnet-4": {
|
||||
InputPricePerToken: 0.001,
|
||||
OutputPricePerToken: 0.002,
|
||||
},
|
||||
})
|
||||
|
||||
tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
|
||||
|
||||
result := resolveAccountStatsCost(
|
||||
context.Background(),
|
||||
cs, bs,
|
||||
1, 10, "claude-sonnet-4",
|
||||
tokens, 1, 999.0, // totalCost ignored
|
||||
)
|
||||
require.NotNil(t, result)
|
||||
// 100*0.001 + 50*0.002 = 0.1 + 0.1 = 0.2
|
||||
require.InDelta(t, 0.2, *result, 1e-12)
|
||||
}
|
||||
|
||||
func TestResolveAccountStatsCost_AllMiss_ReturnsNil(t *testing.T) {
|
||||
channel := &Channel{
|
||||
ID: 1,
|
||||
Status: StatusActive,
|
||||
ApplyPricingToAccountStats: false,
|
||||
// No custom rules
|
||||
}
|
||||
cs := newTestChannelServiceForStats(t, channel, 10, "anthropic")
|
||||
|
||||
// BillingService with no pricing for the model
|
||||
bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{})
|
||||
|
||||
tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
|
||||
|
||||
result := resolveAccountStatsCost(
|
||||
context.Background(),
|
||||
cs, bs,
|
||||
1, 10, "totally-unknown-model",
|
||||
tokens, 1, 0.0,
|
||||
)
|
||||
require.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestResolveAccountStatsCost_NilBillingService_SkipsLiteLLM(t *testing.T) {
|
||||
channel := &Channel{
|
||||
ID: 1,
|
||||
Status: StatusActive,
|
||||
ApplyPricingToAccountStats: false,
|
||||
}
|
||||
cs := newTestChannelServiceForStats(t, channel, 10, "anthropic")
|
||||
|
||||
result := resolveAccountStatsCost(
|
||||
context.Background(),
|
||||
cs, nil, // billingService is nil
|
||||
1, 10, "claude-sonnet-4",
|
||||
UsageTokens{InputTokens: 100}, 1, 0.0,
|
||||
)
|
||||
require.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestResolveAccountStatsCost_CustomRulePriorityOverApplyPricing(t *testing.T) {
|
||||
// Both custom rule and ApplyPricingToAccountStats are configured;
|
||||
// custom rule should take precedence.
|
||||
channel := &Channel{
|
||||
ID: 1,
|
||||
Status: StatusActive,
|
||||
ApplyPricingToAccountStats: true,
|
||||
AccountStatsPricingRules: []AccountStatsPricingRule{
|
||||
{
|
||||
GroupIDs: []int64{10},
|
||||
Pricing: []ChannelModelPricing{
|
||||
{
|
||||
ID: 100,
|
||||
Models: []string{"claude-sonnet-4"},
|
||||
InputPrice: testPtrFloat64(0.05),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
cs := newTestChannelServiceForStats(t, channel, 10, "anthropic")
|
||||
|
||||
tokens := UsageTokens{InputTokens: 100}
|
||||
|
||||
result := resolveAccountStatsCost(
|
||||
context.Background(),
|
||||
cs, nil,
|
||||
1, 10, "claude-sonnet-4",
|
||||
tokens, 1, 99.0, // totalCost = 99.0 (would be used if ApplyPricing wins)
|
||||
)
|
||||
require.NotNil(t, result)
|
||||
// Custom rule: 100*0.05 = 5.0 (NOT 99.0 from totalCost)
|
||||
require.InDelta(t, 5.0, *result, 1e-12)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers for resolveAccountStatsCost tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// newTestChannelServiceForStats creates a ChannelService with a single channel
|
||||
// mapped to the given groupID, suitable for resolveAccountStatsCost tests.
|
||||
func newTestChannelServiceForStats(t *testing.T, channel *Channel, groupID int64, platform string) *ChannelService {
|
||||
t.Helper()
|
||||
cache := newEmptyChannelCache()
|
||||
cache.channelByGroupID[groupID] = channel
|
||||
cache.groupPlatform[groupID] = platform
|
||||
cs := &ChannelService{}
|
||||
cache.loadedAt = time.Now()
|
||||
cs.cache.Store(cache)
|
||||
return cs
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user