| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404 |
- //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()))
- }
- // ---------- crossedDownward ----------
- func TestCrossedDownward_CrossesBelow(t *testing.T) {
- // oldBalance > threshold, newBalance < threshold → true
- require.True(t, crossedDownward(100, 5, 10))
- }
- func TestCrossedDownward_ExactlyAtThreshold(t *testing.T) {
- // oldBalance > threshold, newBalance == threshold → false (not below)
- require.False(t, crossedDownward(100, 10, 10))
- }
- func TestCrossedDownward_OldExactlyAtThreshold_NewBelow(t *testing.T) {
- // oldBalance == threshold, newBalance < threshold → true
- // (at-or-above → below counts as a crossing)
- require.True(t, crossedDownward(10, 5, 10))
- }
- func TestCrossedDownward_AlreadyBelow(t *testing.T) {
- // oldBalance < threshold → false (already below, no new crossing)
- require.False(t, crossedDownward(5, 3, 10))
- }
- func TestCrossedDownward_BothAbove(t *testing.T) {
- // oldBalance > threshold, newBalance > threshold → false (no crossing)
- require.False(t, crossedDownward(100, 50, 10))
- }
- func TestCrossedDownward_ZeroThreshold(t *testing.T) {
- // threshold == 0 → oldV >= 0 is always true, but newV < 0 only for negatives
- // Typical case: positive balances should not fire when threshold is 0.
- require.False(t, crossedDownward(10, 5, 0))
- require.False(t, crossedDownward(0, 0, 0))
- }
- func TestCrossedDownward_ZeroThreshold_NegativeNew(t *testing.T) {
- // Edge case: newBalance goes negative with threshold=0.
- require.True(t, crossedDownward(5, -1, 0))
- }
- func TestCrossedDownward_NegativeValues(t *testing.T) {
- // Both already negative, threshold is positive → no crossing (already below).
- require.False(t, crossedDownward(-5, -10, 10))
- }
- func TestCrossedDownward_LargeDecrement(t *testing.T) {
- // A single large deduction crosses the threshold.
- require.True(t, crossedDownward(1000, 0.5, 100))
- }
- func TestCrossedDownward_SmallDecrement_NoCrossing(t *testing.T) {
- // A tiny deduction stays above threshold.
- require.False(t, crossedDownward(100, 99.99, 10))
- }
- // ---------- checkQuotaDimCrossings ----------
- func TestCheckQuotaDimCrossings_NoDimensions(t *testing.T) {
- s, _ := newBalanceNotifyServiceForTest()
- account := &Account{ID: 1, Name: "test", Platform: PlatformAnthropic}
- // Empty dims → no crossing, no panic.
- s.checkQuotaDimCrossings(account, nil, 10, []string{"admin@example.com"}, "TestSite")
- s.checkQuotaDimCrossings(account, []quotaDim{}, 10, []string{"admin@example.com"}, "TestSite")
- }
- func TestCheckQuotaDimCrossings_DisabledDimension(t *testing.T) {
- s, _ := newBalanceNotifyServiceForTest()
- account := &Account{ID: 1, Name: "test", Platform: PlatformAnthropic}
- dims := []quotaDim{
- {
- name: quotaDimDaily,
- enabled: false, // disabled
- threshold: 100,
- thresholdType: thresholdTypeFixed,
- currentUsed: 950,
- limit: 1000,
- },
- }
- // Disabled dimension should be skipped even if crossing would occur.
- s.checkQuotaDimCrossings(account, dims, 50, []string{"admin@example.com"}, "TestSite")
- }
- func TestCheckQuotaDimCrossings_ZeroThresholdSkipped(t *testing.T) {
- s, _ := newBalanceNotifyServiceForTest()
- account := &Account{ID: 1, Name: "test", Platform: PlatformAnthropic}
- dims := []quotaDim{
- {
- name: quotaDimDaily,
- enabled: true,
- threshold: 0, // zero threshold
- thresholdType: thresholdTypeFixed,
- currentUsed: 950,
- limit: 1000,
- },
- }
- // Zero threshold → skipped.
- s.checkQuotaDimCrossings(account, dims, 50, []string{"admin@example.com"}, "TestSite")
- }
- func TestCheckQuotaDimCrossings_NoCrossing_BothBelowThreshold(t *testing.T) {
- s, _ := newBalanceNotifyServiceForTest()
- account := &Account{ID: 1, Name: "test", Platform: PlatformAnthropic}
- // threshold=400 remaining, limit=1000 → effectiveThreshold = 600 (usage trigger)
- // currentUsed=300 (after), oldUsed=300-50=250 (before). Both < 600, no crossing.
- dims := []quotaDim{
- {
- name: quotaDimDaily,
- enabled: true,
- threshold: 400,
- thresholdType: thresholdTypeFixed,
- currentUsed: 300,
- limit: 1000,
- },
- }
- s.checkQuotaDimCrossings(account, dims, 50, []string{"admin@example.com"}, "TestSite")
- }
- func TestCheckQuotaDimCrossings_NoCrossing_BothAboveThreshold(t *testing.T) {
- s, _ := newBalanceNotifyServiceForTest()
- account := &Account{ID: 1, Name: "test", Platform: PlatformAnthropic}
- // threshold=400 remaining, limit=1000 → effectiveThreshold = 600 (usage trigger)
- // currentUsed=800 (after), oldUsed=800-50=750 (before). Both >= 600, no crossing.
- dims := []quotaDim{
- {
- name: quotaDimDaily,
- enabled: true,
- threshold: 400,
- thresholdType: thresholdTypeFixed,
- currentUsed: 800,
- limit: 1000,
- },
- }
- s.checkQuotaDimCrossings(account, dims, 50, []string{"admin@example.com"}, "TestSite")
- }
- func TestCheckQuotaDimCrossings_NegativeResolvedThreshold_Skipped(t *testing.T) {
- s, _ := newBalanceNotifyServiceForTest()
- account := &Account{ID: 1, Name: "test", Platform: PlatformAnthropic}
- // threshold=1200 remaining, limit=1000 → effectiveThreshold = 1000-1200 = -200
- // Negative resolved threshold → skipped.
- dims := []quotaDim{
- {
- name: quotaDimDaily,
- enabled: true,
- threshold: 1200,
- thresholdType: thresholdTypeFixed,
- currentUsed: 950,
- limit: 1000,
- },
- }
- s.checkQuotaDimCrossings(account, dims, 50, []string{"admin@example.com"}, "TestSite")
- }
- func TestCheckQuotaDimCrossings_PercentageThreshold_NoCrossing(t *testing.T) {
- s, _ := newBalanceNotifyServiceForTest()
- account := &Account{ID: 1, Name: "test", Platform: PlatformAnthropic}
- // threshold=30%, limit=1000 → effectiveThreshold = 1000 * (1 - 0.30) = 700
- // currentUsed=500, oldUsed=500-50=450. Both < 700, no crossing.
- dims := []quotaDim{
- {
- name: quotaDimWeekly,
- enabled: true,
- threshold: 30,
- thresholdType: thresholdTypePercentage,
- currentUsed: 500,
- limit: 1000,
- },
- }
- s.checkQuotaDimCrossings(account, dims, 50, []string{"admin@example.com"}, "TestSite")
- }
- func TestCheckQuotaDimCrossings_ZeroLimit_Skipped(t *testing.T) {
- s, _ := newBalanceNotifyServiceForTest()
- account := &Account{ID: 1, Name: "test", Platform: PlatformAnthropic}
- // limit=0 → resolvedThreshold returns 0 → skipped.
- dims := []quotaDim{
- {
- name: quotaDimTotal,
- enabled: true,
- threshold: 100,
- thresholdType: thresholdTypeFixed,
- currentUsed: 50,
- limit: 0,
- },
- }
- s.checkQuotaDimCrossings(account, dims, 50, []string{"admin@example.com"}, "TestSite")
- }
- func TestCheckQuotaDimCrossings_MultipleDims_MixedResults(t *testing.T) {
- s, _ := newBalanceNotifyServiceForTest()
- account := &Account{ID: 1, Name: "test", Platform: PlatformAnthropic}
- // dim1: no crossing (both below effective threshold)
- // dim2: disabled (skipped)
- // dim3: zero threshold (skipped)
- dims := []quotaDim{
- {
- name: quotaDimDaily,
- enabled: true,
- threshold: 400,
- thresholdType: thresholdTypeFixed,
- currentUsed: 300, // oldUsed=250, effectiveThreshold=600, both below
- limit: 1000,
- },
- {
- name: quotaDimWeekly,
- enabled: false,
- threshold: 100,
- thresholdType: thresholdTypeFixed,
- currentUsed: 900,
- limit: 1000,
- },
- {
- name: quotaDimTotal,
- enabled: true,
- threshold: 0,
- thresholdType: thresholdTypeFixed,
- currentUsed: 500,
- limit: 1000,
- },
- }
- // None should trigger. No panic expected.
- s.checkQuotaDimCrossings(account, dims, 50, []string{"admin@example.com"}, "TestSite")
- }
|