🧪 test: add JWT secret rotation BDD scenarios and step implementations (#12)
✨ merge: implement JWT secret rotation with BDD scenario isolation - Implement JWT secret rotation mechanism (closes #8) - Add per-scenario state isolation for BDD tests (closes #14) - Validate password reset workflow via BDD tests (closes #7) - Fix port conflicts in test validation - Add state tracer for debugging test execution - Document BDD isolation strategies in ADR 0025 - Fix PostgreSQL configuration environment variables Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai> Co-authored-by: Gabriel Radureau <arcodange@gmail.com> Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
This commit was merged in pull request #12.
This commit is contained in:
@@ -2,41 +2,157 @@ package testserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// getPostgresHost returns the appropriate PostgreSQL host based on environment
|
||||
// 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
|
||||
httpServer *http.Server
|
||||
port int
|
||||
baseURL string
|
||||
db *sql.DB
|
||||
authService user.AuthService // Reference to auth service for cleanup
|
||||
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: 9191,
|
||||
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
|
||||
cfg := createTestConfig(s.port)
|
||||
cfg := createTestConfig(s.port, v2Enabled)
|
||||
realServer := server.NewServer(cfg, context.Background())
|
||||
|
||||
// Store auth service for cleanup
|
||||
s.authService = realServer.GetAuthService()
|
||||
|
||||
// Initialize database connection for cleanup
|
||||
if err := s.initDBConnection(); err != nil {
|
||||
return fmt.Errorf("failed to initialize database connection: %w", err)
|
||||
@@ -57,12 +173,192 @@ func (s *Server) Start() error {
|
||||
}()
|
||||
|
||||
// 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 {
|
||||
cfg := createTestConfig(s.port)
|
||||
// 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,
|
||||
@@ -73,10 +369,19 @@ func (s *Server) initDBConnection() error {
|
||||
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)
|
||||
// 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
|
||||
@@ -87,14 +392,39 @@ func (s *Server) initDBConnection() error {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -190,107 +520,187 @@ func (s *Server) CleanupDatabase() error {
|
||||
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()
|
||||
if isCleanupLoggingEnabled() {
|
||||
log.Info().Msg("CLEANUP: Database cleanup completed successfully")
|
||||
}
|
||||
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
|
||||
// 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")
|
||||
}
|
||||
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()
|
||||
schemaName := generateSchemaName(feature, scenario)
|
||||
s.schemaMutex.Lock()
|
||||
defer s.schemaMutex.Unlock()
|
||||
|
||||
return s.httpServer.Shutdown(ctx)
|
||||
// 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
|
||||
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 {
|
||||
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 createTestConfig(port int) *config.Config {
|
||||
// Load actual config to respect environment variables
|
||||
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 for testing
|
||||
},
|
||||
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,
|
||||
},
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Override server port for testing
|
||||
cfg.Server.Port = port
|
||||
cfg.API.V2Enabled = true // Ensure v2 is enabled for testing
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
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
|
||||
tags := os.Getenv("GODOG_TAGS")
|
||||
return strings.Contains(tags, "@v2")
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user