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