use std::io::Write;
use std::sync::Arc;
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use tempfile::tempdir;
use tower::ServiceExt;
use ulid::Ulid;
use crate::events::{Event, EventData, EventWriter, Kind, Rect, Source, Target};
use crate::http::state::AppState;
fn test_state() -> (Arc<AppState>, tempfile::TempDir) {
let dir = tempdir().unwrap();
let root = dir.path().join("root");
let state = dir.path().join("state");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&state).unwrap();
let events = state.join("events.jsonl");
let writer = Arc::new(EventWriter::new(&events));
let app_state = Arc::new(AppState::new(root, state, writer, 16));
(app_state, dir)
}
fn post_url() -> &'static str {
"/api/events"
}
fn sample_payload(doc: &str) -> serde_json::Value {
serde_json::json!({
"v": 2,
"id": null,
"ts": "2026-07-05T18:21:00Z",
"src": "domi.js",
"doc": doc,
"kind": "click",
"target": {"id": "btn-save", "selector": null, "rect": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 1.0}},
"data": {"value": "Save"}
})
}
fn write_three_events(state: &Arc<AppState>) -> Vec<Ulid> {
let w = state.writer.clone();
let mut ids = Vec::new();
for i in 0..3 {
let id = Ulid::new();
let ev = Event {
v: 2,
id,
ts: chrono::Utc::now(),
src: Source::DomiJs,
doc: format!("doc-{i}"),
kind: Kind::Click,
target: Target {
id: None,
selector: None,
rect: Rect {
x: 0.0,
y: 0.0,
w: 1.0,
h: 1.0,
},
},
data: EventData::Click {
value: Some(format!("v{i}").into()),
},
};
w.write(&ev).unwrap();
ids.push(id);
}
ids
}
fn app_router(state: Arc<AppState>) -> axum::Router {
crate::http::router::build_router(state)
}
#[tokio::test]
async fn banner_returns_expected_json_shape() {
let (state, _dir) = test_state();
let app = app_router(state);
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["name"], "domi-server");
assert_eq!(json["protocol"], "2");
assert!(json["version"].is_string());
assert!(!json["version"].as_str().unwrap().is_empty());
}
#[tokio::test]
async fn healthz_returns_ok() {
let (state, _dir) = test_state();
let app = app_router(state.clone());
let response = app
.oneshot(
Request::builder()
.uri("/healthz")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["status"], "ok");
assert_eq!(json["serverId"], state.server_id.to_string());
}
#[tokio::test]
async fn serve_html_200_with_shim_injected() {
let (state, _dir) = test_state();
std::fs::write(
state.root.join("dashboard.html"),
r#"<!doctype html><html><body><script src="../scripts/runtime/domi.js"></script></body></html>"#,
)
.unwrap();
let app = app_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/dashboard.html")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let s = std::str::from_utf8(&body).unwrap();
assert!(s.contains("window.__DOMI_SERVER__"), "shim injected");
assert!(s.contains("runtime/domi.js"), "original script tag preserved");
let shim_pos = s.find("window.__DOMI_SERVER__").unwrap();
let original_pos = s.find("runtime/domi.js").unwrap();
assert!(shim_pos < original_pos, "shim before original");
}
#[tokio::test]
async fn serve_css_200_unchanged() {
let (state, _dir) = test_state();
let mut f = std::fs::File::create(state.root.join("style.css")).unwrap();
f.write_all(b"body { color: red; }").unwrap();
let app = app_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/style.css")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let s = std::str::from_utf8(&body).unwrap();
assert!(s.contains("color: red"));
}
#[tokio::test]
async fn serve_404_on_missing() {
let (state, _dir) = test_state();
let app = app_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/nope.html")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn post_event_204_and_appends_to_file() {
let (state, _dir) = test_state();
let app = app_router(state.clone());
let payload = sample_payload("smoke-1");
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(post_url())
.header("content-type", "application/json")
.body(Body::from(payload.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NO_CONTENT);
let events_path = state.state_dir.join("events.jsonl");
let body = std::fs::read_to_string(&events_path).unwrap();
assert_eq!(body.lines().count(), 1);
}
#[tokio::test]
async fn post_event_stamps_id_when_null() {
let (state, _dir) = test_state();
let app = app_router(state.clone());
let payload = sample_payload("smoke-id-null");
let _ = app
.oneshot(
Request::builder()
.method("POST")
.uri(post_url())
.header("content-type", "application/json")
.body(Body::from(payload.to_string()))
.unwrap(),
)
.await
.unwrap();
let events_path = state.state_dir.join("events.jsonl");
let body = std::fs::read_to_string(&events_path).unwrap();
let ev: serde_json::Value = serde_json::from_str(body.lines().next().unwrap()).unwrap();
assert!(ev["id"].is_string());
let s = ev["id"].as_str().unwrap();
assert_eq!(s.len(), 26, "stamped id is a ULID (26 chars)");
assert!(!s.contains("null"));
}
#[tokio::test]
async fn post_event_stamps_id_when_missing() {
let (state, _dir) = test_state();
let app = app_router(state.clone());
let mut payload = sample_payload("smoke-id-missing");
payload.as_object_mut().unwrap().remove("id");
let _ = app
.oneshot(
Request::builder()
.method("POST")
.uri(post_url())
.header("content-type", "application/json")
.body(Body::from(payload.to_string()))
.unwrap(),
)
.await
.unwrap();
let events_path = state.state_dir.join("events.jsonl");
let body = std::fs::read_to_string(&events_path).unwrap();
let ev: serde_json::Value = serde_json::from_str(body.lines().next().unwrap()).unwrap();
assert_eq!(ev["id"].as_str().unwrap().len(), 26);
}
#[tokio::test]
async fn post_event_400_on_v_not_2() {
let (state, _dir) = test_state();
let app = app_router(state);
let mut payload = sample_payload("smoke-v1");
payload["v"] = serde_json::json!(1);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(post_url())
.header("content-type", "application/json")
.body(Body::from(payload.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn post_event_400_on_empty_doc() {
let (state, _dir) = test_state();
let app = app_router(state);
let payload = sample_payload("");
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(post_url())
.header("content-type", "application/json")
.body(Body::from(payload.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn post_event_400_on_bad_kind() {
let (state, _dir) = test_state();
let app = app_router(state);
let mut payload = sample_payload("smoke-bad-kind");
payload["kind"] = serde_json::json!("bogus");
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(post_url())
.header("content-type", "application/json")
.body(Body::from(payload.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn post_event_accepts_null_rect_for_rail_audit() {
let (state, _dir) = test_state();
let app = app_router(state.clone());
let payload = serde_json::json!({
"v": 2,
"id": null,
"ts": "2026-07-05T18:21:00Z",
"src": "domi-audit.js",
"doc": "smoke-rail",
"kind": "rail-add",
"target": {
"id": null,
"selector": null,
"rect": null
},
"data": { "body": "hi", "targetId": null }
});
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(post_url())
.header("content-type", "application/json")
.body(Body::from(payload.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn get_events_returns_filtered_after_since() {
let (state, _dir) = test_state();
let ids = write_three_events(&state);
let app = app_router(state);
let url = format!("/api/events?since={}", ids[0]);
let response = app
.oneshot(Request::builder().uri(&url).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let events = json["events"].as_array().unwrap();
assert_eq!(events.len(), 2, "expected events 2 and 3, got {:?}", events);
assert_eq!(json["nextSince"].as_str().unwrap(), ids[2].to_string());
}
#[tokio::test]
async fn get_events_filters_by_doc() {
let (state, _dir) = test_state();
write_three_events(&state);
let app = app_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/api/events?doc=doc-1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let events = json["events"].as_array().unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0]["doc"], "doc-1");
}
#[tokio::test]
async fn get_events_default_limit_100() {
let (state, _dir) = test_state();
let mut lines = String::new();
for _i in 0..150 {
let id = Ulid::new();
let ev = Event {
v: 2,
id,
ts: chrono::Utc::now(),
src: Source::DomiJs,
doc: "x".into(),
kind: Kind::Click,
target: Target {
id: None,
selector: None,
rect: Rect {
x: 0.0,
y: 0.0,
w: 1.0,
h: 1.0,
},
},
data: EventData::Click { value: None },
};
lines.push_str(&serde_json::to_string(&ev).unwrap());
lines.push('\n');
}
std::fs::write(state.state_dir.join("events.jsonl"), lines).unwrap();
let app = app_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/api/events")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["events"].as_array().unwrap().len(), 100);
}
#[tokio::test]
async fn get_events_limit_clamped_to_1000() {
let (state, _dir) = test_state();
let mut lines = String::new();
for _i in 0..1500 {
let id = Ulid::new();
let ev = Event {
v: 2,
id,
ts: chrono::Utc::now(),
src: Source::DomiJs,
doc: "x".into(),
kind: Kind::Click,
target: Target {
id: None,
selector: None,
rect: Rect {
x: 0.0,
y: 0.0,
w: 1.0,
h: 1.0,
},
},
data: EventData::Click { value: None },
};
lines.push_str(&serde_json::to_string(&ev).unwrap());
lines.push('\n');
}
std::fs::write(state.state_dir.join("events.jsonl"), lines).unwrap();
let app = app_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/api/events?limit=9999")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["events"].as_array().unwrap().len(), 1000);
}