diff --git a/Networking/Networking.xcodeproj/project.pbxproj b/Networking/Networking.xcodeproj/project.pbxproj index e29983bd170..2485911f3b5 100644 --- a/Networking/Networking.xcodeproj/project.pbxproj +++ b/Networking/Networking.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* Begin PBXBuildFile section */ 020220E223966CD900290165 /* product-shipping-classes-load-one.json in Resources */ = {isa = PBXBuildFile; fileRef = 020220E123966CD900290165 /* product-shipping-classes-load-one.json */; }; 0205021C27C86B9700FB1C6B /* inbox-note-without-isRead.json in Resources */ = {isa = PBXBuildFile; fileRef = 0205021B27C86B9700FB1C6B /* inbox-note-without-isRead.json */; }; + 02050F57296FE90A00710E63 /* domain-suggestions-paid.json in Resources */ = {isa = PBXBuildFile; fileRef = 02050F56296FE90A00710E63 /* domain-suggestions-paid.json */; }; + 02050F59296FEC5B00710E63 /* domain-products.json in Resources */ = {isa = PBXBuildFile; fileRef = 02050F58296FEC5B00710E63 /* domain-products.json */; }; 020C907B24C6E108001E2BEB /* product-variation-update.json in Resources */ = {isa = PBXBuildFile; fileRef = 020C907A24C6E108001E2BEB /* product-variation-update.json */; }; 020C907F24C7D359001E2BEB /* ProductVariationMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 020C907E24C7D359001E2BEB /* ProductVariationMapperTests.swift */; }; 020D07B823D852BB00FD9580 /* Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = 020D07B723D852BB00FD9580 /* Media.swift */; }; @@ -826,6 +828,8 @@ /* Begin PBXFileReference section */ 020220E123966CD900290165 /* product-shipping-classes-load-one.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "product-shipping-classes-load-one.json"; sourceTree = ""; }; 0205021B27C86B9700FB1C6B /* inbox-note-without-isRead.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "inbox-note-without-isRead.json"; sourceTree = ""; }; + 02050F56296FE90A00710E63 /* domain-suggestions-paid.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "domain-suggestions-paid.json"; sourceTree = ""; }; + 02050F58296FEC5B00710E63 /* domain-products.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "domain-products.json"; sourceTree = ""; }; 020C907A24C6E108001E2BEB /* product-variation-update.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "product-variation-update.json"; sourceTree = ""; }; 020C907E24C7D359001E2BEB /* ProductVariationMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductVariationMapperTests.swift; sourceTree = ""; }; 020D07B723D852BB00FD9580 /* Media.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Media.swift; sourceTree = ""; }; @@ -2167,7 +2171,9 @@ 028CB71A290224D700331C09 /* create-account-error-username.json */, 028CB712290223CB00331C09 /* create-account-success.json */, 02EF166F292F0CF400D90AD6 /* create-cart-success.json */, + 02050F58296FEC5B00710E63 /* domain-products.json */, 0239306C291A973F00B2632F /* domain-suggestions.json */, + 02050F56296FE90A00710E63 /* domain-suggestions-paid.json */, DE50295F28C609A300551736 /* jetpack-connected-user.json */, DE34051A28BDF12C00CF0D97 /* jetpack-connection-url.json */, DE50296228C609DE00551736 /* jetpack-user-not-connected.json */, @@ -3115,6 +3121,7 @@ 02BA23CA22EEF62C009539E7 /* order-stats-v4-wcadmin-activated.json in Resources */, 31A451D627863A2E00FE81AA /* stripe-account-unknown-status.json in Resources */, 45152819257A84A60076B03C /* product-attributes-all.json in Resources */, + 02050F59296FEC5B00710E63 /* domain-products.json in Resources */, 45AB8B1324AA34CB00B5B36E /* product-tags-deleted.json in Resources */, 02698CFC24C1B0CE005337C4 /* product-variations-load-all-first-on-sale-empty-sale-price.json in Resources */, 31A451D427863A2E00FE81AA /* stripe-account-rejected-listed.json in Resources */, @@ -3137,6 +3144,7 @@ D8A284F425FBB48D0019A84B /* product-attribute-terms.json in Resources */, 74E30951216E8DCE00ABCE4C /* site-visits-alt.json in Resources */, 74ABA1C5213F17AA00FFAD30 /* top-performers-day.json in Resources */, + 02050F57296FE90A00710E63 /* domain-suggestions-paid.json in Resources */, 31B8D6B626583970008E3DB2 /* wcpay-account-implicitly-not-eligible.json in Resources */, 26B2F74724C55A6E0065CCC8 /* leaderboards-year.json in Resources */, 31054714262E2F3B00C5C02B /* wcpay-payment-intent-requires-payment-method.json in Resources */, diff --git a/Networking/Networking/Remote/DomainRemote.swift b/Networking/Networking/Remote/DomainRemote.swift index 9a1f3e56c1d..d67dfa6cdf5 100644 --- a/Networking/Networking/Remote/DomainRemote.swift +++ b/Networking/Networking/Remote/DomainRemote.swift @@ -7,6 +7,15 @@ public protocol DomainRemoteProtocol { /// - Returns: The result of free domain suggestions. func loadFreeDomainSuggestions(query: String) async throws -> [FreeDomainSuggestion] + /// Loads domain suggestions that are not free based on the query. + /// - Parameter query: What the domain suggestions are based on. + /// - Returns: A list of paid domain suggestions. + func loadPaidDomainSuggestions(query: String) async throws -> [PaidDomainSuggestion] + + /// Loads WPCOM domain products for domain cost and sale info in `loadPaidDomainSuggestions`. + /// - Returns: A list of domain products. + func loadDomainProducts() async throws -> [DomainProduct] + /// Loads all domains for a site. /// - Parameter siteID: ID of the site to load the domains for. /// - Returns: A list of domains. @@ -27,6 +36,26 @@ public class DomainRemote: Remote, DomainRemoteProtocol { return try await enqueue(request) } + public func loadPaidDomainSuggestions(query: String) async throws -> [PaidDomainSuggestion] { + let path = Path.domainSuggestions + let parameters: [String: Any] = [ + ParameterKey.query: query, + ParameterKey.quantity: Defaults.domainSuggestionsQuantity + ] + let request = DotcomRequest(wordpressApiVersion: .mark1_1, method: .get, path: path, parameters: parameters) + return try await enqueue(request) + } + + public func loadDomainProducts() async throws -> [DomainProduct] { + let path = Path.domainProducts + let parameters: [String: Any] = [ + ParameterKey.domainProductType: "domains" + ] + let request = DotcomRequest(wordpressApiVersion: .mark1_1, method: .get, path: path, parameters: parameters) + let productsByName: [String: DomainProduct] = try await enqueue(request) + return Array(productsByName.values) + } + public func loadDomains(siteID: Int64) async throws -> [SiteDomain] { let path = "sites/\(siteID)/\(Path.domains)" let request = DotcomRequest(wordpressApiVersion: .mark1_1, method: .get, path: path) @@ -53,6 +82,41 @@ public struct FreeDomainSuggestion: Decodable, Equatable { } } +/// Necessary data for a paid domain suggestion. +public struct PaidDomainSuggestion: Decodable, Equatable { + /// Domain name. + public let name: String + /// WPCOM product ID. + public let productID: Int64 + /// Whether there is privacy support. Used when creating a cart with a domain product. + public let supportsPrivacy: Bool + + private enum CodingKeys: String, CodingKey { + case name = "domain_name" + case productID = "product_id" + case supportsPrivacy = "supports_privacy" + } +} + +/// Necessary data for a WPCOM domain product. +public struct DomainProduct: Decodable, Equatable { + /// WPCOM product ID. + public let productID: Int64 + /// The duration of the product, localized on the backend (e.g. "year"). + public let term: String + /// Cost string including the currency. + public let cost: String + /// Optional sale cost string including the currency. + public let saleCost: String? + + private enum CodingKeys: String, CodingKey { + case productID = "product_id" + case term = "product_term" + case cost = "combined_cost_display" + case saleCost = "combined_sale_cost_display" + } +} + /// Necessary data for a site's domain. public struct SiteDomain: Decodable, Equatable { /// Domain name. @@ -114,10 +178,13 @@ private extension DomainRemote { static let quantity = "quantity" /// Whether to restrict suggestions to only wordpress.com subdomains. If `true`, only `quantity` and `query` parameters are respected. static let wordPressDotComSubdomainsOnly = "only_wordpressdotcom" + /// The type of WPCOM products. + static let domainProductType = "type" } enum Path { static let domainSuggestions = "domains/suggestions" + static let domainProducts = "products" static let domains = "domains" } } diff --git a/Networking/NetworkingTests/Remote/DomainRemoteTests.swift b/Networking/NetworkingTests/Remote/DomainRemoteTests.swift index 80dd34bae40..bb87550b88a 100644 --- a/Networking/NetworkingTests/Remote/DomainRemoteTests.swift +++ b/Networking/NetworkingTests/Remote/DomainRemoteTests.swift @@ -40,6 +40,56 @@ final class DomainRemoteTests: XCTestCase { await assertThrowsError({_ = try await remote.loadFreeDomainSuggestions(query: "domain")}, errorAssert: { ($0 as? NetworkError) == .notFound }) } + // MARK: - `loadPaidDomainSuggestions` + + func test_loadPaidDomainSuggestions_returns_suggestions_on_success() async throws { + // Given + let remote = DomainRemote(network: network) + network.simulateResponse(requestUrlSuffix: "domains/suggestions", filename: "domain-suggestions-paid") + + // When + let suggestions = try await remote.loadPaidDomainSuggestions(query: "domain") + + // Then + XCTAssertEqual(suggestions, [ + .init(name: "color.bar", productID: 356, supportsPrivacy: true), + .init(name: "color.ink", productID: 359, supportsPrivacy: true) + ]) + } + + func test_loadPaidDomainSuggestions_returns_error_on_empty_response() async throws { + // Given + let remote = DomainRemote(network: network) + + await assertThrowsError({_ = try await remote.loadPaidDomainSuggestions(query: "domain")}, errorAssert: { ($0 as? NetworkError) == .notFound }) + } + + // MARK: - `loadDomainProducts` + + func test_loadDomainProducts_returns_products_on_success() async throws { + // Given + let remote = DomainRemote(network: network) + network.simulateResponse(requestUrlSuffix: "products", filename: "domain-products") + + // When + // Products are in random order because of the product name mapping. + // They are sorted here to ensure the same order for unit testing. + let products = try await remote.loadDomainProducts().sorted(by: { $0.productID < $1.productID }) + + // Then + XCTAssertEqual(products, [ + .init(productID: 355, term: "year", cost: "US$15.00", saleCost: "US$3.90"), + .init(productID: 356, term: "year", cost: "US$60.00", saleCost: nil) + ]) + } + + func test_loadDomainProducts_returns_error_on_empty_response() async throws { + // Given + let remote = DomainRemote(network: network) + + await assertThrowsError({_ = try await remote.loadDomainProducts()}, errorAssert: { ($0 as? NetworkError) == .notFound }) + } + // MARK: - `loadDomains` func test_loadDomains_returns_domains_on_success() async throws { diff --git a/Networking/NetworkingTests/Responses/domain-products.json b/Networking/NetworkingTests/Responses/domain-products.json new file mode 100644 index 00000000000..26bcfeb1769 --- /dev/null +++ b/Networking/NetworkingTests/Responses/domain-products.json @@ -0,0 +1,64 @@ +{ + "dotart_domain": { + "product_id": 355, + "product_name": ".art Domain Registration", + "product_slug": "dotart_domain", + "description": "", + "product_type": "domain_reg", + "available": true, + "billing_product_slug": "wp-dot-art-registration", + "is_domain_registration": true, + "cost_display": "US$2.00", + "combined_cost_display": "US$15.00", + "cost": 2, + "cost_smallest_unit": 200, + "currency_code": "USD", + "price_tier_list": [], + "price_tier_usage_quantity": null, + "product_term": "year", + "price_tiers": [], + "price_tier_slug": "", + "tld": "art", + "is_privacy_protection_product_purchase_allowed": true, + "sale_cost": 3.9, + "combined_sale_cost_display": "US$3.90", + "sale_coupon": { + "start_date": "2022-12-01 00:01:00", + "expires": "2023-03-31 00:00:00", + "discount": 74, + "purchase_types": [ + 3 + ], + "product_ids": [ + 355 + ], + "allowed_for_domain_transfers": false, + "allowed_for_renewals": false, + "allowed_for_new_purchases": true, + "code": "7ea7af1f76879264", + "tld_rank": null + } + }, + "dotbar_domain": { + "product_id": 356, + "product_name": ".bar Domain Registration", + "product_slug": "dotbar_domain", + "description": "", + "product_type": "domain_reg", + "available": true, + "billing_product_slug": "wp-dot-bar-registration", + "is_domain_registration": true, + "cost_display": "US$47.00", + "combined_cost_display": "US$60.00", + "cost": 47, + "cost_smallest_unit": 4700, + "currency_code": "USD", + "price_tier_list": [], + "price_tier_usage_quantity": null, + "product_term": "year", + "price_tiers": [], + "price_tier_slug": "", + "tld": "bar", + "is_privacy_protection_product_purchase_allowed": true + } +} diff --git a/Networking/NetworkingTests/Responses/domain-suggestions-paid.json b/Networking/NetworkingTests/Responses/domain-suggestions-paid.json new file mode 100644 index 00000000000..1d22f62f9ff --- /dev/null +++ b/Networking/NetworkingTests/Responses/domain-suggestions-paid.json @@ -0,0 +1,30 @@ +[ + { + "domain_name": "color.bar", + "relevance": 0.33, + "supports_privacy": true, + "vendor": "donuts", + "match_reasons": [ + "exact-match" + ], + "product_id": 356, + "product_slug": "dotbar_domain", + "cost": "US$60.00", + "raw_price": 60, + "currency_code": "USD" + }, + { + "domain_name": "color.ink", + "relevance": 0.33, + "supports_privacy": true, + "vendor": "donuts", + "match_reasons": [ + "exact-match" + ], + "product_id": 359, + "product_slug": "dotink_domain", + "cost": "US$25.00", + "raw_price": 25, + "currency_code": "USD" + } +] diff --git a/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockDomainRemote.swift b/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockDomainRemote.swift index 2bdbfdd8c68..74bdfc074db 100644 --- a/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockDomainRemote.swift +++ b/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockDomainRemote.swift @@ -30,6 +30,16 @@ extension MockDomainRemote: DomainRemoteProtocol { return try result.get() } + func loadPaidDomainSuggestions(query: String) async throws -> [PaidDomainSuggestion] { + // TODO: 8558 - Yosemite layer for paid domains + throw NetworkError.notFound + } + + func loadDomainProducts() async throws -> [DomainProduct] { + // TODO: 8558 - Yosemite layer for paid domains + throw NetworkError.notFound + } + func loadDomains(siteID: Int64) async throws -> [SiteDomain] { guard let result = loadDomainsResult else { XCTFail("Could not find result for loading domains.")