package openenrollment_test import ( "context" "io" "log/slog" "testing" "time" "galaxy/lobby/internal/adapters/gamestub" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/openenrollment" "galaxy/lobby/internal/service/shared" "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 } } func seedDraftGame( t *testing.T, store *gamestub.Store, id common.GameID, gameType game.GameType, ownerUserID string, now time.Time, ) game.Game { t.Helper() record, err := game.New(game.NewGameInput{ GameID: id, GameName: "Seed", GameType: gameType, OwnerUserID: ownerUserID, MinPlayers: 2, MaxPlayers: 4, StartGapHours: 4, StartGapPlayers: 1, EnrollmentEndsAt: now.Add(24 * time.Hour), TurnSchedule: "0 */6 * * *", TargetEngineVersion: "1.0.0", Now: now, }) require.NoError(t, err) require.NoError(t, store.Save(context.Background(), record)) return record } func newService(t *testing.T, store ports.GameStore, clock func() time.Time) *openenrollment.Service { t.Helper() svc, err := openenrollment.NewService(openenrollment.Dependencies{ Games: store, Clock: clock, Logger: silentLogger(), }) require.NoError(t, err) return svc } func TestHandleAdminHappyPath(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) store := gamestub.NewStore() seedDraftGame(t, store, "game-alpha", game.GameTypePublic, "", now) service := newService(t, store, fixedClock(now.Add(time.Hour))) updated, err := service.Handle(context.Background(), openenrollment.Input{ Actor: shared.NewAdminActor(), GameID: "game-alpha", }) require.NoError(t, err) require.Equal(t, game.StatusEnrollmentOpen, updated.Status) require.Equal(t, now.Add(time.Hour).UTC(), updated.UpdatedAt) } func TestHandleOwnerHappyPath(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) store := gamestub.NewStore() seedDraftGame(t, store, "game-p", game.GameTypePrivate, "user-1", now) service := newService(t, store, fixedClock(now.Add(time.Hour))) updated, err := service.Handle(context.Background(), openenrollment.Input{ Actor: shared.NewUserActor("user-1"), GameID: "game-p", }) require.NoError(t, err) require.Equal(t, game.StatusEnrollmentOpen, updated.Status) } func TestHandleNonOwnerForbidden(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) store := gamestub.NewStore() seedDraftGame(t, store, "game-p", game.GameTypePrivate, "user-1", now) service := newService(t, store, fixedClock(now.Add(time.Hour))) _, err := service.Handle(context.Background(), openenrollment.Input{ Actor: shared.NewUserActor("user-2"), GameID: "game-p", }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestHandleUserCannotOpenPublicGame(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) store := gamestub.NewStore() seedDraftGame(t, store, "game-pub", game.GameTypePublic, "", now) service := newService(t, store, fixedClock(now.Add(time.Hour))) _, err := service.Handle(context.Background(), openenrollment.Input{ Actor: shared.NewUserActor("user-1"), GameID: "game-pub", }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestHandleFromEnrollmentOpenConflict(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) store := gamestub.NewStore() record := seedDraftGame(t, store, "game-x", game.GameTypePublic, "", now) require.NoError(t, store.UpdateStatus(context.Background(), ports.UpdateStatusInput{ GameID: record.GameID, ExpectedFrom: game.StatusDraft, To: game.StatusEnrollmentOpen, Trigger: game.TriggerCommand, At: now.Add(5 * time.Minute), })) service := newService(t, store, fixedClock(now.Add(time.Hour))) _, err := service.Handle(context.Background(), openenrollment.Input{ Actor: shared.NewAdminActor(), GameID: record.GameID, }) require.ErrorIs(t, err, game.ErrConflict) } func TestHandleFromReadyToStartInvalidTransition(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) store := gamestub.NewStore() record := seedDraftGame(t, store, "game-rts", game.GameTypePublic, "", now) require.NoError(t, store.UpdateStatus(context.Background(), ports.UpdateStatusInput{ GameID: record.GameID, ExpectedFrom: game.StatusDraft, To: game.StatusEnrollmentOpen, Trigger: game.TriggerCommand, At: now.Add(5 * time.Minute), })) require.NoError(t, store.UpdateStatus(context.Background(), ports.UpdateStatusInput{ GameID: record.GameID, ExpectedFrom: game.StatusEnrollmentOpen, To: game.StatusReadyToStart, Trigger: game.TriggerManual, At: now.Add(10 * time.Minute), })) service := newService(t, store, fixedClock(now.Add(time.Hour))) // ExpectedFrom=draft but actual=ready_to_start → conflict via CAS. _, err := service.Handle(context.Background(), openenrollment.Input{ Actor: shared.NewAdminActor(), GameID: record.GameID, }) require.ErrorIs(t, err, game.ErrConflict) } func TestHandleNotFound(t *testing.T) { t.Parallel() store := gamestub.NewStore() service := newService(t, store, fixedClock(time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC))) _, err := service.Handle(context.Background(), openenrollment.Input{ Actor: shared.NewAdminActor(), GameID: "game-missing", }) require.ErrorIs(t, err, game.ErrNotFound) } func TestHandleInvalidActor(t *testing.T) { t.Parallel() store := gamestub.NewStore() service := newService(t, store, fixedClock(time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC))) _, err := service.Handle(context.Background(), openenrollment.Input{ Actor: shared.Actor{Kind: shared.ActorKindUser}, GameID: "game-x", }) require.Error(t, err) require.Contains(t, err.Error(), "actor") } func TestHandleInvalidGameID(t *testing.T) { t.Parallel() store := gamestub.NewStore() service := newService(t, store, fixedClock(time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC))) _, err := service.Handle(context.Background(), openenrollment.Input{ Actor: shared.NewAdminActor(), GameID: "bad-id", }) require.Error(t, err) }