feat: backend service

This commit is contained in:
Ilia Denisov
2026-05-06 10:14:55 +03:00
committed by GitHub
parent 3e2622757e
commit f446c6a2ac
1486 changed files with 49720 additions and 266401 deletions
+272
View File
@@ -0,0 +1,272 @@
package user
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
// ActorRef identifies the principal that produced an audit-bearing
// mutation. The wire shape mirrors the OpenAPI ActorRef schema. Type is
// a free-form string ("user", "admin", "system" in MVP); ID is opaque
// (a user UUID, an admin username, or empty for system).
type ActorRef struct {
Type string
ID string
}
// Validate rejects empty actor types. Admin handlers always populate
// Type; user-side mutations supply Type internally.
func (a ActorRef) Validate() error {
if strings.TrimSpace(a.Type) == "" {
return ErrInvalidActor
}
return nil
}
// Account is the read-side aggregate served by GetAccount and the
// admin/internal account fetches. It mirrors the OpenAPI `Account`
// schema; handlers convert it to the JSON wire shape.
type Account struct {
UserID uuid.UUID
Email string
UserName string
DisplayName string
PreferredLanguage string
TimeZone string
DeclaredCountry string
PermanentBlock bool
Entitlement EntitlementSnapshot
ActiveSanctions []ActiveSanction
ActiveLimits []ActiveLimit
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
}
// AccountPage is the paged listing returned by ListAccounts.
type AccountPage struct {
Items []Account
Page int
PageSize int
Total int
}
// UpdateProfileInput carries the mutable profile fields exposed by
// `PATCH /api/v1/user/account/profile`. The pointer fields keep the
// "unspecified" / "explicit empty" distinction so a request that omits
// `display_name` does not clear the stored value.
type UpdateProfileInput struct {
DisplayName *string
}
// UpdateSettingsInput carries the mutable settings fields exposed by
// `PATCH /api/v1/user/account/settings`.
type UpdateSettingsInput struct {
PreferredLanguage *string
TimeZone *string
}
// GetAccount loads the account aggregate for userID. Returns
// ErrAccountNotFound when the row is missing or has been soft-deleted.
//
// The entitlement snapshot is read through the in-memory cache when
// available, falling back to Postgres when the cache is cold (Warm not
// yet completed for a freshly-restarted process). Sanctions and limits
// are always read from Postgres.
func (s *Service) GetAccount(ctx context.Context, userID uuid.UUID) (Account, error) {
if userID == uuid.Nil {
return Account{}, ErrAccountNotFound
}
row, err := s.deps.Store.LookupAccount(ctx, userID)
if err != nil {
if errors.Is(err, ErrAccountNotFound) {
return Account{}, err
}
return Account{}, fmt.Errorf("user get account: %w", err)
}
snapshot, err := s.lookupSnapshot(ctx, userID)
if err != nil {
return Account{}, fmt.Errorf("user get account: snapshot: %w", err)
}
sanctions, err := s.deps.Store.ListActiveSanctions(ctx, userID)
if err != nil {
return Account{}, fmt.Errorf("user get account: sanctions: %w", err)
}
limits, err := s.deps.Store.ListActiveLimits(ctx, userID)
if err != nil {
return Account{}, fmt.Errorf("user get account: limits: %w", err)
}
return assembleAccount(row, snapshot, sanctions, limits), nil
}
// ListAccounts returns a paged listing of live accounts ordered by
// `created_at DESC, user_id DESC`. Soft-deleted rows are excluded.
func (s *Service) ListAccounts(ctx context.Context, page, pageSize int) (AccountPage, error) {
page, pageSize = normalisePaging(page, pageSize)
rows, total, err := s.deps.Store.ListAccountRows(ctx, page, pageSize)
if err != nil {
return AccountPage{}, fmt.Errorf("user list accounts: %w", err)
}
out := AccountPage{
Items: make([]Account, 0, len(rows)),
Page: page,
PageSize: pageSize,
Total: total,
}
for _, row := range rows {
snapshot, err := s.lookupSnapshot(ctx, row.UserID)
if err != nil {
return AccountPage{}, fmt.Errorf("user list accounts: snapshot for %s: %w", row.UserID, err)
}
sanctions, err := s.deps.Store.ListActiveSanctions(ctx, row.UserID)
if err != nil {
return AccountPage{}, fmt.Errorf("user list accounts: sanctions for %s: %w", row.UserID, err)
}
limits, err := s.deps.Store.ListActiveLimits(ctx, row.UserID)
if err != nil {
return AccountPage{}, fmt.Errorf("user list accounts: limits for %s: %w", row.UserID, err)
}
out.Items = append(out.Items, assembleAccount(row, snapshot, sanctions, limits))
}
return out, nil
}
// ResolveByEmail returns the user_id of the live account whose email
// matches the supplied (lower-cased, trimmed) value. Returns
// ErrAccountNotFound when no live row exists; soft-deleted rows are
// excluded.
func (s *Service) ResolveByEmail(ctx context.Context, email string) (uuid.UUID, error) {
normalised := strings.ToLower(strings.TrimSpace(email))
if normalised == "" {
return uuid.Nil, ErrInvalidInput
}
id, ok, err := s.deps.Store.LookupAccountIDByEmail(ctx, normalised)
if err != nil {
return uuid.Nil, fmt.Errorf("user resolve by email: %w", err)
}
if !ok {
return uuid.Nil, ErrAccountNotFound
}
return id, nil
}
// UpdateProfile patches the caller's mutable profile fields and
// returns the refreshed account aggregate.
func (s *Service) UpdateProfile(ctx context.Context, userID uuid.UUID, input UpdateProfileInput) (Account, error) {
if userID == uuid.Nil {
return Account{}, ErrAccountNotFound
}
if input.DisplayName != nil {
// PATCH semantics: omitted fields are not touched. An explicit
// empty value is allowed and clears the stored display name —
// matching the OpenAPI description of UpdateProfileRequest.
if err := s.deps.Store.UpdateAccountDisplayName(ctx, userID, *input.DisplayName, s.deps.Now().UTC()); err != nil {
if errors.Is(err, ErrAccountNotFound) {
return Account{}, err
}
return Account{}, fmt.Errorf("user update profile: %w", err)
}
}
return s.GetAccount(ctx, userID)
}
// UpdateSettings patches the caller's mutable settings fields and
// returns the refreshed account aggregate.
func (s *Service) UpdateSettings(ctx context.Context, userID uuid.UUID, input UpdateSettingsInput) (Account, error) {
if userID == uuid.Nil {
return Account{}, ErrAccountNotFound
}
patch := settingsPatch{}
if input.PreferredLanguage != nil {
trimmed := strings.TrimSpace(*input.PreferredLanguage)
if trimmed == "" {
return Account{}, fmt.Errorf("%w: preferred_language must be non-empty", ErrInvalidInput)
}
patch.PreferredLanguage = &trimmed
}
if input.TimeZone != nil {
trimmed := strings.TrimSpace(*input.TimeZone)
if trimmed == "" {
return Account{}, fmt.Errorf("%w: time_zone must be non-empty", ErrInvalidInput)
}
if _, err := time.LoadLocation(trimmed); err != nil {
return Account{}, fmt.Errorf("%w: time_zone must be a valid IANA zone", ErrInvalidInput)
}
patch.TimeZone = &trimmed
}
if patch.empty() {
return s.GetAccount(ctx, userID)
}
if err := s.deps.Store.UpdateAccountSettings(ctx, userID, patch, s.deps.Now().UTC()); err != nil {
if errors.Is(err, ErrAccountNotFound) {
return Account{}, err
}
return Account{}, fmt.Errorf("user update settings: %w", err)
}
return s.GetAccount(ctx, userID)
}
// lookupSnapshot consults the cache first and falls back to a direct
// Postgres read when the cache is cold. The cache miss is silent: the
// `Ready()` flag governs the readiness probe, not the live path.
func (s *Service) lookupSnapshot(ctx context.Context, userID uuid.UUID) (EntitlementSnapshot, error) {
if cached, ok := s.deps.Cache.Get(userID); ok {
return cached, nil
}
snap, err := s.deps.Store.LookupEntitlementSnapshot(ctx, userID)
if err != nil {
return EntitlementSnapshot{}, err
}
s.deps.Cache.Add(snap)
return snap, nil
}
// GetEntitlementSnapshot returns the latest entitlement snapshot for
// userID through the cache-first read path. Used by the lobby package
// to evaluate the per-user `max_registered_race_names`
// quota at race-name registration time.
func (s *Service) GetEntitlementSnapshot(ctx context.Context, userID uuid.UUID) (EntitlementSnapshot, error) {
return s.lookupSnapshot(ctx, userID)
}
func assembleAccount(row AccountRow, snapshot EntitlementSnapshot, sanctions []ActiveSanction, limits []ActiveLimit) Account {
return Account{
UserID: row.UserID,
Email: row.Email,
UserName: row.UserName,
DisplayName: row.DisplayName,
PreferredLanguage: row.PreferredLanguage,
TimeZone: row.TimeZone,
DeclaredCountry: row.DeclaredCountry,
PermanentBlock: row.PermanentBlock,
Entitlement: snapshot,
ActiveSanctions: sanctions,
ActiveLimits: limits,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
DeletedAt: row.DeletedAt,
}
}
func normalisePaging(page, pageSize int) (int, int) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 50
}
if pageSize > 200 {
pageSize = 200
}
return page, pageSize
}
+104
View File
@@ -0,0 +1,104 @@
package user
import (
"context"
"sync"
"sync/atomic"
"github.com/google/uuid"
)
// Cache is the in-memory write-through projection of the active rows in
// `backend.entitlement_snapshots`. Reads (Get) are RLocked; writes (Add)
// are Locked.
//
// The cache keys snapshots by user_id. Soft-delete does not evict
// entries — read paths gate visibility through the
// `accounts.deleted_at IS NULL` predicate, so a cached snapshot for a
// soft-deleted user is harmless and is reaped on the next process
// reboot.
//
// The caller is expected to commit the corresponding database write
// *before* invoking Add so that the cache stays consistent under crash:
// a Postgres commit failure leaves the cache untouched, matching the
// previous DB state. This mirrors the post-commit write-through pattern
// established in `backend/internal/auth.Cache`.
type Cache struct {
mu sync.RWMutex
byID map[uuid.UUID]EntitlementSnapshot
ready atomic.Bool
}
// NewCache constructs an empty Cache. The cache reports Ready() == false
// until Warm completes successfully.
func NewCache() *Cache {
return &Cache{
byID: make(map[uuid.UUID]EntitlementSnapshot),
}
}
// Warm replaces the cache contents with every row loaded from store. It
// is intended to be called exactly once at process boot before the HTTP
// listener accepts traffic; successful completion flips Ready to true.
// Subsequent calls re-warm the cache (useful in tests).
func (c *Cache) Warm(ctx context.Context, store *Store) error {
if c == nil {
return nil
}
snapshots, err := store.ListEntitlementSnapshots(ctx)
if err != nil {
return err
}
c.mu.Lock()
defer c.mu.Unlock()
c.byID = make(map[uuid.UUID]EntitlementSnapshot, len(snapshots))
for _, snap := range snapshots {
c.byID[snap.UserID] = snap
}
c.ready.Store(true)
return nil
}
// Ready reports whether Warm has completed at least once. The HTTP
// readiness probe wires through this method together with the auth
// cache so `/readyz` only flips to 200 after every cache is hydrated.
func (c *Cache) Ready() bool {
if c == nil {
return false
}
return c.ready.Load()
}
// Size returns the number of cached entitlement snapshots. Useful for
// the startup log line and tests.
func (c *Cache) Size() int {
if c == nil {
return 0
}
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.byID)
}
// Get returns the snapshot for userID and a presence flag. Misses
// always return the zero EntitlementSnapshot and false.
func (c *Cache) Get(userID uuid.UUID) (EntitlementSnapshot, bool) {
if c == nil {
return EntitlementSnapshot{}, false
}
c.mu.RLock()
defer c.mu.RUnlock()
s, ok := c.byID[userID]
return s, ok
}
// Add stores snap in the cache. It is safe to call on an existing
// entry — the value is overwritten with the latest snapshot.
func (c *Cache) Add(snap EntitlementSnapshot) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.byID[snap.UserID] = snap
}
+80
View File
@@ -0,0 +1,80 @@
package user_test
import (
"context"
"database/sql"
"testing"
"time"
"galaxy/backend/internal/user"
"github.com/google/uuid"
)
func TestCacheGetReturnsFalseUntilAdded(t *testing.T) {
t.Parallel()
cache := user.NewCache()
if _, ok := cache.Get(uuid.New()); ok {
t.Fatalf("Get on empty cache returned ok=true")
}
}
func TestCacheReadyFlipsAfterWarm(t *testing.T) {
t.Parallel()
cache := user.NewCache()
if cache.Ready() {
t.Fatalf("Ready() = true before Warm")
}
store := user.NewStore(stubDB(t))
if err := cache.Warm(context.Background(), store); err == nil {
t.Fatalf("Warm against an empty stub DB unexpectedly succeeded")
}
if cache.Ready() {
t.Fatalf("Ready() flipped after a failed Warm")
}
}
func TestCacheAddIsVisibleToReader(t *testing.T) {
t.Parallel()
cache := user.NewCache()
id := uuid.New()
now := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC)
cache.Add(user.EntitlementSnapshot{
UserID: id,
Tier: user.TierFree,
IsPaid: false,
Source: "system",
Actor: user.ActorRef{Type: "system"},
StartsAt: now,
MaxRegisteredRaceNames: 1,
UpdatedAt: now,
})
got, ok := cache.Get(id)
if !ok {
t.Fatalf("Get after Add returned ok=false")
}
if got.Tier != user.TierFree {
t.Fatalf("Get returned tier %q, want %q", got.Tier, user.TierFree)
}
if cache.Size() != 1 {
t.Fatalf("Size = %d, want 1", cache.Size())
}
}
// stubDB returns a *sql.DB that fails every query. Used only by the
// "Warm-on-failure does not flip Ready" test where the actual driver
// behaviour is irrelevant.
func stubDB(t *testing.T) *sql.DB {
t.Helper()
// sql.Open("postgres", ...) without a registered driver returns
// an error; use a malformed DSN against the stdlib's bundled
// `unknown` driver to force a query-time failure. We rely on
// pgx-stdlib being already registered by the project, so the
// driver name "pgx" is safe to use even when the DSN is bogus.
db, err := sql.Open("pgx", "postgres://disabled.invalid:5432/none?sslmode=disable&connect_timeout=1")
if err != nil {
t.Fatalf("sql.Open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
+82
View File
@@ -0,0 +1,82 @@
package user
import (
"context"
"github.com/google/uuid"
"go.uber.org/zap"
)
// LobbyCascade collects the lobby-side hooks the user lifecycle invokes
// after a successful soft-delete or permanent-block transition. The
// real implementation lives in `backend/internal/lobby`.
// Until then `NewNoopLobbyCascade` satisfies the contract.
type LobbyCascade interface {
OnUserDeleted(ctx context.Context, userID uuid.UUID) error
OnUserBlocked(ctx context.Context, userID uuid.UUID) error
}
// NotificationCascade collects the notification-side hooks invoked at
// soft-delete. The real implementation lives in
// `backend/internal/notification`.
type NotificationCascade interface {
OnUserDeleted(ctx context.Context, userID uuid.UUID) error
}
// GeoCascade collects the geo-side hooks invoked at soft-delete. The
// real implementation is `*geo.Service` once The implementation lands the
// `OnUserDeleted` method.
type GeoCascade interface {
OnUserDeleted(ctx context.Context, userID uuid.UUID) error
}
// SessionRevoker revokes every active session bound to a user. The
// canonical implementation wraps `*auth.Service.RevokeAllForUser`. The
// adapter lives in `cmd/backend/main.go` so `auth` does not export an
// extra method shape.
type SessionRevoker interface {
RevokeAllForUser(ctx context.Context, userID uuid.UUID) error
}
// NewNoopLobbyCascade returns a LobbyCascade that logs every invocation
// at info level and returns nil. The canonical lobby is wired in `cmd/backend/main.go`.
// implementation; until then the no-op keeps the cascade orchestration
// callable end-to-end.
func NewNoopLobbyCascade(logger *zap.Logger) LobbyCascade {
if logger == nil {
logger = zap.NewNop()
}
return &noopLobbyCascade{logger: logger.Named("user.lobby.noop")}
}
type noopLobbyCascade struct {
logger *zap.Logger
}
func (c *noopLobbyCascade) OnUserDeleted(_ context.Context, userID uuid.UUID) error {
c.logger.Info("lobby on-user-deleted (noop cascade)", zap.String("user_id", userID.String()))
return nil
}
func (c *noopLobbyCascade) OnUserBlocked(_ context.Context, userID uuid.UUID) error {
c.logger.Info("lobby on-user-blocked (noop cascade)", zap.String("user_id", userID.String()))
return nil
}
// NewNoopNotificationCascade returns a NotificationCascade that logs
// every invocation at info level and returns nil. The canonical implementation replaces // it with the real notification implementation.
func NewNoopNotificationCascade(logger *zap.Logger) NotificationCascade {
if logger == nil {
logger = zap.NewNop()
}
return &noopNotificationCascade{logger: logger.Named("user.notification.noop")}
}
type noopNotificationCascade struct {
logger *zap.Logger
}
func (c *noopNotificationCascade) OnUserDeleted(_ context.Context, userID uuid.UUID) error {
c.logger.Info("notification on-user-deleted (noop cascade)", zap.String("user_id", userID.String()))
return nil
}
+150
View File
@@ -0,0 +1,150 @@
package user
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
// Tier values mirror the closed set documented in
// `backend/README.md` and enforced by CHECK constraints
// on `entitlement_records` and `entitlement_snapshots`.
const (
TierFree = "free"
TierMonthly = "monthly"
TierYearly = "yearly"
TierPermanent = "permanent"
)
// EntitlementSnapshot is the read-side view of the user's current
// entitlement. It mirrors the OpenAPI `EntitlementSnapshot` schema.
type EntitlementSnapshot struct {
UserID uuid.UUID
Tier string
IsPaid bool
Source string
Actor ActorRef
ReasonCode string
StartsAt time.Time
EndsAt *time.Time
MaxRegisteredRaceNames int32
UpdatedAt time.Time
}
// ApplyEntitlementInput carries the admin-supplied parameters of
// `POST /api/v1/admin/users/{user_id}/entitlements`. StartsAt and
// EndsAt are optional: when omitted the tier policy supplies sensible
// defaults (StartsAt = now, EndsAt = now + tier window for monthly /
// yearly, otherwise NULL).
type ApplyEntitlementInput struct {
UserID uuid.UUID
Tier string
Source string
Actor ActorRef
ReasonCode string
StartsAt *time.Time
EndsAt *time.Time
}
// tierBinding is the static policy table that maps tiers to their
// derived attributes. The implementation keeps the values inline; later stages
// can move the table to configuration if marketing requirements
// diverge.
type tierBinding struct {
IsPaid bool
MaxRegisteredRaceNames int32
DefaultDuration time.Duration // 0 = no expiry
}
var tierPolicy = map[string]tierBinding{
TierFree: {IsPaid: false, MaxRegisteredRaceNames: 1, DefaultDuration: 0},
TierMonthly: {IsPaid: true, MaxRegisteredRaceNames: 5, DefaultDuration: 30 * 24 * time.Hour},
TierYearly: {IsPaid: true, MaxRegisteredRaceNames: 5, DefaultDuration: 365 * 24 * time.Hour},
TierPermanent: {IsPaid: true, MaxRegisteredRaceNames: 5, DefaultDuration: 0},
}
// defaultFreeSnapshot returns the entitlement snapshot installed for a
// brand-new account at registration time.
func defaultFreeSnapshot(userID uuid.UUID, now time.Time) EntitlementSnapshot {
binding := tierPolicy[TierFree]
return EntitlementSnapshot{
UserID: userID,
Tier: TierFree,
IsPaid: binding.IsPaid,
Source: "system",
Actor: ActorRef{Type: "system"},
ReasonCode: "",
StartsAt: now,
EndsAt: nil,
MaxRegisteredRaceNames: binding.MaxRegisteredRaceNames,
UpdatedAt: now,
}
}
// ApplyEntitlement validates the supplied input, derives missing
// attributes from the tier policy, persists an immutable record and a
// fresh snapshot atomically, refreshes the cache, and returns the
// up-to-date account aggregate.
//
// Tier downgrades are accepted: lobby state in the consolidated implementation enforces the
// "already-registered race names are never revoked on downgrade" rule
// at the lobby surface; user.Service is unconcerned with that
// invariant on the entitlement write path.
func (s *Service) ApplyEntitlement(ctx context.Context, input ApplyEntitlementInput) (Account, error) {
if input.UserID == uuid.Nil {
return Account{}, ErrAccountNotFound
}
binding, ok := tierPolicy[strings.TrimSpace(input.Tier)]
if !ok {
return Account{}, fmt.Errorf("%w: %q", ErrInvalidTier, input.Tier)
}
if err := input.Actor.Validate(); err != nil {
return Account{}, err
}
source := strings.TrimSpace(input.Source)
if source == "" {
return Account{}, fmt.Errorf("%w: source must be non-empty", ErrInvalidInput)
}
now := s.deps.Now().UTC()
startsAt := now
if input.StartsAt != nil {
startsAt = input.StartsAt.UTC()
}
var endsAt *time.Time
switch {
case input.EndsAt != nil:
t := input.EndsAt.UTC()
endsAt = &t
case binding.DefaultDuration > 0:
t := startsAt.Add(binding.DefaultDuration)
endsAt = &t
}
snapshot := EntitlementSnapshot{
UserID: input.UserID,
Tier: input.Tier,
IsPaid: binding.IsPaid,
Source: source,
Actor: input.Actor,
ReasonCode: input.ReasonCode,
StartsAt: startsAt,
EndsAt: endsAt,
MaxRegisteredRaceNames: binding.MaxRegisteredRaceNames,
UpdatedAt: now,
}
persisted, err := s.deps.Store.ApplyEntitlementTx(ctx, snapshot)
if err != nil {
if errors.Is(err, ErrAccountNotFound) {
return Account{}, err
}
return Account{}, fmt.Errorf("user apply entitlement: %w", err)
}
s.deps.Cache.Add(persisted)
return s.GetAccount(ctx, input.UserID)
}
+27
View File
@@ -0,0 +1,27 @@
package user
import "errors"
// ErrAccountNotFound is returned by lookups against `backend.accounts`
// when the row is missing or has been soft-deleted. Handlers map it to
// HTTP 404.
var ErrAccountNotFound = errors.New("user: account not found")
// ErrInvalidInput marks a request as syntactically valid but
// semantically rejected (empty display name when the field was
// supplied, blank time zone, etc.). Handlers map it to HTTP 400.
var ErrInvalidInput = errors.New("user: invalid input")
// ErrInvalidTier is returned by ApplyEntitlement when the supplied tier
// does not belong to the closed MVP set documented in
// `backend/README.md`. Handlers map it to HTTP 400.
var ErrInvalidTier = errors.New("user: invalid tier")
// ErrInvalidSanctionCode marks an ApplySanction request whose
// sanction_code is not in the closed MVP set ({permanent_block}).
var ErrInvalidSanctionCode = errors.New("user: invalid sanction code")
// ErrInvalidActor is returned when an admin-side mutation arrives
// without a non-empty actor.type. The user-side mutations populate
// actor.type internally and never produce this error.
var ErrInvalidActor = errors.New("user: invalid actor")
+77
View File
@@ -0,0 +1,77 @@
package user
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
// ActiveLimit is the read-side projection of a row in `limit_active`
// joined with the audit columns from the underlying `limit_records`
// row. It mirrors the OpenAPI `ActiveLimit` schema.
type ActiveLimit struct {
LimitCode string
Value int32
ReasonCode string
Actor ActorRef
AppliedAt time.Time
ExpiresAt *time.Time
}
// ApplyLimitInput carries the admin-supplied parameters of
// `POST /api/v1/admin/users/{user_id}/limits`.
type ApplyLimitInput struct {
UserID uuid.UUID
LimitCode string
Value int32
ReasonCode string
Actor ActorRef
ExpiresAt *time.Time
}
// ApplyLimit persists a fresh `limit_records` row and upserts
// `limit_active` in one transaction. The implementation keeps `limit_code` as an
// open string; The implementation may add a CHECK constraint once the closed
// set is locked in.
func (s *Service) ApplyLimit(ctx context.Context, input ApplyLimitInput) (Account, error) {
if input.UserID == uuid.Nil {
return Account{}, ErrAccountNotFound
}
if strings.TrimSpace(input.LimitCode) == "" {
return Account{}, fmt.Errorf("%w: limit_code must be non-empty", ErrInvalidInput)
}
if err := input.Actor.Validate(); err != nil {
return Account{}, err
}
if strings.TrimSpace(input.ReasonCode) == "" {
return Account{}, fmt.Errorf("%w: reason_code must be non-empty", ErrInvalidInput)
}
now := s.deps.Now().UTC()
expiresAt := input.ExpiresAt
if expiresAt != nil {
t := expiresAt.UTC()
expiresAt = &t
}
if err := s.deps.Store.ApplyLimitTx(ctx, limitInsert{
UserID: input.UserID,
LimitCode: input.LimitCode,
Value: input.Value,
ReasonCode: input.ReasonCode,
ActorType: input.Actor.Type,
ActorID: input.Actor.ID,
AppliedAt: now,
ExpiresAt: expiresAt,
}); err != nil {
if errors.Is(err, ErrAccountNotFound) {
return Account{}, err
}
return Account{}, fmt.Errorf("user apply limit: %w", err)
}
return s.GetAccount(ctx, input.UserID)
}
+133
View File
@@ -0,0 +1,133 @@
package user
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
)
// SanctionCode values mirror the closed MVP set from
// `backend/README.md` and the CHECK constraint on
// `sanction_records`.
const (
SanctionCodePermanentBlock = "permanent_block"
)
// ActiveSanction is the read-side projection of a row in
// `sanction_active` joined with the audit columns from the underlying
// `sanction_records` row. It mirrors the OpenAPI `ActiveSanction`
// schema.
type ActiveSanction struct {
SanctionCode string
Scope string
ReasonCode string
Actor ActorRef
AppliedAt time.Time
ExpiresAt *time.Time
}
// ApplySanctionInput carries the admin-supplied parameters of
// `POST /api/v1/admin/users/{user_id}/sanctions`.
type ApplySanctionInput struct {
UserID uuid.UUID
SanctionCode string
Scope string
ReasonCode string
Actor ActorRef
ExpiresAt *time.Time
}
// ApplySanction persists a fresh `sanction_records` row, upserts
// `sanction_active`, and — when sanction_code == "permanent_block" —
// flips `accounts.permanent_block = true` in the same transaction.
// After commit it revokes every active session for the user (if a
// SessionRevoker is wired) and fires the lobby on-user-blocked
// cascade.
//
// Errors from the post-commit cascade are joined and logged; they do
// not roll back the persisted sanction.
func (s *Service) ApplySanction(ctx context.Context, input ApplySanctionInput) (Account, error) {
if input.UserID == uuid.Nil {
return Account{}, ErrAccountNotFound
}
if err := validateSanctionCode(input.SanctionCode); err != nil {
return Account{}, err
}
if err := input.Actor.Validate(); err != nil {
return Account{}, err
}
if strings.TrimSpace(input.Scope) == "" {
return Account{}, fmt.Errorf("%w: scope must be non-empty", ErrInvalidInput)
}
if strings.TrimSpace(input.ReasonCode) == "" {
return Account{}, fmt.Errorf("%w: reason_code must be non-empty", ErrInvalidInput)
}
now := s.deps.Now().UTC()
expiresAt := input.ExpiresAt
if expiresAt != nil {
t := expiresAt.UTC()
expiresAt = &t
}
flipPermanent := input.SanctionCode == SanctionCodePermanentBlock
if err := s.deps.Store.ApplySanctionTx(ctx, sanctionInsert{
UserID: input.UserID,
SanctionCode: input.SanctionCode,
Scope: input.Scope,
ReasonCode: input.ReasonCode,
ActorType: input.Actor.Type,
ActorID: input.Actor.ID,
AppliedAt: now,
ExpiresAt: expiresAt,
FlipPermanent: flipPermanent,
}); err != nil {
if errors.Is(err, ErrAccountNotFound) {
return Account{}, err
}
return Account{}, fmt.Errorf("user apply sanction: %w", err)
}
if flipPermanent {
if err := s.cascadePermanentBlock(ctx, input.UserID); err != nil {
s.deps.Logger.Warn("permanent-block cascade returned error",
zap.String("user_id", input.UserID.String()),
zap.Error(err),
)
}
}
return s.GetAccount(ctx, input.UserID)
}
func validateSanctionCode(code string) error {
switch strings.TrimSpace(code) {
case SanctionCodePermanentBlock:
return nil
default:
return fmt.Errorf("%w: %q", ErrInvalidSanctionCode, code)
}
}
// cascadePermanentBlock revokes every active session and fires the
// lobby on-user-blocked hook. Both calls are best-effort — they run
// after the database commit and only join errors for the caller to
// log.
func (s *Service) cascadePermanentBlock(ctx context.Context, userID uuid.UUID) error {
var joined error
if s.deps.SessionRevoker != nil {
if err := s.deps.SessionRevoker.RevokeAllForUser(ctx, userID); err != nil {
joined = errors.Join(joined, fmt.Errorf("session revoke: %w", err))
}
}
if s.deps.Lobby != nil {
if err := s.deps.Lobby.OnUserBlocked(ctx, userID); err != nil {
joined = errors.Join(joined, fmt.Errorf("lobby on-user-blocked: %w", err))
}
}
return joined
}
+84
View File
@@ -0,0 +1,84 @@
package user
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"go.uber.org/zap"
)
// SoftDelete marks the account as soft-deleted with an audit trail of
// who initiated the operation, then drives the documented in-process
// cascade across `auth`, `lobby`, `notification`, and `geo`.
//
// The `accounts` row is the canonical state; cascade calls run after
// the database commit and are best-effort. Cascade failures are joined
// into the returned error and logged but never roll back the
// soft-delete: the producer signal is "this user is gone", and
// downstream cleanup is idempotent so a future retry can finish the
// job.
//
// Repeated calls on an already-soft-deleted account are no-ops: the
// store reports `false` for "row changed" and the cascade is skipped.
func (s *Service) SoftDelete(ctx context.Context, userID uuid.UUID, actor ActorRef) error {
if userID == uuid.Nil {
return ErrAccountNotFound
}
if err := actor.Validate(); err != nil {
return err
}
now := s.deps.Now().UTC()
changed, err := s.deps.Store.SoftDeleteAccount(ctx, userID, actor, now)
if err != nil {
return fmt.Errorf("user soft delete: %w", err)
}
if !changed {
s.deps.Logger.Info("user soft delete skipped (already deleted)",
zap.String("user_id", userID.String()),
)
return nil
}
s.deps.Logger.Info("user soft deleted",
zap.String("user_id", userID.String()),
zap.String("actor_type", actor.Type),
)
return s.runSoftDeleteCascade(ctx, userID)
}
// runSoftDeleteCascade fans the soft-delete signal out to dependent
// modules in the documented order: auth → lobby → notification → geo.
// Each call's error is joined; the loop continues even after a
// failure so the remaining modules still get notified.
func (s *Service) runSoftDeleteCascade(ctx context.Context, userID uuid.UUID) error {
var joined error
if s.deps.SessionRevoker != nil {
if err := s.deps.SessionRevoker.RevokeAllForUser(ctx, userID); err != nil {
joined = errors.Join(joined, fmt.Errorf("session revoke: %w", err))
}
}
if s.deps.Lobby != nil {
if err := s.deps.Lobby.OnUserDeleted(ctx, userID); err != nil {
joined = errors.Join(joined, fmt.Errorf("lobby on-user-deleted: %w", err))
}
}
if s.deps.Notification != nil {
if err := s.deps.Notification.OnUserDeleted(ctx, userID); err != nil {
joined = errors.Join(joined, fmt.Errorf("notification on-user-deleted: %w", err))
}
}
if s.deps.Geo != nil {
if err := s.deps.Geo.OnUserDeleted(ctx, userID); err != nil {
joined = errors.Join(joined, fmt.Errorf("geo on-user-deleted: %w", err))
}
}
if joined != nil {
s.deps.Logger.Warn("soft-delete cascade returned errors",
zap.String("user_id", userID.String()),
zap.Error(joined),
)
}
return joined
}
+193
View File
@@ -0,0 +1,193 @@
package user_test
import (
"context"
"errors"
"testing"
"time"
"galaxy/backend/internal/user"
"github.com/google/uuid"
)
// TestSoftDeleteCascadeRunsAuthLobbyNotificationGeoInOrder verifies the
// documented cascade order. The test uses recording stubs for every
// hook and asserts that each one received the soft-delete signal
// exactly once for the right user_id.
func TestSoftDeleteCascadeRunsAuthLobbyNotificationGeoInOrder(t *testing.T) {
db := startPostgres(t)
revoker := &orderTracker{name: "auth"}
lobby := &orderingLobbyCascade{name: "lobby"}
notif := &orderingNotificationCascade{name: "notification"}
geo := &orderingGeoCascade{name: "geo"}
var order []string
revoker.appendTo = func(s string) { order = append(order, s) }
lobby.appendTo = func(s string) { order = append(order, s) }
notif.appendTo = func(s string) { order = append(order, s) }
geo.appendTo = func(s string) { order = append(order, s) }
svc := user.NewService(user.Deps{
Store: user.NewStore(db),
Cache: user.NewCache(),
Lobby: lobby,
Notification: notif,
Geo: geo,
SessionRevoker: revoker,
UserNameMaxRetries: 10,
Now: time.Now,
})
uid, err := svc.EnsureByEmail(context.Background(), "leo@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail: %v", err)
}
if err := svc.SoftDelete(context.Background(), uid, user.ActorRef{Type: "user", ID: uid.String()}); err != nil {
t.Fatalf("SoftDelete: %v", err)
}
want := []string{"auth", "lobby", "notification", "geo"}
if !equalStrings(order, want) {
t.Fatalf("cascade order = %v, want %v", order, want)
}
// Second call is a no-op — cascade must not fire again.
if err := svc.SoftDelete(context.Background(), uid, user.ActorRef{Type: "user", ID: uid.String()}); err != nil {
t.Fatalf("idempotent SoftDelete: %v", err)
}
if !equalStrings(order, want) {
t.Fatalf("idempotent SoftDelete fired cascade again: %v", order)
}
}
// TestSoftDeleteCascadeErrorDoesNotRollback covers the contract that
// cascade failures are surfaced to the caller but do not undo the
// `accounts.deleted_at` write.
func TestSoftDeleteCascadeErrorDoesNotRollback(t *testing.T) {
db := startPostgres(t)
failingNotif := &failingNotificationCascade{err: errors.New("notification down")}
svc := user.NewService(user.Deps{
Store: user.NewStore(db),
Cache: user.NewCache(),
Lobby: &orderingLobbyCascade{},
Notification: failingNotif,
Geo: &orderingGeoCascade{},
SessionRevoker: &orderTracker{},
UserNameMaxRetries: 10,
Now: time.Now,
})
uid, err := svc.EnsureByEmail(context.Background(), "mia@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail: %v", err)
}
err = svc.SoftDelete(context.Background(), uid, user.ActorRef{Type: "user", ID: uid.String()})
if err == nil {
t.Fatalf("SoftDelete returned nil despite failing cascade")
}
if !errors.Is(err, failingNotif.err) {
t.Fatalf("SoftDelete error = %v, want join containing %v", err, failingNotif.err)
}
var deletedAt *time.Time
if scanErr := db.QueryRowContext(context.Background(),
`SELECT deleted_at FROM backend.accounts WHERE user_id = $1`, uid,
).Scan(&deletedAt); scanErr != nil {
t.Fatalf("SELECT deleted_at: %v", scanErr)
}
if deletedAt == nil {
t.Fatalf("deleted_at = NULL despite SoftDelete commit")
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// orderTracker spies on a single call kind and pushes its name into
// the ordered slice when invoked. It satisfies user.SessionRevoker.
type orderTracker struct {
name string
calls int
lastUser uuid.UUID
appendTo func(string)
}
func (r *orderTracker) RevokeAllForUser(_ context.Context, userID uuid.UUID) error {
r.calls++
r.lastUser = userID
if r.appendTo != nil && r.name != "" {
r.appendTo(r.name)
}
return nil
}
type orderingLobbyCascade struct {
name string
appendTo func(string)
deleted int
blocked int
}
func (c *orderingLobbyCascade) OnUserDeleted(_ context.Context, _ uuid.UUID) error {
c.deleted++
if c.appendTo != nil && c.name != "" {
c.appendTo(c.name)
}
return nil
}
func (c *orderingLobbyCascade) OnUserBlocked(_ context.Context, _ uuid.UUID) error {
c.blocked++
return nil
}
type orderingNotificationCascade struct {
name string
appendTo func(string)
calls int
}
func (c *orderingNotificationCascade) OnUserDeleted(_ context.Context, _ uuid.UUID) error {
c.calls++
if c.appendTo != nil && c.name != "" {
c.appendTo(c.name)
}
return nil
}
type orderingGeoCascade struct {
name string
appendTo func(string)
calls int
}
func (c *orderingGeoCascade) OnUserDeleted(_ context.Context, _ uuid.UUID) error {
c.calls++
if c.appendTo != nil && c.name != "" {
c.appendTo(c.name)
}
return nil
}
type failingNotificationCascade struct {
err error
calls int
}
func (c *failingNotificationCascade) OnUserDeleted(_ context.Context, _ uuid.UUID) error {
c.calls++
return c.err
}
+757
View File
@@ -0,0 +1,757 @@
package user
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"galaxy/backend/internal/postgres/jet/backend/model"
"galaxy/backend/internal/postgres/jet/backend/table"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
)
// Store is the Postgres-backed query surface for the user package.
// All queries are built through go-jet against the generated table
// bindings under `backend/internal/postgres/jet/backend/table`.
type Store struct {
db *sql.DB
}
// NewStore constructs a Store wrapping db.
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
// AccountRow mirrors a row in `backend.accounts` with the specific
// projection the user-package read paths need. It is not a full
// representation of the table; column subsets like the audit trail are
// folded into Account by the Service layer.
type AccountRow struct {
UserID uuid.UUID
Email string
UserName string
DisplayName string
PreferredLanguage string
TimeZone string
DeclaredCountry string
PermanentBlock bool
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
}
// accountInsert is the parameter struct for InsertAccountWithSnapshot.
type accountInsert struct {
UserID uuid.UUID
Email string
UserName string
PreferredLanguage string
TimeZone string
DeclaredCountry string
}
// settingsPatch carries the optional settings columns supplied by an
// `UpdateSettingsInput`. Nil pointers mean "leave the column alone".
type settingsPatch struct {
PreferredLanguage *string
TimeZone *string
}
func (p settingsPatch) empty() bool {
return p.PreferredLanguage == nil && p.TimeZone == nil
}
// sanctionInsert is the parameter struct for ApplySanctionTx.
type sanctionInsert struct {
UserID uuid.UUID
SanctionCode string
Scope string
ReasonCode string
ActorType string
ActorID string
AppliedAt time.Time
ExpiresAt *time.Time
FlipPermanent bool
}
// limitInsert is the parameter struct for ApplyLimitTx.
type limitInsert struct {
UserID uuid.UUID
LimitCode string
Value int32
ReasonCode string
ActorType string
ActorID string
AppliedAt time.Time
ExpiresAt *time.Time
}
// errEmailRace is a sentinel returned by InsertAccountWithSnapshot when
// the ON CONFLICT (email) DO NOTHING branch fires. The caller looks up
// the existing user_id and returns it instead.
var errEmailRace = errors.New("user store: email already exists")
// accountColumns is the canonical projection used by every read of
// `backend.accounts`. Centralised so the model-row → AccountRow
// converter stays in sync with the SELECT order.
func accountColumns() postgres.ColumnList {
a := table.Accounts
return postgres.ColumnList{
a.UserID, a.Email, a.UserName, a.DisplayName,
a.PreferredLanguage, a.TimeZone, a.DeclaredCountry, a.PermanentBlock,
a.CreatedAt, a.UpdatedAt, a.DeletedAt,
}
}
// snapshotColumns is the canonical projection used by every read of
// `backend.entitlement_snapshots`.
func snapshotColumns() postgres.ColumnList {
s := table.EntitlementSnapshots
return postgres.ColumnList{
s.UserID, s.Tier, s.IsPaid, s.Source, s.ActorType, s.ActorID,
s.ReasonCode, s.StartsAt, s.EndsAt, s.MaxRegisteredRaceNames, s.UpdatedAt,
}
}
// LookupAccountIDByEmail returns the user_id of the live account for
// email. The boolean reports whether a row was found. Soft-deleted
// rows are skipped.
func (s *Store) LookupAccountIDByEmail(ctx context.Context, email string) (uuid.UUID, bool, error) {
stmt := postgres.SELECT(table.Accounts.UserID).
FROM(table.Accounts).
WHERE(
table.Accounts.Email.EQ(postgres.String(email)).
AND(table.Accounts.DeletedAt.IS_NULL()),
).
LIMIT(1)
var row model.Accounts
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return uuid.Nil, false, nil
}
return uuid.Nil, false, err
}
return row.UserID, true, nil
}
// LookupAccount returns the AccountRow projection for userID. Soft-deleted
// rows are excluded; returns ErrAccountNotFound when no live row exists.
func (s *Store) LookupAccount(ctx context.Context, userID uuid.UUID) (AccountRow, error) {
stmt := postgres.SELECT(accountColumns()).
FROM(table.Accounts).
WHERE(
table.Accounts.UserID.EQ(postgres.UUID(userID)).
AND(table.Accounts.DeletedAt.IS_NULL()),
).
LIMIT(1)
var row model.Accounts
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return AccountRow{}, ErrAccountNotFound
}
return AccountRow{}, fmt.Errorf("user store: scan account: %w", err)
}
return modelToAccountRow(row), nil
}
// ListAccountRows returns the requested page of live accounts together
// with the total live-row count for pagination.
func (s *Store) ListAccountRows(ctx context.Context, page, pageSize int) ([]AccountRow, int, error) {
a := table.Accounts
totalStmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).
FROM(a).
WHERE(a.DeletedAt.IS_NULL())
var totalDest struct {
Count int64 `alias:"count"`
}
if err := totalStmt.QueryContext(ctx, s.db, &totalDest); err != nil {
return nil, 0, fmt.Errorf("user store: count accounts: %w", err)
}
offset := (page - 1) * pageSize
listStmt := postgres.SELECT(accountColumns()).
FROM(a).
WHERE(a.DeletedAt.IS_NULL()).
ORDER_BY(a.CreatedAt.DESC(), a.UserID.DESC()).
LIMIT(int64(pageSize)).OFFSET(int64(offset))
var rows []model.Accounts
if err := listStmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, 0, fmt.Errorf("user store: list accounts: %w", err)
}
out := make([]AccountRow, 0, len(rows))
for _, row := range rows {
out = append(out, modelToAccountRow(row))
}
return out, int(totalDest.Count), nil
}
// InsertAccountWithSnapshot persists a brand-new accounts row and the
// matching default entitlement snapshot in one transaction. On
// ON CONFLICT (email) DO NOTHING it returns errEmailRace so the caller
// can recover the existing user_id; on user_name UNIQUE violation it
// returns the underlying pgconn error so the caller can retry the
// suffix.
func (s *Store) InsertAccountWithSnapshot(ctx context.Context, account accountInsert, snapshot EntitlementSnapshot) (uuid.UUID, error) {
var declaredCountryArg postgres.Expression = postgres.StringExp(postgres.NULL)
if account.DeclaredCountry != "" {
declaredCountryArg = postgres.String(account.DeclaredCountry)
}
var insertedID uuid.UUID
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
insertStmt := table.Accounts.INSERT(
table.Accounts.UserID, table.Accounts.Email, table.Accounts.UserName,
table.Accounts.PreferredLanguage, table.Accounts.TimeZone, table.Accounts.DeclaredCountry,
).VALUES(
account.UserID, account.Email, account.UserName,
account.PreferredLanguage, account.TimeZone, declaredCountryArg,
).
ON_CONFLICT(table.Accounts.Email).DO_NOTHING().
RETURNING(table.Accounts.UserID)
var inserted model.Accounts
if err := insertStmt.QueryContext(ctx, tx, &inserted); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return errEmailRace
}
return err
}
insertedID = inserted.UserID
return insertSnapshotTx(ctx, tx, snapshot)
})
if err != nil {
return uuid.Nil, err
}
return insertedID, nil
}
// LookupEntitlementSnapshot loads the snapshot row for userID. Returns
// ErrAccountNotFound when no row exists (a fresh account without a
// snapshot is treated as "account not found" — the bootstrap path
// always inserts the default snapshot).
func (s *Store) LookupEntitlementSnapshot(ctx context.Context, userID uuid.UUID) (EntitlementSnapshot, error) {
stmt := postgres.SELECT(snapshotColumns()).
FROM(table.EntitlementSnapshots).
WHERE(table.EntitlementSnapshots.UserID.EQ(postgres.UUID(userID))).
LIMIT(1)
var row model.EntitlementSnapshots
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return EntitlementSnapshot{}, ErrAccountNotFound
}
return EntitlementSnapshot{}, fmt.Errorf("user store: lookup snapshot for %s: %w", userID, err)
}
return modelToSnapshot(row), nil
}
// ListEntitlementSnapshots loads every snapshot row. Cache.Warm calls
// this at process boot.
func (s *Store) ListEntitlementSnapshots(ctx context.Context) ([]EntitlementSnapshot, error) {
stmt := postgres.SELECT(snapshotColumns()).FROM(table.EntitlementSnapshots)
var rows []model.EntitlementSnapshots
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("user store: list snapshots: %w", err)
}
out := make([]EntitlementSnapshot, 0, len(rows))
for _, row := range rows {
out = append(out, modelToSnapshot(row))
}
return out, nil
}
// ListActiveSanctions returns the active sanctions for userID joined
// with the audit columns from the underlying records row. Order is
// applied_at DESC so the most recent sanction surfaces first.
func (s *Store) ListActiveSanctions(ctx context.Context, userID uuid.UUID) ([]ActiveSanction, error) {
a := table.SanctionActive
r := table.SanctionRecords
stmt := postgres.SELECT(
r.SanctionCode, r.Scope, r.ReasonCode,
r.ActorType, r.ActorID,
r.AppliedAt, r.ExpiresAt,
).
FROM(a.INNER_JOIN(r, r.RecordID.EQ(a.RecordID))).
WHERE(a.UserID.EQ(postgres.UUID(userID))).
ORDER_BY(r.AppliedAt.DESC())
var rows []model.SanctionRecords
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("user store: list active sanctions: %w", err)
}
out := make([]ActiveSanction, 0, len(rows))
for _, row := range rows {
entry := ActiveSanction{
SanctionCode: row.SanctionCode,
Scope: row.Scope,
ReasonCode: row.ReasonCode,
Actor: ActorRef{Type: row.ActorType, ID: derefString(row.ActorID)},
AppliedAt: row.AppliedAt,
}
if row.ExpiresAt != nil {
t := *row.ExpiresAt
entry.ExpiresAt = &t
}
out = append(out, entry)
}
return out, nil
}
// ListActiveLimits returns the active limits for userID joined with
// the audit columns from the underlying records row.
func (s *Store) ListActiveLimits(ctx context.Context, userID uuid.UUID) ([]ActiveLimit, error) {
a := table.LimitActive
r := table.LimitRecords
stmt := postgres.SELECT(
r.LimitCode, a.Value, r.ReasonCode,
r.ActorType, r.ActorID,
r.AppliedAt, r.ExpiresAt,
).
FROM(a.INNER_JOIN(r, r.RecordID.EQ(a.RecordID))).
WHERE(a.UserID.EQ(postgres.UUID(userID))).
ORDER_BY(r.AppliedAt.DESC())
var rows []struct {
LimitRecords model.LimitRecords
LimitActive model.LimitActive
}
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("user store: list active limits: %w", err)
}
out := make([]ActiveLimit, 0, len(rows))
for _, row := range rows {
entry := ActiveLimit{
LimitCode: row.LimitRecords.LimitCode,
Value: row.LimitActive.Value,
ReasonCode: row.LimitRecords.ReasonCode,
Actor: ActorRef{Type: row.LimitRecords.ActorType, ID: derefString(row.LimitRecords.ActorID)},
AppliedAt: row.LimitRecords.AppliedAt,
}
if row.LimitRecords.ExpiresAt != nil {
t := *row.LimitRecords.ExpiresAt
entry.ExpiresAt = &t
}
out = append(out, entry)
}
return out, nil
}
// UpdateAccountDisplayName patches accounts.display_name and bumps
// updated_at. Returns ErrAccountNotFound when no live row matches.
func (s *Store) UpdateAccountDisplayName(ctx context.Context, userID uuid.UUID, displayName string, now time.Time) error {
a := table.Accounts
stmt := a.UPDATE(a.DisplayName, a.UpdatedAt).
SET(displayName, now).
WHERE(
a.UserID.EQ(postgres.UUID(userID)).
AND(a.DeletedAt.IS_NULL()),
)
res, err := stmt.ExecContext(ctx, s.db)
if err != nil {
return fmt.Errorf("user store: update display_name: %w", err)
}
return rowsAffectedOrNotFound(res)
}
// UpdateAccountSettings patches the supplied settings columns and bumps
// updated_at. Empty patches are a precondition error from the caller.
func (s *Store) UpdateAccountSettings(ctx context.Context, userID uuid.UUID, patch settingsPatch, now time.Time) error {
if patch.empty() {
return fmt.Errorf("user store: update settings: empty patch")
}
a := table.Accounts
rest := make([]any, 0, 2)
if patch.PreferredLanguage != nil {
rest = append(rest, a.PreferredLanguage.SET(postgres.String(*patch.PreferredLanguage)))
}
if patch.TimeZone != nil {
rest = append(rest, a.TimeZone.SET(postgres.String(*patch.TimeZone)))
}
stmt := a.UPDATE().
SET(a.UpdatedAt.SET(postgres.TimestampzT(now)), rest...).
WHERE(
a.UserID.EQ(postgres.UUID(userID)).
AND(a.DeletedAt.IS_NULL()),
)
res, err := stmt.ExecContext(ctx, s.db)
if err != nil {
return fmt.Errorf("user store: update settings: %w", err)
}
return rowsAffectedOrNotFound(res)
}
// ApplyEntitlementTx persists a fresh entitlement_records row and
// upserts the matching entitlement_snapshots row in one transaction.
// Returns the persisted snapshot exactly as stored (created_at is the
// input UpdatedAt, etc.).
func (s *Store) ApplyEntitlementTx(ctx context.Context, snap EntitlementSnapshot) (EntitlementSnapshot, error) {
if err := s.assertAccountLive(ctx, snap.UserID); err != nil {
return EntitlementSnapshot{}, err
}
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
recordID := uuid.New()
actorID := nullableString(snap.Actor.ID)
var endsAt any
if snap.EndsAt != nil {
endsAt = *snap.EndsAt
}
recordStmt := table.EntitlementRecords.INSERT(
table.EntitlementRecords.RecordID,
table.EntitlementRecords.UserID,
table.EntitlementRecords.Tier,
table.EntitlementRecords.IsPaid,
table.EntitlementRecords.Source,
table.EntitlementRecords.ActorType,
table.EntitlementRecords.ActorID,
table.EntitlementRecords.ReasonCode,
table.EntitlementRecords.StartsAt,
table.EntitlementRecords.EndsAt,
table.EntitlementRecords.CreatedAt,
).VALUES(
recordID, snap.UserID, snap.Tier, snap.IsPaid, snap.Source,
snap.Actor.Type, actorID, snap.ReasonCode,
snap.StartsAt, endsAt, snap.UpdatedAt,
)
if _, err := recordStmt.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert entitlement record: %w", err)
}
return upsertSnapshotTx(ctx, tx, snap)
})
if err != nil {
return EntitlementSnapshot{}, err
}
return snap, nil
}
// ApplySanctionTx persists a fresh sanction_records row, upserts
// sanction_active, and (when alsoFlipPermanent is set) flips
// accounts.permanent_block to true — all in one transaction.
func (s *Store) ApplySanctionTx(ctx context.Context, input sanctionInsert) error {
if err := s.assertAccountLive(ctx, input.UserID); err != nil {
return err
}
return withTx(ctx, s.db, func(tx *sql.Tx) error {
recordID := uuid.New()
actorID := nullableString(input.ActorID)
var expiresAt any
if input.ExpiresAt != nil {
expiresAt = *input.ExpiresAt
}
recordStmt := table.SanctionRecords.INSERT(
table.SanctionRecords.RecordID,
table.SanctionRecords.UserID,
table.SanctionRecords.SanctionCode,
table.SanctionRecords.Scope,
table.SanctionRecords.ReasonCode,
table.SanctionRecords.ActorType,
table.SanctionRecords.ActorID,
table.SanctionRecords.AppliedAt,
table.SanctionRecords.ExpiresAt,
).VALUES(
recordID, input.UserID, input.SanctionCode, input.Scope, input.ReasonCode,
input.ActorType, actorID, input.AppliedAt, expiresAt,
)
if _, err := recordStmt.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert sanction record: %w", err)
}
sa := table.SanctionActive
activeStmt := sa.INSERT(sa.UserID, sa.SanctionCode, sa.RecordID).
VALUES(input.UserID, input.SanctionCode, recordID).
ON_CONFLICT(sa.UserID, sa.SanctionCode).
DO_UPDATE(postgres.SET(
sa.RecordID.SET(sa.EXCLUDED.RecordID),
))
if _, err := activeStmt.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("upsert sanction_active: %w", err)
}
if input.FlipPermanent {
a := table.Accounts
permStmt := a.UPDATE().
SET(
a.PermanentBlock.SET(postgres.Bool(true)),
a.UpdatedAt.SET(postgres.TimestampzT(input.AppliedAt)),
).
WHERE(
a.UserID.EQ(postgres.UUID(input.UserID)).
AND(a.DeletedAt.IS_NULL()),
)
if _, err := permStmt.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("flip permanent_block: %w", err)
}
}
return nil
})
}
// ApplyLimitTx persists a fresh limit_records row and upserts
// limit_active in one transaction.
func (s *Store) ApplyLimitTx(ctx context.Context, input limitInsert) error {
if err := s.assertAccountLive(ctx, input.UserID); err != nil {
return err
}
return withTx(ctx, s.db, func(tx *sql.Tx) error {
recordID := uuid.New()
actorID := nullableString(input.ActorID)
var expiresAt any
if input.ExpiresAt != nil {
expiresAt = *input.ExpiresAt
}
recordStmt := table.LimitRecords.INSERT(
table.LimitRecords.RecordID,
table.LimitRecords.UserID,
table.LimitRecords.LimitCode,
table.LimitRecords.Value,
table.LimitRecords.ReasonCode,
table.LimitRecords.ActorType,
table.LimitRecords.ActorID,
table.LimitRecords.AppliedAt,
table.LimitRecords.ExpiresAt,
).VALUES(
recordID, input.UserID, input.LimitCode, input.Value, input.ReasonCode,
input.ActorType, actorID, input.AppliedAt, expiresAt,
)
if _, err := recordStmt.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert limit record: %w", err)
}
la := table.LimitActive
activeStmt := la.INSERT(la.UserID, la.LimitCode, la.RecordID, la.Value).
VALUES(input.UserID, input.LimitCode, recordID, input.Value).
ON_CONFLICT(la.UserID, la.LimitCode).
DO_UPDATE(postgres.SET(
la.RecordID.SET(la.EXCLUDED.RecordID),
la.Value.SET(la.EXCLUDED.Value),
))
if _, err := activeStmt.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("upsert limit_active: %w", err)
}
return nil
})
}
// SoftDeleteAccount marks the account soft-deleted with the supplied
// actor trail. The boolean reports whether the row actually changed
// (true for a fresh delete; false when the row was already
// soft-deleted or does not exist). The caller distinguishes "already
// gone" from "never existed" by reading the row separately when it
// matters; for the cascade orchestration "no change" is treated as a
// successful idempotent operation.
func (s *Store) SoftDeleteAccount(ctx context.Context, userID uuid.UUID, actor ActorRef, now time.Time) (bool, error) {
a := table.Accounts
actorIDExpr := nullableStringExpr(actor.ID)
stmt := a.UPDATE().
SET(
a.DeletedAt.SET(postgres.TimestampzT(now)),
a.DeletedActorType.SET(postgres.String(actor.Type)),
a.DeletedActorID.SET(actorIDExpr),
a.UpdatedAt.SET(postgres.TimestampzT(now)),
).
WHERE(
a.UserID.EQ(postgres.UUID(userID)).
AND(a.DeletedAt.IS_NULL()),
)
res, err := stmt.ExecContext(ctx, s.db)
if err != nil {
return false, fmt.Errorf("user store: soft delete %s: %w", userID, err)
}
affected, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("user store: soft delete rows-affected: %w", err)
}
return affected > 0, nil
}
// assertAccountLive returns ErrAccountNotFound when userID does not
// match a live accounts row. Used by the mutation paths to fail fast
// before opening a transaction.
func (s *Store) assertAccountLive(ctx context.Context, userID uuid.UUID) error {
a := table.Accounts
stmt := postgres.SELECT(a.UserID).
FROM(a).
WHERE(
a.UserID.EQ(postgres.UUID(userID)).
AND(a.DeletedAt.IS_NULL()),
).
LIMIT(1)
var row model.Accounts
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return ErrAccountNotFound
}
return fmt.Errorf("user store: account live-check: %w", err)
}
return nil
}
func insertSnapshotTx(ctx context.Context, tx *sql.Tx, snap EntitlementSnapshot) error {
es := table.EntitlementSnapshots
actorID := nullableString(snap.Actor.ID)
var endsAt any
if snap.EndsAt != nil {
endsAt = *snap.EndsAt
}
stmt := es.INSERT(
es.UserID, es.Tier, es.IsPaid, es.Source, es.ActorType, es.ActorID,
es.ReasonCode, es.StartsAt, es.EndsAt,
es.MaxRegisteredRaceNames, es.UpdatedAt,
).VALUES(
snap.UserID, snap.Tier, snap.IsPaid, snap.Source, snap.Actor.Type, actorID,
snap.ReasonCode, snap.StartsAt, endsAt, snap.MaxRegisteredRaceNames, snap.UpdatedAt,
)
if _, err := stmt.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert entitlement_snapshots: %w", err)
}
return nil
}
func upsertSnapshotTx(ctx context.Context, tx *sql.Tx, snap EntitlementSnapshot) error {
es := table.EntitlementSnapshots
actorID := nullableString(snap.Actor.ID)
var endsAt any
if snap.EndsAt != nil {
endsAt = *snap.EndsAt
}
stmt := es.INSERT(
es.UserID, es.Tier, es.IsPaid, es.Source, es.ActorType, es.ActorID,
es.ReasonCode, es.StartsAt, es.EndsAt,
es.MaxRegisteredRaceNames, es.UpdatedAt,
).VALUES(
snap.UserID, snap.Tier, snap.IsPaid, snap.Source, snap.Actor.Type, actorID,
snap.ReasonCode, snap.StartsAt, endsAt, snap.MaxRegisteredRaceNames, snap.UpdatedAt,
).
ON_CONFLICT(es.UserID).
DO_UPDATE(postgres.SET(
es.Tier.SET(es.EXCLUDED.Tier),
es.IsPaid.SET(es.EXCLUDED.IsPaid),
es.Source.SET(es.EXCLUDED.Source),
es.ActorType.SET(es.EXCLUDED.ActorType),
es.ActorID.SET(es.EXCLUDED.ActorID),
es.ReasonCode.SET(es.EXCLUDED.ReasonCode),
es.StartsAt.SET(es.EXCLUDED.StartsAt),
es.EndsAt.SET(es.EXCLUDED.EndsAt),
es.MaxRegisteredRaceNames.SET(es.EXCLUDED.MaxRegisteredRaceNames),
es.UpdatedAt.SET(es.EXCLUDED.UpdatedAt),
))
if _, err := stmt.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("upsert entitlement_snapshots: %w", err)
}
return nil
}
// modelToAccountRow projects a generated model row onto the public
// AccountRow struct. The DeclaredCountry field is collapsed from
// nullable to "" by the projection.
func modelToAccountRow(row model.Accounts) AccountRow {
out := AccountRow{
UserID: row.UserID,
Email: row.Email,
UserName: row.UserName,
DisplayName: row.DisplayName,
PreferredLanguage: row.PreferredLanguage,
TimeZone: row.TimeZone,
PermanentBlock: row.PermanentBlock,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
if row.DeclaredCountry != nil {
out.DeclaredCountry = *row.DeclaredCountry
}
if row.DeletedAt != nil {
t := *row.DeletedAt
out.DeletedAt = &t
}
return out
}
// modelToSnapshot projects a generated model row onto the public
// EntitlementSnapshot struct.
func modelToSnapshot(row model.EntitlementSnapshots) EntitlementSnapshot {
out := EntitlementSnapshot{
UserID: row.UserID,
Tier: row.Tier,
IsPaid: row.IsPaid,
Source: row.Source,
Actor: ActorRef{Type: row.ActorType, ID: derefString(row.ActorID)},
ReasonCode: row.ReasonCode,
StartsAt: row.StartsAt,
MaxRegisteredRaceNames: row.MaxRegisteredRaceNames,
UpdatedAt: row.UpdatedAt,
}
if row.EndsAt != nil {
t := *row.EndsAt
out.EndsAt = &t
}
return out
}
// nullableString converts a Go string to the `any` form expected by jet
// VALUES: an empty string becomes nil so the column receives NULL.
func nullableString(v string) any {
if v == "" {
return nil
}
return v
}
// nullableStringExpr returns a typed jet expression: the empty string
// produces NULL, otherwise a String literal. Used by UPDATE SET paths
// where jet's SET wants a typed Expression rather than `any`.
func nullableStringExpr(v string) postgres.StringExpression {
if v == "" {
return postgres.StringExp(postgres.NULL)
}
return postgres.String(v)
}
// derefString returns the empty string when p is nil, otherwise *p.
func derefString(p *string) string {
if p == nil {
return ""
}
return *p
}
// rowsAffectedOrNotFound returns ErrAccountNotFound when the UPDATE
// affected zero rows, nil otherwise. Used by the account-mutation paths
// that need fail-fast on a missing/soft-deleted target.
func rowsAffectedOrNotFound(res sql.Result) error {
if res == nil {
return nil
}
affected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("user store: rows affected: %w", err)
}
if affected == 0 {
return ErrAccountNotFound
}
return nil
}
// withTx wraps fn in a Postgres transaction. fn's return value
// determines commit (nil) vs rollback (non-nil). Rollback errors are
// swallowed when fn already returned an error, since the latter is
// more actionable.
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("user store: begin tx: %w", err)
}
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("user store: commit tx: %w", err)
}
return nil
}
+218
View File
@@ -0,0 +1,218 @@
// Package user owns the platform's account identity records inside the
// `backend.accounts` table together with the entitlement, sanction,
// limit and soft-delete surfaces documented in `backend/PLAN.md` §5.2.
//
// The implementation expanded the surface introduced by currently: the package
// now exposes account read/mutation flows, admin-side overrides
// (sanctions, limits, entitlements), in-process soft-delete cascades
// across `lobby`, `notification`, `geo`, and a write-through
// entitlement-snapshot cache that mirrors the
// `backend/internal/auth.Cache` pattern.
//
// External dependencies that have not landed yet (lobby in 5.4,
// notification in 5.7) are injected through the LobbyCascade and
// NotificationCascade interfaces; the package ships no-op
// implementations that satisfy those contracts until the real services
// arrive.
package user
import (
"context"
"crypto/rand"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"go.uber.org/zap"
)
// Constraint names mirror the names declared in
// `backend/internal/postgres/migrations/00001_init.sql`. Keeping them as
// constants avoids string-typo surprises at runtime when error
// classification asks Postgres which UNIQUE was violated.
const (
constraintAccountsEmailUnique = "accounts_email_unique"
constraintAccountsUserNameUnique = "accounts_user_name_unique"
)
// pgErrCodeUniqueViolation is the SQLSTATE value emitted by Postgres when
// a UNIQUE constraint is violated. The pgx driver surfaces the value on
// `*pgconn.PgError`.
const pgErrCodeUniqueViolation = "23505"
// userNameCharset is the alphabet of the placeholder `Player-XXXXXXXX`
// suffix. Mixed-case letters plus digits gives 62^8 ≈ 2.18×10¹⁴
// possibilities, which makes 10 collision retries an enormous safety
// margin even at MVP scale.
const userNameCharset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
// userNameSuffixLen is the length of the random suffix appended after
// `Player-`.
const userNameSuffixLen = 8
// Deps aggregates every collaborator the user Service depends on.
// Constructing the Service through Deps (rather than positional args)
// keeps wiring patches small when new dependencies are added.
//
// Store must be non-nil. Cache, Lobby, Notification, Geo and
// SessionRevoker are tested-in-isolation interfaces; production wires
// the matching real implementations through `cmd/backend/main.go`.
type Deps struct {
Store *Store
Cache *Cache
Lobby LobbyCascade
Notification NotificationCascade
Geo GeoCascade
SessionRevoker SessionRevoker
// UserNameMaxRetries caps the retry budget for synthesising a unique
// placeholder `accounts.user_name` at registration. A zero or
// negative value falls back to 1.
UserNameMaxRetries int
// Logger is named under "user" by NewService. Nil falls back to
// zap.NewNop.
Logger *zap.Logger
// Now overrides time.Now for deterministic tests. A nil Now defaults
// to time.Now in NewService.
Now func() time.Time
}
// Service is the user-domain entry point. Concurrency safety is
// delegated to Postgres for persisted state and to the embedded Cache
// for the in-memory entitlement snapshot projection.
type Service struct {
deps Deps
}
// NewService constructs a Service from deps. A nil Now defaults to
// time.Now; a nil Logger defaults to zap.NewNop. DB and Store must be
// supplied — calling Service methods with nil values will panic at
// first use, matching how main.go signals missing wiring.
func NewService(deps Deps) *Service {
if deps.Now == nil {
deps.Now = time.Now
}
if deps.Logger == nil {
deps.Logger = zap.NewNop()
}
deps.Logger = deps.Logger.Named("user")
if deps.UserNameMaxRetries <= 0 {
deps.UserNameMaxRetries = 1
}
return &Service{deps: deps}
}
// EnsureByEmail returns the user_id of the live account whose email
// matches the supplied (lower-cased, trimmed) value, creating a new
// account if none exists.
//
// For new accounts the function uses the supplied "would-be" values:
// preferredLanguage is written as-is, timeZone is written as-is, and
// declaredCountry is written as NULL when empty. Existing accounts keep
// every stored value; only their user_id is returned.
//
// EnsureByEmail is idempotent on email under concurrent calls. The
// implementation uses ON CONFLICT (email) DO NOTHING RETURNING so a
// concurrent inserter does not double-create. Synthetic user_name
// collisions are retried with a fresh suffix up to UserNameMaxRetries
// times.
//
// On a successful new-account insert the function additionally
// materialises the default `free` entitlement snapshot inside the same
// transaction so no account exists without a snapshot, and refreshes
// the in-memory cache with the freshly persisted snapshot.
func (s *Service) EnsureByEmail(ctx context.Context, email, preferredLanguage, timeZone, declaredCountry string) (uuid.UUID, error) {
normalised := strings.ToLower(strings.TrimSpace(email))
if normalised == "" {
return uuid.Nil, errors.New("ensure account by email: email is empty")
}
if userID, ok, err := s.deps.Store.LookupAccountIDByEmail(ctx, normalised); err != nil {
return uuid.Nil, fmt.Errorf("ensure account by email: lookup: %w", err)
} else if ok {
return userID, nil
}
return s.insertNew(ctx, normalised, preferredLanguage, timeZone, declaredCountry)
}
func (s *Service) insertNew(ctx context.Context, email, prefLang, tz, country string) (uuid.UUID, error) {
for attempt := 0; attempt < s.deps.UserNameMaxRetries; attempt++ {
userName, err := generatePlayerName()
if err != nil {
return uuid.Nil, fmt.Errorf("ensure account by email: generate user_name: %w", err)
}
userID := uuid.New()
now := s.deps.Now().UTC()
snapshot := defaultFreeSnapshot(userID, now)
insertedID, err := s.deps.Store.InsertAccountWithSnapshot(ctx, accountInsert{
UserID: userID,
Email: email,
UserName: userName,
PreferredLanguage: prefLang,
TimeZone: tz,
DeclaredCountry: country,
}, snapshot)
switch {
case err == nil:
s.deps.Cache.Add(snapshot)
return insertedID, nil
case errors.Is(err, errEmailRace):
existing, ok, lerr := s.deps.Store.LookupAccountIDByEmail(ctx, email)
if lerr != nil {
return uuid.Nil, fmt.Errorf("ensure account by email: lookup after race: %w", lerr)
}
if !ok {
return uuid.Nil, fmt.Errorf("ensure account by email: email exists yet lookup empty (likely soft-deleted)")
}
return existing, nil
case isUniqueViolation(err, constraintAccountsUserNameUnique):
continue
default:
return uuid.Nil, fmt.Errorf("ensure account by email: insert: %w", err)
}
}
return uuid.Nil, fmt.Errorf("ensure account by email: user_name collisions exceeded %d retries", s.deps.UserNameMaxRetries)
}
// generatePlayerName produces a `Player-XXXXXXXX` placeholder where the
// suffix is eight cryptographically-random alphanumeric characters. The
// modulo-bias of `byte%62` is acceptable here: collision avoidance is
// the only invariant — the placeholder never carries cryptographic
// significance and a future stage may surface a separate "claim
// user_name" flow.
func generatePlayerName() (string, error) {
suffix := make([]byte, userNameSuffixLen)
if _, err := rand.Read(suffix); err != nil {
return "", err
}
for i := range suffix {
suffix[i] = userNameCharset[int(suffix[i])%len(userNameCharset)]
}
var sb strings.Builder
sb.Grow(len("Player-") + userNameSuffixLen)
sb.WriteString("Player-")
sb.Write(suffix)
return sb.String(), nil
}
func isUniqueViolation(err error, constraintName string) bool {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
return false
}
if pgErr.Code != pgErrCodeUniqueViolation {
return false
}
if constraintName == "" {
return true
}
return pgErr.ConstraintName == constraintName
}
+201
View File
@@ -0,0 +1,201 @@
package user_test
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
backendserver "galaxy/backend/internal/server"
"galaxy/backend/internal/server/middleware/basicauth"
"galaxy/backend/internal/user"
"github.com/google/uuid"
)
// TestUserSurfaceEndToEnd exercises the user-facing slice of the gin
// router with a real Postgres pool and the real handlers. It is the
// thinnest possible integration test that proves the wire layer wires
// the user.Service correctly — the contract test already validates the
// OpenAPI envelope on every endpoint, and the focused unit tests in
// user_test.go cover the business logic of Service.
func TestUserSurfaceEndToEnd(t *testing.T) {
db := startPostgres(t)
revoker := &recordingRevoker{}
lobby := &recordingLobbyCascade{}
notif := &recordingNotificationCascade{}
geo := &recordingGeoCascade{}
svc := user.NewService(user.Deps{
Store: user.NewStore(db),
Cache: user.NewCache(),
Lobby: lobby,
Notification: notif,
Geo: geo,
SessionRevoker: revoker,
UserNameMaxRetries: 10,
Now: time.Now,
})
uid, err := svc.EnsureByEmail(context.Background(), "nora@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail: %v", err)
}
const adminPassword = "user-e2e-test-secret"
verifier := basicauth.NewStaticVerifier(adminPassword)
handler, err := backendserver.NewRouter(backendserver.RouterDependencies{
AdminVerifier: verifier,
UserAccount: backendserver.NewUserAccountHandlers(svc, nil),
AdminUsers: backendserver.NewAdminUsersHandlers(svc, nil),
InternalUsers: backendserver.NewInternalUsersHandlers(svc, nil),
})
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
// 1. GET /api/v1/user/account → 200 with default `free` entitlement.
resp := doRequest(t, handler, "GET", "/api/v1/user/account",
header("X-User-ID", uid.String()), nil)
if resp.Code != http.StatusOK {
t.Fatalf("user/account GET status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
body := decodeAccountResponse(t, resp)
if body.Account.Entitlement.PlanCode != user.TierFree {
t.Fatalf("entitlement.plan_code = %q, want %q", body.Account.Entitlement.PlanCode, user.TierFree)
}
// 2. PATCH profile → display_name updated.
resp = doRequest(t, handler, "PATCH", "/api/v1/user/account/profile",
header("X-User-ID", uid.String()),
map[string]any{"display_name": "Nora"})
if resp.Code != http.StatusOK {
t.Fatalf("profile PATCH status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
body = decodeAccountResponse(t, resp)
if body.Account.DisplayName != "Nora" {
t.Fatalf("display_name = %q, want %q", body.Account.DisplayName, "Nora")
}
// 3. POST admin entitlement → tier flips to monthly.
adminAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("operator:"+adminPassword))
resp = doRequest(t, handler, "POST", "/api/v1/admin/users/"+uid.String()+"/entitlements",
header("Authorization", adminAuth),
map[string]any{
"tier": "monthly",
"source": "admin",
"actor": map[string]any{"type": "admin", "id": "operator"},
})
if resp.Code != http.StatusOK {
t.Fatalf("admin entitlement POST status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
body = decodeAccountResponse(t, resp)
if body.Account.Entitlement.PlanCode != user.TierMonthly {
t.Fatalf("entitlement.plan_code = %q, want %q", body.Account.Entitlement.PlanCode, user.TierMonthly)
}
if body.Account.Entitlement.EndsAt == nil {
t.Fatalf("monthly entitlement returned ends_at = nil")
}
// 4. POST user soft-delete → 204.
resp = doRequest(t, handler, "POST", "/api/v1/user/account/delete",
header("X-User-ID", uid.String()), nil)
if resp.Code != http.StatusNoContent {
t.Fatalf("user delete POST status = %d, want 204; body=%s", resp.Code, resp.Body.String())
}
if revoker.calls != 1 {
t.Fatalf("session revoker calls = %d, want 1", revoker.calls)
}
if lobby.deletedCalls != 1 {
t.Fatalf("lobby.OnUserDeleted calls = %d, want 1", lobby.deletedCalls)
}
if notif.calls != 1 {
t.Fatalf("notification.OnUserDeleted calls = %d, want 1", notif.calls)
}
if geo.calls != 1 {
t.Fatalf("geo.OnUserDeleted calls = %d, want 1", geo.calls)
}
// 5. GET internal account-internal after soft-delete → 404.
resp = doRequest(t, handler, "GET", "/api/v1/internal/users/"+uid.String()+"/account-internal", nil, nil)
if resp.Code != http.StatusNotFound {
t.Fatalf("internal account-internal GET after delete = %d, want 404; body=%s",
resp.Code, resp.Body.String())
}
// 6. GET user/account on a fresh UUID → 404.
stranger := uuid.New().String()
resp = doRequest(t, handler, "GET", "/api/v1/user/account",
header("X-User-ID", stranger), nil)
if resp.Code != http.StatusNotFound {
t.Fatalf("user/account GET stranger status = %d, want 404; body=%s", resp.Code, resp.Body.String())
}
}
type accountResponseBody struct {
Account struct {
UserID string `json:"user_id"`
Email string `json:"email"`
UserName string `json:"user_name"`
DisplayName string `json:"display_name"`
Entitlement struct {
PlanCode string `json:"plan_code"`
IsPaid bool `json:"is_paid"`
MaxRegisteredRaceNames int32 `json:"max_registered_race_names"`
EndsAt *string `json:"ends_at"`
} `json:"entitlement"`
} `json:"account"`
}
type headerKV struct{ key, value string }
func header(key, value string) headerKV { return headerKV{key: key, value: value} }
func doRequest(t *testing.T, handler http.Handler, method, path string, hdr any, body map[string]any) *httptest.ResponseRecorder {
t.Helper()
var rdr *bytes.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
rdr = bytes.NewReader(raw)
} else {
rdr = bytes.NewReader(nil)
}
req, err := http.NewRequest(method, "http://backend.internal"+path, rdr)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if hdr != nil {
switch h := hdr.(type) {
case headerKV:
req.Header.Set(h.key, h.value)
case []headerKV:
for _, kv := range h {
req.Header.Set(kv.key, kv.value)
}
}
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
func decodeAccountResponse(t *testing.T, rec *httptest.ResponseRecorder) accountResponseBody {
t.Helper()
var out accountResponseBody
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("unmarshal AccountResponse: %v; body=%s", err, rec.Body.String())
}
return out
}
+569
View File
@@ -0,0 +1,569 @@
package user_test
import (
"context"
"database/sql"
"net/url"
"strings"
"testing"
"time"
backendpg "galaxy/backend/internal/postgres"
"galaxy/backend/internal/user"
pgshared "galaxy/postgres"
"github.com/google/uuid"
testcontainers "github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
const (
testImage = "postgres:16-alpine"
testUser = "galaxy"
testPassword = "galaxy"
testDatabase = "galaxy_backend"
testSchema = "backend"
testStartup = 90 * time.Second
testOpTimeout = 10 * time.Second
)
// startPostgres spins up a Postgres testcontainer with the backend schema
// migrated up. The returned db is closed and the container terminated by
// t.Cleanup hooks; tests should not close them explicitly.
func startPostgres(t *testing.T) *sql.DB {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
pgContainer, err := tcpostgres.Run(ctx, testImage,
tcpostgres.WithDatabase(testDatabase),
tcpostgres.WithUsername(testUser),
tcpostgres.WithPassword(testPassword),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(testStartup),
),
)
if err != nil {
t.Skipf("postgres testcontainer unavailable, skipping: %v", err)
}
t.Cleanup(func() {
if termErr := testcontainers.TerminateContainer(pgContainer); termErr != nil {
t.Errorf("terminate postgres container: %v", termErr)
}
})
baseDSN, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("connection string: %v", err)
}
scopedDSN, err := dsnWithSearchPath(baseDSN, testSchema)
if err != nil {
t.Fatalf("scope dsn: %v", err)
}
cfg := pgshared.DefaultConfig()
cfg.PrimaryDSN = scopedDSN
cfg.OperationTimeout = testOpTimeout
db, err := pgshared.OpenPrimary(ctx, cfg)
if err != nil {
t.Fatalf("open primary: %v", err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Errorf("close db: %v", err)
}
})
if err := pgshared.Ping(ctx, db, cfg.OperationTimeout); err != nil {
t.Fatalf("ping: %v", err)
}
if err := backendpg.ApplyMigrations(ctx, db); err != nil {
t.Fatalf("apply migrations: %v", err)
}
return db
}
func dsnWithSearchPath(baseDSN, schema string) (string, error) {
parsed, err := url.Parse(baseDSN)
if err != nil {
return "", err
}
values := parsed.Query()
values.Set("search_path", schema)
if values.Get("sslmode") == "" {
values.Set("sslmode", "disable")
}
parsed.RawQuery = values.Encode()
return parsed.String(), nil
}
// newServiceForTest builds a *user.Service with a real Postgres pool
// and an empty cache. The cascade dependencies are left nil — the
// tests in this file exercise EnsureByEmail and the lookup paths that
// do not need them. Tests that drive sanctions/limits/soft-delete
// build their own Deps inline.
func newServiceForTest(db *sql.DB, now func() time.Time) *user.Service {
return user.NewService(user.Deps{
Store: user.NewStore(db),
Cache: user.NewCache(),
UserNameMaxRetries: 10,
Now: now,
})
}
func TestEnsureByEmailCreatesNewAccount(t *testing.T) {
db := startPostgres(t)
svc := newServiceForTest(db, time.Now)
ctx := context.Background()
uid, err := svc.EnsureByEmail(ctx, "Pilot@Example.Test", "ru", "Europe/Kaliningrad", "RU")
if err != nil {
t.Fatalf("EnsureByEmail: %v", err)
}
if uid == uuid.Nil {
t.Fatalf("EnsureByEmail returned uuid.Nil")
}
var (
gotEmail string
gotName string
gotLang string
gotTZ string
gotCountry *string
gotDeleted *time.Time
permanent bool
)
err = db.QueryRowContext(ctx, `
SELECT email, user_name, preferred_language, time_zone, declared_country, deleted_at, permanent_block
FROM backend.accounts WHERE user_id = $1
`, uid).Scan(&gotEmail, &gotName, &gotLang, &gotTZ, &gotCountry, &gotDeleted, &permanent)
if err != nil {
t.Fatalf("post-insert SELECT: %v", err)
}
if gotEmail != "pilot@example.test" {
t.Fatalf("email = %q, want lower-cased %q", gotEmail, "pilot@example.test")
}
if !strings.HasPrefix(gotName, "Player-") || len(gotName) != len("Player-")+8 {
t.Fatalf("user_name = %q, want Player-XXXXXXXX (8 chars)", gotName)
}
if gotLang != "ru" {
t.Fatalf("preferred_language = %q, want %q", gotLang, "ru")
}
if gotTZ != "Europe/Kaliningrad" {
t.Fatalf("time_zone = %q, want %q", gotTZ, "Europe/Kaliningrad")
}
if gotCountry == nil || *gotCountry != "RU" {
t.Fatalf("declared_country = %v, want %q", gotCountry, "RU")
}
if gotDeleted != nil {
t.Fatalf("deleted_at = %v, want NULL", gotDeleted)
}
if permanent {
t.Fatalf("permanent_block = true, want false")
}
}
func TestEnsureByEmailIdempotentOnSecondCall(t *testing.T) {
db := startPostgres(t)
svc := newServiceForTest(db, time.Now)
ctx := context.Background()
first, err := svc.EnsureByEmail(ctx, "alice@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("first EnsureByEmail: %v", err)
}
second, err := svc.EnsureByEmail(ctx, "alice@example.test", "ru", "Asia/Tokyo", "JP")
if err != nil {
t.Fatalf("second EnsureByEmail: %v", err)
}
if first != second {
t.Fatalf("user_id changed on second call: first=%s second=%s", first, second)
}
// The second call's "would-be" values must be ignored — the row keeps
// the values from the first call.
var lang, tz string
var country *string
err = db.QueryRowContext(ctx, `
SELECT preferred_language, time_zone, declared_country
FROM backend.accounts WHERE user_id = $1
`, first).Scan(&lang, &tz, &country)
if err != nil {
t.Fatalf("post-second SELECT: %v", err)
}
if lang != "en" || tz != "UTC" || country != nil {
t.Fatalf("existing account mutated: lang=%q tz=%q country=%v",
lang, tz, country)
}
}
func TestEnsureByEmailEmptyDeclaredCountryWritesNull(t *testing.T) {
db := startPostgres(t)
svc := newServiceForTest(db, time.Now)
uid, err := svc.EnsureByEmail(context.Background(),
"bob@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail: %v", err)
}
var country *string
err = db.QueryRowContext(context.Background(),
`SELECT declared_country FROM backend.accounts WHERE user_id = $1`,
uid).Scan(&country)
if err != nil {
t.Fatalf("SELECT: %v", err)
}
if country != nil {
t.Fatalf("declared_country = %q, want NULL", *country)
}
}
func TestEnsureByEmailRejectsEmpty(t *testing.T) {
db := startPostgres(t)
svc := newServiceForTest(db, time.Now)
if _, err := svc.EnsureByEmail(context.Background(),
" ", "en", "UTC", ""); err == nil {
t.Fatalf("expected error for blank email")
}
}
func TestEnsureByEmailInstallsDefaultEntitlement(t *testing.T) {
db := startPostgres(t)
svc := newServiceForTest(db, time.Now)
uid, err := svc.EnsureByEmail(context.Background(), "kira@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail: %v", err)
}
account, err := svc.GetAccount(context.Background(), uid)
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if account.Entitlement.Tier != user.TierFree {
t.Fatalf("default tier = %q, want %q", account.Entitlement.Tier, user.TierFree)
}
if account.Entitlement.IsPaid {
t.Fatalf("default is_paid = true, want false")
}
if account.Entitlement.MaxRegisteredRaceNames != 1 {
t.Fatalf("default max_registered_race_names = %d, want 1", account.Entitlement.MaxRegisteredRaceNames)
}
if account.Entitlement.Source != "system" {
t.Fatalf("default source = %q, want \"system\"", account.Entitlement.Source)
}
if account.Entitlement.Actor.Type != "system" {
t.Fatalf("default actor.type = %q, want \"system\"", account.Entitlement.Actor.Type)
}
}
func TestGetAccountReturnsErrAccountNotFoundForMissing(t *testing.T) {
db := startPostgres(t)
svc := newServiceForTest(db, time.Now)
if _, err := svc.GetAccount(context.Background(), uuid.New()); err == nil {
t.Fatalf("expected error for missing user")
}
}
func TestResolveByEmailFindsLiveAccount(t *testing.T) {
db := startPostgres(t)
svc := newServiceForTest(db, time.Now)
uid, err := svc.EnsureByEmail(context.Background(), "carol@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail: %v", err)
}
resolved, err := svc.ResolveByEmail(context.Background(), "Carol@Example.Test")
if err != nil {
t.Fatalf("ResolveByEmail: %v", err)
}
if resolved != uid {
t.Fatalf("ResolveByEmail = %s, want %s", resolved, uid)
}
}
func TestResolveByEmailReturnsErrAccountNotFoundForMissing(t *testing.T) {
db := startPostgres(t)
svc := newServiceForTest(db, time.Now)
if _, err := svc.ResolveByEmail(context.Background(), "ghost@example.test"); err == nil {
t.Fatalf("expected error for missing email")
}
}
func TestUpdateProfileWritesDisplayName(t *testing.T) {
db := startPostgres(t)
svc := newServiceForTest(db, time.Now)
uid, err := svc.EnsureByEmail(context.Background(), "dan@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail: %v", err)
}
displayName := "Daniel"
account, err := svc.UpdateProfile(context.Background(), uid, user.UpdateProfileInput{DisplayName: &displayName})
if err != nil {
t.Fatalf("UpdateProfile: %v", err)
}
if account.DisplayName != "Daniel" {
t.Fatalf("display_name = %q, want %q", account.DisplayName, "Daniel")
}
}
func TestUpdateSettingsRejectsInvalidTimezone(t *testing.T) {
db := startPostgres(t)
svc := newServiceForTest(db, time.Now)
uid, err := svc.EnsureByEmail(context.Background(), "eve@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail: %v", err)
}
bogus := "Mars/Olympus"
_, err = svc.UpdateSettings(context.Background(), uid, user.UpdateSettingsInput{TimeZone: &bogus})
if err == nil {
t.Fatalf("expected error for invalid time_zone")
}
}
func TestApplyEntitlementMonthlyComputesEndsAt(t *testing.T) {
db := startPostgres(t)
frozen := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC)
svc := newServiceForTest(db, func() time.Time { return frozen })
uid, err := svc.EnsureByEmail(context.Background(), "fox@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail: %v", err)
}
account, err := svc.ApplyEntitlement(context.Background(), user.ApplyEntitlementInput{
UserID: uid,
Tier: user.TierMonthly,
Source: "admin",
Actor: user.ActorRef{Type: "admin", ID: "operator"},
})
if err != nil {
t.Fatalf("ApplyEntitlement: %v", err)
}
if account.Entitlement.Tier != user.TierMonthly {
t.Fatalf("tier = %q, want %q", account.Entitlement.Tier, user.TierMonthly)
}
if !account.Entitlement.IsPaid {
t.Fatalf("monthly tier returned is_paid=false")
}
if account.Entitlement.EndsAt == nil {
t.Fatalf("monthly tier returned ends_at = nil")
}
got := account.Entitlement.EndsAt.UTC()
want := frozen.Add(30 * 24 * time.Hour)
if !got.Equal(want) {
t.Fatalf("ends_at = %s, want %s", got, want)
}
}
func TestApplyEntitlementRejectsUnknownTier(t *testing.T) {
db := startPostgres(t)
svc := newServiceForTest(db, time.Now)
uid, err := svc.EnsureByEmail(context.Background(), "gail@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail: %v", err)
}
_, err = svc.ApplyEntitlement(context.Background(), user.ApplyEntitlementInput{
UserID: uid,
Tier: "platinum",
Source: "admin",
Actor: user.ActorRef{Type: "admin", ID: "operator"},
})
if err == nil {
t.Fatalf("expected ErrInvalidTier")
}
}
func TestApplySanctionPermanentBlockFlipsFlagAndCallsRevoker(t *testing.T) {
db := startPostgres(t)
revoker := &recordingRevoker{}
lobby := &recordingLobbyCascade{}
svc := user.NewService(user.Deps{
Store: user.NewStore(db),
Cache: user.NewCache(),
Lobby: lobby,
SessionRevoker: revoker,
UserNameMaxRetries: 10,
Now: time.Now,
})
uid, err := svc.EnsureByEmail(context.Background(), "han@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail: %v", err)
}
if _, err := svc.ApplySanction(context.Background(), user.ApplySanctionInput{
UserID: uid,
SanctionCode: user.SanctionCodePermanentBlock,
Scope: "platform",
ReasonCode: "tos_violation",
Actor: user.ActorRef{Type: "admin", ID: "operator"},
}); err != nil {
t.Fatalf("ApplySanction: %v", err)
}
if revoker.calls != 1 || revoker.lastUser != uid {
t.Fatalf("revoker calls=%d lastUser=%s, want 1 / %s", revoker.calls, revoker.lastUser, uid)
}
if lobby.blockedCalls != 1 || lobby.lastBlockedUser != uid {
t.Fatalf("lobby blocked calls=%d lastUser=%s, want 1 / %s", lobby.blockedCalls, lobby.lastBlockedUser, uid)
}
var permanent bool
if err := db.QueryRowContext(context.Background(),
`SELECT permanent_block FROM backend.accounts WHERE user_id = $1`, uid).Scan(&permanent); err != nil {
t.Fatalf("SELECT permanent_block: %v", err)
}
if !permanent {
t.Fatalf("permanent_block = false after permanent_block sanction")
}
}
func TestApplyLimitWritesActiveRow(t *testing.T) {
db := startPostgres(t)
svc := newServiceForTest(db, time.Now)
uid, err := svc.EnsureByEmail(context.Background(), "iris@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail: %v", err)
}
if _, err := svc.ApplyLimit(context.Background(), user.ApplyLimitInput{
UserID: uid,
LimitCode: "max_active_games",
Value: 3,
ReasonCode: "manual_review",
Actor: user.ActorRef{Type: "admin", ID: "operator"},
}); err != nil {
t.Fatalf("ApplyLimit: %v", err)
}
var value int32
if err := db.QueryRowContext(context.Background(),
`SELECT value FROM backend.limit_active WHERE user_id = $1 AND limit_code = 'max_active_games'`, uid,
).Scan(&value); err != nil {
t.Fatalf("SELECT limit_active.value: %v", err)
}
if value != 3 {
t.Fatalf("limit_active.value = %d, want 3", value)
}
}
func TestListAccountsExcludesSoftDeleted(t *testing.T) {
db := startPostgres(t)
revoker := &recordingRevoker{}
svc := user.NewService(user.Deps{
Store: user.NewStore(db),
Cache: user.NewCache(),
Lobby: &recordingLobbyCascade{},
Notification: &recordingNotificationCascade{},
SessionRevoker: revoker,
UserNameMaxRetries: 10,
Now: time.Now,
})
live, err := svc.EnsureByEmail(context.Background(), "live@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail live: %v", err)
}
gone, err := svc.EnsureByEmail(context.Background(), "gone@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail gone: %v", err)
}
if err := svc.SoftDelete(context.Background(), gone, user.ActorRef{Type: "user", ID: gone.String()}); err != nil {
t.Fatalf("SoftDelete: %v", err)
}
page, err := svc.ListAccounts(context.Background(), 1, 50)
if err != nil {
t.Fatalf("ListAccounts: %v", err)
}
for _, item := range page.Items {
if item.UserID == gone {
t.Fatalf("ListAccounts returned a soft-deleted account: %+v", item)
}
if item.DeletedAt != nil {
t.Fatalf("ListAccounts returned an account with non-nil DeletedAt: %+v", item)
}
}
found := false
for _, item := range page.Items {
if item.UserID == live {
found = true
break
}
}
if !found {
t.Fatalf("ListAccounts did not include the live account")
}
}
// recordingRevoker is a SessionRevoker spy that captures every call
// for assertion. It is shared across tests in this package.
type recordingRevoker struct {
calls int
lastUser uuid.UUID
}
func (r *recordingRevoker) RevokeAllForUser(_ context.Context, userID uuid.UUID) error {
r.calls++
r.lastUser = userID
return nil
}
// recordingLobbyCascade captures the OnUserDeleted / OnUserBlocked
// calls so soft-delete and permanent-block tests can assert ordering
// and frequency.
type recordingLobbyCascade struct {
deletedCalls int
blockedCalls int
lastDeletedUser uuid.UUID
lastBlockedUser uuid.UUID
}
func (c *recordingLobbyCascade) OnUserDeleted(_ context.Context, userID uuid.UUID) error {
c.deletedCalls++
c.lastDeletedUser = userID
return nil
}
func (c *recordingLobbyCascade) OnUserBlocked(_ context.Context, userID uuid.UUID) error {
c.blockedCalls++
c.lastBlockedUser = userID
return nil
}
// recordingNotificationCascade captures OnUserDeleted invocations.
type recordingNotificationCascade struct {
calls int
lastUser uuid.UUID
}
func (c *recordingNotificationCascade) OnUserDeleted(_ context.Context, userID uuid.UUID) error {
c.calls++
c.lastUser = userID
return nil
}
// recordingGeoCascade captures OnUserDeleted invocations.
type recordingGeoCascade struct {
calls int
lastUser uuid.UUID
}
func (c *recordingGeoCascade) OnUserDeleted(_ context.Context, userID uuid.UUID) error {
c.calls++
c.lastUser = userID
return nil
}
// silence unused-import warnings on database/sql when tests only need
// it through fixture helpers.
var _ = sql.LevelDefault