use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{Value, json};
use crate::{
config::ReviewConfig,
integrations::search_client::{
EmbedderState, HealthResponse as SearchHealth, IndexInfo, SearchClient, SearchClientError,
SearchResult,
},
llm::{LlmError, LlmProvider, LlmRequest, LlmResponse},
service::AppState,
};
use super::{
ToolError, call_review_health, require_str, tool_descriptors, wrap_result, wrap_tool_error,
};
use crate::models::ReviewResult;
struct OkLlmTool;
#[async_trait]
impl LlmProvider for OkLlmTool {
fn name(&self) -> &str {
"ok-tool-stub"
}
async fn complete(&self, req: LlmRequest) -> Result<LlmResponse, LlmError> {
Ok(LlmResponse {
text: "ok".into(),
model: req.model.clone(),
input_tokens: 1,
output_tokens: 1,
latency_ms: 0,
cost_usd: 0.0,
finish_reason: None,
})
}
}
struct AuthErrorLlmTool;
#[async_trait]
impl LlmProvider for AuthErrorLlmTool {
fn name(&self) -> &str {
"auth-error-tool-stub"
}
async fn complete(&self, _req: LlmRequest) -> Result<LlmResponse, LlmError> {
Err(LlmError::AccessDenied("bad key".into()))
}
}
struct FakeSearchTool;
#[async_trait]
impl SearchClient for FakeSearchTool {
async fn health(&self) -> Result<SearchHealth, SearchClientError> {
Ok(SearchHealth {
status: "ok".into(),
embedder: EmbedderState::Bool(true),
})
}
async fn list_indexes(&self) -> Result<Vec<IndexInfo>, SearchClientError> {
Ok(vec![])
}
async fn search(
&self,
_: &str,
_: &str,
_: Option<u32>,
) -> Result<Vec<SearchResult>, SearchClientError> {
Ok(vec![])
}
}
struct FailSearchTool;
#[async_trait]
impl SearchClient for FailSearchTool {
async fn health(&self) -> Result<SearchHealth, SearchClientError> {
Err(SearchClientError::Unavailable("down".to_string()))
}
async fn list_indexes(&self) -> Result<Vec<IndexInfo>, SearchClientError> {
Err(SearchClientError::Unavailable("down".to_string()))
}
async fn search(
&self,
_: &str,
_: &str,
_: Option<u32>,
) -> Result<Vec<SearchResult>, SearchClientError> {
Err(SearchClientError::Unavailable("down".to_string()))
}
}
fn make_tool_state(llm: Arc<dyn LlmProvider>) -> AppState {
AppState::new(
ReviewConfig::load(None),
llm,
Arc::new(FakeSearchTool),
None,
)
}
fn make_tool_state_fail_search(llm: Arc<dyn LlmProvider>) -> AppState {
AppState::new(
ReviewConfig::load(None),
llm,
Arc::new(FailSearchTool),
None,
)
}
#[test]
fn tools_list_has_four_tools() {
let tools = tool_descriptors();
let arr = tools.as_array().expect("must be array");
assert_eq!(arr.len(), 4, "expected 4 tools, got {}", arr.len());
let names: Vec<&str> = arr
.iter()
.filter_map(|t| t.get("name").and_then(Value::as_str))
.collect();
assert!(names.contains(&"review_pr"), "missing review_pr");
assert!(names.contains(&"review_diff"), "missing review_diff");
assert!(names.contains(&"review_health"), "missing review_health");
assert!(
names.contains(&"console_metrics"),
"missing console_metrics"
);
}
#[test]
fn each_tool_has_input_schema() {
let tools = tool_descriptors();
for tool in tools.as_array().unwrap() {
let name = tool.get("name").and_then(Value::as_str).unwrap_or("?");
assert!(
tool.get("inputSchema").is_some(),
"tool '{name}' is missing inputSchema"
);
}
}
#[test]
fn require_str_returns_error_on_missing() {
let args = json!({});
let result = require_str(&args, "owner");
assert!(
matches!(result, Err(ToolError::InvalidParams(_))),
"expected InvalidParams"
);
}
#[test]
fn require_str_extracts_value() {
let args = json!({ "owner": "alice" });
assert_eq!(require_str(&args, "owner").unwrap(), "alice");
}
#[test]
fn wrap_tool_error_sets_is_error_true() {
let v = wrap_tool_error("boom");
assert_eq!(v["isError"], json!(true));
let text = v["content"][0]["text"].as_str().unwrap();
assert!(text.contains("boom"));
}
#[tokio::test]
async fn review_health_inference_ok() {
let state = make_tool_state(Arc::new(OkLlmTool));
let result = call_review_health(&state).await;
let text = result["content"][0]["text"].as_str().expect("text field");
let health: Value = serde_json::from_str(text).expect("valid JSON");
assert_eq!(health["inference"], "ok");
assert_eq!(health["status"], "ok");
assert!(
health["reviewer_model"].is_string(),
"reviewer_model must be present"
);
assert!(health["dry_run"].is_boolean(), "dry_run must be present");
}
#[tokio::test]
async fn review_health_inference_auth_error_degraded() {
let state = make_tool_state(Arc::new(AuthErrorLlmTool));
let result = call_review_health(&state).await;
let text = result["content"][0]["text"].as_str().expect("text field");
let health: Value = serde_json::from_str(text).expect("valid JSON");
assert_eq!(health["inference"], "auth_error");
assert_eq!(health["status"], "degraded");
}
#[tokio::test]
async fn review_health_required_dep_down_degraded() {
let state = make_tool_state_fail_search(Arc::new(OkLlmTool));
let result = call_review_health(&state).await;
let text = result["content"][0]["text"].as_str().expect("text field");
let health: Value = serde_json::from_str(text).expect("valid JSON");
assert_eq!(
health["status"], "degraded",
"required dep (trusty_search) down → status must be degraded"
);
assert_eq!(
health["inference"], "ok",
"inference must be ok (OkLlmTool always succeeds)"
);
assert_eq!(
health["deps"]["trusty_search"]["reachable"], false,
"trusty_search.reachable must be false when search is down"
);
}
#[tokio::test]
async fn review_health_optional_dep_down_ok() {
let state = make_tool_state(Arc::new(OkLlmTool));
let result = call_review_health(&state).await;
let text = result["content"][0]["text"].as_str().expect("text field");
let health: Value = serde_json::from_str(text).expect("valid JSON");
assert_eq!(
health["status"], "ok",
"optional dep absent → status must remain ok"
);
assert_eq!(
health["deps"]["trusty_search"]["reachable"], true,
"trusty_search.reachable must be true (FakeSearchTool succeeds)"
);
assert_eq!(
health["deps"]["trusty_analyze"]["reachable"], false,
"trusty_analyze.reachable must be false (no analyze configured)"
);
}
#[test]
fn wrap_result_surfaces_reviewer_model_fallback() {
let result = ReviewResult::new("acme", "backend", 7, "Add X", "https://example/pr/7");
let reason = "failed to build provider for reviewer_model override 'openrouter/x' \
(key empty); fell back to the startup 'bedrock' provider";
let envelope = wrap_result(&result, Some(reason));
assert_eq!(
envelope["reviewer_model_fallback"], reason,
"envelope must carry reviewer_model_fallback for detection"
);
assert_eq!(
envelope["isError"], false,
"fallback is non-breaking, not an error"
);
let text = envelope["content"][0]["text"].as_str().expect("text field");
let payload: Value = serde_json::from_str(text).expect("valid JSON payload");
assert_eq!(
payload["reviewer_model_fallback"], reason,
"payload JSON must also carry the fallback so the LLM sees it"
);
}
#[test]
fn wrap_result_no_fallback_omits_field() {
let result = ReviewResult::new("acme", "backend", 8, "Add Y", "https://example/pr/8");
let envelope = wrap_result(&result, None);
assert!(
envelope.get("reviewer_model_fallback").is_none(),
"no fallback → envelope must NOT carry the marker"
);
let text = envelope["content"][0]["text"].as_str().expect("text field");
let payload: Value = serde_json::from_str(text).expect("valid JSON payload");
assert!(
payload.get("reviewer_model_fallback").is_none(),
"no fallback → payload must NOT carry the marker"
);
}