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
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)
|
|
}
|
|
}
|