Files
foxhunt/crates/database/src/test_helpers.rs
jgrusewski 9c3d741a08 refactor: restructure repo — crates/, bin/, testing/ layout
Move 17 library crates into crates/, CLI binary into bin/fxt,
consolidate 10 test crates into testing/, split config crate
from deployment config files.

Root directory reduced from 38+ to ~17 directories.
All Cargo.toml paths and build.rs proto refs updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:56:00 +01:00

544 lines
16 KiB
Rust

//! 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<R>` 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<F, Fut, R>(pool: &PgPool, test_fn: F) -> DatabaseResult<R>
where
F: FnOnce(Transaction<'_, Postgres>) -> Fut,
Fut: Future<Output = DatabaseResult<R>>,
{
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<R>` 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<F, Fut, R>(
pool: &PgPool,
test_name: &str,
test_fn: F,
) -> DatabaseResult<R>
where
F: FnOnce(Transaction<'_, Postgres>) -> Fut,
Fut: Future<Output = DatabaseResult<R>>,
{
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");
}
}