use pretty_assertions::assert_eq;
use rho_providers::{
credentials::{save_provider_api_key, MemoryCredentialStore},
model::provider_models::ProviderModelHealth,
provider::{self, ProviderAuthKind},
};
use super::*;
use crate::{
claude_runtime::auth::ClaudeProbeSnapshot,
plugins::{PluginOrigin, PluginReportEntry, PluginScope, PluginStatus},
tools::mcp::{
report::{ConnectedServerReport, McpLiveServerState},
McpServerReport, McpTransportSummary,
},
};
fn auth_check<'a>(checks: &'a [DoctorCheck], auth_mode: &str) -> &'a DoctorCheck {
checks
.iter()
.find(|check| {
check.id
== DoctorCheckId::ProviderAuth {
auth_mode: auth_mode.into(),
}
})
.unwrap_or_else(|| panic!("no authentication row for {auth_mode}"))
}
#[test]
fn authentication_rows_reflect_the_injected_store() {
let store = MemoryCredentialStore::default();
save_provider_api_key(&store, "openai", "sk-test").unwrap();
let checks = authentication_checks(&store, "anthropic-api-key", &|_| false);
assert_eq!(
auth_check(&checks, "api-key"),
&DoctorCheck::new(
DoctorCheckId::ProviderAuth {
auth_mode: "api-key".into()
},
"OpenAI API key",
DoctorStatus::Ok,
"authenticated",
)
);
assert_eq!(
auth_check(&checks, "anthropic-api-key"),
&DoctorCheck::new(
DoctorCheckId::ProviderAuth {
auth_mode: "anthropic-api-key".into()
},
"Anthropic API key",
DoctorStatus::Warn,
"missing",
)
.with_hint("run /login anthropic-api-key")
);
let inactive = authentication_checks(&store, "api-key", &|_| false);
assert_eq!(
auth_check(&inactive, "anthropic-api-key"),
&DoctorCheck::new(
DoctorCheckId::ProviderAuth {
auth_mode: "anthropic-api-key".into()
},
"Anthropic API key",
DoctorStatus::Info,
"missing",
)
);
let optional_host = provider::providers()
.into_iter()
.find(|descriptor| descriptor.has_none_auth() && !descriptor.is_keyless())
.expect("a provider that runs with or without a key");
let optional_mode = optional_host
.auth_modes()
.find(|mode| mode.auth_kind != ProviderAuthKind::None)
.expect("keyed auth mode");
assert_eq!(
auth_check(&checks, optional_mode.id).status,
DoctorStatus::Info
);
let from_env = authentication_checks(&store, "api-key", &|mode| mode == "api-key");
assert_eq!(
auth_check(&from_env, "api-key").summary,
"authenticated via environment"
);
}
#[test]
fn herdr_probe_maps_to_status() {
let cases = [
(
HerdrProbe::NotConfigured,
DoctorStatus::Info,
"not configured",
),
(HerdrProbe::Reachable, DoctorStatus::Ok, "connected"),
(HerdrProbe::Unreachable, DoctorStatus::Fail, "unreachable"),
(HerdrProbe::Unknown, DoctorStatus::Warn, "unknown"),
];
for (probe, status, summary) in cases {
let check = herdr_check(probe);
assert_eq!(
(check.status, check.summary.as_str()),
(status, summary),
"{probe:?}"
);
}
}
#[test]
fn endpoint_health_maps_to_status() {
let cases = [
(
ProviderModelHealth::ReachableWithModels { model_count: 3 },
"ollama",
DoctorStatus::Ok,
"reachable, 3 models",
None,
),
(
ProviderModelHealth::ReachableWithoutModels,
"ollama",
DoctorStatus::Warn,
"no models",
Some("the endpoint is reachable but has no installed models"),
),
(
ProviderModelHealth::Unreachable {
error: "connection refused".into(),
},
"ollama",
DoctorStatus::Fail,
"unreachable",
Some("connection refused"),
),
(
ProviderModelHealth::InvalidResponse {
error: "HTTP 500".into(),
},
"ollama",
DoctorStatus::Fail,
"invalid response",
Some("HTTP 500"),
),
(
ProviderModelHealth::Unreachable {
error: "connection refused".into(),
},
"openai",
DoctorStatus::Info,
"unreachable",
Some("connection refused"),
),
(
ProviderModelHealth::InvalidResponse {
error: "HTTP 500".into(),
},
"openai",
DoctorStatus::Info,
"invalid response",
Some("HTTP 500"),
),
(
ProviderModelHealth::ReachableWithoutModels,
"openai",
DoctorStatus::Info,
"no models",
Some("the endpoint is reachable but has no installed models"),
),
];
for (health, active_provider, status, summary, hint) in cases {
let check = endpoint_check("ollama", &health, active_provider);
assert_eq!(check.label, "Ollama connection");
assert_eq!(
(check.status, check.summary.as_str(), check.hint.as_deref()),
(status, summary, hint),
"{health:?} active={active_provider}"
);
}
}
#[test]
fn claude_rows_cover_signed_in_signed_out_and_error() {
let signed_in = ClaudeProbeSnapshot {
auth: Ok(serde_json::from_value(
serde_json::json!({ "loggedIn": true, "email": "dev@example.com", "subscriptionType": "max" }),
)
.unwrap()),
version: Ok("2.1.0 (Claude Code)".into()),
};
let rows = claude_checks(&signed_in);
assert_eq!(
rows,
vec![
DoctorCheck::new(
DoctorCheckId::ClaudeAuth,
CLAUDE_AUTH_LABEL,
DoctorStatus::Ok,
"signed in as dev@example.com (max)",
),
DoctorCheck::new(
DoctorCheckId::ClaudeBinary,
CLAUDE_BINARY_LABEL,
DoctorStatus::Ok,
"2.1.0 (Claude Code)",
),
]
);
let signed_out = ClaudeProbeSnapshot {
auth: Ok(serde_json::from_value(serde_json::json!({ "loggedIn": false })).unwrap()),
version: Err("claude code: binary not found on PATH".into()),
};
let rows = claude_checks(&signed_out);
assert_eq!(
(
rows[0].status,
rows[0].summary.as_str(),
rows[0].hint.as_deref()
),
(
DoctorStatus::Warn,
"not signed in",
Some("run /login claude-code")
)
);
assert_eq!(
(
rows[1].status,
rows[1].summary.as_str(),
rows[1].hint.as_deref()
),
(
DoctorStatus::Warn,
"unavailable",
Some("claude code: binary not found on PATH")
)
);
}
#[test]
fn mcp_row_follows_session_summary() {
let unconfigured = mcp_check(&McpSessionReport::default());
assert_eq!(
(unconfigured.status, unconfigured.summary.as_str()),
(DoctorStatus::Info, "not configured")
);
let connected = McpSessionReport {
mode: McpLoadMode::Native,
servers: vec![McpServerReport::connected(ConnectedServerReport {
identity: "filesystem".into(),
transport: McpTransportSummary::StreamableHttp {
url: "https://example.com/mcp".into(),
},
tools: Vec::new(),
instructions: None,
live: McpLiveServerState::default(),
filtered_out_count: 0,
collision_skipped_count: 0,
})],
};
let check = mcp_check(&connected);
assert_eq!(
(check.status, check.summary.as_str(), check.hint.as_deref()),
(
DoctorStatus::Ok,
"connected",
Some("1 connected server, 0 exported tools")
)
);
let degraded = McpSessionReport {
mode: McpLoadMode::Native,
servers: vec![McpServerReport::failed(
"filesystem",
McpTransportSummary::StreamableHttp {
url: "https://example.com/mcp".into(),
},
"connection refused",
)],
};
let check = mcp_check(°raded);
assert_eq!(
(check.status, check.summary.as_str(), check.hint.as_deref()),
(
DoctorStatus::Warn,
"degraded",
Some("1 server problem, 0 connected, 0 tools; run /mcp for details")
)
);
}
fn plugin(name: &str, status: PluginStatus) -> PluginReportEntry {
PluginReportEntry {
name: name.into(),
version: None,
description: None,
root: format!("/plugins/{name}"),
scope: PluginScope::User,
origin: PluginOrigin::Install,
enabled: status != PluginStatus::Disabled,
status,
problems: Vec::new(),
skill_count: 1,
mcp_server_count: 0,
skill_names: vec!["hello".into()],
mcp_server_names: Vec::new(),
}
}
#[test]
fn plugins_row_flags_rejected_packages() {
let none = plugins_check(&PluginLoadReport::default());
assert_eq!(
(none.status, none.summary.as_str()),
(DoctorStatus::Info, "none discovered")
);
let clean = plugins_check(&PluginLoadReport {
plugins: vec![plugin("hello", PluginStatus::Loaded)],
});
assert_eq!(
(clean.status, clean.summary.as_str()),
(DoctorStatus::Ok, "1 loaded")
);
let rejected = plugins_check(&PluginLoadReport {
plugins: vec![
plugin("hello", PluginStatus::Loaded),
plugin("broken", PluginStatus::Rejected),
],
});
assert_eq!(
(rejected.status, rejected.summary.as_str()),
(DoctorStatus::Warn, "1 loaded, 1 rejected")
);
}
#[test]
fn path_check_reports_writability() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config.toml");
let sessions = dir.path().join("sessions");
let writable = path_check(
DoctorCheckId::ConfigPath,
"Configuration",
&config,
PathKind::File,
);
assert_eq!(
(
writable.status,
writable.summary.as_str(),
writable.hint.as_deref()
),
(
DoctorStatus::Ok,
"writable",
Some(config.display().to_string().as_str())
)
);
std::fs::write(&sessions, b"not a dir").unwrap();
let not_dir = path_check(
DoctorCheckId::SessionRoot,
"Sessions",
&sessions,
PathKind::Directory,
);
assert_eq!(
(not_dir.status, not_dir.summary.as_str()),
(DoctorStatus::Fail, "not writable")
);
}