use std::time::{Duration, Instant};
use super::*;
#[test]
fn unwrap_mcp_content_extracts_text_json() {
use serde_json::json;
let envelope = json!({
"content": [{"type": "text", "text": "[{\"id\":\"foo\"}]"}],
"isError": false
});
let result = unwrap_mcp_content(envelope);
assert!(result.is_array(), "expected array, got: {result}");
assert_eq!(result[0]["id"], "foo");
}
#[test]
fn unwrap_mcp_content_non_json_text_returns_string() {
use serde_json::json;
let envelope = json!({
"content": [{"type": "text", "text": "plain text, not json"}],
"isError": false
});
let result = unwrap_mcp_content(envelope);
assert!(result.is_string(), "expected string for non-JSON text");
}
#[test]
fn unwrap_mcp_content_passthrough_on_unknown_shape() {
use serde_json::json;
let raw = json!({"data": [1, 2, 3]});
let result = unwrap_mcp_content(raw.clone());
assert_eq!(result, raw);
}
#[test]
fn compute_backoff_delay_base() {
assert_eq!(
compute_backoff_delay(1, BACKOFF_BASE_MS, BACKOFF_CAP_MS),
BACKOFF_BASE_MS,
"first failure must wait base_ms"
);
}
#[test]
fn compute_backoff_delay_doubles() {
assert_eq!(
compute_backoff_delay(2, BACKOFF_BASE_MS, BACKOFF_CAP_MS),
2 * BACKOFF_BASE_MS,
"second failure must double the delay"
);
}
#[test]
fn compute_backoff_delay_caps() {
assert_eq!(
compute_backoff_delay(100, BACKOFF_BASE_MS, BACKOFF_CAP_MS),
BACKOFF_CAP_MS,
"large attempt must cap at cap_ms"
);
}
#[test]
fn compute_backoff_delay_attempt_zero() {
assert_eq!(
compute_backoff_delay(0, BACKOFF_BASE_MS, BACKOFF_CAP_MS),
BACKOFF_BASE_MS,
"attempt=0 must not underflow — returns base_ms"
);
}
#[tokio::test]
async fn mcp_handle_absent_binary_returns_error() {
let handle =
McpServiceHandle::new("/nonexistent/trusty-analyze-xyzzy", vec!["mcp".to_string()]);
let result = handle.poll_metrics().await;
assert!(result.is_err(), "absent binary must return Err");
}
#[test]
fn mcp_handle_constructs_without_io() {
let handle = McpServiceHandle::new("trusty-analyze", vec!["mcp".to_string()]);
assert_eq!(handle.binary, "trusty-analyze");
assert_eq!(handle.args, vec!["mcp"]);
}
#[tokio::test]
async fn mcp_handle_absent_never_retries() {
let handle = McpServiceHandle::new(
"/nonexistent/trusty-analyze-xyzzy2",
vec!["mcp".to_string()],
);
let r1 = handle.poll_metrics().await;
let r2 = handle.poll_metrics().await;
assert!(r1.is_err(), "first poll must return Err for absent binary");
assert!(r2.is_err(), "second poll must also return Err (no retry)");
}
#[tokio::test]
async fn mcp_handle_degraded_state_returns_degraded_error() {
let handle = McpServiceHandle::new("trusty-analyze", vec!["mcp".to_string()]);
handle
.prime_degraded_with_backoff_for_test(Duration::from_secs(60))
.await;
let result = handle.poll_metrics().await;
assert!(result.is_err(), "degraded handle must return Err");
match result.unwrap_err() {
McpHandleError::Degraded { hint } => {
assert!(!hint.is_empty(), "degraded hint must not be empty");
assert!(
hint.contains("console_metrics"),
"hint must mention console_metrics, got: {hint}"
);
}
other => panic!("expected McpHandleError::Degraded, got: {other}"),
}
}
#[tokio::test]
async fn test_degraded_hint_returns_some_when_degraded() {
let handle = McpServiceHandle::new("trusty-analyze", vec!["mcp".to_string()]);
{
let mut guard = handle.state.lock().await;
let (state_opt, _) = &mut *guard;
*state_opt = Some(HandleState::Degraded);
}
let hint = handle.degraded_hint().await;
assert!(hint.is_some(), "Degraded state must return Some(hint)");
assert!(
hint.unwrap().contains("console_metrics"),
"hint must mention console_metrics"
);
let h2 = McpServiceHandle::new("trusty-analyze", vec!["mcp".to_string()]);
assert!(
h2.degraded_hint().await.is_none(),
"None state must return None"
);
let h3 = McpServiceHandle::new("trusty-analyze", vec!["mcp".to_string()]);
{
let mut guard = h3.state.lock().await;
let (state_opt, _) = &mut *guard;
*state_opt = Some(HandleState::Absent);
}
assert!(
h3.degraded_hint().await.is_none(),
"Absent state must return None"
);
}
#[tokio::test]
async fn mcp_handle_respawn_failure_applies_backoff() {
let handle = McpServiceHandle::new("trusty-analyze", vec!["mcp".to_string()]);
{
let mut guard = handle.state.lock().await;
let (state_opt, backoff) = &mut *guard;
backoff.failure_count = 1;
backoff.next_attempt = Instant::now() + Duration::from_secs(60);
assert!(state_opt.is_none());
}
let handle_with_true = McpServiceHandle::new("true", vec![]);
{
let mut guard = handle_with_true.state.lock().await;
let (_state_opt, backoff) = &mut *guard;
backoff.failure_count = 1;
backoff.next_attempt = Instant::now() + Duration::from_secs(60);
}
let result = handle_with_true.poll_metrics().await;
assert!(
result.is_err(),
"poll_metrics must return Err while in backoff window — respawn path must be gated"
);
assert!(
matches!(result.unwrap_err(), McpHandleError::Backoff { .. }),
"error must be McpHandleError::Backoff"
);
}
#[tokio::test]
#[cfg(unix)]
#[ignore]
async fn mcp_handle_probe_detects_missing_console_metrics_tool() {
let script = r#"
while IFS= read -r line; do
id=$(echo "$line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null)
method=$(echo "$line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('method',''))" 2>/dev/null)
case "$method" in
initialize) echo "{\"jsonrpc\":\"2.0\",\"id\":$id,\"result\":{\"protocolVersion\":\"2024-11-05\",\"serverInfo\":{\"name\":\"stub\",\"version\":\"0.0.1\"},\"capabilities\":{}}}" ;;
"notifications/initialized") ;;
"tools/list") echo "{\"jsonrpc\":\"2.0\",\"id\":$id,\"result\":{\"tools\":[]}}" ;;
*) echo "{\"jsonrpc\":\"2.0\",\"id\":$id,\"result\":{}}" ;;
esac
done
"#;
let handle = McpServiceHandle::new("sh", vec!["-c".to_string(), script.to_string()]);
let result = handle.poll_metrics().await;
assert!(
result.is_err(),
"stub with no console_metrics must return Err"
);
assert!(
matches!(result.unwrap_err(), McpHandleError::Degraded { .. }),
"error must be McpHandleError::Degraded when console_metrics absent"
);
}
#[tokio::test]
async fn mcp_handle_degraded_within_backoff_window_stays_degraded() {
let handle = McpServiceHandle::new("true", vec![]);
handle
.prime_degraded_with_backoff_for_test(Duration::from_secs(60))
.await;
let result = handle.poll_metrics().await;
assert!(
result.is_err(),
"Degraded handle within backoff window must return Err"
);
assert!(
matches!(result.unwrap_err(), McpHandleError::Degraded { .. }),
"error must remain Degraded while inside the backoff window"
);
let guard = handle.state.lock().await;
let (state_opt, _) = &*guard;
assert!(
matches!(state_opt, Some(HandleState::Degraded)),
"state must remain Degraded when backoff window has not elapsed"
);
}
#[tokio::test]
async fn mcp_handle_degraded_self_heals_after_backoff_window() {
let handle = McpServiceHandle::new("/nonexistent/xyzzy-selfheal-test", vec!["mcp".to_string()]);
handle
.prime_degraded_with_backoff_for_test(Duration::ZERO)
.await;
let result = handle.poll_metrics().await;
assert!(
result.is_err(),
"self-healing re-probe must still return Err (binary absent)"
);
assert!(
!matches!(result.unwrap_err(), McpHandleError::Degraded { .. }),
"after backoff window the handle must not return Degraded — \
re-probe should have cleared it (expected Absent)"
);
let guard = handle.state.lock().await;
let (state_opt, _) = &*guard;
assert!(
matches!(state_opt, Some(HandleState::Absent)),
"state must be Absent after re-probe with nonexistent binary, \
not Degraded"
);
}
#[tokio::test]
async fn call_tool_checked_returns_tool_unavailable_when_tool_absent() {
use std::collections::HashSet;
use trusty_common::stdio_mcp_client::StdioMcpClient;
let handle = McpServiceHandle::new("trusty-analyze", vec!["mcp".to_string()]);
let cat_client = StdioMcpClient::spawn("cat", &[], "test-client")
.await
.expect("cat must be available");
let client_arc = std::sync::Arc::new(tokio::sync::Mutex::new(
Box::new(cat_client) as Box<StdioMcpClient>
));
let mut tool_set = HashSet::new();
tool_set.insert("console_metrics".to_string());
{
let mut guard = handle.state.lock().await;
let (state_opt, backoff) = &mut *guard;
backoff.reset();
*state_opt = Some(HandleState::Connected {
client: std::sync::Arc::clone(&client_arc),
tool_names: tool_set,
daemon_version: "0.7.0".to_string(),
});
}
let result = handle
.call_tool_checked("list_analyze_indexes", serde_json::json!({}))
.await;
assert!(
result.is_err(),
"call_tool_checked must return Err when tool is absent from cached set"
);
match result.unwrap_err() {
McpHandleError::ToolUnavailable { tool, hint } => {
assert_eq!(
tool, "list_analyze_indexes",
"ToolUnavailable must name the requested tool"
);
assert!(
!hint.is_empty(),
"ToolUnavailable must include a non-empty actionable hint"
);
assert!(
hint.contains("list_analyze_indexes"),
"hint must mention the missing tool name, got: {hint}"
);
}
other => panic!(
"expected McpHandleError::ToolUnavailable, got: {other} — \
capability-gate must fire BEFORE any JSON-RPC call"
),
}
}
#[tokio::test]
#[cfg(unix)]
async fn test_daemon_version_returns_some_when_connected() {
use std::collections::HashSet;
use trusty_common::stdio_mcp_client::StdioMcpClient;
let handle = McpServiceHandle::new("trusty-analyze", vec!["mcp".to_string()]);
assert!(
handle.daemon_version().await.is_none(),
"uninitialised handle must return None for daemon_version"
);
let cat_client = StdioMcpClient::spawn("cat", &[], "test-client")
.await
.expect("cat must be available");
let client_arc = std::sync::Arc::new(tokio::sync::Mutex::new(
Box::new(cat_client) as Box<StdioMcpClient>
));
let mut tool_set = HashSet::new();
tool_set.insert("console_metrics".to_string());
{
let mut guard = handle.state.lock().await;
let (state_opt, _) = &mut *guard;
*state_opt = Some(HandleState::Connected {
client: client_arc,
tool_names: tool_set,
daemon_version: "1.2.3-test".to_string(),
});
}
let v = handle.daemon_version().await;
assert_eq!(
v.as_deref(),
Some("1.2.3-test"),
"Connected handle must return the cached daemon version"
);
let h2 = McpServiceHandle::new("/nonexistent/binary", vec![]);
{
let mut guard = h2.state.lock().await;
let (state_opt, _) = &mut *guard;
*state_opt = Some(HandleState::Absent);
}
assert!(
h2.daemon_version().await.is_none(),
"Absent handle must return None for daemon_version"
);
}
#[tokio::test]
async fn outer_lock_not_held_during_probe_outer_lock_remains_acquirable() {
let handle = McpServiceHandle::new("true", vec![]);
{
let mut guard = handle.state.lock().await;
let (state_opt, _) = &mut *guard;
*state_opt = Some(HandleState::Absent);
}
let guard = handle.state.lock().await;
let (state_opt, _) = &*guard;
assert!(
state_opt.is_some(),
"outer lock must be acquirable; state must be readable without blocking"
);
drop(guard);
let h2 = McpServiceHandle::new("true", vec![]);
h2.prime_degraded_with_backoff_for_test(Duration::from_secs(60))
.await;
let r2 = h2.poll_metrics().await;
assert!(
matches!(r2.unwrap_err(), McpHandleError::Degraded { .. }),
"Degraded state within backoff window must return McpHandleError::Degraded"
);
let h3 = McpServiceHandle::new("/nonexistent/binary-locktest-xyzzy", vec![]);
let r3 = h3.poll_metrics().await;
assert!(
matches!(r3.unwrap_err(), McpHandleError::Absent),
"absent binary must return McpHandleError::Absent"
);
}