| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367 |
- //go:build integration
- package repository
- import (
- "context"
- "fmt"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/redis/go-redis/v9"
- "github.com/stretchr/testify/require"
- "github.com/stretchr/testify/suite"
- )
- type BillingCacheSuite struct {
- IntegrationRedisSuite
- }
- func (s *BillingCacheSuite) TestUserBalance() {
- tests := []struct {
- name string
- fn func(ctx context.Context, rdb *redis.Client, cache service.BillingCache)
- }{
- {
- name: "missing_key_returns_redis_nil",
- fn: func(ctx context.Context, rdb *redis.Client, cache service.BillingCache) {
- _, err := cache.GetUserBalance(ctx, 1)
- require.ErrorIs(s.T(), err, redis.Nil, "expected redis.Nil for missing balance key")
- },
- },
- {
- name: "deduct_on_nonexistent_is_noop",
- fn: func(ctx context.Context, rdb *redis.Client, cache service.BillingCache) {
- userID := int64(1)
- balanceKey := fmt.Sprintf("%s%d", billingBalanceKeyPrefix, userID)
- require.NoError(s.T(), cache.DeductUserBalance(ctx, userID, 1), "DeductUserBalance should not error")
- _, err := rdb.Get(ctx, balanceKey).Result()
- require.ErrorIs(s.T(), err, redis.Nil, "expected missing key after deduct on non-existent")
- },
- },
- {
- name: "set_and_get_with_ttl",
- fn: func(ctx context.Context, rdb *redis.Client, cache service.BillingCache) {
- userID := int64(2)
- balanceKey := fmt.Sprintf("%s%d", billingBalanceKeyPrefix, userID)
- require.NoError(s.T(), cache.SetUserBalance(ctx, userID, 10.5), "SetUserBalance")
- got, err := cache.GetUserBalance(ctx, userID)
- require.NoError(s.T(), err, "GetUserBalance")
- require.Equal(s.T(), 10.5, got, "balance mismatch")
- ttl, err := rdb.TTL(ctx, balanceKey).Result()
- require.NoError(s.T(), err, "TTL")
- s.AssertTTLWithin(ttl, 1*time.Second, billingCacheTTL)
- },
- },
- {
- name: "deduct_reduces_balance",
- fn: func(ctx context.Context, rdb *redis.Client, cache service.BillingCache) {
- userID := int64(3)
- require.NoError(s.T(), cache.SetUserBalance(ctx, userID, 10.5), "SetUserBalance")
- require.NoError(s.T(), cache.DeductUserBalance(ctx, userID, 2.25), "DeductUserBalance")
- got, err := cache.GetUserBalance(ctx, userID)
- require.NoError(s.T(), err, "GetUserBalance after deduct")
- require.Equal(s.T(), 8.25, got, "deduct mismatch")
- },
- },
- {
- name: "invalidate_removes_key",
- fn: func(ctx context.Context, rdb *redis.Client, cache service.BillingCache) {
- userID := int64(100)
- balanceKey := fmt.Sprintf("%s%d", billingBalanceKeyPrefix, userID)
- require.NoError(s.T(), cache.SetUserBalance(ctx, userID, 50.0), "SetUserBalance")
- exists, err := rdb.Exists(ctx, balanceKey).Result()
- require.NoError(s.T(), err, "Exists")
- require.Equal(s.T(), int64(1), exists, "expected balance key to exist")
- require.NoError(s.T(), cache.InvalidateUserBalance(ctx, userID), "InvalidateUserBalance")
- exists, err = rdb.Exists(ctx, balanceKey).Result()
- require.NoError(s.T(), err, "Exists after invalidate")
- require.Equal(s.T(), int64(0), exists, "expected balance key to be removed after invalidate")
- _, err = cache.GetUserBalance(ctx, userID)
- require.ErrorIs(s.T(), err, redis.Nil, "expected redis.Nil after invalidate")
- },
- },
- {
- name: "deduct_refreshes_ttl",
- fn: func(ctx context.Context, rdb *redis.Client, cache service.BillingCache) {
- userID := int64(103)
- balanceKey := fmt.Sprintf("%s%d", billingBalanceKeyPrefix, userID)
- require.NoError(s.T(), cache.SetUserBalance(ctx, userID, 100.0), "SetUserBalance")
- ttl1, err := rdb.TTL(ctx, balanceKey).Result()
- require.NoError(s.T(), err, "TTL before deduct")
- s.AssertTTLWithin(ttl1, 1*time.Second, billingCacheTTL)
- require.NoError(s.T(), cache.DeductUserBalance(ctx, userID, 25.0), "DeductUserBalance")
- balance, err := cache.GetUserBalance(ctx, userID)
- require.NoError(s.T(), err, "GetUserBalance")
- require.Equal(s.T(), 75.0, balance, "expected balance 75.0")
- ttl2, err := rdb.TTL(ctx, balanceKey).Result()
- require.NoError(s.T(), err, "TTL after deduct")
- s.AssertTTLWithin(ttl2, 1*time.Second, billingCacheTTL)
- },
- },
- }
- for _, tt := range tests {
- s.Run(tt.name, func() {
- rdb := testRedis(s.T())
- cache := NewBillingCache(rdb)
- ctx := context.Background()
- tt.fn(ctx, rdb, cache)
- })
- }
- }
- func (s *BillingCacheSuite) TestSubscriptionCache() {
- tests := []struct {
- name string
- fn func(ctx context.Context, rdb *redis.Client, cache service.BillingCache)
- }{
- {
- name: "missing_key_returns_redis_nil",
- fn: func(ctx context.Context, rdb *redis.Client, cache service.BillingCache) {
- userID := int64(10)
- groupID := int64(20)
- _, err := cache.GetSubscriptionCache(ctx, userID, groupID)
- require.ErrorIs(s.T(), err, redis.Nil, "expected redis.Nil for missing subscription key")
- },
- },
- {
- name: "update_usage_on_nonexistent_is_noop",
- fn: func(ctx context.Context, rdb *redis.Client, cache service.BillingCache) {
- userID := int64(11)
- groupID := int64(21)
- subKey := fmt.Sprintf("%s%d:%d", billingSubKeyPrefix, userID, groupID)
- require.NoError(s.T(), cache.UpdateSubscriptionUsage(ctx, userID, groupID, 1.0), "UpdateSubscriptionUsage should not error")
- exists, err := rdb.Exists(ctx, subKey).Result()
- require.NoError(s.T(), err, "Exists")
- require.Equal(s.T(), int64(0), exists, "expected missing subscription key after UpdateSubscriptionUsage on non-existent")
- },
- },
- {
- name: "set_and_get_with_ttl",
- fn: func(ctx context.Context, rdb *redis.Client, cache service.BillingCache) {
- userID := int64(12)
- groupID := int64(22)
- subKey := fmt.Sprintf("%s%d:%d", billingSubKeyPrefix, userID, groupID)
- data := &service.SubscriptionCacheData{
- Status: "active",
- ExpiresAt: time.Now().Add(1 * time.Hour),
- DailyUsage: 1.0,
- WeeklyUsage: 2.0,
- MonthlyUsage: 3.0,
- Version: 7,
- }
- require.NoError(s.T(), cache.SetSubscriptionCache(ctx, userID, groupID, data), "SetSubscriptionCache")
- gotSub, err := cache.GetSubscriptionCache(ctx, userID, groupID)
- require.NoError(s.T(), err, "GetSubscriptionCache")
- require.Equal(s.T(), "active", gotSub.Status)
- require.Equal(s.T(), int64(7), gotSub.Version)
- require.Equal(s.T(), 1.0, gotSub.DailyUsage)
- ttl, err := rdb.TTL(ctx, subKey).Result()
- require.NoError(s.T(), err, "TTL subKey")
- s.AssertTTLWithin(ttl, 1*time.Second, billingCacheTTL)
- },
- },
- {
- name: "update_usage_increments_all_fields",
- fn: func(ctx context.Context, rdb *redis.Client, cache service.BillingCache) {
- userID := int64(13)
- groupID := int64(23)
- data := &service.SubscriptionCacheData{
- Status: "active",
- ExpiresAt: time.Now().Add(1 * time.Hour),
- DailyUsage: 1.0,
- WeeklyUsage: 2.0,
- MonthlyUsage: 3.0,
- Version: 1,
- }
- require.NoError(s.T(), cache.SetSubscriptionCache(ctx, userID, groupID, data), "SetSubscriptionCache")
- require.NoError(s.T(), cache.UpdateSubscriptionUsage(ctx, userID, groupID, 0.5), "UpdateSubscriptionUsage")
- gotSub, err := cache.GetSubscriptionCache(ctx, userID, groupID)
- require.NoError(s.T(), err, "GetSubscriptionCache after update")
- require.Equal(s.T(), 1.5, gotSub.DailyUsage)
- require.Equal(s.T(), 2.5, gotSub.WeeklyUsage)
- require.Equal(s.T(), 3.5, gotSub.MonthlyUsage)
- },
- },
- {
- name: "invalidate_removes_key",
- fn: func(ctx context.Context, rdb *redis.Client, cache service.BillingCache) {
- userID := int64(101)
- groupID := int64(10)
- subKey := fmt.Sprintf("%s%d:%d", billingSubKeyPrefix, userID, groupID)
- data := &service.SubscriptionCacheData{
- Status: "active",
- ExpiresAt: time.Now().Add(1 * time.Hour),
- DailyUsage: 1.0,
- WeeklyUsage: 2.0,
- MonthlyUsage: 3.0,
- Version: 1,
- }
- require.NoError(s.T(), cache.SetSubscriptionCache(ctx, userID, groupID, data), "SetSubscriptionCache")
- exists, err := rdb.Exists(ctx, subKey).Result()
- require.NoError(s.T(), err, "Exists")
- require.Equal(s.T(), int64(1), exists, "expected subscription key to exist")
- require.NoError(s.T(), cache.InvalidateSubscriptionCache(ctx, userID, groupID), "InvalidateSubscriptionCache")
- exists, err = rdb.Exists(ctx, subKey).Result()
- require.NoError(s.T(), err, "Exists after invalidate")
- require.Equal(s.T(), int64(0), exists, "expected subscription key to be removed after invalidate")
- _, err = cache.GetSubscriptionCache(ctx, userID, groupID)
- require.ErrorIs(s.T(), err, redis.Nil, "expected redis.Nil after invalidate")
- },
- },
- {
- name: "missing_status_returns_parsing_error",
- fn: func(ctx context.Context, rdb *redis.Client, cache service.BillingCache) {
- userID := int64(102)
- groupID := int64(11)
- subKey := fmt.Sprintf("%s%d:%d", billingSubKeyPrefix, userID, groupID)
- fields := map[string]any{
- "expires_at": time.Now().Add(1 * time.Hour).Unix(),
- "daily_usage": 1.0,
- "weekly_usage": 2.0,
- "monthly_usage": 3.0,
- "version": 1,
- }
- require.NoError(s.T(), rdb.HSet(ctx, subKey, fields).Err(), "HSet")
- _, err := cache.GetSubscriptionCache(ctx, userID, groupID)
- require.Error(s.T(), err, "expected error for missing status field")
- require.NotErrorIs(s.T(), err, redis.Nil, "expected parsing error, not redis.Nil")
- require.Equal(s.T(), "invalid cache: missing status", err.Error())
- },
- },
- }
- for _, tt := range tests {
- s.Run(tt.name, func() {
- rdb := testRedis(s.T())
- cache := NewBillingCache(rdb)
- ctx := context.Background()
- tt.fn(ctx, rdb, cache)
- })
- }
- }
- // TestDeductUserBalance_ErrorPropagation 验证 P2-12 修复:
- // Redis 真实错误应传播,key 不存在(redis.Nil)应返回 nil。
- func (s *BillingCacheSuite) TestDeductUserBalance_ErrorPropagation() {
- tests := []struct {
- name string
- fn func(ctx context.Context, cache service.BillingCache)
- expectErr bool
- }{
- {
- name: "key_not_exists_returns_nil",
- fn: func(ctx context.Context, cache service.BillingCache) {
- // key 不存在时,Lua 脚本返回 0(redis.Nil),应返回 nil 而非错误
- err := cache.DeductUserBalance(ctx, 99999, 1.0)
- require.NoError(s.T(), err, "DeductUserBalance on non-existent key should return nil")
- },
- },
- {
- name: "existing_key_deducts_successfully",
- fn: func(ctx context.Context, cache service.BillingCache) {
- require.NoError(s.T(), cache.SetUserBalance(ctx, 200, 50.0))
- err := cache.DeductUserBalance(ctx, 200, 10.0)
- require.NoError(s.T(), err, "DeductUserBalance should succeed")
- bal, err := cache.GetUserBalance(ctx, 200)
- require.NoError(s.T(), err)
- require.Equal(s.T(), 40.0, bal, "余额应为 40.0")
- },
- },
- {
- name: "cancelled_context_propagates_error",
- fn: func(ctx context.Context, cache service.BillingCache) {
- require.NoError(s.T(), cache.SetUserBalance(ctx, 201, 50.0))
- cancelCtx, cancel := context.WithCancel(ctx)
- cancel() // 立即取消
- err := cache.DeductUserBalance(cancelCtx, 201, 10.0)
- require.Error(s.T(), err, "cancelled context should propagate error")
- },
- },
- }
- for _, tt := range tests {
- s.Run(tt.name, func() {
- rdb := testRedis(s.T())
- cache := NewBillingCache(rdb)
- ctx := context.Background()
- tt.fn(ctx, cache)
- })
- }
- }
- // TestUpdateSubscriptionUsage_ErrorPropagation 验证 P2-12 修复:
- // Redis 真实错误应传播,key 不存在(redis.Nil)应返回 nil。
- func (s *BillingCacheSuite) TestUpdateSubscriptionUsage_ErrorPropagation() {
- s.Run("key_not_exists_returns_nil", func() {
- rdb := testRedis(s.T())
- cache := NewBillingCache(rdb)
- ctx := context.Background()
- err := cache.UpdateSubscriptionUsage(ctx, 88888, 77777, 1.0)
- require.NoError(s.T(), err, "UpdateSubscriptionUsage on non-existent key should return nil")
- })
- s.Run("cancelled_context_propagates_error", func() {
- rdb := testRedis(s.T())
- cache := NewBillingCache(rdb)
- ctx := context.Background()
- data := &service.SubscriptionCacheData{
- Status: "active",
- ExpiresAt: time.Now().Add(1 * time.Hour),
- Version: 1,
- }
- require.NoError(s.T(), cache.SetSubscriptionCache(ctx, 301, 401, data))
- cancelCtx, cancel := context.WithCancel(ctx)
- cancel()
- err := cache.UpdateSubscriptionUsage(cancelCtx, 301, 401, 1.0)
- require.Error(s.T(), err, "cancelled context should propagate error")
- })
- }
- func TestBillingCacheSuite(t *testing.T) {
- suite.Run(t, new(BillingCacheSuite))
- }
|