pub(crate) mod client;
pub(crate) mod forex;
pub(crate) mod models;
use std::time::Duration;
use crate::adapters::singleton::keyless_limiter;
use crate::error::Result;
use client::FrankfurterClient;
const FRANKFURTER_RATE_PER_SEC: f64 = 5.0;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
keyless_limiter!(rate = FRANKFURTER_RATE_PER_SEC);
fn client() -> Result<FrankfurterClient> {
FrankfurterClient::new(DEFAULT_TIMEOUT, shared_limiter(), client::FRANKFURTER_BASE)
}
pub(crate) use forex::fetch_forex_quote_response;
#[cfg(test)]
mod tests {
use super::forex::{date_to_timestamp, to_quote};
use super::*;
use crate::error::FinanceError;
use crate::rate_limiter::RateLimiter;
use std::sync::Arc;
fn test_client(base_url: &str) -> FrankfurterClient {
FrankfurterClient::new(
Duration::from_secs(5),
Arc::new(RateLimiter::new(100.0)),
base_url,
)
.unwrap()
}
fn series_payload() -> String {
serde_json::json!({
"amount": 1.0,
"base": "USD",
"start_date": "2026-07-29",
"end_date": "2026-07-31",
"rates": {
"2026-07-31": { "EUR": 0.8707 },
"2026-07-29": { "EUR": 0.87873 },
"2026-07-30": { "EUR": 0.87138 }
}
})
.to_string()
}
#[test]
fn iso_dates_become_midnight_utc() {
assert_eq!(date_to_timestamp("2026-07-31"), Some(1_785_456_000));
assert_eq!(date_to_timestamp("not a date"), None);
}
#[test]
fn quote_uses_the_latest_rate_and_the_prior_close_for_change() {
let series = vec![
("2026-07-29".to_string(), 0.87873),
("2026-07-30".to_string(), 0.87138),
("2026-07-31".to_string(), 0.8707),
];
let q = to_quote("USD", "EUR", &series).unwrap();
assert_eq!(q.symbol, "USDEUR");
assert_eq!(q.base_currency.as_deref(), Some("USD"));
assert_eq!(q.quote_currency.as_deref(), Some("EUR"));
assert_eq!(q.price, Some(0.8707));
assert_eq!(q.bid, None);
assert_eq!(q.ask, None);
let change = q.change.unwrap();
assert!((change - (0.8707 - 0.87138)).abs() < 1e-12, "{change}");
let pct = q.change_percent.unwrap();
assert!(
(pct - ((0.8707 - 0.87138) / 0.87138 * 100.0)).abs() < 1e-9,
"{pct}"
);
assert_eq!(q.timestamp, date_to_timestamp("2026-07-31"));
}
#[test]
fn a_single_published_day_yields_no_change() {
let series = vec![("2026-07-31".to_string(), 0.8707)];
let q = to_quote("USD", "EUR", &series).unwrap();
assert_eq!(q.price, Some(0.8707));
assert_eq!(q.change, None);
assert_eq!(q.change_percent, None);
}
#[test]
fn an_empty_series_yields_no_quote() {
assert!(to_quote("USD", "EUR", &[]).is_none());
}
#[tokio::test]
async fn rates_come_back_in_chronological_order() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/\d{4}-\d{2}-\d{2}\.\.$".into()),
)
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("base".into(), "USD".into()),
mockito::Matcher::UrlEncoded("symbols".into(), "EUR".into()),
]))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(series_payload())
.create_async()
.await;
let series = test_client(&server.url())
.recent_rates("USD", "EUR")
.await
.unwrap();
assert_eq!(
series.iter().map(|(d, _)| d.as_str()).collect::<Vec<_>>(),
["2026-07-29", "2026-07-30", "2026-07-31"]
);
}
#[tokio::test]
async fn unknown_currency_maps_to_symbol_not_found() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Any)
.with_status(404)
.with_header("content-type", "application/json")
.with_body(serde_json::json!({ "message": "not found" }).to_string())
.create_async()
.await;
let err = test_client(&server.url())
.recent_rates("USD", "ZZZ")
.await
.unwrap_err();
assert!(
matches!(err, FinanceError::SymbolNotFound { .. }),
"{err:?}"
);
}
#[tokio::test]
async fn invalid_pair_maps_to_invalid_parameter() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Any)
.with_status(422)
.with_header("content-type", "application/json")
.with_body(serde_json::json!({ "message": "bad currency pair" }).to_string())
.create_async()
.await;
let err = test_client(&server.url())
.recent_rates("USD", "USD")
.await
.unwrap_err();
match err {
FinanceError::InvalidParameter { reason, .. } => {
assert!(reason.contains("bad currency pair"), "got {reason}");
}
other => panic!("expected InvalidParameter, got {other:?}"),
}
}
#[tokio::test]
async fn identical_currencies_short_circuit_without_a_request() {
let q = fetch_forex_quote_response("usd", "USD").await.unwrap();
assert_eq!(q.symbol, "USDUSD");
assert_eq!(q.price, Some(1.0));
assert_eq!(q.change, Some(0.0));
}
}