use super::*;
#[test]
fn bb_paged_full_pr_deserializes() {
let json = r#"{
"values": [{
"id": 42,
"title": "Add foo widget",
"state": "MERGED",
"created_on": "2024-01-02T03:04:05+00:00",
"updated_on": "2024-01-03T00:00:00+00:00",
"author": {
"display_name": "Ada Lovelace",
"nickname": "ada",
"uuid": "{abc}"
},
"merge_commit": {"hash": "deadbeefcafe"}
}],
"next": "https://api.bitbucket.org/2.0/repositories/w/r/pullrequests?page=2"
}"#;
let page: BbPaged<BbPullRequest> = serde_json::from_str(json).expect("parses");
assert_eq!(page.values.len(), 1);
assert!(page.next.is_some());
let mapped = map_pr(page.values.into_iter().next().unwrap(), "w/r");
assert_eq!(mapped.pr_number, 42);
assert_eq!(mapped.repository, "w/r");
assert_eq!(mapped.state, PrState::Merged);
assert_eq!(mapped.author, "ada");
assert!(mapped.commit_shas.contains("deadbeefcafe"));
assert!(mapped.merged_at.is_some());
}
#[test]
fn bb_declined_pr_maps_to_closed_with_empty_shas() {
let json = r#"{
"id": 7,
"title": "abandoned",
"state": "DECLINED",
"created_on": "2024-05-01T12:00:00Z",
"author": {"display_name": "Bob"}
}"#;
let pr: BbPullRequest = serde_json::from_str(json).expect("parses");
let mapped = map_pr(pr, "w/r");
assert_eq!(mapped.pr_number, 7);
assert_eq!(mapped.state, PrState::Closed);
assert!(mapped.merged_at.is_none());
assert_eq!(mapped.commit_shas, "[]");
assert_eq!(mapped.author, "Bob");
}
#[test]
fn bb_superseded_pr_maps_to_closed() {
let json = r#"{
"id": 8,
"title": "old version",
"state": "SUPERSEDED",
"created_on": "2024-05-01T12:00:00Z"
}"#;
let pr: BbPullRequest = serde_json::from_str(json).expect("parses");
let mapped = map_pr(pr, "w/r");
assert_eq!(mapped.state, PrState::Closed);
assert_eq!(mapped.author, "");
}
#[test]
fn bb_author_best_name_priority() {
use crate::collect::bitbucket::types::BbAuthor;
let a = BbAuthor {
display_name: Some("Ada Lovelace".into()),
nickname: Some("ada".into()),
uuid: Some("{abc}".into()),
};
assert_eq!(a.best_name(), "ada");
let a = BbAuthor {
display_name: Some("Ada Lovelace".into()),
nickname: None,
uuid: Some("{abc}".into()),
};
assert_eq!(a.best_name(), "Ada Lovelace");
let a = BbAuthor {
display_name: None,
nickname: Some(" ".into()),
uuid: Some("{abc}".into()),
};
assert_eq!(a.best_name(), "{abc}");
let a = BbAuthor {
display_name: None,
nickname: None,
uuid: None,
};
assert_eq!(a.best_name(), "");
}
#[tokio::test]
async fn fetch_pull_requests_follows_next_cursor() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let base = server.uri();
let page2_url = format!("{base}/repositories/acme/widgets/pullrequests?page=2");
let page1 = serde_json::json!({
"values": [{
"id": 1,
"title": "first",
"state": "OPEN",
"created_on": "2024-01-01T00:00:00Z"
}],
"next": page2_url,
});
let page2 = serde_json::json!({
"values": [{
"id": 2,
"title": "second",
"state": "MERGED",
"created_on": "2024-02-01T00:00:00Z",
"updated_on": "2024-02-02T00:00:00Z",
"merge_commit": {"hash": "abc123"}
}]
});
Mock::given(method("GET"))
.and(path("/repositories/acme/widgets/pullrequests"))
.respond_with(ResponseTemplate::new(200).set_body_json(page1.clone()))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/repositories/acme/widgets/pullrequests"))
.respond_with(ResponseTemplate::new(200).set_body_json(page2.clone()))
.mount(&server)
.await;
let client = BitbucketClient::new(&BitbucketConfig {
token: Some("dummy".into()),
workspace: Some("acme".into()),
repo_slug: Some("widgets".into()),
fetch_prs: true,
api_base_url: Some(base),
..Default::default()
})
.expect("client builds");
let prs = client.fetch_pull_requests().await.expect("fetch");
assert_eq!(prs.len(), 2);
assert_eq!(prs[0].pr_number, 1);
assert_eq!(prs[0].state, PrState::Open);
assert_eq!(prs[1].pr_number, 2);
assert_eq!(prs[1].state, PrState::Merged);
assert!(prs[1].commit_shas.contains("abc123"));
}
pub(super) struct EnvVarGuard {
pub(super) name: &'static str,
pub(super) original: Option<String>,
}
impl EnvVarGuard {
pub(super) fn remove(name: &'static str) -> Self {
let original = std::env::var(name).ok();
unsafe { std::env::remove_var(name) };
Self { name, original }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
unsafe {
match self.original.as_deref() {
Some(v) => std::env::set_var(self.name, v),
None => std::env::remove_var(self.name),
}
}
}
}
#[test]
fn client_new_rejects_missing_auth() {
let _t = EnvVarGuard::remove("BITBUCKET_TOKEN");
let _p = EnvVarGuard::remove("BITBUCKET_APP_PASSWORD");
let result = BitbucketClient::new(&BitbucketConfig {
workspace: Some("acme".into()),
repo_slug: Some("widgets".into()),
fetch_prs: true,
..Default::default()
});
match result {
Ok(_) => panic!("expected auth failure, got Ok(_)"),
Err(CollectError::Config(_)) => {}
Err(other) => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn app_password_env_var_expanded() {
let _t = EnvVarGuard::remove("BITBUCKET_TOKEN");
let _fallback = EnvVarGuard::remove("BITBUCKET_APP_PASSWORD");
unsafe { std::env::set_var("TGA_TEST_BB_APP_PW_842", "s3cr3t-expanded") };
let _guard = EnvVarGuard {
name: "TGA_TEST_BB_APP_PW_842",
original: None,
};
let client = BitbucketClient::new(&BitbucketConfig {
username: Some("myuser".into()),
app_password: Some("${TGA_TEST_BB_APP_PW_842}".into()),
workspace: Some("acme".into()),
repo_slug: Some("widgets".into()),
fetch_prs: true,
..Default::default()
})
.expect("client builds with expanded app_password");
match &client.auth {
BbAuth::Basic { username, password } => {
assert_eq!(username, "myuser");
assert_eq!(
password, "s3cr3t-expanded",
"app_password placeholder must be expanded from env; \
got literal placeholder instead"
);
}
BbAuth::Bearer(_) => panic!("expected Basic auth, got Bearer"),
}
}
#[test]
fn app_password_config_takes_precedence_over_env_fallback() {
let _t = EnvVarGuard::remove("BITBUCKET_TOKEN");
unsafe { std::env::set_var("BITBUCKET_APP_PASSWORD", "env-fallback-value") };
let _fallback = EnvVarGuard {
name: "BITBUCKET_APP_PASSWORD",
original: None,
};
let client = BitbucketClient::new(&BitbucketConfig {
username: Some("bob".into()),
app_password: Some("config-literal-pw".into()),
workspace: Some("acme".into()),
repo_slug: Some("widgets".into()),
fetch_prs: true,
..Default::default()
})
.expect("client builds");
match &client.auth {
BbAuth::Basic { password, .. } => {
assert_eq!(
password, "config-literal-pw",
"config app_password must win over BITBUCKET_APP_PASSWORD env fallback"
);
}
BbAuth::Bearer(_) => panic!("expected Basic auth, got Bearer"),
}
}
#[test]
fn app_password_falls_back_to_env_when_config_absent() {
let _t = EnvVarGuard::remove("BITBUCKET_TOKEN");
unsafe { std::env::set_var("BITBUCKET_APP_PASSWORD", "env-only-pw") };
let _fallback = EnvVarGuard {
name: "BITBUCKET_APP_PASSWORD",
original: None,
};
let client = BitbucketClient::new(&BitbucketConfig {
username: Some("carol".into()),
app_password: None, workspace: Some("acme".into()),
repo_slug: Some("widgets".into()),
fetch_prs: true,
..Default::default()
})
.expect("client builds from env fallback");
match &client.auth {
BbAuth::Basic { password, .. } => {
assert_eq!(password, "env-only-pw");
}
BbAuth::Bearer(_) => panic!("expected Basic auth, got Bearer"),
}
}