Files
dance-lessons-coach/pkg/bdd/testserver/server.go
Gabriel Radureau 7b0135c537
Some checks failed
CI/CD Pipeline / Build Docker Cache (push) Successful in 9s
CI/CD Pipeline / CI Pipeline (push) Failing after 4m47s
🚀 feat: implement random port selection for BDD tests to prevent conflicts
2026-04-10 14:29:04 +02:00

519 lines
14 KiB
Go

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
// Check for random port mode (better for parallel testing)
if os.Getenv("RANDOM_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,
},
}
}