mudra-cli 0.1.0

A robust, high-performance currency converter with caching and CLI interface
Documentation
//! Integration tests for the currency converter

use currency_converter::{
    Config, ConversionRequest, CurrencyClient, CurrencyConverter, ExchangeRateService, Result,
};
use serde_json::json;
use std::collections::HashMap;
use wiremock::{
    Mock, MockServer, ResponseTemplate,
    matchers::{method, path, path_regex},
};

/// Test helper to create a mock API server
async fn setup_mock_server() -> MockServer {
    MockServer::start().await
}

/// Create a test client pointing to the mock server
fn create_test_client(server_uri: &str) -> CurrencyClient {
    let config = Config::new()
        .with_base_url(server_uri)
        .with_api_key("test-api-key");

    CurrencyClient::with_config(config).unwrap()
}

/// Mock successful exchange rate response
fn mock_exchange_rates_response() -> serde_json::Value {
    json!({
        "result": "success",
        "base_code": "USD",
        "time_last_update_unix": 1640995200,
        "time_next_update_unix": 1641081600,
        "conversion_rates": {
            "EUR": 0.8823,
            "GBP": 0.7404,
            "JPY": 115.108,
            "CAD": 1.2773,
            "AUD": 1.3943,
            "CHF": 0.9178
        }
    })
}

/// Mock historical exchange rate response
fn mock_historical_rates_response() -> serde_json::Value {
    json!({
        "result": "success",
        "base_code": "USD",
        "year": 2024,
        "month": 1,
        "day": 1,
        "conversion_rates": {
            "EUR": 0.9020,
            "GBP": 0.7850,
            "JPY": 120.500,
            "CAD": 1.3200,
            "AUD": 1.4500
        }
    })
}

#[tokio::test]
async fn test_basic_currency_conversion() -> Result<()> {
    let mock_server = setup_mock_server().await;

    // Set up mock response
    Mock::given(method("GET"))
        .and(path_regex(r"/test-api-key/latest/USD"))
        .respond_with(ResponseTemplate::new(200).set_body_json(mock_exchange_rates_response()))
        .mount(&mock_server)
        .await;

    // Create test client and service
    let client = create_test_client(&mock_server.uri());
    let service = ExchangeRateService::new(client);
    let converter = CurrencyConverter::new(service);

    // Test conversion
    let request = ConversionRequest::from_components(100.0, "USD", "EUR")?;
    let result = converter.convert(request).await?;

    // Verify results
    assert_eq!(result.request.from.amount(), 100.0);
    assert_eq!(result.request.from.currency().code(), "USD");
    assert_eq!(result.request.to.code(), "EUR");
    assert!((result.exchange_rate - 0.8823).abs() < 0.0001);
    assert!((result.result.amount() - 88.23).abs() < 0.01);

    Ok(())
}

#[tokio::test]
async fn test_same_currency_conversion() -> Result<()> {
    // No mock server needed for same currency conversion
    let mock_server = setup_mock_server().await;
    let client = create_test_client(&mock_server.uri());
    let service = ExchangeRateService::new(client);
    let converter = CurrencyConverter::new(service);

    let request = ConversionRequest::from_components(100.0, "USD", "USD")?;
    let result = converter.convert(request).await?;

    assert_eq!(result.exchange_rate, 1.0);
    assert_eq!(result.result.amount(), 100.0);
    assert_eq!(
        result.conversion_type,
        currency_converter::ConversionType::SameCurrency
    );

    Ok(())
}

#[tokio::test]
async fn test_cross_currency_conversion() -> Result<()> {
    let mock_server = setup_mock_server().await;

    // Mock USD rates (for cross conversion via USD)
    Mock::given(method("GET"))
        .and(path_regex(r"/test-api-key/latest/USD"))
        .respond_with(ResponseTemplate::new(200).set_body_json(mock_exchange_rates_response()))
        .mount(&mock_server)
        .await;

    let client = create_test_client(&mock_server.uri());
    let service = ExchangeRateService::new(client);
    let converter = CurrencyConverter::new(service);

    // Test EUR to GBP conversion (should go via USD)
    let request = ConversionRequest::from_components(100.0, "EUR", "GBP")?;
    let result = converter.convert(request).await?;

    // Should be cross conversion
    if let currency_converter::ConversionType::Cross { via_currency } = result.conversion_type {
        assert_eq!(via_currency, "USD");
    } else {
        panic!("Expected cross conversion");
    }

    // Verify calculation: EUR -> USD -> GBP
    // 100 EUR / 0.8823 = ~113.34 USD
    // 113.34 USD * 0.7404 = ~83.91 GBP
    let expected_rate = 0.7404 / 0.8823; // GBP rate / EUR rate
    assert!((result.exchange_rate - expected_rate).abs() < 0.001);

    Ok(())
}

#[tokio::test]
async fn test_batch_conversion() -> Result<()> {
    let mock_server = setup_mock_server().await;

    Mock::given(method("GET"))
        .and(path_regex(r"/test-api-key/latest/USD"))
        .respond_with(ResponseTemplate::new(200).set_body_json(mock_exchange_rates_response()))
        .mount(&mock_server)
        .await;

    let client = create_test_client(&mock_server.uri());
    let service = ExchangeRateService::new(client);
    let converter = CurrencyConverter::new(service);

    // Test batch conversion
    let requests = vec![
        ConversionRequest::from_components(100.0, "USD", "EUR")?,
        ConversionRequest::from_components(50.0, "USD", "GBP")?,
        ConversionRequest::from_components(200.0, "USD", "JPY")?,
    ];

    let results = converter.convert_batch(requests).await;

    assert_eq!(results.len(), 3);

    // Check first result (USD to EUR)
    let eur_result = results[0].as_ref().unwrap();
    assert!((eur_result.result.amount() - 88.23).abs() < 0.01);

    // Check second result (USD to GBP)
    let gbp_result = results[1].as_ref().unwrap();
    assert!((gbp_result.result.amount() - 37.02).abs() < 0.01);

    // Check third result (USD to JPY)
    let jpy_result = results[2].as_ref().unwrap();
    assert!((jpy_result.result.amount() - 23021.6).abs() < 0.1);

    Ok(())
}

#[tokio::test]
async fn test_historical_conversion() -> Result<()> {
    let mock_server = setup_mock_server().await;

    // Mock historical rates endpoint
    Mock::given(method("GET"))
        .and(path_regex(r"/history/test-api-key/USD/2024-01-01"))
        .respond_with(ResponseTemplate::new(200).set_body_json(mock_historical_rates_response()))
        .mount(&mock_server)
        .await;

    let client = create_test_client(&mock_server.uri());
    let service = ExchangeRateService::new(client);

    // Test historical conversion
    let request = currency_converter::api::types::HistoricalConversionRequest {
        amount: 100.0,
        from: "USD".to_string(),
        to: "EUR".to_string(),
        date: "2024-01-01".to_string(),
    };

    let result = service.convert_historical(request).await?;

    assert_eq!(result.base_code, "USD");
    assert_eq!(result.target_code, "EUR");
    assert!((result.conversion_rate - 0.9020).abs() < 0.0001);
    assert!((result.conversion_result - 90.20).abs() < 0.01);

    Ok(())
}

#[tokio::test]
async fn test_caching_behavior() -> Result<()> {
    let mock_server = setup_mock_server().await;

    // Mock should only be called once due to caching
    Mock::given(method("GET"))
        .and(path_regex(r"/test-api-key/latest/USD"))
        .respond_with(ResponseTemplate::new(200).set_body_json(mock_exchange_rates_response()))
        .expect(1) // Only expect one call
        .mount(&mock_server)
        .await;

    let client = create_test_client(&mock_server.uri());
    let service = ExchangeRateService::new(client);
    let converter = CurrencyConverter::new(service);

    // First conversion - should hit API
    let request1 = ConversionRequest::from_components(100.0, "USD", "EUR")?;
    let result1 = converter.convert(request1).await?;

    // Second conversion - should use cache
    let request2 = ConversionRequest::from_components(200.0, "USD", "EUR")?;
    let result2 = converter.convert(request2).await?;

    // Both should have same exchange rate (from cache)
    assert_eq!(result1.exchange_rate, result2.exchange_rate);

    // Check cache stats
    let stats = converter.exchange_service.get_cache_stats();
    assert!(stats.hits > 0);
    assert_eq!(stats.total_requests, 2);

    Ok(())
}

