package registerracename_test import ( "context" "encoding/json" "errors" "io" "log/slog" "testing" "time" "galaxy/lobby/internal/adapters/intentpubstub" "galaxy/lobby/internal/adapters/racenamestub" "galaxy/lobby/internal/adapters/userservicestub" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/registerracename" "galaxy/lobby/internal/service/shared" "galaxy/notificationintent" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) 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 } } type fixture struct { now time.Time directory *racenamestub.Directory users *userservicestub.Service intents *intentpubstub.Publisher } func newFixture(t *testing.T, now time.Time) *fixture { t.Helper() directory, err := racenamestub.NewDirectory(racenamestub.WithClock(fixedClock(now))) require.NoError(t, err) return &fixture{ now: now, directory: directory, users: userservicestub.NewService(), intents: intentpubstub.NewPublisher(), } } func (f *fixture) newService(t *testing.T) *registerracename.Service { t.Helper() svc, err := registerracename.NewService(registerracename.Dependencies{ Directory: f.directory, Users: f.users, Intents: f.intents, Clock: fixedClock(f.now), Logger: silentLogger(), }) require.NoError(t, err) return svc } // seedPending creates a pending_registration for (gameID, userID, // raceName) with eligibleUntil. It mirrors the production sequence // (Reserve at approve-time, MarkPendingRegistration at game finish). func (f *fixture) 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)) } // seedRegistered creates an existing registered entry by walking through // the full Reserve → MarkPending → Register flow. It is used to grow the // `len(registered)` for quota tests. func (f *fixture) seedRegistered(t *testing.T, gameID, userID, raceName string) { t.Helper() eligibleUntil := f.now.Add(24 * time.Hour) f.seedPending(t, gameID, userID, raceName, eligibleUntil) require.NoError(t, f.directory.Register(context.Background(), gameID, userID, raceName)) } func defaultEligibility(maxRegistered int) ports.Eligibility { return ports.Eligibility{ Exists: true, CanLogin: true, CanJoinGame: true, CanUpdateProfile: true, MaxRegisteredRaceNames: maxRegistered, } } func TestNewServiceRejectsMissingDeps(t *testing.T) { t.Parallel() _, err := registerracename.NewService(registerracename.Dependencies{}) require.Error(t, err) } func TestRegisterRaceNameHappyPath(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) f := newFixture(t, now) f.users.SetEligibility("user-1", defaultEligibility(2)) f.seedPending(t, "game-1", "user-1", "Stellaris", now.Add(7*24*time.Hour)) svc := f.newService(t) out, err := svc.Handle(context.Background(), registerracename.Input{ Actor: shared.NewUserActor("user-1"), SourceGameID: "game-1", RaceName: "Stellaris", }) require.NoError(t, err) assert.Equal(t, "game-1", out.SourceGameID) assert.Equal(t, "Stellaris", out.RaceName) expectedCanonical, err := f.directory.Canonicalize("Stellaris") require.NoError(t, err) assert.Equal(t, expectedCanonical, out.CanonicalKey) assert.Equal(t, now.UnixMilli(), out.RegisteredAtMs) registered, err := f.directory.ListRegistered(context.Background(), "user-1") require.NoError(t, err) require.Len(t, registered, 1) assert.Equal(t, "game-1", registered[0].SourceGameID) pending, err := f.directory.ListPendingRegistrations(context.Background(), "user-1") require.NoError(t, err) assert.Empty(t, pending) intents := f.intents.Published() require.Len(t, intents, 1) intent := intents[0] assert.Equal(t, notificationintent.NotificationTypeLobbyRaceNameRegistered, intent.NotificationType) require.Equal(t, []string{"user-1"}, intent.RecipientUserIDs) assert.Equal(t, "lobby.race_name.registered:game-1:user-1", intent.IdempotencyKey) var payload notificationintent.LobbyRaceNameRegisteredPayload require.NoError(t, json.Unmarshal([]byte(intent.PayloadJSON), &payload)) assert.Equal(t, "Stellaris", payload.RaceName) } func TestRegisterRaceNameIdempotentRetry(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) f := newFixture(t, now) f.users.SetEligibility("user-1", defaultEligibility(1)) f.seedPending(t, "game-1", "user-1", "Stellaris", now.Add(7*24*time.Hour)) svc := f.newService(t) first, err := svc.Handle(context.Background(), registerracename.Input{ Actor: shared.NewUserActor("user-1"), SourceGameID: "game-1", RaceName: "Stellaris", }) require.NoError(t, err) second, err := svc.Handle(context.Background(), registerracename.Input{ Actor: shared.NewUserActor("user-1"), SourceGameID: "game-1", RaceName: "Stellaris", }) require.NoError(t, err) assert.Equal(t, first, second) registered, err := f.directory.ListRegistered(context.Background(), "user-1") require.NoError(t, err) assert.Len(t, registered, 1, "registration must remain idempotent") intents := f.intents.Published() require.Len(t, intents, 2, "idempotent retry republishes the intent") for _, intent := range intents { assert.Equal(t, "lobby.race_name.registered:game-1:user-1", intent.IdempotencyKey) } } func TestRegisterRaceNameRejectsAdminActor(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) f := newFixture(t, now) svc := f.newService(t) _, err := svc.Handle(context.Background(), registerracename.Input{ Actor: shared.NewAdminActor(), SourceGameID: "game-1", RaceName: "Stellaris", }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestRegisterRaceNameRejectsInvalidActor(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) f := newFixture(t, now) svc := f.newService(t) _, err := svc.Handle(context.Background(), registerracename.Input{ Actor: shared.Actor{Kind: shared.ActorKindUser}, SourceGameID: "game-1", RaceName: "Stellaris", }) require.Error(t, err) require.Contains(t, err.Error(), "actor") } func TestRegisterRaceNameRejectsEmptyRaceName(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) f := newFixture(t, now) svc := f.newService(t) _, err := svc.Handle(context.Background(), registerracename.Input{ Actor: shared.NewUserActor("user-1"), SourceGameID: "game-1", RaceName: " ", }) require.Error(t, err) require.Contains(t, err.Error(), "must not be empty") } func TestRegisterRaceNameRejectsInvalidName(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) f := newFixture(t, now) svc := f.newService(t) _, err := svc.Handle(context.Background(), registerracename.Input{ Actor: shared.NewUserActor("user-1"), SourceGameID: "game-1", // "<>" fails ValidateTypeName. RaceName: "<>", }) require.ErrorIs(t, err, ports.ErrInvalidName) } func TestRegisterRaceNameRejectsUnknownUser(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) f := newFixture(t, now) svc := f.newService(t) _, err := svc.Handle(context.Background(), registerracename.Input{ Actor: shared.NewUserActor("user-ghost"), SourceGameID: "game-1", RaceName: "Stellaris", }) require.ErrorIs(t, err, shared.ErrSubjectNotFound) } func TestRegisterRaceNameRejectsPermanentBlock(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) f := newFixture(t, now) f.users.SetEligibility("user-1", ports.Eligibility{ Exists: true, PermanentBlocked: true, MaxRegisteredRaceNames: 2, }) f.seedPending(t, "game-1", "user-1", "Stellaris", now.Add(7*24*time.Hour)) svc := f.newService(t) _, err := svc.Handle(context.Background(), registerracename.Input{ Actor: shared.NewUserActor("user-1"), SourceGameID: "game-1", RaceName: "Stellaris", }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestRegisterRaceNamePendingMissing(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) f := newFixture(t, now) f.users.SetEligibility("user-1", defaultEligibility(2)) svc := f.newService(t) _, err := svc.Handle(context.Background(), registerracename.Input{ Actor: shared.NewUserActor("user-1"), SourceGameID: "game-1", RaceName: "Stellaris", }) require.ErrorIs(t, err, ports.ErrPendingMissing) } func TestRegisterRaceNamePendingForOtherUserSurfacesAsMissing(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) f := newFixture(t, now) f.users.SetEligibility("user-1", defaultEligibility(2)) // Pending exists for a different user; the actor has none. f.seedPending(t, "game-1", "user-other", "Stellaris", now.Add(24*time.Hour)) svc := f.newService(t) _, err := svc.Handle(context.Background(), registerracename.Input{ Actor: shared.NewUserActor("user-1"), SourceGameID: "game-1", RaceName: "Stellaris", }) // A pending entry held by a different user is reported as missing // for the actor: only registered names by another user surface as // ErrNameTaken on Register (see racenamedir.go port comment and // the Redis adapter behaviour). require.ErrorIs(t, err, ports.ErrPendingMissing) } func TestRegisterRaceNamePendingExpired(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) f := newFixture(t, now) f.users.SetEligibility("user-1", defaultEligibility(2)) // Pending elig until is in the past relative to now. f.seedPending(t, "game-1", "user-1", "Stellaris", now.Add(-time.Minute)) svc := f.newService(t) _, err := svc.Handle(context.Background(), registerracename.Input{ Actor: shared.NewUserActor("user-1"), SourceGameID: "game-1", RaceName: "Stellaris", }) require.ErrorIs(t, err, ports.ErrPendingExpired) } func TestRegisterRaceNameQuotaExceeded(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) f := newFixture(t, now) // Free-tier quota = 1; user already has one registered name. f.users.SetEligibility("user-1", defaultEligibility(1)) f.seedRegistered(t, "game-existing", "user-1", "OldName") f.seedPending(t, "game-new", "user-1", "Stellaris", now.Add(24*time.Hour)) svc := f.newService(t) _, err := svc.Handle(context.Background(), registerracename.Input{ Actor: shared.NewUserActor("user-1"), SourceGameID: "game-new", RaceName: "Stellaris", }) require.ErrorIs(t, err, ports.ErrQuotaExceeded) } func TestRegisterRaceNameUnlimitedQuotaAllowsManyRegistrations(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) f := newFixture(t, now) // MaxRegisteredRaceNames=0 marker → unlimited. f.users.SetEligibility("user-1", defaultEligibility(0)) f.seedRegistered(t, "game-a", "user-1", "First") f.seedRegistered(t, "game-b", "user-1", "Second") f.seedPending(t, "game-c", "user-1", "Third", now.Add(24*time.Hour)) svc := f.newService(t) _, err := svc.Handle(context.Background(), registerracename.Input{ Actor: shared.NewUserActor("user-1"), SourceGameID: "game-c", RaceName: "Third", }) require.NoError(t, err) } func TestRegisterRaceNameUserServiceUnavailable(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) f := newFixture(t, now) f.users.SetFailure("user-1", ports.ErrUserServiceUnavailable) f.seedPending(t, "game-1", "user-1", "Stellaris", now.Add(24*time.Hour)) svc := f.newService(t) _, err := svc.Handle(context.Background(), registerracename.Input{ Actor: shared.NewUserActor("user-1"), SourceGameID: "game-1", RaceName: "Stellaris", }) require.ErrorIs(t, err, ports.ErrUserServiceUnavailable) } func TestRegisterRaceNameCommitsEvenIfPublishFails(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) f := newFixture(t, now) f.users.SetEligibility("user-1", defaultEligibility(2)) f.seedPending(t, "game-1", "user-1", "Stellaris", now.Add(7*24*time.Hour)) f.intents.SetError(errors.New("notification stream unavailable")) svc := f.newService(t) out, err := svc.Handle(context.Background(), registerracename.Input{ Actor: shared.NewUserActor("user-1"), SourceGameID: "game-1", RaceName: "Stellaris", }) require.NoError(t, err) assert.Equal(t, "Stellaris", out.RaceName) registered, err := f.directory.ListRegistered(context.Background(), "user-1") require.NoError(t, err) require.Len(t, registered, 1) }