package rejectapplication_test import ( "context" "errors" "io" "log/slog" "sync" "testing" "time" "galaxy/lobby/internal/adapters/applicationinmem" "galaxy/lobby/internal/adapters/gameinmem" "galaxy/lobby/internal/adapters/mocks" "galaxy/lobby/internal/adapters/racenameinmem" "galaxy/lobby/internal/domain/application" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/rejectapplication" "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 } 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 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 games *gameinmem.Store applications *applicationinmem.Store directory *racenameinmem.Directory intentRec *intentRec intents *mocks.MockIntentPublisher openPublicGameID common.GameID } func newFixture(t *testing.T) *fixture { t.Helper() now := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC) dir, err := racenameinmem.NewDirectory(racenameinmem.WithClock(fixedClock(now))) require.NoError(t, err) games := gameinmem.NewStore() applications := applicationinmem.NewStore() gameRecord, err := game.New(game.NewGameInput{ GameID: "game-public", GameName: "Galactic Open", GameType: game.GameTypePublic, MinPlayers: 2, MaxPlayers: 4, StartGapHours: 2, StartGapPlayers: 1, EnrollmentEndsAt: now.Add(24 * time.Hour), TurnSchedule: "0 */6 * * *", TargetEngineVersion: "1.0.0", Now: now, }) require.NoError(t, err) gameRecord.Status = game.StatusEnrollmentOpen require.NoError(t, games.Save(context.Background(), gameRecord)) rec := &intentRec{} return &fixture{ now: now, games: games, applications: applications, directory: dir, intentRec: rec, openPublicGameID: gameRecord.GameID, } } func newService(t *testing.T, f *fixture) *rejectapplication.Service { t.Helper() if f.intents == nil { f.intents = newIntentMock(t, f.intentRec) } svc, err := rejectapplication.NewService(rejectapplication.Dependencies{ Games: f.games, Applications: f.applications, Directory: f.directory, Intents: f.intents, Clock: fixedClock(f.now), Logger: silentLogger(), }) require.NoError(t, err) return svc } func seedSubmittedApplication(t *testing.T, f *fixture, applicationID common.ApplicationID, userID, raceName string) application.Application { t.Helper() app, err := application.New(application.NewApplicationInput{ ApplicationID: applicationID, GameID: f.openPublicGameID, ApplicantUserID: userID, RaceName: raceName, Now: f.now, }) require.NoError(t, err) require.NoError(t, f.applications.Save(context.Background(), app)) return app } func TestRejectHappyPath(t *testing.T) { t.Parallel() f := newFixture(t) app := seedSubmittedApplication(t, f, "application-1", "user-1", "SolarPilot") svc := newService(t, f) got, err := svc.Handle(context.Background(), rejectapplication.Input{ Actor: shared.NewAdminActor(), GameID: f.openPublicGameID, ApplicationID: app.ApplicationID, }) require.NoError(t, err) assert.Equal(t, application.StatusRejected, got.Status) require.NotNil(t, got.DecidedAt) assert.Equal(t, f.now, got.DecidedAt.UTC()) intents := f.intentRec.snapshot() require.Len(t, intents, 1) assert.Equal(t, notificationintent.NotificationTypeLobbyMembershipRejected, intents[0].NotificationType) assert.Equal(t, []string{"user-1"}, intents[0].RecipientUserIDs) } func TestRejectReleasesPriorReservation(t *testing.T) { t.Parallel() f := newFixture(t) app := seedSubmittedApplication(t, f, "application-1", "user-1", "SolarPilot") require.NoError(t, f.directory.Reserve(context.Background(), f.openPublicGameID.String(), "user-1", "SolarPilot")) svc := newService(t, f) _, err := svc.Handle(context.Background(), rejectapplication.Input{ Actor: shared.NewAdminActor(), GameID: f.openPublicGameID, ApplicationID: app.ApplicationID, }) require.NoError(t, err) availability, err := f.directory.Check(context.Background(), "SolarPilot", "user-other") require.NoError(t, err) assert.False(t, availability.Taken) assert.Empty(t, availability.HolderUserID) } func TestRejectUserActorForbidden(t *testing.T) { t.Parallel() f := newFixture(t) app := seedSubmittedApplication(t, f, "application-1", "user-1", "SolarPilot") svc := newService(t, f) _, err := svc.Handle(context.Background(), rejectapplication.Input{ Actor: shared.NewUserActor("user-1"), GameID: f.openPublicGameID, ApplicationID: app.ApplicationID, }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestRejectNotFound(t *testing.T) { t.Parallel() f := newFixture(t) svc := newService(t, f) _, err := svc.Handle(context.Background(), rejectapplication.Input{ Actor: shared.NewAdminActor(), GameID: f.openPublicGameID, ApplicationID: "application-missing", }) require.ErrorIs(t, err, application.ErrNotFound) } func TestRejectCrossGameNotFound(t *testing.T) { t.Parallel() f := newFixture(t) app := seedSubmittedApplication(t, f, "application-1", "user-1", "SolarPilot") svc := newService(t, f) _, err := svc.Handle(context.Background(), rejectapplication.Input{ Actor: shared.NewAdminActor(), GameID: "game-other", ApplicationID: app.ApplicationID, }) require.ErrorIs(t, err, application.ErrNotFound) } func TestRejectAlreadyDecidedConflict(t *testing.T) { t.Parallel() f := newFixture(t) app := seedSubmittedApplication(t, f, "application-1", "user-1", "SolarPilot") require.NoError(t, f.applications.UpdateStatus(context.Background(), ports.UpdateApplicationStatusInput{ ApplicationID: app.ApplicationID, ExpectedFrom: application.StatusSubmitted, To: application.StatusApproved, At: f.now, })) svc := newService(t, f) _, err := svc.Handle(context.Background(), rejectapplication.Input{ Actor: shared.NewAdminActor(), GameID: f.openPublicGameID, ApplicationID: app.ApplicationID, }) require.ErrorIs(t, err, application.ErrConflict) } func TestRejectPublishFailureDoesNotRollback(t *testing.T) { t.Parallel() f := newFixture(t) app := seedSubmittedApplication(t, f, "application-1", "user-1", "SolarPilot") f.intentRec.setErr(errors.New("publish failed")) svc := newService(t, f) got, err := svc.Handle(context.Background(), rejectapplication.Input{ Actor: shared.NewAdminActor(), GameID: f.openPublicGameID, ApplicationID: app.ApplicationID, }) require.NoError(t, err) assert.Equal(t, application.StatusRejected, got.Status) stored, err := f.applications.Get(context.Background(), app.ApplicationID) require.NoError(t, err) assert.Equal(t, application.StatusRejected, stored.Status) }