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,336 @@
// Package accountview materializes the shared account aggregate view used by
// self-service and trusted administrative reads.
package accountview
import (
"context"
"errors"
"fmt"
"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/shared"
)
// ActorRefView stores transport-ready audit actor metadata.
type ActorRefView struct {
// Type stores the machine-readable actor type.
Type string `json:"type"`
// ID stores the optional stable actor identifier.
ID string `json:"id,omitempty"`
}
// EntitlementSnapshotView stores the transport-ready current entitlement
// snapshot of one account.
type EntitlementSnapshotView struct {
// PlanCode stores the effective entitlement plan code.
PlanCode string `json:"plan_code"`
// IsPaid reports whether the effective plan is paid.
IsPaid bool `json:"is_paid"`
// Source stores the machine-readable mutation source.
Source string `json:"source"`
// Actor stores the audit actor metadata attached to the snapshot.
Actor ActorRefView `json:"actor"`
// ReasonCode stores the machine-readable reason attached to the snapshot.
ReasonCode string `json:"reason_code"`
// StartsAt stores when the effective state started.
StartsAt time.Time `json:"starts_at"`
// EndsAt stores the optional finite effective expiry.
EndsAt *time.Time `json:"ends_at,omitempty"`
// UpdatedAt stores when the snapshot was last recomputed.
UpdatedAt time.Time `json:"updated_at"`
}
// ActiveSanctionView stores one transport-ready active sanction.
type ActiveSanctionView struct {
// SanctionCode stores the active sanction code.
SanctionCode string `json:"sanction_code"`
// Scope stores the machine-readable sanction scope.
Scope string `json:"scope"`
// ReasonCode stores the machine-readable sanction reason.
ReasonCode string `json:"reason_code"`
// Actor stores the audit actor metadata attached to the sanction.
Actor ActorRefView `json:"actor"`
// AppliedAt stores when the sanction became active.
AppliedAt time.Time `json:"applied_at"`
// ExpiresAt stores the optional planned sanction expiry.
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// ActiveLimitView stores one transport-ready active user-specific limit.
type ActiveLimitView struct {
// LimitCode stores the active limit code.
LimitCode string `json:"limit_code"`
// Value stores the current override value.
Value int `json:"value"`
// ReasonCode stores the machine-readable limit reason.
ReasonCode string `json:"reason_code"`
// Actor stores the audit actor metadata attached to the limit.
Actor ActorRefView `json:"actor"`
// AppliedAt stores when the limit became active.
AppliedAt time.Time `json:"applied_at"`
// ExpiresAt stores the optional planned limit expiry.
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// AccountView stores the transport-ready account aggregate shared by
// self-service and admin reads.
type AccountView struct {
// UserID stores the durable regular-user identifier.
UserID string `json:"user_id"`
// 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"`
// PreferredLanguage stores the current BCP 47 preferred language.
PreferredLanguage string `json:"preferred_language"`
// TimeZone stores the current IANA time-zone name.
TimeZone string `json:"time_zone"`
// DeclaredCountry stores the optional latest effective declared country.
DeclaredCountry string `json:"declared_country,omitempty"`
// Entitlement stores the current entitlement snapshot.
Entitlement EntitlementSnapshotView `json:"entitlement"`
// ActiveSanctions stores the current active sanctions sorted by code.
ActiveSanctions []ActiveSanctionView `json:"active_sanctions"`
// ActiveLimits stores the current active user-specific limits sorted by
// code.
ActiveLimits []ActiveLimitView `json:"active_limits"`
// CreatedAt stores when the account was created.
CreatedAt time.Time `json:"created_at"`
// UpdatedAt stores when the account was last mutated.
UpdatedAt time.Time `json:"updated_at"`
}
// Aggregate stores the raw domain state that backs one shared account view.
type Aggregate struct {
// AccountRecord stores the current editable account record.
AccountRecord account.UserAccount
// EntitlementSnapshot stores the current effective entitlement snapshot.
EntitlementSnapshot entitlement.CurrentSnapshot
// ActiveSanctions stores the active sanctions sorted by code.
ActiveSanctions []policy.SanctionRecord
// ActiveLimits stores the active user-specific limits sorted by code.
ActiveLimits []policy.LimitRecord
}
// HasActiveSanction reports whether aggregate currently contains code in its
// active sanction set.
func (aggregate Aggregate) HasActiveSanction(code policy.SanctionCode) bool {
for _, record := range aggregate.ActiveSanctions {
if record.SanctionCode == code {
return true
}
}
return false
}
// HasActiveLimit reports whether aggregate currently contains code in its
// active user-specific limit set.
func (aggregate Aggregate) HasActiveLimit(code policy.LimitCode) bool {
for _, record := range aggregate.ActiveLimits {
if record.LimitCode == code {
return true
}
}
return false
}
// View materializes Aggregate into the shared transport-ready account view.
func (aggregate Aggregate) View() AccountView {
view := AccountView{
UserID: aggregate.AccountRecord.UserID.String(),
Email: aggregate.AccountRecord.Email.String(),
RaceName: aggregate.AccountRecord.RaceName.String(),
PreferredLanguage: aggregate.AccountRecord.PreferredLanguage.String(),
TimeZone: aggregate.AccountRecord.TimeZone.String(),
Entitlement: EntitlementSnapshotView{
PlanCode: string(aggregate.EntitlementSnapshot.PlanCode),
IsPaid: aggregate.EntitlementSnapshot.IsPaid,
Source: aggregate.EntitlementSnapshot.Source.String(),
Actor: actorRefView(aggregate.EntitlementSnapshot.Actor),
ReasonCode: aggregate.EntitlementSnapshot.ReasonCode.String(),
StartsAt: aggregate.EntitlementSnapshot.StartsAt.UTC(),
EndsAt: cloneOptionalTime(aggregate.EntitlementSnapshot.EndsAt),
UpdatedAt: aggregate.EntitlementSnapshot.UpdatedAt.UTC(),
},
ActiveSanctions: make([]ActiveSanctionView, 0, len(aggregate.ActiveSanctions)),
ActiveLimits: make([]ActiveLimitView, 0, len(aggregate.ActiveLimits)),
CreatedAt: aggregate.AccountRecord.CreatedAt.UTC(),
UpdatedAt: aggregate.AccountRecord.UpdatedAt.UTC(),
}
if !aggregate.AccountRecord.DeclaredCountry.IsZero() {
view.DeclaredCountry = aggregate.AccountRecord.DeclaredCountry.String()
}
for _, sanctionRecord := range aggregate.ActiveSanctions {
view.ActiveSanctions = append(view.ActiveSanctions, ActiveSanctionView{
SanctionCode: string(sanctionRecord.SanctionCode),
Scope: sanctionRecord.Scope.String(),
ReasonCode: sanctionRecord.ReasonCode.String(),
Actor: actorRefView(sanctionRecord.Actor),
AppliedAt: sanctionRecord.AppliedAt.UTC(),
ExpiresAt: cloneOptionalTime(sanctionRecord.ExpiresAt),
})
}
for _, limitRecord := range aggregate.ActiveLimits {
view.ActiveLimits = append(view.ActiveLimits, ActiveLimitView{
LimitCode: string(limitRecord.LimitCode),
Value: limitRecord.Value,
ReasonCode: limitRecord.ReasonCode.String(),
Actor: actorRefView(limitRecord.Actor),
AppliedAt: limitRecord.AppliedAt.UTC(),
ExpiresAt: cloneOptionalTime(limitRecord.ExpiresAt),
})
}
return view
}
type entitlementReader interface {
GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error)
}
// Loader materializes the shared current account aggregate for one user id.
type Loader struct {
accounts ports.UserAccountStore
entitlements entitlementReader
sanctions ports.SanctionStore
limits ports.LimitStore
clock ports.Clock
}
// NewLoader constructs one shared account-aggregate loader.
func NewLoader(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
) (*Loader, error) {
switch {
case accounts == nil:
return nil, fmt.Errorf("account view loader: user account store must not be nil")
case entitlements == nil:
return nil, fmt.Errorf("account view loader: entitlement reader must not be nil")
case sanctions == nil:
return nil, fmt.Errorf("account view loader: sanction store must not be nil")
case limits == nil:
return nil, fmt.Errorf("account view loader: limit store must not be nil")
case clock == nil:
return nil, fmt.Errorf("account view loader: clock must not be nil")
default:
return &Loader{
accounts: accounts,
entitlements: entitlements,
sanctions: sanctions,
limits: limits,
clock: clock,
}, nil
}
}
// Load materializes the shared account aggregate identified by userID.
func (loader *Loader) Load(ctx context.Context, userID common.UserID) (Aggregate, error) {
if loader == nil {
return Aggregate{}, shared.InternalError(fmt.Errorf("account view loader must not be nil"))
}
accountRecord, err := loader.accounts.GetByUserID(ctx, userID)
switch {
case err == nil:
case errors.Is(err, ports.ErrNotFound):
return Aggregate{}, shared.SubjectNotFound()
default:
return Aggregate{}, shared.ServiceUnavailable(err)
}
entitlementSnapshot, err := loader.entitlements.GetByUserID(ctx, userID)
switch {
case err == nil:
case errors.Is(err, ports.ErrNotFound):
return Aggregate{}, shared.InternalError(fmt.Errorf("user %q is missing entitlement snapshot", userID))
default:
return Aggregate{}, shared.ServiceUnavailable(err)
}
sanctionRecords, err := loader.sanctions.ListByUserID(ctx, userID)
if err != nil {
return Aggregate{}, shared.ServiceUnavailable(err)
}
limitRecords, err := loader.limits.ListByUserID(ctx, userID)
if err != nil {
return Aggregate{}, shared.ServiceUnavailable(err)
}
now := loader.clock.Now().UTC()
activeSanctions, err := policy.ActiveSanctionsAt(sanctionRecords, now)
if err != nil {
return Aggregate{}, shared.InternalError(fmt.Errorf("evaluate active sanctions for user %q: %w", userID, err))
}
activeLimits, err := policy.ActiveLimitsAt(limitRecords, now)
if err != nil {
return Aggregate{}, shared.InternalError(fmt.Errorf("evaluate active limits for user %q: %w", userID, err))
}
return Aggregate{
AccountRecord: accountRecord,
EntitlementSnapshot: entitlementSnapshot,
ActiveSanctions: activeSanctions,
ActiveLimits: activeLimits,
}, nil
}
func actorRefView(ref common.ActorRef) ActorRefView {
return ActorRefView{
Type: ref.Type.String(),
ID: ref.ID.String(),
}
}
func cloneOptionalTime(value *time.Time) *time.Time {
if value == nil {
return nil
}
cloned := value.UTC()
return &cloned
}
+504
View File
@@ -0,0 +1,504 @@
// Package adminusers implements the trusted administrative user-read surface
// owned by User Service.
package adminusers
import (
"context"
"errors"
"fmt"
"strings"
"time"
"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"
)
// LookupResult stores one exact trusted admin user lookup result.
type LookupResult struct {
// User stores the shared account aggregate of the resolved user.
User accountview.AccountView `json:"user"`
}
// GetUserByIDInput stores one exact trusted lookup by stable user identifier.
type GetUserByIDInput struct {
// UserID stores the stable regular-user identifier to resolve.
UserID string
}
// GetUserByEmailInput stores one exact trusted lookup by normalized e-mail.
type GetUserByEmailInput struct {
// Email stores the normalized login/contact e-mail to resolve.
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
}
// ListUsersInput stores one trusted administrative user-list request.
type ListUsersInput struct {
// PageSize stores the requested maximum number of returned users. The zero
// value selects the frozen default page size.
PageSize int
// PageToken stores the optional opaque continuation cursor.
PageToken string
// PaidState stores the optional coarse free-versus-paid filter.
PaidState string
// PaidExpiresBefore stores the optional strict finite paid-expiry upper
// bound.
PaidExpiresBefore *time.Time
// PaidExpiresAfter stores the optional strict finite paid-expiry lower
// bound.
PaidExpiresAfter *time.Time
// DeclaredCountry stores the optional current declared-country filter.
DeclaredCountry string
// SanctionCode stores the optional active-sanction filter.
SanctionCode string
// LimitCode stores the optional active user-specific limit filter.
LimitCode string
// CanLogin stores the optional derived login-eligibility filter.
CanLogin *bool
// CanCreatePrivateGame stores the optional derived private-game-create
// eligibility filter.
CanCreatePrivateGame *bool
// CanJoinGame stores the optional derived game-join eligibility filter.
CanJoinGame *bool
}
// ListUsersResult stores one trusted administrative page of user aggregates.
type ListUsersResult struct {
// Items stores the returned user aggregates in deterministic order.
Items []accountview.AccountView `json:"items"`
// NextPageToken stores the optional continuation cursor for the next page.
NextPageToken string `json:"next_page_token,omitempty"`
}
type entitlementReader interface {
GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error)
}
type readSupport struct {
accounts ports.UserAccountStore
loader *accountview.Loader
}
func newReadSupport(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
) (readSupport, error) {
loader, err := accountview.NewLoader(accounts, entitlements, sanctions, limits, clock)
if err != nil {
return readSupport{}, fmt.Errorf("account view loader: %w", err)
}
return readSupport{
accounts: accounts,
loader: loader,
}, nil
}
// ByIDGetter executes exact trusted lookups by stable user identifier.
type ByIDGetter struct {
support readSupport
}
// NewByIDGetter constructs one exact admin lookup by user id.
func NewByIDGetter(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
) (*ByIDGetter, error) {
support, err := newReadSupport(accounts, entitlements, sanctions, limits, clock)
if err != nil {
return nil, fmt.Errorf("admin users by-id getter: %w", err)
}
return &ByIDGetter{support: support}, nil
}
// Execute resolves one exact user by stable user identifier.
func (service *ByIDGetter) Execute(ctx context.Context, input GetUserByIDInput) (LookupResult, error) {
if ctx == nil {
return LookupResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return LookupResult{}, err
}
aggregate, err := service.support.loader.Load(ctx, userID)
if err != nil {
return LookupResult{}, err
}
return LookupResult{User: aggregate.View()}, nil
}
// ByEmailGetter executes exact trusted lookups by normalized e-mail.
type ByEmailGetter struct {
support readSupport
}
// NewByEmailGetter constructs one exact admin lookup by normalized e-mail.
func NewByEmailGetter(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
) (*ByEmailGetter, error) {
support, err := newReadSupport(accounts, entitlements, sanctions, limits, clock)
if err != nil {
return nil, fmt.Errorf("admin users by-email getter: %w", err)
}
return &ByEmailGetter{support: support}, nil
}
// Execute resolves one exact user by normalized e-mail.
func (service *ByEmailGetter) Execute(ctx context.Context, input GetUserByEmailInput) (LookupResult, error) {
if ctx == nil {
return LookupResult{}, shared.InvalidRequest("context must not be nil")
}
email, err := shared.ParseEmail(input.Email)
if err != nil {
return LookupResult{}, err
}
record, err := service.support.accounts.GetByEmail(ctx, email)
switch {
case err == nil:
case errors.Is(err, ports.ErrNotFound):
return LookupResult{}, shared.SubjectNotFound()
default:
return LookupResult{}, shared.ServiceUnavailable(err)
}
aggregate, err := service.support.loader.Load(ctx, record.UserID)
if err != nil {
return LookupResult{}, err
}
return LookupResult{User: aggregate.View()}, nil
}
// ByRaceNameGetter executes exact trusted lookups by exact stored race name.
type ByRaceNameGetter struct {
support readSupport
}
// NewByRaceNameGetter constructs one exact admin lookup by exact stored race
// name.
func NewByRaceNameGetter(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
) (*ByRaceNameGetter, 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 &ByRaceNameGetter{support: support}, nil
}
// Execute resolves one exact user by exact stored race name.
func (service *ByRaceNameGetter) Execute(ctx context.Context, input GetUserByRaceNameInput) (LookupResult, error) {
if ctx == nil {
return LookupResult{}, shared.InvalidRequest("context must not be nil")
}
raceName, err := shared.ParseRaceName(input.RaceName)
if err != nil {
return LookupResult{}, err
}
record, err := service.support.accounts.GetByRaceName(ctx, raceName)
switch {
case err == nil:
case errors.Is(err, ports.ErrNotFound):
return LookupResult{}, shared.SubjectNotFound()
default:
return LookupResult{}, shared.ServiceUnavailable(err)
}
aggregate, err := service.support.loader.Load(ctx, record.UserID)
if err != nil {
return LookupResult{}, err
}
return LookupResult{User: aggregate.View()}, nil
}
// Lister executes the trusted administrative filtered user listing.
type Lister struct {
support readSupport
listStore ports.UserListStore
}
// NewLister constructs one trusted administrative filtered user lister.
func NewLister(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
listStore ports.UserListStore,
) (*Lister, error) {
if listStore == nil {
return nil, fmt.Errorf("admin users lister: user list store must not be nil")
}
support, err := newReadSupport(accounts, entitlements, sanctions, limits, clock)
if err != nil {
return nil, fmt.Errorf("admin users lister: %w", err)
}
return &Lister{
support: support,
listStore: listStore,
}, nil
}
// Execute lists users in deterministic newest-first order and combines all
// supplied filters with logical AND semantics.
func (service *Lister) Execute(ctx context.Context, input ListUsersInput) (ListUsersResult, error) {
if ctx == nil {
return ListUsersResult{}, shared.InvalidRequest("context must not be nil")
}
if strings.TrimSpace(input.PageToken) != input.PageToken {
return ListUsersResult{}, shared.InvalidRequest("page_token must not contain surrounding whitespace")
}
pageSize, err := normalizePageSize(input.PageSize)
if err != nil {
return ListUsersResult{}, err
}
filters, err := parseListFilters(input)
if err != nil {
return ListUsersResult{}, err
}
result := ListUsersResult{
Items: make([]accountview.AccountView, 0, pageSize),
}
currentToken := input.PageToken
for len(result.Items) < pageSize {
candidatePage, err := service.listStore.ListUserIDs(ctx, ports.ListUsersInput{
PageSize: 1,
PageToken: currentToken,
Filters: filters,
})
switch {
case err == nil:
case errors.Is(err, ports.ErrInvalidPageToken):
return ListUsersResult{}, shared.InvalidRequest("page_token is invalid or does not match current filters")
default:
return ListUsersResult{}, shared.ServiceUnavailable(err)
}
if len(candidatePage.UserIDs) == 0 {
result.NextPageToken = ""
return result, nil
}
nextToken := candidatePage.NextPageToken
candidateID := candidatePage.UserIDs[0]
aggregate, err := service.support.loader.Load(ctx, candidateID)
if err != nil {
return ListUsersResult{}, err
}
if matchesFilters(aggregate, filters) {
result.Items = append(result.Items, aggregate.View())
result.NextPageToken = nextToken
}
if nextToken == "" {
result.NextPageToken = ""
return result, nil
}
currentToken = nextToken
}
return result, nil
}
func normalizePageSize(value int) (int, error) {
switch {
case value == 0:
return ports.DefaultUserListPageSize, nil
case value < 0:
return 0, shared.InvalidRequest("page_size must be between 1 and 200")
case value > ports.MaxUserListPageSize:
return 0, shared.InvalidRequest("page_size must be between 1 and 200")
default:
return value, nil
}
}
func parseListFilters(input ListUsersInput) (ports.UserListFilters, error) {
paidState, err := parsePaidState(input.PaidState)
if err != nil {
return ports.UserListFilters{}, err
}
declaredCountry, err := parseCountryCode(input.DeclaredCountry)
if err != nil {
return ports.UserListFilters{}, err
}
sanctionCode, err := parseSanctionCode(input.SanctionCode)
if err != nil {
return ports.UserListFilters{}, err
}
limitCode, err := parseLimitCode(input.LimitCode)
if err != nil {
return ports.UserListFilters{}, err
}
filters := ports.UserListFilters{
PaidState: paidState,
PaidExpiresBefore: input.PaidExpiresBefore,
PaidExpiresAfter: input.PaidExpiresAfter,
DeclaredCountry: declaredCountry,
SanctionCode: sanctionCode,
LimitCode: limitCode,
CanLogin: input.CanLogin,
CanCreatePrivateGame: input.CanCreatePrivateGame,
CanJoinGame: input.CanJoinGame,
}
if err := filters.Validate(); err != nil {
return ports.UserListFilters{}, shared.InvalidRequest(err.Error())
}
return filters, nil
}
func parsePaidState(value string) (entitlement.PaidState, error) {
state := entitlement.PaidState(shared.NormalizeString(value))
if !state.IsKnown() {
return "", shared.InvalidRequest(fmt.Sprintf("paid_state %q is unsupported", state))
}
return state, nil
}
func parseCountryCode(value string) (common.CountryCode, error) {
code := common.CountryCode(shared.NormalizeString(value))
if code.IsZero() {
return "", nil
}
if err := code.Validate(); err != nil {
return "", shared.InvalidRequest(fmt.Sprintf("declared_country: %s", err.Error()))
}
return code, nil
}
func parseSanctionCode(value string) (policy.SanctionCode, error) {
code := policy.SanctionCode(shared.NormalizeString(value))
if code == "" {
return "", nil
}
if !code.IsKnown() {
return "", shared.InvalidRequest(fmt.Sprintf("sanction_code %q is unsupported", code))
}
return code, nil
}
func parseLimitCode(value string) (policy.LimitCode, error) {
code := policy.LimitCode(shared.NormalizeString(value))
if code == "" {
return "", nil
}
if !code.IsKnown() {
return "", shared.InvalidRequest(fmt.Sprintf("limit_code %q is unsupported", code))
}
return code, nil
}
func matchesFilters(aggregate accountview.Aggregate, filters ports.UserListFilters) bool {
switch filters.PaidState {
case entitlement.PaidStateFree:
if aggregate.EntitlementSnapshot.IsPaid {
return false
}
case entitlement.PaidStatePaid:
if !aggregate.EntitlementSnapshot.IsPaid {
return false
}
}
if filters.PaidExpiresBefore != nil {
if !aggregate.EntitlementSnapshot.HasFiniteExpiry() || !aggregate.EntitlementSnapshot.EndsAt.Before(filters.PaidExpiresBefore.UTC()) {
return false
}
}
if filters.PaidExpiresAfter != nil {
if !aggregate.EntitlementSnapshot.HasFiniteExpiry() || !aggregate.EntitlementSnapshot.EndsAt.After(filters.PaidExpiresAfter.UTC()) {
return false
}
}
if !filters.DeclaredCountry.IsZero() && aggregate.AccountRecord.DeclaredCountry != filters.DeclaredCountry {
return false
}
if filters.SanctionCode != "" && !aggregate.HasActiveSanction(filters.SanctionCode) {
return false
}
if filters.LimitCode != "" && !aggregate.HasActiveLimit(filters.LimitCode) {
return false
}
canLogin, canCreatePrivateGame, canJoinGame := deriveFilterEligibility(aggregate)
if filters.CanLogin != nil && canLogin != *filters.CanLogin {
return false
}
if filters.CanCreatePrivateGame != nil && canCreatePrivateGame != *filters.CanCreatePrivateGame {
return false
}
if filters.CanJoinGame != nil && canJoinGame != *filters.CanJoinGame {
return false
}
return true
}
func deriveFilterEligibility(aggregate accountview.Aggregate) (bool, bool, bool) {
canLogin := !aggregate.HasActiveSanction(policy.SanctionCodeLoginBlock)
canCreatePrivateGame := canLogin &&
aggregate.EntitlementSnapshot.IsPaid &&
!aggregate.HasActiveSanction(policy.SanctionCodePrivateGameCreateBlock)
canJoinGame := canLogin &&
!aggregate.HasActiveSanction(policy.SanctionCodeGameJoinBlock)
return canLogin, canCreatePrivateGame, canJoinGame
}
@@ -0,0 +1,623 @@
package adminusers
import (
"context"
"errors"
"fmt"
"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 TestByIDGetterExecuteReturnsAggregate(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
service, err := NewByIDGetter(
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "Pilot Nova", now)),
&fakeAdminEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validAdminFreeSnapshot(common.UserID("user-123"), now),
},
},
fakeAdminSanctionStore{
byUserID: map[common.UserID][]policy.SanctionRecord{
common.UserID("user-123"): {
validAdminActiveSanction(common.UserID("user-123"), policy.SanctionCodeLoginBlock, now.Add(-time.Hour)),
expiredAdminSanction(common.UserID("user-123"), policy.SanctionCodeGameJoinBlock, now.Add(-2*time.Hour)),
},
},
},
fakeAdminLimitStore{
byUserID: map[common.UserID][]policy.LimitRecord{
common.UserID("user-123"): {
validAdminActiveLimit(common.UserID("user-123"), policy.LimitCodeMaxOwnedPrivateGames, 3, now.Add(-time.Hour)),
},
},
},
adminFixedClock{now: now},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GetUserByIDInput{UserID: " user-123 "})
require.NoError(t, err)
require.Equal(t, "user-123", result.User.UserID)
require.Equal(t, "pilot@example.com", result.User.Email)
require.Len(t, result.User.ActiveSanctions, 1)
require.Equal(t, string(policy.SanctionCodeLoginBlock), result.User.ActiveSanctions[0].SanctionCode)
require.Len(t, result.User.ActiveLimits, 1)
require.Equal(t, string(policy.LimitCodeMaxOwnedPrivateGames), result.User.ActiveLimits[0].LimitCode)
}
func TestByEmailGetterExecuteUnknownUserReturnsNotFound(t *testing.T) {
t.Parallel()
service, err := NewByEmailGetter(
newFakeAdminAccountStore(),
&fakeAdminEntitlementSnapshotStore{},
fakeAdminSanctionStore{},
fakeAdminLimitStore{},
adminFixedClock{now: time.Unix(1_775_240_500, 0).UTC()},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), GetUserByEmailInput{Email: "missing@example.com"})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
}
func TestByRaceNameGetterExecuteReturnsAggregate(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)),
&fakeAdminEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validAdminFreeSnapshot(common.UserID("user-123"), now),
},
},
fakeAdminSanctionStore{},
fakeAdminLimitStore{},
adminFixedClock{now: now},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GetUserByRaceNameInput{RaceName: " Pilot Nova "})
require.NoError(t, err)
require.Equal(t, "user-123", result.User.UserID)
require.Equal(t, "Pilot Nova", result.User.RaceName)
}
func TestListerExecuteAppliesCombinedFiltersWithLogicalAND(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
firstExpiry := now.Add(48 * time.Hour)
secondExpiry := now.Add(72 * time.Hour)
before := now.Add(96 * time.Hour)
after := now.Add(24 * time.Hour)
canLogin := false
canCreatePrivateGame := false
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),
)
snapshotStore := &fakeAdminEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-300"): validAdminPaidSnapshot(common.UserID("user-300"), now, firstExpiry),
common.UserID("user-200"): validAdminPaidSnapshot(common.UserID("user-200"), now, secondExpiry),
common.UserID("user-100"): validAdminPaidSnapshot(common.UserID("user-100"), now, secondExpiry),
},
}
sanctionStore := fakeAdminSanctionStore{
byUserID: map[common.UserID][]policy.SanctionRecord{
common.UserID("user-300"): {
validAdminActiveSanction(common.UserID("user-300"), policy.SanctionCodeLoginBlock, now.Add(-time.Hour)),
},
common.UserID("user-200"): {
validAdminActiveSanction(common.UserID("user-200"), policy.SanctionCodeLoginBlock, now.Add(-time.Hour)),
},
common.UserID("user-100"): {
validAdminActiveSanction(common.UserID("user-100"), policy.SanctionCodeLoginBlock, now.Add(-time.Hour)),
},
},
}
limitStore := fakeAdminLimitStore{
byUserID: map[common.UserID][]policy.LimitRecord{
common.UserID("user-300"): {
validAdminActiveLimit(common.UserID("user-300"), policy.LimitCodeMaxOwnedPrivateGames, 3, now.Add(-time.Hour)),
},
common.UserID("user-100"): {
validAdminActiveLimit(common.UserID("user-100"), policy.LimitCodeMaxOwnedPrivateGames, 3, now.Add(-time.Hour)),
},
},
}
listStore := &fakeAdminListStore{
pages: map[string]ports.ListUsersResult{
"": {
UserIDs: []common.UserID{common.UserID("user-300")},
NextPageToken: "cursor-1",
},
"cursor-1": {
UserIDs: []common.UserID{common.UserID("user-200")},
NextPageToken: "cursor-2",
},
"cursor-2": {
UserIDs: []common.UserID{common.UserID("user-100")},
NextPageToken: "",
},
},
}
service, err := NewLister(accountStore, snapshotStore, sanctionStore, limitStore, adminFixedClock{now: now}, listStore)
require.NoError(t, err)
result, err := service.Execute(context.Background(), ListUsersInput{
PageSize: 2,
PaidState: "paid",
PaidExpiresBefore: &before,
PaidExpiresAfter: &after,
DeclaredCountry: "DE",
SanctionCode: "login_block",
LimitCode: "max_owned_private_games",
CanLogin: &canLogin,
CanCreatePrivateGame: &canCreatePrivateGame,
CanJoinGame: &canJoinGame,
})
require.NoError(t, err)
require.Len(t, result.Items, 2)
require.Equal(t, "user-300", result.Items[0].UserID)
require.Equal(t, "user-100", result.Items[1].UserID)
require.Equal(t, "", result.NextPageToken)
require.Len(t, listStore.calls, 3)
for _, call := range listStore.calls {
require.Equal(t, 1, call.PageSize)
require.Equal(t, entitlement.PaidStatePaid, call.Filters.PaidState)
require.Equal(t, common.CountryCode("DE"), call.Filters.DeclaredCountry)
require.Equal(t, policy.SanctionCodeLoginBlock, call.Filters.SanctionCode)
require.Equal(t, policy.LimitCodeMaxOwnedPrivateGames, call.Filters.LimitCode)
}
}
func TestListerExecuteDefaultAndMaximumPageSize(t *testing.T) {
t.Parallel()
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),
)
snapshotStore := &fakeAdminEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-300"): validAdminFreeSnapshot(common.UserID("user-300"), now),
common.UserID("user-200"): validAdminFreeSnapshot(common.UserID("user-200"), now),
common.UserID("user-100"): validAdminFreeSnapshot(common.UserID("user-100"), now),
},
}
t.Run("default page size", func(t *testing.T) {
t.Parallel()
listStore := &fakeAdminListStore{
pages: map[string]ports.ListUsersResult{
"": {
UserIDs: []common.UserID{common.UserID("user-300")},
NextPageToken: "cursor-1",
},
"cursor-1": {
UserIDs: []common.UserID{common.UserID("user-200")},
NextPageToken: "cursor-2",
},
"cursor-2": {
UserIDs: []common.UserID{common.UserID("user-100")},
NextPageToken: "",
},
},
}
service, err := NewLister(accountStore, snapshotStore, fakeAdminSanctionStore{}, fakeAdminLimitStore{}, adminFixedClock{now: now}, listStore)
require.NoError(t, err)
result, err := service.Execute(context.Background(), ListUsersInput{})
require.NoError(t, err)
require.Len(t, result.Items, 3)
})
t.Run("maximum page size", func(t *testing.T) {
t.Parallel()
listStore := &fakeAdminListStore{
pages: map[string]ports.ListUsersResult{
"": {
UserIDs: []common.UserID{common.UserID("user-300")},
NextPageToken: "",
},
},
}
service, err := NewLister(accountStore, snapshotStore, fakeAdminSanctionStore{}, fakeAdminLimitStore{}, adminFixedClock{now: now}, listStore)
require.NoError(t, err)
result, err := service.Execute(context.Background(), ListUsersInput{PageSize: ports.MaxUserListPageSize})
require.NoError(t, err)
require.Len(t, result.Items, 1)
})
t.Run("above maximum is rejected", func(t *testing.T) {
t.Parallel()
service, err := NewLister(accountStore, snapshotStore, fakeAdminSanctionStore{}, fakeAdminLimitStore{}, adminFixedClock{now: now}, &fakeAdminListStore{})
require.NoError(t, err)
_, err = service.Execute(context.Background(), ListUsersInput{PageSize: ports.MaxUserListPageSize + 1})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
require.Equal(t, "page_size must be between 1 and 200", err.Error())
})
}
func TestListerExecuteInvalidPageTokenReturnsInvalidRequest(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
service, err := NewLister(
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "Pilot Nova", now)),
&fakeAdminEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validAdminFreeSnapshot(common.UserID("user-123"), now),
},
},
fakeAdminSanctionStore{},
fakeAdminLimitStore{},
adminFixedClock{now: now},
&fakeAdminListStore{err: fmt.Errorf("wrapped: %w", ports.ErrInvalidPageToken)},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), ListUsersInput{PageToken: "bad-token"})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
require.Equal(t, "page_token is invalid or does not match current filters", err.Error())
}
func TestListerExecuteRepairsExpiredPaidSnapshotBeforeFiltering(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
expiredAt := now.Add(-time.Hour)
accountStore := newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "Pilot Nova", now))
snapshotStore := &fakeAdminEntitlementSnapshotStore{
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: adminTimePointer(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,
&fakeAdminEntitlementLifecycleStore{snapshotStore: snapshotStore},
adminFixedClock{now: now},
adminReaderIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-repair-free-record")},
)
require.NoError(t, err)
listStore := &fakeAdminListStore{
pages: map[string]ports.ListUsersResult{
"": {
UserIDs: []common.UserID{common.UserID("user-123")},
NextPageToken: "",
},
},
}
service, err := NewLister(accountStore, reader, fakeAdminSanctionStore{}, fakeAdminLimitStore{}, adminFixedClock{now: now}, listStore)
require.NoError(t, err)
result, err := service.Execute(context.Background(), ListUsersInput{PaidState: "free"})
require.NoError(t, err)
require.Len(t, result.Items, 1)
require.Equal(t, "free", result.Items[0].Entitlement.PlanCode)
require.False(t, result.Items[0].Entitlement.IsPaid)
storedSnapshot, err := snapshotStore.GetByUserID(context.Background(), common.UserID("user-123"))
require.NoError(t, err)
require.Equal(t, entitlement.PlanCodeFree, storedSnapshot.PlanCode)
require.False(t, storedSnapshot.IsPaid)
require.Equal(t, expiredAt, storedSnapshot.StartsAt)
}
type adminFixedClock struct {
now time.Time
}
func (clock adminFixedClock) Now() time.Time {
return clock.now
}
type adminReaderIDGenerator struct {
recordID entitlement.EntitlementRecordID
}
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) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
return generator.recordID, nil
}
func (generator adminReaderIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
return "", errors.New("unexpected NewSanctionRecordID call")
}
func (generator adminReaderIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
return "", errors.New("unexpected NewLimitRecordID call")
}
type fakeAdminAccountStore struct {
byUserID map[common.UserID]account.UserAccount
byEmail map[common.Email]common.UserID
byRaceName map[common.RaceName]common.UserID
updateErr error
renameErr error
createErr error
existsByID map[common.UserID]bool
}
func newFakeAdminAccountStore(records ...account.UserAccount) *fakeAdminAccountStore {
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)),
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.existsByID[record.UserID] = true
}
return store
}
func (store *fakeAdminAccountStore) Create(context.Context, ports.CreateAccountInput) error {
return store.createErr
}
func (store *fakeAdminAccountStore) GetByUserID(_ context.Context, userID common.UserID) (account.UserAccount, error) {
record, ok := store.byUserID[userID]
if !ok {
return account.UserAccount{}, ports.ErrNotFound
}
return record, nil
}
func (store *fakeAdminAccountStore) GetByEmail(_ context.Context, email common.Email) (account.UserAccount, error) {
userID, ok := store.byEmail[email]
if !ok {
return account.UserAccount{}, ports.ErrNotFound
}
return store.byUserID[userID], nil
}
func (store *fakeAdminAccountStore) GetByRaceName(_ context.Context, raceName common.RaceName) (account.UserAccount, error) {
userID, ok := store.byRaceName[raceName]
if !ok {
return account.UserAccount{}, ports.ErrNotFound
}
return store.byUserID[userID], nil
}
func (store *fakeAdminAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
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
}
type fakeAdminEntitlementSnapshotStore struct {
byUserID map[common.UserID]entitlement.CurrentSnapshot
}
func (store *fakeAdminEntitlementSnapshotStore) 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 *fakeAdminEntitlementSnapshotStore) Put(_ context.Context, record entitlement.CurrentSnapshot) error {
if store.byUserID == nil {
store.byUserID = make(map[common.UserID]entitlement.CurrentSnapshot)
}
store.byUserID[record.UserID] = record
return nil
}
type fakeAdminEntitlementLifecycleStore struct {
snapshotStore *fakeAdminEntitlementSnapshotStore
}
func (store *fakeAdminEntitlementLifecycleStore) Grant(context.Context, ports.GrantEntitlementInput) error {
return errors.New("unexpected Grant call")
}
func (store *fakeAdminEntitlementLifecycleStore) Extend(context.Context, ports.ExtendEntitlementInput) error {
return errors.New("unexpected Extend call")
}
func (store *fakeAdminEntitlementLifecycleStore) Revoke(context.Context, ports.RevokeEntitlementInput) error {
return errors.New("unexpected Revoke call")
}
func (store *fakeAdminEntitlementLifecycleStore) RepairExpired(ctx context.Context, input ports.RepairExpiredEntitlementInput) error {
return store.snapshotStore.Put(ctx, input.NewSnapshot)
}
type fakeAdminSanctionStore struct {
byUserID map[common.UserID][]policy.SanctionRecord
}
func (store fakeAdminSanctionStore) Create(context.Context, policy.SanctionRecord) error {
return nil
}
func (store fakeAdminSanctionStore) GetByRecordID(context.Context, policy.SanctionRecordID) (policy.SanctionRecord, error) {
return policy.SanctionRecord{}, ports.ErrNotFound
}
func (store fakeAdminSanctionStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.SanctionRecord, error) {
return append([]policy.SanctionRecord(nil), store.byUserID[userID]...), nil
}
func (store fakeAdminSanctionStore) Update(context.Context, policy.SanctionRecord) error {
return nil
}
type fakeAdminLimitStore struct {
byUserID map[common.UserID][]policy.LimitRecord
}
func (store fakeAdminLimitStore) Create(context.Context, policy.LimitRecord) error {
return nil
}
func (store fakeAdminLimitStore) GetByRecordID(context.Context, policy.LimitRecordID) (policy.LimitRecord, error) {
return policy.LimitRecord{}, ports.ErrNotFound
}
func (store fakeAdminLimitStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.LimitRecord, error) {
return append([]policy.LimitRecord(nil), store.byUserID[userID]...), nil
}
func (store fakeAdminLimitStore) Update(context.Context, policy.LimitRecord) error {
return nil
}
type fakeAdminListStore struct {
pages map[string]ports.ListUsersResult
err error
calls []ports.ListUsersInput
}
func (store *fakeAdminListStore) ListUserIDs(_ context.Context, input ports.ListUsersInput) (ports.ListUsersResult, error) {
store.calls = append(store.calls, input)
if store.err != nil {
return ports.ListUsersResult{}, store.err
}
result, ok := store.pages[input.PageToken]
if !ok {
return ports.ListUsersResult{}, nil
}
return result, nil
}
func validAdminUserAccount(userID string, email string, raceName string, now time.Time) account.UserAccount {
return account.UserAccount{
UserID: common.UserID(userID),
Email: common.Email(email),
RaceName: common.RaceName(raceName),
PreferredLanguage: common.LanguageTag("en"),
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
DeclaredCountry: common.CountryCode("DE"),
CreatedAt: now,
UpdatedAt: now,
}
}
func validAdminFreeSnapshot(userID common.UserID, now time.Time) entitlement.CurrentSnapshot {
return entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: now,
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 validAdminPaidSnapshot(userID common.UserID, now time.Time, endsAt time.Time) entitlement.CurrentSnapshot {
return entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodePaidMonthly,
IsPaid: true,
StartsAt: now.Add(-24 * time.Hour),
EndsAt: adminTimePointer(endsAt),
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_grant"),
UpdatedAt: now,
}
}
func validAdminActiveSanction(userID common.UserID, code policy.SanctionCode, appliedAt time.Time) policy.SanctionRecord {
return policy.SanctionRecord{
RecordID: policy.SanctionRecordID("sanction-" + string(code) + "-" + userID.String()),
UserID: userID,
SanctionCode: code,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("manual_block"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: appliedAt,
}
}
func expiredAdminSanction(userID common.UserID, code policy.SanctionCode, appliedAt time.Time) policy.SanctionRecord {
record := validAdminActiveSanction(userID, code, appliedAt)
record.ExpiresAt = adminTimePointer(appliedAt.Add(30 * time.Minute))
return record
}
func validAdminActiveLimit(userID common.UserID, code policy.LimitCode, value int, appliedAt time.Time) policy.LimitRecord {
return policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-" + string(code) + "-" + userID.String()),
UserID: userID,
LimitCode: code,
Value: value,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: appliedAt,
}
}
func adminTimePointer(value time.Time) *time.Time {
copied := value.UTC()
return &copied
}
@@ -0,0 +1,614 @@
// Package authdirectory implements the auth-facing user-resolution, ensure,
// existence, and block use cases owned by the user service.
package authdirectory
import (
"context"
"errors"
"fmt"
"log/slog"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/shared"
"galaxy/user/internal/telemetry"
)
const (
initialEntitlementSource common.Source = "auth_registration"
initialEntitlementReasonCode common.ReasonCode = "initial_free_entitlement"
initialEntitlementActorType common.ActorType = "service"
initialEntitlementActorID common.ActorID = "user-service"
ensureCreateRetryLimit = 8
)
// ResolveByEmailInput stores one auth-facing resolve-by-email request.
type ResolveByEmailInput struct {
// Email stores the caller-supplied e-mail subject.
Email string
}
// ResolveByEmailResult stores one auth-facing resolve-by-email response.
type ResolveByEmailResult struct {
// Kind stores the coarse user-resolution outcome.
Kind string
// UserID is present only when Kind is `existing`.
UserID string
// BlockReasonCode is present only when Kind is `blocked`.
BlockReasonCode string
}
// Resolver executes the auth-facing resolve-by-email use case.
type Resolver struct {
store ports.AuthDirectoryStore
logger *slog.Logger
telemetry *telemetry.Runtime
}
// NewResolver returns one resolve-by-email use case instance.
func NewResolver(store ports.AuthDirectoryStore) (*Resolver, error) {
return NewResolverWithObservability(store, nil, nil)
}
// NewResolverWithObservability returns one resolve-by-email use case instance
// with optional structured logging and metrics hooks.
func NewResolverWithObservability(
store ports.AuthDirectoryStore,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
) (*Resolver, error) {
if store == nil {
return nil, fmt.Errorf("authdirectory resolver: auth directory store must not be nil")
}
return &Resolver{
store: store,
logger: logger,
telemetry: telemetryRuntime,
}, nil
}
// Execute resolves one e-mail subject without creating any account.
func (service *Resolver) Execute(ctx context.Context, input ResolveByEmailInput) (result ResolveByEmailResult, err error) {
outcome := "failed"
defer func() {
if service.telemetry != nil {
service.telemetry.RecordAuthResolutionOutcome(ctx, "resolve_by_email", outcome)
}
if err != nil {
shared.LogServiceOutcome(service.logger, ctx, "auth resolution failed", err,
"use_case", "resolve_by_email",
"outcome", outcome,
)
}
}()
if ctx == nil {
return ResolveByEmailResult{}, shared.InvalidRequest("context must not be nil")
}
email, err := shared.ParseEmail(input.Email)
if err != nil {
return ResolveByEmailResult{}, err
}
resolution, err := service.store.ResolveByEmail(ctx, email)
if err != nil {
return ResolveByEmailResult{}, shared.ServiceUnavailable(err)
}
if err := resolution.Validate(); err != nil {
return ResolveByEmailResult{}, shared.InternalError(err)
}
result = ResolveByEmailResult{
Kind: string(resolution.Kind),
}
if !resolution.UserID.IsZero() {
result.UserID = resolution.UserID.String()
}
if !resolution.BlockReasonCode.IsZero() {
result.BlockReasonCode = resolution.BlockReasonCode.String()
}
outcome = result.Kind
return result, nil
}
// RegistrationContext stores the create-only auth-facing initialization
// context forwarded by authsession.
type RegistrationContext struct {
// PreferredLanguage stores the initial preferred language.
PreferredLanguage string
// TimeZone stores the initial declared time-zone name.
TimeZone string
}
// EnsureByEmailInput stores one auth-facing ensure-by-email request.
type EnsureByEmailInput struct {
// Email stores the caller-supplied e-mail subject.
Email string
// RegistrationContext stores the required create-only registration context.
RegistrationContext *RegistrationContext
}
// EnsureByEmailResult stores one auth-facing ensure-by-email response.
type EnsureByEmailResult struct {
// Outcome stores the coarse ensure outcome.
Outcome string
// UserID is present only for `existing` and `created`.
UserID string
// BlockReasonCode is present only for `blocked`.
BlockReasonCode string
}
// Ensurer executes the auth-facing ensure-by-email use case.
type Ensurer struct {
store ports.AuthDirectoryStore
clock ports.Clock
idGenerator ports.IDGenerator
policy ports.RaceNamePolicy
logger *slog.Logger
telemetry *telemetry.Runtime
profilePublisher ports.ProfileChangedPublisher
settingsPublisher ports.SettingsChangedPublisher
entitlementPublisher ports.EntitlementChangedPublisher
}
// NewEnsurer returns one ensure-by-email use case instance.
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)
}
// NewEnsurerWithObservability returns one ensure-by-email use case instance
// with optional structured logging, metrics, and post-commit event
// publication hooks.
func NewEnsurerWithObservability(
store ports.AuthDirectoryStore,
clock ports.Clock,
idGenerator ports.IDGenerator,
policy ports.RaceNamePolicy,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
profilePublisher ports.ProfileChangedPublisher,
settingsPublisher ports.SettingsChangedPublisher,
entitlementPublisher ports.EntitlementChangedPublisher,
) (*Ensurer, error) {
switch {
case store == nil:
return nil, fmt.Errorf("authdirectory ensurer: auth directory store must not be nil")
case clock == nil:
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,
settingsPublisher: settingsPublisher,
entitlementPublisher: entitlementPublisher,
}, nil
}
}
// Execute ensures that one e-mail subject maps to an existing user, a newly
// created user, or a blocked outcome.
func (service *Ensurer) Execute(ctx context.Context, input EnsureByEmailInput) (result EnsureByEmailResult, err error) {
outcome := "failed"
userIDString := ""
defer func() {
if service.telemetry != nil {
service.telemetry.RecordUserCreationOutcome(ctx, outcome)
}
shared.LogServiceOutcome(service.logger, ctx, "ensure by email completed", err,
"use_case", "ensure_by_email",
"outcome", outcome,
"user_id", userIDString,
"source", initialEntitlementSource.String(),
)
}()
if ctx == nil {
return EnsureByEmailResult{}, shared.InvalidRequest("context must not be nil")
}
email, err := shared.ParseEmail(input.Email)
if err != nil {
return EnsureByEmailResult{}, err
}
if input.RegistrationContext == nil {
return EnsureByEmailResult{}, shared.InvalidRequest("registration_context must be present")
}
preferredLanguage, err := shared.ParseRegistrationPreferredLanguage(input.RegistrationContext.PreferredLanguage)
if err != nil {
return EnsureByEmailResult{}, err
}
timeZone, err := shared.ParseRegistrationTimeZoneName(input.RegistrationContext.TimeZone)
if err != nil {
return EnsureByEmailResult{}, err
}
now := service.clock.Now().UTC()
for attempt := 0; attempt < ensureCreateRetryLimit; attempt++ {
userID, err := service.idGenerator.NewUserID()
if err != nil {
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
}
raceName, err := service.idGenerator.NewInitialRaceName()
if err != nil {
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
}
accountRecord := account.UserAccount{
UserID: userID,
Email: email,
RaceName: raceName,
PreferredLanguage: preferredLanguage,
TimeZone: timeZone,
CreatedAt: now,
UpdatedAt: now,
}
entitlementSnapshot := entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: now,
Source: initialEntitlementSource,
Actor: common.ActorRef{Type: initialEntitlementActorType, ID: initialEntitlementActorID},
ReasonCode: initialEntitlementReasonCode,
UpdatedAt: now,
}
entitlementRecordID, err := service.idGenerator.NewEntitlementRecordID()
if err != nil {
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
}
entitlementRecord := entitlement.PeriodRecord{
RecordID: entitlementRecordID,
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
Source: initialEntitlementSource,
Actor: common.ActorRef{Type: initialEntitlementActorType, ID: initialEntitlementActorID},
ReasonCode: initialEntitlementReasonCode,
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.ErrConflict) {
continue
}
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
}
if err := ensureResult.Validate(); err != nil {
return EnsureByEmailResult{}, shared.InternalError(err)
}
result = EnsureByEmailResult{
Outcome: string(ensureResult.Outcome),
}
if !ensureResult.UserID.IsZero() {
result.UserID = ensureResult.UserID.String()
userIDString = result.UserID
}
if !ensureResult.BlockReasonCode.IsZero() {
result.BlockReasonCode = ensureResult.BlockReasonCode.String()
}
outcome = result.Outcome
if result.Outcome == string(ports.EnsureByEmailOutcomeCreated) {
service.publishInitializedEvents(ctx, accountRecord, entitlementSnapshot)
}
return result, nil
}
return EnsureByEmailResult{}, shared.ServiceUnavailable(fmt.Errorf("ensure-by-email conflict retry limit exceeded"))
}
func (service *Ensurer) publishInitializedEvents(
ctx context.Context,
accountRecord account.UserAccount,
entitlementSnapshot entitlement.CurrentSnapshot,
) {
occurredAt := accountRecord.UpdatedAt.UTC()
service.publishProfileChanged(ctx, ports.ProfileChangedEvent{
UserID: accountRecord.UserID,
OccurredAt: occurredAt,
Source: initialEntitlementSource,
Operation: ports.ProfileChangedOperationInitialized,
RaceName: accountRecord.RaceName,
})
service.publishSettingsChanged(ctx, ports.SettingsChangedEvent{
UserID: accountRecord.UserID,
OccurredAt: occurredAt,
Source: initialEntitlementSource,
Operation: ports.SettingsChangedOperationInitialized,
PreferredLanguage: accountRecord.PreferredLanguage,
TimeZone: accountRecord.TimeZone,
})
service.publishEntitlementChanged(ctx, ports.EntitlementChangedEvent{
UserID: entitlementSnapshot.UserID,
OccurredAt: occurredAt,
Source: initialEntitlementSource,
Operation: ports.EntitlementChangedOperationInitialized,
PlanCode: entitlementSnapshot.PlanCode,
IsPaid: entitlementSnapshot.IsPaid,
StartsAt: entitlementSnapshot.StartsAt,
EndsAt: entitlementSnapshot.EndsAt,
ReasonCode: entitlementSnapshot.ReasonCode,
Actor: entitlementSnapshot.Actor,
UpdatedAt: entitlementSnapshot.UpdatedAt,
})
}
func (service *Ensurer) publishProfileChanged(ctx context.Context, event ports.ProfileChangedEvent) {
if service.profilePublisher == nil {
return
}
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", "ensure_by_email",
"user_id", event.UserID.String(),
"source", event.Source.String(),
)
}
}
func (service *Ensurer) publishSettingsChanged(ctx context.Context, event ports.SettingsChangedEvent) {
if service.settingsPublisher == nil {
return
}
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", "ensure_by_email",
"user_id", event.UserID.String(),
"source", event.Source.String(),
)
}
}
func (service *Ensurer) publishEntitlementChanged(ctx context.Context, event ports.EntitlementChangedEvent) {
if service.entitlementPublisher == nil {
return
}
if err := service.entitlementPublisher.PublishEntitlementChanged(ctx, event); err != nil {
if service.telemetry != nil {
service.telemetry.RecordEventPublicationFailure(ctx, ports.EntitlementChangedEventType)
}
shared.LogEventPublicationFailure(service.logger, ctx, ports.EntitlementChangedEventType, err,
"use_case", "ensure_by_email",
"user_id", event.UserID.String(),
"source", event.Source.String(),
"reason_code", event.ReasonCode.String(),
"actor_type", event.Actor.Type.String(),
"actor_id", event.Actor.ID.String(),
)
}
}
// ExistsByUserIDInput stores one auth-facing existence check request.
type ExistsByUserIDInput struct {
// UserID stores the caller-supplied stable user identifier.
UserID string
}
// ExistsByUserIDResult stores one auth-facing existence check response.
type ExistsByUserIDResult struct {
// Exists reports whether the supplied user identifier currently exists.
Exists bool
}
// ExistenceChecker executes the auth-facing exists-by-user-id use case.
type ExistenceChecker struct {
store ports.AuthDirectoryStore
}
// NewExistenceChecker returns one exists-by-user-id use case instance.
func NewExistenceChecker(store ports.AuthDirectoryStore) (*ExistenceChecker, error) {
if store == nil {
return nil, fmt.Errorf("authdirectory existence checker: auth directory store must not be nil")
}
return &ExistenceChecker{store: store}, nil
}
// Execute reports whether one stable user identifier exists.
func (service *ExistenceChecker) Execute(ctx context.Context, input ExistsByUserIDInput) (ExistsByUserIDResult, error) {
if ctx == nil {
return ExistsByUserIDResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return ExistsByUserIDResult{}, err
}
exists, err := service.store.ExistsByUserID(ctx, userID)
if err != nil {
return ExistsByUserIDResult{}, shared.ServiceUnavailable(err)
}
return ExistsByUserIDResult{Exists: exists}, nil
}
// BlockByUserIDInput stores one auth-facing block-by-user-id request.
type BlockByUserIDInput struct {
// UserID stores the stable account identifier that must be blocked.
UserID string
// ReasonCode stores the machine-readable block reason.
ReasonCode string
}
// BlockByEmailInput stores one auth-facing block-by-email request.
type BlockByEmailInput struct {
// Email stores the exact normalized e-mail subject that must be blocked.
Email string
// ReasonCode stores the machine-readable block reason.
ReasonCode string
}
// BlockResult stores one auth-facing block response.
type BlockResult struct {
// Outcome reports whether the current call created a new block.
Outcome string
// UserID stores the resolved account when the blocked subject belongs to an
// existing user.
UserID string
}
// BlockByUserIDService executes the auth-facing block-by-user-id use case.
type BlockByUserIDService struct {
store ports.AuthDirectoryStore
clock ports.Clock
}
// NewBlockByUserIDService returns one block-by-user-id use case instance.
func NewBlockByUserIDService(store ports.AuthDirectoryStore, clock ports.Clock) (*BlockByUserIDService, error) {
switch {
case store == nil:
return nil, fmt.Errorf("authdirectory block-by-user-id service: auth directory store must not be nil")
case clock == nil:
return nil, fmt.Errorf("authdirectory block-by-user-id service: clock must not be nil")
default:
return &BlockByUserIDService{store: store, clock: clock}, nil
}
}
// Execute blocks one account addressed by stable user identifier.
func (service *BlockByUserIDService) Execute(ctx context.Context, input BlockByUserIDInput) (BlockResult, error) {
if ctx == nil {
return BlockResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return BlockResult{}, err
}
reasonCode, err := shared.ParseReasonCode(input.ReasonCode)
if err != nil {
return BlockResult{}, err
}
result, err := service.store.BlockByUserID(ctx, ports.BlockByUserIDInput{
UserID: userID,
ReasonCode: reasonCode,
BlockedAt: service.clock.Now().UTC(),
})
if err != nil {
switch {
case errors.Is(err, ports.ErrNotFound):
return BlockResult{}, shared.SubjectNotFound()
default:
return BlockResult{}, shared.ServiceUnavailable(err)
}
}
if err := result.Validate(); err != nil {
return BlockResult{}, shared.InternalError(err)
}
response := BlockResult{Outcome: string(result.Outcome)}
if !result.UserID.IsZero() {
response.UserID = result.UserID.String()
}
return response, nil
}
// BlockByEmailService executes the auth-facing block-by-email use case.
type BlockByEmailService struct {
store ports.AuthDirectoryStore
clock ports.Clock
}
// NewBlockByEmailService returns one block-by-email use case instance.
func NewBlockByEmailService(store ports.AuthDirectoryStore, clock ports.Clock) (*BlockByEmailService, error) {
switch {
case store == nil:
return nil, fmt.Errorf("authdirectory block-by-email service: auth directory store must not be nil")
case clock == nil:
return nil, fmt.Errorf("authdirectory block-by-email service: clock must not be nil")
default:
return &BlockByEmailService{store: store, clock: clock}, nil
}
}
// Execute blocks one exact normalized e-mail subject.
func (service *BlockByEmailService) Execute(ctx context.Context, input BlockByEmailInput) (BlockResult, error) {
if ctx == nil {
return BlockResult{}, shared.InvalidRequest("context must not be nil")
}
email, err := shared.ParseEmail(input.Email)
if err != nil {
return BlockResult{}, err
}
reasonCode, err := shared.ParseReasonCode(input.ReasonCode)
if err != nil {
return BlockResult{}, err
}
result, err := service.store.BlockByEmail(ctx, ports.BlockByEmailInput{
Email: email,
ReasonCode: reasonCode,
BlockedAt: service.clock.Now().UTC(),
})
if err != nil {
return BlockResult{}, shared.ServiceUnavailable(err)
}
if err := result.Validate(); err != nil {
return BlockResult{}, shared.InternalError(err)
}
response := BlockResult{Outcome: string(result.Outcome)}
if !result.UserID.IsZero() {
response.UserID = result.UserID.String()
}
return response, nil
}
@@ -0,0 +1,717 @@
package authdirectory
import (
"context"
"errors"
"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/shared"
"galaxy/user/internal/telemetry"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/attribute"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)
func TestResolverExecute(t *testing.T) {
t.Parallel()
tests := []struct {
name string
store stubAuthDirectoryStore
wantKind string
wantUserID string
wantBlock string
}{
{
name: "existing",
store: stubAuthDirectoryStore{
resolveByEmail: func(_ context.Context, email common.Email) (ports.ResolveByEmailResult, error) {
require.Equal(t, common.Email("pilot@example.com"), email)
return ports.ResolveByEmailResult{
Kind: ports.AuthResolutionKindExisting,
UserID: common.UserID("user-123"),
}, nil
},
},
wantKind: "existing",
wantUserID: "user-123",
},
{
name: "creatable",
store: stubAuthDirectoryStore{
resolveByEmail: func(_ context.Context, email common.Email) (ports.ResolveByEmailResult, error) {
require.Equal(t, common.Email("pilot@example.com"), email)
return ports.ResolveByEmailResult{
Kind: ports.AuthResolutionKindCreatable,
}, nil
},
},
wantKind: "creatable",
},
{
name: "blocked",
store: stubAuthDirectoryStore{
resolveByEmail: func(_ context.Context, email common.Email) (ports.ResolveByEmailResult, error) {
require.Equal(t, common.Email("pilot@example.com"), email)
return ports.ResolveByEmailResult{
Kind: ports.AuthResolutionKindBlocked,
BlockReasonCode: common.ReasonCode("policy_blocked"),
}, nil
},
},
wantKind: "blocked",
wantBlock: "policy_blocked",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
resolver, err := NewResolver(tt.store)
require.NoError(t, err)
result, err := resolver.Execute(context.Background(), ResolveByEmailInput{
Email: " pilot@example.com ",
})
require.NoError(t, err)
require.Equal(t, tt.wantKind, result.Kind)
require.Equal(t, tt.wantUserID, result.UserID)
require.Equal(t, tt.wantBlock, result.BlockReasonCode)
})
}
}
func TestEnsurerExecuteCreatedBuildsInitialRecords(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
ensurer, err := NewEnsurer(stubAuthDirectoryStore{
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.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)
require.Equal(t, entitlement.EntitlementRecordID("entitlement-created"), input.EntitlementRecord.RecordID)
require.Equal(t, input.Account.UserID, input.EntitlementRecord.UserID)
require.Equal(t, input.Entitlement.PlanCode, input.EntitlementRecord.PlanCode)
require.Equal(t, input.Entitlement.StartsAt, input.EntitlementRecord.StartsAt)
require.Equal(t, input.Entitlement.Source, input.EntitlementRecord.Source)
require.Equal(t, input.Entitlement.Actor, input.EntitlementRecord.Actor)
require.Equal(t, input.Entitlement.ReasonCode, input.EntitlementRecord.ReasonCode)
return ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeCreated,
UserID: input.Account.UserID,
}, nil
},
}, fixedClock{now: now}, fixedIDGenerator{
userID: common.UserID("user-created"),
raceName: common.RaceName("player-test123"),
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
}, stubRaceNamePolicy{})
require.NoError(t, err)
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
Email: "created@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "en-us",
TimeZone: "Europe/Kaliningrad",
},
})
require.NoError(t, err)
require.Equal(t, "created", result.Outcome)
require.Equal(t, "user-created", result.UserID)
}
func TestEnsurerExecuteRejectsInvalidRegistrationContext(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input EnsureByEmailInput
wantErr string
}{
{
name: "invalid preferred language",
input: EnsureByEmailInput{
Email: "pilot@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "bad@@tag",
TimeZone: "Europe/Kaliningrad",
},
},
wantErr: "registration_context.preferred_language must be a valid BCP 47 language tag",
},
{
name: "invalid time zone",
input: EnsureByEmailInput{
Email: "pilot@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "en",
TimeZone: "Mars/Olympus",
},
},
wantErr: "registration_context.time_zone must be a valid IANA time zone name",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
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"),
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
}, stubRaceNamePolicy{})
require.NoError(t, err)
_, err = ensurer.Execute(context.Background(), tt.input)
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
require.Equal(t, tt.wantErr, err.Error())
})
}
}
func TestEnsurerExecuteRetriesConflicts(t *testing.T) {
t.Parallel()
attempt := 0
ensurer, err := NewEnsurer(stubAuthDirectoryStore{
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
attempt++
if attempt == 1 {
return ports.EnsureByEmailResult{}, ports.ErrConflict
}
return ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeCreated,
UserID: input.Account.UserID,
}, nil
},
}, 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"},
entitlementRecordIDs: []entitlement.EntitlementRecordID{"entitlement-first", "entitlement-second"},
}, stubRaceNamePolicy{})
require.NoError(t, err)
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
Email: "retry@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "en",
TimeZone: "UTC",
},
})
require.NoError(t, err)
require.Equal(t, 2, attempt)
require.Equal(t, "user-second", result.UserID)
}
func TestEnsurerExecuteReturnsExistingAndBlocked(t *testing.T) {
t.Parallel()
tests := []struct {
name string
store stubAuthDirectoryStore
want EnsureByEmailResult
}{
{
name: "existing",
store: stubAuthDirectoryStore{
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
require.Equal(t, common.Email("pilot@example.com"), input.Email)
return ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeExisting,
UserID: common.UserID("user-existing"),
}, nil
},
},
want: EnsureByEmailResult{
Outcome: "existing",
UserID: "user-existing",
},
},
{
name: "blocked",
store: stubAuthDirectoryStore{
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
require.Equal(t, common.Email("pilot@example.com"), input.Email)
return ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeBlocked,
BlockReasonCode: common.ReasonCode("policy_blocked"),
}, nil
},
},
want: EnsureByEmailResult{
Outcome: "blocked",
BlockReasonCode: "policy_blocked",
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
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"),
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
}, stubRaceNamePolicy{})
require.NoError(t, err)
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
Email: "pilot@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "en",
TimeZone: "UTC",
},
})
require.NoError(t, err)
require.Equal(t, tt.want, result)
})
}
}
func TestEnsurerExecuteCreatedPublishesInitializedEvents(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
publisher := &recordingAuthDomainEventPublisher{}
telemetryRuntime, reader := newObservedAuthTelemetryRuntime(t)
ensurer, err := NewEnsurerWithObservability(stubAuthDirectoryStore{
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
return ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeCreated,
UserID: input.Account.UserID,
}, nil
},
}, fixedClock{now: now}, fixedIDGenerator{
userID: common.UserID("user-created"),
raceName: common.RaceName("player-test123"),
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
}, stubRaceNamePolicy{}, nil, telemetryRuntime, publisher, publisher, publisher)
require.NoError(t, err)
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
Email: "created@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "en-us",
TimeZone: "Europe/Kaliningrad",
},
})
require.NoError(t, err)
require.Equal(t, "created", result.Outcome)
require.Len(t, publisher.profileEvents, 1)
require.Equal(t, ports.ProfileChangedOperationInitialized, publisher.profileEvents[0].Operation)
require.Equal(t, common.Source("auth_registration"), publisher.profileEvents[0].Source)
require.Len(t, publisher.settingsEvents, 1)
require.Equal(t, ports.SettingsChangedOperationInitialized, publisher.settingsEvents[0].Operation)
require.Len(t, publisher.entitlementEvents, 1)
require.Equal(t, ports.EntitlementChangedOperationInitialized, publisher.entitlementEvents[0].Operation)
assertMetricCount(t, reader, "user.user_creation.outcomes", map[string]string{
"outcome": "created",
}, 1)
}
func TestEnsurerExecuteExistingBlockedAndFailedDoNotPublishEvents(t *testing.T) {
t.Parallel()
tests := []struct {
name string
store stubAuthDirectoryStore
input EnsureByEmailInput
wantMetric string
wantErrCode string
wantProfileLen int
}{
{
name: "existing",
store: stubAuthDirectoryStore{
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
return ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeExisting,
UserID: common.UserID("user-existing"),
}, nil
},
},
input: EnsureByEmailInput{
Email: "pilot@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "en",
TimeZone: "UTC",
},
},
wantMetric: "existing",
},
{
name: "blocked",
store: stubAuthDirectoryStore{
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
return ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeBlocked,
BlockReasonCode: common.ReasonCode("policy_blocked"),
}, nil
},
},
input: EnsureByEmailInput{
Email: "pilot@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "en",
TimeZone: "UTC",
},
},
wantMetric: "blocked",
},
{
name: "failed",
store: stubAuthDirectoryStore{},
input: EnsureByEmailInput{
Email: "pilot@example.com",
},
wantMetric: "failed",
wantErrCode: shared.ErrorCodeInvalidRequest,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
publisher := &recordingAuthDomainEventPublisher{}
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"),
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
}, stubRaceNamePolicy{}, nil, telemetryRuntime, publisher, publisher, publisher)
require.NoError(t, err)
_, err = ensurer.Execute(context.Background(), tt.input)
if tt.wantErrCode != "" {
require.Error(t, err)
require.Equal(t, tt.wantErrCode, shared.CodeOf(err))
} else {
require.NoError(t, err)
}
require.Empty(t, publisher.profileEvents)
require.Empty(t, publisher.settingsEvents)
require.Empty(t, publisher.entitlementEvents)
assertMetricCount(t, reader, "user.user_creation.outcomes", map[string]string{
"outcome": tt.wantMetric,
}, 1)
})
}
}
func TestEnsurerExecutePublishFailureDoesNotRollbackCreatedUser(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
publisher := &recordingAuthDomainEventPublisher{err: errors.New("publisher unavailable")}
telemetryRuntime, reader := newObservedAuthTelemetryRuntime(t)
ensurer, err := NewEnsurerWithObservability(stubAuthDirectoryStore{
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
return ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeCreated,
UserID: input.Account.UserID,
}, nil
},
}, fixedClock{now: now}, fixedIDGenerator{
userID: common.UserID("user-created"),
raceName: common.RaceName("player-test123"),
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
}, stubRaceNamePolicy{}, nil, telemetryRuntime, publisher, publisher, publisher)
require.NoError(t, err)
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
Email: "created@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "en-us",
TimeZone: "Europe/Kaliningrad",
},
})
require.NoError(t, err)
require.Equal(t, "created", result.Outcome)
require.Len(t, publisher.profileEvents, 1)
require.Len(t, publisher.settingsEvents, 1)
require.Len(t, publisher.entitlementEvents, 1)
assertMetricCount(t, reader, "user.event_publication_failures", map[string]string{
"event_type": ports.ProfileChangedEventType,
}, 1)
assertMetricCount(t, reader, "user.event_publication_failures", map[string]string{
"event_type": ports.SettingsChangedEventType,
}, 1)
assertMetricCount(t, reader, "user.event_publication_failures", map[string]string{
"event_type": ports.EntitlementChangedEventType,
}, 1)
}
func TestBlockByUserIDServiceMapsNotFound(t *testing.T) {
t.Parallel()
service, err := NewBlockByUserIDService(stubAuthDirectoryStore{
blockByUserID: func(context.Context, ports.BlockByUserIDInput) (ports.BlockResult, error) {
return ports.BlockResult{}, ports.ErrNotFound
},
}, fixedClock{now: time.Unix(1_775_240_000, 0).UTC()})
require.NoError(t, err)
_, err = service.Execute(context.Background(), BlockByUserIDInput{
UserID: "user-missing",
ReasonCode: "policy_blocked",
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
}
type stubAuthDirectoryStore struct {
resolveByEmail func(context.Context, common.Email) (ports.ResolveByEmailResult, error)
ensureByEmail func(context.Context, ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error)
existsByUserID func(context.Context, common.UserID) (bool, error)
blockByUserID func(context.Context, ports.BlockByUserIDInput) (ports.BlockResult, error)
blockByEmail func(context.Context, ports.BlockByEmailInput) (ports.BlockResult, error)
}
func (store stubAuthDirectoryStore) ResolveByEmail(ctx context.Context, email common.Email) (ports.ResolveByEmailResult, error) {
if store.resolveByEmail == nil {
return ports.ResolveByEmailResult{}, errors.New("unexpected ResolveByEmail call")
}
return store.resolveByEmail(ctx, email)
}
func (store stubAuthDirectoryStore) ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error) {
if store.existsByUserID == nil {
return false, errors.New("unexpected ExistsByUserID call")
}
return store.existsByUserID(ctx, userID)
}
func (store stubAuthDirectoryStore) EnsureByEmail(ctx context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
if store.ensureByEmail == nil {
return ports.EnsureByEmailResult{}, errors.New("unexpected EnsureByEmail call")
}
return store.ensureByEmail(ctx, input)
}
func (store stubAuthDirectoryStore) BlockByUserID(ctx context.Context, input ports.BlockByUserIDInput) (ports.BlockResult, error) {
if store.blockByUserID == nil {
return ports.BlockResult{}, errors.New("unexpected BlockByUserID call")
}
return store.blockByUserID(ctx, input)
}
func (store stubAuthDirectoryStore) BlockByEmail(ctx context.Context, input ports.BlockByEmailInput) (ports.BlockResult, error) {
if store.blockByEmail == nil {
return ports.BlockResult{}, errors.New("unexpected BlockByEmail call")
}
return store.blockByEmail(ctx, input)
}
type fixedClock struct {
now time.Time
}
func (clock fixedClock) Now() time.Time {
return clock.now
}
type fixedIDGenerator struct {
userID common.UserID
raceName common.RaceName
entitlementRecordID entitlement.EntitlementRecordID
sanctionRecordID policy.SanctionRecordID
limitRecordID policy.LimitRecordID
}
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) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
return generator.entitlementRecordID, nil
}
func (generator fixedIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
return generator.sanctionRecordID, nil
}
func (generator fixedIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
return generator.limitRecordID, nil
}
type sequenceIDGenerator struct {
userIDs []common.UserID
raceNames []common.RaceName
entitlementRecordIDs []entitlement.EntitlementRecordID
sanctionRecordIDs []policy.SanctionRecordID
limitRecordIDs []policy.LimitRecordID
}
func (generator *sequenceIDGenerator) NewUserID() (common.UserID, error) {
value := generator.userIDs[0]
generator.userIDs = generator.userIDs[1:]
return value, nil
}
func (generator *sequenceIDGenerator) NewInitialRaceName() (common.RaceName, error) {
value := generator.raceNames[0]
generator.raceNames = generator.raceNames[1:]
return value, nil
}
func (generator *sequenceIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
value := generator.entitlementRecordIDs[0]
generator.entitlementRecordIDs = generator.entitlementRecordIDs[1:]
return value, nil
}
func (generator *sequenceIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
value := generator.sanctionRecordIDs[0]
generator.sanctionRecordIDs = generator.sanctionRecordIDs[1:]
return value, nil
}
func (generator *sequenceIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
value := generator.limitRecordIDs[0]
generator.limitRecordIDs = generator.limitRecordIDs[1:]
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
settingsEvents []ports.SettingsChangedEvent
entitlementEvents []ports.EntitlementChangedEvent
}
func (publisher *recordingAuthDomainEventPublisher) PublishProfileChanged(_ context.Context, event ports.ProfileChangedEvent) error {
if err := event.Validate(); err != nil {
return err
}
publisher.profileEvents = append(publisher.profileEvents, event)
return publisher.err
}
func (publisher *recordingAuthDomainEventPublisher) PublishSettingsChanged(_ context.Context, event ports.SettingsChangedEvent) error {
if err := event.Validate(); err != nil {
return err
}
publisher.settingsEvents = append(publisher.settingsEvents, event)
return publisher.err
}
func (publisher *recordingAuthDomainEventPublisher) PublishEntitlementChanged(_ context.Context, event ports.EntitlementChangedEvent) error {
if err := event.Validate(); err != nil {
return err
}
publisher.entitlementEvents = append(publisher.entitlementEvents, event)
return publisher.err
}
func newObservedAuthTelemetryRuntime(t *testing.T) (*telemetry.Runtime, *sdkmetric.ManualReader) {
t.Helper()
reader := sdkmetric.NewManualReader()
meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
tracerProvider := sdktrace.NewTracerProvider()
runtime, err := telemetry.NewWithProviders(meterProvider, tracerProvider)
require.NoError(t, err)
return runtime, reader
}
func assertMetricCount(t *testing.T, reader *sdkmetric.ManualReader, metricName string, wantAttrs map[string]string, wantValue int64) {
t.Helper()
var resourceMetrics metricdata.ResourceMetrics
require.NoError(t, reader.Collect(context.Background(), &resourceMetrics))
for _, scopeMetrics := range resourceMetrics.ScopeMetrics {
for _, metric := range scopeMetrics.Metrics {
if metric.Name != metricName {
continue
}
sum, ok := metric.Data.(metricdata.Sum[int64])
require.True(t, ok)
for _, point := range sum.DataPoints {
if hasMetricAttributes(point.Attributes.ToSlice(), wantAttrs) {
require.Equal(t, wantValue, point.Value)
return
}
}
}
}
require.Failf(t, "test failed", "metric %q with attrs %v not found", metricName, wantAttrs)
}
func hasMetricAttributes(values []attribute.KeyValue, want map[string]string) bool {
if len(values) != len(want) {
return false
}
for _, value := range values {
if want[string(value.Key)] != value.Value.AsString() {
return false
}
}
return true
}
var (
_ ports.AuthDirectoryStore = stubAuthDirectoryStore{}
_ 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)
)
@@ -0,0 +1,121 @@
package entitlementsvc
import (
"context"
"errors"
"testing"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/ports"
"github.com/stretchr/testify/require"
)
func TestReaderGetByUserIDPublishesExpiredRepairEvent(t *testing.T) {
t.Parallel()
userID := common.UserID("user-123")
startsAt := time.Unix(1_775_240_000, 0).UTC()
endsAt := startsAt.Add(24 * time.Hour)
now := endsAt.Add(time.Hour)
snapshotStore := &fakeSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
userID: paidSnapshot(
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
endsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
),
},
}
historyStore := &fakeHistoryStore{
byUserID: map[common.UserID][]entitlement.PeriodRecord{
userID: {
paidRecord(
entitlement.EntitlementRecordID("entitlement-paid"),
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
endsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
),
},
},
}
lifecycleStore := &fakeLifecycleStore{
historyStore: historyStore,
snapshotStore: snapshotStore,
}
publisher := &recordingEntitlementPublisher{}
reader, err := NewReaderWithObservability(snapshotStore, lifecycleStore, fixedClock{now: now}, fixedIDGenerator{
recordID: entitlement.EntitlementRecordID("entitlement-free"),
}, nil, nil, publisher)
require.NoError(t, err)
got, err := reader.GetByUserID(context.Background(), userID)
require.NoError(t, err)
require.Equal(t, entitlement.PlanCodeFree, got.PlanCode)
require.Len(t, publisher.events, 1)
require.Equal(t, ports.EntitlementChangedOperationExpiredRepaired, publisher.events[0].Operation)
require.Equal(t, common.Source("entitlement_expiry_repair"), publisher.events[0].Source)
}
func TestGrantServiceExecutePublisherFailureDoesNotRollbackResult(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
currentFreeStartsAt := now.Add(-24 * time.Hour)
currentSnapshot := freeSnapshot(userID, currentFreeStartsAt, common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
currentRecord := freeRecord(entitlement.EntitlementRecordID("entitlement-free"), userID, currentFreeStartsAt, common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
lifecycleStore := &fakeLifecycleStore{}
publisher := &recordingEntitlementPublisher{err: errors.New("publisher unavailable")}
service, err := NewGrantServiceWithObservability(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
&fakeHistoryStore{byUserID: map[common.UserID][]entitlement.PeriodRecord{userID: {currentRecord}}},
fakeEffectiveReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: currentSnapshot}},
lifecycleStore,
fixedClock{now: now},
fixedIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-paid")},
nil,
nil,
publisher,
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GrantInput{
UserID: userID.String(),
PlanCode: string(entitlement.PlanCodePaidMonthly),
Source: "admin",
ReasonCode: "manual_grant",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
StartsAt: now.Format(time.RFC3339Nano),
EndsAt: now.Add(30 * 24 * time.Hour).Format(time.RFC3339Nano),
})
require.NoError(t, err)
require.Equal(t, entitlement.PlanCodePaidMonthly, result.Entitlement.PlanCode)
require.Len(t, publisher.events, 1)
require.Equal(t, ports.EntitlementChangedOperationGranted, publisher.events[0].Operation)
}
type recordingEntitlementPublisher struct {
err error
events []ports.EntitlementChangedEvent
}
func (publisher *recordingEntitlementPublisher) PublishEntitlementChanged(_ context.Context, event ports.EntitlementChangedEvent) error {
if err := event.Validate(); err != nil {
return err
}
publisher.events = append(publisher.events, event)
return publisher.err
}
var _ ports.EntitlementChangedPublisher = (*recordingEntitlementPublisher)(nil)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,565 @@
package entitlementsvc
import (
"context"
"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/shared"
"github.com/stretchr/testify/require"
)
func TestReaderGetByUserIDRepairsExpiredFinitePaidSnapshot(t *testing.T) {
t.Parallel()
userID := common.UserID("user-123")
startsAt := time.Unix(1_775_240_000, 0).UTC()
endsAt := startsAt.Add(24 * time.Hour)
now := endsAt.Add(2 * time.Hour)
snapshotStore := &fakeSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
userID: paidSnapshot(
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
endsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
),
},
}
historyStore := &fakeHistoryStore{
byUserID: map[common.UserID][]entitlement.PeriodRecord{
userID: {
paidRecord(
entitlement.EntitlementRecordID("entitlement-paid"),
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
endsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
),
},
},
}
lifecycleStore := &fakeLifecycleStore{
historyStore: historyStore,
snapshotStore: snapshotStore,
}
reader, err := NewReader(snapshotStore, lifecycleStore, fixedClock{now: now}, fixedIDGenerator{
recordID: entitlement.EntitlementRecordID("entitlement-free"),
})
require.NoError(t, err)
got, err := reader.GetByUserID(context.Background(), userID)
require.NoError(t, err)
require.Equal(t, entitlement.PlanCodeFree, got.PlanCode)
require.False(t, got.IsPaid)
require.Equal(t, endsAt, got.StartsAt)
require.Equal(t, expiryRepairSource, got.Source)
require.Equal(t, expiryRepairReasonCode, got.ReasonCode)
require.Equal(t, common.ActorRef{Type: expiryRepairActorType, ID: expiryRepairActorID}, got.Actor)
require.Len(t, historyStore.byUserID[userID], 2)
require.Equal(t, got, snapshotStore.byUserID[userID])
require.Equal(t, entitlement.EntitlementRecordID("entitlement-free"), lifecycleStore.repairInput.NewRecord.RecordID)
}
func TestGrantServiceExecuteRejectsInvalidPlanRules(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
freeSnapshot := freeSnapshot(userID, now.Add(-24*time.Hour), common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
freeRecord := freeRecord(entitlement.EntitlementRecordID("entitlement-free"), userID, now.Add(-24*time.Hour), common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
tests := []struct {
name string
input GrantInput
wantErr string
}{
{
name: "free plan not allowed",
input: GrantInput{
UserID: userID.String(),
PlanCode: string(entitlement.PlanCodeFree),
Source: "admin",
ReasonCode: "manual_grant",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
StartsAt: now.Format(time.RFC3339Nano),
},
wantErr: shared.ErrorCodeInvalidRequest,
},
{
name: "future starts at rejected",
input: GrantInput{
UserID: userID.String(),
PlanCode: string(entitlement.PlanCodePaidMonthly),
Source: "admin",
ReasonCode: "manual_grant",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
StartsAt: now.Add(time.Hour).Format(time.RFC3339Nano),
EndsAt: now.Add(31 * 24 * time.Hour).Format(time.RFC3339Nano),
},
wantErr: shared.ErrorCodeInvalidRequest,
},
{
name: "finite plan requires ends at",
input: GrantInput{
UserID: userID.String(),
PlanCode: string(entitlement.PlanCodePaidMonthly),
Source: "admin",
ReasonCode: "manual_grant",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
StartsAt: now.Format(time.RFC3339Nano),
},
wantErr: shared.ErrorCodeInvalidRequest,
},
{
name: "lifetime plan forbids ends at",
input: GrantInput{
UserID: userID.String(),
PlanCode: string(entitlement.PlanCodePaidLifetime),
Source: "admin",
ReasonCode: "manual_grant",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
StartsAt: now.Format(time.RFC3339Nano),
EndsAt: now.Add(24 * time.Hour).Format(time.RFC3339Nano),
},
wantErr: shared.ErrorCodeInvalidRequest,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
service, err := NewGrantService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
&fakeHistoryStore{byUserID: map[common.UserID][]entitlement.PeriodRecord{userID: {freeRecord}}},
fakeEffectiveReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: freeSnapshot}},
&fakeLifecycleStore{},
fixedClock{now: now},
fixedIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-paid")},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), tt.input)
require.Error(t, err)
require.Equal(t, tt.wantErr, shared.CodeOf(err))
})
}
}
func TestGrantServiceExecuteBuildsTransition(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
currentFreeStartsAt := now.Add(-24 * time.Hour)
currentSnapshot := freeSnapshot(userID, currentFreeStartsAt, common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
currentRecord := freeRecord(entitlement.EntitlementRecordID("entitlement-free"), userID, currentFreeStartsAt, common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
lifecycleStore := &fakeLifecycleStore{}
service, err := NewGrantService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
&fakeHistoryStore{byUserID: map[common.UserID][]entitlement.PeriodRecord{userID: {currentRecord}}},
fakeEffectiveReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: currentSnapshot}},
lifecycleStore,
fixedClock{now: now},
fixedIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-paid")},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GrantInput{
UserID: userID.String(),
PlanCode: string(entitlement.PlanCodePaidMonthly),
Source: "admin",
ReasonCode: "manual_grant",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
StartsAt: now.Format(time.RFC3339Nano),
EndsAt: now.Add(30 * 24 * time.Hour).Format(time.RFC3339Nano),
})
require.NoError(t, err)
require.Equal(t, userID.String(), result.UserID)
require.Equal(t, entitlement.PlanCodePaidMonthly, result.Entitlement.PlanCode)
require.Equal(t, entitlement.EntitlementRecordID("entitlement-paid"), lifecycleStore.grantInput.NewRecord.RecordID)
require.Equal(t, currentSnapshot, lifecycleStore.grantInput.ExpectedCurrentSnapshot)
require.Equal(t, currentRecord.RecordID, lifecycleStore.grantInput.UpdatedCurrentRecord.RecordID)
require.NotNil(t, lifecycleStore.grantInput.UpdatedCurrentRecord.ClosedAt)
require.True(t, lifecycleStore.grantInput.UpdatedCurrentRecord.ClosedAt.Equal(now))
}
func TestExtendServiceExecuteBuildsExtensionSegment(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
startsAt := now.Add(-24 * time.Hour)
currentEndsAt := now.Add(24 * time.Hour)
currentSnapshot := paidSnapshot(
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
currentEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
currentRecord := paidRecord(
entitlement.EntitlementRecordID("entitlement-paid-1"),
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
currentEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
lifecycleStore := &fakeLifecycleStore{}
service, err := NewExtendService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
&fakeHistoryStore{byUserID: map[common.UserID][]entitlement.PeriodRecord{userID: {currentRecord}}},
fakeEffectiveReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: currentSnapshot}},
lifecycleStore,
fixedClock{now: now},
fixedIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-paid-2")},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), ExtendInput{
UserID: userID.String(),
Source: "admin",
ReasonCode: "manual_extend",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
EndsAt: currentEndsAt.Add(30 * 24 * time.Hour).Format(time.RFC3339Nano),
})
require.NoError(t, err)
require.Equal(t, currentEndsAt, lifecycleStore.extendInput.NewRecord.StartsAt)
require.Equal(t, startsAt, lifecycleStore.extendInput.NewSnapshot.StartsAt)
require.Equal(t, entitlement.PlanCodePaidMonthly, result.Entitlement.PlanCode)
}
func TestRevokeServiceExecuteBuildsFreeTransition(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
startsAt := now.Add(-24 * time.Hour)
currentEndsAt := now.Add(24 * time.Hour)
currentSnapshot := paidSnapshot(
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
currentEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
currentRecord := paidRecord(
entitlement.EntitlementRecordID("entitlement-paid-1"),
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
currentEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
lifecycleStore := &fakeLifecycleStore{}
service, err := NewRevokeService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
&fakeHistoryStore{byUserID: map[common.UserID][]entitlement.PeriodRecord{userID: {currentRecord}}},
fakeEffectiveReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: currentSnapshot}},
lifecycleStore,
fixedClock{now: now},
fixedIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-free-2")},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), RevokeInput{
UserID: userID.String(),
Source: "admin",
ReasonCode: "manual_revoke",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
})
require.NoError(t, err)
require.Equal(t, entitlement.PlanCodeFree, result.Entitlement.PlanCode)
require.NotNil(t, lifecycleStore.revokeInput.UpdatedCurrentRecord.ClosedAt)
require.True(t, lifecycleStore.revokeInput.UpdatedCurrentRecord.ClosedAt.Equal(now))
require.Equal(t, now, lifecycleStore.revokeInput.NewRecord.StartsAt)
}
type fakeAccountStore struct {
existsByUserID map[common.UserID]bool
}
func (store fakeAccountStore) Create(context.Context, ports.CreateAccountInput) error {
return nil
}
func (store fakeAccountStore) GetByUserID(context.Context, common.UserID) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) GetByEmail(context.Context, common.Email) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) GetByRaceName(context.Context, common.RaceName) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
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
}
type fakeSnapshotStore struct {
byUserID map[common.UserID]entitlement.CurrentSnapshot
}
func (store *fakeSnapshotStore) 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 *fakeSnapshotStore) Put(_ context.Context, record entitlement.CurrentSnapshot) error {
store.byUserID[record.UserID] = record
return nil
}
type fakeHistoryStore struct {
byUserID map[common.UserID][]entitlement.PeriodRecord
}
func (store *fakeHistoryStore) Create(_ context.Context, record entitlement.PeriodRecord) error {
store.byUserID[record.UserID] = append(store.byUserID[record.UserID], record)
return nil
}
func (store *fakeHistoryStore) GetByRecordID(_ context.Context, recordID entitlement.EntitlementRecordID) (entitlement.PeriodRecord, error) {
for _, records := range store.byUserID {
for _, record := range records {
if record.RecordID == recordID {
return record, nil
}
}
}
return entitlement.PeriodRecord{}, ports.ErrNotFound
}
func (store *fakeHistoryStore) ListByUserID(_ context.Context, userID common.UserID) ([]entitlement.PeriodRecord, error) {
records := store.byUserID[userID]
cloned := make([]entitlement.PeriodRecord, len(records))
copy(cloned, records)
return cloned, nil
}
func (store *fakeHistoryStore) Update(_ context.Context, record entitlement.PeriodRecord) error {
records := store.byUserID[record.UserID]
for idx := range records {
if records[idx].RecordID == record.RecordID {
records[idx] = record
store.byUserID[record.UserID] = records
return nil
}
}
return ports.ErrNotFound
}
type fakeEffectiveReader struct {
byUserID map[common.UserID]entitlement.CurrentSnapshot
}
func (reader fakeEffectiveReader) GetByUserID(_ context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) {
record, ok := reader.byUserID[userID]
if !ok {
return entitlement.CurrentSnapshot{}, ports.ErrNotFound
}
return record, nil
}
type fakeLifecycleStore struct {
historyStore *fakeHistoryStore
snapshotStore *fakeSnapshotStore
grantInput ports.GrantEntitlementInput
extendInput ports.ExtendEntitlementInput
revokeInput ports.RevokeEntitlementInput
repairInput ports.RepairExpiredEntitlementInput
}
func (store *fakeLifecycleStore) Grant(_ context.Context, input ports.GrantEntitlementInput) error {
store.grantInput = input
return nil
}
func (store *fakeLifecycleStore) Extend(_ context.Context, input ports.ExtendEntitlementInput) error {
store.extendInput = input
return nil
}
func (store *fakeLifecycleStore) Revoke(_ context.Context, input ports.RevokeEntitlementInput) error {
store.revokeInput = input
return nil
}
func (store *fakeLifecycleStore) RepairExpired(_ context.Context, input ports.RepairExpiredEntitlementInput) error {
store.repairInput = input
if store.historyStore != nil {
store.historyStore.byUserID[input.NewRecord.UserID] = append(store.historyStore.byUserID[input.NewRecord.UserID], input.NewRecord)
}
if store.snapshotStore != nil {
store.snapshotStore.byUserID[input.NewSnapshot.UserID] = input.NewSnapshot
}
return nil
}
type fixedClock struct {
now time.Time
}
func (clock fixedClock) Now() time.Time {
return clock.now
}
type fixedIDGenerator struct {
recordID entitlement.EntitlementRecordID
sanctionRecordID policy.SanctionRecordID
limitRecordID policy.LimitRecordID
}
func (generator fixedIDGenerator) NewUserID() (common.UserID, error) {
return "", nil
}
func (generator fixedIDGenerator) NewInitialRaceName() (common.RaceName, error) {
return "", nil
}
func (generator fixedIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
return generator.recordID, nil
}
func (generator fixedIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
return generator.sanctionRecordID, nil
}
func (generator fixedIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
return generator.limitRecordID, nil
}
func freeSnapshot(
userID common.UserID,
startsAt time.Time,
source common.Source,
reasonCode common.ReasonCode,
) entitlement.CurrentSnapshot {
return entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: startsAt,
Source: source,
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
ReasonCode: reasonCode,
UpdatedAt: startsAt,
}
}
func freeRecord(
recordID entitlement.EntitlementRecordID,
userID common.UserID,
startsAt time.Time,
source common.Source,
reasonCode common.ReasonCode,
) entitlement.PeriodRecord {
return entitlement.PeriodRecord{
RecordID: recordID,
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
Source: source,
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
ReasonCode: reasonCode,
StartsAt: startsAt,
CreatedAt: startsAt,
}
}
func paidSnapshot(
userID common.UserID,
planCode entitlement.PlanCode,
startsAt time.Time,
endsAt time.Time,
source common.Source,
reasonCode common.ReasonCode,
) entitlement.CurrentSnapshot {
return entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: planCode,
IsPaid: true,
StartsAt: startsAt,
EndsAt: timePointer(endsAt),
Source: source,
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: reasonCode,
UpdatedAt: startsAt,
}
}
func paidRecord(
recordID entitlement.EntitlementRecordID,
userID common.UserID,
planCode entitlement.PlanCode,
startsAt time.Time,
endsAt time.Time,
source common.Source,
reasonCode common.ReasonCode,
) entitlement.PeriodRecord {
return entitlement.PeriodRecord{
RecordID: recordID,
UserID: userID,
PlanCode: planCode,
Source: source,
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: reasonCode,
StartsAt: startsAt,
EndsAt: timePointer(endsAt),
CreatedAt: startsAt,
}
}
func timePointer(value time.Time) *time.Time {
utcValue := value.UTC()
return &utcValue
}
var (
_ ports.UserAccountStore = fakeAccountStore{}
_ ports.EntitlementSnapshotStore = (*fakeSnapshotStore)(nil)
_ ports.EntitlementHistoryStore = (*fakeHistoryStore)(nil)
_ ports.EntitlementLifecycleStore = (*fakeLifecycleStore)(nil)
_ ports.Clock = fixedClock{}
_ ports.IDGenerator = fixedIDGenerator{}
)
+194
View File
@@ -0,0 +1,194 @@
// Package geosync implements the trusted geo-facing declared-country sync
// command owned by User Service.
package geosync
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/shared"
"galaxy/user/internal/telemetry"
"golang.org/x/text/language"
)
const geoProfileServiceSource = common.Source("geo_profile_service")
// SyncDeclaredCountryInput stores one trusted geo-facing country-sync request.
type SyncDeclaredCountryInput struct {
// UserID identifies the regular user whose current declared country must be
// synchronized.
UserID string
// DeclaredCountry stores the new current effective declared country.
DeclaredCountry string
}
// SyncDeclaredCountryResult stores one trusted geo-facing country-sync result.
type SyncDeclaredCountryResult struct {
// UserID identifies the synchronized user.
UserID string `json:"user_id"`
// DeclaredCountry stores the current effective declared country after the
// command completes.
DeclaredCountry string `json:"declared_country"`
// UpdatedAt stores the effective account mutation timestamp. Same-value
// no-op syncs return the current stored timestamp unchanged.
UpdatedAt time.Time `json:"updated_at"`
}
// SyncService executes the trusted geo-facing declared-country sync command.
type SyncService struct {
accounts ports.UserAccountStore
clock ports.Clock
publisher ports.DeclaredCountryChangedPublisher
logger *slog.Logger
telemetry *telemetry.Runtime
}
// NewSyncService constructs one trusted declared-country sync command.
func NewSyncService(
accounts ports.UserAccountStore,
clock ports.Clock,
publisher ports.DeclaredCountryChangedPublisher,
) (*SyncService, error) {
return NewSyncServiceWithObservability(accounts, clock, publisher, nil, nil)
}
// NewSyncServiceWithObservability constructs one trusted declared-country sync
// command with optional structured logging and event-publication metrics.
func NewSyncServiceWithObservability(
accounts ports.UserAccountStore,
clock ports.Clock,
publisher ports.DeclaredCountryChangedPublisher,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
) (*SyncService, error) {
switch {
case accounts == nil:
return nil, fmt.Errorf("geo declared-country sync service: user account store must not be nil")
case clock == nil:
return nil, fmt.Errorf("geo declared-country sync service: clock must not be nil")
case publisher == nil:
return nil, fmt.Errorf("geo declared-country sync service: declared-country changed publisher must not be nil")
default:
return &SyncService{
accounts: accounts,
clock: clock,
publisher: publisher,
logger: logger,
telemetry: telemetryRuntime,
}, nil
}
}
// Execute synchronizes the current effective declared country of one user.
func (service *SyncService) Execute(
ctx context.Context,
input SyncDeclaredCountryInput,
) (result SyncDeclaredCountryResult, err error) {
outcome := "failed"
userIDString := ""
defer func() {
shared.LogServiceOutcome(service.logger, ctx, "declared-country sync completed", err,
"use_case", "sync_declared_country",
"outcome", outcome,
"user_id", userIDString,
"source", geoProfileServiceSource.String(),
)
}()
if ctx == nil {
return SyncDeclaredCountryResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return SyncDeclaredCountryResult{}, err
}
userIDString = userID.String()
declaredCountry, err := parseDeclaredCountry(input.DeclaredCountry)
if err != nil {
return SyncDeclaredCountryResult{}, err
}
record, err := service.accounts.GetByUserID(ctx, userID)
switch {
case err == nil:
case errors.Is(err, ports.ErrNotFound):
return SyncDeclaredCountryResult{}, shared.SubjectNotFound()
default:
return SyncDeclaredCountryResult{}, shared.ServiceUnavailable(err)
}
if record.DeclaredCountry == declaredCountry {
outcome = "noop"
return resultFromAccount(record), nil
}
record.DeclaredCountry = declaredCountry
record.UpdatedAt = service.clock.Now().UTC()
if err := service.accounts.Update(ctx, record); err != nil {
switch {
case errors.Is(err, ports.ErrNotFound):
return SyncDeclaredCountryResult{}, shared.SubjectNotFound()
case errors.Is(err, ports.ErrConflict):
return SyncDeclaredCountryResult{}, shared.ServiceUnavailable(err)
default:
return SyncDeclaredCountryResult{}, shared.ServiceUnavailable(err)
}
}
result = resultFromAccount(record)
outcome = "updated"
if err := service.publisher.PublishDeclaredCountryChanged(ctx, ports.DeclaredCountryChangedEvent{
UserID: record.UserID,
DeclaredCountry: record.DeclaredCountry,
UpdatedAt: record.UpdatedAt,
Source: geoProfileServiceSource,
}); err != nil {
if service.telemetry != nil {
service.telemetry.RecordEventPublicationFailure(ctx, ports.DeclaredCountryChangedEventType)
}
shared.LogEventPublicationFailure(service.logger, ctx, ports.DeclaredCountryChangedEventType, err,
"use_case", "sync_declared_country",
"user_id", record.UserID.String(),
"source", geoProfileServiceSource.String(),
)
}
return result, nil
}
func parseDeclaredCountry(value string) (common.CountryCode, error) {
const message = "declared_country must be a valid ISO 3166-1 alpha-2 country code"
code := common.CountryCode(shared.NormalizeString(value))
if err := code.Validate(); err != nil {
return "", shared.InvalidRequest(message)
}
region, err := language.ParseRegion(code.String())
if err != nil || !region.IsCountry() || region.Canonicalize().String() != code.String() {
return "", shared.InvalidRequest(message)
}
return code, nil
}
func resultFromAccount(record account.UserAccount) SyncDeclaredCountryResult {
return SyncDeclaredCountryResult{
UserID: record.UserID.String(),
DeclaredCountry: record.DeclaredCountry.String(),
UpdatedAt: record.UpdatedAt.UTC(),
}
}
@@ -0,0 +1,299 @@
package geosync
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 TestSyncServiceExecuteUpdatesDeclaredCountryAndPublishesEvent(t *testing.T) {
t.Parallel()
createdAt := time.Unix(1_775_240_000, 0).UTC()
updatedAt := createdAt.Add(5 * time.Minute)
record := validAccountRecord(createdAt, createdAt)
store := newFakeAccountStore(record)
publisher := &recordingDeclaredCountryChangedPublisher{
publishHook: func(event ports.DeclaredCountryChangedEvent) error {
stored, err := store.GetByUserID(context.Background(), record.UserID)
require.NoError(t, err)
require.Equal(t, common.CountryCode("FR"), stored.DeclaredCountry)
require.Equal(t, updatedAt, stored.UpdatedAt)
require.Equal(t, common.Source("geo_profile_service"), event.Source)
return nil
},
}
service, err := NewSyncService(store, fixedClock{now: updatedAt}, publisher)
require.NoError(t, err)
result, err := service.Execute(context.Background(), SyncDeclaredCountryInput{
UserID: record.UserID.String(),
DeclaredCountry: "FR",
})
require.NoError(t, err)
require.Equal(t, record.UserID.String(), result.UserID)
require.Equal(t, "FR", result.DeclaredCountry)
require.Equal(t, updatedAt, result.UpdatedAt)
require.Equal(t, 1, store.updateCalls)
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.PreferredLanguage, stored.PreferredLanguage)
require.Equal(t, record.TimeZone, stored.TimeZone)
require.Equal(t, common.CountryCode("FR"), stored.DeclaredCountry)
require.Equal(t, record.CreatedAt, stored.CreatedAt)
require.Equal(t, updatedAt, stored.UpdatedAt)
published := publisher.PublishedEvents()
require.Len(t, published, 1)
require.Equal(t, record.UserID, published[0].UserID)
require.Equal(t, common.CountryCode("FR"), published[0].DeclaredCountry)
require.Equal(t, updatedAt, published[0].UpdatedAt)
require.Equal(t, common.Source("geo_profile_service"), published[0].Source)
}
func TestSyncServiceExecuteSameCountryIsNoOp(t *testing.T) {
t.Parallel()
createdAt := time.Unix(1_775_240_000, 0).UTC()
record := validAccountRecord(createdAt, createdAt.Add(5*time.Minute))
store := newFakeAccountStore(record)
publisher := &recordingDeclaredCountryChangedPublisher{}
service, err := NewSyncService(store, fixedClock{now: createdAt.Add(time.Hour)}, publisher)
require.NoError(t, err)
result, err := service.Execute(context.Background(), SyncDeclaredCountryInput{
UserID: record.UserID.String(),
DeclaredCountry: record.DeclaredCountry.String(),
})
require.NoError(t, err)
require.Equal(t, record.UserID.String(), result.UserID)
require.Equal(t, record.DeclaredCountry.String(), result.DeclaredCountry)
require.Equal(t, record.UpdatedAt, result.UpdatedAt)
require.Zero(t, store.updateCalls)
require.Empty(t, publisher.PublishedEvents())
}
func TestSyncServiceExecuteRejectsInvalidDeclaredCountry(t *testing.T) {
t.Parallel()
service, err := NewSyncService(
newFakeAccountStore(validAccountRecord(time.Unix(1_775_240_000, 0).UTC(), time.Unix(1_775_240_000, 0).UTC())),
fixedClock{now: time.Unix(1_775_240_000, 0).UTC()},
&recordingDeclaredCountryChangedPublisher{},
)
require.NoError(t, err)
tests := []struct {
name string
value string
}{
{name: "alias country code", value: "UK"},
{name: "lowercase", value: "de"},
{name: "non-country region", value: "EU"},
{name: "wrong length", value: "DEU"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := service.Execute(context.Background(), SyncDeclaredCountryInput{
UserID: "user-123",
DeclaredCountry: tt.value,
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
require.EqualError(t, err, "declared_country must be a valid ISO 3166-1 alpha-2 country code")
})
}
}
func TestSyncServiceExecuteUnknownUserReturnsNotFound(t *testing.T) {
t.Parallel()
service, err := NewSyncService(
newFakeAccountStore(),
fixedClock{now: time.Unix(1_775_240_000, 0).UTC()},
&recordingDeclaredCountryChangedPublisher{},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), SyncDeclaredCountryInput{
UserID: "user-missing",
DeclaredCountry: "DE",
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
}
func TestSyncServiceExecutePublisherFailureDoesNotRollbackCommit(t *testing.T) {
t.Parallel()
createdAt := time.Unix(1_775_240_000, 0).UTC()
updatedAt := createdAt.Add(time.Minute)
record := validAccountRecord(createdAt, createdAt)
store := newFakeAccountStore(record)
publisher := &recordingDeclaredCountryChangedPublisher{
err: errors.New("publisher unavailable"),
}
service, err := NewSyncService(store, fixedClock{now: updatedAt}, publisher)
require.NoError(t, err)
result, err := service.Execute(context.Background(), SyncDeclaredCountryInput{
UserID: record.UserID.String(),
DeclaredCountry: "FR",
})
require.NoError(t, err)
require.Equal(t, "FR", result.DeclaredCountry)
require.Equal(t, updatedAt, result.UpdatedAt)
stored, err := store.GetByUserID(context.Background(), record.UserID)
require.NoError(t, err)
require.Equal(t, common.CountryCode("FR"), stored.DeclaredCountry)
require.Equal(t, updatedAt, stored.UpdatedAt)
published := publisher.PublishedEvents()
require.Len(t, published, 1)
require.Equal(t, common.CountryCode("FR"), published[0].DeclaredCountry)
}
type fakeAccountStore struct {
records map[common.UserID]account.UserAccount
updateCalls int
updateErr error
}
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, ports.CreateAccountInput) error {
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, ports.RenameRaceNameInput) error {
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 recordingDeclaredCountryChangedPublisher struct {
err error
publishHook func(event ports.DeclaredCountryChangedEvent) error
published []ports.DeclaredCountryChangedEvent
}
func (publisher *recordingDeclaredCountryChangedPublisher) PublishDeclaredCountryChanged(
_ context.Context,
event ports.DeclaredCountryChangedEvent,
) error {
if err := event.Validate(); err != nil {
return err
}
publisher.published = append(publisher.published, event)
if publisher.publishHook != nil {
if err := publisher.publishHook(event); err != nil {
return err
}
}
return publisher.err
}
func (publisher *recordingDeclaredCountryChangedPublisher) PublishedEvents() []ports.DeclaredCountryChangedEvent {
events := make([]ports.DeclaredCountryChangedEvent, len(publisher.published))
copy(events, publisher.published)
return events
}
type fixedClock struct {
now time.Time
}
func (clock fixedClock) Now() time.Time {
return clock.now
}
func validAccountRecord(createdAt time.Time, updatedAt time.Time) account.UserAccount {
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: updatedAt,
}
}
var (
_ ports.UserAccountStore = (*fakeAccountStore)(nil)
_ ports.DeclaredCountryChangedPublisher = (*recordingDeclaredCountryChangedPublisher)(nil)
_ ports.Clock = fixedClock{}
)
@@ -0,0 +1,397 @@
// Package lobbyeligibility implements the trusted lobby-facing eligibility
// snapshot read owned by User Service.
package lobbyeligibility
import (
"context"
"errors"
"fmt"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/shared"
)
// limitCatalogEntry stores one frozen default quota for free and paid
// entitlement states.
type limitCatalogEntry struct {
code policy.LimitCode
freeValue int
paidValue int
freeEnabled bool
}
// limitCatalog stores the frozen lobby-facing effective limit defaults used
// to materialize numeric quotas from the current entitlement state.
var limitCatalog = []limitCatalogEntry{
{
code: policy.LimitCodeMaxOwnedPrivateGames,
paidValue: 3,
},
{
code: policy.LimitCodeMaxPendingPublicApplications,
freeValue: 3,
paidValue: 10,
freeEnabled: true,
},
{
code: policy.LimitCodeMaxActiveGameMemberships,
freeValue: 3,
paidValue: 10,
freeEnabled: true,
},
}
// ActorRefView stores transport-ready audit actor metadata.
type ActorRefView struct {
// Type stores the machine-readable actor type.
Type string `json:"type"`
// ID stores the optional stable actor identifier.
ID string `json:"id,omitempty"`
}
// EntitlementSnapshotView stores the transport-ready current entitlement
// snapshot used by lobby reads.
type EntitlementSnapshotView struct {
// PlanCode stores the effective entitlement plan code.
PlanCode string `json:"plan_code"`
// IsPaid reports whether the effective plan is paid.
IsPaid bool `json:"is_paid"`
// Source stores the machine-readable mutation source.
Source string `json:"source"`
// Actor stores the audit actor metadata attached to the snapshot.
Actor ActorRefView `json:"actor"`
// ReasonCode stores the machine-readable reason attached to the snapshot.
ReasonCode string `json:"reason_code"`
// StartsAt stores when the effective state started.
StartsAt time.Time `json:"starts_at"`
// EndsAt stores the optional finite effective expiry.
EndsAt *time.Time `json:"ends_at,omitempty"`
// UpdatedAt stores when the snapshot was last recomputed.
UpdatedAt time.Time `json:"updated_at"`
}
// ActiveSanctionView stores one transport-ready active sanction that matters
// to lobby flows.
type ActiveSanctionView struct {
// SanctionCode stores the active sanction code.
SanctionCode string `json:"sanction_code"`
// Scope stores the machine-readable sanction scope.
Scope string `json:"scope"`
// ReasonCode stores the machine-readable sanction reason.
ReasonCode string `json:"reason_code"`
// Actor stores the audit actor metadata attached to the sanction.
Actor ActorRefView `json:"actor"`
// AppliedAt stores when the sanction became active.
AppliedAt time.Time `json:"applied_at"`
// ExpiresAt stores the optional planned sanction expiry.
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// EffectiveLimitView stores one materialized effective lobby quota.
type EffectiveLimitView struct {
// LimitCode stores the machine-readable quota identifier.
LimitCode string `json:"limit_code"`
// Value stores the effective numeric quota after defaults and user
// overrides are applied.
Value int `json:"value"`
}
// EligibilityMarkersView stores the derived booleans consumed by Game Lobby.
type EligibilityMarkersView struct {
// CanLogin reports whether the user may currently log in.
CanLogin bool `json:"can_login"`
// CanCreatePrivateGame reports whether the user may currently create a
// private game.
CanCreatePrivateGame bool `json:"can_create_private_game"`
// CanManagePrivateGame reports whether the user may currently manage a
// private game.
CanManagePrivateGame bool `json:"can_manage_private_game"`
// CanJoinGame reports whether the user may currently join a game.
CanJoinGame bool `json:"can_join_game"`
// CanUpdateProfile reports whether the user may currently update self-
// service profile and settings fields.
CanUpdateProfile bool `json:"can_update_profile"`
}
// GetUserEligibilityInput stores one lobby-facing eligibility read request.
type GetUserEligibilityInput struct {
// UserID identifies the regular user whose effective lobby state is needed.
UserID string
}
// GetUserEligibilityResult stores one lobby-facing eligibility snapshot.
type GetUserEligibilityResult struct {
// Exists reports whether UserID currently identifies a stored user.
Exists bool `json:"exists"`
// UserID echoes the requested stable user identifier.
UserID string `json:"user_id"`
// Entitlement stores the current effective entitlement snapshot for known
// users.
Entitlement *EntitlementSnapshotView `json:"entitlement,omitempty"`
// ActiveSanctions stores only the currently active sanctions relevant to
// lobby decisions.
ActiveSanctions []ActiveSanctionView `json:"active_sanctions"`
// EffectiveLimits stores the materialized numeric quotas used by Game
// Lobby.
EffectiveLimits []EffectiveLimitView `json:"effective_limits"`
// Markers stores the derived decision booleans consumed by Game Lobby.
Markers EligibilityMarkersView `json:"markers"`
}
type entitlementReader interface {
GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error)
}
// SnapshotReader executes the trusted lobby-facing eligibility snapshot read.
type SnapshotReader struct {
accounts ports.UserAccountStore
entitlements entitlementReader
sanctions ports.SanctionStore
limits ports.LimitStore
clock ports.Clock
}
// NewSnapshotReader constructs one lobby-facing eligibility snapshot reader.
func NewSnapshotReader(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
) (*SnapshotReader, error) {
switch {
case accounts == nil:
return nil, fmt.Errorf("lobby eligibility snapshot reader: user account store must not be nil")
case entitlements == nil:
return nil, fmt.Errorf("lobby eligibility snapshot reader: entitlement reader must not be nil")
case sanctions == nil:
return nil, fmt.Errorf("lobby eligibility snapshot reader: sanction store must not be nil")
case limits == nil:
return nil, fmt.Errorf("lobby eligibility snapshot reader: limit store must not be nil")
case clock == nil:
return nil, fmt.Errorf("lobby eligibility snapshot reader: clock must not be nil")
default:
return &SnapshotReader{
accounts: accounts,
entitlements: entitlements,
sanctions: sanctions,
limits: limits,
clock: clock,
}, nil
}
}
// Execute returns one read-optimized eligibility snapshot for Game Lobby.
func (service *SnapshotReader) Execute(
ctx context.Context,
input GetUserEligibilityInput,
) (GetUserEligibilityResult, error) {
if ctx == nil {
return GetUserEligibilityResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return GetUserEligibilityResult{}, err
}
result := GetUserEligibilityResult{
UserID: userID.String(),
ActiveSanctions: []ActiveSanctionView{},
EffectiveLimits: []EffectiveLimitView{},
}
exists, err := service.accounts.ExistsByUserID(ctx, userID)
if err != nil {
return GetUserEligibilityResult{}, shared.ServiceUnavailable(err)
}
if !exists {
return result, nil
}
now := service.clock.Now().UTC()
entitlementSnapshot, err := service.entitlements.GetByUserID(ctx, userID)
switch {
case err == nil:
case errors.Is(err, ports.ErrNotFound):
return GetUserEligibilityResult{}, shared.InternalError(fmt.Errorf("user %q is missing entitlement snapshot", userID))
default:
return GetUserEligibilityResult{}, shared.ServiceUnavailable(err)
}
sanctionRecords, err := service.sanctions.ListByUserID(ctx, userID)
if err != nil {
return GetUserEligibilityResult{}, shared.ServiceUnavailable(err)
}
activeSanctions, err := policy.ActiveSanctionsAt(sanctionRecords, now)
if err != nil {
return GetUserEligibilityResult{}, shared.InternalError(fmt.Errorf("evaluate active sanctions for user %q: %w", userID, err))
}
limitRecords, err := service.limits.ListByUserID(ctx, userID)
if err != nil {
return GetUserEligibilityResult{}, shared.ServiceUnavailable(err)
}
activeLimits, err := policy.ActiveLimitsAt(limitRecords, now)
if err != nil {
return GetUserEligibilityResult{}, shared.InternalError(fmt.Errorf("evaluate active limits for user %q: %w", userID, err))
}
result.Exists = true
result.Entitlement = entitlementSnapshotView(entitlementSnapshot)
result.ActiveSanctions = lobbyRelevantSanctionViews(activeSanctions)
result.EffectiveLimits = materializeEffectiveLimits(entitlementSnapshot.IsPaid, activeLimits)
result.Markers = deriveEligibilityMarkers(entitlementSnapshot.IsPaid, activeSanctions)
return result, nil
}
func entitlementSnapshotView(snapshot entitlement.CurrentSnapshot) *EntitlementSnapshotView {
return &EntitlementSnapshotView{
PlanCode: string(snapshot.PlanCode),
IsPaid: snapshot.IsPaid,
Source: snapshot.Source.String(),
Actor: actorRefView(snapshot.Actor),
ReasonCode: snapshot.ReasonCode.String(),
StartsAt: snapshot.StartsAt.UTC(),
EndsAt: cloneOptionalTime(snapshot.EndsAt),
UpdatedAt: snapshot.UpdatedAt.UTC(),
}
}
func lobbyRelevantSanctionViews(records []policy.SanctionRecord) []ActiveSanctionView {
views := make([]ActiveSanctionView, 0, len(records))
for _, record := range records {
if !isLobbyRelevantSanction(record.SanctionCode) {
continue
}
views = append(views, ActiveSanctionView{
SanctionCode: string(record.SanctionCode),
Scope: record.Scope.String(),
ReasonCode: record.ReasonCode.String(),
Actor: actorRefView(record.Actor),
AppliedAt: record.AppliedAt.UTC(),
ExpiresAt: cloneOptionalTime(record.ExpiresAt),
})
}
return views
}
func materializeEffectiveLimits(isPaid bool, overrides []policy.LimitRecord) []EffectiveLimitView {
overrideValues := make(map[policy.LimitCode]int, len(overrides))
for _, record := range overrides {
overrideValues[record.LimitCode] = record.Value
}
limits := make([]EffectiveLimitView, 0, len(limitCatalog))
for _, entry := range limitCatalog {
if !isPaid && !entry.freeEnabled {
continue
}
value := entry.freeValue
if isPaid {
value = entry.paidValue
}
if override, ok := overrideValues[entry.code]; ok {
value = override
}
limits = append(limits, EffectiveLimitView{
LimitCode: string(entry.code),
Value: value,
})
}
return limits
}
func deriveEligibilityMarkers(
isPaid bool,
activeSanctions []policy.SanctionRecord,
) EligibilityMarkersView {
loginBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodeLoginBlock)
createBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodePrivateGameCreateBlock)
manageBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodePrivateGameManageBlock)
joinBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodeGameJoinBlock)
profileBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodeProfileUpdateBlock)
canLogin := !loginBlocked
return EligibilityMarkersView{
CanLogin: canLogin,
CanCreatePrivateGame: canLogin && isPaid && !createBlocked,
CanManagePrivateGame: canLogin && isPaid && !manageBlocked,
CanJoinGame: canLogin && !joinBlocked,
CanUpdateProfile: canLogin && !profileBlocked,
}
}
func hasActiveSanction(records []policy.SanctionRecord, code policy.SanctionCode) bool {
for _, record := range records {
if record.SanctionCode == code {
return true
}
}
return false
}
func isLobbyRelevantSanction(code policy.SanctionCode) bool {
switch code {
case policy.SanctionCodeLoginBlock,
policy.SanctionCodePrivateGameCreateBlock,
policy.SanctionCodePrivateGameManageBlock,
policy.SanctionCodeGameJoinBlock:
return true
default:
return false
}
}
func actorRefView(actor common.ActorRef) ActorRefView {
return ActorRefView{
Type: actor.Type.String(),
ID: actor.ID.String(),
}
}
func cloneOptionalTime(value *time.Time) *time.Time {
if value == nil {
return nil
}
cloned := value.UTC()
return &cloned
}
@@ -0,0 +1,524 @@
package lobbyeligibility
import (
"context"
"testing"
"time"
"galaxy/user/internal/adapters/redis/userstore"
"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"
"github.com/alicebob/miniredis/v2"
"github.com/stretchr/testify/require"
)
func TestSnapshotReaderExecuteReturnsStableNotFound(t *testing.T) {
t.Parallel()
service, err := NewSnapshotReader(
fakeAccountStore{existsByUserID: map[common.UserID]bool{}},
fakeEntitlementReader{},
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: time.Unix(1_775_240_500, 0).UTC()},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GetUserEligibilityInput{UserID: " user-missing "})
require.NoError(t, err)
require.False(t, result.Exists)
require.Equal(t, "user-missing", result.UserID)
require.Nil(t, result.Entitlement)
require.Empty(t, result.ActiveSanctions)
require.Empty(t, result.EffectiveLimits)
require.Equal(t, EligibilityMarkersView{}, result.Markers)
}
func TestSnapshotReaderExecuteBuildsPaidSnapshotAndDerivedState(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
userID := common.UserID("user-123")
service, err := NewSnapshotReader(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
fakeEntitlementReader{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
userID: paidEntitlementSnapshot(userID, now.Add(-24*time.Hour), now.Add(24*time.Hour)),
},
},
fakeSanctionStore{
byUserID: map[common.UserID][]policy.SanctionRecord{
userID: {
activeSanction(userID, policy.SanctionCodePrivateGameManageBlock, "lobby", now.Add(-time.Hour)),
activeSanction(userID, policy.SanctionCodeProfileUpdateBlock, "profile", now.Add(-30*time.Minute)),
expiredSanction(userID, policy.SanctionCodeGameJoinBlock, "lobby", now.Add(-2*time.Hour)),
},
},
},
fakeLimitStore{
byUserID: map[common.UserID][]policy.LimitRecord{
userID: {
activeLimit(userID, policy.LimitCodeMaxPendingPrivateInvitesSent, 17, now.Add(-time.Hour)),
activeLimit(userID, policy.LimitCodeMaxActivePrivateGames, 2, now.Add(-2*time.Hour)),
},
},
},
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.NotNil(t, result.Entitlement)
require.Equal(t, "paid_monthly", result.Entitlement.PlanCode)
require.True(t, result.Entitlement.IsPaid)
require.Len(t, result.ActiveSanctions, 1)
require.Equal(t, "private_game_manage_block", result.ActiveSanctions[0].SanctionCode)
require.Equal(t, EligibilityMarkersView{
CanLogin: true,
CanCreatePrivateGame: true,
CanManagePrivateGame: false,
CanJoinGame: true,
CanUpdateProfile: false,
}, result.Markers)
require.Equal(t, []EffectiveLimitView{
{LimitCode: "max_owned_private_games", Value: 3},
{LimitCode: "max_pending_public_applications", Value: 10},
{LimitCode: "max_active_game_memberships", Value: 10},
}, result.EffectiveLimits)
}
func TestSnapshotReaderExecuteDeniesUnpaidAndLoginBlockedUsers(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
limits []policy.LimitRecord
wantSanctions []string
wantMarkers EligibilityMarkersView
wantLimits []EffectiveLimitView
}{
{
name: "unpaid defaults",
snapshot: freeEntitlementSnapshot(userID, now.Add(-24*time.Hour)),
limits: []policy.LimitRecord{activeLimit(userID, policy.LimitCodeMaxOwnedPrivateGames, 9, now.Add(-time.Hour))},
wantSanctions: []string{},
wantMarkers: EligibilityMarkersView{
CanLogin: true,
CanCreatePrivateGame: false,
CanManagePrivateGame: false,
CanJoinGame: true,
CanUpdateProfile: true,
},
wantLimits: []EffectiveLimitView{
{LimitCode: "max_pending_public_applications", Value: 3},
{LimitCode: "max_active_game_memberships", Value: 3},
},
},
{
name: "login block denies all markers",
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.SanctionCodeGameJoinBlock, "lobby", now.Add(-30*time.Minute)),
},
wantSanctions: []string{"game_join_block", "login_block"},
wantMarkers: EligibilityMarkersView{
CanLogin: false,
CanCreatePrivateGame: false,
CanManagePrivateGame: false,
CanJoinGame: false,
CanUpdateProfile: false,
},
wantLimits: []EffectiveLimitView{
{LimitCode: "max_owned_private_games", Value: 3},
{LimitCode: "max_pending_public_applications", Value: 10},
{LimitCode: "max_active_game_memberships", Value: 10},
},
},
}
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{byUserID: map[common.UserID][]policy.LimitRecord{userID: tt.limits}},
fixedClock{now: now},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GetUserEligibilityInput{UserID: userID.String()})
require.NoError(t, err)
require.Equal(t, tt.wantMarkers, result.Markers)
require.Equal(t, tt.wantLimits, result.EffectiveLimits)
gotSanctions := make([]string, 0, len(result.ActiveSanctions))
for _, sanction := range result.ActiveSanctions {
gotSanctions = append(gotSanctions, sanction.SanctionCode)
}
require.Equal(t, tt.wantSanctions, gotSanctions)
})
}
}
func TestSnapshotReaderExecuteRepairsExpiredPaidSnapshotWithStore(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
store := newRedisStore(t)
userID := common.UserID("user-123")
accountRecord := validAccountRecord()
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)
require.NoError(t, store.EntitlementSnapshots().Put(context.Background(), entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodePaidMonthly,
IsPaid: true,
StartsAt: now.Add(-30 * 24 * time.Hour),
EndsAt: timePointer(expiredEndsAt),
Source: common.Source("billing"),
Actor: common.ActorRef{Type: common.ActorType("billing"), ID: common.ActorID("invoice-1")},
ReasonCode: common.ReasonCode("renewal"),
UpdatedAt: now.Add(-2 * time.Hour),
}))
entitlementReader, err := entitlementsvc.NewReader(
store.EntitlementSnapshots(),
store.EntitlementLifecycle(),
fixedClock{now: now},
fixedIDGenerator{entitlementRecordID: entitlement.EntitlementRecordID("entitlement-expiry-repair")},
)
require.NoError(t, err)
service, err := NewSnapshotReader(
store.Accounts(),
entitlementReader,
store.Sanctions(),
store.Limits(),
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.NotNil(t, result.Entitlement)
require.Equal(t, "free", result.Entitlement.PlanCode)
require.False(t, result.Entitlement.IsPaid)
require.Equal(t, expiredEndsAt, result.Entitlement.StartsAt)
require.Equal(t, []EffectiveLimitView{
{LimitCode: "max_pending_public_applications", Value: 3},
{LimitCode: "max_active_game_memberships", Value: 3},
}, result.EffectiveLimits)
storedSnapshot, err := store.EntitlementSnapshots().GetByUserID(context.Background(), userID)
require.NoError(t, err)
require.Equal(t, entitlement.PlanCodeFree, storedSnapshot.PlanCode)
require.False(t, storedSnapshot.IsPaid)
}
type fakeAccountStore struct {
existsByUserID map[common.UserID]bool
err error
}
func (store fakeAccountStore) Create(context.Context, ports.CreateAccountInput) error {
return nil
}
func (store fakeAccountStore) GetByUserID(context.Context, common.UserID) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) GetByEmail(context.Context, common.Email) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) GetByRaceName(context.Context, common.RaceName) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
if store.err != nil {
return false, store.err
}
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
}
type fakeEntitlementReader struct {
byUserID map[common.UserID]entitlement.CurrentSnapshot
err error
}
func (reader fakeEntitlementReader) GetByUserID(_ context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) {
if reader.err != nil {
return entitlement.CurrentSnapshot{}, reader.err
}
record, ok := reader.byUserID[userID]
if !ok {
return entitlement.CurrentSnapshot{}, ports.ErrNotFound
}
return record, 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 fixedIDGenerator struct {
entitlementRecordID entitlement.EntitlementRecordID
}
func (generator fixedIDGenerator) NewUserID() (common.UserID, error) {
return "", nil
}
func (generator fixedIDGenerator) NewInitialRaceName() (common.RaceName, error) {
return "", nil
}
func (generator fixedIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
return generator.entitlementRecordID, nil
}
func (generator fixedIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
return "", nil
}
func (generator fixedIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
return "", nil
}
func activeSanction(
userID common.UserID,
code policy.SanctionCode,
scope string,
appliedAt time.Time,
) policy.SanctionRecord {
return policy.SanctionRecord{
RecordID: policy.SanctionRecordID("sanction-" + string(code)),
UserID: userID,
SanctionCode: code,
Scope: common.Scope(scope),
ReasonCode: common.ReasonCode("manual_block"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: appliedAt.UTC(),
}
}
func expiredSanction(
userID common.UserID,
code policy.SanctionCode,
scope string,
appliedAt time.Time,
) policy.SanctionRecord {
record := activeSanction(userID, code, scope, appliedAt)
expiresAt := appliedAt.Add(30 * time.Minute)
record.ExpiresAt = &expiresAt
return record
}
func activeLimit(
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("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: appliedAt.UTC(),
}
}
func removedLimit(
userID common.UserID,
code policy.LimitCode,
value int,
appliedAt time.Time,
) policy.LimitRecord {
record := activeLimit(userID, code, value, appliedAt)
removedAt := appliedAt.Add(15 * time.Minute)
record.RemovedAt = &removedAt
record.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")}
record.RemovedReasonCode = common.ReasonCode("manual_remove")
return record
}
func paidEntitlementSnapshot(
userID common.UserID,
startsAt time.Time,
endsAt time.Time,
) entitlement.CurrentSnapshot {
return entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodePaidMonthly,
IsPaid: true,
StartsAt: startsAt.UTC(),
EndsAt: timePointer(endsAt),
Source: common.Source("billing"),
Actor: common.ActorRef{Type: common.ActorType("billing"), ID: common.ActorID("invoice-1")},
ReasonCode: common.ReasonCode("renewal"),
UpdatedAt: startsAt.UTC(),
}
}
func freeEntitlementSnapshot(userID common.UserID, startsAt time.Time) entitlement.CurrentSnapshot {
return entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: startsAt.UTC(),
Source: common.Source("auth_registration"),
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
ReasonCode: common.ReasonCode("initial_free_entitlement"),
UpdatedAt: startsAt.UTC(),
}
}
func validAccountRecord() 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"),
CreatedAt: createdAt,
UpdatedAt: createdAt,
}
}
func newRedisStore(t *testing.T) *userstore.Store {
t.Helper()
server := miniredis.RunT(t)
store, err := userstore.New(userstore.Config{
Addr: server.Addr(),
DB: 0,
KeyspacePrefix: "user:test:",
OperationTimeout: 250 * time.Millisecond,
})
require.NoError(t, err)
t.Cleanup(func() {
_ = store.Close()
})
return store
}
func timePointer(value time.Time) *time.Time {
utcValue := value.UTC()
return &utcValue
}
var _ ports.UserAccountStore = fakeAccountStore{}
var _ ports.SanctionStore = fakeSanctionStore{}
var _ ports.LimitStore = fakeLimitStore{}
var _ ports.Clock = fixedClock{}
var _ ports.IDGenerator = fixedIDGenerator{}
@@ -0,0 +1,178 @@
package policysvc
import (
"context"
"testing"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"github.com/stretchr/testify/require"
)
func TestApplySanctionServiceExecutePublishesEvent(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{}
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,
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), ApplySanctionInput{
UserID: userID.String(),
SanctionCode: string(policy.SanctionCodeLoginBlock),
Scope: "auth",
ReasonCode: "policy_blocked",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
AppliedAt: now.Add(-time.Minute).Format(time.RFC3339Nano),
ExpiresAt: now.Add(time.Hour).Format(time.RFC3339Nano),
})
require.NoError(t, err)
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)
}
func TestRemoveSanctionServiceExecuteMissingDoesNotPublishEvent(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{}
service, 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 = service.Execute(context.Background(), RemoveSanctionInput{
UserID: userID.String(),
SanctionCode: string(policy.SanctionCodeLoginBlock),
ReasonCode: "manual_remove",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
})
require.NoError(t, err)
require.Empty(t, publisher.sanctionEvents)
}
func TestSetLimitServiceExecutePublishesEvent(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{}
service, err := NewSetLimitServiceWithObservability(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{limitRecordID: policy.LimitRecordID("limit-1")},
nil,
nil,
publisher,
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), SetLimitInput{
UserID: userID.String(),
LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames),
Value: 5,
ReasonCode: "manual_override",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
AppliedAt: now.Add(-time.Minute).Format(time.RFC3339Nano),
ExpiresAt: now.Add(time.Hour).Format(time.RFC3339Nano),
})
require.NoError(t, err)
require.Len(t, publisher.limitEvents, 1)
require.Equal(t, ports.LimitChangedOperationSet, publisher.limitEvents[0].Operation)
require.NotNil(t, publisher.limitEvents[0].Value)
require.Equal(t, 5, *publisher.limitEvents[0].Value)
}
func TestRemoveLimitServiceExecuteMissingDoesNotPublishEvent(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{}
service, err := NewRemoveLimitServiceWithObservability(
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 = service.Execute(context.Background(), RemoveLimitInput{
UserID: userID.String(),
LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames),
ReasonCode: "manual_remove",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
})
require.NoError(t, err)
require.Empty(t, publisher.limitEvents)
}
type recordingPolicyPublisher struct {
sanctionEvents []ports.SanctionChangedEvent
limitEvents []ports.LimitChangedEvent
}
func (publisher *recordingPolicyPublisher) PublishSanctionChanged(_ context.Context, event ports.SanctionChangedEvent) error {
if err := event.Validate(); err != nil {
return err
}
publisher.sanctionEvents = append(publisher.sanctionEvents, event)
return nil
}
func (publisher *recordingPolicyPublisher) PublishLimitChanged(_ context.Context, event ports.LimitChangedEvent) error {
if err := event.Validate(); err != nil {
return err
}
publisher.limitEvents = append(publisher.limitEvents, event)
return nil
}
var (
_ ports.SanctionChangedPublisher = (*recordingPolicyPublisher)(nil)
_ ports.LimitChangedPublisher = (*recordingPolicyPublisher)(nil)
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,705 @@
package policysvc
import (
"context"
"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/shared"
"github.com/stretchr/testify/require"
)
func TestApplySanctionServiceExecuteBuildsActiveRecord(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
service, err := NewApplySanctionService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), ApplySanctionInput{
UserID: userID.String(),
SanctionCode: string(policy.SanctionCodeLoginBlock),
Scope: "auth",
ReasonCode: "policy_blocked",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
AppliedAt: now.Add(-time.Minute).Format(time.RFC3339Nano),
ExpiresAt: now.Add(time.Hour).Format(time.RFC3339Nano),
})
require.NoError(t, err)
require.Equal(t, userID.String(), result.UserID)
require.Len(t, result.ActiveSanctions, 1)
require.Equal(t, string(policy.SanctionCodeLoginBlock), result.ActiveSanctions[0].SanctionCode)
records, err := sanctionStore.ListByUserID(context.Background(), userID)
require.NoError(t, err)
require.Len(t, records, 1)
require.Equal(t, policy.SanctionRecordID("sanction-1"), records[0].RecordID)
}
func TestApplySanctionServiceExecuteRejectsExpiredSanction(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
service, err := NewApplySanctionService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), ApplySanctionInput{
UserID: userID.String(),
SanctionCode: string(policy.SanctionCodeLoginBlock),
Scope: "auth",
ReasonCode: "policy_blocked",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
AppliedAt: now.Add(-2 * time.Hour).Format(time.RFC3339Nano),
ExpiresAt: now.Add(-time.Minute).Format(time.RFC3339Nano),
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
}
func TestApplySanctionServiceExecuteReturnsConflictWhenActiveSanctionExists(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
existing := policy.SanctionRecord{
RecordID: policy.SanctionRecordID("sanction-existing"),
UserID: userID,
SanctionCode: policy.SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy_blocked"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Hour),
}
require.NoError(t, sanctionStore.Create(context.Background(), existing))
service, err := NewApplySanctionService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
newFakeLimitStore(),
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: newFakeLimitStore()},
fixedClock{now: now},
fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), ApplySanctionInput{
UserID: userID.String(),
SanctionCode: string(policy.SanctionCodeLoginBlock),
Scope: "auth",
ReasonCode: "policy_blocked",
Actor: ActorInput{Type: "admin", ID: "admin-2"},
AppliedAt: now.Add(-time.Minute).Format(time.RFC3339Nano),
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeConflict, shared.CodeOf(err))
}
func TestApplySanctionServiceExecuteReturnsNotFoundForUnknownUser(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
service, err := NewApplySanctionService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{}},
newFakeSanctionStore(),
newFakeLimitStore(),
&fakePolicyLifecycleStore{sanctions: newFakeSanctionStore(), limits: newFakeLimitStore()},
fixedClock{now: now},
fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), ApplySanctionInput{
UserID: "user-missing",
SanctionCode: string(policy.SanctionCodeLoginBlock),
Scope: "auth",
ReasonCode: "policy_blocked",
Actor: ActorInput{Type: "admin"},
AppliedAt: now.Format(time.RFC3339Nano),
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
}
func TestRemoveSanctionServiceExecuteIsIdempotentWhenMissing(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
service, err := NewRemoveSanctionService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), RemoveSanctionInput{
UserID: userID.String(),
SanctionCode: string(policy.SanctionCodeLoginBlock),
ReasonCode: "manual_remove",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
})
require.NoError(t, err)
require.Equal(t, userID.String(), result.UserID)
require.Empty(t, result.ActiveSanctions)
}
func TestRemoveSanctionServiceExecuteTreatsConcurrentRemovalAsSuccess(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
record := policy.SanctionRecord{
RecordID: policy.SanctionRecordID("sanction-1"),
UserID: userID,
SanctionCode: policy.SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy_blocked"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Hour),
}
require.NoError(t, sanctionStore.Create(context.Background(), record))
lifecycle := &fakePolicyLifecycleStore{
sanctions: sanctionStore,
limits: limitStore,
removeSanctionHook: func(input ports.RemoveSanctionInput) error {
updated := input.ExpectedActiveRecord
removedAt := now.Add(-time.Minute)
updated.RemovedAt = &removedAt
updated.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")}
updated.RemovedReasonCode = common.ReasonCode("manual_remove")
if err := sanctionStore.Update(context.Background(), updated); err != nil {
return err
}
return ports.ErrConflict
},
}
service, err := NewRemoveSanctionService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
lifecycle,
fixedClock{now: now},
fixedIDGenerator{},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), RemoveSanctionInput{
UserID: userID.String(),
SanctionCode: string(policy.SanctionCodeLoginBlock),
ReasonCode: "manual_remove",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
})
require.NoError(t, err)
require.Empty(t, result.ActiveSanctions)
}
func TestSetLimitServiceExecuteReplacesActiveLimit(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
current := policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-existing"),
UserID: userID,
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
Value: 3,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Hour),
}
require.NoError(t, limitStore.Create(context.Background(), current))
service, err := NewSetLimitService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{limitRecordID: policy.LimitRecordID("limit-new")},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), SetLimitInput{
UserID: userID.String(),
LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames),
Value: 5,
ReasonCode: "manual_override",
Actor: ActorInput{Type: "admin", ID: "admin-2"},
AppliedAt: now.Format(time.RFC3339Nano),
})
require.NoError(t, err)
require.Len(t, result.ActiveLimits, 1)
require.Equal(t, 5, result.ActiveLimits[0].Value)
storedCurrent, err := limitStore.GetByRecordID(context.Background(), current.RecordID)
require.NoError(t, err)
require.NotNil(t, storedCurrent.RemovedAt)
require.True(t, storedCurrent.RemovedAt.Equal(now))
}
func TestSetLimitServiceExecuteRejectsRetroactiveReplacement(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
limitStore := newFakeLimitStore()
current := policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-existing"),
UserID: userID,
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
Value: 3,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Hour),
}
require.NoError(t, limitStore.Create(context.Background(), current))
service, err := NewSetLimitService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
newFakeSanctionStore(),
limitStore,
&fakePolicyLifecycleStore{sanctions: newFakeSanctionStore(), limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{limitRecordID: policy.LimitRecordID("limit-new")},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), SetLimitInput{
UserID: userID.String(),
LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames),
Value: 5,
ReasonCode: "manual_override",
Actor: ActorInput{Type: "admin", ID: "admin-2"},
AppliedAt: now.Add(-2 * time.Hour).Format(time.RFC3339Nano),
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
}
func TestSetLimitServiceExecuteRejectsRetiredLimitCodes(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
tests := []string{
string(policy.LimitCodeMaxActivePrivateGames),
string(policy.LimitCodeMaxPendingPrivateJoinRequests),
string(policy.LimitCodeMaxPendingPrivateInvitesSent),
}
for _, limitCode := range tests {
limitCode := limitCode
t.Run(limitCode, func(t *testing.T) {
t.Parallel()
service, err := NewSetLimitService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
newFakeSanctionStore(),
newFakeLimitStore(),
&fakePolicyLifecycleStore{sanctions: newFakeSanctionStore(), limits: newFakeLimitStore()},
fixedClock{now: now},
fixedIDGenerator{limitRecordID: policy.LimitRecordID("limit-new")},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), SetLimitInput{
UserID: userID.String(),
LimitCode: limitCode,
Value: 5,
ReasonCode: "manual_override",
Actor: ActorInput{Type: "admin", ID: "admin-2"},
AppliedAt: now.Format(time.RFC3339Nano),
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
})
}
}
func TestSetLimitServiceExecuteIgnoresRetiredRecordsDuringReload(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
limitStore := newFakeLimitStore()
require.NoError(t, limitStore.Create(context.Background(), policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-legacy"),
UserID: userID,
LimitCode: policy.LimitCodeMaxActivePrivateGames,
Value: 9,
ReasonCode: common.ReasonCode("legacy_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Hour),
}))
service, err := NewSetLimitService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
newFakeSanctionStore(),
limitStore,
&fakePolicyLifecycleStore{sanctions: newFakeSanctionStore(), limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{limitRecordID: policy.LimitRecordID("limit-new")},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), SetLimitInput{
UserID: userID.String(),
LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames),
Value: 5,
ReasonCode: "manual_override",
Actor: ActorInput{Type: "admin", ID: "admin-2"},
AppliedAt: now.Format(time.RFC3339Nano),
})
require.NoError(t, err)
require.Len(t, result.ActiveLimits, 1)
require.Equal(t, string(policy.LimitCodeMaxOwnedPrivateGames), result.ActiveLimits[0].LimitCode)
}
func TestRemoveLimitServiceExecuteIsIdempotentWhenMissing(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
service, err := NewRemoveLimitService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), RemoveLimitInput{
UserID: userID.String(),
LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames),
ReasonCode: "manual_remove",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
})
require.NoError(t, err)
require.Empty(t, result.ActiveLimits)
}
func TestRemoveLimitServiceExecuteRejectsRetiredLimitCode(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
service, err := NewRemoveLimitService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
newFakeSanctionStore(),
newFakeLimitStore(),
&fakePolicyLifecycleStore{sanctions: newFakeSanctionStore(), limits: newFakeLimitStore()},
fixedClock{now: now},
fixedIDGenerator{},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), RemoveLimitInput{
UserID: userID.String(),
LimitCode: string(policy.LimitCodeMaxPendingPrivateJoinRequests),
ReasonCode: "manual_remove",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
}
type fakeAccountStore struct {
existsByUserID map[common.UserID]bool
}
func (store fakeAccountStore) Create(context.Context, ports.CreateAccountInput) error {
return nil
}
func (store fakeAccountStore) GetByUserID(context.Context, common.UserID) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) GetByEmail(context.Context, common.Email) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) GetByRaceName(context.Context, common.RaceName) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
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
}
type fakeSanctionStore struct {
byUserID map[common.UserID][]policy.SanctionRecord
byRecordID map[policy.SanctionRecordID]policy.SanctionRecord
}
func newFakeSanctionStore() *fakeSanctionStore {
return &fakeSanctionStore{
byUserID: make(map[common.UserID][]policy.SanctionRecord),
byRecordID: make(map[policy.SanctionRecordID]policy.SanctionRecord),
}
}
func (store *fakeSanctionStore) Create(_ context.Context, record policy.SanctionRecord) error {
if err := record.Validate(); err != nil {
return err
}
if _, exists := store.byRecordID[record.RecordID]; exists {
return ports.ErrConflict
}
store.byRecordID[record.RecordID] = record
store.byUserID[record.UserID] = append(store.byUserID[record.UserID], record)
return nil
}
func (store *fakeSanctionStore) GetByRecordID(_ context.Context, recordID policy.SanctionRecordID) (policy.SanctionRecord, error) {
record, ok := store.byRecordID[recordID]
if !ok {
return policy.SanctionRecord{}, ports.ErrNotFound
}
return record, nil
}
func (store *fakeSanctionStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.SanctionRecord, error) {
records := store.byUserID[userID]
cloned := make([]policy.SanctionRecord, len(records))
copy(cloned, records)
return cloned, nil
}
func (store *fakeSanctionStore) Update(_ context.Context, record policy.SanctionRecord) error {
if err := record.Validate(); err != nil {
return err
}
if _, exists := store.byRecordID[record.RecordID]; !exists {
return ports.ErrNotFound
}
store.byRecordID[record.RecordID] = record
records := store.byUserID[record.UserID]
for index := range records {
if records[index].RecordID == record.RecordID {
records[index] = record
store.byUserID[record.UserID] = records
return nil
}
}
return ports.ErrNotFound
}
type fakeLimitStore struct {
byUserID map[common.UserID][]policy.LimitRecord
byRecordID map[policy.LimitRecordID]policy.LimitRecord
}
func newFakeLimitStore() *fakeLimitStore {
return &fakeLimitStore{
byUserID: make(map[common.UserID][]policy.LimitRecord),
byRecordID: make(map[policy.LimitRecordID]policy.LimitRecord),
}
}
func (store *fakeLimitStore) Create(_ context.Context, record policy.LimitRecord) error {
if err := record.Validate(); err != nil {
return err
}
if _, exists := store.byRecordID[record.RecordID]; exists {
return ports.ErrConflict
}
store.byRecordID[record.RecordID] = record
store.byUserID[record.UserID] = append(store.byUserID[record.UserID], record)
return nil
}
func (store *fakeLimitStore) GetByRecordID(_ context.Context, recordID policy.LimitRecordID) (policy.LimitRecord, error) {
record, ok := store.byRecordID[recordID]
if !ok {
return policy.LimitRecord{}, ports.ErrNotFound
}
return record, nil
}
func (store *fakeLimitStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.LimitRecord, error) {
records := store.byUserID[userID]
cloned := make([]policy.LimitRecord, len(records))
copy(cloned, records)
return cloned, nil
}
func (store *fakeLimitStore) Update(_ context.Context, record policy.LimitRecord) error {
if err := record.Validate(); err != nil {
return err
}
if _, exists := store.byRecordID[record.RecordID]; !exists {
return ports.ErrNotFound
}
store.byRecordID[record.RecordID] = record
records := store.byUserID[record.UserID]
for index := range records {
if records[index].RecordID == record.RecordID {
records[index] = record
store.byUserID[record.UserID] = records
return nil
}
}
return ports.ErrNotFound
}
type fakePolicyLifecycleStore struct {
sanctions *fakeSanctionStore
limits *fakeLimitStore
applySanctionHook func(input ports.ApplySanctionInput) error
removeSanctionHook func(input ports.RemoveSanctionInput) error
setLimitHook func(input ports.SetLimitInput) error
removeLimitHook func(input ports.RemoveLimitInput) error
}
func (store *fakePolicyLifecycleStore) ApplySanction(ctx context.Context, input ports.ApplySanctionInput) error {
if store.applySanctionHook != nil {
return store.applySanctionHook(input)
}
records, err := store.sanctions.ListByUserID(ctx, input.NewRecord.UserID)
if err != nil {
return err
}
active, err := policy.ActiveSanctionsAt(records, input.NewRecord.AppliedAt)
if err != nil {
return err
}
for _, record := range active {
if record.SanctionCode == input.NewRecord.SanctionCode {
return ports.ErrConflict
}
}
return store.sanctions.Create(ctx, input.NewRecord)
}
func (store *fakePolicyLifecycleStore) RemoveSanction(ctx context.Context, input ports.RemoveSanctionInput) error {
if store.removeSanctionHook != nil {
return store.removeSanctionHook(input)
}
return store.sanctions.Update(ctx, input.UpdatedRecord)
}
func (store *fakePolicyLifecycleStore) SetLimit(ctx context.Context, input ports.SetLimitInput) error {
if store.setLimitHook != nil {
return store.setLimitHook(input)
}
if input.ExpectedActiveRecord != nil {
if err := store.limits.Update(ctx, *input.UpdatedActiveRecord); err != nil {
return err
}
}
return store.limits.Create(ctx, input.NewRecord)
}
func (store *fakePolicyLifecycleStore) RemoveLimit(ctx context.Context, input ports.RemoveLimitInput) error {
if store.removeLimitHook != nil {
return store.removeLimitHook(input)
}
return store.limits.Update(ctx, input.UpdatedRecord)
}
type fixedClock struct {
now time.Time
}
func (clock fixedClock) Now() time.Time {
return clock.now
}
type fixedIDGenerator struct {
sanctionRecordID policy.SanctionRecordID
limitRecordID policy.LimitRecordID
}
func (generator fixedIDGenerator) NewUserID() (common.UserID, error) {
return "", nil
}
func (generator fixedIDGenerator) NewInitialRaceName() (common.RaceName, error) {
return "", nil
}
func (generator fixedIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
return "", nil
}
func (generator fixedIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
return generator.sanctionRecordID, nil
}
func (generator fixedIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
return generator.limitRecordID, nil
}
var (
_ ports.UserAccountStore = fakeAccountStore{}
_ ports.SanctionStore = (*fakeSanctionStore)(nil)
_ ports.LimitStore = (*fakeLimitStore)(nil)
_ ports.PolicyLifecycleStore = (*fakePolicyLifecycleStore)(nil)
_ ports.Clock = fixedClock{}
_ ports.IDGenerator = fixedIDGenerator{}
)
@@ -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{}
)
+175
View File
@@ -0,0 +1,175 @@
// Package shared provides shared request parsing and error normalization used
// by the user-service application and transport layers.
package shared
import (
"errors"
"net/http"
"strings"
)
const (
// ErrorCodeInvalidRequest reports malformed or semantically invalid caller
// input.
ErrorCodeInvalidRequest = "invalid_request"
// ErrorCodeConflict reports that the requested mutation conflicts with the
// current source-of-truth state.
ErrorCodeConflict = "conflict"
// ErrorCodeSubjectNotFound reports that the requested user subject does not
// exist.
ErrorCodeSubjectNotFound = "subject_not_found"
// ErrorCodeServiceUnavailable reports that a required dependency is
// temporarily unavailable.
ErrorCodeServiceUnavailable = "service_unavailable"
// ErrorCodeInternalError reports that a local invariant failed unexpectedly.
ErrorCodeInternalError = "internal_error"
)
var internalErrorStatusCodes = map[string]int{
ErrorCodeInvalidRequest: http.StatusBadRequest,
ErrorCodeConflict: http.StatusConflict,
ErrorCodeSubjectNotFound: http.StatusNotFound,
ErrorCodeServiceUnavailable: http.StatusServiceUnavailable,
ErrorCodeInternalError: http.StatusInternalServerError,
}
var internalStableMessages = map[string]string{
ErrorCodeConflict: "request conflicts with current state",
ErrorCodeSubjectNotFound: "subject not found",
ErrorCodeServiceUnavailable: "service is unavailable",
ErrorCodeInternalError: "internal server error",
}
// InternalErrorProjection stores the transport-ready representation of one
// normalized trusted-internal error.
type InternalErrorProjection struct {
// StatusCode stores the HTTP status returned to the trusted caller.
StatusCode int
// Code stores the stable machine-readable error code written into the JSON
// envelope.
Code string
// Message stores the stable or caller-safe message written into the JSON
// envelope.
Message string
}
// ServiceError stores one normalized application-layer failure.
type ServiceError struct {
// Code stores the stable machine-readable error code.
Code string
// Message stores the caller-safe error message.
Message string
// Err stores the wrapped underlying cause when one exists.
Err error
}
// Error returns the caller-safe message of ServiceError.
func (err *ServiceError) Error() string {
if err == nil {
return ""
}
if strings.TrimSpace(err.Message) != "" {
return err.Message
}
if strings.TrimSpace(err.Code) != "" {
return err.Code
}
if err.Err != nil {
return err.Err.Error()
}
return ErrorCodeInternalError
}
// Unwrap returns the wrapped underlying cause.
func (err *ServiceError) Unwrap() error {
if err == nil {
return nil
}
return err.Err
}
// NewServiceError returns one new normalized application-layer error.
func NewServiceError(code string, message string, err error) *ServiceError {
return &ServiceError{
Code: strings.TrimSpace(code),
Message: strings.TrimSpace(message),
Err: err,
}
}
// InvalidRequest returns one normalized invalid-request error.
func InvalidRequest(message string) *ServiceError {
return NewServiceError(ErrorCodeInvalidRequest, strings.TrimSpace(message), nil)
}
// Conflict returns one normalized conflict error.
func Conflict() *ServiceError {
return NewServiceError(ErrorCodeConflict, "", nil)
}
// SubjectNotFound returns one normalized subject-not-found error.
func SubjectNotFound() *ServiceError {
return NewServiceError(ErrorCodeSubjectNotFound, "", nil)
}
// ServiceUnavailable returns one normalized dependency-unavailable error.
func ServiceUnavailable(err error) *ServiceError {
return NewServiceError(ErrorCodeServiceUnavailable, "", err)
}
// InternalError returns one normalized invariant-failure error.
func InternalError(err error) *ServiceError {
return NewServiceError(ErrorCodeInternalError, "", err)
}
// CodeOf returns the normalized service error code carried by err when one is
// available.
func CodeOf(err error) string {
serviceErr, ok := errors.AsType[*ServiceError](err)
if !ok || serviceErr == nil {
return ""
}
return serviceErr.Code
}
// ProjectInternalError normalizes err to the frozen trusted-internal HTTP
// error surface.
func ProjectInternalError(err error) InternalErrorProjection {
serviceErr, ok := errors.AsType[*ServiceError](err)
code := CodeOf(err)
if _, exists := internalErrorStatusCodes[code]; !exists {
return InternalErrorProjection{
StatusCode: http.StatusInternalServerError,
Code: ErrorCodeInternalError,
Message: internalStableMessages[ErrorCodeInternalError],
}
}
message := ""
if ok && serviceErr != nil {
message = serviceErr.Message
}
if stable, exists := internalStableMessages[code]; exists {
message = stable
}
if strings.TrimSpace(message) == "" {
message = internalStableMessages[ErrorCodeInternalError]
}
return InternalErrorProjection{
StatusCode: internalErrorStatusCodes[code],
Code: code,
Message: message,
}
}
+131
View File
@@ -0,0 +1,131 @@
package shared
import (
"fmt"
"strings"
"time"
"galaxy/user/internal/domain/common"
"golang.org/x/text/language"
)
// NormalizeString trims surrounding Unicode whitespace from value.
func NormalizeString(value string) string {
return strings.TrimSpace(value)
}
// ParseEmail trims value and validates it as one exact normalized e-mail
// subject used by the auth-facing contract.
func ParseEmail(value string) (common.Email, error) {
email := common.Email(NormalizeString(value))
if err := email.Validate(); err != nil {
return "", InvalidRequest(err.Error())
}
return email, nil
}
// ParseUserID trims value and validates it as one stable user identifier.
func ParseUserID(value string) (common.UserID, error) {
userID := common.UserID(NormalizeString(value))
if err := userID.Validate(); err != nil {
return "", InvalidRequest(err.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 {
return "", InvalidRequest(err.Error())
}
return raceName, nil
}
// ParseReasonCode trims value and validates it as one machine-readable reason
// code.
func ParseReasonCode(value string) (common.ReasonCode, error) {
reasonCode := common.ReasonCode(NormalizeString(value))
if err := reasonCode.Validate(); err != nil {
return "", InvalidRequest(err.Error())
}
return reasonCode, nil
}
// ParseLanguageTag trims value and validates it against the current Stage 03
// boundary and BCP 47 semantics, returning the canonical tag form.
func ParseLanguageTag(value string) (common.LanguageTag, error) {
languageTag := common.LanguageTag(NormalizeString(value))
if err := languageTag.Validate(); err != nil {
return "", InvalidRequest(err.Error())
}
parsedTag, err := language.Parse(languageTag.String())
if err != nil {
return "", InvalidRequest("language tag must be a valid BCP 47 language tag")
}
canonicalTag := common.LanguageTag(parsedTag.String())
if err := canonicalTag.Validate(); err != nil {
return "", InvalidRequest(err.Error())
}
return canonicalTag, nil
}
// ParseTimeZoneName trims value and validates it against the current Stage 03
// boundary and IANA time-zone semantics.
func ParseTimeZoneName(value string) (common.TimeZoneName, error) {
timeZoneName := common.TimeZoneName(NormalizeString(value))
if err := timeZoneName.Validate(); err != nil {
return "", InvalidRequest(err.Error())
}
if _, err := time.LoadLocation(timeZoneName.String()); err != nil {
return "", InvalidRequest("time zone name must be a valid IANA time zone name")
}
return timeZoneName, nil
}
// ParseRegistrationPreferredLanguage trims value, validates it as one create-
// only BCP 47 registration language tag, and returns the canonical tag form.
func ParseRegistrationPreferredLanguage(value string) (common.LanguageTag, error) {
languageTag, err := ParseLanguageTag(value)
if err != nil {
return "", reframeFieldError("registration_context.preferred_language", "language tag", err)
}
return languageTag, nil
}
// ParseRegistrationTimeZoneName trims value and validates it as one create-
// only IANA registration time-zone name.
func ParseRegistrationTimeZoneName(value string) (common.TimeZoneName, error) {
timeZoneName, err := ParseTimeZoneName(value)
if err != nil {
return "", reframeFieldError("registration_context.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 InvalidRequest(message)
}
@@ -0,0 +1,119 @@
package shared
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestParseLanguageTag(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want string
wantErrCode string
wantErr string
}{
{
name: "canonicalizes valid tag",
input: " en-us ",
want: "en-US",
},
{
name: "rejects invalid tag",
input: "en-@",
wantErrCode: ErrorCodeInvalidRequest,
wantErr: "language tag must be a valid BCP 47 language tag",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := ParseLanguageTag(tt.input)
if tt.wantErr != "" {
require.Error(t, err)
require.Empty(t, got)
require.Equal(t, tt.wantErrCode, CodeOf(err))
require.Equal(t, tt.wantErr, err.Error())
return
}
require.NoError(t, err)
require.Equal(t, tt.want, got.String())
})
}
}
func TestParseTimeZoneName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want string
wantErrCode string
wantErr string
}{
{
name: "accepts valid zone",
input: " Europe/Kaliningrad ",
want: "Europe/Kaliningrad",
},
{
name: "rejects invalid zone",
input: "Mars/Olympus",
wantErrCode: ErrorCodeInvalidRequest,
wantErr: "time zone name must be a valid IANA time zone name",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := ParseTimeZoneName(tt.input)
if tt.wantErr != "" {
require.Error(t, err)
require.Empty(t, got)
require.Equal(t, tt.wantErrCode, CodeOf(err))
require.Equal(t, tt.wantErr, err.Error())
return
}
require.NoError(t, err)
require.Equal(t, tt.want, got.String())
})
}
}
func TestParseRegistrationPreferredLanguage(t *testing.T) {
t.Parallel()
got, err := ParseRegistrationPreferredLanguage(" en-us ")
require.NoError(t, err)
require.Equal(t, "en-US", got.String())
_, err = ParseRegistrationPreferredLanguage("bad@@tag")
require.Error(t, err)
require.Equal(t, ErrorCodeInvalidRequest, CodeOf(err))
require.Equal(t, "registration_context.preferred_language must be a valid BCP 47 language tag", err.Error())
}
func TestParseRegistrationTimeZoneName(t *testing.T) {
t.Parallel()
got, err := ParseRegistrationTimeZoneName(" Europe/Kaliningrad ")
require.NoError(t, err)
require.Equal(t, "Europe/Kaliningrad", got.String())
_, err = ParseRegistrationTimeZoneName("Mars/Olympus")
require.Error(t, err)
require.Equal(t, ErrorCodeInvalidRequest, CodeOf(err))
require.Equal(t, "registration_context.time_zone must be a valid IANA time zone name", err.Error())
}
@@ -0,0 +1,73 @@
package shared
import (
"context"
"log/slog"
"galaxy/user/internal/logging"
)
// LogServiceOutcome writes one structured service-level outcome log with a
// stable severity derived from err and with trace fields attached when ctx
// carries an active span.
func LogServiceOutcome(logger *slog.Logger, ctx context.Context, message string, err error, attrs ...any) {
if logger == nil {
logger = slog.Default()
}
attrs = append(attrs, logging.TraceAttrsFromContext(ctx)...)
switch {
case err == nil:
logger.InfoContext(ctx, message, attrs...)
case isExpectedServiceErrorCode(CodeOf(err)):
logger.WarnContext(ctx, message, append(attrs, "error", err.Error())...)
default:
logger.ErrorContext(ctx, message, append(attrs, "error", err.Error())...)
}
}
// MetricOutcome returns the stable low-cardinality outcome label derived from
// err for service metrics.
func MetricOutcome(err error) string {
if err == nil {
return "success"
}
code := CodeOf(err)
if code == "" {
return ErrorCodeInternalError
}
return code
}
// LogEventPublicationFailure writes one structured error log for an auxiliary
// post-commit event publication failure.
func LogEventPublicationFailure(logger *slog.Logger, ctx context.Context, eventType string, err error, attrs ...any) {
if err == nil {
return
}
if logger == nil {
logger = slog.Default()
}
attrs = append(attrs,
"event_type", eventType,
"error", err.Error(),
)
attrs = append(attrs, logging.TraceAttrsFromContext(ctx)...)
logger.ErrorContext(ctx, "auxiliary event publication failed", attrs...)
}
func isExpectedServiceErrorCode(code string) bool {
switch code {
case ErrorCodeInvalidRequest,
ErrorCodeConflict,
ErrorCodeSubjectNotFound:
return true
default:
return false
}
}
+49
View File
@@ -0,0 +1,49 @@
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
}