use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use serde_json::{Value, json};
use tempfile::TempDir;
use tower::ServiceExt;
use trusty_common::uds::server::{CODE_INVALID_PARAMS, RpcResponse, RpcRouter};
use super::{METHODS, register};
use crate::core::paths::FrameworkPaths;
use crate::core::session::{ControlModel, Session, SessionId, SessionStatus};
use crate::daemon::api;
use crate::daemon::error::{CODE_CONFLICT, CODE_NOT_FOUND};
use crate::daemon::state::DaemonState;
fn hermetic() -> (Arc<DaemonState>, TempDir) {
let dir = tempfile::tempdir().expect("temp dir for hermetic DaemonState");
let paths = FrameworkPaths::under(dir.path());
(Arc::new(DaemonState::with_paths(&paths)), dir)
}
fn hermetic_with_session() -> (Arc<DaemonState>, SessionId, TempDir) {
let (state, dir) = hermetic();
let id = register_active(&state, "/tmp/p");
(state, id, dir)
}
fn register_active(state: &Arc<DaemonState>, project: &str) -> SessionId {
let id = SessionId::new();
let mut session = Session::new(id, project, ControlModel::Tmux, None);
session.project_path = Some(std::path::PathBuf::from(project));
session.status = SessionStatus::Active;
state.register_session(session);
id
}
fn rpc_router(state: &Arc<DaemonState>) -> RpcRouter {
register(RpcRouter::new(), state)
}
async fn http(
state: &Arc<DaemonState>,
method: &str,
uri: &str,
body: Option<Value>,
) -> (StatusCode, Value) {
let request = Request::builder().method(method).uri(uri);
let request = match body {
Some(v) => request
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&v).expect("encode body")))
.expect("build request"),
None => request.body(Body::empty()).expect("build request"),
};
let response = api::router(Arc::clone(state))
.oneshot(request)
.await
.expect("the router must answer");
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("read the response body");
let value = if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(&bytes)
.unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).into_owned()))
};
(status, value)
}
async fn rpc_frame(router: &RpcRouter, method: &str, params: Value) -> Value {
let frame = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params });
let response: RpcResponse = router
.dispatch(&serde_json::to_vec(&frame).expect("encode the request frame"))
.await;
serde_json::to_value(response).expect("the response frame must serialise")
}
async fn rpc_ok(router: &RpcRouter, method: &str, params: Value) -> Value {
let frame = rpc_frame(router, method, params).await;
assert!(
frame.get("error").is_none(),
"{method} must succeed, got {frame}"
);
frame
.get("result")
.cloned()
.unwrap_or_else(|| panic!("{method} answered no result: {frame}"))
}
async fn rpc_err(router: &RpcRouter, method: &str, params: Value) -> Value {
let frame = rpc_frame(router, method, params).await;
frame
.get("error")
.cloned()
.unwrap_or_else(|| panic!("{method} must be refused, got {frame}"))
}
fn assert_same(method: &str, mut http_body: Value, mut rpc_result: Value, drop_fields: &[&str]) {
for field in drop_fields {
if let Some(map) = http_body.as_object_mut() {
map.remove(*field);
}
if let Some(map) = rpc_result.as_object_mut() {
map.remove(*field);
}
}
assert_eq!(
http_body, rpc_result,
"{method} must answer identically over HTTP and the socket"
);
}
fn session_json(state: &Arc<DaemonState>, id: SessionId, drop_fields: &[&str]) -> Value {
let session = state.session(id).expect("the session must still exist");
let mut value = serde_json::to_value(session).expect("a Session must serialise");
if let Some(map) = value.as_object_mut() {
for field in drop_fields {
map.remove(*field);
}
}
value
}
#[tokio::test]
async fn rpc_router_registers_every_documented_method() {
let (state, _dir) = hermetic();
let router = rpc_router(&state);
let registered: Vec<&str> = router.method_names().collect();
let mut documented: Vec<&str> = METHODS.to_vec();
documented.sort_unstable();
assert_eq!(
registered, documented,
"the router and METHODS must name the same set"
);
assert_eq!(
METHODS.len(),
16,
"slice 3 owns sixteen names; a new one needs a row in sessions_legacy.rs's table too"
);
}
#[tokio::test]
async fn slice_two_and_slice_three_methods_do_not_collide() {
let (state, _dir) = hermetic();
let combined = register(
super::super::core::register(RpcRouter::new(), &state),
&state,
);
assert_eq!(
combined.method_names().count(),
super::super::core::METHODS.len() + METHODS.len(),
"a collision would show as a shortfall here"
);
}
#[tokio::test]
async fn rpc_reports_invalid_params_for_an_undecodable_payload() {
let (state, _dir) = hermetic();
let error = rpc_err(&rpc_router(&state), "mpm.sessions.get", json!({"id": 42})).await;
assert_eq!(error["code"], json!(CODE_INVALID_PARAMS), "{error}");
}
#[tokio::test]
async fn rpc_sessions_list_reports_invalid_params_for_a_wrong_typed_filter() {
let (state, _dir) = hermetic();
register_active(&state, "/tmp/alpha");
let error = rpc_err(
&rpc_router(&state),
"mpm.sessions.list",
json!({"project": 42}),
)
.await;
assert_eq!(error["code"], json!(CODE_INVALID_PARAMS), "{error}");
assert!(
error["message"]
.as_str()
.unwrap_or_default()
.contains("params do not decode"),
"the refusal must say what failed, not answer with a list: {error}"
);
}
#[tokio::test]
async fn rpc_events_poll_answers_with_no_params() {
let (state, _dir) = hermetic();
let result = rpc_ok(&rpc_router(&state), "mpm.events.poll", Value::Null).await;
assert_eq!(result["events"], json!([]), "{result}");
}
#[tokio::test]
async fn parity_sessions_list_agrees_across_transports() {
let (state, _id, _dir) = hermetic_with_session();
let (status, body) = http(&state, "GET", "/sessions", None).await;
assert_eq!(status, StatusCode::OK);
let result = rpc_ok(&rpc_router(&state), "mpm.sessions.list", Value::Null).await;
assert_eq!(
body["sessions"].as_array().map(Vec::len),
Some(1),
"the fixture session must be visible: {body}"
);
assert_same("mpm.sessions.list", body, result, &[]);
}
#[tokio::test]
async fn parity_sessions_list_project_filter_agrees_across_transports() {
let (state, _dir) = hermetic();
register_active(&state, "/tmp/alpha");
register_active(&state, "/tmp/beta");
let (status, body) = http(&state, "GET", "/sessions?project=/tmp/alpha", None).await;
assert_eq!(status, StatusCode::OK);
let result = rpc_ok(
&rpc_router(&state),
"mpm.sessions.list",
json!({"project": "/tmp/alpha"}),
)
.await;
assert_eq!(
body["sessions"].as_array().map(Vec::len),
Some(1),
"the filter must reach the body: {body}"
);
assert_same("mpm.sessions.list", body, result, &[]);
}
#[tokio::test]
async fn parity_get_session_agrees_across_transports() {
let (state, id, _dir) = hermetic_with_session();
let (status, body) = http(&state, "GET", &format!("/sessions/{}", id.0), None).await;
assert_eq!(status, StatusCode::OK);
let result = rpc_ok(
&rpc_router(&state),
"mpm.sessions.get",
json!({"id": id.0.to_string()}),
)
.await;
assert_same("mpm.sessions.get", body, result, &[]);
}
#[tokio::test]
async fn parity_get_session_unknown_id_agrees_across_transports() {
let (state, _dir) = hermetic();
let unknown = SessionId::new().0.to_string();
let (status, body) = http(&state, "GET", &format!("/sessions/{unknown}"), None).await;
assert_eq!(status, StatusCode::NOT_FOUND);
let error = rpc_err(
&rpc_router(&state),
"mpm.sessions.get",
json!({"id": unknown}),
)
.await;
assert_eq!(error["code"], json!(CODE_NOT_FOUND), "{error}");
assert_eq!(
error["message"], body["error"],
"the socket must carry the HTTP body's message verbatim"
);
}
#[tokio::test]
async fn parity_get_session_malformed_id_agrees_across_transports() {
let (state, _dir) = hermetic();
let (status, body) = http(&state, "GET", "/sessions/not-a-uuid", None).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
let error = rpc_err(
&rpc_router(&state),
"mpm.sessions.get",
json!({"id": "not-a-uuid"}),
)
.await;
assert_eq!(error["code"], json!(CODE_INVALID_PARAMS), "{error}");
assert_eq!(error["message"], body["error"], "{error}");
}
#[tokio::test]
async fn parity_events_poll_agrees_across_transports() {
let (state, _id, _dir) = hermetic_with_session();
let (status, body) = http(&state, "GET", "/events/poll", None).await;
assert_eq!(status, StatusCode::OK);
let result = rpc_ok(&rpc_router(&state), "mpm.events.poll", Value::Null).await;
assert_same("mpm.events.poll", body, result, &[]);
}
#[tokio::test]
async fn parity_session_events_poll_agrees_across_transports() {
let (state, id, _dir) = hermetic_with_session();
let (status, body) = http(
&state,
"GET",
&format!("/sessions/{}/events/poll", id.0),
None,
)
.await;
assert_eq!(status, StatusCode::OK);
let result = rpc_ok(
&rpc_router(&state),
"mpm.sessions.events_poll",
json!({"id": id.0.to_string()}),
)
.await;
assert_same("mpm.sessions.events_poll", body, result, &[]);
}
#[tokio::test]
async fn parity_sessions_output_agrees_across_transports() {
let (state, id, _dir) = hermetic_with_session();
let (status, body) = http(
&state,
"GET",
&format!("/sessions/{}/output?lines=7", id.0),
None,
)
.await;
assert_eq!(status, StatusCode::OK);
let result = rpc_ok(
&rpc_router(&state),
"mpm.sessions.output",
json!({"id": id.0.to_string(), "lines": 7}),
)
.await;
assert_eq!(body["lines"], json!(7), "the argument must reach the body");
assert_eq!(body.get("input_box"), Some(&Value::Null), "{body}");
assert_same("mpm.sessions.output", body, result, &[]);
}
#[tokio::test]
async fn parity_sessions_pane_matches_output_on_both_transports() {
let (state, id, _dir) = hermetic_with_session();
let (status, pane_body) = http(&state, "GET", &format!("/sessions/{}/pane", id.0), None).await;
assert_eq!(status, StatusCode::OK);
let (_, output_body) = http(&state, "GET", &format!("/sessions/{}/output", id.0), None).await;
assert_eq!(pane_body, output_body, "the two HTTP paths are one handler");
let router = rpc_router(&state);
let params = json!({"id": id.0.to_string()});
let pane = rpc_ok(&router, "mpm.sessions.pane", params.clone()).await;
let output = rpc_ok(&router, "mpm.sessions.output", params).await;
assert_eq!(pane, output, "the two method names are one body");
assert_same("mpm.sessions.pane", pane_body, pane, &[]);
}
#[tokio::test]
async fn parity_sessions_output_unknown_agrees_across_transports() {
let (state, _dir) = hermetic();
let unknown = SessionId::new().0.to_string();
let (status, body) = http(&state, "GET", &format!("/sessions/{unknown}/output"), None).await;
assert_eq!(status, StatusCode::NOT_FOUND);
let error = rpc_err(
&rpc_router(&state),
"mpm.sessions.output",
json!({"id": unknown}),
)
.await;
assert_eq!(error["code"], json!(CODE_NOT_FOUND), "{error}");
assert_eq!(error["message"], body["error"], "{error}");
}
#[tokio::test]
async fn parity_sessions_reap_agrees_across_transports() {
let (state, _dir) = hermetic();
let (status, body) = http(&state, "DELETE", "/sessions/dead", None).await;
assert_eq!(status, StatusCode::OK);
let result = rpc_ok(&rpc_router(&state), "mpm.sessions.reap", Value::Null).await;
assert_same("mpm.sessions.reap", body, result, &[]);
}
#[serial_test::serial]
#[tokio::test]
async fn parity_sessions_reap_removes_the_dead_and_spares_the_live_on_both_transports() {
use crate::core::session::SessionHost;
let (state, _dir) = hermetic();
let reap_sees_tmux = crate::daemon::tmux::TmuxDriver::discover()
.and_then(|driver| driver.list_sessions())
.is_ok();
let survivor = SessionId::new();
let mut native = Session::new(survivor, "/tmp/native", ControlModel::Tmux, None);
native.origin = SessionHost::Native;
native.status = SessionStatus::Active;
state.register_session(native);
let victim_http = register_active(&state, "/tmp/dead-http");
let (status, body) = http(&state, "DELETE", "/sessions/dead", None).await;
assert_eq!(status, StatusCode::OK);
assert!(
state.session(survivor).is_some(),
"a native session is never reaped, whatever tmux says"
);
let victim_rpc = register_active(&state, "/tmp/dead-rpc");
let result = rpc_ok(&rpc_router(&state), "mpm.sessions.reap", Value::Null).await;
assert_same("mpm.sessions.reap", body, result.clone(), &[]);
assert!(
state.session(survivor).is_some(),
"the socket must spare it too"
);
if reap_sees_tmux {
assert_eq!(
result["removed"],
json!(1),
"with the tmux listing readable each transport must reap exactly its own victim: {result}"
);
assert!(state.session(victim_http).is_none(), "HTTP must reap it");
assert!(state.session(victim_rpc).is_none(), "the socket must too");
assert_eq!(
state.list_sessions().len(),
1,
"only the native survivor may remain"
);
} else {
assert_eq!(
result["removed"],
json!(0),
"with the tmux listing unreadable neither transport may reap anything: {result}"
);
assert!(
state.session(victim_http).is_some(),
"nothing may be reaped"
);
assert!(state.session(victim_rpc).is_some(), "nor over the socket");
}
}
#[serial_test::serial]
#[tokio::test]
async fn parity_sessions_discover_agrees_across_transports() {
let (state, _dir) = hermetic();
let (drain_status, _drained) = http(&state, "POST", "/sessions/discover", None).await;
assert_eq!(drain_status, StatusCode::OK);
let result = rpc_ok(&rpc_router(&state), "mpm.sessions.discover", Value::Null).await;
let (status, body) = http(&state, "POST", "/sessions/discover", None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
result["discovered"],
json!(0),
"a drained registry must adopt nothing more: {result}"
);
assert_same("mpm.sessions.discover", body, result, &[]);
}
#[tokio::test]
async fn parity_sessions_register_agrees_across_transports() {
let (state, _dir) = hermetic();
let payload = json!({"project": "/tmp/reg", "project_path": null, "name": null});
let (status, body) = http(&state, "POST", "/sessions", Some(payload.clone())).await;
assert_eq!(status, StatusCode::OK);
let http_id: SessionId = serde_json::from_value(body["id"].clone()).expect("an id");
let result = rpc_ok(&rpc_router(&state), "mpm.sessions.register", payload).await;
let rpc_id: SessionId = serde_json::from_value(result["id"].clone()).expect("an id");
assert_same("mpm.sessions.register", body, result, &["id", "name"]);
assert_eq!(state.list_sessions().len(), 2, "both writes must land");
let drop = ["id", "tmux_name", "created_at", "last_seen"];
assert_eq!(
session_json(&state, http_id, &drop),
session_json(&state, rpc_id, &drop),
"the two transports must register the same session shape"
);
}
#[tokio::test]
async fn parity_sessions_connect_agrees_across_transports() {
let (state, _dir) = hermetic();
let payload = json!({"project": "/tmp/conn"});
let (status, body) = http(
&state,
"POST",
"/api/v1/sessions/connect",
Some(payload.clone()),
)
.await;
assert_eq!(status, StatusCode::OK);
let result = rpc_ok(&rpc_router(&state), "mpm.sessions.connect", payload).await;
assert_same("mpm.sessions.connect", body, result, &["id", "name"]);
assert_eq!(state.list_sessions().len(), 2, "both writes must land");
}
#[tokio::test]
async fn parity_sessions_delete_agrees_across_transports() {
let (state, _dir) = hermetic();
let over_http = register_active(&state, "/tmp/del");
let over_rpc = register_active(&state, "/tmp/del");
let (status, body) = http(
&state,
"DELETE",
&format!("/sessions/{}", over_http.0),
None,
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(state.session(over_http).is_none(), "HTTP must remove it");
let result = rpc_ok(
&rpc_router(&state),
"mpm.sessions.delete",
json!({"id": over_rpc.0.to_string()}),
)
.await;
assert!(state.session(over_rpc).is_none(), "the socket must too");
assert_eq!(state.list_sessions().len(), 0, "both removals must land");
assert_eq!(body["removed"], json!(over_http.0.to_string()), "{body}");
assert_eq!(result["removed"], json!(over_rpc.0.to_string()), "{result}");
}
#[tokio::test]
async fn parity_sessions_pause_leaves_the_same_state_on_both_transports() {
let (state, id, _dir) = hermetic_with_session();
let uri = format!("/sessions/{}/pause", id.0);
let payload = json!({"summary": "mid-task"});
let drop = ["paused_at", "last_seen"];
let (status, body) = http(&state, "POST", &uri, Some(payload.clone())).await;
assert_eq!(status, StatusCode::OK);
let after_http = session_json(&state, id, &drop);
assert_eq!(
after_http["status"],
json!("Paused"),
"HTTP must actually pause it: {after_http}"
);
let router = rpc_router(&state);
rpc_ok(
&router,
"mpm.sessions.resume",
json!({"id": id.0.to_string()}),
)
.await;
let result = rpc_ok(
&router,
"mpm.sessions.pause",
json!({"id": id.0.to_string(), "summary": "mid-task"}),
)
.await;
let after_rpc = session_json(&state, id, &drop);
assert_same("mpm.sessions.pause", body, result, &["session_id"]);
assert_eq!(
after_http, after_rpc,
"both transports must leave the same paused session"
);
}
#[tokio::test]
async fn parity_sessions_resume_leaves_the_same_state_on_both_transports() {
let (state, id, _dir) = hermetic_with_session();
let router = rpc_router(&state);
let params = json!({"id": id.0.to_string()});
let drop = ["paused_at", "last_seen"];
rpc_ok(&router, "mpm.sessions.pause", params.clone()).await;
let (status, body) = http(&state, "POST", &format!("/sessions/{}/resume", id.0), None).await;
assert_eq!(status, StatusCode::OK);
let after_http = session_json(&state, id, &drop);
assert_eq!(after_http["pause_summary"], json!(null), "{after_http}");
rpc_ok(&router, "mpm.sessions.pause", params.clone()).await;
let result = rpc_ok(&router, "mpm.sessions.resume", params).await;
let after_rpc = session_json(&state, id, &drop);
assert_same("mpm.sessions.resume", body, result, &[]);
assert_eq!(
after_http, after_rpc,
"both transports must leave the same resumed session"
);
}
#[tokio::test]
async fn parity_sessions_resume_unpaused_agrees_across_transports() {
let (state, id, _dir) = hermetic_with_session();
let (status, body) = http(&state, "POST", &format!("/sessions/{}/resume", id.0), None).await;
assert_eq!(status, StatusCode::CONFLICT);
let error = rpc_err(
&rpc_router(&state),
"mpm.sessions.resume",
json!({"id": id.0.to_string()}),
)
.await;
assert_eq!(error["code"], json!(CODE_CONFLICT), "{error}");
assert_eq!(error["message"], body["error"], "{error}");
assert_eq!(
state.session(id).expect("still there").status,
SessionStatus::Active,
"a refused resume must not mutate the session"
);
}
#[tokio::test]
async fn parity_sessions_command_leaves_the_same_state_on_both_transports() {
let (state, id, _dir) = hermetic_with_session();
let drop = ["last_seen"];
let before = session_json(&state, id, &drop);
let payload = json!({"command": "help"});
let (status, body) = http(
&state,
"POST",
&format!("/sessions/{}/command", id.0),
Some(payload),
)
.await;
assert_eq!(status, StatusCode::OK);
let after_http = session_json(&state, id, &drop);
let result = rpc_ok(
&rpc_router(&state),
"mpm.sessions.command",
json!({"id": id.0.to_string(), "command": "help"}),
)
.await;
let after_rpc = session_json(&state, id, &drop);
assert_same("mpm.sessions.command", body, result, &[]);
assert_eq!(before, after_http, "HTTP must not disturb the registry");
assert_eq!(after_http, after_rpc, "nor may the socket");
assert_eq!(state.list_sessions().len(), 1, "no session may be added");
}
#[tokio::test]
async fn parity_sessions_command_stopped_agrees_across_transports() {
let (state, _dir) = hermetic();
let id = SessionId::new();
let mut session = Session::new(id, "/tmp/p", ControlModel::Tmux, None);
session.status = SessionStatus::Stopped;
state.register_session(session);
let (status, body) = http(
&state,
"POST",
&format!("/sessions/{}/command", id.0),
Some(json!({"command": "help"})),
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
let error = rpc_err(
&rpc_router(&state),
"mpm.sessions.command",
json!({"id": id.0.to_string(), "command": "help"}),
)
.await;
assert_eq!(error["code"], json!(CODE_CONFLICT), "{error}");
assert_eq!(error["message"], body["error"], "{error}");
}
#[tokio::test]
async fn parity_sessions_set_pid_agrees_across_transports() {
let (state, id, _dir) = hermetic_with_session();
let uri = format!("/sessions/{}/pid", id.0);
let (status, body) = http(&state, "PATCH", &uri, Some(json!({"pid": 4242}))).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
state.session(id).expect("still there").pid,
Some(4242),
"HTTP must record the pid"
);
let result = rpc_ok(
&rpc_router(&state),
"mpm.sessions.set_pid",
json!({"id": id.0.to_string(), "pid": 5353}),
)
.await;
assert_eq!(
state.session(id).expect("still there").pid,
Some(5353),
"the socket must record it too"
);
assert_same("mpm.sessions.set_pid", body, result, &["pid"]);
}
#[tokio::test]
async fn parity_sessions_set_pid_unknown_agrees_across_transports() {
let (state, _dir) = hermetic();
let unknown = SessionId::new().0.to_string();
let (status, body) = http(
&state,
"PATCH",
&format!("/sessions/{unknown}/pid"),
Some(json!({"pid": 1})),
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
let error = rpc_err(
&rpc_router(&state),
"mpm.sessions.set_pid",
json!({"id": unknown, "pid": 1}),
)
.await;
assert_eq!(error["code"], json!(CODE_NOT_FOUND), "{error}");
assert_eq!(error["message"], body["error"], "{error}");
}
#[tokio::test]
async fn parity_hooks_ingest_leaves_the_same_event_log_on_both_transports() {
let (state, id, _dir) = hermetic_with_session();
let payload = json!({
"session_id": id.0.to_string(),
"event": "PostToolUse",
"payload": {"tool": "Edit"},
});
let (status, body) = http(&state, "POST", "/hooks", Some(payload.clone())).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(state.recent_hook_events().len(), 1, "HTTP must append one");
let result = rpc_ok(&rpc_router(&state), "mpm.hooks.ingest", payload).await;
let events = state.recent_hook_events();
assert_eq!(events.len(), 2, "the socket must append one too");
assert_same("mpm.hooks.ingest", body, result, &[]);
let mut first = serde_json::to_value(&events[0]).expect("a record must serialise");
let mut second = serde_json::to_value(&events[1]).expect("a record must serialise");
for value in [&mut first, &mut second] {
if let Some(map) = value.as_object_mut() {
map.remove("at");
}
}
assert_eq!(
first, second,
"both transports must append the same record modulo its timestamp"
);
}
#[tokio::test]
async fn parity_hooks_session_start_auto_registers_on_both_transports() {
let (state, _dir) = hermetic();
let over_http = SessionId::new();
let over_rpc = SessionId::new();
let event = |id: SessionId| json!({"session_id": id.0.to_string(), "event": "SessionStart", "payload": {}});
let (status, body) = http(&state, "POST", "/hooks", Some(event(over_http))).await;
assert_eq!(status, StatusCode::OK);
assert!(
state.session(over_http).is_some(),
"HTTP must auto-register the session"
);
let result = rpc_ok(&rpc_router(&state), "mpm.hooks.ingest", event(over_rpc)).await;
assert!(
state.session(over_rpc).is_some(),
"the socket must auto-register it too"
);
assert_same("mpm.hooks.ingest", body, result, &[]);
let drop = ["id", "tmux_name", "created_at", "last_seen"];
assert_eq!(
session_json(&state, over_http, &drop),
session_json(&state, over_rpc, &drop),
"both transports must register the same session shape"
);
}
#[tokio::test]
async fn parity_hooks_ingest_malformed_id_agrees_across_transports() {
let (state, _dir) = hermetic();
let payload = json!({"session_id": "not-a-uuid", "event": "PostToolUse", "payload": {}});
let (status, body) = http(&state, "POST", "/hooks", Some(payload.clone())).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(state.recent_hook_events().len(), 0, "nothing may be logged");
let error = rpc_err(&rpc_router(&state), "mpm.hooks.ingest", payload).await;
assert_eq!(error["code"], json!(CODE_INVALID_PARAMS), "{error}");
assert_eq!(error["message"], body["error"], "{error}");
assert_eq!(state.recent_hook_events().len(), 0, "still nothing logged");
}
#[tokio::test]
async fn parity_hooks_ingest_unknown_event_is_refused_on_both_transports() {
let (state, id, _dir) = hermetic_with_session();
let payload = json!({"session_id": id.0.to_string(), "event": "NotAnEvent", "payload": {}});
let (status, _body) = http(&state, "POST", "/hooks", Some(payload.clone())).await;
assert!(status.is_client_error(), "HTTP must refuse it: {status}");
let error = rpc_err(&rpc_router(&state), "mpm.hooks.ingest", payload).await;
assert_eq!(error["code"], json!(CODE_INVALID_PARAMS), "{error}");
assert_eq!(state.recent_hook_events().len(), 0, "nothing may be logged");
}