package policysvc import ( "context" "testing" "time" "galaxy/user/internal/domain/common" "galaxy/user/internal/domain/policy" "galaxy/user/internal/ports" "github.com/stretchr/testify/require" ) func TestApplySanctionServiceExecutePublishesEvent(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_000, 0).UTC() userID := common.UserID("user-123") sanctionStore := newFakeSanctionStore() limitStore := newFakeLimitStore() publisher := &recordingPolicyPublisher{} lifecyclePublisher := &fakeLifecyclePublisher{} service, err := NewApplySanctionServiceWithObservability( fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}}, sanctionStore, limitStore, &fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore}, fixedClock{now: now}, fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")}, nil, nil, publisher, lifecyclePublisher, ) require.NoError(t, err) _, err = service.Execute(context.Background(), ApplySanctionInput{ UserID: userID.String(), SanctionCode: string(policy.SanctionCodeLoginBlock), Scope: "auth", ReasonCode: "policy_blocked", Actor: ActorInput{Type: "admin", ID: "admin-1"}, AppliedAt: now.Add(-time.Minute).Format(time.RFC3339Nano), ExpiresAt: now.Add(time.Hour).Format(time.RFC3339Nano), }) require.NoError(t, err) require.Len(t, publisher.sanctionEvents, 1) require.Equal(t, ports.SanctionChangedOperationApplied, publisher.sanctionEvents[0].Operation) require.Equal(t, common.Source("admin_internal_api"), publisher.sanctionEvents[0].Source) require.Empty(t, lifecyclePublisher.events, "login_block must not emit a user.lifecycle.permanent_blocked event") } func TestApplySanctionServiceExecutePermanentBlockPublishesLifecycleEvent(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_000, 0).UTC() userID := common.UserID("user-123") sanctionStore := newFakeSanctionStore() limitStore := newFakeLimitStore() publisher := &recordingPolicyPublisher{} lifecyclePublisher := &fakeLifecyclePublisher{} service, err := NewApplySanctionServiceWithObservability( fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}}, sanctionStore, limitStore, &fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore}, fixedClock{now: now}, fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")}, nil, nil, publisher, lifecyclePublisher, ) require.NoError(t, err) appliedAt := now.Add(-time.Minute) _, err = service.Execute(context.Background(), ApplySanctionInput{ UserID: userID.String(), SanctionCode: string(policy.SanctionCodePermanentBlock), Scope: "platform", ReasonCode: "terminal_policy_violation", Actor: ActorInput{Type: "admin", ID: "admin-1"}, AppliedAt: appliedAt.Format(time.RFC3339Nano), }) require.NoError(t, err) require.Len(t, publisher.sanctionEvents, 1) require.Len(t, lifecyclePublisher.events, 1) emitted := lifecyclePublisher.events[0] require.Equal(t, ports.UserLifecyclePermanentBlockedEventType, emitted.EventType) require.Equal(t, userID, emitted.UserID) require.True(t, emitted.OccurredAt.Equal(appliedAt.UTC())) require.Equal(t, common.Source("admin_internal_api"), emitted.Source) require.Equal(t, common.ReasonCode("terminal_policy_violation"), emitted.ReasonCode) require.Equal(t, common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}, emitted.Actor) } func TestRemoveSanctionServicePermanentBlockDoesNotEmitLifecycleEvent(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_000, 0).UTC() userID := common.UserID("user-123") sanctionStore := newFakeSanctionStore() limitStore := newFakeLimitStore() publisher := &recordingPolicyPublisher{} lifecyclePublisher := &fakeLifecyclePublisher{} // First, apply permanent_block so a subsequent remove has an active record // to target. applyService, err := NewApplySanctionServiceWithObservability( fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}}, sanctionStore, limitStore, &fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore}, fixedClock{now: now}, fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")}, nil, nil, publisher, lifecyclePublisher, ) require.NoError(t, err) _, err = applyService.Execute(context.Background(), ApplySanctionInput{ UserID: userID.String(), SanctionCode: string(policy.SanctionCodePermanentBlock), Scope: "platform", ReasonCode: "terminal_policy_violation", Actor: ActorInput{Type: "admin", ID: "admin-1"}, AppliedAt: now.Add(-time.Hour).Format(time.RFC3339Nano), }) require.NoError(t, err) require.Len(t, lifecyclePublisher.events, 1) removeService, err := NewRemoveSanctionServiceWithObservability( fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}}, sanctionStore, limitStore, &fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore}, fixedClock{now: now}, fixedIDGenerator{}, nil, nil, publisher, ) require.NoError(t, err) _, err = removeService.Execute(context.Background(), RemoveSanctionInput{ UserID: userID.String(), SanctionCode: string(policy.SanctionCodePermanentBlock), ReasonCode: "appeal_granted", Actor: ActorInput{Type: "admin", ID: "admin-2"}, }) require.NoError(t, err) require.Len(t, lifecyclePublisher.events, 1, "remove-sanction must not emit an additional lifecycle event") } type fakeLifecyclePublisher struct { events []ports.UserLifecycleEvent } func (publisher *fakeLifecyclePublisher) PublishUserLifecycleEvent(_ context.Context, event ports.UserLifecycleEvent) error { if err := event.Validate(); err != nil { return err } publisher.events = append(publisher.events, event) return nil } var _ ports.UserLifecyclePublisher = (*fakeLifecyclePublisher)(nil) func TestRemoveSanctionServiceExecuteMissingDoesNotPublishEvent(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_000, 0).UTC() userID := common.UserID("user-123") sanctionStore := newFakeSanctionStore() limitStore := newFakeLimitStore() publisher := &recordingPolicyPublisher{} service, err := NewRemoveSanctionServiceWithObservability( fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}}, sanctionStore, limitStore, &fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore}, fixedClock{now: now}, fixedIDGenerator{}, nil, nil, publisher, ) require.NoError(t, err) _, err = service.Execute(context.Background(), RemoveSanctionInput{ UserID: userID.String(), SanctionCode: string(policy.SanctionCodeLoginBlock), ReasonCode: "manual_remove", Actor: ActorInput{Type: "admin", ID: "admin-1"}, }) require.NoError(t, err) require.Empty(t, publisher.sanctionEvents) } func TestSetLimitServiceExecutePublishesEvent(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_000, 0).UTC() userID := common.UserID("user-123") sanctionStore := newFakeSanctionStore() limitStore := newFakeLimitStore() publisher := &recordingPolicyPublisher{} service, err := NewSetLimitServiceWithObservability( fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}}, sanctionStore, limitStore, &fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore}, fixedClock{now: now}, fixedIDGenerator{limitRecordID: policy.LimitRecordID("limit-1")}, nil, nil, publisher, ) require.NoError(t, err) _, err = service.Execute(context.Background(), SetLimitInput{ UserID: userID.String(), LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames), Value: 5, ReasonCode: "manual_override", Actor: ActorInput{Type: "admin", ID: "admin-1"}, AppliedAt: now.Add(-time.Minute).Format(time.RFC3339Nano), ExpiresAt: now.Add(time.Hour).Format(time.RFC3339Nano), }) require.NoError(t, err) require.Len(t, publisher.limitEvents, 1) require.Equal(t, ports.LimitChangedOperationSet, publisher.limitEvents[0].Operation) require.NotNil(t, publisher.limitEvents[0].Value) require.Equal(t, 5, *publisher.limitEvents[0].Value) } func TestRemoveLimitServiceExecuteMissingDoesNotPublishEvent(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_000, 0).UTC() userID := common.UserID("user-123") sanctionStore := newFakeSanctionStore() limitStore := newFakeLimitStore() publisher := &recordingPolicyPublisher{} service, err := NewRemoveLimitServiceWithObservability( fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}}, sanctionStore, limitStore, &fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore}, fixedClock{now: now}, fixedIDGenerator{}, nil, nil, publisher, ) require.NoError(t, err) _, err = service.Execute(context.Background(), RemoveLimitInput{ UserID: userID.String(), LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames), ReasonCode: "manual_remove", Actor: ActorInput{Type: "admin", ID: "admin-1"}, }) require.NoError(t, err) require.Empty(t, publisher.limitEvents) } type recordingPolicyPublisher struct { sanctionEvents []ports.SanctionChangedEvent limitEvents []ports.LimitChangedEvent } func (publisher *recordingPolicyPublisher) PublishSanctionChanged(_ context.Context, event ports.SanctionChangedEvent) error { if err := event.Validate(); err != nil { return err } publisher.sanctionEvents = append(publisher.sanctionEvents, event) return nil } func (publisher *recordingPolicyPublisher) PublishLimitChanged(_ context.Context, event ports.LimitChangedEvent) error { if err := event.Validate(); err != nil { return err } publisher.limitEvents = append(publisher.limitEvents, event) return nil } var ( _ ports.SanctionChangedPublisher = (*recordingPolicyPublisher)(nil) _ ports.LimitChangedPublisher = (*recordingPolicyPublisher)(nil) )