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.

232 lines
5.9 KiB
Go

package leaderboard
import (
"context"
"errors"
"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
}
func newFakeRepo() *fakeRepo {
return &fakeRepo{
entries: make([]*domain.LeaderboardEntry, 0),
stats: map[string]*domain.PlayerStats{},
}
}
func (r *fakeRepo) EnsureSchema(ctx context.Context) error { return nil }
func (r *fakeRepo) IngestEntry(
ctx context.Context,
entry *domain.LeaderboardEntry,
) (*domain.LeaderboardEntry, bool, error) {
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
}
func (r *fakeRepo) ListTop(
ctx context.Context,
filter domain.TopFilter,
limit int,
) ([]*domain.LeaderboardEntry, error) {
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
}
func (r *fakeRepo) GetPlayerStats(ctx context.Context, playerID string) (*domain.PlayerStats, error) {
stats := r.stats[playerID]
if stats == nil {
return nil, domain.ErrPlayerNotFound
}
cp := *stats
return &cp, nil
}
func (r *fakeRepo) GetPlayerRank(ctx context.Context, playerID string) (int64, error) {
if _, ok := r.stats[playerID]; !ok {
return 0, domain.ErrPlayerNotFound
}
return 1, nil
}
func (r *fakeRepo) ListPlayerHistory(
ctx context.Context,
playerID string,
pagination sharedtypes.Pagination,
) ([]*domain.LeaderboardEntry, int64, error) {
out := make([]*domain.LeaderboardEntry, 0)
for _, entry := range r.entries {
if entry.PlayerID == playerID {
out = append(out, entry)
}
}
return out, int64(len(out)), nil
}
func (r *fakeRepo) GetGlobalStats(ctx context.Context, filter domain.TopFilter) (*domain.GlobalStats, error) {
return &domain.GlobalStats{TotalGames: int64(len(r.entries)), UpdatedAt: time.Now().UTC()}, nil
}
type fakeState struct {
data map[string]string
}
func newFakeState() *fakeState {
return &fakeState{data: map[string]string{}}
}
func (s *fakeState) Get(ctx context.Context, key string) (string, bool) {
v, ok := s.data[key]
return v, ok
}
func (s *fakeState) Set(ctx context.Context, key, value string, ttl time.Duration) error {
s.data[key] = value
return nil
}
func (s *fakeState) Delete(ctx context.Context, keys ...string) error {
for _, key := range keys {
delete(s.data, key)
}
return nil
}
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)
}
}
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)
}
}
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))
}
}