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.

454 lines
13 KiB
Go

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 is a test helper.
func newInMemoryRepo() *inMemoryRepo {
return &inMemoryRepo{
sessions: map[string]*domain.GameSession{},
attempts: make([]*domain.SessionAttempt, 0),
}
}
// EnsureSchema is a test helper.
func (r *inMemoryRepo) EnsureSchema(ctx context.Context) error { return nil }
// CreateSession is a test helper.
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 is a test helper.
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 is a test helper.
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 is a test helper.
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 is a test helper.
func (r *inMemoryRepo) CreateAttempt(ctx context.Context, attempt *domain.SessionAttempt) error {
cp := *attempt
r.attempts = append(r.attempts, &cp)
return nil
}
// CreateEvent is a test helper.
func (r *inMemoryRepo) CreateEvent(ctx context.Context, event *domain.SessionEvent) error { return nil }
// ListQuestionIDsForSession is a test helper.
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 is a test helper.
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 is a test helper.
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 is a test helper.
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 is a test helper.
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 is a test helper.
func newFakeStateStore() *fakeStateStore {
return &fakeStateStore{
active: map[string]string{},
locks: map[string]bool{},
}
}
// GetActiveSession is a test helper.
func (s *fakeStateStore) GetActiveSession(ctx context.Context, playerID string) (string, bool) {
id, ok := s.active[playerID]
return id, ok
}
// SetActiveSession is a test helper.
func (s *fakeStateStore) SetActiveSession(ctx context.Context, playerID, sessionID string, ttl time.Duration) error {
s.active[playerID] = sessionID
return nil
}
// ClearActiveSession is a test helper.
func (s *fakeStateStore) ClearActiveSession(ctx context.Context, playerID string) error {
delete(s.active, playerID)
return nil
}
// SetTimer is a test helper.
func (s *fakeStateStore) SetTimer(ctx context.Context, sessionID string, expiresAt time.Time, ttl time.Duration) error {
return nil
}
// GetTimer is a test helper.
func (s *fakeStateStore) GetTimer(ctx context.Context, sessionID string) (time.Time, bool) {
return time.Time{}, false
}
// ClearTimer is a test helper.
func (s *fakeStateStore) ClearTimer(ctx context.Context, sessionID string) error { return nil }
// AcquireLock is a test helper.
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 is a test helper.
func (s *fakeStateStore) ReleaseLock(ctx context.Context, sessionID string) {
delete(s.locks, sessionID)
}
// setupApp wires a test Fiber app with in-memory dependencies and auth middleware.
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 validates start, session read, question read, answer, hint, and end flows.
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 validates auth and ownership guards on session endpoints.
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 verifies /metrics is reachable.
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 is a test helper.
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 is a test helper.
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 is a test helper.
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 is a test helper.
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 is a test helper.
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 is a test helper.
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
}