Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 3 additions & 7 deletions src/routes/rates.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
use crate::{
error::{AppError, AppResult},
services::{rate::RateService, stellar::StellarService},
AppState,
};
use axum::{
Expand All @@ -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<Arc<AppState>>,
Query(params): Query<RateQuery>,
Expand All @@ -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(&params.from, &params.to).await?;
let rate = state.rate_service.fetch_rate(&params.from, &params.to).await?;

Ok(Json(json!({
"success": true,
Expand Down
84 changes: 84 additions & 0 deletions src/services/rate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}