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