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