190 lines
5.7 KiB
Go
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)
|
|
}
|