diff --git a/web-gateway/src/routes/mod.rs b/web-gateway/src/routes/mod.rs index 1b3cbd765..c2de6d567 100644 --- a/web-gateway/src/routes/mod.rs +++ b/web-gateway/src/routes/mod.rs @@ -64,3 +64,93 @@ async fn readiness_check( "ml_training_service": state.ml_training_channel.is_some(), })) } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use tower::ServiceExt; + + use crate::config::GatewayConfig; + + async fn test_state() -> AppState { + AppState::new(GatewayConfig::default()).await.unwrap() + } + + #[tokio::test] + async fn test_health_returns_ok() { + let state = test_state().await; + let app = create_router(state); + let req = Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_ready_returns_service_status() { + let state = test_state().await; + let app = create_router(state); + let req = Request::builder() + .uri("/ready") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["status"], "ok"); + // Default config creates valid channels + assert_eq!(json["trading_service"], true); + } + + #[tokio::test] + async fn test_ready_reports_missing_service() { + let config = GatewayConfig { + trading_service_url: String::new(), + ..GatewayConfig::default() + }; + let state = AppState::new(config).await.unwrap(); + let app = create_router(state); + let req = Request::builder() + .uri("/ready") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["trading_service"], false); + } + + #[tokio::test] + async fn test_protected_route_without_auth_returns_401() { + let state = test_state().await; + let app = create_router(state); + let req = Request::builder() + .uri("/api/trading/positions") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_public_auth_route_no_auth_required() { + let state = test_state().await; + let app = create_router(state); + let req = Request::builder() + .method("POST") + .uri("/api/auth/login") + .header("Content-Type", "application/json") + .body(Body::from(r#"{"username":"test","password":"pass"}"#)) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + // Will be 500 (no JWT secret) or 200, but NOT 401 + assert_ne!(resp.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/web-gateway/src/state.rs b/web-gateway/src/state.rs index 4242ccfdd..3046b39ca 100644 --- a/web-gateway/src/state.rs +++ b/web-gateway/src/state.rs @@ -41,3 +41,57 @@ impl AppState { }) } } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_new_creates_state_with_valid_config() { + let config = GatewayConfig::default(); + let state = AppState::new(config).await.unwrap(); + assert!(state.trading_channel.is_some()); + assert!(state.backtesting_channel.is_some()); + assert!(state.ml_training_channel.is_some()); + } + + #[tokio::test] + async fn test_new_with_empty_url_yields_none_channel() { + let config = GatewayConfig { + trading_service_url: String::new(), + ..GatewayConfig::default() + }; + let state = AppState::new(config).await.unwrap(); + assert!(state.trading_channel.is_none()); + // Other channels should still work + assert!(state.backtesting_channel.is_some()); + } + + #[tokio::test] + async fn test_broadcast_channel_sends_and_receives() { + let config = GatewayConfig::default(); + let state = AppState::new(config).await.unwrap(); + let mut rx = state.ws_broadcast.subscribe(); + state.ws_broadcast.send("hello".to_owned()).unwrap(); + let msg = rx.recv().await.unwrap(); + assert_eq!(msg, "hello"); + } + + #[tokio::test] + async fn test_config_is_arc_wrapped() { + let config = GatewayConfig { + jwt_secret: "test-secret".to_owned(), + ..GatewayConfig::default() + }; + let state = AppState::new(config).await.unwrap(); + assert_eq!(state.config.jwt_secret, "test-secret"); + } + + #[test] + fn test_state_is_clone() { + // AppState must be Clone for Axum's State extractor + fn assert_clone() {} + assert_clone::(); + } +}