feat: game lobby service
This commit is contained in:
@@ -31,7 +31,6 @@ func TestProfileUpdaterExecutePublishesProfileChangedEvent(t *testing.T) {
|
||||
fakeSanctionStore{},
|
||||
fakeLimitStore{},
|
||||
fixedClock{now: now},
|
||||
stubRaceNamePolicy{},
|
||||
nil,
|
||||
nil,
|
||||
publisher,
|
||||
@@ -39,15 +38,16 @@ func TestProfileUpdaterExecutePublishesProfileChangedEvent(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), UpdateMyProfileInput{
|
||||
UserID: "user-123",
|
||||
RaceName: "Nova Prime",
|
||||
UserID: "user-123",
|
||||
DisplayName: "NovaPrime",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Nova Prime", result.Account.RaceName)
|
||||
require.Equal(t, "NovaPrime", result.Account.DisplayName)
|
||||
require.Len(t, publisher.profileEvents, 1)
|
||||
require.Equal(t, ports.ProfileChangedOperationUpdated, publisher.profileEvents[0].Operation)
|
||||
require.Equal(t, common.Source("gateway_self_service"), publisher.profileEvents[0].Source)
|
||||
require.Equal(t, common.RaceName("Nova Prime"), publisher.profileEvents[0].RaceName)
|
||||
require.Equal(t, common.DisplayName("NovaPrime"), publisher.profileEvents[0].DisplayName)
|
||||
require.Equal(t, common.UserName("player-abcdefgh"), publisher.profileEvents[0].UserName)
|
||||
}
|
||||
|
||||
func TestProfileUpdaterExecutePublisherFailureDoesNotRollbackCommit(t *testing.T) {
|
||||
@@ -67,7 +67,6 @@ func TestProfileUpdaterExecutePublisherFailureDoesNotRollbackCommit(t *testing.T
|
||||
fakeSanctionStore{},
|
||||
fakeLimitStore{},
|
||||
fixedClock{now: now},
|
||||
stubRaceNamePolicy{},
|
||||
nil,
|
||||
nil,
|
||||
publisher,
|
||||
@@ -75,16 +74,16 @@ func TestProfileUpdaterExecutePublisherFailureDoesNotRollbackCommit(t *testing.T
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), UpdateMyProfileInput{
|
||||
UserID: "user-123",
|
||||
RaceName: "Nova Prime",
|
||||
UserID: "user-123",
|
||||
DisplayName: "NovaPrime",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Nova Prime", result.Account.RaceName)
|
||||
require.Equal(t, "NovaPrime", result.Account.DisplayName)
|
||||
require.Len(t, publisher.profileEvents, 1)
|
||||
|
||||
storedAccount, err := accountStore.GetByUserID(context.Background(), common.UserID("user-123"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, common.RaceName("Nova Prime"), storedAccount.RaceName)
|
||||
require.Equal(t, common.DisplayName("NovaPrime"), storedAccount.DisplayName)
|
||||
}
|
||||
|
||||
func TestSettingsUpdaterExecuteNoOpDoesNotPublishEvent(t *testing.T) {
|
||||
@@ -94,7 +93,7 @@ func TestSettingsUpdaterExecuteNoOpDoesNotPublishEvent(t *testing.T) {
|
||||
accountStore := newFakeAccountStore(account.UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en-US"),
|
||||
TimeZone: common.TimeZoneName("UTC"),
|
||||
DeclaredCountry: common.CountryCode("DE"),
|
||||
|
||||
@@ -55,8 +55,9 @@ type UpdateMyProfileInput struct {
|
||||
// UserID stores the authenticated regular-user identifier.
|
||||
UserID string
|
||||
|
||||
// RaceName stores the requested exact replacement race name.
|
||||
RaceName string
|
||||
// DisplayName stores the requested replacement display name. An empty
|
||||
// value resets the stored display name.
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
// UpdateMyProfileResult stores one self-service profile mutation result.
|
||||
@@ -123,6 +124,9 @@ func (service *AccountGetter) Execute(ctx context.Context, input GetMyAccountInp
|
||||
if err != nil {
|
||||
return GetMyAccountResult{}, err
|
||||
}
|
||||
if state.HasActiveSanction(policy.SanctionCodePermanentBlock) {
|
||||
return GetMyAccountResult{}, shared.Conflict()
|
||||
}
|
||||
|
||||
return GetMyAccountResult{Account: state.View()}, nil
|
||||
}
|
||||
@@ -131,7 +135,6 @@ func (service *AccountGetter) Execute(ctx context.Context, input GetMyAccountInp
|
||||
type ProfileUpdater struct {
|
||||
accounts ports.UserAccountStore
|
||||
loader *accountview.Loader
|
||||
policy ports.RaceNamePolicy
|
||||
clock ports.Clock
|
||||
logger *slog.Logger
|
||||
telemetry *telemetry.Runtime
|
||||
@@ -145,9 +148,8 @@ func NewProfileUpdater(
|
||||
sanctions ports.SanctionStore,
|
||||
limits ports.LimitStore,
|
||||
clock ports.Clock,
|
||||
policy ports.RaceNamePolicy,
|
||||
) (*ProfileUpdater, error) {
|
||||
return NewProfileUpdaterWithObservability(accounts, entitlements, sanctions, limits, clock, policy, nil, nil, nil)
|
||||
return NewProfileUpdaterWithObservability(accounts, entitlements, sanctions, limits, clock, nil, nil, nil)
|
||||
}
|
||||
|
||||
// NewProfileUpdaterWithObservability constructs one self-service
|
||||
@@ -158,7 +160,6 @@ func NewProfileUpdaterWithObservability(
|
||||
sanctions ports.SanctionStore,
|
||||
limits ports.LimitStore,
|
||||
clock ports.Clock,
|
||||
policy ports.RaceNamePolicy,
|
||||
logger *slog.Logger,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
profilePublisher ports.ProfileChangedPublisher,
|
||||
@@ -167,14 +168,10 @@ func NewProfileUpdaterWithObservability(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("selfservice profile updater: %w", err)
|
||||
}
|
||||
if policy == nil {
|
||||
return nil, fmt.Errorf("selfservice profile updater: race-name policy must not be nil")
|
||||
}
|
||||
|
||||
return &ProfileUpdater{
|
||||
accounts: accounts,
|
||||
loader: loader,
|
||||
policy: policy,
|
||||
clock: clock,
|
||||
logger: logger,
|
||||
telemetry: telemetryRuntime,
|
||||
@@ -204,7 +201,7 @@ func (service *ProfileUpdater) Execute(ctx context.Context, input UpdateMyProfil
|
||||
return UpdateMyProfileResult{}, err
|
||||
}
|
||||
userIDString = userID.String()
|
||||
raceName, err := parseRaceName(input.RaceName)
|
||||
displayName, err := shared.ParseDisplayName(input.DisplayName)
|
||||
if err != nil {
|
||||
return UpdateMyProfileResult{}, err
|
||||
}
|
||||
@@ -213,33 +210,22 @@ func (service *ProfileUpdater) Execute(ctx context.Context, input UpdateMyProfil
|
||||
if err != nil {
|
||||
return UpdateMyProfileResult{}, err
|
||||
}
|
||||
if state.HasActiveSanction(policy.SanctionCodePermanentBlock) {
|
||||
return UpdateMyProfileResult{}, shared.Conflict()
|
||||
}
|
||||
if state.HasActiveSanction(policy.SanctionCodeProfileUpdateBlock) {
|
||||
return UpdateMyProfileResult{}, shared.Conflict()
|
||||
}
|
||||
if state.AccountRecord.RaceName == raceName {
|
||||
if state.AccountRecord.DisplayName == displayName {
|
||||
outcome = "noop"
|
||||
return UpdateMyProfileResult{Account: state.View()}, nil
|
||||
}
|
||||
|
||||
now := service.clock.Now().UTC()
|
||||
currentCanonicalKey, err := service.policy.CanonicalKey(state.AccountRecord.RaceName)
|
||||
if err != nil {
|
||||
return UpdateMyProfileResult{}, shared.ServiceUnavailable(fmt.Errorf("canonicalize current race name: %w", err))
|
||||
}
|
||||
reservation, err := shared.BuildRaceNameReservation(service.policy, userID, raceName, now)
|
||||
if err != nil {
|
||||
return UpdateMyProfileResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
if err := service.accounts.RenameRaceName(ctx, ports.RenameRaceNameInput{
|
||||
UserID: userID,
|
||||
CurrentCanonicalKey: currentCanonicalKey,
|
||||
NewRaceName: raceName,
|
||||
NewReservation: reservation,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
if errors.Is(err, ports.ErrRaceNameConflict) && service.telemetry != nil {
|
||||
service.telemetry.RecordRaceNameReservationConflict(ctx, "update_my_profile")
|
||||
}
|
||||
record := state.AccountRecord
|
||||
record.DisplayName = displayName
|
||||
record.UpdatedAt = now
|
||||
if err := service.accounts.Update(ctx, record); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return UpdateMyProfileResult{}, shared.SubjectNotFound()
|
||||
@@ -344,6 +330,9 @@ func (service *SettingsUpdater) Execute(ctx context.Context, input UpdateMySetti
|
||||
if err != nil {
|
||||
return UpdateMySettingsResult{}, err
|
||||
}
|
||||
if state.HasActiveSanction(policy.SanctionCodePermanentBlock) {
|
||||
return UpdateMySettingsResult{}, shared.Conflict()
|
||||
}
|
||||
if state.HasActiveSanction(policy.SanctionCodeProfileUpdateBlock) {
|
||||
return UpdateMySettingsResult{}, shared.Conflict()
|
||||
}
|
||||
@@ -379,10 +368,6 @@ func (service *SettingsUpdater) Execute(ctx context.Context, input UpdateMySetti
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseRaceName(value string) (common.RaceName, error) {
|
||||
return shared.ParseRaceName(value)
|
||||
}
|
||||
|
||||
func parsePreferredLanguage(value string) (common.LanguageTag, error) {
|
||||
languageTag, err := shared.ParseLanguageTag(value)
|
||||
if err != nil {
|
||||
@@ -423,11 +408,12 @@ func (service *ProfileUpdater) publishProfileChanged(ctx context.Context, record
|
||||
}
|
||||
|
||||
event := ports.ProfileChangedEvent{
|
||||
UserID: record.UserID,
|
||||
OccurredAt: record.UpdatedAt.UTC(),
|
||||
Source: gatewaySelfServiceSource,
|
||||
Operation: ports.ProfileChangedOperationUpdated,
|
||||
RaceName: record.RaceName,
|
||||
UserID: record.UserID,
|
||||
OccurredAt: record.UpdatedAt.UTC(),
|
||||
Source: gatewaySelfServiceSource,
|
||||
Operation: ports.ProfileChangedOperationUpdated,
|
||||
UserName: record.UserName,
|
||||
DisplayName: record.DisplayName,
|
||||
}
|
||||
if err := service.profilePublisher.PublishProfileChanged(ctx, event); err != nil {
|
||||
if service.telemetry != nil {
|
||||
|
||||
@@ -2,7 +2,6 @@ package selfservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -156,74 +155,63 @@ func TestProfileUpdaterExecuteBlockedBySanction(t *testing.T) {
|
||||
},
|
||||
fakeLimitStore{},
|
||||
fixedClock{now: now},
|
||||
stubRaceNamePolicy{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), UpdateMyProfileInput{
|
||||
UserID: "user-123",
|
||||
RaceName: "Nova Prime",
|
||||
UserID: "user-123",
|
||||
DisplayName: "NovaPrime",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeConflict, shared.CodeOf(err))
|
||||
require.Equal(t, 0, accountStore.renameCalls)
|
||||
require.Equal(t, 0, accountStore.updateCalls)
|
||||
}
|
||||
|
||||
func TestProfileUpdaterExecuteSuccessNoOpAndConflict(t *testing.T) {
|
||||
func TestProfileUpdaterExecuteDisplayNameUpdates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
inputRaceName string
|
||||
renameErr error
|
||||
inputDisplay string
|
||||
updateErr error
|
||||
wantCode string
|
||||
wantRaceName string
|
||||
wantRenameCalls int
|
||||
wantCurrentKey account.RaceNameCanonicalKey
|
||||
wantNewKey account.RaceNameCanonicalKey
|
||||
wantDisplay string
|
||||
wantUpdateCalls int
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
inputRaceName: "Nova Prime",
|
||||
wantRaceName: "Nova Prime",
|
||||
wantRenameCalls: 1,
|
||||
wantCurrentKey: canonicalKey(common.RaceName("Pilot Nova")),
|
||||
wantNewKey: canonicalKey(common.RaceName("Nova Prime")),
|
||||
name: "set display name",
|
||||
inputDisplay: "NovaPrime",
|
||||
wantDisplay: "NovaPrime",
|
||||
wantUpdateCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "same canonical different exact",
|
||||
inputRaceName: "P1lot Nova",
|
||||
wantRaceName: "P1lot Nova",
|
||||
wantRenameCalls: 1,
|
||||
wantCurrentKey: canonicalKey(common.RaceName("Pilot Nova")),
|
||||
wantNewKey: canonicalKey(common.RaceName("P1lot Nova")),
|
||||
name: "trims input",
|
||||
inputDisplay: " NovaPrime ",
|
||||
wantDisplay: "NovaPrime",
|
||||
wantUpdateCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "no-op",
|
||||
inputRaceName: " Pilot Nova ",
|
||||
wantRaceName: "Pilot Nova",
|
||||
wantRenameCalls: 0,
|
||||
name: "reset to empty",
|
||||
inputDisplay: " ",
|
||||
wantDisplay: "",
|
||||
wantUpdateCalls: 0,
|
||||
},
|
||||
{
|
||||
name: "conflict",
|
||||
inputRaceName: "Taken Name",
|
||||
renameErr: ports.ErrConflict,
|
||||
wantCode: shared.ErrorCodeConflict,
|
||||
wantRaceName: "Pilot Nova",
|
||||
wantRenameCalls: 1,
|
||||
wantCurrentKey: canonicalKey(common.RaceName("Pilot Nova")),
|
||||
wantNewKey: canonicalKey(common.RaceName("Taken Name")),
|
||||
name: "invalid display name rejected",
|
||||
inputDisplay: "Nova Prime",
|
||||
wantCode: shared.ErrorCodeInvalidRequest,
|
||||
wantDisplay: "",
|
||||
wantUpdateCalls: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
accountStore := newFakeAccountStore(validUserAccount())
|
||||
accountStore.renameErr = tt.renameErr
|
||||
accountStore.updateErr = tt.updateErr
|
||||
service, err := NewProfileUpdater(
|
||||
accountStore,
|
||||
&fakeEntitlementSnapshotStore{
|
||||
@@ -234,13 +222,12 @@ func TestProfileUpdaterExecuteSuccessNoOpAndConflict(t *testing.T) {
|
||||
fakeSanctionStore{},
|
||||
fakeLimitStore{},
|
||||
fixedClock{now: now},
|
||||
stubRaceNamePolicy{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), UpdateMyProfileInput{
|
||||
UserID: "user-123",
|
||||
RaceName: tt.inputRaceName,
|
||||
UserID: "user-123",
|
||||
DisplayName: tt.inputDisplay,
|
||||
})
|
||||
if tt.wantCode != "" {
|
||||
require.Error(t, err)
|
||||
@@ -249,17 +236,13 @@ func TestProfileUpdaterExecuteSuccessNoOpAndConflict(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
require.Equal(t, tt.wantRenameCalls, accountStore.renameCalls)
|
||||
if tt.wantRenameCalls > 0 {
|
||||
require.Equal(t, tt.wantCurrentKey, accountStore.lastRenameInput.CurrentCanonicalKey)
|
||||
require.Equal(t, tt.wantNewKey, accountStore.lastRenameInput.NewReservation.CanonicalKey)
|
||||
}
|
||||
require.Equal(t, tt.wantUpdateCalls, accountStore.updateCalls)
|
||||
|
||||
storedAccount, err := accountStore.GetByUserID(context.Background(), common.UserID("user-123"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantRaceName, storedAccount.RaceName.String())
|
||||
require.Equal(t, tt.wantDisplay, storedAccount.DisplayName.String())
|
||||
if tt.wantCode == "" {
|
||||
require.Equal(t, tt.wantRaceName, result.Account.RaceName)
|
||||
require.Equal(t, tt.wantDisplay, result.Account.DisplayName)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -326,7 +309,7 @@ func TestSettingsUpdaterExecuteCanonicalizedNoOpAndInvalidInputs(t *testing.T) {
|
||||
accountRecord: account.UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en-US"),
|
||||
TimeZone: common.TimeZoneName("UTC"),
|
||||
DeclaredCountry: common.CountryCode("DE"),
|
||||
@@ -406,12 +389,9 @@ func TestSettingsUpdaterExecuteCanonicalizedNoOpAndInvalidInputs(t *testing.T) {
|
||||
}
|
||||
|
||||
type fakeAccountStore struct {
|
||||
records map[common.UserID]account.UserAccount
|
||||
renameErr error
|
||||
updateErr error
|
||||
renameCalls int
|
||||
updateCalls int
|
||||
lastRenameInput ports.RenameRaceNameInput
|
||||
records map[common.UserID]account.UserAccount
|
||||
updateErr error
|
||||
updateCalls int
|
||||
}
|
||||
|
||||
func newFakeAccountStore(records ...account.UserAccount) *fakeAccountStore {
|
||||
@@ -424,7 +404,7 @@ func newFakeAccountStore(records ...account.UserAccount) *fakeAccountStore {
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) Create(_ context.Context, input ports.CreateAccountInput) error {
|
||||
if input.Account.Validate() != nil || input.Reservation.Validate() != nil {
|
||||
if input.Account.Validate() != nil {
|
||||
return ports.ErrConflict
|
||||
}
|
||||
|
||||
@@ -450,9 +430,9 @@ func (store *fakeAccountStore) GetByEmail(_ context.Context, email common.Email)
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) GetByRaceName(_ context.Context, raceName common.RaceName) (account.UserAccount, error) {
|
||||
func (store *fakeAccountStore) GetByUserName(_ context.Context, userName common.UserName) (account.UserAccount, error) {
|
||||
for _, record := range store.records {
|
||||
if record.RaceName == raceName {
|
||||
if record.UserName == userName {
|
||||
return record, nil
|
||||
}
|
||||
}
|
||||
@@ -465,27 +445,6 @@ func (store *fakeAccountStore) ExistsByUserID(_ context.Context, userID common.U
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) RenameRaceName(_ context.Context, input ports.RenameRaceNameInput) error {
|
||||
store.renameCalls++
|
||||
store.lastRenameInput = input
|
||||
if store.renameErr != nil {
|
||||
return store.renameErr
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record, ok := store.records[input.UserID]
|
||||
if !ok {
|
||||
return ports.ErrNotFound
|
||||
}
|
||||
record.RaceName = input.NewRaceName
|
||||
record.UpdatedAt = input.UpdatedAt.UTC()
|
||||
store.records[input.UserID] = record
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) Update(_ context.Context, record account.UserAccount) error {
|
||||
store.updateCalls++
|
||||
if store.updateErr != nil {
|
||||
@@ -553,7 +512,7 @@ func (generator readerIDGenerator) NewUserID() (common.UserID, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (generator readerIDGenerator) NewInitialRaceName() (common.RaceName, error) {
|
||||
func (generator readerIDGenerator) NewUserName() (common.UserName, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
@@ -633,26 +592,12 @@ func (clock fixedClock) Now() time.Time {
|
||||
return clock.now
|
||||
}
|
||||
|
||||
type stubRaceNamePolicy struct{}
|
||||
|
||||
func (stubRaceNamePolicy) CanonicalKey(raceName common.RaceName) (account.RaceNameCanonicalKey, error) {
|
||||
return canonicalKey(raceName), nil
|
||||
}
|
||||
|
||||
func canonicalKey(raceName common.RaceName) account.RaceNameCanonicalKey {
|
||||
return account.RaceNameCanonicalKey(strings.NewReplacer(
|
||||
"1", "i",
|
||||
"0", "o",
|
||||
"8", "b",
|
||||
).Replace(strings.ToLower(raceName.String())))
|
||||
}
|
||||
|
||||
func validUserAccount() account.UserAccount {
|
||||
createdAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
return account.UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
|
||||
DeclaredCountry: common.CountryCode("DE"),
|
||||
@@ -727,6 +672,5 @@ var (
|
||||
_ ports.EntitlementLifecycleStore = (*fakeEntitlementLifecycleStore)(nil)
|
||||
_ ports.SanctionStore = fakeSanctionStore{}
|
||||
_ ports.LimitStore = fakeLimitStore{}
|
||||
_ ports.RaceNamePolicy = stubRaceNamePolicy{}
|
||||
_ ports.IDGenerator = readerIDGenerator{}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user