diff --git a/Networking/Networking.xcodeproj/project.pbxproj b/Networking/Networking.xcodeproj/project.pbxproj index d9a4aa65a0d..abf2223914a 100644 --- a/Networking/Networking.xcodeproj/project.pbxproj +++ b/Networking/Networking.xcodeproj/project.pbxproj @@ -92,6 +92,10 @@ 02DD6492248A3EC00082523E /* product-external.json in Resources */ = {isa = PBXBuildFile; fileRef = 02DD6491248A3EC00082523E /* product-external.json */; }; 02E7FFCB256218F600C53030 /* ShippingLabelRemoteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E7FFCA256218F600C53030 /* ShippingLabelRemoteTests.swift */; }; 02E7FFCF25621C7900C53030 /* shipping-label-print.json in Resources */ = {isa = PBXBuildFile; fileRef = 02E7FFCE25621C7900C53030 /* shipping-label-print.json */; }; + 02EF1664292DADDE00D90AD6 /* PaymentRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EF1663292DADDE00D90AD6 /* PaymentRemote.swift */; }; + 02EF166E292F0C5800D90AD6 /* PaymentRemoteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EF166D292F0C5800D90AD6 /* PaymentRemoteTests.swift */; }; + 02EF1670292F0CF400D90AD6 /* create-cart-success.json in Resources */ = {isa = PBXBuildFile; fileRef = 02EF166F292F0CF400D90AD6 /* create-cart-success.json */; }; + 02EF1672292F0D1900D90AD6 /* load-plan-success.json in Resources */ = {isa = PBXBuildFile; fileRef = 02EF1671292F0D1900D90AD6 /* load-plan-success.json */; }; 02F096C22406691100C0C1D5 /* media-library.json in Resources */ = {isa = PBXBuildFile; fileRef = 02F096C12406691100C0C1D5 /* media-library.json */; }; 0313651928AE559D00EEE571 /* PaymentGatewayMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313651828AE559D00EEE571 /* PaymentGatewayMapper.swift */; }; 0313651B28AE60E000EEE571 /* payment-gateway-cod.json in Resources */ = {isa = PBXBuildFile; fileRef = 0313651A28AE60E000EEE571 /* payment-gateway-cod.json */; }; @@ -841,6 +845,10 @@ 02DD6491248A3EC00082523E /* product-external.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "product-external.json"; sourceTree = ""; }; 02E7FFCA256218F600C53030 /* ShippingLabelRemoteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelRemoteTests.swift; sourceTree = ""; }; 02E7FFCE25621C7900C53030 /* shipping-label-print.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "shipping-label-print.json"; sourceTree = ""; }; + 02EF1663292DADDE00D90AD6 /* PaymentRemote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentRemote.swift; sourceTree = ""; }; + 02EF166D292F0C5800D90AD6 /* PaymentRemoteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentRemoteTests.swift; sourceTree = ""; }; + 02EF166F292F0CF400D90AD6 /* create-cart-success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "create-cart-success.json"; sourceTree = ""; }; + 02EF1671292F0D1900D90AD6 /* load-plan-success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "load-plan-success.json"; sourceTree = ""; }; 02F096C12406691100C0C1D5 /* media-library.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "media-library.json"; sourceTree = ""; }; 0313651828AE559D00EEE571 /* PaymentGatewayMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentGatewayMapper.swift; sourceTree = ""; }; 0313651A28AE60E000EEE571 /* payment-gateway-cod.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "payment-gateway-cod.json"; sourceTree = ""; }; @@ -1738,6 +1746,7 @@ 68F48B0E28E3BB850045C15B /* WCAnalyticsCustomerRemoteTests.swift */, 0239306A291A96F800B2632F /* DomainRemoteTests.swift */, 02616F8B292132800095BC00 /* SiteRemoteTests.swift */, + 02EF166D292F0C5800D90AD6 /* PaymentRemoteTests.swift */, ); path = Remote; sourceTree = ""; @@ -1879,6 +1888,7 @@ 68F48B0A28E3B1CD0045C15B /* WCAnalyticsCustomerRemote.swift */, 023930622918FF5400B2632F /* DomainRemote.swift */, 021940E1291E3CFD0090354E /* SiteRemote.swift */, + 02EF1663292DADDE00D90AD6 /* PaymentRemote.swift */, ); path = Remote; sourceTree = ""; @@ -2006,6 +2016,7 @@ 028CB713290223CB00331C09 /* create-account-error-password.json */, 028CB71A290224D700331C09 /* create-account-error-username.json */, 028CB712290223CB00331C09 /* create-account-success.json */, + 02EF166F292F0CF400D90AD6 /* create-cart-success.json */, 0239306C291A973F00B2632F /* domain-suggestions.json */, DE50295F28C609A300551736 /* jetpack-connected-user.json */, DE34051A28BDF12C00CF0D97 /* jetpack-connection-url.json */, @@ -2072,6 +2083,7 @@ E16C59B828F927CA007D55BB /* iap-order-create.json */, 26B2F74624C55A6E0065CCC8 /* leaderboards-year.json */, 268B68FC24C87E37007EBF1D /* leaderboards-year-alt.json */, + 02EF1671292F0D1900D90AD6 /* load-plan-success.json */, B505F6D420BEE4E600BB1B69 /* me.json */, 93D8BBFE226BC1DA00AD2EB3 /* me-settings.json */, 02F096C12406691100C0C1D5 /* media-library.json */, @@ -2709,6 +2721,7 @@ 743E84FC22174CE100FAC9D7 /* restnoroute_error.json in Resources */, CE20179320E3EFA7005B4C18 /* broken-orders.json in Resources */, D8FBFF2722D529F2006E3336 /* order-stats-v4-month.json in Resources */, + 02EF1672292F0D1900D90AD6 /* load-plan-success.json in Resources */, 2685C0DE263B5A4200D9EE97 /* add-on-groups.json in Resources */, DEC51A9B274E3206009F3DF4 /* plugin-inactive.json in Resources */, CCF48B382628AEAE0034EA83 /* shipping-label-account-settings-no-payment-methods.json in Resources */, @@ -2800,6 +2813,7 @@ B554FA8D2180B59700C54DFF /* notifications-load-hashes.json in Resources */, 022902D622E2436400059692 /* no_stats_permission_error.json in Resources */, FE28F6E826842D57004465C7 /* user-complete.json in Resources */, + 02EF1670292F0CF400D90AD6 /* create-cart-success.json in Resources */, 261CF2CB255C50010090D8D3 /* payment-gateway-list-half.json in Resources */, 02C254D72563999300A04423 /* order-shipping-labels.json in Resources */, 45B204BC24890B1200FE6526 /* category.json in Resources */, @@ -3090,6 +3104,7 @@ B5A2417D217F9ECC00595DEF /* MetaContainer.swift in Sources */, 025CA2C4238EBC4300B05C81 /* ProductShippingClassRemote.swift in Sources */, D88D5A4B230BCF0A007B6E01 /* ProductReviewListMapper.swift in Sources */, + 02EF1664292DADDE00D90AD6 /* PaymentRemote.swift in Sources */, 029C9E5C291507A40013E5EE /* UnauthenticatedRequest.swift in Sources */, 93D8BBFB226BBC5100AD2EB3 /* AccountSettings.swift in Sources */, B53EF53821813806003E146F /* DotcomError.swift in Sources */, @@ -3401,6 +3416,7 @@ CC851D1425E52AB500249E9C /* Decimal+ExtensionsTests.swift in Sources */, B554FA8B2180B1D500C54DFF /* NotificationsRemoteTests.swift in Sources */, B518662A20A09C6F00037A38 /* OrdersRemoteTests.swift in Sources */, + 02EF166E292F0C5800D90AD6 /* PaymentRemoteTests.swift in Sources */, B5969E1520A47F99005E9DF1 /* RemoteTests.swift in Sources */, 74C8F06E20EEC1E800B6EDC9 /* OrderNotesMapperTests.swift in Sources */, 45ED4F10239E8A54004F1BE3 /* TaxClassListMapperTest.swift in Sources */, diff --git a/Networking/Networking/Remote/PaymentRemote.swift b/Networking/Networking/Remote/PaymentRemote.swift new file mode 100644 index 00000000000..dc89e3382a6 --- /dev/null +++ b/Networking/Networking/Remote/PaymentRemote.swift @@ -0,0 +1,102 @@ +import Foundation + +/// Protocol for `PaymentRemote` mainly used for mocking. +public protocol PaymentRemoteProtocol { + /// Loads the WPCOM plan remotely that matches the product ID. + /// - Parameter productID: The ID of the WPCOM plan product. + /// - Returns: The WPCOM plan that matches the given product ID. + func loadPlan(thatMatchesID productID: Int64) async throws -> WPComPlan + + /// Creates a cart with the given product ID for the site ID. + /// - Parameters: + /// - siteID: The ID of the site that the product is being added to. + /// - productID: The ID of the product to be added to the site. + /// - Returns: The remote response from creating a cart. + func createCart(siteID: Int64, productID: Int64) async throws +} + +/// WPCOM Payment Endpoints +/// +public class PaymentRemote: Remote, PaymentRemoteProtocol { + public func loadPlan(thatMatchesID productID: Int64) async throws -> WPComPlan { + let path = Path.products + let request = DotcomRequest(wordpressApiVersion: .mark1_5, method: .get, path: path) + let plans: [WPComPlan] = try await enqueue(request) + guard let plan = plans.first(where: { $0.productID == productID }) else { + throw LoadPlanError.noMatchingPlan + } + return plan + } + + public func createCart(siteID: Int64, productID: Int64) async throws { + let path = "\(Path.cartCreation)/\(siteID)" + + let parameters: [String: Any] = [ + "products": [ + [ + "product_id": productID, + "volume": 1 + ] + ], + // Necessary to create a persistent cart for later checkout, the default value is `true`. + "temporary": false + ] + let request = DotcomRequest(wordpressApiVersion: .mark1_1, method: .post, path: path, parameters: parameters) + let response: CreateCartResponse = try await enqueue(request) + guard response.products.contains(where: { $0.productID == productID }) else { + throw CreateCartError.productNotInCart + } + } +} + +/// Contains necessary data for rendering a WPCOM plan in the app. +public struct WPComPlan: Decodable, Equatable { + public let productID: Int64 + public let name: String + public let formattedPrice: String + + private enum CodingKeys: String, CodingKey { + case productID = "product_id" + case name = "product_name" + case formattedPrice = "formatted_price" + } +} + +/// Possible error cases from loading a WPCOM plan. +public enum LoadPlanError: Error { + case noMatchingPlan +} + +/// Possible error cases from creating cart for a site with a WPCOM plan. +public enum CreateCartError: Error { + case productNotInCart +} + +/// Contains necessary data for handling the remote response from creating a cart. +private struct CreateCartResponse: Decodable { + let products: [Product] + + private enum CodingKeys: String, CodingKey { + case products + } +} + +private extension CreateCartResponse { + /// Describes a product in a cart. + struct Product: Decodable { + let productID: Int64 + + private enum CodingKeys: String, CodingKey { + case productID = "product_id" + } + } +} + +// MARK: - Constants +// +private extension PaymentRemote { + enum Path { + static let products = "plans" + static let cartCreation = "me/shopping-cart" + } +} diff --git a/Networking/Networking/Requests/DotcomRequest.swift b/Networking/Networking/Requests/DotcomRequest.swift index 6c3b28f7e7d..19323ff81bc 100644 --- a/Networking/Networking/Requests/DotcomRequest.swift +++ b/Networking/Networking/Requests/DotcomRequest.swift @@ -53,7 +53,7 @@ struct DotcomRequest: Request { func responseDataValidator() -> ResponseDataValidator { switch wordpressApiVersion { - case .mark1_1, .mark1_2: + case .mark1_1, .mark1_2, .mark1_5: return DotcomValidator() case .wpcomMark2, .wpMark2: return WordPressApiValidator() diff --git a/Networking/Networking/Settings/WordPressAPIVersion.swift b/Networking/Networking/Settings/WordPressAPIVersion.swift index 0b8e94a775e..5d90e18743b 100644 --- a/Networking/Networking/Settings/WordPressAPIVersion.swift +++ b/Networking/Networking/Settings/WordPressAPIVersion.swift @@ -13,6 +13,10 @@ enum WordPressAPIVersion: String { /// case mark1_2 = "rest/v1.2/" + /// WordPress.com Endpoint Mark 1.5 + /// + case mark1_5 = "rest/v1.5/" + /// WPcom REST API Endpoint Mark 2 /// case wpcomMark2 = "wpcom/v2/" diff --git a/Networking/NetworkingTests/Remote/PaymentRemoteTests.swift b/Networking/NetworkingTests/Remote/PaymentRemoteTests.swift new file mode 100644 index 00000000000..7f70b53009c --- /dev/null +++ b/Networking/NetworkingTests/Remote/PaymentRemoteTests.swift @@ -0,0 +1,112 @@ +import XCTest +import TestKit +@testable import Networking + +final class PaymentRemoteTests: XCTestCase { + /// Mock network wrapper. + private var network: MockNetwork! + + override func setUp() { + super.setUp() + network = MockNetwork() + } + + override func tearDown() { + network = nil + super.tearDown() + } + + // MARK: - `loadPlan` + + func test_loadPlan_returns_plan_on_success() async throws { + // Given + let remote = PaymentRemote(network: network) + network.simulateResponse(requestUrlSuffix: "plans", filename: "load-plan-success") + + // When + let plan = try await remote.loadPlan(thatMatchesID: Constants.planProductID) + + // Then + XCTAssertEqual(plan, .init(productID: Constants.planProductID, + name: "WordPress.com eCommerce", + formattedPrice: "NT$2,230")) + } + + func test_loadPlan_throws_noMatchingPlan_error_when_response_does_not_include_plan_with_given_id() async throws { + // Given + let remote = PaymentRemote(network: network) + network.simulateResponse(requestUrlSuffix: "plans", filename: "load-plan-success") + + // When + await assertThrowsError { + _ = try await remote.loadPlan(thatMatchesID: 9) + } errorAssert: { error in + // Then + (error as? LoadPlanError) == .noMatchingPlan + } + } + + func test_loadPlan_throws_notFound_error_when_no_response() async throws { + // Given + let remote = PaymentRemote(network: network) + + // When + await assertThrowsError { + _ = try await remote.loadPlan(thatMatchesID: 9) + } errorAssert: { error in + // Then + (error as? NetworkError) == .notFound + } + } + + // MARK: - `createCart` + + func test_createCart_returns_on_success() async throws { + // Given + let siteID: Int64 = 606 + let remote = PaymentRemote(network: network) + network.simulateResponse(requestUrlSuffix: "me/shopping-cart/\(siteID)", filename: "create-cart-success") + + // When + do { + try await remote.createCart(siteID: siteID, productID: Constants.planProductID) + } catch { + // Then + XCTFail("Unexpected error: \(error)") + } + } + + func test_createCart_throws_productNotInCart_error_when_response_does_not_include_plan_with_given_id() async throws { + // Given + let siteID: Int64 = 606 + let remote = PaymentRemote(network: network) + network.simulateResponse(requestUrlSuffix: "me/shopping-cart/\(siteID)", filename: "create-cart-success") + + // When + await assertThrowsError { + _ = try await remote.createCart(siteID: siteID, productID: 685) + } errorAssert: { error in + // Then + (error as? CreateCartError) == .productNotInCart + } + } + + func test_createCart_throws_notFound_error_when_no_response() async throws { + // Given + let remote = PaymentRemote(network: network) + + // When + await assertThrowsError { + _ = try await remote.createCart(siteID: 606, productID: 685) + } errorAssert: { error in + // Then + (error as? NetworkError) == .notFound + } + } +} + +private extension PaymentRemoteTests { + enum Constants { + static let planProductID: Int64 = 1021 + } +} diff --git a/Networking/NetworkingTests/Responses/create-cart-success.json b/Networking/NetworkingTests/Responses/create-cart-success.json new file mode 100644 index 00000000000..5cbb671e873 --- /dev/null +++ b/Networking/NetworkingTests/Responses/create-cart-success.json @@ -0,0 +1,110 @@ +{ + "cart_generated_at_timestamp": 1669171867, + "blog_id": 2022, + "cart_key": 2022, + "coupon": "", + "coupon_discounts": [], + "coupon_discounts_integer": [], + "coupon_discounts_display": [], + "is_coupon_applied": false, + "has_bundle_credit": false, + "next_domain_is_free": false, + "next_domain_condition": "", + "products": [ + { + "product_id": 1021, + "billing_plan_id": "38838", + "product_name": "WordPress.com eCommerce", + "product_name_en": "WordPress.com E-commerce", + "product_slug": "ecommerce-bundle-monthly", + "product_cost": 70, + "product_cost_display": "US$70", + "product_cost_integer": 7000, + "meta": "", + "cost": 70, + "currency": "USD", + "volume": 1, + "quantity": null, + "current_quantity": null, + "price_tier_minimum_units": null, + "price_tier_maximum_units": null, + "free_trial": false, + "introductory_offer_terms": null, + "cost_before_coupon": 70, + "coupon_savings": 0, + "coupon_savings_integer": 0, + "coupon_savings_display": "US$0", + "is_sale_coupon_applied": false, + "extra": { + "added_from_shopping_cart": true + }, + "bill_period": "31", + "months_per_bill_period": 1, + "is_domain_registration": false, + "time_added_to_cart": 1669171867, + "is_bundled": false, + "item_original_cost": 70, + "item_original_cost_integer": 7000, + "item_original_cost_display": "US$70", + "item_original_monthly_cost_integer": 7000, + "item_original_monthly_cost_display": "US$70", + "item_original_cost_for_quantity_one_integer": 7000, + "item_original_cost_for_quantity_one_display": "US$70", + "item_subtotal_monthly_cost_integer": 7000, + "item_subtotal_monthly_cost_display": "US$70", + "item_original_subtotal": 70, + "item_original_subtotal_integer": 7000, + "item_original_subtotal_display": "US$70", + "item_subtotal": 70, + "item_subtotal_integer": 7000, + "item_subtotal_display": "US$70", + "item_tax": 0, + "item_tax_rate": 0, + "item_tax_breakdown": [], + "item_total": 70, + "item_total_integer": 7000, + "subscription_id": 0, + "is_renewal": false, + "domain_post_renewal_expiration_date": null, + "related_monthly_plan_cost_integer": 0, + "related_monthly_plan_cost_display": "", + "cost_overrides": [], + "is_gift_purchase": false + } + ], + "total_cost": 0, + "currency": "USD", + "total_cost_display": "US$0", + "total_cost_integer": 0, + "temporary": true, + "tax": { + "location": {}, + "display_taxes": false + }, + "coupon_savings_total": 0, + "coupon_savings_total_display": "US$0", + "coupon_savings_total_integer": 0, + "sub_total_with_taxes_display": "US$70", + "sub_total_with_taxes_integer": 7000, + "sub_total": 70, + "sub_total_display": "US$70", + "sub_total_integer": 7000, + "total_tax": 0, + "total_tax_display": "US$0", + "total_tax_integer": 0, + "total_tax_breakdown": [], + "credits": 38, + "credits_display": "US$38", + "credits_integer": 38, + "allowed_payment_methods": [ + "WPCOM_Billing_WPCOM" + ], + "create_new_blog": false, + "terms_of_service": [], + "is_gift_purchase": false, + "gift_details": null, + "messages": { + "errors": [], + "success": [] + } +} diff --git a/Networking/NetworkingTests/Responses/load-plan-success.json b/Networking/NetworkingTests/Responses/load-plan-success.json new file mode 100644 index 00000000000..d3907829d6f --- /dev/null +++ b/Networking/NetworkingTests/Responses/load-plan-success.json @@ -0,0 +1,73 @@ +[ + { + "product_id": 2002, + "product_name": "Jetpack Free", + "meta": null, + "bd_slug": "jetpack-free", + "bd_variation_slug": "jetpack-free-yearly", + "sale_coupon_applied": false, + "sale_coupon": null, + "multi": 0, + "blog_id": null, + "cost": 0, + "orig_cost": null, + "is_cost_from_introductory_offer": false, + "product_slug": "jetpack_free", + "description": "", + "bill_period": -1, + "product_type": "jetpack", + "available": "yes", + "outer_slug": null, + "extra": null, + "capability": "manage_options", + "product_name_short": "Free", + "icon": "https://s0.wordpress.com/i/store/plan-free.png", + "icon_active": "https://s0.wordpress.com/i/store/plan-free-active.png", + "bill_period_label": "for life", + "price": "NT$0", + "formatted_price": "NT$0", + "raw_price": 0, + "product_display_price": "NT$0", + "tagline": null, + "currency_code": "TWD" + }, + { + "product_id": 1021, + "product_name": "WordPress.com eCommerce", + "meta": null, + "bd_slug": "wp-bundles", + "bd_variation_slug": "wp-bundle-ecommerce-monthly", + "sale_coupon_applied": false, + "sale_coupon": null, + "multi": 0, + "blog_id": null, + "cost": 2230, + "orig_cost": null, + "is_cost_from_introductory_offer": false, + "path_slug": "ecommerce-monthly", + "product_slug": "ecommerce-bundle-monthly", + "description": "", + "bill_period": 31, + "product_type": "bundle", + "available": "yes", + "outer_slug": "", + "extra": "", + "capability": "manage_options", + "product_name_short": "eCommerce", + "bundle_product_ids": [ + 12, + 45, + 15, + 50, + 49, + 20 + ], + "bill_period_label": "per month", + "price": "NT$2,230", + "formatted_price": "NT$2,230", + "raw_price": 2230, + "product_display_price": "NT$2,230", + "tagline": null, + "currency_code": "TWD" + } +] diff --git a/Yosemite/Yosemite/Model/Model.swift b/Yosemite/Yosemite/Model/Model.swift index f5419d23dba..6ebad88c80a 100644 --- a/Yosemite/Yosemite/Model/Model.swift +++ b/Yosemite/Yosemite/Model/Model.swift @@ -135,6 +135,7 @@ public typealias TopEarnerStats = Networking.TopEarnerStats public typealias TopEarnerStatsItem = Networking.TopEarnerStatsItem public typealias User = Networking.User public typealias WooAPIVersion = Networking.WooAPIVersion +public typealias WPComPlan = Networking.WPComPlan public typealias StoredProductSettings = Networking.StoredProductSettings public typealias CardReader = Hardware.CardReader public typealias CardReaderEvent = Hardware.CardReaderEvent