billing_cache_integration_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. //go:build integration
  2. package repository
  3. import (
  4. "context"
  5. "fmt"
  6. "testing"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/service"
  9. "github.com/redis/go-redis/v9"
  10. "github.com/stretchr/testify/require"
  11. "github.com/stretchr/testify/suite"
  12. )
  13. type BillingCacheSuite struct {
  14. IntegrationRedisSuite
  15. }
  16. func (s *BillingCacheSuite) TestUserBalance() {
  17. tests := []struct {
  18. name string
  19. fn func(ctx context.Context, rdb *redis.Client, cache service.BillingCache)
  20. }{
  21. {
  22. name: "missing_key_returns_redis_nil",
  23. fn: func(ctx context.Context, rdb *redis.Client, cache service.BillingCache) {
  24. _, err := cache.GetUserBalance(ctx, 1)
  25. require.ErrorIs(s.T(), err, redis.Nil, "expected redis.Nil for missing balance key")
  26. },
  27. },
  28. {
  29. name: "deduct_on_nonexistent_is_noop",
  30. fn: func(ctx context.Context, rdb *redis.Client, cache service.BillingCache) {
  31. userID := int64(1)
  32. balanceKey := fmt.Sprintf("%s%d", billingBalanceKeyPrefix, userID)
  33. require.NoError(s.T(), cache.DeductUserBalance(ctx, userID, 1), "DeductUserBalance should not error")
  34. _, err := rdb.Get(ctx, balanceKey).Result()
  35. require.ErrorIs(s.T(), err, redis.Nil, "expected missing key after deduct on non-existent")
  36. },
  37. },
  38. {
  39. name: "set_and_get_with_ttl",
  40. fn: func(ctx context.Context, rdb *redis.Client, cache service.BillingCache) {
  41. userID := int64(2)
  42. balanceKey := fmt.Sprintf("%s%d", billingBalanceKeyPrefix, userID)
  43. require.NoError(s.T(), cache.SetUserBalance(ctx, userID, 10.5), "SetUserBalance")
  44. got, err := cache.GetUserBalance(ctx, userID)
  45. require.NoError(s.T(), err, "GetUserBalance")
  46. require.Equal(s.T(), 10.5, got, "balance mismatch")
  47. ttl, err := rdb.TTL(ctx, balanceKey).Result()
  48. require.NoError(s.T(), err, "TTL")
  49. s.AssertTTLWithin(ttl, 1*time.Second, billingCacheTTL)
  50. },
  51. },
  52. {
  53. name: "deduct_reduces_balance",
  54. fn: func(ctx context.Context, rdb *redis.Client, cache service.BillingCache) {
  55. userID := int64(3)
  56. require.NoError(s.T(), cache.SetUserBalance(ctx, userID, 10.5), "SetUserBalance")
  57. require.NoError(s.T(), cache.DeductUserBalance(ctx, userID, 2.25), "DeductUserBalance")
  58. got, err := cache.GetUserBalance(ctx, userID)
  59. require.NoError(s.T(), err, "GetUserBalance after deduct")
  60. require.Equal(s.T(), 8.25, got, "deduct mismatch")
  61. },
  62. },
  63. {
  64. name: "invalidate_removes_key",
  65. fn: func(ctx context.Context, rdb *redis.Client, cache service.BillingCache) {
  66. userID := int64(100)
  67. balanceKey := fmt.Sprintf("%s%d", billingBalanceKeyPrefix, userID)
  68. require.NoError(s.T(), cache.SetUserBalance(ctx, userID, 50.0), "SetUserBalance")
  69. exists, err := rdb.Exists(ctx, balanceKey).Result()
  70. require.NoError(s.T(), err, "Exists")
  71. require.Equal(s.T(), int64(1), exists, "expected balance key to exist")
  72. require.NoError(s.T(), cache.InvalidateUserBalance(ctx, userID), "InvalidateUserBalance")
  73. exists, err = rdb.Exists(ctx, balanceKey).Result()
  74. require.NoError(s.T(), err, "Exists after invalidate")
  75. require.Equal(s.T(), int64(0), exists, "expected balance key to be removed after invalidate")
  76. _, err = cache.GetUserBalance(ctx, userID)
  77. require.ErrorIs(s.T(), err, redis.Nil, "expected redis.Nil after invalidate")
  78. },
  79. },
  80. {
  81. name: "deduct_refreshes_ttl",
  82. fn: func(ctx context.Context, rdb *redis.Client, cache service.BillingCache) {
  83. userID := int64(103)
  84. balanceKey := fmt.Sprintf("%s%d", billingBalanceKeyPrefix, userID)
  85. require.NoError(s.T(), cache.SetUserBalance(ctx, userID, 100.0), "SetUserBalance")
  86. ttl1, err := rdb.TTL(ctx, balanceKey).Result()
  87. require.NoError(s.T(), err, "TTL before deduct")
  88. s.AssertTTLWithin(ttl1, 1*time.Second, billingCacheTTL)
  89. require.NoError(s.T(), cache.DeductUserBalance(ctx, userID, 25.0), "DeductUserBalance")
  90. balance, err := cache.GetUserBalance(ctx, userID)
  91. require.NoError(s.T(), err, "GetUserBalance")
  92. require.Equal(s.T(), 75.0, balance, "expected balance 75.0")
  93. ttl2, err := rdb.TTL(ctx, balanceKey).Result()
  94. require.NoError(s.T(), err, "TTL after deduct")
  95. s.AssertTTLWithin(ttl2, 1*time.Second, billingCacheTTL)
  96. },
  97. },
  98. }
  99. for _, tt := range tests {
  100. s.Run(tt.name, func() {
  101. rdb := testRedis(s.T())
  102. cache := NewBillingCache(rdb)
  103. ctx := context.Background()
  104. tt.fn(ctx, rdb, cache)
  105. })
  106. }
  107. }
  108. func (s *BillingCacheSuite) TestSubscriptionCache() {
  109. tests := []struct {
  110. name string
  111. fn func(ctx context.Context, rdb *redis.Client, cache service.BillingCache)
  112. }{
  113. {
  114. name: "missing_key_returns_redis_nil",
  115. fn: func(ctx context.Context, rdb *redis.Client, cache service.BillingCache) {
  116. userID := int64(10)
  117. groupID := int64(20)
  118. _, err := cache.GetSubscriptionCache(ctx, userID, groupID)
  119. require.ErrorIs(s.T(), err, redis.Nil, "expected redis.Nil for missing subscription key")
  120. },
  121. },
  122. {
  123. name: "update_usage_on_nonexistent_is_noop",
  124. fn: func(ctx context.Context, rdb *redis.Client, cache service.BillingCache) {
  125. userID := int64(11)
  126. groupID := int64(21)
  127. subKey := fmt.Sprintf("%s%d:%d", billingSubKeyPrefix, userID, groupID)
  128. require.NoError(s.T(), cache.UpdateSubscriptionUsage(ctx, userID, groupID, 1.0), "UpdateSubscriptionUsage should not error")
  129. exists, err := rdb.Exists(ctx, subKey).Result()
  130. require.NoError(s.T(), err, "Exists")
  131. require.Equal(s.T(), int64(0), exists, "expected missing subscription key after UpdateSubscriptionUsage on non-existent")
  132. },
  133. },
  134. {
  135. name: "set_and_get_with_ttl",
  136. fn: func(ctx context.Context, rdb *redis.Client, cache service.BillingCache) {
  137. userID := int64(12)
  138. groupID := int64(22)
  139. subKey := fmt.Sprintf("%s%d:%d", billingSubKeyPrefix, userID, groupID)
  140. data := &service.SubscriptionCacheData{
  141. Status: "active",
  142. ExpiresAt: time.Now().Add(1 * time.Hour),
  143. DailyUsage: 1.0,
  144. WeeklyUsage: 2.0,
  145. MonthlyUsage: 3.0,
  146. Version: 7,
  147. }
  148. require.NoError(s.T(), cache.SetSubscriptionCache(ctx, userID, groupID, data), "SetSubscriptionCache")
  149. gotSub, err := cache.GetSubscriptionCache(ctx, userID, groupID)
  150. require.NoError(s.T(), err, "GetSubscriptionCache")
  151. require.Equal(s.T(), "active", gotSub.Status)
  152. require.Equal(s.T(), int64(7), gotSub.Version)
  153. require.Equal(s.T(), 1.0, gotSub.DailyUsage)
  154. ttl, err := rdb.TTL(ctx, subKey).Result()
  155. require.NoError(s.T(), err, "TTL subKey")
  156. s.AssertTTLWithin(ttl, 1*time.Second, billingCacheTTL)
  157. },
  158. },
  159. {
  160. name: "update_usage_increments_all_fields",
  161. fn: func(ctx context.Context, rdb *redis.Client, cache service.BillingCache) {
  162. userID := int64(13)
  163. groupID := int64(23)
  164. data := &service.SubscriptionCacheData{
  165. Status: "active",
  166. ExpiresAt: time.Now().Add(1 * time.Hour),
  167. DailyUsage: 1.0,
  168. WeeklyUsage: 2.0,
  169. MonthlyUsage: 3.0,
  170. Version: 1,
  171. }
  172. require.NoError(s.T(), cache.SetSubscriptionCache(ctx, userID, groupID, data), "SetSubscriptionCache")
  173. require.NoError(s.T(), cache.UpdateSubscriptionUsage(ctx, userID, groupID, 0.5), "UpdateSubscriptionUsage")
  174. gotSub, err := cache.GetSubscriptionCache(ctx, userID, groupID)
  175. require.NoError(s.T(), err, "GetSubscriptionCache after update")
  176. require.Equal(s.T(), 1.5, gotSub.DailyUsage)
  177. require.Equal(s.T(), 2.5, gotSub.WeeklyUsage)
  178. require.Equal(s.T(), 3.5, gotSub.MonthlyUsage)
  179. },
  180. },
  181. {
  182. name: "invalidate_removes_key",
  183. fn: func(ctx context.Context, rdb *redis.Client, cache service.BillingCache) {
  184. userID := int64(101)
  185. groupID := int64(10)
  186. subKey := fmt.Sprintf("%s%d:%d", billingSubKeyPrefix, userID, groupID)
  187. data := &service.SubscriptionCacheData{
  188. Status: "active",
  189. ExpiresAt: time.Now().Add(1 * time.Hour),
  190. DailyUsage: 1.0,
  191. WeeklyUsage: 2.0,
  192. MonthlyUsage: 3.0,
  193. Version: 1,
  194. }
  195. require.NoError(s.T(), cache.SetSubscriptionCache(ctx, userID, groupID, data), "SetSubscriptionCache")
  196. exists, err := rdb.Exists(ctx, subKey).Result()
  197. require.NoError(s.T(), err, "Exists")
  198. require.Equal(s.T(), int64(1), exists, "expected subscription key to exist")
  199. require.NoError(s.T(), cache.InvalidateSubscriptionCache(ctx, userID, groupID), "InvalidateSubscriptionCache")
  200. exists, err = rdb.Exists(ctx, subKey).Result()
  201. require.NoError(s.T(), err, "Exists after invalidate")
  202. require.Equal(s.T(), int64(0), exists, "expected subscription key to be removed after invalidate")
  203. _, err = cache.GetSubscriptionCache(ctx, userID, groupID)
  204. require.ErrorIs(s.T(), err, redis.Nil, "expected redis.Nil after invalidate")
  205. },
  206. },
  207. {
  208. name: "missing_status_returns_parsing_error",
  209. fn: func(ctx context.Context, rdb *redis.Client, cache service.BillingCache) {
  210. userID := int64(102)
  211. groupID := int64(11)
  212. subKey := fmt.Sprintf("%s%d:%d", billingSubKeyPrefix, userID, groupID)
  213. fields := map[string]any{
  214. "expires_at": time.Now().Add(1 * time.Hour).Unix(),
  215. "daily_usage": 1.0,
  216. "weekly_usage": 2.0,
  217. "monthly_usage": 3.0,
  218. "version": 1,
  219. }
  220. require.NoError(s.T(), rdb.HSet(ctx, subKey, fields).Err(), "HSet")
  221. _, err := cache.GetSubscriptionCache(ctx, userID, groupID)
  222. require.Error(s.T(), err, "expected error for missing status field")
  223. require.NotErrorIs(s.T(), err, redis.Nil, "expected parsing error, not redis.Nil")
  224. require.Equal(s.T(), "invalid cache: missing status", err.Error())
  225. },
  226. },
  227. }
  228. for _, tt := range tests {
  229. s.Run(tt.name, func() {
  230. rdb := testRedis(s.T())
  231. cache := NewBillingCache(rdb)
  232. ctx := context.Background()
  233. tt.fn(ctx, rdb, cache)
  234. })
  235. }
  236. }
  237. // TestDeductUserBalance_ErrorPropagation 验证 P2-12 修复:
  238. // Redis 真实错误应传播,key 不存在(redis.Nil)应返回 nil。
  239. func (s *BillingCacheSuite) TestDeductUserBalance_ErrorPropagation() {
  240. tests := []struct {
  241. name string
  242. fn func(ctx context.Context, cache service.BillingCache)
  243. expectErr bool
  244. }{
  245. {
  246. name: "key_not_exists_returns_nil",
  247. fn: func(ctx context.Context, cache service.BillingCache) {
  248. // key 不存在时,Lua 脚本返回 0(redis.Nil),应返回 nil 而非错误
  249. err := cache.DeductUserBalance(ctx, 99999, 1.0)
  250. require.NoError(s.T(), err, "DeductUserBalance on non-existent key should return nil")
  251. },
  252. },
  253. {
  254. name: "existing_key_deducts_successfully",
  255. fn: func(ctx context.Context, cache service.BillingCache) {
  256. require.NoError(s.T(), cache.SetUserBalance(ctx, 200, 50.0))
  257. err := cache.DeductUserBalance(ctx, 200, 10.0)
  258. require.NoError(s.T(), err, "DeductUserBalance should succeed")
  259. bal, err := cache.GetUserBalance(ctx, 200)
  260. require.NoError(s.T(), err)
  261. require.Equal(s.T(), 40.0, bal, "余额应为 40.0")
  262. },
  263. },
  264. {
  265. name: "cancelled_context_propagates_error",
  266. fn: func(ctx context.Context, cache service.BillingCache) {
  267. require.NoError(s.T(), cache.SetUserBalance(ctx, 201, 50.0))
  268. cancelCtx, cancel := context.WithCancel(ctx)
  269. cancel() // 立即取消
  270. err := cache.DeductUserBalance(cancelCtx, 201, 10.0)
  271. require.Error(s.T(), err, "cancelled context should propagate error")
  272. },
  273. },
  274. }
  275. for _, tt := range tests {
  276. s.Run(tt.name, func() {
  277. rdb := testRedis(s.T())
  278. cache := NewBillingCache(rdb)
  279. ctx := context.Background()
  280. tt.fn(ctx, cache)
  281. })
  282. }
  283. }
  284. // TestUpdateSubscriptionUsage_ErrorPropagation 验证 P2-12 修复:
  285. // Redis 真实错误应传播,key 不存在(redis.Nil)应返回 nil。
  286. func (s *BillingCacheSuite) TestUpdateSubscriptionUsage_ErrorPropagation() {
  287. s.Run("key_not_exists_returns_nil", func() {
  288. rdb := testRedis(s.T())
  289. cache := NewBillingCache(rdb)
  290. ctx := context.Background()
  291. err := cache.UpdateSubscriptionUsage(ctx, 88888, 77777, 1.0)
  292. require.NoError(s.T(), err, "UpdateSubscriptionUsage on non-existent key should return nil")
  293. })
  294. s.Run("cancelled_context_propagates_error", func() {
  295. rdb := testRedis(s.T())
  296. cache := NewBillingCache(rdb)
  297. ctx := context.Background()
  298. data := &service.SubscriptionCacheData{
  299. Status: "active",
  300. ExpiresAt: time.Now().Add(1 * time.Hour),
  301. Version: 1,
  302. }
  303. require.NoError(s.T(), cache.SetSubscriptionCache(ctx, 301, 401, data))
  304. cancelCtx, cancel := context.WithCancel(ctx)
  305. cancel()
  306. err := cache.UpdateSubscriptionUsage(cancelCtx, 301, 401, 1.0)
  307. require.Error(s.T(), err, "cancelled context should propagate error")
  308. })
  309. }
  310. func TestBillingCacheSuite(t *testing.T) {
  311. suite.Run(t, new(BillingCacheSuite))
  312. }