🧪 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:
504
pkg/bdd/testserver/CONFIG_SCHEMA.md
Normal file
504
pkg/bdd/testserver/CONFIG_SCHEMA.md
Normal file
@@ -0,0 +1,504 @@
|
||||
# BDD Test Configuration Schema
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the configuration architecture for BDD tests in the dance-lessons-coach project.
|
||||
It establishes a clear hierarchy and flow of configuration parameters to ensure predictable, maintainable,
|
||||
and isolated test execution.
|
||||
|
||||
## Configuration Sources (Priority Order)
|
||||
|
||||
### 1. Explicit Parameters (Highest Priority)
|
||||
Passed directly between components with no hidden behavior:
|
||||
- `FEATURE`: Which feature is being tested (`greet`, `config`, `auth`, `health`, `jwt`)
|
||||
- `GODOG_TAGS`: Scenario tag filters (e.g., `@v2`, `~@flaky`, `~@todo`)
|
||||
- `Config` struct: Passed explicitly to server initialization
|
||||
|
||||
### 2. Feature-Specific Configuration Files
|
||||
Loaded from filesystem when testing specific features:
|
||||
- Path: `features/{FEATURE}/{FEATURE}-test-config.yaml`
|
||||
- Used by: Config hot-reload tests only
|
||||
- Monitored by: `testserver.monitorConfigFile()`
|
||||
- Example: `features/config/config-test-config.yaml`
|
||||
|
||||
### 3. Environment Variables (External Control Only)
|
||||
Set by test scripts and CI/CD, **NOT read deep in implementation code**:
|
||||
|
||||
| Variable | Purpose | Default | Set By |
|
||||
|----------|---------|---------|-------|
|
||||
| `DLC_API_V2_ENABLED` | Enable v2 API globally | `false` | Test scripts |
|
||||
| `BDD_SCHEMA_ISOLATION` | Enable per-scenario database schema isolation | `false` | Test scripts, validate-test-suite.sh |
|
||||
| `BDD_ENABLE_CLEANUP_LOGS` | Enable detailed cleanup logging | `false` | Test scripts |
|
||||
| `BDD_TRACE_STATE` | Enable state tracing | `false` | Test scripts |
|
||||
| `FIXED_TEST_PORT` | Use fixed port instead of random | `false` | Test scripts |
|
||||
| `FEATURE` | Current feature under test | `""` | testsetup.CreateTestSuite |
|
||||
| `GODOG_TAGS` | Tag filter for scenario selection | `"~@flaky && ~@todo && ~@skip"` | CreateTestSuite |
|
||||
|
||||
### 4. Hardcoded Defaults (Fallback)
|
||||
Used when no other source provides a value:
|
||||
- Port: Random in range 10000-19999 (or 9191 if FIXED_TEST_PORT=true)
|
||||
- JWT Secret: `test-secret-key-for-bdd-tests`
|
||||
- Database: localhost:5432, postgres/postgres, dance_lessons_coach
|
||||
- Logging Level: debug
|
||||
- v2_enabled: false
|
||||
|
||||
## Configuration Layers (Mermaid Diagram)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph TestExecutionControl["Test Execution Control
|
||||
(Shell/Script Layer)"]
|
||||
A1[Environment Variables]
|
||||
A2[DLC_API_V2_ENABLED]
|
||||
A3[BDD_SCHEMA_ISOLATION]
|
||||
A4[BDD_ENABLE_CLEANUP_LOGS]
|
||||
A5[FEATURE]
|
||||
A6[GODOG_TAGS]
|
||||
end
|
||||
|
||||
subgraph TestSuiteSetup["Test Suite Setup
|
||||
(pkg/bdd/testsetup)"]
|
||||
B1[CreateTestSuite]
|
||||
B2[Set FEATURE]
|
||||
B3[Set GODOG_TAGS]
|
||||
B4[Configure godog.Options]
|
||||
end
|
||||
|
||||
subgraph ServerSetup["Server Setup
|
||||
(pkg/bdd/suite)"]
|
||||
C1[InitializeTestSuite]
|
||||
C2[Create sharedServer]
|
||||
C3[InitializeScenario]
|
||||
end
|
||||
|
||||
subgraph ServerConfiguration["Server Configuration
|
||||
(pkg/bdd/testserver)"]
|
||||
D1[Server.Start]
|
||||
D2[shouldEnableV2]
|
||||
D3[createTestConfig]
|
||||
D4[monitorConfigFile]
|
||||
D5[ReloadConfig]
|
||||
D6[loadConfigFromFile]
|
||||
end
|
||||
|
||||
subgraph ScenarioExecution["Scenario Execution
|
||||
(pkg/bdd/steps)"]
|
||||
E1[BeforeScenario]
|
||||
E2[SetScenarioKey]
|
||||
E3[Execute Steps]
|
||||
E4[AfterScenario]
|
||||
E5[ClearScenarioState]
|
||||
end
|
||||
|
||||
A1 --> B1
|
||||
A2 --> D2
|
||||
A3 --> D1
|
||||
A4 --> D1
|
||||
A5 --> B2
|
||||
A5 --> D2
|
||||
A6 --> B3
|
||||
A6 --> D2
|
||||
|
||||
B1 --> C1
|
||||
B2 --> C1
|
||||
B3 --> C1
|
||||
B4 --> C1
|
||||
|
||||
C1 --> D1
|
||||
C2 --> D1
|
||||
C3 --> E1
|
||||
|
||||
D1 --> D4
|
||||
D2 --> D3
|
||||
D3 --> D1
|
||||
D4 --> D5
|
||||
D5 --> D1
|
||||
D5 --> D6
|
||||
D6 --> D3
|
||||
|
||||
D1 --> E1
|
||||
E1 --> E2
|
||||
E2 --> E3
|
||||
E3 --> E4
|
||||
E4 --> E5
|
||||
|
||||
classDef external fill:#09f,stroke:#333
|
||||
classDef setup fill:#08f,stroke:#333
|
||||
classDef server fill:#090,stroke:#333
|
||||
classDef scenario fill:#000,stroke:#333
|
||||
|
||||
class A1,A2,A3,A4,A5,A6 external
|
||||
class B1,B2,B3,B4 setup
|
||||
class C1,C2,C3 setup
|
||||
class D1,D2,D3,D4,D5,D6 server
|
||||
class E1,E2,E3,E4,E5 scenario
|
||||
```
|
||||
|
||||
## Configuration Flow (Mermaid Sequence Diagram)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Script as Test Script
|
||||
participant TestSetup as testsetup
|
||||
participant Suite as suite.go
|
||||
participant Server as testserver
|
||||
participant ConfigFile as Config File
|
||||
participant Steps as Step Definitions
|
||||
|
||||
Script->>Script: Set env vars (BDD_*, DLC_*)
|
||||
Script->>TestSetup: Run go test ./features/{feature}
|
||||
|
||||
TestSetup->>TestSetup: Read FEATURE from env
|
||||
TestSetup->>TestSetup: Read GODOG_TAGS from env
|
||||
TestSetup->>Suite: CreateTestSuite(FEATURE, tags)
|
||||
|
||||
Suite->>Server: InitializeTestSuite -> NewServer()
|
||||
Server->>Server: shouldEnableV2() checks FEATURE+GODOG_TAGS
|
||||
Server->>Server: createTestConfig(port, v2Enabled)
|
||||
Server->>Server: Start()
|
||||
Server->>Server: Start monitorConfigFile() goroutine
|
||||
|
||||
Suite->>Suite: InitializeScenario
|
||||
Suite->>Steps: Create step context
|
||||
|
||||
loop Each Scenario
|
||||
Suite->>Server: BeforeScenario: SetupSchemaIsolation
|
||||
Suite->>Steps: SetScenarioKeyForAllSteps
|
||||
Steps->>Steps: Clear scenario state
|
||||
|
||||
Steps->>Server: Execute step requests
|
||||
|
||||
alt Config Feature + File Modified
|
||||
ConfigFile->>Server: File modification detected
|
||||
Server->>Server: ReloadConfig()
|
||||
Server->>ConfigFile: loadConfigFromFile()
|
||||
Server->>Server: Restart with new config
|
||||
end
|
||||
|
||||
Suite->>Server: AfterScenario: Cleanup
|
||||
Suite->>Steps: ClearScenarioState
|
||||
end
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
### UC-1: Default Test Run (No v2, No Config File)
|
||||
```
|
||||
Input: go test ./features/greet
|
||||
FEATURE: greet
|
||||
GODOG_TAGS: ~@flaky && ~@todo && ~@skip
|
||||
Config Source: createTestConfig(port)
|
||||
v2_enabled: false
|
||||
Result: v1 scenarios pass, v2 scenarios skipped by tag filter
|
||||
```
|
||||
|
||||
### UC-2: v2 API Tests (Split Test Suite)
|
||||
```
|
||||
Input: go test ./features/greet (with GODOG_TAGS="@v2" in v2 subtest)
|
||||
FEATURE: greet
|
||||
GODOG_TAGS: @v2 && ~@skip
|
||||
Config Source: createTestConfig(port) with v2 check
|
||||
v2_enabled: true (because FEATURE=greet AND tags contain @v2)
|
||||
Result: v2 scenarios execute with v2 API available
|
||||
|
||||
Flow:
|
||||
1. TestGreetBDD runs v1 subtest with tags="~@v2"
|
||||
2. TestGreetBDD runs v2 subtest with tags="@v2"
|
||||
3. Each subtest starts its own server
|
||||
4. Server in v2 subtest has v2_enabled=true
|
||||
5. v2 scenarios pass
|
||||
```
|
||||
|
||||
### UC-3: Config Hot Reload Tests
|
||||
```
|
||||
Input: go test ./features/config
|
||||
FEATURE: config
|
||||
GODOG_TAGS: ~@flaky && ~@todo && ~@skip
|
||||
Config File: features/config/config-test-config.yaml
|
||||
Config Monitor: Watches config file for changes
|
||||
|
||||
When config file is modified:
|
||||
1. monitorConfigFile() detects file change via mod time
|
||||
2. Calls ReloadConfig()
|
||||
3. ReloadConfig() for FEATURE=config: loads from config file
|
||||
4. Server restarts with new config
|
||||
5. Subsequent scenarios see new configuration
|
||||
|
||||
Note: This is the ONLY feature that uses config file hot-reload.
|
||||
All other features use hardcoded/test defaults.
|
||||
```
|
||||
|
||||
### UC-4: Config Hot Reload with v2 Enable
|
||||
```
|
||||
Scenario: Hot reloading feature flags
|
||||
Steps:
|
||||
1. Server starts with default config (v2_enabled: false)
|
||||
2. Test sets v2_enabled: true in config file
|
||||
3. Config monitor detects change
|
||||
4. ReloadConfig() called
|
||||
5. Server loads from config file (NOT createTestConfig)
|
||||
6. Server restarts with v2_enabled: true
|
||||
7. Test verifies v2 API works
|
||||
|
||||
Current Bug: ReloadConfig() calls createTestConfig() which:
|
||||
- Reads FEATURE=config
|
||||
- Reads GODOG_TAGS (doesn't contain @v2)
|
||||
- Sets v2_enabled: false
|
||||
- Overrides the config file setting!
|
||||
|
||||
Fix: ReloadConfig() must load from file for config feature.
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Config Creation Flow
|
||||
|
||||
```go
|
||||
// pkg/bdd/testserver/server.go
|
||||
|
||||
func NewServer() *Server {
|
||||
port := getRandomPort() // 10000-19999
|
||||
return &Server{port: port}
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
cfg := createTestConfig(s.port)
|
||||
// ... start server with cfg
|
||||
go s.monitorConfigFile()
|
||||
}
|
||||
|
||||
// CURRENT - BAD
|
||||
func createTestConfig(port int) *config.Config {
|
||||
feature := os.Getenv("FEATURE")
|
||||
tags := os.Getenv("GODOG_TAGS")
|
||||
|
||||
enableV2 := false
|
||||
if feature == "greet" && strings.Contains(tags, "@v2") {
|
||||
enableV2 = true
|
||||
}
|
||||
// ...
|
||||
return &config.Config{
|
||||
API: config.APIConfig{V2Enabled: enableV2},
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
// PROPOSED - GOOD
|
||||
func createTestConfig(port int, opts ConfigOptions) *config.Config {
|
||||
defaults := &config.Config{
|
||||
Server: config.ServerConfig{Host: "0.0.0.0", Port: port},
|
||||
// ... all hardcoded defaults
|
||||
}
|
||||
|
||||
// Apply explicit options (passed from caller)
|
||||
if opts.V2Enabled {
|
||||
defaults.API.V2Enabled = true
|
||||
}
|
||||
|
||||
return defaults
|
||||
}
|
||||
|
||||
// ConfigOptions passed from testsuite
|
||||
type ConfigOptions struct {
|
||||
V2Enabled bool
|
||||
UseConfigFile bool
|
||||
ConfigFilePath string
|
||||
}
|
||||
```
|
||||
|
||||
### Reload Flow Fix
|
||||
|
||||
```go
|
||||
// pkg/bdd/testserver/server.go
|
||||
|
||||
func (s *Server) ReloadConfig() error {
|
||||
feature := os.Getenv("FEATURE")
|
||||
|
||||
if feature == "config" && s.configFilePath != "" {
|
||||
// For config tests: load from monitored file
|
||||
cfg, err := loadConfigFromFile(s.configFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.applyConfig(cfg)
|
||||
}
|
||||
|
||||
// For all other features: use defaults
|
||||
// (hot reload not supported for non-config features)
|
||||
cfg := createDefaultConfig(s.port)
|
||||
return s.applyConfig(cfg)
|
||||
}
|
||||
|
||||
func loadConfigFromFile(path string) (*config.Config, error) {
|
||||
v := viper.New()
|
||||
v.SetConfigFile(path)
|
||||
v.SetConfigType("yaml")
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cfg config.Config
|
||||
if err := v.Unmarshal(&cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Apply hardcoded values that should NOT come from file
|
||||
// (database connection for BDD tests, etc.)
|
||||
cfg.Database.Host = getDatabaseHost()
|
||||
cfg.Database.Port = getDatabasePort()
|
||||
cfg.Database.User = "postgres"
|
||||
cfg.Database.Password = "postgres"
|
||||
cfg.Database.Name = "dance_lessons_coach"
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration File Format
|
||||
|
||||
### Config Test File (features/config/config-test-config.yaml)
|
||||
```yaml
|
||||
server:
|
||||
host: "127.0.0.1"
|
||||
port: 9191
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
json: false
|
||||
|
||||
api:
|
||||
v2_enabled: false # Will be toggled by tests
|
||||
|
||||
telemetry:
|
||||
enabled: true
|
||||
sampler:
|
||||
type: "parentbased_always_on"
|
||||
ratio: 1.0
|
||||
|
||||
auth:
|
||||
jwt:
|
||||
ttl: 1h
|
||||
|
||||
database:
|
||||
# These are OVERRIDDEN by BDD test infrastructure
|
||||
host: "localhost"
|
||||
port: 5432
|
||||
user: "postgres"
|
||||
password: "postgres"
|
||||
name: "dance_lessons_coach_bdd_test"
|
||||
ssl_mode: "disable"
|
||||
```
|
||||
|
||||
## State Isolation
|
||||
|
||||
### Per-Scenario State
|
||||
- Managed by: `pkg/bdd/steps/scenario_state.go`
|
||||
- Key: SHA256 hash of scenario URI + name
|
||||
- State includes: LastToken, FirstToken, LastUserID, LastSecret, LastError
|
||||
- Cleared: At start of each scenario in BeforeScenario hook
|
||||
|
||||
### Database Schema Isolation
|
||||
- Enabled by: `BDD_SCHEMA_ISOLATION=true`
|
||||
- Mechanism: Creates unique schema per scenario
|
||||
- Schema name: `test_{sha256(scenarioKey)[:8]}`
|
||||
- Search path: Set via `SET search_path TO ...`
|
||||
- Cleanup: Schema dropped after scenario
|
||||
|
||||
### Server-Level State Reset
|
||||
- JWT secrets: Reset after every scenario via `ResetJWTSecrets()`
|
||||
- Database: Cleaned up after every scenario
|
||||
- Auth state: Per-scenario via state manager
|
||||
|
||||
## Package Responsibilities
|
||||
|
||||
### pkg/bdd/testserver
|
||||
- **Purpose**: Test HTTP server management
|
||||
- **Responsibilities**:
|
||||
- Server lifecycle (Start, Stop)
|
||||
- Configuration loading and reloading
|
||||
- Database cleanup
|
||||
- Schema isolation
|
||||
- JWT secret management
|
||||
- Config file monitoring (config feature only)
|
||||
|
||||
### pkg/bdd/testsetup
|
||||
- **Purpose**: Godog test suite setup
|
||||
- **Responsibilities**:
|
||||
- Feature test file discovery
|
||||
- Test suite configuration
|
||||
- Tag filtering
|
||||
- godog options setup
|
||||
|
||||
### pkg/bdd/suite
|
||||
- **Purpose**: Test suite initialization hooks
|
||||
- **Responsibilities**:
|
||||
- BeforeSuite/AfterSuite hooks
|
||||
- BeforeScenario/AfterScenario hooks
|
||||
- Step context creation
|
||||
- State isolation setup
|
||||
|
||||
### pkg/bdd/steps
|
||||
- **Purpose**: Step definitions
|
||||
- **Responsibilities**:
|
||||
- All Gherkin step implementations
|
||||
- Per-scenario state management
|
||||
- Per-feature step organization
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: Fix Config Reload (Urgent)
|
||||
1. Create `loadConfigFromFile()` function
|
||||
2. Modify `ReloadConfig()` to use file for config feature
|
||||
3. Add tests to verify config hot-reload works
|
||||
|
||||
### Phase 2: Clean Up Config Creation
|
||||
1. Create `ConfigOptions` struct
|
||||
2. Modify `createTestConfig()` to accept options
|
||||
3. Update callers to pass explicit options
|
||||
4. Remove env var reading from deep in config creation
|
||||
|
||||
### Phase 3: Document and Validate
|
||||
1. Write comprehensive documentation (this file)
|
||||
2. Add validation tests for all use cases
|
||||
3. Create troubleshooting guide
|
||||
|
||||
### Phase 4: Consider Package Merge (Optional)
|
||||
1. Evaluate merging testserver + testsetup
|
||||
2. Design new `pkg/bdd/testing` package structure
|
||||
3. Migrate code incrementally
|
||||
|
||||
## Rules for Adding New Configuration
|
||||
|
||||
1. **Prefer explicit parameters** over environment variables
|
||||
2. **Read env vars at ONE layer only** (typically test entry point)
|
||||
3. **Document all config sources** in this file
|
||||
4. **Test config combinations** to prevent override bugs
|
||||
5. **Never read env vars in hot paths** (scenario steps, server handlers)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Symptom: Config file changes not applied
|
||||
- Check: Is FEATURE=config?
|
||||
- Check: Does config file exist at `features/config/config-test-config.yaml`?
|
||||
- Check: Does monitorConfigFile() detect the change?
|
||||
- Fix: ReloadConfig() must load from file, not createTestConfig()
|
||||
|
||||
### Symptom: v2 tests fail with 404
|
||||
- Check: Is FEATURE=greet?
|
||||
- Check: Does GODOG_TAGS contain @v2?
|
||||
- Check: Does createTestConfig() see the tags?
|
||||
- Fix: Ensure tags are set before server creation
|
||||
|
||||
### Symptom: State pollution between scenarios
|
||||
- Check: Is schema isolation enabled?
|
||||
- Check: Are step definitions using per-scenario state?
|
||||
- Fix: Use ScenarioState for all mutable state
|
||||
|
||||
## References
|
||||
|
||||
- [Godog Documentation](https://github.com/cucumber/godog)
|
||||
- [pkg/config/config.go](../config/config.go) - Config struct definitions
|
||||
- [pkg/bdd/testsetup/testsetup.go](../testsetup/testsetup.go) - Test suite creation
|
||||
- [pkg/bdd/suite.go](../suite.go) - Test hooks
|
||||
- [ADR-0008: BDD Testing](../adr/0008-bdd-testing.md)
|
||||
241
pkg/bdd/testserver/STATE_TRACER_README.md
Normal file
241
pkg/bdd/testserver/STATE_TRACER_README.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# BDD State Tracer
|
||||
|
||||
## Overview
|
||||
|
||||
The BDD State Tracer is a debugging tool that logs scenario execution, database operations, and state modifications to a file in `$TMPDIR` for analysis of test execution order and state pollution issues.
|
||||
|
||||
## Purpose
|
||||
|
||||
### Why Tracing Was Added
|
||||
|
||||
During multi-iteration BDD test runs with `./scripts/validate-test-suite.sh`, intermittent failures occurred that were difficult to diagnose:
|
||||
- Tests passed when run individually
|
||||
- Tests failed when run together in the validation script
|
||||
- Patterns suggested database state pollution between scenarios across different feature packages
|
||||
|
||||
The tracer was created to answer key questions:
|
||||
1. **Execution Order**: Which scenarios run in which order?
|
||||
2. **State Modifications**: What database writes/cleanups occur and when?
|
||||
3. **Overlap Detection**: Are scenarios running in parallel (causing race conditions)?
|
||||
4. **Isolation Verification**: Is schema isolation working as expected?
|
||||
|
||||
### Key Findings from Tracing
|
||||
|
||||
1. **Sequential Execution**: Each feature package runs in a separate process (separate PIDs), but scenarios within each feature run sequentially
|
||||
2. **Shared Database**: All processes share the same PostgreSQL database connection
|
||||
3. **Schema Isolation Status**: When `BDD_SCHEMA_ISOLATION=false` (default in validate script), all scenarios share the `public` schema
|
||||
4. **Cleanup Operations**: Database cleanup (`CleanupDatabase`) runs after each scenario, deleting all test data from all tables
|
||||
5. **In-Memory State**: JWT secrets are stored in-memory only, not in database - schema isolation doesn't prevent JWT secret pollution
|
||||
|
||||
### Example Trace Output
|
||||
|
||||
```
|
||||
2026-04-11T10:10:53.032156 | auth | User registration | SCENARIO_START |
|
||||
2026-04-11T10:10:53.146438 | auth | User registration | SCENARIO_END | PASSED
|
||||
2026-04-11T10:10:53.152398 | auth | User registration | JWT_RESET | ok
|
||||
2026-04-11T10:10:53.162357 | auth | Failed authentication | SCENARIO_START |
|
||||
2026-04-11T10:10:53.268273 | auth | Failed authentication | SCENARIO_END | PASSED
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Enable Tracing
|
||||
|
||||
Set the environment variable `BDD_TRACE_STATE=1` before running tests:
|
||||
|
||||
```bash
|
||||
# Single run with tracing
|
||||
BDD_TRACE_STATE=1 go test ./features/auth -v
|
||||
|
||||
# Validation script with tracing
|
||||
BDD_TRACE_STATE=1 ./scripts/validate-test-suite.sh 1
|
||||
|
||||
# Multiple runs with tracing
|
||||
BDD_TRACE_STATE=1 ./scripts/validate-test-suite.sh 5
|
||||
```
|
||||
|
||||
### Trace File Location
|
||||
|
||||
Trace files are written to `$TMPDIR` (typically `/var/folders/.../T/` on macOS or `/tmp` on Linux):
|
||||
|
||||
```bash
|
||||
# Find trace files
|
||||
ls -la $TMPDIR/bdd-state-trace-*.log
|
||||
|
||||
# View a trace file
|
||||
cat $TMPDIR/bdd-state-trace-20260411-101053-12345.log
|
||||
```
|
||||
|
||||
### Trace File Format
|
||||
|
||||
```
|
||||
TIMESTAMP | FEATURE | SCENARIO | ACTION | DETAILS
|
||||
2026-04-11T10:10:53.032156 | auth | User registration | SCENARIO_START |
|
||||
2026-04-11T10:10:53.146438 | auth | User registration | SCENARIO_END | PASSED
|
||||
2026-04-11T10:10:53.152398 | auth | User registration | JWT_RESET | ok
|
||||
2026-04-11T10:10:53.162357 | auth | User registration | DB_CLEANUP | all_tables
|
||||
```
|
||||
|
||||
**Columns:**
|
||||
- `TIMESTAMP`: ISO 8601 format with microseconds
|
||||
- `FEATURE`: Feature name from `FEATURE` environment variable
|
||||
- `SCENARIO`: Scenario name (includes URI for disambiguation)
|
||||
- `ACTION`: Type of action (see below)
|
||||
- `DETAILS`: Additional context
|
||||
|
||||
**Action Types:**
|
||||
- `SCENARIO_START` - Scenario execution begins
|
||||
- `SCENARIO_END` - Scenario execution completes (PASSED or FAILED)
|
||||
- `DB_CLEANUP` - Database cleanup operation
|
||||
- `DB_SELECT` - Database read operation
|
||||
- `JWT_RESET` - JWT secrets reset to initial state
|
||||
- `DB_INSERT/UPDATE/DELETE` - Database write operations (future)
|
||||
- `SCHEMA_*` - Schema isolation operations (future)
|
||||
- `TX_*` - Transaction boundary operations (future)
|
||||
|
||||
## Implementation
|
||||
|
||||
### Architecture
|
||||
|
||||
The state tracer uses a simple file-based approach:
|
||||
|
||||
1. **Per-Process Tracing**: Each `go test` process creates its own trace file with unique filename based on timestamp and PID
|
||||
2. **Immediate Flush**: Each trace line is flushed immediately to disk using `Sync()` to prevent data loss
|
||||
3. **No Dependencies**: Uses only standard library (`os`, `fmt`, `time`, `path/filepath`)
|
||||
4. **Singleton Pattern**: Package-level functions for easy usage across the codebase
|
||||
|
||||
### Files
|
||||
|
||||
- `pkg/bdd/testserver/state_tracer.go` - Core tracing functions
|
||||
- `pkg/bdd/suite.go` - Integration with godog Before/After scenario hooks
|
||||
|
||||
### Key Functions
|
||||
|
||||
```go
|
||||
// Package-level functions (called from anywhere)
|
||||
TraceStateScenarioStart(feature, scenario string)
|
||||
TraceStateScenarioEnd(feature, scenario string, err error)
|
||||
TraceStateDBCleanup(feature, scenario, table string)
|
||||
TraceStateJWTSecretOperation(feature, scenario, operation, details string)
|
||||
TraceStateSchemaIsolation(feature, scenario, operation, details string)
|
||||
TraceStateTransaction(feature, scenario, action, details string)
|
||||
TraceStateDBRead(feature, scenario, table, details string)
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
### Current Limitations
|
||||
|
||||
1. **Per-Process Files**: Each `go test` process creates its own file, making correlation across processes manual
|
||||
2. **No Database Write Tracing**: Currently only traces cleanup, not individual INSERT/UPDATE/DELETE operations
|
||||
3. **No API Call Tracing**: Doesn't trace HTTP requests made during scenarios
|
||||
4. **No Timing Analysis**: Doesn't measure duration between operations automatically
|
||||
5. **No Schema Name in Trace**: When schema isolation is enabled, doesn't show which schema is active
|
||||
6. **File Rotation**: No automatic cleanup of old trace files
|
||||
|
||||
### Known Issues
|
||||
|
||||
1. **PID-based filenames**: If multiple runs happen in the same second, filenames could collide
|
||||
2. **Large file sizes**: High-volume tracing could create large files (mitigated by per-run files)
|
||||
3. **No header/footer**: Trace files start immediately with data, no metadata about the run
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Priority 1: Process Correlation
|
||||
- Add a unique run ID that can be passed across all processes
|
||||
- Include process start/end markers to show process lifecycle
|
||||
- Add parent PID tracking to show process hierarchy
|
||||
|
||||
### Priority 2: Database Operation Tracing
|
||||
- Add tracing for all database writes (INSERT, UPDATE, DELETE)
|
||||
- Include query text and affected rows
|
||||
- Trace transaction boundaries with IDs
|
||||
- Add schema name to all database operations when isolation is enabled
|
||||
|
||||
### Priority 3: API Call Tracing
|
||||
- Trace all HTTP requests made during scenarios
|
||||
- Include request method, path, status code, and duration
|
||||
- Mark requests that modify state (POST, PUT, DELETE vs GET)
|
||||
|
||||
### Priority 4: Analysis Tools
|
||||
- Create a `bdd-trace-analyzer` tool to:
|
||||
- Merge trace files from all processes in correct order
|
||||
- Detect overlapping scenarios (parallel execution)
|
||||
- Identify database state pollution patterns
|
||||
- Generate visualization of scenario execution timeline
|
||||
- Flag potential race conditions
|
||||
|
||||
### Priority 5: Improved Output
|
||||
- Add trace file header with metadata (run ID, start time, config, etc.)
|
||||
- Color-coded output for different action types
|
||||
- JSON output option for programmatic analysis
|
||||
- Trace level filtering (DEBUG, INFO, WARN, ERROR)
|
||||
|
||||
### Priority 6: Performance Optimization
|
||||
- Batch writes instead of per-line flush (with configurable flush interval)
|
||||
- Compress old trace files
|
||||
- Automatic cleanup of old files
|
||||
|
||||
## Analysis Use Cases
|
||||
|
||||
### Detecting State Pollution
|
||||
|
||||
Look for patterns like:
|
||||
```
|
||||
PID 1234 | auth | Scenario A | DB_CLEANUP | all_tables
|
||||
PID 5678 | greet | Scenario B | SCENARIO_START |
|
||||
# ^ Scenario B starts AFTER auth cleanup - potential issue
|
||||
```
|
||||
|
||||
### Detecting Parallel Execution
|
||||
|
||||
Check if timestamps overlap:
|
||||
```
|
||||
PID 1234 | 10:10:53.032 | auth | Scenario A | SCENARIO_START
|
||||
PID 5678 | 10:10:53.035 | greet | Scenario B | SCENARIO_START
|
||||
# ^ Both started within 3ms - likely parallel
|
||||
```
|
||||
|
||||
### Verifying Schema Isolation
|
||||
|
||||
Check that each scenario gets its own schema:
|
||||
```
|
||||
PID 1234 | auth | Scenario A | SCHEMA_CREATE | test_a1b2c3d4
|
||||
PID 1234 | auth | Scenario B | SCHEMA_CREATE | test_e5f6g7h8
|
||||
# ^ Different schemas for different scenarios - good
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tracing Not Working
|
||||
|
||||
1. Verify `BDD_TRACE_STATE=1` is set:
|
||||
```bash
|
||||
echo $BDD_TRACE_STATE
|
||||
```
|
||||
2. Check if trace files are being created:
|
||||
```bash
|
||||
ls -la $TMPDIR/bdd-state-trace-*.log
|
||||
```
|
||||
3. Verify the `testserver` package is being used (tracing is integrated there)
|
||||
|
||||
### No Trace Files Found
|
||||
|
||||
- Tracing only works when `BDD_TRACE_STATE=1` is set before the test process starts
|
||||
- Each `go test` process creates its own file - if tests pass quickly, files may be short
|
||||
- Files are created in `$TMPDIR` which defaults to `/tmp` on Linux and a temp folder on macOS
|
||||
|
||||
### Trace Files Too Large
|
||||
|
||||
- Tracing every operation can generate large files
|
||||
- Consider filtering to specific scenarios:
|
||||
```bash
|
||||
# Run only failing scenarios with tracing
|
||||
BDD_TRACE_STATE=1 go test ./features/auth -v -run "TestAuthBDD/Password_reset"
|
||||
```
|
||||
|
||||
## Related Files
|
||||
|
||||
- `pkg/bdd/suite.go` - Godog test suite initialization with tracing hooks
|
||||
- `pkg/bdd/testserver/server.go` - Test server with tracing integration
|
||||
- `scripts/validate-test-suite.sh` - Test validation script
|
||||
35
pkg/bdd/testserver/config_test.go
Normal file
35
pkg/bdd/testserver/config_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package testserver
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCreateTestConfig(t *testing.T) {
|
||||
// Test 1: Default config (no test config file)
|
||||
t.Run("DefaultConfig", func(t *testing.T) {
|
||||
cfg := createTestConfig(9999, false)
|
||||
|
||||
expectedDatabaseName := os.Getenv("DLC_DATABASE_NAME")
|
||||
if expectedDatabaseName == "" {
|
||||
expectedDatabaseName = "dance_lessons_coach"
|
||||
}
|
||||
|
||||
assert.Equal(t, "0.0.0.0", cfg.Server.Host)
|
||||
assert.Equal(t, 9999, cfg.Server.Port)
|
||||
assert.Equal(t, "test-secret-key-for-bdd-tests", cfg.Auth.JWTSecret)
|
||||
assert.Equal(t, "admin123", cfg.Auth.AdminMasterPassword)
|
||||
assert.Equal(t, expectedDatabaseName, cfg.Database.Name)
|
||||
})
|
||||
|
||||
// Test 2: Config with v2 enabled
|
||||
t.Run("V2EnabledConfig", func(t *testing.T) {
|
||||
cfg := createTestConfig(9999, true)
|
||||
|
||||
assert.Equal(t, "0.0.0.0", cfg.Server.Host)
|
||||
assert.Equal(t, 9999, cfg.Server.Port)
|
||||
assert.True(t, cfg.API.V2Enabled)
|
||||
})
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
86
pkg/bdd/testserver/state_tracer.go
Normal file
86
pkg/bdd/testserver/state_tracer.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package testserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TraceStateScenarioStart logs the start of a scenario
|
||||
func TraceStateScenarioStart(feature, scenario string) {
|
||||
writeTraceLine(feature, scenario, "SCENARIO_START", "")
|
||||
}
|
||||
|
||||
// TraceStateScenarioEnd logs the end of a scenario
|
||||
func TraceStateScenarioEnd(feature, scenario string, err error) {
|
||||
status := "PASSED"
|
||||
if err != nil {
|
||||
status = fmt.Sprintf("FAILED: %v", err)
|
||||
}
|
||||
writeTraceLine(feature, scenario, "SCENARIO_END", status)
|
||||
}
|
||||
|
||||
// TraceStateDBCleanup logs a database cleanup operation
|
||||
func TraceStateDBCleanup(feature, scenario, table string) {
|
||||
writeTraceLine(feature, scenario, "DB_CLEANUP", table)
|
||||
}
|
||||
|
||||
// TraceStateJWTSecretOperation logs a JWT secret operation
|
||||
func TraceStateJWTSecretOperation(feature, scenario, operation, details string) {
|
||||
writeTraceLine(feature, scenario, "JWT_"+operation, details)
|
||||
}
|
||||
|
||||
// TraceStateSchemaIsolation logs a schema isolation operation
|
||||
func TraceStateSchemaIsolation(feature, scenario, operation, details string) {
|
||||
writeTraceLine(feature, scenario, "SCHEMA_"+operation, details)
|
||||
}
|
||||
|
||||
// TraceStateTransaction logs a transaction boundary
|
||||
func TraceStateTransaction(feature, scenario, action, details string) {
|
||||
writeTraceLine(feature, scenario, "TX_"+action, details)
|
||||
}
|
||||
|
||||
// TraceStateDBRead logs a database read operation
|
||||
func TraceStateDBRead(feature, scenario, table, details string) {
|
||||
writeTraceLine(feature, scenario, "DB_SELECT", fmt.Sprintf("table=%s %s", table, details))
|
||||
}
|
||||
|
||||
// StateTracingEnabled returns true if BDD_TRACE_STATE environment variable is set to "1"
|
||||
func StateTracingEnabled() bool {
|
||||
return os.Getenv("BDD_TRACE_STATE") == "1"
|
||||
}
|
||||
|
||||
// writeTraceLine writes a trace line to the state trace file in $TMPDIR
|
||||
func writeTraceLine(feature, scenario, action, details string) {
|
||||
if !StateTracingEnabled() {
|
||||
return
|
||||
}
|
||||
tmpDir := os.Getenv("TMPDIR")
|
||||
if tmpDir == "" {
|
||||
tmpDir = "/tmp"
|
||||
}
|
||||
timestamp := time.Now().Format("20060102-150405")
|
||||
pid := os.Getpid()
|
||||
filename := fmt.Sprintf("bdd-state-trace-%s-%d.log", timestamp, pid)
|
||||
filePath := filepath.Join(tmpDir, filename)
|
||||
|
||||
line := fmt.Sprintf("%s | %-15s | %-40s | %-16s | %s\n",
|
||||
time.Now().Format("2006-01-02T15:04:05.000000"),
|
||||
feature,
|
||||
scenario,
|
||||
action,
|
||||
details,
|
||||
)
|
||||
|
||||
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err := file.WriteString(line); err != nil {
|
||||
return
|
||||
}
|
||||
file.Sync()
|
||||
}
|
||||
Reference in New Issue
Block a user