Files
galaxy-game/user/internal/service/lobbyeligibility/service_test.go
T
2026-04-26 20:34:39 +02:00

511 lines
16 KiB
Go

package lobbyeligibility
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"
"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},
{LimitCode: "max_registered_race_names", Value: 2},
}, 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},
{LimitCode: "max_registered_race_names", Value: 1},
},
},
{
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},
{LimitCode: "max_registered_race_names", Value: 2},
},
},
}
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 TestSnapshotReaderExecutePermanentBlockCollapsesMarkers(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
userID := common.UserID("user-123")
tests := []struct {
name string
snapshot entitlement.CurrentSnapshot
sanctions []policy.SanctionRecord
}{
{
name: "permanent_block alone on paid user",
snapshot: paidEntitlementSnapshot(userID, now.Add(-24*time.Hour), now.Add(24*time.Hour)),
sanctions: []policy.SanctionRecord{
activeSanction(userID, policy.SanctionCodePermanentBlock, "platform", now.Add(-time.Hour)),
},
},
{
name: "permanent_block alone on free user",
snapshot: freeEntitlementSnapshot(userID, now.Add(-24*time.Hour)),
sanctions: []policy.SanctionRecord{
activeSanction(userID, policy.SanctionCodePermanentBlock, "platform", now.Add(-time.Hour)),
},
},
{
name: "permanent_block dominates login_block",
snapshot: paidEntitlementSnapshot(userID, now.Add(-24*time.Hour), now.Add(24*time.Hour)),
sanctions: []policy.SanctionRecord{
activeSanction(userID, policy.SanctionCodeLoginBlock, "auth", now.Add(-time.Hour)),
activeSanction(userID, policy.SanctionCodePermanentBlock, "platform", now.Add(-30*time.Minute)),
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
service, err := NewSnapshotReader(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
fakeEntitlementReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: tt.snapshot}},
fakeSanctionStore{byUserID: map[common.UserID][]policy.SanctionRecord{userID: tt.sanctions}},
fakeLimitStore{},
fixedClock{now: now},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GetUserEligibilityInput{UserID: userID.String()})
require.NoError(t, err)
require.True(t, result.Exists)
require.Equal(t, EligibilityMarkersView{}, result.Markers,
"every can_* marker must be false under permanent_block")
gotSanctions := make([]string, 0, len(result.ActiveSanctions))
for _, sanction := range result.ActiveSanctions {
gotSanctions = append(gotSanctions, sanction.SanctionCode)
}
require.Contains(t, gotSanctions, string(policy.SanctionCodePermanentBlock),
"permanent_block must surface in the eligibility snapshot")
})
}
}
// The expired-snapshot repair is exercised end-to-end through the
// runtime-contract test (`runtime_contract_test.go`), which boots a real
// PostgreSQL container and the full runtime. The original miniredis-based
// version of this test was removed in PG_PLAN.md §3 because the
// adapter-level RepairExpired path no longer exists in this package; the
// in-memory fake stores below cover the service-layer logic for every other
// scenario in the file.
var _ = entitlement.EntitlementRecordID("")
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) GetByUserName(context.Context, common.UserName) (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) 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) NewUserName() (common.UserName, 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"),
UserName: common.UserName("player-abcdefgh"),
PreferredLanguage: common.LanguageTag("en"),
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
CreatedAt: createdAt,
UpdatedAt: createdAt,
}
}
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{}