#![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_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_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);
}