package revokeinvite_test import ( "context" "io" "log/slog" "testing" "time" "galaxy/lobby/internal/adapters/gamestub" "galaxy/lobby/internal/adapters/invitestub" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/domain/invite" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/revokeinvite" "galaxy/lobby/internal/service/shared" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( ownerUserID = "user-owner" inviteeUserID = "user-invitee" ) 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 fixture struct { now time.Time games *gamestub.Store invites *invitestub.Store game game.Game } func newFixture(t *testing.T) *fixture { t.Helper() now := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC) games := gamestub.NewStore() invites := invitestub.NewStore() gameRecord, err := game.New(game.NewGameInput{ GameID: "game-private", GameName: "Friends Only", GameType: game.GameTypePrivate, OwnerUserID: ownerUserID, MinPlayers: 2, MaxPlayers: 4, StartGapHours: 2, StartGapPlayers: 1, EnrollmentEndsAt: now.Add(24 * time.Hour), TurnSchedule: "0 */6 * * *", TargetEngineVersion: "1.0.0", Now: now, }) require.NoError(t, err) gameRecord.Status = game.StatusEnrollmentOpen require.NoError(t, games.Save(context.Background(), gameRecord)) return &fixture{ now: now, games: games, invites: invites, game: gameRecord, } } func newService(t *testing.T, f *fixture) *revokeinvite.Service { t.Helper() svc, err := revokeinvite.NewService(revokeinvite.Dependencies{ Games: f.games, Invites: f.invites, Clock: fixedClock(f.now), Logger: silentLogger(), }) require.NoError(t, err) return svc } func seedCreatedInvite(t *testing.T, f *fixture, inviteID common.InviteID, invitee string) invite.Invite { t.Helper() inv, err := invite.New(invite.NewInviteInput{ InviteID: inviteID, GameID: f.game.GameID, InviterUserID: ownerUserID, InviteeUserID: invitee, Now: f.now, ExpiresAt: f.game.EnrollmentEndsAt, }) require.NoError(t, err) require.NoError(t, f.invites.Save(context.Background(), inv)) return inv } func TestRevokeHappyPath(t *testing.T) { t.Parallel() f := newFixture(t) inv := seedCreatedInvite(t, f, "invite-1", inviteeUserID) svc := newService(t, f) got, err := svc.Handle(context.Background(), revokeinvite.Input{ Actor: shared.NewUserActor(ownerUserID), GameID: f.game.GameID, InviteID: inv.InviteID, }) require.NoError(t, err) assert.Equal(t, invite.StatusRevoked, got.Status) require.NotNil(t, got.DecidedAt) assert.Equal(t, f.now, *got.DecidedAt) } func TestRevokeAdminActorForbidden(t *testing.T) { t.Parallel() f := newFixture(t) inv := seedCreatedInvite(t, f, "invite-1", inviteeUserID) svc := newService(t, f) _, err := svc.Handle(context.Background(), revokeinvite.Input{ Actor: shared.NewAdminActor(), GameID: f.game.GameID, InviteID: inv.InviteID, }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestRevokeNonOwnerForbidden(t *testing.T) { t.Parallel() f := newFixture(t) inv := seedCreatedInvite(t, f, "invite-1", inviteeUserID) svc := newService(t, f) _, err := svc.Handle(context.Background(), revokeinvite.Input{ Actor: shared.NewUserActor("user-impostor"), GameID: f.game.GameID, InviteID: inv.InviteID, }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestRevokeInviteNotFound(t *testing.T) { t.Parallel() f := newFixture(t) svc := newService(t, f) _, err := svc.Handle(context.Background(), revokeinvite.Input{ Actor: shared.NewUserActor(ownerUserID), GameID: f.game.GameID, InviteID: "invite-missing", }) require.ErrorIs(t, err, invite.ErrNotFound) } func TestRevokeCrossGameNotFound(t *testing.T) { t.Parallel() f := newFixture(t) inv := seedCreatedInvite(t, f, "invite-1", inviteeUserID) svc := newService(t, f) _, err := svc.Handle(context.Background(), revokeinvite.Input{ Actor: shared.NewUserActor(ownerUserID), GameID: "game-other", InviteID: inv.InviteID, }) require.ErrorIs(t, err, invite.ErrNotFound) } func TestRevokeWrongStatusConflict(t *testing.T) { t.Parallel() f := newFixture(t) inv := seedCreatedInvite(t, f, "invite-1", inviteeUserID) require.NoError(t, f.invites.UpdateStatus(context.Background(), ports.UpdateInviteStatusInput{ InviteID: inv.InviteID, ExpectedFrom: invite.StatusCreated, To: invite.StatusDeclined, At: f.now, })) svc := newService(t, f) _, err := svc.Handle(context.Background(), revokeinvite.Input{ Actor: shared.NewUserActor(ownerUserID), GameID: f.game.GameID, InviteID: inv.InviteID, }) require.ErrorIs(t, err, invite.ErrConflict) } func TestRevokeGameNotFound(t *testing.T) { t.Parallel() f := newFixture(t) inv := seedCreatedInvite(t, f, "invite-1", inviteeUserID) // Re-wire the service against a fresh empty games store so the // owner-check load fails. The invite remains addressable; the missing // game path is a defensive guard, but the surfaced error must be // subject_not_found rather than forbidden. svc, err := revokeinvite.NewService(revokeinvite.Dependencies{ Games: gamestub.NewStore(), Invites: f.invites, Clock: fixedClock(f.now), Logger: silentLogger(), }) require.NoError(t, err) _, err = svc.Handle(context.Background(), revokeinvite.Input{ Actor: shared.NewUserActor(ownerUserID), GameID: f.game.GameID, InviteID: inv.InviteID, }) require.ErrorIs(t, err, game.ErrNotFound) }