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