hotfix_web_ui/
lib.rs

1mod assets;
2mod dashboard;
3mod error;
4
5use axum::Router;
6use axum::routing::get;
7use hotfix::session::SessionInfo;
8
9pub use error::{DashboardError, DashboardResult};
10
11/// Trait for providing session information to the dashboard
12///
13/// This is a read-only subset focused on displaying session data.
14/// For full session control including admin actions, see the SessionController trait in hotfix-http.
15#[async_trait::async_trait]
16pub trait SessionInfoProvider: Clone + Send + Sync {
17    async fn get_session_info(&self) -> anyhow::Result<SessionInfo>;
18}
19
20/// Build a router for the dashboard UI
21///
22/// This requires router state that can serve the required data
23/// to the endpoints as defined in [`SessionInfoProvider`].
24pub fn build_ui_router<S, P>() -> Router<S>
25where
26    S: Clone + Send + Sync + 'static,
27    P: SessionInfoProvider + 'static,
28    P: axum::extract::FromRef<S>,
29{
30    Router::new()
31        .route("/", get(dashboard::dashboard_handler::<S, P>))
32        .route("/static/{*file}", get(assets::static_handler))
33}
34
35#[cfg(test)]
36mod tests {
37    use super::*;
38    use axum::body::Body;
39    use axum::http::{Request, StatusCode};
40    use hotfix::session::{SessionInfo, Status};
41    use tower::ServiceExt;
42
43    #[derive(Clone)]
44    struct MockSessionProvider {
45        session_info: SessionInfo,
46    }
47
48    #[async_trait::async_trait]
49    impl SessionInfoProvider for MockSessionProvider {
50        async fn get_session_info(&self) -> anyhow::Result<SessionInfo> {
51            Ok(self.session_info.clone())
52        }
53    }
54
55    fn create_test_app() -> Router {
56        let provider = MockSessionProvider {
57            session_info: SessionInfo {
58                next_sender_seq_number: 42,
59                next_target_seq_number: 100,
60                status: Status::Active,
61            },
62        };
63        build_ui_router::<MockSessionProvider, MockSessionProvider>().with_state(provider)
64    }
65
66    #[tokio::test]
67    async fn test_dashboard_returns_html() {
68        let app = create_test_app();
69
70        let response = app
71            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
72            .await
73            .unwrap();
74
75        assert_eq!(response.status(), StatusCode::OK);
76
77        let content_type = response
78            .headers()
79            .get("content-type")
80            .and_then(|v| v.to_str().ok());
81        assert!(
82            content_type.is_some_and(|ct| ct.contains("text/html")),
83            "Expected HTML content type"
84        );
85    }
86
87    #[tokio::test]
88    async fn test_static_asset_returns_file() {
89        let app = create_test_app();
90
91        let response = app
92            .oneshot(
93                Request::builder()
94                    .uri("/static/tailwind.js")
95                    .body(Body::empty())
96                    .unwrap(),
97            )
98            .await
99            .unwrap();
100
101        assert_eq!(response.status(), StatusCode::OK);
102
103        let content_type = response
104            .headers()
105            .get("content-type")
106            .and_then(|v| v.to_str().ok());
107        assert!(
108            content_type.is_some_and(
109                |ct| ct.contains("javascript") || ct.contains("application/x-javascript")
110            ),
111            "Expected JavaScript content type, got: {:?}",
112            content_type
113        );
114    }
115
116    #[tokio::test]
117    async fn test_static_asset_not_found() {
118        let app = create_test_app();
119
120        let response = app
121            .oneshot(
122                Request::builder()
123                    .uri("/static/nonexistent.js")
124                    .body(Body::empty())
125                    .unwrap(),
126            )
127            .await
128            .unwrap();
129
130        assert_eq!(response.status(), StatusCode::NOT_FOUND);
131    }
132}