package tests import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/middleware/adaptor" "github.com/prometheus/client_golang/prometheus" applb "knowfoolery/backend/services/leaderboard-service/internal/application/leaderboard" domain "knowfoolery/backend/services/leaderboard-service/internal/domain/leaderboard" httpapi "knowfoolery/backend/services/leaderboard-service/internal/interfaces/http" sharedtypes "knowfoolery/backend/shared/domain/types" sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics" "knowfoolery/backend/shared/infra/utils/validation" sharedhttpx "knowfoolery/backend/shared/testutil/httpx" ) type inMemoryRepo struct { entries []*domain.LeaderboardEntry stats map[string]*domain.PlayerStats } func newInMemoryRepo() *inMemoryRepo { return &inMemoryRepo{ entries: make([]*domain.LeaderboardEntry, 0), stats: map[string]*domain.PlayerStats{}, } } func (r *inMemoryRepo) EnsureSchema(ctx context.Context) error { return nil } func (r *inMemoryRepo) 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: 1, TotalScore: int64(entry.Score), BestScore: entry.Score, AvgScore: float64(entry.Score), AvgSuccessRate: entry.SuccessRate, TotalQuestions: int64(entry.QuestionsAsked), TotalCorrect: int64(entry.QuestionsCorrect), BestDurationSec: &best, } r.stats[entry.PlayerID] = stats } return &cp, false, nil } func (r *inMemoryRepo) 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 *inMemoryRepo) 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 *inMemoryRepo) GetPlayerRank(ctx context.Context, playerID string) (int64, error) { if _, ok := r.stats[playerID]; !ok { return 0, domain.ErrPlayerNotFound } return 1, nil } func (r *inMemoryRepo) ListPlayerHistory( ctx context.Context, playerID string, pagination sharedtypes.Pagination, ) ([]*domain.LeaderboardEntry, int64, error) { out := make([]*domain.LeaderboardEntry, 0) for _, e := range r.entries { if e.PlayerID == playerID { out = append(out, e) } } return out, int64(len(out)), nil } func (r *inMemoryRepo) GetGlobalStats(ctx context.Context, filter domain.TopFilter) (*domain.GlobalStats, error) { return &domain.GlobalStats{ TotalGames: int64(len(r.entries)), TotalPlayers: int64(len(r.stats)), UpdatedAt: time.Now().UTC(), }, nil } type fakeState struct{} func (s *fakeState) Get(ctx context.Context, key string) (string, bool) { return "", false } func (s *fakeState) Set(ctx context.Context, key, value string, ttl time.Duration) error { return nil } func (s *fakeState) Delete(ctx context.Context, keys ...string) error { return nil } func setupApp(t *testing.T) *fiber.App { t.Helper() repo := newInMemoryRepo() svc := applb.NewService(repo, &fakeState{}, applb.Config{UpdateRequireAuth: true}) metrics := sharedmetrics.NewMetrics(sharedmetrics.Config{ ServiceName: "leaderboard-service-test", Enabled: true, Registry: prometheus.NewRegistry(), }) h := httpapi.NewHandler(svc, validation.NewValidator(), nil, metrics, true, 20, 100) app := fiber.New() auth := func(c fiber.Ctx) error { switch c.Get("Authorization") { case "Bearer player": c.Locals("user_id", "user-1") c.Locals("user_roles", []string{"player"}) return c.Next() case "Bearer service": c.Locals("user_id", "svc") c.Locals("user_roles", []string{"service"}) return c.Next() default: return c.SendStatus(http.StatusUnauthorized) } } httpapi.RegisterRoutes(app, h, auth) app.Get("/metrics", adaptor.HTTPHandler(sharedmetrics.Handler())) return app } func TestUpdateAndTop10(t *testing.T) { app := setupApp(t) payload, _ := json.Marshal(map[string]any{ "session_id": "s1", "player_id": "user-1", "player_name": "Alice", "total_score": 10, "questions_asked": 12, "questions_correct": 9, "hints_used": 1, "duration_seconds": 200, "completed_at": time.Now().UTC().Format(time.RFC3339), "completion_type": "completed", }) req := httptest.NewRequest(http.MethodPost, "/leaderboard/update", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer service") { resp := sharedhttpx.MustTest(t, app, req) defer func() { _ = resp.Body.Close() }() assertStatus(t, resp, http.StatusOK, "update failed") } req = httptest.NewRequest(http.MethodGet, "/leaderboard/top10", nil) { resp := sharedhttpx.MustTest(t, app, req) defer func() { _ = resp.Body.Close() }() assertStatus(t, resp, http.StatusOK, "top10 failed") } } func TestPlayerAuthAndStats(t *testing.T) { app := setupApp(t) req := httptest.NewRequest(http.MethodGet, "/leaderboard/players/user-1", nil) req.Header.Set("Authorization", "Bearer player") { resp := sharedhttpx.MustTest(t, app, req) defer func() { _ = resp.Body.Close() }() assertStatus(t, resp, http.StatusNotFound, "expected not found before update") } req = httptest.NewRequest(http.MethodGet, "/leaderboard/players/user-2", nil) req.Header.Set("Authorization", "Bearer player") { resp := sharedhttpx.MustTest(t, app, req) defer func() { _ = resp.Body.Close() }() assertStatus(t, resp, http.StatusForbidden, "expected forbidden for other player") } req = httptest.NewRequest(http.MethodGet, "/leaderboard/stats", nil) { resp := sharedhttpx.MustTest(t, app, req) defer func() { _ = resp.Body.Close() }() assertStatus(t, resp, http.StatusOK, "stats failed") } } func TestMetricsEndpoint(t *testing.T) { app := setupApp(t) req := httptest.NewRequest(http.MethodGet, "/metrics", nil) { resp := sharedhttpx.MustTest(t, app, req) defer func() { _ = resp.Body.Close() }() assertStatus(t, resp, http.StatusOK, "metrics failed") } } func assertStatus(t *testing.T, resp *http.Response, want int, msg string) { t.Helper() if resp.StatusCode != want { t.Fatalf("%s: status=%d want=%d", msg, resp.StatusCode, want) } }