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.

260 lines
8.0 KiB
Go

package tests
// integration_http_test.go contains backend tests for package behavior, error paths, and regressions.
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
}
// newInMemoryRepo creates in-memory test doubles and deterministic fixtures.
func newInMemoryRepo() *inMemoryRepo {
return &inMemoryRepo{
entries: make([]*domain.LeaderboardEntry, 0),
stats: map[string]*domain.PlayerStats{},
}
}
// EnsureSchema initializes schema state required before repository operations.
func (r *inMemoryRepo) EnsureSchema(ctx context.Context) error { return nil }
// IngestEntry supports ingest entry test setup and assertions.
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
}
// ListTop returns filtered collections from the in-memory repository.
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
}
// GetPlayerStats retrieves data from the in-memory repository.
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
}
// GetPlayerRank retrieves data from the in-memory repository.
func (r *inMemoryRepo) GetPlayerRank(ctx context.Context, playerID string) (int64, error) {
if _, ok := r.stats[playerID]; !ok {
return 0, domain.ErrPlayerNotFound
}
return 1, nil
}
// ListPlayerHistory returns filtered collections from the in-memory repository.
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
}
// GetGlobalStats retrieves data from the in-memory repository.
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{}
// Get retrieves data from the in-memory repository.
func (s *fakeState) Get(ctx context.Context, key string) (string, bool) { return "", false }
// 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 {
return nil
}
// Delete removes ephemeral state from the fake cache or state store.
func (s *fakeState) Delete(ctx context.Context, keys ...string) error { return nil }
// setupApp wires the test application with mocked dependencies.
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
}
// TestUpdateAndTop10 ensures update and top10 behavior is handled correctly.
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")
}
}
// TestPlayerAuthAndStats ensures player auth and stats behavior is handled correctly.
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")
}
}
// TestMetricsEndpoint ensures metrics endpoint behavior is handled correctly.
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")
}
}
// assertStatus asserts HTTP status codes and response payload invariants.
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)
}
}