use application::DataProjection; use axum::body::Body; use axum::http::{Request, StatusCode}; use config_memory::MemoryConfigStore; use domain::{AuthPort, PasswordHashPort, UserId}; use http_api::{AppState, router}; use std::sync::Arc; use tcp_server::{ClientTracker, TcpBroadcaster, TcpEventBus}; use tower::ServiceExt; struct TestAuth; impl AuthPort for TestAuth { fn generate_token(&self, _user_id: UserId) -> String { "test-token".into() } fn validate_token(&self, token: &str) -> Option { if token == "test-token" { Some(1) } else { None } } } struct TestHasher; impl PasswordHashPort for TestHasher { async fn hash(&self, _plain: &str) -> Result { Ok("hashed".into()) } async fn verify(&self, _plain: &str, _hash: &str) -> Result { Ok(true) } } fn test_app() -> axum::Router { let state = AppState { config: Arc::new(MemoryConfigStore::new()), events: Arc::new(TcpEventBus::new(16)), widget_states: Arc::new(DataProjection::new()), broadcaster: Arc::new(TcpBroadcaster::new(16)), clients: Arc::new(ClientTracker::new()), auth: Arc::new(TestAuth), hasher: Arc::new(TestHasher), spa_dir: None, }; router(state) } fn authed_json_request(method: &str, uri: &str, body: Option<&str>) -> Request { let builder = Request::builder() .method(method) .uri(uri) .header("content-type", "application/json") .header("authorization", "Bearer test-token"); if let Some(b) = body { builder.body(Body::from(b.to_string())).unwrap() } else { builder.body(Body::empty()).unwrap() } } fn json_request(method: &str, uri: &str, body: Option<&str>) -> Request { let builder = Request::builder() .method(method) .uri(uri) .header("content-type", "application/json"); if let Some(b) = body { builder.body(Body::from(b.to_string())).unwrap() } else { builder.body(Body::empty()).unwrap() } } #[tokio::test] async fn unauthenticated_request_returns_401() { let app = test_app(); let resp = app .oneshot(json_request("GET", "/api/widgets", None)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn create_and_get_widget() { let app = test_app(); let body = r#"{ "id": 1, "name": "weather", "display_hint": "icon_value", "data_source_id": 10, "mappings": [{"source_path": "$.temp", "target_key": "temperature"}] }"#; let resp = app .clone() .oneshot(authed_json_request("POST", "/api/widgets", Some(body))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let resp = app .oneshot(authed_json_request("GET", "/api/widgets/1", None)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = axum::body::to_bytes(resp.into_body(), usize::MAX) .await .unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["name"], "weather"); } #[tokio::test] async fn list_widgets() { let app = test_app(); let w1 = r#"{"id":1,"name":"a","display_hint":"icon_value","data_source_id":1,"mappings":[]}"#; let w2 = r#"{"id":2,"name":"b","display_hint":"key_value","data_source_id":2,"mappings":[]}"#; app.clone() .oneshot(authed_json_request("POST", "/api/widgets", Some(w1))) .await .unwrap(); app.clone() .oneshot(authed_json_request("POST", "/api/widgets", Some(w2))) .await .unwrap(); let resp = app .oneshot(authed_json_request("GET", "/api/widgets", None)) .await .unwrap(); let body = axum::body::to_bytes(resp.into_body(), usize::MAX) .await .unwrap(); let json: Vec = serde_json::from_slice(&body).unwrap(); assert_eq!(json.len(), 2); } #[tokio::test] async fn delete_widget() { let app = test_app(); let body = r#"{"id":1,"name":"a","display_hint":"icon_value","data_source_id":1,"mappings":[]}"#; app.clone() .oneshot(authed_json_request("POST", "/api/widgets", Some(body))) .await .unwrap(); let resp = app .clone() .oneshot(authed_json_request("DELETE", "/api/widgets/1", None)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); let resp = app .oneshot(authed_json_request("GET", "/api/widgets/1", None)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn create_and_get_data_source() { let app = test_app(); let body = r#"{ "id": 10, "name": "weather_api", "source_type": "weather", "poll_interval_secs": 300, "url": "https://api.openweather.org", "api_key": "test-key", "headers": [] }"#; let resp = app .clone() .oneshot(authed_json_request("POST", "/api/data-sources", Some(body))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let resp = app .oneshot(authed_json_request("GET", "/api/data-sources/10", None)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn update_and_get_layout() { let app = test_app(); let body = r#"{ "root": { "type": "container", "direction": "row", "gap": 4, "padding": 2, "children": [ {"sizing": {"type": "flex", "value": 1}, "node": {"type": "leaf", "widget_id": 1}}, {"sizing": {"type": "fixed", "value": 80}, "node": {"type": "leaf", "widget_id": 2}} ] } }"#; let resp = app .clone() .oneshot(authed_json_request("PUT", "/api/layout", Some(body))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let resp = app .oneshot(authed_json_request("GET", "/api/layout", None)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn get_nonexistent_returns_404() { let app = test_app(); let resp = app .clone() .oneshot(authed_json_request("GET", "/api/widgets/99", None)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn auth_status_returns_needs_setup() { let app = test_app(); let resp = app .oneshot(json_request("GET", "/api/auth/status", None)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = axum::body::to_bytes(resp.into_body(), usize::MAX) .await .unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["needs_setup"], true); }