use super::*;
struct FakeToken(Result<String, ()>);
#[async_trait]
impl IssueTokenResolver for FakeToken {
async fn resolve(&self, _owner: &str) -> Result<String, ContextSourceError> {
self.0
.clone()
.map_err(|_| ContextSourceError::NotConfigured {
src: SOURCE_NAME,
reason: "no token".to_string(),
})
}
}
struct FakeSearch(Result<String, ()>);
#[async_trait]
impl IssueSearchTransport for FakeSearch {
async fn search(&self, _t: &str, _q: &str, _n: u32) -> Result<String, ContextSourceError> {
self.0.clone().map_err(|_| ContextSourceError::Api {
src: SOURCE_NAME,
status: 403,
body: "rate limited".to_string(),
})
}
}
fn subject() -> ReviewSubject {
ReviewSubject {
owner: "acme".to_string(),
repo: "backend".to_string(),
title: "Fix login".to_string(),
identifiers: vec!["login".to_string()],
..Default::default()
}
}
#[test]
fn query_builds_search() {
let q = GithubIssuesSource::build_query(&subject()).expect("signal");
assert!(q.starts_with("repo:acme/backend is:issue "));
assert!(q.contains("Fix login"));
}
#[test]
fn query_none_for_local_diff() {
let subj = ReviewSubject {
owner: "local".to_string(),
repo: String::new(),
title: "x".to_string(),
..Default::default()
};
assert!(GithubIssuesSource::build_query(&subj).is_none());
}
#[test]
fn parse_issues_to_section() {
let body = r#"{
"items": [
{"number": 42, "title": "Login broken", "state": "open",
"html_url": "https://github.com/acme/backend/issues/42"}
]
}"#;
let section = GithubIssuesSource::parse_section(body).unwrap();
assert_eq!(section.heading, "Related GitHub issues");
assert_eq!(section.snippets.len(), 1);
assert_eq!(section.snippets[0].title, "#42 — Login broken");
assert_eq!(section.snippets[0].subtitle.as_deref(), Some("open"));
assert_eq!(
section.snippets[0].link.as_deref(),
Some("https://github.com/acme/backend/issues/42")
);
}
#[test]
fn parse_embeds_body() {
let body = r#"{
"items": [
{"number": 5, "title": "Login bug", "state": "open", "html_url": "u",
"body": " The login form rejects valid passwords. "}
]
}"#;
let section = GithubIssuesSource::parse_section(body).unwrap();
assert_eq!(
section.snippets[0].body.as_deref(),
Some("The login form rejects valid passwords.")
);
}
#[test]
fn parse_truncates_long_body() {
let long = "x".repeat(SNIPPET_BODY_CHARS + 100);
let body = format!(
r#"{{"items":[{{"number":1,"title":"t","state":"open","html_url":"u","body":"{long}"}}]}}"#
);
let section = GithubIssuesSource::parse_section(&body).unwrap();
assert_eq!(
section.snippets[0].body.as_deref().unwrap().chars().count(),
SNIPPET_BODY_CHARS
);
}
#[test]
fn parse_no_body_when_empty() {
let body = r#"{"items":[{"number":1,"title":"t","state":"open","html_url":"u","body":" "}]}"#;
let section = GithubIssuesSource::parse_section(body).unwrap();
assert!(section.snippets[0].body.is_none());
}
#[test]
fn parse_filters_pull_requests() {
let body = r#"{
"items": [
{"number": 1, "title": "real issue", "state": "open", "html_url": "u1"},
{"number": 2, "title": "a PR", "state": "open", "html_url": "u2",
"pull_request": {"url": "x"}}
]
}"#;
let section = GithubIssuesSource::parse_section(body).unwrap();
assert_eq!(section.snippets.len(), 1);
assert_eq!(section.snippets[0].title, "#1 — real issue");
}
#[test]
fn parse_error_on_garbage() {
assert!(matches!(
GithubIssuesSource::parse_section("nope"),
Err(ContextSourceError::Parse { .. })
));
}
#[test]
fn from_config_respects_explicit_disable() {
let cfg = super::super::SourceConfig {
enabled: Some(false),
mode: RetrievalMode::Live,
};
let src = GithubIssuesSource::from_config(&cfg, RunMode::Cli, ReviewConfig::load(None));
assert!(!src.is_enabled());
}
#[tokio::test]
async fn disabled_without_token() {
let src = GithubIssuesSource::new(
true,
RetrievalMode::Live,
Box::new(FakeToken(Err(()))),
Box::new(FakeSearch(Ok("{}".into()))),
);
let r = src.gather(&subject()).await;
assert!(matches!(r, Err(ContextSourceError::NotConfigured { .. })));
}
#[tokio::test]
async fn semantic_mode_errors() {
let src = GithubIssuesSource::new(
true,
RetrievalMode::Semantic,
Box::new(FakeToken(Ok("t".into()))),
Box::new(FakeSearch(Ok("{}".into()))),
);
let r = src.gather(&subject()).await;
assert!(matches!(
r,
Err(ContextSourceError::SemanticNotImplemented {
src: "github_issues"
})
));
}
#[tokio::test]
async fn gather_with_fakes() {
let body = r#"{"items":[{"number":7,"title":"bug","state":"closed","html_url":"u"}]}"#;
let src = GithubIssuesSource::new(
true,
RetrievalMode::Live,
Box::new(FakeToken(Ok("tok".into()))),
Box::new(FakeSearch(Ok(body.to_string()))),
);
let section = src.gather(&subject()).await.expect("ok");
assert_eq!(section.snippets.len(), 1);
assert_eq!(section.snippets[0].title, "#7 — bug");
assert_eq!(section.snippets[0].subtitle.as_deref(), Some("closed"));
}
#[test]
fn query_short_unchanged() {
let q = cap_query("repo:acme/backend is:issue fix login");
assert_eq!(q, "repo:acme/backend is:issue fix login");
assert!(q.chars().count() <= GITHUB_QUERY_MAX_CHARS);
}
#[test]
fn query_capped_at_256_chars() {
let long_keywords = "word ".repeat(60); let subj = ReviewSubject {
owner: "acme".to_string(),
repo: "backend".to_string(),
title: long_keywords.trim().to_string(),
..Default::default()
};
let q = GithubIssuesSource::build_query(&subj).expect("signal");
assert!(
q.chars().count() <= GITHUB_QUERY_MAX_CHARS,
"query was {} chars (>256): {:?}",
q.chars().count(),
q
);
}
#[test]
fn query_capped_at_word_boundary() {
let prefix = "repo:acme/backend is:issue "; let filler = "abcde ".repeat(40); let full = format!("{prefix}{filler}extra");
assert!(
full.chars().count() > GITHUB_QUERY_MAX_CHARS,
"test precondition: full query must exceed 256 chars"
);
let capped = cap_query(&full);
assert!(
capped.chars().count() <= GITHUB_QUERY_MAX_CHARS,
"capped query too long: {} chars",
capped.chars().count()
);
assert!(
!capped.ends_with(' '),
"capped query must not end with a space: {:?}",
capped
);
}
#[test]
fn build_query_long_body_stays_under_256() {
let long_body = "important context word ".repeat(30); let subj = ReviewSubject {
owner: "acme".to_string(),
repo: "backend".to_string(),
title: "Fix authentication flow".to_string(),
body: long_body,
identifiers: vec![
"authenticate".to_string(),
"TokenStore".to_string(),
"refresh_token".to_string(),
"validate_session".to_string(),
],
..Default::default()
};
let q = GithubIssuesSource::build_query(&subj).expect("signal");
assert!(
q.chars().count() <= GITHUB_QUERY_MAX_CHARS,
"build_query returned {} chars (>256): {:?}",
q.chars().count(),
q
);
assert!(
q.starts_with("repo:acme/backend is:issue "),
"qualifiers stripped: {:?}",
q
);
}