From ba12d08909e222de9b624e97613ed3864e8e7d23 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Thu, 6 Nov 2025 11:19:52 +0000 Subject: [PATCH 1/3] Use non-analytics variations endpoint for syncs --- .../Sources/Networking/Remote/POSCatalogSyncRemote.swift | 6 +++--- .../NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift index 31901636e8f..ce4e3a31fb5 100644 --- a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift +++ b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift @@ -134,7 +134,7 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol { ] let request = JetpackRequest( - wooApiVersion: .wcAnalytics, + wooApiVersion: .mark3, method: .get, siteID: siteID, path: path, @@ -249,7 +249,7 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol { ] let request = JetpackRequest( - wooApiVersion: .wcAnalytics, + wooApiVersion: .mark3, method: .get, siteID: siteID, path: path, @@ -303,7 +303,7 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol { ] let request = JetpackRequest( - wooApiVersion: .wcAnalytics, + wooApiVersion: .mark3, method: .get, siteID: siteID, path: path, diff --git a/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift b/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift index 664b05ae153..722cb7037ab 100644 --- a/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift +++ b/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift @@ -584,7 +584,6 @@ struct POSCatalogSyncRemoteTests { _ = try? await remote.getProductVariationCount(siteID: sampleSiteID) // Then - verify API versions match the data endpoints - // Products should use .mark3, variations should use .wcAnalytics (based on the load endpoints) // This is verified by checking that the correct paths are called let requests = network.requestsForResponseData.compactMap { $0 as? JetpackRequest } #expect(requests.contains { $0.path.contains("products") }) From 4e0ffb1d6d01295a6256fc8bebcf99c6078be2b7 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Thu, 6 Nov 2025 11:50:11 +0000 Subject: [PATCH 2/3] Require Woo 10.3 to use local catalog --- ...calCatalogEligibilityServiceProtocol.swift | 1 + .../POSLocalCatalogEligibilityService.swift | 40 +++++ .../Mocks/MockPOSSystemStatusService.swift | 40 +++++ ...SLocalCatalogEligibilityServiceTests.swift | 138 ++++++++++++++++++ .../Classes/Yosemite/AuthenticatedState.swift | 6 + 5 files changed, 225 insertions(+) create mode 100644 Modules/Tests/YosemiteTests/Mocks/MockPOSSystemStatusService.swift diff --git a/Modules/Sources/Yosemite/Protocols/POSLocalCatalogEligibilityServiceProtocol.swift b/Modules/Sources/Yosemite/Protocols/POSLocalCatalogEligibilityServiceProtocol.swift index 749d02764ff..9faef02d4d2 100644 --- a/Modules/Sources/Yosemite/Protocols/POSLocalCatalogEligibilityServiceProtocol.swift +++ b/Modules/Sources/Yosemite/Protocols/POSLocalCatalogEligibilityServiceProtocol.swift @@ -14,6 +14,7 @@ public enum POSLocalCatalogEligibilityState: Equatable { public enum POSLocalCatalogIneligibleReason: Equatable { case posTabNotEligible case featureFlagDisabled + case unsupportedWooCommerceVersion(minimumVersion: String) case catalogSizeTooLarge(totalCount: Int, limit: Int) case catalogSizeCheckFailed(underlyingError: String) } diff --git a/Modules/Sources/Yosemite/Tools/POS/POSLocalCatalogEligibilityService.swift b/Modules/Sources/Yosemite/Tools/POS/POSLocalCatalogEligibilityService.swift index dfd91e0dc2a..76f705e5452 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSLocalCatalogEligibilityService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSLocalCatalogEligibilityService.swift @@ -1,8 +1,10 @@ import Foundation import Alamofire +import WooFoundationCore public actor POSLocalCatalogEligibilityService: POSLocalCatalogEligibilityServiceProtocol { private let catalogSizeChecker: POSCatalogSizeCheckerProtocol + private let systemStatusService: POSSystemStatusServiceProtocol private let catalogSizeLimit: Int private let isLocalCatalogFeatureFlagEnabled: Bool @@ -15,14 +17,17 @@ public actor POSLocalCatalogEligibilityService: POSLocalCatalogEligibilityServic /// Initialize eligibility service /// - Parameters: /// - catalogSizeChecker: Service to check catalog size for sites + /// - systemStatusService: Service to check WooCommerce plugin version /// - isLocalCatalogFeatureFlagEnabled: Whether the local catalog feature flag is enabled /// - catalogSizeLimit: Maximum allowed catalog size (products + variations) public init( catalogSizeChecker: POSCatalogSizeCheckerProtocol, + systemStatusService: POSSystemStatusServiceProtocol, isLocalCatalogFeatureFlagEnabled: Bool, catalogSizeLimit: Int? = nil ) { self.catalogSizeChecker = catalogSizeChecker + self.systemStatusService = systemStatusService self.isLocalCatalogFeatureFlagEnabled = isLocalCatalogFeatureFlagEnabled self.catalogSizeLimit = catalogSizeLimit ?? Constants.defaultCatalogSizeLimit } @@ -76,6 +81,40 @@ public actor POSLocalCatalogEligibilityService: POSLocalCatalogEligibilityServic return state } + // Check WooCommerce version - local catalog requires 10.3.0 or higher + do { + let pluginInfo = try await systemStatusService.loadWooCommercePluginAndPOSFeatureSwitch(siteID: siteID) + + guard let wcPlugin = pluginInfo.wcPlugin, wcPlugin.active else { + let state = POSLocalCatalogEligibilityState.ineligible(reason: .posTabNotEligible) + eligibilityStates[siteID] = state + DDLogInfo("📋 POSLocalCatalogEligibilityService: WooCommerce plugin not found or inactive for site \(siteID)") + return state + } + + guard VersionHelpers.isVersionSupported(version: wcPlugin.version, + minimumRequired: Constants.wcPluginMinimumVersionForLocalCatalog) else { + let state = POSLocalCatalogEligibilityState.ineligible( + reason: .unsupportedWooCommerceVersion(minimumVersion: Constants.wcPluginMinimumVersionForLocalCatalog) + ) + eligibilityStates[siteID] = state + DDLogInfo("📋 POSLocalCatalogEligibilityService: WooCommerce version \(wcPlugin.version) below minimum \(Constants.wcPluginMinimumVersionForLocalCatalog) for site \(siteID)") + return state + } + + DDLogInfo("📋 POSLocalCatalogEligibilityService: WooCommerce version \(wcPlugin.version) meets minimum requirement for site \(siteID)") + } catch AFError.explicitlyCancelled, is CancellationError { + throw POSCatalogSyncError.requestCancelled + } catch { + let errorString = String(describing: error) + let state = POSLocalCatalogEligibilityState.ineligible( + reason: .catalogSizeCheckFailed(underlyingError: errorString) + ) + eligibilityStates[siteID] = state + DDLogError("📋 POSLocalCatalogEligibilityService: Failed to check WooCommerce version for site \(siteID): \(error)") + return state + } + // Fetch remote catalog size and check against limit do { let size = try await catalogSizeChecker.checkCatalogSize(for: siteID) @@ -112,5 +151,6 @@ public actor POSLocalCatalogEligibilityService: POSLocalCatalogEligibilityServic private extension POSLocalCatalogEligibilityService { enum Constants { static let defaultCatalogSizeLimit = 1000 + static let wcPluginMinimumVersionForLocalCatalog = "10.3.0-beta" } } diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSSystemStatusService.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSSystemStatusService.swift new file mode 100644 index 00000000000..98ed431a810 --- /dev/null +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSSystemStatusService.swift @@ -0,0 +1,40 @@ +import Foundation +import Networking +@testable import Yosemite + +final class MockPOSSystemStatusService: POSSystemStatusServiceProtocol { + var pluginInfoToReturn: Result + var loadPluginCallCount = 0 + var lastCheckedSiteID: Int64? + + init(pluginInfoToReturn: Result = .success( + POSPluginAndFeatureInfo( + wcPlugin: SystemPlugin( + siteID: 123, + plugin: "woocommerce/woocommerce.php", + name: "WooCommerce", + version: "10.3.0", + versionLatest: "10.3.0", + url: "https://woocommerce.com", + authorName: "WooCommerce", + authorUrl: "https://woocommerce.com", + networkActivated: false, + active: true + ), + featureValue: true + ) + )) { + self.pluginInfoToReturn = pluginInfoToReturn + } + + func loadWooCommercePluginAndPOSFeatureSwitch(siteID: Int64) async throws -> POSPluginAndFeatureInfo { + loadPluginCallCount += 1 + lastCheckedSiteID = siteID + switch pluginInfoToReturn { + case .success(let info): + return info + case .failure(let error): + throw error + } + } +} diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSLocalCatalogEligibilityServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSLocalCatalogEligibilityServiceTests.swift index 7100873f10e..66a6841fbf0 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSLocalCatalogEligibilityServiceTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSLocalCatalogEligibilityServiceTests.swift @@ -14,8 +14,10 @@ struct POSLocalCatalogEligibilityServiceTests { let sizeChecker = MockPOSCatalogSizeChecker( sizeToReturn: .success(POSCatalogSize(productCount: 500, variationCount: 400)) ) + let systemStatusService = MockPOSSystemStatusService() let service = POSLocalCatalogEligibilityService( catalogSizeChecker: sizeChecker, + systemStatusService: systemStatusService, isLocalCatalogFeatureFlagEnabled: true, catalogSizeLimit: 1000 ) @@ -29,8 +31,10 @@ struct POSLocalCatalogEligibilityServiceTests { let sizeChecker = MockPOSCatalogSizeChecker( sizeToReturn: .success(POSCatalogSize(productCount: 600, variationCount: 400)) ) + let systemStatusService = MockPOSSystemStatusService() let service = POSLocalCatalogEligibilityService( catalogSizeChecker: sizeChecker, + systemStatusService: systemStatusService, isLocalCatalogFeatureFlagEnabled: true, catalogSizeLimit: 1000 ) @@ -46,8 +50,10 @@ struct POSLocalCatalogEligibilityServiceTests { let sizeChecker = MockPOSCatalogSizeChecker( sizeToReturn: .success(POSCatalogSize(productCount: 501, variationCount: 500)) ) + let systemStatusService = MockPOSSystemStatusService() let service = POSLocalCatalogEligibilityService( catalogSizeChecker: sizeChecker, + systemStatusService: systemStatusService, isLocalCatalogFeatureFlagEnabled: true, catalogSizeLimit: 1000 ) @@ -77,8 +83,10 @@ struct POSLocalCatalogEligibilityServiceTests { let sizeChecker = MockPOSCatalogSizeChecker( sizeToReturn: .failure(expectedError) ) + let systemStatusService = MockPOSSystemStatusService() let service = POSLocalCatalogEligibilityService( catalogSizeChecker: sizeChecker, + systemStatusService: systemStatusService, isLocalCatalogFeatureFlagEnabled: true, catalogSizeLimit: 1000 ) @@ -106,8 +114,10 @@ struct POSLocalCatalogEligibilityServiceTests { let sizeChecker = MockPOSCatalogSizeChecker( sizeToReturn: .success(POSCatalogSize(productCount: 500, variationCount: 400)) ) + let systemStatusService = MockPOSSystemStatusService() let service = POSLocalCatalogEligibilityService( catalogSizeChecker: sizeChecker, + systemStatusService: systemStatusService, isLocalCatalogFeatureFlagEnabled: true, catalogSizeLimit: 1000 ) @@ -129,8 +139,10 @@ struct POSLocalCatalogEligibilityServiceTests { let sizeChecker = MockPOSCatalogSizeChecker( sizeToReturn: .success(POSCatalogSize(productCount: 500, variationCount: 400)) ) + let systemStatusService = MockPOSSystemStatusService() let service = POSLocalCatalogEligibilityService( catalogSizeChecker: sizeChecker, + systemStatusService: systemStatusService, isLocalCatalogFeatureFlagEnabled: true, catalogSizeLimit: 1000 ) @@ -152,8 +164,10 @@ struct POSLocalCatalogEligibilityServiceTests { let sizeChecker = MockPOSCatalogSizeChecker( sizeToReturn: .success(POSCatalogSize(productCount: 500, variationCount: 400)) ) + let systemStatusService = MockPOSSystemStatusService() let service = POSLocalCatalogEligibilityService( catalogSizeChecker: sizeChecker, + systemStatusService: systemStatusService, isLocalCatalogFeatureFlagEnabled: true, catalogSizeLimit: 1000 ) @@ -190,8 +204,10 @@ struct POSLocalCatalogEligibilityServiceTests { let sizeChecker = MockPOSCatalogSizeChecker( sizeToReturn: .success(POSCatalogSize(productCount: 500, variationCount: 400)) ) + let systemStatusService = MockPOSSystemStatusService() let service = POSLocalCatalogEligibilityService( catalogSizeChecker: sizeChecker, + systemStatusService: systemStatusService, isLocalCatalogFeatureFlagEnabled: false, catalogSizeLimit: 1000 ) @@ -220,8 +236,10 @@ struct POSLocalCatalogEligibilityServiceTests { let sizeChecker = MockPOSCatalogSizeChecker( sizeToReturn: .success(POSCatalogSize(productCount: 100, variationCount: 50)) ) + let systemStatusService = MockPOSSystemStatusService() let service = POSLocalCatalogEligibilityService( catalogSizeChecker: sizeChecker, + systemStatusService: systemStatusService, isLocalCatalogFeatureFlagEnabled: true, catalogSizeLimit: 100 // Custom lower limit ) @@ -250,8 +268,10 @@ struct POSLocalCatalogEligibilityServiceTests { let sizeChecker = MockPOSCatalogSizeChecker( sizeToReturn: .success(POSCatalogSize(productCount: 500, variationCount: 400)) ) + let systemStatusService = MockPOSSystemStatusService() let service = POSLocalCatalogEligibilityService( catalogSizeChecker: sizeChecker, + systemStatusService: systemStatusService, isLocalCatalogFeatureFlagEnabled: true, catalogSizeLimit: 1000 ) @@ -280,8 +300,10 @@ struct POSLocalCatalogEligibilityServiceTests { let sizeChecker = MockPOSCatalogSizeChecker( sizeToReturn: .success(POSCatalogSize(productCount: 2000, variationCount: 0)) ) + let systemStatusService = MockPOSSystemStatusService() let service = POSLocalCatalogEligibilityService( catalogSizeChecker: sizeChecker, + systemStatusService: systemStatusService, isLocalCatalogFeatureFlagEnabled: true, catalogSizeLimit: 1000 ) @@ -309,8 +331,10 @@ struct POSLocalCatalogEligibilityServiceTests { let sizeChecker = MockPOSCatalogSizeChecker( sizeToReturn: .success(POSCatalogSize(productCount: 500, variationCount: 400)) ) + let systemStatusService = MockPOSSystemStatusService() let service = POSLocalCatalogEligibilityService( catalogSizeChecker: sizeChecker, + systemStatusService: systemStatusService, isLocalCatalogFeatureFlagEnabled: true, catalogSizeLimit: 1000 ) @@ -324,4 +348,118 @@ struct POSLocalCatalogEligibilityServiceTests { // Should have checked catalog size since POS was eligible #expect(sizeChecker.checkCatalogSizeCallCount == 1) } + + // MARK: - WooCommerce Version Checking + + @Test("WooCommerce version eligibility", + arguments: [ + ("10.2.0", true, false, 0), // Below minimum + ("10.3.0-beta", true, true, 1), // At minimum (beta) + ("10.3.0", true, true, 1), // At minimum (stable) + ("11.0.0", true, true, 1), // Above minimum + ]) + func testWooCommerceVersionEligibility( + version: String, + isActive: Bool, + expectEligible: Bool, + expectedSizeCheckCount: Int + ) async throws { + let sizeChecker = MockPOSCatalogSizeChecker( + sizeToReturn: .success(POSCatalogSize(productCount: 500, variationCount: 400)) + ) + let systemStatusService = MockPOSSystemStatusService( + pluginInfoToReturn: .success( + POSPluginAndFeatureInfo( + wcPlugin: SystemPlugin( + siteID: siteID, + plugin: "woocommerce/woocommerce.php", + name: "WooCommerce", + version: version, + versionLatest: "11.0.0", + url: "https://woocommerce.com", + authorName: "WooCommerce", + authorUrl: "https://woocommerce.com", + networkActivated: false, + active: isActive + ), + featureValue: true + ) + ) + ) + let service = POSLocalCatalogEligibilityService( + catalogSizeChecker: sizeChecker, + systemStatusService: systemStatusService, + isLocalCatalogFeatureFlagEnabled: true, + catalogSizeLimit: 1000 + ) + try await service.updatePOSEligibility(isEligible: true, for: siteID) + + let state = try await service.catalogEligibility(for: siteID) + + if expectEligible { + #expect(state == .eligible) + } else { + guard case .ineligible(let reason) = state else { + Issue.record("Expected ineligible state for version \(version)") + return + } + guard case .unsupportedWooCommerceVersion(let minimumVersion) = reason else { + Issue.record("Expected unsupportedWooCommerceVersion reason for version \(version)") + return + } + #expect(minimumVersion == "10.3.0-beta") + } + #expect(sizeChecker.checkCatalogSizeCallCount == expectedSizeCheckCount) + } + + @Test("WooCommerce plugin states", + arguments: [ + (nil, POSLocalCatalogIneligibleReason.posTabNotEligible), // Plugin not found + (false, POSLocalCatalogIneligibleReason.posTabNotEligible), // Plugin inactive + ]) + func testWooCommercePluginStates( + isActive: Bool?, + expectedReason: POSLocalCatalogIneligibleReason + ) async throws { + let sizeChecker = MockPOSCatalogSizeChecker( + sizeToReturn: .success(POSCatalogSize(productCount: 500, variationCount: 400)) + ) + + let wcPlugin: SystemPlugin? = isActive.map { active in + SystemPlugin( + siteID: siteID, + plugin: "woocommerce/woocommerce.php", + name: "WooCommerce", + version: "10.3.0", + versionLatest: "10.3.0", + url: "https://woocommerce.com", + authorName: "WooCommerce", + authorUrl: "https://woocommerce.com", + networkActivated: false, + active: active + ) + } + + let systemStatusService = MockPOSSystemStatusService( + pluginInfoToReturn: .success( + POSPluginAndFeatureInfo(wcPlugin: wcPlugin, featureValue: true) + ) + ) + let service = POSLocalCatalogEligibilityService( + catalogSizeChecker: sizeChecker, + systemStatusService: systemStatusService, + isLocalCatalogFeatureFlagEnabled: true, + catalogSizeLimit: 1000 + ) + try await service.updatePOSEligibility(isEligible: true, for: siteID) + + let state = try await service.catalogEligibility(for: siteID) + + guard case .ineligible(let reason) = state else { + Issue.record("Expected ineligible state") + return + } + #expect(reason == expectedReason) + #expect(sizeChecker.checkCatalogSizeCallCount == 0) + } } diff --git a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift index 1465150bf2f..bb1e7a39823 100644 --- a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift +++ b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift @@ -189,6 +189,12 @@ class AuthenticatedState: StoresManagerState { selectedSite: site, appPasswordSupportState: appPasswordSupportState.eraseToAnyPublisher() ), + systemStatusService: POSSystemStatusService( + credentials: credentials, + selectedSite: site, + appPasswordSupportState: appPasswordSupportState.eraseToAnyPublisher(), + storageManager: ServiceLocator.storageManager + ), isLocalCatalogFeatureFlagEnabled: isLocalCatalogFeatureFlagEnabled ) posCatalogEligibilityChecker = eligibilityService From c48b5cbf4f29bbdc32a9325a97f1b6d65b3b580d Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Thu, 6 Nov 2025 12:47:54 +0000 Subject: [PATCH 3/3] Fix lint --- .../Yosemite/Tools/POS/POSLocalCatalogEligibilityService.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/Yosemite/Tools/POS/POSLocalCatalogEligibilityService.swift b/Modules/Sources/Yosemite/Tools/POS/POSLocalCatalogEligibilityService.swift index 76f705e5452..da1bd124e9c 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSLocalCatalogEligibilityService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSLocalCatalogEligibilityService.swift @@ -98,7 +98,8 @@ public actor POSLocalCatalogEligibilityService: POSLocalCatalogEligibilityServic reason: .unsupportedWooCommerceVersion(minimumVersion: Constants.wcPluginMinimumVersionForLocalCatalog) ) eligibilityStates[siteID] = state - DDLogInfo("📋 POSLocalCatalogEligibilityService: WooCommerce version \(wcPlugin.version) below minimum \(Constants.wcPluginMinimumVersionForLocalCatalog) for site \(siteID)") + DDLogInfo("📋 POSLocalCatalogEligibilityService: WooCommerce version \(wcPlugin.version) below minimum" + + "\(Constants.wcPluginMinimumVersionForLocalCatalog) for site \(siteID)") return state }