feat: use postgres

This commit is contained in:
Ilia Denisov
2026-04-26 20:34:39 +02:00
committed by GitHub
parent 48b0056b49
commit fe829285a6
365 changed files with 29223 additions and 24049 deletions
@@ -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)
}