Skip to content

Commit 7275f21

Browse files
authored
[Local Catalog] Check remote feature flag for catalog eligibility (#16336)
2 parents ffc165a + 992cef1 commit 7275f21

File tree

4 files changed

+130
-6
lines changed

4 files changed

+130
-6
lines changed

Modules/Sources/Networking/Remote/FeatureFlagRemote.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public enum RemoteFeatureFlag: Decodable {
3030
case storeCreationCompleteNotification
3131
case pointOfSale
3232
case appPasswordsForJetpackSites
33+
case posLocalCatalogM1
3334

3435
init?(rawValue: String) {
3536
switch rawValue {
@@ -39,6 +40,8 @@ public enum RemoteFeatureFlag: Decodable {
3940
self = .pointOfSale
4041
case "woo_app_passwords_for_jetpack_sites":
4142
self = .appPasswordsForJetpackSites
43+
case "woo_pos_local_catalog_m1":
44+
self = .posLocalCatalogM1
4245
default:
4346
return nil
4447
}

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

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,29 +7,40 @@ public actor POSLocalCatalogEligibilityService: POSLocalCatalogEligibilityServic
77
private let systemStatusService: POSSystemStatusServiceProtocol
88
private let catalogSizeLimit: Int
99
private let isLocalCatalogFeatureFlagEnabled: Bool
10+
private let remoteFeatureFlagProvider: @Sendable () async -> Bool
1011

1112
// Eligibility states cached per site
1213
private var eligibilityStates: [Int64: POSLocalCatalogEligibilityState] = [:]
1314

1415
// POS eligibility states cached per site
1516
private var posEligibilityStates: [Int64: Bool] = [:]
1617

18+
// Cached remote feature flag value
19+
private var cachedRemoteFeatureFlag: Bool?
20+
1721
/// Initialize eligibility service
1822
/// - Parameters:
1923
/// - catalogSizeChecker: Service to check catalog size for sites
2024
/// - systemStatusService: Service to check WooCommerce plugin version
2125
/// - isLocalCatalogFeatureFlagEnabled: Whether the local catalog feature flag is enabled
26+
/// - remoteFeatureFlagProvider: Async closure that fetches the remote feature flag value
2227
/// - catalogSizeLimit: Maximum allowed catalog size (products + variations)
2328
public init(
2429
catalogSizeChecker: POSCatalogSizeCheckerProtocol,
2530
systemStatusService: POSSystemStatusServiceProtocol,
2631
isLocalCatalogFeatureFlagEnabled: Bool,
32+
remoteFeatureFlagProvider: @escaping @Sendable () async -> Bool,
2733
catalogSizeLimit: Int? = nil
2834
) {
2935
self.catalogSizeChecker = catalogSizeChecker
3036
self.systemStatusService = systemStatusService
3137
self.isLocalCatalogFeatureFlagEnabled = isLocalCatalogFeatureFlagEnabled
38+
self.remoteFeatureFlagProvider = remoteFeatureFlagProvider
3239
self.catalogSizeLimit = catalogSizeLimit ?? Constants.defaultCatalogSizeLimit
40+
// Eagerly start fetching the remote flag in the background
41+
Task {
42+
await self.fetchRemoteFlag()
43+
}
3344
}
3445

3546
/// Get catalog eligibility for a specific site
@@ -42,6 +53,19 @@ public actor POSLocalCatalogEligibilityService: POSLocalCatalogEligibilityServic
4253
return try await refreshEligibilityState(for: siteID)
4354
}
4455

56+
/// Fetch and cache the remote feature flag value
57+
/// Returns cached value if available, otherwise returns true (assumes eligible)
58+
private func isRemoteCatalogFeatureFlagEnabled() async -> Bool {
59+
// Return cached value if we have one
60+
return cachedRemoteFeatureFlag ?? true
61+
}
62+
63+
/// Fetch the remote feature flag value and cache it (actor-isolated)
64+
private func fetchRemoteFlag() async {
65+
let value = await remoteFeatureFlagProvider()
66+
cachedRemoteFeatureFlag = value
67+
}
68+
4569
/// Update POS eligibility and refresh catalog eligibility for the specified site
4670
/// - Parameters:
4771
/// - isEligible: Whether POS is eligible
@@ -73,11 +97,13 @@ public actor POSLocalCatalogEligibilityService: POSLocalCatalogEligibilityServic
7397
return state
7498
}
7599

76-
// Check feature flag - if disabled, no need to check catalog size
77-
guard isLocalCatalogFeatureFlagEnabled else {
100+
// Check feature flags - both local and remote must be enabled
101+
let isRemoteEnabled = await isRemoteCatalogFeatureFlagEnabled()
102+
guard isLocalCatalogFeatureFlagEnabled && isRemoteEnabled else {
78103
let state = POSLocalCatalogEligibilityState.ineligible(reason: .featureFlagDisabled)
79104
eligibilityStates[siteID] = state
80-
DDLogInfo("📋 POSLocalCatalogEligibilityService: Local catalog feature flag disabled for site \(siteID)")
105+
DDLogInfo("📋 POSLocalCatalogEligibilityService: Local catalog feature flags disabled for site \(siteID) " +
106+
"(local: \(isLocalCatalogFeatureFlagEnabled), remote: \(isRemoteEnabled))")
81107
return state
82108
}
83109

@@ -147,6 +173,26 @@ public actor POSLocalCatalogEligibilityService: POSLocalCatalogEligibilityServic
147173
}
148174
}
149175

176+
// MARK: - Factory Method
177+
178+
public extension POSLocalCatalogEligibilityService {
179+
/// Creates a remote feature flag provider closure for POS local catalog
180+
/// - Parameter dispatcher: The dispatcher to use for fetching the remote flag
181+
/// - Returns: A closure that fetches the remote feature flag value, defaulting to true if unavailable
182+
static func makeRemoteFeatureFlagProvider(dispatcher: Dispatcher) -> @Sendable () async -> Bool {
183+
return {
184+
await withCheckedContinuation { continuation in
185+
Task { @MainActor in
186+
let action = FeatureFlagAction.isRemoteFeatureFlagEnabled(.posLocalCatalogM1, defaultValue: true) { isEnabled in
187+
continuation.resume(returning: isEnabled)
188+
}
189+
dispatcher.dispatch(action)
190+
}
191+
}
192+
}
193+
}
194+
}
195+
150196
// MARK: - Constants
151197

152198
private extension POSLocalCatalogEligibilityService {

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

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import Testing
77
struct POSLocalCatalogEligibilityServiceTests {
88
private let siteID: Int64 = 123
99

10+
// Default remote feature flag provider that returns true
11+
private func makeRemoteFeatureFlagProvider(returning value: Bool = true) -> @Sendable () async -> Bool {
12+
return { value }
13+
}
14+
1015
// MARK: - Catalog Size Within Limit
1116

1217
@Test("Catalog size within limit returns eligible")
@@ -19,6 +24,7 @@ struct POSLocalCatalogEligibilityServiceTests {
1924
catalogSizeChecker: sizeChecker,
2025
systemStatusService: systemStatusService,
2126
isLocalCatalogFeatureFlagEnabled: true,
27+
remoteFeatureFlagProvider: makeRemoteFeatureFlagProvider(),
2228
catalogSizeLimit: 1000
2329
)
2430
try await service.updatePOSEligibility(isEligible: true, for: siteID)
@@ -36,6 +42,7 @@ struct POSLocalCatalogEligibilityServiceTests {
3642
catalogSizeChecker: sizeChecker,
3743
systemStatusService: systemStatusService,
3844
isLocalCatalogFeatureFlagEnabled: true,
45+
remoteFeatureFlagProvider: makeRemoteFeatureFlagProvider(),
3946
catalogSizeLimit: 1000
4047
)
4148
try await service.updatePOSEligibility(isEligible: true, for: siteID)
@@ -55,6 +62,7 @@ struct POSLocalCatalogEligibilityServiceTests {
5562
catalogSizeChecker: sizeChecker,
5663
systemStatusService: systemStatusService,
5764
isLocalCatalogFeatureFlagEnabled: true,
65+
remoteFeatureFlagProvider: makeRemoteFeatureFlagProvider(),
5866
catalogSizeLimit: 1000
5967
)
6068
try await service.updatePOSEligibility(isEligible: true, for: siteID)
@@ -88,6 +96,7 @@ struct POSLocalCatalogEligibilityServiceTests {
8896
catalogSizeChecker: sizeChecker,
8997
systemStatusService: systemStatusService,
9098
isLocalCatalogFeatureFlagEnabled: true,
99+
remoteFeatureFlagProvider: makeRemoteFeatureFlagProvider(),
91100
catalogSizeLimit: 1000
92101
)
93102
try await service.updatePOSEligibility(isEligible: true, for: siteID)
@@ -119,6 +128,7 @@ struct POSLocalCatalogEligibilityServiceTests {
119128
catalogSizeChecker: sizeChecker,
120129
systemStatusService: systemStatusService,
121130
isLocalCatalogFeatureFlagEnabled: true,
131+
remoteFeatureFlagProvider: makeRemoteFeatureFlagProvider(),
122132
catalogSizeLimit: 1000
123133
)
124134
try await service.updatePOSEligibility(isEligible: true, for: siteID)
@@ -144,6 +154,7 @@ struct POSLocalCatalogEligibilityServiceTests {
144154
catalogSizeChecker: sizeChecker,
145155
systemStatusService: systemStatusService,
146156
isLocalCatalogFeatureFlagEnabled: true,
157+
remoteFeatureFlagProvider: makeRemoteFeatureFlagProvider(),
147158
catalogSizeLimit: 1000
148159
)
149160
try await service.updatePOSEligibility(isEligible: true, for: siteID)
@@ -169,6 +180,7 @@ struct POSLocalCatalogEligibilityServiceTests {
169180
catalogSizeChecker: sizeChecker,
170181
systemStatusService: systemStatusService,
171182
isLocalCatalogFeatureFlagEnabled: true,
183+
remoteFeatureFlagProvider: makeRemoteFeatureFlagProvider(),
172184
catalogSizeLimit: 1000
173185
)
174186
try await service.updatePOSEligibility(isEligible: true, for: siteID)
@@ -199,8 +211,8 @@ struct POSLocalCatalogEligibilityServiceTests {
199211

200212
// MARK: - Feature Flag
201213

202-
@Test("Feature flag disabled returns ineligible")
203-
func testFeatureFlagDisabledReturnsIneligible() async throws {
214+
@Test("Local feature flag disabled returns ineligible")
215+
func testLocalFeatureFlagDisabledReturnsIneligible() async throws {
204216
let sizeChecker = MockPOSCatalogSizeChecker(
205217
sizeToReturn: .success(POSCatalogSize(productCount: 500, variationCount: 400))
206218
)
@@ -209,6 +221,7 @@ struct POSLocalCatalogEligibilityServiceTests {
209221
catalogSizeChecker: sizeChecker,
210222
systemStatusService: systemStatusService,
211223
isLocalCatalogFeatureFlagEnabled: false,
224+
remoteFeatureFlagProvider: makeRemoteFeatureFlagProvider(),
212225
catalogSizeLimit: 1000
213226
)
214227
try await service.updatePOSEligibility(isEligible: true, for: siteID)
@@ -229,6 +242,61 @@ struct POSLocalCatalogEligibilityServiceTests {
229242
#expect(sizeChecker.checkCatalogSizeCallCount == 0)
230243
}
231244

245+
@Test("Remote feature flag disabled returns ineligible after refresh")
246+
func testRemoteFeatureFlagDisabledReturnsIneligible() async throws {
247+
let sizeChecker = MockPOSCatalogSizeChecker(
248+
sizeToReturn: .success(POSCatalogSize(productCount: 500, variationCount: 400))
249+
)
250+
let systemStatusService = MockPOSSystemStatusService()
251+
let service = POSLocalCatalogEligibilityService(
252+
catalogSizeChecker: sizeChecker,
253+
systemStatusService: systemStatusService,
254+
isLocalCatalogFeatureFlagEnabled: true,
255+
remoteFeatureFlagProvider: makeRemoteFeatureFlagProvider(returning: false),
256+
catalogSizeLimit: 1000
257+
)
258+
try await service.updatePOSEligibility(isEligible: true, for: siteID)
259+
260+
// First refresh might check catalog (using default true before fetch completes)
261+
_ = try? await service.refreshEligibilityState(for: siteID)
262+
263+
// Second refresh should use the fetched remote flag value (false)
264+
let state = try await service.refreshEligibilityState(for: siteID)
265+
266+
guard case .ineligible(let reason) = state else {
267+
Issue.record("Expected ineligible state after remote flag fetch")
268+
return
269+
}
270+
271+
guard case .featureFlagDisabled = reason else {
272+
Issue.record("Expected featureFlagDisabled reason")
273+
return
274+
}
275+
276+
// Second refresh should not have checked catalog size (short-circuited by flag)
277+
// First refresh might have checked it (count could be 0 or 1)
278+
#expect(sizeChecker.checkCatalogSizeCallCount <= 1)
279+
}
280+
281+
@Test("Both feature flags required for eligibility")
282+
func testBothFeatureFlagsRequiredForEligibility() async throws {
283+
let sizeChecker = MockPOSCatalogSizeChecker(
284+
sizeToReturn: .success(POSCatalogSize(productCount: 500, variationCount: 400))
285+
)
286+
let systemStatusService = MockPOSSystemStatusService()
287+
288+
// Test with both flags enabled - should be eligible
289+
let serviceWithBothEnabled = POSLocalCatalogEligibilityService(
290+
catalogSizeChecker: sizeChecker,
291+
systemStatusService: systemStatusService,
292+
isLocalCatalogFeatureFlagEnabled: true,
293+
remoteFeatureFlagProvider: makeRemoteFeatureFlagProvider(returning: true),
294+
catalogSizeLimit: 1000
295+
)
296+
try await serviceWithBothEnabled.updatePOSEligibility(isEligible: true, for: siteID)
297+
#expect(try await serviceWithBothEnabled.catalogEligibility(for: siteID) == .eligible)
298+
}
299+
232300
// MARK: - Custom Size Limit
233301

234302
@Test("Custom size limit is respected")
@@ -241,6 +309,7 @@ struct POSLocalCatalogEligibilityServiceTests {
241309
catalogSizeChecker: sizeChecker,
242310
systemStatusService: systemStatusService,
243311
isLocalCatalogFeatureFlagEnabled: true,
312+
remoteFeatureFlagProvider: makeRemoteFeatureFlagProvider(),
244313
catalogSizeLimit: 100 // Custom lower limit
245314
)
246315
try await service.updatePOSEligibility(isEligible: true, for: siteID)
@@ -273,6 +342,7 @@ struct POSLocalCatalogEligibilityServiceTests {
273342
catalogSizeChecker: sizeChecker,
274343
systemStatusService: systemStatusService,
275344
isLocalCatalogFeatureFlagEnabled: true,
345+
remoteFeatureFlagProvider: makeRemoteFeatureFlagProvider(),
276346
catalogSizeLimit: 1000
277347
)
278348

@@ -305,6 +375,7 @@ struct POSLocalCatalogEligibilityServiceTests {
305375
catalogSizeChecker: sizeChecker,
306376
systemStatusService: systemStatusService,
307377
isLocalCatalogFeatureFlagEnabled: true,
378+
remoteFeatureFlagProvider: makeRemoteFeatureFlagProvider(),
308379
catalogSizeLimit: 1000
309380
)
310381

@@ -336,6 +407,7 @@ struct POSLocalCatalogEligibilityServiceTests {
336407
catalogSizeChecker: sizeChecker,
337408
systemStatusService: systemStatusService,
338409
isLocalCatalogFeatureFlagEnabled: true,
410+
remoteFeatureFlagProvider: makeRemoteFeatureFlagProvider(),
339411
catalogSizeLimit: 1000
340412
)
341413

@@ -390,6 +462,7 @@ struct POSLocalCatalogEligibilityServiceTests {
390462
catalogSizeChecker: sizeChecker,
391463
systemStatusService: systemStatusService,
392464
isLocalCatalogFeatureFlagEnabled: true,
465+
remoteFeatureFlagProvider: makeRemoteFeatureFlagProvider(),
393466
catalogSizeLimit: 1000
394467
)
395468
try await service.updatePOSEligibility(isEligible: true, for: siteID)
@@ -449,6 +522,7 @@ struct POSLocalCatalogEligibilityServiceTests {
449522
catalogSizeChecker: sizeChecker,
450523
systemStatusService: systemStatusService,
451524
isLocalCatalogFeatureFlagEnabled: true,
525+
remoteFeatureFlagProvider: makeRemoteFeatureFlagProvider(),
452526
catalogSizeLimit: 1000
453527
)
454528
try await service.updatePOSEligibility(isEligible: true, for: siteID)

WooCommerce/Classes/Yosemite/AuthenticatedState.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,8 @@ class AuthenticatedState: StoresManagerState {
195195
appPasswordSupportState: appPasswordSupportState.eraseToAnyPublisher(),
196196
storageManager: ServiceLocator.storageManager
197197
),
198-
isLocalCatalogFeatureFlagEnabled: isLocalCatalogFeatureFlagEnabled
198+
isLocalCatalogFeatureFlagEnabled: isLocalCatalogFeatureFlagEnabled,
199+
remoteFeatureFlagProvider: POSLocalCatalogEligibilityService.makeRemoteFeatureFlagProvider(dispatcher: dispatcher)
199200
)
200201
posCatalogEligibilityChecker = eligibilityService
201202

0 commit comments

Comments
 (0)