Skip to main content

scope_web/
lib.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 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
36/// Shared application state passed to all handlers via Axum extractors.
37pub struct AppState {
38    /// Application configuration (same as CLI).
39    pub config: Config,
40    /// Client factory for creating chain and DEX clients.
41    pub factory: DefaultClientFactory,
42}
43
44/// Builds the Axum router with all API routes and static file serving.
45pub 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
67/// Serves the embedded single-page web UI.
68async fn serve_ui(uri: axum::http::Uri) -> impl axum::response::IntoResponse {
69    let path = uri.path().trim_start_matches('/');
70
71    // Serve specific static assets
72    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            // Redirect .ico requests to the SVG favicon
93            axum::response::Redirect::permanent("/favicon.svg").into_response()
94        }
95        // SPA fallback: serve index.html for client-side routing
96        _ => axum::response::Html(include_str!("static/index.html")).into_response(),
97    }
98}
99
100/// Starts the web server on the given address.
101///
102/// This is the main entry point called from the CLI `web` command handler.
103pub 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
132// ============================================================================
133// Daemon management
134// ============================================================================
135
136/// Returns the path to the PID file for the daemon.
137pub fn pid_file_path() -> std::path::PathBuf {
138    Config::default_data_dir().join("scope-web.pid")
139}
140
141/// Returns the path to the log file for the daemon.
142pub fn log_file_path() -> std::path::PathBuf {
143    Config::default_data_dir().join("scope-web.log")
144}
145
146/// Stops a running daemon by reading its PID file and sending SIGTERM.
147pub 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        // Send SIGTERM to the daemon process
166        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    // Remove PID file
181    let _ = std::fs::remove_file(&pid_path);
182    Ok(())
183}
184
185/// Starts the server as a background daemon (Unix only).
186///
187/// Spawns the current executable as a detached child process with
188/// stdout/stderr redirected to a log file, then writes the PID.
189#[cfg(unix)]
190pub fn start_daemon(addr: SocketAddr, config: Config) -> anyhow::Result<()> {
191    use std::io::Write;
192
193    let _ = config; // Config reloaded from disk in child
194
195    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    // Redirect stdout/stderr to log file
207    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    // Write PID file
231    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/// Fallback for non-Unix: run in foreground.
241#[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
249/// Returns true if running as a daemon child process.
250pub fn is_daemon_child() -> bool {
251    std::env::var("SCOPE_WEB_DAEMON_CHILD").is_ok()
252}
253
254// ============================================================================
255// Unit Tests
256// ============================================================================
257
258#[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        // Should be false in test context (env var not set)
291        // Note: may be true if test runner sets it, so just ensure it doesn't panic
292        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        // Unknown paths should return index.html (SPA routing)
346        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        // GET /ws/monitor?token=USDC - WebSocket upgrade with required query params
391        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        // With Upgrade headers: 101 Switching Protocols; without proper handshake: 426
403        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        // Missing required token param -> 422 Unprocessable Entity
429        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        // Verify state fields are accessible
457        let _ = state.config.chains.api_keys.len();
458    }
459
460    #[test]
461    fn test_stop_daemon_no_pid_file() {
462        // stop_daemon should handle missing PID file gracefully
463        // We can't easily override the data dir, but verify the function exists
464        // and the path helpers return valid paths
465        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        // Exercise stop_daemon when PID file does not exist (common in tests)
473        let result = stop_daemon();
474        assert!(result.is_ok());
475    }
476
477    // Helper to create the test app state
478    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    // ========================================================================
490    // HTTP routing integration tests — exercise handler code paths
491    // ========================================================================
492
493    #[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        // May succeed or fail depending on file system, but should not panic
541        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        // Will likely fail due to no API key, but exercises the handler
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_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        // Should return 4xx for bad JSON
894        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        // Missing required "address" field
906        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}