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>
544 lines
16 KiB
Rust
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");
|
|
}
|
|
}
|