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#[async_trait::async_trait]
16pub trait SessionInfoProvider: Clone + Send + Sync {
17 async fn get_session_info(&self) -> anyhow::Result<SessionInfo>;
18}
19
20pub 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}