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