package declineinvite_test import ( "context" "io" "log/slog" "testing" "time" "galaxy/lobby/internal/adapters/invitestub" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/invite" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/declineinvite" "galaxy/lobby/internal/service/shared" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( gameID = common.GameID("game-private") 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 invites *invitestub.Store } func newFixture(t *testing.T) *fixture { t.Helper() return &fixture{ now: time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC), invites: invitestub.NewStore(), } } func newService(t *testing.T, f *fixture) *declineinvite.Service { t.Helper() svc, err := declineinvite.NewService(declineinvite.Dependencies{ 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: gameID, InviterUserID: ownerUserID, InviteeUserID: invitee, Now: f.now, ExpiresAt: f.now.Add(24 * time.Hour), }) require.NoError(t, err) require.NoError(t, f.invites.Save(context.Background(), inv)) return inv } func TestDeclineHappyPath(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(), declineinvite.Input{ Actor: shared.NewUserActor(inviteeUserID), GameID: gameID, InviteID: inv.InviteID, }) require.NoError(t, err) assert.Equal(t, invite.StatusDeclined, got.Status) require.NotNil(t, got.DecidedAt) assert.Equal(t, f.now, *got.DecidedAt) } func TestDeclineAdminActorForbidden(t *testing.T) { t.Parallel() f := newFixture(t) inv := seedCreatedInvite(t, f, "invite-1", inviteeUserID) svc := newService(t, f) _, err := svc.Handle(context.Background(), declineinvite.Input{ Actor: shared.NewAdminActor(), GameID: gameID, InviteID: inv.InviteID, }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestDeclineNonInviteeForbidden(t *testing.T) { t.Parallel() f := newFixture(t) inv := seedCreatedInvite(t, f, "invite-1", inviteeUserID) svc := newService(t, f) _, err := svc.Handle(context.Background(), declineinvite.Input{ Actor: shared.NewUserActor("user-impostor"), GameID: gameID, InviteID: inv.InviteID, }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestDeclineNotFound(t *testing.T) { t.Parallel() f := newFixture(t) svc := newService(t, f) _, err := svc.Handle(context.Background(), declineinvite.Input{ Actor: shared.NewUserActor(inviteeUserID), GameID: gameID, InviteID: "invite-missing", }) require.ErrorIs(t, err, invite.ErrNotFound) } func TestDeclineCrossGameNotFound(t *testing.T) { t.Parallel() f := newFixture(t) inv := seedCreatedInvite(t, f, "invite-1", inviteeUserID) svc := newService(t, f) _, err := svc.Handle(context.Background(), declineinvite.Input{ Actor: shared.NewUserActor(inviteeUserID), GameID: "game-other", InviteID: inv.InviteID, }) require.ErrorIs(t, err, invite.ErrNotFound) } func TestDeclineWrongStatusConflict(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.StatusRevoked, At: f.now, })) svc := newService(t, f) _, err := svc.Handle(context.Background(), declineinvite.Input{ Actor: shared.NewUserActor(inviteeUserID), GameID: gameID, InviteID: inv.InviteID, }) require.ErrorIs(t, err, invite.ErrConflict) }