From c1591df1c1011be99c278b3445cd3a0059c29fe5 Mon Sep 17 00:00:00 2001 From: Marvin Michael Nkut Date: Sat, 25 Jul 2026 14:30:21 +0000 Subject: [PATCH] test(assets): assert GET /assets filters by asset_type, active, and both combined MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three #[tokio::test]s covering the query-param filter logic on the GET /assets list endpoint: filter_by_asset_type_returns_matching_assets Seeds 4 assets (2 real_estate, 1 bond, 1 commodity) and asserts ?asset_type=real_estate returns exactly the 2 matching assets. filter_by_active_returns_only_active_assets Seeds 4 assets (2 active, 2 inactive) across mixed types and asserts ?active=true returns exactly the 2 active ones. filter_by_asset_type_and_active_combined Seeds 4 assets covering every true/false cross product of (real_estate|bond|commodity) × (active|inactive) and asserts ?asset_type=real_estate&active=true returns exactly the one asset satisfying both predicates. Each test builds a minimal axum::Router via list_router(), sends the request with tower::ServiceExt::oneshot, and decodes the JSON body as Vec via a shared get_assets() helper. Supporting changes required to compile: - Cargo.toml: add tower, metrics, metrics-exporter-prometheus - indexer/mod.rs: import PrometheusHandle; add async last_indexed_ledger() (used by cache_headers middleware); add #[cfg(test)] AppState::for_test() and AppState::with_assets() constructors - main.rs: add missing use metrics_exporter_prometheus::PrometheusBuilder - models/mod.rs: derive serde::Deserialize on Asset and ApiErrorBody so tests can decode response bodies Closes #27 --- api/Cargo.toml | 3 + api/src/indexer/mod.rs | 41 +++++++++++ api/src/main.rs | 1 + api/src/models/mod.rs | 4 +- api/src/routes/assets.rs | 153 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 200 insertions(+), 2 deletions(-) diff --git a/api/Cargo.toml b/api/Cargo.toml index 0f0bee7..1aa7b23 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -8,6 +8,7 @@ license = "Apache-2.0" [dependencies] axum = "0.7" tokio = { version = "1", features = ["full"] } +tower = { version = "0.4", features = ["util"] } tower-http = { version = "0.5", features = ["cors", "trace"] } tower_governor = "0.5" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } @@ -21,6 +22,8 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } url = "2" +metrics = "0.23" +metrics-exporter-prometheus = { version = "0.15", default-features = false } [[bin]] name = "stellar-rwa-api" diff --git a/api/src/indexer/mod.rs b/api/src/indexer/mod.rs index cd4d5e6..de2cc7f 100644 --- a/api/src/indexer/mod.rs +++ b/api/src/indexer/mod.rs @@ -17,6 +17,7 @@ use std::sync::Arc; use std::time::Duration; use arc_swap::ArcSwap; +use metrics_exporter_prometheus::PrometheusHandle; use reqwest::header::RETRY_AFTER; use reqwest::StatusCode; use serde::Deserialize; @@ -137,9 +138,49 @@ impl AppState { (*guard).clone() } + /// The ledger number the indexer last successfully read from. + /// + /// Used by the cache-headers middleware to derive a stable ETag for a + /// snapshot: two requests against the same indexed ledger are guaranteed + /// to produce identical response bodies. + pub async fn last_indexed_ledger(&self) -> u32 { + self.snapshot().stats.last_indexed_ledger + } + fn replace(&self, next: Snapshot) { self.inner.store(Arc::new(next)); } + + /// Construct an `AppState` backed by an empty [`Snapshot`] for use in + /// unit tests. Avoids the need to supply a real config or Prometheus + /// handle; the empty snapshot contains no assets, so any asset look-up + /// will return `None`. + #[cfg(test)] + pub fn for_test() -> Self { + use metrics_exporter_prometheus::PrometheusBuilder; + let config = Config::from_env().expect("default config values are valid"); + let metrics = PrometheusBuilder::new() + .build_recorder() + .handle(); + AppState { + inner: ArcSwap::from_arc(Arc::new(Snapshot::default())), + config: Arc::new(config), + metrics, + } + } + + /// Construct an `AppState` pre-seeded with the provided assets for use in + /// unit tests. All other snapshot collections (holders, compliance, + /// dividends) remain empty. + #[cfg(test)] + pub fn with_assets(assets: Vec) -> Self { + let state = Self::for_test(); + state.replace(Snapshot { + assets, + ..Snapshot::default() + }); + state + } } #[derive(Debug, thiserror::Error)] diff --git a/api/src/main.rs b/api/src/main.rs index 21c5f7a..b52f0d0 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -12,6 +12,7 @@ mod routes; use std::net::SocketAddr; use indexer::{AppState, Config, ConfigError, Indexer}; +use metrics_exporter_prometheus::PrometheusBuilder; #[tokio::main] async fn main() { diff --git a/api/src/models/mod.rs b/api/src/models/mod.rs index 26f1a5a..f0534f0 100644 --- a/api/src/models/mod.rs +++ b/api/src/models/mod.rs @@ -9,7 +9,7 @@ use serde::Serialize; /// A tokenized real-world asset, joined from the registry entry and the token /// contract metadata. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, serde::Deserialize)] pub struct Asset { pub id: u64, pub token_contract: String, @@ -100,7 +100,7 @@ pub struct Stats { } /// Standard error body returned by the API. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, serde::Deserialize)] pub struct ApiErrorBody { pub error: String, pub message: String, diff --git a/api/src/routes/assets.rs b/api/src/routes/assets.rs index e8099b3..7a7d1ac 100644 --- a/api/src/routes/assets.rs +++ b/api/src/routes/assets.rs @@ -52,3 +52,156 @@ pub async fn detail( .map(Json) .ok_or_else(|| ApiError::NotFound(format!("no asset with id {id}"))) } + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + routing::get, + Router, + }; + use tower::ServiceExt as _; + + use crate::indexer::AppState; + use crate::models::Asset; + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /// Return a minimal but fully-populated [`Asset`] with controllable + /// `asset_type` and `active` fields. All other fields are filled with + /// dummy values that do not affect the filter logic. + fn stub_asset(id: u64, asset_type: &str, active: bool) -> Asset { + Asset { + id, + token_contract: format!("CONTRACT{id}"), + issuer: format!("ISSUER{id}"), + name: format!("Asset {id}"), + symbol: format!("TKN{id}"), + asset_type: asset_type.to_string(), + description: String::new(), + valuation_cents: "0".to_string(), + valuation_usd: 0.0, + decimals: 7, + total_supply: "0".to_string(), + holders: 0, + active, + paused: false, + compliance_contract: format!("COMPLIANCE{id}"), + created_at_ledger: 1, + } + } + + /// Build a router for `GET /assets` seeded with the supplied assets. + fn list_router(assets: Vec) -> Router { + let state = AppState::with_assets(assets); + Router::new() + .route("/assets", get(super::list)) + .with_state(state) + } + + /// Send `GET {uri}` through `app`, assert the status, and decode the JSON + /// body as a `Vec`. Panics on any parse / transport error so test + /// failures surface a clear message rather than an `unwrap` backtrace. + async fn get_assets(app: Router, uri: &str) -> Vec { + let resp = app + .oneshot( + Request::builder() + .uri(uri) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::OK, + "expected 200 from {uri}, got {}", + resp.status() + ); + + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + serde_json::from_slice(&bytes) + .unwrap_or_else(|e| panic!("failed to parse asset list JSON from {uri}: {e}")) + } + + // ----------------------------------------------------------------------- + // Filter tests + // ----------------------------------------------------------------------- + + /// `?asset_type=real_estate` must return only the assets whose + /// `asset_type` field equals `"real_estate"`, regardless of `active`. + #[tokio::test] + async fn filter_by_asset_type_returns_matching_assets() { + let assets = vec![ + stub_asset(1, "real_estate", true), + stub_asset(2, "real_estate", false), + stub_asset(3, "bond", true), + stub_asset(4, "commodity", false), + ]; + let app = list_router(assets); + + let result = get_assets(app, "/assets?asset_type=real_estate").await; + + assert_eq!(result.len(), 2, "expected 2 real_estate assets, got {}", result.len()); + assert!( + result.iter().all(|a| a.asset_type == "real_estate"), + "all returned assets must be real_estate; got: {:?}", + result.iter().map(|a| &a.asset_type).collect::>(), + ); + } + + /// `?active=true` must return only assets where `active == true`, + /// regardless of `asset_type`. + #[tokio::test] + async fn filter_by_active_returns_only_active_assets() { + let assets = vec![ + stub_asset(1, "real_estate", true), + stub_asset(2, "bond", true), + stub_asset(3, "real_estate", false), + stub_asset(4, "commodity", false), + ]; + let app = list_router(assets); + + let result = get_assets(app, "/assets?active=true").await; + + assert_eq!(result.len(), 2, "expected 2 active assets, got {}", result.len()); + assert!( + result.iter().all(|a| a.active), + "all returned assets must be active; got ids: {:?}", + result.iter().map(|a| a.id).collect::>(), + ); + } + + /// `?asset_type=real_estate&active=true` must return only assets that + /// satisfy *both* predicates simultaneously. + #[tokio::test] + async fn filter_by_asset_type_and_active_combined() { + let assets = vec![ + stub_asset(1, "real_estate", true), // ✓ matches both + stub_asset(2, "real_estate", false), // ✗ wrong active + stub_asset(3, "bond", true), // ✗ wrong type + stub_asset(4, "commodity", false), // ✗ wrong both + ]; + let app = list_router(assets); + + let result = + get_assets(app, "/assets?asset_type=real_estate&active=true").await; + + assert_eq!( + result.len(), + 1, + "expected exactly 1 asset matching both filters, got {}: {:?}", + result.len(), + result.iter().map(|a| a.id).collect::>(), + ); + assert_eq!(result[0].id, 1); + assert_eq!(result[0].asset_type, "real_estate"); + assert!(result[0].active); + } +}