#[tokio::test]
async fn test_error_handling_invalid_currency() -> Result<()> {
    let mock_server = setup_mock_server().await;
    let client = create_test_client(&mock_server.uri());
    let service = ExchangeRateService::new(client);
    let converter = CurrencyConverter::new(service);

    // Test invalid currency code
    let result = ConversionRequest::from_components(100.0, "INVALID", "USD");
    assert!(result.is_err());

    Ok(())
}

#[tokio::test]
async fn test_error_handling_invalid_amount() -> Result<()> {
    let mock_server = setup_mock_server().await;
    let client = create_test_client(&mock_server.uri());
    let service = ExchangeRateService::new(client);
    let converter = CurrencyConverter::new(service);

    // Test negative amount
    let result = ConversionRequest::from_components(-100.0, "USD", "EUR");
    assert!(result.is_err());

    // Test NaN amount
    let result = ConversionRequest::from_components(f64::NAN, "USD", "EUR");
    assert!(result.is_err());

    Ok(())
}

#[tokio::test]
async fn test_api_error_handling() -> Result<()> {
    let mock_server = setup_mock_server().await;

    // Mock API error response
    Mock::given(method("GET"))
        .and(path_regex(r"/test-api-key/latest/INVALID"))
        .respond_with(ResponseTemplate::new(400).set_body_json(json!({
            "result": "error",
            "error-type": "unsupported-code"
        })))
        .mount(&mock_server)
        .await;

    let client = create_test_client(&mock_server.uri());
    let service = ExchangeRateService::new(client);

    // This should fail with API error
    let result = service.get_latest_rates("INVALID").await;
    assert!(result.is_err());

    Ok(())
}

#[tokio::test]
async fn test_network_timeout_handling() -> Result<()> {
    let mock_server = setup_mock_server().await;

    // Mock slow response (will timeout)
    Mock::given(method("GET"))
        .and(path_regex(r"/test-api-key/latest/USD"))
        .respond_with(
            ResponseTemplate::new(200)
                .set_delay(std::time::Duration::from_secs(60)) // Longer than timeout
                .set_body_json(mock_exchange_rates_response()),
        )
        .mount(&mock_server)
        .await;

    // Create client with short timeout
    let config = Config::new()
        .with_base_url(&mock_server.uri())
        .with_api_key("test-api-key")
        .with_timeout(std::time::Duration::from_millis(100));

    let client = CurrencyClient::with_config(config).unwrap();
    let service = ExchangeRateService::new(client);

    // This should timeout
    let result = service.get_latest_rates("USD").await;
    assert!(result.is_err());

    Ok(())
}

#[tokio::test]
async fn test_cache_cleanup_and_stats() -> Result<()> {
    let mock_server = setup_mock_server().await;

    Mock::given(method("GET"))
        .and(path_regex(r"/test-api-key/latest/USD"))
        .respond_with(ResponseTemplate::new(200).set_body_json(mock_exchange_rates_response()))
        .mount(&mock_server)
        .await;

    let client = create_test_client(&mock_server.uri());
    let service = ExchangeRateService::new(client);

    // Make a request to populate cache
    let _rates = service.get_latest_rates("USD").await?;

    // Check cache stats
    let stats_before = service.get_cache_stats();
    assert!(stats_before.cached_entries > 0);

    // Clear cache
    service.clear_cache().await;

    // Check cache stats after clear
    let stats_after = service.get_cache_stats();
    assert_eq!(stats_after.cached_entries, 0);

    Ok(())
}