feat: user service

This commit is contained in:
Ilia Denisov
2026-04-10 19:05:02 +02:00
committed by GitHub
parent 710bad712e
commit 23ffcb7535
140 changed files with 33418 additions and 952 deletions
@@ -0,0 +1,159 @@
package selfservice
import (
"context"
"errors"
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/ports"
"github.com/stretchr/testify/require"
)
func TestProfileUpdaterExecutePublishesProfileChangedEvent(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
accountStore := newFakeAccountStore(validUserAccount())
publisher := &recordingSelfServicePublisher{}
service, err := NewProfileUpdaterWithObservability(
accountStore,
&fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
},
},
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: now},
stubRaceNamePolicy{},
nil,
nil,
publisher,
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), UpdateMyProfileInput{
UserID: "user-123",
RaceName: "Nova Prime",
})
require.NoError(t, err)
require.Equal(t, "Nova Prime", result.Account.RaceName)
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)
}
func TestProfileUpdaterExecutePublisherFailureDoesNotRollbackCommit(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
accountStore := newFakeAccountStore(validUserAccount())
publisher := &recordingSelfServicePublisher{profileErr: errors.New("publisher unavailable")}
service, err := NewProfileUpdaterWithObservability(
accountStore,
&fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
},
},
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: now},
stubRaceNamePolicy{},
nil,
nil,
publisher,
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), UpdateMyProfileInput{
UserID: "user-123",
RaceName: "Nova Prime",
})
require.NoError(t, err)
require.Equal(t, "Nova Prime", result.Account.RaceName)
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)
}
func TestSettingsUpdaterExecuteNoOpDoesNotPublishEvent(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
accountStore := newFakeAccountStore(account.UserAccount{
UserID: common.UserID("user-123"),
Email: common.Email("pilot@example.com"),
RaceName: common.RaceName("Pilot Nova"),
PreferredLanguage: common.LanguageTag("en-US"),
TimeZone: common.TimeZoneName("UTC"),
DeclaredCountry: common.CountryCode("DE"),
CreatedAt: time.Unix(1_775_240_000, 0).UTC(),
UpdatedAt: time.Unix(1_775_240_100, 0).UTC(),
})
publisher := &recordingSelfServicePublisher{}
service, err := NewSettingsUpdaterWithObservability(
accountStore,
&fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
},
},
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: now},
nil,
nil,
publisher,
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), UpdateMySettingsInput{
UserID: "user-123",
PreferredLanguage: "en-us",
TimeZone: " UTC ",
})
require.NoError(t, err)
require.Equal(t, "en-US", result.Account.PreferredLanguage)
require.Equal(t, "UTC", result.Account.TimeZone)
require.Empty(t, publisher.settingsEvents)
}
type recordingSelfServicePublisher struct {
profileErr error
settingsErr error
profileEvents []ports.ProfileChangedEvent
settingsEvents []ports.SettingsChangedEvent
}
func (publisher *recordingSelfServicePublisher) PublishProfileChanged(_ context.Context, event ports.ProfileChangedEvent) error {
if err := event.Validate(); err != nil {
return err
}
publisher.profileEvents = append(publisher.profileEvents, event)
return publisher.profileErr
}
func (publisher *recordingSelfServicePublisher) PublishSettingsChanged(_ context.Context, event ports.SettingsChangedEvent) error {
if err := event.Validate(); err != nil {
return err
}
publisher.settingsEvents = append(publisher.settingsEvents, event)
return publisher.settingsErr
}
var (
_ ports.ProfileChangedPublisher = (*recordingSelfServicePublisher)(nil)
_ ports.SettingsChangedPublisher = (*recordingSelfServicePublisher)(nil)
)
@@ -0,0 +1,467 @@
// Package selfservice implements the authenticated self-service account read
// and mutation use cases owned by User Service.
package selfservice
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/accountview"
"galaxy/user/internal/service/shared"
"galaxy/user/internal/telemetry"
)
const gatewaySelfServiceSource = common.Source("gateway_self_service")
// ActorRefView stores transport-ready audit actor metadata.
type ActorRefView = accountview.ActorRefView
// EntitlementSnapshotView stores the transport-ready current entitlement
// snapshot of one account.
type EntitlementSnapshotView = accountview.EntitlementSnapshotView
// ActiveSanctionView stores one transport-ready active sanction.
type ActiveSanctionView = accountview.ActiveSanctionView
// ActiveLimitView stores one transport-ready active user-specific limit.
type ActiveLimitView = accountview.ActiveLimitView
// AccountView stores the transport-ready authenticated self-service account
// aggregate.
type AccountView = accountview.AccountView
// GetMyAccountInput stores one authenticated account-read request.
type GetMyAccountInput struct {
// UserID stores the authenticated regular-user identifier.
UserID string
}
// GetMyAccountResult stores one authenticated account-read result.
type GetMyAccountResult struct {
// Account stores the read-optimized current account aggregate.
Account AccountView `json:"account"`
}
// UpdateMyProfileInput stores one self-service profile mutation request.
type UpdateMyProfileInput struct {
// UserID stores the authenticated regular-user identifier.
UserID string
// RaceName stores the requested exact replacement race name.
RaceName string
}
// UpdateMyProfileResult stores one self-service profile mutation result.
type UpdateMyProfileResult struct {
// Account stores the refreshed account aggregate after the mutation.
Account AccountView `json:"account"`
}
// UpdateMySettingsInput stores one self-service settings mutation request.
type UpdateMySettingsInput struct {
// UserID stores the authenticated regular-user identifier.
UserID string
// PreferredLanguage stores the requested BCP 47 preferred language.
PreferredLanguage string
// TimeZone stores the requested IANA time-zone name.
TimeZone string
}
// UpdateMySettingsResult stores one self-service settings mutation result.
type UpdateMySettingsResult struct {
// Account stores the refreshed account aggregate after the mutation.
Account AccountView `json:"account"`
}
type entitlementReader interface {
GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error)
}
// AccountGetter executes the `GetMyAccount` use case.
type AccountGetter struct {
loader *accountview.Loader
}
// NewAccountGetter constructs one authenticated account-read use case.
func NewAccountGetter(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
) (*AccountGetter, error) {
loader, err := accountview.NewLoader(accounts, entitlements, sanctions, limits, clock)
if err != nil {
return nil, fmt.Errorf("selfservice account getter: %w", err)
}
return &AccountGetter{loader: loader}, nil
}
// Execute reads the current self-service account aggregate of input.UserID.
func (service *AccountGetter) Execute(ctx context.Context, input GetMyAccountInput) (GetMyAccountResult, error) {
if ctx == nil {
return GetMyAccountResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return GetMyAccountResult{}, err
}
state, err := service.loader.Load(ctx, userID)
if err != nil {
return GetMyAccountResult{}, err
}
return GetMyAccountResult{Account: state.View()}, nil
}
// ProfileUpdater executes the `UpdateMyProfile` use case.
type ProfileUpdater struct {
accounts ports.UserAccountStore
loader *accountview.Loader
policy ports.RaceNamePolicy
clock ports.Clock
logger *slog.Logger
telemetry *telemetry.Runtime
profilePublisher ports.ProfileChangedPublisher
}
// NewProfileUpdater constructs one self-service profile-mutation use case.
func NewProfileUpdater(
accounts ports.UserAccountStore,
entitlements entitlementReader,
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)
}
// NewProfileUpdaterWithObservability constructs one self-service
// profile-mutation use case with optional observability hooks.
func NewProfileUpdaterWithObservability(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
policy ports.RaceNamePolicy,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
profilePublisher ports.ProfileChangedPublisher,
) (*ProfileUpdater, error) {
loader, err := accountview.NewLoader(accounts, entitlements, sanctions, limits, clock)
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,
profilePublisher: profilePublisher,
}, nil
}
// Execute updates the current self-service profile fields of input.UserID.
func (service *ProfileUpdater) Execute(ctx context.Context, input UpdateMyProfileInput) (result UpdateMyProfileResult, err error) {
outcome := "failed"
userIDString := ""
defer func() {
shared.LogServiceOutcome(service.logger, ctx, "profile update completed", err,
"use_case", "update_my_profile",
"outcome", outcome,
"user_id", userIDString,
"source", gatewaySelfServiceSource.String(),
)
}()
if ctx == nil {
return UpdateMyProfileResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return UpdateMyProfileResult{}, err
}
userIDString = userID.String()
raceName, err := parseRaceName(input.RaceName)
if err != nil {
return UpdateMyProfileResult{}, err
}
state, err := service.loader.Load(ctx, userID)
if err != nil {
return UpdateMyProfileResult{}, err
}
if state.HasActiveSanction(policy.SanctionCodeProfileUpdateBlock) {
return UpdateMyProfileResult{}, shared.Conflict()
}
if state.AccountRecord.RaceName == raceName {
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")
}
switch {
case errors.Is(err, ports.ErrNotFound):
return UpdateMyProfileResult{}, shared.SubjectNotFound()
case errors.Is(err, ports.ErrConflict):
return UpdateMyProfileResult{}, shared.Conflict()
default:
return UpdateMyProfileResult{}, shared.ServiceUnavailable(err)
}
}
updatedState, err := service.loader.Load(ctx, userID)
if err != nil {
return UpdateMyProfileResult{}, err
}
outcome = "updated"
result = UpdateMyProfileResult{Account: updatedState.View()}
service.publishProfileChanged(ctx, updatedState.AccountRecord)
return result, nil
}
// SettingsUpdater executes the `UpdateMySettings` use case.
type SettingsUpdater struct {
accounts ports.UserAccountStore
loader *accountview.Loader
clock ports.Clock
logger *slog.Logger
telemetry *telemetry.Runtime
settingsPublisher ports.SettingsChangedPublisher
}
// NewSettingsUpdater constructs one self-service settings-mutation use case.
func NewSettingsUpdater(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
) (*SettingsUpdater, error) {
return NewSettingsUpdaterWithObservability(accounts, entitlements, sanctions, limits, clock, nil, nil, nil)
}
// NewSettingsUpdaterWithObservability constructs one self-service
// settings-mutation use case with optional observability hooks.
func NewSettingsUpdaterWithObservability(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
settingsPublisher ports.SettingsChangedPublisher,
) (*SettingsUpdater, error) {
loader, err := accountview.NewLoader(accounts, entitlements, sanctions, limits, clock)
if err != nil {
return nil, fmt.Errorf("selfservice settings updater: %w", err)
}
return &SettingsUpdater{
accounts: accounts,
loader: loader,
clock: clock,
logger: logger,
telemetry: telemetryRuntime,
settingsPublisher: settingsPublisher,
}, nil
}
// Execute updates the current self-service settings fields of input.UserID.
func (service *SettingsUpdater) Execute(ctx context.Context, input UpdateMySettingsInput) (result UpdateMySettingsResult, err error) {
outcome := "failed"
userIDString := ""
defer func() {
shared.LogServiceOutcome(service.logger, ctx, "settings update completed", err,
"use_case", "update_my_settings",
"outcome", outcome,
"user_id", userIDString,
"source", gatewaySelfServiceSource.String(),
)
}()
if ctx == nil {
return UpdateMySettingsResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return UpdateMySettingsResult{}, err
}
userIDString = userID.String()
preferredLanguage, err := parsePreferredLanguage(input.PreferredLanguage)
if err != nil {
return UpdateMySettingsResult{}, err
}
timeZone, err := parseTimeZoneName(input.TimeZone)
if err != nil {
return UpdateMySettingsResult{}, err
}
state, err := service.loader.Load(ctx, userID)
if err != nil {
return UpdateMySettingsResult{}, err
}
if state.HasActiveSanction(policy.SanctionCodeProfileUpdateBlock) {
return UpdateMySettingsResult{}, shared.Conflict()
}
if state.AccountRecord.PreferredLanguage == preferredLanguage && state.AccountRecord.TimeZone == timeZone {
outcome = "noop"
return UpdateMySettingsResult{Account: state.View()}, nil
}
record := state.AccountRecord
record.PreferredLanguage = preferredLanguage
record.TimeZone = timeZone
record.UpdatedAt = service.clock.Now().UTC()
if err := service.accounts.Update(ctx, record); err != nil {
switch {
case errors.Is(err, ports.ErrNotFound):
return UpdateMySettingsResult{}, shared.SubjectNotFound()
case errors.Is(err, ports.ErrConflict):
return UpdateMySettingsResult{}, shared.Conflict()
default:
return UpdateMySettingsResult{}, shared.ServiceUnavailable(err)
}
}
updatedState, err := service.loader.Load(ctx, userID)
if err != nil {
return UpdateMySettingsResult{}, err
}
outcome = "updated"
result = UpdateMySettingsResult{Account: updatedState.View()}
service.publishSettingsChanged(ctx, updatedState.AccountRecord)
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 {
return "", reframeFieldError("preferred_language", "language tag", err)
}
return languageTag, nil
}
func parseTimeZoneName(value string) (common.TimeZoneName, error) {
timeZoneName, err := shared.ParseTimeZoneName(value)
if err != nil {
return "", reframeFieldError("time_zone", "time zone name", err)
}
return timeZoneName, nil
}
func reframeFieldError(fieldName string, valueName string, err error) error {
if err == nil {
return nil
}
message := err.Error()
prefix := valueName + " "
if strings.HasPrefix(message, prefix) {
message = fieldName + " " + strings.TrimPrefix(message, prefix)
} else {
message = fmt.Sprintf("%s: %s", fieldName, message)
}
return shared.InvalidRequest(message)
}
func (service *ProfileUpdater) publishProfileChanged(ctx context.Context, record account.UserAccount) {
if service.profilePublisher == nil {
return
}
event := ports.ProfileChangedEvent{
UserID: record.UserID,
OccurredAt: record.UpdatedAt.UTC(),
Source: gatewaySelfServiceSource,
Operation: ports.ProfileChangedOperationUpdated,
RaceName: record.RaceName,
}
if err := service.profilePublisher.PublishProfileChanged(ctx, event); err != nil {
if service.telemetry != nil {
service.telemetry.RecordEventPublicationFailure(ctx, ports.ProfileChangedEventType)
}
shared.LogEventPublicationFailure(service.logger, ctx, ports.ProfileChangedEventType, err,
"use_case", "update_my_profile",
"user_id", record.UserID.String(),
"source", gatewaySelfServiceSource.String(),
)
}
}
func (service *SettingsUpdater) publishSettingsChanged(ctx context.Context, record account.UserAccount) {
if service.settingsPublisher == nil {
return
}
event := ports.SettingsChangedEvent{
UserID: record.UserID,
OccurredAt: record.UpdatedAt.UTC(),
Source: gatewaySelfServiceSource,
Operation: ports.SettingsChangedOperationUpdated,
PreferredLanguage: record.PreferredLanguage,
TimeZone: record.TimeZone,
}
if err := service.settingsPublisher.PublishSettingsChanged(ctx, event); err != nil {
if service.telemetry != nil {
service.telemetry.RecordEventPublicationFailure(ctx, ports.SettingsChangedEventType)
}
shared.LogEventPublicationFailure(service.logger, ctx, ports.SettingsChangedEventType, err,
"use_case", "update_my_settings",
"user_id", record.UserID.String(),
"source", gatewaySelfServiceSource.String(),
)
}
}
@@ -0,0 +1,732 @@
package selfservice
import (
"context"
"strings"
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/entitlementsvc"
"galaxy/user/internal/service/shared"
"github.com/stretchr/testify/require"
)
func TestAccountGetterExecuteReturnsAggregate(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
accountStore := newFakeAccountStore(validUserAccount())
snapshotStore := &fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
},
}
sanctionStore := fakeSanctionStore{
byUserID: map[common.UserID][]policy.SanctionRecord{
common.UserID("user-123"): {
validActiveSanction(common.UserID("user-123"), policy.SanctionCodeLoginBlock, now.Add(-time.Hour)),
expiredSanction(common.UserID("user-123"), policy.SanctionCodeGameJoinBlock, now.Add(-2*time.Hour)),
},
},
}
limitStore := fakeLimitStore{
byUserID: map[common.UserID][]policy.LimitRecord{
common.UserID("user-123"): {
validActiveLimit(common.UserID("user-123"), policy.LimitCodeMaxOwnedPrivateGames, 3, now.Add(-time.Hour)),
validActiveLimit(common.UserID("user-123"), policy.LimitCodeMaxActivePrivateGames, 1, now.Add(-2*time.Hour)),
},
},
}
service, err := NewAccountGetter(accountStore, snapshotStore, sanctionStore, limitStore, fixedClock{now: now})
require.NoError(t, err)
result, err := service.Execute(context.Background(), GetMyAccountInput{UserID: " user-123 "})
require.NoError(t, err)
require.Equal(t, "user-123", result.Account.UserID)
require.Equal(t, "DE", result.Account.DeclaredCountry)
require.Len(t, result.Account.ActiveSanctions, 1)
require.Equal(t, string(policy.SanctionCodeLoginBlock), result.Account.ActiveSanctions[0].SanctionCode)
require.Len(t, result.Account.ActiveLimits, 1)
require.Equal(t, string(policy.LimitCodeMaxOwnedPrivateGames), result.Account.ActiveLimits[0].LimitCode)
}
func TestAccountGetterExecuteUnknownUserReturnsNotFound(t *testing.T) {
t.Parallel()
service, err := NewAccountGetter(
newFakeAccountStore(),
&fakeEntitlementSnapshotStore{},
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: time.Unix(1_775_240_500, 0).UTC()},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), GetMyAccountInput{UserID: "user-missing"})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
}
func TestAccountGetterExecuteMissingSnapshotReturnsInternalError(t *testing.T) {
t.Parallel()
service, err := NewAccountGetter(
newFakeAccountStore(validUserAccount()),
&fakeEntitlementSnapshotStore{},
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: time.Unix(1_775_240_500, 0).UTC()},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), GetMyAccountInput{UserID: "user-123"})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInternalError, shared.CodeOf(err))
}
func TestAccountGetterExecuteRepairsExpiredPaidSnapshot(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
expiredAt := now.Add(-time.Hour)
snapshotStore := &fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): {
UserID: common.UserID("user-123"),
PlanCode: entitlement.PlanCodePaidMonthly,
IsPaid: true,
StartsAt: now.Add(-30 * 24 * time.Hour),
EndsAt: timePointer(expiredAt),
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_grant"),
UpdatedAt: expiredAt,
},
},
}
reader, err := entitlementsvc.NewReader(
snapshotStore,
&fakeEntitlementLifecycleStore{snapshotStore: snapshotStore},
fixedClock{now: now},
readerIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-free-after-expiry")},
)
require.NoError(t, err)
service, err := NewAccountGetter(
newFakeAccountStore(validUserAccount()),
reader,
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: now},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GetMyAccountInput{UserID: "user-123"})
require.NoError(t, err)
require.Equal(t, "free", result.Account.Entitlement.PlanCode)
require.False(t, result.Account.Entitlement.IsPaid)
require.Equal(t, expiredAt, result.Account.Entitlement.StartsAt)
}
func TestProfileUpdaterExecuteBlockedBySanction(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
accountStore := newFakeAccountStore(validUserAccount())
service, err := NewProfileUpdater(
accountStore,
&fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
},
},
fakeSanctionStore{
byUserID: map[common.UserID][]policy.SanctionRecord{
common.UserID("user-123"): {
validActiveSanction(common.UserID("user-123"), policy.SanctionCodeProfileUpdateBlock, now.Add(-time.Minute)),
},
},
},
fakeLimitStore{},
fixedClock{now: now},
stubRaceNamePolicy{},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), UpdateMyProfileInput{
UserID: "user-123",
RaceName: "Nova Prime",
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeConflict, shared.CodeOf(err))
require.Equal(t, 0, accountStore.renameCalls)
}
func TestProfileUpdaterExecuteSuccessNoOpAndConflict(t *testing.T) {
t.Parallel()
tests := []struct {
name string
inputRaceName string
renameErr error
wantCode string
wantRaceName string
wantRenameCalls int
wantCurrentKey account.RaceNameCanonicalKey
wantNewKey account.RaceNameCanonicalKey
}{
{
name: "success",
inputRaceName: "Nova Prime",
wantRaceName: "Nova Prime",
wantRenameCalls: 1,
wantCurrentKey: canonicalKey(common.RaceName("Pilot Nova")),
wantNewKey: canonicalKey(common.RaceName("Nova Prime")),
},
{
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: "no-op",
inputRaceName: " Pilot Nova ",
wantRaceName: "Pilot Nova",
wantRenameCalls: 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")),
},
}
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
service, err := NewProfileUpdater(
accountStore,
&fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
},
},
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: now},
stubRaceNamePolicy{},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), UpdateMyProfileInput{
UserID: "user-123",
RaceName: tt.inputRaceName,
})
if tt.wantCode != "" {
require.Error(t, err)
require.Equal(t, tt.wantCode, shared.CodeOf(err))
} else {
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)
}
storedAccount, err := accountStore.GetByUserID(context.Background(), common.UserID("user-123"))
require.NoError(t, err)
require.Equal(t, tt.wantRaceName, storedAccount.RaceName.String())
if tt.wantCode == "" {
require.Equal(t, tt.wantRaceName, result.Account.RaceName)
}
})
}
}
func TestSettingsUpdaterExecuteBlockedBySanction(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
accountStore := newFakeAccountStore(validUserAccount())
service, err := NewSettingsUpdater(
accountStore,
&fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
},
},
fakeSanctionStore{
byUserID: map[common.UserID][]policy.SanctionRecord{
common.UserID("user-123"): {
validActiveSanction(common.UserID("user-123"), policy.SanctionCodeProfileUpdateBlock, now.Add(-time.Minute)),
},
},
},
fakeLimitStore{},
fixedClock{now: now},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), UpdateMySettingsInput{
UserID: "user-123",
PreferredLanguage: "en-US",
TimeZone: "UTC",
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeConflict, shared.CodeOf(err))
require.Equal(t, 0, accountStore.updateCalls)
}
func TestSettingsUpdaterExecuteCanonicalizedNoOpAndInvalidInputs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
accountRecord account.UserAccount
inputLanguage string
inputTimeZone string
wantCode string
wantLanguage string
wantTimeZone string
wantUpdateCalls int
}{
{
name: "canonicalized success",
accountRecord: validUserAccount(),
inputLanguage: " en-us ",
inputTimeZone: " UTC ",
wantLanguage: "en-US",
wantTimeZone: "UTC",
wantUpdateCalls: 1,
},
{
name: "no-op",
accountRecord: account.UserAccount{
UserID: common.UserID("user-123"),
Email: common.Email("pilot@example.com"),
RaceName: common.RaceName("Pilot Nova"),
PreferredLanguage: common.LanguageTag("en-US"),
TimeZone: common.TimeZoneName("UTC"),
DeclaredCountry: common.CountryCode("DE"),
CreatedAt: time.Unix(1_775_240_000, 0).UTC(),
UpdatedAt: time.Unix(1_775_240_000, 0).UTC(),
},
inputLanguage: "en-us",
inputTimeZone: " UTC ",
wantLanguage: "en-US",
wantTimeZone: "UTC",
wantUpdateCalls: 0,
},
{
name: "invalid preferred language",
accountRecord: validUserAccount(),
inputLanguage: "bad@@tag",
inputTimeZone: "UTC",
wantCode: shared.ErrorCodeInvalidRequest,
wantLanguage: "en",
wantTimeZone: "Europe/Kaliningrad",
},
{
name: "invalid time zone",
accountRecord: validUserAccount(),
inputLanguage: "en",
inputTimeZone: "Mars/Olympus",
wantCode: shared.ErrorCodeInvalidRequest,
wantLanguage: "en",
wantTimeZone: "Europe/Kaliningrad",
},
}
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(tt.accountRecord)
service, err := NewSettingsUpdater(
accountStore,
&fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
},
},
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: now},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), UpdateMySettingsInput{
UserID: "user-123",
PreferredLanguage: tt.inputLanguage,
TimeZone: tt.inputTimeZone,
})
if tt.wantCode != "" {
require.Error(t, err)
require.Equal(t, tt.wantCode, shared.CodeOf(err))
} else {
require.NoError(t, err)
}
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.wantLanguage, storedAccount.PreferredLanguage.String())
require.Equal(t, tt.wantTimeZone, storedAccount.TimeZone.String())
if tt.wantCode == "" {
require.Equal(t, tt.wantLanguage, result.Account.PreferredLanguage)
require.Equal(t, tt.wantTimeZone, result.Account.TimeZone)
}
})
}
}
type fakeAccountStore struct {
records map[common.UserID]account.UserAccount
renameErr error
updateErr error
renameCalls int
updateCalls int
lastRenameInput ports.RenameRaceNameInput
}
func newFakeAccountStore(records ...account.UserAccount) *fakeAccountStore {
byUserID := make(map[common.UserID]account.UserAccount, len(records))
for _, record := range records {
byUserID[record.UserID] = record
}
return &fakeAccountStore{records: byUserID}
}
func (store *fakeAccountStore) Create(_ context.Context, input ports.CreateAccountInput) error {
if input.Account.Validate() != nil || input.Reservation.Validate() != nil {
return ports.ErrConflict
}
return nil
}
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, email common.Email) (account.UserAccount, error) {
for _, record := range store.records {
if record.Email == email {
return record, nil
}
}
return account.UserAccount{}, ports.ErrNotFound
}
func (store *fakeAccountStore) GetByRaceName(_ context.Context, raceName common.RaceName) (account.UserAccount, error) {
for _, record := range store.records {
if record.RaceName == raceName {
return record, nil
}
}
return account.UserAccount{}, ports.ErrNotFound
}
func (store *fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
_, ok := store.records[userID]
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 {
return store.updateErr
}
if _, ok := store.records[record.UserID]; !ok {
return ports.ErrNotFound
}
store.records[record.UserID] = record
return nil
}
type fakeEntitlementSnapshotStore struct {
byUserID map[common.UserID]entitlement.CurrentSnapshot
}
func (store *fakeEntitlementSnapshotStore) GetByUserID(_ context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) {
record, ok := store.byUserID[userID]
if !ok {
return entitlement.CurrentSnapshot{}, ports.ErrNotFound
}
return record, nil
}
func (store *fakeEntitlementSnapshotStore) Put(_ context.Context, record entitlement.CurrentSnapshot) error {
if store.byUserID != nil {
store.byUserID[record.UserID] = record
}
return nil
}
type fakeEntitlementLifecycleStore struct {
snapshotStore *fakeEntitlementSnapshotStore
}
func (store *fakeEntitlementLifecycleStore) Grant(context.Context, ports.GrantEntitlementInput) error {
return nil
}
func (store *fakeEntitlementLifecycleStore) Extend(context.Context, ports.ExtendEntitlementInput) error {
return nil
}
func (store *fakeEntitlementLifecycleStore) Revoke(context.Context, ports.RevokeEntitlementInput) error {
return nil
}
func (store *fakeEntitlementLifecycleStore) RepairExpired(ctx context.Context, input ports.RepairExpiredEntitlementInput) error {
if store.snapshotStore != nil {
return store.snapshotStore.Put(ctx, input.NewSnapshot)
}
return nil
}
type readerIDGenerator struct {
recordID entitlement.EntitlementRecordID
sanctionRecordID policy.SanctionRecordID
limitRecordID policy.LimitRecordID
}
func (generator readerIDGenerator) NewUserID() (common.UserID, error) {
return "", nil
}
func (generator readerIDGenerator) NewInitialRaceName() (common.RaceName, error) {
return "", nil
}
func (generator readerIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
return generator.recordID, nil
}
func (generator readerIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
return generator.sanctionRecordID, nil
}
func (generator readerIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
return generator.limitRecordID, nil
}
type fakeSanctionStore struct {
byUserID map[common.UserID][]policy.SanctionRecord
err error
}
func (store fakeSanctionStore) Create(context.Context, policy.SanctionRecord) error {
return nil
}
func (store fakeSanctionStore) GetByRecordID(context.Context, policy.SanctionRecordID) (policy.SanctionRecord, error) {
return policy.SanctionRecord{}, ports.ErrNotFound
}
func (store fakeSanctionStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.SanctionRecord, error) {
if store.err != nil {
return nil, store.err
}
records := store.byUserID[userID]
cloned := make([]policy.SanctionRecord, len(records))
copy(cloned, records)
return cloned, nil
}
func (store fakeSanctionStore) Update(context.Context, policy.SanctionRecord) error {
return nil
}
type fakeLimitStore struct {
byUserID map[common.UserID][]policy.LimitRecord
err error
}
func (store fakeLimitStore) Create(context.Context, policy.LimitRecord) error {
return nil
}
func (store fakeLimitStore) GetByRecordID(context.Context, policy.LimitRecordID) (policy.LimitRecord, error) {
return policy.LimitRecord{}, ports.ErrNotFound
}
func (store fakeLimitStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.LimitRecord, error) {
if store.err != nil {
return nil, store.err
}
records := store.byUserID[userID]
cloned := make([]policy.LimitRecord, len(records))
copy(cloned, records)
return cloned, nil
}
func (store fakeLimitStore) Update(context.Context, policy.LimitRecord) error {
return nil
}
type fixedClock struct {
now time.Time
}
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"),
PreferredLanguage: common.LanguageTag("en"),
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
DeclaredCountry: common.CountryCode("DE"),
CreatedAt: createdAt,
UpdatedAt: createdAt,
}
}
func validEntitlementSnapshot(userID common.UserID, now time.Time) entitlement.CurrentSnapshot {
return entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: now.Add(-time.Hour),
Source: common.Source("auth_registration"),
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
ReasonCode: common.ReasonCode("initial_free_entitlement"),
UpdatedAt: now,
}
}
func validActiveSanction(userID common.UserID, code policy.SanctionCode, appliedAt time.Time) policy.SanctionRecord {
return policy.SanctionRecord{
RecordID: policy.SanctionRecordID("sanction-" + string(code)),
UserID: userID,
SanctionCode: code,
Scope: common.Scope("self_service"),
ReasonCode: common.ReasonCode("policy_enforced"),
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
AppliedAt: appliedAt.UTC(),
}
}
func expiredSanction(userID common.UserID, code policy.SanctionCode, appliedAt time.Time) policy.SanctionRecord {
expiresAt := appliedAt.Add(30 * time.Minute)
record := validActiveSanction(userID, code, appliedAt)
record.RecordID = policy.SanctionRecordID(record.RecordID.String() + "-expired")
record.ExpiresAt = &expiresAt
return record
}
func validActiveLimit(userID common.UserID, code policy.LimitCode, value int, appliedAt time.Time) policy.LimitRecord {
return policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-" + string(code)),
UserID: userID,
LimitCode: code,
Value: value,
ReasonCode: common.ReasonCode("policy_enforced"),
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
AppliedAt: appliedAt.UTC(),
}
}
func removedLimit(userID common.UserID, code policy.LimitCode, value int, appliedAt time.Time) policy.LimitRecord {
removedAt := appliedAt.Add(30 * time.Minute)
record := validActiveLimit(userID, code, value, appliedAt)
record.RecordID = policy.LimitRecordID(record.RecordID.String() + "-removed")
record.RemovedAt = &removedAt
record.RemovedBy = common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")}
record.RemovedReasonCode = common.ReasonCode("policy_reset")
return record
}
func timePointer(value time.Time) *time.Time {
utcValue := value.UTC()
return &utcValue
}
var (
_ ports.UserAccountStore = (*fakeAccountStore)(nil)
_ ports.EntitlementSnapshotStore = (*fakeEntitlementSnapshotStore)(nil)
_ ports.EntitlementLifecycleStore = (*fakeEntitlementLifecycleStore)(nil)
_ ports.SanctionStore = fakeSanctionStore{}
_ ports.LimitStore = fakeLimitStore{}
_ ports.RaceNamePolicy = stubRaceNamePolicy{}
_ ports.IDGenerator = readerIDGenerator{}
)