DataSourceConfig refactored to enum: External/Clock/StaticText. Clock generates formatted time via chrono, static text emits configured string. ESP32: connection status indicator (green/red dot bottom-right), per-widget clear before redraw, RenderEvent enum for local + server messages. Polling uses DataUpdate instead of ScreenUpdate to avoid wiping widget state. Empty mappings passthrough raw source data for internal sources.
248 lines
7.0 KiB
Rust
248 lines
7.0 KiB
Rust
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<UserId> {
|
|
if token == "test-token" { Some(1) } else { None }
|
|
}
|
|
}
|
|
|
|
struct TestHasher;
|
|
impl PasswordHashPort for TestHasher {
|
|
async fn hash(&self, _plain: &str) -> Result<String, String> {
|
|
Ok("hashed".into())
|
|
}
|
|
async fn verify(&self, _plain: &str, _hash: &str) -> Result<bool, String> {
|
|
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<Body> {
|
|
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<Body> {
|
|
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": {"kind": "icon_value", "h_align": "left", "v_align": "top"},
|
|
"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":{"kind":"icon_value"},"data_source_id":1,"mappings":[]}"#;
|
|
let w2 = r#"{"id":2,"name":"b","display_hint":{"kind":"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::Value> = 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":{"kind":"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,
|
|
"config": {"type": "external", "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);
|
|
}
|