package publichttp import ( "context" "encoding/json" "net/http" "testing" "time" "galaxy/lobby/internal/adapters/gamestub" "galaxy/lobby/internal/adapters/intentpubstub" "galaxy/lobby/internal/adapters/racenamestub" "galaxy/lobby/internal/adapters/userservicestub" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/listmyracenames" "galaxy/lobby/internal/service/registerracename" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type raceNameFixture struct { now time.Time directory *racenamestub.Directory users *userservicestub.Service intents *intentpubstub.Publisher handler http.Handler } func newRaceNameFixture(t *testing.T) *raceNameFixture { t.Helper() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) directory, err := racenamestub.NewDirectory(racenamestub.WithClock(func() time.Time { return now })) require.NoError(t, err) users := userservicestub.NewService() intents := intentpubstub.NewPublisher() logger := silentLogger() svc, err := registerracename.NewService(registerracename.Dependencies{ Directory: directory, Users: users, Intents: intents, Clock: func() time.Time { return now }, Logger: logger, }) require.NoError(t, err) return &raceNameFixture{ now: now, directory: directory, users: users, intents: intents, handler: newHandler(Dependencies{Logger: logger, RegisterRaceName: svc}, logger), } } func (f *raceNameFixture) seedPending(t *testing.T, gameID, userID, raceName string, eligibleUntil time.Time) { t.Helper() require.NoError(t, f.directory.Reserve(context.Background(), gameID, userID, raceName)) require.NoError(t, f.directory.MarkPendingRegistration(context.Background(), gameID, userID, raceName, eligibleUntil)) } func TestHandleRegisterRaceNameHappyPath(t *testing.T) { t.Parallel() f := newRaceNameFixture(t) f.users.SetEligibility("user-1", ports.Eligibility{Exists: true, MaxRegisteredRaceNames: 2}) f.seedPending(t, "game-1", "user-1", "Stellaris", f.now.Add(7*24*time.Hour)) rec := doRequest(t, f.handler, http.MethodPost, registerRaceNamePath, "user-1", registerRaceNameRequest{ RaceName: "Stellaris", SourceGameID: "game-1", }) require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) var resp registerRaceNameResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) assert.Equal(t, "Stellaris", resp.RaceName) assert.Equal(t, "game-1", resp.SourceGameID) assert.Equal(t, f.now.UnixMilli(), resp.RegisteredAtMs) assert.NotEmpty(t, resp.CanonicalKey) require.Len(t, f.intents.Published(), 1) } func TestHandleRegisterRaceNameRejectsMissingUserHeader(t *testing.T) { t.Parallel() f := newRaceNameFixture(t) rec := doRequest(t, f.handler, http.MethodPost, registerRaceNamePath, "", registerRaceNameRequest{ RaceName: "Stellaris", SourceGameID: "game-1", }) require.Equal(t, http.StatusBadRequest, rec.Code) var env errorResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &env)) assert.Equal(t, "invalid_request", env.Error.Code) } func TestHandleRegisterRaceNameRejectsUnknownFields(t *testing.T) { t.Parallel() f := newRaceNameFixture(t) rec := doRequest(t, f.handler, http.MethodPost, registerRaceNamePath, "user-1", map[string]string{ "race_name": "Stellaris", "source_game_id": "game-1", "extra": "boom", }) require.Equal(t, http.StatusBadRequest, rec.Code) var env errorResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &env)) assert.Equal(t, "invalid_request", env.Error.Code) } func TestHandleRegisterRaceNamePendingMissing(t *testing.T) { t.Parallel() f := newRaceNameFixture(t) f.users.SetEligibility("user-1", ports.Eligibility{Exists: true, MaxRegisteredRaceNames: 2}) rec := doRequest(t, f.handler, http.MethodPost, registerRaceNamePath, "user-1", registerRaceNameRequest{ RaceName: "Stellaris", SourceGameID: "game-1", }) require.Equal(t, http.StatusNotFound, rec.Code) var env errorResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &env)) assert.Equal(t, "subject_not_found", env.Error.Code) } func TestHandleRegisterRaceNamePendingExpired(t *testing.T) { t.Parallel() f := newRaceNameFixture(t) f.users.SetEligibility("user-1", ports.Eligibility{Exists: true, MaxRegisteredRaceNames: 2}) f.seedPending(t, "game-1", "user-1", "Stellaris", f.now.Add(-time.Minute)) rec := doRequest(t, f.handler, http.MethodPost, registerRaceNamePath, "user-1", registerRaceNameRequest{ RaceName: "Stellaris", SourceGameID: "game-1", }) require.Equal(t, http.StatusUnprocessableEntity, rec.Code) var env errorResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &env)) assert.Equal(t, "race_name_pending_window_expired", env.Error.Code) } func TestHandleRegisterRaceNameQuotaExceeded(t *testing.T) { t.Parallel() f := newRaceNameFixture(t) f.users.SetEligibility("user-1", ports.Eligibility{Exists: true, MaxRegisteredRaceNames: 1}) // pre-existing registered race name to exhaust quota f.seedPending(t, "game-old", "user-1", "OldName", f.now.Add(24*time.Hour)) require.NoError(t, f.directory.Register(context.Background(), "game-old", "user-1", "OldName")) // fresh pending the user wants to register f.seedPending(t, "game-1", "user-1", "Stellaris", f.now.Add(24*time.Hour)) rec := doRequest(t, f.handler, http.MethodPost, registerRaceNamePath, "user-1", registerRaceNameRequest{ RaceName: "Stellaris", SourceGameID: "game-1", }) require.Equal(t, http.StatusUnprocessableEntity, rec.Code) var env errorResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &env)) assert.Equal(t, "race_name_registration_quota_exceeded", env.Error.Code) } func TestHandleRegisterRaceNamePermanentBlock(t *testing.T) { t.Parallel() f := newRaceNameFixture(t) f.users.SetEligibility("user-1", ports.Eligibility{ Exists: true, PermanentBlocked: true, MaxRegisteredRaceNames: 2, }) f.seedPending(t, "game-1", "user-1", "Stellaris", f.now.Add(24*time.Hour)) rec := doRequest(t, f.handler, http.MethodPost, registerRaceNamePath, "user-1", registerRaceNameRequest{ RaceName: "Stellaris", SourceGameID: "game-1", }) require.Equal(t, http.StatusForbidden, rec.Code) var env errorResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &env)) assert.Equal(t, "forbidden", env.Error.Code) } func TestHandleRegisterRaceNameUserServiceUnavailable(t *testing.T) { t.Parallel() f := newRaceNameFixture(t) f.users.SetFailure("user-1", ports.ErrUserServiceUnavailable) f.seedPending(t, "game-1", "user-1", "Stellaris", f.now.Add(24*time.Hour)) rec := doRequest(t, f.handler, http.MethodPost, registerRaceNamePath, "user-1", registerRaceNameRequest{ RaceName: "Stellaris", SourceGameID: "game-1", }) require.Equal(t, http.StatusServiceUnavailable, rec.Code) var env errorResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &env)) assert.Equal(t, "service_unavailable", env.Error.Code) } // myRaceNamesFixture wires the self-service GET handler with // the in-process race-name directory, the in-process game store, and a // silent logger. type myRaceNamesFixture struct { now time.Time directory *racenamestub.Directory games *gamestub.Store handler http.Handler } func newMyRaceNamesFixture(t *testing.T) *myRaceNamesFixture { t.Helper() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) directory, err := racenamestub.NewDirectory(racenamestub.WithClock(func() time.Time { return now })) require.NoError(t, err) games := gamestub.NewStore() logger := silentLogger() svc, err := listmyracenames.NewService(listmyracenames.Dependencies{ Directory: directory, Games: games, Logger: logger, }) require.NoError(t, err) return &myRaceNamesFixture{ now: now, directory: directory, games: games, handler: newHandler(Dependencies{Logger: logger, ListMyRaceNames: svc}, logger), } } func (f *myRaceNamesFixture) seedGame(t *testing.T, id common.GameID, status game.Status) { t.Helper() record, err := game.New(game.NewGameInput{ GameID: id, GameName: "Seed " + id.String(), GameType: game.GameTypePublic, MinPlayers: 2, MaxPlayers: 4, StartGapHours: 4, StartGapPlayers: 1, EnrollmentEndsAt: f.now.Add(24 * time.Hour), TurnSchedule: "0 */6 * * *", TargetEngineVersion: "1.0.0", Now: f.now, }) require.NoError(t, err) if status != game.StatusDraft { record.Status = status } require.NoError(t, f.games.Save(context.Background(), record)) } func TestHandleListMyRaceNamesHappyPath(t *testing.T) { t.Parallel() f := newMyRaceNamesFixture(t) const userID = "user-1" f.seedGame(t, "game-finished", game.StatusFinished) require.NoError(t, f.directory.Reserve(context.Background(), "game-finished", userID, "Andromeda")) require.NoError(t, f.directory.MarkPendingRegistration(context.Background(), "game-finished", userID, "Andromeda", f.now.Add(7*24*time.Hour))) require.NoError(t, f.directory.Register(context.Background(), "game-finished", userID, "Andromeda")) f.seedGame(t, "game-pending", game.StatusFinished) require.NoError(t, f.directory.Reserve(context.Background(), "game-pending", userID, "Vega")) require.NoError(t, f.directory.MarkPendingRegistration(context.Background(), "game-pending", userID, "Vega", f.now.Add(24*time.Hour))) f.seedGame(t, "game-running", game.StatusRunning) require.NoError(t, f.directory.Reserve(context.Background(), "game-running", userID, "Orion")) rec := doRequest(t, f.handler, http.MethodGet, myRaceNamesPath, userID, nil) require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) var resp myRaceNamesResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) require.Len(t, resp.Registered, 1) assert.Equal(t, "Andromeda", resp.Registered[0].RaceName) assert.Equal(t, "game-finished", resp.Registered[0].SourceGameID) assert.Equal(t, f.now.UnixMilli(), resp.Registered[0].RegisteredAtMs) require.Len(t, resp.Pending, 1) assert.Equal(t, "Vega", resp.Pending[0].RaceName) assert.Equal(t, "game-pending", resp.Pending[0].SourceGameID) assert.Equal(t, f.now.Add(24*time.Hour).UnixMilli(), resp.Pending[0].EligibleUntilMs) require.Len(t, resp.Reservations, 1) assert.Equal(t, "Orion", resp.Reservations[0].RaceName) assert.Equal(t, "game-running", resp.Reservations[0].GameID) assert.Equal(t, string(game.StatusRunning), resp.Reservations[0].GameStatus) } func TestHandleListMyRaceNamesEmpty(t *testing.T) { t.Parallel() f := newMyRaceNamesFixture(t) rec := doRequest(t, f.handler, http.MethodGet, myRaceNamesPath, "user-empty", nil) require.Equal(t, http.StatusOK, rec.Code) var resp myRaceNamesResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) assert.NotNil(t, resp.Registered) assert.NotNil(t, resp.Pending) assert.NotNil(t, resp.Reservations) assert.Empty(t, resp.Registered) assert.Empty(t, resp.Pending) assert.Empty(t, resp.Reservations) } // TestHandleListMyRaceNamesVisibility confirms that one user's RND // state is not exposed through another user's `X-User-ID`. This is the // exit-criteria check from PLAN.md the func TestHandleListMyRaceNamesVisibility(t *testing.T) { t.Parallel() f := newMyRaceNamesFixture(t) f.seedGame(t, "game-shared", game.StatusEnrollmentOpen) require.NoError(t, f.directory.Reserve(context.Background(), "game-shared", "user-owner", "Polaris")) rec := doRequest(t, f.handler, http.MethodGet, myRaceNamesPath, "user-other", nil) require.Equal(t, http.StatusOK, rec.Code) var resp myRaceNamesResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) assert.Empty(t, resp.Reservations) assert.Empty(t, resp.Pending) assert.Empty(t, resp.Registered) } func TestHandleListMyRaceNamesRejectsMissingUserHeader(t *testing.T) { t.Parallel() f := newMyRaceNamesFixture(t) rec := doRequest(t, f.handler, http.MethodGet, myRaceNamesPath, "", nil) require.Equal(t, http.StatusBadRequest, rec.Code) var env errorResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &env)) assert.Equal(t, "invalid_request", env.Error.Code) } // TestHandleListMyRaceNamesUnwiredService confirms the 500 fallback // when wiring forgets to inject the service. func TestHandleListMyRaceNamesUnwiredService(t *testing.T) { t.Parallel() logger := silentLogger() handler := newHandler(Dependencies{Logger: logger}, logger) rec := doRequest(t, handler, http.MethodGet, myRaceNamesPath, "user-1", nil) require.Equal(t, http.StatusInternalServerError, rec.Code) var env errorResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &env)) assert.Equal(t, "internal_error", env.Error.Code) }