feat: game lobby service
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
// Package accountdeletion implements the trusted `DeleteUser` soft-delete
|
||||
// command owned by User Service.
|
||||
package accountdeletion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/ports"
|
||||
"galaxy/user/internal/service/shared"
|
||||
"galaxy/user/internal/telemetry"
|
||||
)
|
||||
|
||||
const adminInternalAPISource = common.Source("admin_internal_api")
|
||||
|
||||
// Input stores one trusted `DeleteUser` command request.
|
||||
type Input struct {
|
||||
// UserID identifies the regular-user account to soft-delete.
|
||||
UserID string
|
||||
|
||||
// ReasonCode stores the machine-readable mutation reason.
|
||||
ReasonCode string
|
||||
|
||||
// Actor stores the audit actor metadata attached to the mutation.
|
||||
Actor ActorInput
|
||||
}
|
||||
|
||||
// ActorInput stores one transport-facing audit actor payload.
|
||||
type ActorInput struct {
|
||||
// Type stores the machine-readable actor type.
|
||||
Type string
|
||||
|
||||
// ID stores the optional stable actor identifier.
|
||||
ID string
|
||||
}
|
||||
|
||||
// Result stores one trusted `DeleteUser` command outcome.
|
||||
type Result struct {
|
||||
// UserID identifies the soft-deleted account.
|
||||
UserID string `json:"user_id"`
|
||||
|
||||
// DeletedAt stores the committed soft-delete timestamp.
|
||||
DeletedAt time.Time `json:"deleted_at"`
|
||||
}
|
||||
|
||||
// Service executes the explicit trusted `DeleteUser` soft-delete command.
|
||||
type Service struct {
|
||||
accounts ports.UserAccountStore
|
||||
clock ports.Clock
|
||||
lifecyclePublisher ports.UserLifecyclePublisher
|
||||
logger *slog.Logger
|
||||
telemetry *telemetry.Runtime
|
||||
}
|
||||
|
||||
// NewService constructs one `DeleteUser` use case without optional
|
||||
// observability hooks.
|
||||
func NewService(
|
||||
accounts ports.UserAccountStore,
|
||||
clock ports.Clock,
|
||||
lifecyclePublisher ports.UserLifecyclePublisher,
|
||||
) (*Service, error) {
|
||||
return NewServiceWithObservability(accounts, clock, lifecyclePublisher, nil, nil)
|
||||
}
|
||||
|
||||
// NewServiceWithObservability constructs one `DeleteUser` use case with
|
||||
// optional observability hooks.
|
||||
func NewServiceWithObservability(
|
||||
accounts ports.UserAccountStore,
|
||||
clock ports.Clock,
|
||||
lifecyclePublisher ports.UserLifecyclePublisher,
|
||||
logger *slog.Logger,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
) (*Service, error) {
|
||||
switch {
|
||||
case accounts == nil:
|
||||
return nil, fmt.Errorf("account deletion service: user account store must not be nil")
|
||||
case clock == nil:
|
||||
return nil, fmt.Errorf("account deletion service: clock must not be nil")
|
||||
case lifecyclePublisher == nil:
|
||||
return nil, fmt.Errorf("account deletion service: lifecycle publisher must not be nil")
|
||||
default:
|
||||
return &Service{
|
||||
accounts: accounts,
|
||||
clock: clock,
|
||||
lifecyclePublisher: lifecyclePublisher,
|
||||
logger: logger,
|
||||
telemetry: telemetryRuntime,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Execute soft-deletes the account identified by input.UserID. The command is
|
||||
// idempotent per `user_id`: calling it after the account is already
|
||||
// soft-deleted returns `subject_not_found` and does not re-publish the
|
||||
// lifecycle event.
|
||||
func (service *Service) Execute(ctx context.Context, input Input) (result Result, err error) {
|
||||
outcome := shared.ErrorCodeInternalError
|
||||
userIDString := strings.TrimSpace(input.UserID)
|
||||
reasonCodeValue := strings.TrimSpace(input.ReasonCode)
|
||||
actorTypeValue := strings.TrimSpace(input.Actor.Type)
|
||||
actorIDValue := strings.TrimSpace(input.Actor.ID)
|
||||
defer func() {
|
||||
if service.telemetry != nil {
|
||||
service.telemetry.RecordUserLifecycleMutation(ctx, "delete", outcome)
|
||||
}
|
||||
shared.LogServiceOutcome(service.logger, ctx, "delete user completed", err,
|
||||
"use_case", "delete_user",
|
||||
"command", "delete",
|
||||
"outcome", outcome,
|
||||
"user_id", userIDString,
|
||||
"source", adminInternalAPISource.String(),
|
||||
"reason_code", reasonCodeValue,
|
||||
"actor_type", actorTypeValue,
|
||||
"actor_id", actorIDValue,
|
||||
)
|
||||
}()
|
||||
|
||||
if ctx == nil {
|
||||
outcome = shared.ErrorCodeInvalidRequest
|
||||
return Result{}, shared.InvalidRequest("context must not be nil")
|
||||
}
|
||||
|
||||
userID, err := shared.ParseUserID(input.UserID)
|
||||
if err != nil {
|
||||
outcome = shared.MetricOutcome(err)
|
||||
return Result{}, err
|
||||
}
|
||||
userIDString = userID.String()
|
||||
|
||||
reasonCode, err := shared.ParseReasonCode(input.ReasonCode)
|
||||
if err != nil {
|
||||
outcome = shared.MetricOutcome(err)
|
||||
return Result{}, err
|
||||
}
|
||||
reasonCodeValue = reasonCode.String()
|
||||
|
||||
actor, err := parseActor(input.Actor)
|
||||
if err != nil {
|
||||
outcome = shared.MetricOutcome(err)
|
||||
return Result{}, err
|
||||
}
|
||||
actorTypeValue = actor.Type.String()
|
||||
actorIDValue = actor.ID.String()
|
||||
|
||||
record, err := service.accounts.GetByUserID(ctx, userID)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
outcome = shared.ErrorCodeSubjectNotFound
|
||||
return Result{}, shared.SubjectNotFound()
|
||||
default:
|
||||
outcome = shared.ErrorCodeServiceUnavailable
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
if record.IsDeleted() {
|
||||
outcome = shared.ErrorCodeSubjectNotFound
|
||||
return Result{}, shared.SubjectNotFound()
|
||||
}
|
||||
|
||||
now := service.clock.Now().UTC()
|
||||
record.UpdatedAt = now
|
||||
record.DeletedAt = &now
|
||||
|
||||
if err := service.accounts.Update(ctx, record); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
outcome = shared.ErrorCodeSubjectNotFound
|
||||
return Result{}, shared.SubjectNotFound()
|
||||
case errors.Is(err, ports.ErrConflict):
|
||||
outcome = shared.ErrorCodeConflict
|
||||
return Result{}, shared.Conflict()
|
||||
default:
|
||||
outcome = shared.ErrorCodeServiceUnavailable
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
}
|
||||
|
||||
outcome = "success"
|
||||
result = Result{
|
||||
UserID: userID.String(),
|
||||
DeletedAt: now,
|
||||
}
|
||||
publishDeleted(ctx, service.lifecyclePublisher, service.telemetry, service.logger, userID, now, actor, reasonCode)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseActor(input ActorInput) (common.ActorRef, error) {
|
||||
ref := common.ActorRef{
|
||||
Type: common.ActorType(shared.NormalizeString(input.Type)),
|
||||
ID: common.ActorID(shared.NormalizeString(input.ID)),
|
||||
}
|
||||
if err := ref.Validate(); err != nil {
|
||||
if ref.Type.IsZero() {
|
||||
return common.ActorRef{}, shared.InvalidRequest("actor.type must not be empty")
|
||||
}
|
||||
return common.ActorRef{}, shared.InvalidRequest(err.Error())
|
||||
}
|
||||
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
func publishDeleted(
|
||||
ctx context.Context,
|
||||
publisher ports.UserLifecyclePublisher,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
logger *slog.Logger,
|
||||
userID common.UserID,
|
||||
occurredAt time.Time,
|
||||
actor common.ActorRef,
|
||||
reasonCode common.ReasonCode,
|
||||
) {
|
||||
if publisher == nil {
|
||||
return
|
||||
}
|
||||
|
||||
event := ports.UserLifecycleEvent{
|
||||
EventType: ports.UserLifecycleDeletedEventType,
|
||||
UserID: userID,
|
||||
OccurredAt: occurredAt,
|
||||
Source: adminInternalAPISource,
|
||||
Actor: actor,
|
||||
ReasonCode: reasonCode,
|
||||
}
|
||||
if err := publisher.PublishUserLifecycleEvent(ctx, event); err != nil {
|
||||
if telemetryRuntime != nil {
|
||||
telemetryRuntime.RecordEventPublicationFailure(ctx, string(ports.UserLifecycleDeletedEventType))
|
||||
}
|
||||
shared.LogEventPublicationFailure(logger, ctx, string(ports.UserLifecycleDeletedEventType), err,
|
||||
"use_case", "delete_user",
|
||||
"user_id", userID.String(),
|
||||
"source", adminInternalAPISource.String(),
|
||||
"reason_code", reasonCode.String(),
|
||||
"actor_type", actor.Type.String(),
|
||||
"actor_id", actor.ID.String(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package accountdeletion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/ports"
|
||||
"galaxy/user/internal/service/shared"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestServiceExecuteSoftDeletesAndEmitsLifecycleEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
created := now.Add(-24 * time.Hour)
|
||||
|
||||
accounts := newFakeAccountStore()
|
||||
accounts.records[userID] = account.UserAccount{
|
||||
UserID: userID,
|
||||
Email: common.Email("pilot@example.com"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Berlin"),
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
}
|
||||
|
||||
publisher := &fakeLifecyclePublisher{}
|
||||
service, err := NewService(accounts, fixedClock{now: now}, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), Input{
|
||||
UserID: userID.String(),
|
||||
ReasonCode: "user_right_to_be_forgotten",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-1"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, userID.String(), result.UserID)
|
||||
require.True(t, result.DeletedAt.Equal(now))
|
||||
|
||||
stored := accounts.records[userID]
|
||||
require.NotNil(t, stored.DeletedAt)
|
||||
require.True(t, stored.DeletedAt.Equal(now))
|
||||
|
||||
require.Len(t, publisher.events, 1)
|
||||
emitted := publisher.events[0]
|
||||
require.Equal(t, ports.UserLifecycleDeletedEventType, emitted.EventType)
|
||||
require.Equal(t, userID, emitted.UserID)
|
||||
require.True(t, emitted.OccurredAt.Equal(now))
|
||||
require.Equal(t, common.Source("admin_internal_api"), emitted.Source)
|
||||
require.Equal(t, common.ReasonCode("user_right_to_be_forgotten"), emitted.ReasonCode)
|
||||
require.Equal(t, common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}, emitted.Actor)
|
||||
}
|
||||
|
||||
func TestServiceExecuteSecondCallReturnsSubjectNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
created := now.Add(-24 * time.Hour)
|
||||
alreadyDeleted := now.Add(-time.Hour)
|
||||
|
||||
accounts := newFakeAccountStore()
|
||||
accounts.records[userID] = account.UserAccount{
|
||||
UserID: userID,
|
||||
Email: common.Email("pilot@example.com"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Berlin"),
|
||||
CreatedAt: created,
|
||||
UpdatedAt: alreadyDeleted,
|
||||
DeletedAt: &alreadyDeleted,
|
||||
}
|
||||
|
||||
publisher := &fakeLifecyclePublisher{}
|
||||
service, err := NewService(accounts, fixedClock{now: now}, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), Input{
|
||||
UserID: userID.String(),
|
||||
ReasonCode: "user_right_to_be_forgotten",
|
||||
Actor: ActorInput{Type: "admin"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
|
||||
require.Empty(t, publisher.events)
|
||||
}
|
||||
|
||||
func TestServiceExecuteUnknownUserReturnsSubjectNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
accounts := newFakeAccountStore()
|
||||
publisher := &fakeLifecyclePublisher{}
|
||||
service, err := NewService(accounts, fixedClock{now: time.Unix(1_775_240_500, 0).UTC()}, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), Input{
|
||||
UserID: "user-missing",
|
||||
ReasonCode: "manual",
|
||||
Actor: ActorInput{Type: "admin"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
|
||||
require.Empty(t, publisher.events)
|
||||
}
|
||||
|
||||
func TestServiceExecuteInvalidActorRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
accounts := newFakeAccountStore()
|
||||
publisher := &fakeLifecyclePublisher{}
|
||||
service, err := NewService(accounts, fixedClock{now: time.Unix(1_775_240_500, 0).UTC()}, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), Input{
|
||||
UserID: "user-123",
|
||||
ReasonCode: "manual",
|
||||
Actor: ActorInput{},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
|
||||
require.Empty(t, publisher.events)
|
||||
}
|
||||
|
||||
func TestServiceExecuteStoreConflictSurfacesAsConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
created := now.Add(-24 * time.Hour)
|
||||
|
||||
accounts := newFakeAccountStore()
|
||||
accounts.records[userID] = account.UserAccount{
|
||||
UserID: userID,
|
||||
Email: common.Email("pilot@example.com"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Berlin"),
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
}
|
||||
accounts.updateErr = ports.ErrConflict
|
||||
|
||||
publisher := &fakeLifecyclePublisher{}
|
||||
service, err := NewService(accounts, fixedClock{now: now}, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), Input{
|
||||
UserID: userID.String(),
|
||||
ReasonCode: "manual",
|
||||
Actor: ActorInput{Type: "admin"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeConflict, shared.CodeOf(err))
|
||||
require.Empty(t, publisher.events)
|
||||
}
|
||||
|
||||
type fakeAccountStore struct {
|
||||
records map[common.UserID]account.UserAccount
|
||||
updateErr error
|
||||
}
|
||||
|
||||
func newFakeAccountStore() *fakeAccountStore {
|
||||
return &fakeAccountStore{records: map[common.UserID]account.UserAccount{}}
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) Create(context.Context, ports.CreateAccountInput) error {
|
||||
return errors.New("unexpected Create in accountdeletion tests")
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) GetByUserID(_ context.Context, userID common.UserID) (account.UserAccount, error) {
|
||||
record, ok := store.records[userID]
|
||||
if !ok {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) GetByEmail(context.Context, common.Email) (account.UserAccount, error) {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) GetByUserName(context.Context, common.UserName) (account.UserAccount, error) {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
|
||||
record, ok := store.records[userID]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
return !record.IsDeleted(), nil
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) Update(_ context.Context, record account.UserAccount) error {
|
||||
if store.updateErr != nil {
|
||||
return store.updateErr
|
||||
}
|
||||
store.records[record.UserID] = record
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeLifecyclePublisher struct {
|
||||
events []ports.UserLifecycleEvent
|
||||
err error
|
||||
}
|
||||
|
||||
func (publisher *fakeLifecyclePublisher) PublishUserLifecycleEvent(_ context.Context, event ports.UserLifecycleEvent) error {
|
||||
if publisher.err != nil {
|
||||
return publisher.err
|
||||
}
|
||||
publisher.events = append(publisher.events, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fixedClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (clock fixedClock) Now() time.Time {
|
||||
return clock.now
|
||||
}
|
||||
@@ -104,8 +104,13 @@ type AccountView struct {
|
||||
// Email stores the exact normalized login e-mail address.
|
||||
Email string `json:"email"`
|
||||
|
||||
// RaceName stores the current user-facing race name.
|
||||
RaceName string `json:"race_name"`
|
||||
// UserName stores the immutable `player-<suffix>` handle assigned at
|
||||
// account creation.
|
||||
UserName string `json:"user_name"`
|
||||
|
||||
// DisplayName stores the current optional free-text user label. An empty
|
||||
// value indicates no display name is set.
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
|
||||
// PreferredLanguage stores the current BCP 47 preferred language.
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
@@ -177,7 +182,8 @@ func (aggregate Aggregate) View() AccountView {
|
||||
view := AccountView{
|
||||
UserID: aggregate.AccountRecord.UserID.String(),
|
||||
Email: aggregate.AccountRecord.Email.String(),
|
||||
RaceName: aggregate.AccountRecord.RaceName.String(),
|
||||
UserName: aggregate.AccountRecord.UserName.String(),
|
||||
DisplayName: aggregate.AccountRecord.DisplayName.String(),
|
||||
PreferredLanguage: aggregate.AccountRecord.PreferredLanguage.String(),
|
||||
TimeZone: aggregate.AccountRecord.TimeZone.String(),
|
||||
Entitlement: EntitlementSnapshotView{
|
||||
@@ -280,6 +286,9 @@ func (loader *Loader) Load(ctx context.Context, userID common.UserID) (Aggregate
|
||||
default:
|
||||
return Aggregate{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
if accountRecord.IsDeleted() {
|
||||
return Aggregate{}, shared.SubjectNotFound()
|
||||
}
|
||||
|
||||
entitlementSnapshot, err := loader.entitlements.GetByUserID(ctx, userID)
|
||||
switch {
|
||||
|
||||
@@ -35,11 +35,10 @@ type GetUserByEmailInput struct {
|
||||
Email string
|
||||
}
|
||||
|
||||
// GetUserByRaceNameInput stores one exact trusted lookup by exact stored race
|
||||
// name.
|
||||
type GetUserByRaceNameInput struct {
|
||||
// RaceName stores the exact current race name to resolve.
|
||||
RaceName string
|
||||
// GetUserByUserNameInput stores one exact trusted lookup by stored user name.
|
||||
type GetUserByUserNameInput struct {
|
||||
// UserName stores the exact `player-<suffix>` handle to resolve.
|
||||
UserName string
|
||||
}
|
||||
|
||||
// ListUsersInput stores one trusted administrative user-list request.
|
||||
@@ -71,6 +70,16 @@ type ListUsersInput struct {
|
||||
// LimitCode stores the optional active user-specific limit filter.
|
||||
LimitCode string
|
||||
|
||||
// UserName stores the optional exact `user_name` filter.
|
||||
UserName string
|
||||
|
||||
// DisplayName stores the optional `display_name` filter value.
|
||||
DisplayName string
|
||||
|
||||
// DisplayNameMatch selects between `exact` (default) and `prefix` matching
|
||||
// for DisplayName. An empty value is treated as `exact`.
|
||||
DisplayNameMatch string
|
||||
|
||||
// CanLogin stores the optional derived login-eligibility filter.
|
||||
CanLogin *bool
|
||||
|
||||
@@ -207,40 +216,39 @@ func (service *ByEmailGetter) Execute(ctx context.Context, input GetUserByEmailI
|
||||
return LookupResult{User: aggregate.View()}, nil
|
||||
}
|
||||
|
||||
// ByRaceNameGetter executes exact trusted lookups by exact stored race name.
|
||||
type ByRaceNameGetter struct {
|
||||
// ByUserNameGetter executes exact trusted lookups by stored user name.
|
||||
type ByUserNameGetter struct {
|
||||
support readSupport
|
||||
}
|
||||
|
||||
// NewByRaceNameGetter constructs one exact admin lookup by exact stored race
|
||||
// name.
|
||||
func NewByRaceNameGetter(
|
||||
// NewByUserNameGetter constructs one exact admin lookup by stored user name.
|
||||
func NewByUserNameGetter(
|
||||
accounts ports.UserAccountStore,
|
||||
entitlements entitlementReader,
|
||||
sanctions ports.SanctionStore,
|
||||
limits ports.LimitStore,
|
||||
clock ports.Clock,
|
||||
) (*ByRaceNameGetter, error) {
|
||||
) (*ByUserNameGetter, error) {
|
||||
support, err := newReadSupport(accounts, entitlements, sanctions, limits, clock)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("admin users by-race-name getter: %w", err)
|
||||
return nil, fmt.Errorf("admin users by-user-name getter: %w", err)
|
||||
}
|
||||
|
||||
return &ByRaceNameGetter{support: support}, nil
|
||||
return &ByUserNameGetter{support: support}, nil
|
||||
}
|
||||
|
||||
// Execute resolves one exact user by exact stored race name.
|
||||
func (service *ByRaceNameGetter) Execute(ctx context.Context, input GetUserByRaceNameInput) (LookupResult, error) {
|
||||
// Execute resolves one exact user by stored user name.
|
||||
func (service *ByUserNameGetter) Execute(ctx context.Context, input GetUserByUserNameInput) (LookupResult, error) {
|
||||
if ctx == nil {
|
||||
return LookupResult{}, shared.InvalidRequest("context must not be nil")
|
||||
}
|
||||
|
||||
raceName, err := shared.ParseRaceName(input.RaceName)
|
||||
userName, err := shared.ParseUserName(input.UserName)
|
||||
if err != nil {
|
||||
return LookupResult{}, err
|
||||
}
|
||||
|
||||
record, err := service.support.accounts.GetByRaceName(ctx, raceName)
|
||||
record, err := service.support.accounts.GetByUserName(ctx, userName)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
@@ -333,7 +341,19 @@ func (service *Lister) Execute(ctx context.Context, input ListUsersInput) (ListU
|
||||
candidateID := candidatePage.UserIDs[0]
|
||||
|
||||
aggregate, err := service.support.loader.Load(ctx, candidateID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case err == nil:
|
||||
case shared.CodeOf(err) == shared.ErrorCodeSubjectNotFound:
|
||||
// Soft-deleted accounts are silently skipped from the default admin
|
||||
// listing per Stage 22. The candidate index may still reference them
|
||||
// while their account record carries a DeletedAt timestamp.
|
||||
if nextToken == "" {
|
||||
result.NextPageToken = ""
|
||||
return result, nil
|
||||
}
|
||||
currentToken = nextToken
|
||||
continue
|
||||
default:
|
||||
return ListUsersResult{}, err
|
||||
}
|
||||
if matchesFilters(aggregate, filters) {
|
||||
@@ -382,6 +402,18 @@ func parseListFilters(input ListUsersInput) (ports.UserListFilters, error) {
|
||||
if err != nil {
|
||||
return ports.UserListFilters{}, err
|
||||
}
|
||||
userName, err := parseListUserName(input.UserName)
|
||||
if err != nil {
|
||||
return ports.UserListFilters{}, err
|
||||
}
|
||||
displayName, err := parseListDisplayName(input.DisplayName)
|
||||
if err != nil {
|
||||
return ports.UserListFilters{}, err
|
||||
}
|
||||
displayNameMatch, err := parseListDisplayNameMatch(input.DisplayNameMatch, displayName)
|
||||
if err != nil {
|
||||
return ports.UserListFilters{}, err
|
||||
}
|
||||
|
||||
filters := ports.UserListFilters{
|
||||
PaidState: paidState,
|
||||
@@ -390,6 +422,9 @@ func parseListFilters(input ListUsersInput) (ports.UserListFilters, error) {
|
||||
DeclaredCountry: declaredCountry,
|
||||
SanctionCode: sanctionCode,
|
||||
LimitCode: limitCode,
|
||||
UserName: userName,
|
||||
DisplayName: displayName,
|
||||
DisplayNameMatch: displayNameMatch,
|
||||
CanLogin: input.CanLogin,
|
||||
CanCreatePrivateGame: input.CanCreatePrivateGame,
|
||||
CanJoinGame: input.CanJoinGame,
|
||||
@@ -401,6 +436,40 @@ func parseListFilters(input ListUsersInput) (ports.UserListFilters, error) {
|
||||
return filters, nil
|
||||
}
|
||||
|
||||
func parseListUserName(value string) (common.UserName, error) {
|
||||
trimmed := shared.NormalizeString(value)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return shared.ParseUserName(trimmed)
|
||||
}
|
||||
|
||||
func parseListDisplayName(value string) (common.DisplayName, error) {
|
||||
trimmed := shared.NormalizeString(value)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return shared.ParseDisplayName(trimmed)
|
||||
}
|
||||
|
||||
func parseListDisplayNameMatch(value string, displayName common.DisplayName) (ports.DisplayNameMatchMode, error) {
|
||||
trimmed := shared.NormalizeString(value)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
mode := ports.DisplayNameMatchMode(trimmed)
|
||||
if !mode.IsKnown() {
|
||||
return "", shared.InvalidRequest(fmt.Sprintf("display_name_match %q is unsupported", trimmed))
|
||||
}
|
||||
if displayName.IsZero() {
|
||||
return "", shared.InvalidRequest("display_name_match requires display_name")
|
||||
}
|
||||
|
||||
return mode, nil
|
||||
}
|
||||
|
||||
func parsePaidState(value string) (entitlement.PaidState, error) {
|
||||
state := entitlement.PaidState(shared.NormalizeString(value))
|
||||
if !state.IsKnown() {
|
||||
@@ -477,6 +546,23 @@ func matchesFilters(aggregate accountview.Aggregate, filters ports.UserListFilte
|
||||
if filters.LimitCode != "" && !aggregate.HasActiveLimit(filters.LimitCode) {
|
||||
return false
|
||||
}
|
||||
if !filters.UserName.IsZero() && aggregate.AccountRecord.UserName != filters.UserName {
|
||||
return false
|
||||
}
|
||||
if !filters.DisplayName.IsZero() {
|
||||
recordDisplayName := aggregate.AccountRecord.DisplayName.String()
|
||||
filterValue := filters.DisplayName.String()
|
||||
switch filters.DisplayNameMatch {
|
||||
case ports.DisplayNameMatchModePrefix:
|
||||
if !strings.HasPrefix(recordDisplayName, filterValue) {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
if recordDisplayName != filterValue {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
canLogin, canCreatePrivateGame, canJoinGame := deriveFilterEligibility(aggregate)
|
||||
if filters.CanLogin != nil && canLogin != *filters.CanLogin {
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestByIDGetterExecuteReturnsAggregate(t *testing.T) {
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
service, err := NewByIDGetter(
|
||||
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "Pilot Nova", now)),
|
||||
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "player-abcdefgh", now)),
|
||||
&fakeAdminEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
common.UserID("user-123"): validAdminFreeSnapshot(common.UserID("user-123"), now),
|
||||
@@ -75,12 +75,12 @@ func TestByEmailGetterExecuteUnknownUserReturnsNotFound(t *testing.T) {
|
||||
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
|
||||
}
|
||||
|
||||
func TestByRaceNameGetterExecuteReturnsAggregate(t *testing.T) {
|
||||
func TestByUserNameGetterExecuteReturnsAggregate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
service, err := NewByRaceNameGetter(
|
||||
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "Pilot Nova", now)),
|
||||
service, err := NewByUserNameGetter(
|
||||
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "player-abcdefgh", now)),
|
||||
&fakeAdminEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
common.UserID("user-123"): validAdminFreeSnapshot(common.UserID("user-123"), now),
|
||||
@@ -92,10 +92,10 @@ func TestByRaceNameGetterExecuteReturnsAggregate(t *testing.T) {
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), GetUserByRaceNameInput{RaceName: " Pilot Nova "})
|
||||
result, err := service.Execute(context.Background(), GetUserByUserNameInput{UserName: " player-abcdefgh "})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "user-123", result.User.UserID)
|
||||
require.Equal(t, "Pilot Nova", result.User.RaceName)
|
||||
require.Equal(t, "player-abcdefgh", result.User.UserName)
|
||||
}
|
||||
|
||||
func TestListerExecuteAppliesCombinedFiltersWithLogicalAND(t *testing.T) {
|
||||
@@ -111,9 +111,9 @@ func TestListerExecuteAppliesCombinedFiltersWithLogicalAND(t *testing.T) {
|
||||
canJoinGame := false
|
||||
|
||||
accountStore := newFakeAdminAccountStore(
|
||||
validAdminUserAccount("user-300", "u300@example.com", "User 300", now),
|
||||
validAdminUserAccount("user-200", "u200@example.com", "User 200", now),
|
||||
validAdminUserAccount("user-100", "u100@example.com", "User 100", now),
|
||||
validAdminUserAccount("user-300", "u300@example.com", "player-user300a", now),
|
||||
validAdminUserAccount("user-200", "u200@example.com", "player-user200a", now),
|
||||
validAdminUserAccount("user-100", "u100@example.com", "player-user100a", now),
|
||||
)
|
||||
snapshotStore := &fakeAdminEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
@@ -197,9 +197,9 @@ func TestListerExecuteDefaultAndMaximumPageSize(t *testing.T) {
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
accountStore := newFakeAdminAccountStore(
|
||||
validAdminUserAccount("user-300", "u300@example.com", "User 300", now),
|
||||
validAdminUserAccount("user-200", "u200@example.com", "User 200", now),
|
||||
validAdminUserAccount("user-100", "u100@example.com", "User 100", now),
|
||||
validAdminUserAccount("user-300", "u300@example.com", "player-user300a", now),
|
||||
validAdminUserAccount("user-200", "u200@example.com", "player-user200a", now),
|
||||
validAdminUserAccount("user-100", "u100@example.com", "player-user100a", now),
|
||||
)
|
||||
snapshotStore := &fakeAdminEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
@@ -273,7 +273,7 @@ func TestListerExecuteInvalidPageTokenReturnsInvalidRequest(t *testing.T) {
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
service, err := NewLister(
|
||||
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "Pilot Nova", now)),
|
||||
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "player-abcdefgh", now)),
|
||||
&fakeAdminEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
common.UserID("user-123"): validAdminFreeSnapshot(common.UserID("user-123"), now),
|
||||
@@ -360,8 +360,8 @@ func (generator adminReaderIDGenerator) NewUserID() (common.UserID, error) {
|
||||
return "", errors.New("unexpected NewUserID call")
|
||||
}
|
||||
|
||||
func (generator adminReaderIDGenerator) NewInitialRaceName() (common.RaceName, error) {
|
||||
return "", errors.New("unexpected NewInitialRaceName call")
|
||||
func (generator adminReaderIDGenerator) NewUserName() (common.UserName, error) {
|
||||
return "", errors.New("unexpected NewUserName call")
|
||||
}
|
||||
|
||||
func (generator adminReaderIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
|
||||
@@ -379,9 +379,8 @@ func (generator adminReaderIDGenerator) NewLimitRecordID() (policy.LimitRecordID
|
||||
type fakeAdminAccountStore struct {
|
||||
byUserID map[common.UserID]account.UserAccount
|
||||
byEmail map[common.Email]common.UserID
|
||||
byRaceName map[common.RaceName]common.UserID
|
||||
byUserName map[common.UserName]common.UserID
|
||||
updateErr error
|
||||
renameErr error
|
||||
createErr error
|
||||
existsByID map[common.UserID]bool
|
||||
}
|
||||
@@ -390,14 +389,14 @@ func newFakeAdminAccountStore(records ...account.UserAccount) *fakeAdminAccountS
|
||||
store := &fakeAdminAccountStore{
|
||||
byUserID: make(map[common.UserID]account.UserAccount, len(records)),
|
||||
byEmail: make(map[common.Email]common.UserID, len(records)),
|
||||
byRaceName: make(map[common.RaceName]common.UserID, len(records)),
|
||||
byUserName: make(map[common.UserName]common.UserID, len(records)),
|
||||
existsByID: make(map[common.UserID]bool, len(records)),
|
||||
}
|
||||
|
||||
for _, record := range records {
|
||||
store.byUserID[record.UserID] = record
|
||||
store.byEmail[record.Email] = record.UserID
|
||||
store.byRaceName[record.RaceName] = record.UserID
|
||||
store.byUserName[record.UserName] = record.UserID
|
||||
store.existsByID[record.UserID] = true
|
||||
}
|
||||
|
||||
@@ -426,8 +425,8 @@ func (store *fakeAdminAccountStore) GetByEmail(_ context.Context, email common.E
|
||||
return store.byUserID[userID], nil
|
||||
}
|
||||
|
||||
func (store *fakeAdminAccountStore) GetByRaceName(_ context.Context, raceName common.RaceName) (account.UserAccount, error) {
|
||||
userID, ok := store.byRaceName[raceName]
|
||||
func (store *fakeAdminAccountStore) GetByUserName(_ context.Context, userName common.UserName) (account.UserAccount, error) {
|
||||
userID, ok := store.byUserName[userName]
|
||||
if !ok {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
@@ -439,10 +438,6 @@ func (store *fakeAdminAccountStore) ExistsByUserID(_ context.Context, userID com
|
||||
return store.existsByID[userID], nil
|
||||
}
|
||||
|
||||
func (store *fakeAdminAccountStore) RenameRaceName(context.Context, ports.RenameRaceNameInput) error {
|
||||
return store.renameErr
|
||||
}
|
||||
|
||||
func (store *fakeAdminAccountStore) Update(context.Context, account.UserAccount) error {
|
||||
return store.updateErr
|
||||
}
|
||||
@@ -547,11 +542,11 @@ func (store *fakeAdminListStore) ListUserIDs(_ context.Context, input ports.List
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validAdminUserAccount(userID string, email string, raceName string, now time.Time) account.UserAccount {
|
||||
func validAdminUserAccount(userID string, email string, userName string, now time.Time) account.UserAccount {
|
||||
return account.UserAccount{
|
||||
UserID: common.UserID(userID),
|
||||
Email: common.Email(email),
|
||||
RaceName: common.RaceName(raceName),
|
||||
UserName: common.UserName(userName),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
|
||||
DeclaredCountry: common.CountryCode("DE"),
|
||||
|
||||
@@ -22,7 +22,7 @@ const (
|
||||
initialEntitlementActorType common.ActorType = "service"
|
||||
initialEntitlementActorID common.ActorID = "user-service"
|
||||
|
||||
ensureCreateRetryLimit = 8
|
||||
ensureCreateRetryLimit = 10
|
||||
)
|
||||
|
||||
// ResolveByEmailInput stores one auth-facing resolve-by-email request.
|
||||
@@ -155,7 +155,6 @@ type Ensurer struct {
|
||||
store ports.AuthDirectoryStore
|
||||
clock ports.Clock
|
||||
idGenerator ports.IDGenerator
|
||||
policy ports.RaceNamePolicy
|
||||
logger *slog.Logger
|
||||
telemetry *telemetry.Runtime
|
||||
profilePublisher ports.ProfileChangedPublisher
|
||||
@@ -168,9 +167,8 @@ func NewEnsurer(
|
||||
store ports.AuthDirectoryStore,
|
||||
clock ports.Clock,
|
||||
idGenerator ports.IDGenerator,
|
||||
policy ports.RaceNamePolicy,
|
||||
) (*Ensurer, error) {
|
||||
return NewEnsurerWithObservability(store, clock, idGenerator, policy, nil, nil, nil, nil, nil)
|
||||
return NewEnsurerWithObservability(store, clock, idGenerator, nil, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
// NewEnsurerWithObservability returns one ensure-by-email use case instance
|
||||
@@ -180,7 +178,6 @@ func NewEnsurerWithObservability(
|
||||
store ports.AuthDirectoryStore,
|
||||
clock ports.Clock,
|
||||
idGenerator ports.IDGenerator,
|
||||
policy ports.RaceNamePolicy,
|
||||
logger *slog.Logger,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
profilePublisher ports.ProfileChangedPublisher,
|
||||
@@ -194,14 +191,11 @@ func NewEnsurerWithObservability(
|
||||
return nil, fmt.Errorf("authdirectory ensurer: clock must not be nil")
|
||||
case idGenerator == nil:
|
||||
return nil, fmt.Errorf("authdirectory ensurer: id generator must not be nil")
|
||||
case policy == nil:
|
||||
return nil, fmt.Errorf("authdirectory ensurer: race-name policy must not be nil")
|
||||
default:
|
||||
return &Ensurer{
|
||||
store: store,
|
||||
clock: clock,
|
||||
idGenerator: idGenerator,
|
||||
policy: policy,
|
||||
logger: logger,
|
||||
telemetry: telemetryRuntime,
|
||||
profilePublisher: profilePublisher,
|
||||
@@ -256,7 +250,7 @@ func (service *Ensurer) Execute(ctx context.Context, input EnsureByEmailInput) (
|
||||
if err != nil {
|
||||
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
raceName, err := service.idGenerator.NewInitialRaceName()
|
||||
userName, err := service.idGenerator.NewUserName()
|
||||
if err != nil {
|
||||
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
@@ -264,7 +258,7 @@ func (service *Ensurer) Execute(ctx context.Context, input EnsureByEmailInput) (
|
||||
accountRecord := account.UserAccount{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
RaceName: raceName,
|
||||
UserName: userName,
|
||||
PreferredLanguage: preferredLanguage,
|
||||
TimeZone: timeZone,
|
||||
CreatedAt: now,
|
||||
@@ -294,21 +288,16 @@ func (service *Ensurer) Execute(ctx context.Context, input EnsureByEmailInput) (
|
||||
StartsAt: now,
|
||||
CreatedAt: now,
|
||||
}
|
||||
reservation, err := shared.BuildRaceNameReservation(service.policy, userID, raceName, now)
|
||||
if err != nil {
|
||||
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
|
||||
ensureResult, err := service.store.EnsureByEmail(ctx, ports.EnsureByEmailInput{
|
||||
Email: email,
|
||||
Account: accountRecord,
|
||||
Entitlement: entitlementSnapshot,
|
||||
EntitlementRecord: entitlementRecord,
|
||||
Reservation: reservation,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, ports.ErrRaceNameConflict) && service.telemetry != nil {
|
||||
service.telemetry.RecordRaceNameReservationConflict(ctx, "ensure_by_email")
|
||||
if errors.Is(err, ports.ErrUserNameConflict) && service.telemetry != nil {
|
||||
service.telemetry.RecordUserNameConflict(ctx, "ensure_by_email")
|
||||
}
|
||||
if errors.Is(err, ports.ErrConflict) {
|
||||
continue
|
||||
@@ -349,11 +338,12 @@ func (service *Ensurer) publishInitializedEvents(
|
||||
occurredAt := accountRecord.UpdatedAt.UTC()
|
||||
|
||||
service.publishProfileChanged(ctx, ports.ProfileChangedEvent{
|
||||
UserID: accountRecord.UserID,
|
||||
OccurredAt: occurredAt,
|
||||
Source: initialEntitlementSource,
|
||||
Operation: ports.ProfileChangedOperationInitialized,
|
||||
RaceName: accountRecord.RaceName,
|
||||
UserID: accountRecord.UserID,
|
||||
OccurredAt: occurredAt,
|
||||
Source: initialEntitlementSource,
|
||||
Operation: ports.ProfileChangedOperationInitialized,
|
||||
UserName: accountRecord.UserName,
|
||||
DisplayName: accountRecord.DisplayName,
|
||||
})
|
||||
service.publishSettingsChanged(ctx, ports.SettingsChangedEvent{
|
||||
UserID: accountRecord.UserID,
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
@@ -101,12 +100,9 @@ func TestEnsurerExecuteCreatedBuildsInitialRecords(t *testing.T) {
|
||||
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
|
||||
require.Equal(t, common.Email("created@example.com"), input.Email)
|
||||
require.Equal(t, common.UserID("user-created"), input.Account.UserID)
|
||||
require.Equal(t, common.RaceName("player-test123"), input.Account.RaceName)
|
||||
require.Equal(t, common.UserName("player-test123"), input.Account.UserName)
|
||||
require.Equal(t, common.LanguageTag("en-US"), input.Account.PreferredLanguage)
|
||||
require.Equal(t, common.TimeZoneName("Europe/Kaliningrad"), input.Account.TimeZone)
|
||||
require.Equal(t, input.Account.UserID, input.Reservation.UserID)
|
||||
require.Equal(t, input.Account.RaceName, input.Reservation.RaceName)
|
||||
require.Equal(t, accountTestCanonicalKey(input.Account.RaceName), input.Reservation.CanonicalKey)
|
||||
require.Equal(t, entitlement.PlanCodeFree, input.Entitlement.PlanCode)
|
||||
require.False(t, input.Entitlement.IsPaid)
|
||||
require.Equal(t, input.Account.UserID, input.Entitlement.UserID)
|
||||
@@ -124,9 +120,9 @@ func TestEnsurerExecuteCreatedBuildsInitialRecords(t *testing.T) {
|
||||
},
|
||||
}, fixedClock{now: now}, fixedIDGenerator{
|
||||
userID: common.UserID("user-created"),
|
||||
raceName: common.RaceName("player-test123"),
|
||||
userName: common.UserName("player-test123"),
|
||||
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
|
||||
}, stubRaceNamePolicy{})
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
|
||||
@@ -180,9 +176,9 @@ func TestEnsurerExecuteRejectsInvalidRegistrationContext(t *testing.T) {
|
||||
|
||||
ensurer, err := NewEnsurer(stubAuthDirectoryStore{}, fixedClock{now: time.Unix(1_775_240_000, 0).UTC()}, fixedIDGenerator{
|
||||
userID: common.UserID("user-created"),
|
||||
raceName: common.RaceName("player-test123"),
|
||||
userName: common.UserName("player-test123"),
|
||||
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
|
||||
}, stubRaceNamePolicy{})
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ensurer.Execute(context.Background(), tt.input)
|
||||
@@ -210,9 +206,9 @@ func TestEnsurerExecuteRetriesConflicts(t *testing.T) {
|
||||
},
|
||||
}, fixedClock{now: time.Unix(1_775_240_000, 0).UTC()}, &sequenceIDGenerator{
|
||||
userIDs: []common.UserID{"user-first", "user-second"},
|
||||
raceNames: []common.RaceName{"player-first", "player-second"},
|
||||
userNames: []common.UserName{"player-firstxyz", "player-secondxy"},
|
||||
entitlementRecordIDs: []entitlement.EntitlementRecordID{"entitlement-first", "entitlement-second"},
|
||||
}, stubRaceNamePolicy{})
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
|
||||
@@ -276,9 +272,9 @@ func TestEnsurerExecuteReturnsExistingAndBlocked(t *testing.T) {
|
||||
|
||||
ensurer, err := NewEnsurer(tt.store, fixedClock{now: time.Unix(1_775_240_000, 0).UTC()}, fixedIDGenerator{
|
||||
userID: common.UserID("user-created"),
|
||||
raceName: common.RaceName("player-test123"),
|
||||
userName: common.UserName("player-test123"),
|
||||
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
|
||||
}, stubRaceNamePolicy{})
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
|
||||
@@ -310,9 +306,9 @@ func TestEnsurerExecuteCreatedPublishesInitializedEvents(t *testing.T) {
|
||||
},
|
||||
}, fixedClock{now: now}, fixedIDGenerator{
|
||||
userID: common.UserID("user-created"),
|
||||
raceName: common.RaceName("player-test123"),
|
||||
userName: common.UserName("player-test123"),
|
||||
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
|
||||
}, stubRaceNamePolicy{}, nil, telemetryRuntime, publisher, publisher, publisher)
|
||||
}, nil, telemetryRuntime, publisher, publisher, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
|
||||
@@ -407,9 +403,9 @@ func TestEnsurerExecuteExistingBlockedAndFailedDoNotPublishEvents(t *testing.T)
|
||||
telemetryRuntime, reader := newObservedAuthTelemetryRuntime(t)
|
||||
ensurer, err := NewEnsurerWithObservability(tt.store, fixedClock{now: time.Unix(1_775_240_000, 0).UTC()}, fixedIDGenerator{
|
||||
userID: common.UserID("user-created"),
|
||||
raceName: common.RaceName("player-test123"),
|
||||
userName: common.UserName("player-test123"),
|
||||
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
|
||||
}, stubRaceNamePolicy{}, nil, telemetryRuntime, publisher, publisher, publisher)
|
||||
}, nil, telemetryRuntime, publisher, publisher, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ensurer.Execute(context.Background(), tt.input)
|
||||
@@ -446,9 +442,9 @@ func TestEnsurerExecutePublishFailureDoesNotRollbackCreatedUser(t *testing.T) {
|
||||
},
|
||||
}, fixedClock{now: now}, fixedIDGenerator{
|
||||
userID: common.UserID("user-created"),
|
||||
raceName: common.RaceName("player-test123"),
|
||||
userName: common.UserName("player-test123"),
|
||||
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
|
||||
}, stubRaceNamePolicy{}, nil, telemetryRuntime, publisher, publisher, publisher)
|
||||
}, nil, telemetryRuntime, publisher, publisher, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
|
||||
@@ -546,7 +542,7 @@ func (clock fixedClock) Now() time.Time {
|
||||
|
||||
type fixedIDGenerator struct {
|
||||
userID common.UserID
|
||||
raceName common.RaceName
|
||||
userName common.UserName
|
||||
entitlementRecordID entitlement.EntitlementRecordID
|
||||
sanctionRecordID policy.SanctionRecordID
|
||||
limitRecordID policy.LimitRecordID
|
||||
@@ -556,8 +552,8 @@ func (generator fixedIDGenerator) NewUserID() (common.UserID, error) {
|
||||
return generator.userID, nil
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewInitialRaceName() (common.RaceName, error) {
|
||||
return generator.raceName, nil
|
||||
func (generator fixedIDGenerator) NewUserName() (common.UserName, error) {
|
||||
return generator.userName, nil
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
|
||||
@@ -574,7 +570,7 @@ func (generator fixedIDGenerator) NewLimitRecordID() (policy.LimitRecordID, erro
|
||||
|
||||
type sequenceIDGenerator struct {
|
||||
userIDs []common.UserID
|
||||
raceNames []common.RaceName
|
||||
userNames []common.UserName
|
||||
entitlementRecordIDs []entitlement.EntitlementRecordID
|
||||
sanctionRecordIDs []policy.SanctionRecordID
|
||||
limitRecordIDs []policy.LimitRecordID
|
||||
@@ -586,9 +582,9 @@ func (generator *sequenceIDGenerator) NewUserID() (common.UserID, error) {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (generator *sequenceIDGenerator) NewInitialRaceName() (common.RaceName, error) {
|
||||
value := generator.raceNames[0]
|
||||
generator.raceNames = generator.raceNames[1:]
|
||||
func (generator *sequenceIDGenerator) NewUserName() (common.UserName, error) {
|
||||
value := generator.userNames[0]
|
||||
generator.userNames = generator.userNames[1:]
|
||||
return value, nil
|
||||
}
|
||||
|
||||
@@ -610,16 +606,6 @@ func (generator *sequenceIDGenerator) NewLimitRecordID() (policy.LimitRecordID,
|
||||
return value, nil
|
||||
}
|
||||
|
||||
type stubRaceNamePolicy struct{}
|
||||
|
||||
func (stubRaceNamePolicy) CanonicalKey(raceName common.RaceName) (account.RaceNameCanonicalKey, error) {
|
||||
return accountTestCanonicalKey(raceName), nil
|
||||
}
|
||||
|
||||
func accountTestCanonicalKey(raceName common.RaceName) account.RaceNameCanonicalKey {
|
||||
return account.RaceNameCanonicalKey("key:" + raceName.String())
|
||||
}
|
||||
|
||||
type recordingAuthDomainEventPublisher struct {
|
||||
err error
|
||||
profileEvents []ports.ProfileChangedEvent
|
||||
@@ -710,7 +696,6 @@ var (
|
||||
_ ports.Clock = fixedClock{}
|
||||
_ ports.IDGenerator = fixedIDGenerator{}
|
||||
_ ports.IDGenerator = (*sequenceIDGenerator)(nil)
|
||||
_ ports.RaceNamePolicy = stubRaceNamePolicy{}
|
||||
_ ports.ProfileChangedPublisher = (*recordingAuthDomainEventPublisher)(nil)
|
||||
_ ports.SettingsChangedPublisher = (*recordingAuthDomainEventPublisher)(nil)
|
||||
_ ports.EntitlementChangedPublisher = (*recordingAuthDomainEventPublisher)(nil)
|
||||
|
||||
@@ -312,7 +312,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
|
||||
}
|
||||
|
||||
@@ -320,9 +320,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
|
||||
@@ -454,7 +451,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
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +127,9 @@ func (service *SyncService) Execute(
|
||||
default:
|
||||
return SyncDeclaredCountryResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
if record.IsDeleted() {
|
||||
return SyncDeclaredCountryResult{}, shared.SubjectNotFound()
|
||||
}
|
||||
|
||||
if record.DeclaredCountry == declaredCountry {
|
||||
outcome = "noop"
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestSyncServiceExecuteUpdatesDeclaredCountryAndPublishesEvent(t *testing.T)
|
||||
stored, err := store.GetByUserID(context.Background(), record.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record.Email, stored.Email)
|
||||
require.Equal(t, record.RaceName, stored.RaceName)
|
||||
require.Equal(t, record.UserName, stored.UserName)
|
||||
require.Equal(t, record.PreferredLanguage, stored.PreferredLanguage)
|
||||
require.Equal(t, record.TimeZone, stored.TimeZone)
|
||||
require.Equal(t, common.CountryCode("FR"), stored.DeclaredCountry)
|
||||
@@ -210,9 +210,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
|
||||
}
|
||||
}
|
||||
@@ -225,10 +225,6 @@ func (store *fakeAccountStore) ExistsByUserID(_ context.Context, userID common.U
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) RenameRaceName(context.Context, ports.RenameRaceNameInput) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) Update(_ context.Context, record account.UserAccount) error {
|
||||
store.updateCalls++
|
||||
if store.updateErr != nil {
|
||||
@@ -283,7 +279,7 @@ func validAccountRecord(createdAt time.Time, updatedAt time.Time) account.UserAc
|
||||
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"),
|
||||
|
||||
@@ -15,33 +15,66 @@ import (
|
||||
"galaxy/user/internal/service/shared"
|
||||
)
|
||||
|
||||
// limitCatalogEntry stores one frozen default quota for free and paid
|
||||
// entitlement states.
|
||||
// limitCatalogEntry stores the frozen default quota for every tariff plan
|
||||
// plus the toggle that decides whether a `free` default is materialized at
|
||||
// all.
|
||||
type limitCatalogEntry struct {
|
||||
code policy.LimitCode
|
||||
freeValue int
|
||||
paidValue int
|
||||
monthlyValue int
|
||||
yearlyValue int
|
||||
lifetimeValue int
|
||||
freeEnabled bool
|
||||
}
|
||||
|
||||
// planValue returns the frozen default quota for plan.
|
||||
func (entry limitCatalogEntry) planValue(plan entitlement.PlanCode) int {
|
||||
switch plan {
|
||||
case entitlement.PlanCodePaidMonthly:
|
||||
return entry.monthlyValue
|
||||
case entitlement.PlanCodePaidYearly:
|
||||
return entry.yearlyValue
|
||||
case entitlement.PlanCodePaidLifetime:
|
||||
return entry.lifetimeValue
|
||||
default:
|
||||
return entry.freeValue
|
||||
}
|
||||
}
|
||||
|
||||
// limitCatalog stores the frozen lobby-facing effective limit defaults used
|
||||
// to materialize numeric quotas from the current entitlement state.
|
||||
// to materialize numeric quotas from the current entitlement state. Paid
|
||||
// plans share the same default unless stated otherwise; per-plan values
|
||||
// diverge only for `max_registered_race_names`.
|
||||
var limitCatalog = []limitCatalogEntry{
|
||||
{
|
||||
code: policy.LimitCodeMaxOwnedPrivateGames,
|
||||
paidValue: 3,
|
||||
code: policy.LimitCodeMaxOwnedPrivateGames,
|
||||
monthlyValue: 3,
|
||||
yearlyValue: 3,
|
||||
lifetimeValue: 3,
|
||||
},
|
||||
{
|
||||
code: policy.LimitCodeMaxPendingPublicApplications,
|
||||
freeValue: 3,
|
||||
paidValue: 10,
|
||||
freeEnabled: true,
|
||||
code: policy.LimitCodeMaxPendingPublicApplications,
|
||||
freeValue: 3,
|
||||
monthlyValue: 10,
|
||||
yearlyValue: 10,
|
||||
lifetimeValue: 10,
|
||||
freeEnabled: true,
|
||||
},
|
||||
{
|
||||
code: policy.LimitCodeMaxActiveGameMemberships,
|
||||
freeValue: 3,
|
||||
paidValue: 10,
|
||||
freeEnabled: true,
|
||||
code: policy.LimitCodeMaxActiveGameMemberships,
|
||||
freeValue: 3,
|
||||
monthlyValue: 10,
|
||||
yearlyValue: 10,
|
||||
lifetimeValue: 10,
|
||||
freeEnabled: true,
|
||||
},
|
||||
{
|
||||
code: policy.LimitCodeMaxRegisteredRaceNames,
|
||||
freeValue: 1,
|
||||
monthlyValue: 2,
|
||||
yearlyValue: 6,
|
||||
lifetimeValue: 0,
|
||||
freeEnabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -268,7 +301,7 @@ func (service *SnapshotReader) Execute(
|
||||
result.Exists = true
|
||||
result.Entitlement = entitlementSnapshotView(entitlementSnapshot)
|
||||
result.ActiveSanctions = lobbyRelevantSanctionViews(activeSanctions)
|
||||
result.EffectiveLimits = materializeEffectiveLimits(entitlementSnapshot.IsPaid, activeLimits)
|
||||
result.EffectiveLimits = materializeEffectiveLimits(entitlementSnapshot.PlanCode, activeLimits)
|
||||
result.Markers = deriveEligibilityMarkers(entitlementSnapshot.IsPaid, activeSanctions)
|
||||
|
||||
return result, nil
|
||||
@@ -308,22 +341,20 @@ func lobbyRelevantSanctionViews(records []policy.SanctionRecord) []ActiveSanctio
|
||||
return views
|
||||
}
|
||||
|
||||
func materializeEffectiveLimits(isPaid bool, overrides []policy.LimitRecord) []EffectiveLimitView {
|
||||
func materializeEffectiveLimits(plan entitlement.PlanCode, overrides []policy.LimitRecord) []EffectiveLimitView {
|
||||
overrideValues := make(map[policy.LimitCode]int, len(overrides))
|
||||
for _, record := range overrides {
|
||||
overrideValues[record.LimitCode] = record.Value
|
||||
}
|
||||
|
||||
isPaid := plan.IsPaid()
|
||||
limits := make([]EffectiveLimitView, 0, len(limitCatalog))
|
||||
for _, entry := range limitCatalog {
|
||||
if !isPaid && !entry.freeEnabled {
|
||||
continue
|
||||
}
|
||||
|
||||
value := entry.freeValue
|
||||
if isPaid {
|
||||
value = entry.paidValue
|
||||
}
|
||||
value := entry.planValue(plan)
|
||||
if override, ok := overrideValues[entry.code]; ok {
|
||||
value = override
|
||||
}
|
||||
@@ -341,6 +372,10 @@ func deriveEligibilityMarkers(
|
||||
isPaid bool,
|
||||
activeSanctions []policy.SanctionRecord,
|
||||
) EligibilityMarkersView {
|
||||
if hasActiveSanction(activeSanctions, policy.SanctionCodePermanentBlock) {
|
||||
return EligibilityMarkersView{}
|
||||
}
|
||||
|
||||
loginBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodeLoginBlock)
|
||||
createBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodePrivateGameCreateBlock)
|
||||
manageBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodePrivateGameManageBlock)
|
||||
@@ -373,7 +408,8 @@ func isLobbyRelevantSanction(code policy.SanctionCode) bool {
|
||||
case policy.SanctionCodeLoginBlock,
|
||||
policy.SanctionCodePrivateGameCreateBlock,
|
||||
policy.SanctionCodePrivateGameManageBlock,
|
||||
policy.SanctionCodeGameJoinBlock:
|
||||
policy.SanctionCodeGameJoinBlock,
|
||||
policy.SanctionCodePermanentBlock:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -95,6 +95,7 @@ func TestSnapshotReaderExecuteBuildsPaidSnapshotAndDerivedState(t *testing.T) {
|
||||
{LimitCode: "max_owned_private_games", Value: 3},
|
||||
{LimitCode: "max_pending_public_applications", Value: 10},
|
||||
{LimitCode: "max_active_game_memberships", Value: 10},
|
||||
{LimitCode: "max_registered_race_names", Value: 2},
|
||||
}, result.EffectiveLimits)
|
||||
}
|
||||
|
||||
@@ -128,6 +129,7 @@ func TestSnapshotReaderExecuteDeniesUnpaidAndLoginBlockedUsers(t *testing.T) {
|
||||
wantLimits: []EffectiveLimitView{
|
||||
{LimitCode: "max_pending_public_applications", Value: 3},
|
||||
{LimitCode: "max_active_game_memberships", Value: 3},
|
||||
{LimitCode: "max_registered_race_names", Value: 1},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -149,6 +151,7 @@ func TestSnapshotReaderExecuteDeniesUnpaidAndLoginBlockedUsers(t *testing.T) {
|
||||
{LimitCode: "max_owned_private_games", Value: 3},
|
||||
{LimitCode: "max_pending_public_applications", Value: 10},
|
||||
{LimitCode: "max_active_game_memberships", Value: 10},
|
||||
{LimitCode: "max_registered_race_names", Value: 2},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -181,6 +184,71 @@ func TestSnapshotReaderExecuteDeniesUnpaidAndLoginBlockedUsers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotReaderExecutePermanentBlockCollapsesMarkers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
snapshot entitlement.CurrentSnapshot
|
||||
sanctions []policy.SanctionRecord
|
||||
}{
|
||||
{
|
||||
name: "permanent_block alone on paid user",
|
||||
snapshot: paidEntitlementSnapshot(userID, now.Add(-24*time.Hour), now.Add(24*time.Hour)),
|
||||
sanctions: []policy.SanctionRecord{
|
||||
activeSanction(userID, policy.SanctionCodePermanentBlock, "platform", now.Add(-time.Hour)),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "permanent_block alone on free user",
|
||||
snapshot: freeEntitlementSnapshot(userID, now.Add(-24*time.Hour)),
|
||||
sanctions: []policy.SanctionRecord{
|
||||
activeSanction(userID, policy.SanctionCodePermanentBlock, "platform", now.Add(-time.Hour)),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "permanent_block dominates login_block",
|
||||
snapshot: paidEntitlementSnapshot(userID, now.Add(-24*time.Hour), now.Add(24*time.Hour)),
|
||||
sanctions: []policy.SanctionRecord{
|
||||
activeSanction(userID, policy.SanctionCodeLoginBlock, "auth", now.Add(-time.Hour)),
|
||||
activeSanction(userID, policy.SanctionCodePermanentBlock, "platform", now.Add(-30*time.Minute)),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service, err := NewSnapshotReader(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
|
||||
fakeEntitlementReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: tt.snapshot}},
|
||||
fakeSanctionStore{byUserID: map[common.UserID][]policy.SanctionRecord{userID: tt.sanctions}},
|
||||
fakeLimitStore{},
|
||||
fixedClock{now: now},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), GetUserEligibilityInput{UserID: userID.String()})
|
||||
require.NoError(t, err)
|
||||
require.True(t, result.Exists)
|
||||
require.Equal(t, EligibilityMarkersView{}, result.Markers,
|
||||
"every can_* marker must be false under permanent_block")
|
||||
|
||||
gotSanctions := make([]string, 0, len(result.ActiveSanctions))
|
||||
for _, sanction := range result.ActiveSanctions {
|
||||
gotSanctions = append(gotSanctions, sanction.SanctionCode)
|
||||
}
|
||||
require.Contains(t, gotSanctions, string(policy.SanctionCodePermanentBlock),
|
||||
"permanent_block must surface in the eligibility snapshot")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotReaderExecuteRepairsExpiredPaidSnapshotWithStore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -191,12 +259,6 @@ func TestSnapshotReaderExecuteRepairsExpiredPaidSnapshotWithStore(t *testing.T)
|
||||
|
||||
require.NoError(t, store.Accounts().Create(context.Background(), ports.CreateAccountInput{
|
||||
Account: accountRecord,
|
||||
Reservation: account.RaceNameReservation{
|
||||
CanonicalKey: account.RaceNameCanonicalKey("pilot nova"),
|
||||
UserID: userID,
|
||||
RaceName: accountRecord.RaceName,
|
||||
ReservedAt: accountRecord.UpdatedAt,
|
||||
},
|
||||
}))
|
||||
|
||||
expiredEndsAt := now.Add(-time.Minute)
|
||||
@@ -239,6 +301,7 @@ func TestSnapshotReaderExecuteRepairsExpiredPaidSnapshotWithStore(t *testing.T)
|
||||
require.Equal(t, []EffectiveLimitView{
|
||||
{LimitCode: "max_pending_public_applications", Value: 3},
|
||||
{LimitCode: "max_active_game_memberships", Value: 3},
|
||||
{LimitCode: "max_registered_race_names", Value: 1},
|
||||
}, result.EffectiveLimits)
|
||||
|
||||
storedSnapshot, err := store.EntitlementSnapshots().GetByUserID(context.Background(), userID)
|
||||
@@ -264,7 +327,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
|
||||
}
|
||||
|
||||
@@ -276,10 +339,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
|
||||
}
|
||||
@@ -374,7 +433,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
|
||||
}
|
||||
|
||||
@@ -486,7 +545,7 @@ func validAccountRecord() account.UserAccount {
|
||||
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"),
|
||||
CreatedAt: createdAt,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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{}
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/util"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
@@ -36,14 +37,29 @@ func ParseUserID(value string) (common.UserID, error) {
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// ParseRaceName trims value and validates it as one exact stored race name.
|
||||
func ParseRaceName(value string) (common.RaceName, error) {
|
||||
raceName := common.RaceName(NormalizeString(value))
|
||||
if err := raceName.Validate(); err != nil {
|
||||
// ParseUserName trims value and validates it as one exact stored user name.
|
||||
func ParseUserName(value string) (common.UserName, error) {
|
||||
userName := common.UserName(NormalizeString(value))
|
||||
if err := userName.Validate(); err != nil {
|
||||
return "", InvalidRequest(err.Error())
|
||||
}
|
||||
|
||||
return raceName, nil
|
||||
return userName, nil
|
||||
}
|
||||
|
||||
// ParseDisplayName trims value and validates it as one self-service display
|
||||
// name. An empty trimmed value is accepted and represents a reset to no
|
||||
// display name.
|
||||
func ParseDisplayName(value string) (common.DisplayName, error) {
|
||||
trimmed := NormalizeString(value)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
if _, ok := util.ValidateTypeName(trimmed); !ok {
|
||||
return "", InvalidRequest(fmt.Sprintf("display_name %q is invalid", trimmed))
|
||||
}
|
||||
|
||||
return common.DisplayName(trimmed), nil
|
||||
}
|
||||
|
||||
// ParseReasonCode trims value and validates it as one machine-readable reason
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/ports"
|
||||
)
|
||||
|
||||
// BuildRaceNameReservation constructs one validated race-name reservation
|
||||
// record for userID and raceName at reservedAt.
|
||||
func BuildRaceNameReservation(
|
||||
policy ports.RaceNamePolicy,
|
||||
userID common.UserID,
|
||||
raceName common.RaceName,
|
||||
reservedAt time.Time,
|
||||
) (account.RaceNameReservation, error) {
|
||||
if policy == nil {
|
||||
return account.RaceNameReservation{}, fmt.Errorf("build race-name reservation: race-name policy must not be nil")
|
||||
}
|
||||
if err := userID.Validate(); err != nil {
|
||||
return account.RaceNameReservation{}, fmt.Errorf("build race-name reservation: %w", err)
|
||||
}
|
||||
if err := raceName.Validate(); err != nil {
|
||||
return account.RaceNameReservation{}, fmt.Errorf("build race-name reservation: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("build race-name reservation reserved at", reservedAt); err != nil {
|
||||
return account.RaceNameReservation{}, err
|
||||
}
|
||||
|
||||
canonicalKey, err := policy.CanonicalKey(raceName)
|
||||
if err != nil {
|
||||
return account.RaceNameReservation{}, fmt.Errorf("build race-name reservation: %w", err)
|
||||
}
|
||||
|
||||
record := account.RaceNameReservation{
|
||||
CanonicalKey: canonicalKey,
|
||||
UserID: userID,
|
||||
RaceName: raceName,
|
||||
ReservedAt: reservedAt.UTC(),
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return account.RaceNameReservation{}, fmt.Errorf("build race-name reservation: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
Reference in New Issue
Block a user