Skip to main content

scope/web/
mod.rs

1//! # Web Server Module
2//!
3//! Provides a locally hosted HTTP server with REST API endpoints and a
4//! single-page web UI that mirrors all CLI functionality. The server uses
5//! the same `Config` and `DefaultClientFactory` as the CLI, ensuring
6//! identical behavior.
7//!
8//! ## Usage
9//!
10//! ```bash
11//! # Start in foreground (default port 8080)
12//! scope web
13//!
14//! # Custom port and bind address
15//! scope web --port 3000 --bind 0.0.0.0
16//!
17//! # Run as background daemon
18//! scope web --daemon
19//!
20//! # Stop a running daemon
21//! scope web --stop
22//! ```
23
24pub 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
35/// Shared application state passed to all handlers via Axum extractors.
36pub struct AppState {
37    /// Application configuration (same as CLI).
38    pub config: Config,
39    /// Client factory for creating chain and DEX clients.
40    pub factory: DefaultClientFactory,
41}
42
43/// Builds the Axum router with all API routes and static file serving.
44pub 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
60/// Serves the embedded single-page web UI.
61async fn serve_ui(uri: axum::http::Uri) -> impl axum::response::IntoResponse {
62    let path = uri.path().trim_start_matches('/');
63
64    // Serve specific static assets
65    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            // Redirect .ico requests to the SVG favicon
86            axum::response::Redirect::permanent("/favicon.svg").into_response()
87        }
88        // SPA fallback: serve index.html for client-side routing
89        _ => axum::response::Html(include_str!("static/index.html")).into_response(),
90    }
91}
92
93/// Starts the web server on the given address.
94///
95/// This is the main entry point called from the CLI `web` command handler.
96pub 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
112// ============================================================================
113// Daemon management
114// ============================================================================
115
116/// Returns the path to the PID file for the daemon.
117pub fn pid_file_path() -> std::path::PathBuf {
118    Config::default_data_dir().join("scope-web.pid")
119}
120
121/// Returns the path to the log file for the daemon.
122pub fn log_file_path() -> std::path::PathBuf {
123    Config::default_data_dir().join("scope-web.log")
124}
125
126/// Stops a running daemon by reading its PID file and sending SIGTERM.
127pub 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        // Send SIGTERM to the daemon process
146        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    // Remove PID file
161    let _ = std::fs::remove_file(&pid_path);
162    Ok(())
163}
164
165/// Starts the server as a background daemon (Unix only).
166///
167/// Spawns the current executable as a detached child process with
168/// stdout/stderr redirected to a log file, then writes the PID.
169#[cfg(unix)]
170pub fn start_daemon(addr: SocketAddr, config: Config) -> anyhow::Result<()> {
171    use std::io::Write;
172
173    let _ = config; // Config reloaded from disk in child
174
175    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    // Redirect stdout/stderr to log file
187    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    // Write PID file
211    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/// Fallback for non-Unix: run in foreground.
221#[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
229/// Returns true if running as a daemon child process.
230pub fn is_daemon_child() -> bool {
231    std::env::var("SCOPE_WEB_DAEMON_CHILD").is_ok()
232}
233
234// ============================================================================
235// Unit Tests
236// ============================================================================
237
238#[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        // Should be false in test context (env var not set)
268        // Note: may be true if test runner sets it, so just ensure it doesn't panic
269        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        // Unknown paths should return index.html (SPA routing)
323        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        // GET /ws/monitor?token=USDC - WebSocket upgrade with required query params
368        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        // With Upgrade headers: 101 Switching Protocols; without proper handshake: 426
380        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        // Missing required token param -> 422 Unprocessable Entity
406        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        // Verify state fields are accessible
431        let _ = state.config.chains.api_keys.len();
432    }
433
434    #[test]
435    fn test_stop_daemon_no_pid_file() {
436        // stop_daemon should handle missing PID file gracefully
437        // We can't easily override the data dir, but verify the function exists
438        // and the path helpers return valid paths
439        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        // Exercise stop_daemon when PID file does not exist (common in tests)
447        let result = stop_daemon();
448        assert!(result.is_ok());
449    }
450
451    // Helper to create the test app state
452    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    // ========================================================================
461    // HTTP routing integration tests — exercise handler code paths
462    // ========================================================================
463
464    #[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        // May succeed or fail depending on file system, but should not panic
512        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        // Will likely fail due to no API key, but exercises the handler
538        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        // Should return 4xx for bad JSON
865        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        // Missing required "address" field
877        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}