package internalhttp 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 fixedClock(at time.Time) func() time.Time { return func() time.Time { return at } } 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 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 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 TestAdminCreatesPublicGame(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-public"}, fixedClock(now)) body := createGameRequest{ GameName: "Winter Open", GameType: "public", MinPlayers: 4, MaxPlayers: 8, StartGapHours: 6, StartGapPlayers: 2, EnrollmentEndsAt: now.Add(48 * time.Hour).Unix(), TurnSchedule: "0 */4 * * *", TargetEngineVersion: "2.0.0", } rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games", body) require.Equal(t, http.StatusCreated, rec.Code) decoded := decodeGameRecord(t, rec) require.Equal(t, "public", decoded.GameType) require.Equal(t, "", decoded.OwnerUserID) require.Equal(t, "draft", decoded.Status) } func TestAdminCannotCreatePrivateGame(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-priv"}, fixedClock(now)) body := createGameRequest{ GameName: "Private Lobby", 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.StatusForbidden, rec.Code) decoded := decodeError(t, rec) require.Equal(t, "forbidden", decoded.Error.Code) } func TestAdminValidationError(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-bad"}, fixedClock(now)) body := createGameRequest{ GameName: "", 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", body) require.Equal(t, http.StatusBadRequest, rec.Code) decoded := decodeError(t, rec) require.Equal(t, "invalid_request", decoded.Error.Code) } func TestAdminUpdateAllFieldsInDraft(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) store := gameinmem.NewStore() seedDraftForTest(t, store, "game-u", game.GameTypePublic, "", now) handler := buildHandler(t, store, &stubIDGenerator{next: "unused"}, fixedClock(now.Add(time.Hour))) desc := "Updated by admin" body := updateGameRequest{Description: &desc} rec := doRequest(t, handler, http.MethodPatch, "/api/v1/lobby/games/game-u", body) require.Equal(t, http.StatusOK, rec.Code) decoded := decodeGameRecord(t, rec) require.Equal(t, "Updated by admin", decoded.Description) } func TestAdminOpenEnrollment(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.GameTypePublic, "", 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", nil) require.Equal(t, http.StatusOK, rec.Code) decoded := decodeGameRecord(t, rec) require.Equal(t, "enrollment_open", decoded.Status) } func TestAdminCancelFromRunning(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) store := gameinmem.NewStore() record := seedDraftForTest(t, store, "game-run", game.GameTypePublic, "", now) // Force status to running to exercise the 409 conflict path. record.Status = game.StatusRunning startedAt := now.Add(time.Minute) record.StartedAt = &startedAt record.UpdatedAt = startedAt require.NoError(t, store.Save(context.Background(), record)) handler := buildHandler(t, store, &stubIDGenerator{next: "unused"}, fixedClock(now.Add(time.Hour))) rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games/game-run/cancel", nil) require.Equal(t, http.StatusConflict, rec.Code) decoded := decodeError(t, rec) require.Equal(t, "conflict", decoded.Error.Code) } func TestAdminUpdateNotFound(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) handler := buildHandler(t, gameinmem.NewStore(), &stubIDGenerator{next: "unused"}, fixedClock(now)) desc := "x" body := updateGameRequest{Description: &desc} rec := doRequest(t, handler, http.MethodPatch, "/api/v1/lobby/games/game-missing", body) require.Equal(t, http.StatusNotFound, rec.Code) } func TestAdminCreateUnknownFieldRejected(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) handler := buildHandler(t, gameinmem.NewStore(), &stubIDGenerator{next: "unused"}, fixedClock(now)) reqBody := map[string]any{ "game_name": "x", "game_type": "public", "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", "unexpected": "nope", } rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games", reqBody) require.Equal(t, http.StatusBadRequest, rec.Code) } func seedDraftForTest( t *testing.T, store *gameinmem.Store, id common.GameID, gameType game.GameType, ownerUserID string, now time.Time, ) game.Game { 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)) return record }