feat: user service
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
)
|
||||
@@ -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{}
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user