diff --git a/Experiments/Experiments/FeatureFlag.swift b/Experiments/Experiments/FeatureFlag.swift index 53c1177f972..d475c5d3569 100644 --- a/Experiments/Experiments/FeatureFlag.swift +++ b/Experiments/Experiments/FeatureFlag.swift @@ -83,7 +83,7 @@ public enum FeatureFlag: Int { case storeCreationM2 /// Whether in-app purchases are enabled for store creation milestone 2 behind `storeCreationM2` feature flag. - /// If disabled, mock in-app purchases are provided by `MockInAppPurchases`. + /// If disabled, purchases are backed by `WebPurchasesForWPComPlans` for checkout in a webview. /// case storeCreationM2WithInAppPurchasesEnabled diff --git a/Networking/Networking/Remote/PaymentRemote.swift b/Networking/Networking/Remote/PaymentRemote.swift index dc89e3382a6..786e11dedc1 100644 --- a/Networking/Networking/Remote/PaymentRemote.swift +++ b/Networking/Networking/Remote/PaymentRemote.swift @@ -55,6 +55,12 @@ public struct WPComPlan: Decodable, Equatable { public let name: String public let formattedPrice: String + public init(productID: Int64, name: String, formattedPrice: String) { + self.productID = productID + self.name = name + self.formattedPrice = formattedPrice + } + private enum CodingKeys: String, CodingKey { case productID = "product_id" case name = "product_name" diff --git a/WooCommerce/Classes/Authentication/Epilogue/StorePickerCoordinator.swift b/WooCommerce/Classes/Authentication/Epilogue/StorePickerCoordinator.swift index 1f5b769e0bc..305ba757aee 100644 --- a/WooCommerce/Classes/Authentication/Epilogue/StorePickerCoordinator.swift +++ b/WooCommerce/Classes/Authentication/Epilogue/StorePickerCoordinator.swift @@ -179,7 +179,7 @@ private extension StorePickerConfiguration { case .switchingStores: return .modally case .storeCreationFromLogin: - return .navigationStack(animated: false) + return .navigationStack(animated: true) default: return .navigationStack(animated: true) } diff --git a/WooCommerce/Classes/Authentication/Store Creation/Plan/WebCheckoutViewModel.swift b/WooCommerce/Classes/Authentication/Store Creation/Plan/WebCheckoutViewModel.swift new file mode 100644 index 00000000000..f00c322f2b0 --- /dev/null +++ b/WooCommerce/Classes/Authentication/Store Creation/Plan/WebCheckoutViewModel.swift @@ -0,0 +1,69 @@ +import WebKit + +/// View model used for the web view controller to check out. +final class WebCheckoutViewModel: AuthenticatedWebViewModel { + // `AuthenticatedWebViewModel` protocol conformance. + let title = Localization.title + let initialURL: URL? + + /// Keeps track of whether the completion has been triggered so that it's only invoked once. + /// There are usually multiple redirects with the same success URL prefix. + private var isComplete: Bool = false + + private let completion: () -> Void + + /// - Parameters: + /// - siteSlug: The slug of the site URL that the web checkout is for. + /// - completion: Invoked when the webview reaches the success URL. + init(siteSlug: String, completion: @escaping () -> Void) { + self.initialURL = URL(string: String(format: Constants.checkoutURLFormat, siteSlug)) + self.completion = completion + } + + func handleDismissal() { + // no-op: dismissal is handled in the close button in the navigation bar. + } + + func handleRedirect(for url: URL?) { + guard let path = url?.absoluteString else { + return + } + handleCompletionIfPossible(path) + } + + func decidePolicy(for navigationURL: URL) async -> WKNavigationActionPolicy { + handleCompletionIfPossible(navigationURL.absoluteString) + return .allow + } +} + +private extension WebCheckoutViewModel { + func handleCompletionIfPossible(_ url: String) { + guard url.starts(with: Constants.completionURLPrefix) else { + return + } + // Running on the main thread is necessary if this method is triggered from `decidePolicy`. + DispatchQueue.main.async { [weak self] in + self?.handleSuccess() + } + } + + func handleSuccess() { + guard isComplete == false else { + return + } + completion() + isComplete = true + } +} + +private extension WebCheckoutViewModel { + enum Constants { + static let checkoutURLFormat = "https://wordpress.com/checkout/%@" + static let completionURLPrefix = "https://wordpress.com/checkout/thank-you/" + } + + enum Localization { + static let title = NSLocalizedString("Checkout", comment: "Title of the WPCOM checkout web view.") + } +} diff --git a/WooCommerce/Classes/Authentication/Store Creation/Plan/WebPurchasesForWPComPlans.swift b/WooCommerce/Classes/Authentication/Store Creation/Plan/WebPurchasesForWPComPlans.swift new file mode 100644 index 00000000000..ce916b6c3fe --- /dev/null +++ b/WooCommerce/Classes/Authentication/Store Creation/Plan/WebPurchasesForWPComPlans.swift @@ -0,0 +1,83 @@ +import Foundation +import protocol Yosemite.StoresManager +import enum Yosemite.PaymentAction +import struct Yosemite.WPComPlan + +/// An `InAppPurchasesForWPComPlansProtocol` implementation for purchasing a WPCOM plan in a webview. +struct WebPurchasesForWPComPlans { + struct Plan: WPComPlanProduct, Equatable { + let displayName: String + let description: String + let id: String + let displayPrice: String + } + + private let stores: StoresManager + + init(stores: StoresManager = ServiceLocator.stores) { + self.stores = stores + } +} + +extension WebPurchasesForWPComPlans: InAppPurchasesForWPComPlansProtocol { + func fetchProducts() async throws -> [WPComPlanProduct] { + let result = await loadPlan(thatMatchesID: Constants.eCommerceMonthlyPlanProductID) + switch result { + case .success(let plan): + return [plan].map { Plan(displayName: $0.name, description: "", id: "\($0.productID)", displayPrice: $0.formattedPrice) } + case .failure(let error): + throw error + } + } + + func userIsEntitledToProduct(with id: String) async throws -> Bool { + // A newly created site does not have any WPCOM plans. In web, the user can purchase a WPCOM plan for every site. + false + } + + func purchaseProduct(with id: String, for remoteSiteId: Int64) async throws -> InAppPurchaseResult { + let createCartResult = await createCart(productID: id, for: remoteSiteId) + switch createCartResult { + case .success: + // `StoreCreationCoordinator` will then launch the checkout webview after a cart is created. + return .pending + case .failure(let error): + throw error + } + } + + func retryWPComSyncForPurchasedProduct(with id: String) async throws { + // no-op + } + + func inAppPurchasesAreSupported() async -> Bool { + // Web purchases are available for everyone and every site. + true + } +} + +private extension WebPurchasesForWPComPlans { + @MainActor + func loadPlan(thatMatchesID productID: Int64) async -> Result { + await withCheckedContinuation { continuation in + stores.dispatch(PaymentAction.loadPlan(productID: productID) { result in + continuation.resume(returning: result) + }) + } + } + + @MainActor + func createCart(productID: String, for siteID: Int64) async -> Result { + await withCheckedContinuation { continuation in + stores.dispatch(PaymentAction.createCart(productID: productID, siteID: siteID) { result in + continuation.resume(returning: result) + }) + } + } +} + +private extension WebPurchasesForWPComPlans { + enum Constants { + static let eCommerceMonthlyPlanProductID: Int64 = 1021 + } +} diff --git a/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift b/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift index f0810048f5a..cb327f141c7 100644 --- a/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift +++ b/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift @@ -26,16 +26,12 @@ final class StoreCreationCoordinator: Coordinator { /// This property is kept as a lazy var instead of a dependency in the initializer because `InAppPurchasesForWPComPlansManager` is a @MainActor. /// If it's passed in the initializer, all call sites have to become @MainActor which results in too many changes. @MainActor - private lazy var iapManager: InAppPurchasesForWPComPlansProtocol = { -#if DEBUG + private lazy var purchasesManager: InAppPurchasesForWPComPlansProtocol = { if featureFlagService.isFeatureFlagEnabled(.storeCreationM2WithInAppPurchasesEnabled) { return InAppPurchasesForWPComPlansManager(stores: stores) } else { - return MockInAppPurchases() + return WebPurchasesForWPComPlans(stores: stores) } -#else - InAppPurchasesForWPComPlansManager(stores: stores) -#endif }() @Published private var siteIDFromStoreCreation: Int64? @@ -54,7 +50,7 @@ final class StoreCreationCoordinator: Coordinator { stores: StoresManager = ServiceLocator.stores, analytics: Analytics = ServiceLocator.analytics, featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, - iapManager: InAppPurchasesForWPComPlansProtocol? = nil) { + purchasesManager: InAppPurchasesForWPComPlansProtocol? = nil) { self.source = source self.navigationController = navigationController // Passing the `standard` configuration to include sites without WooCommerce (`isWooCommerceActive = false`). @@ -68,8 +64,8 @@ final class StoreCreationCoordinator: Coordinator { self.featureFlagService = featureFlagService Task { @MainActor in - if let iapManager { - self.iapManager = iapManager + if let purchasesManager { + self.purchasesManager = purchasesManager } } } @@ -81,15 +77,17 @@ final class StoreCreationCoordinator: Coordinator { Task { @MainActor in do { presentIAPEligibilityInProgressView() - guard await iapManager.inAppPurchasesAreSupported() else { + guard await purchasesManager.inAppPurchasesAreSupported() else { throw PlanPurchaseError.iapNotSupported } - let products = try await iapManager.fetchProducts() + let products = try await purchasesManager.fetchProducts() + let expectedPlanIdentifier = featureFlagService.isFeatureFlagEnabled(.storeCreationM2WithInAppPurchasesEnabled) ? + Constants.iapPlanIdentifier: Constants.webPlanIdentifier guard let product = products.first, - product.id == Constants.planIdentifier else { + product.id == expectedPlanIdentifier else { throw PlanPurchaseError.noMatchingProduct } - guard try await iapManager.userIsEntitledToProduct(with: product.id) == false else { + guard try await purchasesManager.userIsEntitledToProduct(with: product.id) == false else { throw PlanPurchaseError.productNotEligible } navigationController.dismiss(animated: true) { [weak self] in @@ -299,7 +297,8 @@ private extension StoreCreationCoordinator { guard let self else { return } self.showWPCOMPlan(from: navigationController, planToPurchase: planToPurchase, - siteID: result.siteID) + siteID: result.siteID, + siteSlug: result.siteSlug) } navigationController.pushViewController(storeSummary, animated: true) } @@ -307,10 +306,11 @@ private extension StoreCreationCoordinator { @MainActor func showWPCOMPlan(from navigationController: UINavigationController, planToPurchase: WPComPlanProduct, - siteID: Int64) { + siteID: Int64, + siteSlug: String) { let storePlan = StoreCreationPlanHostingController(viewModel: .init(plan: planToPurchase)) { [weak self] in guard let self else { return } - await self.purchasePlan(from: navigationController, siteID: siteID, planToPurchase: planToPurchase) + await self.purchasePlan(from: navigationController, siteID: siteID, siteSlug: siteSlug, planToPurchase: planToPurchase) } onClose: { [weak self] in guard let self else { return } self.showDiscardChangesAlert() @@ -321,25 +321,40 @@ private extension StoreCreationCoordinator { @MainActor func purchasePlan(from navigationController: UINavigationController, siteID: Int64, + siteSlug: String, planToPurchase: WPComPlanProduct) async { do { - let result = try await iapManager.purchaseProduct(with: planToPurchase.id, for: siteID) - switch result { - case .success: - showInProgressViewWhileWaitingForJetpackSite(from: navigationController, siteID: siteID) - default: - if !featureFlagService.isFeatureFlagEnabled(.storeCreationM2WithInAppPurchasesEnabled) { - // Since a successful result cannot be easily mocked, any result is considered a success - // when using a mock for IAP. + let result = try await purchasesManager.purchaseProduct(with: planToPurchase.id, for: siteID) + + if featureFlagService.isFeatureFlagEnabled(.storeCreationM2WithInAppPurchasesEnabled) { + switch result { + case .success: showInProgressViewWhileWaitingForJetpackSite(from: navigationController, siteID: siteID) + default: + return + } + } else { + switch result { + case .pending: + showWebCheckout(from: navigationController, siteID: siteID, siteSlug: siteSlug) + default: + return } - return } } catch { showPlanPurchaseErrorAlert(from: navigationController, error: error) } } + @MainActor + func showWebCheckout(from navigationController: UINavigationController, siteID: Int64, siteSlug: String) { + let checkoutViewModel = WebCheckoutViewModel(siteSlug: siteSlug) { [weak self] in + self?.showInProgressViewWhileWaitingForJetpackSite(from: navigationController, siteID: siteID) + } + let checkoutController = AuthenticatedWebViewController(viewModel: checkoutViewModel) + navigationController.pushViewController(checkoutController, animated: true) + } + @MainActor func showInProgressViewWhileWaitingForJetpackSite(from navigationController: UINavigationController, siteID: Int64) { @@ -410,11 +425,6 @@ private extension StoreCreationCoordinator { return continuation.resume(throwing: StoreCreationError.newSiteUnavailable) } - // When using a mock for IAP, returns the site without waiting for the site to become a Jetpack site. - if !self.featureFlagService.isFeatureFlagEnabled(.storeCreationM2WithInAppPurchasesEnabled) { - return continuation.resume(returning: site) - } - guard site.isJetpackConnected && site.isJetpackThePluginInstalled else { return continuation.resume(throwing: StoreCreationError.newSiteIsNotJetpackSite) } @@ -425,8 +435,10 @@ private extension StoreCreationCoordinator { @MainActor func showPlanPurchaseErrorAlert(from navigationController: UINavigationController, error: Error) { + let errorMessage = featureFlagService.isFeatureFlagEnabled(.storeCreationM2WithInAppPurchasesEnabled) ? + Localization.PlanPurchaseErrorAlert.defaultErrorMessage: Localization.PlanPurchaseErrorAlert.webPurchaseErrorMessage let alertController = UIAlertController(title: Localization.PlanPurchaseErrorAlert.title, - message: Localization.PlanPurchaseErrorAlert.defaultErrorMessage, + message: errorMessage, preferredStyle: .alert) alertController.view.tintColor = .text _ = alertController.addCancelActionWithTitle(Localization.StoreCreationErrorAlert.cancelActionTitle) { _ in } @@ -493,6 +505,10 @@ private extension StoreCreationCoordinator { "Please try again and make sure you are signed in to an App Store account eligible for purchase.", comment: "Message of the alert when the WPCOM plan cannot be purchased in the store creation flow." ) + static let webPurchaseErrorMessage = NSLocalizedString( + "Please try again, or exit the screen and check back on your store if you previously left the checkout screen while payment is in progress.", + comment: "Message of the alert when the WPCOM plan cannot be purchased in a webview in the store creation flow." + ) static let cancelActionTitle = NSLocalizedString( "OK", comment: "Button title to dismiss the alert when the WPCOM plan cannot be purchased in the store creation flow." @@ -510,7 +526,8 @@ private extension StoreCreationCoordinator { enum Constants { // TODO: 8108 - update the identifier to production value when it's ready - static let planIdentifier = "debug.woocommerce.ecommerce.monthly" + static let iapPlanIdentifier = "debug.woocommerce.ecommerce.monthly" + static let webPlanIdentifier = "1021" } /// Error scenarios when purchasing a WPCOM plan. diff --git a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift index 3ce1c6ef960..ec0452429c0 100644 --- a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift +++ b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift @@ -48,6 +48,7 @@ class AuthenticatedState: StoresManagerState { OrderNoteStore(dispatcher: dispatcher, storageManager: storageManager, network: network), OrderStore(dispatcher: dispatcher, storageManager: storageManager, network: network), OrderStatusStore(dispatcher: dispatcher, storageManager: storageManager, network: network), + PaymentStore(dispatcher: dispatcher, storageManager: storageManager, network: network), PaymentGatewayStore(dispatcher: dispatcher, storageManager: storageManager, network: network), ProductAttributeStore(dispatcher: dispatcher, storageManager: storageManager, network: network), ProductAttributeTermStore(dispatcher: dispatcher, storageManager: storageManager, network: network), diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 37e598226b3..9460c0f4211 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -24,6 +24,8 @@ 0202B68D23876BC100F3EBE0 /* ProductsTabProductViewModel+ProductVariation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0202B68C23876BC100F3EBE0 /* ProductsTabProductViewModel+ProductVariation.swift */; }; 0202B6922387AB0C00F3EBE0 /* WooTab+Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0202B6912387AB0C00F3EBE0 /* WooTab+Tag.swift */; }; 0202B6952387AD1B00F3EBE0 /* UITabBar+Order.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0202B6942387AD1B00F3EBE0 /* UITabBar+Order.swift */; }; + 0203C11C293058CB00EE61BF /* WebPurchasesForWPComPlansTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0203C11B293058CB00EE61BF /* WebPurchasesForWPComPlansTests.swift */; }; + 0203C11F2930645B00EE61BF /* WebCheckoutViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0203C11E2930645B00EE61BF /* WebCheckoutViewModelTests.swift */; }; 0205021E27C8B6C600FB1C6B /* InboxEligibilityUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0205021D27C8B6C600FB1C6B /* InboxEligibilityUseCase.swift */; }; 020624F027951113000D024C /* StoreStatsDataOrRedactedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 020624EF27951113000D024C /* StoreStatsDataOrRedactedView.swift */; }; 02063C8929260AA000130906 /* StoreCreationSuccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02063C8829260A9F00130906 /* StoreCreationSuccessView.swift */; }; @@ -434,6 +436,8 @@ 02EEA9282923338100D05F47 /* StoreCreationPlanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EEA9272923338100D05F47 /* StoreCreationPlanView.swift */; }; 02EEB5C42424AFAA00B8A701 /* TextFieldTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EEB5C22424AFAA00B8A701 /* TextFieldTableViewCell.swift */; }; 02EEB5C52424AFAA00B8A701 /* TextFieldTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 02EEB5C32424AFAA00B8A701 /* TextFieldTableViewCell.xib */; }; + 02EF166A292DE9E100D90AD6 /* WebPurchasesForWPComPlans.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EF1669292DE9E100D90AD6 /* WebPurchasesForWPComPlans.swift */; }; + 02EF166C292DFE9A00D90AD6 /* WebCheckoutViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EF166B292DFE9A00D90AD6 /* WebCheckoutViewModel.swift */; }; 02F49ADA23BF356E00FA0BFA /* TitleAndTextFieldTableViewCell.ViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F49AD923BF356E00FA0BFA /* TitleAndTextFieldTableViewCell.ViewModel+State.swift */; }; 02F49ADC23BF3A0100FA0BFA /* ErrorSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F49ADB23BF3A0100FA0BFA /* ErrorSectionHeaderView.swift */; }; 02F49ADE23BF3A4100FA0BFA /* ErrorSectionHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 02F49ADD23BF3A4100FA0BFA /* ErrorSectionHeaderView.xib */; }; @@ -2007,6 +2011,8 @@ 0202B68C23876BC100F3EBE0 /* ProductsTabProductViewModel+ProductVariation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProductsTabProductViewModel+ProductVariation.swift"; sourceTree = ""; }; 0202B6912387AB0C00F3EBE0 /* WooTab+Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooTab+Tag.swift"; sourceTree = ""; }; 0202B6942387AD1B00F3EBE0 /* UITabBar+Order.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITabBar+Order.swift"; sourceTree = ""; }; + 0203C11B293058CB00EE61BF /* WebPurchasesForWPComPlansTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebPurchasesForWPComPlansTests.swift; sourceTree = ""; }; + 0203C11E2930645B00EE61BF /* WebCheckoutViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebCheckoutViewModelTests.swift; sourceTree = ""; }; 0205021D27C8B6C600FB1C6B /* InboxEligibilityUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxEligibilityUseCase.swift; sourceTree = ""; }; 020624EF27951113000D024C /* StoreStatsDataOrRedactedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreStatsDataOrRedactedView.swift; sourceTree = ""; }; 02063C8829260A9F00130906 /* StoreCreationSuccessView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StoreCreationSuccessView.swift; sourceTree = ""; }; @@ -2417,6 +2423,8 @@ 02EEA9272923338100D05F47 /* StoreCreationPlanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCreationPlanView.swift; sourceTree = ""; }; 02EEB5C22424AFAA00B8A701 /* TextFieldTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldTableViewCell.swift; sourceTree = ""; }; 02EEB5C32424AFAA00B8A701 /* TextFieldTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TextFieldTableViewCell.xib; sourceTree = ""; }; + 02EF1669292DE9E100D90AD6 /* WebPurchasesForWPComPlans.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebPurchasesForWPComPlans.swift; sourceTree = ""; }; + 02EF166B292DFE9A00D90AD6 /* WebCheckoutViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebCheckoutViewModel.swift; sourceTree = ""; }; 02F49AD923BF356E00FA0BFA /* TitleAndTextFieldTableViewCell.ViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TitleAndTextFieldTableViewCell.ViewModel+State.swift"; sourceTree = ""; }; 02F49ADB23BF3A0100FA0BFA /* ErrorSectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorSectionHeaderView.swift; sourceTree = ""; }; 02F49ADD23BF3A4100FA0BFA /* ErrorSectionHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ErrorSectionHeaderView.xib; sourceTree = ""; }; @@ -4013,6 +4021,17 @@ path = TabBar; sourceTree = ""; }; + 0203C11D2930643700EE61BF /* Store Creation */ = { + isa = PBXGroup; + children = ( + 0269A5E62913FD22003B20EB /* StoreCreationCoordinatorTests.swift */, + 020D0BFE2914F6BA00BB3DCE /* LoggedOutStoreCreationCoordinatorTests.swift */, + 0203C11B293058CB00EE61BF /* WebPurchasesForWPComPlansTests.swift */, + 0203C11E2930645B00EE61BF /* WebCheckoutViewModelTests.swift */, + ); + path = "Store Creation"; + sourceTree = ""; + }; 02063C8729260A6500130906 /* Installations */ = { isa = PBXGroup; children = ( @@ -5012,6 +5031,8 @@ 020AF66229235860007760E5 /* StoreCreationPlanViewModel.swift */, 02863F6929246E18006A06AA /* StoreCreationPlanFeaturesView.swift */, 02863F6E2925FC29006A06AA /* MockInAppPurchases.swift */, + 02EF1669292DE9E100D90AD6 /* WebPurchasesForWPComPlans.swift */, + 02EF166B292DFE9A00D90AD6 /* WebCheckoutViewModel.swift */, ); path = Plan; sourceTree = ""; @@ -6275,6 +6296,7 @@ DE2FE58E29261DCE0018040A /* Jetpack Setup */, D8610BDC256F5ABF00A5DF27 /* JetpackErrorViewModelTests.swift */, 57C2F6E424C27B1E00131012 /* Epilogue */, + 0203C11D2930643700EE61BF /* Store Creation */, D8610BF6256F5F0900A5DF27 /* ULErrorViewControllerTests.swift */, DE2FE5822924DA2F0018040A /* JetpackSetupRequiredViewModelTests.swift */, D85DD1D6257F359800861AA8 /* NotWPErrorViewModelTests.swift */, @@ -6290,9 +6312,7 @@ DE61979428A25842005E4362 /* StorePickerViewModelTests.swift */, DE3404E928B4C1D000CF0D97 /* NonAtomicSiteViewModelTests.swift */, DE50295228BF4A8A00551736 /* JetpackConnectionWebViewModelTests.swift */, - 0269A5E62913FD22003B20EB /* StoreCreationCoordinatorTests.swift */, 020D0BFC2914E92800BB3DCE /* StorePickerCoordinatorTests.swift */, - 020D0BFE2914F6BA00BB3DCE /* LoggedOutStoreCreationCoordinatorTests.swift */, ); path = Authentication; sourceTree = ""; @@ -10266,6 +10286,7 @@ CCEC256A27B581E800EF9FA3 /* ProductVariationFormatter.swift in Sources */, 02EA6BFA2435E92600FFF90A /* KingfisherImageDownloader+ImageDownloadable.swift in Sources */, 7E7C5F8F2719BA7300315B61 /* ProductCategoryCellViewModel.swift in Sources */, + 02EF166C292DFE9A00D90AD6 /* WebCheckoutViewModel.swift in Sources */, DEC2962326BD4E6E005A056B /* ShippingLabelCustomsFormInput.swift in Sources */, E1E649EB28461EDF0070B194 /* BetaFeaturesConfiguration.swift in Sources */, 26309F17277D0AEA0012797F /* SafeAreaInsetsKey.swift in Sources */, @@ -10570,6 +10591,7 @@ D85136B9231CED5800DD0539 /* ReviewAge.swift in Sources */, 5718852C2465D9EC00E2486F /* ReviewsCoordinator.swift in Sources */, AE3AA88B290C30B900BE422D /* WebViewControllerConfiguration.swift in Sources */, + 02EF166A292DE9E100D90AD6 /* WebPurchasesForWPComPlans.swift in Sources */, 26E1BECA251BE5390096D0A1 /* RefundItemTableViewCell.swift in Sources */, DE7B479527A38B8F0018742E /* CouponDetailsViewModel.swift in Sources */, E16058F9285876E600E471D4 /* LeftImageTitleSubtitleTableViewCell.swift in Sources */, @@ -11011,6 +11033,7 @@ 02BF9BAF2851E7EA008CE2DD /* MockAppleIDCredentialChecker.swift in Sources */, E17E3BFB266917E20009D977 /* CardPresentModalBluetoothRequiredTests.swift in Sources */, CCD2E68925DD52C100BD975D /* ProductVariationsViewModelTests.swift in Sources */, + 0203C11C293058CB00EE61BF /* WebPurchasesForWPComPlansTests.swift in Sources */, 26FE09E424DCFE5200B9BDF5 /* InAppFeedbackCardViewControllerTests.swift in Sources */, 0215320D2423309B003F2BBD /* UIStackView+SubviewsTests.swift in Sources */, 027B8BBD23FE0DE10040944E /* ProductImageActionHandlerTests.swift in Sources */, @@ -11199,6 +11222,7 @@ 02BC5AA424D27F8900C43326 /* ProductVariationFormViewModel+ObservablesTests.swift in Sources */, CCCC5B1326CC2B9F0034FB63 /* ShippingLabelCustomPackageFormViewModelTests.swift in Sources */, D41C9F3126D9A43200993558 /* WhatsNewViewModelTests.swift in Sources */, + 0203C11F2930645B00EE61BF /* WebCheckoutViewModelTests.swift in Sources */, 02A275BE23FE57DC005C560F /* ProductUIImageLoaderTests.swift in Sources */, 0271139A24DD15D800574A07 /* ProductsTabProductViewModel+VariationTests.swift in Sources */, 57A5D8DF253500F300AA54D6 /* RefundConfirmationViewModelTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/Authentication/LoggedOutStoreCreationCoordinatorTests.swift b/WooCommerce/WooCommerceTests/Authentication/Store Creation/LoggedOutStoreCreationCoordinatorTests.swift similarity index 100% rename from WooCommerce/WooCommerceTests/Authentication/LoggedOutStoreCreationCoordinatorTests.swift rename to WooCommerce/WooCommerceTests/Authentication/Store Creation/LoggedOutStoreCreationCoordinatorTests.swift diff --git a/WooCommerce/WooCommerceTests/Authentication/StoreCreationCoordinatorTests.swift b/WooCommerce/WooCommerceTests/Authentication/Store Creation/StoreCreationCoordinatorTests.swift similarity index 67% rename from WooCommerce/WooCommerceTests/Authentication/StoreCreationCoordinatorTests.swift rename to WooCommerce/WooCommerceTests/Authentication/Store Creation/StoreCreationCoordinatorTests.swift index c5531749dee..40797b624e7 100644 --- a/WooCommerce/WooCommerceTests/Authentication/StoreCreationCoordinatorTests.swift +++ b/WooCommerce/WooCommerceTests/Authentication/Store Creation/StoreCreationCoordinatorTests.swift @@ -1,5 +1,6 @@ import TestKit import XCTest +import Yosemite @testable import WooCommerce final class StoreCreationCoordinatorTests: XCTestCase { @@ -71,13 +72,14 @@ final class StoreCreationCoordinatorTests: XCTestCase { // MARK: - Presentation in different states for store creation M2 - func test_StoreNameFormHostingController_is_presented_when_navigationController_is_presenting_another_view() throws { + func test_StoreNameFormHostingController_is_presented_when_navigationController_is_presenting_another_view_with_iap_enabled() throws { // Given - let featureFlagService = MockFeatureFlagService(isStoreCreationM2Enabled: true) + let featureFlagService = MockFeatureFlagService(isStoreCreationM2Enabled: true, + isStoreCreationM2WithInAppPurchasesEnabled: true) let coordinator = StoreCreationCoordinator(source: .storePicker, navigationController: navigationController, featureFlagService: featureFlagService, - iapManager: MockInAppPurchases(fetchProductsDuration: 0)) + purchasesManager: MockInAppPurchases(fetchProductsDuration: 0)) waitFor { promise in self.navigationController.present(.init(), animated: false) { promise(()) @@ -96,14 +98,15 @@ final class StoreCreationCoordinatorTests: XCTestCase { assertThat(storeCreationNavigationController.topViewController, isAnInstanceOf: StoreNameFormHostingController.self) } - func test_StoreNameFormHostingController_is_presented_when_navigationController_is_showing_another_view() throws { + func test_StoreNameFormHostingController_is_presented_when_navigationController_is_showing_another_view_with_iap_enabled() throws { // Given - let featureFlagService = MockFeatureFlagService(isStoreCreationM2Enabled: true) + let featureFlagService = MockFeatureFlagService(isStoreCreationM2Enabled: true, + isStoreCreationM2WithInAppPurchasesEnabled: true) navigationController.show(.init(), sender: nil) let coordinator = StoreCreationCoordinator(source: .loggedOut(source: .loginEmailError), navigationController: navigationController, featureFlagService: featureFlagService, - iapManager: MockInAppPurchases(fetchProductsDuration: 0)) + purchasesManager: MockInAppPurchases(fetchProductsDuration: 0)) XCTAssertNotNil(navigationController.topViewController) XCTAssertNil(navigationController.presentedViewController) @@ -118,29 +121,61 @@ final class StoreCreationCoordinatorTests: XCTestCase { assertThat(storeCreationNavigationController.topViewController, isAnInstanceOf: StoreNameFormHostingController.self) } - // TODO: uncomment this test case after the flakiness is fixed -// func test_InProgressViewController_is_first_presented_when_fetching_iap_products() throws { -// // Given -// let featureFlagService = MockFeatureFlagService(isStoreCreationM2Enabled: true) -// let iapManager = MockInAppPurchases(fetchProductsDuration: 1) -// let coordinator = StoreCreationCoordinator(source: .storePicker, -// navigationController: navigationController, -// featureFlagService: featureFlagService, -// iapManager: iapManager) -// -// // When -// coordinator.start() -// -// // Then -// waitUntil { -// self.navigationController.presentedViewController is InProgressViewController -// } -// } + func test_StoreNameFormHostingController_is_presented_when_navigationController_is_presenting_another_view_with_iap_disabled() throws { + // Given + let featureFlagService = MockFeatureFlagService(isStoreCreationM2Enabled: true, + isStoreCreationM2WithInAppPurchasesEnabled: false) + let stores = MockStoresManager(sessionManager: .testingInstance) + stores.whenReceivingAction(ofType: PaymentAction.self) { action in + if case let .loadPlan(_, completion) = action { + completion(.success(.init(productID: 1021, name: "", formattedPrice: ""))) + } + } + let coordinator = StoreCreationCoordinator(source: .storePicker, + navigationController: navigationController, + featureFlagService: featureFlagService, + purchasesManager: WebPurchasesForWPComPlans(stores: stores)) + waitFor { promise in + self.navigationController.present(.init(), animated: false) { + promise(()) + } + } + XCTAssertNotNil(navigationController.presentedViewController) + + // When + coordinator.start() + + // Then + waitUntil { + self.navigationController.presentedViewController is UINavigationController + } + let storeCreationNavigationController = try XCTUnwrap(navigationController.presentedViewController as? UINavigationController) + assertThat(storeCreationNavigationController.topViewController, isAnInstanceOf: StoreNameFormHostingController.self) + } + + func test_InProgressViewController_is_first_presented_when_fetching_iap_products() throws { + // Given + let featureFlagService = MockFeatureFlagService(isStoreCreationM2Enabled: true) + // 6 seconds = 6_000_000_000 nanoseconds. + let purchasesManager = MockInAppPurchases(fetchProductsDuration: 6_000_000_000) + let coordinator = StoreCreationCoordinator(source: .storePicker, + navigationController: navigationController, + featureFlagService: featureFlagService, + purchasesManager: purchasesManager) + + // When + coordinator.start() + + // Then + waitUntil(timeout: 5) { + self.navigationController.presentedViewController is InProgressViewController + } + } func test_AuthenticatedWebViewController_is_presented_when_no_matching_iap_product() throws { // Given let featureFlagService = MockFeatureFlagService(isStoreCreationM2Enabled: true) - let iapManager = MockInAppPurchases(fetchProductsDuration: 0, + let purchasesManager = MockInAppPurchases(fetchProductsDuration: 0, products: [ MockInAppPurchases.Plan(displayName: "", description: "", @@ -150,7 +185,7 @@ final class StoreCreationCoordinatorTests: XCTestCase { let coordinator = StoreCreationCoordinator(source: .storePicker, navigationController: navigationController, featureFlagService: featureFlagService, - iapManager: iapManager) + purchasesManager: purchasesManager) // When coordinator.start() @@ -166,11 +201,11 @@ final class StoreCreationCoordinatorTests: XCTestCase { func test_AuthenticatedWebViewController_is_presented_when_user_is_already_entitled_to_iap_product() throws { // Given let featureFlagService = MockFeatureFlagService(isStoreCreationM2Enabled: true) - let iapManager = MockInAppPurchases(fetchProductsDuration: 0, userIsEntitledToProduct: true) + let purchasesManager = MockInAppPurchases(fetchProductsDuration: 0, userIsEntitledToProduct: true) let coordinator = StoreCreationCoordinator(source: .storePicker, navigationController: navigationController, featureFlagService: featureFlagService, - iapManager: iapManager) + purchasesManager: purchasesManager) // When coordinator.start() diff --git a/WooCommerce/WooCommerceTests/Authentication/Store Creation/WebCheckoutViewModelTests.swift b/WooCommerce/WooCommerceTests/Authentication/Store Creation/WebCheckoutViewModelTests.swift new file mode 100644 index 00000000000..c3beb212be4 --- /dev/null +++ b/WooCommerce/WooCommerceTests/Authentication/Store Creation/WebCheckoutViewModelTests.swift @@ -0,0 +1,32 @@ +import XCTest +@testable import WooCommerce + +final class WebCheckoutViewModelTests: XCTestCase { + func test_initialURL_contains_site_slug() { + // Given + let siteSlug = "woo.com" + + // When + let viewModel = WebCheckoutViewModel(siteSlug: siteSlug) {} + + // Then + XCTAssertEqual(viewModel.initialURL?.absoluteString, "https://wordpress.com/checkout/woo.com") + } + + func test_completion_is_only_invoked_once_when_handling_redirects_to_success_URL_multiple_times() throws { + // Given + let successURL = try XCTUnwrap(URL(string: "https://wordpress.com/checkout/thank-you/siteURL")) + + // When + // A variable needs to exist outside of the `waitFor` closure, otherwise the instance is deallocated + // when the async result returns. + var viewModel: WebCheckoutViewModel? + waitFor { promise in + viewModel = WebCheckoutViewModel(siteSlug: "") { + promise(()) + } + viewModel?.handleRedirect(for: successURL) + viewModel?.handleRedirect(for: successURL) + } + } +} diff --git a/WooCommerce/WooCommerceTests/Authentication/Store Creation/WebPurchasesForWPComPlansTests.swift b/WooCommerce/WooCommerceTests/Authentication/Store Creation/WebPurchasesForWPComPlansTests.swift new file mode 100644 index 00000000000..4e081ed53c3 --- /dev/null +++ b/WooCommerce/WooCommerceTests/Authentication/Store Creation/WebPurchasesForWPComPlansTests.swift @@ -0,0 +1,124 @@ +import StoreKit +import TestKit +import XCTest +import Yosemite +@testable import WooCommerce + +final class WebPurchasesForWPComPlansTests: XCTestCase { + private var stores: MockStoresManager! + private var webPurchases: WebPurchasesForWPComPlans! + + override func setUp() { + super.setUp() + stores = MockStoresManager(sessionManager: .testingInstance) + webPurchases = WebPurchasesForWPComPlans(stores: stores) + } + + override func tearDown() { + webPurchases = nil + stores = nil + super.tearDown() + } + + // MARK: - `fetchProducts` + + func test_fetchProducts_returns_plan_from_PaymentAction_loadPlan() async throws { + // Given + stores.whenReceivingAction(ofType: PaymentAction.self) { action in + if case let .loadPlan(_, completion) = action { + completion(.success(.init(productID: 645, name: "woo plan", formattedPrice: "$ 32.8"))) + } + } + + // When + let products = try await webPurchases.fetchProducts() + + // Then + XCTAssertEqual(products as? [WebPurchasesForWPComPlans.Plan], + [.init(displayName: "woo plan", description: "", id: "645", displayPrice: "$ 32.8")]) + } + + func test_fetchProducts_returns_error_from_PaymentAction_loadPlan() async throws { + // Given + stores.whenReceivingAction(ofType: PaymentAction.self) { action in + if case let .loadPlan(_, completion) = action { + completion(.failure(SampleError.first)) + } + } + + await assertThrowsError({ + // When + _ = try await webPurchases.fetchProducts() + }) { error in + // Then + (error as? SampleError) == .first + } + } + + // MARK: - `purchaseProduct` + + func test_purchaseProduct_returns_pending_result_from_PaymentAction_createCart_success() async throws { + // Given + stores.whenReceivingAction(ofType: PaymentAction.self) { action in + if case let .createCart(_, _, completion) = action { + completion(.success(())) + } + } + + // When + let purchaseResult = try await webPurchases.purchaseProduct(with: "productID", for: 134) + + // Then + XCTAssertEqual(purchaseResult, .pending) + } + + func test_purchaseProduct_returns_error_from_PaymentAction_createCart_failure() async throws { + // Given + stores.whenReceivingAction(ofType: PaymentAction.self) { action in + if case let .createCart(_, _, completion) = action { + completion(.failure(SampleError.first)) + } + } + + await assertThrowsError({ + // When + _ = try await webPurchases.purchaseProduct(with: "productID", for: 134) + }) { error in + // Then + (error as? SampleError) == .first + } + } + + // MARK: - `userIsEntitledToProduct` + + func test_userIsEntitledToProduct_returns_false() async throws { + // When + let userIsEntitledToProduct = try await webPurchases.userIsEntitledToProduct(with: "1021") + + // Then + XCTAssertFalse(userIsEntitledToProduct) + } + + // MARK: - `inAppPurchasesAreSupported` + + func test_inAppPurchasesAreSupported_returns_true() async throws { + // When + let inAppPurchasesAreSupported = await webPurchases.inAppPurchasesAreSupported() + + // Then + XCTAssertTrue(inAppPurchasesAreSupported) + } +} + +extension StoreKit.Product.PurchaseResult: Equatable { + public static func == (lhs: StoreKit.Product.PurchaseResult, rhs: StoreKit.Product.PurchaseResult) -> Bool { + switch (lhs, rhs) { + case (.pending, .pending): + return true + case (.userCancelled, .userCancelled): + return true + default: + return false + } + } +} diff --git a/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift b/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift index 34aa8c18396..6bd13edd268 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift @@ -9,6 +9,7 @@ struct MockFeatureFlagService: FeatureFlagService { private let isLoginPrologueOnboardingEnabled: Bool private let isStoreCreationMVPEnabled: Bool private let isStoreCreationM2Enabled: Bool + private let isStoreCreationM2WithInAppPurchasesEnabled: Bool init(isInboxOn: Bool = false, isSplitViewInOrdersTabOn: Bool = false, @@ -16,7 +17,8 @@ struct MockFeatureFlagService: FeatureFlagService { shippingLabelsOnboardingM1: Bool = false, isLoginPrologueOnboardingEnabled: Bool = false, isStoreCreationMVPEnabled: Bool = true, - isStoreCreationM2Enabled: Bool = false) { + isStoreCreationM2Enabled: Bool = false, + isStoreCreationM2WithInAppPurchasesEnabled: Bool = false) { self.isInboxOn = isInboxOn self.isSplitViewInOrdersTabOn = isSplitViewInOrdersTabOn self.isUpdateOrderOptimisticallyOn = isUpdateOrderOptimisticallyOn @@ -24,6 +26,7 @@ struct MockFeatureFlagService: FeatureFlagService { self.isLoginPrologueOnboardingEnabled = isLoginPrologueOnboardingEnabled self.isStoreCreationMVPEnabled = isStoreCreationMVPEnabled self.isStoreCreationM2Enabled = isStoreCreationM2Enabled + self.isStoreCreationM2WithInAppPurchasesEnabled = isStoreCreationM2WithInAppPurchasesEnabled } func isFeatureFlagEnabled(_ featureFlag: FeatureFlag) -> Bool { @@ -42,6 +45,8 @@ struct MockFeatureFlagService: FeatureFlagService { return isStoreCreationMVPEnabled case .storeCreationM2: return isStoreCreationM2Enabled + case .storeCreationM2WithInAppPurchasesEnabled: + return isStoreCreationM2WithInAppPurchasesEnabled default: return false }