package tests // Integration-style HTTP tests for game-session routes, auth guards, and metrics exposure. 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" appsession "knowfoolery/backend/services/game-session-service/internal/application/session" domain "knowfoolery/backend/services/game-session-service/internal/domain/session" httpapi "knowfoolery/backend/services/game-session-service/internal/interfaces/http" sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics" "knowfoolery/backend/shared/infra/utils/validation" sharedhttpx "knowfoolery/backend/shared/testutil/httpx" ) // inMemoryRepo is an in-memory repository used for HTTP integration tests. type inMemoryRepo struct { sessions map[string]*domain.GameSession attempts []*domain.SessionAttempt } // newInMemoryRepo creates in-memory test doubles and deterministic fixtures. func newInMemoryRepo() *inMemoryRepo { return &inMemoryRepo{ sessions: map[string]*domain.GameSession{}, attempts: make([]*domain.SessionAttempt, 0), } } // EnsureSchema initializes schema state required before repository operations. func (r *inMemoryRepo) EnsureSchema(ctx context.Context) error { return nil } // CreateSession persists a new entity in the in-memory repository. func (r *inMemoryRepo) CreateSession(ctx context.Context, session *domain.GameSession) (*domain.GameSession, error) { session.ID = "sess-1" now := time.Now().UTC() session.CreatedAt = now session.UpdatedAt = now cp := *session r.sessions[cp.ID] = &cp return &cp, nil } // GetSessionByID retrieves data from the in-memory repository. func (r *inMemoryRepo) GetSessionByID(ctx context.Context, id string) (*domain.GameSession, error) { s, ok := r.sessions[id] if !ok { return nil, domain.ErrSessionNotFound } cp := *s return &cp, nil } // GetActiveSessionByPlayerID retrieves data from the in-memory repository. func (r *inMemoryRepo) GetActiveSessionByPlayerID(ctx context.Context, playerID string) (*domain.GameSession, error) { for _, s := range r.sessions { if s.PlayerID == playerID && s.Status == domain.StatusActive { cp := *s return &cp, nil } } return nil, domain.ErrSessionNotFound } // UpdateSession updates an existing entity in the in-memory repository. func (r *inMemoryRepo) UpdateSession(ctx context.Context, session *domain.GameSession) (*domain.GameSession, error) { cp := *session cp.UpdatedAt = time.Now().UTC() r.sessions[cp.ID] = &cp return &cp, nil } // CreateAttempt persists a new entity in the in-memory repository. func (r *inMemoryRepo) CreateAttempt(ctx context.Context, attempt *domain.SessionAttempt) error { cp := *attempt r.attempts = append(r.attempts, &cp) return nil } // CreateEvent persists a new entity in the in-memory repository. func (r *inMemoryRepo) CreateEvent(ctx context.Context, event *domain.SessionEvent) error { return nil } // ListQuestionIDsForSession returns filtered collections from the in-memory repository. func (r *inMemoryRepo) ListQuestionIDsForSession(ctx context.Context, sessionID string) ([]string, error) { seen := map[string]bool{} ids := make([]string, 0) for _, a := range r.attempts { if a.SessionID != sessionID { continue } if seen[a.QuestionID] { continue } seen[a.QuestionID] = true ids = append(ids, a.QuestionID) } return ids, nil } // fakeQuestionBank is a deterministic upstream double used in HTTP tests. type fakeQuestionBank struct { questions []appsession.SessionQuestion } // GetRandomQuestion retrieves data from the in-memory repository. func (f *fakeQuestionBank) GetRandomQuestion( ctx context.Context, exclusions []string, theme, difficulty string, ) (*appsession.SessionQuestion, error) { excluded := map[string]bool{} for _, id := range exclusions { excluded[id] = true } for _, q := range f.questions { if excluded[q.ID] { continue } cp := q return &cp, nil } return nil, domain.ErrSessionNotFound } // GetQuestionByID retrieves data from the in-memory repository. func (f *fakeQuestionBank) GetQuestionByID(ctx context.Context, id string) (*appsession.SessionQuestion, error) { for _, q := range f.questions { if q.ID == id { cp := q return &cp, nil } } return nil, domain.ErrSessionNotFound } // ValidateAnswer supports validate answer test setup and assertions. func (f *fakeQuestionBank) ValidateAnswer( ctx context.Context, questionID, answer string, ) (*appsession.AnswerValidationResult, error) { return &appsession.AnswerValidationResult{ Matched: answer == "jupiter", Score: 1, }, nil } // fakeUserClient returns a verified user profile for tests. type fakeUserClient struct{} // GetUserProfile retrieves data from the in-memory repository. func (f *fakeUserClient) GetUserProfile( ctx context.Context, userID, bearerToken string, ) (*appsession.UserProfile, error) { return &appsession.UserProfile{ ID: userID, DisplayName: "Player", EmailVerified: true, }, nil } // fakeStateStore is a minimal in-memory state store. type fakeStateStore struct { active map[string]string locks map[string]bool } // newFakeStateStore creates in-memory test doubles and deterministic fixtures. func newFakeStateStore() *fakeStateStore { return &fakeStateStore{ active: map[string]string{}, locks: map[string]bool{}, } } // GetActiveSession retrieves data from the in-memory repository. func (s *fakeStateStore) GetActiveSession(ctx context.Context, playerID string) (string, bool) { id, ok := s.active[playerID] return id, ok } // SetActiveSession stores ephemeral state in the fake cache or state store. func (s *fakeStateStore) SetActiveSession(ctx context.Context, playerID, sessionID string, ttl time.Duration) error { s.active[playerID] = sessionID return nil } // ClearActiveSession removes ephemeral state from the fake cache or state store. func (s *fakeStateStore) ClearActiveSession(ctx context.Context, playerID string) error { delete(s.active, playerID) return nil } // SetTimer stores ephemeral state in the fake cache or state store. func (s *fakeStateStore) SetTimer(ctx context.Context, sessionID string, expiresAt time.Time, ttl time.Duration) error { return nil } // GetTimer retrieves data from the in-memory repository. func (s *fakeStateStore) GetTimer(ctx context.Context, sessionID string) (time.Time, bool) { return time.Time{}, false } // ClearTimer removes ephemeral state from the fake cache or state store. func (s *fakeStateStore) ClearTimer(ctx context.Context, sessionID string) error { return nil } // AcquireLock simulates distributed lock coordination for concurrent test flows. func (s *fakeStateStore) AcquireLock(ctx context.Context, sessionID string, ttl time.Duration) bool { if s.locks[sessionID] { return false } s.locks[sessionID] = true return true } // ReleaseLock simulates distributed lock coordination for concurrent test flows. func (s *fakeStateStore) ReleaseLock(ctx context.Context, sessionID string) { delete(s.locks, sessionID) } // setupApp wires the test application with mocked dependencies. func setupApp(t *testing.T) *fiber.App { t.Helper() repo := newInMemoryRepo() qb := &fakeQuestionBank{ questions: []appsession.SessionQuestion{ {ID: "q1", Theme: "science", Text: "Largest planet?", Hint: "Gas giant", Difficulty: "easy"}, {ID: "q2", Theme: "science", Text: "Closest star?", Hint: "Visible day", Difficulty: "easy"}, }, } svc := appsession.NewService( repo, qb, &fakeUserClient{}, newFakeStateStore(), appsession.Config{MinAnswerLatencyMs: 0}, ) metrics := sharedmetrics.NewMetrics(sharedmetrics.Config{ ServiceName: "game-session-service-test", Enabled: true, Registry: prometheus.NewRegistry(), }) handler := httpapi.NewHandler(svc, validation.NewValidator(), nil, metrics) app := fiber.New() authMW := func(c fiber.Ctx) error { switch c.Get("Authorization") { case "Bearer player1": c.Locals("user_id", "user-1") c.Locals("user_roles", []string{"player"}) return c.Next() case "Bearer player2": c.Locals("user_id", "user-2") c.Locals("user_roles", []string{"player"}) return c.Next() case "Bearer admin": c.Locals("user_id", "admin-1") c.Locals("user_roles", []string{"admin"}) return c.Next() default: return c.SendStatus(http.StatusUnauthorized) } } httpapi.RegisterRoutes(app, handler, authMW) app.Get("/metrics", adaptor.HTTPHandler(sharedmetrics.Handler())) return app } // TestSessionFlowEndpoints ensures session flow endpoints behavior is handled correctly. func TestSessionFlowEndpoints(t *testing.T) { app := setupApp(t) var sessionID string { startResp := mustJSONRequest(t, app, http.MethodPost, "/sessions/start", map[string]any{}, "Bearer player1") defer func() { _ = startResp.Body.Close() }() if startResp.StatusCode != http.StatusOK { t.Fatalf("start status=%d want=%d", startResp.StatusCode, http.StatusOK) } startData := decodeDataMap(t, startResp) session := asMap(t, startData["session"]) sessionID = asString(t, session["id"]) } { resp := mustJSONRequest(t, app, http.MethodGet, "/sessions/"+sessionID, nil, "Bearer player1") defer func() { _ = resp.Body.Close() }() assertStatus(t, resp, http.StatusOK, "get session failed") } { resp := mustJSONRequest(t, app, http.MethodGet, "/sessions/"+sessionID+"/question", nil, "Bearer player1") defer func() { _ = resp.Body.Close() }() assertStatus(t, resp, http.StatusOK, "get question failed") } { resp := mustJSONRequest(t, app, http.MethodPost, "/sessions/"+sessionID+"/answer", map[string]any{ "answer": "jupiter", }, "Bearer player1") defer func() { _ = resp.Body.Close() }() assertStatus(t, resp, http.StatusOK, "answer failed") } { resp := mustJSONRequest(t, app, http.MethodPost, "/sessions/"+sessionID+"/hint", map[string]any{}, "Bearer player1") defer func() { _ = resp.Body.Close() }() assertStatus(t, resp, http.StatusOK, "hint failed") } { resp := mustJSONRequest(t, app, http.MethodPost, "/sessions/end", map[string]any{ "session_id": sessionID, "reason": "manual", }, "Bearer player1") defer func() { _ = resp.Body.Close() }() assertStatus(t, resp, http.StatusOK, "end failed") } } // TestSessionUnauthorizedAndForbidden ensures session unauthorized and forbidden behavior is handled correctly. func TestSessionUnauthorizedAndForbidden(t *testing.T) { app := setupApp(t) { resp := mustJSONRequest(t, app, http.MethodPost, "/sessions/start", map[string]any{}, "") defer func() { _ = resp.Body.Close() }() assertStatus(t, resp, http.StatusUnauthorized, "expected unauthorized") } var sessionID string { startResp := mustJSONRequest(t, app, http.MethodPost, "/sessions/start", map[string]any{}, "Bearer player1") defer func() { _ = startResp.Body.Close() }() if startResp.StatusCode != http.StatusOK { assertStatus(t, startResp, http.StatusOK, "start failed") } startData := decodeDataMap(t, startResp) session := asMap(t, startData["session"]) if got := asString(t, session["player_id"]); got != "user-1" { t.Fatalf("session player_id=%s want=user-1", got) } sessionID = asString(t, session["id"]) } { resp := mustJSONRequest(t, app, http.MethodGet, "/sessions/"+sessionID, nil, "Bearer player2") defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusForbidden { payload := decodeAny(t, resp) t.Fatalf("expected forbidden: status=%d body=%v", resp.StatusCode, payload) } } } // 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") } // mustJSONRequest builds request fixtures and fails fast on malformed setup. func mustJSONRequest( t *testing.T, app *fiber.App, method, path string, body map[string]any, auth string, ) *http.Response { t.Helper() var reader *bytes.Reader if body == nil { reader = bytes.NewReader(nil) } else { raw, err := json.Marshal(body) if err != nil { t.Fatalf("json marshal failed: %v", err) } reader = bytes.NewReader(raw) } req := httptest.NewRequest(method, path, reader) req.Header.Set("Content-Type", "application/json") if auth != "" { req.Header.Set("Authorization", auth) } return sharedhttpx.MustTest(t, app, req) } // 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) } } // decodeDataMap decodes response data into assertion-friendly structures. func decodeDataMap(t *testing.T, resp *http.Response) map[string]any { t.Helper() var payload struct { Success bool `json:"success"` Data map[string]any `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { t.Fatalf("decode response failed: %v", err) } return payload.Data } // asMap decodes response data into assertion-friendly structures. func asMap(t *testing.T, v any) map[string]any { t.Helper() m, ok := v.(map[string]any) if !ok { t.Fatalf("value is not map: %#v", v) } return m } // asString decodes response data into assertion-friendly structures. func asString(t *testing.T, v any) string { t.Helper() s, ok := v.(string) if !ok { t.Fatalf("value is not string: %#v", v) } return s } // decodeAny decodes response data into assertion-friendly structures. func decodeAny(t *testing.T, resp *http.Response) map[string]any { t.Helper() var payload map[string]any if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { t.Fatalf("decode response failed: %v", err) } return payload }