diff --git a/CHANGELOG.md b/CHANGELOG.md index 8430177c7..c73347f9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - WordPress.com `GET /sites//purchases` endpoint for listing a site's purchases (plans, domains, and other subscriptions) - Publish the Kotlin bindings' per-endpoint Markdown API reference as an `ai-docs` Maven classifier zip on `rs.wordpress.api:kotlin`, generated from the UniFFI bindings for agent/tooling consumption - WordPress.com `POST /sites//domains/primary` endpoint for setting a site's primary domain +- WordPress.com `GET /sites//stats/post/` endpoint for a post's view history, like count, comment count, and metadata - WordPress.com `GET /sites//plans` endpoint for listing the plans a site can buy, priced for that site, with the plan it's currently on flagged. - `RequestExecutionErrorReason` gained `isSiteUnreachable` and `isDeviceOffline` for distinguishing a site that could not be reached (most reliably, a DNS failure) from a device with no network connection. Previously consumers had to match the `NonExistentSiteError` / `DeviceIsOfflineError` variants themselves. Available on both platforms as properties on the reason, which is reachable from `WpRequestResult.RequestExecutionFailed` and `WpApiException.RequestExecutionFailed` on Kotlin. Swift additionally exposes both as convenience properties on `WpApiError` and `RequestExecutionError`. diff --git a/WPCOM_REST_API_CHECKLIST.md b/WPCOM_REST_API_CHECKLIST.md index fd8165847..db3b25bdb 100644 --- a/WPCOM_REST_API_CHECKLIST.md +++ b/WPCOM_REST_API_CHECKLIST.md @@ -329,7 +329,7 @@ investigate the relevant code before making decisions based on this document. - [ ] `GET /rest/v1.1/sites/$site/stats/comments` — top commenters and most-commented posts - [ ] `GET /rest/v1.1/sites/$site/stats/followers` — site followers (filterable by type) - [x] `GET /rest/v1.1/sites/$site/stats/insights` — most popular day/hour, yearly aggregates -- [ ] `GET /rest/v1.1/sites/$site/stats/post/$post_id` — per-post view stats +- [x] `GET /rest/v1.1/sites/$site/stats/post/$post_id` — per-post view stats - [ ] `GET /rest/v1.1/sites/$site/stats/publicize` — social media follower counts - [ ] `GET /rest/v1.1/sites/$site/stats/streak` — posting activity/streak data - [ ] `GET /rest/v1.1/sites/$site/stats/summary` — total likes, comments, followers diff --git a/wp_api/src/wp_com/client.rs b/wp_api/src/wp_com/client.rs index 010124f96..9c554a29f 100644 --- a/wp_api/src/wp_com/client.rs +++ b/wp_api/src/wp_com/client.rs @@ -33,6 +33,7 @@ use super::endpoint::{ StatsFileDownloadsRequestBuilder, StatsFileDownloadsRequestExecutor, }, stats_insights_endpoint::{StatsInsightsRequestBuilder, StatsInsightsRequestExecutor}, + stats_post_endpoint::{StatsPostRequestBuilder, StatsPostRequestExecutor}, stats_referrers_endpoint::{StatsReferrersRequestBuilder, StatsReferrersRequestExecutor}, stats_region_views_endpoint::{ StatsRegionViewsRequestBuilder, StatsRegionViewsRequestExecutor, @@ -96,6 +97,7 @@ pub struct WpComApiRequestBuilder { stats_emails_summary: Arc, stats_devices_platform: Arc, stats_devices_screensize: Arc, + stats_post: Arc, stats_referrers: Arc, stats_subscribers: Arc, stats_region_views: Arc, @@ -144,6 +146,7 @@ impl WpComApiRequestBuilder { stats_emails_summary, stats_devices_platform, stats_devices_screensize, + stats_post, stats_referrers, stats_subscribers, stats_region_views, @@ -203,6 +206,7 @@ pub struct WpComApiClient { stats_emails_summary: Arc, stats_devices_platform: Arc, stats_devices_screensize: Arc, + stats_post: Arc, stats_referrers: Arc, stats_subscribers: Arc, stats_region_views: Arc, @@ -252,6 +256,7 @@ impl WpComApiClient { stats_emails_summary, stats_devices_platform, stats_devices_screensize, + stats_post, stats_referrers, stats_subscribers, stats_region_views, @@ -294,6 +299,7 @@ api_client_generate_endpoint_impl!(WpComApi, stats_devices_browser); api_client_generate_endpoint_impl!(WpComApi, stats_emails_summary); api_client_generate_endpoint_impl!(WpComApi, stats_devices_platform); api_client_generate_endpoint_impl!(WpComApi, stats_devices_screensize); +api_client_generate_endpoint_impl!(WpComApi, stats_post); api_client_generate_endpoint_impl!(WpComApi, stats_referrers); api_client_generate_endpoint_impl!(WpComApi, stats_subscribers); api_client_generate_endpoint_impl!(WpComApi, stats_region_views); diff --git a/wp_api/src/wp_com/endpoint.rs b/wp_api/src/wp_com/endpoint.rs index 6fdde195a..957303e72 100644 --- a/wp_api/src/wp_com/endpoint.rs +++ b/wp_api/src/wp_com/endpoint.rs @@ -30,6 +30,7 @@ pub mod stats_devices_screensize_endpoint; pub mod stats_emails_summary_endpoint; pub mod stats_file_downloads_endpoint; pub mod stats_insights_endpoint; +pub mod stats_post_endpoint; pub mod stats_referrers_endpoint; pub mod stats_region_views_endpoint; pub mod stats_search_terms_endpoint; diff --git a/wp_api/src/wp_com/endpoint/stats_post_endpoint.rs b/wp_api/src/wp_com/endpoint/stats_post_endpoint.rs new file mode 100644 index 000000000..3912c4eee --- /dev/null +++ b/wp_api/src/wp_com/endpoint/stats_post_endpoint.rs @@ -0,0 +1,70 @@ +use crate::{ + request::endpoint::{AsNamespace, DerivedRequest}, + wp_com::{ + WpComNamespace, WpComSiteId, + stats_post::{StatsPostResponse, StatsPostTarget}, + }, +}; +use wp_derive_request_builder::WpDerivedRequest; + +#[derive(WpDerivedRequest)] +enum StatsPostRequest { + #[get(url = "/sites//stats/post/", output = StatsPostResponse)] + GetStatsPost, +} + +impl DerivedRequest for StatsPostRequest { + fn namespace(&self) -> impl AsNamespace { + WpComNamespace::RestV1_1 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + posts::PostId, + request::endpoint::ApiUrlResolver, + wp_com::endpoint::tests::{ + fixture_wp_com_api_url_resolver, validate_wp_com_rest_v1_1_endpoint, + }, + }; + use rstest::*; + use std::sync::Arc; + + #[rstest] + #[case::numeric_id( + WpComSiteId(12345), + StatsPostTarget::Post { id: PostId(2729) }, + "/sites/12345/stats/post/2729" + )] + #[case::large_ids( + WpComSiteId(229889220), + StatsPostTarget::Post { id: PostId(9007199254740991) }, + "/sites/229889220/stats/post/9007199254740991" + )] + // The API addresses the site's home page as post 0. + #[case::home_page( + WpComSiteId(12345), + StatsPostTarget::HomePage, + "/sites/12345/stats/post/0" + )] + fn get_stats_post( + endpoint: StatsPostRequestEndpoint, + #[case] site_id: WpComSiteId, + #[case] target: StatsPostTarget, + #[case] expected_path: &str, + ) { + validate_wp_com_rest_v1_1_endpoint( + endpoint.get_stats_post(&site_id, &target), + expected_path, + ); + } + + #[fixture] + fn endpoint( + fixture_wp_com_api_url_resolver: Arc, + ) -> StatsPostRequestEndpoint { + StatsPostRequestEndpoint::new(fixture_wp_com_api_url_resolver) + } +} diff --git a/wp_api/src/wp_com/mod.rs b/wp_api/src/wp_com/mod.rs index 7d3dc269b..a36cd0470 100644 --- a/wp_api/src/wp_com/mod.rs +++ b/wp_api/src/wp_com/mod.rs @@ -26,6 +26,7 @@ pub mod stats_devices; pub mod stats_emails_summary; pub mod stats_file_downloads; pub mod stats_insights; +pub mod stats_post; pub mod stats_referrers; pub mod stats_region_views; pub mod stats_search_terms; diff --git a/wp_api/src/wp_com/stats_post.rs b/wp_api/src/wp_com/stats_post.rs new file mode 100644 index 000000000..42e62f8ec --- /dev/null +++ b/wp_api/src/wp_com/stats_post.rs @@ -0,0 +1,637 @@ +use crate::{ + date::WpGmtDateTime, + posts::PostId, + wp_com::{me::WpComUserId, stats_visits::StatsVisitsDataValue}, +}; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, fmt}; +use wp_serde_helper::{ + deserialize_empty_array_or_hashmap, deserialize_false_as_none, deserialize_u64_or_string_as_t, +}; + +// The column names the API uses for the daily view history. +const PERIOD_COLUMN: &str = "period"; +const VIEWS_COLUMN: &str = "views"; + +/// The id the API addresses the site's home page by. +const HOME_PAGE_POST_ID: PostId = PostId(0); + +/// What a per-post stats request is about. +/// +/// The API addresses the site's home page as post `0`, which is not a valid +/// [`PostId`] anywhere else in the crate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum StatsPostTarget { + /// A specific post or page. + Post { id: PostId }, + /// The site's home page. See [`StatsPostResponse`] for what the API counts + /// for it, which depends on how the site's front page is configured. + HomePage, +} + +impl fmt::Display for StatsPostTarget { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Post { id } => write!(f, "{id}"), + Self::HomePage => write!(f, "0"), + } + } +} + +impl From for StatsPostTarget { + /// Resolves the API's home page id, so callers working from a list that + /// includes it — `/stats/top-posts` reports one — don't each repeat the + /// check. + fn from(id: PostId) -> Self { + if id == HOME_PAGE_POST_ID { + Self::HomePage + } else { + Self::Post { id } + } + } +} + +/// Response from the per-post stats endpoint. +/// +/// The endpoint returns the target's complete view history, so +/// [`Self::daily_views`] can hold thousands of entries for a long-lived post. +/// Callers that only need a trailing window (such as the "Latest Post Summary" +/// card) should slice the tail of it — `dailyViews.suffix(7)` in Swift, +/// `dailyViews.takeLast(7)` in Kotlin. +/// +/// # The site's home page +/// +/// [`StatsPostTarget::HomePage`] requests post `0`, which `/stats/top-posts` +/// also reports as a pseudo-entry. What the view figures cover then depends on +/// how the site's front page is configured, and the two cases are +/// indistinguishable in the response: +/// +/// - a "latest posts" front page — the views recorded against the home page +/// - a static front page — the whole site's view history +/// +/// The home page is not a post either way, so [`Self::post`], +/// [`Self::discussion`] and [`Self::like_count`] are `None` for both. +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +#[serde(from = "RawStatsPostResponse", into = "RawStatsPostResponse")] +pub struct StatsPostResponse { + /// The date the stats were generated for (format: YYYY-MM-DD). + pub date: String, + /// The target's all-time view count. + pub views: u64, + /// Yearly view totals, keyed by year (e.g. `"2026"`). + /// + /// A target with no recorded views gets an entry for every year from 1970 + /// to the present, each with an empty `months` map, rather than a map + /// covering only the years the target existed for. + pub years: HashMap, + /// Yearly view averages, keyed by year (e.g. `"2026"`). Populated on the + /// same basis as [`Self::years`]. + pub averages: HashMap, + /// The most recent weeks of daily views, oldest first. + pub weeks: Vec, + /// The target's complete daily view history, oldest first. + /// + /// The API sends this as a `fields`/`data` column table; it is flattened + /// while deserializing so callers never handle the column indirection. + /// + /// The history is padded rather than sparse: a target with no views still + /// gets one zero-count entry per day since it was published. + /// + /// Empty if the response doesn't name both the `period` and `views` + /// columns. + pub daily_views: Vec, + /// The highest view count the target reached in a single month. + pub highest_month: u64, + /// The highest monthly average of daily views the target reached. + pub highest_day_average: u64, + /// The highest single-day view count across the last few weeks. Despite the + /// name, this is neither a weekly figure nor an average. + pub highest_week_average: u64, + /// The post's like count. `None` for the site's home page. + pub like_count: Option, + /// The post's comment counts. `None` for the site's home page. + pub discussion: Option, + /// The post the stats belong to. `None` for the site's home page. + pub post: Option, +} + +/// The response as the API sends it, before the `fields`/`data` column table is +/// flattened into [`StatsPostResponse::daily_views`]. +#[derive(Serialize, Deserialize)] +struct RawStatsPostResponse { + date: String, + views: u64, + #[serde(deserialize_with = "deserialize_empty_array_or_hashmap")] + years: HashMap, + #[serde(deserialize_with = "deserialize_empty_array_or_hashmap")] + averages: HashMap, + weeks: Vec, + /// Column names for the `data` rows. Always `["period", "views"]` in every + /// response observed, but read rather than assumed. + fields: Vec, + data: Vec>, + highest_month: u64, + highest_day_average: u64, + highest_week_average: u64, + // The home page has no post behind it, so the API sends these as `null` — + // and `post` as boolean `false` rather than `null`. + like_count: Option, + discussion: Option, + #[serde(deserialize_with = "deserialize_false_as_none")] + post: Option, +} + +impl From for StatsPostResponse { + fn from(raw: RawStatsPostResponse) -> Self { + Self { + date: raw.date, + views: raw.views, + years: raw.years, + averages: raw.averages, + weeks: raw.weeks, + daily_views: daily_views(&raw.fields, raw.data), + highest_month: raw.highest_month, + highest_day_average: raw.highest_day_average, + highest_week_average: raw.highest_week_average, + like_count: raw.like_count, + discussion: raw.discussion, + post: raw.post, + } + } +} + +impl From for RawStatsPostResponse { + fn from(response: StatsPostResponse) -> Self { + Self { + date: response.date, + views: response.views, + years: response.years, + averages: response.averages, + weeks: response.weeks, + fields: vec![PERIOD_COLUMN.to_string(), VIEWS_COLUMN.to_string()], + data: response + .daily_views + .into_iter() + .map(|daily_view| { + vec![ + StatsVisitsDataValue::String(daily_view.period), + StatsVisitsDataValue::Number(daily_view.views), + ] + }) + .collect(), + highest_month: response.highest_month, + highest_day_average: response.highest_day_average, + highest_week_average: response.highest_week_average, + like_count: response.like_count, + discussion: response.discussion, + post: response.post, + } + } +} + +/// Flattens the `fields`/`data` column table into data points, skipping rows the +/// columns can't be read from. +/// +/// Takes `data` by value so each row's period string is moved rather than +/// copied — the history runs to thousands of rows on a long-lived post. +fn daily_views(fields: &[String], data: Vec>) -> Vec { + let (Some(period_index), Some(views_index)) = ( + fields.iter().position(|field| field == PERIOD_COLUMN), + fields.iter().position(|field| field == VIEWS_COLUMN), + ) else { + return vec![]; + }; + + let mut daily_views = Vec::with_capacity(data.len()); + for mut row in data { + let Some(views) = row + .get(views_index) + .and_then(StatsVisitsDataValue::as_number) + else { + continue; + }; + if period_index >= row.len() { + continue; + } + let StatsVisitsDataValue::String(period) = row.swap_remove(period_index) else { + continue; + }; + daily_views.push(StatsPostDailyView { period, views }); + } + daily_views +} + +/// A single day's view count from the daily view history. +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, uniffi::Record)] +pub struct StatsPostDailyView { + /// The day the views were recorded on (format: YYYY-MM-DD). + pub period: String, + /// The number of views on that day. + pub views: u64, +} + +/// A year's view totals. +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct StatsPostYear { + /// View totals keyed by month number (`"1"` through `"12"`). + #[serde(deserialize_with = "deserialize_empty_array_or_hashmap")] + pub months: HashMap, + /// The total views for the year. + pub total: u64, +} + +/// A year's view averages. +/// +/// The API truncates every average to a whole number before sending it. +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct StatsPostAverage { + /// View averages keyed by month number (`"1"` through `"12"`). + #[serde(deserialize_with = "deserialize_empty_array_or_hashmap")] + pub months: HashMap, + /// The average views across the whole year. + pub overall: u64, +} + +/// A week of daily views. +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct StatsPostWeek { + /// The days in the week, oldest first. The final week may be partial. + pub days: Vec, + /// The total views for the week. + pub total: u64, + /// The average daily views for the week, truncated to a whole number by the + /// API. + pub average: u64, + /// The change from the previous week, or `None` for the first week. + pub change: Option, +} + +/// The change in views from one week to the next. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, uniffi::Enum)] +#[serde(from = "RawStatsPostChange", into = "RawStatsPostChange")] +pub enum StatsPostChange { + /// The percentage change from the previous week. Negative when views fell. + Percentage { value: f64 }, + /// The previous week had no views, so the change is unbounded. The API + /// sends this as `{"isInfinity": true}` because the underlying value is + /// infinite and cannot be represented in JSON. + Infinite, +} + +/// The wire representations the API uses for a week's `change`. +/// +/// These three shapes — a number, `{"isInfinity": true}`, and `null` (handled by +/// the surrounding `Option`) — are the only ones the endpoint produces. A week +/// following a zero-view week reports an integer `0` rather than a not-a-number +/// marker, so there is no `isNan` counterpart to model. +#[derive(Serialize, Deserialize)] +#[serde(untagged)] +enum RawStatsPostChange { + Percentage(f64), + Infinite { + // Only ever `true` on the wire; the presence of the key is what + // identifies the shape. + #[serde(rename = "isInfinity")] + is_infinity: bool, + }, +} + +impl From for StatsPostChange { + fn from(raw: RawStatsPostChange) -> Self { + match raw { + RawStatsPostChange::Percentage(value) => Self::Percentage { value }, + RawStatsPostChange::Infinite { .. } => Self::Infinite, + } + } +} + +impl From for RawStatsPostChange { + fn from(change: StatsPostChange) -> Self { + match change { + StatsPostChange::Percentage { value } => Self::Percentage(value), + StatsPostChange::Infinite => Self::Infinite { is_infinity: true }, + } + } +} + +/// A single day within a [`StatsPostWeek`]. +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct StatsPostDay { + /// The day (format: YYYY-MM-DD). + pub day: String, + /// The number of views on that day. + pub count: u64, +} + +/// A post's comment counts. +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct StatsPostDiscussion { + /// The number of comments on the post. + pub comment_count: u64, +} + +/// The post the stats belong to. +/// +/// This mirrors WordPress' raw post row, so it carries the post's editorial +/// metadata. Fields the API sends that aren't modelled here (post content, +/// ping status, and similar) are ignored. +/// +/// The row's `comment_count` is deliberately omitted: the API sends it as a +/// string here, and [`StatsPostDiscussion::comment_count`] carries the same +/// value as a number. +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct StatsPostDetails { + /// The post's ID. + #[serde(rename = "ID")] + pub id: PostId, + /// The post's title. + /// + /// When the stats service can't resolve the stored title, the API + /// substitutes a generated placeholder of the form `# (not found)`. + #[serde(rename = "post_title")] + pub title: String, + /// The post's excerpt. Empty when the post has none. + #[serde(rename = "post_excerpt")] + pub excerpt: String, + /// The post's publication date in the site's timezone (format: YYYY-MM-DD HH:MM:SS). + #[serde(rename = "post_date")] + pub date: String, + /// The post's publication date in GMT. + #[serde(rename = "post_date_gmt")] + pub date_gmt: WpGmtDateTime, + /// The date the post was last modified, in the site's timezone (format: YYYY-MM-DD HH:MM:SS). + #[serde(rename = "post_modified")] + pub modified: String, + /// The date the post was last modified, in GMT. + #[serde(rename = "post_modified_gmt")] + pub modified_gmt: WpGmtDateTime, + /// The post's slug. + #[serde(rename = "post_name")] + pub slug: String, + /// The post's status, e.g. `"publish"` or `"draft"`. + #[serde(rename = "post_status")] + pub status: String, + /// The post's type, e.g. `"post"` or `"page"`. + pub post_type: String, + /// The ID of the post's author. + #[serde( + rename = "post_author", + deserialize_with = "deserialize_u64_or_string_as_t" + )] + pub author_id: WpComUserId, + /// The post's public URL. `None` if the API doesn't supply one. + /// + /// Unlike the fields around it this isn't a stored column — the stats + /// service derives it per request. + #[serde(default, deserialize_with = "deserialize_false_as_none")] + pub permalink: Option, + /// The post's globally unique identifier. Use [`Self::permalink`] for a + /// URL that resolves. + pub guid: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::*; + + const WITH_VIEWS: &str = "tests/wpcom/stats_post/post-with-views.json"; + const NO_VIEWS: &str = "tests/wpcom/stats_post/post-no-views.json"; + const HOMEPAGE: &str = "tests/wpcom/stats_post/homepage.json"; + + fn parse(json_file_path: &str) -> StatsPostResponse { + let file = std::fs::File::open(json_file_path).expect("Failed to open file"); + serde_json::from_reader(file).expect("Unable to parse JSON") + } + + /// Flattens a `fields`/`data` column table given as JSON, for exercising the + /// column handling without a full response envelope. + fn flatten(fields: &[&str], data: &str) -> Vec { + let fields: Vec = fields.iter().map(|f| f.to_string()).collect(); + let data = serde_json::from_str(data).expect("Unable to parse JSON"); + daily_views(&fields, data) + } + + #[rstest] + #[case::home_page(PostId(0), StatsPostTarget::HomePage)] + #[case::post(PostId(2729), StatsPostTarget::Post { id: PostId(2729) })] + fn test_stats_post_target_from_post_id(#[case] id: PostId, #[case] expected: StatsPostTarget) { + assert_eq!(StatsPostTarget::from(id), expected); + } + + #[test] + fn test_stats_post_response_details() { + let response = parse(WITH_VIEWS); + + assert_eq!(response.date, "2026-08-06"); + assert_eq!(response.views, 19096); + assert_eq!(response.highest_month, 3224); + assert_eq!(response.highest_day_average, 293); + assert_eq!(response.highest_week_average, 3); + assert_eq!(response.like_count, Some(9)); + assert_eq!(response.discussion.expect("present").comment_count, 48); + + let year = response.years.get("2013").expect("2013 should exist"); + assert_eq!(year.total, 6146); + assert_eq!(year.months.get("6"), Some(&3224)); + + let average = response.averages.get("2013").expect("2013 should exist"); + assert_eq!(average.overall, 31); + assert_eq!(average.months.get("6"), Some(&293)); + } + + #[test] + fn test_stats_post_details() { + let post = parse(WITH_VIEWS).post.expect("a real post has details"); + + assert_eq!(post.id, PostId(2729)); + assert_eq!( + post.title, + "The Last Version of FeedDemon is Here, and it's Free" + ); + assert_eq!(post.excerpt, "The wait is over."); + assert_eq!(post.date, "2013-06-20 09:15:49"); + assert_eq!(post.date_gmt.0.to_rfc3339(), "2013-06-20T13:15:49+00:00"); + assert_eq!(post.modified, "2013-06-23 21:57:23"); + assert_eq!( + post.modified_gmt.0.to_rfc3339(), + "2013-06-24T01:57:23+00:00" + ); + assert_eq!( + post.slug, + "the-last-version-of-feeddemon-is-here-and-its-free" + ); + assert_eq!(post.status, "publish"); + assert_eq!(post.post_type, "post"); + assert_eq!(post.guid, "https://example.com/?p=2729"); + assert_eq!( + post.permalink.as_deref(), + Some( + "https://example.com/2013/06/20/the-last-version-of-feeddemon-is-here-and-its-free/" + ) + ); + // The API sends `post_author` as a string. + assert_eq!(post.author_id, WpComUserId(5399133)); + } + + #[test] + fn test_stats_post_weeks() { + let weeks = parse(WITH_VIEWS).weeks; + + assert_eq!(weeks.len(), 3); + + let first = &weeks[0]; + assert_eq!(first.days.len(), 7); + assert_eq!(first.days[0].day, "2026-06-29"); + assert_eq!(first.days[0].count, 2); + assert_eq!(first.total, 7); + assert_eq!(first.average, 1); + assert!(first.change.is_none(), "the first week has no prior week"); + + let second = &weeks[1]; + assert_eq!(second.days.len(), 4, "a week may be partial"); + assert_eq!(second.total, 6); + assert_eq!( + second.change, + Some(StatsPostChange::Percentage { + value: 133.33333333333334 + }) + ); + + // The API sends `{"isInfinity": true}` when the previous week had no views. + let last = &weeks[2]; + assert_eq!(last.change, Some(StatsPostChange::Infinite)); + } + + #[test] + fn test_stats_post_change_round_trips() { + let weeks = parse(WITH_VIEWS).weeks; + + assert_eq!(serde_json::to_string(&weeks[0].change).unwrap(), "null"); + assert_eq!( + serde_json::to_string(&weeks[1].change).unwrap(), + "133.33333333333334" + ); + assert_eq!( + serde_json::to_string(&weeks[2].change).unwrap(), + r#"{"isInfinity":true}"# + ); + } + + #[rstest] + #[case::with_views(WITH_VIEWS)] + #[case::no_views(NO_VIEWS)] + #[case::homepage(HOMEPAGE)] + fn test_stats_post_response_round_trips(#[case] json_file_path: &str) { + let serialized = + serde_json::to_value(parse(json_file_path)).expect("Unable to serialize response"); + let reparsed: StatsPostResponse = + serde_json::from_value(serialized.clone()).expect("Unable to parse JSON"); + + assert_eq!( + serde_json::to_value(reparsed).expect("Unable to serialize response"), + serialized + ); + } + + #[test] + fn test_stats_post_daily_views() { + let daily_views = parse(WITH_VIEWS).daily_views; + + assert_eq!(daily_views.len(), 5); + assert_eq!( + daily_views[0], + StatsPostDailyView { + period: "2013-06-20".to_string(), + views: 1194, + } + ); + assert_eq!( + daily_views[4], + StatsPostDailyView { + period: "2026-08-06".to_string(), + views: 0, + } + ); + } + + #[rstest] + // The column positions are read rather than assumed, so swapping them still + // yields the same data points. + #[case::reordered_columns( + &["views", "period"], + r#"[[5, "2026-08-04"], [7, "2026-08-06"]]"#, + vec![("2026-08-04", 5), ("2026-08-06", 7)] + )] + // An unreadable row is dropped rather than derailing the rest. + #[case::unreadable_row( + &["period", "views"], + r#"[["2026-08-04", 5], [null, null], ["2026-08-06", 7]]"#, + vec![("2026-08-04", 5), ("2026-08-06", 7)] + )] + #[case::missing_views_column(&["period"], r#"[["2026-08-04"]]"#, vec![])] + fn test_stats_post_daily_views_column_handling( + #[case] fields: &[&str], + #[case] data: &str, + #[case] expected: Vec<(&str, u64)>, + ) { + let expected: Vec = expected + .into_iter() + .map(|(period, views)| StatsPostDailyView { + period: period.to_string(), + views, + }) + .collect(); + + assert_eq!(flatten(fields, data), expected); + } + + #[test] + fn test_stats_post_homepage() { + // Post 0 is the site's home page. It isn't a post, so the API sends + // `like_count` and `discussion` as null and `post` as boolean `false`, + // while every view field is populated as usual. + let response = parse(HOMEPAGE); + + assert!(response.post.is_none()); + assert!(response.discussion.is_none()); + assert!(response.like_count.is_none()); + + assert_eq!(response.views, 74286); + assert_eq!(response.highest_month, 3822); + assert_eq!(response.daily_views.len(), 3); + assert_eq!(response.daily_views[0].period, "2013-05-19"); + assert_eq!(response.daily_views[0].views, 73); + assert_eq!( + response.years.get("2013").expect("2013 should exist").total, + 14276 + ); + assert!(!response.weeks.is_empty()); + } + + #[test] + fn test_stats_post_without_views() { + let response = parse(NO_VIEWS); + + assert_eq!(response.views, 0); + assert_eq!(response.highest_month, 0); + assert_eq!(response.like_count, Some(0)); + assert_eq!(response.discussion.expect("present").comment_count, 0); + + // With no view to anchor on, the API reports every year from 1970. + assert!(response.years.contains_key("1970")); + assert!(response.averages.contains_key("1970")); + + // The API sends `months` as an empty array rather than an empty object. + let year = response.years.get("2026").expect("2026 should exist"); + assert_eq!(year.total, 0); + assert!(year.months.is_empty()); + + let average = response.averages.get("2026").expect("2026 should exist"); + assert_eq!(average.overall, 0); + assert!(average.months.is_empty()); + + assert_eq!(response.daily_views.len(), 3); + assert!(response.daily_views.iter().all(|d| d.views == 0)); + } +} diff --git a/wp_api/tests/wpcom/stats_post/homepage.json b/wp_api/tests/wpcom/stats_post/homepage.json new file mode 100644 index 000000000..eb72d7be0 --- /dev/null +++ b/wp_api/tests/wpcom/stats_post/homepage.json @@ -0,0 +1,41 @@ +{ + "date": "2026-08-06", + "views": 74286, + "years": { + "2013": { + "months": { "5": 866, "6": 3822 }, + "total": 14276 + } + }, + "averages": { + "2013": { + "months": { "5": 66, "6": 127 }, + "overall": 62 + } + }, + "weeks": [ + { + "days": [ + { "day": "2026-08-05", "count": 8 }, + { "day": "2026-08-06", "count": 6 } + ], + "total": 40, + "average": 11, + "change": 1.7094017094017318 + } + ], + "fields": ["period", "views"], + "data": [ + ["2013-05-19", 73], + ["2013-05-20", 144], + ["2013-05-21", 14] + ], + "highest_month": 3822, + "highest_day_average": 127, + "highest_week_average": 89, + + "_comment": "The home page has no post behind it: the API sends `like_count` and `discussion` as null, and `post` as boolean false rather than null.", + "like_count": null, + "discussion": null, + "post": false +} diff --git a/wp_api/tests/wpcom/stats_post/post-no-views.json b/wp_api/tests/wpcom/stats_post/post-no-views.json new file mode 100644 index 000000000..aa9f66da6 --- /dev/null +++ b/wp_api/tests/wpcom/stats_post/post-no-views.json @@ -0,0 +1,61 @@ +{ + "date": "2026-08-06", + "views": 0, + "years": { + "1970": { "months": [], "total": 0 }, + "1971": { "months": [], "total": 0 }, + "2026": { "months": [], "total": 0 } + }, + "averages": { + "1970": { "months": [], "overall": 0 }, + "1971": { "months": [], "overall": 0 }, + "2026": { "months": [], "overall": 0 } + }, + "weeks": [ + { + "days": [ + { "day": "2026-08-01", "count": 0 }, + { "day": "2026-08-02", "count": 0 } + ], + "total": 0, + "average": 0, + "change": null + }, + { + "days": [ + { "day": "2026-08-05", "count": 0 }, + { "day": "2026-08-06", "count": 0 } + ], + "total": 0, + "average": 0, + "change": 0 + } + ], + "fields": ["period", "views"], + "data": [ + ["2026-08-04", 0], + ["2026-08-05", 0], + ["2026-08-06", 0] + ], + "highest_month": 0, + "highest_day_average": 0, + "highest_week_average": 0, + "like_count": 0, + "discussion": { "comment_count": 0 }, + "post": { + "ID": 169, + "post_author": "1", + "post_date": "2026-06-11 14:22:01", + "post_date_gmt": "2026-06-11 14:22:01", + "post_title": "A Quiet Post", + "post_excerpt": "", + "post_status": "publish", + "post_name": "a-quiet-post", + "post_modified": "2026-06-11 14:22:01", + "post_modified_gmt": "2026-06-11 14:22:01", + "guid": "https://example.com/?p=169", + "permalink": "https://example.com/2026/06/11/a-quiet-post/", + "post_type": "post", + "comment_count": "0" + } +} diff --git a/wp_api/tests/wpcom/stats_post/post-with-views.json b/wp_api/tests/wpcom/stats_post/post-with-views.json new file mode 100644 index 000000000..f09b5ec7b --- /dev/null +++ b/wp_api/tests/wpcom/stats_post/post-with-views.json @@ -0,0 +1,93 @@ +{ + "date": "2026-08-06", + "views": 19096, + "years": { + "2013": { + "months": { "6": 3224, "7": 1250 }, + "total": 6146 + }, + "2026": { + "months": { "8": 9 }, + "total": 147 + } + }, + "averages": { + "2013": { + "months": { "6": 293, "7": 40 }, + "overall": 31 + }, + "2026": { + "months": { "8": 1 }, + "overall": 0 + } + }, + "weeks": [ + { + "days": [ + { "day": "2026-06-29", "count": 2 }, + { "day": "2026-06-30", "count": 1 }, + { "day": "2026-07-01", "count": 1 }, + { "day": "2026-07-02", "count": 1 }, + { "day": "2026-07-03", "count": 1 }, + { "day": "2026-07-04", "count": 1 }, + { "day": "2026-07-05", "count": 0 } + ], + "total": 7, + "average": 1, + "change": null + }, + { + "days": [ + { "day": "2026-08-03", "count": 3 }, + { "day": "2026-08-04", "count": 3 }, + { "day": "2026-08-05", "count": 0 }, + { "day": "2026-08-06", "count": 0 } + ], + "total": 6, + "average": 2, + "change": 133.33333333333334 + }, + { + "days": [ + { "day": "2026-08-10", "count": 4 }, + { "day": "2026-08-11", "count": 1 } + ], + "total": 5, + "average": 3, + "change": { "isInfinity": true } + } + ], + "fields": ["period", "views"], + "data": [ + ["2013-06-20", 1194], + ["2013-06-21", 504], + ["2013-06-22", 197], + ["2026-08-05", 0], + ["2026-08-06", 0] + ], + "highest_month": 3224, + "highest_day_average": 293, + "highest_week_average": 3, + "like_count": 9, + "discussion": { "comment_count": 48 }, + "post": { + "ID": 2729, + "post_author": "5399133", + "post_date": "2013-06-20 09:15:49", + "post_date_gmt": "2013-06-20 13:15:49", + "post_title": "The Last Version of FeedDemon is Here, and it's Free", + "post_excerpt": "The wait is over.", + "post_status": "publish", + "post_name": "the-last-version-of-feeddemon-is-here-and-its-free", + "post_modified": "2013-06-23 21:57:23", + "post_modified_gmt": "2013-06-24 01:57:23", + "guid": "https://example.com/?p=2729", + "permalink": "https://example.com/2013/06/20/the-last-version-of-feeddemon-is-here-and-its-free/", + "post_type": "post", + + "_comment": "Fields below are sent by the API but deliberately not modelled; they must be ignored rather than break parsing.", + "post_content": "The wait is over.", + "comment_count": "48", + "filter": "raw" + } +} diff --git a/wp_com_e2e/src/main.rs b/wp_com_e2e/src/main.rs index 4dfc16bf4..7672e5941 100644 --- a/wp_com_e2e/src/main.rs +++ b/wp_com_e2e/src/main.rs @@ -16,6 +16,7 @@ mod stats_city_views_tests; mod stats_country_views_tests; mod stats_emails_summary_tests; mod stats_insights_tests; +mod stats_post_tests; mod stats_referrers_tests; mod stats_region_views_tests; mod stats_subscribers_tests; @@ -60,6 +61,7 @@ fn collect_tests(ctx: Arc) -> Vec { tests.extend(stats_city_views_tests::tests(Arc::clone(&ctx))); tests.extend(stats_country_views_tests::tests(Arc::clone(&ctx))); tests.extend(stats_insights_tests::tests(Arc::clone(&ctx))); + tests.extend(stats_post_tests::tests(Arc::clone(&ctx))); tests.extend(stats_referrers_tests::tests(Arc::clone(&ctx))); tests.extend(stats_region_views_tests::tests(Arc::clone(&ctx))); tests.extend(stats_summary_tests::tests(Arc::clone(&ctx))); diff --git a/wp_com_e2e/src/stats_post_tests.rs b/wp_com_e2e/src/stats_post_tests.rs new file mode 100644 index 000000000..a106f7c02 --- /dev/null +++ b/wp_com_e2e/src/stats_post_tests.rs @@ -0,0 +1,123 @@ +use libtest_mimic::Trial; +use std::sync::Arc; +use wp_api::{ + api_error::WpApiError, + posts::PostId, + wp_com::{ + WpComSiteId, + sites::SitesListParams, + stats_post::StatsPostTarget, + stats_top_posts::{StatsTopPostsParams, StatsTopPostsPeriod}, + }, +}; + +use crate::context::TestContext; + +pub fn tests(ctx: Arc) -> Vec { + let mut trials = vec![]; + + // Pre-fetch sites during test collection + let sites_result = ctx + .runtime + .block_on(async { ctx.client.sites().get(&SitesListParams::default()).await }); + + if let Ok(response) = sites_result { + let sites = response.data.sites; + + for site in &sites { + let site_id = site.id; + + trials.push(Trial::test( + format!("post_stats::get_stats_post::{}", site_id), + { + let ctx = Arc::clone(&ctx); + move || { + // The endpoint needs a real post, so borrow one from the + // site's top posts. Resolving it here rather than during + // collection keeps the lookup off unrelated test runs. + let Some(target) = most_viewed_post(&ctx, &site_id) else { + return Ok(()); + }; + + ctx.runtime.block_on(async { + ctx.client + .stats_post() + .get_stats_post(&site_id, &target) + .await + .map_err(|e| e.to_string())?; + Ok(()) + }) + } + }, + )); + + // The home page isn't a post, so the API omits the post, discussion, + // and like fields. Every site has one, so this needs no lookup. + trials.push(Trial::test( + format!("post_stats::get_stats_post_homepage::{}", site_id), + { + let ctx = Arc::clone(&ctx); + move || { + ctx.runtime.block_on(async { + let result = ctx + .client + .stats_post() + .get_stats_post(&site_id, &StatsPostTarget::HomePage) + .await; + + match result { + Ok(response) => { + if response.data.post.is_some() { + return Err( + "the home page should have no post details".into() + ); + } + Ok(()) + } + // Test sites without Jetpack Stats have nothing to + // report; that isn't a failure of this endpoint. + Err(WpApiError::UnknownError { response, .. }) + if response.contains("invalid_blog") => + { + Ok(()) + } + Err(e) => Err(format!("{e:?}").into()), + } + }) + } + }, + )); + } + } + + trials +} + +fn most_viewed_post(ctx: &TestContext, site_id: &WpComSiteId) -> Option { + // Look back over several years rather than the default single day, so quiet + // test sites still yield a post. + let params = StatsTopPostsParams { + period: Some(StatsTopPostsPeriod::Year), + num: Some(10), + ..Default::default() + }; + + let response = ctx.runtime.block_on(async { + ctx.client + .stats_top_posts() + .get_stats_top_posts(site_id, ¶ms) + .await + }); + + response + .ok()? + .data + .summary? + .postviews + .iter() + // Id 0 is the homepage pseudo-entry, which isn't a real post. + .find(|post_view| post_view.id != 0) + .map(|post_view| StatsPostTarget::Post { + id: PostId(post_view.id as i64), + }) +} diff --git a/wp_serde_helper/src/json.rs b/wp_serde_helper/src/json.rs index b3d10c0d5..c79697cb9 100644 --- a/wp_serde_helper/src/json.rs +++ b/wp_serde_helper/src/json.rs @@ -14,6 +14,38 @@ where Ok(serde_json::from_value(value).ok()) } +/// Deserialize an optional value the API may send as boolean `false` when it is +/// absent, which PHP endpoints do in place of `null`. +/// +/// Accepts: +/// - Boolean `false` → `None` +/// - `null` → `None` +/// - Anything else → deserialized as `T` +/// +/// This is the type-generic counterpart of [`crate::deserialize_false_or_string`] +/// and the `false`-tolerant helpers in [`crate::numeric`]. +/// +/// # Errors +/// +/// Returns an error for boolean `true`, and for any other value that is not a +/// valid `T`. +pub fn deserialize_false_as_none<'de, T, D>(deserializer: D) -> Result, D::Error> +where + T: DeserializeOwned, + D: de::Deserializer<'de>, +{ + match serde_json::Value::deserialize(deserializer)? { + serde_json::Value::Bool(false) | serde_json::Value::Null => Ok(None), + serde_json::Value::Bool(true) => Err(de::Error::invalid_value( + de::Unexpected::Bool(true), + &"boolean `false`, `null`, or a value", + )), + value => serde_json::from_value(value) + .map(Some) + .map_err(de::Error::custom), + } +} + /// Serialize a value as a JSON string embedded within the parent JSON structure. /// /// This function serializes the value to a JSON string, then embeds that string @@ -215,4 +247,35 @@ mod tests { let result: TreatErrorAsNone = serde_json::from_str(json).unwrap(); assert_eq!(result.inner, None); } + + #[derive(Debug, Deserialize, PartialEq)] + struct FalseAsNoneValue { + id: u64, + } + + #[derive(Debug, Deserialize, PartialEq)] + struct FalseAsNone { + #[serde(deserialize_with = "deserialize_false_as_none")] + inner: Option, + } + + #[rstest] + #[case(r#"{"inner": false}"#, None)] + #[case(r#"{"inner": null}"#, None)] + #[case(r#"{"inner": {"id": 7}}"#, Some(FalseAsNoneValue { id: 7 }))] + fn test_deserialize_false_as_none( + #[case] json: &str, + #[case] expected: Option, + ) { + let result: FalseAsNone = serde_json::from_str(json).unwrap(); + assert_eq!(result.inner, expected); + } + + #[rstest] + #[case::boolean_true(r#"{"inner": true}"#)] + #[case::malformed_value(r#"{"inner": {"id": "x"}}"#)] + fn test_deserialize_false_as_none_errors(#[case] json: &str) { + let result = serde_json::from_str::(json); + assert!(result.is_err(), "The deserializer should emit an error"); + } } diff --git a/wp_serde_helper/src/numeric.rs b/wp_serde_helper/src/numeric.rs index 39f899ee4..fa7e3ffbb 100644 --- a/wp_serde_helper/src/numeric.rs +++ b/wp_serde_helper/src/numeric.rs @@ -51,6 +51,22 @@ where deserialize_i64_or_string(deserializer).map(Into::into) } +/// Deserialize a `u64` and convert it to a type that implements `From`. +/// +/// This is useful for deserializing into newtype wrappers around `u64`. +/// +/// # Errors +/// +/// Returns an error for negative numbers, non-numeric strings, booleans, null, +/// arrays, or objects. +pub fn deserialize_u64_or_string_as_t<'de, D, T>(deserializer: D) -> Result +where + D: Deserializer<'de>, + T: From, +{ + deserialize_u64_or_string(deserializer).map(Into::into) +} + /// Deserialize an optional `u64`, treating `false` and `null` as `None`. /// /// Accepts: