1pub mod api;
25pub mod auth;
26pub mod monitor;
27
28use axum::Router;
29use axum::response::IntoResponse;
30use scope::chains::{ChainClientFactory, DefaultClientFactory};
31use scope::config::Config;
32use scope_cli::cli::output::Output;
33use scope_cli::note;
34use std::net::SocketAddr;
35use std::sync::Arc;
36use tower_http::cors::{Any, CorsLayer};
37
38pub struct AppState {
40 pub config: Config,
42 pub factory: Box<dyn ChainClientFactory>,
45}
46
47impl AppState {
48 pub fn new(config: Config, factory: impl ChainClientFactory + 'static) -> Self {
50 Self {
51 config,
52 factory: Box::new(factory),
53 }
54 }
55}
56
57pub fn build_router(state: Arc<AppState>) -> Router {
59 let cors = CorsLayer::new()
60 .allow_origin(Any)
61 .allow_methods(Any)
62 .allow_headers(Any);
63
64 let auth_token = state.config.web.auth_token.clone();
65 let api_routes = api::routes(state.clone()).layer(axum::middleware::from_fn(
66 move |req: axum::http::Request<axum::body::Body>, next: axum::middleware::Next| {
67 let token = auth_token.clone();
68 async move { auth::auth_middleware(req, next, token).await }
69 },
70 ));
71
72 Router::new()
73 .nest("/api", api_routes)
74 .route("/ws/monitor", axum::routing::get(monitor::ws_handler))
75 .fallback(axum::routing::get(serve_ui))
76 .layer(cors)
77 .with_state(state)
78}
79
80async fn serve_ui(uri: axum::http::Uri) -> impl axum::response::IntoResponse {
82 let path = uri.path().trim_start_matches('/');
83
84 match path {
86 "" | "index.html" => {
87 axum::response::Html(include_str!("static/index.html")).into_response()
88 }
89 "app.js" => (
90 [(axum::http::header::CONTENT_TYPE, "application/javascript")],
91 include_str!("static/app.js"),
92 )
93 .into_response(),
94 "style.css" => (
95 [(axum::http::header::CONTENT_TYPE, "text/css")],
96 include_str!("static/style.css"),
97 )
98 .into_response(),
99 "favicon.svg" => (
100 [(axum::http::header::CONTENT_TYPE, "image/svg+xml")],
101 include_str!("static/favicon.svg"),
102 )
103 .into_response(),
104 "favicon.ico" => {
105 axum::response::Redirect::permanent("/favicon.svg").into_response()
107 }
108 _ => axum::response::Html(include_str!("static/index.html")).into_response(),
110 }
111}
112
113pub async fn start_server(addr: SocketAddr, config: Config, out: &Output) -> anyhow::Result<()> {
117 let built = scope::http::build(&config)?;
118 for notice in &built.notices {
119 tracing::warn!("{notice}");
120 }
121 let http = built.client;
122 let factory = DefaultClientFactory {
123 chains_config: config.chains.clone(),
124 http,
125 };
126 let state = Arc::new(AppState::new(config, factory));
127 let app = build_router(state);
128
129 tracing::info!("Scope web server listening on http://{}", addr);
130 note!(out, "Scope web server listening on http://{}", addr);
131
132 let listener = tokio::net::TcpListener::bind(addr).await?;
133 axum::serve(listener, app).await?;
134
135 Ok(())
136}
137
138pub fn daemon_dir() -> std::path::PathBuf {
144 scope::paths::state_dir()
145}
146
147pub fn pid_file_path() -> std::path::PathBuf {
149 daemon_dir().join("scope-web.pid")
150}
151
152pub fn log_file_path() -> std::path::PathBuf {
154 daemon_dir().join("scope-web.log")
155}
156
157pub fn legacy_pid_file_path() -> Option<std::path::PathBuf> {
161 scope::paths::user_home().map(|h| h.join(".config/scope/data/scope-web.pid"))
162}
163
164fn pid_file_to_stop(
166 current: std::path::PathBuf,
167 legacy: Option<std::path::PathBuf>,
168) -> Option<std::path::PathBuf> {
169 std::iter::once(current).chain(legacy).find(|p| p.exists())
170}
171
172pub fn stop_daemon(out: &Output) -> scope::error::Result<()> {
179 let Some(pid_path) = pid_file_to_stop(pid_file_path(), legacy_pid_file_path()) else {
180 note!(
181 out,
182 "No daemon PID file found at {}",
183 pid_file_path().display()
184 );
185 note!(out, "Is the daemon running?");
186 return Ok(());
187 };
188
189 let pid_str = std::fs::read_to_string(&pid_path).map_err(|e| {
190 scope::error::ScopeError::io_context(e, format!("cannot read {}", pid_path.display()))
191 })?;
192 let pid: u32 = pid_str.trim().parse().map_err(|e| {
193 scope::error::ScopeError::Other(format!("Invalid PID in {}: {}", pid_path.display(), e))
194 })?;
195
196 note!(out, "Stopping Scope web daemon (PID {})...", pid);
197
198 #[cfg(unix)]
199 {
200 let result = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
202 if result == 0 {
203 note!(out, "Daemon stopped.");
204 } else {
205 note!(
206 out,
207 "Failed to stop daemon (process may have already exited)."
208 );
209 }
210 }
211
212 #[cfg(not(unix))]
213 {
214 note!(out, "Daemon stop is only supported on Unix systems.");
215 note!(out, "Please manually terminate PID {}.", pid);
216 }
217
218 let _ = std::fs::remove_file(&pid_path);
220 Ok(())
221}
222
223#[cfg(unix)]
232pub fn start_daemon(addr: SocketAddr, config: Config, out: &Output) -> scope::error::Result<()> {
233 use std::io::Write;
234
235 let _ = config; std::fs::create_dir_all(daemon_dir())?;
238
239 let pid_path = pid_file_path();
240 let log_path = log_file_path();
241
242 note!(out, "Starting Scope web daemon...");
243 note!(out, "URL: http://{}", addr);
244 note!(out, "PID: {}", pid_path.display());
245 note!(out, "Log: {}", log_path.display());
246
247 let log_file = std::fs::OpenOptions::new()
249 .create(true)
250 .append(true)
251 .open(&log_path)?;
252 let log_file_err = log_file.try_clone()?;
253
254 let current_exe = std::env::current_exe()?;
255 let child = std::process::Command::new(current_exe)
256 .args([
257 "web",
258 "--port",
259 &addr.port().to_string(),
260 "--bind",
261 &addr.ip().to_string(),
262 ])
263 .env("SCOPE_WEB_DAEMON_CHILD", "1")
264 .stdout(log_file)
265 .stderr(log_file_err)
266 .stdin(std::process::Stdio::null())
267 .spawn()?;
268
269 let pid = child.id();
270
271 let mut f = std::fs::File::create(&pid_path)?;
273 write!(f, "{}", pid)?;
274
275 note!(out, "Daemon started with PID {}", pid);
276 note!(out, "Stop with: scope web --stop");
277
278 Ok(())
279}
280
281#[cfg(not(unix))]
283pub fn start_daemon(addr: SocketAddr, config: Config, out: &Output) -> scope::error::Result<()> {
284 note!(out, "Daemon mode is only supported on Unix systems.");
285 note!(out, "Starting in foreground instead...");
286 let rt = tokio::runtime::Runtime::new()?;
287 rt.block_on(start_server(addr, config, out))
288 .map_err(|e| scope::error::ScopeError::Other(e.to_string()))
289}
290
291pub fn is_daemon_child() -> bool {
293 std::env::var("SCOPE_WEB_DAEMON_CHILD").is_ok()
294}
295
296#[cfg(test)]
301mod tests {
302 use super::*;
303 use axum::body;
304
305 #[test]
306 fn test_pid_file_path() {
307 let path = pid_file_path();
308 assert!(path.to_string_lossy().contains("scope-web.pid"));
309 }
310
311 #[test]
312 fn test_log_file_path() {
313 let path = log_file_path();
314 assert!(path.to_string_lossy().contains("scope-web.log"));
315 }
316
317 #[test]
318 fn test_build_router() {
319 let config = Config::default();
320 let http: std::sync::Arc<dyn scope::http::HttpClient> =
321 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
322 let factory = DefaultClientFactory {
323 chains_config: config.chains.clone(),
324 http,
325 };
326 let state = Arc::new(AppState::new(config, factory));
327 let _router = build_router(state);
328 }
329
330 #[test]
331 fn test_the_daemon_creates_the_folder_its_files_go_in() {
332 assert_eq!(pid_file_path().parent(), Some(daemon_dir().as_path()));
335 assert_eq!(log_file_path().parent(), Some(daemon_dir().as_path()));
336 assert_eq!(daemon_dir(), scope::paths::state_dir());
337 }
338
339 #[test]
340 fn test_is_daemon_child_default() {
341 let _ = is_daemon_child();
344 }
345
346 #[tokio::test]
347 async fn test_serve_ui_index() {
348 let response = serve_ui(axum::http::Uri::from_static("/"))
349 .await
350 .into_response();
351 assert_eq!(response.status(), axum::http::StatusCode::OK);
352 let body = body::to_bytes(response.into_body(), 1_000_000)
353 .await
354 .unwrap();
355 let html = String::from_utf8(body.to_vec()).unwrap();
356 assert!(html.contains("<!DOCTYPE html>"));
357 assert!(html.contains("Scope"));
358 }
359
360 #[test]
363 fn test_ui_sends_peg_only_when_the_user_sets_it() {
364 let js = include_str!("static/app.js");
365 let html = include_str!("static/index.html");
366 assert!(!js.contains("|| 1.0"), "app.js falls back to a fixed peg");
367 assert!(
368 !html.contains(r#"id="mkt-peg" value="#),
369 "index.html fixes the peg field value"
370 );
371 assert!(
372 js.contains("market.peg_target"),
373 "peg field not filled from config"
374 );
375 assert!(js.contains("pegUserSet"), "peg not gated on a user edit");
376 }
377
378 #[test]
380 fn test_ui_hard_codes_no_dex_venue() {
381 let js = include_str!("static/app.js");
382 let html = include_str!("static/index.html");
383 for text in ["Ethereum DEX", "Solana DEX"] {
384 assert!(!js.contains(text), "app.js lists {text}");
385 assert!(!html.contains(text), "index.html lists {text}");
386 }
387 assert!(
388 js.contains("v.kind !== 'dex'"),
389 "CEX-only selects must drop DEX venues"
390 );
391 }
392
393 #[tokio::test]
394 async fn test_serve_ui_index_html() {
395 let response = serve_ui(axum::http::Uri::from_static("/index.html"))
396 .await
397 .into_response();
398 assert_eq!(response.status(), axum::http::StatusCode::OK);
399 }
400
401 #[tokio::test]
402 async fn test_serve_ui_app_js() {
403 let response = serve_ui(axum::http::Uri::from_static("/app.js"))
404 .await
405 .into_response();
406 assert_eq!(response.status(), axum::http::StatusCode::OK);
407 let body = body::to_bytes(response.into_body(), 1_000_000)
408 .await
409 .unwrap();
410 let js = String::from_utf8(body.to_vec()).unwrap();
411 assert!(js.contains("function") || js.contains("const") || js.contains("var"));
412 }
413
414 #[tokio::test]
415 async fn test_serve_ui_style_css() {
416 let response = serve_ui(axum::http::Uri::from_static("/style.css"))
417 .await
418 .into_response();
419 assert_eq!(response.status(), axum::http::StatusCode::OK);
420 let body = body::to_bytes(response.into_body(), 1_000_000)
421 .await
422 .unwrap();
423 let css = String::from_utf8(body.to_vec()).unwrap();
424 assert!(css.contains(":root") || css.contains("body") || css.contains("nav"));
425 }
426
427 #[tokio::test]
428 async fn test_serve_ui_spa_fallback() {
429 let response = serve_ui(axum::http::Uri::from_static("/some/random/path"))
431 .await
432 .into_response();
433 assert_eq!(response.status(), axum::http::StatusCode::OK);
434 let body = body::to_bytes(response.into_body(), 1_000_000)
435 .await
436 .unwrap();
437 let html = String::from_utf8(body.to_vec()).unwrap();
438 assert!(html.contains("<!DOCTYPE html>"));
439 }
440
441 #[tokio::test]
442 async fn test_serve_ui_favicon_svg() {
443 let response = serve_ui(axum::http::Uri::from_static("/favicon.svg"))
444 .await
445 .into_response();
446 assert_eq!(response.status(), axum::http::StatusCode::OK);
447 let headers = response.headers();
448 let ct = headers.get(axum::http::header::CONTENT_TYPE).unwrap();
449 assert_eq!(ct, "image/svg+xml");
450 }
451
452 #[tokio::test]
453 async fn test_serve_ui_favicon_ico_redirect() {
454 let response = serve_ui(axum::http::Uri::from_static("/favicon.ico"))
455 .await
456 .into_response();
457 assert_eq!(
458 response.status(),
459 axum::http::StatusCode::PERMANENT_REDIRECT
460 );
461 let headers = response.headers();
462 let loc = headers.get(axum::http::header::LOCATION).unwrap();
463 assert_eq!(loc, "/favicon.svg");
464 }
465
466 #[tokio::test]
467 async fn test_route_ws_monitor_handshake() {
468 use axum::http::{Request, StatusCode};
469 use tower::ServiceExt;
470
471 let state = test_state();
472 let app = build_router(state);
473
474 let req = Request::builder()
476 .uri("/ws/monitor?token=USDC")
477 .method("GET")
478 .header("Connection", "Upgrade")
479 .header("Upgrade", "websocket")
480 .header("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
481 .header("Sec-WebSocket-Version", "13")
482 .body(axum::body::Body::empty())
483 .unwrap();
484
485 let response = app.oneshot(req).await.unwrap();
486 let status = response.status();
488 assert!(
489 status == StatusCode::SWITCHING_PROTOCOLS
490 || status == StatusCode::UPGRADE_REQUIRED
491 || status.is_success(),
492 "Unexpected status: {}",
493 status
494 );
495 }
496
497 #[tokio::test]
498 async fn test_route_ws_monitor_missing_token() {
499 use axum::http::Request;
500 use tower::ServiceExt;
501
502 let state = test_state();
503 let app = build_router(state);
504
505 let req = Request::builder()
506 .uri("/ws/monitor")
507 .method("GET")
508 .body(axum::body::Body::empty())
509 .unwrap();
510
511 let response = app.oneshot(req).await.unwrap();
512 assert!(response.status().is_client_error());
514 }
515
516 #[test]
517 fn test_pid_file_path_in_data_dir() {
518 let path = pid_file_path();
519 assert!(path.ends_with("scope-web.pid"));
520 assert!(path.parent().is_some());
521 }
522
523 #[test]
524 fn test_stop_finds_the_current_pid_file_else_the_old_one() {
525 let d = tempfile::tempdir().unwrap();
528 let current = d.path().join("state/scope-web.pid");
529 let legacy = d.path().join(".config/scope/data/scope-web.pid");
530 assert_eq!(
531 pid_file_to_stop(current.clone(), Some(legacy.clone())),
532 None
533 );
534 std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
535 std::fs::write(&legacy, "1\n").unwrap();
536 assert_eq!(
537 pid_file_to_stop(current.clone(), Some(legacy.clone())),
538 Some(legacy.clone())
539 );
540 std::fs::create_dir_all(current.parent().unwrap()).unwrap();
541 std::fs::write(¤t, "2\n").unwrap();
542 assert_eq!(
543 pid_file_to_stop(current.clone(), Some(legacy)),
544 Some(current),
545 "the current file wins"
546 );
547 assert!(
548 legacy_pid_file_path()
549 .unwrap()
550 .ends_with(".config/scope/data/scope-web.pid")
551 );
552 }
553
554 #[test]
555 fn test_log_file_path_in_data_dir() {
556 let path = log_file_path();
557 assert!(path.ends_with("scope-web.log"));
558 assert!(path.parent().is_some());
559 }
560
561 #[test]
562 fn test_app_state_construction() {
563 let config = Config::default();
564 let http: std::sync::Arc<dyn scope::http::HttpClient> =
565 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
566 let factory = DefaultClientFactory {
567 chains_config: config.chains.clone(),
568 http,
569 };
570 let state = AppState::new(config, factory);
571 let _ = state.config.chains.api_keys.len();
573 }
574
575 #[test]
576 fn test_stop_daemon_no_pid_file() {
577 let pid = pid_file_path();
581 let log = log_file_path();
582 assert_eq!(pid.parent(), log.parent());
583 }
584
585 #[test]
589 fn test_stop_daemon_reads_the_pid_file() {
590 let pid_path = pid_file_path();
591 std::fs::create_dir_all(daemon_dir()).unwrap();
592 let _ = std::fs::remove_file(&pid_path);
593 if let Some(legacy) = legacy_pid_file_path() {
594 let _ = std::fs::remove_file(legacy);
595 }
596
597 let (out, cap) = Output::capture();
599 stop_daemon(&out).unwrap();
600 assert!(
601 cap.err().contains("No daemon PID file found"),
602 "{}",
603 cap.err()
604 );
605 assert!(cap.err().contains("Is the daemon running?"));
606
607 std::fs::write(&pid_path, "not-a-pid\n").unwrap();
609 let (out, _) = Output::capture();
610 let err = stop_daemon(&out).unwrap_err();
611 assert!(err.to_string().contains("Invalid PID in"), "{err}");
612 assert!(pid_path.exists());
613 std::fs::remove_file(&pid_path).unwrap();
614
615 std::fs::create_dir(&pid_path).unwrap();
617 let (out, _) = Output::capture();
618 let err = stop_daemon(&out).unwrap_err();
619 assert!(err.to_string().contains("cannot read"), "{err}");
620 std::fs::remove_dir(&pid_path).unwrap();
621
622 let pid = i32::MAX;
625 std::fs::write(&pid_path, format!("{pid}\n")).unwrap();
626 let (out, cap) = Output::capture();
627 stop_daemon(&out).unwrap();
628 let notes = cap.err();
629 assert!(
630 notes.contains(&format!("Stopping Scope web daemon (PID {pid})")),
631 "{notes}"
632 );
633 #[cfg(unix)]
634 assert!(notes.contains("Failed to stop daemon"), "{notes}");
635 #[cfg(not(unix))]
636 assert!(
637 notes.contains(&format!("Please manually terminate PID {pid}")),
638 "{notes}"
639 );
640 assert!(!pid_path.exists(), "the PID file is removed");
641 }
642
643 fn free_addr() -> SocketAddr {
645 std::net::TcpListener::bind("127.0.0.1:0")
646 .unwrap()
647 .local_addr()
648 .unwrap()
649 }
650
651 #[tokio::test]
652 async fn test_start_server_serves_the_ui_and_says_where() {
653 use tokio::io::{AsyncReadExt, AsyncWriteExt};
654 let addr = free_addr();
655 let (out, cap) = Output::capture();
656 let server = start_server(addr, Config::default(), &out);
657 let client = async {
658 let mut stream = loop {
659 match tokio::net::TcpStream::connect(addr).await {
660 Ok(s) => break s,
661 Err(_) => tokio::time::sleep(std::time::Duration::from_millis(20)).await,
662 }
663 };
664 stream
665 .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
666 .await
667 .unwrap();
668 let mut reply = String::new();
669 stream.read_to_string(&mut reply).await.unwrap();
670 reply
671 };
672 let reply = tokio::select! {
673 r = server => panic!("the server stopped: {r:?}"),
674 reply = tokio::time::timeout(std::time::Duration::from_secs(10), client) => {
675 reply.expect("a reply within 10s")
676 }
677 };
678 assert!(reply.starts_with("HTTP/1.1 200"), "{reply}");
679 assert!(reply.contains("<!DOCTYPE html>"));
680 assert!(
681 cap.err()
682 .contains(&format!("Scope web server listening on http://{addr}")),
683 "{}",
684 cap.err()
685 );
686 }
687
688 #[tokio::test]
689 async fn test_start_server_fails_when_the_port_is_taken() {
690 let taken = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
691 let addr = taken.local_addr().unwrap();
692 let (out, _) = Output::capture();
693 let result = tokio::time::timeout(
694 std::time::Duration::from_secs(10),
695 start_server(addr, Config::default(), &out),
696 )
697 .await
698 .expect("start_server returns");
699 assert!(result.is_err(), "bound twice to {addr}");
700 }
701
702 fn test_state() -> Arc<AppState> {
704 let config = Config::default();
705 let http: std::sync::Arc<dyn scope::http::HttpClient> =
706 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
707 let factory = DefaultClientFactory {
708 chains_config: config.chains.clone(),
709 http,
710 };
711 Arc::new(AppState::new(config, factory))
712 }
713
714 #[tokio::test]
719 async fn test_route_get_config_status() {
720 use axum::http::{Request, StatusCode};
721 use tower::ServiceExt;
722
723 let state = test_state();
724 let app = build_router(state);
725
726 let req = Request::builder()
727 .uri("/api/config/status")
728 .method("GET")
729 .body(axum::body::Body::empty())
730 .unwrap();
731
732 let response = app.oneshot(req).await.unwrap();
733 assert_eq!(response.status(), StatusCode::OK);
734
735 let body = body::to_bytes(response.into_body(), 1_000_000)
736 .await
737 .unwrap();
738 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
739 assert!(json.get("config_path").is_some());
740 assert!(json.get("api_keys").is_some());
741 assert!(json.get("version").is_some());
742 }
743
744 #[tokio::test]
745 async fn test_route_post_config_save() {
746 use axum::http::{Request, StatusCode, header};
747 use tower::ServiceExt;
748
749 let state = test_state();
750 let app = build_router(state);
751
752 let payload = serde_json::json!({
753 "api_keys": {},
754 "rpc_endpoints": {}
755 });
756
757 let req = Request::builder()
758 .uri("/api/config")
759 .method("POST")
760 .header(header::CONTENT_TYPE, "application/json")
761 .body(axum::body::Body::from(payload.to_string()))
762 .unwrap();
763
764 let response = app.oneshot(req).await.unwrap();
765 let status = response.status();
767 assert!(status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR);
768 }
769
770 #[tokio::test]
771 async fn test_route_post_address() {
772 use axum::http::{Request, StatusCode, header};
773 use tower::ServiceExt;
774
775 let state = test_state();
776 let app = build_router(state);
777
778 let payload = serde_json::json!({
779 "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
780 "chain": "ethereum"
781 });
782
783 let req = Request::builder()
784 .uri("/api/address")
785 .method("POST")
786 .header(header::CONTENT_TYPE, "application/json")
787 .body(axum::body::Body::from(payload.to_string()))
788 .unwrap();
789
790 let response = app.oneshot(req).await.unwrap();
791 let status = response.status();
793 assert!(
794 status == StatusCode::OK
795 || status == StatusCode::BAD_REQUEST
796 || status == StatusCode::INTERNAL_SERVER_ERROR
797 );
798 }
799
800 #[tokio::test]
801 async fn test_route_post_tx() {
802 use axum::http::{Request, StatusCode, header};
803 use tower::ServiceExt;
804
805 let state = test_state();
806 let app = build_router(state);
807
808 let payload = serde_json::json!({
809 "hash": "0xabc123def456789012345678901234567890123456789012345678901234abcd",
810 "chain": "ethereum"
811 });
812
813 let req = Request::builder()
814 .uri("/api/tx")
815 .method("POST")
816 .header(header::CONTENT_TYPE, "application/json")
817 .body(axum::body::Body::from(payload.to_string()))
818 .unwrap();
819
820 let response = app.oneshot(req).await.unwrap();
821 let status = response.status();
822 assert!(
823 status == StatusCode::OK
824 || status == StatusCode::BAD_REQUEST
825 || status == StatusCode::INTERNAL_SERVER_ERROR
826 );
827 }
828
829 #[tokio::test]
830 async fn test_route_post_crawl() {
831 use axum::http::{Request, StatusCode, header};
832 use tower::ServiceExt;
833
834 let state = test_state();
835 let app = build_router(state);
836
837 let payload = serde_json::json!({
838 "token": "USDC",
839 "chain": "ethereum"
840 });
841
842 let req = Request::builder()
843 .uri("/api/crawl")
844 .method("POST")
845 .header(header::CONTENT_TYPE, "application/json")
846 .body(axum::body::Body::from(payload.to_string()))
847 .unwrap();
848
849 let response = app.oneshot(req).await.unwrap();
850 let status = response.status();
851 assert!(
852 status == StatusCode::OK
853 || status == StatusCode::BAD_REQUEST
854 || status == StatusCode::INTERNAL_SERVER_ERROR
855 );
856 }
857
858 #[tokio::test]
859 async fn test_route_get_discover() {
860 use axum::http::{Request, StatusCode};
861 use tower::ServiceExt;
862
863 let state = test_state();
864 let app = build_router(state);
865
866 let req = Request::builder()
867 .uri("/api/discover?source=profiles&limit=5")
868 .method("GET")
869 .body(axum::body::Body::empty())
870 .unwrap();
871
872 let response = app.oneshot(req).await.unwrap();
873 let status = response.status();
874 assert!(status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR);
875 }
876
877 #[tokio::test]
878 async fn test_route_post_insights() {
879 use axum::http::{Request, StatusCode, header};
880 use tower::ServiceExt;
881
882 let state = test_state();
883 let app = build_router(state);
884
885 let payload = serde_json::json!({
886 "target": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
887 });
888
889 let req = Request::builder()
890 .uri("/api/insights")
891 .method("POST")
892 .header(header::CONTENT_TYPE, "application/json")
893 .body(axum::body::Body::from(payload.to_string()))
894 .unwrap();
895
896 let response = app.oneshot(req).await.unwrap();
897 let status = response.status();
898 assert!(
899 status == StatusCode::OK
900 || status == StatusCode::BAD_REQUEST
901 || status == StatusCode::INTERNAL_SERVER_ERROR
902 );
903 }
904
905 #[tokio::test]
906 async fn test_route_post_compliance_risk() {
907 use axum::http::{Request, StatusCode, header};
908 use tower::ServiceExt;
909
910 let state = test_state();
911 let app = build_router(state);
912
913 let payload = serde_json::json!({
914 "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
915 "chain": "ethereum",
916 "detailed": true
917 });
918
919 let req = Request::builder()
920 .uri("/api/compliance/risk")
921 .method("POST")
922 .header(header::CONTENT_TYPE, "application/json")
923 .body(axum::body::Body::from(payload.to_string()))
924 .unwrap();
925
926 let response = app.oneshot(req).await.unwrap();
927 let status = response.status();
928 assert!(
929 status == StatusCode::OK
930 || status == StatusCode::BAD_REQUEST
931 || status == StatusCode::INTERNAL_SERVER_ERROR
932 );
933 }
934
935 #[tokio::test]
936 async fn test_route_post_export() {
937 use axum::http::{Request, StatusCode, header};
938 use tower::ServiceExt;
939
940 let state = test_state();
941 let app = build_router(state);
942
943 let payload = serde_json::json!({
944 "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
945 "chain": "ethereum",
946 "format": "json"
947 });
948
949 let req = Request::builder()
950 .uri("/api/export")
951 .method("POST")
952 .header(header::CONTENT_TYPE, "application/json")
953 .body(axum::body::Body::from(payload.to_string()))
954 .unwrap();
955
956 let response = app.oneshot(req).await.unwrap();
957 let status = response.status();
958 assert!(
959 status == StatusCode::OK
960 || status == StatusCode::BAD_REQUEST
961 || status == StatusCode::INTERNAL_SERVER_ERROR
962 );
963 }
964
965 #[tokio::test]
966 async fn test_route_post_token_health() {
967 use axum::http::{Request, StatusCode, header};
968 use tower::ServiceExt;
969
970 let state = test_state();
971 let app = build_router(state);
972
973 let payload = serde_json::json!({
974 "token": "USDC",
975 "chain": "ethereum",
976 "with_market": false
977 });
978
979 let req = Request::builder()
980 .uri("/api/token-health")
981 .method("POST")
982 .header(header::CONTENT_TYPE, "application/json")
983 .body(axum::body::Body::from(payload.to_string()))
984 .unwrap();
985
986 let response = app.oneshot(req).await.unwrap();
987 let status = response.status();
988 assert!(
989 status == StatusCode::OK
990 || status == StatusCode::BAD_REQUEST
991 || status == StatusCode::INTERNAL_SERVER_ERROR
992 );
993 }
994
995 #[tokio::test]
996 async fn test_route_post_contract() {
997 use axum::http::{Request, StatusCode, header};
998 use tower::ServiceExt;
999
1000 let state = test_state();
1001 let app = build_router(state);
1002
1003 let payload = serde_json::json!({
1004 "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1005 "chain": "ethereum"
1006 });
1007
1008 let req = Request::builder()
1009 .uri("/api/contract")
1010 .method("POST")
1011 .header(header::CONTENT_TYPE, "application/json")
1012 .body(axum::body::Body::from(payload.to_string()))
1013 .unwrap();
1014
1015 let response = app.oneshot(req).await.unwrap();
1016 let status = response.status();
1017 assert!(
1018 status == StatusCode::OK
1019 || status == StatusCode::BAD_REQUEST
1020 || status == StatusCode::INTERNAL_SERVER_ERROR
1021 );
1022 }
1023
1024 #[tokio::test]
1025 async fn test_route_post_market_summary() {
1026 use axum::http::{Request, StatusCode, header};
1027 use tower::ServiceExt;
1028
1029 let state = test_state();
1030 let app = build_router(state);
1031
1032 let payload = serde_json::json!({
1033 "pair": "USDC",
1034 "market_venue": "binance"
1035 });
1036
1037 let req = Request::builder()
1038 .uri("/api/market/summary")
1039 .method("POST")
1040 .header(header::CONTENT_TYPE, "application/json")
1041 .body(axum::body::Body::from(payload.to_string()))
1042 .unwrap();
1043
1044 let response = app.oneshot(req).await.unwrap();
1045 let status = response.status();
1046 assert!(
1047 status == StatusCode::OK
1048 || status == StatusCode::BAD_REQUEST
1049 || status == StatusCode::INTERNAL_SERVER_ERROR
1050 );
1051 }
1052
1053 #[tokio::test]
1054 async fn test_route_get_address_book_list() {
1055 use axum::http::{Request, StatusCode};
1056 use tower::ServiceExt;
1057
1058 let state = test_state();
1059 let app = build_router(state);
1060
1061 let req = Request::builder()
1062 .uri("/api/address-book/list")
1063 .method("GET")
1064 .body(axum::body::Body::empty())
1065 .unwrap();
1066
1067 let response = app.oneshot(req).await.unwrap();
1068 let status = response.status();
1069 assert!(status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR);
1070 }
1071
1072 #[tokio::test]
1073 async fn test_route_post_address_book_add() {
1074 use axum::http::{Request, StatusCode, header};
1075 use tower::ServiceExt;
1076
1077 let state = test_state();
1078 let app = build_router(state);
1079
1080 let payload = serde_json::json!({
1081 "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1082 "chain": "ethereum",
1083 "label": "Test Wallet"
1084 });
1085
1086 let req = Request::builder()
1087 .uri("/api/address-book/add")
1088 .method("POST")
1089 .header(header::CONTENT_TYPE, "application/json")
1090 .body(axum::body::Body::from(payload.to_string()))
1091 .unwrap();
1092
1093 let response = app.oneshot(req).await.unwrap();
1094 let status = response.status();
1095 assert!(
1096 status == StatusCode::OK
1097 || status == StatusCode::BAD_REQUEST
1098 || status == StatusCode::INTERNAL_SERVER_ERROR
1099 );
1100 }
1101
1102 #[tokio::test]
1103 async fn test_route_invalid_json_body() {
1104 use axum::http::{Request, header};
1105 use tower::ServiceExt;
1106
1107 let state = test_state();
1108 let app = build_router(state);
1109
1110 let req = Request::builder()
1111 .uri("/api/address")
1112 .method("POST")
1113 .header(header::CONTENT_TYPE, "application/json")
1114 .body(axum::body::Body::from("not json"))
1115 .unwrap();
1116
1117 let response = app.oneshot(req).await.unwrap();
1118 assert!(response.status().is_client_error());
1120 }
1121
1122 #[tokio::test]
1123 async fn test_route_missing_required_field() {
1124 use axum::http::{Request, header};
1125 use tower::ServiceExt;
1126
1127 let state = test_state();
1128 let app = build_router(state);
1129
1130 let payload = serde_json::json!({ "chain": "ethereum" });
1132
1133 let req = Request::builder()
1134 .uri("/api/address")
1135 .method("POST")
1136 .header(header::CONTENT_TYPE, "application/json")
1137 .body(axum::body::Body::from(payload.to_string()))
1138 .unwrap();
1139
1140 let response = app.oneshot(req).await.unwrap();
1141 assert!(response.status().is_client_error());
1142 }
1143
1144 #[tokio::test]
1145 async fn test_route_static_fallback() {
1146 use axum::http::{Request, StatusCode};
1147 use tower::ServiceExt;
1148
1149 let state = test_state();
1150 let app = build_router(state);
1151
1152 let req = Request::builder()
1153 .uri("/nonexistent-path")
1154 .method("GET")
1155 .body(axum::body::Body::empty())
1156 .unwrap();
1157
1158 let response = app.oneshot(req).await.unwrap();
1159 assert_eq!(response.status(), StatusCode::OK);
1160 }
1161}