use faucet_core::FaucetError;
use faucet_source_xml::{XmlAuth, XmlPagination, XmlStream, XmlStreamConfig};
use reqwest::Method;
use std::collections::HashMap;
use wiremock::matchers::{body_string_contains, header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn items_doc(start: usize, n: usize) -> String {
let mut s = String::from("<root>");
for i in start..start + n {
s.push_str(&format!("<item><id>{i}</id></item>"));
}
s.push_str("</root>");
s
}
#[tokio::test]
async fn basic_auth_header_is_sent() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/feed.xml"))
.and(header("authorization", "Basic dXNlcjpwYXNz"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "application/xml")
.set_body_string(items_doc(0, 2)),
)
.expect(1)
.mount(&server)
.await;
let config = XmlStreamConfig::new(server.uri(), "/feed.xml")
.records_element_path("root.item")
.auth(XmlAuth::Basic {
username: "user".into(),
password: "pass".into(),
});
let records = XmlStream::new(config).fetch_all().await.unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0]["id"], "0");
}
#[tokio::test]
async fn custom_auth_headers_are_sent() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/soap"))
.and(header("soapaction", "urn:GetUsers"))
.and(header("x-api-key", "secret-key"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "text/xml")
.set_body_string(items_doc(0, 1)),
)
.expect(1)
.mount(&server)
.await;
let mut headers = HashMap::new();
headers.insert("SOAPAction".to_string(), "urn:GetUsers".to_string());
headers.insert("X-API-Key".to_string(), "secret-key".to_string());
let config = XmlStreamConfig::new(server.uri(), "/soap")
.method(Method::POST)
.records_element_path("root.item")
.auth(XmlAuth::Custom { headers });
let records = XmlStream::new(config).fetch_all().await.unwrap();
assert_eq!(records.len(), 1);
}
#[tokio::test]
async fn custom_auth_invalid_header_name_errors() {
let server = MockServer::start().await;
let mut headers = HashMap::new();
headers.insert("Invalid Header Name".to_string(), "v".to_string());
let config = XmlStreamConfig::new(server.uri(), "/feed.xml")
.records_element_path("root.item")
.auth(XmlAuth::Custom { headers });
let err = XmlStream::new(config).fetch_all().await.unwrap_err();
assert!(matches!(err, FaucetError::Auth(_)), "got {err:?}");
}
#[tokio::test]
async fn soap_post_body_is_sent_and_response_extracted() {
let server = MockServer::start().await;
let soap_response = r#"<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<GetUsersResponse>
<User><Name>Alice</Name></User>
<User><Name>Bob</Name></User>
</GetUsersResponse>
</soap:Body>
</soap:Envelope>"#;
Mock::given(method("POST"))
.and(path("/soap"))
.and(body_string_contains("GetUsers"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "text/xml")
.set_body_string(soap_response),
)
.expect(1)
.mount(&server)
.await;
let config = XmlStreamConfig::new(server.uri(), "/soap")
.method(Method::POST)
.body("<soap:Envelope><soap:Body><GetUsers/></soap:Body></soap:Envelope>")
.records_element_path("soap:Envelope.soap:Body.GetUsersResponse.User");
let records = XmlStream::new(config).fetch_all().await.unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0]["Name"], "Alice");
assert_eq!(records[1]["Name"], "Bob");
}
#[tokio::test]
async fn query_params_are_sent() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/feed.xml"))
.and(query_param("format", "xml"))
.and(query_param("v", "2"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "application/xml")
.set_body_string(items_doc(0, 1)),
)
.expect(1)
.mount(&server)
.await;
let config = XmlStreamConfig::new(server.uri(), "/feed.xml")
.records_element_path("root.item")
.query_param("format", "xml")
.query_param("v", "2");
let records = XmlStream::new(config).fetch_all().await.unwrap();
assert_eq!(records.len(), 1);
}
#[tokio::test]
async fn page_number_pagination_walks_pages_until_empty() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/feed.xml"))
.and(query_param("page", "1"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "application/xml")
.set_body_string(items_doc(0, 2)),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/feed.xml"))
.and(query_param("page", "2"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "application/xml")
.set_body_string(items_doc(2, 2)),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/feed.xml"))
.and(query_param("page", "3"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "application/xml")
.set_body_string("<root></root>"),
)
.mount(&server)
.await;
let config = XmlStreamConfig::new(server.uri(), "/feed.xml")
.records_element_path("root.item")
.pagination(XmlPagination::PageNumber {
param_name: "page".into(),
start_page: 1,
page_size: None,
page_size_param: None,
});
let records = XmlStream::new(config).fetch_all().await.unwrap();
assert_eq!(records.len(), 4);
assert_eq!(records[0]["id"], "0");
assert_eq!(records[3]["id"], "3");
}
#[tokio::test]
async fn page_number_pagination_stops_on_short_page() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/feed.xml"))
.and(query_param("page", "1"))
.and(query_param("size", "3"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "application/xml")
.set_body_string(items_doc(0, 3)),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/feed.xml"))
.and(query_param("page", "2"))
.and(query_param("size", "3"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "application/xml")
.set_body_string(items_doc(3, 1)),
)
.mount(&server)
.await;
let config = XmlStreamConfig::new(server.uri(), "/feed.xml")
.records_element_path("root.item")
.pagination(XmlPagination::PageNumber {
param_name: "page".into(),
start_page: 1,
page_size: Some(3),
page_size_param: Some("size".into()),
});
let records = XmlStream::new(config).fetch_all().await.unwrap();
assert_eq!(records.len(), 4, "3 full + 1 short page, then stop");
}
#[tokio::test]
async fn offset_pagination_walks_until_short_page() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/feed.xml"))
.and(query_param("offset", "0"))
.and(query_param("limit", "2"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "application/xml")
.set_body_string(items_doc(0, 2)),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/feed.xml"))
.and(query_param("offset", "2"))
.and(query_param("limit", "2"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "application/xml")
.set_body_string(items_doc(2, 2)),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/feed.xml"))
.and(query_param("offset", "4"))
.and(query_param("limit", "2"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "application/xml")
.set_body_string(items_doc(4, 1)),
)
.mount(&server)
.await;
let config = XmlStreamConfig::new(server.uri(), "/feed.xml")
.records_element_path("root.item")
.pagination(XmlPagination::Offset {
offset_param: "offset".into(),
limit_param: "limit".into(),
limit: 2,
});
let records = XmlStream::new(config).fetch_all().await.unwrap();
assert_eq!(records.len(), 5);
assert_eq!(records[4]["id"], "4");
}
#[tokio::test]
async fn max_pages_caps_fetch() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/feed.xml"))
.and(query_param("page", "1"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "application/xml")
.set_body_string(items_doc(0, 2)),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/feed.xml"))
.and(query_param("page", "2"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "application/xml")
.set_body_string(items_doc(2, 2)),
)
.mount(&server)
.await;
let config = XmlStreamConfig::new(server.uri(), "/feed.xml")
.records_element_path("root.item")
.max_pages(2)
.pagination(XmlPagination::PageNumber {
param_name: "page".into(),
start_page: 1,
page_size: None,
page_size_param: None,
});
let records = XmlStream::new(config).fetch_all().await.unwrap();
assert_eq!(records.len(), 4, "max_pages=2 -> at most 4 records");
}
#[tokio::test]
async fn identical_page_loop_guard_stops_fetch() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/feed.xml"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "application/xml")
.set_body_string(items_doc(0, 2)),
)
.mount(&server)
.await;
let config = XmlStreamConfig::new(server.uri(), "/feed.xml")
.records_element_path("root.item")
.pagination(XmlPagination::PageNumber {
param_name: "page".into(),
start_page: 1,
page_size: None,
page_size_param: None,
});
let records = XmlStream::new(config).fetch_all().await.unwrap();
assert_eq!(records.len(), 2);
}
#[tokio::test]
async fn non_2xx_status_returns_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/feed.xml"))
.respond_with(ResponseTemplate::new(404).set_body_string("not found"))
.mount(&server)
.await;
let config = XmlStreamConfig::new(server.uri(), "/feed.xml").records_element_path("root.item");
let err = XmlStream::new(config).fetch_all().await.unwrap_err();
assert!(
matches!(err, FaucetError::HttpStatus { status, .. } if status == 404),
"got {err:?}"
);
}
#[tokio::test]
async fn malformed_xml_response_returns_transform_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/feed.xml"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "application/xml")
.set_body_string("<root><a></b></root>"),
)
.mount(&server)
.await;
let config = XmlStreamConfig::new(server.uri(), "/feed.xml").records_element_path("root.a");
let err = XmlStream::new(config).fetch_all().await.unwrap_err();
assert!(
matches!(&err, FaucetError::Transform(m) if m.contains("XML parse error")),
"got {err:?}"
);
}
#[tokio::test]
async fn no_records_path_returns_whole_document() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/feed.xml"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "application/xml")
.set_body_string("<root><name>Z</name></root>"),
)
.mount(&server)
.await;
let config = XmlStreamConfig::new(server.uri(), "/feed.xml");
let records = XmlStream::new(config).fetch_all().await.unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0]["root"]["name"], "Z");
}