//! Test helpers for database isolation //! //! This module provides test isolation utilities that ensure each test runs in its own //! database transaction that is automatically rolled back after test completion. //! //! # Transaction Rollback Pattern //! //! The transaction rollback pattern provides: //! - Automatic cleanup: All changes are rolled back after test completion //! - Fast execution: <1ms overhead per test //! - Parallel safety: Tests can run concurrently without conflicts //! - Simple API: Single helper function replaces manual setup/teardown //! //! # Usage //! //! ```rust,no_run //! use database::test_helpers::with_transaction; //! use database::DatabasePool; //! //! #[tokio::test] //! async fn test_my_feature() { //! let pool = get_test_pool().await; //! //! with_transaction(&pool, |tx| async move { //! // All test operations use tx instead of pool //! sqlx::query("INSERT INTO users (name) VALUES ('test')") //! .execute(&mut *tx) //! .await?; //! //! let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users") //! .fetch_one(&mut *tx) //! .await?; //! //! assert_eq!(count, 1); //! Ok(()) //! }).await.unwrap(); //! //! // Transaction automatically rolled back here //! } //! ``` //! //! # Design Rationale //! //! This implementation follows Phase 2 of the Test Isolation Framework Design: //! - Single-connection pattern (no multi-connection support yet) //! - No DDL statement support (use schema-per-test for DDL) //! - Automatic rollback on panic or error //! - Minimal test code changes required //! //! See `/home/jgrusewski/Work/foxhunt/TEST_ISOLATION_FRAMEWORK_DESIGN.md` for full design. use crate::{DatabaseError, DatabaseResult}; use sqlx::{PgPool, Postgres, Transaction}; use std::future::Future; use tracing::{debug, info, warn}; /// Execute a test closure within an isolated database transaction /// /// This function provides automatic transaction isolation for database tests: /// - Begins a new transaction before executing the closure /// - Automatically rolls back the transaction after execution (success or failure) /// - Ensures no test data persists after test completion /// - Enables safe parallel test execution /// /// # Type Parameters /// /// - `F`: The test closure that receives a mutable transaction reference /// - `Fut`: The future returned by the closure /// - `R`: The return type of the test (typically `()`) /// /// # Arguments /// /// - `pool`: Database connection pool (shared across tests) /// - `test_fn`: Async closure that performs test operations using the transaction /// /// # Returns /// /// Returns `DatabaseResult` containing either: /// - `Ok(R)`: Test completed successfully, transaction rolled back /// - `Err(DatabaseError)`: Test failed, transaction rolled back /// /// # Errors /// /// This function will return an error if: /// - Transaction creation fails (database connectivity issues) /// - The test closure returns an error /// - Rollback fails (rare, usually indicates database crash) /// /// # Examples /// /// ## Basic Usage /// /// ```rust,no_run /// use database::test_helpers::with_transaction; /// use sqlx::PgPool; /// /// #[tokio::test] /// async fn test_insert_and_query() { /// let pool = PgPool::connect("postgresql://...").await.unwrap(); /// /// with_transaction(&pool, |tx| async move { /// // Insert test data /// sqlx::query("INSERT INTO orders (symbol, quantity) VALUES ($1, $2)") /// .bind("ES.FUT") /// .bind(10) /// .execute(&mut *tx) /// .await?; /// /// // Query inserted data /// let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM orders") /// .fetch_one(&mut *tx) /// .await?; /// /// assert_eq!(count, 1); /// Ok(()) /// }).await.unwrap(); /// } /// ``` /// /// ## Testing Error Handling /// /// ```rust,no_run /// use database::test_helpers::with_transaction; /// use database::DatabaseError; /// use sqlx::PgPool; /// /// #[tokio::test] /// async fn test_constraint_violation() { /// let pool = PgPool::connect("postgresql://...").await.unwrap(); /// /// let result = with_transaction(&pool, |tx| async move { /// // This should fail with constraint violation /// sqlx::query("INSERT INTO users (id, name) VALUES (1, 'test')") /// .execute(&mut *tx) /// .await?; /// /// sqlx::query("INSERT INTO users (id, name) VALUES (1, 'duplicate')") /// .execute(&mut *tx) /// .await?; /// /// Ok(()) /// }).await; /// /// assert!(result.is_err(), "Should fail with constraint violation"); /// } /// ``` /// /// ## Multiple Operations /// /// ```rust,no_run /// use database::test_helpers::with_transaction; /// use sqlx::PgPool; /// /// #[tokio::test] /// async fn test_multi_table_operations() { /// let pool = PgPool::connect("postgresql://...").await.unwrap(); /// /// with_transaction(&pool, |tx| async move { /// // Insert into multiple tables /// sqlx::query("INSERT INTO users (name) VALUES ('Alice')") /// .execute(&mut *tx) /// .await?; /// /// let user_id: i32 = sqlx::query_scalar( /// "SELECT id FROM users WHERE name = 'Alice'" /// ) /// .fetch_one(&mut *tx) /// .await?; /// /// sqlx::query("INSERT INTO orders (user_id, symbol) VALUES ($1, $2)") /// .bind(user_id) /// .bind("NQ.FUT") /// .execute(&mut *tx) /// .await?; /// /// let order_count: i64 = sqlx::query_scalar( /// "SELECT COUNT(*) FROM orders WHERE user_id = $1" /// ) /// .bind(user_id) /// .fetch_one(&mut *tx) /// .await?; /// /// assert_eq!(order_count, 1); /// Ok(()) /// }).await.unwrap(); /// } /// ``` /// /// # Performance /// /// Transaction rollback overhead is typically <1ms per test: /// - Transaction begin: ~100μs /// - Test execution: (varies) /// - Transaction rollback: ~100μs /// /// This is significantly faster than: /// - Manual cleanup: ~5-10ms (DELETE queries) /// - Database recreation: ~50-100ms /// - Schema-per-test: ~30-40ms /// /// # Limitations /// /// This pattern has some limitations inherited from PostgreSQL transactions: /// /// 1. **Single Connection Only**: All operations must use the provided transaction. /// Multi-connection tests require schema-per-test isolation. /// /// 2. **No DDL Statements**: CREATE/ALTER/DROP statements may not work as expected /// within transactions. Use schema-per-test for DDL testing. /// /// 3. **No Transaction Testing**: Cannot test transaction logic itself (nested /// transactions, savepoints) using this pattern. /// /// 4. **Sequence Values**: Auto-increment sequences may skip values between tests /// (rolled back inserts still consume sequence numbers). /// /// For tests with these requirements, use schema-per-test or container isolation /// (to be implemented in Phase 3-5). /// /// # Thread Safety /// /// This function is safe to call from multiple concurrent tests. Each test gets /// its own isolated transaction, preventing cross-test interference. pub async fn with_transaction(pool: &PgPool, test_fn: F) -> DatabaseResult where F: FnOnce(Transaction<'_, Postgres>) -> Fut, Fut: Future>, { debug!("Beginning test transaction"); // Begin transaction let tx = pool.begin().await.map_err(|e| { warn!("Failed to begin test transaction: {}", e); DatabaseError::Transaction { message: format!("Failed to begin test transaction: {}", e), } })?; // Execute test closure let result = test_fn(tx).await; // Handle result and rollback match result { Ok(value) => { debug!("Test completed successfully, rolling back transaction"); // Transaction is automatically rolled back when dropped // (we don't call commit, so it rolls back) Ok(value) } Err(e) => { debug!("Test failed with error, rolling back transaction: {}", e); // Transaction is automatically rolled back when dropped Err(e) } } } /// Get a test database pool from environment variables /// /// This helper creates a connection pool suitable for testing by reading /// the DATABASE_URL environment variable. /// /// # Environment Variables /// /// - `DATABASE_URL`: PostgreSQL connection string (required) /// /// # Returns /// /// Returns a configured `PgPool` ready for use in tests. /// /// # Panics /// /// Panics if: /// - `DATABASE_URL` is not set /// - Connection to database fails /// - Pool creation fails /// /// # Examples /// /// ```rust,no_run /// use database::test_helpers::{get_test_pool, with_transaction}; /// /// #[tokio::test] /// async fn test_example() { /// let pool = get_test_pool().await; /// /// with_transaction(&pool, |tx| async move { /// // Test operations here /// Ok(()) /// }).await.unwrap(); /// } /// ``` pub async fn get_test_pool() -> PgPool { let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| { "postgresql://foxhunt:foxhunt_dev_password@localhost:5432/foxhunt".to_string() }); info!("Creating test database pool with URL: {}", database_url); PgPool::connect(&database_url) .await .expect("Failed to connect to test database") } /// Execute a test closure with automatic transaction rollback and error logging /// /// This is a convenience wrapper around `with_transaction` that provides /// additional error context for debugging test failures. /// /// # Arguments /// /// - `pool`: Database connection pool /// - `test_name`: Name of the test (for logging purposes) /// - `test_fn`: Async closure that performs test operations /// /// # Returns /// /// Returns `DatabaseResult` with enhanced error messages. /// /// # Examples /// /// ```rust,no_run /// use database::test_helpers::with_transaction_logged; /// use sqlx::PgPool; /// /// #[tokio::test] /// async fn test_with_logging() { /// let pool = PgPool::connect("postgresql://...").await.unwrap(); /// /// with_transaction_logged(&pool, "test_with_logging", |tx| async move { /// // Test operations /// Ok(()) /// }).await.unwrap(); /// } /// ``` pub async fn with_transaction_logged( pool: &PgPool, test_name: &str, test_fn: F, ) -> DatabaseResult where F: FnOnce(Transaction<'_, Postgres>) -> Fut, Fut: Future>, { info!("Starting test: {}", test_name); let start = std::time::Instant::now(); let result = with_transaction(pool, test_fn).await; let elapsed = start.elapsed(); match &result { Ok(_) => { info!( "Test '{}' completed successfully in {:?}", test_name, elapsed ); } Err(e) => { warn!("Test '{}' failed after {:?}: {}", test_name, elapsed, e); } } result } #[cfg(test)] mod tests { use super::*; /// Helper to get test pool for internal tests async fn setup_test_pool() -> PgPool { get_test_pool().await } #[tokio::test] async fn test_with_transaction_basic() { let pool = setup_test_pool().await; // Create a temp table for testing sqlx::query("CREATE TEMP TABLE test_basic (id SERIAL PRIMARY KEY, value TEXT)") .execute(&pool) .await .unwrap(); // Test transaction rollback with_transaction(&pool, |mut tx| async move { sqlx::query("INSERT INTO test_basic (value) VALUES ('test')") .execute(&mut *tx) .await?; let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM test_basic") .fetch_one(&mut *tx) .await?; assert_eq!(count, 1); Ok(()) }) .await .unwrap(); // Verify rollback - table should be empty let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM test_basic") .fetch_one(&pool) .await .unwrap(); assert_eq!(count, 0, "Transaction should have been rolled back"); } #[tokio::test] async fn test_with_transaction_error_rollback() { let pool = setup_test_pool().await; // Create a temp table for testing sqlx::query("CREATE TEMP TABLE test_error (id SERIAL PRIMARY KEY, value TEXT)") .execute(&pool) .await .unwrap(); // Test that errors trigger rollback let result = with_transaction(&pool, |mut tx| async move { sqlx::query("INSERT INTO test_error (value) VALUES ('before_error')") .execute(&mut *tx) .await?; // Simulate an error Err(DatabaseError::Validation { field: "test".to_string(), message: "Simulated error".to_string(), }) }) .await; assert!(result.is_err(), "Test should have failed"); // Verify rollback - table should be empty let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM test_error") .fetch_one(&pool) .await .unwrap(); assert_eq!(count, 0, "Failed transaction should have been rolled back"); } #[tokio::test] async fn test_with_transaction_multiple_operations() { let pool = setup_test_pool().await; // Create temp tables sqlx::query("CREATE TEMP TABLE test_users (id SERIAL PRIMARY KEY, name TEXT)") .execute(&pool) .await .unwrap(); sqlx::query("CREATE TEMP TABLE test_orders (id SERIAL PRIMARY KEY, user_id INT, symbol TEXT)") .execute(&pool) .await .unwrap(); // Test multiple operations with_transaction(&pool, |mut tx| async move { sqlx::query("INSERT INTO test_users (name) VALUES ('Alice')") .execute(&mut *tx) .await?; let user_id: i32 = sqlx::query_scalar("SELECT id FROM test_users WHERE name = 'Alice'") .fetch_one(&mut *tx) .await?; sqlx::query("INSERT INTO test_orders (user_id, symbol) VALUES ($1, $2)") .bind(user_id) .bind("ES.FUT") .execute(&mut *tx) .await?; let order_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM test_orders WHERE user_id = $1") .bind(user_id) .fetch_one(&mut *tx) .await?; assert_eq!(order_count, 1); Ok(()) }) .await .unwrap(); // Verify both tables are empty after rollback let user_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM test_users") .fetch_one(&pool) .await .unwrap(); let order_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM test_orders") .fetch_one(&pool) .await .unwrap(); assert_eq!(user_count, 0, "Users should be rolled back"); assert_eq!(order_count, 0, "Orders should be rolled back"); } #[tokio::test] async fn test_get_test_pool() { let pool = get_test_pool().await; // Verify pool is usable let result: i32 = sqlx::query_scalar("SELECT 1") .fetch_one(&pool) .await .unwrap(); assert_eq!(result, 1); } #[tokio::test] async fn test_with_transaction_logged() { let pool = setup_test_pool().await; // Create temp table sqlx::query("CREATE TEMP TABLE test_logged (id SERIAL PRIMARY KEY, value INT)") .execute(&pool) .await .unwrap(); with_transaction_logged(&pool, "test_logged_transaction", |mut tx| async move { sqlx::query("INSERT INTO test_logged (value) VALUES (42)") .execute(&mut *tx) .await?; Ok(()) }) .await .unwrap(); // Verify rollback let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM test_logged") .fetch_one(&pool) .await .unwrap(); assert_eq!(count, 0, "Transaction should have been rolled back"); } }