diff --git a/CHANGELOG.md b/CHANGELOG.md index caec18c75..4ca590e47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- WordPress.com `/domains/{name}/is-available` endpoint for checking domain availability, pricing, and transfer status + ## [0.2.0] - 2026-05-05 ### Added diff --git a/wp_api/src/wp_com/domains.rs b/wp_api/src/wp_com/domains.rs index 8432f9a1e..0238ad334 100644 --- a/wp_api/src/wp_com/domains.rs +++ b/wp_api/src/wp_com/domains.rs @@ -2,7 +2,7 @@ use crate::{ decimal2::Decimal2, impl_as_query_value_for_new_type, url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension}, - wp_com::segments::SegmentId, + wp_com::{CurrencyCode, WpComSiteId, products::ProductId, segments::SegmentId}, }; use serde::{Deserialize, Serialize}; @@ -116,7 +116,7 @@ pub struct PaidDomainSuggestion { /// Whether the domain supports multi-year registrations. pub multi_year_reg_allowed: bool, /// WordPress.com product ID used to purchase this domain. - pub product_id: u64, + pub product_id: ProductId, /// WordPress.com product slug used to purchase this domain. pub product_slug: String, /// Formatted registration cost (e.g. `"$18.00"`). @@ -127,8 +127,8 @@ pub struct PaidDomainSuggestion { pub renew_raw_price: Decimal2, /// Raw numeric registration price in `currency_code`. pub raw_price: Decimal2, - /// ISO 4217 currency code for `raw_price`/`renew_raw_price` (e.g. `"USD"`). - pub currency_code: String, + /// ISO 4217 currency code for `raw_price`/`renew_raw_price`. + pub currency_code: CurrencyCode, /// Promotional sale price in `currency_code`, if the domain is on sale. pub sale_cost: Option, /// `true` if the TLD requires HSTS (e.g. `.dev`). @@ -151,6 +151,148 @@ pub struct DomainPolicyNotice { pub message: String, } +/// Optional query parameters for `GET /domains/{name}/is-available/`. +#[derive(Debug, Default, Clone, PartialEq, Eq, uniffi::Record)] +pub struct DomainAvailabilityParams { + /// Site ID to check domain availability against. + #[uniffi(default = None)] + pub blog_id: Option, + /// Whether this is a pre-check before adding to cart. + #[uniffi(default = None)] + pub is_cart_pre_check: Option, + /// Vendor for the availability check (e.g. `"100-year-domains"`). + #[uniffi(default = None)] + pub vendor: Option, +} + +impl AppendUrlQueryPairs for DomainAvailabilityParams { + fn append_query_pairs(&self, query_pairs_mut: &mut QueryPairs) { + query_pairs_mut + .append_option_query_value_pair("blog_id", self.blog_id.as_ref()) + .append_option_query_value_pair("is_cart_pre_check", self.is_cart_pre_check.as_ref()) + .append_option_query_value_pair("vendor", self.vendor.as_ref()); + } +} + +/// Availability status for a domain checked via `GET /domains/{name}/is-available/`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] +#[serde(rename_all = "snake_case")] +pub enum DomainAvailabilityStatus { + /// Domain is available for registration. + Available, + /// Premium domain available at a higher price. + AvailablePremium, + /// Domain can be transferred in. + Transferrable, + /// Premium domain can be transferred in. + TransferrablePremium, + /// Already registered by the same user on a different site. + RegisteredOnOtherSiteSameUser, + /// Already mapped to another site by the same user. + MappedToOtherSiteSameUser, + /// TLD is not supported for registration. + TldNotSupported, + /// TLD is currently in maintenance. + TldInMaintenance, + /// Domain is blacklisted. + BlacklistedDomain, + /// A status not covered by the known variants. + #[serde(untagged)] + Other(String), +} + +/// Response from `GET /domains/{name}/is-available/` (v1.3). +/// +/// Reports whether a domain name is available for registration or +/// mapping, along with pricing and product details when applicable. +/// +/// The set of fields present varies by `status`: available domains +/// include full pricing, transferrable domains include partial +/// pricing, and unavailable domains have only the core fields. +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct DomainAvailability { + /// The domain name that was checked. + pub domain_name: String, + /// The TLD portion of the domain (e.g. `"com"`, `"io"`, `"dev"`). + pub tld: String, + /// Availability status. + pub status: DomainAvailabilityStatus, + /// Whether the domain can be mapped to a WordPress.com site + /// (e.g. `"mappable"`, `"blacklisted_domain"`). + pub mappable: String, + /// Whether the domain supports WHOIS privacy protection. + #[serde(default)] + #[uniffi(default = false)] + pub supports_privacy: bool, + /// Provider of the root domain (`"wpcom"` or `"unknown"`). + pub root_domain_provider: String, + // -- Product/pricing fields (conditional on status) -- + /// WordPress.com product ID for purchasing this domain. + pub product_id: Option, + /// WordPress.com product slug (e.g. `"domain_reg"`, + /// `"domain_transfer"`). + pub product_slug: Option, + /// Formatted registration/transfer cost (e.g. `"$18.00"`). + pub cost: Option, + /// Formatted renewal cost. + pub renew_cost: Option, + /// Raw numeric renewal price in `currency_code`. + pub renew_raw_price: Option, + /// Raw numeric registration/transfer price in `currency_code`. + pub raw_price: Option, + /// ISO 4217 currency code. + pub currency_code: Option, + /// Discounted sale price when a coupon applies. + pub sale_cost: Option, + /// `true` if a premium domain exceeds the price limit. + pub is_price_limit_exceeded: Option, + // -- Match/vendor fields (available domains only) -- + /// Reasons the domain matched (e.g. `"exact-match"`, + /// `"tld-exact"`, `"tld-common"`). + pub match_reasons: Option>, + /// The registry vendor (e.g. `"availability"`). + pub vendor: Option, + /// `true` for status `"available_premium"`. + pub is_supported_premium_domain: Option, + // -- Mapping/transfer fields -- + /// Type of ownership verification required (e.g. + /// `"no_verification_required"`). + pub ownership_verification_type: Option, + /// Transfer status for mapped domains (e.g. `"transferrable"`, + /// `"recent_registration_lock_not_transferrable"`). + pub transferrability: Option, + /// Primary domain of the other site where this domain is + /// registered or mapped (same user, different site). + pub other_site_domain: Option, + // -- TLD-specific fields -- + /// `true` if the TLD requires HSTS (e.g. `.dev`). + pub hsts_required: Option, + /// `true` if the `.gay` TLD policy notice is required. + pub is_dot_gay_notice_required: Option, + /// `true` if premium domain transfers are unsupported for this TLD. + pub cannot_transfer_due_to_unsupported_premium_tld: Option, + /// Policy notices attached to the domain (e.g. HSTS warnings). + #[serde(default)] + #[uniffi(default = [])] + pub policy_notices: Vec, + // -- Maintenance -- + /// When domain registration or TLD is in maintenance, the end time. + pub maintenance_end_time: Option, +} + +impl_as_query_value_for_new_type!(DomainName); +uniffi::custom_newtype!(DomainName, String); +/// A domain name (e.g. `"example.com"`, `"myblog.org"`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct DomainName(pub String); + +impl std::fmt::Display for DomainName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + impl_as_query_value_for_new_type!(CountryCode); uniffi::custom_newtype!(CountryCode, String); /// ISO 3166-1 alpha-2 country code (e.g. `"US"`, `"CA"`, `"GB"`). @@ -330,15 +472,15 @@ mod tests { ); assert_eq!(first.max_reg_years, 10); assert!(first.multi_year_reg_allowed); - assert_eq!(first.product_id, 6); + assert_eq!(first.product_id, ProductId(6)); assert_eq!(first.product_slug, "domain_reg"); assert_eq!(first.cost, "$18.00"); assert_eq!(first.renew_cost, "$18.00"); assert_eq!(first.renew_raw_price.hundredths(), 1800); assert_eq!(first.raw_price.hundredths(), 1800); - assert_eq!(first.currency_code, "USD"); - assert_eq!(first.sale_cost, None); - assert_eq!(first.hsts_required, None); + assert_eq!(first.currency_code, CurrencyCode("USD".to_string())); + assert!(first.sale_cost.is_none()); + assert!(first.hsts_required.is_none()); assert!(first.policy_notices.is_empty()); // `freshpage.art` has no `match_reasons` field in the JSON. @@ -509,4 +651,243 @@ mod tests { assert_eq!(only.domain_name, "testsite.home.blog"); assert_eq!(only.vendor, "dotblogsubdomains"); } + + #[rstest] + #[case::available( + "tests/wpcom/domains/is_available/available.json", + "freshsite2025.com", + DomainAvailabilityStatus::Available, + true + )] + #[case::blacklisted( + "tests/wpcom/domains/is_available/not-available.json", + "example.com", + DomainAvailabilityStatus::BlacklistedDomain, + true + )] + #[case::transferrable( + "tests/wpcom/domains/is_available/transferrable.json", + "taken-domain.io", + DomainAvailabilityStatus::Transferrable, + true + )] + #[case::tld_not_supported( + "tests/wpcom/domains/is_available/tld-not-supported.json", + "mysite.ai", + DomainAvailabilityStatus::TldNotSupported, + false + )] + #[case::hsts_required( + "tests/wpcom/domains/is_available/hsts-required.json", + "myproject.dev", + DomainAvailabilityStatus::Other("recent_registration_lock_not_transferrable".to_string()), + false + )] + #[case::available_premium( + "tests/wpcom/domains/is_available/available-premium.json", + "luxury.com", + DomainAvailabilityStatus::AvailablePremium, + true + )] + #[case::mapped_same_user( + "tests/wpcom/domains/is_available/mapped-same-user.json", + "myblog.com", + DomainAvailabilityStatus::MappedToOtherSiteSameUser, + true + )] + #[case::sale_coupon( + "tests/wpcom/domains/is_available/sale-coupon.json", + "freshblog2025.online", + DomainAvailabilityStatus::Available, + true + )] + #[case::maintenance( + "tests/wpcom/domains/is_available/maintenance.json", + "mysite.example", + DomainAvailabilityStatus::TldInMaintenance, + false + )] + #[case::dot_gay_notice( + "tests/wpcom/domains/is_available/dot-gay-notice.json", + "testsite2025.gay", + DomainAvailabilityStatus::Other("mappable".to_string()), + true + )] + fn test_domain_availability_deserialization( + #[case] json_file_path: &str, + #[case] expected_domain: &str, + #[case] expected_status: DomainAvailabilityStatus, + #[case] expected_privacy: bool, + ) { + let file = File::open(json_file_path).expect("Failed to open file"); + let availability: DomainAvailability = + serde_json::from_reader(file).expect("Unable to parse JSON"); + assert_eq!(availability.domain_name, expected_domain); + assert_eq!(availability.status, expected_status); + assert_eq!(availability.supports_privacy, expected_privacy); + } + + #[test] + fn test_domain_availability_available_details() { + let file = File::open("tests/wpcom/domains/is_available/available.json") + .expect("Failed to open file"); + let availability: DomainAvailability = + serde_json::from_reader(file).expect("Unable to parse JSON"); + assert_eq!(availability.product_id, Some(ProductId(6))); + assert_eq!(availability.product_slug.as_deref(), Some("domain_reg")); + assert_eq!(availability.cost.as_deref(), Some("$18.00")); + assert_eq!(availability.renew_cost.as_deref(), Some("$18.00")); + assert_eq!( + availability.raw_price, + Some(Decimal2::from_hundredths(1800)) + ); + assert_eq!( + availability.renew_raw_price, + Some(Decimal2::from_hundredths(1800)) + ); + assert_eq!( + availability.currency_code, + Some(CurrencyCode("USD".to_string())) + ); + assert_eq!( + availability.match_reasons.as_deref(), + Some( + ["exact-match", "tld-exact", "tld-common"] + .map(String::from) + .as_slice() + ) + ); + assert_eq!(availability.vendor.as_deref(), Some("availability")); + assert_eq!( + availability.ownership_verification_type.as_deref(), + Some("no_verification_required") + ); + } + + #[test] + fn test_domain_availability_blacklisted_has_no_pricing() { + let file = File::open("tests/wpcom/domains/is_available/not-available.json") + .expect("Failed to open file"); + let availability: DomainAvailability = + serde_json::from_reader(file).expect("Unable to parse JSON"); + assert!(availability.product_id.is_none()); + assert!(availability.product_slug.is_none()); + assert!(availability.cost.is_none()); + assert!(availability.raw_price.is_none()); + assert!(availability.currency_code.is_none()); + assert!(availability.match_reasons.is_none()); + assert!(availability.policy_notices.is_empty()); + } + + #[test] + fn test_domain_availability_transferrable_partial_pricing() { + let file = File::open("tests/wpcom/domains/is_available/transferrable.json") + .expect("Failed to open file"); + let availability: DomainAvailability = + serde_json::from_reader(file).expect("Unable to parse JSON"); + assert_eq!(availability.product_id, Some(ProductId(1337))); + assert_eq!( + availability.product_slug.as_deref(), + Some("domain_transfer") + ); + assert_eq!(availability.cost.as_deref(), Some("$48.00")); + assert_eq!( + availability.raw_price, + Some(Decimal2::from_hundredths(4800)) + ); + // Transferrable domains don't include renewal pricing. + assert!(availability.renew_cost.is_none()); + assert!(availability.renew_raw_price.is_none()); + } + + #[test] + fn test_domain_availability_premium_fields() { + let file = File::open("tests/wpcom/domains/is_available/available-premium.json") + .expect("Failed to open file"); + let availability: DomainAvailability = + serde_json::from_reader(file).expect("Unable to parse JSON"); + assert_eq!(availability.is_supported_premium_domain, Some(true)); + assert_eq!(availability.is_price_limit_exceeded, Some(false)); + assert_eq!( + availability.raw_price, + Some(Decimal2::from_hundredths(500000)) + ); + } + + #[test] + fn test_domain_availability_mapped_same_user() { + let file = File::open("tests/wpcom/domains/is_available/mapped-same-user.json") + .expect("Failed to open file"); + let availability: DomainAvailability = + serde_json::from_reader(file).expect("Unable to parse JSON"); + assert_eq!( + availability.other_site_domain.as_deref(), + Some("myothersite.wordpress.com") + ); + assert_eq!( + availability.transferrability.as_deref(), + Some("transferrable") + ); + assert!(availability.product_id.is_none()); + } + + #[test] + fn test_domain_availability_sale_coupon() { + let file = File::open("tests/wpcom/domains/is_available/sale-coupon.json") + .expect("Failed to open file"); + let availability: DomainAvailability = + serde_json::from_reader(file).expect("Unable to parse JSON"); + assert_eq!( + availability.sale_cost, + Some(Decimal2::from_hundredths(1000)) + ); + assert_eq!( + availability.raw_price, + Some(Decimal2::from_hundredths(2500)) + ); + } + + #[test] + fn test_domain_availability_dot_gay_notice() { + let file = File::open("tests/wpcom/domains/is_available/dot-gay-notice.json") + .expect("Failed to open file"); + let availability: DomainAvailability = + serde_json::from_reader(file).expect("Unable to parse JSON"); + assert_eq!(availability.is_dot_gay_notice_required, Some(true)); + assert_eq!(availability.policy_notices.len(), 1); + assert_eq!( + availability.policy_notices[0].notice_type, + "gay_accept_requirements" + ); + } + + #[test] + fn test_domain_availability_maintenance() { + let file = File::open("tests/wpcom/domains/is_available/maintenance.json") + .expect("Failed to open file"); + let availability: DomainAvailability = + serde_json::from_reader(file).expect("Unable to parse JSON"); + assert_eq!( + availability.maintenance_end_time.as_deref(), + Some("2026-05-01 12:00:00") + ); + assert!(availability.product_id.is_none()); + } + + #[test] + fn test_domain_availability_hsts_policy_notices() { + let file = File::open("tests/wpcom/domains/is_available/hsts-required.json") + .expect("Failed to open file"); + let availability: DomainAvailability = + serde_json::from_reader(file).expect("Unable to parse JSON"); + assert_eq!(availability.hsts_required, Some(true)); + assert_eq!(availability.policy_notices.len(), 1); + assert_eq!(availability.policy_notices[0].notice_type, "hsts"); + assert_eq!(availability.policy_notices[0].label, "HSTS required"); + assert!( + availability.policy_notices[0] + .message + .contains("SSL certificate") + ); + } } diff --git a/wp_api/src/wp_com/endpoint.rs b/wp_api/src/wp_com/endpoint.rs index 89496f7a1..20d072095 100644 --- a/wp_api/src/wp_com/endpoint.rs +++ b/wp_api/src/wp_com/endpoint.rs @@ -169,6 +169,10 @@ pub(crate) mod tests { validate_endpoint(WpComNamespace::RestV1_1, endpoint_url, path); } + pub fn validate_wp_com_rest_v1_3_endpoint(endpoint_url: ApiEndpointUrl, path: &str) { + validate_endpoint(WpComNamespace::RestV1_3, endpoint_url, path); + } + pub fn validate_wp_com_v2_endpoint(endpoint_url: ApiEndpointUrl, path: &str) { validate_endpoint(WpComNamespace::V2, endpoint_url, path); } diff --git a/wp_api/src/wp_com/endpoint/domains_endpoint.rs b/wp_api/src/wp_com/endpoint/domains_endpoint.rs index a9b215c2a..f69f404db 100644 --- a/wp_api/src/wp_com/endpoint/domains_endpoint.rs +++ b/wp_api/src/wp_com/endpoint/domains_endpoint.rs @@ -3,8 +3,8 @@ use crate::{ wp_com::{ WpComNamespace, domains::{ - CountryCode, DomainSuggestion, DomainSuggestionsParams, SupportedCountries, - SupportedState, + CountryCode, DomainAvailability, DomainAvailabilityParams, DomainName, + DomainSuggestion, DomainSuggestionsParams, SupportedCountries, SupportedState, }, }, }; @@ -18,11 +18,16 @@ enum DomainsRequest { SupportedCountries, #[get(url = "/domains/supported-states/", output = Vec)] SupportedStates, + #[get(url = "/domains//is-available", params = &DomainAvailabilityParams, output = DomainAvailability)] + IsAvailable, } impl DerivedRequest for DomainsRequest { fn namespace(&self) -> impl AsNamespace { - WpComNamespace::RestV1_1 + match self { + Self::IsAvailable => WpComNamespace::RestV1_3, + _ => WpComNamespace::RestV1_1, + } } } @@ -32,9 +37,11 @@ mod tests { use crate::{ request::endpoint::ApiUrlResolver, wp_com::{ - domains::CountryCode, + WpComSiteId, + domains::{CountryCode, DomainAvailabilityParams, DomainName}, endpoint::tests::{ fixture_wp_com_api_url_resolver, validate_wp_com_rest_v1_1_endpoint, + validate_wp_com_rest_v1_3_endpoint, }, segments::SegmentId, }, @@ -106,6 +113,35 @@ mod tests { validate_wp_com_rest_v1_1_endpoint(endpoint.supported_states(&country_code), expected_path); } + #[rstest] + #[case::com(DomainName("example.com".to_string()), "/domains/example.com/is-available?")] + #[case::org(DomainName("myblog.org".to_string()), "/domains/myblog.org/is-available?")] + fn is_available( + endpoint: DomainsRequestEndpoint, + #[case] domain_name: DomainName, + #[case] expected_path: &str, + ) { + validate_wp_com_rest_v1_3_endpoint( + endpoint.is_available(&domain_name, &DomainAvailabilityParams::default()), + expected_path, + ); + } + + #[rstest] + fn is_available_with_params(endpoint: DomainsRequestEndpoint) { + validate_wp_com_rest_v1_3_endpoint( + endpoint.is_available( + &DomainName("test.com".to_string()), + &DomainAvailabilityParams { + blog_id: Some(WpComSiteId(12345)), + is_cart_pre_check: Some(true), + vendor: Some("100-year-domains".to_string()), + }, + ), + "/domains/test.com/is-available?blog_id=12345&is_cart_pre_check=true&vendor=100-year-domains", + ); + } + fn base_domain_suggestions_params() -> DomainSuggestionsParams { DomainSuggestionsParams { query: "coolsite".to_string(), diff --git a/wp_api/src/wp_com/mod.rs b/wp_api/src/wp_com/mod.rs index 56c7535eb..112febc16 100644 --- a/wp_api/src/wp_com/mod.rs +++ b/wp_api/src/wp_com/mod.rs @@ -90,6 +90,7 @@ pub(crate) enum WpComNamespace { Oauth2, RestV1_1, RestV1_2, + RestV1_3, V2, } @@ -99,6 +100,7 @@ impl AsNamespace for WpComNamespace { WpComNamespace::Oauth2 => "/oauth2", WpComNamespace::RestV1_1 => "/rest/v1.1", WpComNamespace::RestV1_2 => "/rest/v1.2", + WpComNamespace::RestV1_3 => "/rest/v1.3", WpComNamespace::V2 => "/wpcom/v2", } } diff --git a/wp_api/tests/wpcom/domains/is_available/available-premium.json b/wp_api/tests/wpcom/domains/is_available/available-premium.json new file mode 100644 index 000000000..c3391df87 --- /dev/null +++ b/wp_api/tests/wpcom/domains/is_available/available-premium.json @@ -0,0 +1,20 @@ +{ + "domain_name": "luxury.com", + "tld": "com", + "status": "available_premium", + "mappable": "mappable", + "supports_privacy": true, + "root_domain_provider": "unknown", + "product_id": 6, + "product_slug": "domain_reg", + "cost": "$5,000.00", + "renew_cost": "$5,000.00", + "renew_raw_price": 5000, + "raw_price": 5000, + "currency_code": "USD", + "is_supported_premium_domain": true, + "is_price_limit_exceeded": false, + "match_reasons": ["exact-match", "tld-exact", "tld-common"], + "vendor": "availability", + "ownership_verification_type": "no_verification_required" +} diff --git a/wp_api/tests/wpcom/domains/is_available/available.json b/wp_api/tests/wpcom/domains/is_available/available.json new file mode 100644 index 000000000..c13284622 --- /dev/null +++ b/wp_api/tests/wpcom/domains/is_available/available.json @@ -0,0 +1,22 @@ +{ + "domain_name": "freshsite2025.com", + "tld": "com", + "status": "available", + "mappable": "mappable", + "supports_privacy": true, + "root_domain_provider": "unknown", + "product_id": 6, + "product_slug": "domain_reg", + "cost": "$18.00", + "renew_cost": "$18.00", + "renew_raw_price": 18, + "raw_price": 18, + "currency_code": "USD", + "match_reasons": [ + "exact-match", + "tld-exact", + "tld-common" + ], + "vendor": "availability", + "ownership_verification_type": "no_verification_required" +} diff --git a/wp_api/tests/wpcom/domains/is_available/dot-gay-notice.json b/wp_api/tests/wpcom/domains/is_available/dot-gay-notice.json new file mode 100644 index 000000000..ba91ab8ac --- /dev/null +++ b/wp_api/tests/wpcom/domains/is_available/dot-gay-notice.json @@ -0,0 +1,17 @@ +{ + "domain_name": "testsite2025.gay", + "tld": "gay", + "status": "mappable", + "mappable": "mappable", + "supports_privacy": true, + "root_domain_provider": "unknown", + "is_dot_gay_notice_required": true, + "ownership_verification_type": "no_verification_required", + "policy_notices": [ + { + "type": "gay_accept_requirements", + "label": "Special requirements", + "message": "Any anti-LGBTQ content is prohibited and can result in registration termination. The registry will donate 20% of all registration revenue to LGBTQ non-profit organizations." + } + ] +} diff --git a/wp_api/tests/wpcom/domains/is_available/hsts-required.json b/wp_api/tests/wpcom/domains/is_available/hsts-required.json new file mode 100644 index 000000000..17033496f --- /dev/null +++ b/wp_api/tests/wpcom/domains/is_available/hsts-required.json @@ -0,0 +1,22 @@ +{ + "domain_name": "myproject.dev", + "tld": "dev", + "status": "recent_registration_lock_not_transferrable", + "mappable": "mappable", + "supports_privacy": false, + "root_domain_provider": "unknown", + "hsts_required": true, + "product_id": 1337, + "product_slug": "domain_transfer", + "cost": "$120.00", + "raw_price": 120, + "currency_code": "USD", + "ownership_verification_type": "no_verification_required", + "policy_notices": [ + { + "type": "hsts", + "label": "HSTS required", + "message": "All domains with this ending require an SSL certificate to host a website." + } + ] +} diff --git a/wp_api/tests/wpcom/domains/is_available/maintenance.json b/wp_api/tests/wpcom/domains/is_available/maintenance.json new file mode 100644 index 000000000..499e6dd19 --- /dev/null +++ b/wp_api/tests/wpcom/domains/is_available/maintenance.json @@ -0,0 +1,9 @@ +{ + "domain_name": "mysite.example", + "tld": "example", + "status": "tld_in_maintenance", + "mappable": "mappable", + "supports_privacy": false, + "root_domain_provider": "unknown", + "maintenance_end_time": "2026-05-01 12:00:00" +} diff --git a/wp_api/tests/wpcom/domains/is_available/mapped-same-user.json b/wp_api/tests/wpcom/domains/is_available/mapped-same-user.json new file mode 100644 index 000000000..20b0bb72f --- /dev/null +++ b/wp_api/tests/wpcom/domains/is_available/mapped-same-user.json @@ -0,0 +1,11 @@ +{ + "domain_name": "myblog.com", + "tld": "com", + "status": "mapped_to_other_site_same_user", + "mappable": "mappable", + "supports_privacy": true, + "root_domain_provider": "unknown", + "other_site_domain": "myothersite.wordpress.com", + "transferrability": "transferrable", + "ownership_verification_type": "no_verification_required" +} diff --git a/wp_api/tests/wpcom/domains/is_available/not-available.json b/wp_api/tests/wpcom/domains/is_available/not-available.json new file mode 100644 index 000000000..d6a2e4bbc --- /dev/null +++ b/wp_api/tests/wpcom/domains/is_available/not-available.json @@ -0,0 +1,8 @@ +{ + "domain_name": "example.com", + "tld": "com", + "status": "blacklisted_domain", + "mappable": "blacklisted_domain", + "supports_privacy": true, + "root_domain_provider": "unknown" +} diff --git a/wp_api/tests/wpcom/domains/is_available/sale-coupon.json b/wp_api/tests/wpcom/domains/is_available/sale-coupon.json new file mode 100644 index 000000000..505ae92c1 --- /dev/null +++ b/wp_api/tests/wpcom/domains/is_available/sale-coupon.json @@ -0,0 +1,22 @@ +{ + "domain_name": "freshblog2025.online", + "tld": "online", + "status": "available", + "mappable": "mappable", + "supports_privacy": true, + "root_domain_provider": "unknown", + "product_id": 271, + "product_slug": "dotonline_domain", + "cost": "$25.00", + "renew_cost": "$25.00", + "renew_raw_price": 25, + "raw_price": 25, + "currency_code": "USD", + "sale_cost": 10.00, + "match_reasons": [ + "exact-match", + "tld-exact" + ], + "vendor": "availability", + "ownership_verification_type": "no_verification_required" +} diff --git a/wp_api/tests/wpcom/domains/is_available/tld-not-supported.json b/wp_api/tests/wpcom/domains/is_available/tld-not-supported.json new file mode 100644 index 000000000..556fd8408 --- /dev/null +++ b/wp_api/tests/wpcom/domains/is_available/tld-not-supported.json @@ -0,0 +1,9 @@ +{ + "domain_name": "mysite.ai", + "tld": "ai", + "status": "tld_not_supported", + "mappable": "mappable", + "supports_privacy": false, + "root_domain_provider": "unknown", + "ownership_verification_type": "no_verification_required" +} diff --git a/wp_api/tests/wpcom/domains/is_available/transferrable.json b/wp_api/tests/wpcom/domains/is_available/transferrable.json new file mode 100644 index 000000000..1206935ed --- /dev/null +++ b/wp_api/tests/wpcom/domains/is_available/transferrable.json @@ -0,0 +1,14 @@ +{ + "domain_name": "taken-domain.io", + "tld": "io", + "status": "transferrable", + "mappable": "mappable", + "supports_privacy": true, + "root_domain_provider": "unknown", + "product_id": 1337, + "product_slug": "domain_transfer", + "cost": "$48.00", + "raw_price": 48, + "currency_code": "USD", + "ownership_verification_type": "no_verification_required" +} diff --git a/wp_com_e2e/src/domains_tests.rs b/wp_com_e2e/src/domains_tests.rs index 5adc0b529..991dd290a 100644 --- a/wp_com_e2e/src/domains_tests.rs +++ b/wp_com_e2e/src/domains_tests.rs @@ -1,7 +1,9 @@ use crate::context::TestContext; use libtest_mimic::Trial; use std::sync::Arc; -use wp_api::wp_com::domains::CountryCode; +use wp_api::wp_com::domains::{ + CountryCode, DomainAvailabilityParams, DomainAvailabilityStatus, DomainName, +}; pub fn tests(ctx: Arc) -> Vec { let mut trials = vec![]; @@ -83,5 +85,73 @@ pub fn tests(ctx: Arc) -> Vec { } })); + trials.push(Trial::test("domains::is_available_taken", { + let ctx = Arc::clone(&ctx); + move || { + ctx.runtime.block_on(async { + let availability = ctx + .client + .domains() + .is_available( + &DomainName("google.com".to_string()), + &DomainAvailabilityParams::default(), + ) + .await + .map_err(|e| e.to_string())? + .data; + + if availability.domain_name != "google.com" { + return Err(format!( + "expected domain_name 'google.com', got '{}'", + availability.domain_name + ) + .into()); + } + + if availability.status == DomainAvailabilityStatus::Available { + return Err("expected google.com to not be available".into()); + } + + Ok(()) + }) + } + })); + + trials.push(Trial::test("domains::is_available_likely_available", { + let ctx = Arc::clone(&ctx); + move || { + ctx.runtime.block_on(async { + let availability = ctx + .client + .domains() + .is_available( + &DomainName("xyzzy-test-unlikely-taken-2025.com".to_string()), + &DomainAvailabilityParams::default(), + ) + .await + .map_err(|e| e.to_string())? + .data; + + if availability.status != DomainAvailabilityStatus::Available { + return Err(format!( + "expected status Available, got {:?}", + availability.status + ) + .into()); + } + + if !availability.supports_privacy { + return Err("expected .com domain to support privacy".into()); + } + + if availability.product_id.is_none() { + return Err("expected product_id for available domain".into()); + } + + Ok(()) + }) + } + })); + trials }