package blockmember_test import ( "context" "io" "log/slog" "strings" "testing" "time" "galaxy/lobby/internal/adapters/gamestub" "galaxy/lobby/internal/adapters/membershipstub" "galaxy/lobby/internal/adapters/racenamestub" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/domain/membership" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/blockmember" "galaxy/lobby/internal/service/shared" "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 fixtures struct { games *gamestub.Store memberships *membershipstub.Store directory *racenamestub.Directory } func newFixtures(t *testing.T) *fixtures { t.Helper() directory, err := racenamestub.NewDirectory() require.NoError(t, err) return &fixtures{ games: gamestub.NewStore(), memberships: membershipstub.NewStore(), directory: directory, } } func (f *fixtures) newService(t *testing.T, clock func() time.Time) *blockmember.Service { t.Helper() svc, err := blockmember.NewService(blockmember.Dependencies{ Games: f.games, Memberships: f.memberships, Clock: clock, Logger: silentLogger(), }) require.NoError(t, err) return svc } func (f *fixtures) seedGame( t *testing.T, 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: "test block", 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) switch status { case game.StatusRunning, game.StatusPaused, 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, f.games.Save(context.Background(), record)) return record } func (f *fixtures) seedMembership( t *testing.T, gameID common.GameID, membershipID common.MembershipID, userID, raceName string, now time.Time, ) membership.Membership { t.Helper() record, err := membership.New(membership.NewMembershipInput{ MembershipID: membershipID, GameID: gameID, UserID: userID, RaceName: raceName, CanonicalKey: strings.ToLower(strings.ReplaceAll(raceName, " ", "")), Now: now, }) require.NoError(t, err) require.NoError(t, f.memberships.Save(context.Background(), record)) require.NoError(t, f.directory.Reserve(context.Background(), gameID.String(), userID, raceName)) return record } func (f *fixtures) markMembership( t *testing.T, membershipID common.MembershipID, to membership.Status, at time.Time, ) { t.Helper() require.NoError(t, f.memberships.UpdateStatus(context.Background(), ports.UpdateMembershipStatusInput{ MembershipID: membershipID, ExpectedFrom: membership.StatusActive, To: to, At: at, })) } func TestNewServiceRejectsMissingDeps(t *testing.T) { t.Parallel() _, err := blockmember.NewService(blockmember.Dependencies{}) require.Error(t, err) } func TestBlockMemberAdminPreStartKeepsReservation(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) f := newFixtures(t) gameRecord := f.seedGame(t, "game-public", game.GameTypePublic, "", game.StatusEnrollmentOpen, now) member := f.seedMembership(t, gameRecord.GameID, "membership-pub", "user-1", "SolarPilot", now) at := now.Add(time.Hour) svc := f.newService(t, fixedClock(at)) result, err := svc.Handle(context.Background(), blockmember.Input{ Actor: shared.NewAdminActor(), GameID: gameRecord.GameID, MembershipID: member.MembershipID, }) require.NoError(t, err) assert.Equal(t, membership.StatusBlocked, result.Status) require.NotNil(t, result.RemovedAt) assert.True(t, result.RemovedAt.Equal(at)) persisted, err := f.memberships.Get(context.Background(), member.MembershipID) require.NoError(t, err) assert.Equal(t, membership.StatusBlocked, persisted.Status) availability, err := f.directory.Check(context.Background(), "SolarPilot", "user-other") require.NoError(t, err) assert.True(t, availability.Taken) assert.Equal(t, "user-1", availability.HolderUserID) } func TestBlockMemberAdminPostStartKeepsReservation(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) f := newFixtures(t) gameRecord := f.seedGame(t, "game-public", game.GameTypePublic, "", game.StatusRunning, now) member := f.seedMembership(t, gameRecord.GameID, "membership-pub", "user-1", "SolarPilot", now) svc := f.newService(t, fixedClock(now.Add(time.Hour))) result, err := svc.Handle(context.Background(), blockmember.Input{ Actor: shared.NewAdminActor(), GameID: gameRecord.GameID, MembershipID: member.MembershipID, }) require.NoError(t, err) assert.Equal(t, membership.StatusBlocked, result.Status) availability, err := f.directory.Check(context.Background(), "SolarPilot", "user-other") require.NoError(t, err) assert.True(t, availability.Taken) assert.Equal(t, "user-1", availability.HolderUserID) } func TestBlockMemberPrivateOwner(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) f := newFixtures(t) gameRecord := f.seedGame(t, "game-priv", game.GameTypePrivate, "user-owner", game.StatusPaused, now) member := f.seedMembership(t, gameRecord.GameID, "membership-priv", "user-2", "SolarPilot", now) svc := f.newService(t, fixedClock(now.Add(time.Hour))) result, err := svc.Handle(context.Background(), blockmember.Input{ Actor: shared.NewUserActor("user-owner"), GameID: gameRecord.GameID, MembershipID: member.MembershipID, }) require.NoError(t, err) assert.Equal(t, membership.StatusBlocked, result.Status) } func TestBlockMemberRejectsPublicGameUserActor(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) f := newFixtures(t) gameRecord := f.seedGame(t, "game-public", game.GameTypePublic, "", game.StatusEnrollmentOpen, now) member := f.seedMembership(t, gameRecord.GameID, "membership-pub", "user-1", "SolarPilot", now) svc := f.newService(t, fixedClock(now.Add(time.Hour))) _, err := svc.Handle(context.Background(), blockmember.Input{ Actor: shared.NewUserActor("user-1"), GameID: gameRecord.GameID, MembershipID: member.MembershipID, }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestBlockMemberRejectsNonOwnerUser(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) f := newFixtures(t) gameRecord := f.seedGame(t, "game-priv", game.GameTypePrivate, "user-owner", game.StatusEnrollmentOpen, now) member := f.seedMembership(t, gameRecord.GameID, "membership-priv", "user-2", "SolarPilot", now) svc := f.newService(t, fixedClock(now.Add(time.Hour))) _, err := svc.Handle(context.Background(), blockmember.Input{ Actor: shared.NewUserActor("user-other"), GameID: gameRecord.GameID, MembershipID: member.MembershipID, }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestBlockMemberRejectsMismatchedGameID(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) f := newFixtures(t) gameRecord := f.seedGame(t, "game-public", game.GameTypePublic, "", game.StatusEnrollmentOpen, now) member := f.seedMembership(t, gameRecord.GameID, "membership-pub", "user-1", "SolarPilot", now) svc := f.newService(t, fixedClock(now.Add(time.Hour))) _, err := svc.Handle(context.Background(), blockmember.Input{ Actor: shared.NewAdminActor(), GameID: common.GameID("game-other"), MembershipID: member.MembershipID, }) require.ErrorIs(t, err, membership.ErrNotFound) } func TestBlockMemberRejectsMissingMembership(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) f := newFixtures(t) f.seedGame(t, "game-public", game.GameTypePublic, "", game.StatusEnrollmentOpen, now) svc := f.newService(t, fixedClock(now.Add(time.Hour))) _, err := svc.Handle(context.Background(), blockmember.Input{ Actor: shared.NewAdminActor(), GameID: "game-public", MembershipID: common.MembershipID("membership-missing"), }) require.ErrorIs(t, err, membership.ErrNotFound) } func TestBlockMemberRejectsAlreadyTerminalMembership(t *testing.T) { t.Parallel() cases := []struct { name string to membership.Status }{ {"removed", membership.StatusRemoved}, {"blocked", membership.StatusBlocked}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) f := newFixtures(t) gameRecord := f.seedGame(t, "game-public", game.GameTypePublic, "", game.StatusRunning, now) member := f.seedMembership(t, gameRecord.GameID, "membership-pub", "user-1", "SolarPilot", now) f.markMembership(t, member.MembershipID, tc.to, now.Add(time.Minute)) svc := f.newService(t, fixedClock(now.Add(time.Hour))) _, err := svc.Handle(context.Background(), blockmember.Input{ Actor: shared.NewAdminActor(), GameID: gameRecord.GameID, MembershipID: member.MembershipID, }) require.ErrorIs(t, err, membership.ErrConflict) }) } } func TestBlockMemberRejectsTerminalGameStatuses(t *testing.T) { t.Parallel() statuses := []game.Status{ game.StatusDraft, game.StatusFinished, game.StatusCancelled, } for _, status := range statuses { t.Run(string(status), func(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) f := newFixtures(t) gameRecord := f.seedGame(t, "game-public", game.GameTypePublic, "", status, now) member := f.seedMembership(t, gameRecord.GameID, "membership-pub", "user-1", "SolarPilot", now) svc := f.newService(t, fixedClock(now.Add(time.Hour))) _, err := svc.Handle(context.Background(), blockmember.Input{ Actor: shared.NewAdminActor(), GameID: gameRecord.GameID, MembershipID: member.MembershipID, }) require.ErrorIs(t, err, game.ErrConflict) }) } } func TestBlockMemberInvalidActor(t *testing.T) { t.Parallel() f := newFixtures(t) svc := f.newService(t, fixedClock(time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC))) _, err := svc.Handle(context.Background(), blockmember.Input{ Actor: shared.Actor{Kind: shared.ActorKindUser}, GameID: "game-x", MembershipID: "membership-x", }) require.Error(t, err) require.Contains(t, err.Error(), "actor") }