#![allow(unexpected_cfgs)] #![cfg(feature = "__backtesting_integration")] //! Comprehensive gRPC Error Handling Tests for Backtesting Service //! //! This test suite validates all gRPC error codes and edge cases for the Backtesting Service, //! focusing on backtest job management, data validation, and resource constraints. //! //! Coverage areas: //! - InvalidArgument: Invalid date ranges, missing symbols, bad parameters //! - NotFound: Non-existent backtest jobs //! - FailedPrecondition: Invalid job state transitions //! - ResourceExhausted: Too many concurrent jobs //! - Internal: Data loading failures, computation errors //! - DeadlineExceeded: Long-running backtests //! - Aborted: Job cancellation handling //! //! Total: 12 comprehensive error scenario tests use anyhow::Result; use std::time::Duration; use tonic::{Code, Request}; // Import TLI proto definitions (Backtesting Service interface) use backtesting_service::foxhunt::tli::{ backtesting_service_client::BacktestingServiceClient, GetBacktestResultsRequest, GetBacktestStatusRequest, StartBacktestRequest, StopBacktestRequest, }; // ============================================================================ // HELPER FUNCTIONS // ============================================================================ /// Create authenticated Backtesting Service client /// /// Returns a client with an interceptor that adds JWT authentication headers to all requests. /// /// # Implementation Note /// We create the client and return it directly. The caller receives the intercepted client /// but the specific closure type is opaque. Each call site must let Rust infer the type /// or use it immediately without storing in a variable with an explicit type annotation. async fn create_authenticated_client() -> Result< BacktestingServiceClient< tonic::service::interceptor::InterceptedService< tonic::transport::Channel, impl tonic::service::Interceptor + Clone, >, >, > { let channel = tonic::transport::Channel::from_static("http://localhost:50053") .connect() .await?; // Create valid JWT token for authentication let token = create_valid_jwt_token()?; // Create interceptor closure that adds JWT auth header let interceptor = move |mut req: Request<()>| { req.metadata_mut().insert( "authorization", format!("Bearer {}", token).parse().expect("INVARIANT: Valid parse input"), ); Ok(req) }; // Create client with interceptor let client = BacktestingServiceClient::with_interceptor(channel, interceptor); Ok(client) } /// Generate valid JWT token for testing fn create_valid_jwt_token() -> Result { use chrono::{Duration, Utc}; use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] struct Claims { sub: String, exp: usize, iat: usize, iss: String, aud: String, roles: Vec, permissions: Vec, jti: String, } let jwt_secret = std::env::var("JWT_SECRET") .unwrap_or_else(|_| "OvFLDUbIDak3CSCi5t6zKfsAp65cjTOJ85q9YE+TFY8b361DGg1gSTra2rW6mps3cWrRGQ/NXRA5uftUpMldvOaEHMMgfBs4JjVODDElREdvUFm0EttD1A==".to_string()); let claims = Claims { sub: "test_user_001".to_string(), exp: (Utc::now() + Duration::hours(1)).timestamp() as usize, iat: Utc::now().timestamp() as usize, iss: "foxhunt-api".to_string(), aud: "foxhunt-backtesting".to_string(), roles: vec!["trader".to_string()], permissions: vec!["backtest:run".to_string()], jti: uuid::Uuid::new_v4().to_string(), }; let token = encode( &Header::new(Algorithm::HS256), &claims, &EncodingKey::from_secret(jwt_secret.as_bytes()), )?; Ok(token) } // ============================================================================ // INVALID ARGUMENT TESTS (Validation Failures) // ============================================================================ #[tokio::test] async fn test_start_backtest_empty_symbols_returns_invalid_argument() -> Result<()> { println!("\n=== Test: Start Backtest - Empty Symbols (InvalidArgument) ==="); let mut client = create_authenticated_client().await?; let request = Request::new(StartBacktestRequest { strategy_name: "test_strategy".to_string(), symbols: vec![], // Invalid: empty symbols list start_date_unix_nanos: 1609459200000000000, // 2021-01-01 end_date_unix_nanos: 1640995200000000000, // 2022-01-01 initial_capital: 100000.0, parameters: std::collections::HashMap::new(), save_results: true, description: "Test backtest".to_string(), }); let result = client.start_backtest(request).await; assert!(result.is_err(), "Expected error for empty symbols"); let status = result.unwrap_err(); assert_eq!( status.code(), Code::InvalidArgument, "Expected InvalidArgument error code" ); assert!( status.message().contains("symbol") || status.message().contains("empty"), "Error message should mention symbols" ); println!(" ✓ Empty symbols list correctly rejected"); Ok(()) } #[tokio::test] async fn test_start_backtest_invalid_date_range_returns_invalid_argument() -> Result<()> { println!("\n=== Test: Start Backtest - Invalid Date Range (InvalidArgument) ==="); let mut client = create_authenticated_client().await?; let request = Request::new(StartBacktestRequest { strategy_name: "test_strategy".to_string(), symbols: vec!["BTC/USD".to_string()], start_date_unix_nanos: 1640995200000000000, // 2022-01-01 (after end date) end_date_unix_nanos: 1609459200000000000, // 2021-01-01 initial_capital: 100000.0, parameters: std::collections::HashMap::new(), save_results: true, description: "Test backtest".to_string(), }); let result = client.start_backtest(request).await; assert!(result.is_err(), "Expected error for invalid date range"); let status = result.unwrap_err(); assert_eq!(status.code(), Code::InvalidArgument); assert!( status.message().contains("date") || status.message().contains("range"), "Error message should mention date range" ); println!(" ✓ Invalid date range correctly rejected"); Ok(()) } #[tokio::test] async fn test_start_backtest_zero_capital_returns_invalid_argument() -> Result<()> { println!("\n=== Test: Start Backtest - Zero Capital (InvalidArgument) ==="); let mut client = create_authenticated_client().await?; let request = Request::new(StartBacktestRequest { strategy_name: "test_strategy".to_string(), symbols: vec!["BTC/USD".to_string()], start_date_unix_nanos: 1609459200000000000, end_date_unix_nanos: 1640995200000000000, initial_capital: 0.0, // Invalid: zero capital parameters: std::collections::HashMap::new(), save_results: true, description: "Test backtest".to_string(), }); let result = client.start_backtest(request).await; assert!(result.is_err(), "Expected error for zero capital"); let status = result.unwrap_err(); assert_eq!(status.code(), Code::InvalidArgument); assert!( status.message().contains("capital") || status.message().contains("zero"), "Error message should mention capital" ); println!(" ✓ Zero capital correctly rejected"); Ok(()) } #[tokio::test] async fn test_start_backtest_empty_strategy_name_returns_invalid_argument() -> Result<()> { println!("\n=== Test: Start Backtest - Empty Strategy Name (InvalidArgument) ==="); let mut client = create_authenticated_client().await?; let request = Request::new(StartBacktestRequest { strategy_name: "".to_string(), // Invalid: empty strategy name symbols: vec!["BTC/USD".to_string()], start_date_unix_nanos: 1609459200000000000, end_date_unix_nanos: 1640995200000000000, initial_capital: 100000.0, parameters: std::collections::HashMap::new(), save_results: true, description: "Test backtest".to_string(), }); let result = client.start_backtest(request).await; assert!(result.is_err(), "Expected error for empty strategy name"); let status = result.unwrap_err(); assert_eq!(status.code(), Code::InvalidArgument); println!(" ✓ Empty strategy name correctly rejected"); Ok(()) } // ============================================================================ // NOT FOUND TESTS (Non-existent Resources) // ============================================================================ #[tokio::test] async fn test_get_backtest_status_nonexistent_job_returns_not_found() -> Result<()> { println!("\n=== Test: Get Backtest Status - Non-existent Job (NotFound) ==="); let mut client = create_authenticated_client().await?; let request = Request::new(GetBacktestStatusRequest { backtest_id: "nonexistent_job_999999".to_string(), }); let result = client.get_backtest_status(request).await; assert!(result.is_err(), "Expected error for non-existent job"); let status = result.unwrap_err(); assert_eq!( status.code(), Code::NotFound, "Expected NotFound error code" ); assert!( status.message().contains("not found") || status.message().contains("exist"), "Error message should mention not found" ); println!(" ✓ Non-existent job correctly returns NotFound"); Ok(()) } #[tokio::test] async fn test_get_backtest_results_nonexistent_job_returns_not_found() -> Result<()> { println!("\n=== Test: Get Backtest Results - Non-existent Job (NotFound) ==="); let mut client = create_authenticated_client().await?; let request = Request::new(GetBacktestResultsRequest { backtest_id: "nonexistent_job_888888".to_string(), include_trades: true, include_metrics: true, }); let result = client.get_backtest_results(request).await; assert!(result.is_err(), "Expected error for non-existent job"); let status = result.unwrap_err(); assert_eq!(status.code(), Code::NotFound); println!(" ✓ Non-existent results correctly returns NotFound"); Ok(()) } #[tokio::test] async fn test_stop_backtest_nonexistent_job_returns_not_found() -> Result<()> { println!("\n=== Test: Stop Backtest - Non-existent Job (NotFound) ==="); let mut client = create_authenticated_client().await?; let request = Request::new(StopBacktestRequest { backtest_id: "nonexistent_job_777777".to_string(), save_partial_results: false, }); let result = client.stop_backtest(request).await; assert!(result.is_err(), "Expected error for non-existent job"); let status = result.unwrap_err(); assert_eq!(status.code(), Code::NotFound); println!(" ✓ Stop non-existent job correctly returns NotFound"); Ok(()) } // ============================================================================ // FAILED PRECONDITION TESTS (Invalid State Transitions) // ============================================================================ #[tokio::test] async fn test_get_results_incomplete_backtest_returns_failed_precondition() -> Result<()> { println!("\n=== Test: Get Results - Incomplete Backtest (FailedPrecondition) ==="); let mut client = create_authenticated_client().await?; // Start a backtest let start_request = Request::new(StartBacktestRequest { strategy_name: "test_strategy".to_string(), symbols: vec!["BTC/USD".to_string()], start_date_unix_nanos: 1609459200000000000, end_date_unix_nanos: 1640995200000000000, initial_capital: 100000.0, parameters: std::collections::HashMap::new(), save_results: true, description: "Incomplete backtest test".to_string(), }); let start_result = client.start_backtest(start_request).await?; let backtest_id = start_result.into_inner().backtest_id; // Immediately try to get results (should fail - backtest not complete) let results_request = Request::new(GetBacktestResultsRequest { backtest_id: backtest_id.clone(), include_trades: true, include_metrics: true, }); let result = client.get_backtest_results(results_request).await; if result.is_err() { let status = result.unwrap_err(); assert_eq!( status.code(), Code::FailedPrecondition, "Expected FailedPrecondition for incomplete backtest" ); println!(" ✓ Get results for incomplete backtest correctly rejected"); } else { println!(" ℹ Backtest completed too quickly or returns partial results"); } // Clean up - stop the backtest let _ = client .stop_backtest(Request::new(StopBacktestRequest { backtest_id, save_partial_results: false, })) .await; Ok(()) } #[tokio::test] async fn test_stop_already_completed_backtest_returns_failed_precondition() -> Result<()> { println!("\n=== Test: Stop Backtest - Already Completed (FailedPrecondition) ==="); let mut client = create_authenticated_client().await?; // Start a very short backtest that will complete quickly let start_request = Request::new(StartBacktestRequest { strategy_name: "test_strategy".to_string(), symbols: vec!["BTC/USD".to_string()], start_date_unix_nanos: 1609459200000000000, end_date_unix_nanos: 1609459200001000000, // 1ms duration initial_capital: 100000.0, parameters: std::collections::HashMap::new(), save_results: true, description: "Quick backtest".to_string(), }); let start_result = client.start_backtest(start_request).await?; let backtest_id = start_result.into_inner().backtest_id; // Wait for backtest to complete tokio::time::sleep(Duration::from_secs(2)).await; // Try to stop already completed backtest let stop_request = Request::new(StopBacktestRequest { backtest_id, save_partial_results: false, }); let result = client.stop_backtest(stop_request).await; if result.is_err() { let status = result.unwrap_err(); if status.code() == Code::FailedPrecondition { println!(" ✓ Stop completed backtest correctly rejected"); } else { println!(" ℹ Got error code: {:?}", status.code()); } } else { println!(" ℹ Stop succeeded (idempotent operation)"); } Ok(()) } // ============================================================================ // RESOURCE EXHAUSTED TESTS (Too Many Jobs) // ============================================================================ #[tokio::test] #[ignore = "Slow test - requires many concurrent jobs"] async fn test_start_backtest_too_many_concurrent_jobs_returns_resource_exhausted() -> Result<()> { println!("\n=== Test: Start Backtest - Too Many Concurrent Jobs (ResourceExhausted) ==="); let mut client = create_authenticated_client().await?; // Start many backtests concurrently to exhaust resources let mut job_ids = vec![]; let mut exhausted = false; for i in 0..50 { let request = Request::new(StartBacktestRequest { strategy_name: format!("stress_test_{}", i), symbols: vec!["BTC/USD".to_string()], start_date_unix_nanos: 1609459200000000000, end_date_unix_nanos: 1640995200000000000, initial_capital: 100000.0, parameters: std::collections::HashMap::new(), save_results: false, description: format!("Stress test job {}", i), }); match client.start_backtest(request).await { Ok(response) => { job_ids.push(response.into_inner().backtest_id); }, Err(status) => { if status.code() == Code::ResourceExhausted { println!(" ✓ Resource exhaustion triggered after {} jobs", i); exhausted = true; break; } }, } } // Clean up all started jobs for job_id in job_ids { let _ = client .stop_backtest(Request::new(StopBacktestRequest { backtest_id: job_id, save_partial_results: false, })) .await; } if !exhausted { println!(" ℹ Resource exhaustion not triggered (high capacity or small dataset)"); } Ok(()) } // ============================================================================ // INTERNAL ERROR TESTS (Data Loading Failures) // ============================================================================ #[tokio::test] async fn test_start_backtest_missing_market_data_returns_internal() -> Result<()> { println!("\n=== Test: Start Backtest - Missing Market Data (Internal) ==="); let mut client = create_authenticated_client().await?; // Request backtest for future dates (no data available) // Using year 2100 (approximately 4102444800 seconds since epoch) // i64::MAX is 9223372036854775807, so we use values well within range let request = Request::new(StartBacktestRequest { strategy_name: "test_strategy".to_string(), symbols: vec!["BTC/USD".to_string()], start_date_unix_nanos: 4102444800000000000_i64, // 2100-01-01 (far future) end_date_unix_nanos: 4133980800000000000_i64, // 2101-01-01 initial_capital: 100000.0, parameters: std::collections::HashMap::new(), save_results: false, description: "Missing data test".to_string(), }); let result = client.start_backtest(request).await; if result.is_err() { let status = result.unwrap_err(); // Could be Internal (data loading error) or InvalidArgument (invalid date) assert!( status.code() == Code::Internal || status.code() == Code::InvalidArgument, "Expected Internal or InvalidArgument for missing data" ); println!(" ✓ Missing market data correctly handled"); } else { println!(" ℹ Request succeeded (may return empty results)"); } Ok(()) } // ============================================================================ // DEADLINE EXCEEDED TESTS (Long-running Jobs) // ============================================================================ #[tokio::test] async fn test_start_backtest_with_short_timeout_may_fail() -> Result<()> { println!("\n=== Test: Start Backtest - Short Timeout (DeadlineExceeded) ==="); let channel = tonic::transport::Channel::from_static("http://localhost:50053") .timeout(Duration::from_micros(1)) // Very short timeout .connect() .await?; let token = create_valid_jwt_token()?; let mut client = BacktestingServiceClient::with_interceptor(channel, move |mut req: Request<()>| { req.metadata_mut().insert( "authorization", format!("Bearer {}", token).parse().expect("INVARIANT: Valid parse input"), ); Ok(req) }); let request = Request::new(StartBacktestRequest { strategy_name: "timeout_test".to_string(), symbols: vec!["BTC/USD".to_string()], start_date_unix_nanos: 1609459200000000000, end_date_unix_nanos: 1640995200000000000, initial_capital: 100000.0, parameters: std::collections::HashMap::new(), save_results: false, description: "Timeout test".to_string(), }); let result = client.start_backtest(request).await; if result.is_err() { let status = result.unwrap_err(); if status.code() == Code::DeadlineExceeded { println!(" ✓ Request timed out as expected"); } else { println!(" ℹ Request failed with: {:?}", status.code()); } } else { println!(" ℹ Request completed within deadline"); } Ok(()) }