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