diff --git a/WooCommerce/Classes/POS/TabBar/POSIneligibleView.swift b/WooCommerce/Classes/POS/TabBar/POSIneligibleView.swift index e91cac78c45..a44fb0412ed 100644 --- a/WooCommerce/Classes/POS/TabBar/POSIneligibleView.swift +++ b/WooCommerce/Classes/POS/TabBar/POSIneligibleView.swift @@ -77,10 +77,6 @@ struct POSIneligibleView: View { private var suggestionText: String { switch reason { - case .notTablet: - return NSLocalizedString("pos.ineligible.suggestion.notTablet", - value: "Please use a tablet to access POS features.", - comment: "Suggestion for not tablet: use iPad") case .unsupportedIOSVersion: return NSLocalizedString("pos.ineligible.suggestion.unsupportedIOSVersion", value: "Point of Sale requires iOS 17 or later. Please update your device to iOS 17+ to use this feature.", @@ -105,16 +101,6 @@ struct POSIneligibleView: View { return NSLocalizedString("pos.ineligible.suggestion.featureSwitchSyncFailure", value: "Try relaunching the app or check your internet connection and try again.", comment: "Suggestion for feature switch sync failure: relaunch or check connection") - case let .unsupportedCountry(supportedCountries): - let countryNames = supportedCountries.map { $0.readableCountry } - let formattedCountryList = ListFormatter.localizedString(byJoining: countryNames) - let format = NSLocalizedString( - "pos.ineligible.suggestion.unsupportedCountry", - value: "POS is currently only available in %1$@. Check back later for availability in your region.", - comment: "Suggestion for unsupported country with list of supported countries. " + - "%1$@ is a placeholder for the localized list of supported country names." - ) - return String.localizedStringWithFormat(format, formattedCountryList) case let .unsupportedCurrency(supportedCurrencies): let currencyList = supportedCurrencies.map { $0.rawValue } let formattedCurrencyList = ListFormatter.localizedString(byJoining: currencyList) @@ -130,10 +116,6 @@ struct POSIneligibleView: View { return NSLocalizedString("pos.ineligible.suggestion.siteSettingsNotAvailable", value: "Check your internet connection and try relaunching the app. If the issue persists, please contact support.", comment: "Suggestion for site settings unavailable: check connection or contact support") - case .featureFlagDisabled: - return NSLocalizedString("pos.ineligible.suggestion.featureFlagDisabled", - value: "POS is currently disabled.", - comment: "Suggestion for disabled feature flag: notify that POS is disabled remotely") case .selfDeallocated: return NSLocalizedString("pos.ineligible.suggestion.selfDeallocated", value: "Try relaunching the app to resolve this issue.", @@ -176,24 +158,6 @@ private extension POSIneligibleView { } } -#Preview("Unsupported country") { - if #available(iOS 17.0, *) { - POSIneligibleView( - reason: .unsupportedCountry(supportedCountries: [.US, .GB]), - onRefresh: {} - ) - } -} - -#Preview("Not a tablet") { - if #available(iOS 17.0, *) { - POSIneligibleView( - reason: .notTablet, - onRefresh: {} - ) - } -} - #Preview("Unsupported iOS version") { if #available(iOS 17.0, *) { POSIneligibleView( @@ -212,15 +176,6 @@ private extension POSIneligibleView { } } -#Preview("Feature flag disabled") { - if #available(iOS 17.0, *) { - POSIneligibleView( - reason: .featureFlagDisabled, - onRefresh: {} - ) - } -} - #Preview("Feature switch disabled") { if #available(iOS 17.0, *) { POSIneligibleView( diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift index 653d977aa01..0579e6621f4 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift @@ -1,4 +1,3 @@ -import Combine import Foundation import UIKit import class WooFoundation.CurrencySettings @@ -17,15 +16,12 @@ import class Yosemite.PluginsService /// Represents the reasons why a site may be ineligible for POS. enum POSIneligibleReason: 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 } @@ -46,9 +42,6 @@ protocol POSEntryPointEligibilityCheckerProtocol { } final class POSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol { - private var siteSettingsEligibility: POSEligibilityState? - private var featureFlagEligibility: POSEligibilityState? - private let siteID: Int64 private let userInterfaceIdiom: UIUserInterfaceIdiom private let siteSettings: SelectedSiteSettingsProtocol @@ -80,18 +73,13 @@ final class POSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol { /// Determines whether the POS entry point can be shown based on the selected store and feature gates. func checkEligibility() async -> POSEligibilityState { - switch checkDeviceEligibility() { - case .eligible: - break - case .ineligible(let reason): - return .ineligible(reason: reason) + guard #available(iOS 17.0, *) else { + return .ineligible(reason: .unsupportedIOSVersion) } 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 @@ -99,15 +87,6 @@ final class POSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol { 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 @@ -118,55 +97,21 @@ final class POSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol { /// Checks the final visibility of the POS tab. func checkVisibility() async -> Bool { - if featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi2) { - return await checkVisibilityBasedOnCountryAndRemoteFeatureFlag() - } else { - let eligibility = await checkEligibility() - return eligibility == .eligible - } - } -} - -private extension POSTabEligibilityChecker { - func checkDeviceEligibility() -> POSEligibilityState { - guard #available(iOS 17.0, *) else { - return .ineligible(reason: .unsupportedIOSVersion) - } - guard userInterfaceIdiom == .pad else { - return .ineligible(reason: .notTablet) - } - - return .eligible - } - - func checkVisibilityBasedOnCountryAndRemoteFeatureFlag() async -> Bool { - guard checkDeviceEligibility() == .eligible else { return false } - async let siteSettingsEligibility = checkSiteSettingsEligibility() + async let siteSettingsEligibility = waitAndCheckSiteSettingsEligibility() async let featureFlagEligibility = checkRemoteFeatureEligibility() - self.siteSettingsEligibility = await siteSettingsEligibility switch await siteSettingsEligibility { - case .eligible: + case .ineligible(.unsupportedCountry): + return false + default: break - case let .ineligible(reason): - if case .unsupportedCurrency = reason { - break - } else { - return false - } } - self.featureFlagEligibility = await featureFlagEligibility - switch await featureFlagEligibility { - case .eligible: - return true - case .ineligible: - return false - } + return await featureFlagEligibility == .eligible } } @@ -220,11 +165,36 @@ private extension POSTabEligibilityChecker { // MARK: - Site Settings Related Eligibility Check private extension POSTabEligibilityChecker { + enum SiteSettingsEligibilityState { + case eligible + case ineligible(reason: SiteSettingsIneligibleReason) + } + + enum SiteSettingsIneligibleReason { + case siteSettingsNotAvailable + case unsupportedCountry(supportedCountries: [CountryCode]) + case unsupportedCurrency(supportedCurrencies: [CurrencyCode]) + } + func checkSiteSettingsEligibility() async -> POSEligibilityState { - if let siteSettingsEligibility { - return siteSettingsEligibility + let siteSettingsEligibility = await waitAndCheckSiteSettingsEligibility() + switch siteSettingsEligibility { + case .eligible: + return .eligible + case .ineligible(reason: let reason): + switch reason { + case .siteSettingsNotAvailable, .unsupportedCountry: + // This is an edge case where the store country is expected to be eligible from the visilibity check, but site settings might have + // changed to an unsupported country during the session. In this case, we return an ineligible reason that prompts the merchant to + // relaunch the app. + return .ineligible(reason: .siteSettingsNotAvailable) + case let .unsupportedCurrency(supportedCurrencies: supportedCurrencies): + return .ineligible(reason: .unsupportedCurrency(supportedCurrencies: supportedCurrencies)) + } } + } + func waitAndCheckSiteSettingsEligibility() async -> SiteSettingsEligibilityState { // Waits for the first site settings that matches the given site ID. let siteSettings = await waitForSiteSettingsRefresh() guard siteSettings.isNotEmpty else { @@ -249,7 +219,7 @@ private extension POSTabEligibilityChecker { return [] } - func isEligibleFromCountryAndCurrencyCode(countryCode: CountryCode, currencyCode: CurrencyCode) -> POSEligibilityState { + func isEligibleFromCountryAndCurrencyCode(countryCode: CountryCode, currencyCode: CurrencyCode) -> SiteSettingsEligibilityState { let supportedCountries: [CountryCode] = [.US, .GB] let supportedCurrencies: [CountryCode: [CurrencyCode]] = [.US: [.USD], .GB: [.GBP]] @@ -270,15 +240,21 @@ private extension POSTabEligibilityChecker { // MARK: - Remote Feature Flag Eligibility Check private extension POSTabEligibilityChecker { - @MainActor - func checkRemoteFeatureEligibility() async -> POSEligibilityState { - if let featureFlagEligibility { - return featureFlagEligibility - } + enum RemoteFeatureFlagEligibilityState: Equatable { + case eligible + case ineligible(reason: RemoteFeatureFlagIneligibleReason) + } + + enum RemoteFeatureFlagIneligibleReason: Equatable { + case selfDeallocated + case featureFlagDisabled + } + @MainActor + func checkRemoteFeatureEligibility() async -> RemoteFeatureFlagEligibilityState { // 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 - return await withCheckedContinuation { [weak self] continuation in + await withCheckedContinuation { [weak self] continuation in guard let self else { return continuation.resume(returning: .ineligible(reason: .selfDeallocated)) } diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerTests.swift index 9706b1319b2..100c1130c1f 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerTests.swift @@ -24,75 +24,15 @@ struct POSTabEligibilityCheckerTests { siteSettings = MockSelectedSiteSettings() } - @Test(arguments: [true, false]) - func is_eligible_when_all_conditions_satisfied(isPointOfSaleAsATabi2Enabled: Bool) async throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) - setupCountry(country: .us) - accountWhitelistedInBackend(true) - let checker = POSTabEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkEligibility() - - // Then - #expect(result == .eligible) - } - - @Test(arguments: [true, false]) - func is_ineligible_when_account_not_whitelisted_and_feature_flag_disabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) - setupCountry(country: .us) - accountWhitelistedInBackend(false) - let checker = POSTabEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkEligibility() - - // Then - #expect(result == .ineligible(reason: .featureFlagDisabled)) - } - - @Test(arguments: [true, false]) - func is_ineligible_when_device_is_not_iPad(isPointOfSaleAsATabi2Enabled: Bool) async throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) - setupCountry(country: .us) - accountWhitelistedInBackend(true) - let checker = POSTabEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .phone, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkEligibility() - - // Then - #expect(result == .ineligible(reason: .notTablet)) - } + // MARK: `checkVisibility` @Test(arguments: [ - (country: Country.us, currency: CurrencyCode.USD, isPointOfSaleAsATabi2Enabled: true), - (country: Country.us, currency: CurrencyCode.USD, isPointOfSaleAsATabi2Enabled: false), - (country: Country.gb, currency: CurrencyCode.GBP, isPointOfSaleAsATabi2Enabled: true), - (country: Country.gb, currency: CurrencyCode.GBP, isPointOfSaleAsATabi2Enabled: false) + (country: Country.us, currency: CurrencyCode.USD), + (country: Country.gb, currency: CurrencyCode.GBP) ]) - fileprivate func is_eligible_when_country_and_currency_supported(country: Country, currency: CurrencyCode, isPointOfSaleAsATabi2Enabled: Bool) async throws { + fileprivate func is_visible_when_all_conditions_satisfied(country: Country, currency: CurrencyCode) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) setupCountry(country: country, currency: currency) accountWhitelistedInBackend(true) let checker = POSTabEligibilityChecker(siteID: siteID, @@ -103,21 +43,19 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkEligibility() + let result = await checker.checkVisibility() // Then - #expect(result == .eligible) + #expect(result == true) } @Test(arguments: [ - (country: Country.ca, currency: CurrencyCode.CAD, isPointOfSaleAsATabi2Enabled: true), - (country: Country.ca, currency: CurrencyCode.CAD, isPointOfSaleAsATabi2Enabled: false), - (country: Country.es, currency: CurrencyCode.EUR, isPointOfSaleAsATabi2Enabled: true), - (country: Country.es, currency: CurrencyCode.EUR, isPointOfSaleAsATabi2Enabled: false) + (country: Country.ca, currency: CurrencyCode.CAD), + (country: Country.es, currency: CurrencyCode.EUR) ]) - fileprivate func is_ineligible_when_country_is_not_supported(country: Country, currency: CurrencyCode, isPointOfSaleAsATabi2Enabled: Bool) async throws { + fileprivate func is_invisible_when_country_is_not_supported(country: Country, currency: CurrencyCode) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) setupCountry(country: country, currency: currency) accountWhitelistedInBackend(true) let checker = POSTabEligibilityChecker(siteID: siteID, @@ -128,28 +66,22 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkEligibility() + let result = await checker.checkVisibility() // Then - #expect(result == .ineligible(reason: .unsupportedCountry(supportedCountries: [.US, .GB]))) + #expect(result == false) } + @Test(arguments: [ - (country: Country.us, currency: CurrencyCode.GBP, expectedSupportedCurrencies: [CurrencyCode.USD], isPointOfSaleAsATabi2Enabled: true), - (country: Country.us, currency: CurrencyCode.GBP, expectedSupportedCurrencies: [CurrencyCode.USD], isPointOfSaleAsATabi2Enabled: false), - (country: Country.us, currency: CurrencyCode.CAD, expectedSupportedCurrencies: [CurrencyCode.USD], isPointOfSaleAsATabi2Enabled: true), - (country: Country.us, currency: CurrencyCode.CAD, expectedSupportedCurrencies: [CurrencyCode.USD], isPointOfSaleAsATabi2Enabled: false), - (country: Country.gb, currency: CurrencyCode.EUR, expectedSupportedCurrencies: [CurrencyCode.GBP], isPointOfSaleAsATabi2Enabled: true), - (country: Country.gb, currency: CurrencyCode.EUR, expectedSupportedCurrencies: [CurrencyCode.GBP], isPointOfSaleAsATabi2Enabled: false), - (country: Country.gb, currency: CurrencyCode.USD, expectedSupportedCurrencies: [CurrencyCode.GBP], isPointOfSaleAsATabi2Enabled: true), - (country: Country.gb, currency: CurrencyCode.USD, expectedSupportedCurrencies: [CurrencyCode.GBP], isPointOfSaleAsATabi2Enabled: false) + (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_ineligible_when_currency_is_not_supported(country: Country, - currency: CurrencyCode, - expectedSupportedCurrencies: [CurrencyCode], - isPointOfSaleAsATabi2Enabled: Bool) async throws { + fileprivate func is_visible_when_currency_is_not_supported(country: Country, currency: CurrencyCode) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) setupCountry(country: country, currency: currency) accountWhitelistedInBackend(true) let checker = POSTabEligibilityChecker(siteID: siteID, @@ -160,16 +92,16 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkEligibility() + let result = await checker.checkVisibility() // Then - #expect(result == .ineligible(reason: .unsupportedCurrency(supportedCurrencies: expectedSupportedCurrencies))) + #expect(result == true) } - @Test(arguments: [true, false]) - func is_ineligible_when_woocommerce_version_is_below_minimum(isPointOfSaleAsATabi2Enabled: Bool) async throws { + + func is_visible_when_woocommerce_version_is_below_minimum() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) setupCountry(country: .us) accountWhitelistedInBackend(true) setupWooCommerceVersion("9.5.0") @@ -181,16 +113,15 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkEligibility() + let result = await checker.checkVisibility() // Then - #expect(result == .ineligible(reason: .unsupportedWooCommerceVersion(minimumVersion: "9.6.0-beta"))) + #expect(result == true) } - @Test(arguments: [true, false]) - func is_eligible_when_core_version_is_10_0_0_and_POS_feature_enabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { + @Test func is_visible_when_core_version_is_10_0_0_and_POS_feature_enabled() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) setupCountry(country: .us) accountWhitelistedInBackend(true) setupWooCommerceVersion("10.0.0") @@ -203,16 +134,16 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkEligibility() + let result = await checker.checkVisibility() // Then - #expect(result == .eligible) + #expect(result == true) } - @Test(arguments: [true, false]) - func is_ineligible_when_core_version_is_10_0_0_and_POS_feature_disabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { + + func is_visible_when_core_version_is_10_0_0_and_POS_feature_disabled() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) setupCountry(country: .us) accountWhitelistedInBackend(true) setupWooCommerceVersion("10.0.0") @@ -225,16 +156,16 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkEligibility() + let result = await checker.checkVisibility() // Then - #expect(result == .ineligible(reason: .featureSwitchDisabled)) + #expect(result == true) } - @Test(arguments: [true, false]) - func is_ineligible_when_core_version_is_10_0_0_and_POS_feature_check_fails(isPointOfSaleAsATabi2Enabled: Bool) async throws { + + func is_visible_when_core_version_is_10_0_0_and_POS_feature_check_fails() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) setupCountry(country: .us) accountWhitelistedInBackend(true) setupWooCommerceVersion("10.0.0") @@ -247,16 +178,15 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkEligibility() + let result = await checker.checkVisibility() // Then - #expect(result == .ineligible(reason: .featureSwitchSyncFailure)) + #expect(result == true) } - @Test(arguments: [true, false]) - func is_eligible_when_core_version_is_below_10_0_0_and_POS_feature_disabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { + @Test func is_visible_when_core_version_is_below_10_0_0_and_POS_feature_disabled() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) setupCountry(country: .us) accountWhitelistedInBackend(true) setupWooCommerceVersion("9.9.9") @@ -269,52 +199,73 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkEligibility() + let result = await checker.checkVisibility() // Then - #expect(result == .eligible) + #expect(result == true) } - @Test func checkInitialVisibility_returns_true_when_cached_tab_visibility_is_enabled() async throws { + @Test func is_visible_when_site_settings_are_from_correct_siteID() async throws { // Given - let checker = POSTabEligibilityChecker(siteID: siteID, eligibilityService: eligibilityService, stores: stores) - setupPOSTabVisibility(siteID: siteID, isVisible: true) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) - // When - let result = checker.checkInitialVisibility() + // 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) + ] - // Then - #expect(result == true) - } + 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() - @Test func checkInitialVisibility_returns_false_when_cached_tab_visibility_is_disabled() async throws { - // Given - let checker = POSTabEligibilityChecker(siteID: siteID, eligibilityService: eligibilityService, stores: stores) - setupPOSTabVisibility(siteID: siteID, isVisible: false) + accountWhitelistedInBackend(true) + let checker = POSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) // When - let result = checker.checkInitialVisibility() + let result = await checker.checkEligibility() // Then - #expect(result == false) + #expect(result == .eligible) } - @Test func checkInitialVisibility_returns_false_when_cached_tab_visibility_is_unavailable() async throws { + @Test func is_invisible_when_remote_feature_flag_disabled() async throws { // Given - let checker = POSTabEligibilityChecker(siteID: siteID, eligibilityService: eligibilityService, stores: stores) - setupPOSTabVisibility(siteID: siteID, isVisible: nil) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) + setupCountry(country: .us) + accountWhitelistedInBackend(false) + let checker = POSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) // When - let result = checker.checkInitialVisibility() + let result = await checker.checkVisibility() // Then #expect(result == false) } - @Test(arguments: [true, false]) - func checkEligibility_skips_settings_from_initialLoad(isPointOfSaleAsATabi2Enabled: Bool) async throws { + @Test func checkVisibility_skips_settings_from_initialLoad() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) // Initial settings (cached) - makes site eligible (US) let initialSettings = [ @@ -342,38 +293,42 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkEligibility() + let result = await checker.checkVisibility() - // Then - Should be ineligible because fresh settings show CA (not cached US) - #expect(result == .ineligible(reason: .unsupportedCountry(supportedCountries: [.US, .GB]))) + // Then - Should be invisible because fresh settings show CA (not cached US) + #expect(result == false) } - @Test(arguments: [true, false]) - func checkEligibility_filters_by_correct_siteID(isPointOfSaleAsATabi2Enabled: Bool) async throws { + @Test func is_invisible_when_device_is_not_iPad() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + let checker = POSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .phone, // Not iPad + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) - // 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) - ] + // When + let result = await checker.checkVisibility() - 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() + // Then + #expect(result == false) + } + @Test func checkVisibility_and_checkEligibility_return_expected_result_after_site_settings_available() async throws { + // Given - no site settings are immediately available (empty stream that will emit values later) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) accountWhitelistedInBackend(true) + + // Creates a publisher that will emit values after a delay to simulate site settings loading + let countrySetting = mockCountrySetting(country: .us) + let currencySetting = mockCurrencySetting(currency: .USD) + let settingsSubject = PassthroughSubject<(siteID: Int64, settings: [SiteSetting], source: SettingsUpdateSource), Never>() + siteSettings.mockSettingsStream = settingsSubject.eraseToAnyPublisher() + let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, @@ -381,24 +336,69 @@ struct POSTabEligibilityCheckerTests { stores: stores, featureFlagService: featureFlagService) + // When - Call checkVisibility and checkEligibility concurrently before site settings are available + async let visibilityTask = checker.checkVisibility() + async let eligibilityTask = checker.checkEligibility() + + // Simulate site settings becoming available after methods are called + Task { + settingsSubject.send((siteID: siteID, settings: [countrySetting, currencySetting], source: .refresh)) + settingsSubject.send(completion: .finished) + } + + let visibilityResult = await visibilityTask + let eligibilityResult = await eligibilityTask + + // Then - both methods should wait for site settings and return expected results. + #expect(visibilityResult == true) + #expect(eligibilityResult == .eligible) + } + + // MARK: - `checkInitialVisibility Tests + + @Test func checkInitialVisibility_returns_true_when_cached_tab_visibility_is_enabled() async throws { + // Given + let checker = POSTabEligibilityChecker(siteID: siteID, eligibilityService: eligibilityService, stores: stores) + setupPOSTabVisibility(siteID: siteID, isVisible: true) + // When - let result = await checker.checkEligibility() + let result = checker.checkInitialVisibility() // Then - #expect(result == .eligible) + #expect(result == true) } - // MARK: - checkVisibility Tests + @Test func checkInitialVisibility_returns_false_when_cached_tab_visibility_is_disabled() async throws { + // Given + let checker = POSTabEligibilityChecker(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 = POSTabEligibilityChecker(siteID: siteID, eligibilityService: eligibilityService, stores: stores) + setupPOSTabVisibility(siteID: siteID, isVisible: nil) + + // When + let result = checker.checkInitialVisibility() + + // Then + #expect(result == false) + } + + // MARK: - `checkEligibility` Tests @Test(arguments: [ - // Eligible countries and currencies. (country: Country.us, currency: CurrencyCode.USD), - (country: Country.gb, currency: CurrencyCode.GBP), - // Eligible countries but ineligible currencies. - (country: Country.us, currency: CurrencyCode.EUR), - (country: Country.gb, currency: CurrencyCode.CAD) + (country: Country.gb, currency: CurrencyCode.GBP) ]) - fileprivate func checkVisibility_returns_true_when_i2_enabled_and_country_remote_feature_eligible(country: Country, currency: CurrencyCode) async throws { + fileprivate func is_eligible_when_all_conditions_satisfied(country: Country, currency: CurrencyCode) async throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) setupCountry(country: country, currency: currency) @@ -411,14 +411,17 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() // Then - #expect(result == true) + #expect(result == .eligible) } - @Test(arguments: [(country: Country.ca, currency: CurrencyCode.CAD), (country: Country.es, currency: CurrencyCode.EUR)]) - fileprivate func checkVisibility_returns_false_when_pointOfSaleAsATabi2_enabled_but_country_ineligible(country: Country, currency: CurrencyCode) async throws { + @Test(arguments: [ + (country: Country.ca, currency: CurrencyCode.CAD), + (country: Country.es, currency: CurrencyCode.EUR) + ]) + fileprivate func is_ineligible_when_country_is_not_supported(country: Country, currency: CurrencyCode) async throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) setupCountry(country: country, currency: currency) @@ -431,18 +434,25 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() // Then - #expect(result == false) + #expect(result == .ineligible(reason: .siteSettingsNotAvailable)) } - @Test(arguments: [(country: Country.us, currency: CurrencyCode.USD), (country: Country.gb, currency: .GBP)]) - fileprivate func checkVisibility_returns_false_when_i2_enabled_but_remote_feature_flag_disabled(country: Country, currency: CurrencyCode) async throws { + @Test(arguments: [ + (country: Country.us, currency: CurrencyCode.GBP, expectedSupportedCurrencies: [CurrencyCode.USD]), + (country: Country.us, currency: CurrencyCode.CAD, expectedSupportedCurrencies: [CurrencyCode.USD]), + (country: Country.gb, currency: CurrencyCode.EUR, expectedSupportedCurrencies: [CurrencyCode.GBP]), + (country: Country.gb, currency: CurrencyCode.USD, expectedSupportedCurrencies: [CurrencyCode.GBP]) + ]) + fileprivate func is_ineligible_when_currency_is_not_supported(country: Country, + currency: CurrencyCode, + expectedSupportedCurrencies: [CurrencyCode]) async throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) setupCountry(country: country, currency: currency) - accountWhitelistedInBackend(false) + accountWhitelistedInBackend(true) let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, @@ -451,17 +461,18 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() // Then - #expect(result == false) + #expect(result == .ineligible(reason: .unsupportedCurrency(supportedCurrencies: expectedSupportedCurrencies))) } - @Test func checkVisibility_returns_true_when_pointOfSaleAsATabi2_disabled_and_checkEligibility_eligible() async throws { + func is_ineligible_when_woocommerce_version_is_below_minimum() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) setupCountry(country: .us) accountWhitelistedInBackend(true) + setupWooCommerceVersion("9.5.0") let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, @@ -470,18 +481,19 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() // Then - #expect(result == true) + #expect(result == .ineligible(reason: .unsupportedWooCommerceVersion(minimumVersion: "9.6.0-beta"))) } - @Test(arguments: [(country: Country.us, currency: CurrencyCode.GBP), (country: Country.gb, currency: .EUR)]) - fileprivate func checkVisibility_returns_false_when_i2_disabled_and_checkEligibility_ineligible(country: Country, currency: CurrencyCode) async throws { + func is_eligible_when_core_version_is_10_0_0_and_POS_feature_enabled() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) - setupCountry(country: country, currency: currency) // Ineligible country/currency combination + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) + setupCountry(country: .us) accountWhitelistedInBackend(true) + setupWooCommerceVersion("10.0.0") + setupPOSFeatureEnabled(.success(true)) let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, @@ -490,37 +502,40 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() // Then - #expect(result == false) + #expect(result == .eligible) } - @Test(arguments: [true, false]) - func checkVisibility_returns_false_when_device_is_not_iPad(isPointOfSaleAsATabi2Enabled: Bool) async throws { + func is_ineligible_when_core_version_is_10_0_0_and_POS_feature_disabled() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) setupCountry(country: .us) accountWhitelistedInBackend(true) + setupWooCommerceVersion("10.0.0") + setupPOSFeatureEnabled(.success(false)) let checker = POSTabEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .phone, // Not iPad + userInterfaceIdiom: .pad, siteSettings: siteSettings, pluginsService: pluginsService, stores: stores, featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() // Then - #expect(result == false) + #expect(result == .ineligible(reason: .featureSwitchDisabled)) } - @Test func checkEligibility_uses_cached_values_after_checkVisibility_when_i2_feature_is_enabled() async throws { + func is_ineligible_when_core_version_is_10_0_0_and_POS_feature_check_fails() async throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) setupCountry(country: .us) accountWhitelistedInBackend(true) + setupWooCommerceVersion("10.0.0") + setupPOSFeatureEnabled(.failure(NSError(domain: "test", code: 0))) let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, @@ -528,32 +543,20 @@ struct POSTabEligibilityCheckerTests { stores: stores, featureFlagService: featureFlagService) - // When checkVisibility first (which caches siteSettingsEligibility and featureFlagEligibility) - let visibilityResult = await checker.checkVisibility() - - // And site settings and feature flag eligibility changes - setupCountry(country: .ca, currency: .AMD) - accountWhitelistedInBackend(false) - - // Then checkEligibility should use cached values for site settings and feature flags - let eligibilityResult = await checker.checkEligibility() + // When + let result = await checker.checkEligibility() - // Then - both should return the expected results, demonstrating caching works - #expect(visibilityResult == true) - #expect(eligibilityResult == .eligible) + // Then + #expect(result == .ineligible(reason: .featureSwitchSyncFailure)) } - @Test func checkVisibility_and_checkEligibility_return_expected_result_after_site_settings_available() async throws { - // Given - no site settings are immediately available (empty stream that will emit values later) + func is_eligible_when_core_version_is_below_10_0_0_and_POS_feature_disabled() async throws { + // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) + setupCountry(country: .us) accountWhitelistedInBackend(true) - - // Creates a publisher that will emit values after a delay to simulate site settings loading - let countrySetting = mockCountrySetting(country: .us) - let currencySetting = mockCurrencySetting(currency: .USD) - let settingsSubject = PassthroughSubject<(siteID: Int64, settings: [SiteSetting], source: SettingsUpdateSource), Never>() - siteSettings.mockSettingsStream = settingsSubject.eraseToAnyPublisher() - + setupWooCommerceVersion("9.9.9") + setupPOSFeatureEnabled(.success(false)) let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, @@ -561,22 +564,11 @@ struct POSTabEligibilityCheckerTests { stores: stores, featureFlagService: featureFlagService) - // When - Call checkVisibility and checkEligibility concurrently before site settings are available - async let visibilityTask = checker.checkVisibility() - async let eligibilityTask = checker.checkEligibility() - - // Simulate site settings becoming available after methods are called - Task { - settingsSubject.send((siteID: siteID, settings: [countrySetting, currencySetting], source: .refresh)) - settingsSubject.send(completion: .finished) - } - - let visibilityResult = await visibilityTask - let eligibilityResult = await eligibilityTask + // When + let result = await checker.checkEligibility() - // Then - both methods should wait for site settings and return expected results. - #expect(visibilityResult == true) - #expect(eligibilityResult == .eligible) + // Then + #expect(result == .eligible) } }