Files
galaxy-game/lobby/internal/api/publichttp/games_test.go
T
2026-04-28 20:39:18 +02:00

359 lines
10 KiB
Go

package publichttp
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"time"
"galaxy/lobby/internal/adapters/gameinmem"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/game"
"galaxy/lobby/internal/ports"
"galaxy/lobby/internal/service/cancelgame"
"galaxy/lobby/internal/service/creategame"
"galaxy/lobby/internal/service/openenrollment"
"galaxy/lobby/internal/service/updategame"
"github.com/stretchr/testify/require"
)
type stubIDGenerator struct {
next common.GameID
}
func (g *stubIDGenerator) NewGameID() (common.GameID, error) {
return g.next, nil
}
func (g *stubIDGenerator) NewApplicationID() (common.ApplicationID, error) {
return "application-stub", nil
}
func (g *stubIDGenerator) NewInviteID() (common.InviteID, error) {
return "invite-stub", nil
}
func (g *stubIDGenerator) NewMembershipID() (common.MembershipID, error) {
return "membership-stub", nil
}
func silentLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func buildHandler(t *testing.T, store *gameinmem.Store, ids ports.IDGenerator, clock func() time.Time) http.Handler {
t.Helper()
logger := silentLogger()
createSvc, err := creategame.NewService(creategame.Dependencies{
Games: store,
IDs: ids,
Clock: clock,
Logger: logger,
})
require.NoError(t, err)
updateSvc, err := updategame.NewService(updategame.Dependencies{
Games: store,
Clock: clock,
Logger: logger,
})
require.NoError(t, err)
openSvc, err := openenrollment.NewService(openenrollment.Dependencies{
Games: store,
Clock: clock,
Logger: logger,
})
require.NoError(t, err)
cancelSvc, err := cancelgame.NewService(cancelgame.Dependencies{
Games: store,
Clock: clock,
Logger: logger,
})
require.NoError(t, err)
return newHandler(Dependencies{
Logger: logger,
CreateGame: createSvc,
UpdateGame: updateSvc,
OpenEnrollment: openSvc,
CancelGame: cancelSvc,
}, logger)
}
func doRequest(t *testing.T, handler http.Handler, method, path, userID string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader io.Reader
if body != nil {
data, err := json.Marshal(body)
require.NoError(t, err)
reader = bytes.NewReader(data)
}
req := httptest.NewRequestWithContext(context.Background(), method, path, reader)
if userID != "" {
req.Header.Set(xUserIDHeader, userID)
}
if reader != nil {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
func decodeGameRecord(t *testing.T, rec *httptest.ResponseRecorder) gameRecordResponse {
t.Helper()
var payload gameRecordResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
return payload
}
func decodeError(t *testing.T, rec *httptest.ResponseRecorder) errorResponse {
t.Helper()
var payload errorResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
return payload
}
func fixedClock(at time.Time) func() time.Time {
return func() time.Time { return at }
}
func TestCreateGameHappyPath(t *testing.T) {
t.Parallel()
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
store := gameinmem.NewStore()
handler := buildHandler(t, store, &stubIDGenerator{next: "game-first"}, fixedClock(now))
body := createGameRequest{
GameName: "Friends Game",
GameType: "private",
MinPlayers: 2,
MaxPlayers: 4,
StartGapHours: 4,
StartGapPlayers: 1,
EnrollmentEndsAt: now.Add(12 * time.Hour).Unix(),
TurnSchedule: "0 0 * * *",
TargetEngineVersion: "1.0.0",
}
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games", "user-42", body)
require.Equal(t, http.StatusCreated, rec.Code)
decoded := decodeGameRecord(t, rec)
require.Equal(t, "game-first", decoded.GameID)
require.Equal(t, "private", decoded.GameType)
require.Equal(t, "user-42", decoded.OwnerUserID)
require.Equal(t, "draft", decoded.Status)
require.Equal(t, body.EnrollmentEndsAt, decoded.EnrollmentEndsAt)
require.Equal(t, now.UnixMilli(), decoded.CreatedAt)
}
func TestCreateGameMissingUserIDHeader(t *testing.T) {
t.Parallel()
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
handler := buildHandler(t, gameinmem.NewStore(), &stubIDGenerator{next: "game-x"}, fixedClock(now))
body := createGameRequest{
GameName: "x",
GameType: "private",
MinPlayers: 2,
MaxPlayers: 4,
StartGapHours: 4,
StartGapPlayers: 1,
EnrollmentEndsAt: now.Add(time.Hour).Unix(),
TurnSchedule: "0 0 * * *",
TargetEngineVersion: "1.0.0",
}
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games", "", body)
require.Equal(t, http.StatusBadRequest, rec.Code)
decoded := decodeError(t, rec)
require.Equal(t, "invalid_request", decoded.Error.Code)
require.Contains(t, decoded.Error.Message, "X-User-ID")
}
func TestCreateGameUnknownJSONFieldRejected(t *testing.T) {
t.Parallel()
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
handler := buildHandler(t, gameinmem.NewStore(), &stubIDGenerator{next: "game-x"}, fixedClock(now))
reqBody := map[string]any{
"game_name": "x",
"game_type": "private",
"min_players": 2,
"max_players": 4,
"start_gap_hours": 4,
"start_gap_players": 1,
"enrollment_ends_at": now.Add(time.Hour).Unix(),
"turn_schedule": "0 0 * * *",
"target_engine_version": "1.0.0",
"owner_user_id": "user-42", // unknown — must be rejected
}
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games", "user-42", reqBody)
require.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestCreateGameUserCannotCreatePublic(t *testing.T) {
t.Parallel()
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
handler := buildHandler(t, gameinmem.NewStore(), &stubIDGenerator{next: "game-x"}, fixedClock(now))
body := createGameRequest{
GameName: "x",
GameType: "public",
MinPlayers: 2,
MaxPlayers: 4,
StartGapHours: 4,
StartGapPlayers: 1,
EnrollmentEndsAt: now.Add(time.Hour).Unix(),
TurnSchedule: "0 0 * * *",
TargetEngineVersion: "1.0.0",
}
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games", "user-42", body)
require.Equal(t, http.StatusForbidden, rec.Code)
decoded := decodeError(t, rec)
require.Equal(t, "forbidden", decoded.Error.Code)
}
func TestUpdateGameNotFound(t *testing.T) {
t.Parallel()
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
handler := buildHandler(t, gameinmem.NewStore(), &stubIDGenerator{next: "game-x"}, fixedClock(now))
desc := "new"
body := updateGameRequest{Description: &desc}
rec := doRequest(t, handler, http.MethodPatch, "/api/v1/lobby/games/game-missing", "user-1", body)
require.Equal(t, http.StatusNotFound, rec.Code)
decoded := decodeError(t, rec)
require.Equal(t, "subject_not_found", decoded.Error.Code)
}
func TestOpenEnrollmentHappyPath(t *testing.T) {
t.Parallel()
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
store := gameinmem.NewStore()
seedDraftForTest(t, store, "game-oe", game.GameTypePrivate, "user-1", now)
handler := buildHandler(t, store, &stubIDGenerator{next: "unused"}, fixedClock(now.Add(time.Hour)))
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games/game-oe/open-enrollment", "user-1", nil)
require.Equal(t, http.StatusOK, rec.Code)
decoded := decodeGameRecord(t, rec)
require.Equal(t, "enrollment_open", decoded.Status)
}
func TestOpenEnrollmentForbidden(t *testing.T) {
t.Parallel()
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
store := gameinmem.NewStore()
seedDraftForTest(t, store, "game-oe", game.GameTypePrivate, "user-1", now)
handler := buildHandler(t, store, &stubIDGenerator{next: "unused"}, fixedClock(now.Add(time.Hour)))
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games/game-oe/open-enrollment", "user-2", nil)
require.Equal(t, http.StatusForbidden, rec.Code)
}
func TestOpenEnrollmentConflict(t *testing.T) {
t.Parallel()
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
store := gameinmem.NewStore()
seedDraftForTest(t, store, "game-oe", game.GameTypePrivate, "user-1", now)
require.NoError(t, store.UpdateStatus(context.Background(), ports.UpdateStatusInput{
GameID: "game-oe",
ExpectedFrom: game.StatusDraft,
To: game.StatusEnrollmentOpen,
Trigger: game.TriggerCommand,
At: now.Add(5 * time.Minute),
}))
handler := buildHandler(t, store, &stubIDGenerator{next: "unused"}, fixedClock(now.Add(time.Hour)))
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games/game-oe/open-enrollment", "user-1", nil)
require.Equal(t, http.StatusConflict, rec.Code)
decoded := decodeError(t, rec)
require.Equal(t, "conflict", decoded.Error.Code)
}
func TestCancelGameHappyPath(t *testing.T) {
t.Parallel()
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
store := gameinmem.NewStore()
seedDraftForTest(t, store, "game-cx", game.GameTypePrivate, "user-1", now)
handler := buildHandler(t, store, &stubIDGenerator{next: "unused"}, fixedClock(now.Add(time.Hour)))
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games/game-cx/cancel", "user-1", nil)
require.Equal(t, http.StatusOK, rec.Code)
decoded := decodeGameRecord(t, rec)
require.Equal(t, "cancelled", decoded.Status)
}
func seedDraftForTest(
t *testing.T,
store *gameinmem.Store,
id common.GameID,
gameType game.GameType,
ownerUserID string,
now time.Time,
) {
t.Helper()
record, err := game.New(game.NewGameInput{
GameID: id,
GameName: "Seed",
GameType: gameType,
OwnerUserID: ownerUserID,
MinPlayers: 2,
MaxPlayers: 4,
StartGapHours: 4,
StartGapPlayers: 1,
EnrollmentEndsAt: now.Add(24 * time.Hour),
TurnSchedule: "0 */6 * * *",
TargetEngineVersion: "1.0.0",
Now: now,
})
require.NoError(t, err)
require.NoError(t, store.Save(context.Background(), record))
}
func TestIsValidationErrorHeuristic(t *testing.T) {
t.Parallel()
require.True(t, isValidationError(errStr("game name must not be empty")))
require.True(t, isValidationError(errStr("status \"ghost\" is unsupported")))
require.True(t, isValidationError(errStr("invalid cron expression")))
require.False(t, isValidationError(nil))
require.False(t, isValidationError(errStr("redis down")))
}
type errString string
func (e errString) Error() string { return string(e) }
func errStr(s string) error { return errString(s) }