package updategame_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/shared" "galaxy/lobby/internal/service/updategame" "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 } } // seedDraftGame stores a draft record with sensible defaults for tests and // returns the persisted record. 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 Game", 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) *updategame.Service { t.Helper() svc, err := updategame.NewService(updategame.Dependencies{ Games: store, Clock: clock, Logger: silentLogger(), }) require.NoError(t, err) return svc } func TestHandleAdminFullEditInDraft(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) store := gamestub.NewStore() seedDraftGame(t, store, "game-a", game.GameTypePublic, "", now) later := now.Add(30 * time.Minute) service := newService(t, store, fixedClock(later)) updated, err := service.Handle(context.Background(), updategame.Input{ Actor: shared.NewAdminActor(), GameID: "game-a", GameName: new("Spring Cup"), Description: new("Warm-up round"), MinPlayers: new(3), MaxPlayers: new(6), StartGapHours: new(2), StartGapPlayers: new(2), TurnSchedule: new("0 9 * * *"), TargetEngineVersion: new("1.1.0"), }) require.NoError(t, err) require.Equal(t, "Spring Cup", updated.GameName) require.Equal(t, "Warm-up round", updated.Description) require.Equal(t, 3, updated.MinPlayers) require.Equal(t, 6, updated.MaxPlayers) require.Equal(t, 2, updated.StartGapHours) require.Equal(t, 2, updated.StartGapPlayers) require.Equal(t, "0 9 * * *", updated.TurnSchedule) require.Equal(t, "1.1.0", updated.TargetEngineVersion) require.Equal(t, later.UTC(), updated.UpdatedAt) } func TestHandleOwnerEditInDraft(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) store := gamestub.NewStore() seedDraftGame(t, store, "game-private", game.GameTypePrivate, "user-1", now) service := newService(t, store, fixedClock(now.Add(time.Hour))) updated, err := service.Handle(context.Background(), updategame.Input{ Actor: shared.NewUserActor("user-1"), GameID: "game-private", Description: new("Private scrim"), }) require.NoError(t, err) require.Equal(t, "Private scrim", updated.Description) } 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-private", game.GameTypePrivate, "user-1", now) service := newService(t, store, fixedClock(now.Add(time.Hour))) _, err := service.Handle(context.Background(), updategame.Input{ Actor: shared.NewUserActor("user-2"), GameID: "game-private", Description: new("drive-by"), }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestHandleUserCannotEditPublicGame(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) store := gamestub.NewStore() seedDraftGame(t, store, "game-public", game.GameTypePublic, "", now) service := newService(t, store, fixedClock(now.Add(time.Hour))) _, err := service.Handle(context.Background(), updategame.Input{ Actor: shared.NewUserActor("user-1"), GameID: "game-public", Description: new("drive-by"), }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestHandleEnrollmentOpenDescriptionOnly(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-open", game.GameTypePublic, "", now) // Force status to enrollment_open via UpdateStatus. 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))) updated, err := service.Handle(context.Background(), updategame.Input{ Actor: shared.NewAdminActor(), GameID: record.GameID, Description: new("Updated while enrollment is open"), }) require.NoError(t, err) require.Equal(t, "Updated while enrollment is open", updated.Description) require.Equal(t, game.StatusEnrollmentOpen, updated.Status) } func TestHandleEnrollmentOpenNonDescriptionRejected(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-open", 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(), updategame.Input{ Actor: shared.NewAdminActor(), GameID: record.GameID, MinPlayers: new(5), }) require.ErrorIs(t, err, game.ErrConflict) } func TestHandleTerminalStatusRejected(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-cancel", game.GameTypePublic, "", now) require.NoError(t, store.UpdateStatus(context.Background(), ports.UpdateStatusInput{ GameID: record.GameID, ExpectedFrom: game.StatusDraft, To: game.StatusCancelled, Trigger: game.TriggerCommand, At: now.Add(10 * time.Minute), })) service := newService(t, store, fixedClock(now.Add(time.Hour))) _, err := service.Handle(context.Background(), updategame.Input{ Actor: shared.NewAdminActor(), GameID: record.GameID, Description: new("ignored"), }) 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(), updategame.Input{ Actor: shared.NewAdminActor(), GameID: "game-missing", Description: new("nope"), }) require.ErrorIs(t, err, game.ErrNotFound) } func TestHandleValidationFailurePropagates(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) store := gamestub.NewStore() seedDraftGame(t, store, "game-a", game.GameTypePublic, "", now) service := newService(t, store, fixedClock(now.Add(time.Hour))) _, err := service.Handle(context.Background(), updategame.Input{ Actor: shared.NewAdminActor(), GameID: "game-a", MinPlayers: new(10), MaxPlayers: new(5), }) require.Error(t, err) require.Contains(t, err.Error(), "max players") } func TestHandleInvalidActorReturnsError(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) store := gamestub.NewStore() service := newService(t, store, fixedClock(now)) _, err := service.Handle(context.Background(), updategame.Input{ Actor: shared.Actor{Kind: shared.ActorKindUser}, // missing user id GameID: "game-a", Description: new("x"), }) require.Error(t, err) require.Contains(t, err.Error(), "actor") } func TestHandleInvalidGameID(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) store := gamestub.NewStore() service := newService(t, store, fixedClock(now)) _, err := service.Handle(context.Background(), updategame.Input{ Actor: shared.NewAdminActor(), GameID: "bad", Description: new("x"), }) require.Error(t, err) } func TestInputHasNonDescriptionFields(t *testing.T) { t.Parallel() require.False(t, updategame.Input{}.HasNonDescriptionFields()) require.False(t, updategame.Input{Description: new("x")}.HasNonDescriptionFields()) require.True(t, updategame.Input{GameName: new("x")}.HasNonDescriptionFields()) require.True(t, updategame.Input{MinPlayers: new(1)}.HasNonDescriptionFields()) }