turash/bugulma/backend/internal/testutils/db.go
Damir Mukimov 0df4812c82
feat: Complete geographical features implementation with full test coverage
- Add comprehensive geographical data models (GeographicalFeature, TransportMode, TransportProfile, TransportOption)
- Implement geographical feature repository with PostGIS support and spatial queries
- Create transportation service for cost calculation and route optimization
- Build spatial resource matcher for geographical resource matching
- Develop environmental impact service for site environmental scoring
- Implement facility location optimizer with multi-criteria analysis
- Add geographical data migration service for SQLite to PostgreSQL migration
- Create database migrations for geographical features and site footprints
- Update geospatial service integration and server initialization
- Add CLI command for geographical data synchronization
- Implement complete test coverage for all geographical components (28 test cases)
- Update test infrastructure for geographical table creation and PostGIS handling

This implements advanced geospatial capabilities including transportation cost modeling, environmental impact assessment, and facility location optimization for the Turash platform.
2025-11-25 06:42:18 +01:00

352 lines
13 KiB
Go

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
}