balance_notify_service_test.go 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. //go:build unit
  2. package service
  3. import (
  4. "testing"
  5. "github.com/stretchr/testify/require"
  6. )
  7. // ---------- resolveBalanceThreshold ----------
  8. func TestResolveBalanceThreshold_Fixed(t *testing.T) {
  9. // Fixed type always returns the raw threshold regardless of totalRecharged.
  10. require.Equal(t, 10.0, resolveBalanceThreshold(10, thresholdTypeFixed, 1000))
  11. require.Equal(t, 10.0, resolveBalanceThreshold(10, thresholdTypeFixed, 0))
  12. require.Equal(t, 0.0, resolveBalanceThreshold(0, thresholdTypeFixed, 1000))
  13. }
  14. func TestResolveBalanceThreshold_Percentage(t *testing.T) {
  15. // 10% of 1000 = 100
  16. require.Equal(t, 100.0, resolveBalanceThreshold(10, thresholdTypePercentage, 1000))
  17. // 50% of 200 = 100
  18. require.Equal(t, 100.0, resolveBalanceThreshold(50, thresholdTypePercentage, 200))
  19. }
  20. func TestResolveBalanceThreshold_PercentageZeroRecharged(t *testing.T) {
  21. // When totalRecharged is 0, percentage falls through to raw threshold
  22. // (treated as fixed). This is the defensive behavior.
  23. require.Equal(t, 10.0, resolveBalanceThreshold(10, thresholdTypePercentage, 0))
  24. }
  25. func TestResolveBalanceThreshold_EmptyType(t *testing.T) {
  26. // Empty type is treated as fixed (not percentage).
  27. require.Equal(t, 10.0, resolveBalanceThreshold(10, "", 1000))
  28. }
  29. // ---------- quotaDim.resolvedThreshold ----------
  30. func TestResolvedThreshold_FixedNormal(t *testing.T) {
  31. // threshold=400 remaining, limit=1000 → usage trigger at 600
  32. d := quotaDim{threshold: 400, thresholdType: thresholdTypeFixed, limit: 1000}
  33. require.Equal(t, 600.0, d.resolvedThreshold())
  34. }
  35. func TestResolvedThreshold_FixedThresholdExceedsLimit(t *testing.T) {
  36. // threshold=1200, limit=1000 → returns negative, callers must skip
  37. d := quotaDim{threshold: 1200, thresholdType: thresholdTypeFixed, limit: 1000}
  38. require.Equal(t, -200.0, d.resolvedThreshold())
  39. }
  40. func TestResolvedThreshold_FixedThresholdEqualsLimit(t *testing.T) {
  41. // threshold=1000, limit=1000 → returns 0 (alert fires at 0 usage)
  42. d := quotaDim{threshold: 1000, thresholdType: thresholdTypeFixed, limit: 1000}
  43. require.Equal(t, 0.0, d.resolvedThreshold())
  44. }
  45. func TestResolvedThreshold_PercentageNormal(t *testing.T) {
  46. // threshold=30%, limit=1000 → usage trigger at 700 (remaining drops to 30%)
  47. d := quotaDim{threshold: 30, thresholdType: thresholdTypePercentage, limit: 1000}
  48. require.InDelta(t, 700.0, d.resolvedThreshold(), 0.001)
  49. }
  50. func TestResolvedThreshold_PercentageZeroPercent(t *testing.T) {
  51. // threshold=0%, limit=1000 → fires when remaining drops to 0 (usage=1000)
  52. d := quotaDim{threshold: 0, thresholdType: thresholdTypePercentage, limit: 1000}
  53. require.InDelta(t, 1000.0, d.resolvedThreshold(), 0.001)
  54. }
  55. func TestResolvedThreshold_PercentageHundredPercent(t *testing.T) {
  56. // threshold=100%, limit=1000 → fires immediately (remaining drops to 100% i.e. nothing used yet)
  57. d := quotaDim{threshold: 100, thresholdType: thresholdTypePercentage, limit: 1000}
  58. require.InDelta(t, 0.0, d.resolvedThreshold(), 0.001)
  59. }
  60. func TestResolvedThreshold_PercentageOverHundred(t *testing.T) {
  61. // threshold=150%, limit=1000 → returns negative (never triggers; callers skip)
  62. d := quotaDim{threshold: 150, thresholdType: thresholdTypePercentage, limit: 1000}
  63. require.Less(t, d.resolvedThreshold(), 0.0)
  64. }
  65. func TestResolvedThreshold_ZeroLimit(t *testing.T) {
  66. // limit=0 → returns 0 to avoid division and false alerts on unlimited quotas
  67. d := quotaDim{threshold: 100, thresholdType: thresholdTypeFixed, limit: 0}
  68. require.Equal(t, 0.0, d.resolvedThreshold())
  69. }
  70. func TestResolvedThreshold_NegativeLimit(t *testing.T) {
  71. // Negative limit treated as 0
  72. d := quotaDim{threshold: 100, thresholdType: thresholdTypeFixed, limit: -10}
  73. require.Equal(t, 0.0, d.resolvedThreshold())
  74. }
  75. // ---------- sanitizeEmailHeader ----------
  76. func TestSanitizeEmailHeader_CRLF(t *testing.T) {
  77. require.Equal(t, "Subject injected", sanitizeEmailHeader("Subject\r\n injected"))
  78. }
  79. func TestSanitizeEmailHeader_OnlyCR(t *testing.T) {
  80. require.Equal(t, "foobar", sanitizeEmailHeader("foo\rbar"))
  81. }
  82. func TestSanitizeEmailHeader_OnlyLF(t *testing.T) {
  83. require.Equal(t, "foobar", sanitizeEmailHeader("foo\nbar"))
  84. }
  85. func TestSanitizeEmailHeader_Clean(t *testing.T) {
  86. require.Equal(t, "Sub2API", sanitizeEmailHeader("Sub2API"))
  87. }
  88. func TestSanitizeEmailHeader_Empty(t *testing.T) {
  89. require.Equal(t, "", sanitizeEmailHeader(""))
  90. }
  91. func TestSanitizeEmailHeader_MultipleNewlines(t *testing.T) {
  92. require.Equal(t, "abc", sanitizeEmailHeader("a\r\nb\r\nc"))
  93. }
  94. // ---------- buildQuotaDims ----------
  95. func TestBuildQuotaDims_AllDimensionsReturned(t *testing.T) {
  96. // Use an account with quota notify config across all 3 dimensions.
  97. a := &Account{
  98. Platform: PlatformAnthropic,
  99. Type: AccountTypeAPIKey,
  100. Extra: map[string]any{
  101. "quota_notify_daily_enabled": true,
  102. "quota_notify_daily_threshold": 100.0,
  103. "quota_notify_daily_threshold_type": thresholdTypeFixed,
  104. "quota_notify_weekly_enabled": true,
  105. "quota_notify_weekly_threshold": 20.0,
  106. "quota_notify_weekly_threshold_type": thresholdTypePercentage,
  107. "quota_notify_total_enabled": false,
  108. "quota_daily_limit": 500.0,
  109. "quota_weekly_limit": 2000.0,
  110. "quota_limit": 10000.0,
  111. "quota_daily_used": 50.0,
  112. "quota_weekly_used": 300.0,
  113. "quota_used": 1000.0,
  114. },
  115. }
  116. dims := buildQuotaDims(a)
  117. require.Len(t, dims, 3)
  118. // Daily
  119. require.Equal(t, quotaDimDaily, dims[0].name)
  120. require.True(t, dims[0].enabled)
  121. require.Equal(t, 100.0, dims[0].threshold)
  122. require.Equal(t, thresholdTypeFixed, dims[0].thresholdType)
  123. require.Equal(t, 500.0, dims[0].limit)
  124. require.Equal(t, 50.0, dims[0].currentUsed)
  125. // Weekly
  126. require.Equal(t, quotaDimWeekly, dims[1].name)
  127. require.True(t, dims[1].enabled)
  128. require.Equal(t, 20.0, dims[1].threshold)
  129. require.Equal(t, thresholdTypePercentage, dims[1].thresholdType)
  130. require.Equal(t, 2000.0, dims[1].limit)
  131. // Total
  132. require.Equal(t, quotaDimTotal, dims[2].name)
  133. require.False(t, dims[2].enabled)
  134. require.Equal(t, 10000.0, dims[2].limit)
  135. require.Equal(t, 1000.0, dims[2].currentUsed)
  136. }
  137. func TestBuildQuotaDims_EmptyExtra(t *testing.T) {
  138. // Missing fields default to zero/disabled.
  139. a := &Account{
  140. Platform: PlatformAnthropic,
  141. Type: AccountTypeAPIKey,
  142. Extra: map[string]any{},
  143. }
  144. dims := buildQuotaDims(a)
  145. require.Len(t, dims, 3)
  146. for _, d := range dims {
  147. require.False(t, d.enabled)
  148. require.Equal(t, 0.0, d.threshold)
  149. require.Equal(t, 0.0, d.limit)
  150. }
  151. }
  152. // ---------- buildQuotaDimsFromState ----------
  153. func TestBuildQuotaDimsFromState_UsesStateValues(t *testing.T) {
  154. // Usage values should come from the state, not the account.
  155. a := &Account{
  156. Platform: PlatformAnthropic,
  157. Type: AccountTypeAPIKey,
  158. Extra: map[string]any{
  159. "quota_notify_daily_enabled": true,
  160. "quota_notify_daily_threshold": 100.0,
  161. "quota_daily_used": 999.0, // should be ignored
  162. "quota_daily_limit": 999.0, // should be ignored
  163. },
  164. }
  165. state := &AccountQuotaState{
  166. DailyUsed: 77.0,
  167. DailyLimit: 500.0,
  168. WeeklyUsed: 88.0,
  169. WeeklyLimit: 2000.0,
  170. TotalUsed: 99.0,
  171. TotalLimit: 10000.0,
  172. }
  173. dims := buildQuotaDimsFromState(a, state)
  174. require.Len(t, dims, 3)
  175. // Settings from account (enabled, threshold, thresholdType)
  176. require.True(t, dims[0].enabled)
  177. require.Equal(t, 100.0, dims[0].threshold)
  178. // Usage from state
  179. require.Equal(t, 77.0, dims[0].currentUsed)
  180. require.Equal(t, 500.0, dims[0].limit)
  181. require.Equal(t, 88.0, dims[1].currentUsed)
  182. require.Equal(t, 2000.0, dims[1].limit)
  183. require.Equal(t, 99.0, dims[2].currentUsed)
  184. require.Equal(t, 10000.0, dims[2].limit)
  185. }
  186. // ---------- collectBalanceNotifyRecipients ----------
  187. func TestCollectBalanceNotifyRecipients_Empty(t *testing.T) {
  188. s := &BalanceNotifyService{}
  189. u := &User{BalanceNotifyExtraEmails: nil}
  190. require.Empty(t, s.collectBalanceNotifyRecipients(u))
  191. }
  192. func TestCollectBalanceNotifyRecipients_FiltersDisabledAndUnverified(t *testing.T) {
  193. s := &BalanceNotifyService{}
  194. u := &User{
  195. BalanceNotifyExtraEmails: []NotifyEmailEntry{
  196. {Email: "a@example.com", Verified: true, Disabled: false},
  197. {Email: "b@example.com", Verified: true, Disabled: true}, // disabled
  198. {Email: "c@example.com", Verified: false, Disabled: false}, // unverified
  199. {Email: "d@example.com", Verified: true, Disabled: false},
  200. },
  201. }
  202. got := s.collectBalanceNotifyRecipients(u)
  203. require.Equal(t, []string{"a@example.com", "d@example.com"}, got)
  204. }
  205. func TestCollectBalanceNotifyRecipients_DeduplicatesCaseInsensitive(t *testing.T) {
  206. s := &BalanceNotifyService{}
  207. u := &User{
  208. BalanceNotifyExtraEmails: []NotifyEmailEntry{
  209. {Email: "User@Example.com", Verified: true},
  210. {Email: "user@example.com", Verified: true},
  211. {Email: "USER@EXAMPLE.COM", Verified: true},
  212. },
  213. }
  214. got := s.collectBalanceNotifyRecipients(u)
  215. require.Len(t, got, 1)
  216. // The original casing of the first entry is preserved.
  217. require.Equal(t, "User@Example.com", got[0])
  218. }
  219. func TestCollectBalanceNotifyRecipients_SkipsEmpty(t *testing.T) {
  220. s := &BalanceNotifyService{}
  221. u := &User{
  222. BalanceNotifyExtraEmails: []NotifyEmailEntry{
  223. {Email: " ", Verified: true},
  224. {Email: "", Verified: true},
  225. {Email: "valid@example.com", Verified: true},
  226. },
  227. }
  228. got := s.collectBalanceNotifyRecipients(u)
  229. require.Equal(t, []string{"valid@example.com"}, got)
  230. }
  231. func TestCollectBalanceNotifyRecipients_TrimsWhitespace(t *testing.T) {
  232. s := &BalanceNotifyService{}
  233. u := &User{
  234. BalanceNotifyExtraEmails: []NotifyEmailEntry{
  235. {Email: " trimmed@example.com ", Verified: true},
  236. },
  237. }
  238. got := s.collectBalanceNotifyRecipients(u)
  239. require.Equal(t, []string{"trimmed@example.com"}, got)
  240. }