diff --git a/WooCommerce/Classes/Model/BetaFeature.swift b/WooCommerce/Classes/Model/BetaFeature.swift index 319c74d4431..2912c85a8c1 100644 --- a/WooCommerce/Classes/Model/BetaFeature.swift +++ b/WooCommerce/Classes/Model/BetaFeature.swift @@ -1,3 +1,4 @@ +import Combine import Storage import protocol WooFoundation.WooAnalyticsEventPropertyType @@ -52,21 +53,6 @@ extension BetaFeature { } } - var isAvailable: Bool { - switch self { - case .inAppPurchases: - return ServiceLocator.featureFlagService.isFeatureFlagEnabled(.inAppPurchasesDebugMenu) - case .pointOfSale: - return POSEligibilityChecker().isEligible() - default: - return true - } - } - - static var availableFeatures: [Self] { - allCases.filter(\.isAvailable) - } - func analyticsProperties(toggleState enabled: Bool) -> [String: WooAnalyticsEventPropertyType] { var properties = ["state": enabled ? "on" : "off"] if analyticsStat == .settingsBetaFeatureToggled { @@ -78,10 +64,11 @@ extension BetaFeature { extension GeneralAppSettingsStorage { func betaFeatureEnabled(_ feature: BetaFeature) -> Bool { - guard feature.isAvailable else { - return false - } - return value(for: feature.settingsKey) + value(for: feature.settingsKey) + } + + func betaFeatureEnabledPublisher(_ feature: BetaFeature) -> AnyPublisher { + publisher(for: feature.settingsKey) } func betaFeatureEnabledBinding(_ feature: BetaFeature) -> Binding { diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfiguration.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfiguration.swift index b3f6e4b5a1e..da2c77b3390 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfiguration.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfiguration.swift @@ -4,7 +4,7 @@ import Yosemite final class BetaFeaturesConfigurationViewController: UIHostingController { init() { - super.init(rootView: BetaFeaturesConfiguration()) + super.init(rootView: BetaFeaturesConfiguration(viewModel: .init())) } required dynamic init?(coder aDecoder: NSCoder) { @@ -13,13 +13,17 @@ final class BetaFeaturesConfigurationViewController: UIHostingController Binding { + appSettings.betaFeatureEnabledBinding(feature) + } +} + +private extension BetaFeaturesConfigurationViewModel { + func observePOSEligibilityForAvailableFeatures() { + posEligibilityChecker.isEligible + .map { [weak self] isEligibleForPOS in + guard let self else { + return [] + } + return BetaFeature.allCases.filter { betaFeature in + switch betaFeature { + case .viewAddOns: + return true + case .inAppPurchases: + return self.featureFlagService.isFeatureFlagEnabled(.inAppPurchasesDebugMenu) + case .pointOfSale: + return isEligibleForPOS + } + } + } + .assign(to: &$availableFeatures) + } +} diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/CardPresentPaymentsOnboardingUseCase.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/CardPresentPaymentsOnboardingUseCase.swift index b3aeb79b542..431d59b4443 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/CardPresentPaymentsOnboardingUseCase.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/CardPresentPaymentsOnboardingUseCase.swift @@ -54,7 +54,6 @@ final class CardPresentPaymentsOnboardingUseCase: CardPresentPaymentsOnboardingU let storageManager: StorageManagerType let stores: StoresManager let configurationLoader: CardPresentConfigurationLoader - let featureFlagService: FeatureFlagService private let cardPresentPluginsDataProvider: CardPresentPluginsDataProvider private let cardPresentPaymentOnboardingStateCache: CardPresentPaymentOnboardingStateCache private let analytics: Analytics @@ -72,7 +71,6 @@ final class CardPresentPaymentsOnboardingUseCase: CardPresentPaymentsOnboardingU init( storageManager: StorageManagerType = ServiceLocator.storageManager, stores: StoresManager = ServiceLocator.stores, - featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, cardPresentPaymentOnboardingStateCache: CardPresentPaymentOnboardingStateCache = CardPresentPaymentOnboardingStateCache.shared, analytics: Analytics = ServiceLocator.analytics ) { @@ -80,7 +78,6 @@ final class CardPresentPaymentsOnboardingUseCase: CardPresentPaymentsOnboardingU self.stores = stores self.configurationLoader = .init(stores: stores) self.cardPresentPluginsDataProvider = .init(storageManager: storageManager, stores: stores, configuration: configurationLoader.configuration) - self.featureFlagService = featureFlagService self.cardPresentPaymentOnboardingStateCache = cardPresentPaymentOnboardingStateCache self.analytics = analytics diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift index fefbe6d52d0..d3251df9bf9 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import UIKit import class WooFoundation.CurrencySettings @@ -5,40 +6,65 @@ import enum WooFoundation.CountryCode import protocol Experiments.FeatureFlagService import struct Yosemite.SiteSetting +protocol POSEligibilityCheckerProtocol { + /// As POS eligibility can change from site settings and card payment onboarding state, it's recommended to observe the eligibility value. + var isEligible: AnyPublisher { get } +} + /// Determines whether the POS entry point can be shown based on the selected store and feature gates. -final class POSEligibilityChecker { +final class POSEligibilityChecker: POSEligibilityCheckerProtocol { + var isEligible: AnyPublisher { + $isEligibleValue.eraseToAnyPublisher() + } + + @Published private var isEligibleValue: Bool = false + + private let userInterfaceIdiom: UIUserInterfaceIdiom private let cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCaseProtocol - private let siteSettings: [SiteSetting] + private let siteSettings: SelectedSiteSettings private let currencySettings: CurrencySettings private let featureFlagService: FeatureFlagService - init(cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCaseProtocol = CardPresentPaymentsOnboardingUseCase(), - siteSettings: [SiteSetting] = ServiceLocator.selectedSiteSettings.siteSettings, + init(userInterfaceIdiom: UIUserInterfaceIdiom = UIDevice.current.userInterfaceIdiom, + cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCaseProtocol = CardPresentPaymentsOnboardingUseCase(), + siteSettings: SelectedSiteSettings = ServiceLocator.selectedSiteSettings, currencySettings: CurrencySettings = ServiceLocator.currencySettings, featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService) { + self.userInterfaceIdiom = userInterfaceIdiom self.siteSettings = siteSettings self.currencySettings = currencySettings self.cardPresentPaymentsOnboarding = cardPresentPaymentsOnboarding self.featureFlagService = featureFlagService + observeOnboardingStateForEligibilityCheck() } +} +private extension POSEligibilityChecker { /// Returns whether the selected store is eligible for POS. - func isEligible() -> Bool { - // Always checks the main POS feature flag before any other checks. - guard featureFlagService.isFeatureFlagEnabled(.displayPointOfSaleToggle) else { - return false + func observeOnboardingStateForEligibilityCheck() { + // Conditions that are fixed for its lifetime. + let isTablet = userInterfaceIdiom == .pad + let isFeatureFlagEnabled = featureFlagService.isFeatureFlagEnabled(.displayPointOfSaleToggle) + guard isTablet && isFeatureFlagEnabled else { + isEligibleValue = false + return } - let isCountryCodeUS = SiteAddress(siteSettings: siteSettings).countryCode == CountryCode.US - let isCurrencyUSD = currencySettings.currencyCode == .USD + cardPresentPaymentsOnboarding.statePublisher + .filter { [weak self] _ in + self?.isEligibleFromSiteChecks() ?? false + } + .map { onboardingState in + // Woo Payments plugin enabled and user setup complete + onboardingState == .completed(plugin: .wcPayOnly) || onboardingState == .completed(plugin: .wcPayPreferred) + } + .assign(to: &$isEligibleValue) + } - // Tablet device - return UIDevice.current.userInterfaceIdiom == .pad - // Woo Payments plugin enabled and user setup complete - && (cardPresentPaymentsOnboarding.state == .completed(plugin: .wcPayOnly) || cardPresentPaymentsOnboarding.state == .completed(plugin: .wcPayPreferred)) - // USD currency - && isCurrencyUSD - // US store location - && isCountryCodeUS + func isEligibleFromSiteChecks() -> Bool { + // Conditions that can change if site settings are synced during the lifetime. + let isCountryCodeUS = SiteAddress(siteSettings: siteSettings.siteSettings).countryCode == .US + let isCurrencyUSD = currencySettings.currencyCode == .USD + return isCountryCodeUS && isCurrencyUSD } } diff --git a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift index d5e595c1f26..e16ed32f19d 100644 --- a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift +++ b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift @@ -34,9 +34,9 @@ struct HubMenu: View { .navigationDestination(isPresented: $viewModel.showingCoupons) { couponListView } - } - .onAppear { - viewModel.setupMenuElements() + .onAppear { + viewModel.setupMenuElements() + } } } diff --git a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift index dc21abc89f3..24a3bc54a99 100644 --- a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift @@ -67,6 +67,8 @@ final class HubMenuViewModel: ObservableObject { private let stores: StoresManager private let featureFlagService: FeatureFlagService private let generalAppSettings: GeneralAppSettingsStorage + private let cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCaseProtocol + private let posEligibilityChecker: POSEligibilityCheckerProtocol private(set) var productReviewFromNoteParcel: ProductReviewFromNoteParcel? @@ -114,6 +116,11 @@ final class HubMenuViewModel: ObservableObject { self.generalAppSettings = generalAppSettings self.switchStoreEnabled = stores.isAuthenticatedWithoutWPCom == false self.blazeEligibilityChecker = blazeEligibilityChecker + self.cardPresentPaymentsOnboarding = CardPresentPaymentsOnboardingUseCase() + self.posEligibilityChecker = POSEligibilityChecker(cardPresentPaymentsOnboarding: cardPresentPaymentsOnboarding, + siteSettings: ServiceLocator.selectedSiteSettings, + currencySettings: ServiceLocator.currencySettings, + featureFlagService: featureFlagService) observeSiteForUIUpdates() observePlanName() tapToPayBadgePromotionChecker.$shouldShowTapToPayBadges.share().assign(to: &$shouldShowNewFeatureBadgeOnPayments) @@ -138,16 +145,16 @@ final class HubMenuViewModel: ObservableObject { } private func setupPOSElement() { - let isBetaFeatureEnabled = generalAppSettings.betaFeatureEnabled(.pointOfSale) - let eligibilityChecker = POSEligibilityChecker(cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCase(), - siteSettings: ServiceLocator.selectedSiteSettings.siteSettings, - currencySettings: ServiceLocator.currencySettings, - featureFlagService: featureFlagService) - if isBetaFeatureEnabled && eligibilityChecker.isEligible() { - posElement = PointOfSaleEntryPoint() - } else { - posElement = nil - } + cardPresentPaymentsOnboarding.refreshIfNecessary() + Publishers.CombineLatest(generalAppSettings.betaFeatureEnabledPublisher(.pointOfSale), posEligibilityChecker.isEligible) + .map { isBetaFeatureEnabled, isEligibleForPOS in + if isBetaFeatureEnabled && isEligibleForPOS { + return PointOfSaleEntryPoint() + } else { + return nil + } + } + .assign(to: &$posElement) } private func setupSettingsElements() { diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index e2a8ad83967..98d27d5ecc1 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -170,6 +170,10 @@ 023A059A24135F2600E3FC99 /* ReviewsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023A059824135F2600E3FC99 /* ReviewsViewController.swift */; }; 023A059B24135F2600E3FC99 /* ReviewsViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 023A059924135F2600E3FC99 /* ReviewsViewController.xib */; }; 023B3EBA2A4E73BD003A720A /* LocalAnnouncementViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023B3EB92A4E73BD003A720A /* LocalAnnouncementViewModelTests.swift */; }; + 023BD5842BFDCBF800A10D7B /* BetaFeaturesConfigurationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023BD5832BFDCBF800A10D7B /* BetaFeaturesConfigurationViewModel.swift */; }; + 023BD5862BFDCECF00A10D7B /* BetaFeaturesConfigurationViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023BD5852BFDCECF00A10D7B /* BetaFeaturesConfigurationViewModelTests.swift */; }; + 023BD5882BFDCF3100A10D7B /* MockInMemoryStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023BD5872BFDCF3100A10D7B /* MockInMemoryStorage.swift */; }; + 023BD58B2BFDCFCB00A10D7B /* POSEligibilityCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023BD58A2BFDCFCB00A10D7B /* POSEligibilityCheckerTests.swift */; }; 023D1DD124AB2D05002B03A3 /* ProductListSelectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023D1DD024AB2D05002B03A3 /* ProductListSelectorViewController.swift */; }; 023D692E2588BF0900F7DA72 /* ShippingLabelPaperSizeListSelectorCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023D692D2588BF0900F7DA72 /* ShippingLabelPaperSizeListSelectorCommand.swift */; }; 023D69442588C6BD00F7DA72 /* ShippingLabelPaperSizeListSelectorCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023D69432588C6BD00F7DA72 /* ShippingLabelPaperSizeListSelectorCommandTests.swift */; }; @@ -2948,6 +2952,10 @@ 023A059824135F2600E3FC99 /* ReviewsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewsViewController.swift; sourceTree = ""; }; 023A059924135F2600E3FC99 /* ReviewsViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ReviewsViewController.xib; sourceTree = ""; }; 023B3EB92A4E73BD003A720A /* LocalAnnouncementViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAnnouncementViewModelTests.swift; sourceTree = ""; }; + 023BD5832BFDCBF800A10D7B /* BetaFeaturesConfigurationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BetaFeaturesConfigurationViewModel.swift; sourceTree = ""; }; + 023BD5852BFDCECF00A10D7B /* BetaFeaturesConfigurationViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BetaFeaturesConfigurationViewModelTests.swift; sourceTree = ""; }; + 023BD5872BFDCF3100A10D7B /* MockInMemoryStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockInMemoryStorage.swift; sourceTree = ""; }; + 023BD58A2BFDCFCB00A10D7B /* POSEligibilityCheckerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSEligibilityCheckerTests.swift; sourceTree = ""; }; 023D1DD024AB2D05002B03A3 /* ProductListSelectorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListSelectorViewController.swift; sourceTree = ""; }; 023D692D2588BF0900F7DA72 /* ShippingLabelPaperSizeListSelectorCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelPaperSizeListSelectorCommand.swift; sourceTree = ""; }; 023D69432588C6BD00F7DA72 /* ShippingLabelPaperSizeListSelectorCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelPaperSizeListSelectorCommandTests.swift; sourceTree = ""; }; @@ -6006,6 +6014,14 @@ path = Domains; sourceTree = ""; }; + 023BD5892BFDCF9500A10D7B /* POS */ = { + isa = PBXGroup; + children = ( + 023BD58A2BFDCFCB00A10D7B /* POSEligibilityCheckerTests.swift */, + ); + path = POS; + sourceTree = ""; + }; 023D69BA2589BF2500F7DA72 /* Refund Shipping Label */ = { isa = PBXGroup; children = ( @@ -6832,6 +6848,7 @@ isa = PBXGroup; children = ( E1E649EA28461EDF0070B194 /* BetaFeaturesConfiguration.swift */, + 023BD5832BFDCBF800A10D7B /* BetaFeaturesConfigurationViewModel.swift */, ); path = "Beta features"; sourceTree = ""; @@ -8748,6 +8765,7 @@ EEFF47312B309221009BF302 /* MockStoreCreationStoreSwitchScheduler.swift */, 02D635782B58071C00B1CBF6 /* MockNote.swift */, EE8A302A2B70B63E001D7C66 /* MockImageService.swift */, + 023BD5872BFDCF3100A10D7B /* MockInMemoryStorage.swift */, ); path = Mocks; sourceTree = ""; @@ -9762,6 +9780,7 @@ EE0EE7A728B74EF300F6061E /* CustomHelpCenterContentTests.swift */, 2631D4F929ED108400F13F20 /* WPComPlanNameSanitizer.swift */, B98FF43F2AAA096200326D16 /* AddressWooTests.swift */, + 023BD5852BFDCECF00A10D7B /* BetaFeaturesConfigurationViewModelTests.swift */, ); path = Model; sourceTree = ""; @@ -10070,6 +10089,7 @@ BA143222273662DE00E4B3AB /* Settings */ = { isa = PBXGroup; children = ( + 023BD5892BFDCF9500A10D7B /* POS */, BAFEF51D273C2151005F94CC /* SettingsViewModelTests.swift */, 02AA586528531D0E0068B6F0 /* CloseAccountCoordinatorTests.swift */, 2609797A2A13D2B500442249 /* Privacy */, @@ -13688,6 +13708,7 @@ D85A3C5226C15DE200C0E026 /* InPersonPaymentsPluginNotSupportedVersionView.swift in Sources */, EE57C11D297AC27300BC31E7 /* TrackEventRequestNotificationHandler.swift in Sources */, DE5746312B43F6180034B10D /* BlazeCampaignCreationFormViewModel.swift in Sources */, + 023BD5842BFDCBF800A10D7B /* BetaFeaturesConfigurationViewModel.swift in Sources */, 680E36B72BD8C49F00E8BCEA /* OrderSubscriptionTableViewCellViewModel.swift in Sources */, CE63023D2BAAEF2C00E3325C /* CustomersListView.swift in Sources */, CE583A0B2107937F00D73C1C /* TextViewTableViewCell.swift in Sources */, @@ -15221,6 +15242,7 @@ B6440FB9292E74230012D506 /* AnalyticsHubTimeRangeSelectionTests.swift in Sources */, B57C5C9E21B80E8300FF82B2 /* SessionManager+Internal.swift in Sources */, 2667BFDB252E659A008099D4 /* MockOrderItem.swift in Sources */, + 023BD5862BFDCECF00A10D7B /* BetaFeaturesConfigurationViewModelTests.swift in Sources */, DEC51AA0274F9922009F3DF4 /* JCPJetpackInstallStepsViewModelTests.swift in Sources */, E1068058285C787100668B46 /* BetaFeaturesTests.swift in Sources */, EE570A8D2BF5EE78006BA026 /* MostActiveCouponsCardViewModelTests.swift in Sources */, @@ -15579,6 +15601,7 @@ E1C6535C27BD1D0A003E87D4 /* CardPresentConfigurationLoaderTests.swift in Sources */, 2688644325D471C700821BA5 /* EditAttributesViewModelTests.swift in Sources */, 8697AFBF2B622DEA00EFAF21 /* BlazeAddParameterViewModelTests.swift in Sources */, + 023BD5882BFDCF3100A10D7B /* MockInMemoryStorage.swift in Sources */, DE001323279A793A00EB0350 /* CouponWooTests.swift in Sources */, 45B98E1F25DECC1C00A1232B /* ShippingLabelAddressFormViewModelTests.swift in Sources */, 028E1F722833E954001F8829 /* DashboardViewModelTests.swift in Sources */, @@ -15689,6 +15712,7 @@ CC923A1D2847A8E0008EEEBE /* OrderStatusListViewModelTests.swift in Sources */, 267C01D129E9B18E00FCC97B /* StorePlanSynchronizerTests.swift in Sources */, EE45E2C42A4A85350085F227 /* TooltipPresenterTests.swift in Sources */, + 023BD58B2BFDCFCB00A10D7B /* POSEligibilityCheckerTests.swift in Sources */, 0388E1A629E04433007DF84D /* PaymentsRouteTests.swift in Sources */, EEC5C8DA2ADE2FD80071E852 /* BlazeCampaignDashboardViewModelTests.swift in Sources */, D85B833D2230DC9D002168F3 /* StringWooTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift b/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift index 0aac2bb7eab..320d2f650c0 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift @@ -24,6 +24,7 @@ struct MockFeatureFlagService: FeatureFlagService { private let isCustomersInHubMenuEnabled: Bool private let isSubscriptionsInOrderCreationCustomersEnabled: Bool private let isMultipleShippingLinesEnabled: Bool + private let isDisplayPointOfSaleToggleEnabled: Bool init(isInboxOn: Bool = false, isUpdateOrderOptimisticallyOn: Bool = false, @@ -46,7 +47,8 @@ struct MockFeatureFlagService: FeatureFlagService { sideBySideViewForOrderForm: Bool = false, isCustomersInHubMenuEnabled: Bool = false, isSubscriptionsInOrderCreationCustomersEnabled: Bool = false, - isMultipleShippingLinesEnabled: Bool = false) { + isMultipleShippingLinesEnabled: Bool = false, + isDisplayPointOfSaleToggleEnabled: Bool = false) { self.isInboxOn = isInboxOn self.isUpdateOrderOptimisticallyOn = isUpdateOrderOptimisticallyOn self.shippingLabelsOnboardingM1 = shippingLabelsOnboardingM1 @@ -69,6 +71,7 @@ struct MockFeatureFlagService: FeatureFlagService { self.isCustomersInHubMenuEnabled = isCustomersInHubMenuEnabled self.isSubscriptionsInOrderCreationCustomersEnabled = isSubscriptionsInOrderCreationCustomersEnabled self.isMultipleShippingLinesEnabled = isMultipleShippingLinesEnabled + self.isDisplayPointOfSaleToggleEnabled = isDisplayPointOfSaleToggleEnabled } func isFeatureFlagEnabled(_ featureFlag: FeatureFlag) -> Bool { @@ -115,6 +118,8 @@ struct MockFeatureFlagService: FeatureFlagService { return isSubscriptionsInOrderCreationCustomersEnabled case .multipleShippingLines: return isMultipleShippingLinesEnabled + case .displayPointOfSaleToggle: + return isDisplayPointOfSaleToggleEnabled default: return false } diff --git a/WooCommerce/WooCommerceTests/Mocks/MockInMemoryStorage.swift b/WooCommerce/WooCommerceTests/Mocks/MockInMemoryStorage.swift new file mode 100644 index 00000000000..d5283e83d3e --- /dev/null +++ b/WooCommerce/WooCommerceTests/Mocks/MockInMemoryStorage.swift @@ -0,0 +1,24 @@ +import Storage + +final class MockInMemoryStorage: FileStorage { + private(set) var data: [URL: Codable] = [:] + + func data(for fileURL: URL) throws -> T where T: Decodable { + guard let data = data[fileURL] as? T else { + throw Errors.readFailed + } + return data + } + + func write(_ data: T, to fileURL: URL) throws where T: Encodable { + self.data[fileURL] = data as? Codable + } + + func deleteFile(at fileURL: URL) throws { + data.removeValue(forKey: fileURL) + } + + enum Errors: Error { + case readFailed + } +} diff --git a/WooCommerce/WooCommerceTests/Model/BetaFeaturesConfigurationViewModelTests.swift b/WooCommerce/WooCommerceTests/Model/BetaFeaturesConfigurationViewModelTests.swift new file mode 100644 index 00000000000..7a7c22bf6c8 --- /dev/null +++ b/WooCommerce/WooCommerceTests/Model/BetaFeaturesConfigurationViewModelTests.swift @@ -0,0 +1,58 @@ +import Combine +import Storage +import XCTest +@testable import WooCommerce + +final class BetaFeaturesConfigurationViewModelTests: XCTestCase { + private var appSettings: GeneralAppSettingsStorage! + + override func setUpWithError() throws { + appSettings = GeneralAppSettingsStorage.init(fileStorage: MockInMemoryStorage()) + } + + override func tearDownWithError() throws { + appSettings = nil + } + + func test_availableFeatures_include_viewAddOns() { + // Given + let viewModel = BetaFeaturesConfigurationViewModel(appSettings: appSettings, + posEligibilityChecker: MockPOSEligibilityChecker(isEligibleValue: true)) + + // Then + XCTAssertTrue(viewModel.availableFeatures.contains(.viewAddOns)) + } + + func test_availableFeatures_include_pos_when_eligible_for_pos() { + // Given + let posEligibilityChecker = MockPOSEligibilityChecker(isEligibleValue: true) + let viewModel = BetaFeaturesConfigurationViewModel(appSettings: appSettings, posEligibilityChecker: posEligibilityChecker) + + // Then + XCTAssertTrue(viewModel.availableFeatures.contains(.pointOfSale)) + } + + func test_availableFeatures_do_not_include_pos_when_not_eligible_for_pos_anymore() { + // Given + let posEligibilityChecker = MockPOSEligibilityChecker(isEligibleValue: true) + let viewModel = BetaFeaturesConfigurationViewModel(appSettings: appSettings, posEligibilityChecker: posEligibilityChecker) + + // When + posEligibilityChecker.isEligibleValue = false + + // Then + XCTAssertFalse(viewModel.availableFeatures.contains(.pointOfSale)) + } +} + +private final class MockPOSEligibilityChecker: POSEligibilityCheckerProtocol { + @Published var isEligibleValue: Bool + + init(isEligibleValue: Bool) { + self.isEligibleValue = isEligibleValue + } + + var isEligible: AnyPublisher { + $isEligibleValue.eraseToAnyPublisher() + } +} diff --git a/WooCommerce/WooCommerceTests/Model/BetaFeaturesTests.swift b/WooCommerce/WooCommerceTests/Model/BetaFeaturesTests.swift index bc8f501a5fd..bbf9ec73929 100644 --- a/WooCommerce/WooCommerceTests/Model/BetaFeaturesTests.swift +++ b/WooCommerce/WooCommerceTests/Model/BetaFeaturesTests.swift @@ -2,7 +2,7 @@ import XCTest import Storage @testable import WooCommerce -class BetaFeaturesTests: XCTestCase { +final class BetaFeaturesTests: XCTestCase { var appSettings: GeneralAppSettingsStorage! override func setUpWithError() throws { @@ -32,26 +32,3 @@ class BetaFeaturesTests: XCTestCase { XCTAssertEqual(enabled, true) } } - -private final class MockInMemoryStorage: FileStorage { - private(set) var data: [URL: Codable] = [:] - - func data(for fileURL: URL) throws -> T where T: Decodable { - guard let data = data[fileURL] as? T else { - throw Errors.readFailed - } - return data - } - - func write(_ data: T, to fileURL: URL) throws where T: Encodable { - self.data[fileURL] = data as? Codable - } - - func deleteFile(at fileURL: URL) throws { - data.removeValue(forKey: fileURL) - } - - enum Errors: Error { - case readFailed - } -} diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSEligibilityCheckerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSEligibilityCheckerTests.swift new file mode 100644 index 00000000000..ce16f0071e3 --- /dev/null +++ b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSEligibilityCheckerTests.swift @@ -0,0 +1,185 @@ +import Combine +import WooFoundation +import XCTest +import Yosemite +@testable import WooCommerce + +final class POSEligibilityCheckerTests: XCTestCase { + private var onboardingUseCase: MockCardPresentPaymentsOnboardingUseCase! + private var stores: MockStoresManager! + private var storageManager: MockStorageManager! + private var siteSettings: SelectedSiteSettings! + @Published private var isEligible: Bool = false + + private let siteID: Int64 = 2 + + override func setUp() { + super.setUp() + onboardingUseCase = MockCardPresentPaymentsOnboardingUseCase(initial: .completed(plugin: .wcPayPreferred)) + stores = MockStoresManager(sessionManager: .makeForTesting(authenticated: true)) + stores.updateDefaultStore(storeID: siteID) + storageManager = MockStorageManager() + siteSettings = SelectedSiteSettings(stores: stores, storageManager: storageManager) + } + + override func tearDown() { + siteSettings = nil + storageManager = nil + stores = nil + onboardingUseCase = nil + super.tearDown() + } + + func test_site_with_all_conditions_is_eligible() throws { + // Given + let featureFlagService = MockFeatureFlagService(isDisplayPointOfSaleToggleEnabled: true) + setupCountry(country: .us) + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + cardPresentPaymentsOnboarding: onboardingUseCase, + siteSettings: siteSettings, + currencySettings: Fixtures.usdCurrencySettings, + featureFlagService: featureFlagService) + checker.isEligible.assign(to: &$isEligible) + + // Then + XCTAssertTrue(isEligible) + } + + func test_non_iPad_devices_are_not_eligible() throws { + // Given + let featureFlagService = MockFeatureFlagService(isDisplayPointOfSaleToggleEnabled: true) + setupCountry(country: .us) + [UIUserInterfaceIdiom.phone, UIUserInterfaceIdiom.mac, UIUserInterfaceIdiom.tv, UIUserInterfaceIdiom.carPlay] + .forEach { userInterfaceIdiom in + let checker = POSEligibilityChecker(userInterfaceIdiom: userInterfaceIdiom, + cardPresentPaymentsOnboarding: onboardingUseCase, + siteSettings: siteSettings, + currencySettings: Fixtures.usdCurrencySettings, + featureFlagService: featureFlagService) + checker.isEligible.assign(to: &$isEligible) + + // Then + XCTAssertFalse(isEligible) + } + } + + func test_non_us_site_is_not_eligible() throws { + // Given + let featureFlagService = MockFeatureFlagService(isDisplayPointOfSaleToggleEnabled: true) + [Country.ca, Country.es, Country.gb].forEach { country in + // When + setupCountry(country: country) + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + cardPresentPaymentsOnboarding: onboardingUseCase, + siteSettings: siteSettings, + currencySettings: Fixtures.usdCurrencySettings, + featureFlagService: featureFlagService) + checker.isEligible.assign(to: &$isEligible) + + // Then + XCTAssertFalse(isEligible) + } + } + + func test_non_usd_site_is_not_eligible() throws { + // Given + let featureFlagService = MockFeatureFlagService(isDisplayPointOfSaleToggleEnabled: true) + setupCountry(country: .us) + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + cardPresentPaymentsOnboarding: onboardingUseCase, + siteSettings: siteSettings, + currencySettings: Fixtures.nonUSDCurrencySettings, + featureFlagService: featureFlagService) + checker.isEligible.assign(to: &$isEligible) + + // Then + XCTAssertFalse(isEligible) + } + + func test_is_not_eligible_when_feature_flag_is_disabled() throws { + // Given + let featureFlagService = MockFeatureFlagService(isDisplayPointOfSaleToggleEnabled: false) + setupCountry(country: .us) + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + cardPresentPaymentsOnboarding: onboardingUseCase, + siteSettings: siteSettings, + currencySettings: Fixtures.usdCurrencySettings, + featureFlagService: featureFlagService) + checker.isEligible.assign(to: &$isEligible) + + // Then + XCTAssertFalse(isEligible) + } + + func test_is_not_eligible_when_onboarding_state_is_not_completed_wcpay() throws { + // Given + let featureFlagService = MockFeatureFlagService(isDisplayPointOfSaleToggleEnabled: true) + setupCountry(country: .us) + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + cardPresentPaymentsOnboarding: onboardingUseCase, + siteSettings: siteSettings, + currencySettings: Fixtures.usdCurrencySettings, + featureFlagService: featureFlagService) + checker.isEligible.assign(to: &$isEligible) + XCTAssertTrue(isEligible) + + // When onboarding state is loading + onboardingUseCase.state = .loading + // Then + XCTAssertFalse(isEligible) + + // When onboarding state is stripeOnly + onboardingUseCase.state = .completed(plugin: .stripeOnly) + // Then + XCTAssertFalse(isEligible) + + // When onboarding state is wcPayOnly + onboardingUseCase.state = .completed(plugin: .wcPayOnly) + // Then + XCTAssertTrue(isEligible) + + // When onboarding state is stripePreferred + onboardingUseCase.state = .completed(plugin: .stripePreferred) + // Then + XCTAssertFalse(isEligible) + + // When onboarding state is pluginNotInstalled + onboardingUseCase.state = .pluginNotInstalled + // Then + XCTAssertFalse(isEligible) + } +} + +private extension POSEligibilityCheckerTests { + func setupCountry(country: Country) { + let setting = SiteSetting.fake() + .copy( + siteID: siteID, + settingID: "woocommerce_default_country", + value: country.rawValue, + settingGroupKey: SiteSettingGroup.general.rawValue + ) + storageManager.insertSampleSiteSetting(readOnlySiteSetting: setting) + siteSettings.refresh() + } + + enum Fixtures { + static let usdCurrencySettings = CurrencySettings(currencyCode: .USD, + currencyPosition: .leftSpace, + thousandSeparator: "", + decimalSeparator: ".", + numberOfDecimals: 3) + static let nonUSDCurrencySettings = CurrencySettings(currencyCode: .CAD, + currencyPosition: .leftSpace, + thousandSeparator: "", + decimalSeparator: ".", + numberOfDecimals: 3) + } + + enum Country: String { + case us = "US:CA" + case ca = "CA:NS" + case gb = "GB" + case es = "ES" + } +}