package publichttp import ( "bytes" "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "testing" "time" "galaxy/lobby/internal/adapters/gamestub" "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 *gamestub.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 := gamestub.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, gamestub.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, gamestub.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, gamestub.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, gamestub.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 := gamestub.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 := gamestub.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 := gamestub.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 := gamestub.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 *gamestub.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) }