package registerracename_test import ( "context" "encoding/json" "errors" "io" "log/slog" "sync" "testing" "time" "galaxy/lobby/internal/adapters/mocks" "galaxy/lobby/internal/adapters/racenameinmem" "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" "go.uber.org/mock/gomock" ) type intentRec struct { mu sync.Mutex published []notificationintent.Intent err error } func (r *intentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) { r.mu.Lock() defer r.mu.Unlock() if r.err != nil { return "", r.err } r.published = append(r.published, intent) return "1", nil } func (r *intentRec) snapshot() []notificationintent.Intent { r.mu.Lock() defer r.mu.Unlock() return append([]notificationintent.Intent(nil), r.published...) } func (r *intentRec) setErr(err error) { r.mu.Lock() defer r.mu.Unlock() r.err = err } type userRec struct { mu sync.Mutex elig map[string]ports.Eligibility failures map[string]error } func (r *userRec) record(_ context.Context, userID string) (ports.Eligibility, error) { r.mu.Lock() defer r.mu.Unlock() if err, ok := r.failures[userID]; ok { return ports.Eligibility{}, err } if e, ok := r.elig[userID]; ok { return e, nil } return ports.Eligibility{Exists: false}, nil } func (r *userRec) setEligibility(userID string, e ports.Eligibility) { r.mu.Lock() defer r.mu.Unlock() if r.elig == nil { r.elig = make(map[string]ports.Eligibility) } r.elig[userID] = e } func (r *userRec) setFailure(userID string, err error) { r.mu.Lock() defer r.mu.Unlock() if r.failures == nil { r.failures = make(map[string]error) } r.failures[userID] = err } func newIntentMock(t *testing.T, rec *intentRec) *mocks.MockIntentPublisher { t.Helper() m := mocks.NewMockIntentPublisher(gomock.NewController(t)) m.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes() return m } func newUserMock(t *testing.T, rec *userRec) *mocks.MockUserService { t.Helper() m := mocks.NewMockUserService(gomock.NewController(t)) m.EXPECT().GetEligibility(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes() return m } 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 *racenameinmem.Directory users *userRec usersMock *mocks.MockUserService intents *intentRec pubMock *mocks.MockIntentPublisher } func newFixture(t *testing.T, now time.Time) *fixture { t.Helper() directory, err := racenameinmem.NewDirectory(racenameinmem.WithClock(fixedClock(now))) require.NoError(t, err) users := &userRec{} intents := &intentRec{} return &fixture{ now: now, directory: directory, users: users, usersMock: newUserMock(t, users), intents: intents, pubMock: newIntentMock(t, intents), } } func (f *fixture) newService(t *testing.T) *registerracename.Service { t.Helper() svc, err := registerracename.NewService(registerracename.Dependencies{ Directory: f.directory, Users: f.usersMock, Intents: f.pubMock, 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.snapshot() 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.snapshot() 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.setErr(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) }