package rejectapplication_test import ( "context" "errors" "io" "log/slog" "testing" "time" "galaxy/lobby/internal/adapters/applicationstub" "galaxy/lobby/internal/adapters/gamestub" "galaxy/lobby/internal/adapters/intentpubstub" "galaxy/lobby/internal/adapters/racenamestub" "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" ) 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 *gamestub.Store applications *applicationstub.Store directory *racenamestub.Directory intents *intentpubstub.Publisher 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 := racenamestub.NewDirectory(racenamestub.WithClock(fixedClock(now))) require.NoError(t, err) games := gamestub.NewStore() applications := applicationstub.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)) return &fixture{ now: now, games: games, applications: applications, directory: dir, intents: intentpubstub.NewPublisher(), openPublicGameID: gameRecord.GameID, } } func newService(t *testing.T, f *fixture) *rejectapplication.Service { t.Helper() 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.intents.Published() 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.intents.SetError(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) }