package service import ( "context" "errors" "testing" "github.com/stretchr/testify/assert" "github.com/topfans/backend/pkg/logger" "github.com/topfans/backend/pkg/models" pb "github.com/topfans/backend/pkg/proto/activity" "github.com/topfans/backend/services/activityService/client" "github.com/topfans/backend/services/activityService/repository" "go.uber.org/zap" ) func init() { if logger.Logger == nil { logger.Logger = zap.NewNop() } } // -------------------- Mocks -------------------- // mockActivityRepo 内存版 ActivityRepository;只实现本测试需要的方法 type mockActivityRepo struct { top3Stats []*models.ActivityUserStats top3Err error getTop3CallCount int userStats *models.ActivityUserStats // 缺省返回(单用户场景) statsByUserID map[int64]*models.ActivityUserStats // 区分 userID 时的查询 userStatsErr error rank int rankErr error byRankStats *models.ActivityUserStats byRankErr error } func (m *mockActivityRepo) GetTop3(activityID, starID int64) ([]*models.ActivityUserStats, error) { m.getTop3CallCount++ if m.top3Err != nil { return nil, m.top3Err } return m.top3Stats, nil } func (m *mockActivityRepo) GetUserStatsForRanking(activityID, userID, starID int64) (*models.ActivityUserStats, error) { if m.userStatsErr != nil { return nil, m.userStatsErr } if m.statsByUserID != nil { if s, ok := m.statsByUserID[userID]; ok { return s, nil } return nil, nil // 未找到视为未参与 } return m.userStats, nil } func (m *mockActivityRepo) GetUserRank(userID, activityID, starID int64) (int, error) { return m.rank, m.rankErr } func (m *mockActivityRepo) GetUserStatsByRank(activityID, starID int64, offset int) (*models.ActivityUserStats, error) { return m.byRankStats, m.byRankErr } // 其他未使用方法(no-op,保证实现接口) func (m *mockActivityRepo) CreateActivity(*models.Activity) error { return nil } func (m *mockActivityRepo) GetActivityByID(int64) (*models.Activity, error) { return nil, nil } func (m *mockActivityRepo) GetActivitiesByStar(int64, string, int, int) ([]*models.Activity, int64, error) { return nil, 0, nil } func (m *mockActivityRepo) UpdateActivityProgress(int64, int64) error { return nil } func (m *mockActivityRepo) GetActivityItems(int64) ([]*models.ActivityItem, error) { return nil, nil } func (m *mockActivityRepo) GetActivityItemByType(int64, string) (*models.ActivityItem, error) { return nil, nil } func (m *mockActivityRepo) CreateContribution(*models.ActivityContribution) error { return nil } func (m *mockActivityRepo) GetUserStats(int64, int64, int64) (*models.ActivityUserStats, error) { return m.userStats, m.userStatsErr } func (m *mockActivityRepo) UpdateUserStats(*models.ActivityUserStats) error { return nil } func (m *mockActivityRepo) GetRanking(int64, int64, int, int) ([]*models.ActivityUserStats, int64, error) { return nil, 0, nil } func (m *mockActivityRepo) GetLatestContributions(int64, int64, int64, int) ([]*models.ActivityContribution, error) { return nil, nil } // mockUserRPC 内存版 UserRPCClient type mockUserRPC struct { profiles map[int64]*client.FanProfile err error } func (m *mockUserRPC) GetFanProfile(userID, starID int64) (*client.FanProfile, error) { if m.err != nil { return nil, m.err } if p, ok := m.profiles[userID]; ok { return p, nil } return nil, errors.New("profile not found") } func (m *mockUserRPC) UpdateCrystalBalance(int64, int64, int64) (int64, error) { return 0, nil } // -------------------- Tests -------------------- func newTestService(repo repository.ActivityRepository, urpc client.UserRPCClient) *activityService { return &activityService{ activityRepo: repo, userRPCClient: urpc, // cache=nil → getTop3WithCache 跳过缓存,直接走 DB } } func TestGetTopRanking_UnrankedUser(t *testing.T) { repo := &mockActivityRepo{ top3Stats: []*models.ActivityUserStats{ {UserID: 1001, TotalContribution: 900}, {UserID: 1002, TotalContribution: 800}, }, userStats: nil, // 未参与 } urpc := &mockUserRPC{profiles: map[int64]*client.FanProfile{ 2001: {UserID: 2001, AvatarUrl: "https://cdn/2001.jpg"}, }} svc := newTestService(repo, urpc) resp, err := svc.GetTopRanking(context.Background(), &pb.TopRankingRequest{ ActivityId: 100, StarId: 7, UserId: 2001, }) assert.NoError(t, err) assert.NotNil(t, resp) assert.Equal(t, uint32(0), resp.Base.Code) assert.Len(t, resp.Top3, 2) assert.NotNil(t, resp.MyInfo) assert.Equal(t, int32(0), resp.MyInfo.Rank) assert.Equal(t, "unranked", resp.MyInfo.Status) assert.Equal(t, int64(0), resp.MyInfo.GapToPrev) assert.Equal(t, "https://cdn/2001.jpg", resp.MyInfo.AvatarUrl, "未上榜用户也应该拿到自己的头像") } func TestGetTopRanking_Rank1(t *testing.T) { repo := &mockActivityRepo{ top3Stats: []*models.ActivityUserStats{ {UserID: 2001, TotalContribution: 1500}, }, userStats: &models.ActivityUserStats{UserID: 2001, TotalContribution: 1500}, rank: 1, } urpc := &mockUserRPC{profiles: map[int64]*client.FanProfile{ 2001: {UserID: 2001, AvatarUrl: "https://cdn/2001.jpg"}, }} svc := newTestService(repo, urpc) resp, err := svc.GetTopRanking(context.Background(), &pb.TopRankingRequest{ ActivityId: 100, StarId: 7, UserId: 2001, }) assert.NoError(t, err) assert.Equal(t, int32(1), resp.MyInfo.Rank) assert.Equal(t, "ranked", resp.MyInfo.Status) assert.Equal(t, int64(0), resp.MyInfo.GapToPrev, "rank=1 时 gap 必为 0") } func TestGetTopRanking_RankInTop3(t *testing.T) { // 我是 rank=3;top3 = [1001(900), 1002(800), 2001(700)] // gap = 1002.contribution(800) - 2001.contribution(700) = 100 repo := &mockActivityRepo{ top3Stats: []*models.ActivityUserStats{ {UserID: 1001, TotalContribution: 900}, {UserID: 1002, TotalContribution: 800}, {UserID: 2001, TotalContribution: 700}, }, statsByUserID: map[int64]*models.ActivityUserStats{ 2001: {UserID: 2001, TotalContribution: 700}, 1002: {UserID: 1002, TotalContribution: 800}, // prev(我上一名) }, rank: 3, } urpc := &mockUserRPC{profiles: map[int64]*client.FanProfile{ 1001: {UserID: 1001, AvatarUrl: "https://cdn/1001.jpg"}, 1002: {UserID: 1002, AvatarUrl: "https://cdn/1002.jpg"}, 2001: {UserID: 2001, AvatarUrl: "https://cdn/2001.jpg"}, }} svc := newTestService(repo, urpc) resp, err := svc.GetTopRanking(context.Background(), &pb.TopRankingRequest{ ActivityId: 100, StarId: 7, UserId: 2001, }) assert.NoError(t, err) assert.Equal(t, int32(3), resp.MyInfo.Rank) assert.Equal(t, "ranked", resp.MyInfo.Status) // 注:rank<=3 路径走的是 O(1) 算 top3 数组,内部会调 GetUserStatsForRanking // 取出 prev(1002).contribution = 800, gap = 800 - 700 = 100 assert.Equal(t, int64(100), resp.MyInfo.GapToPrev) } func TestGetTopRanking_RankBeyondTop3(t *testing.T) { // rank=10; top3 数组长度=3,需走 OFFSET 查询第 9 名(偏移 8) repo := &mockActivityRepo{ top3Stats: []*models.ActivityUserStats{ {UserID: 1001, TotalContribution: 900}, {UserID: 1002, TotalContribution: 800}, {UserID: 1003, TotalContribution: 700}, }, userStats: &models.ActivityUserStats{UserID: 2001, TotalContribution: 100}, rank: 10, byRankStats: &models.ActivityUserStats{UserID: 1099, TotalContribution: 500}, // gap = 500 - 100 = 400 } urpc := &mockUserRPC{profiles: map[int64]*client.FanProfile{ 1001: {UserID: 1001, AvatarUrl: "https://cdn/1001.jpg"}, 1002: {UserID: 1002, AvatarUrl: "https://cdn/1002.jpg"}, 1003: {UserID: 1003, AvatarUrl: "https://cdn/1003.jpg"}, 2001: {UserID: 2001, AvatarUrl: "https://cdn/2001.jpg"}, }} svc := newTestService(repo, urpc) resp, err := svc.GetTopRanking(context.Background(), &pb.TopRankingRequest{ ActivityId: 100, StarId: 7, UserId: 2001, }) assert.NoError(t, err) assert.Equal(t, int32(10), resp.MyInfo.Rank) assert.Equal(t, int64(400), resp.MyInfo.GapToPrev, "rank>3 走 OFFSET 查询") } func TestGetTopRanking_GapClampedToZero(t *testing.T) { // 自己贡献被并发刷成比上一名还高(异常);gap 应被 clamp 到 0 repo := &mockActivityRepo{ top3Stats: []*models.ActivityUserStats{ {UserID: 2001, TotalContribution: 9999}, }, userStats: &models.ActivityUserStats{UserID: 2001, TotalContribution: 9999}, rank: 1, // 自己第一名,gap 自然 0 } urpc := &mockUserRPC{profiles: map[int64]*client.FanProfile{ 2001: {UserID: 2001, AvatarUrl: "https://cdn/2001.jpg"}, }} svc := newTestService(repo, urpc) resp, _ := svc.GetTopRanking(context.Background(), &pb.TopRankingRequest{ ActivityId: 100, StarId: 7, UserId: 2001, }) assert.Equal(t, int64(0), resp.MyInfo.GapToPrev) assert.GreaterOrEqual(t, resp.MyInfo.GapToPrev, int64(0), "gap 永远非负") } func TestGetTopRanking_FanProfileFailure(t *testing.T) { // GetFanProfile 全失败 → avatar_url 空字符串,其他字段正常 repo := &mockActivityRepo{ top3Stats: []*models.ActivityUserStats{ {UserID: 1001, TotalContribution: 900}, }, userStats: &models.ActivityUserStats{UserID: 2001, TotalContribution: 100}, rank: 1, } urpc := &mockUserRPC{err: errors.New("rpc down")} svc := newTestService(repo, urpc) resp, err := svc.GetTopRanking(context.Background(), &pb.TopRankingRequest{ ActivityId: 100, StarId: 7, UserId: 2001, }) assert.NoError(t, err) assert.Equal(t, uint32(0), resp.Base.Code, "RPC 失败不应阻塞接口") for _, it := range resp.Top3 { assert.Equal(t, "", it.AvatarUrl) } assert.Equal(t, "", resp.MyInfo.AvatarUrl) } func TestGetTopRanking_InvalidActivityID(t *testing.T) { svc := newTestService(&mockActivityRepo{}, &mockUserRPC{}) resp, err := svc.GetTopRanking(context.Background(), &pb.TopRankingRequest{ ActivityId: 0, UserId: 1, }) assert.NoError(t, err) assert.NotEqual(t, uint32(0), resp.Base.Code, "activity_id<=0 应返回错误") } func TestGetTopRanking_MissingUserID(t *testing.T) { svc := newTestService(&mockActivityRepo{}, &mockUserRPC{}) resp, err := svc.GetTopRanking(context.Background(), &pb.TopRankingRequest{ ActivityId: 100, UserId: 0, }) assert.NoError(t, err) assert.NotEqual(t, uint32(0), resp.Base.Code, "user_id=0 应返回未授权") } func TestStarIDOrAll(t *testing.T) { assert.Equal(t, "all", starIDOrAll(0)) assert.Equal(t, "all", starIDOrAll(-1)) assert.Equal(t, "123", starIDOrAll(123)) }