diff --git a/backend/internal/service/balance_notify_check_test.go b/backend/internal/service/balance_notify_check_test.go new file mode 100644 index 00000000..955f3129 --- /dev/null +++ b/backend/internal/service/balance_notify_check_test.go @@ -0,0 +1,180 @@ +//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())) +} diff --git a/backend/internal/service/balance_notify_email_body_test.go b/backend/internal/service/balance_notify_email_body_test.go new file mode 100644 index 00000000..9baf164e --- /dev/null +++ b/backend/internal/service/balance_notify_email_body_test.go @@ -0,0 +1,147 @@ +//go:build unit + +package service + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// These tests guard against fmt.Sprintf arg-count mismatches in the email +// templates. A mismatch would produce "%!(EXTRA ...)" or "%!v(MISSING)" in +// the output, which these assertions will catch. + +// ---------- buildBalanceLowEmailBody ---------- + +func TestBuildBalanceLowEmailBody_ContainsRequiredFields(t *testing.T) { + s := &BalanceNotifyService{} + body := s.buildBalanceLowEmailBody("Alice", 3.14, 10.0, "MySite", "") + + // All substituted values should appear in the output. + require.Contains(t, body, "MySite") + require.Contains(t, body, "Alice") + require.Contains(t, body, "$3.14") + require.Contains(t, body, "$10.00") + + // No fmt.Sprintf format error markers. + require.NotContains(t, body, "%!") + require.NotContains(t, body, "MISSING") + require.NotContains(t, body, "EXTRA") +} + +func TestBuildBalanceLowEmailBody_WithRechargeURL(t *testing.T) { + s := &BalanceNotifyService{} + body := s.buildBalanceLowEmailBody("Bob", 5.0, 20.0, "Site", "https://example.com/pay") + + // The recharge anchor element should appear with the URL. + require.Contains(t, body, `href="https://example.com/pay"`) + require.Contains(t, body, "立即充值") + require.NotContains(t, body, "%!") +} + +func TestBuildBalanceLowEmailBody_RechargeURLEscaped(t *testing.T) { + s := &BalanceNotifyService{} + // Try a URL with characters that need HTML escaping. + body := s.buildBalanceLowEmailBody("u", 1.0, 5.0, "Site", `https://example.com/?a=1&b=