From 9a19b6c8bdf50ba56f38c23985cc640775bdf9fe Mon Sep 17 00:00:00 2001 From: Gabriel Comte Date: Mon, 30 Mar 2026 09:44:35 +0200 Subject: [PATCH] Add mock-based unit tests for ExchangeRateProvider Tests cover: correct inverse calculation, caching behavior (fetch_api called only once), missing currency panic, zero rate producing infinity, and multi-currency lookups. All run offline without hitting the API. --- src/fiat_rates/exchange_rate_provider.rs | 99 ++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/src/fiat_rates/exchange_rate_provider.rs b/src/fiat_rates/exchange_rate_provider.rs index e4dadda..cb236f0 100644 --- a/src/fiat_rates/exchange_rate_provider.rs +++ b/src/fiat_rates/exchange_rate_provider.rs @@ -23,3 +23,102 @@ impl ExchangeRateProvider { } } } + +#[cfg(test)] +mod tests { + use super::*; + + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct MockApiConsumer { + fetch_count: &'static AtomicUsize, + } + + impl ExchangeRateApiConsumer for MockApiConsumer { + fn fetch_api(&self) -> HashMap { + self.fetch_count.fetch_add(1, Ordering::SeqCst); + let mut rates = HashMap::new(); + rates.insert(Fiat::USD, 50_000.0); + rates.insert(Fiat::EUR, 45_000.0); + rates.insert(Fiat::JPY, 7_500_000.0); + rates + } + } + + fn mock_provider_with_data(rates: HashMap) -> ExchangeRateProvider { + static UNUSED: AtomicUsize = AtomicUsize::new(0); + ExchangeRateProvider { + data_source: MockApiConsumer { + fetch_count: &UNUSED, + }, + data: Some(rates), + } + } + + fn mock_provider_with_fetch( + counter: &'static AtomicUsize, + ) -> ExchangeRateProvider { + ExchangeRateProvider { + data_source: MockApiConsumer { + fetch_count: counter, + }, + data: None, + } + } + + #[test] + fn btc_value_returns_inverse_of_rate() { + let mut rates = HashMap::new(); + rates.insert(Fiat::USD, 50_000.0); + let mut provider = mock_provider_with_data(rates); + + let btc_value = provider.btc_value(&Fiat::USD); + assert!((btc_value - 1.0 / 50_000.0).abs() < f64::EPSILON); + } + + #[test] + fn data_is_cached_after_first_fetch() { + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let mut provider = mock_provider_with_fetch(&COUNTER); + + assert!(provider.data.is_none()); + provider.btc_value(&Fiat::USD); + assert!(provider.data.is_some()); + assert_eq!(COUNTER.load(Ordering::SeqCst), 1); + + // Second call uses cached data — fetch_api not called again + provider.btc_value(&Fiat::EUR); + assert_eq!(COUNTER.load(Ordering::SeqCst), 1); + } + + #[test] + #[should_panic(expected = "called `Option::unwrap()` on a `None` value")] + fn missing_currency_panics() { + let rates = HashMap::new(); + let mut provider = mock_provider_with_data(rates); + provider.btc_value(&Fiat::USD); + } + + #[test] + fn zero_rate_produces_infinity() { + let mut rates = HashMap::new(); + rates.insert(Fiat::USD, 0.0); + let mut provider = mock_provider_with_data(rates); + + let btc_value = provider.btc_value(&Fiat::USD); + assert!(btc_value.is_infinite()); + } + + #[test] + fn multiple_currencies_return_correct_values() { + let mut rates = HashMap::new(); + rates.insert(Fiat::USD, 50_000.0); + rates.insert(Fiat::EUR, 45_000.0); + rates.insert(Fiat::JPY, 7_500_000.0); + let mut provider = mock_provider_with_data(rates); + + assert!((provider.btc_value(&Fiat::USD) - 1.0 / 50_000.0).abs() < f64::EPSILON); + assert!((provider.btc_value(&Fiat::EUR) - 1.0 / 45_000.0).abs() < f64::EPSILON); + assert!((provider.btc_value(&Fiat::JPY) - 1.0 / 7_500_000.0).abs() < f64::EPSILON); + } +}