balance_notify_check_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "testing"
  6. "github.com/stretchr/testify/require"
  7. )
  8. // newBalanceNotifyServiceForTest constructs a BalanceNotifyService with an
  9. // in-memory settings repo and a non-nil emailService so that the guard-clause
  10. // nil-checks pass. The emailService is intentionally minimal — tests must
  11. // avoid crossing scenarios that would actually dispatch emails.
  12. func newBalanceNotifyServiceForTest() (*BalanceNotifyService, *mockSettingRepo) {
  13. repo := newMockSettingRepo()
  14. // EmailService is a concrete type; construct with the same repo so that
  15. // any accidental fallback reads still succeed. Tests should not trigger a
  16. // crossing that reaches SendEmail.
  17. email := NewEmailService(repo, nil)
  18. return NewBalanceNotifyService(email, repo, nil), repo
  19. }
  20. // ---------- guard clauses ----------
  21. func TestCheckBalanceAfterDeduction_NilUser(t *testing.T) {
  22. s, _ := newBalanceNotifyServiceForTest()
  23. // Should not panic.
  24. s.CheckBalanceAfterDeduction(context.Background(), nil, 100, 50)
  25. }
  26. func TestCheckBalanceAfterDeduction_UserNotifyDisabled(t *testing.T) {
  27. s, repo := newBalanceNotifyServiceForTest()
  28. repo.data[SettingKeyBalanceLowNotifyEnabled] = "true"
  29. repo.data[SettingKeyBalanceLowNotifyThreshold] = "10"
  30. u := &User{ID: 1, BalanceNotifyEnabled: false}
  31. // Even with a crossing, disabled flag short-circuits.
  32. s.CheckBalanceAfterDeduction(context.Background(), u, 20, 15)
  33. }
  34. func TestCheckBalanceAfterDeduction_GlobalDisabled(t *testing.T) {
  35. s, repo := newBalanceNotifyServiceForTest()
  36. repo.data[SettingKeyBalanceLowNotifyEnabled] = "false"
  37. u := &User{ID: 1, BalanceNotifyEnabled: true}
  38. s.CheckBalanceAfterDeduction(context.Background(), u, 20, 15)
  39. }
  40. func TestCheckBalanceAfterDeduction_ThresholdZero(t *testing.T) {
  41. s, repo := newBalanceNotifyServiceForTest()
  42. repo.data[SettingKeyBalanceLowNotifyEnabled] = "true"
  43. repo.data[SettingKeyBalanceLowNotifyThreshold] = "0"
  44. u := &User{ID: 1, BalanceNotifyEnabled: true}
  45. s.CheckBalanceAfterDeduction(context.Background(), u, 20, 15)
  46. }
  47. func TestCheckBalanceAfterDeduction_UserThresholdOverride(t *testing.T) {
  48. s, repo := newBalanceNotifyServiceForTest()
  49. repo.data[SettingKeyBalanceLowNotifyEnabled] = "true"
  50. repo.data[SettingKeyBalanceLowNotifyThreshold] = "100" // global default
  51. customThreshold := 5.0
  52. u := &User{
  53. ID: 1,
  54. BalanceNotifyEnabled: true,
  55. BalanceNotifyThreshold: &customThreshold,
  56. }
  57. // User's 5.0 threshold takes precedence over global 100. 20 -> 15 does not
  58. // cross 5, so nothing fires (verified by absence of panic).
  59. s.CheckBalanceAfterDeduction(context.Background(), u, 20, 15)
  60. }
  61. func TestCheckBalanceAfterDeduction_NoCrossingNotFired(t *testing.T) {
  62. s, repo := newBalanceNotifyServiceForTest()
  63. repo.data[SettingKeyBalanceLowNotifyEnabled] = "true"
  64. repo.data[SettingKeyBalanceLowNotifyThreshold] = "10"
  65. u := &User{ID: 1, BalanceNotifyEnabled: true}
  66. // 100 -> 95, both remain above threshold=10, no crossing.
  67. s.CheckBalanceAfterDeduction(context.Background(), u, 100, 5)
  68. // 5 -> 3, both already below threshold, no crossing (only fires on first
  69. // cross from above-to-below).
  70. s.CheckBalanceAfterDeduction(context.Background(), u, 5, 2)
  71. }
  72. // ---------- nil-service guards on CheckAccountQuotaAfterIncrement ----------
  73. func TestCheckAccountQuotaAfterIncrement_NilAccount(t *testing.T) {
  74. s, _ := newBalanceNotifyServiceForTest()
  75. // Should not panic.
  76. s.CheckAccountQuotaAfterIncrement(context.Background(), nil, 10, nil)
  77. }
  78. func TestCheckAccountQuotaAfterIncrement_ZeroCost(t *testing.T) {
  79. s, _ := newBalanceNotifyServiceForTest()
  80. a := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeAPIKey}
  81. s.CheckAccountQuotaAfterIncrement(context.Background(), a, 0, nil)
  82. }
  83. func TestCheckAccountQuotaAfterIncrement_NegativeCost(t *testing.T) {
  84. s, _ := newBalanceNotifyServiceForTest()
  85. a := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeAPIKey}
  86. s.CheckAccountQuotaAfterIncrement(context.Background(), a, -5, nil)
  87. }
  88. func TestCheckAccountQuotaAfterIncrement_GlobalDisabled(t *testing.T) {
  89. s, repo := newBalanceNotifyServiceForTest()
  90. repo.data[SettingKeyAccountQuotaNotifyEnabled] = "false"
  91. a := &Account{
  92. ID: 1,
  93. Platform: PlatformAnthropic,
  94. Type: AccountTypeAPIKey,
  95. Extra: map[string]any{
  96. "quota_notify_daily_enabled": true,
  97. "quota_notify_daily_threshold": 100.0,
  98. "quota_daily_limit": 1000.0,
  99. "quota_daily_used": 950.0,
  100. },
  101. }
  102. // Global disabled → no processing even if a dim would cross.
  103. s.CheckAccountQuotaAfterIncrement(context.Background(), a, 100, nil)
  104. }
  105. // ---------- sanity: internal helpers still work ----------
  106. func TestGetBalanceNotifyConfig_AllFields(t *testing.T) {
  107. s, repo := newBalanceNotifyServiceForTest()
  108. repo.data[SettingKeyBalanceLowNotifyEnabled] = "true"
  109. repo.data[SettingKeyBalanceLowNotifyThreshold] = "12.5"
  110. repo.data[SettingKeyBalanceLowNotifyRechargeURL] = "https://example.com/pay"
  111. enabled, threshold, url := s.getBalanceNotifyConfig(context.Background())
  112. require.True(t, enabled)
  113. require.Equal(t, 12.5, threshold)
  114. require.Equal(t, "https://example.com/pay", url)
  115. }
  116. func TestGetBalanceNotifyConfig_Disabled(t *testing.T) {
  117. s, repo := newBalanceNotifyServiceForTest()
  118. repo.data[SettingKeyBalanceLowNotifyEnabled] = "false"
  119. enabled, _, _ := s.getBalanceNotifyConfig(context.Background())
  120. require.False(t, enabled)
  121. }
  122. func TestGetBalanceNotifyConfig_InvalidThreshold(t *testing.T) {
  123. s, repo := newBalanceNotifyServiceForTest()
  124. repo.data[SettingKeyBalanceLowNotifyEnabled] = "true"
  125. repo.data[SettingKeyBalanceLowNotifyThreshold] = "not-a-number"
  126. enabled, threshold, _ := s.getBalanceNotifyConfig(context.Background())
  127. require.True(t, enabled)
  128. require.Equal(t, 0.0, threshold)
  129. }
  130. func TestIsAccountQuotaNotifyEnabled(t *testing.T) {
  131. s, repo := newBalanceNotifyServiceForTest()
  132. // Missing key → false
  133. require.False(t, s.isAccountQuotaNotifyEnabled(context.Background()))
  134. // Explicit "false"
  135. repo.data[SettingKeyAccountQuotaNotifyEnabled] = "false"
  136. require.False(t, s.isAccountQuotaNotifyEnabled(context.Background()))
  137. // Explicit "true"
  138. repo.data[SettingKeyAccountQuotaNotifyEnabled] = "true"
  139. require.True(t, s.isAccountQuotaNotifyEnabled(context.Background()))
  140. }
  141. func TestGetSiteName_FallsBackToDefault(t *testing.T) {
  142. s, _ := newBalanceNotifyServiceForTest()
  143. name := s.getSiteName(context.Background())
  144. require.Equal(t, defaultSiteName, name)
  145. }
  146. func TestGetSiteName_Configured(t *testing.T) {
  147. s, repo := newBalanceNotifyServiceForTest()
  148. repo.data[SettingKeySiteName] = "My Site"
  149. require.Equal(t, "My Site", s.getSiteName(context.Background()))
  150. }
  151. // ---------- crossedDownward ----------
  152. func TestCrossedDownward_CrossesBelow(t *testing.T) {
  153. // oldBalance > threshold, newBalance < threshold → true
  154. require.True(t, crossedDownward(100, 5, 10))
  155. }
  156. func TestCrossedDownward_ExactlyAtThreshold(t *testing.T) {
  157. // oldBalance > threshold, newBalance == threshold → false (not below)
  158. require.False(t, crossedDownward(100, 10, 10))
  159. }
  160. func TestCrossedDownward_OldExactlyAtThreshold_NewBelow(t *testing.T) {
  161. // oldBalance == threshold, newBalance < threshold → true
  162. // (at-or-above → below counts as a crossing)
  163. require.True(t, crossedDownward(10, 5, 10))
  164. }
  165. func TestCrossedDownward_AlreadyBelow(t *testing.T) {
  166. // oldBalance < threshold → false (already below, no new crossing)
  167. require.False(t, crossedDownward(5, 3, 10))
  168. }
  169. func TestCrossedDownward_BothAbove(t *testing.T) {
  170. // oldBalance > threshold, newBalance > threshold → false (no crossing)
  171. require.False(t, crossedDownward(100, 50, 10))
  172. }
  173. func TestCrossedDownward_ZeroThreshold(t *testing.T) {
  174. // threshold == 0 → oldV >= 0 is always true, but newV < 0 only for negatives
  175. // Typical case: positive balances should not fire when threshold is 0.
  176. require.False(t, crossedDownward(10, 5, 0))
  177. require.False(t, crossedDownward(0, 0, 0))
  178. }
  179. func TestCrossedDownward_ZeroThreshold_NegativeNew(t *testing.T) {
  180. // Edge case: newBalance goes negative with threshold=0.
  181. require.True(t, crossedDownward(5, -1, 0))
  182. }
  183. func TestCrossedDownward_NegativeValues(t *testing.T) {
  184. // Both already negative, threshold is positive → no crossing (already below).
  185. require.False(t, crossedDownward(-5, -10, 10))
  186. }
  187. func TestCrossedDownward_LargeDecrement(t *testing.T) {
  188. // A single large deduction crosses the threshold.
  189. require.True(t, crossedDownward(1000, 0.5, 100))
  190. }
  191. func TestCrossedDownward_SmallDecrement_NoCrossing(t *testing.T) {
  192. // A tiny deduction stays above threshold.
  193. require.False(t, crossedDownward(100, 99.99, 10))
  194. }
  195. // ---------- checkQuotaDimCrossings ----------
  196. func TestCheckQuotaDimCrossings_NoDimensions(t *testing.T) {
  197. s, _ := newBalanceNotifyServiceForTest()
  198. account := &Account{ID: 1, Name: "test", Platform: PlatformAnthropic}
  199. // Empty dims → no crossing, no panic.
  200. s.checkQuotaDimCrossings(account, nil, 10, []string{"admin@example.com"}, "TestSite")
  201. s.checkQuotaDimCrossings(account, []quotaDim{}, 10, []string{"admin@example.com"}, "TestSite")
  202. }
  203. func TestCheckQuotaDimCrossings_DisabledDimension(t *testing.T) {
  204. s, _ := newBalanceNotifyServiceForTest()
  205. account := &Account{ID: 1, Name: "test", Platform: PlatformAnthropic}
  206. dims := []quotaDim{
  207. {
  208. name: quotaDimDaily,
  209. enabled: false, // disabled
  210. threshold: 100,
  211. thresholdType: thresholdTypeFixed,
  212. currentUsed: 950,
  213. limit: 1000,
  214. },
  215. }
  216. // Disabled dimension should be skipped even if crossing would occur.
  217. s.checkQuotaDimCrossings(account, dims, 50, []string{"admin@example.com"}, "TestSite")
  218. }
  219. func TestCheckQuotaDimCrossings_ZeroThresholdSkipped(t *testing.T) {
  220. s, _ := newBalanceNotifyServiceForTest()
  221. account := &Account{ID: 1, Name: "test", Platform: PlatformAnthropic}
  222. dims := []quotaDim{
  223. {
  224. name: quotaDimDaily,
  225. enabled: true,
  226. threshold: 0, // zero threshold
  227. thresholdType: thresholdTypeFixed,
  228. currentUsed: 950,
  229. limit: 1000,
  230. },
  231. }
  232. // Zero threshold → skipped.
  233. s.checkQuotaDimCrossings(account, dims, 50, []string{"admin@example.com"}, "TestSite")
  234. }
  235. func TestCheckQuotaDimCrossings_NoCrossing_BothBelowThreshold(t *testing.T) {
  236. s, _ := newBalanceNotifyServiceForTest()
  237. account := &Account{ID: 1, Name: "test", Platform: PlatformAnthropic}
  238. // threshold=400 remaining, limit=1000 → effectiveThreshold = 600 (usage trigger)
  239. // currentUsed=300 (after), oldUsed=300-50=250 (before). Both < 600, no crossing.
  240. dims := []quotaDim{
  241. {
  242. name: quotaDimDaily,
  243. enabled: true,
  244. threshold: 400,
  245. thresholdType: thresholdTypeFixed,
  246. currentUsed: 300,
  247. limit: 1000,
  248. },
  249. }
  250. s.checkQuotaDimCrossings(account, dims, 50, []string{"admin@example.com"}, "TestSite")
  251. }
  252. func TestCheckQuotaDimCrossings_NoCrossing_BothAboveThreshold(t *testing.T) {
  253. s, _ := newBalanceNotifyServiceForTest()
  254. account := &Account{ID: 1, Name: "test", Platform: PlatformAnthropic}
  255. // threshold=400 remaining, limit=1000 → effectiveThreshold = 600 (usage trigger)
  256. // currentUsed=800 (after), oldUsed=800-50=750 (before). Both >= 600, no crossing.
  257. dims := []quotaDim{
  258. {
  259. name: quotaDimDaily,
  260. enabled: true,
  261. threshold: 400,
  262. thresholdType: thresholdTypeFixed,
  263. currentUsed: 800,
  264. limit: 1000,
  265. },
  266. }
  267. s.checkQuotaDimCrossings(account, dims, 50, []string{"admin@example.com"}, "TestSite")
  268. }
  269. func TestCheckQuotaDimCrossings_NegativeResolvedThreshold_Skipped(t *testing.T) {
  270. s, _ := newBalanceNotifyServiceForTest()
  271. account := &Account{ID: 1, Name: "test", Platform: PlatformAnthropic}
  272. // threshold=1200 remaining, limit=1000 → effectiveThreshold = 1000-1200 = -200
  273. // Negative resolved threshold → skipped.
  274. dims := []quotaDim{
  275. {
  276. name: quotaDimDaily,
  277. enabled: true,
  278. threshold: 1200,
  279. thresholdType: thresholdTypeFixed,
  280. currentUsed: 950,
  281. limit: 1000,
  282. },
  283. }
  284. s.checkQuotaDimCrossings(account, dims, 50, []string{"admin@example.com"}, "TestSite")
  285. }
  286. func TestCheckQuotaDimCrossings_PercentageThreshold_NoCrossing(t *testing.T) {
  287. s, _ := newBalanceNotifyServiceForTest()
  288. account := &Account{ID: 1, Name: "test", Platform: PlatformAnthropic}
  289. // threshold=30%, limit=1000 → effectiveThreshold = 1000 * (1 - 0.30) = 700
  290. // currentUsed=500, oldUsed=500-50=450. Both < 700, no crossing.
  291. dims := []quotaDim{
  292. {
  293. name: quotaDimWeekly,
  294. enabled: true,
  295. threshold: 30,
  296. thresholdType: thresholdTypePercentage,
  297. currentUsed: 500,
  298. limit: 1000,
  299. },
  300. }
  301. s.checkQuotaDimCrossings(account, dims, 50, []string{"admin@example.com"}, "TestSite")
  302. }
  303. func TestCheckQuotaDimCrossings_ZeroLimit_Skipped(t *testing.T) {
  304. s, _ := newBalanceNotifyServiceForTest()
  305. account := &Account{ID: 1, Name: "test", Platform: PlatformAnthropic}
  306. // limit=0 → resolvedThreshold returns 0 → skipped.
  307. dims := []quotaDim{
  308. {
  309. name: quotaDimTotal,
  310. enabled: true,
  311. threshold: 100,
  312. thresholdType: thresholdTypeFixed,
  313. currentUsed: 50,
  314. limit: 0,
  315. },
  316. }
  317. s.checkQuotaDimCrossings(account, dims, 50, []string{"admin@example.com"}, "TestSite")
  318. }
  319. func TestCheckQuotaDimCrossings_MultipleDims_MixedResults(t *testing.T) {
  320. s, _ := newBalanceNotifyServiceForTest()
  321. account := &Account{ID: 1, Name: "test", Platform: PlatformAnthropic}
  322. // dim1: no crossing (both below effective threshold)
  323. // dim2: disabled (skipped)
  324. // dim3: zero threshold (skipped)
  325. dims := []quotaDim{
  326. {
  327. name: quotaDimDaily,
  328. enabled: true,
  329. threshold: 400,
  330. thresholdType: thresholdTypeFixed,
  331. currentUsed: 300, // oldUsed=250, effectiveThreshold=600, both below
  332. limit: 1000,
  333. },
  334. {
  335. name: quotaDimWeekly,
  336. enabled: false,
  337. threshold: 100,
  338. thresholdType: thresholdTypeFixed,
  339. currentUsed: 900,
  340. limit: 1000,
  341. },
  342. {
  343. name: quotaDimTotal,
  344. enabled: true,
  345. threshold: 0,
  346. thresholdType: thresholdTypeFixed,
  347. currentUsed: 500,
  348. limit: 1000,
  349. },
  350. }
  351. // None should trigger. No panic expected.
  352. s.checkQuotaDimCrossings(account, dims, 50, []string{"admin@example.com"}, "TestSite")
  353. }