From 94edda099939713cab8ea9ad4a948f6c828d4522 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Thu, 3 Jul 2025 14:05:31 -0400 Subject: [PATCH 01/15] Remove POS ineligible cases that are moved to visibility checks in i2. --- .../POS/TabBar/POSIneligibleView.swift | 45 -- .../POS/POSTabEligibilityChecker.swift | 221 +++++++-- .../POSEntryPointControllerTests.swift | 4 +- .../POS/POSTabEligibilityCheckerTests.swift | 467 +++++++++++------- 4 files changed, 460 insertions(+), 277 deletions(-) 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..d845b33f154 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift @@ -17,15 +17,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 } @@ -45,6 +42,27 @@ protocol POSEntryPointEligibilityCheckerProtocol { func checkEligibility() async -> POSEligibilityState } +/// 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) +} + final class POSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol { private var siteSettingsEligibility: POSEligibilityState? private var featureFlagEligibility: POSEligibilityState? @@ -80,15 +98,16 @@ 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 featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi2) else { + // For i1, POS tab visibility check is equal to eligibility check. + return .eligible + } + + 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. @@ -99,14 +118,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: @@ -121,14 +132,52 @@ final class POSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol { if featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi2) { return await checkVisibilityBasedOnCountryAndRemoteFeatureFlag() } else { - let eligibility = await checkEligibility() + let eligibility = await checkEligibilityForI1() return eligibility == .eligible } } } private extension POSTabEligibilityChecker { - func checkDeviceEligibility() -> POSEligibilityState { + /// Determines whether the site is eligible for POS checking all conditions. + func checkEligibilityForI1() async -> LegacyPOSEligibilityState { + switch checkDeviceEligibilityForI1() { + case .eligible: + break + case .ineligible(let reason): + return .ineligible(reason: reason) + } + + async let siteSettingsEligibility = checkSiteSettingsEligibilityForI1() + async let featureFlagEligibility = checkRemoteFeatureEligibility().toLegacyPOSEligibilityState + async let pluginEligibility = checkPluginEligibility().toLegacyPOSEligibilityState + + // 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) + } + } + + func checkDeviceEligibilityForI1() -> LegacyPOSEligibilityState { guard #available(iOS 17.0, *) else { return .ineligible(reason: .unsupportedIOSVersion) } @@ -139,28 +188,24 @@ private extension POSTabEligibilityChecker { return .eligible } +} +private extension POSTabEligibilityChecker { func checkVisibilityBasedOnCountryAndRemoteFeatureFlag() async -> Bool { - guard checkDeviceEligibility() == .eligible else { + guard userInterfaceIdiom == .pad 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 @@ -220,11 +265,53 @@ 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 checkSiteSettingsEligibilityForI1() async -> LegacyPOSEligibilityState { + let siteSettingsEligibility = await waitAndCheckSiteSettingsEligibility() + switch siteSettingsEligibility { + case .eligible: + return .eligible + case .ineligible(reason: let reason): + switch reason { + case .siteSettingsNotAvailable: + return .ineligible(reason: .siteSettingsNotAvailable) + case let .unsupportedCountry(supportedCountries: supportedCountries): + return .ineligible(reason: .unsupportedCountry(supportedCountries: supportedCountries)) + 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 +336,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 +357,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 { + case eligible + case ineligible(reason: RemoteFeatureFlagIneligibleReason) + } + + enum RemoteFeatureFlagIneligibleReason { + 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)) } @@ -308,3 +401,53 @@ private extension POSTabEligibilityChecker { static let wcPluginMinimumVersionWithFeatureSwitch = "10.0.0" } } + +private extension POSIneligibleReason { + var toLegacyPOSIneligibilityReason: LegacyPOSIneligibleReason { + switch self { + case .unsupportedIOSVersion: + return .unsupportedIOSVersion + case .unsupportedWooCommerceVersion(let minimumVersion): + return .unsupportedWooCommerceVersion(minimumVersion: minimumVersion) + case .siteSettingsNotAvailable: + return .siteSettingsNotAvailable + case .wooCommercePluginNotFound: + return .wooCommercePluginNotFound + case .featureSwitchDisabled: + return .featureSwitchDisabled + case .featureSwitchSyncFailure: + return .featureSwitchSyncFailure + case .unsupportedCurrency(let supportedCurrencies): + return .unsupportedCurrency(supportedCurrencies: supportedCurrencies) + case .selfDeallocated: + return .selfDeallocated + } + } +} + +private extension POSEligibilityState { + var toLegacyPOSEligibilityState: LegacyPOSEligibilityState { + switch self { + case .eligible: + return .eligible + case .ineligible(let reason): + return .ineligible(reason: reason.toLegacyPOSIneligibilityReason) + } + } +} + +private extension POSTabEligibilityChecker.RemoteFeatureFlagEligibilityState { + var toLegacyPOSEligibilityState: LegacyPOSEligibilityState { + switch self { + case .eligible: + return .eligible + case .ineligible(let reason): + switch reason { + case .selfDeallocated: + return .ineligible(reason: .selfDeallocated) + case .featureFlagDisabled: + return .ineligible(reason: .featureFlagDisabled) + } + } + } +} diff --git a/WooCommerce/WooCommerceTests/POS/Controllers/POSEntryPointControllerTests.swift b/WooCommerce/WooCommerceTests/POS/Controllers/POSEntryPointControllerTests.swift index 36a43271d76..513fc305018 100644 --- a/WooCommerce/WooCommerceTests/POS/Controllers/POSEntryPointControllerTests.swift +++ b/WooCommerce/WooCommerceTests/POS/Controllers/POSEntryPointControllerTests.swift @@ -6,7 +6,7 @@ struct POSEntryPointControllerTests { @Test func eligibilityState_is_set_to_eligible_when_i2_feature_is_disabled() 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/POSTabEligibilityCheckerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerTests.swift index 9706b1319b2..4ccfc5c3e77 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerTests.swift @@ -24,11 +24,18 @@ struct POSTabEligibilityCheckerTests { siteSettings = MockSelectedSiteSettings() } - @Test(arguments: [true, false]) - func is_eligible_when_all_conditions_satisfied(isPointOfSaleAsATabi2Enabled: Bool) async throws { + // 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) + ]) + fileprivate func is_visible_when_all_conditions_satisfied(country: Country, currency: CurrencyCode, isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) - setupCountry(country: .us) + setupCountry(country: country, currency: currency) accountWhitelistedInBackend(true) let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, @@ -38,18 +45,23 @@ 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_account_not_whitelisted_and_feature_flag_disabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { + @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) + ]) + fileprivate func is_invisible_when_country_is_not_supported(country: Country, currency: CurrencyCode, isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) - setupCountry(country: .us) - accountWhitelistedInBackend(false) + setupCountry(country: country, currency: currency) + accountWhitelistedInBackend(true) let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, @@ -58,41 +70,46 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkEligibility() + let result = await checker.checkVisibility() // Then - #expect(result == .ineligible(reason: .featureFlagDisabled)) + #expect(result == false) } - @Test(arguments: [true, false]) - func is_ineligible_when_device_is_not_iPad(isPointOfSaleAsATabi2Enabled: Bool) async throws { + @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_for_i1(country: Country, currency: CurrencyCode) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) - setupCountry(country: .us) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + setupCountry(country: country, currency: currency) accountWhitelistedInBackend(true) let checker = POSTabEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .phone, + userInterfaceIdiom: .pad, siteSettings: siteSettings, pluginsService: pluginsService, stores: stores, featureFlagService: featureFlagService) // When - let result = await checker.checkEligibility() + let result = await checker.checkVisibility() // Then - #expect(result == .ineligible(reason: .notTablet)) + #expect(result == false) } @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.GBP), + (country: Country.us, currency: CurrencyCode.CAD), + (country: Country.gb, currency: CurrencyCode.EUR), + (country: Country.gb, currency: CurrencyCode.USD) ]) - fileprivate func is_eligible_when_country_and_currency_supported(country: Country, currency: CurrencyCode, isPointOfSaleAsATabi2Enabled: Bool) async throws { + fileprivate func is_visible_when_currency_is_not_supported_for_i2(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,23 +120,18 @@ 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) - ]) - fileprivate func is_ineligible_when_country_is_not_supported(country: Country, currency: CurrencyCode, isPointOfSaleAsATabi2Enabled: Bool) async throws { + func is_invisible_when_woocommerce_version_is_below_minimum_for_i1() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) - setupCountry(country: country, currency: currency) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + setupCountry(country: .us) accountWhitelistedInBackend(true) + setupWooCommerceVersion("9.5.0") let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, @@ -128,30 +140,18 @@ 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) - ]) - fileprivate func is_ineligible_when_currency_is_not_supported(country: Country, - currency: CurrencyCode, - expectedSupportedCurrencies: [CurrencyCode], - isPointOfSaleAsATabi2Enabled: Bool) async throws { + func is_visible_when_woocommerce_version_is_below_minimum_for_i2() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) - setupCountry(country: country, currency: currency) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) + setupCountry(country: .us) accountWhitelistedInBackend(true) + setupWooCommerceVersion("9.5.0") let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, @@ -160,19 +160,20 @@ 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_core_version_is_10_0_0_and_POS_feature_enabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) setupCountry(country: .us) accountWhitelistedInBackend(true) - setupWooCommerceVersion("9.5.0") + setupWooCommerceVersion("10.0.0") + setupPOSFeatureEnabled(.success(true)) let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, @@ -181,20 +182,19 @@ 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 { + func is_invisible_when_core_version_is_10_0_0_and_POS_feature_disabled_for_i1() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) setupCountry(country: .us) accountWhitelistedInBackend(true) setupWooCommerceVersion("10.0.0") - setupPOSFeatureEnabled(.success(true)) + setupPOSFeatureEnabled(.success(false)) let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, @@ -203,16 +203,15 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkEligibility() + let result = await checker.checkVisibility() // Then - #expect(result == .eligible) + #expect(result == false) } - @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_for_i2() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) setupCountry(country: .us) accountWhitelistedInBackend(true) setupWooCommerceVersion("10.0.0") @@ -225,16 +224,15 @@ 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_invisible_when_core_version_is_10_0_0_and_POS_feature_check_fails_for_i1() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) setupCountry(country: .us) accountWhitelistedInBackend(true) setupWooCommerceVersion("10.0.0") @@ -247,20 +245,19 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkEligibility() + let result = await checker.checkVisibility() // Then - #expect(result == .ineligible(reason: .featureSwitchSyncFailure)) + #expect(result == false) } - @Test(arguments: [true, false]) - func is_eligible_when_core_version_is_below_10_0_0_and_POS_feature_disabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { + func is_visible_when_core_version_is_10_0_0_and_POS_feature_check_fails_for_i2() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) setupCountry(country: .us) accountWhitelistedInBackend(true) - setupWooCommerceVersion("9.9.9") - setupPOSFeatureEnabled(.success(false)) + setupWooCommerceVersion("10.0.0") + setupPOSFeatureEnabled(.failure(NSError(domain: "test", code: 0))) let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, @@ -269,50 +266,96 @@ 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(arguments: [true, false]) + func is_visible_when_core_version_is_below_10_0_0_and_POS_feature_disabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given - let checker = POSTabEligibilityChecker(siteID: siteID, eligibilityService: eligibilityService, stores: stores) - setupPOSTabVisibility(siteID: siteID, isVisible: true) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + setupWooCommerceVersion("9.9.9") + setupPOSFeatureEnabled(.success(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 == true) } - @Test func checkInitialVisibility_returns_false_when_cached_tab_visibility_is_disabled() async throws { + @Test(arguments: [true, false]) + func is_visible_when_site_settings_are_from_correct_siteID(isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given - let checker = POSTabEligibilityChecker(siteID: siteID, eligibilityService: eligibilityService, stores: stores) - setupPOSTabVisibility(siteID: siteID, isVisible: false) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + + // 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 = 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(arguments: [true, false]) + func is_invisible_when_remote_feature_flag_disabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given - let checker = POSTabEligibilityChecker(siteID: siteID, eligibilityService: eligibilityService, stores: stores) - setupPOSTabVisibility(siteID: siteID, isVisible: nil) + 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 = 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 { + func checkVisibility_skips_settings_from_initialLoad(isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) @@ -342,38 +385,43 @@ 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 { + func is_invisible_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, // 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 +429,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 +504,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 +527,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 +554,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 +574,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 +595,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 +636,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 +657,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) } } From d22a9b967be17abecfeec5aeef51804e522353c7 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Thu, 3 Jul 2025 15:01:11 -0400 Subject: [PATCH 02/15] Revert `POSTabEligibilityChecker` back to i1 specific, and create `POSTabEligibilityCheckerI2` for i2 that still contains i1 logic. --- .../POS/POSTabEligibilityChecker.swift | 208 +-------- .../POS/POSTabEligibilityCheckerI2.swift | 426 ++++++++++++++++++ .../WooCommerce.xcodeproj/project.pbxproj | 4 + 3 files changed, 448 insertions(+), 190 deletions(-) create mode 100644 WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityCheckerI2.swift diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift index d845b33f154..19712c96c3f 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift @@ -63,10 +63,8 @@ private enum LegacyPOSEligibilityState: Equatable { case ineligible(reason: LegacyPOSIneligibleReason) } +/// POS tab eligibility checker for i1. Will be replaced by `POSTabEligibilityCheckerI2` when removing `pointOfSaleAsATabi2` feature flag. final class POSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol { - private var siteSettingsEligibility: POSEligibilityState? - private var featureFlagEligibility: POSEligibilityState? - private let siteID: Int64 private let userInterfaceIdiom: UIUserInterfaceIdiom private let siteSettings: SelectedSiteSettingsProtocol @@ -98,16 +96,12 @@ 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 { - guard featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi2) else { - // For i1, POS tab visibility check is equal to eligibility check. - return .eligible - } - - guard #available(iOS 17.0, *) else { - return .ineligible(reason: .unsupportedIOSVersion) - } + .eligible + } + private func checkI1Eligibility() async -> LegacyPOSEligibilityState { async let siteSettingsEligibility = checkSiteSettingsEligibility() + async let featureFlagEligibility = checkRemoteFeatureEligibility() async let pluginEligibility = checkPluginEligibility() // Checks site settings first since it's likely to complete fastest. @@ -118,48 +112,6 @@ final class POSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol { 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 { - if featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi2) { - return await checkVisibilityBasedOnCountryAndRemoteFeatureFlag() - } else { - let eligibility = await checkEligibilityForI1() - return eligibility == .eligible - } - } -} - -private extension POSTabEligibilityChecker { - /// Determines whether the site is eligible for POS checking all conditions. - func checkEligibilityForI1() async -> LegacyPOSEligibilityState { - switch checkDeviceEligibilityForI1() { - case .eligible: - break - case .ineligible(let reason): - return .ineligible(reason: reason) - } - - async let siteSettingsEligibility = checkSiteSettingsEligibilityForI1() - async let featureFlagEligibility = checkRemoteFeatureEligibility().toLegacyPOSEligibilityState - async let pluginEligibility = checkPluginEligibility().toLegacyPOSEligibilityState - - // 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: @@ -177,7 +129,14 @@ private extension POSTabEligibilityChecker { } } - func checkDeviceEligibilityForI1() -> LegacyPOSEligibilityState { + func checkVisibility() async -> Bool { + let eligibility = await checkI1Eligibility() + return eligibility == .eligible + } +} + +private extension POSTabEligibilityChecker { + func checkDeviceEligibility() -> LegacyPOSEligibilityState { guard #available(iOS 17.0, *) else { return .ineligible(reason: .unsupportedIOSVersion) } @@ -190,35 +149,10 @@ private extension POSTabEligibilityChecker { } } -private extension POSTabEligibilityChecker { - func checkVisibilityBasedOnCountryAndRemoteFeatureFlag() async -> Bool { - guard userInterfaceIdiom == .pad else { - return false - } - - async let siteSettingsEligibility = waitAndCheckSiteSettingsEligibility() - async let featureFlagEligibility = checkRemoteFeatureEligibility() - - switch await siteSettingsEligibility { - case .ineligible(.unsupportedCountry): - return false - default: - break - } - - switch await featureFlagEligibility { - case .eligible: - return true - case .ineligible: - return false - } - } -} - // MARK: - WC Plugin Related Eligibility Check private extension POSTabEligibilityChecker { - func checkPluginEligibility() async -> POSEligibilityState { + func checkPluginEligibility() async -> LegacyPOSEligibilityState { let wcPlugin = await fetchWooCommercePlugin(siteID: siteID) guard VersionHelpers.isVersionSupported(version: wcPlugin.version, @@ -244,7 +178,7 @@ private extension POSTabEligibilityChecker { } @MainActor - func checkFeatureSwitchEnabled(siteID: Int64) async -> POSEligibilityState { + func checkFeatureSwitchEnabled(siteID: Int64) async -> LegacyPOSEligibilityState { await withCheckedContinuation { [weak self] continuation in guard let self else { return continuation.resume(returning: .ineligible(reason: .selfDeallocated)) @@ -265,53 +199,7 @@ 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 { - 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 checkSiteSettingsEligibilityForI1() async -> LegacyPOSEligibilityState { - let siteSettingsEligibility = await waitAndCheckSiteSettingsEligibility() - switch siteSettingsEligibility { - case .eligible: - return .eligible - case .ineligible(reason: let reason): - switch reason { - case .siteSettingsNotAvailable: - return .ineligible(reason: .siteSettingsNotAvailable) - case let .unsupportedCountry(supportedCountries: supportedCountries): - return .ineligible(reason: .unsupportedCountry(supportedCountries: supportedCountries)) - case let .unsupportedCurrency(supportedCurrencies: supportedCurrencies): - return .ineligible(reason: .unsupportedCurrency(supportedCurrencies: supportedCurrencies)) - } - } - } - - func waitAndCheckSiteSettingsEligibility() async -> SiteSettingsEligibilityState { + func checkSiteSettingsEligibility() async -> LegacyPOSEligibilityState { // Waits for the first site settings that matches the given site ID. let siteSettings = await waitForSiteSettingsRefresh() guard siteSettings.isNotEmpty else { @@ -336,7 +224,7 @@ private extension POSTabEligibilityChecker { return [] } - func isEligibleFromCountryAndCurrencyCode(countryCode: CountryCode, currencyCode: CurrencyCode) -> SiteSettingsEligibilityState { + func isEligibleFromCountryAndCurrencyCode(countryCode: CountryCode, currencyCode: CurrencyCode) -> LegacyPOSEligibilityState { let supportedCountries: [CountryCode] = [.US, .GB] let supportedCurrencies: [CountryCode: [CurrencyCode]] = [.US: [.USD], .GB: [.GBP]] @@ -357,18 +245,8 @@ private extension POSTabEligibilityChecker { // MARK: - Remote Feature Flag Eligibility Check private extension POSTabEligibilityChecker { - enum RemoteFeatureFlagEligibilityState { - case eligible - case ineligible(reason: RemoteFeatureFlagIneligibleReason) - } - - enum RemoteFeatureFlagIneligibleReason { - case selfDeallocated - case featureFlagDisabled - } - @MainActor - func checkRemoteFeatureEligibility() async -> RemoteFeatureFlagEligibilityState { + 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 @@ -401,53 +279,3 @@ private extension POSTabEligibilityChecker { static let wcPluginMinimumVersionWithFeatureSwitch = "10.0.0" } } - -private extension POSIneligibleReason { - var toLegacyPOSIneligibilityReason: LegacyPOSIneligibleReason { - switch self { - case .unsupportedIOSVersion: - return .unsupportedIOSVersion - case .unsupportedWooCommerceVersion(let minimumVersion): - return .unsupportedWooCommerceVersion(minimumVersion: minimumVersion) - case .siteSettingsNotAvailable: - return .siteSettingsNotAvailable - case .wooCommercePluginNotFound: - return .wooCommercePluginNotFound - case .featureSwitchDisabled: - return .featureSwitchDisabled - case .featureSwitchSyncFailure: - return .featureSwitchSyncFailure - case .unsupportedCurrency(let supportedCurrencies): - return .unsupportedCurrency(supportedCurrencies: supportedCurrencies) - case .selfDeallocated: - return .selfDeallocated - } - } -} - -private extension POSEligibilityState { - var toLegacyPOSEligibilityState: LegacyPOSEligibilityState { - switch self { - case .eligible: - return .eligible - case .ineligible(let reason): - return .ineligible(reason: reason.toLegacyPOSIneligibilityReason) - } - } -} - -private extension POSTabEligibilityChecker.RemoteFeatureFlagEligibilityState { - var toLegacyPOSEligibilityState: LegacyPOSEligibilityState { - switch self { - case .eligible: - return .eligible - case .ineligible(let reason): - switch reason { - case .selfDeallocated: - return .ineligible(reason: .selfDeallocated) - case .featureFlagDisabled: - return .ineligible(reason: .featureFlagDisabled) - } - } - } -} diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityCheckerI2.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityCheckerI2.swift new file mode 100644 index 00000000000..f1e786815b9 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityCheckerI2.swift @@ -0,0 +1,426 @@ +import Combine +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) +} + +final class POSTabEligibilityCheckerI2: POSEntryPointEligibilityCheckerProtocol { + private var siteSettingsEligibility: POSEligibilityState? + private var featureFlagEligibility: POSEligibilityState? + + 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 { + guard featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi2) else { + // For i1, POS tab visibility check is equal to eligibility check. + return .eligible + } + + guard #available(iOS 17.0, *) else { + return .ineligible(reason: .unsupportedIOSVersion) + } + + async let siteSettingsEligibility = checkSiteSettingsEligibility() + 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) + } + + // 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 { + if featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi2) { + return await checkVisibilityBasedOnCountryAndRemoteFeatureFlag() + } else { + let eligibility = await checkEligibilityForI1() + return eligibility == .eligible + } + } +} + +private extension POSTabEligibilityCheckerI2 { + /// Determines whether the site is eligible for POS checking all conditions. + func checkEligibilityForI1() async -> LegacyPOSEligibilityState { + switch checkDeviceEligibilityForI1() { + case .eligible: + break + case .ineligible(let reason): + return .ineligible(reason: reason) + } + + async let siteSettingsEligibility = checkSiteSettingsEligibilityForI1() + async let featureFlagEligibility = checkRemoteFeatureEligibility().toLegacyPOSEligibilityState + async let pluginEligibility = checkPluginEligibility().toLegacyPOSEligibilityState + + // 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) + } + } + + func checkDeviceEligibilityForI1() -> LegacyPOSEligibilityState { + guard #available(iOS 17.0, *) else { + return .ineligible(reason: .unsupportedIOSVersion) + } + + guard userInterfaceIdiom == .pad else { + return .ineligible(reason: .notTablet) + } + + return .eligible + } +} + +private extension POSTabEligibilityCheckerI2 { + func checkVisibilityBasedOnCountryAndRemoteFeatureFlag() async -> Bool { + guard userInterfaceIdiom == .pad else { + return false + } + + async let siteSettingsEligibility = waitAndCheckSiteSettingsEligibility() + async let featureFlagEligibility = checkRemoteFeatureEligibility() + + switch await siteSettingsEligibility { + case .ineligible(.unsupportedCountry): + return false + default: + break + } + + switch await featureFlagEligibility { + case .eligible: + return true + case .ineligible: + return false + } + } +} + +// MARK: - WC Plugin Related Eligibility Check + +private extension POSTabEligibilityCheckerI2 { + func checkPluginEligibility() async -> POSEligibilityState { + 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 -> POSEligibilityState { + 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 POSTabEligibilityCheckerI2 { + enum SiteSettingsEligibilityState { + case eligible + case ineligible(reason: SiteSettingsIneligibleReason) + } + + enum SiteSettingsIneligibleReason { + case siteSettingsNotAvailable + case unsupportedCountry(supportedCountries: [CountryCode]) + case unsupportedCurrency(supportedCurrencies: [CurrencyCode]) + } + + func checkSiteSettingsEligibility() async -> POSEligibilityState { + 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 checkSiteSettingsEligibilityForI1() async -> LegacyPOSEligibilityState { + let siteSettingsEligibility = await waitAndCheckSiteSettingsEligibility() + switch siteSettingsEligibility { + case .eligible: + return .eligible + case .ineligible(reason: let reason): + switch reason { + case .siteSettingsNotAvailable: + return .ineligible(reason: .siteSettingsNotAvailable) + case let .unsupportedCountry(supportedCountries: supportedCountries): + return .ineligible(reason: .unsupportedCountry(supportedCountries: supportedCountries)) + 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 { + 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) -> SiteSettingsEligibilityState { + 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 POSTabEligibilityCheckerI2 { + enum RemoteFeatureFlagEligibilityState { + case eligible + case ineligible(reason: RemoteFeatureFlagIneligibleReason) + } + + enum RemoteFeatureFlagIneligibleReason { + 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 + 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 POSTabEligibilityCheckerI2 { + enum Constants { + static let wcPluginName = "WooCommerce" + static let wcPluginMinimumVersion = "9.6.0-beta" + static let wcPluginMinimumVersionWithFeatureSwitch = "10.0.0" + } +} + +private extension POSIneligibleReason { + var toLegacyPOSIneligibilityReason: LegacyPOSIneligibleReason { + switch self { + case .unsupportedIOSVersion: + return .unsupportedIOSVersion + case .unsupportedWooCommerceVersion(let minimumVersion): + return .unsupportedWooCommerceVersion(minimumVersion: minimumVersion) + case .siteSettingsNotAvailable: + return .siteSettingsNotAvailable + case .wooCommercePluginNotFound: + return .wooCommercePluginNotFound + case .featureSwitchDisabled: + return .featureSwitchDisabled + case .featureSwitchSyncFailure: + return .featureSwitchSyncFailure + case .unsupportedCurrency(let supportedCurrencies): + return .unsupportedCurrency(supportedCurrencies: supportedCurrencies) + case .selfDeallocated: + return .selfDeallocated + } + } +} + +private extension POSEligibilityState { + var toLegacyPOSEligibilityState: LegacyPOSEligibilityState { + switch self { + case .eligible: + return .eligible + case .ineligible(let reason): + return .ineligible(reason: reason.toLegacyPOSIneligibilityReason) + } + } +} + +private extension POSTabEligibilityCheckerI2.RemoteFeatureFlagEligibilityState { + var toLegacyPOSEligibilityState: LegacyPOSEligibilityState { + switch self { + case .eligible: + return .eligible + case .ineligible(let reason): + switch reason { + case .selfDeallocated: + return .ineligible(reason: .selfDeallocated) + case .featureFlagDisabled: + return .ineligible(reason: .featureFlagDisabled) + } + } + } +} diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 7ae9b94510c..5a7b8ff0879 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -295,6 +295,7 @@ 025678052575EA1B009D7E6C /* ProductDetailsCellViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025678042575EA1B009D7E6C /* ProductDetailsCellViewModelTests.swift */; }; 025678C125773236009D7E6C /* Collection+ShippingLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025678C025773236009D7E6C /* Collection+ShippingLabel.swift */; }; 025678C725773399009D7E6C /* Collection+ShippingLabelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025678C625773399009D7E6C /* Collection+ShippingLabelTests.swift */; }; + 0256DD0A2E1706B5002FB998 /* POSTabEligibilityCheckerI2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0256DD092E1706B2002FB998 /* POSTabEligibilityCheckerI2.swift */; }; 02577A7F2BFC4BB300B63FE6 /* PaymentMethodsWrapperHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02577A7E2BFC4BB300B63FE6 /* PaymentMethodsWrapperHostingController.swift */; }; 0258B4D82B1590A3008FEA07 /* ConfigurableBundleNoticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0258B4D72B1590A3008FEA07 /* ConfigurableBundleNoticeView.swift */; }; 0258B4DA2B159A0F008FEA07 /* Publisher+WithPrevious.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0258B4D92B159A0F008FEA07 /* Publisher+WithPrevious.swift */; }; @@ -3449,6 +3450,7 @@ 025678042575EA1B009D7E6C /* ProductDetailsCellViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailsCellViewModelTests.swift; sourceTree = ""; }; 025678C025773236009D7E6C /* Collection+ShippingLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+ShippingLabel.swift"; sourceTree = ""; }; 025678C625773399009D7E6C /* Collection+ShippingLabelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+ShippingLabelTests.swift"; sourceTree = ""; }; + 0256DD092E1706B2002FB998 /* POSTabEligibilityCheckerI2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSTabEligibilityCheckerI2.swift; sourceTree = ""; }; 02577A7E2BFC4BB300B63FE6 /* PaymentMethodsWrapperHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentMethodsWrapperHostingController.swift; sourceTree = ""; }; 0258B4D72B1590A3008FEA07 /* ConfigurableBundleNoticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurableBundleNoticeView.swift; sourceTree = ""; }; 0258B4D92B159A0F008FEA07 /* Publisher+WithPrevious.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+WithPrevious.swift"; sourceTree = ""; }; @@ -7753,6 +7755,7 @@ 02E4A0842BFB1D1F006D4F87 /* POS */ = { isa = PBXGroup; children = ( + 0256DD092E1706B2002FB998 /* POSTabEligibilityCheckerI2.swift */, 026B2D162DF92290005B8CAA /* POSTabEligibilityChecker.swift */, 02E4A0822BFB1C4F006D4F87 /* POSEligibilityChecker.swift */, ); @@ -15951,6 +15954,7 @@ CE35F11B2343F3B1007B2A6B /* TwoColumnHeadlineFootnoteTableViewCell.swift in Sources */, B9D19A422AE7B4AD00D944D8 /* CustomAmountRowViewModel.swift in Sources */, D8C251DB230D288A00F49782 /* PushNotesManager.swift in Sources */, + 0256DD0A2E1706B5002FB998 /* POSTabEligibilityCheckerI2.swift in Sources */, 09468D9027D5014E0054A751 /* BulkUpdatePriceViewController.swift in Sources */, 024124842AC54C3D0035A247 /* ConfigurableBundleItemView.swift in Sources */, 0279F0DA252DB4BE0098D7DE /* ProductVariationDetailsFactory.swift in Sources */, From 0ffe2f9359e27a447510eaa0c4851dbb3f77cd5b Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Thu, 3 Jul 2025 15:25:50 -0400 Subject: [PATCH 03/15] Remove i1 logic from `POSTabEligibilityCheckerI2` and revert test cases. --- .../POS/POSTabEligibilityCheckerI2.swift | 169 +--- .../ViewRelated/MainTabBarController.swift | 15 +- .../WooCommerce.xcodeproj/project.pbxproj | 4 + .../POS/POSTabEligibilityCheckerI2Tests.swift | 760 ++++++++++++++++++ .../POS/POSTabEligibilityCheckerTests.swift | 467 +++++------ 5 files changed, 970 insertions(+), 445 deletions(-) create mode 100644 WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerI2Tests.swift diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityCheckerI2.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityCheckerI2.swift index f1e786815b9..b302fd3ff5c 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityCheckerI2.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityCheckerI2.swift @@ -15,27 +15,6 @@ 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) -} - final class POSTabEligibilityCheckerI2: POSEntryPointEligibilityCheckerProtocol { private var siteSettingsEligibility: POSEligibilityState? private var featureFlagEligibility: POSEligibilityState? @@ -71,11 +50,6 @@ final class POSTabEligibilityCheckerI2: POSEntryPointEligibilityCheckerProtocol /// Determines whether the POS entry point can be shown based on the selected store and feature gates. func checkEligibility() async -> POSEligibilityState { - guard featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi2) else { - // For i1, POS tab visibility check is equal to eligibility check. - return .eligible - } - guard #available(iOS 17.0, *) else { return .ineligible(reason: .unsupportedIOSVersion) } @@ -83,7 +57,6 @@ final class POSTabEligibilityCheckerI2: POSEntryPointEligibilityCheckerProtocol async let siteSettingsEligibility = checkSiteSettingsEligibility() async let pluginEligibility = checkPluginEligibility() - // Checks site settings first since it's likely to complete fastest. switch await siteSettingsEligibility { case .eligible: break @@ -91,7 +64,6 @@ final class POSTabEligibilityCheckerI2: POSEntryPointEligibilityCheckerProtocol return .ineligible(reason: reason) } - // Finally checks plugin eligibility. switch await pluginEligibility { case .eligible: return .eligible @@ -102,69 +74,6 @@ final class POSTabEligibilityCheckerI2: POSEntryPointEligibilityCheckerProtocol /// Checks the final visibility of the POS tab. func checkVisibility() async -> Bool { - if featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi2) { - return await checkVisibilityBasedOnCountryAndRemoteFeatureFlag() - } else { - let eligibility = await checkEligibilityForI1() - return eligibility == .eligible - } - } -} - -private extension POSTabEligibilityCheckerI2 { - /// Determines whether the site is eligible for POS checking all conditions. - func checkEligibilityForI1() async -> LegacyPOSEligibilityState { - switch checkDeviceEligibilityForI1() { - case .eligible: - break - case .ineligible(let reason): - return .ineligible(reason: reason) - } - - async let siteSettingsEligibility = checkSiteSettingsEligibilityForI1() - async let featureFlagEligibility = checkRemoteFeatureEligibility().toLegacyPOSEligibilityState - async let pluginEligibility = checkPluginEligibility().toLegacyPOSEligibilityState - - // 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) - } - } - - func checkDeviceEligibilityForI1() -> LegacyPOSEligibilityState { - guard #available(iOS 17.0, *) else { - return .ineligible(reason: .unsupportedIOSVersion) - } - - guard userInterfaceIdiom == .pad else { - return .ineligible(reason: .notTablet) - } - - return .eligible - } -} - -private extension POSTabEligibilityCheckerI2 { - func checkVisibilityBasedOnCountryAndRemoteFeatureFlag() async -> Bool { guard userInterfaceIdiom == .pad else { return false } @@ -179,12 +88,7 @@ private extension POSTabEligibilityCheckerI2 { break } - switch await featureFlagEligibility { - case .eligible: - return true - case .ineligible: - return false - } + return await featureFlagEligibility == .eligible } } @@ -267,23 +171,6 @@ private extension POSTabEligibilityCheckerI2 { } } - func checkSiteSettingsEligibilityForI1() async -> LegacyPOSEligibilityState { - let siteSettingsEligibility = await waitAndCheckSiteSettingsEligibility() - switch siteSettingsEligibility { - case .eligible: - return .eligible - case .ineligible(reason: let reason): - switch reason { - case .siteSettingsNotAvailable: - return .ineligible(reason: .siteSettingsNotAvailable) - case let .unsupportedCountry(supportedCountries: supportedCountries): - return .ineligible(reason: .unsupportedCountry(supportedCountries: supportedCountries)) - 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() @@ -330,12 +217,12 @@ private extension POSTabEligibilityCheckerI2 { // MARK: - Remote Feature Flag Eligibility Check private extension POSTabEligibilityCheckerI2 { - enum RemoteFeatureFlagEligibilityState { + enum RemoteFeatureFlagEligibilityState: Equatable { case eligible case ineligible(reason: RemoteFeatureFlagIneligibleReason) } - enum RemoteFeatureFlagIneligibleReason { + enum RemoteFeatureFlagIneligibleReason: Equatable { case selfDeallocated case featureFlagDisabled } @@ -374,53 +261,3 @@ private extension POSTabEligibilityCheckerI2 { static let wcPluginMinimumVersionWithFeatureSwitch = "10.0.0" } } - -private extension POSIneligibleReason { - var toLegacyPOSIneligibilityReason: LegacyPOSIneligibleReason { - switch self { - case .unsupportedIOSVersion: - return .unsupportedIOSVersion - case .unsupportedWooCommerceVersion(let minimumVersion): - return .unsupportedWooCommerceVersion(minimumVersion: minimumVersion) - case .siteSettingsNotAvailable: - return .siteSettingsNotAvailable - case .wooCommercePluginNotFound: - return .wooCommercePluginNotFound - case .featureSwitchDisabled: - return .featureSwitchDisabled - case .featureSwitchSyncFailure: - return .featureSwitchSyncFailure - case .unsupportedCurrency(let supportedCurrencies): - return .unsupportedCurrency(supportedCurrencies: supportedCurrencies) - case .selfDeallocated: - return .selfDeallocated - } - } -} - -private extension POSEligibilityState { - var toLegacyPOSEligibilityState: LegacyPOSEligibilityState { - switch self { - case .eligible: - return .eligible - case .ineligible(let reason): - return .ineligible(reason: reason.toLegacyPOSIneligibilityReason) - } - } -} - -private extension POSTabEligibilityCheckerI2.RemoteFeatureFlagEligibilityState { - var toLegacyPOSEligibilityState: LegacyPOSEligibilityState { - switch self { - case .eligible: - return .eligible - case .ineligible(let reason): - switch reason { - case .selfDeallocated: - return .ineligible(reason: .selfDeallocated) - case .featureFlagDisabled: - return .ineligible(reason: .featureFlagDisabled) - } - } - } -} diff --git a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift index c9c52733387..422578be113 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) { + POSTabEligibilityCheckerI2(siteID: siteID) + } else { + POSTabEligibilityChecker(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) { + POSTabEligibilityCheckerI2(siteID: siteID) + } else { + POSTabEligibilityChecker(siteID: siteID) + } } self.posEligibilityService = POSEligibilityService() super.init(coder: coder) diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 5a7b8ff0879..35b423818a3 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -296,6 +296,7 @@ 025678C125773236009D7E6C /* Collection+ShippingLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025678C025773236009D7E6C /* Collection+ShippingLabel.swift */; }; 025678C725773399009D7E6C /* Collection+ShippingLabelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025678C625773399009D7E6C /* Collection+ShippingLabelTests.swift */; }; 0256DD0A2E1706B5002FB998 /* POSTabEligibilityCheckerI2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0256DD092E1706B2002FB998 /* POSTabEligibilityCheckerI2.swift */; }; + 0256DD0C2E170C46002FB998 /* POSTabEligibilityCheckerI2Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0256DD0B2E170C42002FB998 /* POSTabEligibilityCheckerI2Tests.swift */; }; 02577A7F2BFC4BB300B63FE6 /* PaymentMethodsWrapperHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02577A7E2BFC4BB300B63FE6 /* PaymentMethodsWrapperHostingController.swift */; }; 0258B4D82B1590A3008FEA07 /* ConfigurableBundleNoticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0258B4D72B1590A3008FEA07 /* ConfigurableBundleNoticeView.swift */; }; 0258B4DA2B159A0F008FEA07 /* Publisher+WithPrevious.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0258B4D92B159A0F008FEA07 /* Publisher+WithPrevious.swift */; }; @@ -3451,6 +3452,7 @@ 025678C025773236009D7E6C /* Collection+ShippingLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+ShippingLabel.swift"; sourceTree = ""; }; 025678C625773399009D7E6C /* Collection+ShippingLabelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+ShippingLabelTests.swift"; sourceTree = ""; }; 0256DD092E1706B2002FB998 /* POSTabEligibilityCheckerI2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSTabEligibilityCheckerI2.swift; sourceTree = ""; }; + 0256DD0B2E170C42002FB998 /* POSTabEligibilityCheckerI2Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSTabEligibilityCheckerI2Tests.swift; sourceTree = ""; }; 02577A7E2BFC4BB300B63FE6 /* PaymentMethodsWrapperHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentMethodsWrapperHostingController.swift; sourceTree = ""; }; 0258B4D72B1590A3008FEA07 /* ConfigurableBundleNoticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurableBundleNoticeView.swift; sourceTree = ""; }; 0258B4D92B159A0F008FEA07 /* Publisher+WithPrevious.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+WithPrevious.swift"; sourceTree = ""; }; @@ -6790,6 +6792,7 @@ children = ( 023BD58A2BFDCFCB00A10D7B /* POSEligibilityCheckerTests.swift */, 0277889D2DF928E3006F5B8C /* POSTabEligibilityCheckerTests.swift */, + 0256DD0B2E170C42002FB998 /* POSTabEligibilityCheckerI2Tests.swift */, ); path = POS; sourceTree = ""; @@ -17322,6 +17325,7 @@ 26B3EC622744772A0075EAE6 /* SimplePaymentsSummaryViewModelTests.swift in Sources */, D85DD1D7257F359800861AA8 /* NotWPErrorViewModelTests.swift in Sources */, DE3877E4283E35E80075D87E /* DiscountTypeBottomSheetListSelectorCommandTests.swift in Sources */, + 0256DD0C2E170C46002FB998 /* POSTabEligibilityCheckerI2Tests.swift in Sources */, 025678C725773399009D7E6C /* Collection+ShippingLabelTests.swift in Sources */, 02BC5AA624D27F8900C43326 /* ProductVariationFormViewModel+ChangesTests.swift in Sources */, 02CD3BFE2C35D04C00E575C4 /* MockCardPresentPaymentService.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerI2Tests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerI2Tests.swift new file mode 100644 index 00000000000..07d07716f8d --- /dev/null +++ b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerI2Tests.swift @@ -0,0 +1,760 @@ +import Combine +import Foundation +import Testing +import WooFoundation +import Yosemite +@testable import WooCommerce + +@MainActor +struct POSTabEligibilityCheckerI2Tests { + 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() + } + + // 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) + ]) + fileprivate func is_visible_when_all_conditions_satisfied(country: Country, currency: CurrencyCode, isPointOfSaleAsATabi2Enabled: Bool) async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + setupCountry(country: country, currency: currency) + accountWhitelistedInBackend(true) + let checker = POSTabEligibilityCheckerI2(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #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) + ]) + fileprivate func is_invisible_when_country_is_not_supported(country: Country, currency: CurrencyCode, isPointOfSaleAsATabi2Enabled: Bool) async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + setupCountry(country: country, currency: currency) + accountWhitelistedInBackend(true) + let checker = POSTabEligibilityCheckerI2(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_for_i1(country: Country, currency: CurrencyCode) async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + setupCountry(country: country, currency: currency) + accountWhitelistedInBackend(true) + let checker = POSTabEligibilityCheckerI2(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_visible_when_currency_is_not_supported_for_i2(country: Country, currency: CurrencyCode) async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) + setupCountry(country: country, currency: currency) + accountWhitelistedInBackend(true) + let checker = POSTabEligibilityCheckerI2(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == true) + } + + func is_invisible_when_woocommerce_version_is_below_minimum_for_i1() async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + setupWooCommerceVersion("9.5.0") + let checker = POSTabEligibilityCheckerI2(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == false) + } + + func is_visible_when_woocommerce_version_is_below_minimum_for_i2() async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + setupWooCommerceVersion("9.5.0") + let checker = POSTabEligibilityCheckerI2(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == true) + } + + @Test(arguments: [true, false]) + func is_visible_when_core_version_is_10_0_0_and_POS_feature_enabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + setupWooCommerceVersion("10.0.0") + setupPOSFeatureEnabled(.success(true)) + let checker = POSTabEligibilityCheckerI2(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == true) + } + + func is_invisible_when_core_version_is_10_0_0_and_POS_feature_disabled_for_i1() async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + setupWooCommerceVersion("10.0.0") + setupPOSFeatureEnabled(.success(false)) + let checker = POSTabEligibilityCheckerI2(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == false) + } + + func is_visible_when_core_version_is_10_0_0_and_POS_feature_disabled_for_i2() async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + setupWooCommerceVersion("10.0.0") + setupPOSFeatureEnabled(.success(false)) + let checker = POSTabEligibilityCheckerI2(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == true) + } + + func is_invisible_when_core_version_is_10_0_0_and_POS_feature_check_fails_for_i1() 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 = POSTabEligibilityCheckerI2(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == false) + } + + func is_visible_when_core_version_is_10_0_0_and_POS_feature_check_fails_for_i2() 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 = POSTabEligibilityCheckerI2(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == true) + } + + @Test(arguments: [true, false]) + func is_visible_when_core_version_is_below_10_0_0_and_POS_feature_disabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + setupWooCommerceVersion("9.9.9") + setupPOSFeatureEnabled(.success(false)) + let checker = POSTabEligibilityCheckerI2(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == true) + } + + @Test(arguments: [true, false]) + func is_visible_when_site_settings_are_from_correct_siteID(isPointOfSaleAsATabi2Enabled: Bool) async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + + // 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 = POSTabEligibilityCheckerI2(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_invisible_when_remote_feature_flag_disabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + setupCountry(country: .us) + accountWhitelistedInBackend(false) + let checker = POSTabEligibilityCheckerI2(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == false) + } + + @Test(arguments: [true, false]) + func checkVisibility_skips_settings_from_initialLoad(isPointOfSaleAsATabi2Enabled: Bool) async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + + // 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 = POSTabEligibilityCheckerI2(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then - Should be invisible because fresh settings show CA (not cached US) + #expect(result == false) + } + + @Test(arguments: [true, false]) + func is_invisible_when_device_is_not_iPad(isPointOfSaleAsATabi2Enabled: Bool) async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + let checker = POSTabEligibilityCheckerI2(siteID: siteID, + userInterfaceIdiom: .phone, // Not iPad + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // 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 = POSTabEligibilityCheckerI2(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + 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 = POSTabEligibilityCheckerI2(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 = POSTabEligibilityCheckerI2(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 = POSTabEligibilityCheckerI2(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: [ + (country: Country.us, currency: CurrencyCode.USD), + (country: Country.gb, currency: CurrencyCode.GBP) + ]) + 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) + accountWhitelistedInBackend(true) + let checker = POSTabEligibilityCheckerI2(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkEligibility() + + // Then + #expect(result == .eligible) + } + + @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) + accountWhitelistedInBackend(true) + let checker = POSTabEligibilityCheckerI2(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkEligibility() + + // Then + #expect(result == .ineligible(reason: .siteSettingsNotAvailable)) + } + + @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(true) + let checker = POSTabEligibilityCheckerI2(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkEligibility() + + // Then + #expect(result == .ineligible(reason: .unsupportedCurrency(supportedCurrencies: expectedSupportedCurrencies))) + } + + func is_ineligible_when_woocommerce_version_is_below_minimum() async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + setupWooCommerceVersion("9.5.0") + let checker = POSTabEligibilityCheckerI2(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkEligibility() + + // Then + #expect(result == .ineligible(reason: .unsupportedWooCommerceVersion(minimumVersion: "9.6.0-beta"))) + } + + func is_eligible_when_core_version_is_10_0_0_and_POS_feature_enabled() async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + setupWooCommerceVersion("10.0.0") + setupPOSFeatureEnabled(.success(true)) + let checker = POSTabEligibilityCheckerI2(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkEligibility() + + // Then + #expect(result == .eligible) + } + + func is_ineligible_when_core_version_is_10_0_0_and_POS_feature_disabled() async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + setupWooCommerceVersion("10.0.0") + setupPOSFeatureEnabled(.success(false)) + let checker = POSTabEligibilityCheckerI2(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkEligibility() + + // Then + #expect(result == .ineligible(reason: .featureSwitchDisabled)) + } + + 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 = POSTabEligibilityCheckerI2(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkEligibility() + + // Then + #expect(result == .ineligible(reason: .featureSwitchSyncFailure)) + } + + 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) + setupWooCommerceVersion("9.9.9") + setupPOSFeatureEnabled(.success(false)) + let checker = POSTabEligibilityCheckerI2(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkEligibility() + + // Then + #expect(result == .eligible) + } +} + +private extension POSTabEligibilityCheckerI2Tests { + 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. + } +} diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerTests.swift index 4ccfc5c3e77..9706b1319b2 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerTests.swift @@ -24,18 +24,11 @@ struct POSTabEligibilityCheckerTests { siteSettings = MockSelectedSiteSettings() } - // 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) - ]) - fileprivate func is_visible_when_all_conditions_satisfied(country: Country, currency: CurrencyCode, isPointOfSaleAsATabi2Enabled: Bool) async throws { + @Test(arguments: [true, false]) + func is_eligible_when_all_conditions_satisfied(isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) - setupCountry(country: country, currency: currency) + setupCountry(country: .us) accountWhitelistedInBackend(true) let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, @@ -45,23 +38,18 @@ 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, 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) - ]) - fileprivate func is_invisible_when_country_is_not_supported(country: Country, currency: CurrencyCode, isPointOfSaleAsATabi2Enabled: Bool) async throws { + @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: country, currency: currency) - accountWhitelistedInBackend(true) + setupCountry(country: .us) + accountWhitelistedInBackend(false) let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, @@ -70,46 +58,41 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() // Then - #expect(result == false) + #expect(result == .ineligible(reason: .featureFlagDisabled)) } - @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_for_i1(country: Country, currency: CurrencyCode) async throws { + @Test(arguments: [true, false]) + func is_ineligible_when_device_is_not_iPad(isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) - setupCountry(country: country, currency: currency) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + setupCountry(country: .us) accountWhitelistedInBackend(true) let checker = POSTabEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .pad, + userInterfaceIdiom: .phone, 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: .notTablet)) } @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) + (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) ]) - fileprivate func is_visible_when_currency_is_not_supported_for_i2(country: Country, currency: CurrencyCode) async throws { + fileprivate func is_eligible_when_country_and_currency_supported(country: Country, currency: CurrencyCode, isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) setupCountry(country: country, currency: currency) accountWhitelistedInBackend(true) let checker = POSTabEligibilityChecker(siteID: siteID, @@ -120,18 +103,23 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() // Then - #expect(result == true) + #expect(result == .eligible) } - func is_invisible_when_woocommerce_version_is_below_minimum_for_i1() async throws { + @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) + ]) + fileprivate func is_ineligible_when_country_is_not_supported(country: Country, currency: CurrencyCode, isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) - setupCountry(country: .us) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + setupCountry(country: country, currency: currency) accountWhitelistedInBackend(true) - setupWooCommerceVersion("9.5.0") let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, @@ -140,18 +128,30 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() // Then - #expect(result == false) + #expect(result == .ineligible(reason: .unsupportedCountry(supportedCountries: [.US, .GB]))) } - func is_visible_when_woocommerce_version_is_below_minimum_for_i2() async throws { + @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) + ]) + fileprivate func is_ineligible_when_currency_is_not_supported(country: Country, + currency: CurrencyCode, + expectedSupportedCurrencies: [CurrencyCode], + isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) - setupCountry(country: .us) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + setupCountry(country: country, currency: currency) accountWhitelistedInBackend(true) - setupWooCommerceVersion("9.5.0") let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, @@ -160,20 +160,19 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() // Then - #expect(result == true) + #expect(result == .ineligible(reason: .unsupportedCurrency(supportedCurrencies: expectedSupportedCurrencies))) } @Test(arguments: [true, false]) - func is_visible_when_core_version_is_10_0_0_and_POS_feature_enabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { + func is_ineligible_when_woocommerce_version_is_below_minimum(isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) setupCountry(country: .us) accountWhitelistedInBackend(true) - setupWooCommerceVersion("10.0.0") - setupPOSFeatureEnabled(.success(true)) + setupWooCommerceVersion("9.5.0") let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, @@ -182,19 +181,20 @@ 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"))) } - func is_invisible_when_core_version_is_10_0_0_and_POS_feature_disabled_for_i1() async throws { + @Test(arguments: [true, false]) + func is_eligible_when_core_version_is_10_0_0_and_POS_feature_enabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) setupCountry(country: .us) accountWhitelistedInBackend(true) setupWooCommerceVersion("10.0.0") - setupPOSFeatureEnabled(.success(false)) + setupPOSFeatureEnabled(.success(true)) let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, @@ -203,15 +203,16 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() // Then - #expect(result == false) + #expect(result == .eligible) } - func is_visible_when_core_version_is_10_0_0_and_POS_feature_disabled_for_i2() async throws { + @Test(arguments: [true, false]) + func is_ineligible_when_core_version_is_10_0_0_and_POS_feature_disabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) setupCountry(country: .us) accountWhitelistedInBackend(true) setupWooCommerceVersion("10.0.0") @@ -224,15 +225,16 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() // Then - #expect(result == true) + #expect(result == .ineligible(reason: .featureSwitchDisabled)) } - func is_invisible_when_core_version_is_10_0_0_and_POS_feature_check_fails_for_i1() async throws { + @Test(arguments: [true, false]) + func is_ineligible_when_core_version_is_10_0_0_and_POS_feature_check_fails(isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) setupCountry(country: .us) accountWhitelistedInBackend(true) setupWooCommerceVersion("10.0.0") @@ -245,19 +247,20 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() // Then - #expect(result == false) + #expect(result == .ineligible(reason: .featureSwitchSyncFailure)) } - func is_visible_when_core_version_is_10_0_0_and_POS_feature_check_fails_for_i2() async throws { + @Test(arguments: [true, false]) + func is_eligible_when_core_version_is_below_10_0_0_and_POS_feature_disabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) setupCountry(country: .us) accountWhitelistedInBackend(true) - setupWooCommerceVersion("10.0.0") - setupPOSFeatureEnabled(.failure(NSError(domain: "test", code: 0))) + setupWooCommerceVersion("9.9.9") + setupPOSFeatureEnabled(.success(false)) let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, @@ -266,96 +269,50 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() // Then - #expect(result == true) + #expect(result == .eligible) } - @Test(arguments: [true, false]) - func is_visible_when_core_version_is_below_10_0_0_and_POS_feature_disabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { + @Test func checkInitialVisibility_returns_true_when_cached_tab_visibility_is_enabled() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) - setupCountry(country: .us) - accountWhitelistedInBackend(true) - setupWooCommerceVersion("9.9.9") - setupPOSFeatureEnabled(.success(false)) - let checker = POSTabEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) + let checker = POSTabEligibilityChecker(siteID: siteID, eligibilityService: eligibilityService, stores: stores) + setupPOSTabVisibility(siteID: siteID, isVisible: true) // When - let result = await checker.checkVisibility() + let result = checker.checkInitialVisibility() // Then #expect(result == true) } - @Test(arguments: [true, false]) - func is_visible_when_site_settings_are_from_correct_siteID(isPointOfSaleAsATabi2Enabled: Bool) async throws { + @Test func checkInitialVisibility_returns_false_when_cached_tab_visibility_is_disabled() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) - - // 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 = POSTabEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) + let checker = POSTabEligibilityChecker(siteID: siteID, eligibilityService: eligibilityService, stores: stores) + setupPOSTabVisibility(siteID: siteID, isVisible: false) // When - let result = await checker.checkEligibility() + let result = checker.checkInitialVisibility() // Then - #expect(result == .eligible) + #expect(result == false) } - @Test(arguments: [true, false]) - func is_invisible_when_remote_feature_flag_disabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { + @Test func checkInitialVisibility_returns_false_when_cached_tab_visibility_is_unavailable() 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) + let checker = POSTabEligibilityChecker(siteID: siteID, eligibilityService: eligibilityService, stores: stores) + setupPOSTabVisibility(siteID: siteID, isVisible: nil) // When - let result = await checker.checkVisibility() + let result = checker.checkInitialVisibility() // Then #expect(result == false) } @Test(arguments: [true, false]) - func checkVisibility_skips_settings_from_initialLoad(isPointOfSaleAsATabi2Enabled: Bool) async throws { + func checkEligibility_skips_settings_from_initialLoad(isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) @@ -385,43 +342,38 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() - // Then - Should be invisible because fresh settings show CA (not cached US) - #expect(result == false) + // Then - Should be ineligible because fresh settings show CA (not cached US) + #expect(result == .ineligible(reason: .unsupportedCountry(supportedCountries: [.US, .GB]))) } @Test(arguments: [true, false]) - func is_invisible_when_device_is_not_iPad(isPointOfSaleAsATabi2Enabled: Bool) async throws { + func checkEligibility_filters_by_correct_siteID(isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) - setupCountry(country: .us) - accountWhitelistedInBackend(true) - let checker = POSTabEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .phone, // Not iPad - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - // When - let result = await checker.checkVisibility() + // 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 == false) - } + 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 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, @@ -429,69 +381,24 @@ 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 = checker.checkInitialVisibility() - - // Then - #expect(result == true) - } - - @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() + let result = await checker.checkEligibility() // Then - #expect(result == false) + #expect(result == .eligible) } - // MARK: - `checkEligibility` Tests + // MARK: - checkVisibility Tests @Test(arguments: [ + // Eligible countries and currencies. (country: Country.us, currency: CurrencyCode.USD), - (country: Country.gb, currency: CurrencyCode.GBP) + (country: Country.gb, currency: CurrencyCode.GBP), + // Eligible countries but ineligible currencies. + (country: Country.us, currency: CurrencyCode.EUR), + (country: Country.gb, currency: CurrencyCode.CAD) ]) - fileprivate func is_eligible_when_all_conditions_satisfied(country: Country, currency: CurrencyCode) async throws { + fileprivate func checkVisibility_returns_true_when_i2_enabled_and_country_remote_feature_eligible(country: Country, currency: CurrencyCode) async throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) setupCountry(country: country, currency: currency) @@ -504,17 +411,14 @@ 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), - (country: Country.es, currency: CurrencyCode.EUR) - ]) - fileprivate func is_ineligible_when_country_is_not_supported(country: Country, currency: CurrencyCode) async throws { + @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 { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) setupCountry(country: country, currency: currency) @@ -527,25 +431,18 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkEligibility() + let result = await checker.checkVisibility() // Then - #expect(result == .ineligible(reason: .siteSettingsNotAvailable)) + #expect(result == false) } - @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 { + @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 { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) setupCountry(country: country, currency: currency) - accountWhitelistedInBackend(true) + accountWhitelistedInBackend(false) let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, @@ -554,18 +451,17 @@ 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 == false) } - func is_ineligible_when_woocommerce_version_is_below_minimum() async throws { + @Test func checkVisibility_returns_true_when_pointOfSaleAsATabi2_disabled_and_checkEligibility_eligible() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) setupCountry(country: .us) accountWhitelistedInBackend(true) - setupWooCommerceVersion("9.5.0") let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, @@ -574,19 +470,18 @@ 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) } - func is_eligible_when_core_version_is_10_0_0_and_POS_feature_enabled() async throws { + @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 { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) - setupCountry(country: .us) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + setupCountry(country: country, currency: currency) // Ineligible country/currency combination accountWhitelistedInBackend(true) - setupWooCommerceVersion("10.0.0") - setupPOSFeatureEnabled(.success(true)) let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, @@ -595,40 +490,37 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkEligibility() + let result = await checker.checkVisibility() // Then - #expect(result == .eligible) + #expect(result == false) } - func is_ineligible_when_core_version_is_10_0_0_and_POS_feature_disabled() async throws { + @Test(arguments: [true, false]) + func checkVisibility_returns_false_when_device_is_not_iPad(isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) setupCountry(country: .us) accountWhitelistedInBackend(true) - setupWooCommerceVersion("10.0.0") - setupPOSFeatureEnabled(.success(false)) let checker = POSTabEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .pad, + userInterfaceIdiom: .phone, // Not iPad siteSettings: siteSettings, pluginsService: pluginsService, stores: stores, featureFlagService: featureFlagService) // When - let result = await checker.checkEligibility() + let result = await checker.checkVisibility() // Then - #expect(result == .ineligible(reason: .featureSwitchDisabled)) + #expect(result == false) } - func is_ineligible_when_core_version_is_10_0_0_and_POS_feature_check_fails() async throws { + @Test func checkEligibility_uses_cached_values_after_checkVisibility_when_i2_feature_is_enabled() 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, @@ -636,20 +528,32 @@ struct POSTabEligibilityCheckerTests { stores: stores, featureFlagService: featureFlagService) - // When - let result = await checker.checkEligibility() + // When checkVisibility first (which caches siteSettingsEligibility and featureFlagEligibility) + let visibilityResult = await checker.checkVisibility() - // Then - #expect(result == .ineligible(reason: .featureSwitchSyncFailure)) + // 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() + + // Then - both should return the expected results, demonstrating caching works + #expect(visibilityResult == true) + #expect(eligibilityResult == .eligible) } - func is_eligible_when_core_version_is_below_10_0_0_and_POS_feature_disabled() async throws { - // Given + @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) - setupCountry(country: .us) accountWhitelistedInBackend(true) - setupWooCommerceVersion("9.9.9") - setupPOSFeatureEnabled(.success(false)) + + // 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, @@ -657,11 +561,22 @@ struct POSTabEligibilityCheckerTests { stores: stores, featureFlagService: featureFlagService) - // When - let result = await checker.checkEligibility() + // When - Call checkVisibility and checkEligibility concurrently before site settings are available + async let visibilityTask = checker.checkVisibility() + async let eligibilityTask = checker.checkEligibility() - // Then - #expect(result == .eligible) + // 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) } } From c75d809509e3556c27f984c63ad9f009d40e860a Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Thu, 3 Jul 2025 15:57:21 -0400 Subject: [PATCH 04/15] Update `POSTabEligibilityCheckerTests` to only contain test cases on `POSTabEligibilityChecker` for i1. --- .../POS/POSTabEligibilityChecker.swift | 7 + .../POS/POSTabEligibilityCheckerTests.swift | 348 +++--------------- 2 files changed, 66 insertions(+), 289 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift index 19712c96c3f..68eef8dfb2e 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift @@ -100,6 +100,13 @@ final class POSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol { } 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() diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerTests.swift index 9706b1319b2..552dcdece63 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerTests.swift @@ -24,11 +24,14 @@ struct POSTabEligibilityCheckerTests { siteSettings = MockSelectedSiteSettings() } - @Test(arguments: [true, false]) - func is_eligible_when_all_conditions_satisfied(isPointOfSaleAsATabi2Enabled: Bool) async throws { + @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: isPointOfSaleAsATabi2Enabled) - setupCountry(country: .us) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + setupCountry(country: country, currency: currency) accountWhitelistedInBackend(true) let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, @@ -38,16 +41,15 @@ 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_account_not_whitelisted_and_feature_flag_disabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { + @Test func is_invisible_when_account_not_whitelisted_and_feature_flag_disabled() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) setupCountry(country: .us) accountWhitelistedInBackend(false) let checker = POSTabEligibilityChecker(siteID: siteID, @@ -58,16 +60,15 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkEligibility() + let result = await checker.checkVisibility() // Then - #expect(result == .ineligible(reason: .featureFlagDisabled)) + #expect(result == false) } - @Test(arguments: [true, false]) - func is_ineligible_when_device_is_not_iPad(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: false) setupCountry(country: .us) accountWhitelistedInBackend(true) let checker = POSTabEligibilityChecker(siteID: siteID, @@ -78,46 +79,19 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkEligibility() - - // Then - #expect(result == .ineligible(reason: .notTablet)) - } - - @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) - ]) - fileprivate func is_eligible_when_country_and_currency_supported(country: Country, currency: CurrencyCode, isPointOfSaleAsATabi2Enabled: Bool) async throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) - setupCountry(country: country, currency: currency) - accountWhitelistedInBackend(true) - let checker = POSTabEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkEligibility() + let result = await checker.checkVisibility() // Then - #expect(result == .eligible) + #expect(result == false) } @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: false) setupCountry(country: country, currency: currency) accountWhitelistedInBackend(true) let checker = POSTabEligibilityChecker(siteID: siteID, @@ -128,28 +102,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_invisible_when_currency_is_not_supported(country: Country, + currency: CurrencyCode) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) setupCountry(country: country, currency: currency) accountWhitelistedInBackend(true) let checker = POSTabEligibilityChecker(siteID: siteID, @@ -160,16 +128,15 @@ 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 == false) } - @Test(arguments: [true, false]) - func is_ineligible_when_woocommerce_version_is_below_minimum(isPointOfSaleAsATabi2Enabled: Bool) async throws { + @Test func is_invisible_when_woocommerce_version_is_below_minimum() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) setupCountry(country: .us) accountWhitelistedInBackend(true) setupWooCommerceVersion("9.5.0") @@ -181,16 +148,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 == false) } - @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: false) setupCountry(country: .us) accountWhitelistedInBackend(true) setupWooCommerceVersion("10.0.0") @@ -203,16 +169,15 @@ 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 { + @Test func is_invisible_when_core_version_is_10_0_0_and_POS_feature_disabled() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) setupCountry(country: .us) accountWhitelistedInBackend(true) setupWooCommerceVersion("10.0.0") @@ -225,16 +190,15 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkEligibility() + let result = await checker.checkVisibility() // Then - #expect(result == .ineligible(reason: .featureSwitchDisabled)) + #expect(result == false) } - @Test(arguments: [true, false]) - func is_ineligible_when_core_version_is_10_0_0_and_POS_feature_check_fails(isPointOfSaleAsATabi2Enabled: Bool) async throws { + @Test func is_invisible_when_core_version_is_10_0_0_and_POS_feature_check_fails() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) setupCountry(country: .us) accountWhitelistedInBackend(true) setupWooCommerceVersion("10.0.0") @@ -247,16 +211,15 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkEligibility() + let result = await checker.checkVisibility() // Then - #expect(result == .ineligible(reason: .featureSwitchSyncFailure)) + #expect(result == false) } - @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: false) setupCountry(country: .us) accountWhitelistedInBackend(true) setupWooCommerceVersion("9.9.9") @@ -269,10 +232,10 @@ 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 { @@ -311,10 +274,9 @@ struct POSTabEligibilityCheckerTests { #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: false) // Initial settings (cached) - makes site eligible (US) let initialSettings = [ @@ -342,16 +304,15 @@ 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 return false because i2 feature flag is disabled + #expect(result == false) } - @Test(arguments: [true, false]) - func checkEligibility_filters_by_correct_siteID(isPointOfSaleAsATabi2Enabled: Bool) async throws { + @Test func is_visible_from_filtering_site_settings_by_correct_siteID() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) // Settings for a different site. let wrongSiteSettings = [ @@ -373,94 +334,6 @@ struct POSTabEligibilityCheckerTests { (siteID: siteID, settings: correctSiteSettings, source: .storageChange) ].publisher.eraseToAnyPublisher() - 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) - } - - // MARK: - checkVisibility 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) - ]) - fileprivate func checkVisibility_returns_true_when_i2_enabled_and_country_remote_feature_eligible(country: Country, currency: CurrencyCode) async throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) - setupCountry(country: country, currency: currency) - accountWhitelistedInBackend(true) - let checker = POSTabEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkVisibility() - - // Then - #expect(result == true) - } - - @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 { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) - setupCountry(country: country, currency: currency) - accountWhitelistedInBackend(true) - let checker = POSTabEligibilityChecker(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.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 { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) - setupCountry(country: country, currency: currency) - accountWhitelistedInBackend(false) - let checker = POSTabEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkVisibility() - - // Then - #expect(result == false) - } - - @Test func checkVisibility_returns_true_when_pointOfSaleAsATabi2_disabled_and_checkEligibility_eligible() async throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) - setupCountry(country: .us) accountWhitelistedInBackend(true) let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, @@ -475,109 +348,6 @@ struct POSTabEligibilityCheckerTests { // Then #expect(result == true) } - - @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 { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) - setupCountry(country: country, currency: currency) // Ineligible country/currency combination - accountWhitelistedInBackend(true) - let checker = POSTabEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkVisibility() - - // Then - #expect(result == false) - } - - @Test(arguments: [true, false]) - func checkVisibility_returns_false_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, // Not iPad - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkVisibility() - - // Then - #expect(result == false) - } - - @Test func checkEligibility_uses_cached_values_after_checkVisibility_when_i2_feature_is_enabled() async throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) - setupCountry(country: .us) - accountWhitelistedInBackend(true) - let checker = POSTabEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - 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() - - // Then - both should return the expected results, demonstrating caching works - #expect(visibilityResult == true) - #expect(eligibilityResult == .eligible) - } - - @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, - pluginsService: pluginsService, - 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) - } } private extension POSTabEligibilityCheckerTests { From b0059a6b010a61ae68ddf0e1149ac5cef3eacf5a Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Thu, 3 Jul 2025 16:03:04 -0400 Subject: [PATCH 05/15] Revert removal of a comment to minimize diffs. --- .../Dashboard/Settings/POS/POSTabEligibilityChecker.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift index 68eef8dfb2e..16c16ab800b 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift @@ -136,6 +136,7 @@ final class POSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol { } } + /// Checks the final visibility of the POS tab. func checkVisibility() async -> Bool { let eligibility = await checkI1Eligibility() return eligibility == .eligible From 9e611a751e38cb8c5aa14f7edefde3ae3e1f493a Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Thu, 3 Jul 2025 16:07:43 -0400 Subject: [PATCH 06/15] Revert changes to use `POSTabEligibilityCheckerI2` now that it is separated to another PR for easier review. --- .../ViewRelated/MainTabBarController.swift | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift index 422578be113..c9c52733387 100644 --- a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift +++ b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift @@ -149,29 +149,20 @@ final class MainTabBarController: UITabBarController { self.analytics = analytics self.stores = stores self.posEligibilityCheckerFactory = posEligibilityCheckerFactory ?? { siteID in - if featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi2) { - POSTabEligibilityCheckerI2(siteID: siteID) - } else { - POSTabEligibilityChecker(siteID: siteID) - } + POSTabEligibilityChecker(siteID: siteID) } self.posEligibilityService = posEligibilityService super.init(coder: coder) } required init?(coder: NSCoder) { - let featureFlagService = ServiceLocator.featureFlagService - self.featureFlagService = featureFlagService + self.featureFlagService = ServiceLocator.featureFlagService self.noticePresenter = ServiceLocator.noticePresenter self.productImageUploader = ServiceLocator.productImageUploader self.analytics = ServiceLocator.analytics self.stores = ServiceLocator.stores self.posEligibilityCheckerFactory = { siteID in - if featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi2) { - POSTabEligibilityCheckerI2(siteID: siteID) - } else { - POSTabEligibilityChecker(siteID: siteID) - } + POSTabEligibilityChecker(siteID: siteID) } self.posEligibilityService = POSEligibilityService() super.init(coder: coder) From 14afb3d97136174360a885f77c60048e1e24d332 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Thu, 3 Jul 2025 16:09:29 -0400 Subject: [PATCH 07/15] Remove i2 files for a separate PR. --- .../POS/POSTabEligibilityCheckerI2.swift | 263 ------ .../WooCommerce.xcodeproj/project.pbxproj | 8 - .../POS/POSTabEligibilityCheckerI2Tests.swift | 760 ------------------ 3 files changed, 1031 deletions(-) delete mode 100644 WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityCheckerI2.swift delete mode 100644 WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerI2Tests.swift diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityCheckerI2.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityCheckerI2.swift deleted file mode 100644 index b302fd3ff5c..00000000000 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityCheckerI2.swift +++ /dev/null @@ -1,263 +0,0 @@ -import Combine -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 - -final class POSTabEligibilityCheckerI2: POSEntryPointEligibilityCheckerProtocol { - private var siteSettingsEligibility: POSEligibilityState? - private var featureFlagEligibility: POSEligibilityState? - - 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 { - guard #available(iOS 17.0, *) else { - return .ineligible(reason: .unsupportedIOSVersion) - } - - async let siteSettingsEligibility = checkSiteSettingsEligibility() - async let pluginEligibility = checkPluginEligibility() - - switch await siteSettingsEligibility { - case .eligible: - break - case .ineligible(let reason): - return .ineligible(reason: reason) - } - - 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 { - guard userInterfaceIdiom == .pad else { - return false - } - - async let siteSettingsEligibility = waitAndCheckSiteSettingsEligibility() - async let featureFlagEligibility = checkRemoteFeatureEligibility() - - switch await siteSettingsEligibility { - case .ineligible(.unsupportedCountry): - return false - default: - break - } - - return await featureFlagEligibility == .eligible - } -} - -// MARK: - WC Plugin Related Eligibility Check - -private extension POSTabEligibilityCheckerI2 { - func checkPluginEligibility() async -> POSEligibilityState { - 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 -> POSEligibilityState { - 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 POSTabEligibilityCheckerI2 { - enum SiteSettingsEligibilityState { - case eligible - case ineligible(reason: SiteSettingsIneligibleReason) - } - - enum SiteSettingsIneligibleReason { - case siteSettingsNotAvailable - case unsupportedCountry(supportedCountries: [CountryCode]) - case unsupportedCurrency(supportedCurrencies: [CurrencyCode]) - } - - func checkSiteSettingsEligibility() async -> POSEligibilityState { - 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 { - 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) -> SiteSettingsEligibilityState { - 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 POSTabEligibilityCheckerI2 { - 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 - 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 POSTabEligibilityCheckerI2 { - enum Constants { - static let wcPluginName = "WooCommerce" - static let wcPluginMinimumVersion = "9.6.0-beta" - static let wcPluginMinimumVersionWithFeatureSwitch = "10.0.0" - } -} diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 35b423818a3..7ae9b94510c 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -295,8 +295,6 @@ 025678052575EA1B009D7E6C /* ProductDetailsCellViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025678042575EA1B009D7E6C /* ProductDetailsCellViewModelTests.swift */; }; 025678C125773236009D7E6C /* Collection+ShippingLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025678C025773236009D7E6C /* Collection+ShippingLabel.swift */; }; 025678C725773399009D7E6C /* Collection+ShippingLabelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025678C625773399009D7E6C /* Collection+ShippingLabelTests.swift */; }; - 0256DD0A2E1706B5002FB998 /* POSTabEligibilityCheckerI2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0256DD092E1706B2002FB998 /* POSTabEligibilityCheckerI2.swift */; }; - 0256DD0C2E170C46002FB998 /* POSTabEligibilityCheckerI2Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0256DD0B2E170C42002FB998 /* POSTabEligibilityCheckerI2Tests.swift */; }; 02577A7F2BFC4BB300B63FE6 /* PaymentMethodsWrapperHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02577A7E2BFC4BB300B63FE6 /* PaymentMethodsWrapperHostingController.swift */; }; 0258B4D82B1590A3008FEA07 /* ConfigurableBundleNoticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0258B4D72B1590A3008FEA07 /* ConfigurableBundleNoticeView.swift */; }; 0258B4DA2B159A0F008FEA07 /* Publisher+WithPrevious.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0258B4D92B159A0F008FEA07 /* Publisher+WithPrevious.swift */; }; @@ -3451,8 +3449,6 @@ 025678042575EA1B009D7E6C /* ProductDetailsCellViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailsCellViewModelTests.swift; sourceTree = ""; }; 025678C025773236009D7E6C /* Collection+ShippingLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+ShippingLabel.swift"; sourceTree = ""; }; 025678C625773399009D7E6C /* Collection+ShippingLabelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+ShippingLabelTests.swift"; sourceTree = ""; }; - 0256DD092E1706B2002FB998 /* POSTabEligibilityCheckerI2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSTabEligibilityCheckerI2.swift; sourceTree = ""; }; - 0256DD0B2E170C42002FB998 /* POSTabEligibilityCheckerI2Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSTabEligibilityCheckerI2Tests.swift; sourceTree = ""; }; 02577A7E2BFC4BB300B63FE6 /* PaymentMethodsWrapperHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentMethodsWrapperHostingController.swift; sourceTree = ""; }; 0258B4D72B1590A3008FEA07 /* ConfigurableBundleNoticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurableBundleNoticeView.swift; sourceTree = ""; }; 0258B4D92B159A0F008FEA07 /* Publisher+WithPrevious.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+WithPrevious.swift"; sourceTree = ""; }; @@ -6792,7 +6788,6 @@ children = ( 023BD58A2BFDCFCB00A10D7B /* POSEligibilityCheckerTests.swift */, 0277889D2DF928E3006F5B8C /* POSTabEligibilityCheckerTests.swift */, - 0256DD0B2E170C42002FB998 /* POSTabEligibilityCheckerI2Tests.swift */, ); path = POS; sourceTree = ""; @@ -7758,7 +7753,6 @@ 02E4A0842BFB1D1F006D4F87 /* POS */ = { isa = PBXGroup; children = ( - 0256DD092E1706B2002FB998 /* POSTabEligibilityCheckerI2.swift */, 026B2D162DF92290005B8CAA /* POSTabEligibilityChecker.swift */, 02E4A0822BFB1C4F006D4F87 /* POSEligibilityChecker.swift */, ); @@ -15957,7 +15951,6 @@ CE35F11B2343F3B1007B2A6B /* TwoColumnHeadlineFootnoteTableViewCell.swift in Sources */, B9D19A422AE7B4AD00D944D8 /* CustomAmountRowViewModel.swift in Sources */, D8C251DB230D288A00F49782 /* PushNotesManager.swift in Sources */, - 0256DD0A2E1706B5002FB998 /* POSTabEligibilityCheckerI2.swift in Sources */, 09468D9027D5014E0054A751 /* BulkUpdatePriceViewController.swift in Sources */, 024124842AC54C3D0035A247 /* ConfigurableBundleItemView.swift in Sources */, 0279F0DA252DB4BE0098D7DE /* ProductVariationDetailsFactory.swift in Sources */, @@ -17325,7 +17318,6 @@ 26B3EC622744772A0075EAE6 /* SimplePaymentsSummaryViewModelTests.swift in Sources */, D85DD1D7257F359800861AA8 /* NotWPErrorViewModelTests.swift in Sources */, DE3877E4283E35E80075D87E /* DiscountTypeBottomSheetListSelectorCommandTests.swift in Sources */, - 0256DD0C2E170C46002FB998 /* POSTabEligibilityCheckerI2Tests.swift in Sources */, 025678C725773399009D7E6C /* Collection+ShippingLabelTests.swift in Sources */, 02BC5AA624D27F8900C43326 /* ProductVariationFormViewModel+ChangesTests.swift in Sources */, 02CD3BFE2C35D04C00E575C4 /* MockCardPresentPaymentService.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerI2Tests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerI2Tests.swift deleted file mode 100644 index 07d07716f8d..00000000000 --- a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerI2Tests.swift +++ /dev/null @@ -1,760 +0,0 @@ -import Combine -import Foundation -import Testing -import WooFoundation -import Yosemite -@testable import WooCommerce - -@MainActor -struct POSTabEligibilityCheckerI2Tests { - 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() - } - - // 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) - ]) - fileprivate func is_visible_when_all_conditions_satisfied(country: Country, currency: CurrencyCode, isPointOfSaleAsATabi2Enabled: Bool) async throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) - setupCountry(country: country, currency: currency) - accountWhitelistedInBackend(true) - let checker = POSTabEligibilityCheckerI2(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkVisibility() - - // Then - #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) - ]) - fileprivate func is_invisible_when_country_is_not_supported(country: Country, currency: CurrencyCode, isPointOfSaleAsATabi2Enabled: Bool) async throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) - setupCountry(country: country, currency: currency) - accountWhitelistedInBackend(true) - let checker = POSTabEligibilityCheckerI2(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_for_i1(country: Country, currency: CurrencyCode) async throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) - setupCountry(country: country, currency: currency) - accountWhitelistedInBackend(true) - let checker = POSTabEligibilityCheckerI2(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_visible_when_currency_is_not_supported_for_i2(country: Country, currency: CurrencyCode) async throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) - setupCountry(country: country, currency: currency) - accountWhitelistedInBackend(true) - let checker = POSTabEligibilityCheckerI2(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkVisibility() - - // Then - #expect(result == true) - } - - func is_invisible_when_woocommerce_version_is_below_minimum_for_i1() async throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) - setupCountry(country: .us) - accountWhitelistedInBackend(true) - setupWooCommerceVersion("9.5.0") - let checker = POSTabEligibilityCheckerI2(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkVisibility() - - // Then - #expect(result == false) - } - - func is_visible_when_woocommerce_version_is_below_minimum_for_i2() async throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) - setupCountry(country: .us) - accountWhitelistedInBackend(true) - setupWooCommerceVersion("9.5.0") - let checker = POSTabEligibilityCheckerI2(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkVisibility() - - // Then - #expect(result == true) - } - - @Test(arguments: [true, false]) - func is_visible_when_core_version_is_10_0_0_and_POS_feature_enabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) - setupCountry(country: .us) - accountWhitelistedInBackend(true) - setupWooCommerceVersion("10.0.0") - setupPOSFeatureEnabled(.success(true)) - let checker = POSTabEligibilityCheckerI2(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkVisibility() - - // Then - #expect(result == true) - } - - func is_invisible_when_core_version_is_10_0_0_and_POS_feature_disabled_for_i1() async throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) - setupCountry(country: .us) - accountWhitelistedInBackend(true) - setupWooCommerceVersion("10.0.0") - setupPOSFeatureEnabled(.success(false)) - let checker = POSTabEligibilityCheckerI2(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkVisibility() - - // Then - #expect(result == false) - } - - func is_visible_when_core_version_is_10_0_0_and_POS_feature_disabled_for_i2() async throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) - setupCountry(country: .us) - accountWhitelistedInBackend(true) - setupWooCommerceVersion("10.0.0") - setupPOSFeatureEnabled(.success(false)) - let checker = POSTabEligibilityCheckerI2(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkVisibility() - - // Then - #expect(result == true) - } - - func is_invisible_when_core_version_is_10_0_0_and_POS_feature_check_fails_for_i1() 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 = POSTabEligibilityCheckerI2(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkVisibility() - - // Then - #expect(result == false) - } - - func is_visible_when_core_version_is_10_0_0_and_POS_feature_check_fails_for_i2() 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 = POSTabEligibilityCheckerI2(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkVisibility() - - // Then - #expect(result == true) - } - - @Test(arguments: [true, false]) - func is_visible_when_core_version_is_below_10_0_0_and_POS_feature_disabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) - setupCountry(country: .us) - accountWhitelistedInBackend(true) - setupWooCommerceVersion("9.9.9") - setupPOSFeatureEnabled(.success(false)) - let checker = POSTabEligibilityCheckerI2(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkVisibility() - - // Then - #expect(result == true) - } - - @Test(arguments: [true, false]) - func is_visible_when_site_settings_are_from_correct_siteID(isPointOfSaleAsATabi2Enabled: Bool) async throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) - - // 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 = POSTabEligibilityCheckerI2(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_invisible_when_remote_feature_flag_disabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) - setupCountry(country: .us) - accountWhitelistedInBackend(false) - let checker = POSTabEligibilityCheckerI2(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkVisibility() - - // Then - #expect(result == false) - } - - @Test(arguments: [true, false]) - func checkVisibility_skips_settings_from_initialLoad(isPointOfSaleAsATabi2Enabled: Bool) async throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) - - // 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 = POSTabEligibilityCheckerI2(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkVisibility() - - // Then - Should be invisible because fresh settings show CA (not cached US) - #expect(result == false) - } - - @Test(arguments: [true, false]) - func is_invisible_when_device_is_not_iPad(isPointOfSaleAsATabi2Enabled: Bool) async throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) - setupCountry(country: .us) - accountWhitelistedInBackend(true) - let checker = POSTabEligibilityCheckerI2(siteID: siteID, - userInterfaceIdiom: .phone, // Not iPad - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkVisibility() - - // 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 = POSTabEligibilityCheckerI2(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - 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 = POSTabEligibilityCheckerI2(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 = POSTabEligibilityCheckerI2(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 = POSTabEligibilityCheckerI2(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: [ - (country: Country.us, currency: CurrencyCode.USD), - (country: Country.gb, currency: CurrencyCode.GBP) - ]) - 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) - accountWhitelistedInBackend(true) - let checker = POSTabEligibilityCheckerI2(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkEligibility() - - // Then - #expect(result == .eligible) - } - - @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) - accountWhitelistedInBackend(true) - let checker = POSTabEligibilityCheckerI2(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkEligibility() - - // Then - #expect(result == .ineligible(reason: .siteSettingsNotAvailable)) - } - - @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(true) - let checker = POSTabEligibilityCheckerI2(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkEligibility() - - // Then - #expect(result == .ineligible(reason: .unsupportedCurrency(supportedCurrencies: expectedSupportedCurrencies))) - } - - func is_ineligible_when_woocommerce_version_is_below_minimum() async throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) - setupCountry(country: .us) - accountWhitelistedInBackend(true) - setupWooCommerceVersion("9.5.0") - let checker = POSTabEligibilityCheckerI2(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkEligibility() - - // Then - #expect(result == .ineligible(reason: .unsupportedWooCommerceVersion(minimumVersion: "9.6.0-beta"))) - } - - func is_eligible_when_core_version_is_10_0_0_and_POS_feature_enabled() async throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) - setupCountry(country: .us) - accountWhitelistedInBackend(true) - setupWooCommerceVersion("10.0.0") - setupPOSFeatureEnabled(.success(true)) - let checker = POSTabEligibilityCheckerI2(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkEligibility() - - // Then - #expect(result == .eligible) - } - - func is_ineligible_when_core_version_is_10_0_0_and_POS_feature_disabled() async throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) - setupCountry(country: .us) - accountWhitelistedInBackend(true) - setupWooCommerceVersion("10.0.0") - setupPOSFeatureEnabled(.success(false)) - let checker = POSTabEligibilityCheckerI2(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkEligibility() - - // Then - #expect(result == .ineligible(reason: .featureSwitchDisabled)) - } - - 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 = POSTabEligibilityCheckerI2(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkEligibility() - - // Then - #expect(result == .ineligible(reason: .featureSwitchSyncFailure)) - } - - 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) - setupWooCommerceVersion("9.9.9") - setupPOSFeatureEnabled(.success(false)) - let checker = POSTabEligibilityCheckerI2(siteID: siteID, - userInterfaceIdiom: .pad, - siteSettings: siteSettings, - pluginsService: pluginsService, - stores: stores, - featureFlagService: featureFlagService) - - // When - let result = await checker.checkEligibility() - - // Then - #expect(result == .eligible) - } -} - -private extension POSTabEligibilityCheckerI2Tests { - 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. - } -} From 10cbc7da20fc2d02fdb7b742a19162a2c1834067 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 4 Jul 2025 14:45:00 -0400 Subject: [PATCH 08/15] Duplicate `POSTabEligibilityChecker*` to `LegacyPOSTabEligibilityChecker*` for i1 implementation. --- .../POS/LegacyPOSTabEligibilityChecker.swift | 261 ++++++++++++++ .../WooCommerce.xcodeproj/project.pbxproj | 8 + .../LegacyPOSTabEligibilityCheckerTests.swift | 340 ++++++++++++++++++ 3 files changed, 609 insertions(+) create mode 100644 WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/LegacyPOSTabEligibilityChecker.swift create mode 100644 WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/LegacyPOSTabEligibilityCheckerTests.swift 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/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/ViewRelated/Settings/POS/LegacyPOSTabEligibilityCheckerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/LegacyPOSTabEligibilityCheckerTests.swift new file mode 100644 index 00000000000..cc62c3d9fb7 --- /dev/null +++ b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/LegacyPOSTabEligibilityCheckerTests.swift @@ -0,0 +1,340 @@ +import Combine +import WooFoundation +import XCTest +import Yosemite +@testable import WooCommerce + +final class LegacyPOSEligibilityCheckerTests: XCTestCase { + 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() + stores = MockStoresManager(sessionManager: .makeForTesting(authenticated: true)) + stores.updateDefaultStore(storeID: siteID) + setupWooCommerceVersion() + storageManager = MockStorageManager() + siteSettings = SelectedSiteSettings(stores: stores, storageManager: storageManager) + } + + override func tearDown() { + siteSettings = nil + storageManager = nil + stores = nil + super.tearDown() + } + + func test_is_eligible_when_all_conditions_satisfied_then_returns_true() throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + siteSettings: siteSettings, + currencySettings: Fixtures.usdCurrencySettings, + stores: stores, + featureFlagService: featureFlagService) + checker.isEligible.assign(to: &$isEligible) + + // Then + XCTAssertTrue(isEligible) + } + + func test_is_eligible_when_account_not_whitelisted_in_backend_and_enabled_via_local_feature_flag_then_returns_true() throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + setupCountry(country: .us) + accountWhitelistedInBackend(false) + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + siteSettings: siteSettings, + currencySettings: Fixtures.usdCurrencySettings, + stores: stores, + featureFlagService: featureFlagService) + checker.isEligible.assign(to: &$isEligible) + + // Then + XCTAssertTrue(isEligible) + } + + func test_is_eligible_when_account_not_whitelisted_in_backend_and_not_enabled_via_local_feature_flag_then_returns_false() throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: false) + setupCountry(country: .us) + accountWhitelistedInBackend(false) + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + siteSettings: siteSettings, + currencySettings: Fixtures.usdCurrencySettings, + stores: stores, + featureFlagService: featureFlagService) + checker.isEligible.assign(to: &$isEligible) + + // Then + XCTAssertFalse(isEligible) + } + + func test_is_eligible_when_non_iPad_device_then_returns_false() throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + setupCountry(country: .us) + [UIUserInterfaceIdiom.phone, UIUserInterfaceIdiom.mac, UIUserInterfaceIdiom.tv, UIUserInterfaceIdiom.carPlay] + .forEach { userInterfaceIdiom in + let checker = POSEligibilityChecker(userInterfaceIdiom: userInterfaceIdiom, + siteSettings: siteSettings, + currencySettings: Fixtures.usdCurrencySettings, + stores: stores, + featureFlagService: featureFlagService) + checker.isEligible.assign(to: &$isEligible) + + // Then + XCTAssertFalse(isEligible) + } + } + + func test_is_eligible_when_non_us_site_then_returns_false() { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + [Country.ca, Country.es, Country.gb].forEach { country in + // When + setupCountry(country: country) + accountWhitelistedInBackend(true) + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + siteSettings: siteSettings, + currencySettings: Fixtures.usdCurrencySettings, + stores: stores, + featureFlagService: featureFlagService) + checker.isEligible.assign(to: &$isEligible) + + // Then + XCTAssertFalse(isEligible) + } + } + + func test_when_non_usd_currency_then_isEligible_returns_false() { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + siteSettings: siteSettings, + currencySettings: Fixtures.nonUSDCurrencySettings, + stores: stores, + featureFlagService: featureFlagService) + checker.isEligible.assign(to: &$isEligible) + + // Then + XCTAssertFalse(isEligible) + } + + func test_is_eligible_when_feature_flag_is_disabled_then_returns_false() throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: false) + setupCountry(country: .us) + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + siteSettings: siteSettings, + currencySettings: Fixtures.usdCurrencySettings, + stores: stores, + featureFlagService: featureFlagService) + checker.isEligible.assign(to: &$isEligible) + + // Then + XCTAssertFalse(isEligible) + } + + func test_is_eligible_when_WooCommerce_version_is_below_9_6_then_returns_false() throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + setupCountry(country: .us) + + // Unsupported WooCommerce version + setupWooCommerceVersion("9.5.2") + + // When + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + siteSettings: siteSettings, + currencySettings: Fixtures.usdCurrencySettings, + stores: stores, + featureFlagService: featureFlagService) + checker.isEligible.assign(to: &$isEligible) + + // Then + XCTAssertFalse(isEligible) + } + + func test_is_eligible_when_WooCommerce_version_is_at_least_9_6_then_returns_true() throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + + // Supported WooCommerce version + setupWooCommerceVersion("9.6.0-beta1") + + // When + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + siteSettings: siteSettings, + currencySettings: Fixtures.usdCurrencySettings, + stores: stores, + featureFlagService: featureFlagService) + checker.isEligible.assign(to: &$isEligible) + + // Then + XCTAssertTrue(isEligible) + } + + func test_is_eligible_when_core_version_is_10_0_0_and_POS_feature_enabled_then_returns_true() throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + + // WC version 10.0.0 with POS feature enabled. + setupWooCommerceVersion("10.0.0") + setupPOSFeatureEnabled(.success(true)) + + // When + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + siteSettings: siteSettings, + currencySettings: Fixtures.usdCurrencySettings, + stores: stores, + featureFlagService: featureFlagService) + checker.isEligible.assign(to: &$isEligible) + + // Then + XCTAssertTrue(isEligible) + } + + func test_is_eligible_when_core_version_is_10_0_0_and_POS_feature_disabled_then_returns_false() throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + + // WC version 10.0.0 with POS feature disabled. + setupWooCommerceVersion("10.0.0") + setupPOSFeatureEnabled(.success(false)) + + // When + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + siteSettings: siteSettings, + currencySettings: Fixtures.usdCurrencySettings, + stores: stores, + featureFlagService: featureFlagService) + checker.isEligible.assign(to: &$isEligible) + + // Then + XCTAssertFalse(isEligible) + } + + func test_is_eligible_when_core_version_is_10_0_0_and_POS_feature_check_fails_then_returns_false() throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + + // WC version 10.0.0 with POS feature check failing. + setupWooCommerceVersion("10.0.0") + setupPOSFeatureEnabled(.failure(NSError(domain: "test", code: 0))) + + // When + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + siteSettings: siteSettings, + currencySettings: Fixtures.usdCurrencySettings, + stores: stores, + featureFlagService: featureFlagService) + checker.isEligible.assign(to: &$isEligible) + + // Then + XCTAssertFalse(isEligible) + } + + func test_is_eligible_when_core_version_is_smaller_than_10_0_0_and_POS_feature_disabled_then_returns_true() throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + + // WC version < 10.0.0 with POS feature disabled. + setupWooCommerceVersion("9.9.9") + setupPOSFeatureEnabled(.success(false)) + + // When + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + siteSettings: siteSettings, + currencySettings: Fixtures.usdCurrencySettings, + stores: stores, + featureFlagService: featureFlagService) + checker.isEligible.assign(to: &$isEligible) + + // Then + XCTAssertTrue(isEligible) + } +} + +private extension LegacyPOSEligibilityCheckerTests { + 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() + } + + func setupWooCommerceVersion(_ version: String = "9.6.0-beta") { + stores.whenReceivingAction(ofType: SystemStatusAction.self) { action in + switch action { + case .fetchSystemPlugin(_, _, let completion): + completion(SystemPlugin.fake().copy(name: "WooCommerce", version: version, active: true)) + default: + break + } + } + } + + func setupPOSFeatureEnabled(_ result: Result) { + stores.whenReceivingAction(ofType: SettingAction.self) { action in + switch action { + case .isFeatureEnabled(_, _, let completion): + completion(result) + default: + break + } + } + } + + func accountWhitelistedInBackend(_ isAllowed: Bool = false) { + stores.whenReceivingAction(ofType: FeatureFlagAction.self) { action in + switch action { + case .isRemoteFeatureFlagEnabled(_, _, completion: let completion): + completion(isAllowed) + } + } + } + + 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" + } +} From 20ecee24d6946cb1922471f95dbc989026a77a17 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 4 Jul 2025 14:45:21 -0400 Subject: [PATCH 09/15] Revert "Revert changes to use `POSTabEligibilityCheckerI2` now that it is separated to another PR for easier review." This reverts commit 9e611a751e38cb8c5aa14f7edefde3ae3e1f493a. --- .../ViewRelated/MainTabBarController.swift | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift index c9c52733387..422578be113 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) { + POSTabEligibilityCheckerI2(siteID: siteID) + } else { + POSTabEligibilityChecker(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) { + POSTabEligibilityCheckerI2(siteID: siteID) + } else { + POSTabEligibilityChecker(siteID: siteID) + } } self.posEligibilityService = POSEligibilityService() super.init(coder: coder) From 2bf4da7b646ab713d0302c66ab0856578464fe86 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 4 Jul 2025 14:46:23 -0400 Subject: [PATCH 10/15] Use `POSTabEligibilityChecker` for i2 and `LegacyPOSTabEligibilityChecker` for i1. --- .../Classes/ViewRelated/MainTabBarController.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift index 422578be113..2a51544c3e4 100644 --- a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift +++ b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift @@ -150,9 +150,9 @@ final class MainTabBarController: UITabBarController { self.stores = stores self.posEligibilityCheckerFactory = posEligibilityCheckerFactory ?? { siteID in if featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi2) { - POSTabEligibilityCheckerI2(siteID: siteID) - } else { POSTabEligibilityChecker(siteID: siteID) + } else { + LegacyPOSTabEligibilityChecker(siteID: siteID) } } self.posEligibilityService = posEligibilityService @@ -168,9 +168,9 @@ final class MainTabBarController: UITabBarController { self.stores = ServiceLocator.stores self.posEligibilityCheckerFactory = { siteID in if featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi2) { - POSTabEligibilityCheckerI2(siteID: siteID) - } else { POSTabEligibilityChecker(siteID: siteID) + } else { + LegacyPOSTabEligibilityChecker(siteID: siteID) } } self.posEligibilityService = POSEligibilityService() From a9c8b9a7e712955c7347209f198450b423eb3521 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 4 Jul 2025 14:53:16 -0400 Subject: [PATCH 11/15] Fix incorrect implementation of `LegacyPOSTabEligibilityCheckerTests`. --- .../LegacyPOSTabEligibilityCheckerTests.swift | 521 +++++++++++------- 1 file changed, 313 insertions(+), 208 deletions(-) diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/LegacyPOSTabEligibilityCheckerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/LegacyPOSTabEligibilityCheckerTests.swift index cc62c3d9fb7..029ea9da348 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/LegacyPOSTabEligibilityCheckerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/LegacyPOSTabEligibilityCheckerTests.swift @@ -1,299 +1,381 @@ import Combine +import Foundation +import Testing import WooFoundation -import XCTest import Yosemite @testable import WooCommerce -final class LegacyPOSEligibilityCheckerTests: XCTestCase { +@MainActor +struct LegacyPOSTabEligibilityCheckerTests { private var stores: MockStoresManager! private var storageManager: MockStorageManager! - private var siteSettings: SelectedSiteSettings! - @Published private var isEligible: Bool = false - + private var siteSettings: MockSelectedSiteSettings! + private var pluginsService: MockPluginsService! + private var eligibilityService: MockPOSEligibilityService! private let siteID: Int64 = 2 - override func setUp() { - super.setUp() + init() async throws { stores = MockStoresManager(sessionManager: .makeForTesting(authenticated: true)) stores.updateDefaultStore(storeID: siteID) - setupWooCommerceVersion() storageManager = MockStorageManager() - siteSettings = SelectedSiteSettings(stores: stores, storageManager: storageManager) - } - - override func tearDown() { - siteSettings = nil - storageManager = nil - stores = nil - super.tearDown() + pluginsService = MockPluginsService() + eligibilityService = MockPOSEligibilityService() + setupWooCommerceVersion() + siteSettings = MockSelectedSiteSettings() } - func test_is_eligible_when_all_conditions_satisfied_then_returns_true() throws { + @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(isPointOfSaleEnabled: true) - setupCountry(country: .us) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + setupCountry(country: country, currency: currency) accountWhitelistedInBackend(true) - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, - siteSettings: siteSettings, - currencySettings: Fixtures.usdCurrencySettings, - stores: stores, - featureFlagService: featureFlagService) - checker.isEligible.assign(to: &$isEligible) + let checker = POSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() // Then - XCTAssertTrue(isEligible) + #expect(result == true) } - func test_is_eligible_when_account_not_whitelisted_in_backend_and_enabled_via_local_feature_flag_then_returns_true() throws { + @Test func is_invisible_when_account_not_whitelisted_and_feature_flag_disabled() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) setupCountry(country: .us) accountWhitelistedInBackend(false) - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, - siteSettings: siteSettings, - currencySettings: Fixtures.usdCurrencySettings, - stores: stores, - featureFlagService: featureFlagService) - checker.isEligible.assign(to: &$isEligible) + let checker = POSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() // Then - XCTAssertTrue(isEligible) + #expect(result == false) } - func test_is_eligible_when_account_not_whitelisted_in_backend_and_not_enabled_via_local_feature_flag_then_returns_false() throws { + @Test func is_invisible_when_device_is_not_iPad() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: false) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) setupCountry(country: .us) - accountWhitelistedInBackend(false) - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, - siteSettings: siteSettings, - currencySettings: Fixtures.usdCurrencySettings, - stores: stores, - featureFlagService: featureFlagService) - checker.isEligible.assign(to: &$isEligible) + accountWhitelistedInBackend(true) + let checker = POSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .phone, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() // Then - XCTAssertFalse(isEligible) + #expect(result == false) } - func test_is_eligible_when_non_iPad_device_then_returns_false() throws { + @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(isPointOfSaleEnabled: true) - setupCountry(country: .us) - [UIUserInterfaceIdiom.phone, UIUserInterfaceIdiom.mac, UIUserInterfaceIdiom.tv, UIUserInterfaceIdiom.carPlay] - .forEach { userInterfaceIdiom in - let checker = POSEligibilityChecker(userInterfaceIdiom: userInterfaceIdiom, - siteSettings: siteSettings, - currencySettings: Fixtures.usdCurrencySettings, - stores: stores, - featureFlagService: featureFlagService) - checker.isEligible.assign(to: &$isEligible) - - // Then - XCTAssertFalse(isEligible) - } - } + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + setupCountry(country: country, currency: currency) + accountWhitelistedInBackend(true) + let checker = POSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) - func test_is_eligible_when_non_us_site_then_returns_false() { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) - [Country.ca, Country.es, Country.gb].forEach { country in - // When - setupCountry(country: country) - accountWhitelistedInBackend(true) - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, - siteSettings: siteSettings, - currencySettings: Fixtures.usdCurrencySettings, - stores: stores, - featureFlagService: featureFlagService) - checker.isEligible.assign(to: &$isEligible) - - // Then - XCTAssertFalse(isEligible) - } + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == false) } - func test_when_non_usd_currency_then_isEligible_returns_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(isPointOfSaleEnabled: true) - setupCountry(country: .us) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + setupCountry(country: country, currency: currency) accountWhitelistedInBackend(true) - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, - siteSettings: siteSettings, - currencySettings: Fixtures.nonUSDCurrencySettings, - stores: stores, - featureFlagService: featureFlagService) - checker.isEligible.assign(to: &$isEligible) + let checker = POSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() // Then - XCTAssertFalse(isEligible) + #expect(result == false) } - func test_is_eligible_when_feature_flag_is_disabled_then_returns_false() throws { + @Test func is_invisible_when_woocommerce_version_is_below_minimum() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: false) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) setupCountry(country: .us) - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, - siteSettings: siteSettings, - currencySettings: Fixtures.usdCurrencySettings, - stores: stores, - featureFlagService: featureFlagService) - checker.isEligible.assign(to: &$isEligible) + accountWhitelistedInBackend(true) + setupWooCommerceVersion("9.5.0") + let checker = POSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() // Then - XCTAssertFalse(isEligible) + #expect(result == false) } - func test_is_eligible_when_WooCommerce_version_is_below_9_6_then_returns_false() throws { + @Test func is_visible_when_core_version_is_10_0_0_and_POS_feature_enabled() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) setupCountry(country: .us) - - // Unsupported WooCommerce version - setupWooCommerceVersion("9.5.2") + accountWhitelistedInBackend(true) + setupWooCommerceVersion("10.0.0") + setupPOSFeatureEnabled(.success(true)) + let checker = POSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) // When - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, - siteSettings: siteSettings, - currencySettings: Fixtures.usdCurrencySettings, - stores: stores, - featureFlagService: featureFlagService) - checker.isEligible.assign(to: &$isEligible) + let result = await checker.checkVisibility() // Then - XCTAssertFalse(isEligible) + #expect(result == true) } - func test_is_eligible_when_WooCommerce_version_is_at_least_9_6_then_returns_true() throws { + @Test func is_invisible_when_core_version_is_10_0_0_and_POS_feature_disabled() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) setupCountry(country: .us) accountWhitelistedInBackend(true) - - // Supported WooCommerce version - setupWooCommerceVersion("9.6.0-beta1") + setupWooCommerceVersion("10.0.0") + setupPOSFeatureEnabled(.success(false)) + let checker = POSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) // When - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, - siteSettings: siteSettings, - currencySettings: Fixtures.usdCurrencySettings, - stores: stores, - featureFlagService: featureFlagService) - checker.isEligible.assign(to: &$isEligible) + let result = await checker.checkVisibility() // Then - XCTAssertTrue(isEligible) + #expect(result == false) } - func test_is_eligible_when_core_version_is_10_0_0_and_POS_feature_enabled_then_returns_true() throws { + @Test func is_invisible_when_core_version_is_10_0_0_and_POS_feature_check_fails() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) setupCountry(country: .us) accountWhitelistedInBackend(true) - - // WC version 10.0.0 with POS feature enabled. setupWooCommerceVersion("10.0.0") - setupPOSFeatureEnabled(.success(true)) + setupPOSFeatureEnabled(.failure(NSError(domain: "test", code: 0))) + let checker = POSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) // When - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, - siteSettings: siteSettings, - currencySettings: Fixtures.usdCurrencySettings, - stores: stores, - featureFlagService: featureFlagService) - checker.isEligible.assign(to: &$isEligible) + let result = await checker.checkVisibility() // Then - XCTAssertTrue(isEligible) + #expect(result == false) } - func test_is_eligible_when_core_version_is_10_0_0_and_POS_feature_disabled_then_returns_false() throws { + @Test func is_visible_when_core_version_is_below_10_0_0_and_POS_feature_disabled() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) setupCountry(country: .us) accountWhitelistedInBackend(true) - - // WC version 10.0.0 with POS feature disabled. - setupWooCommerceVersion("10.0.0") + setupWooCommerceVersion("9.9.9") setupPOSFeatureEnabled(.success(false)) + let checker = POSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) // When - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, - siteSettings: siteSettings, - currencySettings: Fixtures.usdCurrencySettings, - stores: stores, - featureFlagService: featureFlagService) - checker.isEligible.assign(to: &$isEligible) + let result = await checker.checkVisibility() // Then - XCTAssertFalse(isEligible) + #expect(result == true) } - func test_is_eligible_when_core_version_is_10_0_0_and_POS_feature_check_fails_then_returns_false() throws { + @Test func checkInitialVisibility_returns_true_when_cached_tab_visibility_is_enabled() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) - setupCountry(country: .us) - accountWhitelistedInBackend(true) + let checker = POSTabEligibilityChecker(siteID: siteID, eligibilityService: eligibilityService, stores: stores) + setupPOSTabVisibility(siteID: siteID, isVisible: true) - // WC version 10.0.0 with POS feature check failing. - setupWooCommerceVersion("10.0.0") - setupPOSFeatureEnabled(.failure(NSError(domain: "test", code: 0))) + // 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 = POSTabEligibilityChecker(siteID: siteID, eligibilityService: eligibilityService, stores: stores) + setupPOSTabVisibility(siteID: siteID, isVisible: false) // When - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, - siteSettings: siteSettings, - currencySettings: Fixtures.usdCurrencySettings, - stores: stores, - featureFlagService: featureFlagService) - checker.isEligible.assign(to: &$isEligible) + let result = checker.checkInitialVisibility() // Then - XCTAssertFalse(isEligible) + #expect(result == false) } - func test_is_eligible_when_core_version_is_smaller_than_10_0_0_and_POS_feature_disabled_then_returns_true() throws { + @Test func checkInitialVisibility_returns_false_when_cached_tab_visibility_is_unavailable() async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) - setupCountry(country: .us) + let checker = POSTabEligibilityChecker(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 = POSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) - // WC version < 10.0.0 with POS feature disabled. - setupWooCommerceVersion("9.9.9") - setupPOSFeatureEnabled(.success(false)) + // 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 = POSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) // When - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, - siteSettings: siteSettings, - currencySettings: Fixtures.usdCurrencySettings, - stores: stores, - featureFlagService: featureFlagService) - checker.isEligible.assign(to: &$isEligible) + let result = await checker.checkVisibility() // Then - XCTAssertTrue(isEligible) + #expect(result == true) } } -private extension LegacyPOSEligibilityCheckerTests { - 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() +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") { - stores.whenReceivingAction(ofType: SystemStatusAction.self) { action in + 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 .fetchSystemPlugin(_, _, let completion): - completion(SystemPlugin.fake().copy(name: "WooCommerce", version: version, active: true)) - default: - break + case .isRemoteFeatureFlagEnabled(_, _, completion: let completion): + completion(isAllowed) } } } @@ -309,26 +391,8 @@ private extension LegacyPOSEligibilityCheckerTests { } } - func accountWhitelistedInBackend(_ isAllowed: Bool = false) { - stores.whenReceivingAction(ofType: FeatureFlagAction.self) { action in - switch action { - case .isRemoteFeatureFlagEnabled(_, _, completion: let completion): - completion(isAllowed) - } - } - } - - 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) + func setupPOSTabVisibility(siteID: Int64, isVisible: Bool?) { + eligibilityService.cachedTabVisibility[siteID] = isVisible } enum Country: String { @@ -337,4 +401,45 @@ private extension LegacyPOSEligibilityCheckerTests { 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. + } } From f3b84c87f5822b08dffe3d97c89d728d2743d484 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 4 Jul 2025 14:55:03 -0400 Subject: [PATCH 12/15] Use `LegacyPOSTabEligibilityChecker` in `LegacyPOSTabEligibilityCheckerTests`. --- .../LegacyPOSTabEligibilityCheckerTests.swift | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/LegacyPOSTabEligibilityCheckerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/LegacyPOSTabEligibilityCheckerTests.swift index 029ea9da348..a5e2d58d586 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/LegacyPOSTabEligibilityCheckerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/LegacyPOSTabEligibilityCheckerTests.swift @@ -33,7 +33,7 @@ struct LegacyPOSTabEligibilityCheckerTests { let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) setupCountry(country: country, currency: currency) accountWhitelistedInBackend(true) - let checker = POSTabEligibilityChecker(siteID: siteID, + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, pluginsService: pluginsService, @@ -52,7 +52,7 @@ struct LegacyPOSTabEligibilityCheckerTests { let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) setupCountry(country: .us) accountWhitelistedInBackend(false) - let checker = POSTabEligibilityChecker(siteID: siteID, + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, pluginsService: pluginsService, @@ -71,7 +71,7 @@ struct LegacyPOSTabEligibilityCheckerTests { let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) setupCountry(country: .us) accountWhitelistedInBackend(true) - let checker = POSTabEligibilityChecker(siteID: siteID, + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .phone, siteSettings: siteSettings, pluginsService: pluginsService, @@ -94,7 +94,7 @@ struct LegacyPOSTabEligibilityCheckerTests { let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) setupCountry(country: country, currency: currency) accountWhitelistedInBackend(true) - let checker = POSTabEligibilityChecker(siteID: siteID, + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, pluginsService: pluginsService, @@ -120,7 +120,7 @@ struct LegacyPOSTabEligibilityCheckerTests { let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) setupCountry(country: country, currency: currency) accountWhitelistedInBackend(true) - let checker = POSTabEligibilityChecker(siteID: siteID, + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, pluginsService: pluginsService, @@ -140,7 +140,7 @@ struct LegacyPOSTabEligibilityCheckerTests { setupCountry(country: .us) accountWhitelistedInBackend(true) setupWooCommerceVersion("9.5.0") - let checker = POSTabEligibilityChecker(siteID: siteID, + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, pluginsService: pluginsService, @@ -161,7 +161,7 @@ struct LegacyPOSTabEligibilityCheckerTests { accountWhitelistedInBackend(true) setupWooCommerceVersion("10.0.0") setupPOSFeatureEnabled(.success(true)) - let checker = POSTabEligibilityChecker(siteID: siteID, + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, pluginsService: pluginsService, @@ -182,7 +182,7 @@ struct LegacyPOSTabEligibilityCheckerTests { accountWhitelistedInBackend(true) setupWooCommerceVersion("10.0.0") setupPOSFeatureEnabled(.success(false)) - let checker = POSTabEligibilityChecker(siteID: siteID, + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, pluginsService: pluginsService, @@ -203,7 +203,7 @@ struct LegacyPOSTabEligibilityCheckerTests { accountWhitelistedInBackend(true) setupWooCommerceVersion("10.0.0") setupPOSFeatureEnabled(.failure(NSError(domain: "test", code: 0))) - let checker = POSTabEligibilityChecker(siteID: siteID, + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, pluginsService: pluginsService, @@ -224,7 +224,7 @@ struct LegacyPOSTabEligibilityCheckerTests { accountWhitelistedInBackend(true) setupWooCommerceVersion("9.9.9") setupPOSFeatureEnabled(.success(false)) - let checker = POSTabEligibilityChecker(siteID: siteID, + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, pluginsService: pluginsService, @@ -240,7 +240,7 @@ struct LegacyPOSTabEligibilityCheckerTests { @Test func checkInitialVisibility_returns_true_when_cached_tab_visibility_is_enabled() async throws { // Given - let checker = POSTabEligibilityChecker(siteID: siteID, eligibilityService: eligibilityService, stores: stores) + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, eligibilityService: eligibilityService, stores: stores) setupPOSTabVisibility(siteID: siteID, isVisible: true) // When @@ -252,7 +252,7 @@ struct LegacyPOSTabEligibilityCheckerTests { @Test func checkInitialVisibility_returns_false_when_cached_tab_visibility_is_disabled() async throws { // Given - let checker = POSTabEligibilityChecker(siteID: siteID, eligibilityService: eligibilityService, stores: stores) + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, eligibilityService: eligibilityService, stores: stores) setupPOSTabVisibility(siteID: siteID, isVisible: false) // When @@ -264,7 +264,7 @@ struct LegacyPOSTabEligibilityCheckerTests { @Test func checkInitialVisibility_returns_false_when_cached_tab_visibility_is_unavailable() async throws { // Given - let checker = POSTabEligibilityChecker(siteID: siteID, eligibilityService: eligibilityService, stores: stores) + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, eligibilityService: eligibilityService, stores: stores) setupPOSTabVisibility(siteID: siteID, isVisible: nil) // When @@ -296,7 +296,7 @@ struct LegacyPOSTabEligibilityCheckerTests { ].publisher.eraseToAnyPublisher() accountWhitelistedInBackend(true) - let checker = POSTabEligibilityChecker(siteID: siteID, + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, pluginsService: pluginsService, @@ -335,7 +335,7 @@ struct LegacyPOSTabEligibilityCheckerTests { ].publisher.eraseToAnyPublisher() accountWhitelistedInBackend(true) - let checker = POSTabEligibilityChecker(siteID: siteID, + let checker = LegacyPOSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, siteSettings: siteSettings, pluginsService: pluginsService, From e3f2e946e062f3d643f0e355664e7554965ac19d Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 4 Jul 2025 14:55:51 -0400 Subject: [PATCH 13/15] Revert `POSTabEligibilityChecker` changes to be mostly the same as trunk. Will be modified to i2 in the next PR. --- .../POS/TabBar/POSIneligibleView.swift | 45 +++++++++ .../POS/POSTabEligibilityChecker.swift | 91 ++++++++++++------- 2 files changed, 101 insertions(+), 35 deletions(-) diff --git a/WooCommerce/Classes/POS/TabBar/POSIneligibleView.swift b/WooCommerce/Classes/POS/TabBar/POSIneligibleView.swift index a44fb0412ed..e91cac78c45 100644 --- a/WooCommerce/Classes/POS/TabBar/POSIneligibleView.swift +++ b/WooCommerce/Classes/POS/TabBar/POSIneligibleView.swift @@ -77,6 +77,10 @@ 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.", @@ -101,6 +105,16 @@ 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) @@ -116,6 +130,10 @@ 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.", @@ -158,6 +176,24 @@ 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( @@ -176,6 +212,15 @@ 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 16c16ab800b..653d977aa01 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift @@ -17,12 +17,15 @@ 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 } @@ -42,29 +45,10 @@ protocol POSEntryPointEligibilityCheckerProtocol { func checkEligibility() async -> POSEligibilityState } -/// 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 POSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol { + private var siteSettingsEligibility: POSEligibilityState? + private var featureFlagEligibility: POSEligibilityState? + private let siteID: Int64 private let userInterfaceIdiom: UIUserInterfaceIdiom private let siteSettings: SelectedSiteSettingsProtocol @@ -96,10 +80,6 @@ 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 { - .eligible - } - - private func checkI1Eligibility() async -> LegacyPOSEligibilityState { switch checkDeviceEligibility() { case .eligible: break @@ -138,13 +118,17 @@ final class POSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol { /// Checks the final visibility of the POS tab. func checkVisibility() async -> Bool { - let eligibility = await checkI1Eligibility() - return eligibility == .eligible + if featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi2) { + return await checkVisibilityBasedOnCountryAndRemoteFeatureFlag() + } else { + let eligibility = await checkEligibility() + return eligibility == .eligible + } } } private extension POSTabEligibilityChecker { - func checkDeviceEligibility() -> LegacyPOSEligibilityState { + func checkDeviceEligibility() -> POSEligibilityState { guard #available(iOS 17.0, *) else { return .ineligible(reason: .unsupportedIOSVersion) } @@ -155,12 +139,41 @@ private extension POSTabEligibilityChecker { return .eligible } + + func checkVisibilityBasedOnCountryAndRemoteFeatureFlag() async -> Bool { + guard checkDeviceEligibility() == .eligible else { + return false + } + + async let siteSettingsEligibility = checkSiteSettingsEligibility() + async let featureFlagEligibility = checkRemoteFeatureEligibility() + + self.siteSettingsEligibility = await siteSettingsEligibility + switch await siteSettingsEligibility { + case .eligible: + 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 + } + } } // MARK: - WC Plugin Related Eligibility Check private extension POSTabEligibilityChecker { - func checkPluginEligibility() async -> LegacyPOSEligibilityState { + func checkPluginEligibility() async -> POSEligibilityState { let wcPlugin = await fetchWooCommercePlugin(siteID: siteID) guard VersionHelpers.isVersionSupported(version: wcPlugin.version, @@ -186,7 +199,7 @@ private extension POSTabEligibilityChecker { } @MainActor - func checkFeatureSwitchEnabled(siteID: Int64) async -> LegacyPOSEligibilityState { + func checkFeatureSwitchEnabled(siteID: Int64) async -> POSEligibilityState { await withCheckedContinuation { [weak self] continuation in guard let self else { return continuation.resume(returning: .ineligible(reason: .selfDeallocated)) @@ -207,7 +220,11 @@ private extension POSTabEligibilityChecker { // MARK: - Site Settings Related Eligibility Check private extension POSTabEligibilityChecker { - func checkSiteSettingsEligibility() async -> LegacyPOSEligibilityState { + func checkSiteSettingsEligibility() async -> POSEligibilityState { + if let siteSettingsEligibility { + return siteSettingsEligibility + } + // Waits for the first site settings that matches the given site ID. let siteSettings = await waitForSiteSettingsRefresh() guard siteSettings.isNotEmpty else { @@ -232,7 +249,7 @@ private extension POSTabEligibilityChecker { return [] } - func isEligibleFromCountryAndCurrencyCode(countryCode: CountryCode, currencyCode: CurrencyCode) -> LegacyPOSEligibilityState { + func isEligibleFromCountryAndCurrencyCode(countryCode: CountryCode, currencyCode: CurrencyCode) -> POSEligibilityState { let supportedCountries: [CountryCode] = [.US, .GB] let supportedCurrencies: [CountryCode: [CurrencyCode]] = [.US: [.USD], .GB: [.GBP]] @@ -254,10 +271,14 @@ private extension POSTabEligibilityChecker { private extension POSTabEligibilityChecker { @MainActor - func checkRemoteFeatureEligibility() async -> LegacyPOSEligibilityState { + func checkRemoteFeatureEligibility() async -> POSEligibilityState { + if let featureFlagEligibility { + return featureFlagEligibility + } + // 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 + return await withCheckedContinuation { [weak self] continuation in guard let self else { return continuation.resume(returning: .ineligible(reason: .selfDeallocated)) } From 24412fc556c31c247a81a876fc593fbe88c64ae7 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 4 Jul 2025 15:01:29 -0400 Subject: [PATCH 14/15] Rename test case name to hopefully make it more readable. --- .../POS/Controllers/POSEntryPointControllerTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WooCommerce/WooCommerceTests/POS/Controllers/POSEntryPointControllerTests.swift b/WooCommerce/WooCommerceTests/POS/Controllers/POSEntryPointControllerTests.swift index 513fc305018..c1e3c8550ce 100644 --- a/WooCommerce/WooCommerceTests/POS/Controllers/POSEntryPointControllerTests.swift +++ b/WooCommerce/WooCommerceTests/POS/Controllers/POSEntryPointControllerTests.swift @@ -3,7 +3,7 @@ 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: .unsupportedIOSVersion) From 2a024c3cd4aac874e26f6dbc9abf0d7225eeb987 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 4 Jul 2025 15:11:06 -0400 Subject: [PATCH 15/15] POSTabEligibilityCheckerTests: copy content from trunk to branch. --- .../POS/POSTabEligibilityCheckerTests.swift | 348 +++++++++++++++--- 1 file changed, 289 insertions(+), 59 deletions(-) diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerTests.swift index 552dcdece63..9706b1319b2 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerTests.swift @@ -24,14 +24,11 @@ struct POSTabEligibilityCheckerTests { 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 { + @Test(arguments: [true, false]) + func is_eligible_when_all_conditions_satisfied(isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) - setupCountry(country: country, currency: currency) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + setupCountry(country: .us) accountWhitelistedInBackend(true) let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, @@ -41,15 +38,16 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() // Then - #expect(result == true) + #expect(result == .eligible) } - @Test func is_invisible_when_account_not_whitelisted_and_feature_flag_disabled() async throws { + @Test(arguments: [true, false]) + func is_ineligible_when_account_not_whitelisted_and_feature_flag_disabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) setupCountry(country: .us) accountWhitelistedInBackend(false) let checker = POSTabEligibilityChecker(siteID: siteID, @@ -60,15 +58,16 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() // Then - #expect(result == false) + #expect(result == .ineligible(reason: .featureFlagDisabled)) } - @Test func is_invisible_when_device_is_not_iPad() async throws { + @Test(arguments: [true, false]) + func is_ineligible_when_device_is_not_iPad(isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) setupCountry(country: .us) accountWhitelistedInBackend(true) let checker = POSTabEligibilityChecker(siteID: siteID, @@ -79,19 +78,21 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() // Then - #expect(result == false) + #expect(result == .ineligible(reason: .notTablet)) } @Test(arguments: [ - (country: Country.ca, currency: CurrencyCode.CAD), - (country: Country.es, currency: CurrencyCode.EUR) + (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) ]) - fileprivate func is_invisible_when_country_is_not_supported(country: Country, currency: CurrencyCode) async throws { + fileprivate func is_eligible_when_country_and_currency_supported(country: Country, currency: CurrencyCode, isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) setupCountry(country: country, currency: currency) accountWhitelistedInBackend(true) let checker = POSTabEligibilityChecker(siteID: siteID, @@ -102,22 +103,21 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() // Then - #expect(result == false) + #expect(result == .eligible) } @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) + (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) ]) - fileprivate func is_invisible_when_currency_is_not_supported(country: Country, - currency: CurrencyCode) async throws { + fileprivate func is_ineligible_when_country_is_not_supported(country: Country, currency: CurrencyCode, isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) setupCountry(country: country, currency: currency) accountWhitelistedInBackend(true) let checker = POSTabEligibilityChecker(siteID: siteID, @@ -128,15 +128,48 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() // Then - #expect(result == false) + #expect(result == .ineligible(reason: .unsupportedCountry(supportedCountries: [.US, .GB]))) } - @Test func is_invisible_when_woocommerce_version_is_below_minimum() async throws { + @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) + ]) + fileprivate func is_ineligible_when_currency_is_not_supported(country: Country, + currency: CurrencyCode, + expectedSupportedCurrencies: [CurrencyCode], + isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) + setupCountry(country: country, currency: currency) + 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 == .ineligible(reason: .unsupportedCurrency(supportedCurrencies: expectedSupportedCurrencies))) + } + + @Test(arguments: [true, false]) + func is_ineligible_when_woocommerce_version_is_below_minimum(isPointOfSaleAsATabi2Enabled: Bool) async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) setupCountry(country: .us) accountWhitelistedInBackend(true) setupWooCommerceVersion("9.5.0") @@ -148,15 +181,16 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() // Then - #expect(result == false) + #expect(result == .ineligible(reason: .unsupportedWooCommerceVersion(minimumVersion: "9.6.0-beta"))) } - @Test func is_visible_when_core_version_is_10_0_0_and_POS_feature_enabled() async throws { + @Test(arguments: [true, false]) + func is_eligible_when_core_version_is_10_0_0_and_POS_feature_enabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) setupCountry(country: .us) accountWhitelistedInBackend(true) setupWooCommerceVersion("10.0.0") @@ -169,15 +203,16 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() // Then - #expect(result == true) + #expect(result == .eligible) } - @Test func is_invisible_when_core_version_is_10_0_0_and_POS_feature_disabled() async throws { + @Test(arguments: [true, false]) + func is_ineligible_when_core_version_is_10_0_0_and_POS_feature_disabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) setupCountry(country: .us) accountWhitelistedInBackend(true) setupWooCommerceVersion("10.0.0") @@ -190,15 +225,16 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() // Then - #expect(result == false) + #expect(result == .ineligible(reason: .featureSwitchDisabled)) } - @Test func is_invisible_when_core_version_is_10_0_0_and_POS_feature_check_fails() async throws { + @Test(arguments: [true, false]) + func is_ineligible_when_core_version_is_10_0_0_and_POS_feature_check_fails(isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) setupCountry(country: .us) accountWhitelistedInBackend(true) setupWooCommerceVersion("10.0.0") @@ -211,15 +247,16 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() // Then - #expect(result == false) + #expect(result == .ineligible(reason: .featureSwitchSyncFailure)) } - @Test func is_visible_when_core_version_is_below_10_0_0_and_POS_feature_disabled() async throws { + @Test(arguments: [true, false]) + func is_eligible_when_core_version_is_below_10_0_0_and_POS_feature_disabled(isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) setupCountry(country: .us) accountWhitelistedInBackend(true) setupWooCommerceVersion("9.9.9") @@ -232,10 +269,10 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() // Then - #expect(result == true) + #expect(result == .eligible) } @Test func checkInitialVisibility_returns_true_when_cached_tab_visibility_is_enabled() async throws { @@ -274,9 +311,10 @@ struct POSTabEligibilityCheckerTests { #expect(result == false) } - @Test func checkVisibility_skips_settings_from_initialLoad() async throws { + @Test(arguments: [true, false]) + func checkEligibility_skips_settings_from_initialLoad(isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) // Initial settings (cached) - makes site eligible (US) let initialSettings = [ @@ -304,15 +342,16 @@ struct POSTabEligibilityCheckerTests { featureFlagService: featureFlagService) // When - let result = await checker.checkVisibility() + let result = await checker.checkEligibility() - // Then - Should return false because i2 feature flag is disabled - #expect(result == false) + // Then - Should be ineligible because fresh settings show CA (not cached US) + #expect(result == .ineligible(reason: .unsupportedCountry(supportedCountries: [.US, .GB]))) } - @Test func is_visible_from_filtering_site_settings_by_correct_siteID() async throws { + @Test(arguments: [true, false]) + func checkEligibility_filters_by_correct_siteID(isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) // Settings for a different site. let wrongSiteSettings = [ @@ -334,6 +373,94 @@ struct POSTabEligibilityCheckerTests { (siteID: siteID, settings: correctSiteSettings, source: .storageChange) ].publisher.eraseToAnyPublisher() + 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) + } + + // MARK: - checkVisibility 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) + ]) + fileprivate func checkVisibility_returns_true_when_i2_enabled_and_country_remote_feature_eligible(country: Country, currency: CurrencyCode) async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) + setupCountry(country: country, currency: currency) + accountWhitelistedInBackend(true) + let checker = POSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == true) + } + + @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 { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) + setupCountry(country: country, currency: currency) + accountWhitelistedInBackend(true) + let checker = POSTabEligibilityChecker(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.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 { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) + setupCountry(country: country, currency: currency) + accountWhitelistedInBackend(false) + let checker = POSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == false) + } + + @Test func checkVisibility_returns_true_when_pointOfSaleAsATabi2_disabled_and_checkEligibility_eligible() async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + setupCountry(country: .us) accountWhitelistedInBackend(true) let checker = POSTabEligibilityChecker(siteID: siteID, userInterfaceIdiom: .pad, @@ -348,6 +475,109 @@ struct POSTabEligibilityCheckerTests { // Then #expect(result == true) } + + @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 { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false) + setupCountry(country: country, currency: currency) // Ineligible country/currency combination + accountWhitelistedInBackend(true) + let checker = POSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == false) + } + + @Test(arguments: [true, false]) + func checkVisibility_returns_false_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, // Not iPad + siteSettings: siteSettings, + pluginsService: pluginsService, + stores: stores, + featureFlagService: featureFlagService) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == false) + } + + @Test func checkEligibility_uses_cached_values_after_checkVisibility_when_i2_feature_is_enabled() async throws { + // Given + let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true) + setupCountry(country: .us) + accountWhitelistedInBackend(true) + let checker = POSTabEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, + siteSettings: siteSettings, + pluginsService: pluginsService, + 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() + + // Then - both should return the expected results, demonstrating caching works + #expect(visibilityResult == true) + #expect(eligibilityResult == .eligible) + } + + @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, + pluginsService: pluginsService, + 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) + } } private extension POSTabEligibilityCheckerTests {