feat: backend service

This commit is contained in:
Ilia Denisov
2026-05-06 10:14:55 +03:00
committed by GitHub
parent 3e2622757e
commit f446c6a2ac
1486 changed files with 49720 additions and 266401 deletions
+569
View File
@@ -0,0 +1,569 @@
package user_test
import (
"context"
"database/sql"
"net/url"
"strings"
"testing"
"time"
backendpg "galaxy/backend/internal/postgres"
"galaxy/backend/internal/user"
pgshared "galaxy/postgres"
"github.com/google/uuid"
testcontainers "github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
const (
testImage = "postgres:16-alpine"
testUser = "galaxy"
testPassword = "galaxy"
testDatabase = "galaxy_backend"
testSchema = "backend"
testStartup = 90 * time.Second
testOpTimeout = 10 * time.Second
)
// startPostgres spins up a Postgres testcontainer with the backend schema
// migrated up. The returned db is closed and the container terminated by
// t.Cleanup hooks; tests should not close them explicitly.
func startPostgres(t *testing.T) *sql.DB {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
pgContainer, err := tcpostgres.Run(ctx, testImage,
tcpostgres.WithDatabase(testDatabase),
tcpostgres.WithUsername(testUser),
tcpostgres.WithPassword(testPassword),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(testStartup),
),
)
if err != nil {
t.Skipf("postgres testcontainer unavailable, skipping: %v", err)
}
t.Cleanup(func() {
if termErr := testcontainers.TerminateContainer(pgContainer); termErr != nil {
t.Errorf("terminate postgres container: %v", termErr)
}
})
baseDSN, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("connection string: %v", err)
}
scopedDSN, err := dsnWithSearchPath(baseDSN, testSchema)
if err != nil {
t.Fatalf("scope dsn: %v", err)
}
cfg := pgshared.DefaultConfig()
cfg.PrimaryDSN = scopedDSN
cfg.OperationTimeout = testOpTimeout
db, err := pgshared.OpenPrimary(ctx, cfg)
if err != nil {
t.Fatalf("open primary: %v", err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Errorf("close db: %v", err)
}
})
if err := pgshared.Ping(ctx, db, cfg.OperationTimeout); err != nil {
t.Fatalf("ping: %v", err)
}
if err := backendpg.ApplyMigrations(ctx, db); err != nil {
t.Fatalf("apply migrations: %v", err)
}
return db
}
func dsnWithSearchPath(baseDSN, schema string) (string, error) {
parsed, err := url.Parse(baseDSN)
if err != nil {
return "", err
}
values := parsed.Query()
values.Set("search_path", schema)
if values.Get("sslmode") == "" {
values.Set("sslmode", "disable")
}
parsed.RawQuery = values.Encode()
return parsed.String(), nil
}
// newServiceForTest builds a *user.Service with a real Postgres pool
// and an empty cache. The cascade dependencies are left nil — the
// tests in this file exercise EnsureByEmail and the lookup paths that
// do not need them. Tests that drive sanctions/limits/soft-delete
// build their own Deps inline.
func newServiceForTest(db *sql.DB, now func() time.Time) *user.Service {
return user.NewService(user.Deps{
Store: user.NewStore(db),
Cache: user.NewCache(),
UserNameMaxRetries: 10,
Now: now,
})
}
func TestEnsureByEmailCreatesNewAccount(t *testing.T) {
db := startPostgres(t)
svc := newServiceForTest(db, time.Now)
ctx := context.Background()
uid, err := svc.EnsureByEmail(ctx, "Pilot@Example.Test", "ru", "Europe/Kaliningrad", "RU")
if err != nil {
t.Fatalf("EnsureByEmail: %v", err)
}
if uid == uuid.Nil {
t.Fatalf("EnsureByEmail returned uuid.Nil")
}
var (
gotEmail string
gotName string
gotLang string
gotTZ string
gotCountry *string
gotDeleted *time.Time
permanent bool
)
err = db.QueryRowContext(ctx, `
SELECT email, user_name, preferred_language, time_zone, declared_country, deleted_at, permanent_block
FROM backend.accounts WHERE user_id = $1
`, uid).Scan(&gotEmail, &gotName, &gotLang, &gotTZ, &gotCountry, &gotDeleted, &permanent)
if err != nil {
t.Fatalf("post-insert SELECT: %v", err)
}
if gotEmail != "pilot@example.test" {
t.Fatalf("email = %q, want lower-cased %q", gotEmail, "pilot@example.test")
}
if !strings.HasPrefix(gotName, "Player-") || len(gotName) != len("Player-")+8 {
t.Fatalf("user_name = %q, want Player-XXXXXXXX (8 chars)", gotName)
}
if gotLang != "ru" {
t.Fatalf("preferred_language = %q, want %q", gotLang, "ru")
}
if gotTZ != "Europe/Kaliningrad" {
t.Fatalf("time_zone = %q, want %q", gotTZ, "Europe/Kaliningrad")
}
if gotCountry == nil || *gotCountry != "RU" {
t.Fatalf("declared_country = %v, want %q", gotCountry, "RU")
}
if gotDeleted != nil {
t.Fatalf("deleted_at = %v, want NULL", gotDeleted)
}
if permanent {
t.Fatalf("permanent_block = true, want false")
}
}
func TestEnsureByEmailIdempotentOnSecondCall(t *testing.T) {
db := startPostgres(t)
svc := newServiceForTest(db, time.Now)
ctx := context.Background()
first, err := svc.EnsureByEmail(ctx, "alice@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("first EnsureByEmail: %v", err)
}
second, err := svc.EnsureByEmail(ctx, "alice@example.test", "ru", "Asia/Tokyo", "JP")
if err != nil {
t.Fatalf("second EnsureByEmail: %v", err)
}
if first != second {
t.Fatalf("user_id changed on second call: first=%s second=%s", first, second)
}
// The second call's "would-be" values must be ignored — the row keeps
// the values from the first call.
var lang, tz string
var country *string
err = db.QueryRowContext(ctx, `
SELECT preferred_language, time_zone, declared_country
FROM backend.accounts WHERE user_id = $1
`, first).Scan(&lang, &tz, &country)
if err != nil {
t.Fatalf("post-second SELECT: %v", err)
}
if lang != "en" || tz != "UTC" || country != nil {
t.Fatalf("existing account mutated: lang=%q tz=%q country=%v",
lang, tz, country)
}
}
func TestEnsureByEmailEmptyDeclaredCountryWritesNull(t *testing.T) {
db := startPostgres(t)
svc := newServiceForTest(db, time.Now)
uid, err := svc.EnsureByEmail(context.Background(),
"bob@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail: %v", err)
}
var country *string
err = db.QueryRowContext(context.Background(),
`SELECT declared_country FROM backend.accounts WHERE user_id = $1`,
uid).Scan(&country)
if err != nil {
t.Fatalf("SELECT: %v", err)
}
if country != nil {
t.Fatalf("declared_country = %q, want NULL", *country)
}
}
func TestEnsureByEmailRejectsEmpty(t *testing.T) {
db := startPostgres(t)
svc := newServiceForTest(db, time.Now)
if _, err := svc.EnsureByEmail(context.Background(),
" ", "en", "UTC", ""); err == nil {
t.Fatalf("expected error for blank email")
}
}
func TestEnsureByEmailInstallsDefaultEntitlement(t *testing.T) {
db := startPostgres(t)
svc := newServiceForTest(db, time.Now)
uid, err := svc.EnsureByEmail(context.Background(), "kira@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail: %v", err)
}
account, err := svc.GetAccount(context.Background(), uid)
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if account.Entitlement.Tier != user.TierFree {
t.Fatalf("default tier = %q, want %q", account.Entitlement.Tier, user.TierFree)
}
if account.Entitlement.IsPaid {
t.Fatalf("default is_paid = true, want false")
}
if account.Entitlement.MaxRegisteredRaceNames != 1 {
t.Fatalf("default max_registered_race_names = %d, want 1", account.Entitlement.MaxRegisteredRaceNames)
}
if account.Entitlement.Source != "system" {
t.Fatalf("default source = %q, want \"system\"", account.Entitlement.Source)
}
if account.Entitlement.Actor.Type != "system" {
t.Fatalf("default actor.type = %q, want \"system\"", account.Entitlement.Actor.Type)
}
}
func TestGetAccountReturnsErrAccountNotFoundForMissing(t *testing.T) {
db := startPostgres(t)
svc := newServiceForTest(db, time.Now)
if _, err := svc.GetAccount(context.Background(), uuid.New()); err == nil {
t.Fatalf("expected error for missing user")
}
}
func TestResolveByEmailFindsLiveAccount(t *testing.T) {
db := startPostgres(t)
svc := newServiceForTest(db, time.Now)
uid, err := svc.EnsureByEmail(context.Background(), "carol@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail: %v", err)
}
resolved, err := svc.ResolveByEmail(context.Background(), "Carol@Example.Test")
if err != nil {
t.Fatalf("ResolveByEmail: %v", err)
}
if resolved != uid {
t.Fatalf("ResolveByEmail = %s, want %s", resolved, uid)
}
}
func TestResolveByEmailReturnsErrAccountNotFoundForMissing(t *testing.T) {
db := startPostgres(t)
svc := newServiceForTest(db, time.Now)
if _, err := svc.ResolveByEmail(context.Background(), "ghost@example.test"); err == nil {
t.Fatalf("expected error for missing email")
}
}
func TestUpdateProfileWritesDisplayName(t *testing.T) {
db := startPostgres(t)
svc := newServiceForTest(db, time.Now)
uid, err := svc.EnsureByEmail(context.Background(), "dan@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail: %v", err)
}
displayName := "Daniel"
account, err := svc.UpdateProfile(context.Background(), uid, user.UpdateProfileInput{DisplayName: &displayName})
if err != nil {
t.Fatalf("UpdateProfile: %v", err)
}
if account.DisplayName != "Daniel" {
t.Fatalf("display_name = %q, want %q", account.DisplayName, "Daniel")
}
}
func TestUpdateSettingsRejectsInvalidTimezone(t *testing.T) {
db := startPostgres(t)
svc := newServiceForTest(db, time.Now)
uid, err := svc.EnsureByEmail(context.Background(), "eve@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail: %v", err)
}
bogus := "Mars/Olympus"
_, err = svc.UpdateSettings(context.Background(), uid, user.UpdateSettingsInput{TimeZone: &bogus})
if err == nil {
t.Fatalf("expected error for invalid time_zone")
}
}
func TestApplyEntitlementMonthlyComputesEndsAt(t *testing.T) {
db := startPostgres(t)
frozen := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC)
svc := newServiceForTest(db, func() time.Time { return frozen })
uid, err := svc.EnsureByEmail(context.Background(), "fox@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail: %v", err)
}
account, err := svc.ApplyEntitlement(context.Background(), user.ApplyEntitlementInput{
UserID: uid,
Tier: user.TierMonthly,
Source: "admin",
Actor: user.ActorRef{Type: "admin", ID: "operator"},
})
if err != nil {
t.Fatalf("ApplyEntitlement: %v", err)
}
if account.Entitlement.Tier != user.TierMonthly {
t.Fatalf("tier = %q, want %q", account.Entitlement.Tier, user.TierMonthly)
}
if !account.Entitlement.IsPaid {
t.Fatalf("monthly tier returned is_paid=false")
}
if account.Entitlement.EndsAt == nil {
t.Fatalf("monthly tier returned ends_at = nil")
}
got := account.Entitlement.EndsAt.UTC()
want := frozen.Add(30 * 24 * time.Hour)
if !got.Equal(want) {
t.Fatalf("ends_at = %s, want %s", got, want)
}
}
func TestApplyEntitlementRejectsUnknownTier(t *testing.T) {
db := startPostgres(t)
svc := newServiceForTest(db, time.Now)
uid, err := svc.EnsureByEmail(context.Background(), "gail@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail: %v", err)
}
_, err = svc.ApplyEntitlement(context.Background(), user.ApplyEntitlementInput{
UserID: uid,
Tier: "platinum",
Source: "admin",
Actor: user.ActorRef{Type: "admin", ID: "operator"},
})
if err == nil {
t.Fatalf("expected ErrInvalidTier")
}
}
func TestApplySanctionPermanentBlockFlipsFlagAndCallsRevoker(t *testing.T) {
db := startPostgres(t)
revoker := &recordingRevoker{}
lobby := &recordingLobbyCascade{}
svc := user.NewService(user.Deps{
Store: user.NewStore(db),
Cache: user.NewCache(),
Lobby: lobby,
SessionRevoker: revoker,
UserNameMaxRetries: 10,
Now: time.Now,
})
uid, err := svc.EnsureByEmail(context.Background(), "han@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail: %v", err)
}
if _, err := svc.ApplySanction(context.Background(), user.ApplySanctionInput{
UserID: uid,
SanctionCode: user.SanctionCodePermanentBlock,
Scope: "platform",
ReasonCode: "tos_violation",
Actor: user.ActorRef{Type: "admin", ID: "operator"},
}); err != nil {
t.Fatalf("ApplySanction: %v", err)
}
if revoker.calls != 1 || revoker.lastUser != uid {
t.Fatalf("revoker calls=%d lastUser=%s, want 1 / %s", revoker.calls, revoker.lastUser, uid)
}
if lobby.blockedCalls != 1 || lobby.lastBlockedUser != uid {
t.Fatalf("lobby blocked calls=%d lastUser=%s, want 1 / %s", lobby.blockedCalls, lobby.lastBlockedUser, uid)
}
var permanent bool
if err := db.QueryRowContext(context.Background(),
`SELECT permanent_block FROM backend.accounts WHERE user_id = $1`, uid).Scan(&permanent); err != nil {
t.Fatalf("SELECT permanent_block: %v", err)
}
if !permanent {
t.Fatalf("permanent_block = false after permanent_block sanction")
}
}
func TestApplyLimitWritesActiveRow(t *testing.T) {
db := startPostgres(t)
svc := newServiceForTest(db, time.Now)
uid, err := svc.EnsureByEmail(context.Background(), "iris@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail: %v", err)
}
if _, err := svc.ApplyLimit(context.Background(), user.ApplyLimitInput{
UserID: uid,
LimitCode: "max_active_games",
Value: 3,
ReasonCode: "manual_review",
Actor: user.ActorRef{Type: "admin", ID: "operator"},
}); err != nil {
t.Fatalf("ApplyLimit: %v", err)
}
var value int32
if err := db.QueryRowContext(context.Background(),
`SELECT value FROM backend.limit_active WHERE user_id = $1 AND limit_code = 'max_active_games'`, uid,
).Scan(&value); err != nil {
t.Fatalf("SELECT limit_active.value: %v", err)
}
if value != 3 {
t.Fatalf("limit_active.value = %d, want 3", value)
}
}
func TestListAccountsExcludesSoftDeleted(t *testing.T) {
db := startPostgres(t)
revoker := &recordingRevoker{}
svc := user.NewService(user.Deps{
Store: user.NewStore(db),
Cache: user.NewCache(),
Lobby: &recordingLobbyCascade{},
Notification: &recordingNotificationCascade{},
SessionRevoker: revoker,
UserNameMaxRetries: 10,
Now: time.Now,
})
live, err := svc.EnsureByEmail(context.Background(), "live@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail live: %v", err)
}
gone, err := svc.EnsureByEmail(context.Background(), "gone@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail gone: %v", err)
}
if err := svc.SoftDelete(context.Background(), gone, user.ActorRef{Type: "user", ID: gone.String()}); err != nil {
t.Fatalf("SoftDelete: %v", err)
}
page, err := svc.ListAccounts(context.Background(), 1, 50)
if err != nil {
t.Fatalf("ListAccounts: %v", err)
}
for _, item := range page.Items {
if item.UserID == gone {
t.Fatalf("ListAccounts returned a soft-deleted account: %+v", item)
}
if item.DeletedAt != nil {
t.Fatalf("ListAccounts returned an account with non-nil DeletedAt: %+v", item)
}
}
found := false
for _, item := range page.Items {
if item.UserID == live {
found = true
break
}
}
if !found {
t.Fatalf("ListAccounts did not include the live account")
}
}
// recordingRevoker is a SessionRevoker spy that captures every call
// for assertion. It is shared across tests in this package.
type recordingRevoker struct {
calls int
lastUser uuid.UUID
}
func (r *recordingRevoker) RevokeAllForUser(_ context.Context, userID uuid.UUID) error {
r.calls++
r.lastUser = userID
return nil
}
// recordingLobbyCascade captures the OnUserDeleted / OnUserBlocked
// calls so soft-delete and permanent-block tests can assert ordering
// and frequency.
type recordingLobbyCascade struct {
deletedCalls int
blockedCalls int
lastDeletedUser uuid.UUID
lastBlockedUser uuid.UUID
}
func (c *recordingLobbyCascade) OnUserDeleted(_ context.Context, userID uuid.UUID) error {
c.deletedCalls++
c.lastDeletedUser = userID
return nil
}
func (c *recordingLobbyCascade) OnUserBlocked(_ context.Context, userID uuid.UUID) error {
c.blockedCalls++
c.lastBlockedUser = userID
return nil
}
// recordingNotificationCascade captures OnUserDeleted invocations.
type recordingNotificationCascade struct {
calls int
lastUser uuid.UUID
}
func (c *recordingNotificationCascade) OnUserDeleted(_ context.Context, userID uuid.UUID) error {
c.calls++
c.lastUser = userID
return nil
}
// recordingGeoCascade captures OnUserDeleted invocations.
type recordingGeoCascade struct {
calls int
lastUser uuid.UUID
}
func (c *recordingGeoCascade) OnUserDeleted(_ context.Context, userID uuid.UUID) error {
c.calls++
c.lastUser = userID
return nil
}
// silence unused-import warnings on database/sql when tests only need
// it through fixture helpers.
var _ = sql.LevelDefault