Files
foxhunt/adaptive-strategy/tests/hot_reload_integration.rs
jgrusewski 1b0a122174 Wave 144-145: Test enablement and JWT authentication fix
Wave 144: Enable 112 infrastructure and E2E tests
- Remove #[ignore] from PostgreSQL tests (41 tests)
- Remove #[ignore] from Redis tests (18 tests)
- Remove #[ignore] from Vault tests (11 tests)
- Remove #[ignore] from E2E tests (42 tests: service health, backtesting, trading)
- Fix test_metrics_output (add metrics initialization)
- Create infrastructure health check script

Wave 145: Fix JWT authentication for E2E tests
- Add JWT_SECRET, JWT_ISSUER, JWT_AUDIENCE to Trading Service
- Add JWT_SECRET, JWT_ISSUER, JWT_AUDIENCE to Backtesting Service
- Add JWT_SECRET, JWT_ISSUER, JWT_AUDIENCE to ML Training Service
- Fix auth_helpers.rs hardcoded issuer/audience values
- Migrate E2E tests to TestAuthConfig pattern

Root Cause (Wave 145): Backend services missing JWT environment variables
Solution: Unified JWT configuration across all services
Result: Services healthy, E2E tests need .env sourced for validation

Agents: 311-320 (Wave 144), 331-342 (Wave 145)
Files Modified: 35 (14 modified, 21 created)
Documentation: 21 reports created (1,455+ lines)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 15:37:38 +02:00

915 lines
26 KiB
Rust

