feat: game lobby service

This commit is contained in:
Ilia Denisov
2026-04-25 23:20:55 +02:00
committed by GitHub
parent 32dc29359a
commit 48b0056b49
336 changed files with 57074 additions and 1418 deletions
@@ -21,6 +21,7 @@ func TestApplySanctionServiceExecutePublishesEvent(t *testing.T) {
limitStore := newFakeLimitStore()
publisher := &recordingPolicyPublisher{}
lifecyclePublisher := &fakeLifecyclePublisher{}
service, err := NewApplySanctionServiceWithObservability(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
@@ -31,6 +32,7 @@ func TestApplySanctionServiceExecutePublishesEvent(t *testing.T) {
nil,
nil,
publisher,
lifecyclePublisher,
)
require.NoError(t, err)
@@ -47,8 +49,130 @@ func TestApplySanctionServiceExecutePublishesEvent(t *testing.T) {
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()
+52 -10
View File
@@ -267,10 +267,11 @@ func (support commandSupport) loadActiveLimits(
// ApplySanctionService executes the explicit trusted sanction-apply command.
type ApplySanctionService struct {
support commandSupport
logger *slog.Logger
telemetry *telemetry.Runtime
publisher ports.SanctionChangedPublisher
support commandSupport
logger *slog.Logger
telemetry *telemetry.Runtime
publisher ports.SanctionChangedPublisher
lifecyclePublisher ports.UserLifecyclePublisher
}
// NewApplySanctionService constructs one sanction-apply use case.
@@ -282,11 +283,13 @@ func NewApplySanctionService(
clock ports.Clock,
idGenerator ports.IDGenerator,
) (*ApplySanctionService, error) {
return NewApplySanctionServiceWithObservability(accounts, sanctions, limits, lifecycle, clock, idGenerator, nil, nil, nil)
return NewApplySanctionServiceWithObservability(accounts, sanctions, limits, lifecycle, clock, idGenerator, nil, nil, nil, nil)
}
// NewApplySanctionServiceWithObservability constructs one sanction-apply use
// case with optional observability hooks.
// case with optional observability hooks. `lifecyclePublisher` is consulted
// when the newly applied sanction is `SanctionCodePermanentBlock`: one
// `user.lifecycle.permanent_blocked` event is emitted after the commit.
func NewApplySanctionServiceWithObservability(
accounts ports.UserAccountStore,
sanctions ports.SanctionStore,
@@ -297,6 +300,7 @@ func NewApplySanctionServiceWithObservability(
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
publisher ports.SanctionChangedPublisher,
lifecyclePublisher ports.UserLifecyclePublisher,
) (*ApplySanctionService, error) {
support, err := newCommandSupport(accounts, sanctions, limits, lifecycle, clock, idGenerator)
if err != nil {
@@ -304,10 +308,11 @@ func NewApplySanctionServiceWithObservability(
}
return &ApplySanctionService{
support: support,
logger: logger,
telemetry: telemetryRuntime,
publisher: publisher,
support: support,
logger: logger,
telemetry: telemetryRuntime,
publisher: publisher,
lifecyclePublisher: lifecyclePublisher,
}, nil
}
@@ -389,6 +394,9 @@ func (service *ApplySanctionService) Execute(ctx context.Context, input ApplySan
ActiveSanctions: sanctionViews(active),
}
publishSanctionChanged(ctx, service.publisher, service.telemetry, service.logger, "apply_sanction", ports.SanctionChangedOperationApplied, record)
if record.SanctionCode == policy.SanctionCodePermanentBlock {
publishUserLifecyclePermanentBlocked(ctx, service.lifecyclePublisher, service.telemetry, service.logger, record)
}
return result, nil
}
@@ -1177,6 +1185,40 @@ func publishSanctionChanged(
}
}
func publishUserLifecyclePermanentBlocked(
ctx context.Context,
publisher ports.UserLifecyclePublisher,
telemetryRuntime *telemetry.Runtime,
logger *slog.Logger,
record policy.SanctionRecord,
) {
if publisher == nil {
return
}
event := ports.UserLifecycleEvent{
EventType: ports.UserLifecyclePermanentBlockedEventType,
UserID: record.UserID,
OccurredAt: record.AppliedAt.UTC(),
Source: adminInternalAPISource,
Actor: record.Actor,
ReasonCode: record.ReasonCode,
}
if err := publisher.PublishUserLifecycleEvent(ctx, event); err != nil {
if telemetryRuntime != nil {
telemetryRuntime.RecordEventPublicationFailure(ctx, string(ports.UserLifecyclePermanentBlockedEventType))
}
shared.LogEventPublicationFailure(logger, ctx, string(ports.UserLifecyclePermanentBlockedEventType), err,
"use_case", "apply_sanction",
"user_id", record.UserID.String(),
"source", adminInternalAPISource.String(),
"reason_code", record.ReasonCode.String(),
"actor_type", record.Actor.Type.String(),
"actor_id", record.Actor.ID.String(),
)
}
}
func publishLimitChanged(
ctx context.Context,
publisher ports.LimitChangedPublisher,
@@ -468,7 +468,7 @@ func (store fakeAccountStore) GetByEmail(context.Context, common.Email) (account
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) GetByRaceName(context.Context, common.RaceName) (account.UserAccount, error) {
func (store fakeAccountStore) GetByUserName(context.Context, common.UserName) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
@@ -476,9 +476,6 @@ func (store fakeAccountStore) ExistsByUserID(_ context.Context, userID common.Us
return store.existsByUserID[userID], nil
}
func (store fakeAccountStore) RenameRaceName(context.Context, ports.RenameRaceNameInput) error {
return nil
}
func (store fakeAccountStore) Update(context.Context, account.UserAccount) error {
return nil
@@ -679,7 +676,7 @@ func (generator fixedIDGenerator) NewUserID() (common.UserID, error) {
return "", nil
}
func (generator fixedIDGenerator) NewInitialRaceName() (common.RaceName, error) {
func (generator fixedIDGenerator) NewUserName() (common.UserName, error) {
return "", nil
}