package testutils import ( "context" "crypto/sha256" "database/sql" "encoding/hex" "fmt" "os" "testing" "bugulma/backend/internal/domain" _ "github.com/jackc/pgx/v5/stdlib" "github.com/peterldowns/pgtestdb" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" ) // GormMigrator implements pgtestdb.Migrator interface for GORM migrations // This migrator ensures PostGIS is properly set up before running migrations type GormMigrator struct{} // Hash returns a unique identifier for the migration state // This is used by pgtestdb to identify template databases // Update the version string when migrations change to force template recreation func (m *GormMigrator) Hash() (string, error) { // Create a hash based on the migration state // This should be deterministic and change when migrations change hash := sha256.Sum256([]byte("bugulma-backend-migrations-v3")) return hex.EncodeToString(hash[:]), nil } // Migrate runs all GORM migrations including PostGIS setup // This function is called by pgtestdb when creating template databases // PostGIS extension MUST be created before any migrations that use geometry types func (m *GormMigrator) Migrate(ctx context.Context, db *sql.DB, config pgtestdb.Config) error { // Step 1: Try to enable PostGIS extension FIRST (before any migrations) // This must be done using raw SQL connection, not GORM, to ensure it persists // If PostGIS cannot be enabled (e.g., permission issues), we'll skip PostGIS migrations postgisEnabled := false if err := enablePostGISExtension(db); err != nil { // PostGIS extension creation failed - this might happen if: // - User doesn't have permission to create extensions // - PostGIS is not installed in PostgreSQL // - Template database restrictions // We'll continue without PostGIS - tests that need it will fail with clear errors _ = err postgisEnabled = false } else { postgisEnabled = true } // Step 2: Create GORM connection gormDB, err := gorm.Open(postgres.New(postgres.Config{ Conn: db, }), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { return fmt.Errorf("failed to create GORM connection: %w", err) } // Step 3: Run domain migrations (creates tables, but not PostGIS columns) if err := domain.AutoMigrate(gormDB); err != nil { return fmt.Errorf("failed to run domain migrations: %w", err) } // Step 4: Run PostGIS-specific migrations (creates geometry columns, indexes) // Only run if PostGIS was successfully enabled if postgisEnabled { if err := domain.RunPostGISMigrations(gormDB); err != nil { // PostGIS migrations failed - this is critical for spatial operations // Return error to fail template creation - better to fail early than have broken tests return fmt.Errorf("failed to run PostGIS migrations: %w", err) } } // If PostGIS is not enabled, skip PostGIS migrations // Tests that need PostGIS will fail with appropriate error messages // Step 5: Create additional indexes if err := domain.CreateIndexes(gormDB); err != nil { // Index creation errors are non-fatal (indexes might already exist) // Log but don't fail - this allows migrations to be idempotent _ = err } // Step 6: Run geographical feature migrations manually // Since geographical features are created via golang-migrate, we need to run them manually if postgisEnabled { if err := runGeographicalFeatureMigrations(gormDB); err != nil { // Geographical migrations are important for geo tests but not critical for core functionality // Log the error but don't fail - tests that need geographical features will fail appropriately _ = err } } return nil } // runGeographicalFeatureMigrations runs the geographical feature table migrations func runGeographicalFeatureMigrations(db *gorm.DB) error { // Create geographical_features table createTableSQL := ` CREATE TABLE IF NOT EXISTS geographical_features ( id TEXT PRIMARY KEY, name TEXT, feature_type VARCHAR(50) NOT NULL, osm_type VARCHAR(50), osm_id VARCHAR(50), properties JSONB DEFAULT '{}'::jsonb, processing_version VARCHAR(20) DEFAULT '1.0', quality_score DOUBLE PRECISION DEFAULT 0.0, source VARCHAR(100) DEFAULT 'osm', created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); ` if err := db.Exec(createTableSQL).Error; err != nil { return fmt.Errorf("failed to create geographical_features table: %w", err) } // Add geometry column addGeometrySQL := ` DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'geographical_features' AND column_name = 'geometry' ) THEN ALTER TABLE geographical_features ADD COLUMN geometry GEOMETRY(Geometry, 4326); END IF; END $$; ` if err := db.Exec(addGeometrySQL).Error; err != nil { return fmt.Errorf("failed to add geometry column: %w", err) } // Create indexes indexSQLs := []string{ `CREATE INDEX IF NOT EXISTS idx_geographical_features_geometry ON geographical_features USING GIST (geometry)`, `CREATE INDEX IF NOT EXISTS idx_geographical_features_type ON geographical_features (feature_type)`, `CREATE INDEX IF NOT EXISTS idx_geographical_features_osm_id ON geographical_features (osm_type, osm_id)`, `CREATE INDEX IF NOT EXISTS idx_geographical_features_properties ON geographical_features USING GIN (properties)`, `CREATE INDEX IF NOT EXISTS idx_geographical_features_created_at ON geographical_features (created_at)`, } for _, sql := range indexSQLs { if err := db.Exec(sql).Error; err != nil { return fmt.Errorf("failed to create index: %w", err) } } // Add site footprint geometry column addFootprintSQL := ` DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'sites' AND column_name = 'footprint_geometry' ) THEN ALTER TABLE sites ADD COLUMN footprint_geometry geometry(Polygon, 4326); CREATE INDEX IF NOT EXISTS idx_sites_footprint_geometry ON sites USING GIST (footprint_geometry); END IF; END $$; ` if err := db.Exec(addFootprintSQL).Error; err != nil { return fmt.Errorf("failed to add footprint geometry column: %w", err) } return nil } // enablePostGISExtension enables the PostGIS extension in the database // This must be done using raw SQL connection to ensure it persists // PostGIS extension is required for spatial operations and geometry types // The extension must be created BEFORE any migrations that use geometry types func enablePostGISExtension(db *sql.DB) error { // Always try to create the extension (IF NOT EXISTS handles existing case) // This ensures PostGIS is available even if the database was cloned without extensions if _, err := db.Exec("CREATE EXTENSION IF NOT EXISTS postgis"); err != nil { return fmt.Errorf("failed to create PostGIS extension: %w", err) } // Verify PostGIS is properly initialized by checking for PostGIS functions var version string if err := db.QueryRow("SELECT PostGIS_Version()").Scan(&version); err != nil { return fmt.Errorf("PostGIS extension exists but functions are not available: %w", err) } // PostGIS is properly initialized return nil } // getEnv gets environment variable or returns default value func getEnv(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value } return defaultValue } // ginkgoTBWrapper wraps Ginkgo's FullGinkgoTInterface to work with pgtestdb // pgtestdb requires a testing.TB interface, but Ginkgo provides a different interface type ginkgoTBWrapper struct { t interface { Helper() Logf(format string, args ...interface{}) Fatalf(format string, args ...interface{}) Failed() bool } cleanups []func() } func (w *ginkgoTBWrapper) Helper() { w.t.Helper() } func (w *ginkgoTBWrapper) Logf(format string, args ...interface{}) { w.t.Logf(format, args...) } func (w *ginkgoTBWrapper) Fatalf(format string, args ...interface{}) { w.t.Fatalf(format, args...) } func (w *ginkgoTBWrapper) Failed() bool { return w.t.Failed() } func (w *ginkgoTBWrapper) Cleanup(fn func()) { w.cleanups = append(w.cleanups, fn) } // SetupTestDB creates an isolated PostgreSQL database for testing using pgtestdb // Each test gets its own temporary database with migrations already applied // This function accepts testing.TB interface, compatible with *testing.T // // Example usage: // // func TestMyFeature(t *testing.T) { // db := testutils.SetupTestDB(t) // repo := repository.NewMyRepository(db) // // Your test code here // } func SetupTestDB(t testing.TB) *gorm.DB { return setupTestDBWithTB(t) } // SetupTestDBForGinkgo creates an isolated PostgreSQL database for Ginkgo tests // Use this function in Ginkgo BeforeEach blocks // // Example usage: // // BeforeEach(func() { // db = testutils.SetupTestDBForGinkgo(GinkgoT()) // repo = repository.NewMyRepository(db) // }) func SetupTestDBForGinkgo(ginkgoT interface { Helper() Logf(format string, args ...interface{}) Fatalf(format string, args ...interface{}) Failed() bool }) *gorm.DB { wrapper := &ginkgoTBWrapper{t: ginkgoT} return setupTestDBWithTB(wrapper) } // setupTestDBWithTB is the internal implementation that works with both // standard testing.T and Ginkgo's testing interface func setupTestDBWithTB(t interface { Helper() Logf(format string, args ...interface{}) Fatalf(format string, args ...interface{}) Failed() bool Cleanup(func()) }) *gorm.DB { // Configure PostgreSQL connection // Defaults match the running Docker Compose PostgreSQL container (turash-postgres) // // IMPORTANT: pgtestdb creates ISOLATED test databases - it does NOT touch production data! // - Connects to 'postgres' database (admin database) to CREATE new test databases // - Each test gets a unique temporary database (e.g., pgtestdb_abc123) // - Test databases are automatically DROPPED after each test completes // - Production database 'turash' is NEVER modified or accessed conf := pgtestdb.Config{ DriverName: "pgx", User: getEnv("POSTGRES_USER", "turash"), Password: getEnv("POSTGRES_PASSWORD", "turash123"), Host: getEnv("POSTGRES_HOST", "localhost"), Port: getEnv("POSTGRES_PORT", "5432"), Database: getEnv("POSTGRES_DB", "postgres"), // Connect to 'postgres' database to create test databases Options: "sslmode=disable", } // Create migrator instance migrator := &GormMigrator{} // Create isolated test database with migrations // pgtestdb.New accepts any type that implements the TB interface methods // It will: // 1. Check if a template database exists (by Hash()) // 2. Create template if needed (runs Migrate function) // 3. Clone template for this test (fast, milliseconds) // 4. Return connection to cloned database sqlDB := pgtestdb.New(t, conf, migrator) // Check if PostGIS is already enabled or if geometry column exists // If geometry column exists, PostGIS was set up in the template var postgisEnabled bool var columnExists bool // Check if PostGIS extension exists if err := sqlDB.QueryRow("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'postgis')").Scan(&postgisEnabled); err != nil { postgisEnabled = false } // Check if geometry column exists (indicates PostGIS was set up) if err := sqlDB.QueryRow("SELECT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_name = 'sites' AND column_name = 'location_geometry')").Scan(&columnExists); err != nil { columnExists = false } // If PostGIS is not enabled but column exists, try to enable PostGIS // This handles cases where template had PostGIS but cloned database doesn't inherit it if !postgisEnabled && columnExists { // Try to enable PostGIS - if it fails, that's OK since column already exists if err := enablePostGISExtension(sqlDB); err == nil { postgisEnabled = true } } else if !postgisEnabled && !columnExists { // PostGIS not enabled and column doesn't exist - try to enable PostGIS if err := enablePostGISExtension(sqlDB); err != nil { // Can't enable PostGIS - tests that need it will fail t.Logf("Warning: Failed to enable PostGIS extension: %v", err) } else { postgisEnabled = true } } // Convert to GORM DB for use in tests gormDB, err := gorm.Open(postgres.New(postgres.Config{ Conn: sqlDB, }), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { t.Fatalf("Failed to create GORM DB: %v", err) } // Run PostGIS migrations if PostGIS is enabled // This ensures geometry columns exist even if they weren't in the template if postgisEnabled { if err := domain.RunPostGISMigrations(gormDB); err != nil { // PostGIS migrations failed - this is critical for spatial operations // Fail the test setup rather than continuing with broken database t.Fatalf("Failed to run PostGIS migrations: %v", err) } } return gormDB }