From ee92777a8de11359960bd42831d91a6bc71f24dd Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Fri, 17 Apr 2026 00:55:16 -0400 Subject: [PATCH 01/14] Add products endpoint with types and e2e tests Implement `GET /products` with optional `type` filter parameter. The `Product` type covers all product variants (domain registrations, transfers, Jetpack plans, etc.) with optional fields for domain-specific data (`tld`, `sale_coupon`, HSTS) and non-domain data (`introductory_offer`, price tiers). --- wp_api/src/wp_com/client.rs | 6 + wp_api/src/wp_com/endpoint.rs | 1 + .../src/wp_com/endpoint/products_endpoint.rs | 58 +++++ wp_api/src/wp_com/mod.rs | 1 + wp_api/src/wp_com/products.rs | 236 ++++++++++++++++++ wp_api/tests/wpcom/products/all.json | 213 ++++++++++++++++ wp_api/tests/wpcom/products/domains.json | 136 ++++++++++ wp_com_e2e/src/main.rs | 2 + wp_com_e2e/src/products_tests.rs | 65 +++++ 9 files changed, 718 insertions(+) create mode 100644 wp_api/src/wp_com/endpoint/products_endpoint.rs create mode 100644 wp_api/src/wp_com/products.rs create mode 100644 wp_api/tests/wpcom/products/all.json create mode 100644 wp_api/tests/wpcom/products/domains.json create mode 100644 wp_com_e2e/src/products_tests.rs diff --git a/wp_api/src/wp_com/client.rs b/wp_api/src/wp_com/client.rs index a3f202735..4a995cda7 100644 --- a/wp_api/src/wp_com/client.rs +++ b/wp_api/src/wp_com/client.rs @@ -7,6 +7,7 @@ use super::endpoint::{ me_connections_endpoint::{MeConnectionsRequestBuilder, MeConnectionsRequestExecutor}, me_endpoint::{MeRequestBuilder, MeRequestExecutor}, oauth2::{Oauth2RequestBuilder, Oauth2RequestExecutor}, + products_endpoint::{ProductsRequestBuilder, ProductsRequestExecutor}, publicize_endpoint::{PublicizeRequestBuilder, PublicizeRequestExecutor}, stats_city_views_endpoint::{StatsCityViewsRequestBuilder, StatsCityViewsRequestExecutor}, stats_clicks_endpoint::{StatsClicksRequestBuilder, StatsClicksRequestExecutor}, @@ -74,6 +75,7 @@ pub struct WpComApiRequestBuilder { me: Arc, me_connections: Arc, oauth2: Arc, + products: Arc, publicize: Arc, sites: Arc, stats_city_views: Arc, @@ -117,6 +119,7 @@ impl WpComApiRequestBuilder { me, me_connections, oauth2, + products, publicize, sites, stats_city_views, @@ -171,6 +174,7 @@ pub struct WpComApiClient { me: Arc, me_connections: Arc, oauth2: Arc, + products: Arc, publicize: Arc, sites: Arc, stats_city_views: Arc, @@ -215,6 +219,7 @@ impl WpComApiClient { me, me_connections, oauth2, + products, publicize, sites, stats_city_views, @@ -252,6 +257,7 @@ api_client_generate_endpoint_impl!(WpComApi, languages); api_client_generate_endpoint_impl!(WpComApi, me); api_client_generate_endpoint_impl!(WpComApi, me_connections); api_client_generate_endpoint_impl!(WpComApi, oauth2); +api_client_generate_endpoint_impl!(WpComApi, products); api_client_generate_endpoint_impl!(WpComApi, publicize); api_client_generate_endpoint_impl!(WpComApi, sites); api_client_generate_endpoint_impl!(WpComApi, stats_city_views); diff --git a/wp_api/src/wp_com/endpoint.rs b/wp_api/src/wp_com/endpoint.rs index 10291f0f9..89496f7a1 100644 --- a/wp_api/src/wp_com/endpoint.rs +++ b/wp_api/src/wp_com/endpoint.rs @@ -14,6 +14,7 @@ pub mod languages_endpoint; pub mod me_connections_endpoint; pub mod me_endpoint; pub mod oauth2; +pub mod products_endpoint; pub mod publicize_endpoint; pub mod segments_endpoint; pub mod sites_endpoint; diff --git a/wp_api/src/wp_com/endpoint/products_endpoint.rs b/wp_api/src/wp_com/endpoint/products_endpoint.rs new file mode 100644 index 000000000..fbab25094 --- /dev/null +++ b/wp_api/src/wp_com/endpoint/products_endpoint.rs @@ -0,0 +1,58 @@ +use crate::{ + request::endpoint::{AsNamespace, DerivedRequest}, + wp_com::{ + WpComNamespace, + products::{ProductMap, ProductsParams}, + }, +}; +use wp_derive_request_builder::WpDerivedRequest; + +#[derive(WpDerivedRequest)] +enum ProductsRequest { + #[get(url = "/products", params = &ProductsParams, output = ProductMap)] + List, +} + +impl DerivedRequest for ProductsRequest { + fn namespace() -> impl AsNamespace { + WpComNamespace::RestV1_1 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + request::endpoint::ApiUrlResolver, + wp_com::{ + endpoint::tests::{ + fixture_wp_com_api_url_resolver, validate_wp_com_rest_v1_1_endpoint, + }, + products::ProductsParams, + }, + }; + use rstest::*; + use std::sync::Arc; + + #[rstest] + fn list_no_filter(endpoint: ProductsRequestEndpoint) { + validate_wp_com_rest_v1_1_endpoint(endpoint.list(&ProductsParams::default()), "/products?"); + } + + #[rstest] + fn list_with_type_filter(endpoint: ProductsRequestEndpoint) { + validate_wp_com_rest_v1_1_endpoint( + endpoint.list(&ProductsParams { + product_type: Some("domains".to_string()), + }), + "/products?type=domains", + ); + } + + #[fixture] + fn endpoint( + fixture_wp_com_api_url_resolver: Arc, + ) -> ProductsRequestEndpoint { + ProductsRequestEndpoint::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 351ec2fc8..627930f69 100644 --- a/wp_api/src/wp_com/mod.rs +++ b/wp_api/src/wp_com/mod.rs @@ -12,6 +12,7 @@ pub mod language; pub mod me; pub mod me_connections; pub mod oauth2; +pub mod products; pub mod publicize; pub mod segments; pub mod sites; diff --git a/wp_api/src/wp_com/products.rs b/wp_api/src/wp_com/products.rs new file mode 100644 index 000000000..e406bd21f --- /dev/null +++ b/wp_api/src/wp_com/products.rs @@ -0,0 +1,236 @@ +use crate::url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Parameters for `GET /products`. +#[derive(Debug, Default, Clone, PartialEq, Eq, uniffi::Record)] +pub struct ProductsParams { + /// Filter by product type (e.g. `"domains"`). + #[uniffi(default = None)] + pub product_type: Option, +} + +impl AppendUrlQueryPairs for ProductsParams { + fn append_query_pairs(&self, query_pairs_mut: &mut QueryPairs) { + query_pairs_mut.append_option_query_value_pair("type", self.product_type.as_ref()); + } +} + +/// Map of product slug to product, as returned by `GET /products`. +pub type ProductMap = HashMap; + +/// A WordPress.com product. +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct Product { + pub product_id: u64, + pub product_name: String, + pub product_slug: String, + pub description: String, + pub product_type: String, + pub available: bool, + pub billing_product_slug: String, + pub is_domain_registration: bool, + /// Formatted display cost (e.g. `"$18.00"`), localized to the account's + /// currency. + pub cost_display: String, + /// Formatted combined cost without decimal places (e.g. `"$18"`). + pub combined_cost_display: String, + /// Numeric cost in the account's currency. + pub cost: f64, + /// Cost in the smallest currency unit (e.g. cents). + pub cost_smallest_unit: u64, + pub currency_code: String, + /// Billing period (e.g. `"year"`). + pub product_term: String, + /// Localized billing period label. + pub product_term_localized: String, + pub price_tier_slug: String, + #[serde(default)] + #[uniffi(default = [])] + pub price_tier_list: Vec, + /// Top-level domain for registration products (e.g. `"com"`, `"net"`). + /// Absent for non-registration products like domain mapping. + #[serde(default)] + #[uniffi(default = None)] + pub tld: Option, + /// Whether WHOIS privacy can be purchased with this domain. + #[serde(default)] + #[uniffi(default = None)] + pub is_privacy_protection_product_purchase_allowed: Option, + /// Whether HSTS is required for this TLD (e.g. `.dev`, `.app`). + #[serde(default)] + #[uniffi(default = None)] + pub is_hsts_required: Option, + /// Whether the `.gay` TLD policy notice is required. + #[serde(default)] + #[uniffi(default = None)] + pub is_dot_gay_notice_required: Option, + /// Formatted monthly cost (e.g. `"$1.50"`). + #[serde(default)] + #[uniffi(default = None)] + pub cost_per_month_display: Option, + /// Numeric sale price when a coupon applies. + #[serde(default)] + #[uniffi(default = None)] + pub sale_cost: Option, + /// Formatted combined sale cost (e.g. `"$6.00"`). + #[serde(default)] + #[uniffi(default = None)] + pub combined_sale_cost_display: Option, + /// Active sale coupon details, if any. + #[serde(default)] + #[uniffi(default = None)] + pub sale_coupon: Option, + /// Introductory offer details, if any. + #[serde(default)] + #[uniffi(default = None)] + pub introductory_offer: Option, +} + +/// A pricing tier for usage-based products. +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct PriceTier { + pub minimum_units: u64, + /// `None` for the highest (unbounded) tier. + pub maximum_units: Option, + pub minimum_price: u64, + pub maximum_price: u64, + pub minimum_price_display: String, + pub minimum_price_monthly_display: String, + /// `None` for the highest (unbounded) tier. + pub maximum_price_display: Option, + /// `None` for the highest (unbounded) tier. + pub maximum_price_monthly_display: Option, + #[serde(default)] + #[uniffi(default = None)] + pub flat_fee: Option, + #[serde(default)] + #[uniffi(default = None)] + pub per_unit_fee: Option, + #[serde(default)] + #[uniffi(default = None)] + pub transform_quantity_divide_by: Option, + #[serde(default)] + #[uniffi(default = None)] + pub transform_quantity_round: Option, +} + +/// Details of an active sale coupon applied to a product. +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct SaleCoupon { + pub start_date: String, + pub expires: String, + /// Discount percentage (e.g. `65` means 65% off). + pub discount: u32, + pub product_ids: Vec, + #[serde(default)] + #[uniffi(default = [])] + pub purchase_types: Vec, + pub allowed_for_domain_transfers: bool, + pub allowed_for_renewals: bool, + pub allowed_for_new_purchases: bool, + pub code: String, + #[serde(default)] + #[uniffi(default = None)] + pub tld_rank: Option, +} + +/// Introductory pricing offer for a product. +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct IntroductoryOffer { + /// Unit of the offer interval (e.g. `"month"`). + pub interval_unit: String, + pub interval_count: u32, + #[serde(default)] + #[uniffi(default = None)] + pub usage_limit: Option, + /// Cost per interval during the offer period. + pub cost_per_interval: f64, + pub transition_after_renewal_count: u32, + pub should_prorate_when_offer_ends: bool, +} + +#[cfg(test)] +mod tests { + use std::fs::File; + + use super::*; + + #[test] + fn test_products_domains_deserialization() { + let file = File::open("tests/wpcom/products/domains.json").expect("Failed to open file"); + let products: ProductMap = serde_json::from_reader(file).expect("Unable to parse JSON"); + + assert_eq!(products.len(), 5); + + // domain_map is a non-registration product without tld. + let domain_map = products.get("domain_map").expect("domain_map missing"); + assert_eq!(domain_map.product_id, 1001); + assert!(!domain_map.is_domain_registration); + assert_eq!(domain_map.tld, None); + + // domain_reg is a registration product. + let domain_reg = products.get("domain_reg").expect("domain_reg missing"); + assert!(domain_reg.is_domain_registration); + assert_eq!(domain_reg.tld.as_deref(), Some("com")); + + // dotdev_domain requires HSTS. + let dotdev = products + .get("dotdev_domain") + .expect("dotdev_domain missing"); + assert_eq!(dotdev.is_hsts_required, Some(true)); + + // dotgay_domain requires the .gay policy notice. + let dotgay = products + .get("dotgay_domain") + .expect("dotgay_domain missing"); + assert_eq!(dotgay.is_dot_gay_notice_required, Some(true)); + } + + #[test] + fn test_products_with_sale_coupon() { + let file = File::open("tests/wpcom/products/domains.json").expect("Failed to open file"); + let products: ProductMap = serde_json::from_reader(file).expect("Unable to parse JSON"); + + let dotinfo = products + .get("dotinfo_domain") + .expect("dotinfo_domain missing"); + let coupon = dotinfo + .sale_coupon + .as_ref() + .expect("dotinfo_domain should have sale_coupon"); + assert_eq!(coupon.discount, 65); + assert_eq!(coupon.code, "fakecoupon123"); + assert_eq!(dotinfo.sale_cost, Some(7.0)); + } + + #[test] + fn test_products_all_deserialization() { + let file = File::open("tests/wpcom/products/all.json").expect("Failed to open file"); + let products: ProductMap = serde_json::from_reader(file).expect("Unable to parse JSON"); + + assert_eq!(products.len(), 7); + + // Verify a product with price tiers (including an unbounded top tier). + let storage = products + .get("fake_storage_addon_yearly") + .expect("fake_storage_addon_yearly missing"); + assert_eq!(storage.price_tier_list.len(), 2); + assert_eq!(storage.price_tier_list[0].minimum_units, 0); + assert!( + storage.price_tier_list[1].maximum_units.is_none(), + "top tier should be unbounded" + ); + + // Verify a product with an introductory offer. + let mail = products + .get("fake_mail_monthly") + .expect("fake_mail_monthly missing"); + let offer = mail + .introductory_offer + .as_ref() + .expect("fake_mail_monthly should have introductory_offer"); + assert_eq!(offer.interval_unit, "month"); + assert_eq!(offer.interval_count, 3); + } +} diff --git a/wp_api/tests/wpcom/products/all.json b/wp_api/tests/wpcom/products/all.json new file mode 100644 index 000000000..ea7182edc --- /dev/null +++ b/wp_api/tests/wpcom/products/all.json @@ -0,0 +1,213 @@ +{ + "domain_map": { + "product_id": 1001, + "product_name": "Fake Domain Connection", + "product_slug": "domain_map", + "description": "Connect an external domain to your site.", + "product_type": "domain_map", + "available": true, + "billing_product_slug": "fake-domain-mapping", + "is_domain_registration": false, + "cost_display": "$0.00", + "combined_cost_display": "$0", + "cost": 0, + "cost_smallest_unit": 0, + "currency_code": "USD", + "price_tier_list": [], + "price_tier_usage_quantity": null, + "product_term": "year", + "product_term_localized": "year", + "price_tiers": [], + "price_tier_slug": "", + "cost_per_month_display": "$0" + }, + "domain_reg": { + "product_id": 1002, + "product_name": "Fake .com Domain Registration", + "product_slug": "domain_reg", + "description": "Register a .com domain.", + "product_type": "domain_reg", + "available": true, + "billing_product_slug": "fake-dot-com-registration", + "is_domain_registration": true, + "cost_display": "$12.00", + "combined_cost_display": "$12", + "cost": 12, + "cost_smallest_unit": 1200, + "currency_code": "USD", + "price_tier_list": [], + "price_tier_usage_quantity": null, + "product_term": "year", + "product_term_localized": "year", + "price_tiers": [], + "price_tier_slug": "", + "tld": "com", + "is_privacy_protection_product_purchase_allowed": true, + "cost_per_month_display": "$1.00" + }, + "dotinfo_domain": { + "product_id": 1003, + "product_name": "Fake .info Domain Registration", + "product_slug": "dotinfo_domain", + "description": "Register a .info domain.", + "product_type": "domain_reg", + "available": true, + "billing_product_slug": "fake-dot-info-registration", + "is_domain_registration": true, + "cost_display": "$20.00", + "combined_cost_display": "$20", + "cost": 20, + "cost_smallest_unit": 2000, + "currency_code": "USD", + "price_tier_list": [], + "price_tier_usage_quantity": null, + "product_term": "year", + "product_term_localized": "year", + "price_tiers": [], + "price_tier_slug": "", + "tld": "info", + "is_privacy_protection_product_purchase_allowed": true, + "sale_cost": 7.00, + "combined_sale_cost_display": "$7.00", + "sale_coupon": { + "start_date": "2025-01-01 00:00:00", + "expires": "2025-12-31 00:00:00", + "discount": 65, + "product_ids": [1003, 1005], + "purchase_types": [3], + "allowed_for_domain_transfers": false, + "allowed_for_renewals": false, + "allowed_for_new_purchases": true, + "code": "fakecoupon123", + "tld_rank": null + }, + "cost_per_month_display": "$1.67" + }, + "dotdev_domain": { + "product_id": 1004, + "product_name": "Fake .dev Domain Registration", + "product_slug": "dotdev_domain", + "description": "Register a .dev domain.", + "product_type": "domain_reg", + "available": true, + "billing_product_slug": "fake-dot-dev-registration", + "is_domain_registration": true, + "cost_display": "$15.00", + "combined_cost_display": "$15", + "cost": 15, + "cost_smallest_unit": 1500, + "currency_code": "USD", + "price_tier_list": [], + "price_tier_usage_quantity": null, + "product_term": "year", + "product_term_localized": "year", + "price_tiers": [], + "price_tier_slug": "", + "tld": "dev", + "is_privacy_protection_product_purchase_allowed": true, + "is_hsts_required": true, + "cost_per_month_display": "$1.25" + }, + "dotgay_domain": { + "product_id": 1005, + "product_name": "Fake .gay Domain Registration", + "product_slug": "dotgay_domain", + "description": "Register a .gay domain.", + "product_type": "domain_reg", + "available": true, + "billing_product_slug": "fake-dot-gay-registration", + "is_domain_registration": true, + "cost_display": "$30.00", + "combined_cost_display": "$30", + "cost": 30, + "cost_smallest_unit": 3000, + "currency_code": "USD", + "price_tier_list": [], + "price_tier_usage_quantity": null, + "product_term": "year", + "product_term_localized": "year", + "price_tiers": [], + "price_tier_slug": "", + "tld": "gay", + "is_privacy_protection_product_purchase_allowed": true, + "is_dot_gay_notice_required": true, + "cost_per_month_display": "$2.50" + }, + "fake_mail_monthly": { + "product_id": 2001, + "product_name": "Fake Mail Monthly", + "product_slug": "fake_mail_monthly", + "description": "Fake email hosting, billed monthly.", + "product_type": "fake_mail", + "available": true, + "billing_product_slug": "fake-mail-monthly", + "is_domain_registration": false, + "cost_display": "$5.00", + "combined_cost_display": "$5", + "cost": 5, + "cost_smallest_unit": 500, + "currency_code": "USD", + "price_tier_list": [], + "price_tier_usage_quantity": null, + "product_term": "month", + "product_term_localized": "month", + "price_tiers": [], + "price_tier_slug": "", + "cost_per_month_display": "$5.00", + "introductory_offer": { + "interval_unit": "month", + "interval_count": 3, + "usage_limit": null, + "cost_per_interval": 0, + "transition_after_renewal_count": 0, + "should_prorate_when_offer_ends": false + } + }, + "fake_storage_addon_yearly": { + "product_id": 3001, + "product_name": "Fake 1GB Storage Add-on (Yearly)", + "product_slug": "fake_storage_addon_yearly", + "description": "Add extra storage to your site.", + "product_type": "fake_storage", + "available": true, + "billing_product_slug": "fake-storage-addon-yearly", + "is_domain_registration": false, + "cost_display": "$50.00", + "combined_cost_display": "$50", + "cost": 50, + "cost_smallest_unit": 5000, + "currency_code": "USD", + "price_tier_list": [ + { + "minimum_units": 0, + "maximum_units": 50, + "minimum_price": 0, + "maximum_price": 250000, + "minimum_price_display": "$0", + "minimum_price_monthly_display": "$0", + "maximum_price_display": "$2,500", + "maximum_price_monthly_display": "$208.33" + }, + { + "minimum_units": 51, + "maximum_units": null, + "minimum_price": 255000, + "maximum_price": 0, + "minimum_price_display": "$2,550", + "minimum_price_monthly_display": "$212.50", + "maximum_price_display": null, + "maximum_price_monthly_display": null, + "flat_fee": 100000, + "per_unit_fee": 5000, + "transform_quantity_divide_by": 1, + "transform_quantity_round": "up" + } + ], + "price_tier_usage_quantity": null, + "product_term": "year", + "product_term_localized": "year", + "price_tiers": [], + "price_tier_slug": "fake-storage-tiers", + "cost_per_month_display": "$4.17" + } +} diff --git a/wp_api/tests/wpcom/products/domains.json b/wp_api/tests/wpcom/products/domains.json new file mode 100644 index 000000000..c477eaf40 --- /dev/null +++ b/wp_api/tests/wpcom/products/domains.json @@ -0,0 +1,136 @@ +{ + "domain_map": { + "product_id": 1001, + "product_name": "Fake Domain Connection", + "product_slug": "domain_map", + "description": "Connect an external domain to your site.", + "product_type": "domain_map", + "available": true, + "billing_product_slug": "fake-domain-mapping", + "is_domain_registration": false, + "cost_display": "$0.00", + "combined_cost_display": "$0", + "cost": 0, + "cost_smallest_unit": 0, + "currency_code": "USD", + "price_tier_list": [], + "price_tier_usage_quantity": null, + "product_term": "year", + "product_term_localized": "year", + "price_tiers": [], + "price_tier_slug": "", + "cost_per_month_display": "$0" + }, + "domain_reg": { + "product_id": 1002, + "product_name": "Fake .com Domain Registration", + "product_slug": "domain_reg", + "description": "Register a .com domain.", + "product_type": "domain_reg", + "available": true, + "billing_product_slug": "fake-dot-com-registration", + "is_domain_registration": true, + "cost_display": "$12.00", + "combined_cost_display": "$12", + "cost": 12, + "cost_smallest_unit": 1200, + "currency_code": "USD", + "price_tier_list": [], + "price_tier_usage_quantity": null, + "product_term": "year", + "product_term_localized": "year", + "price_tiers": [], + "price_tier_slug": "", + "tld": "com", + "is_privacy_protection_product_purchase_allowed": true, + "cost_per_month_display": "$1.00" + }, + "dotinfo_domain": { + "product_id": 1003, + "product_name": "Fake .info Domain Registration", + "product_slug": "dotinfo_domain", + "description": "Register a .info domain.", + "product_type": "domain_reg", + "available": true, + "billing_product_slug": "fake-dot-info-registration", + "is_domain_registration": true, + "cost_display": "$20.00", + "combined_cost_display": "$20", + "cost": 20, + "cost_smallest_unit": 2000, + "currency_code": "USD", + "price_tier_list": [], + "price_tier_usage_quantity": null, + "product_term": "year", + "product_term_localized": "year", + "price_tiers": [], + "price_tier_slug": "", + "tld": "info", + "is_privacy_protection_product_purchase_allowed": true, + "sale_cost": 7.00, + "combined_sale_cost_display": "$7.00", + "sale_coupon": { + "start_date": "2025-01-01 00:00:00", + "expires": "2025-12-31 00:00:00", + "discount": 65, + "product_ids": [1003, 1005], + "purchase_types": [3], + "allowed_for_domain_transfers": false, + "allowed_for_renewals": false, + "allowed_for_new_purchases": true, + "code": "fakecoupon123", + "tld_rank": null + }, + "cost_per_month_display": "$1.67" + }, + "dotdev_domain": { + "product_id": 1004, + "product_name": "Fake .dev Domain Registration", + "product_slug": "dotdev_domain", + "description": "Register a .dev domain.", + "product_type": "domain_reg", + "available": true, + "billing_product_slug": "fake-dot-dev-registration", + "is_domain_registration": true, + "cost_display": "$15.00", + "combined_cost_display": "$15", + "cost": 15, + "cost_smallest_unit": 1500, + "currency_code": "USD", + "price_tier_list": [], + "price_tier_usage_quantity": null, + "product_term": "year", + "product_term_localized": "year", + "price_tiers": [], + "price_tier_slug": "", + "tld": "dev", + "is_privacy_protection_product_purchase_allowed": true, + "is_hsts_required": true, + "cost_per_month_display": "$1.25" + }, + "dotgay_domain": { + "product_id": 1005, + "product_name": "Fake .gay Domain Registration", + "product_slug": "dotgay_domain", + "description": "Register a .gay domain.", + "product_type": "domain_reg", + "available": true, + "billing_product_slug": "fake-dot-gay-registration", + "is_domain_registration": true, + "cost_display": "$30.00", + "combined_cost_display": "$30", + "cost": 30, + "cost_smallest_unit": 3000, + "currency_code": "USD", + "price_tier_list": [], + "price_tier_usage_quantity": null, + "product_term": "year", + "product_term_localized": "year", + "price_tiers": [], + "price_tier_slug": "", + "tld": "gay", + "is_privacy_protection_product_purchase_allowed": true, + "is_dot_gay_notice_required": true, + "cost_per_month_display": "$2.50" + } +} diff --git a/wp_com_e2e/src/main.rs b/wp_com_e2e/src/main.rs index 06960e305..4f30c13d5 100644 --- a/wp_com_e2e/src/main.rs +++ b/wp_com_e2e/src/main.rs @@ -8,6 +8,7 @@ mod context; mod domains_tests; mod languages_tests; mod me_tests; +mod products_tests; mod sites_tests; mod stats_city_views_tests; mod stats_country_views_tests; @@ -67,6 +68,7 @@ fn collect_tests(ctx: Arc) -> Vec { tests.extend(stats_subscribers_tests::tests(Arc::clone(&ctx))); tests.extend(stats_top_posts_tests::tests(Arc::clone(&ctx))); tests.extend(subscribers_by_user_type_tests::tests(Arc::clone(&ctx))); + tests.extend(products_tests::tests(Arc::clone(&ctx))); tests.extend(wp_service_tests::tests(Arc::clone(&ctx))); tests } diff --git a/wp_com_e2e/src/products_tests.rs b/wp_com_e2e/src/products_tests.rs new file mode 100644 index 000000000..b982d9019 --- /dev/null +++ b/wp_com_e2e/src/products_tests.rs @@ -0,0 +1,65 @@ +use crate::context::TestContext; +use libtest_mimic::Trial; +use std::sync::Arc; +use wp_api::wp_com::products::ProductsParams; + +pub fn tests(ctx: Arc) -> Vec { + let mut trials = vec![]; + + trials.push(Trial::test("products::list_all", { + let ctx = Arc::clone(&ctx); + move || { + ctx.runtime.block_on(async { + let products = ctx + .client + .products() + .list(&ProductsParams::default()) + .await + .map_err(|e| e.to_string())? + .data; + + if products.is_empty() { + return Err("expected non-empty products list".into()); + } + + Ok(()) + }) + } + })); + + trials.push(Trial::test("products::list_domains", { + let ctx = Arc::clone(&ctx); + move || { + ctx.runtime.block_on(async { + let products = ctx + .client + .products() + .list(&ProductsParams { + product_type: Some("domains".to_string()), + }) + .await + .map_err(|e| e.to_string())? + .data; + + if products.is_empty() { + return Err("expected non-empty domain products list".into()); + } + + // All returned products should be domain-related. + for (slug, product) in &products { + if !product.product_type.contains("domain") { + return Err(format!( + "expected domain product type for {slug}, got {}", + product.product_type + ) + .into()); + } + } + + Ok(()) + }) + } + })); + + trials +} From 984d566d3213fbf908d680466cc1c78c7a0e51ea Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Fri, 17 Apr 2026 18:13:12 -0400 Subject: [PATCH 02/14] Change Product sale_cost to Decimal2 --- wp_api/src/wp_com/products.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/wp_api/src/wp_com/products.rs b/wp_api/src/wp_com/products.rs index e406bd21f..79c6bbda7 100644 --- a/wp_api/src/wp_com/products.rs +++ b/wp_api/src/wp_com/products.rs @@ -1,4 +1,7 @@ -use crate::url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension}; +use crate::{ + decimal2::Decimal2, + url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension}, +}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -72,7 +75,7 @@ pub struct Product { /// Numeric sale price when a coupon applies. #[serde(default)] #[uniffi(default = None)] - pub sale_cost: Option, + pub sale_cost: Option, /// Formatted combined sale cost (e.g. `"$6.00"`). #[serde(default)] #[uniffi(default = None)] @@ -201,7 +204,7 @@ mod tests { .expect("dotinfo_domain should have sale_coupon"); assert_eq!(coupon.discount, 65); assert_eq!(coupon.code, "fakecoupon123"); - assert_eq!(dotinfo.sale_cost, Some(7.0)); + assert_eq!(dotinfo.sale_cost, Some(Decimal2::from_hundredths(700))); } #[test] From 9de3016e375810a11b015e8fa51b754394d6c91e Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Mon, 20 Apr 2026 19:03:05 -0400 Subject: [PATCH 03/14] Use Decimal2 for Product cost fields and fix tld_rank type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Product.cost: f64 → Decimal2 - IntroductoryOffer.cost_per_interval: f64 → Decimal2 - SaleCoupon.tld_rank: Option → Option to match backend --- wp_api/src/wp_com/products.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wp_api/src/wp_com/products.rs b/wp_api/src/wp_com/products.rs index 79c6bbda7..d0734851b 100644 --- a/wp_api/src/wp_com/products.rs +++ b/wp_api/src/wp_com/products.rs @@ -39,7 +39,7 @@ pub struct Product { /// Formatted combined cost without decimal places (e.g. `"$18"`). pub combined_cost_display: String, /// Numeric cost in the account's currency. - pub cost: f64, + pub cost: Decimal2, /// Cost in the smallest currency unit (e.g. cents). pub cost_smallest_unit: u64, pub currency_code: String, @@ -135,7 +135,7 @@ pub struct SaleCoupon { pub code: String, #[serde(default)] #[uniffi(default = None)] - pub tld_rank: Option, + pub tld_rank: Option, } /// Introductory pricing offer for a product. @@ -148,7 +148,7 @@ pub struct IntroductoryOffer { #[uniffi(default = None)] pub usage_limit: Option, /// Cost per interval during the offer period. - pub cost_per_interval: f64, + pub cost_per_interval: Decimal2, pub transition_after_renewal_count: u32, pub should_prorate_when_offer_ends: bool, } From 04541d09c12053646162f2fc809e726227349964 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Mon, 20 Apr 2026 21:26:10 -0400 Subject: [PATCH 04/14] Add locale param to products endpoint Accept an optional WPComLanguage locale in ProductsParams to get localized product names and descriptions from the API. --- .../src/wp_com/endpoint/products_endpoint.rs | 24 +++++++ wp_api/src/wp_com/products.rs | 40 ++++++++++- .../tests/wpcom/products/all-locale-es.json | 68 +++++++++++++++++++ .../wpcom/products/domains-locale-ja.json | 48 +++++++++++++ wp_com_e2e/src/products_tests.rs | 27 +++++++- 5 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 wp_api/tests/wpcom/products/all-locale-es.json create mode 100644 wp_api/tests/wpcom/products/domains-locale-ja.json diff --git a/wp_api/src/wp_com/endpoint/products_endpoint.rs b/wp_api/src/wp_com/endpoint/products_endpoint.rs index fbab25094..3c083707f 100644 --- a/wp_api/src/wp_com/endpoint/products_endpoint.rs +++ b/wp_api/src/wp_com/endpoint/products_endpoint.rs @@ -31,6 +31,7 @@ mod tests { products::ProductsParams, }, }; + use crate::wp_com::language::WPComLanguage; use rstest::*; use std::sync::Arc; @@ -44,11 +45,34 @@ mod tests { validate_wp_com_rest_v1_1_endpoint( endpoint.list(&ProductsParams { product_type: Some("domains".to_string()), + ..Default::default() }), "/products?type=domains", ); } + #[rstest] + fn list_with_locale(endpoint: ProductsRequestEndpoint) { + validate_wp_com_rest_v1_1_endpoint( + endpoint.list(&ProductsParams { + locale: Some(WPComLanguage::Spanish), + ..Default::default() + }), + "/products?locale=es", + ); + } + + #[rstest] + fn list_with_type_and_locale(endpoint: ProductsRequestEndpoint) { + validate_wp_com_rest_v1_1_endpoint( + endpoint.list(&ProductsParams { + product_type: Some("domains".to_string()), + locale: Some(WPComLanguage::Japanese), + }), + "/products?type=domains&locale=ja", + ); + } + #[fixture] fn endpoint( fixture_wp_com_api_url_resolver: Arc, diff --git a/wp_api/src/wp_com/products.rs b/wp_api/src/wp_com/products.rs index d0734851b..6d1eb84f6 100644 --- a/wp_api/src/wp_com/products.rs +++ b/wp_api/src/wp_com/products.rs @@ -1,6 +1,7 @@ use crate::{ decimal2::Decimal2, url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension}, + wp_com::language::WPComLanguage, }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -11,11 +12,16 @@ pub struct ProductsParams { /// Filter by product type (e.g. `"domains"`). #[uniffi(default = None)] pub product_type: Option, + /// Locale for localized product names and descriptions. + #[uniffi(default = None)] + pub locale: Option, } impl AppendUrlQueryPairs for ProductsParams { fn append_query_pairs(&self, query_pairs_mut: &mut QueryPairs) { - query_pairs_mut.append_option_query_value_pair("type", self.product_type.as_ref()); + query_pairs_mut + .append_option_query_value_pair("type", self.product_type.as_ref()) + .append_option_query_value_pair("locale", self.locale.as_ref()); } } @@ -236,4 +242,36 @@ mod tests { assert_eq!(offer.interval_unit, "month"); assert_eq!(offer.interval_count, 3); } + + #[test] + fn test_products_locale_es_deserialization() { + let file = File::open("tests/wpcom/products/all-locale-es.json") + .expect("Failed to open file"); + let products: ProductMap = serde_json::from_reader(file).expect("Unable to parse JSON"); + + assert_eq!(products.len(), 3); + + // Localized product name. + let domain_map = products.get("domain_map").expect("domain_map missing"); + assert_eq!(domain_map.product_name, "Conexión de dominio falso"); + assert_eq!(domain_map.product_term_localized, "año"); + } + + #[test] + fn test_products_locale_ja_domains_deserialization() { + let file = File::open("tests/wpcom/products/domains-locale-ja.json") + .expect("Failed to open file"); + let products: ProductMap = serde_json::from_reader(file).expect("Unable to parse JSON"); + + assert_eq!(products.len(), 2); + + let domain_map = products.get("domain_map").expect("domain_map missing"); + assert_eq!(domain_map.product_name, "偽ドメイン連携"); + assert_eq!(domain_map.product_term_localized, "年"); + + // Domain registration fields still present with locale. + let domain_reg = products.get("domain_reg").expect("domain_reg missing"); + assert!(domain_reg.is_domain_registration); + assert_eq!(domain_reg.tld.as_deref(), Some("com")); + } } diff --git a/wp_api/tests/wpcom/products/all-locale-es.json b/wp_api/tests/wpcom/products/all-locale-es.json new file mode 100644 index 000000000..6c9a1cae6 --- /dev/null +++ b/wp_api/tests/wpcom/products/all-locale-es.json @@ -0,0 +1,68 @@ +{ + "domain_map": { + "product_id": 1001, + "product_name": "Conexión de dominio falso", + "product_slug": "domain_map", + "description": "Conecta un dominio externo a tu sitio.", + "product_type": "domain_map", + "available": true, + "billing_product_slug": "fake-domain-mapping", + "is_domain_registration": false, + "cost_display": "$0.00", + "combined_cost_display": "$0", + "cost": 0, + "cost_smallest_unit": 0, + "currency_code": "USD", + "price_tier_list": [], + "price_tier_usage_quantity": null, + "product_term": "year", + "product_term_localized": "año", + "price_tiers": [], + "price_tier_slug": "", + "cost_per_month_display": "$0" + }, + "fake_plan_yearly": { + "product_id": 5001, + "product_name": "Plan falso anual", + "product_slug": "fake_plan_yearly", + "description": "Plan básico con dominio gratuito.", + "product_type": "bundle", + "available": true, + "billing_product_slug": "fake-plan-yearly", + "is_domain_registration": false, + "cost_display": "$48.00", + "combined_cost_display": "$48", + "cost": 48, + "cost_smallest_unit": 4800, + "currency_code": "USD", + "price_tier_list": [], + "price_tier_usage_quantity": null, + "product_term": "year", + "product_term_localized": "año", + "price_tiers": [], + "price_tier_slug": "", + "cost_per_month_display": "$4" + }, + "fake_jetpack_backup_daily": { + "product_id": 4001, + "product_name": "Copia de seguridad diaria de Jetpack falsa", + "product_slug": "fake_jetpack_backup_daily", + "description": "Copias de seguridad diarias automatizadas.", + "product_type": "jetpack", + "available": true, + "billing_product_slug": "fake-jetpack-backup-daily", + "is_domain_registration": false, + "cost_display": "$120.00", + "combined_cost_display": "$120", + "cost": 120, + "cost_smallest_unit": 12000, + "currency_code": "USD", + "price_tier_list": [], + "price_tier_usage_quantity": null, + "product_term": "year", + "product_term_localized": "año", + "price_tiers": [], + "price_tier_slug": "", + "cost_per_month_display": "$10" + } +} diff --git a/wp_api/tests/wpcom/products/domains-locale-ja.json b/wp_api/tests/wpcom/products/domains-locale-ja.json new file mode 100644 index 000000000..a83528c59 --- /dev/null +++ b/wp_api/tests/wpcom/products/domains-locale-ja.json @@ -0,0 +1,48 @@ +{ + "domain_map": { + "product_id": 1001, + "product_name": "偽ドメイン連携", + "product_slug": "domain_map", + "description": "外部ドメインをサイトに接続します。", + "product_type": "domain_map", + "available": true, + "billing_product_slug": "fake-domain-mapping", + "is_domain_registration": false, + "cost_display": "$0.00", + "combined_cost_display": "$0", + "cost": 0, + "cost_smallest_unit": 0, + "currency_code": "USD", + "price_tier_list": [], + "price_tier_usage_quantity": null, + "product_term": "year", + "product_term_localized": "年", + "price_tiers": [], + "price_tier_slug": "", + "cost_per_month_display": "$0" + }, + "domain_reg": { + "product_id": 1002, + "product_name": "偽 .com ドメイン登録", + "product_slug": "domain_reg", + "description": ".com ドメインを登録します。", + "product_type": "domain_reg", + "available": true, + "billing_product_slug": "fake-dot-com-registration", + "is_domain_registration": true, + "cost_display": "$12.00", + "combined_cost_display": "$12", + "cost": 12, + "cost_smallest_unit": 1200, + "currency_code": "USD", + "price_tier_list": [], + "price_tier_usage_quantity": null, + "product_term": "year", + "product_term_localized": "年", + "price_tiers": [], + "price_tier_slug": "", + "tld": "com", + "is_privacy_protection_product_purchase_allowed": true, + "cost_per_month_display": "$1.00" + } +} diff --git a/wp_com_e2e/src/products_tests.rs b/wp_com_e2e/src/products_tests.rs index b982d9019..296c7993d 100644 --- a/wp_com_e2e/src/products_tests.rs +++ b/wp_com_e2e/src/products_tests.rs @@ -1,7 +1,7 @@ use crate::context::TestContext; use libtest_mimic::Trial; use std::sync::Arc; -use wp_api::wp_com::products::ProductsParams; +use wp_api::wp_com::{language::WPComLanguage, products::ProductsParams}; pub fn tests(ctx: Arc) -> Vec { let mut trials = vec![]; @@ -36,6 +36,7 @@ pub fn tests(ctx: Arc) -> Vec { .products() .list(&ProductsParams { product_type: Some("domains".to_string()), + ..Default::default() }) .await .map_err(|e| e.to_string())? @@ -61,5 +62,29 @@ pub fn tests(ctx: Arc) -> Vec { } })); + trials.push(Trial::test("products::list_with_locale", { + let ctx = Arc::clone(&ctx); + move || { + ctx.runtime.block_on(async { + let products = ctx + .client + .products() + .list(&ProductsParams { + locale: Some(WPComLanguage::Spanish), + ..Default::default() + }) + .await + .map_err(|e| e.to_string())? + .data; + + if products.is_empty() { + return Err("expected non-empty products list with locale".into()); + } + + Ok(()) + }) + } + })); + trials } From 07bca0ac9376c4cbc54d1924957421362e494673 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Mon, 20 Apr 2026 21:37:12 -0400 Subject: [PATCH 05/14] Add ProductTypeFilter enum for products endpoint Replace stringly-typed product_type filter with a ProductTypeFilter enum (Domains, Jetpack, Other) to prevent typos that silently return all results instead of a filtered set. --- .../src/wp_com/endpoint/products_endpoint.rs | 34 ++++++-- wp_api/src/wp_com/products.rs | 65 +++++++++++++- wp_api/tests/wpcom/products/jetpack.json | 84 +++++++++++++++++++ wp_com_e2e/src/products_tests.rs | 31 ++++++- 4 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 wp_api/tests/wpcom/products/jetpack.json diff --git a/wp_api/src/wp_com/endpoint/products_endpoint.rs b/wp_api/src/wp_com/endpoint/products_endpoint.rs index 3c083707f..153a68e22 100644 --- a/wp_api/src/wp_com/endpoint/products_endpoint.rs +++ b/wp_api/src/wp_com/endpoint/products_endpoint.rs @@ -28,10 +28,10 @@ mod tests { endpoint::tests::{ fixture_wp_com_api_url_resolver, validate_wp_com_rest_v1_1_endpoint, }, - products::ProductsParams, + language::WPComLanguage, + products::{ProductTypeFilter, ProductsParams}, }, }; - use crate::wp_com::language::WPComLanguage; use rstest::*; use std::sync::Arc; @@ -41,16 +41,40 @@ mod tests { } #[rstest] - fn list_with_type_filter(endpoint: ProductsRequestEndpoint) { + fn list_with_domains_filter(endpoint: ProductsRequestEndpoint) { validate_wp_com_rest_v1_1_endpoint( endpoint.list(&ProductsParams { - product_type: Some("domains".to_string()), + product_type: Some(ProductTypeFilter::Domains), ..Default::default() }), "/products?type=domains", ); } + #[rstest] + fn list_with_jetpack_filter(endpoint: ProductsRequestEndpoint) { + validate_wp_com_rest_v1_1_endpoint( + endpoint.list(&ProductsParams { + product_type: Some(ProductTypeFilter::Jetpack), + ..Default::default() + }), + "/products?type=jetpack", + ); + } + + #[rstest] + fn list_with_other_filter(endpoint: ProductsRequestEndpoint) { + validate_wp_com_rest_v1_1_endpoint( + endpoint.list(&ProductsParams { + product_type: Some(ProductTypeFilter::Other { + value: "theme".to_string(), + }), + ..Default::default() + }), + "/products?type=theme", + ); + } + #[rstest] fn list_with_locale(endpoint: ProductsRequestEndpoint) { validate_wp_com_rest_v1_1_endpoint( @@ -66,7 +90,7 @@ mod tests { fn list_with_type_and_locale(endpoint: ProductsRequestEndpoint) { validate_wp_com_rest_v1_1_endpoint( endpoint.list(&ProductsParams { - product_type: Some("domains".to_string()), + product_type: Some(ProductTypeFilter::Domains), locale: Some(WPComLanguage::Japanese), }), "/products?type=domains&locale=ja", diff --git a/wp_api/src/wp_com/products.rs b/wp_api/src/wp_com/products.rs index 6d1eb84f6..4c64dbe98 100644 --- a/wp_api/src/wp_com/products.rs +++ b/wp_api/src/wp_com/products.rs @@ -1,17 +1,41 @@ use crate::{ decimal2::Decimal2, - url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension}, + url_query::{AppendUrlQueryPairs, AsQueryValue, QueryPairs, QueryPairsExtension}, wp_com::language::WPComLanguage, }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +/// Filter for the `type` query parameter on `GET /products`. +/// +/// The API supports `"domains"` and `"jetpack"` as built-in filters. +/// Use `Other` for any value not covered by these variants. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] +pub enum ProductTypeFilter { + /// Return only domain-related products (registrations, transfers, mapping, etc.). + Domains, + /// Return only Jetpack plans and products. + Jetpack, + /// A product type filter not covered by the known variants. + Other { value: String }, +} + +impl AsQueryValue for ProductTypeFilter { + fn as_query_value(&self) -> impl AsRef { + match self { + Self::Domains => "domains".to_string(), + Self::Jetpack => "jetpack".to_string(), + Self::Other { value } => value.clone(), + } + } +} + /// Parameters for `GET /products`. #[derive(Debug, Default, Clone, PartialEq, Eq, uniffi::Record)] pub struct ProductsParams { - /// Filter by product type (e.g. `"domains"`). + /// Filter by product type. #[uniffi(default = None)] - pub product_type: Option, + pub product_type: Option, /// Locale for localized product names and descriptions. #[uniffi(default = None)] pub locale: Option, @@ -274,4 +298,39 @@ mod tests { assert!(domain_reg.is_domain_registration); assert_eq!(domain_reg.tld.as_deref(), Some("com")); } + + #[test] + fn test_products_jetpack_deserialization() { + let file = + File::open("tests/wpcom/products/jetpack.json").expect("Failed to open file"); + let products: ProductMap = serde_json::from_reader(file).expect("Unable to parse JSON"); + + assert_eq!(products.len(), 3); + + // Basic jetpack product without introductory offer. + let backup = products + .get("fake_jetpack_backup_daily") + .expect("fake_jetpack_backup_daily missing"); + assert_eq!(backup.product_type, "jetpack"); + assert!(!backup.is_domain_registration); + assert!(backup.introductory_offer.is_none()); + + // Jetpack product with introductory offer. + let security = products + .get("fake_jetpack_security_yearly") + .expect("fake_jetpack_security_yearly missing"); + assert_eq!(security.product_type, "jetpack"); + let offer = security + .introductory_offer + .as_ref() + .expect("should have introductory_offer"); + assert_eq!(offer.interval_unit, "year"); + assert_eq!(offer.cost_per_interval, Decimal2::from_hundredths(12000)); + + // Bundle product also returned by jetpack filter. + let complete = products + .get("fake_jetpack_complete") + .expect("fake_jetpack_complete missing"); + assert_eq!(complete.product_type, "bundle"); + } } diff --git a/wp_api/tests/wpcom/products/jetpack.json b/wp_api/tests/wpcom/products/jetpack.json new file mode 100644 index 000000000..ddf2defff --- /dev/null +++ b/wp_api/tests/wpcom/products/jetpack.json @@ -0,0 +1,84 @@ +{ + "fake_jetpack_backup_daily": { + "product_id": 4001, + "product_name": "Fake Jetpack VaultPress Backup Daily", + "product_slug": "fake_jetpack_backup_daily", + "description": "Daily automated backups.", + "product_type": "jetpack", + "available": true, + "billing_product_slug": "fake-jetpack-backup-daily", + "is_domain_registration": false, + "cost_display": "$120.00", + "combined_cost_display": "$120", + "cost": 120, + "cost_smallest_unit": 12000, + "currency_code": "USD", + "price_tier_list": [], + "price_tier_usage_quantity": null, + "product_term": "year", + "product_term_localized": "year", + "price_tiers": [], + "price_tier_slug": "", + "cost_per_month_display": "$10" + }, + "fake_jetpack_security_yearly": { + "product_id": 4002, + "product_name": "Fake Jetpack Security", + "product_slug": "fake_jetpack_security_yearly", + "description": "Security scanning and backups.", + "product_type": "jetpack", + "available": true, + "billing_product_slug": "fake-jetpack-security-yearly", + "is_domain_registration": false, + "cost_display": "$240.00", + "combined_cost_display": "$240", + "cost": 240, + "cost_smallest_unit": 24000, + "currency_code": "USD", + "price_tier_list": [], + "price_tier_usage_quantity": null, + "product_term": "year", + "product_term_localized": "year", + "price_tiers": [], + "price_tier_slug": "", + "introductory_offer": { + "interval_unit": "year", + "interval_count": 1, + "usage_limit": null, + "cost_per_interval": 120, + "transition_after_renewal_count": 0, + "should_prorate_when_offer_ends": false + }, + "cost_per_month_display": "$20" + }, + "fake_jetpack_complete": { + "product_id": 4003, + "product_name": "Fake Jetpack Complete", + "product_slug": "fake_jetpack_complete", + "description": "The full Jetpack suite.", + "product_type": "bundle", + "available": true, + "billing_product_slug": "fake-jetpack-complete", + "is_domain_registration": false, + "cost_display": "$500.00", + "combined_cost_display": "$500", + "cost": 500, + "cost_smallest_unit": 50000, + "currency_code": "USD", + "price_tier_list": [], + "price_tier_usage_quantity": null, + "product_term": "year", + "product_term_localized": "year", + "price_tiers": [], + "price_tier_slug": "", + "introductory_offer": { + "interval_unit": "year", + "interval_count": 1, + "usage_limit": null, + "cost_per_interval": 250, + "transition_after_renewal_count": 0, + "should_prorate_when_offer_ends": false + }, + "cost_per_month_display": "$41.67" + } +} diff --git a/wp_com_e2e/src/products_tests.rs b/wp_com_e2e/src/products_tests.rs index 296c7993d..21b832aed 100644 --- a/wp_com_e2e/src/products_tests.rs +++ b/wp_com_e2e/src/products_tests.rs @@ -1,7 +1,10 @@ use crate::context::TestContext; use libtest_mimic::Trial; use std::sync::Arc; -use wp_api::wp_com::{language::WPComLanguage, products::ProductsParams}; +use wp_api::wp_com::{ + language::WPComLanguage, + products::{ProductTypeFilter, ProductsParams}, +}; pub fn tests(ctx: Arc) -> Vec { let mut trials = vec![]; @@ -35,7 +38,7 @@ pub fn tests(ctx: Arc) -> Vec { .client .products() .list(&ProductsParams { - product_type: Some("domains".to_string()), + product_type: Some(ProductTypeFilter::Domains), ..Default::default() }) .await @@ -62,6 +65,30 @@ pub fn tests(ctx: Arc) -> Vec { } })); + trials.push(Trial::test("products::list_jetpack", { + let ctx = Arc::clone(&ctx); + move || { + ctx.runtime.block_on(async { + let products = ctx + .client + .products() + .list(&ProductsParams { + product_type: Some(ProductTypeFilter::Jetpack), + ..Default::default() + }) + .await + .map_err(|e| e.to_string())? + .data; + + if products.is_empty() { + return Err("expected non-empty jetpack products list".into()); + } + + Ok(()) + }) + } + })); + trials.push(Trial::test("products::list_with_locale", { let ctx = Arc::clone(&ctx); move || { From cb6e09f85c8af8366be25e5fa2ab7957ae467f82 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Mon, 20 Apr 2026 22:04:07 -0400 Subject: [PATCH 06/14] Extract DomainProductInfo struct for domain-specific fields Group domain-specific optional fields (tld, is_privacy_protection_- product_purchase_allowed, is_hsts_required, is_dot_gay_notice_required) into a DomainProductInfo struct, flattened for JSON compatibility. --- wp_api/src/wp_com/products.rs | 54 +++++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/wp_api/src/wp_com/products.rs b/wp_api/src/wp_com/products.rs index 4c64dbe98..3b73128d8 100644 --- a/wp_api/src/wp_com/products.rs +++ b/wp_api/src/wp_com/products.rs @@ -81,23 +81,10 @@ pub struct Product { #[serde(default)] #[uniffi(default = [])] pub price_tier_list: Vec, - /// Top-level domain for registration products (e.g. `"com"`, `"net"`). - /// Absent for non-registration products like domain mapping. - #[serde(default)] - #[uniffi(default = None)] - pub tld: Option, - /// Whether WHOIS privacy can be purchased with this domain. - #[serde(default)] - #[uniffi(default = None)] - pub is_privacy_protection_product_purchase_allowed: Option, - /// Whether HSTS is required for this TLD (e.g. `.dev`, `.app`). - #[serde(default)] - #[uniffi(default = None)] - pub is_hsts_required: Option, - /// Whether the `.gay` TLD policy notice is required. - #[serde(default)] - #[uniffi(default = None)] - pub is_dot_gay_notice_required: Option, + /// Domain-specific fields. Fields are populated only for domain + /// registration products; all will be `None` for other product types. + #[serde(flatten)] + pub domain_info: DomainProductInfo, /// Formatted monthly cost (e.g. `"$1.50"`). #[serde(default)] #[uniffi(default = None)] @@ -120,6 +107,29 @@ pub struct Product { pub introductory_offer: Option, } +/// Domain-specific product metadata, only populated for domain registration +/// products. +#[derive(Debug, Default, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct DomainProductInfo { + /// Top-level domain for registration products (e.g. `"com"`, `"net"`). + /// Absent for non-registration products like domain mapping. + #[serde(default)] + #[uniffi(default = None)] + pub tld: Option, + /// Whether WHOIS privacy can be purchased with this domain. + #[serde(default)] + #[uniffi(default = None)] + pub is_privacy_protection_product_purchase_allowed: Option, + /// Whether HSTS is required for this TLD (e.g. `.dev`, `.app`). + #[serde(default)] + #[uniffi(default = None)] + pub is_hsts_required: Option, + /// Whether the `.gay` TLD policy notice is required. + #[serde(default)] + #[uniffi(default = None)] + pub is_dot_gay_notice_required: Option, +} + /// A pricing tier for usage-based products. #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct PriceTier { @@ -200,24 +210,24 @@ mod tests { let domain_map = products.get("domain_map").expect("domain_map missing"); assert_eq!(domain_map.product_id, 1001); assert!(!domain_map.is_domain_registration); - assert_eq!(domain_map.tld, None); + assert_eq!(domain_map.domain_info.tld, None); // domain_reg is a registration product. let domain_reg = products.get("domain_reg").expect("domain_reg missing"); assert!(domain_reg.is_domain_registration); - assert_eq!(domain_reg.tld.as_deref(), Some("com")); + assert_eq!(domain_reg.domain_info.tld.as_deref(), Some("com")); // dotdev_domain requires HSTS. let dotdev = products .get("dotdev_domain") .expect("dotdev_domain missing"); - assert_eq!(dotdev.is_hsts_required, Some(true)); + assert_eq!(dotdev.domain_info.is_hsts_required, Some(true)); // dotgay_domain requires the .gay policy notice. let dotgay = products .get("dotgay_domain") .expect("dotgay_domain missing"); - assert_eq!(dotgay.is_dot_gay_notice_required, Some(true)); + assert_eq!(dotgay.domain_info.is_dot_gay_notice_required, Some(true)); } #[test] @@ -296,7 +306,7 @@ mod tests { // Domain registration fields still present with locale. let domain_reg = products.get("domain_reg").expect("domain_reg missing"); assert!(domain_reg.is_domain_registration); - assert_eq!(domain_reg.tld.as_deref(), Some("com")); + assert_eq!(domain_reg.domain_info.tld.as_deref(), Some("com")); } #[test] From 327cd48f1de69cc004657e750a9cd70aae4943ea Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Mon, 20 Apr 2026 22:11:13 -0400 Subject: [PATCH 07/14] Fix missing &self in ProductsRequest::namespace Match the DerivedRequest trait signature which requires &self. --- wp_api/src/wp_com/endpoint/products_endpoint.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wp_api/src/wp_com/endpoint/products_endpoint.rs b/wp_api/src/wp_com/endpoint/products_endpoint.rs index 153a68e22..866965441 100644 --- a/wp_api/src/wp_com/endpoint/products_endpoint.rs +++ b/wp_api/src/wp_com/endpoint/products_endpoint.rs @@ -14,7 +14,7 @@ enum ProductsRequest { } impl DerivedRequest for ProductsRequest { - fn namespace() -> impl AsNamespace { + fn namespace(&self) -> impl AsNamespace { WpComNamespace::RestV1_1 } } From 49b834ef49403c0833d8d19fef0436f3ceffe116 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Mon, 20 Apr 2026 22:56:23 -0400 Subject: [PATCH 08/14] Cargo fmt --- wp_api/src/wp_com/products.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/wp_api/src/wp_com/products.rs b/wp_api/src/wp_com/products.rs index 3b73128d8..95641ee2d 100644 --- a/wp_api/src/wp_com/products.rs +++ b/wp_api/src/wp_com/products.rs @@ -279,8 +279,8 @@ mod tests { #[test] fn test_products_locale_es_deserialization() { - let file = File::open("tests/wpcom/products/all-locale-es.json") - .expect("Failed to open file"); + let file = + File::open("tests/wpcom/products/all-locale-es.json").expect("Failed to open file"); let products: ProductMap = serde_json::from_reader(file).expect("Unable to parse JSON"); assert_eq!(products.len(), 3); @@ -293,8 +293,8 @@ mod tests { #[test] fn test_products_locale_ja_domains_deserialization() { - let file = File::open("tests/wpcom/products/domains-locale-ja.json") - .expect("Failed to open file"); + let file = + File::open("tests/wpcom/products/domains-locale-ja.json").expect("Failed to open file"); let products: ProductMap = serde_json::from_reader(file).expect("Unable to parse JSON"); assert_eq!(products.len(), 2); @@ -311,8 +311,7 @@ mod tests { #[test] fn test_products_jetpack_deserialization() { - let file = - File::open("tests/wpcom/products/jetpack.json").expect("Failed to open file"); + let file = File::open("tests/wpcom/products/jetpack.json").expect("Failed to open file"); let products: ProductMap = serde_json::from_reader(file).expect("Unable to parse JSON"); assert_eq!(products.len(), 3); From e07ceb7e7faafe9c06fc5a187d8e4962a3886679 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Wed, 22 Apr 2026 21:44:49 -0400 Subject: [PATCH 09/14] Add CurrencyCode and ProductId newtypes CurrencyCode (in wp_com module) wraps an ISO 4217 string. ProductId (in products module) wraps a u64 with built-in deserialization from both numeric and string representations, since the API is inconsistent about the encoding. --- wp_api/src/wp_com/mod.rs | 12 ++++++++++++ wp_api/src/wp_com/products.rs | 34 +++++++++++++++++++++++++++++----- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/wp_api/src/wp_com/mod.rs b/wp_api/src/wp_com/mod.rs index 627930f69..522bfaa33 100644 --- a/wp_api/src/wp_com/mod.rs +++ b/wp_api/src/wp_com/mod.rs @@ -58,6 +58,18 @@ impl std::fmt::Display for WpComSiteId { } } +uniffi::custom_newtype!(CurrencyCode, String); +/// ISO 4217 currency code (e.g. `"USD"`, `"TRY"`, `"EUR"`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct CurrencyCode(pub String); + +impl std::fmt::Display for CurrencyCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + pub(crate) enum WpComNamespace { Oauth2, RestV1_1, diff --git a/wp_api/src/wp_com/products.rs b/wp_api/src/wp_com/products.rs index 95641ee2d..c5cb4baf3 100644 --- a/wp_api/src/wp_com/products.rs +++ b/wp_api/src/wp_com/products.rs @@ -1,11 +1,35 @@ use crate::{ decimal2::Decimal2, url_query::{AppendUrlQueryPairs, AsQueryValue, QueryPairs, QueryPairsExtension}, - wp_com::language::WPComLanguage, + wp_com::{CurrencyCode, language::WPComLanguage}, }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +uniffi::custom_newtype!(ProductId, u64); +/// WordPress.com product identifier. +/// +/// Deserializes from both numeric (`6`) and string (`"6"`) representations, +/// since the API is inconsistent about the encoding. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct ProductId(pub u64); + +impl<'de> Deserialize<'de> for ProductId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + wp_serde_helper::deserialize_u64_or_string(deserializer).map(Self) + } +} + +impl std::fmt::Display for ProductId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + /// Filter for the `type` query parameter on `GET /products`. /// /// The API supports `"domains"` and `"jetpack"` as built-in filters. @@ -55,7 +79,7 @@ pub type ProductMap = HashMap; /// A WordPress.com product. #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct Product { - pub product_id: u64, + pub product_id: ProductId, pub product_name: String, pub product_slug: String, pub description: String, @@ -72,7 +96,7 @@ pub struct Product { pub cost: Decimal2, /// Cost in the smallest currency unit (e.g. cents). pub cost_smallest_unit: u64, - pub currency_code: String, + pub currency_code: CurrencyCode, /// Billing period (e.g. `"year"`). pub product_term: String, /// Localized billing period label. @@ -165,7 +189,7 @@ pub struct SaleCoupon { pub expires: String, /// Discount percentage (e.g. `65` means 65% off). pub discount: u32, - pub product_ids: Vec, + pub product_ids: Vec, #[serde(default)] #[uniffi(default = [])] pub purchase_types: Vec, @@ -208,7 +232,7 @@ mod tests { // domain_map is a non-registration product without tld. let domain_map = products.get("domain_map").expect("domain_map missing"); - assert_eq!(domain_map.product_id, 1001); + assert_eq!(domain_map.product_id, ProductId(1001)); assert!(!domain_map.is_domain_registration); assert_eq!(domain_map.domain_info.tld, None); From 9efe20d8f7ae23347b37aeb5a7dadeb7adfd7b4f Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Sun, 26 Apr 2026 16:43:39 -0400 Subject: [PATCH 10/14] Make DomainProductInfo optional with non-optional inner fields Since tld and is_privacy_protection_product_purchase_allowed are always present for domain products, make them non-optional. With tld as a required String, serde(flatten) correctly produces None for non-domain products and Some for domain products. --- wp_api/src/wp_com/products.rs | 60 +++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/wp_api/src/wp_com/products.rs b/wp_api/src/wp_com/products.rs index c5cb4baf3..f63ca6c70 100644 --- a/wp_api/src/wp_com/products.rs +++ b/wp_api/src/wp_com/products.rs @@ -105,10 +105,9 @@ pub struct Product { #[serde(default)] #[uniffi(default = [])] pub price_tier_list: Vec, - /// Domain-specific fields. Fields are populated only for domain - /// registration products; all will be `None` for other product types. + /// Domain-specific fields, present only for domain registration products. #[serde(flatten)] - pub domain_info: DomainProductInfo, + pub domain_info: Option, /// Formatted monthly cost (e.g. `"$1.50"`). #[serde(default)] #[uniffi(default = None)] @@ -133,25 +132,20 @@ pub struct Product { /// Domain-specific product metadata, only populated for domain registration /// products. -#[derive(Debug, Default, Clone, Serialize, Deserialize, uniffi::Record)] +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct DomainProductInfo { - /// Top-level domain for registration products (e.g. `"com"`, `"net"`). - /// Absent for non-registration products like domain mapping. - #[serde(default)] - #[uniffi(default = None)] - pub tld: Option, + /// Top-level domain (e.g. `"com"`, `"net"`). + pub tld: String, /// Whether WHOIS privacy can be purchased with this domain. - #[serde(default)] - #[uniffi(default = None)] - pub is_privacy_protection_product_purchase_allowed: Option, + pub is_privacy_protection_product_purchase_allowed: bool, /// Whether HSTS is required for this TLD (e.g. `.dev`, `.app`). #[serde(default)] - #[uniffi(default = None)] - pub is_hsts_required: Option, + #[uniffi(default = false)] + pub is_hsts_required: bool, /// Whether the `.gay` TLD policy notice is required. #[serde(default)] - #[uniffi(default = None)] - pub is_dot_gay_notice_required: Option, + #[uniffi(default = false)] + pub is_dot_gay_notice_required: bool, } /// A pricing tier for usage-based products. @@ -230,28 +224,41 @@ mod tests { assert_eq!(products.len(), 5); - // domain_map is a non-registration product without tld. + // domain_map is a non-registration product without domain_info. let domain_map = products.get("domain_map").expect("domain_map missing"); assert_eq!(domain_map.product_id, ProductId(1001)); assert!(!domain_map.is_domain_registration); - assert_eq!(domain_map.domain_info.tld, None); + assert!(domain_map.domain_info.is_none()); - // domain_reg is a registration product. + // domain_reg is a registration product with domain_info. let domain_reg = products.get("domain_reg").expect("domain_reg missing"); assert!(domain_reg.is_domain_registration); - assert_eq!(domain_reg.domain_info.tld.as_deref(), Some("com")); + let domain_reg_info = domain_reg + .domain_info + .as_ref() + .expect("domain_reg should have domain_info"); + assert_eq!(domain_reg_info.tld, "com"); + assert!(domain_reg_info.is_privacy_protection_product_purchase_allowed); // dotdev_domain requires HSTS. let dotdev = products .get("dotdev_domain") .expect("dotdev_domain missing"); - assert_eq!(dotdev.domain_info.is_hsts_required, Some(true)); + let dotdev_info = dotdev + .domain_info + .as_ref() + .expect("dotdev_domain should have domain_info"); + assert!(dotdev_info.is_hsts_required); // dotgay_domain requires the .gay policy notice. let dotgay = products .get("dotgay_domain") .expect("dotgay_domain missing"); - assert_eq!(dotgay.domain_info.is_dot_gay_notice_required, Some(true)); + let dotgay_info = dotgay + .domain_info + .as_ref() + .expect("dotgay_domain should have domain_info"); + assert!(dotgay_info.is_dot_gay_notice_required); } #[test] @@ -330,7 +337,14 @@ mod tests { // Domain registration fields still present with locale. let domain_reg = products.get("domain_reg").expect("domain_reg missing"); assert!(domain_reg.is_domain_registration); - assert_eq!(domain_reg.domain_info.tld.as_deref(), Some("com")); + assert_eq!( + domain_reg + .domain_info + .as_ref() + .expect("domain_reg should have domain_info") + .tld, + "com" + ); } #[test] From 252f79cf211c7afccb4980fccf673729dccfc5cc Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Sun, 26 Apr 2026 16:49:17 -0400 Subject: [PATCH 11/14] Add ProductTerm enum for billing period Known values from the backend: month, year, two years, three years, hundred years, one time. Includes Other(String) fallback. --- wp_api/src/wp_com/products.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/wp_api/src/wp_com/products.rs b/wp_api/src/wp_com/products.rs index f63ca6c70..fc4b8c8d7 100644 --- a/wp_api/src/wp_com/products.rs +++ b/wp_api/src/wp_com/products.rs @@ -73,6 +73,26 @@ impl AppendUrlQueryPairs for ProductsParams { } } +/// Billing interval for a product. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] +pub enum ProductTerm { + #[serde(rename = "month")] + Month, + #[serde(rename = "year")] + Year, + #[serde(rename = "two years")] + TwoYears, + #[serde(rename = "three years")] + ThreeYears, + #[serde(rename = "hundred years")] + HundredYears, + #[serde(rename = "one time")] + OneTime, + /// A billing term not covered by the known variants. + #[serde(untagged)] + Other(String), +} + /// Map of product slug to product, as returned by `GET /products`. pub type ProductMap = HashMap; @@ -97,8 +117,8 @@ pub struct Product { /// Cost in the smallest currency unit (e.g. cents). pub cost_smallest_unit: u64, pub currency_code: CurrencyCode, - /// Billing period (e.g. `"year"`). - pub product_term: String, + /// Billing period. + pub product_term: ProductTerm, /// Localized billing period label. pub product_term_localized: String, pub price_tier_slug: String, From ba14767f6b1f1106231f7e4e0f3083c861cd6350 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Sun, 26 Apr 2026 17:04:14 -0400 Subject: [PATCH 12/14] Add TimeSpanUnit enum for introductory offer interval Matches the backend's `Time_Span_Unit` enum: day, week, month, year, indefinite. Includes Other(String) fallback. --- wp_api/src/wp_com/mod.rs | 16 ++++++++++++++++ wp_api/src/wp_com/products.rs | 10 +++++----- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/wp_api/src/wp_com/mod.rs b/wp_api/src/wp_com/mod.rs index 522bfaa33..56c7535eb 100644 --- a/wp_api/src/wp_com/mod.rs +++ b/wp_api/src/wp_com/mod.rs @@ -70,6 +70,22 @@ impl std::fmt::Display for CurrencyCode { } } +/// Unit of a time span in the billing system. +/// +/// Corresponds to the backend's `Time_Span_Unit` enum. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] +#[serde(rename_all = "snake_case")] +pub enum TimeSpanUnit { + Day, + Week, + Month, + Year, + Indefinite, + /// A unit not covered by the known variants. + #[serde(untagged)] + Other(String), +} + pub(crate) enum WpComNamespace { Oauth2, RestV1_1, diff --git a/wp_api/src/wp_com/products.rs b/wp_api/src/wp_com/products.rs index fc4b8c8d7..6b1cea4da 100644 --- a/wp_api/src/wp_com/products.rs +++ b/wp_api/src/wp_com/products.rs @@ -1,7 +1,7 @@ use crate::{ decimal2::Decimal2, url_query::{AppendUrlQueryPairs, AsQueryValue, QueryPairs, QueryPairsExtension}, - wp_com::{CurrencyCode, language::WPComLanguage}, + wp_com::{CurrencyCode, TimeSpanUnit, language::WPComLanguage}, }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -219,8 +219,8 @@ pub struct SaleCoupon { /// Introductory pricing offer for a product. #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct IntroductoryOffer { - /// Unit of the offer interval (e.g. `"month"`). - pub interval_unit: String, + /// Unit of the offer interval. + pub interval_unit: TimeSpanUnit, pub interval_count: u32, #[serde(default)] #[uniffi(default = None)] @@ -324,7 +324,7 @@ mod tests { .introductory_offer .as_ref() .expect("fake_mail_monthly should have introductory_offer"); - assert_eq!(offer.interval_unit, "month"); + assert_eq!(offer.interval_unit, TimeSpanUnit::Month); assert_eq!(offer.interval_count, 3); } @@ -391,7 +391,7 @@ mod tests { .introductory_offer .as_ref() .expect("should have introductory_offer"); - assert_eq!(offer.interval_unit, "year"); + assert_eq!(offer.interval_unit, TimeSpanUnit::Year); assert_eq!(offer.cost_per_interval, Decimal2::from_hundredths(12000)); // Bundle product also returned by jetpack filter. From b01200575b004f7f05c9dd8bae55f01b3cc9c39e Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Sun, 26 Apr 2026 17:13:35 -0400 Subject: [PATCH 13/14] Use WpGmtDateTime for SaleCoupon date fields The existing date deserializer already handles the MySQL format ("2025-01-01 00:00:00") used by the coupon start_date and expires fields. --- wp_api/src/wp_com/products.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/wp_api/src/wp_com/products.rs b/wp_api/src/wp_com/products.rs index 6b1cea4da..58245306e 100644 --- a/wp_api/src/wp_com/products.rs +++ b/wp_api/src/wp_com/products.rs @@ -1,4 +1,5 @@ use crate::{ + date::WpGmtDateTime, decimal2::Decimal2, url_query::{AppendUrlQueryPairs, AsQueryValue, QueryPairs, QueryPairsExtension}, wp_com::{CurrencyCode, TimeSpanUnit, language::WPComLanguage}, @@ -199,8 +200,8 @@ pub struct PriceTier { /// Details of an active sale coupon applied to a product. #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct SaleCoupon { - pub start_date: String, - pub expires: String, + pub start_date: WpGmtDateTime, + pub expires: WpGmtDateTime, /// Discount percentage (e.g. `65` means 65% off). pub discount: u32, pub product_ids: Vec, From e022ffa2f844defd4102980938922b8fb5778b27 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Mon, 4 May 2026 13:23:08 -0400 Subject: [PATCH 14/14] Add changelog entry for `/products` endpoint --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8307cc69f..af35e5284 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [Template Part Revisions](https://developer.wordpress.org/rest-api/reference/wp_template_part-revisions/) endpoint - WordPress.com Publicize endpoints (`/sites//publicize/connections` and `/sites//publicize/services`) for listing, creating, updating, and deleting Jetpack Social connections - WordPress.com `/me/connections` (keyring) endpoint for listing third-party OAuth connections used by Jetpack Social +- WordPress.com `/products` endpoint - `WpApiCache` APIs to remove cached data for a self-hosted site (by URL) or a WordPress.com site (by site ID), with matching Swift wrappers on `WordPressApiCache` - `WpDerivedRequest` now supports plain `get` requests - `WpDerivedRequest` now supports `additional_query_pairs`