use std::path::PathBuf;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::analysis::findings::Severity;
use crate::diff::hunks::Hunk;
use super::support::analyzer_with_fast_retry;
use super::support::{analyzer_for, hunks_for_python_at, hunks_for_python_at_two_lines};
use crate::test_support::{cfg_for, mount_sse, request_count, server_returning, sse, temp_cache};
#[tokio::test]
async fn two_issues_yield_two_findings_with_full_fields() {
let server = MockServer::start().await;
let body = "{\"issues\": [\
{\"line\": 100, \"severity\": \"high\", \"category\": \"bug\", \
\"message\": \"first bug\", \"suggestion\": \"fix it\"}, \
{\"line\": 101, \"severity\": \"medium\", \"category\": \"performance\", \
\"message\": \"second issue\", \"suggestion\": \"optimize\"}\
], \"summary\": \"two findings\"}";
mount_sse(
&server,
ResponseTemplate::new(200).set_body_raw(sse(&[body]), "text/event-stream"),
)
.await;
let (analyzer, _dir) = analyzer_for(&server);
let result = analyzer
.analyze_file(&hunks_for_python_at_two_lines())
.await;
assert_eq!(
result.findings.len(),
2,
"two issues must yield two findings"
);
let first = &result.findings[0];
assert_eq!(first.kind, "bug");
assert_eq!(first.line, 100);
assert_eq!(first.message, "first bug");
assert_eq!(first.suggestion.as_deref(), Some("fix it"));
let second = &result.findings[1];
assert_eq!(second.kind, "performance");
assert_eq!(second.line, 101);
assert_eq!(second.message, "second issue");
assert_eq!(second.suggestion.as_deref(), Some("optimize"));
}
#[tokio::test]
async fn severity_string_maps_to_severity_enum() {
let server = MockServer::start().await;
let body = "{\"issues\": [\
{\"line\": 100, \"severity\": \"critical\", \"category\": \"c\", \"message\": \"m\"}, \
{\"line\": 100, \"severity\": \"high\", \"category\": \"c\", \"message\": \"m\"}, \
{\"line\": 100, \"severity\": \"medium\", \"category\": \"c\", \"message\": \"m\"}, \
{\"line\": 100, \"severity\": \"low\", \"category\": \"c\", \"message\": \"m\"}, \
{\"line\": 100, \"severity\": \"info\", \"category\": \"c\", \"message\": \"m\"}\
], \"summary\": \"\"}";
mount_sse(
&server,
ResponseTemplate::new(200).set_body_raw(sse(&[body]), "text/event-stream"),
)
.await;
let (analyzer, _dir) = analyzer_for(&server);
let result = analyzer.analyze_file(&hunks_for_python_at(100)).await;
let got: Vec<Severity> = result.findings.iter().map(|f| f.severity).collect();
assert_eq!(
got,
vec![
Severity::Error,
Severity::Error,
Severity::Warning,
Severity::Info,
Severity::Info,
],
"critical/high → Error, medium → Warning, low/info → Info, in that order"
);
}
#[tokio::test]
async fn out_of_range_line_is_dropped_not_clamped() {
use crate::diff::hunks::HunkLine;
let server = MockServer::start().await;
let body = "{\"issues\": [\
{\"line\": 5, \"severity\": \"critical\", \"category\": \"bug\", \"message\": \"oops\"}\
], \"summary\": \"\"}";
mount_sse(
&server,
ResponseTemplate::new(200).set_body_raw(sse(&[body]), "text/event-stream"),
)
.await;
let (analyzer, _dir) = analyzer_for(&server);
let hunks = vec![Hunk {
file_path: PathBuf::from("src/lib.py"),
old_start: 99,
old_count: 0,
new_start: 100,
new_count: 11,
lines: (100..=110u32)
.map(|n| HunkLine::Added(format!("line {n}")))
.collect(),
}];
let result = analyzer.analyze_file(&hunks).await;
assert!(
result.findings.is_empty(),
"no finding may be emitted on an out-of-range line, got {:?}",
result.findings
);
assert!(
result.failed_files.is_empty(),
"an out-of-range line is a model misreport, not a failure, got {:?}",
result.failed_files
);
assert_eq!(
result.dropped_out_of_range, 1,
"the drop must be counted, not silent"
);
assert!(
!result.findings.iter().any(|f| f.line == 100),
"no clamping: an out-of-range line must never appear on a valid line"
);
}
#[tokio::test]
async fn unknown_severity_marks_the_file_unanalyzed() {
let server = MockServer::start().await;
let body = "{\"issues\": [\
{\"line\": 100, \"severity\": \"blocker\", \"category\": \"bug\", \"message\": \"bad\"}\
], \"summary\": \"\"}";
mount_sse(
&server,
ResponseTemplate::new(200).set_body_raw(sse(&[body]), "text/event-stream"),
)
.await;
let (analyzer, _dir) = analyzer_for(&server);
let result = analyzer.analyze_file(&hunks_for_python_at(100)).await;
assert!(
result.findings.is_empty(),
"the malformed record must be skipped, got {:?}",
result.findings
);
assert!(
result
.failed_files
.contains_key(&PathBuf::from("src/lib.py")),
"unknown severity must mark the file unanalyzed, got {:?}",
result.failed_files
);
assert_eq!(
result.dropped_out_of_range, 0,
"unknown severity is not an out-of-range drop, got {}",
result.dropped_out_of_range
);
}
#[tokio::test]
async fn missing_required_field_marks_the_file_unanalyzed() {
let server = MockServer::start().await;
let body = "{\"issues\": [\
{\"severity\": \"high\", \"category\": \"bug\", \"message\": \"x\"}\
], \"summary\": \"\"}";
mount_sse(
&server,
ResponseTemplate::new(200).set_body_raw(sse(&[body]), "text/event-stream"),
)
.await;
let (analyzer, _dir) = analyzer_for(&server);
let result = analyzer.analyze_file(&hunks_for_python_at(100)).await;
assert!(result.findings.is_empty());
assert!(
result
.failed_files
.contains_key(&PathBuf::from("src/lib.py")),
"missing field must mark the file unanalyzed, got {:?}",
result.failed_files
);
assert_eq!(
result.dropped_out_of_range, 0,
"a missing field is a malformed record, not an out-of-range drop - \
without this the two failure classes are indistinguishable here, got {}",
result.dropped_out_of_range
);
}
#[tokio::test]
async fn transport_failure_marks_the_file_failed() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let (cache, _dir) = temp_cache();
let mut cfg = cfg_for(&server, "m", 1);
cfg.timeout_secs = 30;
let analyzer = analyzer_with_fast_retry(&cfg, cache);
let result = analyzer.analyze_file(&hunks_for_python_at(100)).await;
assert!(
result.findings.is_empty(),
"transport failure yields no findings, got {:?}",
result.findings
);
assert!(
result
.failed_files
.contains_key(&PathBuf::from("src/lib.py")),
"transport failure must mark the file failed, got {:?}",
result.failed_files
);
}
#[tokio::test]
async fn missing_issues_field_marks_the_file_failed() {
let server = MockServer::start().await;
let body = "{\"summary\": \"no issues here\"}";
mount_sse(
&server,
ResponseTemplate::new(200).set_body_raw(sse(&[body]), "text/event-stream"),
)
.await;
let (analyzer, _dir) = analyzer_for(&server);
let result = analyzer.analyze_file(&hunks_for_python_at(100)).await;
assert!(
result.findings.is_empty(),
"missing `issues` must yield no findings, got {:?}",
result.findings
);
assert!(
result
.failed_files
.contains_key(&PathBuf::from("src/lib.py")),
"missing `issues` must mark the file failed, got {:?}",
result.failed_files
);
}
#[tokio::test]
async fn line_beyond_u32_marks_the_file_unanalyzed() {
let server = MockServer::start().await;
let body = "{\"issues\": [\
{\"line\": 4294967296, \"severity\": \"high\", \"category\": \"bug\", \"message\": \"m\"}\
], \"summary\": \"\"}";
mount_sse(
&server,
ResponseTemplate::new(200).set_body_raw(sse(&[body]), "text/event-stream"),
)
.await;
let (analyzer, _dir) = analyzer_for(&server);
let result = analyzer.analyze_file(&hunks_for_python_at(100)).await;
assert!(
result.findings.is_empty(),
"the record must be skipped, got {:?}",
result.findings
);
assert!(
result
.failed_files
.contains_key(&PathBuf::from("src/lib.py")),
"a line beyond u32 must mark the file unanalyzed, got {:?}",
result.failed_files
);
assert_eq!(
result.dropped_out_of_range, 0,
"this is a malformed record, not an out-of-range drop, got {}",
result.dropped_out_of_range
);
}
#[tokio::test]
async fn a_schema_invalid_response_is_not_cached() {
let server = server_returning(&["{\"summary\": \"nothing useful\"}"]).await;
let (analyzer, _dir) = analyzer_for(&server);
let first = analyzer.analyze_file(&hunks_for_python_at(100)).await;
let second = analyzer.analyze_file(&hunks_for_python_at(100)).await;
assert!(
!first.failed_files.is_empty(),
"first call must fail the file"
);
assert!(!second.failed_files.is_empty(), "second call must fail too");
assert_eq!(
request_count(&server).await,
2,
"a schema-invalid response must not be cached: the second call has to \
ask again rather than replay the stored failure"
);
}
#[tokio::test]
async fn an_out_of_range_record_with_a_bad_severity_still_fails_the_file() {
let server = MockServer::start().await;
let body = "{\"issues\": [\
{\"line\": 5, \"severity\": \"blocker\", \"category\": \"bug\", \"message\": \"m\"}\
], \"summary\": \"\"}";
mount_sse(
&server,
ResponseTemplate::new(200).set_body_raw(sse(&[body]), "text/event-stream"),
)
.await;
let (analyzer, _dir) = analyzer_for(&server);
let result = analyzer.analyze_file(&hunks_for_python_at(100)).await;
assert!(result.findings.is_empty());
assert!(
result
.failed_files
.contains_key(&PathBuf::from("src/lib.py")),
"shape is checked before membership, so the bad severity wins, got {:?}",
result.failed_files
);
assert_eq!(
result.dropped_out_of_range, 0,
"it is malformed, not a drop - counting it as a drop would report the \
file as analyzed, got {}",
result.dropped_out_of_range
);
}