package service import ( "context" "encoding/json" "errors" "testing" "time" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/topfans/backend/pkg/models" pb "github.com/topfans/backend/pkg/proto/activity" ) // fakeCacheClient 内存版 cache(支持命中/未命中/脏数据/Redis 故障/set 失败) type fakeCacheClient struct { store map[string]string getErr error // 非 nil 模拟 Redis 故障 setErr error setCalled bool lastSetKey string lastSetValue interface{} lastSetTTL time.Duration } func newFakeCache() *fakeCacheClient { return &fakeCacheClient{store: map[string]string{}} } func (f *fakeCacheClient) Get(ctx context.Context, key string) *redis.StringCmd { cmd := redis.NewStringCmd(ctx, key) if f.getErr != nil { cmd.SetErr(f.getErr) return cmd } if v, ok := f.store[key]; ok { cmd.SetVal(v) } else { cmd.SetErr(redis.Nil) } return cmd } func (f *fakeCacheClient) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) *redis.StatusCmd { f.setCalled = true f.lastSetKey = key f.lastSetValue = value f.lastSetTTL = expiration cmd := redis.NewStatusCmd(ctx, key, value, expiration) if f.setErr != nil { cmd.SetErr(f.setErr) return cmd } cmd.SetVal("OK") if s, ok := value.(string); ok { f.store[key] = s } else if b, err := json.Marshal(value); err == nil { f.store[key] = string(b) } return cmd } func newCacheTestService(repo *mockActivityRepo, cache *fakeCacheClient) *activityService { return &activityService{ activityRepo: repo, userRPCClient: &mockUserRPC{}, cache: cache, } } // TestGetTop3WithCache_Hit 缓存命中 → 不调 DB func TestGetTop3WithCache_Hit(t *testing.T) { repo := &mockActivityRepo{ top3Stats: []*models.ActivityUserStats{{UserID: 999}}, // 不应被调用 } cache := newFakeCache() cached := []*pb.TopRankingItem{ {Rank: 1, UserId: 1001, AvatarUrl: "https://cdn/1001.jpg"}, {Rank: 2, UserId: 1002, AvatarUrl: "https://cdn/1002.jpg"}, } b, _ := json.Marshal(cached) cache.store["activity:top3:100:7"] = string(b) svc := newCacheTestService(repo, cache) items, hit, err := svc.getTop3WithCache(context.Background(), 100, 7) assert.NoError(t, err) assert.True(t, hit, "应命中缓存") assert.Len(t, items, 2) assert.Equal(t, int64(1001), items[0].UserId) assert.Equal(t, 0, repo.getTop3CallCount, "命中缓存不应查 DB") assert.False(t, cache.setCalled, "命中缓存不应回写") } // TestGetTop3WithCache_Miss 缓存未命中 → 回源 DB + 写回 func TestGetTop3WithCache_Miss(t *testing.T) { repo := &mockActivityRepo{ top3Stats: []*models.ActivityUserStats{ {UserID: 1001, TotalContribution: 900}, {UserID: 1002, TotalContribution: 800}, }, } cache := newFakeCache() svc := newCacheTestService(repo, cache) items, hit, err := svc.getTop3WithCache(context.Background(), 100, 7) assert.NoError(t, err) assert.False(t, hit) assert.Len(t, items, 2) assert.Equal(t, 1, repo.getTop3CallCount, "miss 必须查一次 DB") assert.True(t, cache.setCalled, "miss 后必须回写") assert.Equal(t, "activity:top3:100:7", cache.lastSetKey) assert.Equal(t, 30*time.Second, cache.lastSetTTL) } // TestGetTop3WithCache_CorruptedJSON 脏数据 → 当 miss 处理,覆盖写 func TestGetTop3WithCache_CorruptedJSON(t *testing.T) { repo := &mockActivityRepo{ top3Stats: []*models.ActivityUserStats{{UserID: 1001}}, } cache := newFakeCache() cache.store["activity:top3:100:7"] = "not-a-json{" svc := newCacheTestService(repo, cache) items, hit, err := svc.getTop3WithCache(context.Background(), 100, 7) assert.NoError(t, err) assert.False(t, hit, "脏数据视为 miss") assert.Len(t, items, 1) assert.True(t, cache.setCalled, "必须覆盖写入") } // TestGetTop3WithCache_RedisDown Get 报错(非 nil)→ 记 WARN + 回源 DB func TestGetTop3WithCache_RedisDown(t *testing.T) { repo := &mockActivityRepo{ top3Stats: []*models.ActivityUserStats{{UserID: 1001}}, } cache := newFakeCache() cache.getErr = errors.New("connection refused") svc := newCacheTestService(repo, cache) items, hit, err := svc.getTop3WithCache(context.Background(), 100, 7) assert.NoError(t, err, "Redis 故障不应让接口失败") assert.False(t, hit) assert.Len(t, items, 1, "回源 DB 仍应返回结果") } // TestGetTop3WithCache_SetFailure Set 失败 → 仍返回 DB 结果 func TestGetTop3WithCache_SetFailure(t *testing.T) { repo := &mockActivityRepo{ top3Stats: []*models.ActivityUserStats{{UserID: 1001}}, } cache := newFakeCache() cache.setErr = errors.New("write timeout") svc := newCacheTestService(repo, cache) items, hit, err := svc.getTop3WithCache(context.Background(), 100, 7) assert.NoError(t, err, "Set 失败不应让接口失败") assert.False(t, hit) assert.Len(t, items, 1, "Set 失败也要返回 DB 结果") } // TestGetTop3WithCache_NilCache cache=nil → 跳过 Redis,直接走 DB func TestGetTop3WithCache_NilCache(t *testing.T) { repo := &mockActivityRepo{ top3Stats: []*models.ActivityUserStats{{UserID: 1001}}, } svc := &activityService{ activityRepo: repo, userRPCClient: &mockUserRPC{}, cache: nil, } items, hit, err := svc.getTop3WithCache(context.Background(), 100, 7) assert.NoError(t, err) assert.False(t, hit) assert.Len(t, items, 1) assert.Equal(t, 1, repo.getTop3CallCount) } // TestGetTop3WithCache_StarIDZeroKey starID<=0 时 key 用 "all" 占位 func TestGetTop3WithCache_StarIDZeroKey(t *testing.T) { repo := &mockActivityRepo{ top3Stats: []*models.ActivityUserStats{{UserID: 1001}}, } cache := newFakeCache() svc := newCacheTestService(repo, cache) _, _, _ = svc.getTop3WithCache(context.Background(), 100, 0) assert.Equal(t, "activity:top3:100:all", cache.lastSetKey) }