feat: game lobby service

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