Skip to content

Commit 79ac3b9

Browse files
authored
[Local catalog] Feature eligibility checker (#16276)
2 parents 8b6f4c3 + 1ba7a70 commit 79ac3b9

18 files changed

+684
-41
lines changed

Modules/Sources/PointOfSale/Models/PointOfSaleSettingsController.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ protocol PointOfSaleSettingsControllerProtocol {
1515
var connectedCardReader: CardPresentPaymentCardReader? { get }
1616
var storeViewModel: POSSettingsStoreViewModel { get }
1717
var localCatalogViewModel: POSSettingsLocalCatalogViewModel? { get }
18+
var isLocalCatalogEligible: Bool { get }
1819
}
1920

2021
@Observable final class PointOfSaleSettingsController: PointOfSaleSettingsControllerProtocol {
@@ -23,6 +24,7 @@ protocol PointOfSaleSettingsControllerProtocol {
2324

2425
let storeViewModel: POSSettingsStoreViewModel
2526
let localCatalogViewModel: POSSettingsLocalCatalogViewModel?
27+
let isLocalCatalogEligible: Bool
2628

2729
init(siteID: Int64,
2830
settingsService: PointOfSaleSettingsServiceProtocol,
@@ -31,12 +33,15 @@ protocol PointOfSaleSettingsControllerProtocol {
3133
defaultSiteName: String?,
3234
siteSettings: [SiteSetting],
3335
grdbManager: GRDBManagerProtocol?,
34-
catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol?) {
36+
catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol?,
37+
isLocalCatalogEligible: Bool) {
3538
self.storeViewModel = POSSettingsStoreViewModel(siteID: siteID,
3639
settingsService: settingsService,
3740
pluginsService: pluginsService,
3841
defaultSiteName: defaultSiteName,
3942
siteSettings: siteSettings)
43+
self.isLocalCatalogEligible = isLocalCatalogEligible
44+
4045
if let catalogSyncCoordinator, let grdbManager {
4146
self.localCatalogViewModel = POSSettingsLocalCatalogViewModel(
4247
siteID: siteID,
@@ -80,6 +85,10 @@ final class PointOfSaleSettingsPreviewController: PointOfSaleSettingsControllerP
8085
siteSettings: [])
8186

8287
var localCatalogViewModel: POSSettingsLocalCatalogViewModel?
88+
89+
var isLocalCatalogEligible: Bool {
90+
localCatalogViewModel != nil
91+
}
8392
}
8493

8594
final class MockPointOfSaleSettingsService: PointOfSaleSettingsServiceProtocol {

Modules/Sources/PointOfSale/Presentation/PointOfSaleEntryPointView.swift

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,13 @@ public struct PointOfSaleEntryPointView: View {
6363
siteSettings: [SiteSetting],
6464
grdbManager: GRDBManagerProtocol?,
6565
catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol?,
66+
isLocalCatalogEligible: Bool,
6667
services: POSDependencyProviding) {
6768
self.onPointOfSaleModeActiveStateChange = onPointOfSaleModeActiveStateChange
6869

69-
// Use observable controller with GRDB if available and feature flag is enabled, otherwise fall back to standard controller
70-
// Note: We check feature flag here for eligibility. Once eligibility checking is
71-
// refactored to be more centralized, this check can be simplified.
72-
let isGRDBEnabled = services.featureFlags.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1)
73-
if let grdbManager = grdbManager, catalogSyncCoordinator != nil, isGRDBEnabled {
70+
// Use observable controller with GRDB if local catalog is eligible,
71+
// otherwise fall back to standard controller.
72+
if isLocalCatalogEligible, let grdbManager = grdbManager {
7473
self.itemsController = PointOfSaleObservableItemsController(
7574
siteID: siteID,
7675
grdbManager: grdbManager,
@@ -113,7 +112,8 @@ public struct PointOfSaleEntryPointView: View {
113112
defaultSiteName: defaultSiteName,
114113
siteSettings: siteSettings,
115114
grdbManager: grdbManager,
116-
catalogSyncCoordinator: catalogSyncCoordinator)
115+
catalogSyncCoordinator: catalogSyncCoordinator,
116+
isLocalCatalogEligible: isLocalCatalogEligible)
117117
self.collectOrderPaymentAnalyticsTracker = collectOrderPaymentAnalyticsTracker
118118
self.searchHistoryService = searchHistoryService
119119
self.popularPurchasableItemsController = PointOfSaleItemsController(
@@ -203,6 +203,7 @@ public struct PointOfSaleEntryPointView: View {
203203
siteSettings: [],
204204
grdbManager: nil,
205205
catalogSyncCoordinator: nil,
206+
isLocalCatalogEligible: false,
206207
services: POSPreviewServices()
207208
)
208209
}

Modules/Sources/PointOfSale/Presentation/Settings/PointOfSaleSettingsView.swift

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import SwiftUI
33
struct PointOfSaleSettingsView: View {
44
@Environment(\.dismiss) private var dismiss
55
@Environment(\.posAnalytics) private var analytics
6-
@Environment(\.posFeatureFlags) private var featureFlags
76
@State private var selection: SidebarNavigation? = .store
87

98
let settingsController: PointOfSaleSettingsControllerProtocol
@@ -55,8 +54,7 @@ extension PointOfSaleSettingsView {
5554
}
5655
)
5756

58-
// TODO: WOOMOB-1287 - integrate with local catalog feature eligibility
59-
if featureFlags.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1) && settingsController.localCatalogViewModel != nil {
57+
if settingsController.isLocalCatalogEligible {
6058
PointOfSaleSettingsCard(
6159
item: .localCatalog,
6260
isSelected: selection == .localCatalog,
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import Foundation
2+
3+
/// Eligibility state for local catalog feature
4+
/// Provides diagnostic information for UI display and decision-making
5+
public enum POSLocalCatalogEligibilityState: Equatable {
6+
/// Local catalog is eligible for use
7+
case eligible
8+
9+
/// Local catalog is not eligible
10+
case ineligible(reason: POSLocalCatalogIneligibleReason)
11+
}
12+
13+
/// Reasons why local catalog is ineligible
14+
public enum POSLocalCatalogIneligibleReason: Equatable {
15+
case posTabNotVisible
16+
case featureFlagDisabled
17+
case catalogSizeTooLarge(totalCount: Int, limit: Int)
18+
case catalogSizeCheckFailed(underlyingError: String)
19+
}
20+
21+
/// Service that provides eligibility information for local catalog feature
22+
///
23+
/// Other services can query this for eligibility state and reasons:
24+
/// - Sync coordinator can check if catalog is eligible
25+
/// - Settings UI can display eligibility status and reasons
26+
/// - Analytics can track why stores are ineligible
27+
///
28+
/// NOTE: This service checks catalog-related eligibility (size limits) and feature flag state.
29+
/// The service performs an initial eligibility check during initialization.
30+
public protocol POSLocalCatalogEligibilityServiceProtocol {
31+
/// Current eligibility state (synchronously accessible on main thread)
32+
var eligibilityState: POSLocalCatalogEligibilityState { get }
33+
34+
/// Update the POS tab visibility state and refresh eligibility
35+
/// - Parameter isPOSTabVisible: Whether the POS tab is visible
36+
func updateVisibility(isPOSTabVisible: Bool) async
37+
38+
/// Force refresh eligibility (bypasses cache and updates eligibilityState)
39+
/// - Returns: Fresh eligibility state with reason if ineligible
40+
@discardableResult func refreshEligibilityState() async -> POSLocalCatalogEligibilityState
41+
}

Modules/Sources/Yosemite/Tools/POS/POSCatalogSizeChecker.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Foundation
22
import Networking
3+
import Combine
34

45
/// Protocol for checking the size of a remote POS catalog
56
public protocol POSCatalogSizeCheckerProtocol {
@@ -18,6 +19,15 @@ public struct POSCatalogSizeChecker: POSCatalogSizeCheckerProtocol {
1819
self.syncRemote = syncRemote
1920
}
2021

22+
public init(credentials: Credentials?,
23+
selectedSite: AnyPublisher<JetpackSite?, Never>,
24+
appPasswordSupportState: AnyPublisher<Bool, Never>) {
25+
let syncRemote = POSCatalogSyncRemote(network: AlamofireNetwork(credentials: credentials,
26+
selectedSite: selectedSite,
27+
appPasswordSupportState: appPasswordSupportState))
28+
self.init(syncRemote: syncRemote)
29+
}
30+
2131
public func checkCatalogSize(for siteID: Int64) async throws -> POSCatalogSize {
2232
// Make concurrent requests to get both counts
2333
async let productCount = syncRemote.getProductCount(siteID: siteID)

Modules/Tests/PointOfSaleTests/Controllers/PointOfSaleSettingsControllerTests.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Foundation
44
@testable import Yosemite
55
import Storage
66

7+
@MainActor
78
struct PointOfSaleSettingsControllerTests {
89
private let mockSettingsService = MockPointOfSaleSettingsService()
910
private let mockCardPresentPaymentService = MockCardPresentPaymentService()
@@ -19,7 +20,8 @@ struct PointOfSaleSettingsControllerTests {
1920
defaultSiteName: "Test Store",
2021
siteSettings: [],
2122
grdbManager: nil,
22-
catalogSyncCoordinator: nil)
23+
catalogSyncCoordinator: nil,
24+
isLocalCatalogEligible: true)
2325

2426
// When
2527
let cardReader = sut.connectedCardReader
@@ -38,7 +40,8 @@ struct PointOfSaleSettingsControllerTests {
3840
defaultSiteName: "Test Store",
3941
siteSettings: [],
4042
grdbManager: nil,
41-
catalogSyncCoordinator: nil)
43+
catalogSyncCoordinator: nil,
44+
isLocalCatalogEligible: true)
4245

4346
// Initially nil
4447
#expect(sut.connectedCardReader == nil)
@@ -80,4 +83,5 @@ final class MockPointOfSaleSettingsController: PointOfSaleSettingsControllerProt
8083
defaultSiteName: "Sample Store",
8184
siteSettings: [])
8285
var localCatalogViewModel: POSSettingsLocalCatalogViewModel?
86+
var isLocalCatalogEligible = true
8387
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import Foundation
2+
@testable import PointOfSale
3+
4+
/// Mock implementation of POSLocalCatalogEligibilityServiceProtocol for testing
5+
@MainActor
6+
public final class MockPOSLocalCatalogEligibilityService: POSLocalCatalogEligibilityServiceProtocol {
7+
public var eligibilityState: POSLocalCatalogEligibilityState
8+
public var refreshCallCount = 0
9+
10+
public init(eligibilityState: POSLocalCatalogEligibilityState = .eligible) {
11+
self.eligibilityState = eligibilityState
12+
}
13+
14+
public func refreshEligibilityState() async -> POSLocalCatalogEligibilityState {
15+
refreshCallCount += 1
16+
return eligibilityState
17+
}
18+
19+
public func updateVisibility(isPOSTabVisible: Bool) async { }
20+
}

Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSizeChecker.swift

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,18 @@ import Foundation
22
@testable import Yosemite
33

44
final class MockPOSCatalogSizeChecker: POSCatalogSizeCheckerProtocol {
5-
// MARK: - checkCatalogSize tracking
6-
private(set) var checkCatalogSizeCallCount = 0
7-
private(set) var lastCheckedSiteID: Int64?
8-
var checkCatalogSizeResult: Result<POSCatalogSize, Error> = .success(POSCatalogSize(productCount: 100, variationCount: 50)) // 150 total - well under limit
5+
var sizeToReturn: Result<POSCatalogSize, Error>
6+
var checkCatalogSizeCallCount = 0
7+
var lastCheckedSiteID: Int64?
8+
9+
init(sizeToReturn: Result<POSCatalogSize, Error> = .success(POSCatalogSize(productCount: 100, variationCount: 50))) {
10+
self.sizeToReturn = sizeToReturn
11+
}
912

1013
func checkCatalogSize(for siteID: Int64) async throws -> POSCatalogSize {
1114
checkCatalogSizeCallCount += 1
1215
lastCheckedSiteID = siteID
13-
14-
switch checkCatalogSizeResult {
16+
switch sizeToReturn {
1517
case .success(let size):
1618
return size
1719
case .failure(let error):

Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ struct POSCatalogSyncCoordinatorTests {
150150

151151
@Test func performFullSyncIfApplicable_skips_sync_when_catalog_size_exceeds_limit() async throws {
152152
// Given - catalog size is above the 1000 item limit
153-
mockCatalogSizeChecker.checkCatalogSizeResult = .success(POSCatalogSize(productCount: 800, variationCount: 300)) // 1100 total
153+
mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 800, variationCount: 300)) // 1100 total
154154
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: nil)
155155

156156
// When
@@ -164,7 +164,7 @@ struct POSCatalogSyncCoordinatorTests {
164164

165165
@Test func performFullSyncIfApplicable_starts_sync_when_catalog_size_is_at_limit() async throws {
166166
// Given - catalog size is exactly at the 1000 item limit
167-
mockCatalogSizeChecker.checkCatalogSizeResult = .success(POSCatalogSize(productCount: 600, variationCount: 400)) // 1000 total
167+
mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 600, variationCount: 400)) // 1000 total
168168
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: nil)
169169

170170
// When
@@ -178,7 +178,7 @@ struct POSCatalogSyncCoordinatorTests {
178178

179179
@Test func performFullSyncIfApplicable_starts_sync_when_catalog_size_is_under_limit() async throws {
180180
// Given - catalog size is below the 1000 item limit
181-
mockCatalogSizeChecker.checkCatalogSizeResult = .success(POSCatalogSize(productCount: 300, variationCount: 200)) // 500 total
181+
mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 300, variationCount: 200)) // 500 total
182182
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: nil)
183183

184184
// When
@@ -193,7 +193,7 @@ struct POSCatalogSyncCoordinatorTests {
193193
@Test func performFullSyncIfApplicable_skips_sync_when_catalog_size_check_fails() async throws {
194194
// Given - catalog size check throws an error
195195
let sizeCheckError = NSError(domain: "size_check", code: 500, userInfo: [NSLocalizedDescriptionKey: "Network error"])
196-
mockCatalogSizeChecker.checkCatalogSizeResult = .failure(sizeCheckError)
196+
mockCatalogSizeChecker.sizeToReturn = .failure(sizeCheckError)
197197
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: nil)
198198

199199
// When
@@ -207,7 +207,7 @@ struct POSCatalogSyncCoordinatorTests {
207207

208208
@Test func performFullSyncIfApplicable_respects_time_only_when_catalog_size_is_acceptable() async throws {
209209
// Given - catalog size is acceptable but sync is recent
210-
mockCatalogSizeChecker.checkCatalogSizeResult = .success(POSCatalogSize(productCount: 200, variationCount: 100)) // 300 total
210+
mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 200, variationCount: 100)) // 300 total
211211
let thirtyMinutesAgo = Date().addingTimeInterval(-30 * 60)
212212
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: thirtyMinutesAgo)
213213

@@ -504,7 +504,7 @@ struct POSCatalogSyncCoordinatorTests {
504504
@Test(arguments: [.zero, 60 * 60])
505505
func performIncrementalSyncIfApplicable_skips_sync_when_catalog_size_exceeds_limit(maxAge: TimeInterval) async throws {
506506
// Given - catalog size is above the 1000 item limit
507-
mockCatalogSizeChecker.checkCatalogSizeResult = .success(POSCatalogSize(productCount: 700, variationCount: 400)) // 1100 total
507+
mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 700, variationCount: 400)) // 1100 total
508508
let fullSyncDate = Date().addingTimeInterval(-3600)
509509
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: fullSyncDate)
510510

@@ -520,7 +520,7 @@ struct POSCatalogSyncCoordinatorTests {
520520
@Test(arguments: [.zero, 60 * 60])
521521
func performIncrementalSyncIfApplicable_performs_sync_when_catalog_size_is_at_limit(maxAge: TimeInterval) async throws {
522522
// Given - catalog size is exactly at the 1000 item limit
523-
mockCatalogSizeChecker.checkCatalogSizeResult = .success(POSCatalogSize(productCount: 500, variationCount: 500)) // 1000 total
523+
mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 500, variationCount: 500)) // 1000 total
524524
let fullSyncDate = Date().addingTimeInterval(-3600)
525525
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: fullSyncDate)
526526

@@ -536,7 +536,7 @@ struct POSCatalogSyncCoordinatorTests {
536536
@Test(arguments: [.zero, 60 * 60])
537537
func performIncrementalSyncIfApplicable_performs_sync_when_catalog_size_is_under_limit(maxAge: TimeInterval) async throws {
538538
// Given - catalog size is below the 1000 item limit
539-
mockCatalogSizeChecker.checkCatalogSizeResult = .success(POSCatalogSize(productCount: 200, variationCount: 150)) // 350 total
539+
mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 200, variationCount: 150)) // 350 total
540540
let fullSyncDate = Date().addingTimeInterval(-3600)
541541
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: fullSyncDate)
542542

@@ -553,7 +553,7 @@ struct POSCatalogSyncCoordinatorTests {
553553
func performIncrementalSyncIfApplicable_skips_sync_when_catalog_size_check_fails(maxAge: TimeInterval) async throws {
554554
// Given - catalog size check throws an error
555555
let sizeCheckError = NSError(domain: "size_check", code: 500, userInfo: [NSLocalizedDescriptionKey: "Network error"])
556-
mockCatalogSizeChecker.checkCatalogSizeResult = .failure(sizeCheckError)
556+
mockCatalogSizeChecker.sizeToReturn = .failure(sizeCheckError)
557557
let fullSyncDate = Date().addingTimeInterval(-3600)
558558
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: fullSyncDate)
559559

@@ -568,7 +568,7 @@ struct POSCatalogSyncCoordinatorTests {
568568

569569
@Test func performIncrementalSyncIfApplicable_checks_size_before_age_check() async throws {
570570
// Given - catalog is over limit but would otherwise sync due to age
571-
mockCatalogSizeChecker.checkCatalogSizeResult = .success(POSCatalogSize(productCount: 800, variationCount: 300)) // 1100 total
571+
mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 800, variationCount: 300)) // 1100 total
572572
let maxAge: TimeInterval = 2
573573
let staleIncrementalSyncDate = Date().addingTimeInterval(-(maxAge + 1)) // Older than max age
574574
let fullSyncDate = Date().addingTimeInterval(-3600)

WooCommerce/Classes/POS/Adaptors/POSServiceLocatorAdaptor.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import protocol PointOfSale.POSExternalNavigationProviding
1414
import protocol PointOfSale.POSExternalViewProviding
1515

1616
final class POSServiceLocatorAdaptor: POSDependencyProviding {
17+
init() {
18+
}
19+
1720
var analytics: POSAnalyticsProviding {
1821
POSAnalyticsAdaptor()
1922
}

0 commit comments

Comments
 (0)