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