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"));
}
#[tokio::test]
async fn fetch_pr_commits_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/9/commits?page=2");
let page1 = serde_json::json!({
"values": [{"hash": "1111111111111111111111111111111111111a"}],
"next": page2_url,
});
let page2 = serde_json::json!({
"values": [{"hash": "2222222222222222222222222222222222222b"}],
});
Mock::given(method("GET"))
.and(path("/repositories/acme/widgets/pullrequests/9/commits"))
.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/9/commits"))
.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 shas = client.fetch_pr_commits(9).await.expect("fetch");
assert_eq!(
shas,
vec![
"1111111111111111111111111111111111111a".to_string(),
"2222222222222222222222222222222222222b".to_string(),
]
);
}
#[tokio::test]
async fn fetch_pull_requests_persists_full_commit_list() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let base = server.uri();
let prs_page = serde_json::json!({
"values": [{
"id": 5,
"title": "multi-commit PR",
"state": "MERGED",
"created_on": "2024-01-01T00:00:00Z",
"updated_on": "2024-01-02T00:00:00Z",
"merge_commit": {"hash": "36c721d47ff0"}
}]
});
let commits_page = serde_json::json!({
"values": [
{"hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
{"hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},
]
});
Mock::given(method("GET"))
.and(path("/repositories/acme/widgets/pullrequests"))
.respond_with(ResponseTemplate::new(200).set_body_json(prs_page))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/repositories/acme/widgets/pullrequests/5/commits"))
.respond_with(ResponseTemplate::new(200).set_body_json(commits_page))
.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(), 1);
let shas: Vec<String> =
serde_json::from_str(&prs[0].commit_shas).expect("commit_shas is a JSON array");
assert_eq!(
shas,
vec![
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
]
);
assert!(!prs[0].commit_shas.contains("36c721d47ff0"));
}
#[tokio::test]
async fn fetch_pull_requests_falls_back_on_commit_fetch_error() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let base = server.uri();
let prs_page = serde_json::json!({
"values": [{
"id": 6,
"title": "commits endpoint unreachable",
"state": "MERGED",
"created_on": "2024-01-01T00:00:00Z",
"updated_on": "2024-01-02T00:00:00Z",
"merge_commit": {"hash": "deadbeefcafe"}
}]
});
Mock::given(method("GET"))
.and(path("/repositories/acme/widgets/pullrequests"))
.respond_with(ResponseTemplate::new(200).set_body_json(prs_page))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/repositories/acme/widgets/pullrequests/6/commits"))
.respond_with(ResponseTemplate::new(404))
.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(), 1);
assert!(prs[0].commit_shas.contains("deadbeefcafe"));
}
fn auth_config(
token: Option<&str>,
username: Option<&str>,
app_password: Option<&str>,
) -> BitbucketConfig {
BitbucketConfig {
token: token.map(Into::into),
username: username.map(Into::into),
app_password: app_password.map(Into::into),
workspace: Some("acme".into()),
repo_slug: Some("widgets".into()),
fetch_prs: true,
..Default::default()
}
}
fn env_map(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let owned: Vec<(String, String)> = pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
move |name: &str| {
owned
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.clone())
}
}
#[test]
fn resolve_auth_rejects_missing_auth() {
let cfg = auth_config(None, None, None);
match resolve_auth(&cfg, env_map(&[])) {
Ok(_) => panic!("expected auth failure, got Ok(_)"),
Err(CollectError::Config(_)) => {}
Err(other) => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn resolve_auth_rejects_username_without_password() {
let cfg = auth_config(None, Some("carol"), None);
match resolve_auth(&cfg, env_map(&[])) {
Err(CollectError::Config(_)) => {}
Ok(_) => panic!("expected Config error, got Ok(_)"),
Err(other) => panic!("expected Config error, got {other:?}"),
}
}
#[test]
fn resolve_auth_config_token_yields_bearer() {
let cfg = auth_config(Some("tok-123"), Some("u"), Some("p"));
match resolve_auth(&cfg, env_map(&[])).expect("resolves") {
BbAuth::Bearer(t) => assert_eq!(t, "tok-123"),
BbAuth::Basic { .. } => panic!("expected Bearer, got Basic"),
}
}
#[test]
fn resolve_auth_token_expands_and_falls_back() {
let cfg = auth_config(Some("${TGA_BB_TOKEN}"), None, None);
match resolve_auth(&cfg, env_map(&[("TGA_BB_TOKEN", "expanded-tok")])).expect("resolves") {
BbAuth::Bearer(t) => assert_eq!(t, "expanded-tok"),
BbAuth::Basic { .. } => panic!("expected Bearer"),
}
let cfg = auth_config(None, None, None);
match resolve_auth(&cfg, env_map(&[("BITBUCKET_TOKEN", "env-tok")])).expect("resolves") {
BbAuth::Bearer(t) => assert_eq!(t, "env-tok"),
BbAuth::Basic { .. } => panic!("expected Bearer from env fallback"),
}
}
#[test]
fn resolve_auth_app_password_env_var_expanded() {
let cfg = auth_config(None, Some("myuser"), Some("${TGA_BB_APP_PW}"));
match resolve_auth(&cfg, env_map(&[("TGA_BB_APP_PW", "s3cr3t-expanded")])).expect("resolves") {
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 resolve_auth_app_password_config_takes_precedence_over_env_fallback() {
let cfg = auth_config(None, Some("bob"), Some("config-literal-pw"));
match resolve_auth(
&cfg,
env_map(&[("BITBUCKET_APP_PASSWORD", "env-fallback-value")]),
)
.expect("resolves")
{
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 resolve_auth_app_password_falls_back_to_env_when_config_absent() {
let cfg = auth_config(None, Some("carol"), None);
match resolve_auth(&cfg, env_map(&[("BITBUCKET_APP_PASSWORD", "env-only-pw")]))
.expect("resolves")
{
BbAuth::Basic { password, .. } => {
assert_eq!(password, "env-only-pw");
}
BbAuth::Bearer(_) => panic!("expected Basic auth, got Bearer"),
}
}
fn sample_pr(repository: &str, pr_number: u64, state: PrState, fetched_at: &str) -> PullRequest {
PullRequest {
id: 0,
pr_number,
repository: repository.to_string(),
title: "T".to_string(),
author: "ada".to_string(),
state,
created_at: Utc::now(),
merged_at: None,
commit_shas: "[]".to_string(),
fetched_at: fetched_at.to_string(),
head_ref: None,
body_ticket_id: None,
}
}
fn store_test_client() -> BitbucketClient {
BitbucketClient::new(&BitbucketConfig {
token: Some("dummy".into()),
workspace: Some("acme".into()),
repo_slug: Some("widgets".into()),
..Default::default()
})
.expect("client builds")
}
#[test]
fn store_pull_requests_stale_write_guard_rejects_older_fetched_at() {
let db = Database::open_in_memory().expect("open db");
let client = store_test_client();
let fresh = sample_pr("acme/widgets", 42, PrState::Merged, "2026-01-02T00:00:00Z");
client
.store_pull_requests(&db, &[fresh])
.expect("initial upsert");
let stale = sample_pr("acme/widgets", 42, PrState::Open, "2026-01-01T00:00:00Z");
client
.store_pull_requests(&db, &[stale])
.expect("stale upsert must not error, just be rejected by the WHERE guard");
let state: String = db
.connection()
.query_row(
"SELECT state FROM pull_requests \
WHERE provider = 'bitbucket' AND repository = ?1 AND pr_number = 42",
params!["acme/widgets"],
|r| r.get(0),
)
.expect("read back state");
assert_eq!(
state, "merged",
"stale write with an older fetched_at must be rejected"
);
}
#[test]
fn store_pull_requests_applies_genuinely_newer_fetched_at() {
let db = Database::open_in_memory().expect("open db");
let client = store_test_client();
let first = sample_pr("acme/widgets", 77, PrState::Merged, "2026-01-01T00:00:00Z");
client
.store_pull_requests(&db, &[first])
.expect("initial upsert");
let newer = sample_pr("acme/widgets", 77, PrState::Open, "2026-01-02T00:00:00Z");
client
.store_pull_requests(&db, &[newer])
.expect("newer upsert");
let state: String = db
.connection()
.query_row(
"SELECT state FROM pull_requests \
WHERE provider = 'bitbucket' AND repository = ?1 AND pr_number = 77",
params!["acme/widgets"],
|r| r.get(0),
)
.expect("read back state");
assert_eq!(
state, "open",
"a genuinely newer fetched_at must overwrite the previous row"
);
}
#[tokio::test]
async fn fetch_pull_requests_covers_every_configured_repo() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let base = server.uri();
for (slug, id) in [("widgets", 1u64), ("gadgets", 2u64)] {
Mock::given(method("GET"))
.and(path(format!("/repositories/acme/{slug}/pullrequests")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [{
"id": id,
"title": format!("PR in {slug}"),
"state": "OPEN",
"created_on": "2024-01-01T00:00:00Z",
}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!(
"/repositories/acme/{slug}/pullrequests/{id}/commits"
)))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"values": []})),
)
.mount(&server)
.await;
}
let client = BitbucketClient::new_for_repos(
&BitbucketConfig {
token: Some("dummy".into()),
workspaces: vec!["acme".into()],
fetch_prs: true,
api_base_url: Some(base),
..Default::default()
},
vec![
("acme".to_string(), "widgets".to_string()),
("acme".to_string(), "gadgets".to_string()),
],
)
.expect("client builds");
let prs = client.fetch_pull_requests().await.expect("fetch");
let mut repositories: Vec<String> = prs.iter().map(|p| p.repository.clone()).collect();
repositories.sort();
assert_eq!(
repositories,
vec!["acme/gadgets".to_string(), "acme/widgets".to_string()]
);
}
#[tokio::test]
async fn one_failing_repo_does_not_discard_the_others() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let base = server.uri();
Mock::given(method("GET"))
.and(path("/repositories/acme/widgets/pullrequests"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [{
"id": 1,
"title": "readable",
"state": "OPEN",
"created_on": "2024-01-01T00:00:00Z",
}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/repositories/acme/widgets/pullrequests/1/commits"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"values": []})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/repositories/acme/locked/pullrequests"))
.respond_with(ResponseTemplate::new(403))
.mount(&server)
.await;
let client = BitbucketClient::new_for_repos(
&BitbucketConfig {
token: Some("dummy".into()),
fetch_prs: true,
api_base_url: Some(base),
..Default::default()
},
vec![
("acme".to_string(), "widgets".to_string()),
("acme".to_string(), "locked".to_string()),
],
)
.expect("client builds");
let prs = client.fetch_pull_requests().await.expect("fetch");
assert_eq!(prs.len(), 1, "the readable repository still contributes");
assert_eq!(prs[0].repository, "acme/widgets");
}
#[tokio::test]
async fn every_repo_failing_returns_the_error_not_an_empty_list() {
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let client = BitbucketClient::new_for_repos(
&BitbucketConfig {
token: Some("dummy".into()),
fetch_prs: true,
api_base_url: Some(server.uri()),
..Default::default()
},
vec![("acme".to_string(), "widgets".to_string())],
)
.expect("client builds");
let err = client
.fetch_pull_requests()
.await
.expect_err("a wholly failed fetch must not read as zero pull requests");
assert!(
err.to_string().contains("401"),
"the status must survive: {err}"
);
}
#[test]
fn new_for_repos_refuses_an_empty_repository_set() {
let built = BitbucketClient::new_for_repos(
&BitbucketConfig {
token: Some("dummy".into()),
..Default::default()
},
Vec::new(),
);
match built {
Err(CollectError::Config(msg)) => assert!(msg.contains("no repositories"), "{msg}"),
Err(other) => panic!("expected a Config error, got {other:?}"),
Ok(_) => panic!("an empty repository set must not build a client"),
}
}