display_hint becomes {kind, h_align, v_align} object in API, SQLite
gets alignment columns, SPA widget form gets alignment selects, layout
preview reflects actual alignment instead of hardcoded center
250 lines
7.0 KiB
Rust
250 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,
|
|
"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);
|
|
}
|