- Add AllocatePortfolioArgs struct with validation
- Support 5 allocation strategies (equal-weight, risk-parity, ml-optimized, mean-variance, kelly)
- Implement constraint validation (0 < min < max < 1.0, positive capital)
- Real gRPC integration with Trading Agent Service via API Gateway
- Formatted table output with portfolio allocations and risk metrics
- JWT authentication support via Bearer token in gRPC metadata
- 15 comprehensive TDD integration tests (all passing)
- Case-insensitive strategy parsing
Test Results: cargo test -p tli --test agent_commands_test
✅ 15 passed, 0 failed
Files:
- tli/src/commands/agent.rs (NEW - 466 lines)
- tli/src/commands/mod.rs (export AgentArgs)
- tli/src/main.rs (integrate agent command)
- tli/tests/agent_commands_test.rs (NEW - 15 tests)
- tli/proto/trading_agent.proto (NEW)
Co-authored-by: Wave 12.3.3 TDD Implementation
589 lines
18 KiB
Rust
589 lines
18 KiB
Rust
//! Real Backend Integration Tests for API Gateway
|
|
//!
|
|
//! Tests that verify the API Gateway correctly proxies requests to real backend services:
|
|
//! - Trading Service (port 50052)
|
|
//! - Backtesting Service (port 50053)
|
|
//! - ML Training Service (port 50054)
|
|
//!
|
|
//! All tests use REAL gRPC communication with REAL services (no mocks).
|
|
|
|
#[path = "common/mod.rs"]
|
|
mod common;
|
|
|
|
use anyhow::Result;
|
|
use common::{cleanup_redis, generate_test_token, wait_for_redis};
|
|
use std::time::Duration;
|
|
use tonic::transport::Channel;
|
|
use tonic::Request;
|
|
|
|
const REDIS_URL: &str = "redis://localhost:6379";
|
|
const API_GATEWAY_URL: &str = "http://localhost:50051";
|
|
const TRADING_SERVICE_URL: &str = "http://localhost:50052";
|
|
const BACKTESTING_SERVICE_URL: &str = "http://localhost:50053";
|
|
const ML_TRAINING_SERVICE_URL: &str = "http://localhost:50054";
|
|
|
|
// Import proto definitions - we'll use tli's generated proto that includes all service definitions
|
|
// API Gateway tests need to use tli's proto definitions since they're testing the client-facing interface
|
|
use tli::proto::{
|
|
Trading as TradingHealthRequest,
|
|
Backtesting as BacktestingHealthRequest,
|
|
};
|
|
|
|
// For clients, we use the actual service client types from tli
|
|
use tli::proto::{
|
|
trading_service_client::TradingServiceClient,
|
|
backtesting_service_client::BacktestingServiceClient,
|
|
};
|
|
|
|
// ML Training proto from api_gateway
|
|
use api_gateway::ml_training::{
|
|
ml_training_service_client::MlTrainingServiceClient,
|
|
HealthCheckRequest as MlHealthRequest,
|
|
};
|
|
|
|
/// Helper to wait for a service to be ready
|
|
async fn wait_for_service_ready(url: &str, service_name: &str) -> Result<()> {
|
|
let max_attempts = 30;
|
|
let retry_delay = Duration::from_millis(500);
|
|
|
|
for attempt in 1..=max_attempts {
|
|
match tokio::net::TcpStream::connect(url.trim_start_matches("http://")).await {
|
|
Ok(_) => {
|
|
println!("✓ {} is ready (attempt {})", service_name, attempt);
|
|
return Ok(());
|
|
}
|
|
Err(_) if attempt < max_attempts => {
|
|
tokio::time::sleep(retry_delay).await;
|
|
}
|
|
Err(e) => {
|
|
return Err(anyhow::anyhow!(
|
|
"{} not ready after {} attempts: {}",
|
|
service_name,
|
|
max_attempts,
|
|
e
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
unreachable!()
|
|
}
|
|
|
|
/// Setup test environment (Redis, wait for services)
|
|
async fn setup_test_environment() -> Result<()> {
|
|
// Wait for Redis
|
|
wait_for_redis(REDIS_URL, 50).await?;
|
|
cleanup_redis(REDIS_URL).await?;
|
|
|
|
// Wait for all backend services
|
|
wait_for_service_ready("localhost:50051", "API Gateway").await?;
|
|
wait_for_service_ready("localhost:50052", "Trading Service").await?;
|
|
wait_for_service_ready("localhost:50053", "Backtesting Service").await?;
|
|
wait_for_service_ready("localhost:50054", "ML Training Service").await?;
|
|
|
|
println!("✓ All services are ready for testing");
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// TRADING SERVICE INTEGRATION TESTS
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_trading_service_direct_connection() -> Result<()> {
|
|
println!("\n=== Test: Trading Service Direct Connection (No Proxy) ===");
|
|
|
|
setup_test_environment().await?;
|
|
|
|
// Connect directly to Trading Service (bypass API Gateway)
|
|
let channel = Channel::from_static(TRADING_SERVICE_URL)
|
|
.connect()
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Failed to connect to Trading Service: {}", e))?;
|
|
|
|
let mut client = TradingServiceClient::new(channel);
|
|
|
|
// Call health check
|
|
let request = Request::new(TradingHealthRequest {});
|
|
let response = client
|
|
.health_check(request)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Trading Service health check failed: {}", e))?;
|
|
|
|
let health = response.into_inner();
|
|
println!("✓ Trading Service health: {}", health.status);
|
|
assert_eq!(health.status, "healthy", "Trading Service should be healthy");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_trading_service_via_api_gateway_proxy() -> Result<()> {
|
|
println!("\n=== Test: Trading Service via API Gateway Proxy ===");
|
|
|
|
setup_test_environment().await?;
|
|
|
|
// Generate valid JWT token
|
|
let (token, _jti) = generate_test_token(
|
|
"test_user",
|
|
vec!["trader".to_string()],
|
|
vec!["api.access".to_string(), "trading.submit".to_string()],
|
|
3600,
|
|
)?;
|
|
|
|
// Connect to API Gateway (which proxies to Trading Service)
|
|
let channel = Channel::from_static(API_GATEWAY_URL)
|
|
.connect()
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Failed to connect to API Gateway: {}", e))?;
|
|
|
|
let mut client = TradingServiceClient::new(channel);
|
|
|
|
// Call health check through API Gateway proxy
|
|
let mut request = Request::new(TradingHealthRequest {});
|
|
request.metadata_mut().insert(
|
|
"authorization",
|
|
format!("Bearer {}", token).parse().unwrap(),
|
|
);
|
|
|
|
let start = std::time::Instant::now();
|
|
let response = client
|
|
.health_check(request)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Trading Service health check via proxy failed: {}", e))?;
|
|
let elapsed = start.elapsed();
|
|
|
|
let health = response.into_inner();
|
|
println!("✓ Trading Service health via proxy: {}", health.status);
|
|
println!(" Proxy latency: {:?} (target: <1ms)", elapsed);
|
|
|
|
assert_eq!(health.status, "healthy", "Trading Service should be healthy");
|
|
assert!(
|
|
elapsed < Duration::from_millis(50),
|
|
"Proxy latency should be <50ms, got {:?}",
|
|
elapsed
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_trading_service_proxy_requires_auth() -> Result<()> {
|
|
println!("\n=== Test: Trading Service Proxy Requires Authentication ===");
|
|
|
|
setup_test_environment().await?;
|
|
|
|
// Connect to API Gateway WITHOUT auth token
|
|
let channel = Channel::from_static(API_GATEWAY_URL)
|
|
.connect()
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Failed to connect to API Gateway: {}", e))?;
|
|
|
|
let mut client = TradingServiceClient::new(channel);
|
|
|
|
// Call health check without auth header
|
|
let request = Request::new(TradingHealthRequest {});
|
|
let result = client.health_check(request).await;
|
|
|
|
// Should fail with UNAUTHENTICATED
|
|
assert!(
|
|
result.is_err(),
|
|
"Request without auth should be rejected by API Gateway"
|
|
);
|
|
|
|
let error = result.unwrap_err();
|
|
println!("✓ Correctly rejected: {:?}", error.code());
|
|
assert_eq!(
|
|
error.code(),
|
|
tonic::Code::Unauthenticated,
|
|
"Should return UNAUTHENTICATED"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// BACKTESTING SERVICE INTEGRATION TESTS
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_backtesting_service_direct_connection() -> Result<()> {
|
|
println!("\n=== Test: Backtesting Service Direct Connection (No Proxy) ===");
|
|
|
|
setup_test_environment().await?;
|
|
|
|
// Connect directly to Backtesting Service (bypass API Gateway)
|
|
let channel = Channel::from_static(BACKTESTING_SERVICE_URL)
|
|
.connect()
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Failed to connect to Backtesting Service: {}", e))?;
|
|
|
|
let mut client = BacktestingServiceClient::new(channel);
|
|
|
|
// Call health check
|
|
let request = Request::new(BacktestingHealthRequest {});
|
|
let response = client
|
|
.health_check(request)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Backtesting Service health check failed: {}", e))?;
|
|
|
|
let health = response.into_inner();
|
|
println!("✓ Backtesting Service health: {}", health.status);
|
|
assert_eq!(
|
|
health.status, "healthy",
|
|
"Backtesting Service should be healthy"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_backtesting_service_via_api_gateway_proxy() -> Result<()> {
|
|
println!("\n=== Test: Backtesting Service via API Gateway Proxy ===");
|
|
|
|
setup_test_environment().await?;
|
|
|
|
// Generate valid JWT token
|
|
let (token, _jti) = generate_test_token(
|
|
"test_user",
|
|
vec!["trader".to_string()],
|
|
vec!["api.access".to_string(), "backtesting.run".to_string()],
|
|
3600,
|
|
)?;
|
|
|
|
// Connect to API Gateway (which proxies to Backtesting Service)
|
|
let channel = Channel::from_static(API_GATEWAY_URL)
|
|
.connect()
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Failed to connect to API Gateway: {}", e))?;
|
|
|
|
let mut client = BacktestingServiceClient::new(channel);
|
|
|
|
// Call health check through API Gateway proxy
|
|
let mut request = Request::new(BacktestingHealthRequest {});
|
|
request.metadata_mut().insert(
|
|
"authorization",
|
|
format!("Bearer {}", token).parse().unwrap(),
|
|
);
|
|
|
|
let start = std::time::Instant::now();
|
|
let response = client
|
|
.health_check(request)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Backtesting Service health check via proxy failed: {}", e))?;
|
|
let elapsed = start.elapsed();
|
|
|
|
let health = response.into_inner();
|
|
println!("✓ Backtesting Service health via proxy: {}", health.status);
|
|
println!(" Proxy latency: {:?} (target: <1ms)", elapsed);
|
|
|
|
assert_eq!(
|
|
health.status, "healthy",
|
|
"Backtesting Service should be healthy"
|
|
);
|
|
assert!(
|
|
elapsed < Duration::from_millis(50),
|
|
"Proxy latency should be <50ms, got {:?}",
|
|
elapsed
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_backtesting_service_proxy_requires_auth() -> Result<()> {
|
|
println!("\n=== Test: Backtesting Service Proxy Requires Authentication ===");
|
|
|
|
setup_test_environment().await?;
|
|
|
|
// Connect to API Gateway WITHOUT auth token
|
|
let channel = Channel::from_static(API_GATEWAY_URL)
|
|
.connect()
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Failed to connect to API Gateway: {}", e))?;
|
|
|
|
let mut client = BacktestingServiceClient::new(channel);
|
|
|
|
// Call health check without auth header
|
|
let request = Request::new(BacktestingHealthRequest {});
|
|
let result = client.health_check(request).await;
|
|
|
|
// Should fail with UNAUTHENTICATED
|
|
assert!(
|
|
result.is_err(),
|
|
"Request without auth should be rejected by API Gateway"
|
|
);
|
|
|
|
let error = result.unwrap_err();
|
|
println!("✓ Correctly rejected: {:?}", error.code());
|
|
assert_eq!(
|
|
error.code(),
|
|
tonic::Code::Unauthenticated,
|
|
"Should return UNAUTHENTICATED"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// ML TRAINING SERVICE INTEGRATION TESTS
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_ml_training_service_direct_connection() -> Result<()> {
|
|
println!("\n=== Test: ML Training Service Direct Connection (No Proxy) ===");
|
|
|
|
setup_test_environment().await?;
|
|
|
|
// Connect directly to ML Training Service (bypass API Gateway)
|
|
let channel = Channel::from_static(ML_TRAINING_SERVICE_URL)
|
|
.connect()
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Failed to connect to ML Training Service: {}", e))?;
|
|
|
|
let mut client = MlTrainingServiceClient::new(channel);
|
|
|
|
// Call health check
|
|
let request = Request::new(MlHealthRequest {});
|
|
let response = client
|
|
.health_check(request)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("ML Training Service health check failed: {}", e))?;
|
|
|
|
let health = response.into_inner();
|
|
println!("✓ ML Training Service health: {}", health.status);
|
|
assert_eq!(
|
|
health.status, "healthy",
|
|
"ML Training Service should be healthy"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_ml_training_service_via_api_gateway_proxy() -> Result<()> {
|
|
println!("\n=== Test: ML Training Service via API Gateway Proxy ===");
|
|
|
|
setup_test_environment().await?;
|
|
|
|
// Generate valid JWT token
|
|
let (token, _jti) = generate_test_token(
|
|
"test_user",
|
|
vec!["admin".to_string()],
|
|
vec!["api.access".to_string(), "ml.train".to_string()],
|
|
3600,
|
|
)?;
|
|
|
|
// Connect to API Gateway (which proxies to ML Training Service)
|
|
let channel = Channel::from_static(API_GATEWAY_URL)
|
|
.connect()
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Failed to connect to API Gateway: {}", e))?;
|
|
|
|
let mut client = MlTrainingServiceClient::new(channel);
|
|
|
|
// Call health check through API Gateway proxy
|
|
let mut request = Request::new(MlHealthRequest {});
|
|
request.metadata_mut().insert(
|
|
"authorization",
|
|
format!("Bearer {}", token).parse().unwrap(),
|
|
);
|
|
|
|
let start = std::time::Instant::now();
|
|
let response = client
|
|
.health_check(request)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("ML Training Service health check via proxy failed: {}", e))?;
|
|
let elapsed = start.elapsed();
|
|
|
|
let health = response.into_inner();
|
|
println!("✓ ML Training Service health via proxy: {}", health.status);
|
|
println!(" Proxy latency: {:?} (target: <1ms)", elapsed);
|
|
|
|
assert_eq!(
|
|
health.status, "healthy",
|
|
"ML Training Service should be healthy"
|
|
);
|
|
assert!(
|
|
elapsed < Duration::from_millis(50),
|
|
"Proxy latency should be <50ms, got {:?}",
|
|
elapsed
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_ml_training_service_proxy_requires_auth() -> Result<()> {
|
|
println!("\n=== Test: ML Training Service Proxy Requires Authentication ===");
|
|
|
|
setup_test_environment().await?;
|
|
|
|
// Connect to API Gateway WITHOUT auth token
|
|
let channel = Channel::from_static(API_GATEWAY_URL)
|
|
.connect()
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Failed to connect to API Gateway: {}", e))?;
|
|
|
|
let mut client = MlTrainingServiceClient::new(channel);
|
|
|
|
// Call health check without auth header
|
|
let request = Request::new(MlHealthRequest {});
|
|
let result = client.health_check(request).await;
|
|
|
|
// Should fail with UNAUTHENTICATED
|
|
assert!(
|
|
result.is_err(),
|
|
"Request without auth should be rejected by API Gateway"
|
|
);
|
|
|
|
let error = result.unwrap_err();
|
|
println!("✓ Correctly rejected: {:?}", error.code());
|
|
assert_eq!(
|
|
error.code(),
|
|
tonic::Code::Unauthenticated,
|
|
"Should return UNAUTHENTICATED"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// MULTI-SERVICE INTEGRATION TESTS
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_api_gateway_routes_to_all_backend_services() -> Result<()> {
|
|
println!("\n=== Test: API Gateway Routes to All Backend Services ===");
|
|
|
|
setup_test_environment().await?;
|
|
|
|
// Generate valid JWT token
|
|
let (token, _jti) = generate_test_token(
|
|
"test_user",
|
|
vec!["admin".to_string(), "trader".to_string()],
|
|
vec![
|
|
"api.access".to_string(),
|
|
"trading.submit".to_string(),
|
|
"backtesting.run".to_string(),
|
|
"ml.train".to_string(),
|
|
],
|
|
3600,
|
|
)?;
|
|
|
|
// Connect to API Gateway once
|
|
let channel = Channel::from_static(API_GATEWAY_URL)
|
|
.connect()
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Failed to connect to API Gateway: {}", e))?;
|
|
|
|
// Test Trading Service routing
|
|
{
|
|
let mut client = TradingServiceClient::new(channel.clone());
|
|
let mut request = Request::new(TradingHealthRequest {});
|
|
request.metadata_mut().insert(
|
|
"authorization",
|
|
format!("Bearer {}", token).parse().unwrap(),
|
|
);
|
|
|
|
let response = client.health_check(request).await?;
|
|
println!(" ✓ Trading Service: {}", response.into_inner().status);
|
|
}
|
|
|
|
// Test Backtesting Service routing
|
|
{
|
|
let mut client = BacktestingServiceClient::new(channel.clone());
|
|
let mut request = Request::new(BacktestingHealthRequest {});
|
|
request.metadata_mut().insert(
|
|
"authorization",
|
|
format!("Bearer {}", token).parse().unwrap(),
|
|
);
|
|
|
|
let response = client.health_check(request).await?;
|
|
println!(
|
|
" ✓ Backtesting Service: {}",
|
|
response.into_inner().status
|
|
);
|
|
}
|
|
|
|
// Test ML Training Service routing
|
|
{
|
|
let mut client = MlTrainingServiceClient::new(channel.clone());
|
|
let mut request = Request::new(MlHealthRequest {});
|
|
request.metadata_mut().insert(
|
|
"authorization",
|
|
format!("Bearer {}", token).parse().unwrap(),
|
|
);
|
|
|
|
let response = client.health_check(request).await?;
|
|
println!(
|
|
" ✓ ML Training Service: {}",
|
|
response.into_inner().status
|
|
);
|
|
}
|
|
|
|
println!("✓ API Gateway successfully routes to all 3 backend services");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_api_gateway_proxy_latency_across_services() -> Result<()> {
|
|
println!("\n=== Test: API Gateway Proxy Latency Across All Services ===");
|
|
|
|
setup_test_environment().await?;
|
|
|
|
// Generate valid JWT token
|
|
let (token, _jti) = generate_test_token(
|
|
"test_user",
|
|
vec!["admin".to_string(), "trader".to_string()],
|
|
vec![
|
|
"api.access".to_string(),
|
|
"trading.submit".to_string(),
|
|
"backtesting.run".to_string(),
|
|
"ml.train".to_string(),
|
|
],
|
|
3600,
|
|
)?;
|
|
|
|
let channel = Channel::from_static(API_GATEWAY_URL)
|
|
.connect()
|
|
.await?;
|
|
|
|
let mut latencies = Vec::new();
|
|
|
|
// Measure Trading Service latency (10 samples)
|
|
for _ in 0..10 {
|
|
let mut client = TradingServiceClient::new(channel.clone());
|
|
let mut request = Request::new(TradingHealthRequest {});
|
|
request.metadata_mut().insert(
|
|
"authorization",
|
|
format!("Bearer {}", token).parse().unwrap(),
|
|
);
|
|
|
|
let start = std::time::Instant::now();
|
|
let _ = client.health_check(request).await?;
|
|
latencies.push(start.elapsed());
|
|
}
|
|
|
|
// Calculate P50, P95, P99
|
|
latencies.sort();
|
|
let p50 = latencies[latencies.len() / 2];
|
|
let p95 = latencies[latencies.len() * 95 / 100];
|
|
let p99 = latencies[latencies.len() * 99 / 100];
|
|
|
|
println!("\n Proxy Latency Statistics:");
|
|
println!(" ├─ P50: {:?}", p50);
|
|
println!(" ├─ P95: {:?}", p95);
|
|
println!(" └─ P99: {:?}", p99);
|
|
|
|
assert!(
|
|
p99 < Duration::from_millis(50),
|
|
"P99 latency should be <50ms, got {:?}",
|
|
p99
|
|
);
|
|
println!("✓ Proxy latency within acceptable bounds");
|
|
|
|
Ok(())
|
|
}
|