package testserver import ( "context" "crypto/sha256" "database/sql" "encoding/hex" "fmt" "math/rand" "net/http" "os" "strconv" "strings" "sync" "time" "dance-lessons-coach/pkg/cache" "dance-lessons-coach/pkg/config" "dance-lessons-coach/pkg/server" "dance-lessons-coach/pkg/user" _ "github.com/lib/pq" "github.com/rs/zerolog/log" "github.com/spf13/viper" ) // isCleanupLoggingEnabled returns true if BDD_ENABLE_CLEANUP_LOGS environment variable is set to "true" func isCleanupLoggingEnabled() bool { return os.Getenv("BDD_ENABLE_CLEANUP_LOGS") == "true" } // isSchemaIsolationEnabled returns true if BDD_SCHEMA_ISOLATION environment variable is set to "true" func isSchemaIsolationEnabled() bool { return os.Getenv("BDD_SCHEMA_ISOLATION") == "true" } // generateSchemaName creates a unique schema name for a scenario // Format: test_{sha256(feature_scenario)[:8]} func generateSchemaName(feature, scenario string) string { hash := sha256.Sum256([]byte(feature + ":" + scenario)) hashStr := hex.EncodeToString(hash[:]) return "test_" + hashStr[:8] } type Server struct { httpServer *http.Server port int baseURL string db *sql.DB authService user.AuthService // Reference to auth service for cleanup cacheService cache.Service // Reference to cache service for cleanup isolatedRepo *user.PostgresRepository // Per-package isolated repo (BDD_SCHEMA_ISOLATION=true) isolatedSchemaName string // Per-package schema name to drop on Stop() schemaMutex sync.Mutex // Protects schema operations currentSchema string // Current schema being used originalSearchPath string // Original search_path to restore } // getDatabaseHost returns the database host from environment variable or defaults to localhost func getDatabaseHost() string { host := os.Getenv("DLC_DATABASE_HOST") if host == "" { return "localhost" } return host } // getDatabasePort returns the database port from environment variable or defaults to 5432 func getDatabasePort() int { port := 5432 if portEnv := os.Getenv("DLC_DATABASE_PORT"); portEnv != "" { if parsedPort, err := strconv.Atoi(portEnv); err == nil { port = parsedPort } } return port } // getDatabaseName returns the database name from environment variable or defaults to dance_lessons_coach func getDatabaseName() string { name := os.Getenv("DLC_DATABASE_NAME") if name == "" { return "dance_lessons_coach" } return name } // getDatabaseSSLMode returns the SSL mode from environment variable or defaults to disable func getDatabaseSSLMode() string { sslMode := os.Getenv("DLC_DATABASE_SSL_MODE") if sslMode == "" { return "disable" } return sslMode } 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, currentSchema: "public", originalSearchPath: "public", } } func (s *Server) Start() error { s.baseURL = fmt.Sprintf("http://localhost:%d", s.port) // Determine if v2 should be enabled based on feature and tags // This is the ONLY place where we check env vars for v2 configuration v2Enabled := s.shouldEnableV2() // Create real server instance from pkg/server. // When BDD_SCHEMA_ISOLATION=true, each test package (process) gets its own // isolated PostgreSQL schema with its own connection pool + migrations. // This makes `go test ./features/...` parallel-safe because each feature // package runs in its own process and gets its own schema. cfg := createTestConfig(s.port, v2Enabled) var realServer *server.Server if isSchemaIsolationEnabled() { feature := os.Getenv("FEATURE") if feature == "" { feature = "bdd" } schemaName := generateSchemaName(feature, "package_root") log.Info().Str("schema", schemaName).Str("feature", feature).Msg("ISOLATION: Building per-package isolated repo") // Connect a default repo briefly just to CREATE SCHEMA (uses cfg from env vars) bootstrapRepo, err := user.NewPostgresRepository(cfg) if err != nil { return fmt.Errorf("ISOLATION bootstrap repo failed: %w", err) } // Drop + recreate to ensure clean slate per process _ = bootstrapRepo.Exec(fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", schemaName)) if err := bootstrapRepo.Exec(fmt.Sprintf("CREATE SCHEMA %s", schemaName)); err != nil { bootstrapRepo.Close() return fmt.Errorf("ISOLATION CREATE SCHEMA failed: %w", err) } bootstrapRepo.Close() // Build the per-package isolated repo (runs migrations in the new schema) dsn := user.BuildSchemaIsolatedDSN(cfg, schemaName) isolatedRepo, err := user.NewPostgresRepositoryFromDSN(cfg, dsn) if err != nil { return fmt.Errorf("ISOLATION isolated repo failed: %w", err) } s.isolatedRepo = isolatedRepo s.isolatedSchemaName = schemaName // Build user service backed by the isolated repo jwtConfig := user.JWTConfig{ Secret: cfg.GetJWTSecret(), ExpirationTime: time.Hour * 24, Issuer: "dance-lessons-coach", } isolatedUserService := user.NewUserService(isolatedRepo, jwtConfig, cfg.GetAdminMasterPassword()) realServer = server.NewServerWithUserRepo(cfg, context.Background(), isolatedRepo, isolatedUserService) } else { realServer = server.NewServer(cfg, context.Background()) } // Store auth service for cleanup s.authService = realServer.GetAuthService() // Store cache service for cleanup s.cacheService = realServer.GetCacheService() // 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 from file // This is the ONLY feature that uses config file hot-reload feature := os.Getenv("FEATURE") var realServer *server.Server if feature == "config" { // For config feature: load config from the monitored file cfg, err := s.loadConfigFromFile() if err != nil { log.Warn().Err(err).Msg("Failed to load config from file, using defaults") cfg = createTestConfig(s.port, false) } realServer = server.NewServer(cfg, context.Background()) } else { // For other features: use defaults with v2 check cfg := createTestConfig(s.port, s.shouldEnableV2()) 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() } // loadConfigFromFile loads configuration from the monitored config file // Used for config feature hot-reload tests only func (s *Server) loadConfigFromFile() (*config.Config, error) { feature := os.Getenv("FEATURE") if feature == "" { return nil, fmt.Errorf("FEATURE not set") } configPath := fmt.Sprintf("features/%s/%s-test-config.yaml", feature, feature) v := viper.New() v.SetConfigFile(configPath) v.SetConfigType("yaml") if err := v.ReadInConfig(); err != nil { return nil, fmt.Errorf("failed to read config file %s: %w", configPath, err) } var cfg config.Config if err := v.Unmarshal(&cfg); err != nil { return nil, fmt.Errorf("failed to unmarshal config from %s: %w", configPath, err) } // Apply BDD test infrastructure defaults that should NOT come from config file // These are specific to the test environment cfg.Database.Host = getDatabaseHost() cfg.Database.Port = getDatabasePort() cfg.Database.User = "postgres" cfg.Database.Password = "postgres" cfg.Database.Name = getDatabaseName() cfg.Database.SSLMode = getDatabaseSSLMode() // Ensure auth defaults if cfg.Auth.JWTSecret == "" { cfg.Auth.JWTSecret = "test-secret-key-for-bdd-tests" } if cfg.Auth.AdminMasterPassword == "" { cfg.Auth.AdminMasterPassword = "admin123" } // Ensure logging default if cfg.Logging.Level == "" { cfg.Logging.Level = "debug" } return &cfg, nil } // 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 { var loadErr error cfg, loadErr = s.loadConfigFromFile() if loadErr != nil { log.Warn().Err(loadErr).Str("path", configPath).Msg("Failed to load config, using defaults") cfg = nil } } } // Fallback to default config if feature-specific not available if cfg == nil { cfg = createTestConfig(s.port, s.shouldEnableV2()) } 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 } // ResetJWTSecrets resets JWT secrets to initial state for test cleanup // This prevents JWT secret pollution between tests func (s *Server) ResetJWTSecrets() error { if s.authService == nil { if isCleanupLoggingEnabled() { log.Info().Msg("CLEANUP: No auth service available, skipping JWT secrets reset") } return nil } s.authService.ResetJWTSecrets() if isCleanupLoggingEnabled() { log.Info().Msg("CLEANUP: JWT secrets reset to initial state") } return nil } // FlushCache clears all cached data to prevent cache pollution between scenarios // This prevents cached responses from affecting subsequent test scenarios func (s *Server) FlushCache() error { if s.cacheService == nil { if isCleanupLoggingEnabled() { log.Info().Msg("CLEANUP: No cache service available, skipping cache flush") } return nil } s.cacheService.Flush() if isCleanupLoggingEnabled() { log.Info().Msg("CLEANUP: Cache flushed successfully") } 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 { if isCleanupLoggingEnabled() { log.Info().Msg("CLEANUP: No database connection, skipping cleanup") } return nil // No database connection, skip cleanup } // Log database state before cleanup if isCleanupLoggingEnabled() { log.Info().Msg("CLEANUP: Starting database 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) } if isCleanupLoggingEnabled() { log.Info().Msg("CLEANUP: Database cleanup completed successfully") } return nil } // SetupScenarioSchema creates and activates a unique schema for the scenario func (s *Server) SetupScenarioSchema(feature, scenario string) error { if !isSchemaIsolationEnabled() { if isCleanupLoggingEnabled() { log.Info().Str("feature", feature).Str("scenario", scenario).Msg("ISOLATION: Schema isolation disabled, using public schema") } return nil } schemaName := generateSchemaName(feature, scenario) s.schemaMutex.Lock() defer s.schemaMutex.Unlock() // Store original search path if not already stored if s.originalSearchPath == "" { var err error s.originalSearchPath, err = s.getCurrentSearchPath() if err != nil { log.Warn().Err(err).Msg("ISOLATION: Failed to get current search_path") s.originalSearchPath = "public" } } // Create the schema createSQL := fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", schemaName) if _, err := s.db.Exec(createSQL); err != nil { return fmt.Errorf("failed to create schema %s: %w", schemaName, err) } // Set search path to use the new schema (testserver's own connection) searchPathSQL := fmt.Sprintf("SET search_path = %s, %s", schemaName, s.originalSearchPath) if _, err := s.db.Exec(searchPathSQL); err != nil { return fmt.Errorf("failed to set search_path: %w", err) } s.currentSchema = schemaName if isCleanupLoggingEnabled() { log.Info().Str("feature", feature).Str("scenario", scenario).Str("schema", schemaName).Msg("ISOLATION: Created and activated schema") } return nil } // TeardownScenarioSchema drops the scenario's schema and restores search path func (s *Server) TeardownScenarioSchema() error { if !isSchemaIsolationEnabled() { return nil } s.schemaMutex.Lock() defer s.schemaMutex.Unlock() if s.currentSchema == "" || s.currentSchema == "public" { if isCleanupLoggingEnabled() { log.Info().Msg("ISOLATION: No custom schema to teardown") } return nil } schemaName := s.currentSchema // Restore original search path restoreSQL := fmt.Sprintf("SET search_path = %s", s.originalSearchPath) if _, err := s.db.Exec(restoreSQL); err != nil { log.Warn().Err(err).Str("original", s.originalSearchPath).Msg("ISOLATION: Failed to restore search_path") } // Drop the schema - CASCADE ensures dependent objects are also dropped dropSQL := fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", schemaName) if _, err := s.db.Exec(dropSQL); err != nil { return fmt.Errorf("failed to drop schema %s: %w", schemaName, err) } s.currentSchema = "" if isCleanupLoggingEnabled() { log.Info().Str("schema", schemaName).Msg("ISOLATION: Dropped schema") } return nil } // getCurrentSearchPath retrieves the current search_path setting func (s *Server) getCurrentSearchPath() (string, error) { var searchPath string err := s.db.QueryRow("SHOW search_path").Scan(&searchPath) return searchPath, err } func (s *Server) Stop() error { // Cleanup the per-package isolated schema + close its pool, if any. // (BDD_SCHEMA_ISOLATION=true path - see Start().) if s.isolatedRepo != nil { if s.isolatedSchemaName != "" { if err := s.isolatedRepo.Exec(fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", s.isolatedSchemaName)); err != nil { log.Warn().Err(err).Str("schema", s.isolatedSchemaName).Msg("ISOLATION: failed to drop schema on Stop") } } if err := s.isolatedRepo.Close(); err != nil { log.Warn().Err(err).Msg("ISOLATION: failed to close isolated repo") } s.isolatedRepo = nil s.isolatedSchemaName = "" } if s.httpServer != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return s.httpServer.Shutdown(ctx) } return nil } func (s *Server) GetBaseURL() string { return s.baseURL } func (s *Server) GetPort() int { return s.port } // waitForServerReady waits for the server to be ready func (s *Server) waitForServerReady() error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() for { select { case <-ctx.Done(): return fmt.Errorf("server not ready after 10s: %w", ctx.Err()) case <-ticker.C: // Try to connect to the health endpoint resp, err := http.Get(fmt.Sprintf("%s/api/health", s.baseURL)) if err == nil { resp.Body.Close() return nil } } } } // shouldEnableV2 determines if v2 API should be enabled for this test server. // This is the ONLY place that reads FEATURE and GODOG_TAGS env vars. // // 2026-05-05: previous version used strings.Contains(tags, "@v2") which // wrongly matched the negation `~@v2` as well. This made the "v1" greet // sub-test (tags `~@v2 && ~@skip`) actually run with v2 enabled, masking // the gate behavior we now test in feature `@v2-gate` scenario. Fixed // here by inspecting each && clause and checking for positive inclusion. func (s *Server) shouldEnableV2() bool { feature := os.Getenv("FEATURE") // Only check for v2 in greet feature (where we have @v2 tagged scenarios) if feature != "greet" { // For config feature, v2 is controlled via config file hot-reload // For other features, v2 is disabled by default return false } // For greet feature: enable v2 if tags include `@v2` as a POSITIVE clause. // Godog tag expression syntax: clauses separated by `&&` or `||`, negation // via leading `~`. A positive clause matches exactly `@v2` (after trim). tags := os.Getenv("GODOG_TAGS") for _, clause := range strings.FieldsFunc(tags, func(r rune) bool { return r == '&' || r == '|' || r == ' ' }) { clause = strings.TrimSpace(clause) if clause == "@v2" { return true } } return false } // createTestConfig creates a test configuration // Pass v2Enabled explicitly to avoid reading env vars deep in the stack func createTestConfig(port int, v2Enabled bool) *config.Config { // Check for rate limit env vars, use defaults if not set rateLimitEnabled := true rateLimitRPM := 60 rateLimitBurst := 10 if env := os.Getenv("DLC_RATE_LIMIT_ENABLED"); env != "" { rateLimitEnabled = strings.EqualFold(env, "true") || env == "1" } if env := os.Getenv("DLC_RATE_LIMIT_REQUESTS_PER_MINUTE"); env != "" { if val, err := strconv.Atoi(env); err == nil { rateLimitRPM = val } } if env := os.Getenv("DLC_RATE_LIMIT_BURST_SIZE"); env != "" { if val, err := strconv.Atoi(env); err == nil { rateLimitBurst = val } } return &config.Config{ Server: config.ServerConfig{ Host: "0.0.0.0", Port: port, }, Database: config.DatabaseConfig{ Host: getDatabaseHost(), Port: getDatabasePort(), User: "postgres", Password: "postgres", Name: getDatabaseName(), SSLMode: getDatabaseSSLMode(), }, Auth: config.AuthConfig{ JWTSecret: "test-secret-key-for-bdd-tests", AdminMasterPassword: "admin123", JWT: config.JWTConfig{ TTL: 24 * time.Hour, }, }, API: config.APIConfig{ V2Enabled: v2Enabled, }, Logging: config.LoggingConfig{ Level: "debug", }, RateLimit: config.RateLimitConfig{ Enabled: rateLimitEnabled, RequestsPerMinute: rateLimitRPM, BurstSize: rateLimitBurst, }, } }