package creategame_test import ( "context" "errors" "io" "log/slog" "testing" "time" "galaxy/lobby/internal/adapters/gameinmem" "galaxy/lobby/internal/adapters/idgen" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/creategame" "galaxy/lobby/internal/service/shared" "github.com/stretchr/testify/require" ) type stubIDGenerator struct { next common.GameID err error } func (g *stubIDGenerator) NewGameID() (common.GameID, error) { if g.err != nil { return "", g.err } return g.next, nil } func (g *stubIDGenerator) NewApplicationID() (common.ApplicationID, error) { return "application-stub", nil } func (g *stubIDGenerator) NewInviteID() (common.InviteID, error) { return "invite-stub", nil } func (g *stubIDGenerator) NewMembershipID() (common.MembershipID, error) { return "membership-stub", nil } func newFixedClock(at time.Time) func() time.Time { return func() time.Time { return at } } func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } func validPublicInput(now time.Time) creategame.Input { return creategame.Input{ Actor: shared.NewAdminActor(), GameName: "Spring Classic", Description: "", GameType: game.GameTypePublic, MinPlayers: 2, MaxPlayers: 4, StartGapHours: 4, StartGapPlayers: 1, EnrollmentEndsAt: now.Add(24 * time.Hour), TurnSchedule: "0 */6 * * *", TargetEngineVersion: "1.0.0", } } func validPrivateInput(now time.Time, userID string) creategame.Input { return creategame.Input{ Actor: shared.NewUserActor(userID), GameName: "Friends only", GameType: game.GameTypePrivate, MinPlayers: 2, MaxPlayers: 4, StartGapHours: 4, StartGapPlayers: 1, EnrollmentEndsAt: now.Add(12 * time.Hour), TurnSchedule: "0 0 * * *", TargetEngineVersion: "1.0.0", } } func TestNewServiceRequiresStoreAndIDs(t *testing.T) { t.Parallel() _, err := creategame.NewService(creategame.Dependencies{}) require.Error(t, err) _, err = creategame.NewService(creategame.Dependencies{Games: gameinmem.NewStore()}) require.Error(t, err) _, err = creategame.NewService(creategame.Dependencies{ Games: gameinmem.NewStore(), IDs: &stubIDGenerator{next: "game-ok"}, }) require.NoError(t, err) } func TestHandleAdminCreatesPublicGame(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) store := gameinmem.NewStore() service, err := creategame.NewService(creategame.Dependencies{ Games: store, IDs: &stubIDGenerator{next: "game-alpha"}, Clock: newFixedClock(now), Logger: silentLogger(), }) require.NoError(t, err) record, err := service.Handle(context.Background(), validPublicInput(now)) require.NoError(t, err) require.Equal(t, common.GameID("game-alpha"), record.GameID) require.Equal(t, game.GameTypePublic, record.GameType) require.Equal(t, game.StatusDraft, record.Status) require.Equal(t, "", record.OwnerUserID) require.Equal(t, now.UTC(), record.CreatedAt) require.Equal(t, now.UTC(), record.UpdatedAt) stored, err := store.Get(context.Background(), record.GameID) require.NoError(t, err) require.Equal(t, record, stored) } func TestHandleUserCreatesPrivateGame(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 11, 0, 0, 0, time.UTC) store := gameinmem.NewStore() service, err := creategame.NewService(creategame.Dependencies{ Games: store, IDs: &stubIDGenerator{next: "game-beta"}, Clock: newFixedClock(now), Logger: silentLogger(), }) require.NoError(t, err) record, err := service.Handle(context.Background(), validPrivateInput(now, "user-42")) require.NoError(t, err) require.Equal(t, common.GameID("game-beta"), record.GameID) require.Equal(t, game.GameTypePrivate, record.GameType) require.Equal(t, "user-42", record.OwnerUserID) } func TestHandleAdminForbiddenForPrivateGame(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) service, err := creategame.NewService(creategame.Dependencies{ Games: gameinmem.NewStore(), IDs: &stubIDGenerator{next: "game-x"}, Clock: newFixedClock(now), Logger: silentLogger(), }) require.NoError(t, err) input := validPrivateInput(now, "user-1") input.Actor = shared.NewAdminActor() _, err = service.Handle(context.Background(), input) require.ErrorIs(t, err, shared.ErrForbidden) } func TestHandleUserForbiddenForPublicGame(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) service, err := creategame.NewService(creategame.Dependencies{ Games: gameinmem.NewStore(), IDs: &stubIDGenerator{next: "game-x"}, Clock: newFixedClock(now), Logger: silentLogger(), }) require.NoError(t, err) input := validPublicInput(now) input.Actor = shared.NewUserActor("user-1") _, err = service.Handle(context.Background(), input) require.ErrorIs(t, err, shared.ErrForbidden) } func TestHandleInvalidActorReturnsError(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) service, err := creategame.NewService(creategame.Dependencies{ Games: gameinmem.NewStore(), IDs: &stubIDGenerator{next: "game-x"}, Clock: newFixedClock(now), Logger: silentLogger(), }) require.NoError(t, err) input := validPublicInput(now) input.Actor = shared.Actor{Kind: shared.ActorKindUser} // missing user id _, err = service.Handle(context.Background(), input) require.Error(t, err) require.Contains(t, err.Error(), "actor") } func TestHandleDomainValidationFailurePropagates(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) service, err := creategame.NewService(creategame.Dependencies{ Games: gameinmem.NewStore(), IDs: &stubIDGenerator{next: "game-bad-cron"}, Clock: newFixedClock(now), Logger: silentLogger(), }) require.NoError(t, err) input := validPublicInput(now) input.TurnSchedule = "not a cron" _, err = service.Handle(context.Background(), input) require.Error(t, err) require.Contains(t, err.Error(), "turn schedule") } func TestHandleEnrollmentDeadlineInPastFails(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) service, err := creategame.NewService(creategame.Dependencies{ Games: gameinmem.NewStore(), IDs: &stubIDGenerator{next: "game-past"}, Clock: newFixedClock(now), Logger: silentLogger(), }) require.NoError(t, err) input := validPublicInput(now) input.EnrollmentEndsAt = now.Add(-time.Hour) _, err = service.Handle(context.Background(), input) require.Error(t, err) require.Contains(t, err.Error(), "enrollment ends at") } func TestHandleIDGeneratorErrorPropagates(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) boom := errors.New("entropy exhausted") service, err := creategame.NewService(creategame.Dependencies{ Games: gameinmem.NewStore(), IDs: &stubIDGenerator{err: boom}, Clock: newFixedClock(now), Logger: silentLogger(), }) require.NoError(t, err) _, err = service.Handle(context.Background(), validPublicInput(now)) require.ErrorIs(t, err, boom) } func TestHandleStoreErrorPropagates(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) boom := errors.New("redis down") service, err := creategame.NewService(creategame.Dependencies{ Games: failingStore{err: boom}, IDs: &stubIDGenerator{next: "game-fail"}, Clock: newFixedClock(now), Logger: silentLogger(), }) require.NoError(t, err) _, err = service.Handle(context.Background(), validPublicInput(now)) require.ErrorIs(t, err, boom) } // failingStore is a ports.GameStore whose mutating methods always fail. type failingStore struct { err error } func (s failingStore) Save(context.Context, game.Game) error { return s.err } func (s failingStore) Get(context.Context, common.GameID) (game.Game, error) { return game.Game{}, s.err } func (s failingStore) GetByStatus(context.Context, game.Status) ([]game.Game, error) { return nil, s.err } func (s failingStore) GetByOwner(context.Context, string) ([]game.Game, error) { return nil, s.err } func (s failingStore) UpdateStatus(context.Context, ports.UpdateStatusInput) error { return s.err } func (s failingStore) UpdateRuntimeSnapshot(context.Context, ports.UpdateRuntimeSnapshotInput) error { return s.err } func (s failingStore) UpdateRuntimeBinding(context.Context, ports.UpdateRuntimeBindingInput) error { return s.err } func (s failingStore) CountByStatus(context.Context) (map[game.Status]int, error) { return nil, s.err } func TestHandleUsesRealIDGeneratorShape(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 12, 0, 0, 0, time.UTC) store := gameinmem.NewStore() service, err := creategame.NewService(creategame.Dependencies{ Games: store, IDs: idgen.NewGenerator(), Clock: newFixedClock(now), Logger: silentLogger(), }) require.NoError(t, err) record, err := service.Handle(context.Background(), validPublicInput(now)) require.NoError(t, err) require.NoError(t, record.GameID.Validate()) }