//! Database Integration Tests //! //! Tests migration 046 schema validation, foreign key cascades, triggers, and indexes //! //! **Test Coverage:** //! - Migration 046 schema validation //! - Parent-child foreign key cascade //! - Trigger-based batch aggregation //! - Concurrent transaction isolation //! - Index performance validation (<10ms queries) use std::time::Instant; use sqlx::PgPool; use std::time::Duration; use uuid::Uuid; /// Create test database pool async fn create_test_pool() -> PgPool { let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| { "postgresql://foxhunt:foxhunt_dev_password@localhost:5432/foxhunt".to_string() }); PgPool::connect(&database_url) .await .expect("Failed to connect to database") } /// Helper to create a test batch job async fn create_test_batch(pool: &PgPool, name: &str) -> Uuid { let id = Uuid::new_v4(); sqlx::query( "INSERT INTO batch_jobs (id, name, description, status, config_json) VALUES ($1, $2, $3, 'Pending', '{}'::jsonb)" ) .bind(id) .bind(name) .bind("Test batch job") .execute(pool) .await .expect("Failed to create batch job"); id } /// Helper to create a test child job async fn create_test_child(pool: &PgPool, batch_id: Uuid, model_type: &str) -> Uuid { let id = Uuid::new_v4(); sqlx::query( "INSERT INTO child_jobs (id, batch_id, model_type, status, total_epochs, config_json) VALUES ($1, $2, $3, 'Pending', 10, '{}'::jsonb)" ) .bind(id) .bind(batch_id) .bind(model_type) .execute(pool) .await .expect("Failed to create child job"); id } /// Clean up test data async fn cleanup_test_data(pool: &PgPool, batch_id: Uuid) { // Delete batch (cascade should handle children) sqlx::query("DELETE FROM batch_jobs WHERE id = $1") .bind(batch_id) .execute(pool) .await .expect("Failed to cleanup batch"); } // ============================================================================ // Test 1: Migration 046 schema validation // ============================================================================ #[tokio::test] async fn test_migration_046_schema() { let pool = create_test_pool().await; // Verify batch_jobs table exists let batch_table_exists: bool = sqlx::query_scalar( "SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_name = 'batch_jobs' )" ) .fetch_one(&pool) .await .expect("Failed to check batch_jobs table"); assert!(batch_table_exists, "batch_jobs table does not exist"); println!("✅ batch_jobs table exists"); // Verify child_jobs table exists let child_table_exists: bool = sqlx::query_scalar( "SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_name = 'child_jobs' )" ) .fetch_one(&pool) .await .expect("Failed to check child_jobs table"); assert!(child_table_exists, "child_jobs table does not exist"); println!("✅ child_jobs table exists"); // Verify batch_jobs columns let batch_columns: Vec = sqlx::query_scalar( "SELECT column_name FROM information_schema.columns WHERE table_name = 'batch_jobs' ORDER BY column_name" ) .fetch_all(&pool) .await .expect("Failed to fetch batch_jobs columns"); let expected_batch_columns = vec![ "completed_at", "completed_jobs", "config_json", "created_at", "description", "failed_jobs", "id", "name", "overall_progress", "pending_jobs", "running_jobs", "started_at", "status", "total_jobs" ]; for col in &expected_batch_columns { assert!( batch_columns.contains(&col.to_string()), "Missing batch_jobs column: {}", col ); } println!("✅ All batch_jobs columns present: {}", batch_columns.len()); // Verify child_jobs columns let child_columns: Vec = sqlx::query_scalar( "SELECT column_name FROM information_schema.columns WHERE table_name = 'child_jobs' ORDER BY column_name" ) .fetch_all(&pool) .await .expect("Failed to fetch child_jobs columns"); let expected_child_columns = vec![ "batch_id", "completed_at", "config_json", "created_at", "current_epoch", "error_message", "id", "model_type", "model_weight", "progress_pct", "started_at", "status", "total_epochs", "updated_at" ]; for col in &expected_child_columns { assert!( child_columns.contains(&col.to_string()), "Missing child_jobs column: {}", col ); } println!("✅ All child_jobs columns present: {}", child_columns.len()); // Verify constraints let constraints: Vec = sqlx::query_scalar( "SELECT constraint_name FROM information_schema.table_constraints WHERE table_name IN ('batch_jobs', 'child_jobs') AND constraint_type = 'CHECK'" ) .fetch_all(&pool) .await .expect("Failed to fetch constraints"); assert!(constraints.len() >= 5, "Expected at least 5 CHECK constraints"); println!("✅ CHECK constraints validated: {}", constraints.len()); pool.close().await; } // ============================================================================ // Test 2: Parent-child foreign key cascade // ============================================================================ #[tokio::test] async fn test_foreign_key_cascade() { let pool = create_test_pool().await; // Create a batch with children let batch_id = create_test_batch(&pool, "cascade_test").await; let child1_id = create_test_child(&pool, batch_id, "DQN").await; let child2_id = create_test_child(&pool, batch_id, "PPO").await; println!("✅ Created batch {} with 2 children", batch_id); // Verify children exist let child_count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM child_jobs WHERE batch_id = $1" ) .bind(batch_id) .fetch_one(&pool) .await .expect("Failed to count children"); assert_eq!(child_count, 2); println!("✅ Verified 2 children exist"); // Delete parent (should cascade to children) sqlx::query("DELETE FROM batch_jobs WHERE id = $1") .bind(batch_id) .execute(&pool) .await .expect("Failed to delete batch"); println!("✅ Deleted parent batch"); // Verify children are gone (cascade) let remaining_children: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM child_jobs WHERE batch_id = $1" ) .bind(batch_id) .fetch_one(&pool) .await .expect("Failed to count remaining children"); assert_eq!(remaining_children, 0); println!("✅ CASCADE delete validated: children removed"); // Verify specific children don't exist let child1_exists: bool = sqlx::query_scalar( "SELECT EXISTS (SELECT 1 FROM child_jobs WHERE id = $1)" ) .bind(child1_id) .fetch_one(&pool) .await .expect("Failed to check child1"); let child2_exists: bool = sqlx::query_scalar( "SELECT EXISTS (SELECT 1 FROM child_jobs WHERE id = $1)" ) .bind(child2_id) .fetch_one(&pool) .await .expect("Failed to check child2"); assert!(!child1_exists && !child2_exists); println!("✅ Both children cascade-deleted successfully"); pool.close().await; } // ============================================================================ // Test 3: Trigger-based batch aggregation // ============================================================================ #[tokio::test] async fn test_trigger_batch_aggregation() { let pool = create_test_pool().await; // Create batch let batch_id = create_test_batch(&pool, "aggregation_test").await; // Create 4 children (DQN, PPO, MAMBA-2, TFT) let models = vec!["DQN", "PPO", "MAMBA-2", "TFT"]; for model in &models { create_test_child(&pool, batch_id, model).await; } println!("✅ Created batch with 4 children"); // Verify batch aggregates (trigger should update) let batch: (i32, i32, i32, i32, i32, f64) = sqlx::query_as( "SELECT total_jobs, pending_jobs, running_jobs, completed_jobs, failed_jobs, overall_progress FROM batch_jobs WHERE id = $1" ) .bind(batch_id) .fetch_one(&pool) .await .expect("Failed to fetch batch"); assert_eq!(batch.0, 4); // total_jobs assert_eq!(batch.1, 4); // pending_jobs assert_eq!(batch.2, 0); // running_jobs assert_eq!(batch.3, 0); // completed_jobs assert_eq!(batch.4, 0); // failed_jobs println!("✅ Initial aggregates: total=4, pending=4"); // Update one child to Running sqlx::query( "UPDATE child_jobs SET status = 'Running', progress_pct = 50.0 WHERE batch_id = $1 AND model_type = 'DQN'" ) .bind(batch_id) .execute(&pool) .await .expect("Failed to update child"); // Verify trigger updated batch let batch_after: (String, i32, i32, f64) = sqlx::query_as( "SELECT status, pending_jobs, running_jobs, overall_progress FROM batch_jobs WHERE id = $1" ) .bind(batch_id) .fetch_one(&pool) .await .expect("Failed to fetch batch"); assert_eq!(batch_after.0, "Running"); // status assert_eq!(batch_after.1, 3); // pending_jobs (3 left) assert_eq!(batch_after.2, 1); // running_jobs (1 running) assert!(batch_after.3 > 0.0); // overall_progress > 0 println!("✅ Trigger updated batch: status=Running, progress={:.1}%", batch_after.3); // Complete all children sqlx::query( "UPDATE child_jobs SET status = 'Completed', progress_pct = 100.0 WHERE batch_id = $1" ) .bind(batch_id) .execute(&pool) .await .expect("Failed to complete children"); // Verify batch is Completed let final_batch: (String, i32, f64) = sqlx::query_as( "SELECT status, completed_jobs, overall_progress FROM batch_jobs WHERE id = $1" ) .bind(batch_id) .fetch_one(&pool) .await .expect("Failed to fetch final batch"); assert_eq!(final_batch.0, "Completed"); assert_eq!(final_batch.1, 4); // all completed assert_eq!(final_batch.2, 100.0); // 100% progress println!("✅ Batch completion detected: status=Completed, progress=100.0%"); cleanup_test_data(&pool, batch_id).await; pool.close().await; } // ============================================================================ // Test 4: Concurrent transaction isolation // ============================================================================ #[tokio::test] async fn test_concurrent_transactions() { let pool = create_test_pool().await; // Create batch let batch_id = create_test_batch(&pool, "concurrent_test").await; // Create 2 children let child1_id = create_test_child(&pool, batch_id, "DQN").await; let child2_id = create_test_child(&pool, batch_id, "PPO").await; println!("✅ Created batch with 2 children"); // Spawn concurrent updates let pool1 = pool.clone(); let pool2 = pool.clone(); let handle1 = tokio::spawn(async move { // Transaction 1: Update child1 let mut tx = pool1.begin().await.expect("Failed to begin tx1"); sqlx::query( "UPDATE child_jobs SET status = 'Running', progress_pct = 25.0 WHERE id = $1" ) .bind(child1_id) .execute(&mut *tx) .await .expect("Failed to update child1"); // Small delay to test isolation tokio::time::sleep(Duration::from_millis(10)).await; tx.commit().await.expect("Failed to commit tx1"); }); let handle2 = tokio::spawn(async move { // Transaction 2: Update child2 let mut tx = pool2.begin().await.expect("Failed to begin tx2"); sqlx::query( "UPDATE child_jobs SET status = 'Running', progress_pct = 75.0 WHERE id = $1" ) .bind(child2_id) .execute(&mut *tx) .await .expect("Failed to update child2"); // Small delay to test isolation tokio::time::sleep(Duration::from_millis(10)).await; tx.commit().await.expect("Failed to commit tx2"); }); // Wait for both transactions handle1.await.expect("Transaction 1 panicked"); handle2.await.expect("Transaction 2 panicked"); println!("✅ Concurrent transactions completed"); // Verify both updates succeeded let children: Vec<(String, f64)> = sqlx::query_as( "SELECT status, progress_pct FROM child_jobs WHERE batch_id = $1 ORDER BY model_type" ) .bind(batch_id) .fetch_all(&pool) .await .expect("Failed to fetch children"); assert_eq!(children.len(), 2); assert_eq!(children[0].0, "Running"); // DQN assert_eq!(children[0].1, 25.0); assert_eq!(children[1].0, "Running"); // PPO assert_eq!(children[1].1, 75.0); println!("✅ Transaction isolation validated: both updates persisted"); cleanup_test_data(&pool, batch_id).await; pool.close().await; } // ============================================================================ // Test 5: Index performance validation (<10ms queries) // ============================================================================ #[tokio::test] async fn test_index_performance() { let pool = create_test_pool().await; // Create test data let batch_id = create_test_batch(&pool, "perf_test").await; for model in &["DQN", "PPO", "MAMBA-2", "TFT"] { create_test_child(&pool, batch_id, model).await; } println!("✅ Created test data"); // Test 1: Query by batch status (should use idx_batch_jobs_status) let start = Instant::now(); let _batches: Vec = sqlx::query_scalar( "SELECT name FROM batch_jobs WHERE status = 'Pending'" ) .fetch_all(&pool) .await .expect("Failed to query batches"); let duration1 = start.elapsed(); assert!(duration1 < Duration::from_millis(10), "Query 1 too slow: {:?}", duration1); println!("✅ Batch status query: {:?} (<10ms)", duration1); // Test 2: Query by child batch_id (should use idx_child_jobs_batch_id) let start = Instant::now(); let _children: Vec = sqlx::query_scalar( "SELECT model_type FROM child_jobs WHERE batch_id = $1" ) .bind(batch_id) .fetch_all(&pool) .await .expect("Failed to query children"); let duration2 = start.elapsed(); assert!(duration2 < Duration::from_millis(10), "Query 2 too slow: {:?}", duration2); println!("✅ Child batch_id query: {:?} (<10ms)", duration2); // Test 3: Composite query (should use idx_child_jobs_batch_status) let start = Instant::now(); let _count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM child_jobs WHERE batch_id = $1 AND status = 'Pending'" ) .bind(batch_id) .fetch_one(&pool) .await .expect("Failed to count children"); let duration3 = start.elapsed(); assert!(duration3 < Duration::from_millis(10), "Query 3 too slow: {:?}", duration3); println!("✅ Composite batch+status query: {:?} (<10ms)", duration3); // Test 4: JSONB query (should use GIN index) let start = Instant::now(); let _batches: Vec = sqlx::query_scalar( "SELECT name FROM batch_jobs WHERE config_json @> '{}'::jsonb" ) .fetch_all(&pool) .await .expect("Failed to query JSONB"); let duration4 = start.elapsed(); assert!(duration4 < Duration::from_millis(10), "Query 4 too slow: {:?}", duration4); println!("✅ JSONB GIN index query: {:?} (<10ms)", duration4); // Test 5: ORDER BY created_at (should use idx_batch_jobs_created_at) let start = Instant::now(); let _batches: Vec = sqlx::query_scalar( "SELECT name FROM batch_jobs ORDER BY created_at DESC LIMIT 10" ) .fetch_all(&pool) .await .expect("Failed to query ordered batches"); let duration5 = start.elapsed(); assert!(duration5 < Duration::from_millis(10), "Query 5 too slow: {:?}", duration5); println!("✅ ORDER BY created_at query: {:?} (<10ms)", duration5); println!("✅ All index performance tests passed (<10ms)"); cleanup_test_data(&pool, batch_id).await; pool.close().await; }