use wiremock::MockServer;
use super::support::hunks_for_python_at;
use crate::analysis::code_quality::CodeQualityAnalyzer;
use crate::analysis::result::FailureReason;
use crate::llm::chain::ProviderChain;
use crate::test_support::{
cfg_for, fast_retry_chain, request_count, server_failing_with, server_returning, temp_cache,
};
async fn server_with_finding(line: u32) -> MockServer {
let body = format!(
r#"{{"issues": [{{"line": {line}, "severity": "medium", "category": "style", "message": "m"}}], "summary": "s"}}"#
);
server_returning(&[&body]).await
}
fn served(analyzer: &CodeQualityAnalyzer, index: usize) -> usize {
analyzer.chain().providers()[index].served()
}
#[tokio::test]
async fn a_file_served_by_the_head_is_credited_to_provider_zero() {
let head = server_with_finding(100).await;
let (cache, _dir) = temp_cache();
let analyzer = CodeQualityAnalyzer::new(fast_retry_chain(&[cfg_for(&head, "a", 1)]), cache);
let result = analyzer.analyze_file(&hunks_for_python_at(100)).await;
assert_eq!(result.findings.len(), 1);
assert_eq!(served(&analyzer, 0), 1);
}
#[tokio::test]
async fn a_file_served_by_the_fallback_is_credited_to_it() {
let dead = server_failing_with(500).await;
let fallback = server_with_finding(100).await;
let (cache, _dir) = temp_cache();
let analyzer = CodeQualityAnalyzer::new(
fast_retry_chain(&[cfg_for(&dead, "a", 1), cfg_for(&fallback, "b", 1)]),
cache,
);
let result = analyzer.analyze_file(&hunks_for_python_at(100)).await;
assert_eq!(result.findings.len(), 1, "the fallback's findings are kept");
assert!(result.failed_files.is_empty(), "the file WAS analyzed");
assert_eq!(served(&analyzer, 1), 1, "the fallback is credited");
assert_eq!(served(&analyzer, 0), 0, "the head served nothing");
}
#[tokio::test]
async fn the_cache_entry_is_written_under_the_serving_provider_s_key() {
let dead = server_failing_with(500).await;
let fallback = server_with_finding(100).await;
let (cache, _dir) = temp_cache();
let analyzer = CodeQualityAnalyzer::new(
fast_retry_chain(&[
cfg_for(&dead, "model-a", 1),
cfg_for(&fallback, "model-b", 1),
]),
cache.clone(),
);
let hunks = hunks_for_python_at(100);
let result = analyzer.analyze_file(&hunks).await;
assert!(result.failed_files.is_empty());
let language = crate::languages::detect(&hunks[0].file_path).expect("python");
let payload = crate::analysis::payload::render(language, &hunks).expect("payload");
let system = crate::analysis::prompt::build_analysis_prompt(language);
let key_a = analyzer.chain().providers()[0].cache_key(&cache, &system, &payload.text);
let key_b = analyzer.chain().providers()[1].cache_key(&cache, &system, &payload.text);
assert!(
cache.get(&key_b).is_some(),
"the fallback's answer must be cached under the fallback's key"
);
assert!(
cache.get(&key_a).is_none(),
"nothing may be cached under the head's key - it never answered"
);
let revived = server_with_finding(100).await;
let spare = server_with_finding(100).await;
let second = CodeQualityAnalyzer::new(
fast_retry_chain(&[
cfg_for(&revived, "model-a", 1),
cfg_for(&spare, "model-b", 1),
]),
cache,
);
let _ = second.analyze_file(&hunks).await;
assert_eq!(served(&second, 0), 1, "the restored head serves");
assert_eq!(
request_count(&revived).await,
1,
"the restored head must actually be contacted"
);
}
#[tokio::test]
async fn a_chain_where_every_provider_fails_reports_all_of_them() {
let first = server_failing_with(500).await;
let second = server_failing_with(503).await;
let (cache, _dir) = temp_cache();
let analyzer = CodeQualityAnalyzer::new(
fast_retry_chain(&[cfg_for(&first, "a", 1), cfg_for(&second, "b", 1)]),
cache,
);
let result = analyzer.analyze_file(&hunks_for_python_at(100)).await;
let reason = result
.failed_files
.values()
.next()
.expect("the file is unanalyzed");
match reason {
FailureReason::ChainFailed(failures) => {
assert_eq!(failures.len(), 2);
assert_eq!(failures[0].model, "a");
assert_eq!(failures[0].reason.status(), Some(500));
assert_eq!(failures[1].model, "b");
assert_eq!(failures[1].reason.status(), Some(503));
}
other => panic!("expected ChainFailed, got {other:?}"),
}
}
#[tokio::test]
async fn a_single_provider_failure_is_not_wrapped_in_all_providers() {
let only = server_failing_with(500).await;
let (cache, _dir) = temp_cache();
let analyzer = CodeQualityAnalyzer::new(fast_retry_chain(&[cfg_for(&only, "a", 1)]), cache);
let result = analyzer.analyze_file(&hunks_for_python_at(100)).await;
let reason = result
.failed_files
.values()
.next()
.expect("the file is unanalyzed");
assert!(
matches!(
reason,
FailureReason::Transport {
status: Some(500),
..
}
),
"a lone provider's failure must stay a plain transport failure, got {reason:?}"
);
}
#[tokio::test]
async fn a_401_at_the_head_does_not_reach_the_fallback() {
let unauthorized = server_failing_with(401).await;
let fallback = server_with_finding(100).await;
let (cache, _dir) = temp_cache();
let analyzer = CodeQualityAnalyzer::new(
fast_retry_chain(&[cfg_for(&unauthorized, "a", 1), cfg_for(&fallback, "b", 1)]),
cache,
);
let result = analyzer.analyze_file(&hunks_for_python_at(100)).await;
assert!(!result.failed_files.is_empty(), "the file is unanalyzed");
assert_eq!(request_count(&fallback).await, 0);
assert_eq!(served(&analyzer, 0), 0, "nobody served the file");
assert_eq!(served(&analyzer, 1), 0);
}
#[tokio::test]
async fn provider_counts_sum_across_files() {
let dead = server_failing_with(500).await;
let fallback = server_with_finding(100).await;
let (cache, _dir) = temp_cache();
let mut head = cfg_for(&dead, "a", 1);
head.max_concurrent = 1;
let analyzer =
CodeQualityAnalyzer::new(fast_retry_chain(&[head, cfg_for(&fallback, "b", 1)]), cache);
let by_file = vec![
vec![super::support::python_hunk("src/one.py", 100)],
vec![super::support::python_hunk("src/two.py", 100)],
];
let _ = analyzer.analyze_files(&by_file).await;
assert_eq!(served(&analyzer, 1), 2, "two files served by the fallback");
assert_eq!(
request_count(&dead).await,
1,
"sticky demotion: the dead head is contacted once, not once per file"
);
}
#[test]
fn a_chain_is_built_from_the_enabled_providers_only() {
let disabled = crate::config::LlmConfig {
enabled: false,
endpoint: Some("http://parked/v1".to_owned()),
model: Some("parked".to_owned()),
..crate::config::LlmConfig::default()
};
let live = crate::config::LlmConfig {
endpoint: Some("http://live/v1".to_owned()),
model: Some("live".to_owned()),
..crate::config::LlmConfig::default()
};
let config = crate::config::Config {
max_review_rounds: crate::config::DEFAULT_MAX_REVIEW_ROUNDS,
llm: vec![disabled, live],
};
let chain = ProviderChain::new(&config.providers()).expect("chain builds");
assert_eq!(chain.providers().len(), 1);
assert_eq!(chain.providers()[0].model(), "live");
}