diff --git a/Yosemite/Yosemite.xcodeproj/project.pbxproj b/Yosemite/Yosemite.xcodeproj/project.pbxproj index fb04efca244..084ddab2690 100644 --- a/Yosemite/Yosemite.xcodeproj/project.pbxproj +++ b/Yosemite/Yosemite.xcodeproj/project.pbxproj @@ -82,6 +82,10 @@ 02E4F5E423CD5628003B0010 /* NSOrderedSet+Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E4F5E323CD5628003B0010 /* NSOrderedSet+Array.swift */; }; 02E7FFD52562226B00C53030 /* ShippingLabelStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E7FFD42562226B00C53030 /* ShippingLabelStoreTests.swift */; }; 02E7FFD92562234F00C53030 /* MockShippingLabelRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E7FFD82562234F00C53030 /* MockShippingLabelRemote.swift */; }; + 02EF1666292DB65000D90AD6 /* PaymentStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EF1665292DB65000D90AD6 /* PaymentStore.swift */; }; + 02EF1668292DB68C00D90AD6 /* PaymentAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EF1667292DB68C00D90AD6 /* PaymentAction.swift */; }; + 02F2722D292F18BF00C36419 /* PaymentStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F2722C292F18BF00C36419 /* PaymentStoreTests.swift */; }; + 02F2722F292F18FD00C36419 /* MockPaymentRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F2722E292F18FD00C36419 /* MockPaymentRemote.swift */; }; 02F6AAAC270556A4002425D0 /* Models+Copiable.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F6AAAB270556A4002425D0 /* Models+Copiable.generated.swift */; }; 02FF054D23D983F30058E6E7 /* MediaFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FF054523D983F30058E6E7 /* MediaFileManager.swift */; }; 02FF054E23D983F30058E6E7 /* MediaImageExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FF054623D983F30058E6E7 /* MediaImageExporter.swift */; }; @@ -507,6 +511,10 @@ 02E4F5E323CD5628003B0010 /* NSOrderedSet+Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSOrderedSet+Array.swift"; sourceTree = ""; }; 02E7FFD42562226B00C53030 /* ShippingLabelStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelStoreTests.swift; sourceTree = ""; }; 02E7FFD82562234F00C53030 /* MockShippingLabelRemote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockShippingLabelRemote.swift; sourceTree = ""; }; + 02EF1665292DB65000D90AD6 /* PaymentStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentStore.swift; sourceTree = ""; }; + 02EF1667292DB68C00D90AD6 /* PaymentAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentAction.swift; sourceTree = ""; }; + 02F2722C292F18BF00C36419 /* PaymentStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentStoreTests.swift; sourceTree = ""; }; + 02F2722E292F18FD00C36419 /* MockPaymentRemote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPaymentRemote.swift; sourceTree = ""; }; 02F6AAAB270556A4002425D0 /* Models+Copiable.generated.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Models+Copiable.generated.swift"; sourceTree = ""; }; 02FF054523D983F30058E6E7 /* MediaFileManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaFileManager.swift; sourceTree = ""; }; 02FF054623D983F30058E6E7 /* MediaImageExporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaImageExporter.swift; sourceTree = ""; }; @@ -1206,6 +1214,7 @@ 021BA0C328576940006E9886 /* MockDotcomAccountRemote.swift */, 02DAE7F9291A9F36009342B7 /* MockDomainRemote.swift */, 02616F932921E1CD0095BC00 /* MockSiteRemote.swift */, + 02F2722E292F18FD00C36419 /* MockPaymentRemote.swift */, ); path = Remote; sourceTree = ""; @@ -1419,6 +1428,7 @@ 02E3B622290267D3007E0F13 /* AccountCreationStore.swift */, 02393066291A02AC00B2632F /* DomainStore.swift */, 021940E5291E8AD80090354E /* SiteStore.swift */, + 02EF1665292DB65000D90AD6 /* PaymentStore.swift */, ); path = Stores; sourceTree = ""; @@ -1480,6 +1490,7 @@ 02E3B629290622DE007E0F13 /* AccountCreationStoreTests.swift */, 02DAE7F7291A9F11009342B7 /* DomainStoreTests.swift */, 02616F912921E1530095BC00 /* SiteStoreTests.swift */, + 02F2722C292F18BF00C36419 /* PaymentStoreTests.swift */, ); path = Stores; sourceTree = ""; @@ -1667,6 +1678,7 @@ 02E3B624290267F2007E0F13 /* AccountCreationAction.swift */, 02393064291A018600B2632F /* DomainAction.swift */, 021940E3291E8A660090354E /* SiteAction.swift */, + 02EF1667292DB68C00D90AD6 /* PaymentAction.swift */, ); path = Actions; sourceTree = ""; @@ -2001,6 +2013,7 @@ 261F94E6242EFF8700762B58 /* ProductCategoryStore.swift in Sources */, 57DFCC7925003C4000251E0C /* FetchResultSnapshotsProvider.swift in Sources */, 7492FAD9217FAD1000ED2C69 /* SiteSetting+ReadOnlyConvertible.swift in Sources */, + 02EF1666292DB65000D90AD6 /* PaymentStore.swift in Sources */, E1BD4D0027ABF84D006416D9 /* CardPresentPaymentsConfiguration.swift in Sources */, CE3B7AD52225EBF10050FE4B /* OrderStatusAction.swift in Sources */, 7493750C224987D9007D85D1 /* ProductAttribute+ReadOnlyConvertible.swift in Sources */, @@ -2115,6 +2128,7 @@ B52E002E211A3F5500700FDE /* ReadOnlyType.swift in Sources */, 5726456F250BD4E4005BBD7C /* OrdersUpsertUseCase.swift in Sources */, 3147030C2670333200EF253A /* WCPayAccount+PaymentGatewayAccount.swift in Sources */, + 02EF1668292DB68C00D90AD6 /* PaymentAction.swift in Sources */, B5C9DE162087FF0E006B910A /* Store.swift in Sources */, 02FF056123D98FD40058E6E7 /* ImageSourceWriter.swift in Sources */, 0212AC5E242C67FA00C51F6C /* ProductsSortOrder.swift in Sources */, @@ -2279,6 +2293,7 @@ 02FF055B23D9846A0058E6E7 /* MediaDirectoryTests.swift in Sources */, 265BCA0024301ACD004E53EE /* ProductCategoryStoreTests.swift in Sources */, 02FF056D23DEDCB90058E6E7 /* MockImageSourceWriter.swift in Sources */, + 02F2722F292F18FD00C36419 /* MockPaymentRemote.swift in Sources */, FE28F6F2268462A6004465C7 /* UserStoreTests.swift in Sources */, 02DAE7FA291A9F36009342B7 /* MockDomainRemote.swift in Sources */, 741F34842195F752005F5BD9 /* CommentStoreTests.swift in Sources */, @@ -2295,6 +2310,7 @@ 031FD8A026FC970400B315C7 /* RosettaTestingHelper.swift in Sources */, 077F39E526A5C98200ABEADC /* SystemStatusStoreTests.swift in Sources */, 022F931D257F27B40011CD94 /* MockShippingLabelAddress.swift in Sources */, + 02F2722D292F18BF00C36419 /* PaymentStoreTests.swift in Sources */, 02FF056923DECD5B0058E6E7 /* MediaImageExporterTests.swift in Sources */, 0202B6972387AFBF00F3EBE0 /* MockInMemoryStorage.swift in Sources */, 028BCE2422DE22BB00056966 /* SiteVisitStatsStoreErrorTests.swift in Sources */, diff --git a/Yosemite/Yosemite/Actions/PaymentAction.swift b/Yosemite/Yosemite/Actions/PaymentAction.swift new file mode 100644 index 00000000000..4d22d033a81 --- /dev/null +++ b/Yosemite/Yosemite/Actions/PaymentAction.swift @@ -0,0 +1,22 @@ +import Foundation + +/// PaymentAction: Defines all of the Actions supported by the PaymentStore. +/// +public enum PaymentAction: Action { + /// Loads a specific WPCOM plan. + /// - Parameters: + /// - productID: The ID of the WPCOM product to return. + /// - completion: Invoked when the WPCOM plan that matches the given ID is loaded. + case loadPlan(productID: Int64, + completion: (Result) -> Void) + + /// Creates a cart with a WPCOM plan. + /// - Parameters: + /// - productID: The ID of the WPCOM plan product. It is of string type to integrate with `InAppPurchasesForWPComPlansProtocol`. + /// If the value is not a string of integer value, an error `CreateCartError.invalidProductID` is returned. + /// - siteID: The site ID for the WPCOM plan to be attached to. + /// - completion: The result of cart creation. + case createCart(productID: String, + siteID: Int64, + completion: (Result) -> Void) +} diff --git a/Yosemite/Yosemite/Stores/PaymentStore.swift b/Yosemite/Yosemite/Stores/PaymentStore.swift new file mode 100644 index 00000000000..050c7665d4a --- /dev/null +++ b/Yosemite/Yosemite/Stores/PaymentStore.swift @@ -0,0 +1,93 @@ +import Foundation +import Networking +import protocol Storage.StorageManagerType + +/// Handles `PaymentAction` +/// +public final class PaymentStore: Store { + // Keeps a strong reference to remote to keep requests alive. + private let remote: PaymentRemoteProtocol + + public init(remote: PaymentRemoteProtocol, + dispatcher: Dispatcher, + storageManager: StorageManagerType, + network: Network) { + self.remote = remote + super.init(dispatcher: dispatcher, storageManager: storageManager, network: network) + } + + public convenience override init(dispatcher: Dispatcher, + storageManager: StorageManagerType, + network: Network) { + let remote = PaymentRemote(network: network) + self.init(remote: remote, + dispatcher: dispatcher, + storageManager: storageManager, + network: network) + } + + public override func registerSupportedActions(in dispatcher: Dispatcher) { + dispatcher.register(processor: self, for: PaymentAction.self) + } + + /// Called whenever a given Action is dispatched. + /// + public override func onAction(_ action: Action) { + guard let action = action as? PaymentAction else { + assertionFailure("PaymentStore received an unsupported action: \(action)") + return + } + switch action { + case .loadPlan(let productID, let completion): + loadPlan(productID: productID, completion: completion) + case .createCart(let productID, let siteID, let completion): + createCart(productID: productID, siteID: siteID, completion: completion) + } + } +} + +private extension PaymentStore { + func loadPlan(productID: Int64, + completion: @escaping (Result) -> Void) { + Task { @MainActor in + do { + let plan = try await remote.loadPlan(thatMatchesID: productID) + completion(.success(plan)) + } catch { + completion(.failure(error)) + } + } + } + + func createCart(productID: String, + siteID: Int64, + completion: @escaping (Result) -> Void) { + Task { @MainActor in + do { + guard let productID = Int64(productID) else { + return completion(.failure(CreateCartError.invalidProductID)) + } + _ = try await remote.createCart(siteID: siteID, productID: productID) + completion(.success(())) + } catch { + switch error { + case let networkError as Networking.CreateCartError: + switch networkError { + case .productNotInCart: + completion(.failure(CreateCartError.productNotInCart)) + } + default: + completion(.failure(error)) + } + } + } + } +} + +/// Possible cart creation errors. +public enum CreateCartError: Error, Equatable { + /// Product ID is not in the correct format for WPCOM plans. + case invalidProductID + /// The expected product is not in the created cart. + case productNotInCart +} diff --git a/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockPaymentRemote.swift b/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockPaymentRemote.swift new file mode 100644 index 00000000000..fcf2d88d31f --- /dev/null +++ b/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockPaymentRemote.swift @@ -0,0 +1,40 @@ +import Networking +import XCTest + +/// Mock for `PaymentRemote`. +/// +final class MockPaymentRemote { + /// The results to return in `loadPlan`. + private var loadPlanResult: Result? + + /// The results to return in `createCart`. + private var createCartResult: Result? + + /// Returns the value when `loadPlan` is called. + func whenLoadingPlan(thenReturn result: Result) { + loadPlanResult = result + } + + /// Returns the value when `createCart` is called. + func whenCreatingCart(thenReturn result: Result) { + createCartResult = result + } +} + +extension MockPaymentRemote: PaymentRemoteProtocol { + func loadPlan(thatMatchesID productID: Int64) async throws -> Networking.WPComPlan { + guard let result = loadPlanResult else { + XCTFail("Could not find result for loading a plan.") + throw NetworkError.notFound + } + return try result.get() + } + + func createCart(siteID: Int64, productID: Int64) async throws { + guard let result = createCartResult else { + XCTFail("Could not find result for creating a cart.") + throw NetworkError.notFound + } + return try result.get() + } +} diff --git a/Yosemite/YosemiteTests/Stores/PaymentStoreTests.swift b/Yosemite/YosemiteTests/Stores/PaymentStoreTests.swift new file mode 100644 index 00000000000..c4d3e347b82 --- /dev/null +++ b/Yosemite/YosemiteTests/Stores/PaymentStoreTests.swift @@ -0,0 +1,140 @@ +import TestKit +import XCTest +@testable import Networking +@testable import Yosemite + +final class PaymentStoreTests: XCTestCase { + /// Mock Dispatcher. + private var dispatcher: Dispatcher! + + /// Mock Storage: InMemory. + private var storageManager: MockStorageManager! + + /// Mock Network: Allows us to inject predefined responses. + private var network: MockNetwork! + + private var remote: MockPaymentRemote! + private var store: PaymentStore! + + override func setUp() { + super.setUp() + dispatcher = Dispatcher() + storageManager = MockStorageManager() + network = MockNetwork() + remote = MockPaymentRemote() + store = PaymentStore(remote: remote, dispatcher: dispatcher, storageManager: storageManager, network: network) + } + + override func tearDown() { + store = nil + remote = nil + network = nil + storageManager = nil + dispatcher = nil + super.tearDown() + } + + // MARK: - `loadPlan` + + func test_loadPlan_returns_plan_on_success() throws { + // Given + remote.whenLoadingPlan(thenReturn: .success(.init(productID: 12, name: "woo", formattedPrice: "$16.8"))) + + // When + let result = waitFor { promise in + self.store.onAction(PaymentAction.loadPlan(productID: 12) { result in + promise(result) + }) + } + + // Then + XCTAssertTrue(result.isSuccess) + let plan = try XCTUnwrap(result.get()) + XCTAssertEqual(plan, .init(productID: 12, name: "woo", formattedPrice: "$16.8")) + } + + func test_loadPlan_returns_failure_on_error() throws { + // Given + remote.whenLoadingPlan(thenReturn: .failure(NetworkError.timeout)) + + // When + let result = waitFor { promise in + self.store.onAction(PaymentAction.loadPlan(productID: 12) { result in + promise(result) + }) + } + + // Then + XCTAssertTrue(result.isFailure) + let error = try XCTUnwrap(result.failure) + XCTAssertEqual(error as? NetworkError, .timeout) + } + + // MARK: - `createCart` + + func test_createCart_returns_on_success() throws { + // Given + remote.whenCreatingCart(thenReturn: .success(())) + + // When + let result = waitFor { promise in + self.store.onAction(PaymentAction.createCart(productID: "12", siteID: 62) { result in + promise(result) + }) + } + + // Then + XCTAssertTrue(result.isSuccess) + } + + func test_createCart_returns_invalidProductID_error_when_productID_is_not_integer() throws { + // Given + remote.whenCreatingCart(thenReturn: .failure(NetworkError.timeout)) + + // When + let result = waitFor { promise in + self.store.onAction(PaymentAction.createCart(productID: "wo0", siteID: 62) { result in + promise(result) + }) + } + + // Then + XCTAssertTrue(result.isFailure) + let error = try XCTUnwrap(result.failure) + XCTAssertEqual(error as? Yosemite.CreateCartError, .invalidProductID) + } + + func test_createCart_relays_networking_CreateCartError_failure() throws { + // Given + remote.whenCreatingCart(thenReturn: .failure(Networking.CreateCartError.productNotInCart)) + + // When + let result = waitFor { promise in + self.store.onAction(PaymentAction.createCart(productID: "12", siteID: 62) { result in + promise(result) + }) + } + + // Then + XCTAssertTrue(result.isFailure) + let error = try XCTUnwrap(result.failure) + XCTAssertEqual(error as? Yosemite.CreateCartError, .productNotInCart) + } + + func test_createCart_returns_failure_on_error() throws { + // Given + remote.whenCreatingCart(thenReturn: .failure(NetworkError.timeout)) + + // When + let result = waitFor { promise in + self.store.onAction(PaymentAction.createCart(productID: "12", siteID: 62) { result in + promise(result) + }) + } + + // Then + XCTAssertTrue(result.isFailure) + let error = try XCTUnwrap(result.failure) + XCTAssertEqual(error as? NetworkError, .timeout) + } +}