//! Hot-Reload Integration Tests for PostgreSQL NOTIFY/LISTEN
//!
//! These tests verify the hot-reload functionality of the adaptive strategy
//! configuration system, including:
//! - PostgreSQL NOTIFY/LISTEN lifecycle
//! - Configuration change propagation
//! - Concurrent update handling
//! - ACID transaction verification
//! - Cache invalidation
//! - Performance characteristics
//!
//! ## Prerequisites
//! - PostgreSQL database running
//! - Migrations 015 and 016 applied
//! - DATABASE_URL environment variable set
#![allow(unused_crate_dependencies)]
//!
//! ## Running Tests
//! ```bash
//! # Run all hot-reload tests
//! cargo test --test hot_reload_integration --features postgres
//!
//! # Run performance tests (normally ignored)
//! cargo test --test hot_reload_integration --features postgres -- --ignored
//! ```
#![cfg(feature = "postgres")]
use adaptive_strategy::database_loader::DatabaseConfigLoader;
use serde_json::json;
use sqlx::{Executor, PgPool, Row};
use std::env;
use std::time::Duration;
use tokio::time::timeout;
// ============================================================================
// TEST HELPERS
// ============================================================================
/// Get database URL from environment or use default test database
fn get_database_url() -> String {
env::var("DATABASE_URL").unwrap_or_else(|_| {
"postgresql://postgres:postgres@localhost:5432/foxhunt_test".to_string()
})
}
/// Create a DatabaseConfigLoader for testing
async fn create_loader() -> DatabaseConfigLoader {
let url = get_database_url();
DatabaseConfigLoader::new(&url)
.await
.expect("Failed to create DatabaseConfigLoader")
}
/// Create a separate connection pool for test operations
async fn create_test_pool() -> PgPool {
let url = get_database_url();
PgPool::connect(&url)
.await
.expect("Failed to create test pool")
}
/// Helper to wait for a notification with timeout
async fn wait_for_notification(
loader: &mut DatabaseConfigLoader,
timeout_secs: u64,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let result = timeout(
Duration::from_secs(timeout_secs),
loader.check_for_updates(),
)
.await??;
Ok(result)
}
/// Cleanup helper to remove test strategy after test completes
async fn cleanup_test_strategy(pool: &PgPool, strategy_id: &str) {
let _ = sqlx::query("DELETE FROM adaptive_strategy_config WHERE strategy_id = $1")
.bind(strategy_id)
.execute(pool)
.await;
}
// ============================================================================
// CATEGORY 1: HOT-RELOAD NOTIFICATION TESTS
// ============================================================================
/// Test 1: Verify basic NOTIFY/LISTEN lifecycle
#[tokio::test]
async fn test_hot_reload_enable_and_listen() {
let mut loader = create_loader().await;
// Enable hot-reload
loader
.enable_hot_reload()
.await
.expect("Failed to enable hot-reload");
// Trigger a change via SQL UPDATE
let pool = create_test_pool().await;
sqlx::query(
"UPDATE adaptive_strategy_config
SET name = 'Updated Test Strategy', updated_at = NOW()
WHERE strategy_id = 'default-production'"
)
.execute(&pool)
.await
.expect("Failed to update config");
// Wait for notification (with timeout to prevent hanging)
let notification = wait_for_notification(&mut loader, 5)
.await
.expect("Timeout waiting for notification");
assert!(
notification.is_some(),
"Expected notification to be received"
);
assert_eq!(
notification.unwrap(),
"default-production",
"Notification should contain correct strategy_id"
);
}
/// Test 2: Verify model changes trigger notifications
#[tokio::test]
async fn test_hot_reload_notification_on_model_update() {
let mut loader = create_loader().await;
loader.enable_hot_reload().await.unwrap();
let pool = create_test_pool().await;
// Get a model to update
let model_id: uuid::Uuid = sqlx::query_scalar(
"SELECT id FROM adaptive_strategy_models
WHERE strategy_config_id = (
SELECT id FROM adaptive_strategy_config
WHERE strategy_id = 'default-production'
) LIMIT 1"
)
.fetch_one(&pool)
.await
.expect("Failed to find model");
// Update the model
sqlx::query(
"UPDATE adaptive_strategy_models
SET initial_weight = 0.35, updated_at = NOW()
WHERE id = $1"
)
.bind(model_id)
.execute(&pool)
.await
.expect("Failed to update model");
// Wait for notification
let notification = wait_for_notification(&mut loader, 5)
.await
.expect("Timeout waiting for notification");
assert!(
notification.is_some(),
"Model update should trigger notification"
);
}
/// Test 3: Verify feature changes trigger notifications
#[tokio::test]
async fn test_hot_reload_notification_on_feature_update() {
let mut loader = create_loader().await;
loader.enable_hot_reload().await.unwrap();
let pool = create_test_pool().await;
// Get a feature to update
let feature_id: uuid::Uuid = sqlx::query_scalar(
"SELECT id FROM adaptive_strategy_features
WHERE strategy_config_id = (
SELECT id FROM adaptive_strategy_config
WHERE strategy_id = 'default-production'
) LIMIT 1"
)
.fetch_one(&pool)
.await
.expect("Failed to find feature");
// Update the feature
sqlx::query(
"UPDATE adaptive_strategy_features
SET enabled = NOT enabled, updated_at = NOW()
WHERE id = $1"
)
.bind(feature_id)
.execute(&pool)
.await
.expect("Failed to update feature");
// Wait for notification
let notification = wait_for_notification(&mut loader, 5)
.await
.expect("Timeout waiting for notification");
assert!(
notification.is_some(),
"Feature update should trigger notification"
);
}
/// Test 4: Verify multiple listeners receive same notification
#[tokio::test]
async fn test_hot_reload_multiple_listeners() {
// Create 3 separate loaders (simulating multiple services)
let mut loader1 = create_loader().await;
let mut loader2 = create_loader().await;
let mut loader3 = create_loader().await;
loader1.enable_hot_reload().await.unwrap();
loader2.enable_hot_reload().await.unwrap();
loader3.enable_hot_reload().await.unwrap();
// Trigger a change
let pool = create_test_pool().await;
sqlx::query(
"UPDATE adaptive_strategy_config
SET max_position_size = 0.08
WHERE strategy_id = 'default-production'"
)
.execute(&pool)
.await
.unwrap();
// All 3 listeners should receive notification
let notif1 = wait_for_notification(&mut loader1, 5).await.unwrap();
let notif2 = wait_for_notification(&mut loader2, 5).await.unwrap();
let notif3 = wait_for_notification(&mut loader3, 5).await.unwrap();
assert!(notif1.is_some(), "Loader 1 should receive notification");
assert!(notif2.is_some(), "Loader 2 should receive notification");
assert!(notif3.is_some(), "Loader 3 should receive notification");
// All should have same payload
assert_eq!(notif1, notif2);
assert_eq!(notif2, notif3);
}
/// Test 5: Verify notification payload format
#[tokio::test]
async fn test_hot_reload_notification_format() {
let mut loader = create_loader().await;
loader.enable_hot_reload().await.unwrap();
let pool = create_test_pool().await;
// Trigger INSERT operation
sqlx::query(
"INSERT INTO adaptive_strategy_config (
strategy_id, name, description
) VALUES ($1, $2, $3)
ON CONFLICT (strategy_id) DO UPDATE SET name = EXCLUDED.name"
)
.bind("test-notification-format")
.bind("Test Notification Format")
.bind("Testing notification payload structure")
.execute(&pool)
.await
.unwrap();
let notification = wait_for_notification(&mut loader, 5)
.await
.unwrap()
.expect("Should receive notification");
assert_eq!(
notification, "test-notification-format",
"Payload should contain strategy_id"
);
// Cleanup
cleanup_test_strategy(&pool, "test-notification-format").await;
}
/// Test 6: Verify listener reconnection after connection loss
#[tokio::test]
async fn test_hot_reload_listener_reconnection() {
let mut loader = create_loader().await;
// Enable hot-reload
loader.enable_hot_reload().await.unwrap();
// Simulate connection loss by re-enabling (creates new listener)
loader.enable_hot_reload().await.unwrap();
// Verify notifications still work
let pool = create_test_pool().await;
sqlx::query(
"UPDATE adaptive_strategy_config
SET description = 'Reconnection test'
WHERE strategy_id = 'default-production'"
)
.execute(&pool)
.await
.unwrap();
let notification = wait_for_notification(&mut loader, 5)
.await
.unwrap();
assert!(
notification.is_some(),
"Notifications should work after reconnection"
);
}
// ============================================================================
// CATEGORY 2: ACID TRANSACTION TESTS
// ============================================================================
/// Test 7: Verify atomic commit across 3 tables
#[tokio::test]
async fn test_atomic_config_update_all_or_nothing() {
let pool = create_test_pool().await;
// Create test strategy
let test_id = "test-atomic-update";
sqlx::query(
"INSERT INTO adaptive_strategy_config (
strategy_id, name
) VALUES ($1, $2)"
)
.bind(test_id)
.bind("Atomic Test Strategy")
.execute(&pool)
.await
.unwrap();
// Get config ID
let config_id: uuid::Uuid = sqlx::query_scalar(
"SELECT id FROM adaptive_strategy_config WHERE strategy_id = $1"
)
.bind(test_id)
.fetch_one(&pool)
.await
.unwrap();
// Start transaction
let mut tx = pool.begin().await.unwrap();
// Update all 3 tables atomically
sqlx::query("UPDATE adaptive_strategy_config SET name = 'Updated' WHERE id = $1")
.bind(config_id)
.execute(&mut *tx)
.await
.unwrap();
sqlx::query(
"INSERT INTO adaptive_strategy_models (
strategy_config_id, model_id, model_name, model_type, parameters, initial_weight
) VALUES ($1, $2, $3, $4, $5, $6)"
)
.bind(config_id)
.bind("test-model")
.bind("Test Model")
.bind("mamba2")
.bind(json!({}))
.bind(0.5)
.execute(&mut *tx)
.await
.unwrap();
sqlx::query(
"INSERT INTO adaptive_strategy_features (
strategy_config_id, feature_name, feature_type, parameters
) VALUES ($1, $2, $3, $4)"
)
.bind(config_id)
.bind("test-feature")
.bind("microstructure")
.bind(json!({}))
.execute(&mut *tx)
.await
.unwrap();
// Commit transaction
tx.commit().await.unwrap();
// Verify all changes persisted
let name: String = sqlx::query_scalar(
"SELECT name FROM adaptive_strategy_config WHERE id = $1"
)
.bind(config_id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(name, "Updated", "Config update should persist");
let model_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM adaptive_strategy_models WHERE strategy_config_id = $1"
)
.bind(config_id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(model_count, 1, "Model should be inserted");
// Cleanup
cleanup_test_strategy(&pool, test_id).await;
}
/// Test 8: Verify rollback on constraint violation
#[tokio::test]
async fn test_transaction_rollback_on_constraint_violation() {
let pool = create_test_pool().await;
// Get original config
let original_name: String = sqlx::query_scalar(
"SELECT name FROM adaptive_strategy_config WHERE strategy_id = 'default-production'"
)
.fetch_one(&pool)
.await
.unwrap();
// Start transaction
let mut tx = pool.begin().await.unwrap();
// Update config
sqlx::query(
"UPDATE adaptive_strategy_config SET name = 'Will be rolled back'
WHERE strategy_id = 'default-production'"
)
.execute(&mut *tx)
.await
.unwrap();
// Attempt to insert duplicate strategy_id (UNIQUE constraint violation)
let result = sqlx::query(
"INSERT INTO adaptive_strategy_config (strategy_id, name)
VALUES ('default-production', 'Duplicate')"
)
.execute(&mut *tx)
.await;
assert!(result.is_err(), "Should fail on UNIQUE constraint");
// Transaction will auto-rollback when tx is dropped
drop(tx);
// Verify original config unchanged
let current_name: String = sqlx::query_scalar(
"SELECT name FROM adaptive_strategy_config WHERE strategy_id = 'default-production'"
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
current_name, original_name,
"Config should be unchanged after rollback"
);
}
/// Test 9: Verify transaction isolation
#[tokio::test]
async fn test_transaction_isolation_read_committed() {
let pool1 = create_test_pool().await;
let pool2 = create_test_pool().await;
// Start transaction T1
let mut tx1 = pool1.begin().await.unwrap();
// T1: Read original value
let original: f64 = sqlx::query_scalar(
"SELECT max_position_size FROM adaptive_strategy_config
WHERE strategy_id = 'default-production'"
)
.fetch_one(&mut *tx1)
.await
.unwrap();
// T1: Update value
sqlx::query(
"UPDATE adaptive_strategy_config SET max_position_size = 0.20
WHERE strategy_id = 'default-production'"
)
.execute(&mut *tx1)
.await
.unwrap();
// T2: Read value BEFORE T1 commits (should see original due to READ COMMITTED)
let uncommitted_read: f64 = sqlx::query_scalar(
"SELECT max_position_size FROM adaptive_strategy_config
WHERE strategy_id = 'default-production'"
)
.fetch_one(&pool2)
.await
.unwrap();
assert_eq!(
uncommitted_read, original,
"Should see original value before T1 commits"
);
// T1: Commit
tx1.commit().await.unwrap();
// T2: Read value AFTER T1 commits (should see updated value)
let committed_read: f64 = sqlx::query_scalar(
"SELECT max_position_size FROM adaptive_strategy_config
WHERE strategy_id = 'default-production'"
)
.fetch_one(&pool2)
.await
.unwrap();
assert_eq!(
committed_read, 0.20,
"Should see updated value after T1 commits"
);
// Restore original value
sqlx::query(
"UPDATE adaptive_strategy_config SET max_position_size = $1
WHERE strategy_id = 'default-production'"
)
.bind(original)
.execute(&pool1)
.await
.unwrap();
}
/// Test 10: Verify validation prevents invalid configs
#[tokio::test]
async fn test_transaction_consistency_validation() {
let loader = create_loader().await;
// Attempt to load a config with invalid data (would fail validation)
// This tests that validation happens AFTER database load
let pool = create_test_pool().await;
// Create invalid config
let test_id = "test-invalid-config";
let result = sqlx::query(
"INSERT INTO adaptive_strategy_config (
strategy_id, name, max_position_size
) VALUES ($1, $2, $3)"
)
.bind(test_id)
.bind("Invalid Config")
.bind(1.5) // Invalid: max_position_size > 1.0
.execute(&pool)
.await;
// Insert should succeed (DB doesn't validate business rules)
assert!(result.is_ok(), "DB insert should succeed");
// But load_config should fail validation
let config_result = loader.load_config(test_id).await;
assert!(
config_result.is_err(),
"load_config should fail validation for invalid config"
);
// Cleanup
cleanup_test_strategy(&pool, test_id).await;
}
/// Test 11: Verify durability after commit
#[tokio::test]
async fn test_transaction_durability_after_commit() {
let pool1 = create_test_pool().await;
let test_id = "test-durability";
// Create and commit config
{
let mut tx = pool1.begin().await.unwrap();
sqlx::query(
"INSERT INTO adaptive_strategy_config (strategy_id, name)
VALUES ($1, $2)"
)
.bind(test_id)
.bind("Durability Test")
.execute(&mut *tx)
.await
.unwrap();
tx.commit().await.unwrap();
} // tx and pool1 dropped here
// Create new pool (simulating application restart)
let pool2 = create_test_pool().await;
// Verify config persisted
let name: Option<String> = sqlx::query_scalar(
"SELECT name FROM adaptive_strategy_config WHERE strategy_id = $1"
)
.bind(test_id)
.fetch_optional(&pool2)
.await
.unwrap();
assert_eq!(
name,
Some("Durability Test".to_string()),
"Committed changes should persist after connection close"
);
// Cleanup
cleanup_test_strategy(&pool2, test_id).await;
}
// ============================================================================
// CATEGORY 3: CONCURRENT UPDATE TESTS
// ============================================================================
/// Test 15: Verify concurrent updates with version checking
#[tokio::test]
async fn test_concurrent_updates_with_version() {
let pool = create_test_pool().await;
let test_id = "test-concurrent-version";
// Create test config with version=1
sqlx::query(
"INSERT INTO adaptive_strategy_config (strategy_id, name, version)
VALUES ($1, $2, 1)"
)
.bind(test_id)
.bind("Concurrent Test")
.execute(&pool)
.await
.unwrap();
// Simulate optimistic locking: two tasks try to update based on version=1
let pool1 = pool.clone();
let pool2 = pool.clone();
let id1 = test_id.to_string();
let id2 = test_id.to_string();
let task1 = tokio::spawn(async move {
sqlx::query(
"UPDATE adaptive_strategy_config
SET name = 'Task 1 Update', version = version + 1
WHERE strategy_id = $1 AND version = 1"
)
.bind(&id1)
.execute(&pool1)
.await
});
let task2 = tokio::spawn(async move {
// Small delay to ensure task1 goes first
tokio::time::sleep(Duration::from_millis(10)).await;
sqlx::query(
"UPDATE adaptive_strategy_config
SET name = 'Task 2 Update', version = version + 1
WHERE strategy_id = $1 AND version = 1"
)
.bind(&id2)
.execute(&pool2)
.await
});
let result1 = task1.await.unwrap().unwrap();
let result2 = task2.await.unwrap().unwrap();
// One should succeed (rows_affected=1), one should fail (rows_affected=0)
assert_eq!(
result1.rows_affected() + result2.rows_affected(),
1,
"Only one update should succeed"
);
// Verify final version is 2
let version: i32 = sqlx::query_scalar(
"SELECT version FROM adaptive_strategy_config WHERE strategy_id = $1"
)
.bind(test_id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(version, 2, "Version should be incremented once");
// Cleanup
cleanup_test_strategy(&pool, test_id).await;
}
// ============================================================================
// CATEGORY 4: PERFORMANCE TESTS (IGNORED BY DEFAULT)
// ============================================================================
/// Test 18: Measure config loading latency
#[tokio::test]
async fn test_config_load_latency_benchmark() {
let loader = create_loader().await;
let mut latencies = Vec::new();
// Load config 100 times
for _ in 0..100 {
let start = std::time::Instant::now();
let _ = loader.load_config("default-production").await.unwrap();
let elapsed = start.elapsed();
latencies.push(elapsed);
}
// Calculate percentiles
latencies.sort();
let p50 = latencies[50];
let p95 = latencies[95];
let p99 = latencies[99];
println!("Config load latency:");
println!(" p50: {:?}", p50);
println!(" p95: {:?}", p95);
println!(" p99: {:?}", p99);
// Assert p99 < 100ms (reasonable for database query)
assert!(
p99 < Duration::from_millis(100),
"p99 latency should be < 100ms, got {:?}",
p99
);
}
/// Test 20: Measure notification propagation delay
#[tokio::test]
async fn test_notification_propagation_delay() {
let mut loader = create_loader().await;
loader.enable_hot_reload().await.unwrap();
let pool = create_test_pool().await;
// Measure 10 notification propagation delays
let mut delays = Vec::new();
for i in 0..10 {
let start = std::time::Instant::now();
// Trigger update
sqlx::query(
"UPDATE adaptive_strategy_config
SET description = $1
WHERE strategy_id = 'default-production'"
)
.bind(format!("Delay test {}", i))
.execute(&pool)
.await
.unwrap();
// Wait for notification
let _ = wait_for_notification(&mut loader, 5).await.unwrap();
let elapsed = start.elapsed();
delays.push(elapsed);
}
// Calculate average delay
let avg_delay: Duration = delays.iter().sum::<Duration>() / delays.len() as u32;
println!("Notification propagation delay: {:?}", avg_delay);
// Assert average delay < 100ms
assert!(
avg_delay < Duration::from_millis(100),
"Average notification delay should be < 100ms, got {:?}",
avg_delay
);
}
// ============================================================================
// CATEGORY 5: EDGE CASES & FAILURE MODES
// ============================================================================
/// Test 22: Handle malformed notification payload gracefully
#[tokio::test]
async fn test_malformed_notification_payload() {
let mut loader = create_loader().await;
loader.enable_hot_reload().await.unwrap();
let pool = create_test_pool().await;
// Send malformed notification directly
sqlx::query("SELECT pg_notify('adaptive_strategy_config_change', 'invalid-json')")
.execute(&pool)
.await
.unwrap();
// check_for_updates should handle gracefully (return None or skip)
let result = wait_for_notification(&mut loader, 2).await;
// Should not panic, either returns None or times out
assert!(result.is_ok(), "Should handle malformed payload gracefully");
}
/// Test 24: Verify config persists during service restart simulation
#[tokio::test]
async fn test_config_update_during_service_restart() {
let pool = create_test_pool().await;
let test_id = "test-service-restart";
// Create config
sqlx::query(
"INSERT INTO adaptive_strategy_config (strategy_id, name)
VALUES ($1, $2)"
)
.bind(test_id)
.bind("Original Name")
.execute(&pool)
.await
.unwrap();
// Update config
sqlx::query(
"UPDATE adaptive_strategy_config SET name = 'Updated Name'
WHERE strategy_id = $1"
)
.bind(test_id)
.execute(&pool)
.await
.unwrap();
// Immediately close and create new loader (simulates restart)
drop(pool);
let new_loader = create_loader().await;
let config = new_loader.load_config(test_id).await.unwrap().unwrap();
assert_eq!(
config.name, "Updated Name",
"Config should reflect latest update after restart"
);
// Cleanup
let pool = create_test_pool().await;
cleanup_test_strategy(&pool, test_id).await;
}
/// Test 25: Verify partial transaction failure causes complete rollback
#[tokio::test]
async fn test_partial_transaction_failure() {
let pool = create_test_pool().await;
let test_id = "test-partial-failure";
// Create test config
sqlx::query(
"INSERT INTO adaptive_strategy_config (strategy_id, name)
VALUES ($1, $2)"
)
.bind(test_id)
.bind("Partial Failure Test")
.execute(&pool)
.await
.unwrap();
let config_id: uuid::Uuid = sqlx::query_scalar(
"SELECT id FROM adaptive_strategy_config WHERE strategy_id = $1"
)
.bind(test_id)
.fetch_one(&pool)
.await
.unwrap();
// Start transaction
let mut tx = pool.begin().await.unwrap();
// Step 1: Update config (succeeds)
sqlx::query("UPDATE adaptive_strategy_config SET name = 'Updated' WHERE id = $1")
.bind(config_id)
.execute(&mut *tx)
.await
.unwrap();
// Step 2: Insert model with invalid foreign key (fails)
let invalid_uuid = uuid::Uuid::new_v4();
let result = sqlx::query(
"INSERT INTO adaptive_strategy_models (
strategy_config_id, model_id, model_name, model_type, parameters, initial_weight
) VALUES ($1, $2, $3, $4, $5, $6)"
)
.bind(invalid_uuid) // Invalid foreign key
.bind("test-model")
.bind("Test Model")
.bind("mamba2")
.bind(json!({}))
.bind(0.5)
.execute(&mut *tx)
.await;
assert!(result.is_err(), "Should fail on foreign key constraint");
// Transaction auto-rolls back
drop(tx);
// Verify config unchanged
let name: String = sqlx::query_scalar(
"SELECT name FROM adaptive_strategy_config WHERE id = $1"
)
.bind(config_id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
name, "Partial Failure Test",
"Config should be unchanged after transaction rollback"
);
// Cleanup
cleanup_test_strategy(&pool, test_id).await;
}