✨ feat: implement schema-per-scenario isolation for BDD tests
- Add BDD_SCHEMA_ISOLATION env var to enable schema-per-scenario mode - Generate unique schema names using SHA256 hash of feature+scenario - Implement SetupScenarioSchema() and TeardownScenarioSchema() methods - Handle search_path configuration for schema isolation - Use CASCADE drop to clean up all scenario-created DB objects - Add isSchemaIsolationEnabled() helper to suite.go - Update cleanup flow: skip table clearing when schema isolation is active - Add ADR 0025 documenting isolation strategies and decision rationale Activation: Set BDD_SCHEMA_ISOLATION=true to enable Debug: Set BDD_ENABLE_CLEANUP_LOGS=true for verbose isolation logging Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
@@ -2,13 +2,16 @@ 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"
|
||||
@@ -25,6 +28,30 @@ 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
|
||||
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")
|
||||
@@ -45,14 +72,6 @@ func getDatabasePort() int {
|
||||
return port
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
httpServer *http.Server
|
||||
port int
|
||||
baseURL string
|
||||
db *sql.DB
|
||||
authService user.AuthService // Reference to auth service for cleanup
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Seed the random number generator for random port selection
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
@@ -95,7 +114,9 @@ func NewServer() *Server {
|
||||
}
|
||||
|
||||
return &Server{
|
||||
port: port,
|
||||
port: port,
|
||||
currentSchema: "public",
|
||||
originalSearchPath: "public",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,6 +451,96 @@ func (s *Server) CleanupDatabase() error {
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
// CloseDatabase closes the database connection
|
||||
func (s *Server) CloseDatabase() error {
|
||||
if s.db != nil {
|
||||
|
||||
Reference in New Issue
Block a user