use car_memgine::MemgineEngine;
use car_server_core::{run_dispatch, ServerState, ServerStateConfig};
use futures::{SinkExt, StreamExt};
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
use std::sync::Arc;
use tempfile::TempDir;
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use tokio_tungstenite::{accept_async, connect_async, tungstenite::Message};
type Ws =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
const HOST_TOKEN: &str = "test-host-token-bbbbbbbbbbbbbbbbbbbbbbbbbbb";
fn gated_state(journal_dir: std::path::PathBuf) -> Arc<ServerState> {
let engine = Arc::new(Mutex::new(MemgineEngine::new(None)));
let cfg = ServerStateConfig::new(journal_dir).with_shared_memgine(engine);
let state = Arc::new(ServerState::with_config(cfg));
state
.install_host_token(HOST_TOKEN.to_string())
.expect("install host token");
state
}
async fn spawn_dispatcher(state: Arc<ServerState>) -> SocketAddr {
let listener = TcpListener::bind(SocketAddr::V4(SocketAddrV4::new(
Ipv4Addr::new(127, 0, 0, 1),
0,
)))
.await
.expect("bind loopback");
let addr = listener.local_addr().expect("local_addr");
tokio::spawn(async move {
let (stream, peer) = listener.accept().await.expect("accept");
let ws = accept_async(stream).await.expect("ws handshake");
let (write, read) = ws.split();
let _ = run_dispatch(read, Box::pin(write), peer.to_string(), state).await;
});
addr
}
async fn call(ws: &mut Ws, id: &str, method: &str, params: serde_json::Value) -> serde_json::Value {
ws.send(Message::Text(
serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })
.to_string()
.into(),
))
.await
.expect("send");
let text = ws
.next()
.await
.expect("frame")
.expect("frame ok")
.into_text()
.expect("text")
.to_string();
serde_json::from_str(&text).expect("parse")
}
#[tokio::test]
async fn status_and_test_send_are_host_gated_and_status_reports_liveness() {
let journal = TempDir::new().unwrap();
let fake_home = TempDir::new().unwrap();
std::env::set_var("HOME", fake_home.path());
let state = gated_state(journal.path().to_path_buf());
let addr = spawn_dispatcher(state).await;
let (mut ws, _) = connect_async(format!("ws://{addr}"))
.await
.expect("connect");
for (i, method) in ["messaging.status", "messaging.test_send"]
.into_iter()
.enumerate()
{
let resp = call(&mut ws, &format!("n{i}"), method, serde_json::json!({})).await;
assert!(
resp.get("error").is_some(),
"{method} must REJECT a non-host caller, got: {resp}"
);
assert!(
resp.get("result").is_none(),
"{method} must not return a result to a non-host caller"
);
}
let auth = call(
&mut ws,
"auth",
"session.auth",
serde_json::json!({ "host_token": HOST_TOKEN }),
)
.await;
assert_eq!(auth["result"]["role"], "host", "host auth: {auth}");
let set = call(
&mut ws,
"h1",
"messaging.config.set",
serde_json::json!({ "enabled": true }),
)
.await;
assert!(set.get("error").is_none(), "host config.set: {set}");
let status = call(&mut ws, "h2", "messaging.status", serde_json::json!({})).await;
assert!(status.get("error").is_none(), "host status: {status}");
let r = &status["result"];
assert_eq!(r["channel"], "imessage", "status names the channel");
assert_eq!(r["enabled"], true, "enabled reflects the config");
assert_eq!(r["paired"], false, "no handle paired yet");
assert_eq!(
r["watcher_running"], false,
"watcher_running is false without a supervisor (no fake liveness)"
);
assert!(
r.get("fda_readable").is_some(),
"fda_readable is always present (probed daemon-side)"
);
assert!(
r.get("last_send_ok").is_none(),
"no send recorded ⇒ last_send_ok omitted"
);
let _ = call(
&mut ws,
"h3",
"messaging.config.set",
serde_json::json!({ "add_handles": ["+15551234567"] }),
)
.await;
let status2 = call(&mut ws, "h4", "messaging.status", serde_json::json!({})).await;
assert_eq!(
status2["result"]["paired"], true,
"status reflects the paired handle"
);
let test_off = call(
&mut ws,
"h5",
"messaging.test_send",
serde_json::json!({ "channel": "slack" }),
)
.await;
assert!(
test_off.get("error").is_none(),
"test_send returns a structured result, not a JSON-RPC error: {test_off}"
);
assert_eq!(
test_off["result"]["ok"], false,
"slack test_send is not ok (off / unsupported) — and attempts no send"
);
assert!(
test_off["result"]["error"].is_string(),
"an actionable error string is returned: {test_off}"
);
if std::env::var("CAR_TEST_LIVE_IMESSAGE").is_ok() {
let test = call(&mut ws, "h6", "messaging.test_send", serde_json::json!({})).await;
assert!(
test.get("error").is_none(),
"live iMessage test_send returns a structured result, not a JSON-RPC error: {test}"
);
assert!(
test["result"].get("ok").is_some(),
"live iMessage test_send result carries an `ok` field: {test}"
);
}
}