feat: use postgres
This commit is contained in:
@@ -0,0 +1,656 @@
|
||||
package userstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/authblock"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
"galaxy/user/internal/ports"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// All time values are aligned to microseconds because PostgreSQL's
|
||||
// timestamptz only stores microsecond precision; using nanoseconds here
|
||||
// would cause round-trip mismatches.
|
||||
var fixtureCreatedAt = time.Unix(1_775_240_000, 0).UTC()
|
||||
|
||||
func validAccount() account.UserAccount {
|
||||
return account.UserAccount{
|
||||
UserID: common.UserID("user-pilot-001"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
UserName: common.UserName("player-aaaaaaaa"),
|
||||
DisplayName: common.DisplayName("NovaPrime"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
|
||||
CreatedAt: fixtureCreatedAt,
|
||||
UpdatedAt: fixtureCreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func validFreeSnapshot(userID common.UserID, at time.Time) entitlement.CurrentSnapshot {
|
||||
return entitlement.CurrentSnapshot{
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodeFree,
|
||||
IsPaid: false,
|
||||
StartsAt: at.UTC(),
|
||||
Source: common.Source("auth_signup"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("auth")},
|
||||
ReasonCode: common.ReasonCode("initial_free_entitlement"),
|
||||
UpdatedAt: at.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func validFreePeriod(userID common.UserID, recordID entitlement.EntitlementRecordID, at time.Time) entitlement.PeriodRecord {
|
||||
return entitlement.PeriodRecord{
|
||||
RecordID: recordID,
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodeFree,
|
||||
Source: common.Source("auth_signup"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("auth")},
|
||||
ReasonCode: common.ReasonCode("initial_free_entitlement"),
|
||||
StartsAt: at.UTC(),
|
||||
CreatedAt: at.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func paidPeriod(userID common.UserID, recordID entitlement.EntitlementRecordID, startsAt, endsAt time.Time) entitlement.PeriodRecord {
|
||||
end := endsAt.UTC()
|
||||
return entitlement.PeriodRecord{
|
||||
RecordID: recordID,
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodePaidMonthly,
|
||||
Source: common.Source("admin"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("manual_grant"),
|
||||
StartsAt: startsAt.UTC(),
|
||||
EndsAt: &end,
|
||||
CreatedAt: startsAt.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func paidSnapshot(userID common.UserID, startsAt, endsAt, updatedAt time.Time) entitlement.CurrentSnapshot {
|
||||
end := endsAt.UTC()
|
||||
return entitlement.CurrentSnapshot{
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodePaidMonthly,
|
||||
IsPaid: true,
|
||||
StartsAt: startsAt.UTC(),
|
||||
EndsAt: &end,
|
||||
Source: common.Source("admin"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("manual_grant"),
|
||||
UpdatedAt: updatedAt.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func validSanction(userID common.UserID, code policy.SanctionCode, appliedAt time.Time) policy.SanctionRecord {
|
||||
return policy.SanctionRecord{
|
||||
RecordID: policy.SanctionRecordID("sanction-" + string(code) + "-1"),
|
||||
UserID: userID,
|
||||
SanctionCode: code,
|
||||
Scope: common.Scope("platform"),
|
||||
ReasonCode: common.ReasonCode("manual_block"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: appliedAt.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func validLimit(userID common.UserID, code policy.LimitCode, value int, appliedAt time.Time) policy.LimitRecord {
|
||||
return policy.LimitRecord{
|
||||
RecordID: policy.LimitRecordID("limit-" + string(code) + "-1"),
|
||||
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 TestAccountCreateAndLookups(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
record := validAccount()
|
||||
require.NoError(t, store.Create(ctx, ports.CreateAccountInput{Account: record}))
|
||||
|
||||
got, err := store.GetByUserID(ctx, record.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record, got)
|
||||
|
||||
got, err = store.GetByEmail(ctx, record.Email)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record, got)
|
||||
|
||||
got, err = store.GetByUserName(ctx, record.UserName)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record, got)
|
||||
|
||||
exists, err := store.ExistsByUserID(ctx, record.UserID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists)
|
||||
}
|
||||
|
||||
func TestAccountCreateConflictsAreClassified(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
record := validAccount()
|
||||
require.NoError(t, store.Create(ctx, ports.CreateAccountInput{Account: record}))
|
||||
|
||||
// Same UserID -> generic conflict.
|
||||
require.True(t, errors.Is(store.Create(ctx, ports.CreateAccountInput{Account: record}), ports.ErrConflict))
|
||||
|
||||
// Same UserName, different UserID/email -> ErrUserNameConflict (which
|
||||
// also satisfies errors.Is(ErrConflict)).
|
||||
clone := validAccount()
|
||||
clone.UserID = common.UserID("user-pilot-002")
|
||||
clone.Email = common.Email("pilot2@example.com")
|
||||
err := store.Create(ctx, ports.CreateAccountInput{Account: clone})
|
||||
require.True(t, errors.Is(err, ports.ErrUserNameConflict))
|
||||
require.True(t, errors.Is(err, ports.ErrConflict))
|
||||
|
||||
// Same email, different UserID/user_name -> generic conflict.
|
||||
clone = validAccount()
|
||||
clone.UserID = common.UserID("user-pilot-003")
|
||||
clone.UserName = common.UserName("player-bbbbbbbb")
|
||||
err = store.Create(ctx, ports.CreateAccountInput{Account: clone})
|
||||
require.True(t, errors.Is(err, ports.ErrConflict))
|
||||
require.False(t, errors.Is(err, ports.ErrUserNameConflict))
|
||||
}
|
||||
|
||||
func TestAccountUpdateRespectsImmutableFieldsAndSoftDelete(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
record := validAccount()
|
||||
require.NoError(t, store.Create(ctx, ports.CreateAccountInput{Account: record}))
|
||||
|
||||
updated := record
|
||||
updated.DisplayName = common.DisplayName("HelloWorld")
|
||||
updated.DeclaredCountry = common.CountryCode("DE")
|
||||
updated.UpdatedAt = record.UpdatedAt.Add(time.Minute)
|
||||
require.NoError(t, store.Update(ctx, updated))
|
||||
|
||||
got, err := store.GetByUserID(ctx, record.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, updated, got)
|
||||
|
||||
// Mutating user_name must surface as ErrConflict.
|
||||
mutating := updated
|
||||
mutating.UserName = common.UserName("player-xxxxxxxx")
|
||||
require.True(t, errors.Is(store.Update(ctx, mutating), ports.ErrConflict))
|
||||
|
||||
// Soft-delete via Update sets DeletedAt; ExistsByUserID flips to false.
|
||||
deletedAt := updated.UpdatedAt.Add(time.Minute)
|
||||
soft := updated
|
||||
soft.DeletedAt = &deletedAt
|
||||
soft.UpdatedAt = deletedAt
|
||||
require.NoError(t, store.Update(ctx, soft))
|
||||
|
||||
exists, err := store.ExistsByUserID(ctx, record.UserID)
|
||||
require.NoError(t, err)
|
||||
require.False(t, exists)
|
||||
}
|
||||
|
||||
func TestBlockedEmailUpsertAndGet(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
record := authblock.BlockedEmailSubject{
|
||||
Email: common.Email("blocked@example.com"),
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
BlockedAt: fixtureCreatedAt,
|
||||
}
|
||||
require.NoError(t, store.PutBlockedEmail(ctx, record))
|
||||
|
||||
got, err := store.GetBlockedEmail(ctx, record.Email)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record, got)
|
||||
|
||||
// Upsert replaces existing.
|
||||
updated := record
|
||||
updated.ReasonCode = common.ReasonCode("admin_blocked")
|
||||
updated.BlockedAt = record.BlockedAt.Add(time.Hour)
|
||||
updated.Actor = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
|
||||
require.NoError(t, store.PutBlockedEmail(ctx, updated))
|
||||
|
||||
got, err = store.GetBlockedEmail(ctx, record.Email)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, updated, got)
|
||||
}
|
||||
|
||||
func TestResolveByEmailReturnsCreatableExistingBlockedAndDeleted(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
creatable, err := store.ResolveByEmail(ctx, common.Email("nobody@example.com"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.AuthResolutionKindCreatable, creatable.Kind)
|
||||
|
||||
require.NoError(t, store.PutBlockedEmail(ctx, authblock.BlockedEmailSubject{
|
||||
Email: common.Email("blocked@example.com"),
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
BlockedAt: fixtureCreatedAt,
|
||||
}))
|
||||
blocked, err := store.ResolveByEmail(ctx, common.Email("blocked@example.com"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.AuthResolutionKindBlocked, blocked.Kind)
|
||||
require.Equal(t, common.ReasonCode("policy_blocked"), blocked.BlockReasonCode)
|
||||
|
||||
record := validAccount()
|
||||
require.NoError(t, store.Create(ctx, ports.CreateAccountInput{Account: record}))
|
||||
existing, err := store.ResolveByEmail(ctx, record.Email)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.AuthResolutionKindExisting, existing.Kind)
|
||||
require.Equal(t, record.UserID, existing.UserID)
|
||||
|
||||
// Soft-delete the account; the email lookup must now resolve to blocked.
|
||||
deletedAt := record.UpdatedAt.Add(time.Minute)
|
||||
soft := record
|
||||
soft.DeletedAt = &deletedAt
|
||||
soft.UpdatedAt = deletedAt
|
||||
require.NoError(t, store.Update(ctx, soft))
|
||||
|
||||
deletedResult, err := store.ResolveByEmail(ctx, record.Email)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.AuthResolutionKindBlocked, deletedResult.Kind)
|
||||
require.Equal(t, deletedAccountBlockReasonCode, deletedResult.BlockReasonCode)
|
||||
}
|
||||
|
||||
func TestEnsureByEmailCoversAllOutcomes(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
record := validAccount()
|
||||
snapshot := validFreeSnapshot(record.UserID, record.CreatedAt)
|
||||
period := validFreePeriod(record.UserID, entitlement.EntitlementRecordID("entitlement-initial"), record.CreatedAt)
|
||||
|
||||
created, err := store.EnsureByEmail(ctx, ports.EnsureByEmailInput{
|
||||
Email: record.Email,
|
||||
Account: record,
|
||||
Entitlement: snapshot,
|
||||
EntitlementRecord: period,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.EnsureByEmailOutcomeCreated, created.Outcome)
|
||||
require.Equal(t, record.UserID, created.UserID)
|
||||
|
||||
// Second call with the same email returns existing. The Account input
|
||||
// describes the would-be-created record if no account existed yet; its
|
||||
// email must match the request email per ports.EnsureByEmailInput.Validate.
|
||||
existingCandidate := validSecondAccount()
|
||||
existingCandidate.Email = record.Email
|
||||
existing, err := store.EnsureByEmail(ctx, ports.EnsureByEmailInput{
|
||||
Email: record.Email,
|
||||
Account: existingCandidate,
|
||||
Entitlement: validFreeSnapshot(existingCandidate.UserID, record.CreatedAt),
|
||||
EntitlementRecord: validFreePeriod(existingCandidate.UserID, entitlement.EntitlementRecordID("entitlement-second"), record.CreatedAt),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.EnsureByEmailOutcomeExisting, existing.Outcome)
|
||||
require.Equal(t, record.UserID, existing.UserID)
|
||||
|
||||
// Blocked email path.
|
||||
require.NoError(t, store.PutBlockedEmail(ctx, authblock.BlockedEmailSubject{
|
||||
Email: common.Email("blocked@example.com"),
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
BlockedAt: fixtureCreatedAt,
|
||||
}))
|
||||
blockedAccount := validSecondAccount()
|
||||
blockedAccount.Email = common.Email("blocked@example.com")
|
||||
blockedSnapshot := validFreeSnapshot(blockedAccount.UserID, record.CreatedAt)
|
||||
blockedPeriod := validFreePeriod(blockedAccount.UserID, entitlement.EntitlementRecordID("entitlement-blocked"), record.CreatedAt)
|
||||
blocked, err := store.EnsureByEmail(ctx, ports.EnsureByEmailInput{
|
||||
Email: blockedAccount.Email,
|
||||
Account: blockedAccount,
|
||||
Entitlement: blockedSnapshot,
|
||||
EntitlementRecord: blockedPeriod,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.EnsureByEmailOutcomeBlocked, blocked.Outcome)
|
||||
require.Equal(t, common.ReasonCode("policy_blocked"), blocked.BlockReasonCode)
|
||||
|
||||
// Soft-deleted account → blocked(account_deleted).
|
||||
deletedAt := record.UpdatedAt.Add(time.Hour)
|
||||
soft := record
|
||||
soft.DeletedAt = &deletedAt
|
||||
soft.UpdatedAt = deletedAt
|
||||
require.NoError(t, store.Update(ctx, soft))
|
||||
|
||||
deletedCandidate := validSecondAccount()
|
||||
deletedCandidate.Email = record.Email
|
||||
deletedCandidate.UserID = common.UserID("user-third")
|
||||
deletedCandidate.UserName = common.UserName("player-cccccccc")
|
||||
deletedResult, err := store.EnsureByEmail(ctx, ports.EnsureByEmailInput{
|
||||
Email: record.Email,
|
||||
Account: deletedCandidate,
|
||||
Entitlement: validFreeSnapshot(deletedCandidate.UserID, record.CreatedAt),
|
||||
EntitlementRecord: validFreePeriod(deletedCandidate.UserID, entitlement.EntitlementRecordID("entitlement-second-2"), record.CreatedAt),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.EnsureByEmailOutcomeBlocked, deletedResult.Outcome)
|
||||
require.Equal(t, deletedAccountBlockReasonCode, deletedResult.BlockReasonCode)
|
||||
}
|
||||
|
||||
func validSecondAccount() account.UserAccount {
|
||||
return account.UserAccount{
|
||||
UserID: common.UserID("user-second"),
|
||||
Email: common.Email("second@example.com"),
|
||||
UserName: common.UserName("player-bbbbbbbb"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("UTC"),
|
||||
CreatedAt: fixtureCreatedAt,
|
||||
UpdatedAt: fixtureCreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockByUserIDAndBlockByEmail(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
record := validAccount()
|
||||
require.NoError(t, store.Create(ctx, ports.CreateAccountInput{Account: record}))
|
||||
|
||||
res, err := store.BlockByUserID(ctx, ports.BlockByUserIDInput{
|
||||
UserID: record.UserID,
|
||||
ReasonCode: common.ReasonCode("manual_block"),
|
||||
BlockedAt: fixtureCreatedAt.Add(time.Hour),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.AuthBlockOutcomeBlocked, res.Outcome)
|
||||
require.Equal(t, record.UserID, res.UserID)
|
||||
|
||||
// Replay returns AlreadyBlocked.
|
||||
res, err = store.BlockByUserID(ctx, ports.BlockByUserIDInput{
|
||||
UserID: record.UserID,
|
||||
ReasonCode: common.ReasonCode("manual_block"),
|
||||
BlockedAt: fixtureCreatedAt.Add(2 * time.Hour),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.AuthBlockOutcomeAlreadyBlocked, res.Outcome)
|
||||
require.Equal(t, record.UserID, res.UserID)
|
||||
|
||||
// Block by email for a non-existing address records the block with
|
||||
// nil resolved_user_id.
|
||||
res, err = store.BlockByEmail(ctx, ports.BlockByEmailInput{
|
||||
Email: common.Email("ghost@example.com"),
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
BlockedAt: fixtureCreatedAt.Add(time.Hour),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.AuthBlockOutcomeBlocked, res.Outcome)
|
||||
require.True(t, res.UserID.IsZero())
|
||||
}
|
||||
|
||||
func TestEntitlementSnapshotPutAndGet(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
record := validAccount()
|
||||
require.NoError(t, store.Create(ctx, ports.CreateAccountInput{Account: record}))
|
||||
|
||||
snapshot := validFreeSnapshot(record.UserID, record.CreatedAt)
|
||||
require.NoError(t, store.PutEntitlement(ctx, snapshot))
|
||||
|
||||
got, err := store.GetEntitlementByUserID(ctx, record.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, snapshot, got)
|
||||
|
||||
// Upsert replaces.
|
||||
paid := paidSnapshot(record.UserID, record.CreatedAt, record.CreatedAt.Add(30*24*time.Hour), record.CreatedAt.Add(time.Minute))
|
||||
require.NoError(t, store.PutEntitlement(ctx, paid))
|
||||
got, err = store.GetEntitlementByUserID(ctx, record.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, paid, got)
|
||||
}
|
||||
|
||||
func TestEntitlementHistoryCRUDAndList(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
record := validAccount()
|
||||
require.NoError(t, store.Create(ctx, ports.CreateAccountInput{Account: record}))
|
||||
|
||||
first := validFreePeriod(record.UserID, entitlement.EntitlementRecordID("entitlement-1"), record.CreatedAt)
|
||||
second := paidPeriod(record.UserID, entitlement.EntitlementRecordID("entitlement-2"), record.CreatedAt.Add(time.Hour), record.CreatedAt.Add(48*time.Hour))
|
||||
|
||||
require.NoError(t, store.CreateEntitlementRecord(ctx, first))
|
||||
require.NoError(t, store.CreateEntitlementRecord(ctx, second))
|
||||
|
||||
require.True(t, errors.Is(store.CreateEntitlementRecord(ctx, first), ports.ErrConflict))
|
||||
|
||||
got, err := store.GetEntitlementRecordByID(ctx, first.RecordID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, first, got)
|
||||
|
||||
list, err := store.ListEntitlementRecordsByUserID(ctx, record.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list, 2)
|
||||
require.Equal(t, first.RecordID, list[0].RecordID)
|
||||
require.Equal(t, second.RecordID, list[1].RecordID)
|
||||
|
||||
closedAt := record.CreatedAt.Add(2 * time.Hour)
|
||||
updated := first
|
||||
updated.ClosedAt = &closedAt
|
||||
updated.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
|
||||
updated.ClosedReasonCode = common.ReasonCode("superseded")
|
||||
require.NoError(t, store.UpdateEntitlementRecord(ctx, updated))
|
||||
|
||||
got, err = store.GetEntitlementRecordByID(ctx, updated.RecordID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, updated, got)
|
||||
}
|
||||
|
||||
func TestEntitlementLifecycleGrantExtendRevokeRepair(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
record := validAccount()
|
||||
require.NoError(t, store.Create(ctx, ports.CreateAccountInput{Account: record}))
|
||||
|
||||
freeSnap := validFreeSnapshot(record.UserID, record.CreatedAt)
|
||||
freeRecord := validFreePeriod(record.UserID, entitlement.EntitlementRecordID("entitlement-free-1"), record.CreatedAt)
|
||||
require.NoError(t, store.PutEntitlement(ctx, freeSnap))
|
||||
require.NoError(t, store.CreateEntitlementRecord(ctx, freeRecord))
|
||||
|
||||
closedAt := record.CreatedAt.Add(time.Hour)
|
||||
closedFree := freeRecord
|
||||
closedFree.ClosedAt = &closedAt
|
||||
closedFree.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
|
||||
closedFree.ClosedReasonCode = common.ReasonCode("superseded")
|
||||
|
||||
paidStart := closedAt
|
||||
paidEnd := paidStart.Add(30 * 24 * time.Hour)
|
||||
paid := paidPeriod(record.UserID, entitlement.EntitlementRecordID("entitlement-paid-1"), paidStart, paidEnd)
|
||||
paidSnap := paidSnapshot(record.UserID, paidStart, paidEnd, paidStart)
|
||||
|
||||
require.NoError(t, store.GrantEntitlement(ctx, ports.GrantEntitlementInput{
|
||||
ExpectedCurrentSnapshot: freeSnap,
|
||||
ExpectedCurrentRecord: freeRecord,
|
||||
UpdatedCurrentRecord: closedFree,
|
||||
NewRecord: paid,
|
||||
NewSnapshot: paidSnap,
|
||||
}))
|
||||
|
||||
got, err := store.GetEntitlementByUserID(ctx, record.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, paidSnap, got)
|
||||
|
||||
// Extend with a new paid segment.
|
||||
extendStart := paidEnd
|
||||
extendEnd := extendStart.Add(30 * 24 * time.Hour)
|
||||
extendRecord := paidPeriod(record.UserID, entitlement.EntitlementRecordID("entitlement-paid-2"), extendStart, extendEnd)
|
||||
extendSnap := paidSnapshot(record.UserID, paidStart, extendEnd, extendStart)
|
||||
require.NoError(t, store.ExtendEntitlement(ctx, ports.ExtendEntitlementInput{
|
||||
ExpectedCurrentSnapshot: paidSnap,
|
||||
NewRecord: extendRecord,
|
||||
NewSnapshot: extendSnap,
|
||||
}))
|
||||
|
||||
// Revoke -> back to free.
|
||||
revokeAt := extendStart.Add(time.Hour)
|
||||
revokedPaid := extendRecord
|
||||
revokedPaid.ClosedAt = &revokeAt
|
||||
revokedPaid.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
|
||||
revokedPaid.ClosedReasonCode = common.ReasonCode("revoked")
|
||||
freeAgain := validFreePeriod(record.UserID, entitlement.EntitlementRecordID("entitlement-free-2"), revokeAt)
|
||||
freeAgainSnap := validFreeSnapshot(record.UserID, revokeAt)
|
||||
require.NoError(t, store.RevokeEntitlement(ctx, ports.RevokeEntitlementInput{
|
||||
ExpectedCurrentSnapshot: extendSnap,
|
||||
ExpectedCurrentRecord: extendRecord,
|
||||
UpdatedCurrentRecord: revokedPaid,
|
||||
NewRecord: freeAgain,
|
||||
NewSnapshot: freeAgainSnap,
|
||||
}))
|
||||
|
||||
got, err = store.GetEntitlementByUserID(ctx, record.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, freeAgainSnap, got)
|
||||
}
|
||||
|
||||
func TestEntitlementLifecycleConflictsOnSnapshotMismatch(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
record := validAccount()
|
||||
require.NoError(t, store.Create(ctx, ports.CreateAccountInput{Account: record}))
|
||||
freeSnap := validFreeSnapshot(record.UserID, record.CreatedAt)
|
||||
require.NoError(t, store.PutEntitlement(ctx, freeSnap))
|
||||
|
||||
stale := freeSnap
|
||||
stale.UpdatedAt = freeSnap.UpdatedAt.Add(-time.Hour)
|
||||
freeRecord := validFreePeriod(record.UserID, entitlement.EntitlementRecordID("entitlement-free-1"), record.CreatedAt)
|
||||
require.NoError(t, store.CreateEntitlementRecord(ctx, freeRecord))
|
||||
|
||||
closedAt := record.CreatedAt.Add(time.Hour)
|
||||
closedFree := freeRecord
|
||||
closedFree.ClosedAt = &closedAt
|
||||
closedFree.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
|
||||
closedFree.ClosedReasonCode = common.ReasonCode("superseded")
|
||||
paid := paidPeriod(record.UserID, entitlement.EntitlementRecordID("entitlement-paid-1"), closedAt, closedAt.Add(time.Hour))
|
||||
paidSnap := paidSnapshot(record.UserID, closedAt, closedAt.Add(time.Hour), closedAt)
|
||||
|
||||
err := store.GrantEntitlement(ctx, ports.GrantEntitlementInput{
|
||||
ExpectedCurrentSnapshot: stale,
|
||||
ExpectedCurrentRecord: freeRecord,
|
||||
UpdatedCurrentRecord: closedFree,
|
||||
NewRecord: paid,
|
||||
NewSnapshot: paidSnap,
|
||||
})
|
||||
require.True(t, errors.Is(err, ports.ErrConflict))
|
||||
}
|
||||
|
||||
func TestPolicyApplyRemoveSanctionAndLimit(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
record := validAccount()
|
||||
require.NoError(t, store.Create(ctx, ports.CreateAccountInput{Account: record}))
|
||||
|
||||
sanction := validSanction(record.UserID, policy.SanctionCodeLoginBlock, fixtureCreatedAt.Add(time.Minute))
|
||||
require.NoError(t, store.ApplySanction(ctx, ports.ApplySanctionInput{NewRecord: sanction}))
|
||||
|
||||
got, err := store.GetSanctionByRecordID(ctx, sanction.RecordID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, sanction, got)
|
||||
|
||||
// Re-applying the same sanction code without removing first must return
|
||||
// ErrConflict because (user_id, sanction_code) is unique on
|
||||
// sanction_active.
|
||||
dup := sanction
|
||||
dup.RecordID = policy.SanctionRecordID("sanction-login_block-2")
|
||||
require.True(t, errors.Is(store.ApplySanction(ctx, ports.ApplySanctionInput{NewRecord: dup}), ports.ErrConflict))
|
||||
|
||||
removedAt := sanction.AppliedAt.Add(time.Hour)
|
||||
updated := sanction
|
||||
updated.RemovedAt = &removedAt
|
||||
updated.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
|
||||
updated.RemovedReasonCode = common.ReasonCode("manual_unblock")
|
||||
require.NoError(t, store.RemoveSanction(ctx, ports.RemoveSanctionInput{
|
||||
ExpectedActiveRecord: sanction,
|
||||
UpdatedRecord: updated,
|
||||
}))
|
||||
|
||||
got, err = store.GetSanctionByRecordID(ctx, sanction.RecordID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, updated, got)
|
||||
|
||||
// Now SetLimit on a fresh code; replay must conflict.
|
||||
limit := validLimit(record.UserID, policy.LimitCodeMaxOwnedPrivateGames, 5, fixtureCreatedAt.Add(2*time.Minute))
|
||||
require.NoError(t, store.SetLimit(ctx, ports.SetLimitInput{NewRecord: limit}))
|
||||
|
||||
dupLimit := limit
|
||||
dupLimit.RecordID = policy.LimitRecordID("limit-max_owned_private_games-2")
|
||||
require.True(t, errors.Is(store.SetLimit(ctx, ports.SetLimitInput{NewRecord: dupLimit}), ports.ErrConflict))
|
||||
|
||||
// SetLimit with ExpectedActiveRecord -> replaces in the active slot.
|
||||
expected := limit
|
||||
expected.RemovedAt = nil
|
||||
expected.RemovedBy = common.ActorRef{}
|
||||
expected.RemovedReasonCode = ""
|
||||
supersededTime := limit.AppliedAt.Add(time.Hour)
|
||||
supersededLimit := limit
|
||||
supersededLimit.RemovedAt = &supersededTime
|
||||
supersededLimit.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
|
||||
supersededLimit.RemovedReasonCode = common.ReasonCode("superseded")
|
||||
|
||||
newLimit := validLimit(record.UserID, policy.LimitCodeMaxOwnedPrivateGames, 7, supersededTime)
|
||||
newLimit.RecordID = policy.LimitRecordID("limit-max_owned_private_games-3")
|
||||
require.NoError(t, store.SetLimit(ctx, ports.SetLimitInput{
|
||||
ExpectedActiveRecord: &expected,
|
||||
UpdatedActiveRecord: &supersededLimit,
|
||||
NewRecord: newLimit,
|
||||
}))
|
||||
|
||||
gotLimit, err := store.GetLimitByRecordID(ctx, newLimit.RecordID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, newLimit, gotLimit)
|
||||
}
|
||||
|
||||
func TestUserListPaginatesNewestFirstAndDetectsFilterMismatch(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
base := fixtureCreatedAt
|
||||
for index, suffix := range []string{"a", "b", "c", "d", "e"} {
|
||||
acc := validAccount()
|
||||
acc.UserID = common.UserID("user-list-" + suffix)
|
||||
acc.Email = common.Email("list-" + suffix + "@example.com")
|
||||
acc.UserName = common.UserName("player-list" + suffix + "xx")
|
||||
acc.CreatedAt = base.Add(time.Duration(index) * time.Minute)
|
||||
acc.UpdatedAt = acc.CreatedAt
|
||||
require.NoError(t, store.Create(ctx, ports.CreateAccountInput{Account: acc}))
|
||||
}
|
||||
|
||||
page1, err := store.ListUserIDs(ctx, ports.ListUsersInput{PageSize: 2})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, page1.UserIDs, 2)
|
||||
require.Equal(t, common.UserID("user-list-e"), page1.UserIDs[0])
|
||||
require.Equal(t, common.UserID("user-list-d"), page1.UserIDs[1])
|
||||
require.NotEmpty(t, page1.NextPageToken)
|
||||
|
||||
page2, err := store.ListUserIDs(ctx, ports.ListUsersInput{
|
||||
PageSize: 2,
|
||||
PageToken: page1.NextPageToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, page2.UserIDs, 2)
|
||||
require.Equal(t, common.UserID("user-list-c"), page2.UserIDs[0])
|
||||
require.Equal(t, common.UserID("user-list-b"), page2.UserIDs[1])
|
||||
|
||||
// Mismatched filters must reject the previously-issued token.
|
||||
mismatched, err := store.ListUserIDs(ctx, ports.ListUsersInput{
|
||||
PageSize: 2,
|
||||
PageToken: page1.NextPageToken,
|
||||
Filters: ports.UserListFilters{PaidState: entitlement.PaidStatePaid},
|
||||
})
|
||||
require.True(t, errors.Is(err, ports.ErrInvalidPageToken), "got result %#v err %v", mismatched, err)
|
||||
}
|
||||
Reference in New Issue
Block a user