#![expect(clippy::unwrap_used)]
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
use crate::client::test_helpers::{test_client, test_client_hybrid};
#[tokio::test]
async fn get_bug_history_returns_entries() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug/42/history"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": [{
"id": 42,
"alias": [],
"history": [
{
"who": "alice@example.com",
"when": "2025-01-15T10:30:00Z",
"changes": [
{
"field_name": "status",
"removed": "NEW",
"added": "ASSIGNED"
},
{
"field_name": "assigned_to",
"removed": "",
"added": "alice@example.com"
}
]
},
{
"who": "bob@example.com",
"when": "2025-01-16T14:00:00Z",
"changes": [
{
"field_name": "status",
"removed": "ASSIGNED",
"added": "RESOLVED"
}
]
}
]
}]
})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let history = client.get_bug_history_since(42, None).await.unwrap();
assert_eq!(history.len(), 2);
assert_eq!(history[0].who, "alice@example.com");
assert_eq!(history[0].changes.len(), 2);
assert_eq!(history[0].changes[0].field_name, "status");
assert_eq!(history[0].changes[0].removed, "NEW");
assert_eq!(history[0].changes[0].added, "ASSIGNED");
assert_eq!(history[1].changes.len(), 1);
}
#[tokio::test]
async fn get_bug_history_empty() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug/99/history"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": [{"id": 99, "alias": [], "history": []}]
})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let history = client.get_bug_history_since(99, None).await.unwrap();
assert!(history.is_empty());
}
#[tokio::test]
async fn get_bug_history_with_attachment_id() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug/10/history"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": [{
"id": 10,
"alias": [],
"history": [{
"who": "carol@example.com",
"when": "2025-02-01T09:00:00Z",
"changes": [{
"field_name": "attachments.isobsolete",
"removed": "0",
"added": "1",
"attachment_id": 555
}]
}]
}]
})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let history = client.get_bug_history_since(10, None).await.unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].changes[0].attachment_id, Some(555));
}
#[tokio::test]
async fn get_bug_history_since_filters_by_date() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug/42/history"))
.and(query_param("new_since", "2025-06-01T00:00:00Z"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": [{
"id": 42,
"alias": [],
"history": [{
"who": "alice@example.com",
"when": "2025-06-15T10:00:00Z",
"changes": [{"field_name": "status", "removed": "NEW", "added": "ASSIGNED"}]
}]
}]
})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let history = client
.get_bug_history_since(42, Some("2025-06-01T00:00:00Z"))
.await
.unwrap();
assert_eq!(history.len(), 1);
}
#[tokio::test]
async fn get_bug_passes_params() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug/1"))
.and(query_param("include_fields", "id,summary"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({"bugs": [{"id": 1, "summary": "test", "status": "NEW"}]}),
))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let bug = client.get_bug("1", Some("id,summary"), None).await.unwrap();
assert_eq!(bug.id, 1);
}
#[tokio::test]
async fn get_bug_default_fields_include_bug_json_fields() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug/1"))
.and(query_param(
"include_fields",
"id,summary,status,resolution,dupe_of,product,component,version,assigned_to,priority,severity,creation_time,last_change_time,creator,url,whiteboard,keywords,blocks,depends_on,cc,op_sys,rep_platform,deadline",
))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({"bugs": [{"id": 1, "summary": "test", "status": "NEW"}]}),
))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let bug = client.get_bug("1", None, None).await.unwrap();
assert_eq!(bug.id, 1);
}
#[tokio::test]
async fn get_bug_falls_back_on_100500() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug/99"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"error": true,
"code": BUGZILLA_INTERNAL_ERROR,
"message": "Extension crash"
})))
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "99"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({"bugs": [{"id": 99, "summary": "fallback bug", "status": "NEW"}]}),
))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let bug = client.get_bug("99", None, None).await.unwrap();
assert_eq!(bug.id, 99);
assert_eq!(bug.summary, "fallback bug");
}
#[tokio::test]
async fn search_bugs_sends_option_fields() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("cc", "user@example.com"))
.and(query_param("alias", "my-alias"))
.and(query_param("summary", "crash"))
.and(query_param("limit", "25"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": []
})))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
cc: Some("user@example.com".into()),
alias: Some("my-alias".into()),
summary: Some("crash".into()),
limit: Some(25),
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert!(bugs.is_empty());
}
#[tokio::test]
async fn search_bugs_sends_product_filter() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("product", "Product"))
.and(query_param("limit", "50"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": [{
"id": 217_630,
"summary": "Test bug",
"status": "WORKING",
"product": "Product",
"component": "Triage",
"assigned_to": "test@example.com",
"priority": "P1",
"severity": "high",
"creation_time": "2026-03-09T09:33:08Z",
"last_change_time": "2026-03-18T05:49:05Z"
}]
})))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
product: vec!["Product".into()],
limit: Some(50),
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert_eq!(bugs.len(), 1);
assert_eq!(bugs[0].id, 217_630);
}
use crate::test_helpers::xmlrpc_bug_response;
#[tokio::test]
async fn hybrid_search_rest_has_results_no_xmlrpc_call() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": [{"id": 1, "summary": "REST bug", "status": "NEW"}]
})))
.expect(1)
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/xmlrpc.cgi"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&mock)
.await;
let client = test_client_hybrid(&mock.uri());
let params = SearchParams {
product: vec!["P".into()],
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert_eq!(bugs.len(), 1);
assert_eq!(bugs[0].summary, "REST bug");
}
#[tokio::test]
async fn hybrid_search_rest_empty_with_filters_falls_back_to_xmlrpc() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
.expect(1)
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/xmlrpc.cgi"))
.respond_with(
ResponseTemplate::new(200).set_body_string(xmlrpc_bug_response(99, "XML-RPC bug")),
)
.expect(1)
.mount(&mock)
.await;
let client = test_client_hybrid(&mock.uri());
let params = SearchParams {
product: vec!["P".into()],
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert_eq!(bugs.len(), 1);
assert_eq!(bugs[0].id, 99);
assert_eq!(bugs[0].summary, "XML-RPC bug");
}
#[tokio::test]
async fn hybrid_search_rest_empty_with_quicksearch_only_no_fallback() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("quicksearch", "anything"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
.expect(1)
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/xmlrpc.cgi"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&mock)
.await;
let client = test_client_hybrid(&mock.uri());
let params = SearchParams {
quicksearch: Some("anything".into()),
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert!(bugs.is_empty());
}
#[tokio::test]
async fn hybrid_search_rest_empty_with_summary_only_no_fallback() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("summary", "kernel panic"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
.expect(1)
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/xmlrpc.cgi"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&mock)
.await;
let client = test_client_hybrid(&mock.uri());
let params = SearchParams {
summary: Some("kernel panic".into()),
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert!(bugs.is_empty());
}
#[tokio::test]
async fn hybrid_search_xmlrpc_fallback_timeout_returns_empty_rest_result() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
.expect(1)
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/xmlrpc.cgi"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(xmlrpc_bug_response(42, "would-be-result"))
.set_delay(std::time::Duration::from_secs(10)),
)
.mount(&mock)
.await;
let client = test_client_hybrid(&mock.uri());
let params = SearchParams {
product: vec!["P".into()],
..Default::default()
};
let start = std::time::Instant::now();
let bugs = client
.search_bugs_hybrid(¶ms, std::time::Duration::from_millis(200))
.await
.unwrap();
let elapsed = start.elapsed();
assert!(bugs.is_empty(), "expected empty REST fallback");
assert!(
elapsed < std::time::Duration::from_secs(2),
"fallback cap did not fire in time: elapsed={elapsed:?}"
);
}
#[tokio::test]
async fn hybrid_search_rest_empty_without_filters_no_fallback() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
.expect(1)
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/xmlrpc.cgi"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&mock)
.await;
let client = test_client_hybrid(&mock.uri());
let params = SearchParams::default();
let bugs = client.search_bugs(¶ms).await.unwrap();
assert!(bugs.is_empty());
}
#[tokio::test]
async fn hybrid_get_bug_rest_500_falls_back_to_xmlrpc() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug/42"))
.respond_with(ResponseTemplate::new(500).set_body_string("error"))
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/xmlrpc.cgi"))
.respond_with(
ResponseTemplate::new(200).set_body_string(xmlrpc_bug_response(42, "XML-RPC result")),
)
.expect(1)
.mount(&mock)
.await;
let client = test_client_hybrid(&mock.uri());
let bug = client.get_bug("42", None, None).await.unwrap();
assert_eq!(bug.id, 42);
assert_eq!(bug.summary, "XML-RPC result");
}
#[tokio::test]
async fn hybrid_get_bug_rest_401_does_not_fall_back() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug/42"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"error": true,
"code": 102,
"message": "Invalid API key"
})))
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/xmlrpc.cgi"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&mock)
.await;
let client = test_client_hybrid(&mock.uri());
let err = client.get_bug("42", None, None).await.unwrap_err();
assert!(
err.to_string().contains("Invalid API key"),
"should propagate auth error, got: {err}"
);
}
#[test]
fn has_negated_filters_detects_negation() {
let params = SearchParams {
status: vec!["!CLOSED".into()],
..Default::default()
};
assert!(super::has_negated_filters(¶ms));
}
#[test]
fn has_negated_filters_false_for_positive_only() {
let params = SearchParams {
status: vec!["NEW".into()],
..Default::default()
};
assert!(!super::has_negated_filters(¶ms));
}
#[test]
fn has_raw_boolean_chart_params_detects_f1() {
let params = SearchParams {
raw_params: vec![
("f1".into(), "qa_contact".into()),
("o1".into(), "equals".into()),
("v1".into(), "user@example.com".into()),
],
..Default::default()
};
assert!(super::has_raw_boolean_chart_params(¶ms));
}
#[test]
fn has_raw_boolean_chart_params_false_for_non_chart() {
let params = SearchParams {
raw_params: vec![("product".into(), "Firefox".into())],
..Default::default()
};
assert!(!super::has_raw_boolean_chart_params(¶ms));
}
#[test]
fn has_raw_boolean_chart_params_false_for_empty() {
let params = SearchParams::default();
assert!(!super::has_raw_boolean_chart_params(¶ms));
}
#[tokio::test]
async fn search_bugs_multi_value_sends_repeated_params() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("status", "NEW"))
.and(query_param("status", "ASSIGNED"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": [{"id": 1, "summary": "Bug 1", "status": "NEW"}]
})))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
status: vec!["NEW".into(), "ASSIGNED".into()],
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert_eq!(bugs.len(), 1);
}
#[tokio::test]
async fn search_bugs_negation_sends_boolean_chart() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("f1", "bug_status"))
.and(query_param("o1", "notequals"))
.and(query_param("v1", "CLOSED"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": [{"id": 2, "summary": "Open bug", "status": "NEW"}]
})))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
status: vec!["!CLOSED".into()],
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert_eq!(bugs.len(), 1);
}
#[tokio::test]
async fn search_bugs_mixed_positive_and_negated() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("status", "NEW"))
.and(query_param("f1", "bug_severity"))
.and(query_param("o1", "notequals"))
.and(query_param("v1", "enhancement"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": [{"id": 3, "summary": "Real bug", "status": "NEW"}]
})))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
status: vec!["NEW".into()],
severity: vec!["!enhancement".into()],
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert_eq!(bugs.len(), 1);
}
#[tokio::test]
async fn search_bugs_negations_in_two_fields_use_distinct_indices() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("f1", "bug_status"))
.and(query_param("o1", "notequals"))
.and(query_param("v1", "CLOSED"))
.and(query_param("f2", "bug_severity"))
.and(query_param("o2", "notequals"))
.and(query_param("v2", "enhancement"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
status: vec!["!CLOSED".into()],
severity: vec!["!enhancement".into()],
..Default::default()
};
client.search_bugs(¶ms).await.unwrap();
}
#[test]
fn has_raw_boolean_chart_params_false_for_non_chart_letter_with_digit() {
let params = SearchParams {
raw_params: vec![("a1".into(), "value".into())],
..Default::default()
};
assert!(!super::has_raw_boolean_chart_params(¶ms));
}
#[tokio::test]
async fn hybrid_search_bugs_with_raw_params_does_not_xmlrpc_fallback() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/xmlrpc.cgi"))
.respond_with(
ResponseTemplate::new(200).set_body_string(xmlrpc_bug_response(99, "xmlrpc-only")),
)
.mount(&mock)
.await;
let client = test_client_hybrid(&mock.uri());
let params = SearchParams {
raw_params: vec![
("f1".into(), "qa_contact".into()),
("o1".into(), "equals".into()),
("v1".into(), "user@example.com".into()),
],
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert!(
bugs.is_empty(),
"expected empty REST result, not XML-RPC fallback; got {bugs:?}"
);
}
#[tokio::test]
async fn hybrid_get_bug_falls_back_on_residual_100500_error() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"error": true,
"code": BUGZILLA_INTERNAL_ERROR,
"message": "Extension crash"
})))
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"error": true,
"code": BUGZILLA_INTERNAL_ERROR,
"message": "Extension crash"
})))
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/xmlrpc.cgi"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(xmlrpc_bug_response(42, "recovered via xmlrpc")),
)
.expect(1)
.mount(&mock)
.await;
let client = test_client_hybrid(&mock.uri());
let bug = client.get_bug("42", None, None).await.unwrap();
assert_eq!(bug.id, 42);
assert_eq!(bug.summary, "recovered via xmlrpc");
}
#[tokio::test]
async fn search_bugs_rejects_negated_plus_raw_boolean_chart() {
let mock = MockServer::start().await;
let client = test_client(&mock.uri());
let params = SearchParams {
status: vec!["!CLOSED".into()],
raw_params: vec![
("f1".into(), "qa_contact".into()),
("o1".into(), "equals".into()),
("v1".into(), "user@example.com".into()),
],
..Default::default()
};
let result = client.search_bugs(¶ms).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("cannot combine negated filters"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn search_bugs_all_fields_reach_server() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("product", "Firefox"))
.and(query_param("component", "General"))
.and(query_param("status", "NEW"))
.and(query_param("assigned_to", "dev@test.com"))
.and(query_param("creator", "reporter@test.com"))
.and(query_param("priority", "P1"))
.and(query_param("severity", "major"))
.and(query_param("cc", "watcher@test.com"))
.and(query_param("alias", "my-bug"))
.and(query_param("id", "42"))
.and(query_param("limit", "10"))
.and(query_param("summary", "crash"))
.and(query_param("quicksearch", "qs-term"))
.and(query_param("include_fields", "id,summary"))
.and(query_param("exclude_fields", "cc"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": [{"id": 42, "summary": "crash", "status": "NEW"}]
})))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
product: vec!["Firefox".into()],
component: vec!["General".into()],
status: vec!["NEW".into()],
assigned_to: vec!["dev@test.com".into()],
creator: vec!["reporter@test.com".into()],
priority: vec!["P1".into()],
severity: vec!["major".into()],
cc: Some("watcher@test.com".into()),
alias: Some("my-bug".into()),
id: vec![42],
limit: Some(10),
summary: Some("crash".into()),
quicksearch: Some("qs-term".into()),
include_fields: Some("id,summary".into()),
exclude_fields: Some("cc".into()),
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert_eq!(bugs.len(), 1);
}
#[tokio::test]
async fn search_bugs_sends_creation_time_query_param() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("creation_time", "2026-04-01T00:00:00Z"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
creation_time: Some("2026-04-01T00:00:00Z".into()),
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert!(bugs.is_empty());
}
#[tokio::test]
async fn search_bugs_sends_last_change_time_query_param() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("last_change_time", "2026-04-15T00:00:00Z"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
last_change_time: Some("2026-04-15T00:00:00Z".into()),
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert!(bugs.is_empty());
}
#[tokio::test]
async fn search_bugs_sends_whiteboard_query_param() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("whiteboard", "needs-review"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
whiteboard: vec!["needs-review".into()],
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert!(bugs.is_empty());
}
#[tokio::test]
async fn search_bugs_sends_target_milestone_query_param() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("target_milestone", "5.0"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
target_milestone: vec!["5.0".into()],
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert!(bugs.is_empty());
}
#[tokio::test]
async fn search_bugs_sends_version_query_param() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("version", "9.4"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
version: vec!["9.4".into()],
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert!(bugs.is_empty());
}
#[tokio::test]
async fn search_bugs_sends_op_sys_query_param() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("op_sys", "Linux"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
op_sys: vec!["Linux".into()],
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert!(bugs.is_empty());
}
#[tokio::test]
async fn search_bugs_sends_platform_query_param() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("platform", "x86_64"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
platform: vec!["x86_64".into()],
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert!(bugs.is_empty());
}
#[tokio::test]
async fn search_bugs_sends_resolution_query_param() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("resolution", "FIXED"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
resolution: vec!["FIXED".into()],
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert!(bugs.is_empty());
}
#[tokio::test]
async fn search_bugs_sends_qa_contact_query_param() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("qa_contact", "qa@example.com"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
qa_contact: vec!["qa@example.com".into()],
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert!(bugs.is_empty());
}
#[tokio::test]
async fn search_bugs_sends_url_query_param() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("url", "github.com/foo"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
url: vec!["github.com/foo".into()],
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert!(bugs.is_empty());
}
#[tokio::test]
async fn search_bugs_negation_resolution_uses_notequals() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("f1", "resolution"))
.and(query_param("o1", "notequals"))
.and(query_param("v1", "FIXED"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
resolution: vec!["!FIXED".into()],
..Default::default()
};
client.search_bugs(¶ms).await.unwrap();
}
#[tokio::test]
async fn search_bugs_negation_whiteboard_uses_notsubstring() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("f1", "status_whiteboard"))
.and(query_param("o1", "notsubstring"))
.and(query_param("v1", "wip"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
whiteboard: vec!["!wip".into()],
..Default::default()
};
client.search_bugs(¶ms).await.unwrap();
}
#[tokio::test]
async fn search_bugs_negation_url_uses_notsubstring() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("f1", "bug_file_loc"))
.and(query_param("o1", "notsubstring"))
.and(query_param("v1", "github.com"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
url: vec!["!github.com".into()],
..Default::default()
};
client.search_bugs(¶ms).await.unwrap();
}
#[test]
fn force_id_fields_prepends_id_to_idless_include() {
let (inc, exc) = force_id_fields(Some("summary,status"), None);
assert_eq!(inc.as_deref(), Some("id,summary,status"));
assert_eq!(exc, None);
}
#[test]
fn force_id_fields_leaves_include_with_id_unchanged() {
let (inc, _) = force_id_fields(Some("summary,id,status"), None);
assert_eq!(inc.as_deref(), Some("summary,id,status"));
}
#[test]
fn force_id_fields_matches_id_case_insensitively() {
let (inc, _) = force_id_fields(Some("summary,ID"), None);
assert_eq!(inc.as_deref(), Some("summary,ID"));
}
#[test]
fn force_id_fields_trims_tokens_when_detecting_id() {
let (inc, _) = force_id_fields(Some(" id , summary "), None);
assert_eq!(inc.as_deref(), Some(" id , summary "));
}
#[test]
fn force_id_fields_include_none_stays_none() {
let (inc, _) = force_id_fields(None, None);
assert_eq!(inc, None);
}
#[test]
fn force_id_fields_strips_id_from_exclude() {
let (_, exc) = force_id_fields(None, Some("id,status"));
assert_eq!(exc.as_deref(), Some("status"));
}
#[test]
fn force_id_fields_exclude_only_id_becomes_none() {
let (_, exc) = force_id_fields(None, Some("id"));
assert_eq!(exc, None);
}
#[test]
fn force_id_fields_exclude_without_id_unchanged() {
let (_, exc) = force_id_fields(None, Some("status,priority"));
assert_eq!(exc.as_deref(), Some("status,priority"));
}
#[tokio::test]
async fn search_bugs_prepends_id_to_idless_include_fields() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("include_fields", "id,summary,status"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": [{"id": 7, "summary": "s", "status": "NEW"}]
})))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
include_fields: Some("summary,status".into()),
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert_eq!(bugs.len(), 1);
}
#[tokio::test]
async fn search_bugs_strips_id_from_exclude_fields() {
use wiremock::matchers::query_param_is_missing;
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param_is_missing("exclude_fields"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
exclude_fields: Some("id".into()),
..Default::default()
};
client.search_bugs(¶ms).await.unwrap();
}
#[tokio::test]
async fn get_bug_prepends_id_to_idless_include_fields() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug/1"))
.and(query_param("include_fields", "id,summary,status"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({"bugs": [{"id": 1, "summary": "test", "status": "NEW"}]}),
))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let bug = client
.get_bug("1", Some("summary,status"), None)
.await
.unwrap();
assert_eq!(bug.id, 1);
}
#[tokio::test]
async fn get_bug_via_search_fallback_carries_id() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug/1"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"error": true,
"code": BUGZILLA_INTERNAL_ERROR,
"message": "Extension crash"
})))
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "1"))
.and(query_param("include_fields", "id,summary,status"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({"bugs": [{"id": 1, "summary": "test", "status": "NEW"}]}),
))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let bug = client
.get_bug("1", Some("summary,status"), None)
.await
.unwrap();
assert_eq!(bug.id, 1);
}
#[tokio::test]
async fn xmlrpc_search_idless_include_fields_carries_id() {
use crate::client::test_helpers::test_client_xmlrpc;
use wiremock::matchers::body_string_contains;
let mock = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/xmlrpc.cgi"))
.and(body_string_contains("<name>include_fields</name>"))
.and(body_string_contains("<string>id</string>"))
.and(body_string_contains("<string>summary</string>"))
.and(body_string_contains("<string>status</string>"))
.respond_with(ResponseTemplate::new(200).set_body_string(xmlrpc_bug_response(5, "x")))
.expect(1)
.mount(&mock)
.await;
let client = test_client_xmlrpc(&mock.uri());
let params = SearchParams {
include_fields: Some("summary,status".into()),
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert_eq!(bugs.len(), 1);
}
#[tokio::test]
async fn update_bug_surfaces_200_error_envelope() {
let mock = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/rest/bug/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"error": true,
"code": 115,
"message": "You are not allowed to edit this bug"
})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = crate::types::UpdateBugParams::default();
let err = client.update_bug(42, ¶ms).await.unwrap_err();
assert!(
matches!(err, crate::error::BzrError::Api { code: 115, .. }),
"expected Api error code 115, got {err:?}"
);
}
#[tokio::test]
async fn update_bug_accepts_success_envelope() {
let mock = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/rest/bug/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": [{"id": 42, "alias": [], "changes": {}}]
})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = crate::types::UpdateBugParams::default();
client.update_bug(42, ¶ms).await.unwrap();
}