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::init(&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 pid_file_path() -> std::path::PathBuf {
144 Config::default_data_dir().join("scope-web.pid")
145}
146
147pub fn log_file_path() -> std::path::PathBuf {
149 Config::default_data_dir().join("scope-web.log")
150}
151
152pub fn stop_daemon(out: &Output) -> scope::error::Result<()> {
159 let pid_path = pid_file_path();
160 if !pid_path.exists() {
161 note!(out, "No daemon PID file found at {}", pid_path.display());
162 note!(out, "Is the daemon running?");
163 return Ok(());
164 }
165
166 let pid_str = std::fs::read_to_string(&pid_path).map_err(|e| {
167 scope::error::ScopeError::io_context(e, format!("cannot read {}", pid_path.display()))
168 })?;
169 let pid: u32 = pid_str.trim().parse().map_err(|e| {
170 scope::error::ScopeError::Other(format!("Invalid PID in {}: {}", pid_path.display(), e))
171 })?;
172
173 note!(out, "Stopping Scope web daemon (PID {})...", pid);
174
175 #[cfg(unix)]
176 {
177 let result = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
179 if result == 0 {
180 note!(out, "Daemon stopped.");
181 } else {
182 note!(
183 out,
184 "Failed to stop daemon (process may have already exited)."
185 );
186 }
187 }
188
189 #[cfg(not(unix))]
190 {
191 note!(out, "Daemon stop is only supported on Unix systems.");
192 note!(out, "Please manually terminate PID {}.", pid);
193 }
194
195 let _ = std::fs::remove_file(&pid_path);
197 Ok(())
198}
199
200#[cfg(unix)]
209pub fn start_daemon(addr: SocketAddr, config: Config, out: &Output) -> scope::error::Result<()> {
210 use std::io::Write;
211
212 let _ = config; let data_dir = Config::default_data_dir();
215 std::fs::create_dir_all(&data_dir)?;
216
217 let pid_path = pid_file_path();
218 let log_path = log_file_path();
219
220 note!(out, "Starting Scope web daemon...");
221 note!(out, "URL: http://{}", addr);
222 note!(out, "PID: {}", pid_path.display());
223 note!(out, "Log: {}", log_path.display());
224
225 let log_file = std::fs::OpenOptions::new()
227 .create(true)
228 .append(true)
229 .open(&log_path)?;
230 let log_file_err = log_file.try_clone()?;
231
232 let current_exe = std::env::current_exe()?;
233 let child = std::process::Command::new(current_exe)
234 .args([
235 "web",
236 "--port",
237 &addr.port().to_string(),
238 "--bind",
239 &addr.ip().to_string(),
240 ])
241 .env("SCOPE_WEB_DAEMON_CHILD", "1")
242 .stdout(log_file)
243 .stderr(log_file_err)
244 .stdin(std::process::Stdio::null())
245 .spawn()?;
246
247 let pid = child.id();
248
249 let mut f = std::fs::File::create(&pid_path)?;
251 write!(f, "{}", pid)?;
252
253 note!(out, "Daemon started with PID {}", pid);
254 note!(out, "Stop with: scope web --stop");
255
256 Ok(())
257}
258
259#[cfg(not(unix))]
261pub fn start_daemon(addr: SocketAddr, config: Config, out: &Output) -> scope::error::Result<()> {
262 note!(out, "Daemon mode is only supported on Unix systems.");
263 note!(out, "Starting in foreground instead...");
264 let rt = tokio::runtime::Runtime::new()?;
265 rt.block_on(start_server(addr, config, out))
266 .map_err(|e| scope::error::ScopeError::Other(e.to_string()))
267}
268
269pub fn is_daemon_child() -> bool {
271 std::env::var("SCOPE_WEB_DAEMON_CHILD").is_ok()
272}
273
274#[cfg(test)]
279mod tests {
280 use super::*;
281 use axum::body;
282
283 #[test]
284 fn test_pid_file_path() {
285 let path = pid_file_path();
286 assert!(path.to_string_lossy().contains("scope-web.pid"));
287 }
288
289 #[test]
290 fn test_log_file_path() {
291 let path = log_file_path();
292 assert!(path.to_string_lossy().contains("scope-web.log"));
293 }
294
295 #[test]
296 fn test_build_router() {
297 let config = Config::default();
298 let http: std::sync::Arc<dyn scope::http::HttpClient> =
299 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
300 let factory = DefaultClientFactory {
301 chains_config: config.chains.clone(),
302 http,
303 };
304 let state = Arc::new(AppState::new(config, factory));
305 let _router = build_router(state);
306 }
307
308 #[test]
309 fn test_is_daemon_child_default() {
310 let _ = is_daemon_child();
313 }
314
315 #[tokio::test]
316 async fn test_serve_ui_index() {
317 let response = serve_ui(axum::http::Uri::from_static("/"))
318 .await
319 .into_response();
320 assert_eq!(response.status(), axum::http::StatusCode::OK);
321 let body = body::to_bytes(response.into_body(), 1_000_000)
322 .await
323 .unwrap();
324 let html = String::from_utf8(body.to_vec()).unwrap();
325 assert!(html.contains("<!DOCTYPE html>"));
326 assert!(html.contains("Scope"));
327 }
328
329 #[test]
332 fn test_ui_sends_peg_only_when_the_user_sets_it() {
333 let js = include_str!("static/app.js");
334 let html = include_str!("static/index.html");
335 assert!(!js.contains("|| 1.0"), "app.js falls back to a fixed peg");
336 assert!(
337 !html.contains(r#"id="mkt-peg" value="#),
338 "index.html fixes the peg field value"
339 );
340 assert!(
341 js.contains("market.peg_target"),
342 "peg field not filled from config"
343 );
344 assert!(js.contains("pegUserSet"), "peg not gated on a user edit");
345 }
346
347 #[test]
349 fn test_ui_hard_codes_no_dex_venue() {
350 let js = include_str!("static/app.js");
351 let html = include_str!("static/index.html");
352 for text in ["Ethereum DEX", "Solana DEX"] {
353 assert!(!js.contains(text), "app.js lists {text}");
354 assert!(!html.contains(text), "index.html lists {text}");
355 }
356 assert!(
357 js.contains("v.kind !== 'dex'"),
358 "CEX-only selects must drop DEX venues"
359 );
360 }
361
362 #[tokio::test]
363 async fn test_serve_ui_index_html() {
364 let response = serve_ui(axum::http::Uri::from_static("/index.html"))
365 .await
366 .into_response();
367 assert_eq!(response.status(), axum::http::StatusCode::OK);
368 }
369
370 #[tokio::test]
371 async fn test_serve_ui_app_js() {
372 let response = serve_ui(axum::http::Uri::from_static("/app.js"))
373 .await
374 .into_response();
375 assert_eq!(response.status(), axum::http::StatusCode::OK);
376 let body = body::to_bytes(response.into_body(), 1_000_000)
377 .await
378 .unwrap();
379 let js = String::from_utf8(body.to_vec()).unwrap();
380 assert!(js.contains("function") || js.contains("const") || js.contains("var"));
381 }
382
383 #[tokio::test]
384 async fn test_serve_ui_style_css() {
385 let response = serve_ui(axum::http::Uri::from_static("/style.css"))
386 .await
387 .into_response();
388 assert_eq!(response.status(), axum::http::StatusCode::OK);
389 let body = body::to_bytes(response.into_body(), 1_000_000)
390 .await
391 .unwrap();
392 let css = String::from_utf8(body.to_vec()).unwrap();
393 assert!(css.contains(":root") || css.contains("body") || css.contains("nav"));
394 }
395
396 #[tokio::test]
397 async fn test_serve_ui_spa_fallback() {
398 let response = serve_ui(axum::http::Uri::from_static("/some/random/path"))
400 .await
401 .into_response();
402 assert_eq!(response.status(), axum::http::StatusCode::OK);
403 let body = body::to_bytes(response.into_body(), 1_000_000)
404 .await
405 .unwrap();
406 let html = String::from_utf8(body.to_vec()).unwrap();
407 assert!(html.contains("<!DOCTYPE html>"));
408 }
409
410 #[tokio::test]
411 async fn test_serve_ui_favicon_svg() {
412 let response = serve_ui(axum::http::Uri::from_static("/favicon.svg"))
413 .await
414 .into_response();
415 assert_eq!(response.status(), axum::http::StatusCode::OK);
416 let headers = response.headers();
417 let ct = headers.get(axum::http::header::CONTENT_TYPE).unwrap();
418 assert_eq!(ct, "image/svg+xml");
419 }
420
421 #[tokio::test]
422 async fn test_serve_ui_favicon_ico_redirect() {
423 let response = serve_ui(axum::http::Uri::from_static("/favicon.ico"))
424 .await
425 .into_response();
426 assert_eq!(
427 response.status(),
428 axum::http::StatusCode::PERMANENT_REDIRECT
429 );
430 let headers = response.headers();
431 let loc = headers.get(axum::http::header::LOCATION).unwrap();
432 assert_eq!(loc, "/favicon.svg");
433 }
434
435 #[tokio::test]
436 async fn test_route_ws_monitor_handshake() {
437 use axum::http::{Request, StatusCode};
438 use tower::ServiceExt;
439
440 let state = test_state();
441 let app = build_router(state);
442
443 let req = Request::builder()
445 .uri("/ws/monitor?token=USDC")
446 .method("GET")
447 .header("Connection", "Upgrade")
448 .header("Upgrade", "websocket")
449 .header("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
450 .header("Sec-WebSocket-Version", "13")
451 .body(axum::body::Body::empty())
452 .unwrap();
453
454 let response = app.oneshot(req).await.unwrap();
455 let status = response.status();
457 assert!(
458 status == StatusCode::SWITCHING_PROTOCOLS
459 || status == StatusCode::UPGRADE_REQUIRED
460 || status.is_success(),
461 "Unexpected status: {}",
462 status
463 );
464 }
465
466 #[tokio::test]
467 async fn test_route_ws_monitor_missing_token() {
468 use axum::http::Request;
469 use tower::ServiceExt;
470
471 let state = test_state();
472 let app = build_router(state);
473
474 let req = Request::builder()
475 .uri("/ws/monitor")
476 .method("GET")
477 .body(axum::body::Body::empty())
478 .unwrap();
479
480 let response = app.oneshot(req).await.unwrap();
481 assert!(response.status().is_client_error());
483 }
484
485 #[test]
486 fn test_pid_file_path_in_data_dir() {
487 let path = pid_file_path();
488 assert!(path.ends_with("scope-web.pid"));
489 assert!(path.parent().is_some());
490 }
491
492 #[test]
493 fn test_log_file_path_in_data_dir() {
494 let path = log_file_path();
495 assert!(path.ends_with("scope-web.log"));
496 assert!(path.parent().is_some());
497 }
498
499 #[test]
500 fn test_app_state_construction() {
501 let config = Config::default();
502 let http: std::sync::Arc<dyn scope::http::HttpClient> =
503 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
504 let factory = DefaultClientFactory {
505 chains_config: config.chains.clone(),
506 http,
507 };
508 let state = AppState::new(config, factory);
509 let _ = state.config.chains.api_keys.len();
511 }
512
513 #[test]
514 fn test_stop_daemon_no_pid_file() {
515 let pid = pid_file_path();
519 let log = log_file_path();
520 assert_eq!(pid.parent(), log.parent());
521 }
522
523 #[test]
524 fn test_stop_daemon_invoked_no_pid_file() {
525 let (out, _) = Output::capture();
527 let result = stop_daemon(&out);
528 assert!(result.is_ok());
529 }
530
531 fn test_state() -> Arc<AppState> {
533 let config = Config::default();
534 let http: std::sync::Arc<dyn scope::http::HttpClient> =
535 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
536 let factory = DefaultClientFactory {
537 chains_config: config.chains.clone(),
538 http,
539 };
540 Arc::new(AppState::new(config, factory))
541 }
542
543 #[tokio::test]
548 async fn test_route_get_config_status() {
549 use axum::http::{Request, StatusCode};
550 use tower::ServiceExt;
551
552 let state = test_state();
553 let app = build_router(state);
554
555 let req = Request::builder()
556 .uri("/api/config/status")
557 .method("GET")
558 .body(axum::body::Body::empty())
559 .unwrap();
560
561 let response = app.oneshot(req).await.unwrap();
562 assert_eq!(response.status(), StatusCode::OK);
563
564 let body = body::to_bytes(response.into_body(), 1_000_000)
565 .await
566 .unwrap();
567 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
568 assert!(json.get("config_path").is_some());
569 assert!(json.get("api_keys").is_some());
570 assert!(json.get("version").is_some());
571 }
572
573 #[tokio::test]
574 async fn test_route_post_config_save() {
575 use axum::http::{Request, StatusCode, header};
576 use tower::ServiceExt;
577
578 let state = test_state();
579 let app = build_router(state);
580
581 let payload = serde_json::json!({
582 "api_keys": {},
583 "rpc_endpoints": {}
584 });
585
586 let req = Request::builder()
587 .uri("/api/config")
588 .method("POST")
589 .header(header::CONTENT_TYPE, "application/json")
590 .body(axum::body::Body::from(payload.to_string()))
591 .unwrap();
592
593 let response = app.oneshot(req).await.unwrap();
594 let status = response.status();
596 assert!(status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR);
597 }
598
599 #[tokio::test]
600 async fn test_route_post_address() {
601 use axum::http::{Request, StatusCode, header};
602 use tower::ServiceExt;
603
604 let state = test_state();
605 let app = build_router(state);
606
607 let payload = serde_json::json!({
608 "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
609 "chain": "ethereum"
610 });
611
612 let req = Request::builder()
613 .uri("/api/address")
614 .method("POST")
615 .header(header::CONTENT_TYPE, "application/json")
616 .body(axum::body::Body::from(payload.to_string()))
617 .unwrap();
618
619 let response = app.oneshot(req).await.unwrap();
620 let status = response.status();
622 assert!(
623 status == StatusCode::OK
624 || status == StatusCode::BAD_REQUEST
625 || status == StatusCode::INTERNAL_SERVER_ERROR
626 );
627 }
628
629 #[tokio::test]
630 async fn test_route_post_tx() {
631 use axum::http::{Request, StatusCode, header};
632 use tower::ServiceExt;
633
634 let state = test_state();
635 let app = build_router(state);
636
637 let payload = serde_json::json!({
638 "hash": "0xabc123def456789012345678901234567890123456789012345678901234abcd",
639 "chain": "ethereum"
640 });
641
642 let req = Request::builder()
643 .uri("/api/tx")
644 .method("POST")
645 .header(header::CONTENT_TYPE, "application/json")
646 .body(axum::body::Body::from(payload.to_string()))
647 .unwrap();
648
649 let response = app.oneshot(req).await.unwrap();
650 let status = response.status();
651 assert!(
652 status == StatusCode::OK
653 || status == StatusCode::BAD_REQUEST
654 || status == StatusCode::INTERNAL_SERVER_ERROR
655 );
656 }
657
658 #[tokio::test]
659 async fn test_route_post_crawl() {
660 use axum::http::{Request, StatusCode, header};
661 use tower::ServiceExt;
662
663 let state = test_state();
664 let app = build_router(state);
665
666 let payload = serde_json::json!({
667 "token": "USDC",
668 "chain": "ethereum"
669 });
670
671 let req = Request::builder()
672 .uri("/api/crawl")
673 .method("POST")
674 .header(header::CONTENT_TYPE, "application/json")
675 .body(axum::body::Body::from(payload.to_string()))
676 .unwrap();
677
678 let response = app.oneshot(req).await.unwrap();
679 let status = response.status();
680 assert!(
681 status == StatusCode::OK
682 || status == StatusCode::BAD_REQUEST
683 || status == StatusCode::INTERNAL_SERVER_ERROR
684 );
685 }
686
687 #[tokio::test]
688 async fn test_route_get_discover() {
689 use axum::http::{Request, StatusCode};
690 use tower::ServiceExt;
691
692 let state = test_state();
693 let app = build_router(state);
694
695 let req = Request::builder()
696 .uri("/api/discover?source=profiles&limit=5")
697 .method("GET")
698 .body(axum::body::Body::empty())
699 .unwrap();
700
701 let response = app.oneshot(req).await.unwrap();
702 let status = response.status();
703 assert!(status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR);
704 }
705
706 #[tokio::test]
707 async fn test_route_post_insights() {
708 use axum::http::{Request, StatusCode, header};
709 use tower::ServiceExt;
710
711 let state = test_state();
712 let app = build_router(state);
713
714 let payload = serde_json::json!({
715 "target": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
716 });
717
718 let req = Request::builder()
719 .uri("/api/insights")
720 .method("POST")
721 .header(header::CONTENT_TYPE, "application/json")
722 .body(axum::body::Body::from(payload.to_string()))
723 .unwrap();
724
725 let response = app.oneshot(req).await.unwrap();
726 let status = response.status();
727 assert!(
728 status == StatusCode::OK
729 || status == StatusCode::BAD_REQUEST
730 || status == StatusCode::INTERNAL_SERVER_ERROR
731 );
732 }
733
734 #[tokio::test]
735 async fn test_route_post_compliance_risk() {
736 use axum::http::{Request, StatusCode, header};
737 use tower::ServiceExt;
738
739 let state = test_state();
740 let app = build_router(state);
741
742 let payload = serde_json::json!({
743 "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
744 "chain": "ethereum",
745 "detailed": true
746 });
747
748 let req = Request::builder()
749 .uri("/api/compliance/risk")
750 .method("POST")
751 .header(header::CONTENT_TYPE, "application/json")
752 .body(axum::body::Body::from(payload.to_string()))
753 .unwrap();
754
755 let response = app.oneshot(req).await.unwrap();
756 let status = response.status();
757 assert!(
758 status == StatusCode::OK
759 || status == StatusCode::BAD_REQUEST
760 || status == StatusCode::INTERNAL_SERVER_ERROR
761 );
762 }
763
764 #[tokio::test]
765 async fn test_route_post_export() {
766 use axum::http::{Request, StatusCode, header};
767 use tower::ServiceExt;
768
769 let state = test_state();
770 let app = build_router(state);
771
772 let payload = serde_json::json!({
773 "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
774 "chain": "ethereum",
775 "format": "json"
776 });
777
778 let req = Request::builder()
779 .uri("/api/export")
780 .method("POST")
781 .header(header::CONTENT_TYPE, "application/json")
782 .body(axum::body::Body::from(payload.to_string()))
783 .unwrap();
784
785 let response = app.oneshot(req).await.unwrap();
786 let status = response.status();
787 assert!(
788 status == StatusCode::OK
789 || status == StatusCode::BAD_REQUEST
790 || status == StatusCode::INTERNAL_SERVER_ERROR
791 );
792 }
793
794 #[tokio::test]
795 async fn test_route_post_token_health() {
796 use axum::http::{Request, StatusCode, header};
797 use tower::ServiceExt;
798
799 let state = test_state();
800 let app = build_router(state);
801
802 let payload = serde_json::json!({
803 "token": "USDC",
804 "chain": "ethereum",
805 "with_market": false
806 });
807
808 let req = Request::builder()
809 .uri("/api/token-health")
810 .method("POST")
811 .header(header::CONTENT_TYPE, "application/json")
812 .body(axum::body::Body::from(payload.to_string()))
813 .unwrap();
814
815 let response = app.oneshot(req).await.unwrap();
816 let status = response.status();
817 assert!(
818 status == StatusCode::OK
819 || status == StatusCode::BAD_REQUEST
820 || status == StatusCode::INTERNAL_SERVER_ERROR
821 );
822 }
823
824 #[tokio::test]
825 async fn test_route_post_contract() {
826 use axum::http::{Request, StatusCode, header};
827 use tower::ServiceExt;
828
829 let state = test_state();
830 let app = build_router(state);
831
832 let payload = serde_json::json!({
833 "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
834 "chain": "ethereum"
835 });
836
837 let req = Request::builder()
838 .uri("/api/contract")
839 .method("POST")
840 .header(header::CONTENT_TYPE, "application/json")
841 .body(axum::body::Body::from(payload.to_string()))
842 .unwrap();
843
844 let response = app.oneshot(req).await.unwrap();
845 let status = response.status();
846 assert!(
847 status == StatusCode::OK
848 || status == StatusCode::BAD_REQUEST
849 || status == StatusCode::INTERNAL_SERVER_ERROR
850 );
851 }
852
853 #[tokio::test]
854 async fn test_route_post_market_summary() {
855 use axum::http::{Request, StatusCode, header};
856 use tower::ServiceExt;
857
858 let state = test_state();
859 let app = build_router(state);
860
861 let payload = serde_json::json!({
862 "pair": "USDC",
863 "market_venue": "binance"
864 });
865
866 let req = Request::builder()
867 .uri("/api/market/summary")
868 .method("POST")
869 .header(header::CONTENT_TYPE, "application/json")
870 .body(axum::body::Body::from(payload.to_string()))
871 .unwrap();
872
873 let response = app.oneshot(req).await.unwrap();
874 let status = response.status();
875 assert!(
876 status == StatusCode::OK
877 || status == StatusCode::BAD_REQUEST
878 || status == StatusCode::INTERNAL_SERVER_ERROR
879 );
880 }
881
882 #[tokio::test]
883 async fn test_route_get_address_book_list() {
884 use axum::http::{Request, StatusCode};
885 use tower::ServiceExt;
886
887 let state = test_state();
888 let app = build_router(state);
889
890 let req = Request::builder()
891 .uri("/api/address-book/list")
892 .method("GET")
893 .body(axum::body::Body::empty())
894 .unwrap();
895
896 let response = app.oneshot(req).await.unwrap();
897 let status = response.status();
898 assert!(status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR);
899 }
900
901 #[tokio::test]
902 async fn test_route_post_address_book_add() {
903 use axum::http::{Request, StatusCode, header};
904 use tower::ServiceExt;
905
906 let state = test_state();
907 let app = build_router(state);
908
909 let payload = serde_json::json!({
910 "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
911 "chain": "ethereum",
912 "label": "Test Wallet"
913 });
914
915 let req = Request::builder()
916 .uri("/api/address-book/add")
917 .method("POST")
918 .header(header::CONTENT_TYPE, "application/json")
919 .body(axum::body::Body::from(payload.to_string()))
920 .unwrap();
921
922 let response = app.oneshot(req).await.unwrap();
923 let status = response.status();
924 assert!(
925 status == StatusCode::OK
926 || status == StatusCode::BAD_REQUEST
927 || status == StatusCode::INTERNAL_SERVER_ERROR
928 );
929 }
930
931 #[tokio::test]
932 async fn test_route_invalid_json_body() {
933 use axum::http::{Request, header};
934 use tower::ServiceExt;
935
936 let state = test_state();
937 let app = build_router(state);
938
939 let req = Request::builder()
940 .uri("/api/address")
941 .method("POST")
942 .header(header::CONTENT_TYPE, "application/json")
943 .body(axum::body::Body::from("not json"))
944 .unwrap();
945
946 let response = app.oneshot(req).await.unwrap();
947 assert!(response.status().is_client_error());
949 }
950
951 #[tokio::test]
952 async fn test_route_missing_required_field() {
953 use axum::http::{Request, header};
954 use tower::ServiceExt;
955
956 let state = test_state();
957 let app = build_router(state);
958
959 let payload = serde_json::json!({ "chain": "ethereum" });
961
962 let req = Request::builder()
963 .uri("/api/address")
964 .method("POST")
965 .header(header::CONTENT_TYPE, "application/json")
966 .body(axum::body::Body::from(payload.to_string()))
967 .unwrap();
968
969 let response = app.oneshot(req).await.unwrap();
970 assert!(response.status().is_client_error());
971 }
972
973 #[tokio::test]
974 async fn test_route_static_fallback() {
975 use axum::http::{Request, StatusCode};
976 use tower::ServiceExt;
977
978 let state = test_state();
979 let app = build_router(state);
980
981 let req = Request::builder()
982 .uri("/nonexistent-path")
983 .method("GET")
984 .body(axum::body::Body::empty())
985 .unwrap();
986
987 let response = app.oneshot(req).await.unwrap();
988 assert_eq!(response.status(), StatusCode::OK);
989 }
990}