use super::*;
use crate::collect::jira::model::*;
#[test]
fn jira_search_response_deserializes() {
let json = r#"{
"startAt": 0,
"total": 1,
"issues": [
{
"key": "PROJ-1",
"fields": {
"summary": "Fix bug",
"status": {"name": "Done"},
"issuetype": {"name": "Bug"},
"customfield_10016": 5.0
}
}
]
}"#;
let resp: SearchResponse = serde_json::from_str(json).expect("parses");
assert_eq!(resp.total, 1);
assert_eq!(resp.issues.len(), 1);
let issue = JiraClient::convert_issue(
resp.issues.into_iter().next().expect("one"),
Some("customfield_10016"),
);
assert_eq!(issue.key, "PROJ-1");
assert_eq!(issue.summary, "Fix bug");
assert_eq!(issue.status, "Done");
assert_eq!(issue.issue_type, "Bug");
assert_eq!(issue.story_points, Some(5.0));
}
#[test]
fn field_descriptor_deserializes() {
let json = r#"[
{"id": "customfield_10016", "name": "Story Points"},
{"id": "summary", "name": "Summary"}
]"#;
let fields: Vec<FieldDescriptor> = serde_json::from_str(json).expect("parses");
assert_eq!(fields.len(), 2);
assert_eq!(fields[0].id, "customfield_10016");
assert_eq!(fields[0].name, "Story Points");
}
#[test]
fn convert_issue_returns_none_when_field_missing() {
let json = r#"{
"key": "PROJ-2",
"fields": {
"summary": "x",
"status": {"name": "Open"},
"issuetype": {"name": "Task"}
}
}"#;
let api: ApiIssue = serde_json::from_str(json).expect("parses");
let issue = JiraClient::convert_issue(api, Some("customfield_10016"));
assert!(issue.story_points.is_none());
}
#[test]
fn changelog_search_response_parses_status_transition() {
let json = r#"{
"startAt": 0,
"total": 1,
"issues": [
{
"key": "PROJ-1",
"fields": {"project": {"key": "PROJ"}},
"changelog": {
"histories": [
{
"author": {"displayName": "Jane Doe"},
"created": "2026-01-01T10:00:00.000+0000",
"items": [
{"field": "status", "fromString": "To Do", "toString": "In Progress"}
]
}
]
}
}
]
}"#;
let resp: ChangelogSearchResponse = serde_json::from_str(json).expect("parses");
assert_eq!(resp.issues.len(), 1);
let issue = ChangelogIssue::from_api(resp.issues.into_iter().next().expect("one"));
assert_eq!(issue.key, "PROJ-1");
assert_eq!(issue.project_key, "PROJ");
assert_eq!(issue.transitions.len(), 1);
let t = &issue.transitions[0];
assert_eq!(t.from_status.as_deref(), Some("To Do"));
assert_eq!(t.to_status, "In Progress");
assert_eq!(t.author.as_deref(), Some("Jane Doe"));
}
#[test]
fn changelog_ignores_non_status_fields() {
let json = r#"{
"key": "PROJ-2",
"fields": {"project": {"key": "PROJ"}},
"changelog": {
"histories": [
{
"created": "2026-01-01T10:00:00.000+0000",
"items": [
{"field": "assignee", "fromString": "Alice", "toString": "Bob"},
{"field": "status", "fromString": "Open", "toString": "Closed"}
]
}
]
}
}"#;
let api: ChangelogApiIssue = serde_json::from_str(json).expect("parses");
let issue = ChangelogIssue::from_api(api);
assert_eq!(
issue.transitions.len(),
1,
"assignee changes must be filtered out"
);
assert_eq!(issue.transitions[0].to_status, "Closed");
}
#[test]
fn changelog_initial_transition_has_no_from_status() {
let json = r#"{
"key": "PROJ-3",
"fields": {"project": {"key": "PROJ"}},
"changelog": {
"histories": [
{
"created": "2026-01-01T10:00:00.000+0000",
"items": [
{"field": "status", "toString": "Open"}
]
}
]
}
}"#;
let api: ChangelogApiIssue = serde_json::from_str(json).expect("parses");
let issue = ChangelogIssue::from_api(api);
assert_eq!(issue.transitions.len(), 1);
assert!(issue.transitions[0].from_status.is_none());
assert_eq!(issue.transitions[0].to_status, "Open");
}
#[test]
fn changelog_falls_back_to_key_prefix_when_project_missing() {
let json = r#"{
"key": "INFRA-42",
"fields": {},
"changelog": {"histories": []}
}"#;
let api: ChangelogApiIssue = serde_json::from_str(json).expect("parses");
let issue = ChangelogIssue::from_api(api);
assert_eq!(issue.project_key, "INFRA");
}
#[test]
fn changelog_skips_unparseable_timestamp() {
let json = r#"{
"key": "PROJ-4",
"fields": {"project": {"key": "PROJ"}},
"changelog": {
"histories": [
{"created": "not-a-date", "items": [{"field": "status", "toString": "Done"}]}
]
}
}"#;
let api: ChangelogApiIssue = serde_json::from_str(json).expect("parses");
let issue = ChangelogIssue::from_api(api);
assert!(issue.transitions.is_empty());
}
#[test]
fn comment_body_len_for_plain_string() {
let json = r#"{"id": "1001", "author": {"displayName": "Jane Doe"}, "created": "2026-01-01T10:00:00.000+0000", "body": "hello world"}"#;
let api: ApiComment = serde_json::from_str(json).expect("parses");
let comment = JiraComment::from_api(api).expect("valid timestamp parses");
assert_eq!(comment.id, "1001");
assert_eq!(comment.author.as_deref(), Some("Jane Doe"));
assert_eq!(comment.body_len, "hello world".len() as i64);
}
#[test]
fn comment_body_len_for_adf_object() {
let json = r#"{
"id": "1002",
"created": "2026-01-01T10:00:00.000+0000",
"body": {"type": "doc", "version": 1, "content": []}
}"#;
let api: ApiComment = serde_json::from_str(json).expect("parses");
let comment = JiraComment::from_api(api).expect("valid timestamp parses");
assert!(comment.author.is_none());
let expected_len = serde_json::to_string(&json!({"type": "doc", "version": 1, "content": []}))
.unwrap()
.len() as i64;
assert_eq!(comment.body_len, expected_len);
}
#[test]
fn comment_with_unparseable_timestamp_is_skipped() {
let json = r#"{"id": "1003", "created": "not-a-date", "body": "x"}"#;
let api: ApiComment = serde_json::from_str(json).expect("parses");
assert!(JiraComment::from_api(api).is_none());
}
#[test]
fn comment_search_response_deserializes() {
let json = r#"{
"startAt": 0,
"maxResults": 100,
"total": 2,
"comments": [
{"id": "1", "created": "2026-01-01T00:00:00.000+0000", "body": "a"},
{"id": "2", "created": "2026-01-02T00:00:00.000+0000", "body": "b"}
]
}"#;
let resp: CommentSearchResponse = serde_json::from_str(json).expect("parses");
assert_eq!(resp.comments.len(), 2);
}
#[test]
fn parse_jira_datetime_accepts_both_shapes() {
assert!(parse_jira_datetime("2026-01-01T00:00:00Z").is_some());
assert!(parse_jira_datetime("2026-01-01T00:00:00.000+0000").is_some());
assert!(parse_jira_datetime("garbage").is_none());
}
#[test]
fn parse_jira_datetime_accepts_second_precision_colonless_offset() {
assert!(
parse_jira_datetime("2026-01-01T10:00:00+0000").is_some(),
"the JIRA Server/DC shape must not be silently dropped"
);
}
#[test]
fn unset_credential_env_var_is_a_config_error() {
let config = JiraConfig {
url: Some("https://example.atlassian.net".into()),
username: Some("bot@example.com".into()),
token: Some("${TGA_TEST_JIRA_TOKEN_DEFINITELY_UNSET_4067}".into()),
..Default::default()
};
let Err(err) = JiraClient::new(&config) else {
panic!("must reject an unresolved credential");
};
let msg = err.to_string();
assert!(msg.contains("jira.token"), "must name the field: {msg}");
assert!(
msg.contains("TGA_TEST_JIRA_TOKEN_DEFINITELY_UNSET_4067"),
"must name the unresolved variable: {msg}"
);
}
#[test]
fn absent_credentials_are_not_an_error() {
let config = JiraConfig {
url: Some("https://example.atlassian.net".into()),
..Default::default()
};
assert!(JiraClient::new(&config).is_ok());
}
mod paged_http {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
use crate::collect::jira::retry::RetryPolicy;
fn fast_retry() -> RetryPolicy {
RetryPolicy {
max_attempts: 3,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(1),
max_total_delay: Duration::from_millis(100),
}
}
fn client_for(server: &MockServer) -> JiraClient {
let config = JiraConfig {
url: Some(server.uri()),
timezone: Some("UTC".to_string()),
..Default::default()
};
JiraClient::new(&config)
.expect("client builds")
.with_retry_policy(fast_retry())
}
fn issue(key: &str, updated: &str) -> serde_json::Value {
json!({
"key": key,
"fields": {"project": {"key": "PROJ"}, "updated": updated},
"changelog": {"histories": []}
})
}
fn at_minute(minute: usize) -> String {
format!("2026-01-01T00:{minute:02}:00.000+0000")
}
struct ShiftingChangelog {
seen: Arc<Mutex<Vec<(String, u64)>>>,
}
impl Respond for ShiftingChangelog {
fn respond(&self, request: &Request) -> ResponseTemplate {
let body: serde_json::Value = serde_json::from_slice(&request.body).expect("json body");
let jql = body["jql"].as_str().unwrap_or_default().to_string();
let start_at = body["startAt"].as_u64().unwrap_or_default();
self.seen
.lock()
.expect("lock")
.push((jql.clone(), start_at));
if !jql.contains("updated >=") {
let issues: Vec<serde_json::Value> = (1..=50)
.map(|i| issue(&format!("PROJ-{i}"), &at_minute(i)))
.collect();
return ResponseTemplate::new(200).set_body_json(json!({"issues": issues}));
}
let mut issues: Vec<serde_json::Value> = vec![
issue("PROJ-50", &at_minute(50)),
issue("PROJ-51", &at_minute(51)),
issue("PROJ-52", &at_minute(52)),
issue("PROJ-53", &at_minute(53)),
issue("PROJ-3", "2026-01-01T09:00:00.000+0000"),
];
issues.drain(..(start_at as usize).min(issues.len()));
ResponseTemplate::new(200).set_body_json(json!({"issues": issues}))
}
}
#[tokio::test]
async fn changelog_walk_survives_a_ticket_edited_mid_walk() {
let server = MockServer::start().await;
let seen = Arc::new(Mutex::new(Vec::new()));
Mock::given(method("POST"))
.and(path("/rest/api/3/search"))
.respond_with(ShiftingChangelog {
seen: Arc::clone(&seen),
})
.mount(&server)
.await;
let scope = SyncScope {
project_key: "PROJ".into(),
since: None,
};
let walk = client_for(&server)
.search_with_changelog(&scope, 60)
.await
.expect("walk succeeds");
let keys: Vec<&str> = walk.issues.iter().map(|i| i.key.as_str()).collect();
assert!(
keys.contains(&"PROJ-51"),
"the ticket after the page boundary must still be read; got {} issues",
keys.len()
);
assert_eq!(
keys.len(),
53,
"every distinct ticket exactly once (re-read boundary deduplicated)"
);
assert_eq!(
keys.iter().filter(|k| **k == "PROJ-3").count(),
1,
"the re-sorted ticket must not be emitted twice"
);
let requests = seen.lock().expect("lock").clone();
assert_eq!(requests.len(), 2, "walk should take two pages");
assert!(
!requests[0].0.contains("updated >="),
"first page is the unbounded window: {}",
requests[0].0
);
assert!(
requests[1].0.contains("updated >= \"2026-01-01 00:50\""),
"the second page must re-anchor on the first page's max updated, \
not page by absolute offset: {}",
requests[1].0
);
assert_eq!(
requests[1].1, 1,
"only the single item already held from the 00:50 minute is skipped"
);
}
struct FlakyComments {
calls: Arc<AtomicUsize>,
}
impl Respond for FlakyComments {
fn respond(&self, _request: &Request) -> ResponseTemplate {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
if n == 0 {
return ResponseTemplate::new(500);
}
ResponseTemplate::new(200).set_body_json(json!({
"startAt": 0,
"maxResults": 100,
"total": 1,
"comments": [
{"id": "9001", "created": "2026-01-05T09:30:00.000+0000", "body": "ok"}
]
}))
}
}
#[tokio::test]
async fn fetch_comments_retries_a_transient_500() {
let server = MockServer::start().await;
let calls = Arc::new(AtomicUsize::new(0));
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-1/comment"))
.respond_with(FlakyComments {
calls: Arc::clone(&calls),
})
.mount(&server)
.await;
let comments = client_for(&server)
.fetch_comments("PROJ-1")
.await
.expect("the retry must recover the transient failure");
assert_eq!(comments.len(), 1);
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"one failed attempt plus one successful retry"
);
}
fn myself_body(tz: &str) -> serde_json::Value {
json!({"accountId": "abc", "displayName": "Bot", "timeZone": tz})
}
#[tokio::test]
async fn account_timezone_prefers_configured_value() {
let server = MockServer::start().await;
let config = JiraConfig {
url: Some(server.uri()),
timezone: Some("Asia/Kolkata".to_string()),
..Default::default()
};
let client = JiraClient::new(&config).expect("builds");
assert_eq!(
client.account_timezone().await.expect("resolves"),
chrono_tz::Tz::Asia__Kolkata
);
assert!(
server
.received_requests()
.await
.expect("recorded")
.is_empty(),
"a pinned timezone must not cost a round-trip"
);
}
#[tokio::test]
async fn account_timezone_discovers_from_myself() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/myself"))
.respond_with(ResponseTemplate::new(200).set_body_json(myself_body("America/New_York")))
.mount(&server)
.await;
let client = client_for_discovery(&server);
assert_eq!(
client.account_timezone().await.expect("resolves"),
chrono_tz::Tz::America__New_York
);
assert_eq!(
client.account_timezone().await.expect("cached"),
chrono_tz::Tz::America__New_York
);
let probes = server
.received_requests()
.await
.expect("recorded")
.into_iter()
.filter(|r| r.url.path().ends_with("/myself"))
.count();
assert_eq!(probes, 1, "the zone must be cached like story_point_field");
}
#[tokio::test]
async fn account_timezone_errors_when_undiscoverable() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/myself"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let Err(err) = client_for_discovery(&server).account_timezone().await else {
panic!("must not silently assume UTC");
};
let msg = err.to_string();
assert!(
msg.contains("jira.timezone"),
"the error must name the remediation: {msg}"
);
}
#[tokio::test]
async fn account_timezone_rejects_an_unknown_zone() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/myself"))
.respond_with(ResponseTemplate::new(200).set_body_json(myself_body("Nowhere/Fake")))
.mount(&server)
.await;
let Err(err) = client_for_discovery(&server).account_timezone().await else {
panic!("must reject an unknown zone");
};
assert!(err.to_string().contains("Nowhere/Fake"));
}
fn client_for_discovery(server: &MockServer) -> JiraClient {
let config = JiraConfig {
url: Some(server.uri()),
..Default::default()
};
JiraClient::new(&config)
.expect("client builds")
.with_retry_policy(fast_retry())
}
#[tokio::test]
async fn a_429_is_retried_and_its_retry_after_is_read() {
let server = MockServer::start().await;
let calls = Arc::new(AtomicUsize::new(0));
struct Throttling {
calls: Arc<AtomicUsize>,
}
impl Respond for Throttling {
fn respond(&self, _request: &Request) -> ResponseTemplate {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
if n == 0 {
return ResponseTemplate::new(429).insert_header("Retry-After", "0");
}
ResponseTemplate::new(200).set_body_json(json!({
"startAt": 0, "maxResults": 100, "total": 1,
"comments": [
{"id": "1", "created": "2026-01-05T09:30:00.000+0000", "body": "ok"}
]
}))
}
}
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-2/comment"))
.respond_with(Throttling {
calls: Arc::clone(&calls),
})
.mount(&server)
.await;
let comments = client_for(&server)
.fetch_comments("PROJ-2")
.await
.expect("the 429 must be retried");
assert_eq!(comments.len(), 1);
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn fetch_comments_does_not_retry_a_404() {
let server = MockServer::start().await;
let calls = Arc::new(AtomicUsize::new(0));
struct AlwaysMissing {
calls: Arc<AtomicUsize>,
}
impl Respond for AlwaysMissing {
fn respond(&self, _request: &Request) -> ResponseTemplate {
self.calls.fetch_add(1, Ordering::SeqCst);
ResponseTemplate::new(404)
}
}
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-9/comment"))
.respond_with(AlwaysMissing {
calls: Arc::clone(&calls),
})
.mount(&server)
.await;
let err = client_for(&server).fetch_comments("PROJ-9").await;
assert!(err.is_err());
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"a 404 is permanent for the life of the run"
);
}
fn comment_page(start: usize, n: usize) -> serde_json::Value {
let comments: Vec<serde_json::Value> = (start..start + n)
.map(|i| json!({"id": i.to_string(), "created": "2026-01-05T09:30:00.000+0000", "body": "x"}))
.collect();
json!({"startAt": start, "maxResults": 100, "comments": comments})
}
struct PagedComments {
pages: Vec<usize>,
calls: Arc<AtomicUsize>,
}
impl Respond for PagedComments {
fn respond(&self, request: &Request) -> ResponseTemplate {
let start_at: usize = request
.url
.query_pairs()
.find(|(k, _)| k == "startAt")
.and_then(|(_, v)| v.parse().ok())
.unwrap_or(0);
self.calls.fetch_add(1, Ordering::SeqCst);
let mut offset = 0usize;
for n in &self.pages {
if offset == start_at {
return ResponseTemplate::new(200).set_body_json(comment_page(offset, *n));
}
offset += n;
}
ResponseTemplate::new(200).set_body_json(comment_page(start_at, 0))
}
}
#[tokio::test]
async fn fetch_comments_pages_every_comment_when_total_is_absent() {
let server = MockServer::start().await;
let calls = Arc::new(AtomicUsize::new(0));
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-7/comment"))
.respond_with(PagedComments {
pages: vec![100, 50],
calls: Arc::clone(&calls),
})
.mount(&server)
.await;
let comments = client_for(&server)
.fetch_comments("PROJ-7")
.await
.expect("walk succeeds");
assert_eq!(
comments.len(),
150,
"every comment must be ingested even with no `total` to terminate on"
);
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"the 50-entry short page ends the walk; no third request"
);
}
#[tokio::test]
async fn fetch_comments_stops_after_an_empty_page_on_an_exact_multiple() {
let server = MockServer::start().await;
let calls = Arc::new(AtomicUsize::new(0));
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-8/comment"))
.respond_with(PagedComments {
pages: vec![100],
calls: Arc::clone(&calls),
})
.mount(&server)
.await;
let comments = client_for(&server)
.fetch_comments("PROJ-8")
.await
.expect("walk succeeds");
assert_eq!(comments.len(), 100);
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"a full final page costs one empty probe to confirm the end"
);
}
fn comment_page_with_max(start: usize, n: usize, max: usize) -> serde_json::Value {
let comments: Vec<serde_json::Value> = (start..start + n)
.map(|i| json!({"id": i.to_string(), "created": "2026-01-05T09:30:00.000+0000", "body": "x"}))
.collect();
json!({"startAt": start, "maxResults": max, "comments": comments})
}
struct ShrunkPages {
max: usize,
total: usize,
echo_max: bool,
calls: Arc<AtomicUsize>,
}
impl Respond for ShrunkPages {
fn respond(&self, request: &Request) -> ResponseTemplate {
let start_at: usize = request
.url
.query_pairs()
.find(|(k, _)| k == "startAt")
.and_then(|(_, v)| v.parse().ok())
.unwrap_or(0);
self.calls.fetch_add(1, Ordering::SeqCst);
let n = self.total.saturating_sub(start_at).min(self.max);
let body = if self.echo_max {
comment_page_with_max(start_at, n, self.max)
} else {
let mut b = comment_page_with_max(start_at, n, self.max);
b.as_object_mut().expect("object").remove("maxResults");
b
};
ResponseTemplate::new(200).set_body_json(body)
}
}
#[tokio::test]
async fn fetch_comments_honours_the_page_size_the_server_applied() {
let server = MockServer::start().await;
let calls = Arc::new(AtomicUsize::new(0));
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-11/comment"))
.respond_with(ShrunkPages {
max: 50,
total: 150,
echo_max: true,
calls: Arc::clone(&calls),
})
.mount(&server)
.await;
let comments = client_for(&server)
.fetch_comments("PROJ-11")
.await
.expect("walk succeeds");
assert_eq!(
comments.len(),
150,
"a server paging smaller than requested must not end the walk after \
page 1; every comment must still be ingested"
);
assert_eq!(
calls.load(Ordering::SeqCst),
4,
"three 50-entry pages plus the empty probe that confirms the end"
);
}
#[tokio::test]
async fn fetch_comments_pages_to_empty_when_the_server_omits_max_results() {
let server = MockServer::start().await;
let calls = Arc::new(AtomicUsize::new(0));
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-12/comment"))
.respond_with(ShrunkPages {
max: 50,
total: 120,
echo_max: false,
calls: Arc::clone(&calls),
})
.mount(&server)
.await;
let comments = client_for(&server)
.fetch_comments("PROJ-12")
.await
.expect("walk succeeds");
assert_eq!(
comments.len(),
120,
"an unstated page size must never be treated as proof the walk is done"
);
assert_eq!(calls.load(Ordering::SeqCst), 4);
}
fn history_entry(created: &str, from: &str, to: &str) -> serde_json::Value {
json!({
"author": {"displayName": "Jane Doe"},
"created": created,
"items": [{"field": "status", "fromString": from, "toString": to}]
})
}
fn oldest() -> serde_json::Value {
history_entry("2026-01-01T10:00:00.000+0000", "To Do", "In Progress")
}
fn middle() -> serde_json::Value {
history_entry("2026-02-01T10:00:00.000+0000", "In Progress", "In Review")
}
fn newest() -> serde_json::Value {
history_entry("2026-03-01T10:00:00.000+0000", "In Review", "Done")
}
fn search_body(total: u64, histories: Vec<serde_json::Value>) -> serde_json::Value {
json!({
"issues": [{
"key": "PROJ-1",
"fields": {"project": {"key": "PROJ"}, "updated": "2026-03-01T10:00:00.000+0000"},
"changelog": {"total": total, "histories": histories}
}]
})
}
fn scope() -> SyncScope {
SyncScope {
project_key: "PROJ".into(),
since: None,
}
}
async fn hits(server: &MockServer, suffix: &str) -> usize {
server
.received_requests()
.await
.expect("request recording enabled")
.iter()
.filter(|r| r.url.path().ends_with(suffix))
.count()
}
#[tokio::test]
async fn search_with_changelog_flags_a_truncated_embedded_changelog() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rest/api/3/search"))
.respond_with(ResponseTemplate::new(200).set_body_json(search_body(3, vec![newest()])))
.mount(&server)
.await;
let walk = client_for(&server)
.search_with_changelog(&scope(), 10)
.await
.expect("search succeeds");
assert_eq!(walk.issues.len(), 1);
assert_eq!(
walk.issues[0].truncated_history_total,
Some(3),
"the shortfall must be recorded with the count that proves it"
);
assert_eq!(
hits(&server, "/changelog").await,
0,
"the repair is the caller's to make, inside per-ticket isolation"
);
}
#[tokio::test]
async fn search_with_changelog_does_not_flag_a_complete_embedded_changelog() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rest/api/3/search"))
.respond_with(
ResponseTemplate::new(200).set_body_json(search_body(2, vec![middle(), newest()])),
)
.mount(&server)
.await;
let walk = client_for(&server)
.search_with_changelog(&scope(), 10)
.await
.expect("search succeeds");
assert_eq!(walk.issues[0].transitions.len(), 2);
assert_eq!(
walk.issues[0].truncated_history_total, None,
"a complete embedded changelog must not be sent for repair"
);
assert_eq!(hits(&server, "/changelog").await, 0);
}
#[tokio::test]
async fn missing_changelog_total_does_not_flag_truncation() {
let server = MockServer::start().await;
let body = json!({
"issues": [{
"key": "PROJ-1",
"fields": {"project": {"key": "PROJ"}},
"changelog": {"histories": [newest()]}
}]
});
Mock::given(method("POST"))
.and(path("/rest/api/3/search"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let walk = client_for(&server)
.search_with_changelog(&scope(), 10)
.await
.expect("search succeeds");
assert_eq!(walk.issues[0].transitions.len(), 1);
assert_eq!(walk.issues[0].truncated_history_total, None);
assert_eq!(hits(&server, "/changelog").await, 0);
}
#[tokio::test]
async fn fetch_changelog_pages_to_exhaustion() {
use wiremock::matchers::query_param;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-9/changelog"))
.and(query_param("startAt", "0"))
.respond_with(ResponseTemplate::new(200).set_body_json(
json!({"startAt": 0, "maxResults": 100, "total": 3, "values": [oldest(), middle()]}),
))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-9/changelog"))
.and(query_param("startAt", "2"))
.respond_with(ResponseTemplate::new(200).set_body_json(
json!({"startAt": 2, "maxResults": 100, "total": 3, "values": [newest()]}),
))
.mount(&server)
.await;
let transitions = client_for(&server)
.fetch_changelog("PROJ-9", None)
.await
.expect("full walk succeeds");
assert_eq!(transitions.len(), 3);
assert_eq!(transitions[0].to_status, "In Progress");
assert_eq!(transitions[1].to_status, "In Review");
assert_eq!(transitions[2].to_status, "Done");
assert_eq!(hits(&server, "/changelog").await, 2, "both pages fetched");
}
#[tokio::test]
async fn fetch_changelog_errors_when_server_returns_fewer_than_total() {
use wiremock::matchers::query_param;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-9/changelog"))
.and(query_param("startAt", "0"))
.respond_with(ResponseTemplate::new(200).set_body_json(
json!({"startAt": 0, "maxResults": 100, "total": 5, "values": [oldest(), middle()]}),
))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-9/changelog"))
.and(query_param("startAt", "2"))
.respond_with(
ResponseTemplate::new(200).set_body_json(
json!({"startAt": 2, "maxResults": 100, "total": 5, "values": []}),
),
)
.mount(&server)
.await;
let err = client_for(&server)
.fetch_changelog("PROJ-9", None)
.await
.expect_err("a short walk must not pass as a complete history");
match &err {
CollectError::IncompleteChangelog {
key,
expected,
retrieved,
} => {
assert_eq!(key, "PROJ-9");
assert_eq!(*expected, 5);
assert_eq!(*retrieved, 2);
}
other => panic!("expected IncompleteChangelog, got {other:?}"),
}
let msg = err.to_string();
assert!(
msg.contains("PROJ-9"),
"message must name the ticket: {msg}"
);
assert!(
msg.contains('5') && msg.contains('2'),
"counts missing: {msg}"
);
}
#[tokio::test]
async fn fetch_changelog_keeps_paging_when_the_endpoint_omits_total() {
use wiremock::matchers::query_param;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-10/changelog"))
.and(query_param("startAt", "0"))
.respond_with(ResponseTemplate::new(200).set_body_json(
json!({"startAt": 0, "maxResults": 100, "values": [oldest(), middle()]}),
))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-10/changelog"))
.and(query_param("startAt", "2"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({"startAt": 2, "maxResults": 100, "values": [newest()]})),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-10/changelog"))
.and(query_param("startAt", "3"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({"startAt": 3, "maxResults": 100, "values": []})),
)
.mount(&server)
.await;
let transitions = client_for(&server)
.fetch_changelog("PROJ-10", None)
.await
.expect("walk succeeds");
assert_eq!(
transitions.len(),
3,
"an absent `total` must not read as zero-entries-remaining and end \
the walk after page 1"
);
assert_eq!(
transitions[0].from_status.as_deref(),
Some("To Do"),
"the OLDEST entry is precisely what a page-1 stop drops"
);
}
#[tokio::test]
async fn fetch_changelog_errors_when_an_absent_total_hides_a_shortfall() {
use wiremock::matchers::query_param;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-10/changelog"))
.and(query_param("startAt", "0"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({"startAt": 0, "maxResults": 100, "values": [newest()]})),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-10/changelog"))
.and(query_param("startAt", "1"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({"startAt": 1, "maxResults": 100, "values": []})),
)
.mount(&server)
.await;
let err = client_for(&server)
.fetch_changelog("PROJ-10", Some(3))
.await
.expect_err("a repair that recovers less than the search proved exists must error");
match &err {
CollectError::IncompleteChangelog {
key,
expected,
retrieved,
} => {
assert_eq!(key, "PROJ-10");
assert_eq!(*expected, 3, "the search's count is the standing bound");
assert_eq!(*retrieved, 1);
}
other => panic!("expected IncompleteChangelog, got {other:?}"),
}
}
#[tokio::test]
async fn a_broken_changelog_endpoint_does_not_abort_the_search_walk() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rest/api/3/search"))
.respond_with(ResponseTemplate::new(200).set_body_json(search_body(3, vec![newest()])))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-1/changelog"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let walk = client_for(&server)
.search_with_changelog(&scope(), 10)
.await
.expect("a broken per-ticket endpoint must not abort the walk");
assert_eq!(walk.issues.len(), 1);
assert_eq!(
walk.issues[0].truncated_history_total,
Some(3),
"the ticket is still flagged for repair; the caller decides what a \
failed repair costs"
);
}
#[tokio::test]
async fn fallback_transitions_replace_embedded_without_duplication() {
use crate::core::db::{upsert_ticket_transition, Database, TicketTransitionRow};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rest/api/3/search"))
.respond_with(ResponseTemplate::new(200).set_body_json(search_body(3, vec![newest()])))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-1/changelog"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"startAt": 0, "maxResults": 100, "total": 3,
"values": [oldest(), middle(), newest()]
})))
.mount(&server)
.await;
let client = client_for(&server);
let walk = client
.search_with_changelog(&scope(), 10)
.await
.expect("search succeeds");
let issue = &walk.issues[0];
let transitions = client
.fetch_changelog(&issue.key, issue.truncated_history_total)
.await
.expect("repair succeeds");
assert_eq!(
transitions.len(),
3,
"the shared entry must appear once, not twice"
);
assert_eq!(
transitions[0].from_status.as_deref(),
Some("To Do"),
"the OLDEST transition is the one truncation drops; it must be present"
);
let db = Database::open_in_memory().expect("open");
for t in &transitions {
upsert_ticket_transition(
db.connection(),
&TicketTransitionRow {
ticket_key: issue.key.clone(),
project_key: issue.project_key.clone(),
from_status: t.from_status.clone(),
to_status: t.to_status.clone(),
transitioned_at: t.created.to_rfc3339(),
author: t.author.clone(),
},
)
.expect("upsert");
}
let count: i64 = db
.connection()
.query_row(
"SELECT COUNT(*) FROM fact_ticket_transitions WHERE ticket_key = 'PROJ-1'",
[],
|r| r.get(0),
)
.expect("count");
assert_eq!(
count, 3,
"every recovered transition is a distinct grain row"
);
}
}