package testserver import ( "context" "database/sql" "fmt" "math/rand" "net/http" "os" "strconv" "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" ) type Server struct { httpServer *http.Server port int baseURL string db *sql.DB } func init() { // Seed the random number generator for random port selection rand.Seed(time.Now().UnixNano()) } func NewServer() *Server { // Get feature-specific port from configuration feature := os.Getenv("FEATURE") port := 9191 // Default port // Use random port by default for better parallel testing // Can be disabled with FIXED_TEST_PORT=true if needed if os.Getenv("FIXED_TEST_PORT") != "true" { // Generate a random port in the test range (10000-19999) port = 10000 + rand.Intn(9999) log.Debug().Int("port", port).Msg("Using random test port") } else if feature != "" { // Try to read port from feature-specific config configPath := fmt.Sprintf("features/%s/%s-test-config.yaml", feature, feature) if _, statErr := os.Stat(configPath); statErr == nil { // Read config file to get port content, err := os.ReadFile(configPath) if err == nil { // Simple YAML parsing to extract port lines := strings.Split(string(content), "\n") for _, line := range lines { if strings.Contains(line, "port:") { parts := strings.Split(line, ":") if len(parts) >= 2 { portStr := strings.TrimSpace(parts[1]) if p, err := strconv.Atoi(portStr); err == nil { port = p break } } } } } } } return &Server{ port: port, } } 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() { // Get feature-specific config path feature := os.Getenv("FEATURE") var testConfigPath string if feature != "" { testConfigPath = fmt.Sprintf("features/%s/%s-test-config.yaml", feature, feature) } else { 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 { // Get feature-specific configuration feature := os.Getenv("FEATURE") var cfg *config.Config if feature != "" { // Try to load feature-specific config configPath := fmt.Sprintf("features/%s/%s-test-config.yaml", feature, feature) if _, err := os.Stat(configPath); err == nil { v := viper.New() v.SetConfigFile(configPath) v.SetConfigType("yaml") if readErr := v.ReadInConfig(); readErr == nil { var featureCfg config.Config if unmarshalErr := v.Unmarshal(&featureCfg); unmarshalErr == nil { // Set default values if not configured if featureCfg.Auth.JWTSecret == "" { featureCfg.Auth.JWTSecret = "default-secret-key-please-change-in-production" } if featureCfg.Auth.AdminMasterPassword == "" { featureCfg.Auth.AdminMasterPassword = "admin123" } cfg = &featureCfg } } } } // Fallback to default config if feature-specific not available if cfg == nil { 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, ) // Log the database configuration being used log.Debug(). Str("host", cfg.Database.Host). Int("port", cfg.Database.Port). Str("user", cfg.Database.User). Str("dbname", cfg.Database.Name). Str("sslmode", cfg.Database.SSLMode). Msg("Database connection initialized with test configuration") var dbErr error s.db, dbErr = sql.Open("postgres", dsn) if dbErr != nil { return fmt.Errorf("failed to open database connection: %w", dbErr) } // 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 for feature-specific config file first // This supports the new modular BDD test structure feature := os.Getenv("FEATURE") var configPaths []string if feature != "" { // Feature-specific config takes precedence configPaths = []string{ fmt.Sprintf("features/%s/%s-test-config.yaml", feature, feature), "test-config.yaml", // Fallback to legacy config } } else { // When running all features, use legacy config configPaths = []string{"test-config.yaml"} } // Try each config path in order for _, configPath := range configPaths { if _, err := os.Stat(configPath); err == nil { // Config file exists, use it v := viper.New() v.SetConfigFile(configPath) v.SetConfigType("yaml") // Read the 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" } log.Debug(). Str("config", configPath). Str("db_host", cfg.Database.Host). Int("db_port", cfg.Database.Port). Str("db_user", cfg.Database.User). Str("db_name", cfg.Database.Name). Bool("v2flag", cfg.API.V2Enabled). Msg("Using test config file") return &cfg } } } } // No test config file found, use hardcoded test defaults // This ensures test suite has complete control and isn't affected by // environment variables or main config file settings log.Debug(). Str("db_host", "localhost"). Int("db_port", 5432). Str("db_user", "postgres"). Str("db_name", "dance_lessons_coach_bdd_test"). Msg("No test config file found, using hardcoded test defaults") 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, }, } }