plausible_cli/client/
mod.rs

1use reqwest::{Client as HttpClient, Response, StatusCode};
2use serde::{de::DeserializeOwned, Deserialize, Serialize};
3use serde_json::{Map, Value};
4use std::time::Duration;
5use url::Url;
6
7const DEFAULT_BASE_URL: &str = "https://plausible.io/";
8const DEFAULT_TIMEOUT_SECS: u64 = 15;
9
10/// High-level Plausible API client.
11#[derive(Debug, Clone)]
12pub struct PlausibleClient {
13    http: HttpClient,
14    base_url: Url,
15    api_key: String,
16    user_agent: String,
17}
18
19impl PlausibleClient {
20    /// Create a new client targeting the public Plausible API endpoint.
21    pub fn new(api_key: impl Into<String>) -> Result<Self, ClientError> {
22        let base = Url::parse(DEFAULT_BASE_URL).map_err(ClientError::InvalidBaseUrl)?;
23        Self::with_base_url(api_key, base)
24    }
25
26    /// Create a client with a custom base URL (useful for self-hosted Plausible).
27    pub fn with_base_url(api_key: impl Into<String>, base_url: Url) -> Result<Self, ClientError> {
28        let api_key = api_key.into();
29        if api_key.trim().is_empty() {
30            return Err(ClientError::Validation("api_key cannot be empty"));
31        }
32        let base_url = normalize_base_url(base_url);
33        let user_agent = format!("plausible-cli/{}", env!("CARGO_PKG_VERSION"));
34        let http = HttpClient::builder()
35            .user_agent(&user_agent)
36            .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
37            .build()
38            .map_err(ClientError::HttpClient)?;
39
40        Ok(Self {
41            http,
42            base_url,
43            api_key,
44            user_agent,
45        })
46    }
47
48    /// Returns the effective base URL.
49    pub fn base_url(&self) -> &Url {
50        &self.base_url
51    }
52
53    /// Returns the configured user agent string.
54    pub fn user_agent(&self) -> &str {
55        &self.user_agent
56    }
57
58    fn endpoint(&self, fragment: &str) -> Result<Url, ClientError> {
59        self.base_url
60            .join(fragment)
61            .map_err(ClientError::InvalidEndpoint)
62    }
63
64    /// Fetch the list of sites accessible to the API key.
65    pub async fn list_sites(&self) -> Result<Vec<SiteSummary>, ClientError> {
66        let url = self.endpoint("api/v1/sites")?;
67        let response = self
68            .http
69            .get(url)
70            .bearer_auth(&self.api_key)
71            .send()
72            .await
73            .map_err(ClientError::Http)?;
74        self.handle_response(response).await
75    }
76
77    /// Create a Plausible site.
78    pub async fn create_site(
79        &self,
80        request: &CreateSiteRequest,
81    ) -> Result<SiteSummary, ClientError> {
82        if request.domain.trim().is_empty() {
83            return Err(ClientError::Validation("domain cannot be empty"));
84        }
85        let url = self.endpoint("api/v1/sites")?;
86        let response = self
87            .http
88            .post(url)
89            .bearer_auth(&self.api_key)
90            .json(request)
91            .send()
92            .await
93            .map_err(ClientError::Http)?;
94        self.handle_response(response).await
95    }
96
97    /// Update mutable properties for a Plausible site.
98    pub async fn update_site(
99        &self,
100        site_id: &str,
101        request: &UpdateSiteRequest,
102    ) -> Result<SiteSummary, ClientError> {
103        if site_id.trim().is_empty() {
104            return Err(ClientError::Validation("site_id cannot be empty"));
105        }
106        let url = self.endpoint(&format!("api/v1/sites/{site_id}"))?;
107        let response = self
108            .http
109            .patch(url)
110            .bearer_auth(&self.api_key)
111            .json(request)
112            .send()
113            .await
114            .map_err(ClientError::Http)?;
115        self.handle_response(response).await
116    }
117
118    /// Reset statistics for a site.
119    pub async fn reset_site_stats(
120        &self,
121        site_id: &str,
122        request: &ResetSiteStatsRequest,
123    ) -> Result<(), ClientError> {
124        if site_id.trim().is_empty() {
125            return Err(ClientError::Validation("site_id cannot be empty"));
126        }
127        let url = self.endpoint(&format!("api/v1/sites/{site_id}/reset-stats"))?;
128        let response = self
129            .http
130            .post(url)
131            .bearer_auth(&self.api_key)
132            .json(request)
133            .send()
134            .await
135            .map_err(ClientError::Http)?;
136        if response.status().is_success() {
137            Ok(())
138        } else {
139            let status = response.status();
140            let message = response
141                .text()
142                .await
143                .unwrap_or_else(|_| String::from("unable to read error body"));
144            Err(ClientError::Api { status, message })
145        }
146    }
147
148    /// Delete a Plausible site.
149    pub async fn delete_site(&self, site_id: &str) -> Result<(), ClientError> {
150        if site_id.trim().is_empty() {
151            return Err(ClientError::Validation("site_id cannot be empty"));
152        }
153        let url = self.endpoint(&format!("api/v1/sites/{site_id}"))?;
154        let response = self
155            .http
156            .delete(url)
157            .bearer_auth(&self.api_key)
158            .send()
159            .await
160            .map_err(ClientError::Http)?;
161        if response.status().is_success() || response.status() == StatusCode::NO_CONTENT {
162            Ok(())
163        } else {
164            let status = response.status();
165            let message = response
166                .text()
167                .await
168                .unwrap_or_else(|_| String::from("unable to read error body"));
169            Err(ClientError::Api { status, message })
170        }
171    }
172
173    /// Query aggregate statistics for a site.
174    pub async fn stats_aggregate(
175        &self,
176        query: &AggregateQuery,
177    ) -> Result<AggregateResponse, ClientError> {
178        if query.site_id.trim().is_empty() {
179            return Err(ClientError::Validation("site_id cannot be empty"));
180        }
181        let url = self.endpoint("api/v1/stats/aggregate")?;
182        let params = build_stats_query(
183            &query.site_id,
184            Some(&query.metrics),
185            &StatsQueryOptions {
186                period: query.period.as_ref(),
187                date: query.date.as_ref(),
188                filters: &query.filters,
189                properties: &query.properties,
190                compare: query.compare.as_ref(),
191                interval: query.interval.as_ref(),
192                sort: query.sort.as_ref(),
193                limit: query.limit,
194                page: query.page,
195                include: None,
196                property: None,
197            },
198        );
199
200        let response = self
201            .http
202            .get(url)
203            .bearer_auth(&self.api_key)
204            .query(&params)
205            .send()
206            .await
207            .map_err(ClientError::Http)?;
208        self.handle_response(response).await
209    }
210
211    /// Query timeseries statistics for a site.
212    pub async fn stats_timeseries(
213        &self,
214        query: &TimeseriesQuery,
215    ) -> Result<TimeseriesResponse, ClientError> {
216        if query.site_id.trim().is_empty() {
217            return Err(ClientError::Validation("site_id cannot be empty"));
218        }
219        let url = self.endpoint("api/v1/stats/timeseries")?;
220        let params = build_stats_query(
221            &query.site_id,
222            Some(&query.metrics),
223            &StatsQueryOptions {
224                period: query.period.as_ref(),
225                date: query.date.as_ref(),
226                filters: &query.filters,
227                properties: &query.properties,
228                compare: query.compare.as_ref(),
229                interval: query.interval.as_ref(),
230                sort: query.sort.as_ref(),
231                limit: query.limit,
232                page: query.page,
233                include: None,
234                property: None,
235            },
236        );
237        let response = self
238            .http
239            .get(url)
240            .bearer_auth(&self.api_key)
241            .query(&params)
242            .send()
243            .await
244            .map_err(ClientError::Http)?;
245        self.handle_response(response).await
246    }
247
248    /// Query breakdown statistics for a site.
249    pub async fn stats_breakdown(
250        &self,
251        query: &BreakdownQuery,
252    ) -> Result<BreakdownResponse, ClientError> {
253        if query.site_id.trim().is_empty() {
254            return Err(ClientError::Validation("site_id cannot be empty"));
255        }
256        if query.property.trim().is_empty() {
257            return Err(ClientError::Validation("property cannot be empty"));
258        }
259        let url = self.endpoint("api/v1/stats/breakdown")?;
260        let params = build_stats_query(
261            &query.site_id,
262            Some(&query.metrics),
263            &StatsQueryOptions {
264                period: query.period.as_ref(),
265                date: query.date.as_ref(),
266                filters: &query.filters,
267                properties: &query.properties,
268                compare: query.compare.as_ref(),
269                interval: None,
270                sort: query.sort.as_ref(),
271                limit: query.limit,
272                page: query.page,
273                include: query.include.as_ref(),
274                property: Some(&query.property),
275            },
276        );
277        let response = self
278            .http
279            .get(url)
280            .bearer_auth(&self.api_key)
281            .query(&params)
282            .send()
283            .await
284            .map_err(ClientError::Http)?;
285        self.handle_response(response).await
286    }
287
288    /// Fetch realtime visitor counts.
289    pub async fn stats_realtime_visitors(
290        &self,
291        site_id: &str,
292    ) -> Result<RealtimeVisitorsResponse, ClientError> {
293        if site_id.trim().is_empty() {
294            return Err(ClientError::Validation("site_id cannot be empty"));
295        }
296        let url = self.endpoint("api/v1/stats/realtime/visitors")?;
297        let response = self
298            .http
299            .get(url)
300            .bearer_auth(&self.api_key)
301            .query(&[("site_id", site_id)])
302            .send()
303            .await
304            .map_err(ClientError::Http)?;
305        self.handle_response(response).await
306    }
307
308    /// Send a custom event to Plausible.
309    pub async fn send_event(&self, event: &Value) -> Result<(), ClientError> {
310        if !event.is_object() {
311            return Err(ClientError::Validation(
312                "event payload must be a JSON object",
313            ));
314        }
315        let url = self.endpoint("api/v1/events")?;
316        let response = self
317            .http
318            .post(url)
319            .bearer_auth(&self.api_key)
320            .json(event)
321            .send()
322            .await
323            .map_err(ClientError::Http)?;
324        let status = response.status();
325        if status.is_success() {
326            Ok(())
327        } else {
328            let message = response
329                .text()
330                .await
331                .unwrap_or_else(|_| String::from("unable to read error body"));
332            Err(ClientError::Api { status, message })
333        }
334    }
335
336    async fn handle_response<T: DeserializeOwned>(
337        &self,
338        response: Response,
339    ) -> Result<T, ClientError> {
340        let status = response.status();
341        if status.is_success() {
342            response.json::<T>().await.map_err(ClientError::Http)
343        } else {
344            let message = response
345                .text()
346                .await
347                .unwrap_or_else(|_| String::from("unable to read error body"));
348            Err(ClientError::Api { status, message })
349        }
350    }
351}
352
353fn normalize_base_url(mut url: Url) -> Url {
354    if url.path().is_empty() {
355        url.set_path("/");
356    } else if !url.path().ends_with('/') {
357        let mut path = url.path().trim_end_matches('/').to_string();
358        path.push('/');
359        url.set_path(&path);
360    }
361    url
362}
363
364struct StatsQueryOptions<'a> {
365    period: Option<&'a String>,
366    date: Option<&'a String>,
367    filters: &'a [String],
368    properties: &'a [String],
369    compare: Option<&'a String>,
370    interval: Option<&'a String>,
371    sort: Option<&'a String>,
372    limit: Option<u32>,
373    page: Option<u32>,
374    include: Option<&'a String>,
375    property: Option<&'a String>,
376}
377
378fn build_stats_query(
379    site_id: &str,
380    metrics: Option<&[String]>,
381    options: &StatsQueryOptions<'_>,
382) -> Vec<(String, String)> {
383    let mut params: Vec<(String, String)> = Vec::new();
384    params.push(("site_id".into(), site_id.to_string()));
385    let metrics = if let Some(metrics) = metrics {
386        if metrics.is_empty() {
387            vec!["visitors".to_string()]
388        } else {
389            metrics.to_vec()
390        }
391    } else {
392        vec!["visitors".to_string()]
393    };
394    params.push(("metrics".into(), metrics.join(",")));
395
396    if let Some(period) = options.period {
397        params.push(("period".into(), period.clone()));
398    }
399    if let Some(date) = options.date {
400        params.push(("date".into(), date.clone()));
401    }
402    if !options.filters.is_empty() {
403        params.push(("filters".into(), options.filters.join(";")));
404    }
405    if !options.properties.is_empty() {
406        params.push(("properties".into(), options.properties.join(";")));
407    }
408    if let Some(compare) = options.compare {
409        params.push(("compare".into(), compare.clone()));
410    }
411    if let Some(interval) = options.interval {
412        params.push(("interval".into(), interval.clone()));
413    }
414    if let Some(sort) = options.sort {
415        params.push(("sort".into(), sort.clone()));
416    }
417    if let Some(limit) = options.limit {
418        params.push(("limit".into(), limit.to_string()));
419    }
420    if let Some(page) = options.page {
421        params.push(("page".into(), page.to_string()));
422    }
423    if let Some(include) = options.include {
424        params.push(("include".into(), include.clone()));
425    }
426    if let Some(property) = options.property {
427        params.push(("property".into(), property.clone()));
428    }
429    params
430}
431
432/// Query parameters for the stats aggregate endpoint.
433#[derive(Debug, Clone, Default)]
434pub struct AggregateQuery {
435    pub site_id: String,
436    pub metrics: Vec<String>,
437    pub period: Option<String>,
438    pub date: Option<String>,
439    pub filters: Vec<String>,
440    pub properties: Vec<String>,
441    pub compare: Option<String>,
442    pub interval: Option<String>,
443    pub sort: Option<String>,
444    pub limit: Option<u32>,
445    pub page: Option<u32>,
446}
447
448/// Response envelope for aggregate metrics.
449#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
450pub struct AggregateResponse {
451    pub results: Map<String, Value>,
452}
453
454impl AggregateResponse {
455    /// Convenience helper to access numeric metrics as f64 values.
456    pub fn metric_as_f64(&self, metric: &str) -> Option<f64> {
457        self.results.get(metric).and_then(|value| value.as_f64())
458    }
459}
460
461/// Query parameters for the stats timeseries endpoint.
462#[derive(Debug, Clone, Default)]
463pub struct TimeseriesQuery {
464    pub site_id: String,
465    pub metrics: Vec<String>,
466    pub period: Option<String>,
467    pub date: Option<String>,
468    pub interval: Option<String>,
469    pub filters: Vec<String>,
470    pub properties: Vec<String>,
471    pub compare: Option<String>,
472    pub sort: Option<String>,
473    pub limit: Option<u32>,
474    pub page: Option<u32>,
475}
476
477/// Response envelope for timeseries metrics.
478#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
479pub struct TimeseriesResponse {
480    #[serde(default)]
481    pub results: Vec<Map<String, Value>>,
482    #[serde(default)]
483    pub totals: Map<String, Value>,
484}
485
486/// Query parameters for the stats breakdown endpoint.
487#[derive(Debug, Clone, Default)]
488pub struct BreakdownQuery {
489    pub site_id: String,
490    pub property: String,
491    pub metrics: Vec<String>,
492    pub period: Option<String>,
493    pub date: Option<String>,
494    pub filters: Vec<String>,
495    pub properties: Vec<String>,
496    pub compare: Option<String>,
497    pub sort: Option<String>,
498    pub limit: Option<u32>,
499    pub page: Option<u32>,
500    pub include: Option<String>,
501}
502
503/// Response envelope for breakdown metrics.
504#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
505pub struct BreakdownResponse {
506    #[serde(default)]
507    pub results: Vec<Map<String, Value>>,
508    #[serde(default)]
509    pub page: Option<u32>,
510    #[serde(default)]
511    pub total_pages: Option<u32>,
512    #[serde(default)]
513    pub totals: Option<Map<String, Value>>,
514}
515
516/// Minimal representation of a Plausible site entry.
517#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
518pub struct SiteSummary {
519    pub domain: String,
520    #[serde(default)]
521    pub timezone: Option<String>,
522    #[serde(default)]
523    pub is_main_site: Option<bool>,
524    #[serde(default)]
525    pub public: Option<bool>,
526    #[serde(default)]
527    pub verified: Option<bool>,
528}
529
530/// Request payload to create a new site.
531#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
532pub struct CreateSiteRequest {
533    pub domain: String,
534    #[serde(skip_serializing_if = "Option::is_none")]
535    pub timezone: Option<String>,
536    #[serde(skip_serializing_if = "Option::is_none")]
537    pub public: Option<bool>,
538}
539
540/// Request payload to update an existing site.
541#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
542pub struct UpdateSiteRequest {
543    #[serde(skip_serializing_if = "Option::is_none")]
544    pub timezone: Option<String>,
545    #[serde(skip_serializing_if = "Option::is_none")]
546    pub public: Option<bool>,
547    #[serde(rename = "is_main_site", skip_serializing_if = "Option::is_none")]
548    pub main_site: Option<bool>,
549}
550
551/// Request payload for resetting site statistics.
552#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
553pub struct ResetSiteStatsRequest {
554    #[serde(skip_serializing_if = "Option::is_none")]
555    pub date: Option<String>,
556}
557
558/// Response payload for realtime visitors.
559#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
560pub struct RealtimeVisitorsResponse {
561    pub visitors: i64,
562    #[serde(default)]
563    pub pageviews: Option<i64>,
564    #[serde(default)]
565    pub bounce_rate: Option<f64>,
566    #[serde(default)]
567    pub visit_duration: Option<f64>,
568}
569
570#[derive(thiserror::Error, Debug)]
571pub enum ClientError {
572    #[error("invalid base URL: {0}")]
573    InvalidBaseUrl(#[source] url::ParseError),
574    #[error("invalid endpoint: {0}")]
575    InvalidEndpoint(#[source] url::ParseError),
576    #[error("HTTP client build error: {0}")]
577    HttpClient(#[source] reqwest::Error),
578    #[error(transparent)]
579    Http(#[from] reqwest::Error),
580    #[error("request validation failed: {0}")]
581    Validation(&'static str),
582    #[error("API request failed with status {status}: {message}")]
583    Api { status: StatusCode, message: String },
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589    use httpmock::{prelude::*, Method::PATCH};
590    use serde_json::json;
591
592    fn test_client(api_key: &str, server: &MockServer) -> PlausibleClient {
593        let url = Url::parse(&format!("{}/", server.base_url())).expect("url parse");
594        PlausibleClient::with_base_url(api_key, url).expect("client")
595    }
596
597    #[tokio::test]
598    async fn list_sites_fetches_with_auth_header() {
599        let server = MockServer::start_async().await;
600
601        server
602            .mock_async(|when, then| {
603                when.method(GET)
604                    .path("/api/v1/sites")
605                    .header("authorization", "Bearer secret");
606                then.status(200)
607                    .header("content-type", "application/json")
608                    .json_body(json!([
609                        {
610                            "domain": "example.com",
611                            "timezone": "UTC",
612                            "is_main_site": false,
613                            "public": false,
614                            "verified": true
615                        }
616                    ]));
617            })
618            .await;
619
620        let client = test_client("secret", &server);
621        let sites = client.list_sites().await.expect("list sites");
622        assert_eq!(sites.len(), 1);
623        assert_eq!(sites[0].domain, "example.com");
624        assert_eq!(sites[0].timezone.as_deref(), Some("UTC"));
625    }
626
627    #[tokio::test]
628    async fn stats_aggregate_builds_expected_query() {
629        let server = MockServer::start_async().await;
630
631        server
632            .mock_async(|when, then| {
633                when.method(GET)
634                    .path("/api/v1/stats/aggregate")
635                    .header("authorization", "Bearer test-key")
636                    .query_param("site_id", "example.com")
637                    .query_param("metrics", "visitors,pageviews")
638                    .query_param("filters", "event:page==/docs");
639                then.status(200)
640                    .header("content-type", "application/json")
641                    .json_body(json!({
642                        "results": {
643                            "visitors": 120,
644                            "pageviews": 350
645                        }
646                    }));
647            })
648            .await;
649
650        let client = test_client("test-key", &server);
651        let query = AggregateQuery {
652            site_id: "example.com".into(),
653            metrics: vec!["visitors".into(), "pageviews".into()],
654            filters: vec!["event:page==/docs".into()],
655            ..AggregateQuery::default()
656        };
657
658        let response = client.stats_aggregate(&query).await.expect("aggregate");
659        assert_eq!(response.metric_as_f64("visitors"), Some(120.0));
660        assert_eq!(response.metric_as_f64("pageviews"), Some(350.0));
661    }
662
663    #[tokio::test]
664    async fn stats_aggregate_requires_site_id() {
665        let server = MockServer::start_async().await;
666        let client = test_client("secret", &server);
667        let query = AggregateQuery::default();
668        let err = client
669            .stats_aggregate(&query)
670            .await
671            .expect_err("validation");
672        assert!(matches!(err, ClientError::Validation(msg) if msg.contains("site_id")));
673    }
674
675    #[tokio::test]
676    async fn non_success_status_returns_api_error() {
677        let server = MockServer::start_async().await;
678
679        server
680            .mock_async(|when, then| {
681                when.method(GET).path("/api/v1/sites");
682                then.status(401)
683                    .header("content-type", "text/plain")
684                    .body("unauthorized");
685            })
686            .await;
687
688        let client = test_client("secret", &server);
689        let err = client.list_sites().await.expect_err("api error");
690        assert!(
691            matches!(err, ClientError::Api { status, .. } if status == StatusCode::UNAUTHORIZED)
692        );
693    }
694
695    #[tokio::test]
696    async fn stats_timeseries_hits_endpoint_with_query() {
697        let server = MockServer::start_async().await;
698
699        server
700            .mock_async(|when, then| {
701                when.method(GET)
702                    .path("/api/v1/stats/timeseries")
703                    .header("authorization", "Bearer key-123")
704                    .query_param("site_id", "example.com")
705                    .query_param("metrics", "visitors")
706                    .query_param("interval", "date");
707                then.status(200)
708                    .header("content-type", "application/json")
709                    .json_body(json!({
710                        "results": [
711                            { "date": "2024-01-01", "visitors": 10 },
712                            { "date": "2024-01-02", "visitors": 12 }
713                        ],
714                        "totals": { "visitors": 22 }
715                    }));
716            })
717            .await;
718
719        let client = test_client("key-123", &server);
720        let query = TimeseriesQuery {
721            site_id: "example.com".into(),
722            metrics: vec!["visitors".into()],
723            interval: Some("date".into()),
724            ..TimeseriesQuery::default()
725        };
726
727        let response = client
728            .stats_timeseries(&query)
729            .await
730            .expect("timeseries response");
731        assert_eq!(response.results.len(), 2);
732        assert_eq!(response.results[0].get("visitors"), Some(&json!(10)));
733        assert_eq!(
734            response.totals.get("visitors").and_then(|v| v.as_i64()),
735            Some(22)
736        );
737    }
738
739    #[tokio::test]
740    async fn stats_breakdown_hits_endpoint_with_query() {
741        let server = MockServer::start_async().await;
742
743        server
744            .mock_async(|when, then| {
745                when.method(GET)
746                    .path("/api/v1/stats/breakdown")
747                    .header("authorization", "Bearer breakdown-key")
748                    .query_param("site_id", "example.com")
749                    .query_param("property", "event:page")
750                    .query_param("metrics", "visitors");
751                then.status(200)
752                    .header("content-type", "application/json")
753                    .json_body(json!({
754                        "results": [
755                            { "value": "/docs", "visitors": 50 },
756                            { "value": "/blog", "visitors": 30 }
757                        ],
758                        "page": 1,
759                        "total_pages": 1
760                    }));
761            })
762            .await;
763
764        let client = test_client("breakdown-key", &server);
765        let query = BreakdownQuery {
766            site_id: "example.com".into(),
767            property: "event:page".into(),
768            metrics: vec!["visitors".into()],
769            ..BreakdownQuery::default()
770        };
771
772        let response = client
773            .stats_breakdown(&query)
774            .await
775            .expect("breakdown response");
776        assert_eq!(response.results.len(), 2);
777        assert_eq!(response.results[0].get("value"), Some(&json!("/docs")));
778        assert_eq!(response.total_pages, Some(1));
779    }
780
781    #[tokio::test]
782    async fn send_event_posts_payload() {
783        let server = MockServer::start_async().await;
784
785        server
786            .mock_async(|when, then| {
787                when.method(POST)
788                    .path("/api/v1/events")
789                    .header("authorization", "Bearer event-key")
790                    .json_body(json!({
791                        "name": "Signup",
792                        "domain": "example.com"
793                    }));
794                then.status(202);
795            })
796            .await;
797
798        let client = test_client("event-key", &server);
799        let event = json!({
800            "name": "Signup",
801            "domain": "example.com"
802        });
803        client.send_event(&event).await.expect("send event");
804    }
805
806    #[tokio::test]
807    async fn send_event_rejects_non_object_payload() {
808        let server = MockServer::start_async().await;
809        let client = test_client("key", &server);
810        let event = serde_json::Value::String("not-object".into());
811        let err = client.send_event(&event).await.expect_err("validation");
812        assert!(matches!(err, ClientError::Validation(msg) if msg.contains("JSON object")));
813    }
814
815    #[tokio::test]
816    async fn create_site_posts_payload() {
817        let server = MockServer::start_async().await;
818
819        server
820            .mock_async(|when, then| {
821                when.method(POST)
822                    .path("/api/v1/sites")
823                    .header("authorization", "Bearer site-key")
824                    .json_body(json!({
825                        "domain": "example.com",
826                        "timezone": "UTC",
827                        "public": true
828                    }));
829                then.status(201)
830                    .header("content-type", "application/json")
831                    .json_body(json!({
832                        "domain": "example.com",
833                        "timezone": "UTC",
834                        "public": true,
835                        "verified": false
836                    }));
837            })
838            .await;
839
840        let client = test_client("site-key", &server);
841        let site = client
842            .create_site(&CreateSiteRequest {
843                domain: "example.com".into(),
844                timezone: Some("UTC".into()),
845                public: Some(true),
846            })
847            .await
848            .expect("create site");
849        assert_eq!(site.domain, "example.com");
850        assert_eq!(site.public, Some(true));
851    }
852
853    #[tokio::test]
854    async fn update_site_sends_patch_body() {
855        let server = MockServer::start_async().await;
856
857        server
858            .mock_async(|when, then| {
859                when.method(PATCH)
860                    .path("/api/v1/sites/example.com")
861                    .header("authorization", "Bearer update-key")
862                    .json_body(json!({ "timezone": "Europe/Berlin" }));
863                then.status(200)
864                    .header("content-type", "application/json")
865                    .json_body(json!({
866                        "domain": "example.com",
867                        "timezone": "Europe/Berlin"
868                    }));
869            })
870            .await;
871
872        let client = test_client("update-key", &server);
873        let site = client
874            .update_site(
875                "example.com",
876                &UpdateSiteRequest {
877                    timezone: Some("Europe/Berlin".into()),
878                    public: None,
879                    main_site: None,
880                },
881            )
882            .await
883            .expect("update site");
884        assert_eq!(site.timezone.as_deref(), Some("Europe/Berlin"));
885    }
886
887    #[tokio::test]
888    async fn reset_site_stats_posts_date_range() {
889        let server = MockServer::start_async().await;
890
891        server
892            .mock_async(|when, then| {
893                when.method(POST)
894                    .path("/api/v1/sites/example.com/reset-stats")
895                    .header("authorization", "Bearer reset-key")
896                    .json_body(json!({ "date": "2024-01-01" }));
897                then.status(202);
898            })
899            .await;
900
901        let client = test_client("reset-key", &server);
902        client
903            .reset_site_stats(
904                "example.com",
905                &ResetSiteStatsRequest {
906                    date: Some("2024-01-01".into()),
907                },
908            )
909            .await
910            .expect("reset stats");
911    }
912
913    #[tokio::test]
914    async fn delete_site_issues_delete() {
915        let server = MockServer::start_async().await;
916
917        server
918            .mock_async(|when, then| {
919                when.method(DELETE)
920                    .path("/api/v1/sites/example.com")
921                    .header("authorization", "Bearer delete-key");
922                then.status(204);
923            })
924            .await;
925
926        let client = test_client("delete-key", &server);
927        client
928            .delete_site("example.com")
929            .await
930            .expect("delete site");
931    }
932
933    #[tokio::test]
934    async fn realtime_visitors_fetches_metrics() {
935        let server = MockServer::start_async().await;
936
937        server
938            .mock_async(|when, then| {
939                when.method(GET)
940                    .path("/api/v1/stats/realtime/visitors")
941                    .header("authorization", "Bearer realtime-key")
942                    .query_param("site_id", "example.com");
943                then.status(200)
944                    .header("content-type", "application/json")
945                    .json_body(json!({
946                        "visitors": 5,
947                        "pageviews": 7
948                    }));
949            })
950            .await;
951
952        let client = test_client("realtime-key", &server);
953        let realtime = client
954            .stats_realtime_visitors("example.com")
955            .await
956            .expect("realtime stats");
957        assert_eq!(realtime.visitors, 5);
958        assert_eq!(realtime.pageviews, Some(7));
959    }
960}