//! End-to-end tests for the Truth Engine integration. //! //! These tests verify the `evaluate` and `truth_status` MCP tools, //! enhanced query responses with truth fields, and the background //! scoring pipeline. //! //! Prerequisites: //! - OpenBrain MCP server running with `OPENBRAIN__TRUTH__ENABLED=true` //! - Database accessible and migrated //! - Set `OPENBRAIN_E2E_BASE_URL` if not using default `http://127.0.0.1:3100` use serde_json::{json, Value}; use std::time::Duration; use uuid::Uuid; // ── Helpers (matching e2e_mcp.rs transport patterns) ─────────────────── fn base_url() -> String { std::env::var("OPENBRAIN_E2E_BASE_URL").unwrap_or_else(|_| "http://127.0.0.1:3100".to_string()) } fn api_key() -> Option { std::env::var("OPENBRAIN_E2E_API_KEY") .ok() .or_else(|| std::env::var("OPENBRAIN__AUTH__API_KEYS").ok()) .map(|keys| keys.split(',').next().unwrap_or("").trim().to_string()) .filter(|k| !k.is_empty()) } fn apply_request_headers( mut req_builder: reqwest::RequestBuilder, api_key_override: Option<&str>, ) -> reqwest::RequestBuilder { if let Some(key) = api_key_override { req_builder = req_builder.header("X-API-Key", key); } req_builder } async fn wait_until_ready(client: &reqwest::Client, base: &str) { for _ in 0..60 { let resp = client.get(format!("{base}/ready")).send().await; if let Ok(resp) = resp { if resp.status().is_success() { let body: Value = resp.json().await.expect("/ready JSON response"); if body.get("status").and_then(Value::as_str) == Some("ready") { return; } } } tokio::time::sleep(Duration::from_millis(500)).await; } panic!("Server did not become ready at {base}/ready within timeout"); } async fn call_jsonrpc( client: &reqwest::Client, base: &str, request: Value, ) -> Value { let api_key = api_key(); let req_builder = apply_request_headers( client.post(format!("{base}/mcp/message")).json(&request), api_key.as_deref(), ); req_builder .send() .await .expect("JSON-RPC HTTP request") .json() .await .expect("JSON-RPC response body") } fn parse_tool_response(tool_name: &str, response: Value) -> Value { if let Some(error) = response.get("error") { panic!("tools/call for '{tool_name}' failed: {error}"); } let text_payload = response .get("result") .and_then(|r| r.get("content")) .and_then(Value::as_array) .and_then(|arr| arr.first()) .and_then(|item| item.get("text")) .and_then(Value::as_str) .expect("result.content[0].text payload"); serde_json::from_str(text_payload).expect("tool text payload to be valid JSON") } async fn call_tool( client: &reqwest::Client, base: &str, tool_name: &str, arguments: Value, ) -> Value { let request = json!({ "jsonrpc": "2.0", "id": Uuid::new_v4().to_string(), "method": "tools/call", "params": { "name": tool_name, "arguments": arguments } }); let response = call_jsonrpc(client, base, request).await; parse_tool_response(tool_name, response) } async fn list_tools( client: &reqwest::Client, base: &str, ) -> Vec { let request = json!({ "jsonrpc": "2.0", "id": Uuid::new_v4().to_string(), "method": "tools/list" }); let response = call_jsonrpc(client, base, request).await; response .get("result") .and_then(|r| r.get("tools")) .and_then(Value::as_array) .map(|tools| { tools .iter() .filter_map(|t| t.get("name").and_then(Value::as_str).map(String::from)) .collect() }) .unwrap_or_default() } /// Store a test memory and return its ID async fn store_memory( client: &reqwest::Client, base: &str, content: &str, ) -> String { let result = call_tool(client, base, "store", json!({ "content": content })).await; result .get("id") .and_then(Value::as_str) .unwrap_or("unknown") .to_string() } // ── Tests ────────────────────────────────────────────────────────────── #[tokio::test] async fn e2e_tools_list_includes_truth_tools() { let client = reqwest::Client::new(); let base = base_url(); wait_until_ready(&client, &base).await; let tools = list_tools(&client, &base).await; assert!( tools.contains(&"evaluate".to_string()), "tools/list should contain 'evaluate', got: {:?}", tools ); assert!( tools.contains(&"truth_status".to_string()), "tools/list should contain 'truth_status', got: {:?}", tools ); } #[tokio::test] async fn e2e_truth_status_returns_valid_structure() { let client = reqwest::Client::new(); let base = base_url(); wait_until_ready(&client, &base).await; let result = call_tool(&client, &base, "truth_status", json!({})).await; // Should have the basic structure regardless of enabled state assert!( result.get("enabled").is_some(), "truth_status should contain 'enabled' field, got: {result}" ); } #[tokio::test] async fn e2e_truth_status_counts_are_consistent() { let client = reqwest::Client::new(); let base = base_url(); wait_until_ready(&client, &base).await; let result = call_tool(&client, &base, "truth_status", json!({})).await; if result.get("enabled").and_then(Value::as_bool) == Some(true) { let total = result.get("total_memories").and_then(Value::as_u64).unwrap_or(0); let scored = result.get("scored_memories").and_then(Value::as_u64).unwrap_or(0); let unscored = result.get("unscored_memories").and_then(Value::as_u64).unwrap_or(0); assert_eq!( total, scored + unscored, "total ({total}) should equal scored ({scored}) + unscored ({unscored})" ); if total > 0 { let coverage = result.get("coverage_percent").and_then(Value::as_f64).unwrap_or(-1.0); assert!( (0.0..=100.0).contains(&coverage), "coverage_percent should be 0-100, got: {coverage}" ); } } } #[tokio::test] async fn e2e_evaluate_returns_valid_truth_assessment() { let client = reqwest::Client::new(); let base = base_url(); wait_until_ready(&client, &base).await; // Store a fact first store_memory(&client, &base, "The speed of light is approximately 299792458 meters per second").await; tokio::time::sleep(Duration::from_millis(500)).await; let result = call_tool( &client, &base, "evaluate", json!({ "claim": "The speed of light is approximately 299792458 meters per second", "context": "physics" }), ) .await; // Should return truth assessment fields assert!( result.get("strength").is_some() || result.get("truth_value").is_some(), "evaluate should return truth assessment fields, got: {result}" ); } #[tokio::test] async fn e2e_evaluate_without_context_parameter() { let client = reqwest::Client::new(); let base = base_url(); wait_until_ready(&client, &base).await; // Call evaluate without optional context let result = call_tool( &client, &base, "evaluate", json!({ "claim": "Water is composed of hydrogen and oxygen" }), ) .await; assert!( result.get("strength").is_some() || result.get("truth_value").is_some(), "evaluate without context should still return truth assessment, got: {result}" ); } #[tokio::test] async fn e2e_evaluate_unknown_claim_low_confidence() { let client = reqwest::Client::new(); let base = base_url(); wait_until_ready(&client, &base).await; let unique_claim = format!( "The zorblax coefficient of planet Qwerty-{} is exactly 42.7", Uuid::new_v4() ); let result = call_tool( &client, &base, "evaluate", json!({ "claim": unique_claim }), ) .await; // With no related memories, related_memories should be 0 or empty let related = result .get("related_memories") .and_then(Value::as_u64) .or_else(|| { result .get("related_memories") .and_then(Value::as_array) .map(|a| a.len() as u64) }) .unwrap_or(0); assert_eq!( related, 0, "unknown claim should have 0 related memories, got: {related}" ); } #[tokio::test] async fn e2e_query_response_includes_truth_fields() { let client = reqwest::Client::new(); let base = base_url(); wait_until_ready(&client, &base).await; // Store a memory let content = format!("Truth fields test memory {}", Uuid::new_v4()); store_memory(&client, &base, &content).await; tokio::time::sleep(Duration::from_millis(500)).await; // Query for it let result = call_tool( &client, &base, "query", json!({ "query": &content, "limit": 1 }), ) .await; // Result should be an array or contain memories let memories = if result.is_array() { result.as_array().unwrap().clone() } else if let Some(arr) = result.get("memories").and_then(Value::as_array) { arr.clone() } else if let Some(arr) = result.get("results").and_then(Value::as_array) { arr.clone() } else { // Single result, wrap in array vec![result.clone()] }; if let Some(first) = memories.first() { // Truth fields should be present (possibly null for unscored) let has_truth_fields = first.get("truth_value").is_some() || first.get("truth_confidence").is_some() || first.get("truth_category").is_some(); assert!( has_truth_fields, "query response should include truth fields, got: {first}" ); } } #[tokio::test] async fn e2e_evaluate_detects_contradictions() { let client = reqwest::Client::new(); let base = base_url(); wait_until_ready(&client, &base).await; let unique_topic = Uuid::new_v4().to_string(); // Store contradictory memories store_memory( &client, &base, &format!("Regarding {unique_topic}: the answer is definitely yes"), ) .await; store_memory( &client, &base, &format!("Regarding {unique_topic}: the answer is definitely no"), ) .await; tokio::time::sleep(Duration::from_millis(500)).await; let result = call_tool( &client, &base, "evaluate", json!({ "claim": format!("Regarding {unique_topic}: the answer is yes") }), ) .await; // Should return some result (the key test is that it doesn't error) assert!( result.get("strength").is_some() || result.get("truth_value").is_some() || result.get("confidence").is_some(), "evaluate with contradictions should still return a result, got: {result}" ); }