You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
444 lines
13 KiB
Go
444 lines
13 KiB
Go
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)
|
|
}
|
|
}
|