package cancelgame_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/cancelgame" "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 } } // seedGameWithStatus persists a fresh game and, when wanted differs from // `draft`, overwrites the Status field directly via Save. This bypasses // the transition gate so individual tests can exercise cancel against any // status the surface must reject or accept. func seedGameWithStatus( t *testing.T, store *gamestub.Store, id common.GameID, gameType game.GameType, ownerUserID string, status game.Status, 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) if status != game.StatusDraft { record.Status = status record.UpdatedAt = now.Add(time.Minute) if status == game.StatusRunning || status == game.StatusPaused || status == game.StatusFinished { startedAt := now.Add(time.Minute) record.StartedAt = &startedAt } if status == game.StatusFinished { finishedAt := now.Add(2 * time.Minute) record.FinishedAt = &finishedAt } } require.NoError(t, store.Save(context.Background(), record)) return record } func newService(t *testing.T, store ports.GameStore, clock func() time.Time) *cancelgame.Service { t.Helper() svc, err := cancelgame.NewService(cancelgame.Dependencies{ Games: store, Clock: clock, Logger: silentLogger(), }) require.NoError(t, err) return svc } func TestHandleFromCancellableStatuses(t *testing.T) { t.Parallel() statuses := []game.Status{ game.StatusDraft, game.StatusEnrollmentOpen, game.StatusReadyToStart, game.StatusStartFailed, } for _, status := range statuses { t.Run(string(status), func(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) store := gamestub.NewStore() record := seedGameWithStatus(t, store, "game-a", game.GameTypePublic, "", status, now) service := newService(t, store, fixedClock(now.Add(time.Hour))) updated, err := service.Handle(context.Background(), cancelgame.Input{ Actor: shared.NewAdminActor(), GameID: record.GameID, }) require.NoError(t, err) require.Equal(t, game.StatusCancelled, updated.Status) require.Equal(t, now.Add(time.Hour).UTC(), updated.UpdatedAt) }) } } func TestHandleFromRejectedStatuses(t *testing.T) { t.Parallel() statuses := []game.Status{ game.StatusStarting, game.StatusRunning, game.StatusPaused, } for _, status := range statuses { t.Run(string(status), func(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) store := gamestub.NewStore() record := seedGameWithStatus(t, store, "game-b", game.GameTypePublic, "", status, now) service := newService(t, store, fixedClock(now.Add(time.Hour))) _, err := service.Handle(context.Background(), cancelgame.Input{ Actor: shared.NewAdminActor(), GameID: record.GameID, }) require.ErrorIs(t, err, game.ErrConflict) }) } } func TestHandleAlreadyCancelledIsConflict(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) store := gamestub.NewStore() record := seedGameWithStatus(t, store, "game-c", game.GameTypePublic, "", game.StatusCancelled, now) service := newService(t, store, fixedClock(now.Add(time.Hour))) _, err := service.Handle(context.Background(), cancelgame.Input{ Actor: shared.NewAdminActor(), GameID: record.GameID, }) require.ErrorIs(t, err, game.ErrConflict) } func TestHandleFinishedIsConflict(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) store := gamestub.NewStore() record := seedGameWithStatus(t, store, "game-f", game.GameTypePublic, "", game.StatusFinished, now) service := newService(t, store, fixedClock(now.Add(time.Hour))) _, err := service.Handle(context.Background(), cancelgame.Input{ Actor: shared.NewAdminActor(), GameID: record.GameID, }) require.ErrorIs(t, err, game.ErrConflict) } func TestHandleOwnerCancelsPrivate(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) store := gamestub.NewStore() record := seedGameWithStatus(t, store, "game-priv", game.GameTypePrivate, "user-1", game.StatusEnrollmentOpen, now) service := newService(t, store, fixedClock(now.Add(time.Hour))) updated, err := service.Handle(context.Background(), cancelgame.Input{ Actor: shared.NewUserActor("user-1"), GameID: record.GameID, }) require.NoError(t, err) require.Equal(t, game.StatusCancelled, updated.Status) } func TestHandleNonOwnerForbidden(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) store := gamestub.NewStore() record := seedGameWithStatus(t, store, "game-priv", game.GameTypePrivate, "user-1", game.StatusEnrollmentOpen, now) service := newService(t, store, fixedClock(now.Add(time.Hour))) _, err := service.Handle(context.Background(), cancelgame.Input{ Actor: shared.NewUserActor("user-2"), GameID: record.GameID, }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestHandleUserCannotCancelPublic(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) store := gamestub.NewStore() record := seedGameWithStatus(t, store, "game-pub", game.GameTypePublic, "", game.StatusEnrollmentOpen, now) service := newService(t, store, fixedClock(now.Add(time.Hour))) _, err := service.Handle(context.Background(), cancelgame.Input{ Actor: shared.NewUserActor("user-1"), GameID: record.GameID, }) require.ErrorIs(t, err, shared.ErrForbidden) } 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(), cancelgame.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(), cancelgame.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(), cancelgame.Input{ Actor: shared.NewAdminActor(), GameID: "bad", }) require.Error(t, err) }