package testserver import ( "context" "database/sql" "fmt" "net/http" "os" "strings" "time" "dance-lessons-coach/pkg/config" "dance-lessons-coach/pkg/server" _ "github.com/lib/pq" "github.com/rs/zerolog/log" "github.com/spf13/viper" ) // getPostgresHost returns the appropriate PostgreSQL host based on environment type Server struct { httpServer *http.Server port int baseURL string db *sql.DB } func NewServer() *Server { return &Server{ port: 9191, } } func (s *Server) Start() error { s.baseURL = fmt.Sprintf("http://localhost:%d", s.port) // Create real server instance from pkg/server cfg := createTestConfig(s.port) realServer := server.NewServer(cfg, context.Background()) // Initialize database connection for cleanup if err := s.initDBConnection(); err != nil { return fmt.Errorf("failed to initialize database connection: %w", err) } // Start HTTP server in same process s.httpServer = &http.Server{ Addr: fmt.Sprintf(":%d", s.port), Handler: realServer.Router(), } go func() { if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err != http.ErrServerClosed { log.Error().Err(err).Msg("Test server failed") } } }() // Wait for server to be ready if err := s.waitForServerReady(); err != nil { return err } // Start config file monitoring for test config changes go s.monitorConfigFile() return nil } // monitorConfigFile monitors the test config file for changes and reloads configuration func (s *Server) monitorConfigFile() { testConfigPath := "test-config.yaml" lastModTime := time.Time{} fileExists := false for { // Check if test config file exists if _, err := os.Stat(testConfigPath); os.IsNotExist(err) { if fileExists { // File was deleted, reload with default config fileExists = false log.Debug().Str("file", testConfigPath).Msg("Test config file deleted, reloading with default config") if err := s.ReloadConfig(); err != nil { log.Warn().Err(err).Msg("Failed to reload test server config after file deletion") } } time.Sleep(1 * time.Second) continue } fileExists = true // Get file modification time fileInfo, err := os.Stat(testConfigPath) if err != nil { time.Sleep(1 * time.Second) continue } // If file has changed, reload config if !fileInfo.ModTime().Equal(lastModTime) { lastModTime = fileInfo.ModTime() log.Debug().Str("file", testConfigPath).Msg("Test config file changed, reloading server") // Reload server configuration if err := s.ReloadConfig(); err != nil { log.Warn().Err(err).Msg("Failed to reload test server config") } } time.Sleep(1 * time.Second) } } // ReloadConfig reloads the server configuration by restarting the server func (s *Server) ReloadConfig() error { log.Debug().Msg("Reloading test server configuration") // Stop current server if s.httpServer != nil { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := s.httpServer.Shutdown(ctx); err != nil { log.Warn().Err(err).Msg("Failed to shutdown server for reload") return err } } // Recreate server with new config cfg := createTestConfig(s.port) realServer := server.NewServer(cfg, context.Background()) s.httpServer = &http.Server{ Addr: fmt.Sprintf(":%d", s.port), Handler: realServer.Router(), } // Start server in background go func() { if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err != http.ErrServerClosed { log.Error().Err(err).Msg("Test server failed after reload") } } }() // Wait for server to be ready again return s.waitForServerReady() } // initDBConnection initializes a direct database connection for cleanup operations func (s *Server) initDBConnection() error { cfg := createTestConfig(s.port) dsn := fmt.Sprintf( "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", cfg.Database.Host, cfg.Database.Port, cfg.Database.User, cfg.Database.Password, cfg.Database.Name, cfg.Database.SSLMode, ) var err error s.db, err = sql.Open("postgres", dsn) if err != nil { return fmt.Errorf("failed to open database connection: %w", err) } // Test the connection if err := s.db.Ping(); err != nil { return fmt.Errorf("failed to ping database: %w", err) } return nil } // CleanupDatabase deletes all test data from all tables // This uses raw SQL to avoid dependency on repositories and handles foreign keys properly // Uses SET CONSTRAINTS ALL DEFERRED to temporarily disable foreign key checks func (s *Server) CleanupDatabase() error { if s.db == nil { log.Debug().Msg("No database connection, skipping cleanup") return nil // No database connection, skip cleanup } // Start a transaction for atomic cleanup tx, err := s.db.Begin() if err != nil { return fmt.Errorf("failed to start cleanup transaction: %w", err) } // Ensure transaction is rolled back if cleanup fails defer func() { if err != nil { tx.Rollback() } }() // Disable foreign key constraints temporarily // This is valid PostgreSQL syntax: https://www.postgresql.org/docs/current/sql-set-constraints.html if _, err := tx.Exec("SET CONSTRAINTS ALL DEFERRED"); err != nil { log.Warn().Err(err).Msg("Failed to set constraints deferred, continuing cleanup") // Continue anyway, some constraints might still work } // Get all tables in the database rows, err := tx.Query(` SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' `) if err != nil { return fmt.Errorf("failed to query tables: %w", err) } // Ensure rows are closed defer func() { if rows != nil { rows.Close() } }() // Collect all tables var tables []string for rows.Next() { var tableName string if err := rows.Scan(&tableName); err != nil { log.Warn().Err(err).Str("table", tableName).Msg("Failed to scan table name") continue } // Skip system tables and internal tables if strings.HasPrefix(tableName, "pg_") || strings.HasPrefix(tableName, "sql_") || tableName == "spatial_ref_sys" || tableName == "goose_db_version" { continue } tables = append(tables, tableName) } // Check for errors during table scanning if err = rows.Err(); err != nil { return fmt.Errorf("error during table scanning: %w", err) } // Delete from tables in reverse order to handle foreign keys // This works better when constraints are deferred for i := len(tables) - 1; i >= 0; i-- { table := tables[i] query := fmt.Sprintf("DELETE FROM %s", table) if _, err := tx.Exec(query); err != nil { log.Warn().Err(err).Str("table", table).Msg("Failed to cleanup table") // Continue with other tables even if one fails continue } log.Debug().Str("table", table).Msg("Cleaned up table") } // Reset sequence counters for all tables for _, table := range tables { // Try the common pattern first: table_id_seq query := fmt.Sprintf("ALTER SEQUENCE IF EXISTS %s_id_seq RESTART WITH 1", table) if _, err := tx.Exec(query); err != nil { // Try alternative sequence naming patterns altQueries := []string{ fmt.Sprintf("ALTER SEQUENCE IF EXISTS %s_seq RESTART WITH 1", table), fmt.Sprintf("ALTER SEQUENCE IF EXISTS %s RESTART WITH 1", table), } for _, altQuery := range altQueries { if _, err := tx.Exec(altQuery); err == nil { break } } } } // Commit the transaction if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit cleanup transaction: %w", err) } log.Debug().Msg("Database cleanup completed successfully") return nil } // CloseDatabase closes the database connection func (s *Server) CloseDatabase() error { if s.db != nil { return s.db.Close() } return nil } func (s *Server) waitForServerReady() error { maxAttempts := 30 attempt := 0 for attempt < maxAttempts { resp, err := http.Get(fmt.Sprintf("%s/api/ready", s.baseURL)) if err == nil && resp.StatusCode == http.StatusOK { resp.Body.Close() return nil } if resp != nil { resp.Body.Close() } attempt++ time.Sleep(100 * time.Millisecond) } return fmt.Errorf("server did not become ready after %d attempts", maxAttempts) } func (s *Server) Stop() error { if s.httpServer == nil { return nil } // Shutdown HTTP server gracefully ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return s.httpServer.Shutdown(ctx) } func (s *Server) GetBaseURL() string { return s.baseURL } func createTestConfig(port int) *config.Config { // Check if there's a test config file (used by config hot reloading tests) // If it exists, use it. Otherwise, use default config. testConfigPath := "test-config.yaml" if _, err := os.Stat(testConfigPath); err == nil { // Test config file exists, use it v := viper.New() v.SetConfigFile(testConfigPath) v.SetConfigType("yaml") // Read the test config file if err := v.ReadInConfig(); err == nil { var cfg config.Config if err := v.Unmarshal(&cfg); err == nil { // Override server port for testing cfg.Server.Port = port // Set default auth values if not configured if cfg.Auth.JWTSecret == "" { cfg.Auth.JWTSecret = "default-secret-key-please-change-in-production" } if cfg.Auth.AdminMasterPassword == "" { cfg.Auth.AdminMasterPassword = "admin123" } return &cfg } } } // No test config file, use default config cfg, err := config.LoadConfig() if err != nil { log.Warn().Err(err).Msg("Failed to load config, using defaults") // Fallback to defaults if config loading fails return &config.Config{ Server: config.ServerConfig{ Host: "localhost", Port: port, }, Shutdown: config.ShutdownConfig{ Timeout: 5 * time.Second, }, Logging: config.LoggingConfig{ JSON: false, Level: "trace", }, Telemetry: config.TelemetryConfig{ Enabled: false, }, API: config.APIConfig{ V2Enabled: true, // Enable v2 by default for most tests }, Auth: config.AuthConfig{ JWTSecret: "default-secret-key-please-change-in-production", AdminMasterPassword: "admin123", }, Database: config.DatabaseConfig{ Host: "localhost", // Fallback if env vars not set Port: 5432, User: "postgres", Password: "postgres", Name: "dance_lessons_coach_bdd_test", // Separate BDD test database SSLMode: "disable", MaxOpenConns: 10, MaxIdleConns: 5, ConnMaxLifetime: time.Hour, }, } } // Override server port for testing cfg.Server.Port = port // Set default auth values if not configured if cfg.Auth.JWTSecret == "" { cfg.Auth.JWTSecret = "default-secret-key-please-change-in-production" } if cfg.Auth.AdminMasterPassword == "" { cfg.Auth.AdminMasterPassword = "admin123" } return cfg }