1pub mod api;
25pub mod auth;
26pub mod monitor;
27
28use axum::Router;
29use axum::response::IntoResponse;
30use scope::chains::DefaultClientFactory;
31use scope::config::Config;
32use std::net::SocketAddr;
33use std::sync::Arc;
34use tower_http::cors::{Any, CorsLayer};
35
36pub struct AppState {
38 pub config: Config,
40 pub factory: DefaultClientFactory,
42}
43
44pub fn build_router(state: Arc<AppState>) -> Router {
46 let cors = CorsLayer::new()
47 .allow_origin(Any)
48 .allow_methods(Any)
49 .allow_headers(Any);
50
51 let auth_token = state.config.web.auth_token.clone();
52 let api_routes = api::routes(state.clone()).layer(axum::middleware::from_fn(
53 move |req: axum::http::Request<axum::body::Body>, next: axum::middleware::Next| {
54 let token = auth_token.clone();
55 async move { auth::auth_middleware(req, next, token).await }
56 },
57 ));
58
59 Router::new()
60 .nest("/api", api_routes)
61 .route("/ws/monitor", axum::routing::get(monitor::ws_handler))
62 .fallback(axum::routing::get(serve_ui))
63 .layer(cors)
64 .with_state(state)
65}
66
67async fn serve_ui(uri: axum::http::Uri) -> impl axum::response::IntoResponse {
69 let path = uri.path().trim_start_matches('/');
70
71 match path {
73 "" | "index.html" => {
74 axum::response::Html(include_str!("static/index.html")).into_response()
75 }
76 "app.js" => (
77 [(axum::http::header::CONTENT_TYPE, "application/javascript")],
78 include_str!("static/app.js"),
79 )
80 .into_response(),
81 "style.css" => (
82 [(axum::http::header::CONTENT_TYPE, "text/css")],
83 include_str!("static/style.css"),
84 )
85 .into_response(),
86 "favicon.svg" => (
87 [(axum::http::header::CONTENT_TYPE, "image/svg+xml")],
88 include_str!("static/favicon.svg"),
89 )
90 .into_response(),
91 "favicon.ico" => {
92 axum::response::Redirect::permanent("/favicon.svg").into_response()
94 }
95 _ => axum::response::Html(include_str!("static/index.html")).into_response(),
97 }
98}
99
100pub async fn start_server(addr: SocketAddr, config: Config) -> anyhow::Result<()> {
104 let http: std::sync::Arc<dyn scope::http::HttpClient> = if config.ghola.enabled {
105 match scope::http::GholaHttpClient::new(config.ghola.stealth, config.ghola.buffer_size) {
106 Ok(client) => std::sync::Arc::new(client),
107 Err(_) => std::sync::Arc::new(
108 scope::http::NativeHttpClient::new().expect("Failed to create HTTP client"),
109 ),
110 }
111 } else {
112 std::sync::Arc::new(
113 scope::http::NativeHttpClient::new().expect("Failed to create HTTP client"),
114 )
115 };
116 let factory = DefaultClientFactory {
117 chains_config: config.chains.clone(),
118 http,
119 };
120 let state = Arc::new(AppState { config, factory });
121 let app = build_router(state);
122
123 tracing::info!("Scope web server listening on http://{}", addr);
124 eprintln!("Scope web server listening on http://{}", addr);
125
126 let listener = tokio::net::TcpListener::bind(addr).await?;
127 axum::serve(listener, app).await?;
128
129 Ok(())
130}
131
132pub fn pid_file_path() -> std::path::PathBuf {
138 Config::default_data_dir().join("scope-web.pid")
139}
140
141pub fn log_file_path() -> std::path::PathBuf {
143 Config::default_data_dir().join("scope-web.log")
144}
145
146pub fn stop_daemon() -> anyhow::Result<()> {
148 let pid_path = pid_file_path();
149 if !pid_path.exists() {
150 eprintln!("No daemon PID file found at {}", pid_path.display());
151 eprintln!("Is the daemon running?");
152 return Ok(());
153 }
154
155 let pid_str = std::fs::read_to_string(&pid_path)?;
156 let pid: u32 = pid_str
157 .trim()
158 .parse()
159 .map_err(|e| anyhow::anyhow!("Invalid PID in {}: {}", pid_path.display(), e))?;
160
161 eprintln!("Stopping Scope web daemon (PID {})...", pid);
162
163 #[cfg(unix)]
164 {
165 let result = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
167 if result == 0 {
168 eprintln!("Daemon stopped.");
169 } else {
170 eprintln!("Failed to stop daemon (process may have already exited).");
171 }
172 }
173
174 #[cfg(not(unix))]
175 {
176 eprintln!("Daemon stop is only supported on Unix systems.");
177 eprintln!("Please manually terminate PID {}.", pid);
178 }
179
180 let _ = std::fs::remove_file(&pid_path);
182 Ok(())
183}
184
185#[cfg(unix)]
190pub fn start_daemon(addr: SocketAddr, config: Config) -> anyhow::Result<()> {
191 use std::io::Write;
192
193 let _ = config; let data_dir = Config::default_data_dir();
196 std::fs::create_dir_all(&data_dir)?;
197
198 let pid_path = pid_file_path();
199 let log_path = log_file_path();
200
201 eprintln!("Starting Scope web daemon...");
202 eprintln!(" URL: http://{}", addr);
203 eprintln!(" PID: {}", pid_path.display());
204 eprintln!(" Log: {}", log_path.display());
205
206 let log_file = std::fs::OpenOptions::new()
208 .create(true)
209 .append(true)
210 .open(&log_path)?;
211 let log_file_err = log_file.try_clone()?;
212
213 let current_exe = std::env::current_exe()?;
214 let child = std::process::Command::new(current_exe)
215 .args([
216 "web",
217 "--port",
218 &addr.port().to_string(),
219 "--bind",
220 &addr.ip().to_string(),
221 ])
222 .env("SCOPE_WEB_DAEMON_CHILD", "1")
223 .stdout(log_file)
224 .stderr(log_file_err)
225 .stdin(std::process::Stdio::null())
226 .spawn()?;
227
228 let pid = child.id();
229
230 let mut f = std::fs::File::create(&pid_path)?;
232 write!(f, "{}", pid)?;
233
234 eprintln!("Daemon started with PID {}", pid);
235 eprintln!("Stop with: scope web --stop");
236
237 Ok(())
238}
239
240#[cfg(not(unix))]
242pub fn start_daemon(addr: SocketAddr, config: Config) -> anyhow::Result<()> {
243 eprintln!("Daemon mode is only supported on Unix systems.");
244 eprintln!("Starting in foreground instead...");
245 let rt = tokio::runtime::Runtime::new()?;
246 rt.block_on(start_server(addr, config))
247}
248
249pub fn is_daemon_child() -> bool {
251 std::env::var("SCOPE_WEB_DAEMON_CHILD").is_ok()
252}
253
254#[cfg(test)]
259mod tests {
260 use super::*;
261 use axum::body;
262
263 #[test]
264 fn test_pid_file_path() {
265 let path = pid_file_path();
266 assert!(path.to_string_lossy().contains("scope-web.pid"));
267 }
268
269 #[test]
270 fn test_log_file_path() {
271 let path = log_file_path();
272 assert!(path.to_string_lossy().contains("scope-web.log"));
273 }
274
275 #[test]
276 fn test_build_router() {
277 let config = Config::default();
278 let http: std::sync::Arc<dyn scope::http::HttpClient> =
279 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
280 let factory = DefaultClientFactory {
281 chains_config: config.chains.clone(),
282 http,
283 };
284 let state = Arc::new(AppState { config, factory });
285 let _router = build_router(state);
286 }
287
288 #[test]
289 fn test_is_daemon_child_default() {
290 let _ = is_daemon_child();
293 }
294
295 #[tokio::test]
296 async fn test_serve_ui_index() {
297 let response = serve_ui(axum::http::Uri::from_static("/"))
298 .await
299 .into_response();
300 assert_eq!(response.status(), axum::http::StatusCode::OK);
301 let body = body::to_bytes(response.into_body(), 1_000_000)
302 .await
303 .unwrap();
304 let html = String::from_utf8(body.to_vec()).unwrap();
305 assert!(html.contains("<!DOCTYPE html>"));
306 assert!(html.contains("Scope"));
307 }
308
309 #[tokio::test]
310 async fn test_serve_ui_index_html() {
311 let response = serve_ui(axum::http::Uri::from_static("/index.html"))
312 .await
313 .into_response();
314 assert_eq!(response.status(), axum::http::StatusCode::OK);
315 }
316
317 #[tokio::test]
318 async fn test_serve_ui_app_js() {
319 let response = serve_ui(axum::http::Uri::from_static("/app.js"))
320 .await
321 .into_response();
322 assert_eq!(response.status(), axum::http::StatusCode::OK);
323 let body = body::to_bytes(response.into_body(), 1_000_000)
324 .await
325 .unwrap();
326 let js = String::from_utf8(body.to_vec()).unwrap();
327 assert!(js.contains("function") || js.contains("const") || js.contains("var"));
328 }
329
330 #[tokio::test]
331 async fn test_serve_ui_style_css() {
332 let response = serve_ui(axum::http::Uri::from_static("/style.css"))
333 .await
334 .into_response();
335 assert_eq!(response.status(), axum::http::StatusCode::OK);
336 let body = body::to_bytes(response.into_body(), 1_000_000)
337 .await
338 .unwrap();
339 let css = String::from_utf8(body.to_vec()).unwrap();
340 assert!(css.contains(":root") || css.contains("body") || css.contains("nav"));
341 }
342
343 #[tokio::test]
344 async fn test_serve_ui_spa_fallback() {
345 let response = serve_ui(axum::http::Uri::from_static("/some/random/path"))
347 .await
348 .into_response();
349 assert_eq!(response.status(), axum::http::StatusCode::OK);
350 let body = body::to_bytes(response.into_body(), 1_000_000)
351 .await
352 .unwrap();
353 let html = String::from_utf8(body.to_vec()).unwrap();
354 assert!(html.contains("<!DOCTYPE html>"));
355 }
356
357 #[tokio::test]
358 async fn test_serve_ui_favicon_svg() {
359 let response = serve_ui(axum::http::Uri::from_static("/favicon.svg"))
360 .await
361 .into_response();
362 assert_eq!(response.status(), axum::http::StatusCode::OK);
363 let headers = response.headers();
364 let ct = headers.get(axum::http::header::CONTENT_TYPE).unwrap();
365 assert_eq!(ct, "image/svg+xml");
366 }
367
368 #[tokio::test]
369 async fn test_serve_ui_favicon_ico_redirect() {
370 let response = serve_ui(axum::http::Uri::from_static("/favicon.ico"))
371 .await
372 .into_response();
373 assert_eq!(
374 response.status(),
375 axum::http::StatusCode::PERMANENT_REDIRECT
376 );
377 let headers = response.headers();
378 let loc = headers.get(axum::http::header::LOCATION).unwrap();
379 assert_eq!(loc, "/favicon.svg");
380 }
381
382 #[tokio::test]
383 async fn test_route_ws_monitor_handshake() {
384 use axum::http::{Request, StatusCode};
385 use tower::ServiceExt;
386
387 let state = test_state();
388 let app = build_router(state);
389
390 let req = Request::builder()
392 .uri("/ws/monitor?token=USDC")
393 .method("GET")
394 .header("Connection", "Upgrade")
395 .header("Upgrade", "websocket")
396 .header("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
397 .header("Sec-WebSocket-Version", "13")
398 .body(axum::body::Body::empty())
399 .unwrap();
400
401 let response = app.oneshot(req).await.unwrap();
402 let status = response.status();
404 assert!(
405 status == StatusCode::SWITCHING_PROTOCOLS
406 || status == StatusCode::UPGRADE_REQUIRED
407 || status.is_success(),
408 "Unexpected status: {}",
409 status
410 );
411 }
412
413 #[tokio::test]
414 async fn test_route_ws_monitor_missing_token() {
415 use axum::http::Request;
416 use tower::ServiceExt;
417
418 let state = test_state();
419 let app = build_router(state);
420
421 let req = Request::builder()
422 .uri("/ws/monitor")
423 .method("GET")
424 .body(axum::body::Body::empty())
425 .unwrap();
426
427 let response = app.oneshot(req).await.unwrap();
428 assert!(response.status().is_client_error());
430 }
431
432 #[test]
433 fn test_pid_file_path_in_data_dir() {
434 let path = pid_file_path();
435 assert!(path.ends_with("scope-web.pid"));
436 assert!(path.parent().is_some());
437 }
438
439 #[test]
440 fn test_log_file_path_in_data_dir() {
441 let path = log_file_path();
442 assert!(path.ends_with("scope-web.log"));
443 assert!(path.parent().is_some());
444 }
445
446 #[test]
447 fn test_app_state_construction() {
448 let config = Config::default();
449 let http: std::sync::Arc<dyn scope::http::HttpClient> =
450 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
451 let factory = DefaultClientFactory {
452 chains_config: config.chains.clone(),
453 http,
454 };
455 let state = AppState { config, factory };
456 let _ = state.config.chains.api_keys.len();
458 }
459
460 #[test]
461 fn test_stop_daemon_no_pid_file() {
462 let pid = pid_file_path();
466 let log = log_file_path();
467 assert_eq!(pid.parent(), log.parent());
468 }
469
470 #[test]
471 fn test_stop_daemon_invoked_no_pid_file() {
472 let result = stop_daemon();
474 assert!(result.is_ok());
475 }
476
477 fn test_state() -> Arc<AppState> {
479 let config = Config::default();
480 let http: std::sync::Arc<dyn scope::http::HttpClient> =
481 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
482 let factory = DefaultClientFactory {
483 chains_config: config.chains.clone(),
484 http,
485 };
486 Arc::new(AppState { config, factory })
487 }
488
489 #[tokio::test]
494 async fn test_route_get_config_status() {
495 use axum::http::{Request, StatusCode};
496 use tower::ServiceExt;
497
498 let state = test_state();
499 let app = build_router(state);
500
501 let req = Request::builder()
502 .uri("/api/config/status")
503 .method("GET")
504 .body(axum::body::Body::empty())
505 .unwrap();
506
507 let response = app.oneshot(req).await.unwrap();
508 assert_eq!(response.status(), StatusCode::OK);
509
510 let body = body::to_bytes(response.into_body(), 1_000_000)
511 .await
512 .unwrap();
513 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
514 assert!(json.get("config_path").is_some());
515 assert!(json.get("api_keys").is_some());
516 assert!(json.get("version").is_some());
517 }
518
519 #[tokio::test]
520 async fn test_route_post_config_save() {
521 use axum::http::{Request, StatusCode, header};
522 use tower::ServiceExt;
523
524 let state = test_state();
525 let app = build_router(state);
526
527 let payload = serde_json::json!({
528 "api_keys": {},
529 "rpc_endpoints": {}
530 });
531
532 let req = Request::builder()
533 .uri("/api/config")
534 .method("POST")
535 .header(header::CONTENT_TYPE, "application/json")
536 .body(axum::body::Body::from(payload.to_string()))
537 .unwrap();
538
539 let response = app.oneshot(req).await.unwrap();
540 let status = response.status();
542 assert!(status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR);
543 }
544
545 #[tokio::test]
546 async fn test_route_post_address() {
547 use axum::http::{Request, StatusCode, header};
548 use tower::ServiceExt;
549
550 let state = test_state();
551 let app = build_router(state);
552
553 let payload = serde_json::json!({
554 "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
555 "chain": "ethereum"
556 });
557
558 let req = Request::builder()
559 .uri("/api/address")
560 .method("POST")
561 .header(header::CONTENT_TYPE, "application/json")
562 .body(axum::body::Body::from(payload.to_string()))
563 .unwrap();
564
565 let response = app.oneshot(req).await.unwrap();
566 let status = response.status();
568 assert!(
569 status == StatusCode::OK
570 || status == StatusCode::BAD_REQUEST
571 || status == StatusCode::INTERNAL_SERVER_ERROR
572 );
573 }
574
575 #[tokio::test]
576 async fn test_route_post_tx() {
577 use axum::http::{Request, StatusCode, header};
578 use tower::ServiceExt;
579
580 let state = test_state();
581 let app = build_router(state);
582
583 let payload = serde_json::json!({
584 "hash": "0xabc123def456789012345678901234567890123456789012345678901234abcd",
585 "chain": "ethereum"
586 });
587
588 let req = Request::builder()
589 .uri("/api/tx")
590 .method("POST")
591 .header(header::CONTENT_TYPE, "application/json")
592 .body(axum::body::Body::from(payload.to_string()))
593 .unwrap();
594
595 let response = app.oneshot(req).await.unwrap();
596 let status = response.status();
597 assert!(
598 status == StatusCode::OK
599 || status == StatusCode::BAD_REQUEST
600 || status == StatusCode::INTERNAL_SERVER_ERROR
601 );
602 }
603
604 #[tokio::test]
605 async fn test_route_post_crawl() {
606 use axum::http::{Request, StatusCode, header};
607 use tower::ServiceExt;
608
609 let state = test_state();
610 let app = build_router(state);
611
612 let payload = serde_json::json!({
613 "token": "USDC",
614 "chain": "ethereum"
615 });
616
617 let req = Request::builder()
618 .uri("/api/crawl")
619 .method("POST")
620 .header(header::CONTENT_TYPE, "application/json")
621 .body(axum::body::Body::from(payload.to_string()))
622 .unwrap();
623
624 let response = app.oneshot(req).await.unwrap();
625 let status = response.status();
626 assert!(
627 status == StatusCode::OK
628 || status == StatusCode::BAD_REQUEST
629 || status == StatusCode::INTERNAL_SERVER_ERROR
630 );
631 }
632
633 #[tokio::test]
634 async fn test_route_get_discover() {
635 use axum::http::{Request, StatusCode};
636 use tower::ServiceExt;
637
638 let state = test_state();
639 let app = build_router(state);
640
641 let req = Request::builder()
642 .uri("/api/discover?source=profiles&limit=5")
643 .method("GET")
644 .body(axum::body::Body::empty())
645 .unwrap();
646
647 let response = app.oneshot(req).await.unwrap();
648 let status = response.status();
649 assert!(status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR);
650 }
651
652 #[tokio::test]
653 async fn test_route_post_insights() {
654 use axum::http::{Request, StatusCode, header};
655 use tower::ServiceExt;
656
657 let state = test_state();
658 let app = build_router(state);
659
660 let payload = serde_json::json!({
661 "target": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
662 });
663
664 let req = Request::builder()
665 .uri("/api/insights")
666 .method("POST")
667 .header(header::CONTENT_TYPE, "application/json")
668 .body(axum::body::Body::from(payload.to_string()))
669 .unwrap();
670
671 let response = app.oneshot(req).await.unwrap();
672 let status = response.status();
673 assert!(
674 status == StatusCode::OK
675 || status == StatusCode::BAD_REQUEST
676 || status == StatusCode::INTERNAL_SERVER_ERROR
677 );
678 }
679
680 #[tokio::test]
681 async fn test_route_post_compliance_risk() {
682 use axum::http::{Request, StatusCode, header};
683 use tower::ServiceExt;
684
685 let state = test_state();
686 let app = build_router(state);
687
688 let payload = serde_json::json!({
689 "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
690 "chain": "ethereum",
691 "detailed": true
692 });
693
694 let req = Request::builder()
695 .uri("/api/compliance/risk")
696 .method("POST")
697 .header(header::CONTENT_TYPE, "application/json")
698 .body(axum::body::Body::from(payload.to_string()))
699 .unwrap();
700
701 let response = app.oneshot(req).await.unwrap();
702 let status = response.status();
703 assert!(
704 status == StatusCode::OK
705 || status == StatusCode::BAD_REQUEST
706 || status == StatusCode::INTERNAL_SERVER_ERROR
707 );
708 }
709
710 #[tokio::test]
711 async fn test_route_post_export() {
712 use axum::http::{Request, StatusCode, header};
713 use tower::ServiceExt;
714
715 let state = test_state();
716 let app = build_router(state);
717
718 let payload = serde_json::json!({
719 "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
720 "chain": "ethereum",
721 "format": "json"
722 });
723
724 let req = Request::builder()
725 .uri("/api/export")
726 .method("POST")
727 .header(header::CONTENT_TYPE, "application/json")
728 .body(axum::body::Body::from(payload.to_string()))
729 .unwrap();
730
731 let response = app.oneshot(req).await.unwrap();
732 let status = response.status();
733 assert!(
734 status == StatusCode::OK
735 || status == StatusCode::BAD_REQUEST
736 || status == StatusCode::INTERNAL_SERVER_ERROR
737 );
738 }
739
740 #[tokio::test]
741 async fn test_route_post_token_health() {
742 use axum::http::{Request, StatusCode, header};
743 use tower::ServiceExt;
744
745 let state = test_state();
746 let app = build_router(state);
747
748 let payload = serde_json::json!({
749 "token": "USDC",
750 "chain": "ethereum",
751 "with_market": false
752 });
753
754 let req = Request::builder()
755 .uri("/api/token-health")
756 .method("POST")
757 .header(header::CONTENT_TYPE, "application/json")
758 .body(axum::body::Body::from(payload.to_string()))
759 .unwrap();
760
761 let response = app.oneshot(req).await.unwrap();
762 let status = response.status();
763 assert!(
764 status == StatusCode::OK
765 || status == StatusCode::BAD_REQUEST
766 || status == StatusCode::INTERNAL_SERVER_ERROR
767 );
768 }
769
770 #[tokio::test]
771 async fn test_route_post_contract() {
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/contract")
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();
792 assert!(
793 status == StatusCode::OK
794 || status == StatusCode::BAD_REQUEST
795 || status == StatusCode::INTERNAL_SERVER_ERROR
796 );
797 }
798
799 #[tokio::test]
800 async fn test_route_post_market_summary() {
801 use axum::http::{Request, StatusCode, header};
802 use tower::ServiceExt;
803
804 let state = test_state();
805 let app = build_router(state);
806
807 let payload = serde_json::json!({
808 "pair": "USDC",
809 "market_venue": "binance"
810 });
811
812 let req = Request::builder()
813 .uri("/api/market/summary")
814 .method("POST")
815 .header(header::CONTENT_TYPE, "application/json")
816 .body(axum::body::Body::from(payload.to_string()))
817 .unwrap();
818
819 let response = app.oneshot(req).await.unwrap();
820 let status = response.status();
821 assert!(
822 status == StatusCode::OK
823 || status == StatusCode::BAD_REQUEST
824 || status == StatusCode::INTERNAL_SERVER_ERROR
825 );
826 }
827
828 #[tokio::test]
829 async fn test_route_get_address_book_list() {
830 use axum::http::{Request, StatusCode};
831 use tower::ServiceExt;
832
833 let state = test_state();
834 let app = build_router(state);
835
836 let req = Request::builder()
837 .uri("/api/address-book/list")
838 .method("GET")
839 .body(axum::body::Body::empty())
840 .unwrap();
841
842 let response = app.oneshot(req).await.unwrap();
843 let status = response.status();
844 assert!(status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR);
845 }
846
847 #[tokio::test]
848 async fn test_route_post_address_book_add() {
849 use axum::http::{Request, StatusCode, header};
850 use tower::ServiceExt;
851
852 let state = test_state();
853 let app = build_router(state);
854
855 let payload = serde_json::json!({
856 "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
857 "chain": "ethereum",
858 "label": "Test Wallet"
859 });
860
861 let req = Request::builder()
862 .uri("/api/address-book/add")
863 .method("POST")
864 .header(header::CONTENT_TYPE, "application/json")
865 .body(axum::body::Body::from(payload.to_string()))
866 .unwrap();
867
868 let response = app.oneshot(req).await.unwrap();
869 let status = response.status();
870 assert!(
871 status == StatusCode::OK
872 || status == StatusCode::BAD_REQUEST
873 || status == StatusCode::INTERNAL_SERVER_ERROR
874 );
875 }
876
877 #[tokio::test]
878 async fn test_route_invalid_json_body() {
879 use axum::http::{Request, header};
880 use tower::ServiceExt;
881
882 let state = test_state();
883 let app = build_router(state);
884
885 let req = Request::builder()
886 .uri("/api/address")
887 .method("POST")
888 .header(header::CONTENT_TYPE, "application/json")
889 .body(axum::body::Body::from("not json"))
890 .unwrap();
891
892 let response = app.oneshot(req).await.unwrap();
893 assert!(response.status().is_client_error());
895 }
896
897 #[tokio::test]
898 async fn test_route_missing_required_field() {
899 use axum::http::{Request, header};
900 use tower::ServiceExt;
901
902 let state = test_state();
903 let app = build_router(state);
904
905 let payload = serde_json::json!({ "chain": "ethereum" });
907
908 let req = Request::builder()
909 .uri("/api/address")
910 .method("POST")
911 .header(header::CONTENT_TYPE, "application/json")
912 .body(axum::body::Body::from(payload.to_string()))
913 .unwrap();
914
915 let response = app.oneshot(req).await.unwrap();
916 assert!(response.status().is_client_error());
917 }
918
919 #[tokio::test]
920 async fn test_route_static_fallback() {
921 use axum::http::{Request, StatusCode};
922 use tower::ServiceExt;
923
924 let state = test_state();
925 let app = build_router(state);
926
927 let req = Request::builder()
928 .uri("/nonexistent-path")
929 .method("GET")
930 .body(axum::body::Body::empty())
931 .unwrap();
932
933 let response = app.oneshot(req).await.unwrap();
934 assert_eq!(response.status(), StatusCode::OK);
935 }
936}