//! Job Spawner Tests //! //! Comprehensive test suite for the JobSpawner module following TDD principles. //! Tests cover batch creation, child job spawning, ordering, rollback, and edge cases. use ml_training_service::job_spawner::{Asset, ChildJob, JobSpawner, ModelType}; use sqlx::PgPool; use std::path::PathBuf; use uuid::Uuid; /// Helper function to run migrations on test database async fn run_migrations(pool: &PgPool) { sqlx::migrate!("../../migrations") .run(pool) .await .expect("Failed to run migrations"); } /// Helper function to create test assets fn create_test_assets(count: usize) -> Vec { (0..count) .map(|i| Asset { symbol: format!("TEST{}", i), data_file: PathBuf::from(format!("/test/data/TEST{}.parquet", i)), }) .collect() } /// Helper function to create test models in correct training order fn create_test_models() -> Vec { vec![ ModelType::DQN, ModelType::PPO, ModelType::MAMBA, ModelType::TFT, ] } #[sqlx::test] async fn test_spawn_batch_single_asset_4_models(pool: PgPool) { run_migrations(&pool).await; let spawner = JobSpawner::new(pool.clone()); let assets = create_test_assets(1); let models = create_test_models(); let batch = spawner .spawn_batch(assets.clone(), models.clone()) .await .expect("Failed to spawn batch"); // Verify batch was created assert!(batch.batch_id != Uuid::nil()); assert_eq!(batch.status, "Pending"); assert_eq!(batch.assets.len(), 1); assert_eq!(batch.models.len(), 4); // Query child jobs let child_jobs: Vec = sqlx::query_as( "SELECT id, batch_id, model_type, status, created_at, config_json FROM child_jobs WHERE batch_id = $1 ORDER BY created_at", ) .bind(batch.batch_id) .fetch_all(&pool) .await .expect("Failed to fetch child jobs"); // Should create 4 child jobs (1 asset × 4 models) assert_eq!(child_jobs.len(), 4); // Verify all jobs are Pending for job in &child_jobs { assert_eq!(job.status, "Pending"); assert_eq!(job.batch_id, batch.batch_id); } } #[sqlx::test] async fn test_spawn_batch_2_assets_4_models(pool: PgPool) { run_migrations(&pool).await; let spawner = JobSpawner::new(pool.clone()); let assets = create_test_assets(2); let models = create_test_models(); let batch = spawner .spawn_batch(assets, models) .await .expect("Failed to spawn batch"); // Query child jobs let child_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM child_jobs WHERE batch_id = $1") .bind(batch.batch_id) .fetch_one(&pool) .await .expect("Failed to count jobs"); // Should create 8 child jobs (2 assets × 4 models) assert_eq!(child_count, 8); // Verify batch metadata let batch_record: (i32,) = sqlx::query_as("SELECT total_jobs FROM batch_jobs WHERE id = $1") .bind(batch.batch_id) .fetch_one(&pool) .await .expect("Failed to fetch batch"); assert_eq!(batch_record.0, 8); } #[sqlx::test] async fn test_spawn_batch_sequential_order(pool: PgPool) { run_migrations(&pool).await; let spawner = JobSpawner::new(pool.clone()); let assets = create_test_assets(1); let models = create_test_models(); let batch = spawner .spawn_batch(assets, models) .await .expect("Failed to spawn batch"); // Fetch jobs in creation order let jobs: Vec<(String,)> = sqlx::query_as( "SELECT model_type FROM child_jobs WHERE batch_id = $1 ORDER BY created_at", ) .bind(batch.batch_id) .fetch_all(&pool) .await .expect("Failed to fetch jobs"); // Verify correct sequential order: DQN → PPO → MAMBA-2 → TFT assert_eq!(jobs.len(), 4); assert_eq!(jobs[0].0, "DQN"); assert_eq!(jobs[1].0, "PPO"); assert_eq!(jobs[2].0, "MAMBA-2"); assert_eq!(jobs[3].0, "TFT"); } #[sqlx::test] async fn test_spawn_batch_creates_parent(pool: PgPool) { run_migrations(&pool).await; let spawner = JobSpawner::new(pool.clone()); let assets = create_test_assets(1); let models = create_test_models(); let batch = spawner .spawn_batch(assets.clone(), models.clone()) .await .expect("Failed to spawn batch"); // Verify batch exists in database let batch_record: (Uuid, String, i32) = sqlx::query_as("SELECT id, status, total_jobs FROM batch_jobs WHERE id = $1") .bind(batch.batch_id) .fetch_one(&pool) .await .expect("Failed to fetch batch"); assert_eq!(batch_record.0, batch.batch_id); assert_eq!(batch_record.1, "Pending"); assert_eq!(batch_record.2, 4); // 1 asset × 4 models } #[sqlx::test] async fn test_spawn_batch_all_pending_status(pool: PgPool) { run_migrations(&pool).await; let spawner = JobSpawner::new(pool.clone()); let assets = create_test_assets(2); let models = create_test_models(); let batch = spawner .spawn_batch(assets, models) .await .expect("Failed to spawn batch"); // Count pending jobs let pending_count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM child_jobs WHERE batch_id = $1 AND status = 'Pending'", ) .bind(batch.batch_id) .fetch_one(&pool) .await .expect("Failed to count pending jobs"); // All 8 jobs should be Pending assert_eq!(pending_count, 8); // Verify batch status let batch_status: (String, i32, i32) = sqlx::query_as( "SELECT status, pending_jobs, total_jobs FROM batch_jobs WHERE id = $1", ) .bind(batch.batch_id) .fetch_one(&pool) .await .expect("Failed to fetch batch status"); assert_eq!(batch_status.0, "Pending"); assert_eq!(batch_status.1, 8); // pending_jobs assert_eq!(batch_status.2, 8); // total_jobs } #[sqlx::test] async fn test_spawn_batch_unique_job_ids(pool: PgPool) { run_migrations(&pool).await; let spawner = JobSpawner::new(pool.clone()); let assets = create_test_assets(3); let models = create_test_models(); let batch = spawner .spawn_batch(assets, models) .await .expect("Failed to spawn batch"); // Fetch all job IDs let job_ids: Vec<(Uuid,)> = sqlx::query_as("SELECT id FROM child_jobs WHERE batch_id = $1") .bind(batch.batch_id) .fetch_all(&pool) .await .expect("Failed to fetch job IDs"); // Should have 12 jobs (3 assets × 4 models) assert_eq!(job_ids.len(), 12); // Verify all IDs are unique let unique_ids: std::collections::HashSet<_> = job_ids.iter().map(|r| r.0).collect(); assert_eq!(unique_ids.len(), 12); // Verify batch ID is also unique and not nil assert!(batch.batch_id != Uuid::nil()); } #[sqlx::test] async fn test_get_next_pending_job_returns_oldest(pool: PgPool) { run_migrations(&pool).await; let spawner = JobSpawner::new(pool.clone()); // Create first batch let assets1 = vec![Asset { symbol: "ES.FUT".to_string(), data_file: PathBuf::from("/test/ES.parquet"), }]; let models1 = vec![ModelType::DQN]; let batch1 = spawner .spawn_batch(assets1, models1) .await .expect("Failed to spawn first batch"); // Wait a moment to ensure different timestamps tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; // Create second batch let assets2 = vec![Asset { symbol: "NQ.FUT".to_string(), data_file: PathBuf::from("/test/NQ.parquet"), }]; let models2 = vec![ModelType::PPO]; let _batch2 = spawner .spawn_batch(assets2, models2) .await .expect("Failed to spawn second batch"); // Get next pending job let next_job = spawner .get_next_pending_job() .await .expect("Failed to get next job") .expect("No pending job found"); // Should return job from first batch (FIFO order) assert_eq!(next_job.batch_id, batch1.batch_id); assert_eq!(next_job.model_type, "DQN"); } #[sqlx::test] async fn test_get_next_pending_job_empty_queue(pool: PgPool) { run_migrations(&pool).await; let spawner = JobSpawner::new(pool.clone()); // No jobs in queue let next_job = spawner .get_next_pending_job() .await .expect("Failed to query jobs"); assert!(next_job.is_none()); } #[sqlx::test] async fn test_spawn_batch_rollback_on_error(pool: PgPool) { run_migrations(&pool).await; let spawner = JobSpawner::new(pool.clone()); // Try to create a batch with invalid model type (this should fail) // We'll simulate this by creating a batch and then trying to insert // a child job with a NULL model_type which violates NOT NULL constraint // First, verify tables are empty let initial_batch_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM batch_jobs") .fetch_one(&pool) .await .expect("Failed to count batches"); let initial_job_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM child_jobs") .fetch_one(&pool) .await .expect("Failed to count jobs"); // Create a batch with empty assets (should fail validation) let empty_assets: Vec = vec![]; let models = create_test_models(); let result = spawner.spawn_batch(empty_assets, models).await; // Should return an error assert!(result.is_err()); // Verify no records were created (rollback successful) let final_batch_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM batch_jobs") .fetch_one(&pool) .await .expect("Failed to count batches"); let final_job_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM child_jobs") .fetch_one(&pool) .await .expect("Failed to count jobs"); assert_eq!(final_batch_count, initial_batch_count); assert_eq!(final_job_count, initial_job_count); } #[sqlx::test] async fn test_spawn_batch_with_config_metadata(pool: PgPool) { run_migrations(&pool).await; let spawner = JobSpawner::new(pool.clone()); let assets = create_test_assets(1); let models = create_test_models(); let batch = spawner .spawn_batch(assets, models) .await .expect("Failed to spawn batch"); // Verify config_json is populated in child jobs let configs: Vec<(serde_json::Value,)> = sqlx::query_as("SELECT config_json FROM child_jobs WHERE batch_id = $1") .bind(batch.batch_id) .fetch_all(&pool) .await .expect("Failed to fetch configs"); // All jobs should have valid JSON config for (config,) in configs { assert!(config.is_object()); } } #[sqlx::test] async fn test_spawn_multiple_batches_independently(pool: PgPool) { run_migrations(&pool).await; let spawner = JobSpawner::new(pool.clone()); // Create batch 1 let assets1 = create_test_assets(1); let models1 = vec![ModelType::DQN, ModelType::PPO]; let batch1 = spawner .spawn_batch(assets1, models1) .await .expect("Failed to spawn batch 1"); // Create batch 2 let assets2 = create_test_assets(2); let models2 = vec![ModelType::MAMBA]; let batch2 = spawner .spawn_batch(assets2, models2) .await .expect("Failed to spawn batch 2"); // Verify batches are independent assert_ne!(batch1.batch_id, batch2.batch_id); // Count jobs for each batch let batch1_jobs: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM child_jobs WHERE batch_id = $1") .bind(batch1.batch_id) .fetch_one(&pool) .await .expect("Failed to count batch1 jobs"); let batch2_jobs: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM child_jobs WHERE batch_id = $1") .bind(batch2.batch_id) .fetch_one(&pool) .await .expect("Failed to count batch2 jobs"); assert_eq!(batch1_jobs, 2); // 1 asset × 2 models assert_eq!(batch2_jobs, 2); // 2 assets × 1 model } #[sqlx::test] async fn test_get_batch_status(pool: PgPool) { run_migrations(&pool).await; let spawner = JobSpawner::new(pool.clone()); let assets = create_test_assets(1); let models = create_test_models(); let batch = spawner .spawn_batch(assets, models) .await .expect("Failed to spawn batch"); // Get batch status let status = spawner .get_batch_status(batch.batch_id) .await .expect("Failed to get batch status") .expect("Batch not found"); assert_eq!(status.batch_id, batch.batch_id); assert_eq!(status.status, "Pending"); assert_eq!(status.total_jobs, 4); assert_eq!(status.pending_jobs, 4); assert_eq!(status.completed_jobs, 0); assert_eq!(status.failed_jobs, 0); }