topfans/backend/services/activityService/service/activity_service_cache_test.go
2026-06-24 18:10:12 +08:00

190 lines
5.7 KiB
Go

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)
}