test: add 66 unit tests for balance/quota notify + plan validation
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
This commit is contained in:
180
backend/internal/service/balance_notify_check_test.go
Normal file
180
backend/internal/service/balance_notify_check_test.go
Normal file
@@ -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()))
|
||||
}
|
||||
147
backend/internal/service/balance_notify_email_body_test.go
Normal file
147
backend/internal/service/balance_notify_email_body_test.go
Normal file
@@ -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=<script>`)
|
||||
|
||||
// `&` and `<` should be escaped in the href.
|
||||
require.Contains(t, body, "&")
|
||||
require.Contains(t, body, "<script>")
|
||||
require.NotContains(t, body, "<script>")
|
||||
}
|
||||
|
||||
func TestBuildBalanceLowEmailBody_NoRechargeURLOmitsButton(t *testing.T) {
|
||||
s := &BalanceNotifyService{}
|
||||
body := s.buildBalanceLowEmailBody("u", 1.0, 5.0, "Site", "")
|
||||
// The anchor element should not be rendered (style class may still appear).
|
||||
require.NotContains(t, body, `<a href`)
|
||||
require.NotContains(t, body, "立即充值")
|
||||
}
|
||||
|
||||
// ---------- buildQuotaAlertEmailBody ----------
|
||||
|
||||
func TestBuildQuotaAlertEmailBody_AllFieldsPresent(t *testing.T) {
|
||||
s := &BalanceNotifyService{}
|
||||
body := s.buildQuotaAlertEmailBody(
|
||||
42, // accountID
|
||||
"acc-foo", // accountName
|
||||
"anthropic", // platform
|
||||
"日限额 / Daily", // dimLabel
|
||||
750.50, // used
|
||||
1000.0, // limit
|
||||
249.50, // remaining
|
||||
"$249.50", // thresholdDisplay
|
||||
"MySite", // siteName
|
||||
)
|
||||
|
||||
require.Contains(t, body, "MySite")
|
||||
require.Contains(t, body, "#42")
|
||||
require.Contains(t, body, "acc-foo")
|
||||
require.Contains(t, body, "anthropic")
|
||||
require.Contains(t, body, "Daily")
|
||||
require.Contains(t, body, "$750.50")
|
||||
require.Contains(t, body, "$1000.00")
|
||||
require.Contains(t, body, "$249.50")
|
||||
|
||||
// No format error markers.
|
||||
require.NotContains(t, body, "%!")
|
||||
require.NotContains(t, body, "MISSING")
|
||||
require.NotContains(t, body, "EXTRA")
|
||||
}
|
||||
|
||||
func TestBuildQuotaAlertEmailBody_UnlimitedDisplay(t *testing.T) {
|
||||
s := &BalanceNotifyService{}
|
||||
body := s.buildQuotaAlertEmailBody(
|
||||
1, "n", "p", "dim",
|
||||
100.0, 0.0, // limit=0 triggers unlimited branch
|
||||
0.0, "30%", "Site",
|
||||
)
|
||||
require.Contains(t, body, "无限制")
|
||||
require.Contains(t, body, "Unlimited")
|
||||
}
|
||||
|
||||
func TestBuildQuotaAlertEmailBody_PercentageThresholdDisplay(t *testing.T) {
|
||||
s := &BalanceNotifyService{}
|
||||
body := s.buildQuotaAlertEmailBody(
|
||||
1, "n", "p", "dim",
|
||||
700.0, 1000.0, 300.0,
|
||||
"30%", // percentage-formatted threshold
|
||||
"Site",
|
||||
)
|
||||
require.Contains(t, body, "30%")
|
||||
require.NotContains(t, body, "%!")
|
||||
}
|
||||
|
||||
func TestBuildQuotaAlertEmailBody_RemainingClampedAtZero(t *testing.T) {
|
||||
// Even though caller is responsible for clamping, this test documents the
|
||||
// display behavior with remaining=0.
|
||||
s := &BalanceNotifyService{}
|
||||
body := s.buildQuotaAlertEmailBody(
|
||||
1, "n", "p", "dim",
|
||||
1500.0, 1000.0, 0.0, // used > limit (over-quota)
|
||||
"$100.00", "Site",
|
||||
)
|
||||
require.Contains(t, body, "$0.00")
|
||||
}
|
||||
|
||||
// ---------- sanity checks on the CSS `%%` escape ----------
|
||||
|
||||
func TestBuildBalanceLowEmailBody_NoCSSFormatError(t *testing.T) {
|
||||
s := &BalanceNotifyService{}
|
||||
body := s.buildBalanceLowEmailBody("u", 1.0, 5.0, "Site", "")
|
||||
// CSS `linear-gradient(135deg, #f59e0b 0%, #d97706 100%)` should appear with
|
||||
// literal percent signs (from the %% escape in the template).
|
||||
require.True(t,
|
||||
strings.Contains(body, "0%") && strings.Contains(body, "100%"),
|
||||
"CSS gradient percentages not rendered; got: %s", body)
|
||||
}
|
||||
|
||||
func TestBuildQuotaAlertEmailBody_NoCSSFormatError(t *testing.T) {
|
||||
s := &BalanceNotifyService{}
|
||||
body := s.buildQuotaAlertEmailBody(1, "n", "p", "d", 0, 0, 0, "$0.00", "Site")
|
||||
require.True(t,
|
||||
strings.Contains(body, "0%") && strings.Contains(body, "100%"),
|
||||
"CSS gradient percentages not rendered; got: %s", body)
|
||||
}
|
||||
280
backend/internal/service/balance_notify_service_test.go
Normal file
280
backend/internal/service/balance_notify_service_test.go
Normal file
@@ -0,0 +1,280 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ---------- resolveBalanceThreshold ----------
|
||||
|
||||
func TestResolveBalanceThreshold_Fixed(t *testing.T) {
|
||||
// Fixed type always returns the raw threshold regardless of totalRecharged.
|
||||
require.Equal(t, 10.0, resolveBalanceThreshold(10, thresholdTypeFixed, 1000))
|
||||
require.Equal(t, 10.0, resolveBalanceThreshold(10, thresholdTypeFixed, 0))
|
||||
require.Equal(t, 0.0, resolveBalanceThreshold(0, thresholdTypeFixed, 1000))
|
||||
}
|
||||
|
||||
func TestResolveBalanceThreshold_Percentage(t *testing.T) {
|
||||
// 10% of 1000 = 100
|
||||
require.Equal(t, 100.0, resolveBalanceThreshold(10, thresholdTypePercentage, 1000))
|
||||
// 50% of 200 = 100
|
||||
require.Equal(t, 100.0, resolveBalanceThreshold(50, thresholdTypePercentage, 200))
|
||||
}
|
||||
|
||||
func TestResolveBalanceThreshold_PercentageZeroRecharged(t *testing.T) {
|
||||
// When totalRecharged is 0, percentage falls through to raw threshold
|
||||
// (treated as fixed). This is the defensive behavior.
|
||||
require.Equal(t, 10.0, resolveBalanceThreshold(10, thresholdTypePercentage, 0))
|
||||
}
|
||||
|
||||
func TestResolveBalanceThreshold_EmptyType(t *testing.T) {
|
||||
// Empty type is treated as fixed (not percentage).
|
||||
require.Equal(t, 10.0, resolveBalanceThreshold(10, "", 1000))
|
||||
}
|
||||
|
||||
// ---------- quotaDim.resolvedThreshold ----------
|
||||
|
||||
func TestResolvedThreshold_FixedNormal(t *testing.T) {
|
||||
// threshold=400 remaining, limit=1000 → usage trigger at 600
|
||||
d := quotaDim{threshold: 400, thresholdType: thresholdTypeFixed, limit: 1000}
|
||||
require.Equal(t, 600.0, d.resolvedThreshold())
|
||||
}
|
||||
|
||||
func TestResolvedThreshold_FixedThresholdExceedsLimit(t *testing.T) {
|
||||
// threshold=1200, limit=1000 → returns negative, callers must skip
|
||||
d := quotaDim{threshold: 1200, thresholdType: thresholdTypeFixed, limit: 1000}
|
||||
require.Equal(t, -200.0, d.resolvedThreshold())
|
||||
}
|
||||
|
||||
func TestResolvedThreshold_FixedThresholdEqualsLimit(t *testing.T) {
|
||||
// threshold=1000, limit=1000 → returns 0 (alert fires at 0 usage)
|
||||
d := quotaDim{threshold: 1000, thresholdType: thresholdTypeFixed, limit: 1000}
|
||||
require.Equal(t, 0.0, d.resolvedThreshold())
|
||||
}
|
||||
|
||||
func TestResolvedThreshold_PercentageNormal(t *testing.T) {
|
||||
// threshold=30%, limit=1000 → usage trigger at 700 (remaining drops to 30%)
|
||||
d := quotaDim{threshold: 30, thresholdType: thresholdTypePercentage, limit: 1000}
|
||||
require.InDelta(t, 700.0, d.resolvedThreshold(), 0.001)
|
||||
}
|
||||
|
||||
func TestResolvedThreshold_PercentageZeroPercent(t *testing.T) {
|
||||
// threshold=0%, limit=1000 → fires when remaining drops to 0 (usage=1000)
|
||||
d := quotaDim{threshold: 0, thresholdType: thresholdTypePercentage, limit: 1000}
|
||||
require.InDelta(t, 1000.0, d.resolvedThreshold(), 0.001)
|
||||
}
|
||||
|
||||
func TestResolvedThreshold_PercentageHundredPercent(t *testing.T) {
|
||||
// threshold=100%, limit=1000 → fires immediately (remaining drops to 100% i.e. nothing used yet)
|
||||
d := quotaDim{threshold: 100, thresholdType: thresholdTypePercentage, limit: 1000}
|
||||
require.InDelta(t, 0.0, d.resolvedThreshold(), 0.001)
|
||||
}
|
||||
|
||||
func TestResolvedThreshold_PercentageOverHundred(t *testing.T) {
|
||||
// threshold=150%, limit=1000 → returns negative (never triggers; callers skip)
|
||||
d := quotaDim{threshold: 150, thresholdType: thresholdTypePercentage, limit: 1000}
|
||||
require.Less(t, d.resolvedThreshold(), 0.0)
|
||||
}
|
||||
|
||||
func TestResolvedThreshold_ZeroLimit(t *testing.T) {
|
||||
// limit=0 → returns 0 to avoid division and false alerts on unlimited quotas
|
||||
d := quotaDim{threshold: 100, thresholdType: thresholdTypeFixed, limit: 0}
|
||||
require.Equal(t, 0.0, d.resolvedThreshold())
|
||||
}
|
||||
|
||||
func TestResolvedThreshold_NegativeLimit(t *testing.T) {
|
||||
// Negative limit treated as 0
|
||||
d := quotaDim{threshold: 100, thresholdType: thresholdTypeFixed, limit: -10}
|
||||
require.Equal(t, 0.0, d.resolvedThreshold())
|
||||
}
|
||||
|
||||
// ---------- sanitizeEmailHeader ----------
|
||||
|
||||
func TestSanitizeEmailHeader_CRLF(t *testing.T) {
|
||||
require.Equal(t, "Subject injected", sanitizeEmailHeader("Subject\r\n injected"))
|
||||
}
|
||||
|
||||
func TestSanitizeEmailHeader_OnlyCR(t *testing.T) {
|
||||
require.Equal(t, "foobar", sanitizeEmailHeader("foo\rbar"))
|
||||
}
|
||||
|
||||
func TestSanitizeEmailHeader_OnlyLF(t *testing.T) {
|
||||
require.Equal(t, "foobar", sanitizeEmailHeader("foo\nbar"))
|
||||
}
|
||||
|
||||
func TestSanitizeEmailHeader_Clean(t *testing.T) {
|
||||
require.Equal(t, "Sub2API", sanitizeEmailHeader("Sub2API"))
|
||||
}
|
||||
|
||||
func TestSanitizeEmailHeader_Empty(t *testing.T) {
|
||||
require.Equal(t, "", sanitizeEmailHeader(""))
|
||||
}
|
||||
|
||||
func TestSanitizeEmailHeader_MultipleNewlines(t *testing.T) {
|
||||
require.Equal(t, "abc", sanitizeEmailHeader("a\r\nb\r\nc"))
|
||||
}
|
||||
|
||||
// ---------- buildQuotaDims ----------
|
||||
|
||||
func TestBuildQuotaDims_AllDimensionsReturned(t *testing.T) {
|
||||
// Use an account with quota notify config across all 3 dimensions.
|
||||
a := &Account{
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{
|
||||
"quota_notify_daily_enabled": true,
|
||||
"quota_notify_daily_threshold": 100.0,
|
||||
"quota_notify_daily_threshold_type": thresholdTypeFixed,
|
||||
"quota_notify_weekly_enabled": true,
|
||||
"quota_notify_weekly_threshold": 20.0,
|
||||
"quota_notify_weekly_threshold_type": thresholdTypePercentage,
|
||||
"quota_notify_total_enabled": false,
|
||||
"quota_daily_limit": 500.0,
|
||||
"quota_weekly_limit": 2000.0,
|
||||
"quota_limit": 10000.0,
|
||||
"quota_daily_used": 50.0,
|
||||
"quota_weekly_used": 300.0,
|
||||
"quota_used": 1000.0,
|
||||
},
|
||||
}
|
||||
|
||||
dims := buildQuotaDims(a)
|
||||
require.Len(t, dims, 3)
|
||||
|
||||
// Daily
|
||||
require.Equal(t, quotaDimDaily, dims[0].name)
|
||||
require.True(t, dims[0].enabled)
|
||||
require.Equal(t, 100.0, dims[0].threshold)
|
||||
require.Equal(t, thresholdTypeFixed, dims[0].thresholdType)
|
||||
require.Equal(t, 500.0, dims[0].limit)
|
||||
require.Equal(t, 50.0, dims[0].currentUsed)
|
||||
|
||||
// Weekly
|
||||
require.Equal(t, quotaDimWeekly, dims[1].name)
|
||||
require.True(t, dims[1].enabled)
|
||||
require.Equal(t, 20.0, dims[1].threshold)
|
||||
require.Equal(t, thresholdTypePercentage, dims[1].thresholdType)
|
||||
require.Equal(t, 2000.0, dims[1].limit)
|
||||
|
||||
// Total
|
||||
require.Equal(t, quotaDimTotal, dims[2].name)
|
||||
require.False(t, dims[2].enabled)
|
||||
require.Equal(t, 10000.0, dims[2].limit)
|
||||
require.Equal(t, 1000.0, dims[2].currentUsed)
|
||||
}
|
||||
|
||||
func TestBuildQuotaDims_EmptyExtra(t *testing.T) {
|
||||
// Missing fields default to zero/disabled.
|
||||
a := &Account{
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{},
|
||||
}
|
||||
dims := buildQuotaDims(a)
|
||||
require.Len(t, dims, 3)
|
||||
for _, d := range dims {
|
||||
require.False(t, d.enabled)
|
||||
require.Equal(t, 0.0, d.threshold)
|
||||
require.Equal(t, 0.0, d.limit)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- buildQuotaDimsFromState ----------
|
||||
|
||||
func TestBuildQuotaDimsFromState_UsesStateValues(t *testing.T) {
|
||||
// Usage values should come from the state, not the account.
|
||||
a := &Account{
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{
|
||||
"quota_notify_daily_enabled": true,
|
||||
"quota_notify_daily_threshold": 100.0,
|
||||
"quota_daily_used": 999.0, // should be ignored
|
||||
"quota_daily_limit": 999.0, // should be ignored
|
||||
},
|
||||
}
|
||||
state := &AccountQuotaState{
|
||||
DailyUsed: 77.0,
|
||||
DailyLimit: 500.0,
|
||||
WeeklyUsed: 88.0,
|
||||
WeeklyLimit: 2000.0,
|
||||
TotalUsed: 99.0,
|
||||
TotalLimit: 10000.0,
|
||||
}
|
||||
dims := buildQuotaDimsFromState(a, state)
|
||||
require.Len(t, dims, 3)
|
||||
// Settings from account (enabled, threshold, thresholdType)
|
||||
require.True(t, dims[0].enabled)
|
||||
require.Equal(t, 100.0, dims[0].threshold)
|
||||
// Usage from state
|
||||
require.Equal(t, 77.0, dims[0].currentUsed)
|
||||
require.Equal(t, 500.0, dims[0].limit)
|
||||
require.Equal(t, 88.0, dims[1].currentUsed)
|
||||
require.Equal(t, 2000.0, dims[1].limit)
|
||||
require.Equal(t, 99.0, dims[2].currentUsed)
|
||||
require.Equal(t, 10000.0, dims[2].limit)
|
||||
}
|
||||
|
||||
// ---------- collectBalanceNotifyRecipients ----------
|
||||
|
||||
func TestCollectBalanceNotifyRecipients_Empty(t *testing.T) {
|
||||
s := &BalanceNotifyService{}
|
||||
u := &User{BalanceNotifyExtraEmails: nil}
|
||||
require.Empty(t, s.collectBalanceNotifyRecipients(u))
|
||||
}
|
||||
|
||||
func TestCollectBalanceNotifyRecipients_FiltersDisabledAndUnverified(t *testing.T) {
|
||||
s := &BalanceNotifyService{}
|
||||
u := &User{
|
||||
BalanceNotifyExtraEmails: []NotifyEmailEntry{
|
||||
{Email: "a@example.com", Verified: true, Disabled: false},
|
||||
{Email: "b@example.com", Verified: true, Disabled: true}, // disabled
|
||||
{Email: "c@example.com", Verified: false, Disabled: false}, // unverified
|
||||
{Email: "d@example.com", Verified: true, Disabled: false},
|
||||
},
|
||||
}
|
||||
got := s.collectBalanceNotifyRecipients(u)
|
||||
require.Equal(t, []string{"a@example.com", "d@example.com"}, got)
|
||||
}
|
||||
|
||||
func TestCollectBalanceNotifyRecipients_DeduplicatesCaseInsensitive(t *testing.T) {
|
||||
s := &BalanceNotifyService{}
|
||||
u := &User{
|
||||
BalanceNotifyExtraEmails: []NotifyEmailEntry{
|
||||
{Email: "User@Example.com", Verified: true},
|
||||
{Email: "user@example.com", Verified: true},
|
||||
{Email: "USER@EXAMPLE.COM", Verified: true},
|
||||
},
|
||||
}
|
||||
got := s.collectBalanceNotifyRecipients(u)
|
||||
require.Len(t, got, 1)
|
||||
// The original casing of the first entry is preserved.
|
||||
require.Equal(t, "User@Example.com", got[0])
|
||||
}
|
||||
|
||||
func TestCollectBalanceNotifyRecipients_SkipsEmpty(t *testing.T) {
|
||||
s := &BalanceNotifyService{}
|
||||
u := &User{
|
||||
BalanceNotifyExtraEmails: []NotifyEmailEntry{
|
||||
{Email: " ", Verified: true},
|
||||
{Email: "", Verified: true},
|
||||
{Email: "valid@example.com", Verified: true},
|
||||
},
|
||||
}
|
||||
got := s.collectBalanceNotifyRecipients(u)
|
||||
require.Equal(t, []string{"valid@example.com"}, got)
|
||||
}
|
||||
|
||||
func TestCollectBalanceNotifyRecipients_TrimsWhitespace(t *testing.T) {
|
||||
s := &BalanceNotifyService{}
|
||||
u := &User{
|
||||
BalanceNotifyExtraEmails: []NotifyEmailEntry{
|
||||
{Email: " trimmed@example.com ", Verified: true},
|
||||
},
|
||||
}
|
||||
got := s.collectBalanceNotifyRecipients(u)
|
||||
require.Equal(t, []string{"trimmed@example.com"}, got)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidatePlanRequired_AllValid(t *testing.T) {
|
||||
err := validatePlanRequired("Pro", 1, 9.99, 30, "days")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestValidatePlanRequired_EmptyName(t *testing.T) {
|
||||
err := validatePlanRequired("", 1, 9.99, 30, "days")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "plan name")
|
||||
}
|
||||
|
||||
func TestValidatePlanRequired_WhitespaceName(t *testing.T) {
|
||||
err := validatePlanRequired(" ", 1, 9.99, 30, "days")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "plan name")
|
||||
}
|
||||
|
||||
func TestValidatePlanRequired_ZeroGroupID(t *testing.T) {
|
||||
err := validatePlanRequired("Pro", 0, 9.99, 30, "days")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "group")
|
||||
}
|
||||
|
||||
func TestValidatePlanRequired_NegativeGroupID(t *testing.T) {
|
||||
err := validatePlanRequired("Pro", -1, 9.99, 30, "days")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "group")
|
||||
}
|
||||
|
||||
func TestValidatePlanRequired_ZeroPrice(t *testing.T) {
|
||||
err := validatePlanRequired("Pro", 1, 0, 30, "days")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "price")
|
||||
}
|
||||
|
||||
func TestValidatePlanRequired_NegativePrice(t *testing.T) {
|
||||
err := validatePlanRequired("Pro", 1, -5, 30, "days")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "price")
|
||||
}
|
||||
|
||||
func TestValidatePlanRequired_ZeroValidityDays(t *testing.T) {
|
||||
err := validatePlanRequired("Pro", 1, 9.99, 0, "days")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "validity days")
|
||||
}
|
||||
|
||||
func TestValidatePlanRequired_NegativeValidityDays(t *testing.T) {
|
||||
err := validatePlanRequired("Pro", 1, 9.99, -7, "days")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "validity days")
|
||||
}
|
||||
|
||||
func TestValidatePlanRequired_EmptyValidityUnit(t *testing.T) {
|
||||
err := validatePlanRequired("Pro", 1, 9.99, 30, "")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "validity unit")
|
||||
}
|
||||
|
||||
func TestValidatePlanRequired_WhitespaceValidityUnit(t *testing.T) {
|
||||
err := validatePlanRequired("Pro", 1, 9.99, 30, " ")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "validity unit")
|
||||
}
|
||||
|
||||
func TestValidatePlanRequired_NameValidatedFirst(t *testing.T) {
|
||||
// When multiple fields are invalid, name should be reported first
|
||||
// (follows the order of checks in the function).
|
||||
err := validatePlanRequired("", 0, 0, 0, "")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "plan name")
|
||||
}
|
||||
|
||||
func TestValidatePlanRequired_TrimmedValidName(t *testing.T) {
|
||||
// Whitespace-surrounded but non-empty name is accepted (trimmed check only
|
||||
// rejects pure whitespace).
|
||||
err := validatePlanRequired(" Pro ", 1, 9.99, 30, "days")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
Reference in New Issue
Block a user