balance_notify_service_test.go (27 tests): - resolveBalanceThreshold: fixed/percentage/zero recharged/empty type - quotaDim.resolvedThreshold: fixed normal/exceed/equal limit, percentage 0/30/100/>100, zero/negative limit - sanitizeEmailHeader: CRLF/CR/LF/clean/empty/multiple newlines - buildQuotaDims / buildQuotaDimsFromState: all dimensions, empty extra, state-vs-account precedence - collectBalanceNotifyRecipients: empty, filter disabled/unverified, case-insensitive dedup, skip empty, trim balance_notify_check_test.go (16 tests): - CheckBalanceAfterDeduction guard clauses: nil user/disabled/global-off/threshold=0/user-override/no-crossing - CheckAccountQuotaAfterIncrement guards: nil account/zero cost/negative cost/global-disabled - getBalanceNotifyConfig: all fields, disabled, invalid threshold - isAccountQuotaNotifyEnabled: missing/false/true - getSiteName: default fallback + configured balance_notify_email_body_test.go (10 tests): - Guards against fmt.Sprintf arg-count mismatches in email templates - Verifies HTML escaping of recharge URL - Verifies CSS %% escape produces literal % in output - Verifies unlimited/percentage/over-quota display branches payment_config_plans_validation_test.go (13 tests): - validatePlanRequired: all 5 validation branches + whitespace handling
181 lines
6.5 KiB
Go
181 lines
6.5 KiB
Go
//go:build unit
|
|
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// newBalanceNotifyServiceForTest constructs a BalanceNotifyService with an
|
|
// in-memory settings repo and a non-nil emailService so that the guard-clause
|
|
// nil-checks pass. The emailService is intentionally minimal — tests must
|
|
// avoid crossing scenarios that would actually dispatch emails.
|
|
func newBalanceNotifyServiceForTest() (*BalanceNotifyService, *mockSettingRepo) {
|
|
repo := newMockSettingRepo()
|
|
// EmailService is a concrete type; construct with the same repo so that
|
|
// any accidental fallback reads still succeed. Tests should not trigger a
|
|
// crossing that reaches SendEmail.
|
|
email := NewEmailService(repo, nil)
|
|
return NewBalanceNotifyService(email, repo, nil), repo
|
|
}
|
|
|
|
// ---------- guard clauses ----------
|
|
|
|
func TestCheckBalanceAfterDeduction_NilUser(t *testing.T) {
|
|
s, _ := newBalanceNotifyServiceForTest()
|
|
// Should not panic.
|
|
s.CheckBalanceAfterDeduction(context.Background(), nil, 100, 50)
|
|
}
|
|
|
|
func TestCheckBalanceAfterDeduction_UserNotifyDisabled(t *testing.T) {
|
|
s, repo := newBalanceNotifyServiceForTest()
|
|
repo.data[SettingKeyBalanceLowNotifyEnabled] = "true"
|
|
repo.data[SettingKeyBalanceLowNotifyThreshold] = "10"
|
|
u := &User{ID: 1, BalanceNotifyEnabled: false}
|
|
// Even with a crossing, disabled flag short-circuits.
|
|
s.CheckBalanceAfterDeduction(context.Background(), u, 20, 15)
|
|
}
|
|
|
|
func TestCheckBalanceAfterDeduction_GlobalDisabled(t *testing.T) {
|
|
s, repo := newBalanceNotifyServiceForTest()
|
|
repo.data[SettingKeyBalanceLowNotifyEnabled] = "false"
|
|
u := &User{ID: 1, BalanceNotifyEnabled: true}
|
|
s.CheckBalanceAfterDeduction(context.Background(), u, 20, 15)
|
|
}
|
|
|
|
func TestCheckBalanceAfterDeduction_ThresholdZero(t *testing.T) {
|
|
s, repo := newBalanceNotifyServiceForTest()
|
|
repo.data[SettingKeyBalanceLowNotifyEnabled] = "true"
|
|
repo.data[SettingKeyBalanceLowNotifyThreshold] = "0"
|
|
u := &User{ID: 1, BalanceNotifyEnabled: true}
|
|
s.CheckBalanceAfterDeduction(context.Background(), u, 20, 15)
|
|
}
|
|
|
|
func TestCheckBalanceAfterDeduction_UserThresholdOverride(t *testing.T) {
|
|
s, repo := newBalanceNotifyServiceForTest()
|
|
repo.data[SettingKeyBalanceLowNotifyEnabled] = "true"
|
|
repo.data[SettingKeyBalanceLowNotifyThreshold] = "100" // global default
|
|
customThreshold := 5.0
|
|
u := &User{
|
|
ID: 1,
|
|
BalanceNotifyEnabled: true,
|
|
BalanceNotifyThreshold: &customThreshold,
|
|
}
|
|
// User's 5.0 threshold takes precedence over global 100. 20 -> 15 does not
|
|
// cross 5, so nothing fires (verified by absence of panic).
|
|
s.CheckBalanceAfterDeduction(context.Background(), u, 20, 15)
|
|
}
|
|
|
|
func TestCheckBalanceAfterDeduction_NoCrossingNotFired(t *testing.T) {
|
|
s, repo := newBalanceNotifyServiceForTest()
|
|
repo.data[SettingKeyBalanceLowNotifyEnabled] = "true"
|
|
repo.data[SettingKeyBalanceLowNotifyThreshold] = "10"
|
|
u := &User{ID: 1, BalanceNotifyEnabled: true}
|
|
|
|
// 100 -> 95, both remain above threshold=10, no crossing.
|
|
s.CheckBalanceAfterDeduction(context.Background(), u, 100, 5)
|
|
// 5 -> 3, both already below threshold, no crossing (only fires on first
|
|
// cross from above-to-below).
|
|
s.CheckBalanceAfterDeduction(context.Background(), u, 5, 2)
|
|
}
|
|
|
|
// ---------- nil-service guards on CheckAccountQuotaAfterIncrement ----------
|
|
|
|
func TestCheckAccountQuotaAfterIncrement_NilAccount(t *testing.T) {
|
|
s, _ := newBalanceNotifyServiceForTest()
|
|
// Should not panic.
|
|
s.CheckAccountQuotaAfterIncrement(context.Background(), nil, 10, nil)
|
|
}
|
|
|
|
func TestCheckAccountQuotaAfterIncrement_ZeroCost(t *testing.T) {
|
|
s, _ := newBalanceNotifyServiceForTest()
|
|
a := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeAPIKey}
|
|
s.CheckAccountQuotaAfterIncrement(context.Background(), a, 0, nil)
|
|
}
|
|
|
|
func TestCheckAccountQuotaAfterIncrement_NegativeCost(t *testing.T) {
|
|
s, _ := newBalanceNotifyServiceForTest()
|
|
a := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeAPIKey}
|
|
s.CheckAccountQuotaAfterIncrement(context.Background(), a, -5, nil)
|
|
}
|
|
|
|
func TestCheckAccountQuotaAfterIncrement_GlobalDisabled(t *testing.T) {
|
|
s, repo := newBalanceNotifyServiceForTest()
|
|
repo.data[SettingKeyAccountQuotaNotifyEnabled] = "false"
|
|
a := &Account{
|
|
ID: 1,
|
|
Platform: PlatformAnthropic,
|
|
Type: AccountTypeAPIKey,
|
|
Extra: map[string]any{
|
|
"quota_notify_daily_enabled": true,
|
|
"quota_notify_daily_threshold": 100.0,
|
|
"quota_daily_limit": 1000.0,
|
|
"quota_daily_used": 950.0,
|
|
},
|
|
}
|
|
// Global disabled → no processing even if a dim would cross.
|
|
s.CheckAccountQuotaAfterIncrement(context.Background(), a, 100, nil)
|
|
}
|
|
|
|
// ---------- sanity: internal helpers still work ----------
|
|
|
|
func TestGetBalanceNotifyConfig_AllFields(t *testing.T) {
|
|
s, repo := newBalanceNotifyServiceForTest()
|
|
repo.data[SettingKeyBalanceLowNotifyEnabled] = "true"
|
|
repo.data[SettingKeyBalanceLowNotifyThreshold] = "12.5"
|
|
repo.data[SettingKeyBalanceLowNotifyRechargeURL] = "https://example.com/pay"
|
|
|
|
enabled, threshold, url := s.getBalanceNotifyConfig(context.Background())
|
|
require.True(t, enabled)
|
|
require.Equal(t, 12.5, threshold)
|
|
require.Equal(t, "https://example.com/pay", url)
|
|
}
|
|
|
|
func TestGetBalanceNotifyConfig_Disabled(t *testing.T) {
|
|
s, repo := newBalanceNotifyServiceForTest()
|
|
repo.data[SettingKeyBalanceLowNotifyEnabled] = "false"
|
|
|
|
enabled, _, _ := s.getBalanceNotifyConfig(context.Background())
|
|
require.False(t, enabled)
|
|
}
|
|
|
|
func TestGetBalanceNotifyConfig_InvalidThreshold(t *testing.T) {
|
|
s, repo := newBalanceNotifyServiceForTest()
|
|
repo.data[SettingKeyBalanceLowNotifyEnabled] = "true"
|
|
repo.data[SettingKeyBalanceLowNotifyThreshold] = "not-a-number"
|
|
|
|
enabled, threshold, _ := s.getBalanceNotifyConfig(context.Background())
|
|
require.True(t, enabled)
|
|
require.Equal(t, 0.0, threshold)
|
|
}
|
|
|
|
func TestIsAccountQuotaNotifyEnabled(t *testing.T) {
|
|
s, repo := newBalanceNotifyServiceForTest()
|
|
|
|
// Missing key → false
|
|
require.False(t, s.isAccountQuotaNotifyEnabled(context.Background()))
|
|
|
|
// Explicit "false"
|
|
repo.data[SettingKeyAccountQuotaNotifyEnabled] = "false"
|
|
require.False(t, s.isAccountQuotaNotifyEnabled(context.Background()))
|
|
|
|
// Explicit "true"
|
|
repo.data[SettingKeyAccountQuotaNotifyEnabled] = "true"
|
|
require.True(t, s.isAccountQuotaNotifyEnabled(context.Background()))
|
|
}
|
|
|
|
func TestGetSiteName_FallsBackToDefault(t *testing.T) {
|
|
s, _ := newBalanceNotifyServiceForTest()
|
|
name := s.getSiteName(context.Background())
|
|
require.Equal(t, defaultSiteName, name)
|
|
}
|
|
|
|
func TestGetSiteName_Configured(t *testing.T) {
|
|
s, repo := newBalanceNotifyServiceForTest()
|
|
repo.data[SettingKeySiteName] = "My Site"
|
|
require.Equal(t, "My Site", s.getSiteName(context.Background()))
|
|
}
|