use super::*;
use std::collections::HashMap as StdHashMap;
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::collect::errors::CollectError;
fn config() -> GithubIssuesSourceConfig {
let mut label_mappings = StdHashMap::new();
label_mappings.insert("bug".to_string(), "bug_fix".to_string());
GithubIssuesSourceConfig {
repo: "acme/widgets".to_string(),
token_env: "TGA_TEST_GITHUB_TOKEN_ABSENT".to_string(),
label_mappings,
}
}
fn refs(n: u64) -> Vec<GitHubRef> {
(1..=n)
.map(|number| GitHubRef { repo: None, number })
.collect()
}
async fn request_count(server: &MockServer) -> usize {
server
.received_requests()
.await
.map(|r| r.len())
.unwrap_or_default()
}
#[tokio::test]
async fn a_rate_limited_batch_stops_and_caches_nothing() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "300"))
.mount(&server)
.await;
let budget = FetchBudget::with_sleep_budget(std::time::Duration::from_secs(1));
let batch = fetch_issues_batch(
&reqwest::Client::new(),
&config(),
&refs(50),
Some(&server.uri()),
&budget,
)
.await;
assert!(
batch.signals.is_empty(),
"a throttled lookup is an unknown answer; caching it as `no signal` is the bug"
);
let reason = batch
.stopped_early
.expect("a batch cut short by a rate limit must say so");
assert!(
reason.contains("UNCLASSIFIED"),
"the stop must name what is missing, got: {reason}"
);
assert_eq!(
request_count(&server).await,
1,
"50 references must not cost 50 rejected lookups once the budget is spent"
);
}
#[tokio::test]
async fn the_batch_stops_at_the_reference_lookup_cap() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let budget = FetchBudget::new();
let over_cap = (MAX_REFERENCE_LOOKUPS + 25) as u64;
let batch = fetch_issues_batch(
&reqwest::Client::new(),
&config(),
&refs(over_cap),
Some(&server.uri()),
&budget,
)
.await;
assert_eq!(batch.signals.len(), MAX_REFERENCE_LOOKUPS);
assert_eq!(request_count(&server).await, MAX_REFERENCE_LOOKUPS);
let reason = batch
.stopped_early
.expect("hitting the lookup cap must be reported, never silent");
assert!(reason.contains("UNCLASSIFIED"), "got: {reason}");
assert_eq!(
budget.notices().len(),
1,
"the cap must also reach the run's truncation ledger"
);
}
#[tokio::test]
async fn a_labelled_issue_still_classifies() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r#"{"number":7,"labels":[{"name":"bug"}]}"#),
)
.mount(&server)
.await;
let budget = FetchBudget::new();
let batch = fetch_issues_batch(
&reqwest::Client::new(),
&config(),
&refs(1),
Some(&server.uri()),
&budget,
)
.await;
assert!(batch.stopped_early.is_none());
let signal = batch
.signals
.get("acme/widgets#1")
.expect("the reference was looked up")
.as_ref()
.expect("a `bug` label maps to a category");
assert_eq!(signal.category, "bug_fix");
}
#[tokio::test]
async fn a_missing_issue_is_a_cacheable_no_signal() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let budget = FetchBudget::new();
let issue = fetch_issue(
&reqwest::Client::new(),
&config(),
"acme/widgets",
404,
Some(&server.uri()),
&budget,
)
.await
.expect("a 404 is an answer, not a failure");
assert!(issue.is_none());
assert!(budget.tripped_error().is_none());
}
#[tokio::test]
async fn a_rate_limited_single_fetch_returns_throttled() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
.mount(&server)
.await;
let budget = FetchBudget::new();
let err = fetch_issue(
&reqwest::Client::new(),
&config(),
"acme/widgets",
1,
Some(&server.uri()),
&budget,
)
.await
.expect_err("a persistent rate limit must not read as `no signal`");
assert!(
matches!(err, CollectError::Throttled { status: 429, .. }),
"got {err:?}"
);
}