mod common;
use common::{fixture, form_field, MethodIs};
use futures::StreamExt;
use matomo::reqwest::MatomoClient;
use matomo::{
ActionDetail, ApiErrorKind, Auth, DateRange, Error, IdSite, Limit, Period, Segment, VisitorType,
};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, Respond, ResponseTemplate};
fn json_ok(body: String) -> ResponseTemplate {
ResponseTemplate::new(200)
.insert_header("content-type", "application/json")
.set_body_raw(body, "application/json")
}
async fn mount_method(server: &MockServer, m: &'static str, fixture_name: &'static str) {
Mock::given(method("POST"))
.and(path("/index.php"))
.and(MethodIs(m))
.respond_with(json_ok(fixture(fixture_name)))
.mount(server)
.await;
}
async fn client_for(server: &MockServer) -> MatomoClient {
MatomoClient::builder()
.base_url(server.uri())
.auth(Auth::token("secret-token"))
.skip_preflight()
.build()
.unwrap()
}
#[tokio::test]
async fn visits_summary_mixed_numerics() {
let server = MockServer::start().await;
mount_method(&server, "VisitsSummary.get", "visits-summary.json").await;
let client = client_for(&server).await;
let s = client
.visits_summary()
.get(1u32, Period::Day(matomo::Date::Today), None)
.await
.unwrap();
assert_eq!(s.nb_visits, 273);
assert_eq!(s.bounce_rate, "64%");
assert!((s.nb_actions_per_visit - 1.9).abs() < 1e-9);
}
#[tokio::test]
async fn live_visits_with_actions_and_goals() {
let server = MockServer::start().await;
mount_method(&server, "Live.getLastVisitsDetails", "live-details.json").await;
let client = client_for(&server).await;
let cursor = matomo::Cursor::new(
1,
Period::Day(matomo::Date::Today),
Limit::count(10).unwrap(),
None,
)
.unwrap();
let (visits, next) = client.live().next_page(&cursor).await.unwrap();
assert_eq!(visits.len(), 4);
assert!(next.is_some());
let new = &visits[0];
assert_eq!(new.visitor_type, Some(VisitorType::New));
assert!(new.goal_conversions.is_empty()); assert!(new.latitude.is_some());
let returning = &visits[1];
assert_eq!(returning.visitor_type, Some(VisitorType::Returning));
let dl = &visits[2];
let has_download = dl
.action_details
.iter()
.any(|d| matches!(d, ActionDetail::Download(_)));
assert!(has_download, "expected a download actionDetail");
let has_action = dl
.action_details
.iter()
.any(|d| matches!(d, ActionDetail::Action(_)));
assert!(has_action, "expected an action actionDetail");
}
#[tokio::test]
async fn referrers_both_spellings() {
let server = MockServer::start().await;
mount_method(&server, "Referrers.getReferrerType", "ref-type.json").await;
mount_method(&server, "Referrers.getAll", "ref-all.json").await;
let client = client_for(&server).await;
let types = client
.referrers()
.get_referrer_type(1u32, Period::Day(matomo::Date::Today), None)
.await
.unwrap();
assert_eq!(types[0].referrer_type, 1);
let all = client
.referrers()
.get_all(1u32, Period::Day(matomo::Date::Today), None)
.await
.unwrap();
assert_eq!(all[0].referer_type, 2); }
#[tokio::test]
async fn actions_trees_and_downloads() {
let server = MockServer::start().await;
mount_method(&server, "Actions.getPageUrls", "actions-pageurls.json").await;
mount_method(&server, "Actions.getDownloads", "actions-downloads.json").await;
let client = client_for(&server).await;
let pages = client
.actions()
.get_page_urls(1u32, Period::Day(matomo::Date::Today), None)
.await
.unwrap();
assert!(!pages.is_empty());
assert!(pages.iter().any(|p| p.url.is_some()));
let downloads = client
.actions()
.get_downloads(1u32, Period::Day(matomo::Date::Today), None)
.await
.unwrap();
assert_eq!(downloads[0].label, "www.ede-group.com");
}
#[tokio::test]
async fn error_envelope_maps_to_api_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/index.php"))
.and(MethodIs("VisitsSummary.get"))
.respond_with(json_ok(
r#"{"result":"error","message":"You can't access this resource as it requires a valid token_auth."}"#.to_string(),
))
.mount(&server)
.await;
let client = client_for(&server).await;
let err = client
.visits_summary()
.get(1u32, Period::Day(matomo::Date::Today), None)
.await
.unwrap_err();
match err {
Error::Api { kind, method, .. } => {
assert_eq!(method, "VisitsSummary.get");
assert_eq!(kind, ApiErrorKind::Auth);
}
other => panic!("expected Error::Api, got {other:?}"),
}
}
#[tokio::test]
async fn non_json_body_surfaces_raw() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/index.php"))
.respond_with(
ResponseTemplate::new(200).set_body_raw("<html>Fatal error</html>", "text/html"),
)
.mount(&server)
.await;
let client = client_for(&server).await;
let err = client
.visits_summary()
.get(1u32, Period::Day(matomo::Date::Today), None)
.await
.unwrap_err();
match err {
Error::NonJsonBody { method, body } => {
assert_eq!(method, "VisitsSummary.get");
assert!(body.contains("Fatal error"));
}
other => panic!("expected NonJsonBody, got {other:?}"),
}
}
struct CaptureSegment(std::sync::Arc<std::sync::Mutex<Option<String>>>);
impl Respond for CaptureSegment {
fn respond(&self, request: &wiremock::Request) -> ResponseTemplate {
*self.0.lock().unwrap() = form_field(request, "segment");
json_ok(fixture("ref-type.json"))
}
}
#[tokio::test]
async fn segment_is_sent_not_double_encoded() {
let server = MockServer::start().await;
let captured = std::sync::Arc::new(std::sync::Mutex::new(None));
Mock::given(method("POST"))
.and(path("/index.php"))
.respond_with(CaptureSegment(captured.clone()))
.mount(&server)
.await;
let client = client_for(&server).await;
let seg = Segment::new("referrerType==search;referrerKeyword==foo");
let _ = client
.referrers()
.get_referrer_type(1u32, Period::Day(matomo::Date::Today), Some(seg))
.await
.unwrap();
let got = captured.lock().unwrap().clone().expect("segment captured");
assert_eq!(got, "referrerType==search;referrerKeyword==foo");
}
#[tokio::test]
async fn subpath_base_url_is_preserved() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/matomo/index.php"))
.and(MethodIs("VisitsSummary.get"))
.respond_with(json_ok(fixture("visits-summary.json")))
.mount(&server)
.await;
let client = MatomoClient::builder()
.base_url(format!("{}/matomo", server.uri())) .auth(Auth::token("t"))
.skip_preflight()
.build()
.unwrap();
let s = client
.visits_summary()
.get(1u32, Period::Day(matomo::Date::Today), None)
.await
.unwrap();
assert_eq!(s.nb_visits, 273);
}
#[tokio::test]
async fn limit_all_rejected_in_paging() {
let err =
matomo::Cursor::new(1, Period::Day(matomo::Date::Today), Limit::All, None).unwrap_err();
assert!(matches!(err, Error::Config(_)));
}
#[tokio::test]
async fn stream_terminates_on_empty_page() {
let server = MockServer::start().await;
let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
struct Pager(std::sync::Arc<std::sync::atomic::AtomicUsize>, String);
impl Respond for Pager {
fn respond(&self, _: &wiremock::Request) -> ResponseTemplate {
let n = self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if n == 0 {
json_ok(self.1.clone())
} else {
json_ok("[]".to_string())
}
}
}
Mock::given(method("POST"))
.and(path("/index.php"))
.respond_with(Pager(calls.clone(), fixture("live-details.json")))
.mount(&server)
.await;
let client = client_for(&server).await;
let period = Period::Range(DateRange::LastN(7));
let mut stream = client
.live()
.stream(1, period, Limit::count(4).unwrap(), None)
.unwrap();
let mut count = 0;
while let Some(item) = stream.next().await {
item.unwrap();
count += 1;
}
assert_eq!(count, 4);
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2);
}
#[tokio::test]
async fn id_site_multiple_serializes_csv() {
let server = MockServer::start().await;
let captured = std::sync::Arc::new(std::sync::Mutex::new(None));
struct Cap(std::sync::Arc<std::sync::Mutex<Option<String>>>);
impl Respond for Cap {
fn respond(&self, request: &wiremock::Request) -> ResponseTemplate {
*self.0.lock().unwrap() = form_field(request, "idSite");
json_ok(fixture("visits-summary.json"))
}
}
Mock::given(method("POST"))
.and(path("/index.php"))
.respond_with(Cap(captured.clone()))
.mount(&server)
.await;
let client = client_for(&server).await;
let _ = client
.visits_summary()
.get(
IdSite::Multiple(vec![1, 2, 3]),
Period::Day(matomo::Date::Today),
None,
)
.await
.unwrap();
assert_eq!(captured.lock().unwrap().clone().unwrap(), "1,2,3");
}
#[tokio::test]
async fn preflight_runs_and_passes() {
let server = MockServer::start().await;
mount_method(&server, "API.getMatomoVersion", "version.json").await;
mount_method(&server, "API.getReportMetadata", "report-metadata.json").await;
mount_method(&server, "VisitsSummary.get", "visits-summary.json").await;
let client = MatomoClient::builder()
.base_url(server.uri())
.auth(Auth::token("t"))
.build()
.unwrap();
let s = client
.visits_summary()
.get(1u32, Period::Day(matomo::Date::Today), None)
.await
.unwrap();
assert_eq!(s.nb_visits, 273);
}