package listmemberships_test import ( "context" "io" "log/slog" "testing" "time" "galaxy/lobby/internal/adapters/gameinmem" "galaxy/lobby/internal/adapters/membershipinmem" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/domain/membership" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/listmemberships" "galaxy/lobby/internal/service/shared" "github.com/stretchr/testify/require" ) func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } type fixture struct { games *gameinmem.Store memberships *membershipinmem.Store svc *listmemberships.Service } func newFixture(t *testing.T) *fixture { t.Helper() games := gameinmem.NewStore() memberships := membershipinmem.NewStore() svc, err := listmemberships.NewService(listmemberships.Dependencies{ Games: games, Memberships: memberships, Logger: silentLogger(), }) require.NoError(t, err) return &fixture{games: games, memberships: memberships, svc: svc} } func seedGame( t *testing.T, store *gameinmem.Store, id common.GameID, gameType game.GameType, ownerUserID string, now time.Time, ) { 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)) } func seedMembership( t *testing.T, store *membershipinmem.Store, gameID common.GameID, userID string, status membership.Status, joinedAt time.Time, ) { t.Helper() record, err := membership.New(membership.NewMembershipInput{ MembershipID: common.MembershipID("membership-" + userID + "-" + gameID.String()), GameID: gameID, UserID: userID, RaceName: "Race-" + userID, CanonicalKey: "race-" + userID, Now: joinedAt, }) require.NoError(t, err) require.NoError(t, store.Save(context.Background(), record)) if status != membership.StatusActive { require.NoError(t, store.UpdateStatus(context.Background(), ports.UpdateMembershipStatusInput{ MembershipID: record.MembershipID, ExpectedFrom: membership.StatusActive, To: status, At: joinedAt.Add(time.Minute), })) } } func TestHandleAdminReturnsAllMemberships(t *testing.T) { t.Parallel() fix := newFixture(t) now := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) id := common.GameID("game-1") seedGame(t, fix.games, id, game.GameTypePublic, "", now) seedMembership(t, fix.memberships, id, "user-a", membership.StatusActive, now) seedMembership(t, fix.memberships, id, "user-b", membership.StatusRemoved, now.Add(time.Minute)) seedMembership(t, fix.memberships, id, "user-c", membership.StatusBlocked, now.Add(2*time.Minute)) out, err := fix.svc.Handle(context.Background(), listmemberships.Input{ Actor: shared.NewAdminActor(), GameID: id, }) require.NoError(t, err) require.Len(t, out.Items, 3) } func TestHandleOwnerOfPrivateGameSeesAll(t *testing.T) { t.Parallel() fix := newFixture(t) now := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) id := common.GameID("game-private") seedGame(t, fix.games, id, game.GameTypePrivate, "user-owner", now) seedMembership(t, fix.memberships, id, "user-a", membership.StatusActive, now) seedMembership(t, fix.memberships, id, "user-b", membership.StatusRemoved, now) out, err := fix.svc.Handle(context.Background(), listmemberships.Input{ Actor: shared.NewUserActor("user-owner"), GameID: id, }) require.NoError(t, err) require.Len(t, out.Items, 2) } func TestHandleActiveMemberSeesAll(t *testing.T) { t.Parallel() fix := newFixture(t) now := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) id := common.GameID("game-public") seedGame(t, fix.games, id, game.GameTypePublic, "", now) seedMembership(t, fix.memberships, id, "user-a", membership.StatusActive, now) seedMembership(t, fix.memberships, id, "user-b", membership.StatusBlocked, now) out, err := fix.svc.Handle(context.Background(), listmemberships.Input{ Actor: shared.NewUserActor("user-a"), GameID: id, }) require.NoError(t, err) require.Len(t, out.Items, 2) } func TestHandleBlockedMemberForbidden(t *testing.T) { t.Parallel() fix := newFixture(t) now := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) id := common.GameID("game-public-blocked") seedGame(t, fix.games, id, game.GameTypePublic, "", now) seedMembership(t, fix.memberships, id, "user-a", membership.StatusBlocked, now) _, err := fix.svc.Handle(context.Background(), listmemberships.Input{ Actor: shared.NewUserActor("user-a"), GameID: id, }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestHandleStrangerForbidden(t *testing.T) { t.Parallel() fix := newFixture(t) now := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) id := common.GameID("game-public-stranger") seedGame(t, fix.games, id, game.GameTypePublic, "", now) seedMembership(t, fix.memberships, id, "user-a", membership.StatusActive, now) _, err := fix.svc.Handle(context.Background(), listmemberships.Input{ Actor: shared.NewUserActor("user-stranger"), GameID: id, }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestHandleGameNotFound(t *testing.T) { t.Parallel() fix := newFixture(t) _, err := fix.svc.Handle(context.Background(), listmemberships.Input{ Actor: shared.NewAdminActor(), GameID: common.GameID("game-missing"), }) require.ErrorIs(t, err, game.ErrNotFound) } func TestHandlePagination(t *testing.T) { t.Parallel() fix := newFixture(t) now := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) id := common.GameID("game-page") seedGame(t, fix.games, id, game.GameTypePublic, "", now) for i := range 4 { seedMembership(t, fix.memberships, id, "user-"+string(rune('a'+i)), membership.StatusActive, now.Add(time.Duration(i)*time.Minute)) } first, err := fix.svc.Handle(context.Background(), listmemberships.Input{ Actor: shared.NewAdminActor(), GameID: id, Page: shared.Page{Size: 2}, }) require.NoError(t, err) require.Len(t, first.Items, 2) require.NotEmpty(t, first.NextPageToken) page2, err := shared.ParsePage("2", first.NextPageToken) require.NoError(t, err) second, err := fix.svc.Handle(context.Background(), listmemberships.Input{ Actor: shared.NewAdminActor(), GameID: id, Page: page2, }) require.NoError(t, err) require.Len(t, second.Items, 2) require.Empty(t, second.NextPageToken) } func TestNewServiceRejectsMissingDeps(t *testing.T) { t.Parallel() cases := []struct { name string deps listmemberships.Dependencies }{ {"nil games", listmemberships.Dependencies{Memberships: membershipinmem.NewStore()}}, {"nil memberships", listmemberships.Dependencies{Games: gameinmem.NewStore()}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() _, err := listmemberships.NewService(tc.deps) require.Error(t, err) }) } }