diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/LegacyPOSTabEligibilityChecker.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/LegacyPOSTabEligibilityChecker.swift new file mode 100644 index 00000000000..2d18286a44f --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/LegacyPOSTabEligibilityChecker.swift @@ -0,0 +1,261 @@ +import Foundation +import UIKit +import class WooFoundation.CurrencySettings +import enum WooFoundation.CountryCode +import enum WooFoundation.CurrencyCode +import protocol Experiments.FeatureFlagService +import struct Yosemite.SiteSetting +import protocol Yosemite.POSEligibilityServiceProtocol +import protocol Yosemite.StoresManager +import class Yosemite.POSEligibilityService +import struct Yosemite.SystemPlugin +import enum Yosemite.FeatureFlagAction +import enum Yosemite.SettingAction +import protocol Yosemite.PluginsServiceProtocol +import class Yosemite.PluginsService + +/// Legacy enum containing POS invisible reasons + POSIneligibleReason cases for i1. +private enum LegacyPOSIneligibleReason: Equatable { + case notTablet + case unsupportedIOSVersion + case unsupportedWooCommerceVersion(minimumVersion: String) + case siteSettingsNotAvailable + case wooCommercePluginNotFound + case featureFlagDisabled + case featureSwitchDisabled + case featureSwitchSyncFailure + case unsupportedCountry(supportedCountries: [CountryCode]) + case unsupportedCurrency(supportedCurrencies: [CurrencyCode]) + case selfDeallocated +} + +/// Legacy POS eligibility state for i1. +private enum LegacyPOSEligibilityState: Equatable { + case eligible + case ineligible(reason: LegacyPOSIneligibleReason) +} + +/// POS tab eligibility checker for i1. Will be replaced by `POSTabEligibilityCheckerI2` when removing `pointOfSaleAsATabi2` feature flag. +final class LegacyPOSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol { + private let siteID: Int64 + private let userInterfaceIdiom: UIUserInterfaceIdiom + private let siteSettings: SelectedSiteSettingsProtocol + private let pluginsService: PluginsServiceProtocol + private let eligibilityService: POSEligibilityServiceProtocol + private let stores: StoresManager + private let featureFlagService: FeatureFlagService + + init(siteID: Int64, + userInterfaceIdiom: UIUserInterfaceIdiom = UIDevice.current.userInterfaceIdiom, + siteSettings: SelectedSiteSettingsProtocol = ServiceLocator.selectedSiteSettings, + pluginsService: PluginsServiceProtocol = PluginsService(storageManager: ServiceLocator.storageManager), + eligibilityService: POSEligibilityServiceProtocol = POSEligibilityService(), + stores: StoresManager = ServiceLocator.stores, + featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService) { + self.siteID = siteID + self.userInterfaceIdiom = userInterfaceIdiom + self.siteSettings = siteSettings + self.pluginsService = pluginsService + self.eligibilityService = eligibilityService + self.stores = stores + self.featureFlagService = featureFlagService + } + + /// Checks the initial visibility of the POS tab without dependance on network requests. + func checkInitialVisibility() -> Bool { + eligibilityService.loadCachedPOSTabVisibility(siteID: siteID) ?? false + } + + /// Determines whether the POS entry point can be shown based on the selected store and feature gates. + func checkEligibility() async -> POSEligibilityState { + .eligible + } + + private func checkI1Eligibility() async -> LegacyPOSEligibilityState { + switch checkDeviceEligibility() { + case .eligible: + break + case .ineligible(let reason): + return .ineligible(reason: reason) + } + + async let siteSettingsEligibility = checkSiteSettingsEligibility() + async let featureFlagEligibility = checkRemoteFeatureEligibility() + async let pluginEligibility = checkPluginEligibility() + + // Checks site settings first since it's likely to complete fastest. + switch await siteSettingsEligibility { + case .eligible: + break + case .ineligible(let reason): + return .ineligible(reason: reason) + } + + // Then checks feature flag. + switch await featureFlagEligibility { + case .eligible: + break + case .ineligible(let reason): + return .ineligible(reason: reason) + } + + // Finally checks plugin eligibility. + switch await pluginEligibility { + case .eligible: + return .eligible + case .ineligible(let reason): + return .ineligible(reason: reason) + } + } + + /// Checks the final visibility of the POS tab. + func checkVisibility() async -> Bool { + let eligibility = await checkI1Eligibility() + return eligibility == .eligible + } +} + +private extension LegacyPOSTabEligibilityChecker { + func checkDeviceEligibility() -> LegacyPOSEligibilityState { + guard #available(iOS 17.0, *) else { + return .ineligible(reason: .unsupportedIOSVersion) + } + + guard userInterfaceIdiom == .pad else { + return .ineligible(reason: .notTablet) + } + + return .eligible + } +} + +// MARK: - WC Plugin Related Eligibility Check + +private extension LegacyPOSTabEligibilityChecker { + func checkPluginEligibility() async -> LegacyPOSEligibilityState { + let wcPlugin = await fetchWooCommercePlugin(siteID: siteID) + + guard VersionHelpers.isVersionSupported(version: wcPlugin.version, + minimumRequired: Constants.wcPluginMinimumVersion) else { + return .ineligible(reason: .unsupportedWooCommerceVersion(minimumVersion: Constants.wcPluginMinimumVersion)) + } + + // For versions below 10.0.0, the feature is enabled by default. + let isFeatureSwitchSupported = VersionHelpers.isVersionSupported(version: wcPlugin.version, + minimumRequired: Constants.wcPluginMinimumVersionWithFeatureSwitch, + includesDevAndBetaVersions: true) + if !isFeatureSwitchSupported { + return .eligible + } + + // For versions that support the feature switch, checks if the feature switch is enabled. + return await checkFeatureSwitchEnabled(siteID: siteID) + } + + @MainActor + func fetchWooCommercePlugin(siteID: Int64) async -> SystemPlugin { + await pluginsService.waitForPluginInStorage(siteID: siteID, pluginName: Constants.wcPluginName, isActive: true) + } + + @MainActor + func checkFeatureSwitchEnabled(siteID: Int64) async -> LegacyPOSEligibilityState { + await withCheckedContinuation { [weak self] continuation in + guard let self else { + return continuation.resume(returning: .ineligible(reason: .selfDeallocated)) + } + let action = SettingAction.isFeatureEnabled(siteID: siteID, feature: .pointOfSale) { result in + switch result { + case .success(let isEnabled): + continuation.resume(returning: isEnabled ? .eligible : .ineligible(reason: .featureSwitchDisabled)) + case .failure: + continuation.resume(returning: .ineligible(reason: .featureSwitchSyncFailure)) + } + } + stores.dispatch(action) + } + } +} + +// MARK: - Site Settings Related Eligibility Check + +private extension LegacyPOSTabEligibilityChecker { + func checkSiteSettingsEligibility() async -> LegacyPOSEligibilityState { + // Waits for the first site settings that matches the given site ID. + let siteSettings = await waitForSiteSettingsRefresh() + guard siteSettings.isNotEmpty else { + return .ineligible(reason: .siteSettingsNotAvailable) + } + + // Conditions that can change if site settings are synced during the lifetime. + let countryCode = SiteAddress(siteSettings: siteSettings).countryCode + let currencyCode = CurrencySettings(siteSettings: siteSettings).currencyCode + + return isEligibleFromCountryAndCurrencyCode(countryCode: countryCode, currencyCode: currencyCode) + } + + func waitForSiteSettingsRefresh() async -> [SiteSetting] { + for await siteSettings in siteSettings.settingsStream.values { + guard siteSettings.siteID == siteID, siteSettings.settings.isNotEmpty, siteSettings.source != .initialLoad else { + continue + } + return siteSettings.settings + } + // If we get here, the stream completed without yielding any values for our site ID which is unexpected. + return [] + } + + func isEligibleFromCountryAndCurrencyCode(countryCode: CountryCode, currencyCode: CurrencyCode) -> LegacyPOSEligibilityState { + let supportedCountries: [CountryCode] = [.US, .GB] + let supportedCurrencies: [CountryCode: [CurrencyCode]] = [.US: [.USD], + .GB: [.GBP]] + + // Checks country first. + guard supportedCountries.contains(countryCode) else { + return .ineligible(reason: .unsupportedCountry(supportedCountries: supportedCountries)) + } + + let supportedCurrenciesForCountry = supportedCurrencies[countryCode] ?? [] + guard supportedCurrenciesForCountry.contains(currencyCode) else { + return .ineligible(reason: .unsupportedCurrency(supportedCurrencies: supportedCurrenciesForCountry)) + } + return .eligible + } +} + +// MARK: - Remote Feature Flag Eligibility Check + +private extension LegacyPOSTabEligibilityChecker { + @MainActor + func checkRemoteFeatureEligibility() async -> LegacyPOSEligibilityState { + // Only whitelisted accounts in WPCOM have the Point of Sale remote feature flag enabled. These can be found at D159901-code + // If the account is whitelisted, then the remote value takes preference over the local feature flag configuration + await withCheckedContinuation { [weak self] continuation in + guard let self else { + return continuation.resume(returning: .ineligible(reason: .selfDeallocated)) + } + let action = FeatureFlagAction.isRemoteFeatureFlagEnabled(.pointOfSale, defaultValue: false) { [weak self] result in + guard let self else { + return continuation.resume(returning: .ineligible(reason: .selfDeallocated)) + } + switch result { + case true: + // The site is whitelisted. + continuation.resume(returning: .eligible) + case false: + // When the site is not whitelisted, check the local feature flag configuration. + let localFeatureFlag = featureFlagService.isFeatureFlagEnabled(.pointOfSale) + continuation.resume(returning: localFeatureFlag ? .eligible : .ineligible(reason: .featureFlagDisabled)) + } + } + self.stores.dispatch(action) + } + } +} + +private extension LegacyPOSTabEligibilityChecker { + enum Constants { + static let wcPluginName = "WooCommerce" + static let wcPluginMinimumVersion = "9.6.0-beta" + static let wcPluginMinimumVersionWithFeatureSwitch = "10.0.0" + } +} diff --git a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift index c9c52733387..2a51544c3e4 100644 --- a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift +++ b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift @@ -149,20 +149,29 @@ final class MainTabBarController: UITabBarController { self.analytics = analytics self.stores = stores self.posEligibilityCheckerFactory = posEligibilityCheckerFactory ?? { siteID in - POSTabEligibilityChecker(siteID: siteID) + if featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi2) { + POSTabEligibilityChecker(siteID: siteID) + } else { + LegacyPOSTabEligibilityChecker(siteID: siteID) + } } self.posEligibilityService = posEligibilityService super.init(coder: coder) } required init?(coder: NSCoder) { - self.featureFlagService = ServiceLocator.featureFlagService + let featureFlagService = ServiceLocator.featureFlagService + self.featureFlagService = featureFlagService self.noticePresenter = ServiceLocator.noticePresenter self.productImageUploader = ServiceLocator.productImageUploader self.analytics = ServiceLocator.analytics self.stores = ServiceLocator.stores self.posEligibilityCheckerFactory = { siteID in - POSTabEligibilityChecker(siteID: siteID) + if featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi2) { + POSTabEligibilityChecker(siteID: siteID) + } else { + LegacyPOSTabEligibilityChecker(siteID: siteID) + } } self.posEligibilityService = POSEligibilityService() super.init(coder: coder) diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 7ae9b94510c..372fa4deda9 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -516,6 +516,8 @@ 02B653AC2429F7BF00A9C839 /* MockTaxClassStoresManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B653AB2429F7BF00A9C839 /* MockTaxClassStoresManager.swift */; }; 02B7C4F62BE375D800F8E93A /* CollapsibleCustomerCardHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B7C4F52BE375D800F8E93A /* CollapsibleCustomerCardHeaderView.swift */; }; 02B8650F24A9E2D800265779 /* Product+SwiftUIPreviewHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B8650E24A9E2D800265779 /* Product+SwiftUIPreviewHelpers.swift */; }; + 02B881832E1857E0009375F5 /* LegacyPOSTabEligibilityChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B881822E1857DF009375F5 /* LegacyPOSTabEligibilityChecker.swift */; }; + 02B881852E18586E009375F5 /* LegacyPOSTabEligibilityCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B881842E18586B009375F5 /* LegacyPOSTabEligibilityCheckerTests.swift */; }; 02B8E4192DFBC218001D01FD /* MainTabBarController+TabsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B8E4182DFBC218001D01FD /* MainTabBarController+TabsTests.swift */; }; 02B8E41B2DFBC33D001D01FD /* MockPOSEligibilityChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B8E41A2DFBC33C001D01FD /* MockPOSEligibilityChecker.swift */; }; 02B9243F2C2200D600DC75F2 /* PointOfSaleCardPresentPaymentReaderUpdateFailedLowBatteryAlertViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B9243E2C2200D600DC75F2 /* PointOfSaleCardPresentPaymentReaderUpdateFailedLowBatteryAlertViewModel.swift */; }; @@ -3672,6 +3674,8 @@ 02B653AB2429F7BF00A9C839 /* MockTaxClassStoresManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTaxClassStoresManager.swift; sourceTree = ""; }; 02B7C4F52BE375D800F8E93A /* CollapsibleCustomerCardHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleCustomerCardHeaderView.swift; sourceTree = ""; }; 02B8650E24A9E2D800265779 /* Product+SwiftUIPreviewHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Product+SwiftUIPreviewHelpers.swift"; sourceTree = ""; }; + 02B881822E1857DF009375F5 /* LegacyPOSTabEligibilityChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyPOSTabEligibilityChecker.swift; sourceTree = ""; }; + 02B881842E18586B009375F5 /* LegacyPOSTabEligibilityCheckerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyPOSTabEligibilityCheckerTests.swift; sourceTree = ""; }; 02B8E4182DFBC218001D01FD /* MainTabBarController+TabsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainTabBarController+TabsTests.swift"; sourceTree = ""; }; 02B8E41A2DFBC33C001D01FD /* MockPOSEligibilityChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPOSEligibilityChecker.swift; sourceTree = ""; }; 02B9243E2C2200D600DC75F2 /* PointOfSaleCardPresentPaymentReaderUpdateFailedLowBatteryAlertViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentReaderUpdateFailedLowBatteryAlertViewModel.swift; sourceTree = ""; }; @@ -6788,6 +6792,7 @@ children = ( 023BD58A2BFDCFCB00A10D7B /* POSEligibilityCheckerTests.swift */, 0277889D2DF928E3006F5B8C /* POSTabEligibilityCheckerTests.swift */, + 02B881842E18586B009375F5 /* LegacyPOSTabEligibilityCheckerTests.swift */, ); path = POS; sourceTree = ""; @@ -7755,6 +7760,7 @@ children = ( 026B2D162DF92290005B8CAA /* POSTabEligibilityChecker.swift */, 02E4A0822BFB1C4F006D4F87 /* POSEligibilityChecker.swift */, + 02B881822E1857DF009375F5 /* LegacyPOSTabEligibilityChecker.swift */, ); path = POS; sourceTree = ""; @@ -16619,6 +16625,7 @@ AE7C957D27C3F187007E8E12 /* FeeOrDiscountLineDetailsViewModel.swift in Sources */, 4520A15C2721B2A9001FA573 /* FilterOrderListViewModel.swift in Sources */, B582F95920FFCEAA0060934A /* UITableViewHeaderFooterView+Helpers.swift in Sources */, + 02B881832E1857E0009375F5 /* LegacyPOSTabEligibilityChecker.swift in Sources */, DA41043A2C247B6900E8456A /* PointOfSalePreviewOrderController.swift in Sources */, 20F6A46C2DE5FCEF0066D8CB /* POSItemFetchAnalytics.swift in Sources */, B933CCB02AA6220E00938F3F /* TaxRateRow.swift in Sources */, @@ -17337,6 +17344,7 @@ 02A9A496244D84AB00757B99 /* ProductsSortOrderBottomSheetListSelectorCommandTests.swift in Sources */, B9B6DEF1283F8EB100901FB7 /* SitePluginsURLTests.swift in Sources */, 6891C3662D364C1A00B5B48C /* CollectCashViewHelperTests.swift in Sources */, + 02B881852E18586E009375F5 /* LegacyPOSTabEligibilityCheckerTests.swift in Sources */, D83F5935225B3CDD00626E75 /* DatePickerTableViewCellTests.swift in Sources */, AEB6903729770B1D00872FE0 /* ProductListViewModelTests.swift in Sources */, 03B9E52B2A1505A7005C77F5 /* TapToPayReconnectionControllerTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/POS/Controllers/POSEntryPointControllerTests.swift b/WooCommerce/WooCommerceTests/POS/Controllers/POSEntryPointControllerTests.swift index 36a43271d76..c1e3c8550ce 100644 --- a/WooCommerce/WooCommerceTests/POS/Controllers/POSEntryPointControllerTests.swift +++ b/WooCommerce/WooCommerceTests/POS/Controllers/POSEntryPointControllerTests.swift @@ -3,10 +3,10 @@ import Testing struct POSEntryPointControllerTests { @available(iOS 17.0, *) - @Test func eligibilityState_is_set_to_eligible_when_i2_feature_is_disabled() async throws { + @Test func eligibilityState_is_always_eligible_when_i2_feature_is_disabled_regardless_of_eligibility_checker() async throws { // Given let mockEligibilityChecker = MockPOSEligibilityChecker() - mockEligibilityChecker.eligibility = .ineligible(reason: .notTablet) + mockEligibilityChecker.eligibility = .ineligible(reason: .unsupportedIOSVersion) let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) // When @@ -23,7 +23,7 @@ struct POSEntryPointControllerTests { @Test func eligibilityState_is_set_to_ineligible_when_i2_feature_is_enabled_and_checker_returns_ineligible() async throws { // Given let mockEligibilityChecker = MockPOSEligibilityChecker() - let expectedState = POSEligibilityState.ineligible(reason: .notTablet) + let expectedState = POSEligibilityState.ineligible(reason: .unsupportedIOSVersion) mockEligibilityChecker.eligibility = expectedState let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/LegacyPOSTabEligibilityCheckerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/LegacyPOSTabEligibilityCheckerTests.swift new file mode 100644 index 00000000000..a5e2d58d586 --- /dev/null +++ b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/LegacyPOSTabEligibilityCheckerTests.swift @@ -0,0 +1,445 @@ +import Combine +import Foundation +import Testing +import WooFoundation +import Yosemite +@testable import WooCommerce + +@MainActor +struct LegacyPOSTabEligibilityCheckerTests { + private var stores: MockStoresManager! + private var storageManager: MockStorageManager! + private var siteSettings: MockSelectedSiteSettings! + private var pluginsService: MockPluginsService! + private var eligibilityService: MockPOSEligibilityService! + private let siteID: Int64 = 2 + + init() async throws { + stores = MockStoresManager(sessionManager: .makeForTesting(authenticated: true)) + stores.updateDefaultStore(storeID: siteID) + storageManager = MockStorageManager() + pluginsService = MockPluginsService() + eligibilityService = MockPOSEligibilityService() + setupWooCommerceVersion() + siteSettings = MockSelectedSiteSettings() + } + + @Test(arguments: [ + (country: Country.us, currency: CurrencyCode.USD), + (country: Country.gb, currency: CurrencyCode.GBP) + ]) + fileprivate func is_visible_when_all_conditions_satisfied(country: Country, currency: CurrencyCode) async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + setupCountry(country: country, currency: currency) + accountWhitelistedInBackend(true) + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == true) + } + + @Test func is_invisible_when_account_not_whitelisted_and_feature_flag_disabled() async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + setupCountry(country: .us) + accountWhitelistedInBackend(false) + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == false) + } + + @Test func is_invisible_when_device_is_not_iPad() async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .phone, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == false) + } + + @Test(arguments: [ + (country: Country.ca, currency: CurrencyCode.CAD), + (country: Country.es, currency: CurrencyCode.EUR) + ]) + fileprivate func is_invisible_when_country_is_not_supported(country: Country, currency: CurrencyCode) async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + setupCountry(country: country, currency: currency) + accountWhitelistedInBackend(true) + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == false) + } + + @Test(arguments: [ + (country: Country.us, currency: CurrencyCode.GBP), + (country: Country.us, currency: CurrencyCode.CAD), + (country: Country.gb, currency: CurrencyCode.EUR), + (country: Country.gb, currency: CurrencyCode.USD) + ]) + fileprivate func is_invisible_when_currency_is_not_supported(country: Country, + currency: CurrencyCode) async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + setupCountry(country: country, currency: currency) + accountWhitelistedInBackend(true) + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == false) + } + + @Test func is_invisible_when_woocommerce_version_is_below_minimum() async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + setupWooCommerceVersion("9.5.0") + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == false) + } + + @Test func is_visible_when_core_version_is_10_0_0_and_POS_feature_enabled() async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + setupWooCommerceVersion("10.0.0") + setupPOSFeatureEnabled(.success(true)) + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == true) + } + + @Test func is_invisible_when_core_version_is_10_0_0_and_POS_feature_disabled() async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + setupWooCommerceVersion("10.0.0") + setupPOSFeatureEnabled(.success(false)) + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == false) + } + + @Test func is_invisible_when_core_version_is_10_0_0_and_POS_feature_check_fails() async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + setupWooCommerceVersion("10.0.0") + setupPOSFeatureEnabled(.failure(NSError(domain: "test", code: 0))) + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == false) + } + + @Test func is_visible_when_core_version_is_below_10_0_0_and_POS_feature_disabled() async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + setupWooCommerceVersion("9.9.9") + setupPOSFeatureEnabled(.success(false)) + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == true) + } + + @Test func checkInitialVisibility_returns_true_when_cached_tab_visibility_is_enabled() async throws { + // Given + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, eligibilityService: eligibilityService, stores: stores) + setupPOSTabVisibility(siteID: siteID, isVisible: true) + + // When + let result = checker.checkInitialVisibility() + + // Then + #expect(result == true) + } + + @Test func checkInitialVisibility_returns_false_when_cached_tab_visibility_is_disabled() async throws { + // Given + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, eligibilityService: eligibilityService, stores: stores) + setupPOSTabVisibility(siteID: siteID, isVisible: false) + + // When + let result = checker.checkInitialVisibility() + + // Then + #expect(result == false) + } + + @Test func checkInitialVisibility_returns_false_when_cached_tab_visibility_is_unavailable() async throws { + // Given + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, eligibilityService: eligibilityService, stores: stores) + setupPOSTabVisibility(siteID: siteID, isVisible: nil) + + // When + let result = checker.checkInitialVisibility() + + // Then + #expect(result == false) + } + + @Test func checkVisibility_skips_settings_from_initialLoad() async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + + // Initial settings (cached) - makes site eligible (US) + let initialSettings = [ + mockCountrySetting(country: .us), + mockCurrencySetting(currency: .USD) + ] + // New settings - makes site ineligible (Canada). + let newSettings = [ + mockCountrySetting(country: .ca), + mockCurrencySetting(currency: .USD) + ] + siteSettings.mockSettingsStream = [ + // Emits cached settings first (should be skipped). + (siteID: siteID, settings: initialSettings, source: .initialLoad), + // Emits new settings (should be used for eligibility check). + (siteID: siteID, settings: newSettings, source: .storageChange) + ].publisher.eraseToAnyPublisher() + + accountWhitelistedInBackend(true) + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then - Should return false because i2 feature flag is disabled + #expect(result == false) + } + + @Test func is_visible_from_filtering_site_settings_by_correct_siteID() async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + + // Settings for a different site. + let wrongSiteSettings = [ + mockCountrySetting(country: .ca, siteID: 999), + mockCurrencySetting(currency: .CAD, siteID: 999) + ] + // Settings for correct site. + let correctSiteSettings = [ + mockCountrySetting(country: .us), + mockCurrencySetting(currency: .USD) + ] + + siteSettings.mockSettingsStream = [ + // Emits settings for a different site (should be filtered out). + (siteID: 999, settings: wrongSiteSettings, source: .storageChange), + // Emits first settings for correct site (should be skipped). + (siteID: siteID, settings: [SiteSetting.fake().copy(siteID: siteID, settingID: "temp")], source: .initialLoad), + // Emits fresh settings for correct site (should be used). + (siteID: siteID, settings: correctSiteSettings, source: .storageChange) + ].publisher.eraseToAnyPublisher() + + accountWhitelistedInBackend(true) + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == true) + } +} + +private extension LegacyPOSTabEligibilityCheckerTests { + func setupCountry(country: Country, currency: CurrencyCode = .USD) { + let countrySetting = mockCountrySetting(country: country) + let currencySetting = mockCurrencySetting(currency: currency) + siteSettings.mockSettingsStream = [ + // Emits cached settings first (should be skipped). + (siteID: siteID, settings: [], source: .storageChange), + // Emits fresh settings (should be used for eligibility check). + (siteID: siteID, settings: [countrySetting, currencySetting], source: .refresh) + ].publisher.eraseToAnyPublisher() + } + + func setupWooCommerceVersion(_ version: String = "9.6.0-beta") { + pluginsService.pluginToReturn = .fake().copy( + siteID: siteID, + plugin: "WooCommerce", + version: version, + active: true + ) + } + + func accountWhitelistedInBackend(_ isAllowed: Bool = false) { + stores.whenReceivingAction(ofType: FeatureFlagAction.self) { action in + switch action { + case .isRemoteFeatureFlagEnabled(_, _, completion: let completion): + completion(isAllowed) + } + } + } + + func setupPOSFeatureEnabled(_ result: Result) { + stores.whenReceivingAction(ofType: SettingAction.self) { action in + switch action { + case .isFeatureEnabled(_, _, let completion): + completion(result) + default: + break + } + } + } + + func setupPOSTabVisibility(siteID: Int64, isVisible: Bool?) { + eligibilityService.cachedTabVisibility[siteID] = isVisible + } + + enum Country: String { + case us = "US:CA" + case ca = "CA:NS" + case gb = "GB" + case es = "ES" + } + + func mockCountrySetting(country: Country, siteID: Int64? = nil) -> SiteSetting { + SiteSetting.fake() + .copy( + siteID: siteID ?? siteID, + settingID: "woocommerce_default_country", + value: country.rawValue, + settingGroupKey: SiteSettingGroup.general.rawValue + ) + } + + func mockCurrencySetting(currency: CurrencyCode, siteID: Int64? = nil) -> SiteSetting { + SiteSetting.fake() + .copy( + siteID: siteID ?? siteID, + settingID: "woocommerce_currency", + value: currency.rawValue, + settingGroupKey: SiteSettingGroup.general.rawValue + ) + } +} + +private final class MockPluginsService: PluginsServiceProtocol { + var pluginToReturn: SystemPlugin = .fake() + + func waitForPluginInStorage(siteID: Int64, pluginName: String, isActive: Bool) async -> SystemPlugin { + pluginToReturn + } +} + +private final class MockSelectedSiteSettings: SelectedSiteSettingsProtocol { + var mockSettingsStream: AnyPublisher<(siteID: Int64, settings: [SiteSetting], source: SettingsUpdateSource), Never>? + var siteSettings: [SiteSetting] = [] + + var settingsStream: AnyPublisher<(siteID: Int64, settings: [SiteSetting], source: SettingsUpdateSource), Never> { + return mockSettingsStream ?? Empty().eraseToAnyPublisher() + } + + func refresh() { + // Mock implementation - no action needed. + } +}