use super::*;
use crate::{
config::ReviewConfig,
integrations::{
analyze_client::{
AnalyzeClient, AnalyzeClientError, AnalyzeHealthResponse, ComplexityHotspot, Smell,
},
search_client::{
EmbedderState, HealthResponse, IndexInfo, SearchClient, SearchClientError, SearchResult,
},
},
llm::{LlmError, LlmProvider, LlmRequest, LlmResponse},
pipeline::runner::ReviewDeps,
};
use async_trait::async_trait;
use std::sync::Arc;
struct StubLlm;
#[async_trait]
impl LlmProvider for StubLlm {
fn name(&self) -> &str {
"stub"
}
async fn complete(&self, _req: LlmRequest) -> Result<LlmResponse, LlmError> {
Err(LlmError::Transport("unused".to_string()))
}
}
struct StubSearch {
health: Option<bool>,
}
#[async_trait]
impl SearchClient for StubSearch {
async fn health(&self) -> Result<HealthResponse, SearchClientError> {
match self.health {
Some(ok) => Ok(HealthResponse {
status: if ok { "ok" } else { "starting" }.to_string(),
embedder: EmbedderState::Bool(ok),
}),
None => Err(SearchClientError::Unavailable("down".to_string())),
}
}
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 StubAnalyze {
ready: bool,
}
#[async_trait]
impl AnalyzeClient for StubAnalyze {
async fn health(&self) -> Result<AnalyzeHealthResponse, AnalyzeClientError> {
Ok(AnalyzeHealthResponse {
status: "ok".to_string(),
search_reachable: true,
})
}
async fn has_analysis(&self, _: &str) -> bool {
self.ready
}
async fn complexity_hotspots(
&self,
_: &str,
_: Option<u32>,
) -> Result<Vec<ComplexityHotspot>, AnalyzeClientError> {
Ok(vec![])
}
async fn smells(&self, _: &str) -> Result<Vec<Smell>, AnalyzeClientError> {
Ok(vec![])
}
}
fn deps(search_health: Option<bool>, analyze_ready: Option<bool>) -> ReviewDeps {
ReviewDeps {
llm: Arc::new(StubLlm),
verifier: None,
search: Arc::new(StubSearch {
health: search_health,
}),
analyze: analyze_ready
.map(|r| Arc::new(StubAnalyze { ready: r }) as Arc<dyn AnalyzeClient>),
dedup: None,
}
}
fn config() -> ReviewConfig {
ReviewConfig::load(None)
}
#[tokio::test]
async fn proceeds_when_both_healthy() {
let cfg = config(); let d = deps(Some(true), Some(true));
assert_eq!(preflight_context(&cfg, &d).await, GateOutcome::Proceed);
}
#[tokio::test]
async fn skips_when_search_down_and_required() {
let cfg = config();
let d = deps(None, Some(true)); match preflight_context(&cfg, &d).await {
GateOutcome::Skip(msg) => {
assert!(msg.contains("trusty-search"), "msg: {msg}");
assert!(msg.contains("start"), "msg must be actionable: {msg}");
}
other => panic!("expected Skip, got {other:?}"),
}
}
#[tokio::test]
async fn skips_when_search_unhealthy_and_required() {
let cfg = config();
let d = deps(Some(false), Some(true)); assert!(matches!(
preflight_context(&cfg, &d).await,
GateOutcome::Skip(_)
));
}
#[tokio::test]
async fn degraded_when_search_down_and_opted_out() {
let mut cfg = config();
cfg.context.require_search = false;
let d = deps(None, Some(true));
match preflight_context(&cfg, &d).await {
GateOutcome::Degraded(msg) => assert!(msg.contains("trusty-search"), "msg: {msg}"),
other => panic!("expected Degraded, got {other:?}"),
}
}
#[tokio::test]
async fn skips_when_analyze_down_and_required() {
let cfg = config();
let d = deps(Some(true), Some(false)); match preflight_context(&cfg, &d).await {
GateOutcome::Skip(msg) => {
assert!(msg.contains("trusty-analyze"), "msg: {msg}");
assert!(msg.contains("start"), "msg must be actionable: {msg}");
}
other => panic!("expected Skip, got {other:?}"),
}
}
#[tokio::test]
async fn skips_when_analyze_absent_and_required() {
let cfg = config();
let d = deps(Some(true), None); assert!(matches!(
preflight_context(&cfg, &d).await,
GateOutcome::Skip(_)
));
}
#[tokio::test]
async fn degraded_when_analyze_down_and_opted_out() {
let mut cfg = config();
cfg.context.require_analyze = false;
let d = deps(Some(true), Some(false));
match preflight_context(&cfg, &d).await {
GateOutcome::Degraded(msg) => assert!(msg.contains("trusty-analyze"), "msg: {msg}"),
other => panic!("expected Degraded, got {other:?}"),
}
}
#[tokio::test]
async fn search_down_skip_takes_priority_over_analyze() {
let cfg = config();
let d = deps(None, Some(false));
match preflight_context(&cfg, &d).await {
GateOutcome::Skip(msg) => assert!(msg.contains("trusty-search"), "msg: {msg}"),
other => panic!("expected search Skip, got {other:?}"),
}
}
#[tokio::test]
async fn both_opted_out_and_down_proceeds_degraded() {
let mut cfg = config();
cfg.context.require_search = false;
cfg.context.require_analyze = false;
let d = deps(None, Some(false));
match preflight_context(&cfg, &d).await {
GateOutcome::Degraded(msg) => assert!(msg.contains("trusty-search"), "msg: {msg}"),
other => panic!("expected Degraded, got {other:?}"),
}
}
#[test]
fn degraded_banner_contains_warning() {
let banner = degraded_banner("trusty-search unavailable at http://x");
assert!(banner.contains("DEGRADED"));
assert!(banner.contains("NOT AUTHORITATIVE"));
assert!(banner.contains("trusty-search unavailable at http://x"));
}