matomo-rs 0.1.0

Async client for the Matomo Reporting API, focused on data export and migration
Documentation
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);
    // short page is NOT a terminator: we still get a next cursor.
    assert!(next.is_some());

    let new = &visits[0];
    assert_eq!(new.visitor_type, Some(VisitorType::New));
    assert!(new.goal_conversions.is_empty()); // goalConversions == 0
    assert!(new.latitude.is_some()); // string "0" -> 0.0

    let returning = &visits[1];
    assert_eq!(returning.visitor_type, Some(VisitorType::Returning));

    // the download visit carries a download action detail.
    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); // referrer_type

    let all = client
        .referrers()
        .get_all(1u32, Period::Day(matomo::Date::Today), None)
        .await
        .unwrap();
    assert_eq!(all[0].referer_type, 2); // referer_type (misspelled)
}

#[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:?}"),
    }
}

/// Captures the request body so we can assert the segment is sent verbatim
/// (single-encoded by reqwest, not pre-encoded by us).
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");
    // Decoded body must equal the raw expression exactly (no double-encoding).
    assert_eq!(got, "referrerType==search;referrerKeyword==foo");
}

#[tokio::test]
async fn subpath_base_url_is_preserved() {
    let server = MockServer::start().await;
    // mount only under the /matomo/ sub-path.
    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())) // no trailing slash
        .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);
            // first page: full fixture; second page: empty -> terminator.
            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);
}