diff --git a/Modules/Sources/PointOfSale/Models/PointOfSaleSettingsController.swift b/Modules/Sources/PointOfSale/Models/PointOfSaleSettingsController.swift index d1a1e010489..05e6bc2ed2b 100644 --- a/Modules/Sources/PointOfSale/Models/PointOfSaleSettingsController.swift +++ b/Modules/Sources/PointOfSale/Models/PointOfSaleSettingsController.swift @@ -15,6 +15,7 @@ protocol PointOfSaleSettingsControllerProtocol { var connectedCardReader: CardPresentPaymentCardReader? { get } var storeViewModel: POSSettingsStoreViewModel { get } var localCatalogViewModel: POSSettingsLocalCatalogViewModel? { get } + var isLocalCatalogEligible: Bool { get } } @Observable final class PointOfSaleSettingsController: PointOfSaleSettingsControllerProtocol { @@ -23,6 +24,7 @@ protocol PointOfSaleSettingsControllerProtocol { let storeViewModel: POSSettingsStoreViewModel let localCatalogViewModel: POSSettingsLocalCatalogViewModel? + let isLocalCatalogEligible: Bool init(siteID: Int64, settingsService: PointOfSaleSettingsServiceProtocol, @@ -31,12 +33,15 @@ protocol PointOfSaleSettingsControllerProtocol { defaultSiteName: String?, siteSettings: [SiteSetting], grdbManager: GRDBManagerProtocol?, - catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol?) { + catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol?, + isLocalCatalogEligible: Bool) { self.storeViewModel = POSSettingsStoreViewModel(siteID: siteID, settingsService: settingsService, pluginsService: pluginsService, defaultSiteName: defaultSiteName, siteSettings: siteSettings) + self.isLocalCatalogEligible = isLocalCatalogEligible + if let catalogSyncCoordinator, let grdbManager { self.localCatalogViewModel = POSSettingsLocalCatalogViewModel( siteID: siteID, @@ -80,6 +85,10 @@ final class PointOfSaleSettingsPreviewController: PointOfSaleSettingsControllerP siteSettings: []) var localCatalogViewModel: POSSettingsLocalCatalogViewModel? + + var isLocalCatalogEligible: Bool { + localCatalogViewModel != nil + } } final class MockPointOfSaleSettingsService: PointOfSaleSettingsServiceProtocol { diff --git a/Modules/Sources/PointOfSale/Presentation/PointOfSaleEntryPointView.swift b/Modules/Sources/PointOfSale/Presentation/PointOfSaleEntryPointView.swift index 53c2da3b2cd..f7d96f7894f 100644 --- a/Modules/Sources/PointOfSale/Presentation/PointOfSaleEntryPointView.swift +++ b/Modules/Sources/PointOfSale/Presentation/PointOfSaleEntryPointView.swift @@ -63,14 +63,13 @@ public struct PointOfSaleEntryPointView: View { siteSettings: [SiteSetting], grdbManager: GRDBManagerProtocol?, catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol?, + isLocalCatalogEligible: Bool, services: POSDependencyProviding) { self.onPointOfSaleModeActiveStateChange = onPointOfSaleModeActiveStateChange - // Use observable controller with GRDB if available and feature flag is enabled, otherwise fall back to standard controller - // Note: We check feature flag here for eligibility. Once eligibility checking is - // refactored to be more centralized, this check can be simplified. - let isGRDBEnabled = services.featureFlags.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1) - if let grdbManager = grdbManager, catalogSyncCoordinator != nil, isGRDBEnabled { + // Use observable controller with GRDB if local catalog is eligible, + // otherwise fall back to standard controller. + if isLocalCatalogEligible, let grdbManager = grdbManager { self.itemsController = PointOfSaleObservableItemsController( siteID: siteID, grdbManager: grdbManager, @@ -113,7 +112,8 @@ public struct PointOfSaleEntryPointView: View { defaultSiteName: defaultSiteName, siteSettings: siteSettings, grdbManager: grdbManager, - catalogSyncCoordinator: catalogSyncCoordinator) + catalogSyncCoordinator: catalogSyncCoordinator, + isLocalCatalogEligible: isLocalCatalogEligible) self.collectOrderPaymentAnalyticsTracker = collectOrderPaymentAnalyticsTracker self.searchHistoryService = searchHistoryService self.popularPurchasableItemsController = PointOfSaleItemsController( @@ -203,6 +203,7 @@ public struct PointOfSaleEntryPointView: View { siteSettings: [], grdbManager: nil, catalogSyncCoordinator: nil, + isLocalCatalogEligible: false, services: POSPreviewServices() ) } diff --git a/Modules/Sources/PointOfSale/Presentation/Settings/PointOfSaleSettingsView.swift b/Modules/Sources/PointOfSale/Presentation/Settings/PointOfSaleSettingsView.swift index 5ceaaf4fe37..1458dcd0791 100644 --- a/Modules/Sources/PointOfSale/Presentation/Settings/PointOfSaleSettingsView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Settings/PointOfSaleSettingsView.swift @@ -3,7 +3,6 @@ import SwiftUI struct PointOfSaleSettingsView: View { @Environment(\.dismiss) private var dismiss @Environment(\.posAnalytics) private var analytics - @Environment(\.posFeatureFlags) private var featureFlags @State private var selection: SidebarNavigation? = .store let settingsController: PointOfSaleSettingsControllerProtocol @@ -55,8 +54,7 @@ extension PointOfSaleSettingsView { } ) - // TODO: WOOMOB-1287 - integrate with local catalog feature eligibility - if featureFlags.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1) && settingsController.localCatalogViewModel != nil { + if settingsController.isLocalCatalogEligible { PointOfSaleSettingsCard( item: .localCatalog, isSelected: selection == .localCatalog, diff --git a/Modules/Sources/PointOfSale/Protocols/POSLocalCatalogEligibilityServiceProtocol.swift b/Modules/Sources/PointOfSale/Protocols/POSLocalCatalogEligibilityServiceProtocol.swift new file mode 100644 index 00000000000..f37457bd567 --- /dev/null +++ b/Modules/Sources/PointOfSale/Protocols/POSLocalCatalogEligibilityServiceProtocol.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Eligibility state for local catalog feature +/// Provides diagnostic information for UI display and decision-making +public enum POSLocalCatalogEligibilityState: Equatable { + /// Local catalog is eligible for use + case eligible + + /// Local catalog is not eligible + case ineligible(reason: POSLocalCatalogIneligibleReason) +} + +/// Reasons why local catalog is ineligible +public enum POSLocalCatalogIneligibleReason: Equatable { + case posTabNotVisible + case featureFlagDisabled + case catalogSizeTooLarge(totalCount: Int, limit: Int) + case catalogSizeCheckFailed(underlyingError: String) +} + +/// Service that provides eligibility information for local catalog feature +/// +/// Other services can query this for eligibility state and reasons: +/// - Sync coordinator can check if catalog is eligible +/// - Settings UI can display eligibility status and reasons +/// - Analytics can track why stores are ineligible +/// +/// NOTE: This service checks catalog-related eligibility (size limits) and feature flag state. +/// The service performs an initial eligibility check during initialization. +public protocol POSLocalCatalogEligibilityServiceProtocol { + /// Current eligibility state (synchronously accessible on main thread) + var eligibilityState: POSLocalCatalogEligibilityState { get } + + /// Update the POS tab visibility state and refresh eligibility + /// - Parameter isPOSTabVisible: Whether the POS tab is visible + func updateVisibility(isPOSTabVisible: Bool) async + + /// Force refresh eligibility (bypasses cache and updates eligibilityState) + /// - Returns: Fresh eligibility state with reason if ineligible + @discardableResult func refreshEligibilityState() async -> POSLocalCatalogEligibilityState +} diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSizeChecker.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSizeChecker.swift index 0b3847fd167..fa370bdb3c4 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSizeChecker.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSizeChecker.swift @@ -1,5 +1,6 @@ import Foundation import Networking +import Combine /// Protocol for checking the size of a remote POS catalog public protocol POSCatalogSizeCheckerProtocol { @@ -18,6 +19,15 @@ public struct POSCatalogSizeChecker: POSCatalogSizeCheckerProtocol { self.syncRemote = syncRemote } + public init(credentials: Credentials?, + selectedSite: AnyPublisher, + appPasswordSupportState: AnyPublisher) { + let syncRemote = POSCatalogSyncRemote(network: AlamofireNetwork(credentials: credentials, + selectedSite: selectedSite, + appPasswordSupportState: appPasswordSupportState)) + self.init(syncRemote: syncRemote) + } + public func checkCatalogSize(for siteID: Int64) async throws -> POSCatalogSize { // Make concurrent requests to get both counts async let productCount = syncRemote.getProductCount(siteID: siteID) diff --git a/Modules/Tests/PointOfSaleTests/Controllers/PointOfSaleSettingsControllerTests.swift b/Modules/Tests/PointOfSaleTests/Controllers/PointOfSaleSettingsControllerTests.swift index c3f0115ef32..152cfbc24e9 100644 --- a/Modules/Tests/PointOfSaleTests/Controllers/PointOfSaleSettingsControllerTests.swift +++ b/Modules/Tests/PointOfSaleTests/Controllers/PointOfSaleSettingsControllerTests.swift @@ -4,6 +4,7 @@ import Foundation @testable import Yosemite import Storage +@MainActor struct PointOfSaleSettingsControllerTests { private let mockSettingsService = MockPointOfSaleSettingsService() private let mockCardPresentPaymentService = MockCardPresentPaymentService() @@ -19,7 +20,8 @@ struct PointOfSaleSettingsControllerTests { defaultSiteName: "Test Store", siteSettings: [], grdbManager: nil, - catalogSyncCoordinator: nil) + catalogSyncCoordinator: nil, + isLocalCatalogEligible: true) // When let cardReader = sut.connectedCardReader @@ -38,7 +40,8 @@ struct PointOfSaleSettingsControllerTests { defaultSiteName: "Test Store", siteSettings: [], grdbManager: nil, - catalogSyncCoordinator: nil) + catalogSyncCoordinator: nil, + isLocalCatalogEligible: true) // Initially nil #expect(sut.connectedCardReader == nil) @@ -80,4 +83,5 @@ final class MockPointOfSaleSettingsController: PointOfSaleSettingsControllerProt defaultSiteName: "Sample Store", siteSettings: []) var localCatalogViewModel: POSSettingsLocalCatalogViewModel? + var isLocalCatalogEligible = true } diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPOSLocalCatalogEligibilityService.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPOSLocalCatalogEligibilityService.swift new file mode 100644 index 00000000000..021ebbb3259 --- /dev/null +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockPOSLocalCatalogEligibilityService.swift @@ -0,0 +1,20 @@ +import Foundation +@testable import PointOfSale + +/// Mock implementation of POSLocalCatalogEligibilityServiceProtocol for testing +@MainActor +public final class MockPOSLocalCatalogEligibilityService: POSLocalCatalogEligibilityServiceProtocol { + public var eligibilityState: POSLocalCatalogEligibilityState + public var refreshCallCount = 0 + + public init(eligibilityState: POSLocalCatalogEligibilityState = .eligible) { + self.eligibilityState = eligibilityState + } + + public func refreshEligibilityState() async -> POSLocalCatalogEligibilityState { + refreshCallCount += 1 + return eligibilityState + } + + public func updateVisibility(isPOSTabVisible: Bool) async { } +} diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSizeChecker.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSizeChecker.swift index e88ac49fdae..cd90fe1e4d7 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSizeChecker.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSizeChecker.swift @@ -2,16 +2,18 @@ import Foundation @testable import Yosemite final class MockPOSCatalogSizeChecker: POSCatalogSizeCheckerProtocol { - // MARK: - checkCatalogSize tracking - private(set) var checkCatalogSizeCallCount = 0 - private(set) var lastCheckedSiteID: Int64? - var checkCatalogSizeResult: Result = .success(POSCatalogSize(productCount: 100, variationCount: 50)) // 150 total - well under limit + var sizeToReturn: Result + var checkCatalogSizeCallCount = 0 + var lastCheckedSiteID: Int64? + + init(sizeToReturn: Result = .success(POSCatalogSize(productCount: 100, variationCount: 50))) { + self.sizeToReturn = sizeToReturn + } func checkCatalogSize(for siteID: Int64) async throws -> POSCatalogSize { checkCatalogSizeCallCount += 1 lastCheckedSiteID = siteID - - switch checkCatalogSizeResult { + switch sizeToReturn { case .success(let size): return size case .failure(let error): diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift index ca4c8031a42..699c213be5b 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift @@ -150,7 +150,7 @@ struct POSCatalogSyncCoordinatorTests { @Test func performFullSyncIfApplicable_skips_sync_when_catalog_size_exceeds_limit() async throws { // Given - catalog size is above the 1000 item limit - mockCatalogSizeChecker.checkCatalogSizeResult = .success(POSCatalogSize(productCount: 800, variationCount: 300)) // 1100 total + mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 800, variationCount: 300)) // 1100 total try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: nil) // When @@ -164,7 +164,7 @@ struct POSCatalogSyncCoordinatorTests { @Test func performFullSyncIfApplicable_starts_sync_when_catalog_size_is_at_limit() async throws { // Given - catalog size is exactly at the 1000 item limit - mockCatalogSizeChecker.checkCatalogSizeResult = .success(POSCatalogSize(productCount: 600, variationCount: 400)) // 1000 total + mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 600, variationCount: 400)) // 1000 total try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: nil) // When @@ -178,7 +178,7 @@ struct POSCatalogSyncCoordinatorTests { @Test func performFullSyncIfApplicable_starts_sync_when_catalog_size_is_under_limit() async throws { // Given - catalog size is below the 1000 item limit - mockCatalogSizeChecker.checkCatalogSizeResult = .success(POSCatalogSize(productCount: 300, variationCount: 200)) // 500 total + mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 300, variationCount: 200)) // 500 total try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: nil) // When @@ -193,7 +193,7 @@ struct POSCatalogSyncCoordinatorTests { @Test func performFullSyncIfApplicable_skips_sync_when_catalog_size_check_fails() async throws { // Given - catalog size check throws an error let sizeCheckError = NSError(domain: "size_check", code: 500, userInfo: [NSLocalizedDescriptionKey: "Network error"]) - mockCatalogSizeChecker.checkCatalogSizeResult = .failure(sizeCheckError) + mockCatalogSizeChecker.sizeToReturn = .failure(sizeCheckError) try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: nil) // When @@ -207,7 +207,7 @@ struct POSCatalogSyncCoordinatorTests { @Test func performFullSyncIfApplicable_respects_time_only_when_catalog_size_is_acceptable() async throws { // Given - catalog size is acceptable but sync is recent - mockCatalogSizeChecker.checkCatalogSizeResult = .success(POSCatalogSize(productCount: 200, variationCount: 100)) // 300 total + mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 200, variationCount: 100)) // 300 total let thirtyMinutesAgo = Date().addingTimeInterval(-30 * 60) try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: thirtyMinutesAgo) @@ -504,7 +504,7 @@ struct POSCatalogSyncCoordinatorTests { @Test(arguments: [.zero, 60 * 60]) func performIncrementalSyncIfApplicable_skips_sync_when_catalog_size_exceeds_limit(maxAge: TimeInterval) async throws { // Given - catalog size is above the 1000 item limit - mockCatalogSizeChecker.checkCatalogSizeResult = .success(POSCatalogSize(productCount: 700, variationCount: 400)) // 1100 total + mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 700, variationCount: 400)) // 1100 total let fullSyncDate = Date().addingTimeInterval(-3600) try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: fullSyncDate) @@ -520,7 +520,7 @@ struct POSCatalogSyncCoordinatorTests { @Test(arguments: [.zero, 60 * 60]) func performIncrementalSyncIfApplicable_performs_sync_when_catalog_size_is_at_limit(maxAge: TimeInterval) async throws { // Given - catalog size is exactly at the 1000 item limit - mockCatalogSizeChecker.checkCatalogSizeResult = .success(POSCatalogSize(productCount: 500, variationCount: 500)) // 1000 total + mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 500, variationCount: 500)) // 1000 total let fullSyncDate = Date().addingTimeInterval(-3600) try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: fullSyncDate) @@ -536,7 +536,7 @@ struct POSCatalogSyncCoordinatorTests { @Test(arguments: [.zero, 60 * 60]) func performIncrementalSyncIfApplicable_performs_sync_when_catalog_size_is_under_limit(maxAge: TimeInterval) async throws { // Given - catalog size is below the 1000 item limit - mockCatalogSizeChecker.checkCatalogSizeResult = .success(POSCatalogSize(productCount: 200, variationCount: 150)) // 350 total + mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 200, variationCount: 150)) // 350 total let fullSyncDate = Date().addingTimeInterval(-3600) try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: fullSyncDate) @@ -553,7 +553,7 @@ struct POSCatalogSyncCoordinatorTests { func performIncrementalSyncIfApplicable_skips_sync_when_catalog_size_check_fails(maxAge: TimeInterval) async throws { // Given - catalog size check throws an error let sizeCheckError = NSError(domain: "size_check", code: 500, userInfo: [NSLocalizedDescriptionKey: "Network error"]) - mockCatalogSizeChecker.checkCatalogSizeResult = .failure(sizeCheckError) + mockCatalogSizeChecker.sizeToReturn = .failure(sizeCheckError) let fullSyncDate = Date().addingTimeInterval(-3600) try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: fullSyncDate) @@ -568,7 +568,7 @@ struct POSCatalogSyncCoordinatorTests { @Test func performIncrementalSyncIfApplicable_checks_size_before_age_check() async throws { // Given - catalog is over limit but would otherwise sync due to age - mockCatalogSizeChecker.checkCatalogSizeResult = .success(POSCatalogSize(productCount: 800, variationCount: 300)) // 1100 total + mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 800, variationCount: 300)) // 1100 total let maxAge: TimeInterval = 2 let staleIncrementalSyncDate = Date().addingTimeInterval(-(maxAge + 1)) // Older than max age let fullSyncDate = Date().addingTimeInterval(-3600) diff --git a/WooCommerce/Classes/POS/Adaptors/POSServiceLocatorAdaptor.swift b/WooCommerce/Classes/POS/Adaptors/POSServiceLocatorAdaptor.swift index 5103f38b217..3bdae4293a8 100644 --- a/WooCommerce/Classes/POS/Adaptors/POSServiceLocatorAdaptor.swift +++ b/WooCommerce/Classes/POS/Adaptors/POSServiceLocatorAdaptor.swift @@ -14,6 +14,9 @@ import protocol PointOfSale.POSExternalNavigationProviding import protocol PointOfSale.POSExternalViewProviding final class POSServiceLocatorAdaptor: POSDependencyProviding { + init() { + } + var analytics: POSAnalyticsProviding { POSAnalyticsAdaptor() } diff --git a/WooCommerce/Classes/POS/Eligibility/POSLocalCatalogEligibilityService.swift b/WooCommerce/Classes/POS/Eligibility/POSLocalCatalogEligibilityService.swift new file mode 100644 index 00000000000..186b0156e93 --- /dev/null +++ b/WooCommerce/Classes/POS/Eligibility/POSLocalCatalogEligibilityService.swift @@ -0,0 +1,94 @@ +import Foundation +import CocoaLumberjackSwift +import Yosemite +import Experiments +import protocol PointOfSale.POSLocalCatalogEligibilityServiceProtocol +import enum PointOfSale.POSLocalCatalogEligibilityState +import enum PointOfSale.POSLocalCatalogIneligibleReason + +@MainActor +final class POSLocalCatalogEligibilityService: POSLocalCatalogEligibilityServiceProtocol { + private let siteID: Int64 + private let catalogSizeChecker: POSCatalogSizeCheckerProtocol + private let catalogSizeLimit: Int + private let featureFlagService: FeatureFlagService + private var isPOSTabVisible: Bool + + // Current eligibility state + private(set) var eligibilityState: POSLocalCatalogEligibilityState + + /// Initialize eligibility service and perform initial eligibility check + init( + siteID: Int64, + catalogSizeChecker: POSCatalogSizeCheckerProtocol, + featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, + isPOSTabVisible: Bool, + catalogSizeLimit: Int? = nil + ) async { + self.siteID = siteID + self.catalogSizeChecker = catalogSizeChecker + self.featureFlagService = featureFlagService + self.isPOSTabVisible = isPOSTabVisible + self.catalogSizeLimit = catalogSizeLimit ?? Constants.defaultCatalogSizeLimit + + // Perform initial check + self.eligibilityState = .eligible // Temporary + await self.refreshEligibilityState() + } + + /// Update the visibility state and refresh eligibility + func updateVisibility(isPOSTabVisible: Bool) async { + self.isPOSTabVisible = isPOSTabVisible + await refreshEligibilityState() + } + + @discardableResult func refreshEligibilityState() async -> POSLocalCatalogEligibilityState { + // Check POS tab visibility FIRST - no point in checking catalog if POS tab isn't visible + guard isPOSTabVisible else { + eligibilityState = .ineligible(reason: .posTabNotVisible) + DDLogInfo("📋 POSLocalCatalogEligibilityService: POS tab not visible for site \(siteID)") + return eligibilityState + } + + // Check feature flag - if disabled, no need to check catalog size + let isFeatureFlagEnabled = featureFlagService.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1) + guard isFeatureFlagEnabled else { + eligibilityState = .ineligible(reason: .featureFlagDisabled) + DDLogInfo("📋 POSLocalCatalogEligibilityService: Local catalog feature flag disabled for site \(siteID)") + return eligibilityState + } + + // Fetch remote catalog size and check against limit + do { + let size = try await catalogSizeChecker.checkCatalogSize(for: siteID) + + if size.totalCount > catalogSizeLimit { + eligibilityState = .ineligible( + reason: .catalogSizeTooLarge(totalCount: size.totalCount, limit: catalogSizeLimit) + ) + DDLogInfo("📋 POSLocalCatalogEligibilityService: Site \(siteID) catalog size \(size.totalCount) exceeds limit \(catalogSizeLimit)") + return eligibilityState + } + + DDLogInfo("📋 POSLocalCatalogEligibilityService: Site \(siteID) catalog size \(size.totalCount) is within limit \(catalogSizeLimit)") + eligibilityState = .eligible + return eligibilityState + + } catch { + let errorString = String(describing: error) + eligibilityState = .ineligible( + reason: .catalogSizeCheckFailed(underlyingError: errorString) + ) + DDLogError("📋 POSLocalCatalogEligibilityService: Failed to check catalog size for site \(siteID): \(error)") + return eligibilityState + } + } +} + +// MARK: - Constants + +private extension POSLocalCatalogEligibilityService { + enum Constants { + static let defaultCatalogSizeLimit = 1000 + } +} diff --git a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift index 660315965cd..ec0c85bbac4 100644 --- a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift +++ b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift @@ -42,6 +42,9 @@ final class POSTabCoordinator { private let pushNotesManager: PushNotesManager private let eligibilityChecker: POSEntryPointEligibilityCheckerProtocol + /// Local catalog eligibility service - created asynchronously during init + private(set) var localCatalogEligibilityService: POSLocalCatalogEligibilityServiceProtocol? + private lazy var posItemFetchStrategyFactory: PointOfSaleItemFetchStrategyFactory = { PointOfSaleItemFetchStrategyFactory(siteID: siteID, credentials: credentials, @@ -72,11 +75,10 @@ final class POSTabCoordinator { }() /// Creates the appropriate barcode scan service based on local catalog availability - private func createBarcodeScanService(grdbManager: GRDBManagerProtocol?, - catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol?) -> any PointOfSaleBarcodeScanServiceProtocol { - // Use local barcode scanning if both GRDB manager and catalog sync coordinator are available - // This indicates the local catalog feature is properly initialized and can be used - if let grdbManager, catalogSyncCoordinator != nil { + private func createBarcodeScanService(isLocalCatalogEligible: Bool, + grdbManager: GRDBManagerProtocol?) -> any PointOfSaleBarcodeScanServiceProtocol { + if isLocalCatalogEligible, + let grdbManager { return PointOfSaleLocalBarcodeScanService(siteID: siteID, grdbManager: grdbManager, currencySettings: currencySettings) @@ -105,7 +107,8 @@ final class POSTabCoordinator { storageManager: StorageManagerType = ServiceLocator.storageManager, currencySettings: CurrencySettings = ServiceLocator.currencySettings, pushNotesManager: PushNotesManager = ServiceLocator.pushNotesManager, - eligibilityChecker: POSEntryPointEligibilityCheckerProtocol) { + eligibilityChecker: POSEntryPointEligibilityCheckerProtocol, + initialPOSTabVisibility: Bool) { self.siteID = siteID self.storesManager = storesManager self.defaultSitePublisher = storesManager.sessionManager.defaultSitePublisher @@ -124,6 +127,32 @@ final class POSTabCoordinator { self.eligibilityChecker = eligibilityChecker tabContainerController.wrappedController = POSTabViewController() + + // Create local catalog eligibility service asynchronously + // Use initial POS tab visibility from cached check + Task { @MainActor [weak self] in + guard let self else { return } + + let service = await POSLocalCatalogEligibilityService( + siteID: siteID, + catalogSizeChecker: POSCatalogSizeChecker( + credentials: credentials, + selectedSite: defaultSitePublisher, + appPasswordSupportState: isAppPasswordSupported + ), + isPOSTabVisible: initialPOSTabVisibility + ) + + self.localCatalogEligibilityService = service + } + } + + /// Update local catalog eligibility when POS tab visibility changes + func updatePOSTabVisibility(_ isPOSTabVisible: Bool) { + Task { @MainActor [weak self] in + guard let self, let service = self.localCatalogEligibilityService else { return } + await service.updateVisibility(isPOSTabVisible: isPOSTabVisible) + } } func onTabSelected() { @@ -143,6 +172,21 @@ private extension POSTabCoordinator { func presentPOSView(siteID: Int64) { Task { @MainActor [weak self] in guard let self else { return } + + // Get local catalog eligibility as bool from service + // Service is created asynchronously in init, might not be ready yet + let isLocalCatalogEligible: Bool + if let service = localCatalogEligibilityService { + // Retry transient failures before using the value + if case .ineligible(reason: .catalogSizeCheckFailed) = service.eligibilityState { + await service.refreshEligibilityState() + } + isLocalCatalogEligible = service.eligibilityState == .eligible + } else { + // Service not ready yet (rare race condition), assume ineligible + isLocalCatalogEligible = false + } + let serviceAdaptor = POSServiceLocatorAdaptor() let collectPaymentAnalyticsAdaptor = POSCollectOrderPaymentAnalyticsAdaptor(analytics: serviceAdaptor.analytics) let cardPresentPaymentService = await CardPresentPaymentService(siteID: siteID, @@ -156,13 +200,15 @@ private extension POSTabCoordinator { let pluginsService = PluginsService(storageManager: storageManager) let siteTimezone = storesManager.sessionManager.defaultSite?.siteTimezone ?? .current - let grdbManager: GRDBManagerProtocol? = serviceAdaptor.featureFlags.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1) ? ServiceLocator.grdbManager : nil - let catalogSyncCoordinator = ServiceLocator.posCatalogSyncCoordinator + // Only initialize local catalog infrastructure if eligible + let grdbManager: GRDBManagerProtocol? = isLocalCatalogEligible ? ServiceLocator.grdbManager : nil + let catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol? = isLocalCatalogEligible ? ServiceLocator.posCatalogSyncCoordinator : nil - // Create appropriate barcode scan service based on local catalog availability - // Will use local GRDB-based scanning if both grdbManager and catalogSyncCoordinator are available, + // Create appropriate barcode scan service based on local catalog eligibility + // Will use local GRDB-based scanning if eligible and infrastructure is available, // otherwise falls back to remote API-based scanning - let barcodeScanService = createBarcodeScanService(grdbManager: grdbManager, catalogSyncCoordinator: catalogSyncCoordinator) + let barcodeScanService = createBarcodeScanService(isLocalCatalogEligible: isLocalCatalogEligible, + grdbManager: grdbManager) if let receiptService = POSReceiptService(siteID: siteID, credentials: credentials, @@ -203,6 +249,7 @@ private extension POSTabCoordinator { siteSettings: ServiceLocator.selectedSiteSettings.siteSettings, grdbManager: grdbManager, catalogSyncCoordinator: catalogSyncCoordinator, + isLocalCatalogEligible: isLocalCatalogEligible, services: serviceAdaptor ) diff --git a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift index f0ffdaabbca..4ad0802092d 100644 --- a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift +++ b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift @@ -721,6 +721,9 @@ private extension MainTabBarController { updateTabViewControllers(isPOSTabVisible: isPOSTabVisible, isBookingsTabVisible: isBookingsTabVisible) viewModel.loadHubMenuTabBadge() + // Update POS tab coordinator with new visibility state for local catalog eligibility + posTabCoordinator?.updatePOSTabVisibility(isPOSTabVisible) + // Begin foreground synchronization if POS tab becomes visible await isPOSTabVisible ? posSyncDispatcher.start() : posSyncDispatcher.stop() } @@ -826,12 +829,15 @@ private extension MainTabBarController { selectedIndex = WooTab.myStore.visibleIndex(isPOSTabVisible: isPOSTabVisible, isBookingsTabVisible: isBookingsTabVisible) + // Create POS tab coordinator with initial visibility from cache + let initialPOSTabVisibility = posTabVisibilityChecker?.checkInitialVisibility() ?? false posTabCoordinator = POSTabCoordinator( siteID: siteID, tabContainerController: posContainerController, viewControllerToPresent: self, storesManager: stores, - eligibilityChecker: POSTabEligibilityChecker(siteID: siteID) + eligibilityChecker: POSTabEligibilityChecker(siteID: siteID), + initialPOSTabVisibility: initialPOSTabVisibility ) // Updates site ID for the bookings tab to display correct bookings diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 75f6968ef19..803bb7bc5fb 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -706,6 +706,10 @@ 20134CE62D4D1BDF00076A80 /* LearnMoreViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20134CE52D4D1BDF00076A80 /* LearnMoreViewModelTests.swift */; }; 20134CE82D4D38E000076A80 /* CardPresentPaymentPlugin+SetUpTapToPay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20134CE72D4D38E000076A80 /* CardPresentPaymentPlugin+SetUpTapToPay.swift */; }; 20203AB22B31EEF1009D0C11 /* ExpandableBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20203AB12B31EEF1009D0C11 /* ExpandableBottomSheet.swift */; }; + 2020B8732EAA405500422B86 /* POSLocalCatalogEligibilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2020B8712EAA405500422B86 /* POSLocalCatalogEligibilityService.swift */; }; + 2020B8762EAA41C200422B86 /* POSLocalCatalogEligibilityServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2020B8742EAA41C200422B86 /* POSLocalCatalogEligibilityServiceTests.swift */; }; + 2020B8782EAA425700422B86 /* MockPOSLocalCatalogEligibilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2020B8772EAA425700422B86 /* MockPOSLocalCatalogEligibilityService.swift */; }; + 2020B87A2EAA439200422B86 /* MockPOSCatalogSizeChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2020B8792EAA439200422B86 /* MockPOSCatalogSizeChecker.swift */; }; 2024966A2B0CC97100EE527D /* MockWooPaymentsDepositService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 202496692B0CC97100EE527D /* MockWooPaymentsDepositService.swift */; }; 202D2A5A2AC5933100E4ABC0 /* TopTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 202D2A592AC5933100E4ABC0 /* TopTabView.swift */; }; 203A5C312AC5ADD700BF29A1 /* WooPaymentsPayoutsOverviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 203A5C302AC5ADD700BF29A1 /* WooPaymentsPayoutsOverviewView.swift */; }; @@ -3618,6 +3622,10 @@ 20134CE52D4D1BDF00076A80 /* LearnMoreViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LearnMoreViewModelTests.swift; sourceTree = ""; }; 20134CE72D4D38E000076A80 /* CardPresentPaymentPlugin+SetUpTapToPay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CardPresentPaymentPlugin+SetUpTapToPay.swift"; sourceTree = ""; }; 20203AB12B31EEF1009D0C11 /* ExpandableBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandableBottomSheet.swift; sourceTree = ""; }; + 2020B8712EAA405500422B86 /* POSLocalCatalogEligibilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSLocalCatalogEligibilityService.swift; sourceTree = ""; }; + 2020B8742EAA41C200422B86 /* POSLocalCatalogEligibilityServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSLocalCatalogEligibilityServiceTests.swift; sourceTree = ""; }; + 2020B8772EAA425700422B86 /* MockPOSLocalCatalogEligibilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPOSLocalCatalogEligibilityService.swift; sourceTree = ""; }; + 2020B8792EAA439200422B86 /* MockPOSCatalogSizeChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPOSCatalogSizeChecker.swift; sourceTree = ""; }; 202496692B0CC97100EE527D /* MockWooPaymentsDepositService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockWooPaymentsDepositService.swift; sourceTree = ""; }; 202D2A592AC5933100E4ABC0 /* TopTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopTabView.swift; sourceTree = ""; }; 203A5C302AC5ADD700BF29A1 /* WooPaymentsPayoutsOverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooPaymentsPayoutsOverviewView.swift; sourceTree = ""; }; @@ -6757,6 +6765,7 @@ 029327662BF59D2D00D703E7 /* POS */ = { isa = PBXGroup; children = ( + 2020B8722EAA405500422B86 /* Eligibility */, 683F18652E9CC838007BC608 /* POSNotificationScheduler.swift */, 01654EB02E786223001DBB6F /* Adaptors */, 02ABF9B92DF7F8E200348186 /* TabBar */, @@ -6953,9 +6962,11 @@ 02CD3BFC2C35D01600E575C4 /* Mocks */ = { isa = PBXGroup; children = ( + 2020B8772EAA425700422B86 /* MockPOSLocalCatalogEligibilityService.swift */, 02DE61812E86437E0060DCEF /* MockPOSAnalytics.swift */, 02F5E5DE2E857A90002DEE24 /* MockSelectedSiteSettings.swift */, 02F5E5DC2E857A50002DEE24 /* MockPOSSiteSettingService.swift */, + 2020B8792EAA439200422B86 /* MockPOSCatalogSizeChecker.swift */, ); path = Mocks; sourceTree = ""; @@ -7303,6 +7314,22 @@ path = "Bulk Update"; sourceTree = ""; }; + 2020B8722EAA405500422B86 /* Eligibility */ = { + isa = PBXGroup; + children = ( + 2020B8712EAA405500422B86 /* POSLocalCatalogEligibilityService.swift */, + ); + path = Eligibility; + sourceTree = ""; + }; + 2020B8752EAA41C200422B86 /* Eligibility */ = { + isa = PBXGroup; + children = ( + 2020B8742EAA41C200422B86 /* POSLocalCatalogEligibilityServiceTests.swift */, + ); + path = Eligibility; + sourceTree = ""; + }; 202496682B0BC07E00EE527D /* Deposits Overview */ = { isa = PBXGroup; children = ( @@ -11996,6 +12023,7 @@ DABF35242C11B40C006AF826 /* POS */ = { isa = PBXGroup; children = ( + 2020B8752EAA41C200422B86 /* Eligibility */, 68051E1D2E9DFE5100228196 /* POSNotificationSchedulerTests.swift */, 687C006C2D63469F00F832FC /* Analytics */, 02CD3BFC2C35D01600E575C4 /* Mocks */, @@ -15100,6 +15128,7 @@ DE3877E0283B68CF0075D87E /* DiscountTypeBottomSheetListSelectorCommand.swift in Sources */, B9FBEF9A2A7BCCF100AC609B /* UnderlineableTitleAndSubtitleAndDetailTableViewCell.swift in Sources */, 020DD48D2322A617005822B1 /* ProductsTabProductViewModel.swift in Sources */, + 2020B8732EAA405500422B86 /* POSLocalCatalogEligibilityService.swift in Sources */, B5A56BF5219F5AB20065A902 /* NSNotificationName+Woo.swift in Sources */, D449C51B26DE6B5000D75B02 /* ReportList.swift in Sources */, 68E952D0287587BF0095A23D /* CardReaderManualRowView.swift in Sources */, @@ -15743,6 +15772,7 @@ 03FBDAFD263EE4E800ACE257 /* CouponListViewModelTests.swift in Sources */, 036F6EA6281847D5006D84F8 /* PaymentCaptureOrchestratorTests.swift in Sources */, B555531321B57E8800449E71 /* MockUserNotificationsCenterAdapter.swift in Sources */, + 2020B8762EAA41C200422B86 /* POSLocalCatalogEligibilityServiceTests.swift in Sources */, 682210ED2909666600814E14 /* CustomerSearchUICommandTests.swift in Sources */, 4590B652261C8D1E00A6FCE0 /* WeightFormatterTests.swift in Sources */, DE78DE442B2846AF002E58DE /* ThemesCarouselViewModelTests.swift in Sources */, @@ -15783,6 +15813,7 @@ 02DE61822E86437E0060DCEF /* MockPOSAnalytics.swift in Sources */, 02FADAA52A607CEE00FE8683 /* MockImageTextScanner.swift in Sources */, 68674D312B6C895D00E93FBD /* ReceiptEligibilityUseCaseTests.swift in Sources */, + 2020B8782EAA425700422B86 /* MockPOSLocalCatalogEligibilityService.swift in Sources */, CE4DA5C821DD759400074607 /* CurrencyFormatterTests.swift in Sources */, DE61979528A25842005E4362 /* StorePickerViewModelTests.swift in Sources */, EEB4E2D329B2047700371C3C /* StoreOnboardingViewHostingControllerTests.swift in Sources */, @@ -16057,6 +16088,7 @@ 02BAB02124D0235F00F8B06E /* ProductPriceSettingsViewModel+ProductVariationTests.swift in Sources */, CC2E72F727B6BFB800A62872 /* ProductVariationFormatterTests.swift in Sources */, 02B21C5529C84E4A00C5623B /* WPAdminWebViewModelTests.swift in Sources */, + 2020B87A2EAA439200422B86 /* MockPOSCatalogSizeChecker.swift in Sources */, D802547826551DB8001B2CC1 /* CardPresentModalDisplayMessageTests.swift in Sources */, 02503C632538301400FD235D /* ProductVariationFormActionsFactory+ReadonlyVariationTests.swift in Sources */, B9A5317F2D2FCC5600208304 /* WooShippingCustomsItemViewModelTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift b/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift index 93707695a87..18eff831786 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift @@ -26,6 +26,7 @@ final class MockFeatureFlagService: FeatureFlagService, POSFeatureFlagProviding var isProductImageOptimizedHandlingEnabled: Bool var isFeatureFlagEnabledReturnValue: [FeatureFlag: Bool] = [:] var isCIABBookingsEnabled: Bool + var isLocalCatalogEnabled: Bool init(isInboxOn: Bool = false, isShowInboxCTAEnabled: Bool = false, @@ -48,7 +49,8 @@ final class MockFeatureFlagService: FeatureFlagService, POSFeatureFlagProviding notificationSettings: Bool = false, allowMerchantAIAPIKey: Bool = false, isProductImageOptimizedHandlingEnabled: Bool = false, - isCIABBookingsEnabled: Bool = false) { + isCIABBookingsEnabled: Bool = false, + isLocalCatalogEnabled: Bool = false) { self.isInboxOn = isInboxOn self.isShowInboxCTAEnabled = isShowInboxCTAEnabled self.isUpdateOrderOptimisticallyOn = isUpdateOrderOptimisticallyOn @@ -71,6 +73,7 @@ final class MockFeatureFlagService: FeatureFlagService, POSFeatureFlagProviding self.allowMerchantAIAPIKey = allowMerchantAIAPIKey self.isProductImageOptimizedHandlingEnabled = isProductImageOptimizedHandlingEnabled self.isCIABBookingsEnabled = isCIABBookingsEnabled + self.isLocalCatalogEnabled = isLocalCatalogEnabled } func isFeatureFlagEnabled(_ featureFlag: FeatureFlag) -> Bool { @@ -125,6 +128,8 @@ final class MockFeatureFlagService: FeatureFlagService, POSFeatureFlagProviding return isProductImageOptimizedHandlingEnabled case .ciabBookings: return isCIABBookingsEnabled + case .pointOfSaleLocalCatalogi1: + return isLocalCatalogEnabled default: return false } diff --git a/WooCommerce/WooCommerceTests/POS/Eligibility/POSLocalCatalogEligibilityServiceTests.swift b/WooCommerce/WooCommerceTests/POS/Eligibility/POSLocalCatalogEligibilityServiceTests.swift new file mode 100644 index 00000000000..4507f065b2f --- /dev/null +++ b/WooCommerce/WooCommerceTests/POS/Eligibility/POSLocalCatalogEligibilityServiceTests.swift @@ -0,0 +1,328 @@ +import Foundation +import Testing +import PointOfSale +@testable import WooCommerce +@testable import Yosemite +import protocol Experiments.FeatureFlagService +import enum Experiments.FeatureFlag + +@Suite("POSLocalCatalogEligibilityService Tests") +@MainActor +struct POSLocalCatalogEligibilityServiceTests { + private let siteID: Int64 = 123 + + // MARK: - Catalog Size Within Limit + + @Test("Catalog size within limit returns eligible") + func testCatalogSizeWithinLimitReturnsEligible() async { + let sizeChecker = MockPOSCatalogSizeChecker( + sizeToReturn: .success(POSCatalogSize(productCount: 500, variationCount: 400)) + ) + let featureFlagService = MockFeatureFlagService(isLocalCatalogEnabled: true) + let service = await POSLocalCatalogEligibilityService( + siteID: siteID, + catalogSizeChecker: sizeChecker, + featureFlagService: featureFlagService, + isPOSTabVisible: true, + catalogSizeLimit: 1000 + ) + + #expect(service.eligibilityState == .eligible) + } + + @Test("Exactly at size limit returns eligible") + func testExactlyAtSizeLimitReturnsEligible() async { + let sizeChecker = MockPOSCatalogSizeChecker( + sizeToReturn: .success(POSCatalogSize(productCount: 600, variationCount: 400)) + ) + let featureFlagService = MockFeatureFlagService(isLocalCatalogEnabled: true) + let service = await POSLocalCatalogEligibilityService( + siteID: siteID, + catalogSizeChecker: sizeChecker, + featureFlagService: featureFlagService, + isPOSTabVisible: true, + catalogSizeLimit: 1000 + ) + + #expect(service.eligibilityState == .eligible) + } + + // MARK: - Catalog Size Exceeds Limit + + @Test("Catalog one over limit returns ineligible") + func testCatalogOneOverLimitReturnsIneligible() async { + let sizeChecker = MockPOSCatalogSizeChecker( + sizeToReturn: .success(POSCatalogSize(productCount: 501, variationCount: 500)) + ) + let featureFlagService = MockFeatureFlagService(isLocalCatalogEnabled: true) + let service = await POSLocalCatalogEligibilityService( + siteID: siteID, + catalogSizeChecker: sizeChecker, + featureFlagService: featureFlagService, + isPOSTabVisible: true, + catalogSizeLimit: 1000 + ) + + let state = service.eligibilityState + + guard case .ineligible(let reason) = state else { + Issue.record("Expected ineligible state") + return + } + + guard case .catalogSizeTooLarge(let totalCount, let limit) = reason else { + Issue.record("Expected catalogSizeTooLarge reason") + return + } + + #expect(totalCount == 1001) + #expect(limit == 1000) + } + + // MARK: - Catalog Size Check Failure + + @Test("Catalog size check failure returns ineligible") + func testCatalogSizeCheckFailureReturnsIneligible() async { + let expectedError = NSError(domain: "test", code: 123, userInfo: nil) + let sizeChecker = MockPOSCatalogSizeChecker( + sizeToReturn: .failure(expectedError) + ) + let featureFlagService = MockFeatureFlagService(isLocalCatalogEnabled: true) + let service = await POSLocalCatalogEligibilityService( + siteID: siteID, + catalogSizeChecker: sizeChecker, + featureFlagService: featureFlagService, + isPOSTabVisible: true, + catalogSizeLimit: 1000 + ) + + let state = service.eligibilityState + + guard case .ineligible(let reason) = state else { + Issue.record("Expected ineligible state") + return + } + + guard case .catalogSizeCheckFailed(let errorString) = reason else { + Issue.record("Expected catalogSizeCheckFailed reason") + return + } + + #expect(errorString.contains("123")) + } + + // MARK: - Caching + + @Test("Second call uses cached state") + func testSecondCallUsesCachedState() async { + let sizeChecker = MockPOSCatalogSizeChecker( + sizeToReturn: .success(POSCatalogSize(productCount: 500, variationCount: 400)) + ) + let featureFlagService = MockFeatureFlagService(isLocalCatalogEnabled: true) + let service = await POSLocalCatalogEligibilityService( + siteID: siteID, + catalogSizeChecker: sizeChecker, + featureFlagService: featureFlagService, + isPOSTabVisible: true, + catalogSizeLimit: 1000 + ) + + // First call + let firstState = service.eligibilityState + #expect(firstState == .eligible) + #expect(sizeChecker.checkCatalogSizeCallCount == 1) + + // Second call should use cache + let secondState = service.eligibilityState + #expect(secondState == .eligible) + #expect(sizeChecker.checkCatalogSizeCallCount == 1) // Should not increment + } + + @Test("Refresh bypasses cache") + func testRefreshBypassesCache() async { + let sizeChecker = MockPOSCatalogSizeChecker( + sizeToReturn: .success(POSCatalogSize(productCount: 500, variationCount: 400)) + ) + let featureFlagService = MockFeatureFlagService(isLocalCatalogEnabled: true) + let service = await POSLocalCatalogEligibilityService( + siteID: siteID, + catalogSizeChecker: sizeChecker, + featureFlagService: featureFlagService, + isPOSTabVisible: true, + catalogSizeLimit: 1000 + ) + + // First call + let firstState = service.eligibilityState + #expect(firstState == .eligible) + #expect(sizeChecker.checkCatalogSizeCallCount == 1) + + // Refresh should fetch again + let refreshedState = await service.refreshEligibilityState() + #expect(refreshedState == .eligible) + #expect(sizeChecker.checkCatalogSizeCallCount == 2) // Should increment + } + + @Test("Refresh updates cache") + func testRefreshUpdatesCache() async { + let sizeChecker = MockPOSCatalogSizeChecker( + sizeToReturn: .success(POSCatalogSize(productCount: 500, variationCount: 400)) + ) + let featureFlagService = MockFeatureFlagService(isLocalCatalogEnabled: true) + let service = await POSLocalCatalogEligibilityService( + siteID: siteID, + catalogSizeChecker: sizeChecker, + featureFlagService: featureFlagService, + isPOSTabVisible: true, + catalogSizeLimit: 1000 + ) + + // First call caches eligible state + _ = service.eligibilityState + #expect(sizeChecker.checkCatalogSizeCallCount == 1) + + // Change the size checker to return ineligible + sizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 1000, variationCount: 100)) + + // Refresh should update cache + let refreshedState = await service.refreshEligibilityState() + guard case .ineligible = refreshedState else { + Issue.record("Expected ineligible after refresh") + return + } + #expect(sizeChecker.checkCatalogSizeCallCount == 2) + + // Next get should return cached ineligible + let cachedState = service.eligibilityState + guard case .ineligible = cachedState else { + Issue.record("Expected cached ineligible state") + return + } + #expect(sizeChecker.checkCatalogSizeCallCount == 2) // Should not increment + } + + // MARK: - Feature Flag + + @Test("Feature flag disabled returns ineligible") + func testFeatureFlagDisabledReturnsIneligible() async { + let sizeChecker = MockPOSCatalogSizeChecker( + sizeToReturn: .success(POSCatalogSize(productCount: 500, variationCount: 400)) + ) + let featureFlagService = MockFeatureFlagService(isLocalCatalogEnabled: false) + let service = await POSLocalCatalogEligibilityService( + siteID: siteID, + catalogSizeChecker: sizeChecker, + featureFlagService: featureFlagService, + isPOSTabVisible: true, + catalogSizeLimit: 1000 + ) + + let state = service.eligibilityState + + guard case .ineligible(let reason) = state else { + Issue.record("Expected ineligible state") + return + } + + guard case .featureFlagDisabled = reason else { + Issue.record("Expected featureFlagDisabled reason") + return + } + + // Should not have checked catalog size + #expect(sizeChecker.checkCatalogSizeCallCount == 0) + } + + // MARK: - Custom Size Limit + + @Test("Custom size limit is respected") + func testCustomSizeLimitIsRespected() async { + let sizeChecker = MockPOSCatalogSizeChecker( + sizeToReturn: .success(POSCatalogSize(productCount: 100, variationCount: 50)) + ) + let featureFlagService = MockFeatureFlagService(isLocalCatalogEnabled: true) + let service = await POSLocalCatalogEligibilityService( + siteID: siteID, + catalogSizeChecker: sizeChecker, + featureFlagService: featureFlagService, + isPOSTabVisible: true, + catalogSizeLimit: 100 // Custom lower limit + ) + + let state = service.eligibilityState + + guard case .ineligible(let reason) = state else { + Issue.record("Expected ineligible state") + return + } + + guard case .catalogSizeTooLarge(let totalCount, let limit) = reason else { + Issue.record("Expected catalogSizeTooLarge reason") + return + } + + #expect(totalCount == 150) + #expect(limit == 100) + } + + // MARK: - POS Tab Visibility + + @Test("POS tab not visible returns ineligible") + func testPOSTabNotVisibleReturnsIneligible() async { + let sizeChecker = MockPOSCatalogSizeChecker( + sizeToReturn: .success(POSCatalogSize(productCount: 500, variationCount: 400)) + ) + let featureFlagService = MockFeatureFlagService(isLocalCatalogEnabled: true) + let service = await POSLocalCatalogEligibilityService( + siteID: siteID, + catalogSizeChecker: sizeChecker, + featureFlagService: featureFlagService, + isPOSTabVisible: false, + catalogSizeLimit: 1000 + ) + + let state = service.eligibilityState + + guard case .ineligible(let reason) = state else { + Issue.record("Expected ineligible state") + return + } + + guard case .posTabNotVisible = reason else { + Issue.record("Expected posTabNotVisible reason") + return + } + + // Should not have checked catalog size + #expect(sizeChecker.checkCatalogSizeCallCount == 0) + } + + @Test("POS tab visibility checked before catalog size") + func testPOSTabVisibilityCheckedFirst() async { + let sizeChecker = MockPOSCatalogSizeChecker( + sizeToReturn: .success(POSCatalogSize(productCount: 2000, variationCount: 0)) + ) + let featureFlagService = MockFeatureFlagService(isLocalCatalogEnabled: true) + let service = await POSLocalCatalogEligibilityService( + siteID: siteID, + catalogSizeChecker: sizeChecker, + featureFlagService: featureFlagService, + isPOSTabVisible: false, + catalogSizeLimit: 1000 + ) + + // Should be ineligible due to POS tab not visible, not catalog size + guard case .ineligible(let reason) = service.eligibilityState else { + Issue.record("Expected ineligible state") + return + } + + guard case .posTabNotVisible = reason else { + Issue.record("Expected posTabNotVisible reason, not catalogSizeTooLarge") + return + } + + // Should not have checked catalog size since POS tab wasn't visible + #expect(sizeChecker.checkCatalogSizeCallCount == 0) + } +} diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSCatalogSizeChecker.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSCatalogSizeChecker.swift new file mode 100644 index 00000000000..cd90fe1e4d7 --- /dev/null +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSCatalogSizeChecker.swift @@ -0,0 +1,23 @@ +import Foundation +@testable import Yosemite + +final class MockPOSCatalogSizeChecker: POSCatalogSizeCheckerProtocol { + var sizeToReturn: Result + var checkCatalogSizeCallCount = 0 + var lastCheckedSiteID: Int64? + + init(sizeToReturn: Result = .success(POSCatalogSize(productCount: 100, variationCount: 50))) { + self.sizeToReturn = sizeToReturn + } + + func checkCatalogSize(for siteID: Int64) async throws -> POSCatalogSize { + checkCatalogSizeCallCount += 1 + lastCheckedSiteID = siteID + switch sizeToReturn { + case .success(let size): + return size + case .failure(let error): + throw error + } + } +} diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSLocalCatalogEligibilityService.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSLocalCatalogEligibilityService.swift new file mode 100644 index 00000000000..021ebbb3259 --- /dev/null +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSLocalCatalogEligibilityService.swift @@ -0,0 +1,20 @@ +import Foundation +@testable import PointOfSale + +/// Mock implementation of POSLocalCatalogEligibilityServiceProtocol for testing +@MainActor +public final class MockPOSLocalCatalogEligibilityService: POSLocalCatalogEligibilityServiceProtocol { + public var eligibilityState: POSLocalCatalogEligibilityState + public var refreshCallCount = 0 + + public init(eligibilityState: POSLocalCatalogEligibilityState = .eligible) { + self.eligibilityState = eligibilityState + } + + public func refreshEligibilityState() async -> POSLocalCatalogEligibilityState { + refreshCallCount += 1 + return eligibilityState + } + + public func updateVisibility(isPOSTabVisible: Bool) async { } +}