package leaderboard // service_test.go contains backend tests for package behavior, error paths, and regressions. import ( "context" "encoding/json" "errors" "strings" "testing" "time" domain "knowfoolery/backend/services/leaderboard-service/internal/domain/leaderboard" sharedtypes "knowfoolery/backend/shared/domain/types" ) type fakeRepo struct { entries []*domain.LeaderboardEntry stats map[string]*domain.PlayerStats ingestErr error topErr error statsErr error rankErr error historyErr error globalErr error } // newFakeRepo creates in-memory test doubles and deterministic fixtures. func newFakeRepo() *fakeRepo { return &fakeRepo{ entries: make([]*domain.LeaderboardEntry, 0), stats: map[string]*domain.PlayerStats{}, } } // EnsureSchema initializes schema state required before repository operations. func (r *fakeRepo) EnsureSchema(ctx context.Context) error { return nil } // IngestEntry supports ingest entry test setup and assertions. func (r *fakeRepo) IngestEntry( ctx context.Context, entry *domain.LeaderboardEntry, ) (*domain.LeaderboardEntry, bool, error) { if r.ingestErr != nil { return nil, false, r.ingestErr } for _, e := range r.entries { if e.SessionID == entry.SessionID { return e, true, nil } } cp := *entry cp.ID = "id-" + entry.SessionID cp.CreatedAt = time.Now().UTC() r.entries = append(r.entries, &cp) stats := r.stats[entry.PlayerID] if stats == nil { best := entry.DurationSeconds stats = &domain.PlayerStats{ PlayerID: entry.PlayerID, PlayerName: entry.PlayerName, GamesPlayed: 1, GamesCompleted: 0, TotalScore: int64(entry.Score), BestScore: entry.Score, AvgScore: float64(entry.Score), AvgSuccessRate: entry.SuccessRate, TotalQuestions: int64(entry.QuestionsAsked), TotalCorrect: int64(entry.QuestionsCorrect), BestDurationSec: &best, } if entry.CompletionType == domain.CompletionCompleted { stats.GamesCompleted = 1 } r.stats[entry.PlayerID] = stats return &cp, false, nil } stats.GamesPlayed++ stats.TotalScore += int64(entry.Score) stats.TotalQuestions += int64(entry.QuestionsAsked) stats.TotalCorrect += int64(entry.QuestionsCorrect) stats.AvgScore = float64(stats.TotalScore) / float64(stats.GamesPlayed) if stats.TotalQuestions > 0 { stats.AvgSuccessRate = float64(stats.TotalCorrect) * 100 / float64(stats.TotalQuestions) } if entry.Score > stats.BestScore { stats.BestScore = entry.Score best := entry.DurationSeconds stats.BestDurationSec = &best } return &cp, false, nil } // ListTop returns filtered collections from the in-memory repository. func (r *fakeRepo) ListTop( ctx context.Context, filter domain.TopFilter, limit int, ) ([]*domain.LeaderboardEntry, error) { if r.topErr != nil { return nil, r.topErr } if len(r.entries) < limit { limit = len(r.entries) } out := make([]*domain.LeaderboardEntry, 0, limit) for i := 0; i < limit; i++ { out = append(out, r.entries[i]) } return out, nil } // GetPlayerStats retrieves data from the in-memory repository. func (r *fakeRepo) GetPlayerStats(ctx context.Context, playerID string) (*domain.PlayerStats, error) { if r.statsErr != nil { return nil, r.statsErr } stats := r.stats[playerID] if stats == nil { return nil, domain.ErrPlayerNotFound } cp := *stats return &cp, nil } // GetPlayerRank retrieves data from the in-memory repository. func (r *fakeRepo) GetPlayerRank(ctx context.Context, playerID string) (int64, error) { if r.rankErr != nil { return 0, r.rankErr } if _, ok := r.stats[playerID]; !ok { return 0, domain.ErrPlayerNotFound } return 1, nil } // ListPlayerHistory returns filtered collections from the in-memory repository. func (r *fakeRepo) ListPlayerHistory( ctx context.Context, playerID string, pagination sharedtypes.Pagination, ) ([]*domain.LeaderboardEntry, int64, error) { if r.historyErr != nil { return nil, 0, r.historyErr } out := make([]*domain.LeaderboardEntry, 0) for _, entry := range r.entries { if entry.PlayerID == playerID { out = append(out, entry) } } return out, int64(len(out)), nil } // GetGlobalStats retrieves data from the in-memory repository. func (r *fakeRepo) GetGlobalStats(ctx context.Context, filter domain.TopFilter) (*domain.GlobalStats, error) { if r.globalErr != nil { return nil, r.globalErr } return &domain.GlobalStats{TotalGames: int64(len(r.entries)), UpdatedAt: time.Now().UTC()}, nil } type fakeState struct { data map[string]string setErr error deleteErr error } // newFakeState creates in-memory test doubles and deterministic fixtures. func newFakeState() *fakeState { return &fakeState{data: map[string]string{}} } // Get retrieves data from the in-memory repository. func (s *fakeState) Get(ctx context.Context, key string) (string, bool) { v, ok := s.data[key] return v, ok } // Set stores ephemeral state in the fake cache or state store. func (s *fakeState) Set(ctx context.Context, key, value string, ttl time.Duration) error { if s.setErr != nil { return s.setErr } s.data[key] = value return nil } // Delete removes ephemeral state from the fake cache or state store. func (s *fakeState) Delete(ctx context.Context, keys ...string) error { if s.deleteErr != nil { return s.deleteErr } for _, key := range keys { delete(s.data, key) } return nil } // TestUpdateScoreIdempotent ensures update score idempotent behavior is handled correctly. func TestUpdateScoreIdempotent(t *testing.T) { repo := newFakeRepo() state := newFakeState() svc := NewService(repo, state, Config{}) in := UpdateScoreInput{ SessionID: "s1", PlayerID: "u1", PlayerName: "Alice", TotalScore: 8, QuestionsAsked: 10, QuestionsCorrect: 7, HintsUsed: 1, DurationSeconds: 100, CompletedAt: time.Now().UTC(), CompletionType: "completed", } one, err := svc.UpdateScore(context.Background(), in) if err != nil { t.Fatalf("UpdateScore first failed: %v", err) } two, err := svc.UpdateScore(context.Background(), in) if err != nil { t.Fatalf("UpdateScore second failed: %v", err) } if one.ID != two.ID { t.Fatalf("idempotency failed: %s != %s", one.ID, two.ID) } } // TestUpdateScoreValidatesInput ensures update score validates input behavior is handled correctly. func TestUpdateScoreValidatesInput(t *testing.T) { svc := NewService(newFakeRepo(), newFakeState(), Config{}) _, err := svc.UpdateScore(context.Background(), UpdateScoreInput{ SessionID: "s1", PlayerID: "u1", PlayerName: "A", TotalScore: 1, QuestionsAsked: 1, QuestionsCorrect: 2, DurationSeconds: 1, CompletedAt: time.Now().UTC(), CompletionType: "completed", }) if !errors.Is(err, domain.ErrInvalidInput) { t.Fatalf("expected ErrInvalidInput, got %v", err) } } // TestGetPlayerRanking ensures get player ranking behavior is handled correctly. func TestGetPlayerRanking(t *testing.T) { repo := newFakeRepo() state := newFakeState() svc := NewService(repo, state, Config{}) _, _ = svc.UpdateScore(context.Background(), UpdateScoreInput{ SessionID: "s1", PlayerID: "u1", PlayerName: "Alice", TotalScore: 3, QuestionsAsked: 4, QuestionsCorrect: 2, DurationSeconds: 50, CompletedAt: time.Now().UTC(), CompletionType: "completed", }) result, err := svc.GetPlayerRanking(context.Background(), GetPlayerRankingInput{ PlayerID: "u1", Pagination: sharedtypes.Pagination{ Page: 1, PageSize: 10, }, }) if err != nil { t.Fatalf("GetPlayerRanking failed: %v", err) } if result.Rank != 1 { t.Fatalf("rank=%d want=1", result.Rank) } if len(result.History) != 1 { t.Fatalf("history len=%d want=1", len(result.History)) } } // TestUpdateScoreValidationAndErrorPaths ensures update score validation and error paths behavior is handled correctly. func TestUpdateScoreValidationAndErrorPaths(t *testing.T) { svc := NewService(newFakeRepo(), newFakeState(), Config{}) cases := []UpdateScoreInput{ {SessionID: "", PlayerID: "u1", CompletionType: "completed"}, {SessionID: "s1", PlayerID: "", CompletionType: "completed"}, {SessionID: "s1", PlayerID: "u1", TotalScore: -1, CompletionType: "completed"}, {SessionID: "s1", PlayerID: "u1", QuestionsAsked: 1, QuestionsCorrect: 2, CompletionType: "completed"}, {SessionID: "s1", PlayerID: "u1", CompletionType: "invalid"}, } for i, in := range cases { _, err := svc.UpdateScore(context.Background(), in) if !errors.Is(err, domain.ErrInvalidInput) { t.Fatalf("case %d: expected invalid input, got %v", i, err) } } repo := newFakeRepo() repo.ingestErr = errors.New("ingest boom") svc = NewService(repo, newFakeState(), Config{}) _, err := svc.UpdateScore(context.Background(), UpdateScoreInput{ SessionID: "s1", PlayerID: "u1", PlayerName: "Alice", CompletionType: "completed", }) if err == nil || !strings.Contains(err.Error(), "ingest boom") { t.Fatalf("expected ingest error, got %v", err) } } // TestTopAndStatsCachePaths ensures top and stats cache paths behavior is handled correctly. func TestTopAndStatsCachePaths(t *testing.T) { repo := newFakeRepo() state := newFakeState() svc := NewService(repo, state, Config{}) _, _ = svc.UpdateScore(context.Background(), UpdateScoreInput{ SessionID: "s1", PlayerID: "u1", PlayerName: "Alice", TotalScore: 8, QuestionsAsked: 10, QuestionsCorrect: 7, DurationSeconds: 100, CompletedAt: time.Now().UTC(), CompletionType: "completed", }) top, err := svc.GetTop10(context.Background(), GetTopInput{Window: domain.Window7d}) if err != nil { t.Fatalf("GetTop10 failed: %v", err) } if len(top.Items) == 0 || top.Items[0].Rank != 1 { t.Fatalf("expected ranked top item") } cacheKey := "lb:top10:v1::7d" payload, _ := json.Marshal(Top10Result{ Items: []RankedEntry{{Rank: 1, Entry: domain.LeaderboardEntry{SessionID: "cached"}}}, }) state.data[cacheKey] = string(payload) cached, err := svc.GetTop10(context.Background(), GetTopInput{Window: domain.Window7d}) if err != nil { t.Fatalf("GetTop10 cached failed: %v", err) } if cached.Items[0].Entry.SessionID != "cached" { t.Fatalf("expected cached top payload") } stats, err := svc.GetGlobalStats(context.Background(), GetStatsInput{Window: domain.WindowAll}) if err != nil { t.Fatalf("GetGlobalStats failed: %v", err) } if stats.TotalGames <= 0 { t.Fatalf("expected total games > 0") } state.data["lb:stats:global:v1::all"] = `{"TotalGames":42}` cachedStats, err := svc.GetGlobalStats(context.Background(), GetStatsInput{Window: domain.WindowAll}) if err != nil { t.Fatalf("cached stats failed: %v", err) } if cachedStats.TotalGames != 42 { t.Fatalf("expected cached total games") } } // TestRankingValidationAndErrorPaths ensures ranking validation and error paths behavior is handled correctly. func TestRankingValidationAndErrorPaths(t *testing.T) { repo := newFakeRepo() state := newFakeState() svc := NewService(repo, state, Config{}) _, err := svc.GetPlayerRanking(context.Background(), GetPlayerRankingInput{PlayerID: ""}) if !errors.Is(err, domain.ErrInvalidInput) { t.Fatalf("expected invalid input for empty player id, got %v", err) } _, _ = svc.UpdateScore(context.Background(), UpdateScoreInput{ SessionID: "s1", PlayerID: "u1", PlayerName: "Alice", TotalScore: 3, QuestionsAsked: 4, QuestionsCorrect: 2, DurationSeconds: 50, CompletedAt: time.Now().UTC(), CompletionType: "completed", }) state.data["lb:rank:u1"] = "not-a-number" _, err = svc.GetPlayerRanking(context.Background(), GetPlayerRankingInput{ PlayerID: "u1", Pagination: sharedtypes.Pagination{ Page: 1, PageSize: 1, }, }) if err != nil { t.Fatalf("expected fallback to repo rank when cache parse fails: %v", err) } repo.statsErr = errors.New("stats boom") _, err = svc.GetPlayerRanking(context.Background(), GetPlayerRankingInput{PlayerID: "u1"}) if err == nil || !strings.Contains(err.Error(), "stats boom") { t.Fatalf("expected stats error, got %v", err) } repo.statsErr = nil repo.rankErr = errors.New("rank boom") state.data["lb:rank:u1"] = "0" _, err = svc.GetPlayerRanking(context.Background(), GetPlayerRankingInput{PlayerID: "u1"}) if err == nil || !strings.Contains(err.Error(), "rank boom") { t.Fatalf("expected rank error, got %v", err) } repo.rankErr = nil repo.historyErr = errors.New("history boom") _, err = svc.GetPlayerRanking(context.Background(), GetPlayerRankingInput{PlayerID: "u1"}) if err == nil || !strings.Contains(err.Error(), "history boom") { t.Fatalf("expected history error, got %v", err) } } // TestGlobalStatsAndTopErrors ensures global stats and top errors behavior is handled correctly. func TestGlobalStatsAndTopErrors(t *testing.T) { repo := newFakeRepo() repo.topErr = errors.New("top boom") svc := NewService(repo, newFakeState(), Config{}) _, err := svc.GetTop10(context.Background(), GetTopInput{Window: domain.Window("bogus")}) if err == nil || !strings.Contains(err.Error(), "top boom") { t.Fatalf("expected top error, got %v", err) } repo = newFakeRepo() repo.globalErr = errors.New("global boom") svc = NewService(repo, newFakeState(), Config{}) _, err = svc.GetGlobalStats(context.Background(), GetStatsInput{Window: domain.Window("bogus")}) if err == nil || !strings.Contains(err.Error(), "global boom") { t.Fatalf("expected global error, got %v", err) } }