From 230ba51dc989f1271266d5cd1e541ce69c0d030e Mon Sep 17 00:00:00 2001 From: ghzhost Date: Wed, 2 Sep 2026 21:49:13 +0000 Subject: [PATCH] fix(rates): instantiate RateService singleton in AppState to enable cache (#36) --- src/main.rs | 5 +++ src/routes/rates.rs | 10 ++---- src/services/rate.rs | 84 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 7 deletions(-) diff --git a/src/main.rs b/src/main.rs index 4841533..a13d7dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,7 @@ pub struct AppState { pub pool: PgPool, pub config: Config, pub loop_health: BackgroundLoopHealth, + pub rate_service: services::rate::RateService, } /// Last-successful-tick timestamps for the keeper and reconciliation @@ -105,10 +106,14 @@ async fn main() -> Result<()> { let pool = db::create_pool(&config).await?; db::run_migrations(&pool).await?; + let stellar_svc = services::stellar::StellarService::new(&config.horizon_url); + let rate_service = services::rate::RateService::new(stellar_svc, config.rate_cache_ttl_secs); + let state = Arc::new(AppState { pool, config: config.clone(), loop_health: BackgroundLoopHealth::default(), + rate_service, }); // Spawn the keeper background loop: periodically scans for subscriptions diff --git a/src/routes/rates.rs b/src/routes/rates.rs index 144e6d1..362a839 100644 --- a/src/routes/rates.rs +++ b/src/routes/rates.rs @@ -1,6 +1,5 @@ use crate::{ error::{AppError, AppResult}, - services::{rate::RateService, stellar::StellarService}, AppState, }; use axum::{ @@ -26,8 +25,8 @@ pub struct RateQuery { /// GET /api/rates?from=USD&to=XLM /// /// Returns the current exchange rate between two Stellar assets by probing the -/// Stellar DEX. Results are cached server-side for `RATE_CACHE_TTL_SECS`. -/// No authentication required — rates are public information. +/// Stellar DEX. Results are cached server-side in `AppState::rate_service` for +/// `RATE_CACHE_TTL_SECS`. No authentication required — rates are public information. pub async fn get_rate( State(state): State>, Query(params): Query, @@ -38,10 +37,7 @@ pub async fn get_rate( )); } - let stellar_svc = StellarService::new(&state.config.horizon_url); - let rate_svc = RateService::new(stellar_svc, state.config.rate_cache_ttl_secs); - - let rate = rate_svc.fetch_rate(¶ms.from, ¶ms.to).await?; + let rate = state.rate_service.fetch_rate(¶ms.from, ¶ms.to).await?; Ok(Json(json!({ "success": true, diff --git a/src/services/rate.rs b/src/services/rate.rs index 6bcdd28..433c127 100644 --- a/src/services/rate.rs +++ b/src/services/rate.rs @@ -157,3 +157,87 @@ fn parse_asset_code(code: &str) -> crate::models::payment::Asset { } } } + + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::{ + matchers::{method, path}, + Mock, MockServer, ResponseTemplate, + }; + + #[tokio::test] + async fn rate_service_caches_rates_within_ttl() { + let mock_server = MockServer::start().await; + + let response_body = serde_json::json!({ + "_embedded": { + "records": [ + { + "source_asset_type": "native", + "source_amount": "1.0000000", + "destination_asset_type": "credit_alphanum4", + "destination_asset_code": "USDC", + "destination_asset_issuer": "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + "destination_amount": "0.1250000", + "path": [] + } + ] + } + }); + + // Mock Horizon /paths/strict-send endpoint expecting exactly 1 call + Mock::given(method("GET")) + .and(path("/paths/strict-send")) + .respond_with(ResponseTemplate::new(200).set_body_json(response_body)) + .expect(1) + .mount(&mock_server) + .await; + + let stellar_svc = StellarService::new(&mock_server.uri()); + let rate_svc = RateService::new(stellar_svc, 60); + + // First call: hits mock server + let rate1 = rate_svc + .fetch_rate("XLM", "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN") + .await + .expect("fetch_rate should succeed"); + assert_eq!(rate1.rate, 0.125); + + // Second call: should hit in-memory cache and NOT hit mock server again + let rate2 = rate_svc + .fetch_rate("XLM", "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN") + .await + .expect("fetch_rate should succeed from cache"); + assert_eq!(rate2.rate, 0.125); + assert_eq!(rate1.timestamp, rate2.timestamp); + } + + #[tokio::test] + async fn rate_service_falls_back_when_dex_empty() { + let mock_server = MockServer::start().await; + + let response_body = serde_json::json!({ + "_embedded": { + "records": [] + } + }); + + Mock::given(method("GET")) + .and(path("/paths/strict-send")) + .respond_with(ResponseTemplate::new(200).set_body_json(response_body)) + .expect(1) + .mount(&mock_server) + .await; + + let stellar_svc = StellarService::new(&mock_server.uri()); + let rate_svc = RateService::new(stellar_svc, 60); + + let rate = rate_svc + .fetch_rate("XLM", "USD") + .await + .expect("fetch_rate fallback should succeed"); + assert_eq!(rate.rate, 0.11); + } +}