package auth import ( "context" "database/sql" "fmt" "os" "path/filepath" "runtime" "sync" "testing" "time" "github.com/google/uuid" _ "github.com/jackc/pgx/v5/stdlib" "github.com/jackc/pgx/v5/pgxpool" "github.com/pressly/goose/v3" ) // migrationsDir resolves the backend/migrations directory relative to this test file. func migrationsDir() string { _, filename, _, _ := runtime.Caller(0) // filename: .../backend/internal/auth/testdb_test.go // migrations: .../backend/migrations return filepath.Join(filepath.Dir(filename), "..", "..", "migrations") } // gooseMu guards the global goose.SetTableName / goose.SetDialect state. var gooseMu sync.Mutex // setupTestDB creates an isolated Postgres schema, runs all goose migrations // against it (including goose's own version table inside the test schema), and // returns a pgxpool.Pool scoped to that schema plus a cleanup function. // // The test is skipped when TEST_DATABASE_URL is unset. Falls back to // DATABASE_URL if set (for local dev convenience with the compose Postgres). // // Each invocation creates a unique schema (test_) ensuring isolation // across concurrent test packages. func setupTestDB(t *testing.T) (*pgxpool.Pool, func()) { t.Helper() dsn := os.Getenv("TEST_DATABASE_URL") if dsn == "" { dsn = os.Getenv("DATABASE_URL") } if dsn == "" { t.Skip("TEST_DATABASE_URL (or DATABASE_URL) not set — integration test skipped") return nil, nil } // Build a schema name from the UUID, replacing hyphens with underscores // so it is a valid SQL identifier (no quoting required in all contexts). rawID := uuid.New().String()[:12] cleanID := make([]byte, len(rawID)) for i := 0; i < len(rawID); i++ { if rawID[i] == '-' { cleanID[i] = '_' } else { cleanID[i] = rawID[i] } } schemaName := "test_" + string(cleanID) // Bootstrap connection: create the schema. bootstrapDB, err := sql.Open("pgx", dsn) if err != nil { t.Fatalf("setupTestDB: sql.Open bootstrap: %v", err) } if err := bootstrapDB.Ping(); err != nil { bootstrapDB.Close() t.Fatalf("setupTestDB: ping: %v", err) } if _, err := bootstrapDB.Exec(fmt.Sprintf("CREATE SCHEMA %q", schemaName)); err != nil { bootstrapDB.Close() t.Fatalf("setupTestDB: CREATE SCHEMA: %v", err) } bootstrapDB.Close() // Schema-scoped DSN: connect into the test schema so all DDL (including // goose's version table) lands in the test schema, not in public. schemaDSN := schemaScopedDSN(dsn, schemaName) // Run goose migrations with a schema-specific version table name so we // don't collide with the public-schema goose_db_version (which tracks // the production migration state). schemaName is already hyphen-free so // this is a valid SQL identifier. versionTable := schemaName + "_goose_version" { gooseMu.Lock() defer gooseMu.Unlock() prevTable := goose.TableName() goose.SetTableName(versionTable) defer goose.SetTableName(prevTable) goose.SetBaseFS(nil) if err := goose.SetDialect("postgres"); err != nil { dropSchema(dsn, schemaName) t.Fatalf("setupTestDB: goose.SetDialect: %v", err) } schemaDB, err := sql.Open("pgx", schemaDSN) if err != nil { dropSchema(dsn, schemaName) t.Fatalf("setupTestDB: sql.Open schema-scoped: %v", err) } if err := goose.Up(schemaDB, migrationsDir()); err != nil { schemaDB.Close() dropSchema(dsn, schemaName) t.Fatalf("setupTestDB: goose.Up: %v", err) } schemaDB.Close() } // Build a pgxpool with the same schema-scoped DSN for use by tests. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() cfg, err := pgxpool.ParseConfig(schemaDSN) if err != nil { dropSchema(dsn, schemaName) t.Fatalf("setupTestDB: pgxpool.ParseConfig: %v", err) } cfg.MaxConns = 5 pool, err := pgxpool.NewWithConfig(ctx, cfg) if err != nil { dropSchema(dsn, schemaName) t.Fatalf("setupTestDB: pgxpool.NewWithConfig: %v", err) } cleanup := func() { pool.Close() dropSchema(dsn, schemaName) } return pool, cleanup } // schemaScopedDSN appends a search_path parameter to dsn so all connections // default to the given schema (with public as fallback for extensions). func schemaScopedDSN(dsn, schemaName string) string { sep := "?" for i := 0; i < len(dsn); i++ { if dsn[i] == '?' { sep = "&" break } } return fmt.Sprintf("%s%ssearch_path=%s,public", dsn, sep, schemaName) } // dropSchema drops the named schema (CASCADE) using a fresh connection. // Errors are intentionally ignored — this runs in cleanup. func dropSchema(dsn, schemaName string) { db, err := sql.Open("pgx", dsn) if err != nil { return } defer db.Close() db.Exec(fmt.Sprintf("DROP SCHEMA IF EXISTS %q CASCADE", schemaName)) //nolint:errcheck } // TestSetupTestDB_Roundtrip verifies that setupTestDB creates an isolated // schema, applies migrations, and returns a usable pool against that schema. func TestSetupTestDB_Roundtrip(t *testing.T) { pool, cleanup := setupTestDB(t) defer cleanup() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := pool.Ping(ctx); err != nil { t.Fatalf("pool.Ping: %v", err) } // The users table created by 0002_auth.sql must be visible in the test schema. // pgx returns pgx.ErrNoRows for LIMIT 0 queries — that is expected and means // the table exists (just empty). Any other error indicates a missing table. row := pool.QueryRow(ctx, "SELECT 1 FROM users LIMIT 0") if err := row.Scan(new(int)); err != nil { if err.Error() != "no rows in result set" { t.Fatalf("users table not visible in test schema: %v", err) } } }