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