package userstore import ( "context" "database/sql" "net/url" "os" "strings" "sync" "testing" "time" "galaxy/postgres" "galaxy/user/internal/adapters/postgres/migrations" testcontainers "github.com/testcontainers/testcontainers-go" tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/testcontainers/testcontainers-go/wait" ) const ( pkgPostgresImage = "postgres:16-alpine" pkgSuperUser = "galaxy" pkgSuperPassword = "galaxy" pkgSuperDatabase = "galaxy_user" pkgServiceRole = "userservice" pkgServicePassword = "userservice" pkgServiceSchema = "user" pkgContainerStartup = 90 * time.Second pkgOperationTimeout = 10 * time.Second ) var ( pkgContainerOnce sync.Once pkgContainerErr error pkgContainerEnv *postgresEnv ) type postgresEnv struct { container *tcpostgres.PostgresContainer dsn string pool *sql.DB } func ensurePostgresEnv(t testing.TB) *postgresEnv { t.Helper() pkgContainerOnce.Do(func() { pkgContainerEnv, pkgContainerErr = startPostgresEnv() }) if pkgContainerErr != nil { t.Skipf("postgres container start failed (Docker unavailable?): %v", pkgContainerErr) } return pkgContainerEnv } func startPostgresEnv() (*postgresEnv, error) { ctx := context.Background() container, err := tcpostgres.Run(ctx, pkgPostgresImage, tcpostgres.WithDatabase(pkgSuperDatabase), tcpostgres.WithUsername(pkgSuperUser), tcpostgres.WithPassword(pkgSuperPassword), testcontainers.WithWaitStrategy( wait.ForLog("database system is ready to accept connections"). WithOccurrence(2). WithStartupTimeout(pkgContainerStartup), ), ) if err != nil { return nil, err } baseDSN, err := container.ConnectionString(ctx, "sslmode=disable") if err != nil { _ = testcontainers.TerminateContainer(container) return nil, err } if err := provisionRoleAndSchema(ctx, baseDSN); err != nil { _ = testcontainers.TerminateContainer(container) return nil, err } scopedDSN, err := dsnForServiceRole(baseDSN) if err != nil { _ = testcontainers.TerminateContainer(container) return nil, err } cfg := postgres.DefaultConfig() cfg.PrimaryDSN = scopedDSN cfg.OperationTimeout = pkgOperationTimeout pool, err := postgres.OpenPrimary(ctx, cfg) if err != nil { _ = testcontainers.TerminateContainer(container) return nil, err } if err := postgres.Ping(ctx, pool, pkgOperationTimeout); err != nil { _ = pool.Close() _ = testcontainers.TerminateContainer(container) return nil, err } if err := postgres.RunMigrations(ctx, pool, migrations.FS(), "."); err != nil { _ = pool.Close() _ = testcontainers.TerminateContainer(container) return nil, err } return &postgresEnv{ container: container, dsn: scopedDSN, pool: pool, }, nil } func provisionRoleAndSchema(ctx context.Context, baseDSN string) error { cfg := postgres.DefaultConfig() cfg.PrimaryDSN = baseDSN cfg.OperationTimeout = pkgOperationTimeout db, err := postgres.OpenPrimary(ctx, cfg) if err != nil { return err } defer func() { _ = db.Close() }() statements := []string{ `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'userservice') THEN CREATE ROLE userservice LOGIN PASSWORD 'userservice'; END IF; END $$;`, `CREATE SCHEMA IF NOT EXISTS "user" AUTHORIZATION userservice;`, `GRANT USAGE ON SCHEMA "user" TO userservice;`, } for _, statement := range statements { if _, err := db.ExecContext(ctx, statement); err != nil { return err } } return nil } func dsnForServiceRole(baseDSN string) (string, error) { parsed, err := url.Parse(baseDSN) if err != nil { return "", err } values := url.Values{} values.Set("search_path", pkgServiceSchema) values.Set("sslmode", "disable") scoped := url.URL{ Scheme: parsed.Scheme, User: url.UserPassword(pkgServiceRole, pkgServicePassword), Host: parsed.Host, Path: parsed.Path, RawQuery: values.Encode(), } return scoped.String(), nil } // newTestStore returns a Store backed by the package-scoped pool. Every // invocation truncates the user-owned tables so individual tests start from // a clean slate while sharing one container start. func newTestStore(t *testing.T) *Store { t.Helper() env := ensurePostgresEnv(t) truncateAll(t, env.pool) store, err := New(Config{DB: env.pool, OperationTimeout: pkgOperationTimeout}) if err != nil { t.Fatalf("new store: %v", err) } return store } func truncateAll(t *testing.T, db *sql.DB) { t.Helper() statement := strings.Join([]string{ "TRUNCATE TABLE", "sanction_active, limit_active,", "sanction_records, limit_records,", "entitlement_snapshots, entitlement_records,", "blocked_emails, accounts", "RESTART IDENTITY CASCADE", }, " ") if _, err := db.ExecContext(context.Background(), statement); err != nil { t.Fatalf("truncate tables: %v", err) } } // TestMain runs first when `go test` enters the package. We drive it through // a TestMain so the container started by the first test is shut down on the // way out, even when individual tests panic. func TestMain(m *testing.M) { code := m.Run() if pkgContainerEnv != nil { if pkgContainerEnv.pool != nil { _ = pkgContainerEnv.pool.Close() } if pkgContainerEnv.container != nil { _ = testcontainers.TerminateContainer(pkgContainerEnv.container) } } os.Exit(code) }