1use crate::AppState;
4use axum::Json;
5use axum::extract::State;
6use axum::response::IntoResponse;
7use scope::market::{HealthOverrides, fetch_token_summary_by_name};
8use scope_cli::cli::crawl::{self, Period};
9use serde::Deserialize;
10use std::sync::Arc;
11
12#[derive(Debug, Deserialize)]
14pub struct TokenHealthRequest {
15 pub token: String,
17 #[serde(default = "super::default_chain")]
19 pub chain: String,
20 #[serde(default)]
22 pub with_market: bool,
23 #[serde(default = "super::default_venue")]
25 pub market_venue: String,
26 #[serde(flatten)]
29 pub health: HealthOverrides,
30}
31
32pub async fn handle(
37 State(state): State<Arc<AppState>>,
38 Json(req): Json<TokenHealthRequest>,
39) -> impl IntoResponse {
40 let resolved = match super::resolve_address_book(&req.token, &state.config) {
42 Ok(r) => r,
43 Err(e) => return super::error_response(super::status_for(&e), e.to_string()),
44 };
45 let token = resolved.value;
46 let chain = resolved.chain.unwrap_or(req.chain);
47
48 let analytics = match crawl::fetch_analytics_for_input(
50 &token,
51 &chain,
52 Period::Hour24,
53 10,
54 state.factory.as_ref(),
55 None,
56 &scope_cli::cli::output::Output::stdio(),
57 )
58 .await
59 {
60 Ok(a) => a,
61 Err(e) => return super::error_response(super::status_for(&e), e.to_string()),
62 };
63
64 let market_result = if req.with_market {
67 let thresholds = state.config.market.health.with_overrides(&req.health);
68 Some(fetch_token_summary_by_name(&req.market_venue, &analytics, &thresholds).await)
69 } else {
70 None
71 };
72 let (market_summary, market_error) = match market_result {
73 Some(Ok(summary)) => (Some(summary), None),
74 Some(Err(e)) => (None, Some(e.to_string())),
75 None => (None, None),
76 };
77
78 let market_json = market_summary.map(|m| m.to_json());
80
81 Json(serde_json::json!({
82 "analytics": analytics,
83 "market": market_json,
84 "market_error": market_error,
85 }))
86 .into_response()
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92 use scope::market::DexVenue;
93
94 #[test]
95 fn test_deserialize_full() {
96 let json = serde_json::json!({
97 "token": "USDC",
98 "chain": "polygon",
99 "with_market": true,
100 "market_venue": "biconomy"
101 });
102 let req: TokenHealthRequest = serde_json::from_value(json).unwrap();
103 assert_eq!(req.token, "USDC");
104 assert_eq!(req.chain, "polygon");
105 assert!(req.with_market);
106 assert_eq!(req.market_venue, "biconomy");
107 }
108
109 #[test]
110 fn test_deserialize_minimal() {
111 let json = serde_json::json!({
112 "token": "USDC"
113 });
114 let req: TokenHealthRequest = serde_json::from_value(json).unwrap();
115 assert_eq!(req.token, "USDC");
116 assert_eq!(req.chain, "ethereum");
117 assert!(!req.with_market);
118 assert_eq!(req.market_venue, "binance");
119 }
120
121 #[test]
122 fn test_defaults() {
123 assert_eq!(super::super::default_chain(), "ethereum");
124 assert_eq!(super::super::default_venue(), "binance");
125 }
126
127 #[test]
128 fn test_with_market_flag() {
129 let json = serde_json::json!({
130 "token": "USDC",
131 "with_market": true
132 });
133 let req: TokenHealthRequest = serde_json::from_value(json).unwrap();
134 assert!(req.with_market);
135
136 let json_false = serde_json::json!({
137 "token": "USDC",
138 "with_market": false
139 });
140 let req_false: TokenHealthRequest = serde_json::from_value(json_false).unwrap();
141 assert!(!req_false.with_market);
142 }
143
144 #[tokio::test]
145 async fn test_handle_token_health_direct() {
146 use crate::AppState;
147 use axum::extract::State;
148 use axum::response::IntoResponse;
149 use scope::chains::DefaultClientFactory;
150 use scope::config::Config;
151
152 let config = Config::default();
153 let http: std::sync::Arc<dyn scope::http::HttpClient> =
154 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
155 let factory = DefaultClientFactory {
156 chains_config: config.chains.clone(),
157 http,
158 };
159 let state = std::sync::Arc::new(AppState::new(config, factory));
160 let req = TokenHealthRequest {
161 token: "USDC".to_string(),
162 chain: "ethereum".to_string(),
163 with_market: false,
164 market_venue: "binance".to_string(),
165 health: HealthOverrides::default(),
166 };
167 let response = handle(State(state), axum::Json(req)).await.into_response();
168 let status = response.status();
169 assert!(status.is_success() || status.is_client_error() || status.is_server_error());
170 }
171
172 #[tokio::test]
173 async fn test_handle_token_health_with_market() {
174 use crate::AppState;
175 use axum::extract::State;
176 use axum::response::IntoResponse;
177 use scope::chains::DefaultClientFactory;
178 use scope::config::Config;
179
180 let config = Config::default();
181 let http: std::sync::Arc<dyn scope::http::HttpClient> =
182 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
183 let factory = DefaultClientFactory {
184 chains_config: config.chains.clone(),
185 http,
186 };
187 let state = std::sync::Arc::new(AppState::new(config, factory));
188 let req = TokenHealthRequest {
189 token: "USDC".to_string(),
190 chain: "ethereum".to_string(),
191 with_market: true,
192 market_venue: "eth".to_string(),
193 health: HealthOverrides::default(),
194 };
195 let response = handle(State(state), axum::Json(req)).await.into_response();
196 let status = response.status();
197 assert!(status.is_success() || status.is_client_error() || status.is_server_error());
198 }
199
200 #[tokio::test]
201 async fn test_handle_token_health_with_cex_market() {
202 use crate::AppState;
203 use axum::extract::State;
204 use axum::response::IntoResponse;
205 use scope::chains::DefaultClientFactory;
206 use scope::config::Config;
207
208 let config = Config::default();
209 let http: std::sync::Arc<dyn scope::http::HttpClient> =
210 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
211 let factory = DefaultClientFactory {
212 chains_config: config.chains.clone(),
213 http,
214 };
215 let state = std::sync::Arc::new(AppState::new(config, factory));
216 let req = TokenHealthRequest {
217 token: "USDC".to_string(),
218 chain: "ethereum".to_string(),
219 with_market: true,
220 market_venue: "binance".to_string(), health: HealthOverrides::default(),
222 };
223 let response = handle(State(state), axum::Json(req)).await.into_response();
224 let status = response.status();
225 assert!(status.is_success() || status.is_server_error());
227 }
228
229 #[test]
230 fn test_is_dex_venue() {
231 assert!(DexVenue::parse("ethereum").is_some());
232 assert!(DexVenue::parse("eth").is_some());
233 assert!(DexVenue::parse("Ethereum").is_some());
234 assert!(DexVenue::parse("ETH").is_some());
235 assert!(DexVenue::parse("solana").is_some());
236 assert!(DexVenue::parse("Solana").is_some());
237 assert!(DexVenue::parse("binance").is_none());
238 assert!(DexVenue::parse("mexc").is_none());
239 assert!(DexVenue::parse("okx").is_none());
240 assert!(DexVenue::parse("").is_none());
241 }
242
243 #[test]
244 fn test_dex_venue_to_chain() {
245 assert_eq!(
246 DexVenue::parse("ethereum").map(DexVenue::chain),
247 Some("ethereum")
248 );
249 assert_eq!(
250 DexVenue::parse("eth").map(DexVenue::chain),
251 Some("ethereum")
252 );
253 assert_eq!(
254 DexVenue::parse("Ethereum").map(DexVenue::chain),
255 Some("ethereum")
256 );
257 assert_eq!(
258 DexVenue::parse("solana").map(DexVenue::chain),
259 Some("solana")
260 );
261 assert_eq!(
262 DexVenue::parse("Solana").map(DexVenue::chain),
263 Some("solana")
264 );
265 assert_eq!(DexVenue::parse("unknown"), None); }
267
268 #[tokio::test]
269 async fn test_handle_token_health_invalid_token_error_path() {
270 use crate::AppState;
271 use axum::extract::State;
272 use axum::response::IntoResponse;
273 use scope::chains::DefaultClientFactory;
274 use scope::config::Config;
275
276 let config = Config::default();
277 let http: std::sync::Arc<dyn scope::http::HttpClient> =
278 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
279 let factory = DefaultClientFactory {
280 chains_config: config.chains.clone(),
281 http,
282 };
283 let state = std::sync::Arc::new(AppState::new(config, factory));
284 let req = TokenHealthRequest {
285 token: "INVALID_TOKEN_XYZ_NONEXISTENT".to_string(),
286 chain: "ethereum".to_string(),
287 with_market: false,
288 market_venue: "binance".to_string(),
289 health: HealthOverrides::default(),
290 };
291 let response = handle(State(state), axum::Json(req)).await.into_response();
292 let status = response.status();
293 assert!(
296 status.is_success()
297 || status == axum::http::StatusCode::NOT_FOUND
298 || status.is_server_error(),
299 "{status}"
300 );
301 }
302
303 #[test]
304 fn test_token_health_request_debug() {
305 let req = TokenHealthRequest {
306 token: "USDC".to_string(),
307 chain: "ethereum".to_string(),
308 with_market: true,
309 market_venue: "binance".to_string(),
310 health: HealthOverrides::default(),
311 };
312 let debug = format!("{:?}", req);
313 assert!(debug.contains("TokenHealthRequest"));
314 }
315
316 #[test]
317 fn test_deserialize_with_optional_venue() {
318 let json = serde_json::json!({
319 "token": "DAI",
320 "market_venue": "mexc"
321 });
322 let req: TokenHealthRequest = serde_json::from_value(json).unwrap();
323 assert_eq!(req.token, "DAI");
324 assert_eq!(req.market_venue, "mexc");
325 }
326
327 #[tokio::test]
328 async fn test_handle_token_health_success_json_structure() {
329 use crate::AppState;
330 use axum::body;
331 use axum::extract::State;
332 use axum::response::IntoResponse;
333 use scope::chains::DefaultClientFactory;
334 use scope::config::Config;
335
336 let config = Config::default();
337 let http: std::sync::Arc<dyn scope::http::HttpClient> =
338 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
339 let factory = DefaultClientFactory {
340 chains_config: config.chains.clone(),
341 http,
342 };
343 let state = std::sync::Arc::new(AppState::new(config, factory));
344 let req = TokenHealthRequest {
345 token: "USDC".to_string(),
346 chain: "ethereum".to_string(),
347 with_market: false,
348 market_venue: "binance".to_string(),
349 health: HealthOverrides::default(),
350 };
351 let response = handle(State(state), axum::Json(req)).await.into_response();
352 if response.status().is_success() {
353 let body_bytes = body::to_bytes(response.into_body(), 1_000_000)
354 .await
355 .unwrap();
356 let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
357 assert!(json.get("analytics").is_some());
358 assert!(json.get("market").is_some());
359 }
360 }
361
362 #[tokio::test]
363 async fn test_handle_token_health_label_not_found() {
364 use crate::AppState;
365 use axum::extract::State;
366 use axum::http::StatusCode;
367 use axum::response::IntoResponse;
368 use scope::chains::DefaultClientFactory;
369 use scope::config::Config;
370
371 let tmp = tempfile::tempdir().unwrap();
372 let config = Config {
373 address_book: scope::config::AddressBookConfig {
374 data_dir: Some(tmp.path().to_path_buf()),
375 },
376 ..Default::default()
377 };
378 let http: std::sync::Arc<dyn scope::http::HttpClient> =
379 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
380 let factory = DefaultClientFactory {
381 chains_config: config.chains.clone(),
382 http,
383 };
384 let state = std::sync::Arc::new(AppState::new(config, factory));
385 let req = TokenHealthRequest {
386 token: "@nonexistent".to_string(),
387 chain: "ethereum".to_string(),
388 with_market: false,
389 market_venue: "binance".to_string(),
390 health: HealthOverrides::default(),
391 };
392 let response = handle(State(state), axum::Json(req)).await.into_response();
393 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
394 let body = axum::body::to_bytes(response.into_body(), 1_000_000)
395 .await
396 .unwrap();
397 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
398 assert!(json["error"].as_str().unwrap().contains("@nonexistent"));
399 }
400}