From bc688c5c8ba366441f4064c30bd4d592517c4249 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Wed, 8 Oct 2025 21:10:36 +0800 Subject: [PATCH 01/16] Add back catalog endpoints with full sync integration. --- .../Remote/POSCatalogSyncRemote.swift | 156 ++++++++++++++++++ .../Tools/POS/POSCatalogFullSyncService.swift | 59 ++++++- .../Tools/POS/POSCatalogSyncCoordinator.swift | 5 +- 3 files changed, 218 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift index b7854e37543..894ab396bf9 100644 --- a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift +++ b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift @@ -24,6 +24,35 @@ public protocol POSCatalogSyncRemoteProtocol { // periphery:ignore func loadProductVariations(modifiedAfter: Date, siteID: Int64, pageNumber: Int) async throws -> PagedItems + /// Generates a POS catalog. The catalog is generated asynchronously and a download URL is returned in the + /// status response endpoint associated with a job ID. + /// + /// - Parameters: + /// - siteID: Site ID to generate catalog for. + /// - fields: Optional array of fields to include in catalog. + /// - Returns: Catalog job response with job ID. + /// + // periphery:ignore - TODO - remove this periphery ignore comment when this endpoint is integrated with catalog sync + func generateCatalog(for siteID: Int64) async throws -> POSCatalogGenerationResponse + + /// Checks the status of a catalog generation job. A download URL is returned when the job is complete. + /// + /// - Parameters: + /// - siteID: Site ID for the catalog job. + /// - jobID: Job ID to check status for. + /// - Returns: Catalog status response. + /// + // periphery:ignore - TODO - remove this periphery ignore comment when this endpoint is integrated with catalog sync + func checkCatalogStatus(for siteID: Int64, jobID: String) async throws -> POSCatalogStatusResponse + + /// Downloads the generated catalog at the specified download URL. + /// - Parameters: + /// - siteID: Site ID to download catalog for. + /// - downloadURL: Download URL of the catalog file. + /// - Returns: List of products and variations in the POS catalog. + // periphery:ignore - TODO - remove this periphery ignore comment when this method is integrated with catalog sync + func downloadCatalog(for siteID: Int64, downloadURL: String) async throws -> POSCatalogResponse + /// Loads POS products for full sync. /// /// - Parameters: @@ -127,6 +156,61 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol { // MARK: - Full Sync Endpoints + /// Generates a POS catalog. The catalog is generated asynchronously and a download URL is returned in the + /// status response endpoint associated with a job ID. + /// + /// - Parameters: + /// - siteID: Site ID to generate catalog for. + /// - fields: Optional array of fields to include in catalog. + /// - Returns: Catalog job response with job ID. + /// + // periphery:ignore - TODO - remove this periphery ignore comment when this endpoint is integrated with catalog sync + public func generateCatalog(for siteID: Int64) async throws -> POSCatalogGenerationResponse { + let path = "catalog" + let parameters: [String: Any] = [ + ParameterKey.fullSyncFields: POSProduct.requestFields + ] + let request = JetpackRequest(wooApiVersion: .mark3, method: .post, siteID: siteID, path: path, parameters: parameters, availableAsRESTRequest: true) + let mapper = SingleItemMapper(siteID: siteID) + return try await enqueue(request, mapper: mapper) + } + + /// Checks the status of a catalog generation job. A download URL is returned when the job is complete. + /// + /// - Parameters: + /// - siteID: Site ID for the catalog job. + /// - jobID: Job ID to check status for. + /// - Returns: Catalog status response. + /// + // periphery:ignore - TODO - remove this periphery ignore comment when this endpoint is integrated with catalog sync + public func checkCatalogStatus(for siteID: Int64, jobID: String) async throws -> POSCatalogStatusResponse { + let path = "catalog/status/\(jobID)" + let request = JetpackRequest(wooApiVersion: .mark3, method: .get, siteID: siteID, path: path, availableAsRESTRequest: true) + let mapper = SingleItemMapper(siteID: siteID) + return try await enqueue(request, mapper: mapper) + } + + /// Downloads the generated catalog at the specified download URL. + /// - Parameters: + /// - siteID: Site ID to download catalog for. + /// - downloadURL: Download URL of the catalog file. + /// - Returns: List of products and variations in the POS catalog. + // periphery:ignore - TODO - remove this periphery ignore comment when this method is integrated with catalog sync + public func downloadCatalog(for siteID: Int64, downloadURL: String) async throws -> POSCatalogResponse { + // TODO: WOOMOB-1173 - move download task to the background using `URLSessionConfiguration.background` + guard let url = URL(string: downloadURL) else { + throw NetworkError.invalidURL + } + let request = URLRequest(url: url) + let mapper = ListMapper(siteID: siteID) + let items = try await enqueue(request, mapper: mapper) + let variationProductTypeKey = "variation" + let products = items.filter { $0.productTypeKey != variationProductTypeKey } + let variations = items.filter { $0.productTypeKey == variationProductTypeKey } + .map { $0.toVariation } + return POSCatalogResponse(products: products, variations: variations) + } + /// Loads POS products for full sync. /// /// - Parameters: @@ -252,6 +336,7 @@ private extension POSCatalogSyncRemote { static let page = "page" static let perPage = "per_page" static let fields = "_fields" + static let fullSyncFields = "fields" } enum Path { @@ -259,3 +344,74 @@ private extension POSCatalogSyncRemote { static let variations = "variations" } } + +// MARK: - Response Models + +/// Response from catalog generation request. +// periphery:ignore - TODO - remove this periphery ignore comment when the corresponding endpoint is integrated with catalog sync +public struct POSCatalogGenerationResponse: Decodable { + /// Unique identifier for tracking the catalog generation job. + public let jobID: String + + private enum CodingKeys: String, CodingKey { + case jobID = "job_id" + } +} + +/// Response from catalog status check. +// periphery:ignore - TODO - remove this periphery ignore comment when the corresponding endpoint is integrated with catalog sync +public struct POSCatalogStatusResponse: Decodable { + /// Current status of the catalog generation job. + public let status: POSCatalogStatus + /// Download URL for the completed catalog (available when status is complete). + public let downloadURL: String? + /// Progress percentage of the catalog generation (0.0 to 100.0). + public let progress: Double + + private enum CodingKeys: String, CodingKey { + case status + case downloadURL = "download_url" + case progress + } +} + +/// Catalog generation status. +public enum POSCatalogStatus: String, Decodable { + case pending + case processing + case complete + case failed +} + +/// POS catalog from download. +// periphery:ignore - TODO - remove this periphery ignore comment when the corresponding endpoint is integrated with catalog sync +public struct POSCatalogResponse { + public let products: [POSProduct] + public let variations: [POSProductVariation] +} + +private extension POSProduct { + var toVariation: POSProductVariation { + let variationAttributes = attributes.compactMap { attribute in + try? attribute.toProductVariationAttribute() + } + + let firstImage = images.first + + return .init( + siteID: siteID, + productID: parentID, + productVariationID: productID, + attributes: variationAttributes, + image: firstImage, + fullDescription: fullDescription, + sku: sku, + globalUniqueID: globalUniqueID, + price: price, + downloadable: downloadable, + manageStock: manageStock, + stockQuantity: stockQuantity, + stockStatusKey: stockStatusKey + ) + } +} diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift index 77002f485e2..1ba8465b6f0 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift @@ -5,6 +5,7 @@ import class Networking.POSCatalogSyncRemote import Storage import struct Combine.AnyPublisher import struct NetworkingCore.JetpackSite +import struct Networking.POSCatalogResponse // TODO - remove the periphery ignore comment when the catalog is integrated with POS. // periphery:ignore @@ -64,9 +65,16 @@ public final class POSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol public func startFullSync(for siteID: Int64) async throws -> POSCatalog { DDLogInfo("🔄 Starting full catalog sync for site ID: \(siteID)") + let usesCatalogEndpoint = false + do { // Sync from network - let catalog = try await loadCatalog(for: siteID, syncRemote: syncRemote) + let catalog: POSCatalog + if usesCatalogEndpoint { + catalog = try await loadCatalogFromCatalogEndpoint(for: siteID, syncRemote: syncRemote) + } else { + catalog = try await loadCatalog(for: siteID, syncRemote: syncRemote) + } DDLogInfo("✅ Loaded \(catalog.products.count) products and \(catalog.variations.count) variations for siteID \(siteID)") // Persist to database @@ -102,4 +110,53 @@ private extension POSCatalogFullSyncService { return POSCatalog(products: products, variations: variations, syncDate: syncStartDate) } + func loadCatalogFromCatalogEndpoint(for siteID: Int64, syncRemote: POSCatalogSyncRemoteProtocol) async throws -> POSCatalog { + let downloadStartTime = CFAbsoluteTimeGetCurrent() + let catalog = try await downloadCatalog(for: siteID, syncRemote: syncRemote) + let downloadTime = CFAbsoluteTimeGetCurrent() - downloadStartTime + print("đŸŸŖ Download completed - Time: \(String(format: "%.2f", downloadTime))s") + + return .init(products: catalog.products, variations: catalog.variations, syncDate: .init()) + } +} + +private extension POSCatalogFullSyncService { + func downloadCatalog(for siteID: Int64, syncRemote: POSCatalogSyncRemoteProtocol) async throws -> POSCatalogResponse { + print("đŸŸŖ Starting catalog generation...") + + // 1. Generate catalog and get job ID + let jobResponse = try await syncRemote.generateCatalog(for: siteID) + print("đŸŸŖ Catalog generation started - Job ID: \(jobResponse.jobID)") + + // 2. Poll for completion + let downloadURL = try await pollForCatalogCompletion(jobID: jobResponse.jobID, siteID: siteID, syncRemote: syncRemote) + print("đŸŸŖ Catalog ready for download: \(downloadURL)") + + // 3. Download using the provided URL + return try await syncRemote.downloadCatalog(for: siteID, downloadURL: downloadURL) + } + + func pollForCatalogCompletion(jobID: String, siteID: Int64, syncRemote: POSCatalogSyncRemoteProtocol) async throws -> String { + let maxAttempts = 1000 // each attempt is made 1 second after the last one completes + var attempts = 0 + + while attempts < maxAttempts { + let statusResponse = try await syncRemote.checkCatalogStatus(for: siteID, jobID: jobID) + + switch statusResponse.status { + case .complete: + guard let downloadURL = statusResponse.downloadURL else { + throw POSCatalogSyncError.invalidData + } + return downloadURL + + case .pending, .processing: + print("đŸŸŖ Catalog generation \(statusResponse.status) at \(statusResponse.progress)%... (attempt \(attempts + 1)/\(maxAttempts))") + try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second + attempts += 1 + } + } + + throw POSCatalogSyncError.timeout + } } diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift index 50cf47ddb93..42f75680a12 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift @@ -47,6 +47,8 @@ public extension POSCatalogSyncCoordinatorProtocol { public enum POSCatalogSyncError: Error, Equatable { case syncAlreadyInProgress(siteID: Int64) case negativeMaxAge + case invalidData + case timeout } public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { @@ -278,6 +280,7 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { private extension POSCatalogSyncCoordinator { enum Constants { - static let defaultSizeLimitForPOSCatalog = 1000 + // Temporary high limit to allow large catalog size + static let defaultSizeLimitForPOSCatalog = 100000000 } } From cf11514fbf7afe156d46d309f0b32b185d8b25a1 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Tue, 21 Oct 2025 15:17:34 +0800 Subject: [PATCH 02/16] Update catalog remote based on the latest API behavior. --- .../Remote/POSCatalogSyncRemote.swift | 24 +++++++++++------ .../Tools/POS/POSCatalogFullSyncService.swift | 26 +++++++++++++------ .../Tools/POS/POSCatalogSyncCoordinator.swift | 1 + .../ForegroundPOSCatalogSyncDispatcher.swift | 6 +++++ 4 files changed, 41 insertions(+), 16 deletions(-) diff --git a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift index 894ab396bf9..42df6bd4ad3 100644 --- a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift +++ b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift @@ -166,9 +166,11 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol { /// // periphery:ignore - TODO - remove this periphery ignore comment when this endpoint is integrated with catalog sync public func generateCatalog(for siteID: Int64) async throws -> POSCatalogGenerationResponse { - let path = "catalog" + let path = "products/catalog/generate" let parameters: [String: Any] = [ - ParameterKey.fullSyncFields: POSProduct.requestFields + ParameterKey.fullSyncFields: POSProduct.requestFields, + // TODO: make it a parameter + "force_generate": true ] let request = JetpackRequest(wooApiVersion: .mark3, method: .post, siteID: siteID, path: path, parameters: parameters, availableAsRESTRequest: true) let mapper = SingleItemMapper(siteID: siteID) @@ -184,8 +186,11 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol { /// // periphery:ignore - TODO - remove this periphery ignore comment when this endpoint is integrated with catalog sync public func checkCatalogStatus(for siteID: Int64, jobID: String) async throws -> POSCatalogStatusResponse { - let path = "catalog/status/\(jobID)" - let request = JetpackRequest(wooApiVersion: .mark3, method: .get, siteID: siteID, path: path, availableAsRESTRequest: true) + let path = "products/catalog/status" + let parameters: [String: Any] = [ + "job_id": jobID + ] + let request = JetpackRequest(wooApiVersion: .mark3, method: .get, siteID: siteID, path: path, parameters: parameters, availableAsRESTRequest: true) let mapper = SingleItemMapper(siteID: siteID) return try await enqueue(request, mapper: mapper) } @@ -351,27 +356,30 @@ private extension POSCatalogSyncRemote { // periphery:ignore - TODO - remove this periphery ignore comment when the corresponding endpoint is integrated with catalog sync public struct POSCatalogGenerationResponse: Decodable { /// Unique identifier for tracking the catalog generation job. - public let jobID: String + public let jobID: String? + /// Download URL when it is already available. + public let downloadURL: String? private enum CodingKeys: String, CodingKey { case jobID = "job_id" + case downloadURL = "download_url" } } /// Response from catalog status check. // periphery:ignore - TODO - remove this periphery ignore comment when the corresponding endpoint is integrated with catalog sync public struct POSCatalogStatusResponse: Decodable { + /// Unique identifier for tracking the catalog generation job. + public let jobID: String /// Current status of the catalog generation job. public let status: POSCatalogStatus /// Download URL for the completed catalog (available when status is complete). public let downloadURL: String? - /// Progress percentage of the catalog generation (0.0 to 100.0). - public let progress: Double private enum CodingKeys: String, CodingKey { + case jobID = "job_id" case status case downloadURL = "download_url" - case progress } } diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift index 1ba8465b6f0..8bc5302c711 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift @@ -65,7 +65,7 @@ public final class POSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol public func startFullSync(for siteID: Int64) async throws -> POSCatalog { DDLogInfo("🔄 Starting full catalog sync for site ID: \(siteID)") - let usesCatalogEndpoint = false + let usesCatalogEndpoint = true do { // Sync from network @@ -126,13 +126,22 @@ private extension POSCatalogFullSyncService { // 1. Generate catalog and get job ID let jobResponse = try await syncRemote.generateCatalog(for: siteID) - print("đŸŸŖ Catalog generation started - Job ID: \(jobResponse.jobID)") - - // 2. Poll for completion - let downloadURL = try await pollForCatalogCompletion(jobID: jobResponse.jobID, siteID: siteID, syncRemote: syncRemote) - print("đŸŸŖ Catalog ready for download: \(downloadURL)") + let downloadURL: String? + if let url = jobResponse.downloadURL { + downloadURL = url + print("đŸŸŖ Catalog ready for download: \(url)") + } else if let jobID = jobResponse.jobID { + // 2. Poll for completion + downloadURL = try await pollForCatalogCompletion(jobID: jobID, siteID: siteID, syncRemote: syncRemote) + print("đŸŸŖ Catalog generation started") + } else { + downloadURL = nil + } // 3. Download using the provided URL + guard let downloadURL else { + throw POSCatalogSyncError.invalidData + } return try await syncRemote.downloadCatalog(for: siteID, downloadURL: downloadURL) } @@ -149,11 +158,12 @@ private extension POSCatalogFullSyncService { throw POSCatalogSyncError.invalidData } return downloadURL - case .pending, .processing: - print("đŸŸŖ Catalog generation \(statusResponse.status) at \(statusResponse.progress)%... (attempt \(attempts + 1)/\(maxAttempts))") + print("đŸŸŖ Catalog generation \(statusResponse.status)... (attempt \(attempts + 1)/\(maxAttempts))") try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second attempts += 1 + case .failed: + throw POSCatalogSyncError.generationFailed } } diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift index 42f75680a12..d87f16ac372 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift @@ -49,6 +49,7 @@ public enum POSCatalogSyncError: Error, Equatable { case negativeMaxAge case invalidData case timeout + case generationFailed } public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { diff --git a/WooCommerce/Classes/Tools/BackgroundTasks/ForegroundPOSCatalogSyncDispatcher.swift b/WooCommerce/Classes/Tools/BackgroundTasks/ForegroundPOSCatalogSyncDispatcher.swift index 2741c66e098..c1129c8b4ba 100644 --- a/WooCommerce/Classes/Tools/BackgroundTasks/ForegroundPOSCatalogSyncDispatcher.swift +++ b/WooCommerce/Classes/Tools/BackgroundTasks/ForegroundPOSCatalogSyncDispatcher.swift @@ -160,6 +160,12 @@ final actor ForegroundPOSCatalogSyncDispatcher { DDLogInfo("â„šī¸ ForegroundPOSCatalogSyncDispatcher: Sync already in progress for site \(siteID)") case .negativeMaxAge: DDLogError("â›”ī¸ ForegroundPOSCatalogSyncDispatcher: Invalid max age for site \(siteID)") + case .invalidData: + DDLogError("â›”ī¸ ForegroundPOSCatalogSyncDispatcher: Invalid data encountered during sync for site \(siteID)") + case .timeout: + DDLogError("â›”ī¸ ForegroundPOSCatalogSyncDispatcher: Sync timed out for site \(siteID)") + case .generationFailed: + DDLogError("â›”ī¸ ForegroundPOSCatalogSyncDispatcher: Sync generation failed for site \(siteID)") } } catch { DDLogError("â›”ī¸ ForegroundPOSCatalogSyncDispatcher: Sync failed for site \(siteID): \(error)") From 87489b4f1ebe23a0604cf8aa364eb87390666cf3 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Wed, 22 Oct 2025 20:41:38 +0800 Subject: [PATCH 03/16] Update catalog remote to call one endpoint. --- .../Remote/POSCatalogSyncRemote.swift | 76 +++++-------------- .../Tools/POS/POSCatalogFullSyncService.swift | 20 +++-- 2 files changed, 27 insertions(+), 69 deletions(-) diff --git a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift index 42df6bd4ad3..1231f9cc5bb 100644 --- a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift +++ b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift @@ -24,26 +24,15 @@ public protocol POSCatalogSyncRemoteProtocol { // periphery:ignore func loadProductVariations(modifiedAfter: Date, siteID: Int64, pageNumber: Int) async throws -> PagedItems - /// Generates a POS catalog. The catalog is generated asynchronously and a download URL is returned in the - /// status response endpoint associated with a job ID. + /// Starts generation of a POS catalog. + /// The catalog is generated asynchronously and a download URL may be returned when the file is ready. /// /// - Parameters: /// - siteID: Site ID to generate catalog for. - /// - fields: Optional array of fields to include in catalog. /// - Returns: Catalog job response with job ID. /// // periphery:ignore - TODO - remove this periphery ignore comment when this endpoint is integrated with catalog sync - func generateCatalog(for siteID: Int64) async throws -> POSCatalogGenerationResponse - - /// Checks the status of a catalog generation job. A download URL is returned when the job is complete. - /// - /// - Parameters: - /// - siteID: Site ID for the catalog job. - /// - jobID: Job ID to check status for. - /// - Returns: Catalog status response. - /// - // periphery:ignore - TODO - remove this periphery ignore comment when this endpoint is integrated with catalog sync - func checkCatalogStatus(for siteID: Int64, jobID: String) async throws -> POSCatalogStatusResponse + func requestCatalogGeneration(for siteID: Int64) async throws -> POSCatalogRequestResponse /// Downloads the generated catalog at the specified download URL. /// - Parameters: @@ -156,42 +145,30 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol { // MARK: - Full Sync Endpoints - /// Generates a POS catalog. The catalog is generated asynchronously and a download URL is returned in the - /// status response endpoint associated with a job ID. + /// Starts generation of a POS catalog. + /// The catalog is generated asynchronously and a download URL may be returned immediately or via the status response endpoint associated with a job ID. /// /// - Parameters: /// - siteID: Site ID to generate catalog for. - /// - fields: Optional array of fields to include in catalog. /// - Returns: Catalog job response with job ID. /// // periphery:ignore - TODO - remove this periphery ignore comment when this endpoint is integrated with catalog sync - public func generateCatalog(for siteID: Int64) async throws -> POSCatalogGenerationResponse { - let path = "products/catalog/generate" + public func requestCatalogGeneration(for siteID: Int64) async throws -> POSCatalogRequestResponse { + let path = "products/catalog" let parameters: [String: Any] = [ ParameterKey.fullSyncFields: POSProduct.requestFields, // TODO: make it a parameter "force_generate": true ] - let request = JetpackRequest(wooApiVersion: .mark3, method: .post, siteID: siteID, path: path, parameters: parameters, availableAsRESTRequest: true) - let mapper = SingleItemMapper(siteID: siteID) - return try await enqueue(request, mapper: mapper) - } - - /// Checks the status of a catalog generation job. A download URL is returned when the job is complete. - /// - /// - Parameters: - /// - siteID: Site ID for the catalog job. - /// - jobID: Job ID to check status for. - /// - Returns: Catalog status response. - /// - // periphery:ignore - TODO - remove this periphery ignore comment when this endpoint is integrated with catalog sync - public func checkCatalogStatus(for siteID: Int64, jobID: String) async throws -> POSCatalogStatusResponse { - let path = "products/catalog/status" - let parameters: [String: Any] = [ - "job_id": jobID - ] - let request = JetpackRequest(wooApiVersion: .mark3, method: .get, siteID: siteID, path: path, parameters: parameters, availableAsRESTRequest: true) - let mapper = SingleItemMapper(siteID: siteID) + let request = JetpackRequest( + wooApiVersion: .mark3, + method: .post, + siteID: siteID, + path: path, + parameters: parameters, + availableAsRESTRequest: true + ) + let mapper = SingleItemMapper(siteID: siteID) return try await enqueue(request, mapper: mapper) } @@ -354,30 +331,13 @@ private extension POSCatalogSyncRemote { /// Response from catalog generation request. // periphery:ignore - TODO - remove this periphery ignore comment when the corresponding endpoint is integrated with catalog sync -public struct POSCatalogGenerationResponse: Decodable { - /// Unique identifier for tracking the catalog generation job. - public let jobID: String? - /// Download URL when it is already available. - public let downloadURL: String? - - private enum CodingKeys: String, CodingKey { - case jobID = "job_id" - case downloadURL = "download_url" - } -} - -/// Response from catalog status check. -// periphery:ignore - TODO - remove this periphery ignore comment when the corresponding endpoint is integrated with catalog sync -public struct POSCatalogStatusResponse: Decodable { - /// Unique identifier for tracking the catalog generation job. - public let jobID: String +public struct POSCatalogRequestResponse: Decodable { /// Current status of the catalog generation job. public let status: POSCatalogStatus - /// Download URL for the completed catalog (available when status is complete). + /// Download URL when it is already available. public let downloadURL: String? private enum CodingKeys: String, CodingKey { - case jobID = "job_id" case status case downloadURL = "download_url" } diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift index 8bc5302c711..15201f8f38e 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift @@ -125,17 +125,15 @@ private extension POSCatalogFullSyncService { print("đŸŸŖ Starting catalog generation...") // 1. Generate catalog and get job ID - let jobResponse = try await syncRemote.generateCatalog(for: siteID) + let response = try await syncRemote.requestCatalogGeneration(for: siteID) let downloadURL: String? - if let url = jobResponse.downloadURL { + if let url = response.downloadURL { downloadURL = url print("đŸŸŖ Catalog ready for download: \(url)") - } else if let jobID = jobResponse.jobID { + } else { // 2. Poll for completion - downloadURL = try await pollForCatalogCompletion(jobID: jobID, siteID: siteID, syncRemote: syncRemote) + downloadURL = try await pollForCatalogCompletion(siteID: siteID, syncRemote: syncRemote) print("đŸŸŖ Catalog generation started") - } else { - downloadURL = nil } // 3. Download using the provided URL @@ -145,21 +143,21 @@ private extension POSCatalogFullSyncService { return try await syncRemote.downloadCatalog(for: siteID, downloadURL: downloadURL) } - func pollForCatalogCompletion(jobID: String, siteID: Int64, syncRemote: POSCatalogSyncRemoteProtocol) async throws -> String { + func pollForCatalogCompletion(siteID: Int64, syncRemote: POSCatalogSyncRemoteProtocol) async throws -> String { let maxAttempts = 1000 // each attempt is made 1 second after the last one completes var attempts = 0 while attempts < maxAttempts { - let statusResponse = try await syncRemote.checkCatalogStatus(for: siteID, jobID: jobID) + let response = try await syncRemote.requestCatalogGeneration(for: siteID) - switch statusResponse.status { + switch response.status { case .complete: - guard let downloadURL = statusResponse.downloadURL else { + guard let downloadURL = response.downloadURL else { throw POSCatalogSyncError.invalidData } return downloadURL case .pending, .processing: - print("đŸŸŖ Catalog generation \(statusResponse.status)... (attempt \(attempts + 1)/\(maxAttempts))") + print("đŸŸŖ Catalog generation \(response.status)... (attempt \(attempts + 1)/\(maxAttempts))") try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second attempts += 1 case .failed: From 7acf2bb13a7c08dc36752fe989e1fa78191d2942 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Thu, 30 Oct 2025 10:27:36 +0800 Subject: [PATCH 04/16] Add feature flag for catalog API. --- Modules/Sources/Experiments/DefaultFeatureFlagService.swift | 2 ++ Modules/Sources/Experiments/FeatureFlag.swift | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/Modules/Sources/Experiments/DefaultFeatureFlagService.swift b/Modules/Sources/Experiments/DefaultFeatureFlagService.swift index 7dbc908dd7d..35d93100b77 100644 --- a/Modules/Sources/Experiments/DefaultFeatureFlagService.swift +++ b/Modules/Sources/Experiments/DefaultFeatureFlagService.swift @@ -100,6 +100,8 @@ public struct DefaultFeatureFlagService: FeatureFlagService { return buildConfig == .localDeveloper || buildConfig == .alpha case .pointOfSaleSurveys: return buildConfig == .localDeveloper || buildConfig == .alpha + case .pointOfSaleCatalogAPI: + return false default: return true } diff --git a/Modules/Sources/Experiments/FeatureFlag.swift b/Modules/Sources/Experiments/FeatureFlag.swift index baeada3cb49..669088111dd 100644 --- a/Modules/Sources/Experiments/FeatureFlag.swift +++ b/Modules/Sources/Experiments/FeatureFlag.swift @@ -207,4 +207,8 @@ public enum FeatureFlag: Int { /// Enables surveys for potential and current POS merchants /// case pointOfSaleSurveys + + /// Enables using the catalog API endpoint for Point of Sale catalog sync + /// + case pointOfSaleCatalogAPI } From b5925acc3f558bc4bb48b9776f11032ceb986b86 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Thu, 30 Oct 2025 13:38:35 +0800 Subject: [PATCH 05/16] DI feature flag value to use catalog API. Add support for `force_generate` parameter. --- .../Remote/POSCatalogSyncRemote.swift | 9 +++++---- .../Tools/POS/POSCatalogFullSyncService.swift | 18 ++++++++++-------- .../Classes/Yosemite/AuthenticatedState.swift | 3 ++- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift index 1231f9cc5bb..51ba5c61b0d 100644 --- a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift +++ b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift @@ -29,10 +29,11 @@ public protocol POSCatalogSyncRemoteProtocol { /// /// - Parameters: /// - siteID: Site ID to generate catalog for. + /// - forceGeneration: Whether to always generate a catalog. /// - Returns: Catalog job response with job ID. /// // periphery:ignore - TODO - remove this periphery ignore comment when this endpoint is integrated with catalog sync - func requestCatalogGeneration(for siteID: Int64) async throws -> POSCatalogRequestResponse + func requestCatalogGeneration(for siteID: Int64, forceGeneration: Bool) async throws -> POSCatalogRequestResponse /// Downloads the generated catalog at the specified download URL. /// - Parameters: @@ -153,12 +154,11 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol { /// - Returns: Catalog job response with job ID. /// // periphery:ignore - TODO - remove this periphery ignore comment when this endpoint is integrated with catalog sync - public func requestCatalogGeneration(for siteID: Int64) async throws -> POSCatalogRequestResponse { + public func requestCatalogGeneration(for siteID: Int64, forceGeneration: Bool) async throws -> POSCatalogRequestResponse { let path = "products/catalog" let parameters: [String: Any] = [ ParameterKey.fullSyncFields: POSProduct.requestFields, - // TODO: make it a parameter - "force_generate": true + ParameterKey.forceGenerate: forceGeneration ] let request = JetpackRequest( wooApiVersion: .mark3, @@ -319,6 +319,7 @@ private extension POSCatalogSyncRemote { static let perPage = "per_page" static let fields = "_fields" static let fullSyncFields = "fields" + static let forceGenerate = "force_generate" } enum Path { diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift index 15201f8f38e..1b5091000a2 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift @@ -31,12 +31,14 @@ public final class POSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol private let syncRemote: POSCatalogSyncRemoteProtocol private let persistenceService: POSCatalogPersistenceServiceProtocol private let batchedLoader: BatchedRequestLoader + private let usesCatalogAPI: Bool public convenience init?(credentials: Credentials?, selectedSite: AnyPublisher, appPasswordSupportState: AnyPublisher, batchSize: Int = 2, - grdbManager: GRDBManagerProtocol) { + grdbManager: GRDBManagerProtocol, + usesCatalogAPI: Bool = false) { guard let credentials else { DDLogError("â›”ī¸ Could not create POSCatalogFullSyncService due missing credentials") return nil @@ -46,18 +48,20 @@ public final class POSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol appPasswordSupportState: appPasswordSupportState) let syncRemote = POSCatalogSyncRemote(network: network) let persistenceService = POSCatalogPersistenceService(grdbManager: grdbManager) - self.init(syncRemote: syncRemote, batchSize: batchSize, persistenceService: persistenceService) + self.init(syncRemote: syncRemote, batchSize: batchSize, persistenceService: persistenceService, usesCatalogAPI: usesCatalogAPI) } init( syncRemote: POSCatalogSyncRemoteProtocol, batchSize: Int, retryDelay: TimeInterval = 2.0, - persistenceService: POSCatalogPersistenceServiceProtocol + persistenceService: POSCatalogPersistenceServiceProtocol, + usesCatalogAPI: Bool = false ) { self.syncRemote = syncRemote self.persistenceService = persistenceService self.batchedLoader = BatchedRequestLoader(batchSize: batchSize, retryDelay: retryDelay) + self.usesCatalogAPI = usesCatalogAPI } // MARK: - Protocol Conformance @@ -65,12 +69,10 @@ public final class POSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol public func startFullSync(for siteID: Int64) async throws -> POSCatalog { DDLogInfo("🔄 Starting full catalog sync for site ID: \(siteID)") - let usesCatalogEndpoint = true - do { // Sync from network let catalog: POSCatalog - if usesCatalogEndpoint { + if usesCatalogAPI { catalog = try await loadCatalogFromCatalogEndpoint(for: siteID, syncRemote: syncRemote) } else { catalog = try await loadCatalog(for: siteID, syncRemote: syncRemote) @@ -125,7 +127,7 @@ private extension POSCatalogFullSyncService { print("đŸŸŖ Starting catalog generation...") // 1. Generate catalog and get job ID - let response = try await syncRemote.requestCatalogGeneration(for: siteID) + let response = try await syncRemote.requestCatalogGeneration(for: siteID, forceGeneration: true) let downloadURL: String? if let url = response.downloadURL { downloadURL = url @@ -148,7 +150,7 @@ private extension POSCatalogFullSyncService { var attempts = 0 while attempts < maxAttempts { - let response = try await syncRemote.requestCatalogGeneration(for: siteID) + let response = try await syncRemote.requestCatalogGeneration(for: siteID, forceGeneration: false) switch response.status { case .complete: diff --git a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift index 8efc471b60a..1a09889cf0d 100644 --- a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift +++ b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift @@ -159,7 +159,8 @@ class AuthenticatedState: StoresManagerState { let fullSyncService = POSCatalogFullSyncService(credentials: credentials, selectedSite: site, appPasswordSupportState: appPasswordSupportState.eraseToAnyPublisher(), - grdbManager: ServiceLocator.grdbManager), + grdbManager: ServiceLocator.grdbManager, + usesCatalogAPI: ServiceLocator.featureFlagService.isFeatureFlagEnabled(.pointOfSaleCatalogAPI)), let incrementalSyncService = POSCatalogIncrementalSyncService( credentials: credentials, selectedSite: site, From e12af581a7dcbdfffc877ce4c2577465c1886110 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Thu, 30 Oct 2025 13:53:06 +0800 Subject: [PATCH 06/16] Revert testing code for large catalog. --- .../Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift index d87f16ac372..facfff895fe 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift @@ -281,7 +281,6 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { private extension POSCatalogSyncCoordinator { enum Constants { - // Temporary high limit to allow large catalog size - static let defaultSizeLimitForPOSCatalog = 100000000 + static let defaultSizeLimitForPOSCatalog = 1000 } } From 6235afe811ff3038e4d42b8d15073f25070622d7 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Thu, 30 Oct 2025 16:28:44 +0800 Subject: [PATCH 07/16] Add `regenerateCatalog` parameter to force catalog generation from user-initiated action, currently from the CTA in catalog settings. --- .../POSSettingsLocalCatalogViewModel.swift | 2 +- .../PointOfSale/Utils/PreviewHelpers.swift | 2 +- .../Tools/POS/POSCatalogFullSyncService.swift | 38 ++++++++++--------- .../Tools/POS/POSCatalogSyncCoordinator.swift | 17 ++++++--- 4 files changed, 33 insertions(+), 26 deletions(-) diff --git a/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogViewModel.swift b/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogViewModel.swift index d06de2705b7..f102a0e16a8 100644 --- a/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogViewModel.swift +++ b/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogViewModel.swift @@ -53,7 +53,7 @@ final class POSSettingsLocalCatalogViewModel { defer { isRefreshingCatalog = false } do { - try await catalogSyncCoordinator.performFullSync(for: siteID) + try await catalogSyncCoordinator.performFullSync(for: siteID, regenerateCatalog: true) await loadCatalogData() } catch { DDLogError("â›”ī¸ POSSettingsLocalCatalog: Failed to refresh catalog: \(error)") diff --git a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift index 2fbf4b9e3b8..6d2d82ed4f3 100644 --- a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift +++ b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift @@ -609,7 +609,7 @@ final class POSPreviewCatalogSettingsService: POSCatalogSettingsServiceProtocol } final class POSPreviewCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { - func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval) async throws { + func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval, regenerateCatalog: Bool) async throws { // Simulates a full sync operation with a 1 second delay. try await Task.sleep(nanoseconds: 1_000_000_000) } diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift index 1b5091000a2..64ba2c64c2d 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift @@ -11,9 +11,11 @@ import struct Networking.POSCatalogResponse // periphery:ignore public protocol POSCatalogFullSyncServiceProtocol { /// Starts a full catalog sync process - /// - Parameter siteID: The site ID to sync catalog for + /// - Parameters: + /// - siteID: The site ID to sync catalog for + /// - regenerateCatalog: Whether to force the catalog generation /// - Returns: The synced catalog containing products and variations - func startFullSync(for siteID: Int64) async throws -> POSCatalog + func startFullSync(for siteID: Int64, regenerateCatalog: Bool) async throws -> POSCatalog } /// POS catalog from full sync. @@ -66,14 +68,14 @@ public final class POSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol // MARK: - Protocol Conformance - public func startFullSync(for siteID: Int64) async throws -> POSCatalog { - DDLogInfo("🔄 Starting full catalog sync for site ID: \(siteID)") + public func startFullSync(for siteID: Int64, regenerateCatalog: Bool = false) async throws -> POSCatalog { + DDLogInfo("🔄 Starting full catalog sync for site ID: \(siteID) with regenerateCatalog: \(regenerateCatalog)") do { // Sync from network let catalog: POSCatalog if usesCatalogAPI { - catalog = try await loadCatalogFromCatalogEndpoint(for: siteID, syncRemote: syncRemote) + catalog = try await loadCatalogFromCatalogAPI(for: siteID, syncRemote: syncRemote, regenerateCatalog: regenerateCatalog) } else { catalog = try await loadCatalog(for: siteID, syncRemote: syncRemote) } @@ -112,33 +114,32 @@ private extension POSCatalogFullSyncService { return POSCatalog(products: products, variations: variations, syncDate: syncStartDate) } - func loadCatalogFromCatalogEndpoint(for siteID: Int64, syncRemote: POSCatalogSyncRemoteProtocol) async throws -> POSCatalog { + func loadCatalogFromCatalogAPI(for siteID: Int64, syncRemote: POSCatalogSyncRemoteProtocol, regenerateCatalog: Bool) async throws -> POSCatalog { let downloadStartTime = CFAbsoluteTimeGetCurrent() - let catalog = try await downloadCatalog(for: siteID, syncRemote: syncRemote) + let catalog = try await downloadCatalog(for: siteID, syncRemote: syncRemote, regenerateCatalog: regenerateCatalog) let downloadTime = CFAbsoluteTimeGetCurrent() - downloadStartTime - print("đŸŸŖ Download completed - Time: \(String(format: "%.2f", downloadTime))s") + DDLogInfo("đŸŸŖ Catalog download completed - Time: \(String(format: "%.2f", downloadTime))s") return .init(products: catalog.products, variations: catalog.variations, syncDate: .init()) } } private extension POSCatalogFullSyncService { - func downloadCatalog(for siteID: Int64, syncRemote: POSCatalogSyncRemoteProtocol) async throws -> POSCatalogResponse { - print("đŸŸŖ Starting catalog generation...") + func downloadCatalog(for siteID: Int64, syncRemote: POSCatalogSyncRemoteProtocol, regenerateCatalog: Bool) async throws -> POSCatalogResponse { + DDLogInfo("đŸŸŖ Starting catalog request...") - // 1. Generate catalog and get job ID - let response = try await syncRemote.requestCatalogGeneration(for: siteID, forceGeneration: true) + // 1. Requests catalog until download URL is available. + let response = try await syncRemote.requestCatalogGeneration(for: siteID, forceGeneration: regenerateCatalog) let downloadURL: String? if let url = response.downloadURL { downloadURL = url - print("đŸŸŖ Catalog ready for download: \(url)") + DDLogInfo("đŸŸŖ Catalog ready for download: \(url)") } else { - // 2. Poll for completion + // 2. Polls for completion until download URL is available. downloadURL = try await pollForCatalogCompletion(siteID: siteID, syncRemote: syncRemote) - print("đŸŸŖ Catalog generation started") } - // 3. Download using the provided URL + // 3. Downloads catalog using the provided URL. guard let downloadURL else { throw POSCatalogSyncError.invalidData } @@ -146,7 +147,8 @@ private extension POSCatalogFullSyncService { } func pollForCatalogCompletion(siteID: Int64, syncRemote: POSCatalogSyncRemoteProtocol) async throws -> String { - let maxAttempts = 1000 // each attempt is made 1 second after the last one completes + // Each attempt is made 1 second after the last one completes. + let maxAttempts = 1000 var attempts = 0 while attempts < maxAttempts { @@ -159,7 +161,7 @@ private extension POSCatalogFullSyncService { } return downloadURL case .pending, .processing: - print("đŸŸŖ Catalog generation \(response.status)... (attempt \(attempts + 1)/\(maxAttempts))") + DDLogInfo("đŸŸŖ Catalog request \(response.status)... (attempt \(attempts + 1)/\(maxAttempts))") try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second attempts += 1 case .failed: diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift index facfff895fe..3aa3c4ae8e1 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift @@ -8,8 +8,9 @@ public protocol POSCatalogSyncCoordinatorProtocol { /// - Parameters: /// - siteID: The site ID to sync catalog for /// - maxAge: Maximum age before a sync is considered stale + /// - regenerateCatalog: Whether to regenerate the catalog regardless of maxAge or other conditions (default false) /// - Throws: POSCatalogSyncError.syncAlreadyInProgress if a sync is already running for this site - func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval) async throws + func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval, regenerateCatalog: Bool) async throws /// Performs an incremental sync if applicable based on sync conditions /// - Parameters: @@ -29,8 +30,8 @@ public protocol POSCatalogSyncCoordinatorProtocol { } public extension POSCatalogSyncCoordinatorProtocol { - func performFullSync(for siteID: Int64) async throws { - try await performFullSyncIfApplicable(for: siteID, maxAge: .zero) + func performFullSync(for siteID: Int64, regenerateCatalog: Bool = false) async throws { + try await performFullSyncIfApplicable(for: siteID, maxAge: .zero, regenerateCatalog: regenerateCatalog) } func performIncrementalSync(for siteID: Int64) async throws { @@ -76,7 +77,7 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { self.catalogSizeChecker = catalogSizeChecker } - public func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval) async throws { + public func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval, regenerateCatalog: Bool) async throws { guard maxAge >= 0 else { throw POSCatalogSyncError.negativeMaxAge } @@ -100,18 +101,22 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { DDLogInfo("🔄 POSCatalogSyncCoordinator starting full sync for site \(siteID)") - _ = try await fullSyncService.startFullSync(for: siteID) + _ = try await fullSyncService.startFullSync(for: siteID, regenerateCatalog: regenerateCatalog) DDLogInfo("✅ POSCatalogSyncCoordinator completed full sync for site \(siteID)") } + public func performFullSync(for siteID: Int64, regenerateCatalog: Bool = false) async throws { + try await performFullSyncIfApplicable(for: siteID, maxAge: .zero, regenerateCatalog: regenerateCatalog) + } + public func performSmartSync(for siteID: Int64, fullSyncMaxAge: TimeInterval) async throws { let lastFullSync = await lastFullSyncDate(for: siteID) ?? Date(timeIntervalSince1970: 0) let lastFullSyncUTC = ISO8601DateFormatter().string(from: lastFullSync) if Date().timeIntervalSince(lastFullSync) >= fullSyncMaxAge { DDLogInfo("🔄 POSCatalogSyncCoordinator: Performing full sync for site \(siteID) (last full sync: \(lastFullSyncUTC) UTC)") - try await performFullSync(for: siteID) + try await performFullSync(for: siteID, regenerateCatalog: false) } else { DDLogInfo("🔄 POSCatalogSyncCoordinator: Performing incremental sync for site \(siteID) (last full sync: \(lastFullSyncUTC) UTC)") try await performIncrementalSync(for: siteID) From 8748b8016ed1fb68106513687c4848b1613075cb Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 31 Oct 2025 08:20:54 +0800 Subject: [PATCH 08/16] Move download URL logging to when the URL is ready for both cases (catalog ready immediately or not). --- .../Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift index 64ba2c64c2d..1faad68ae72 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift @@ -133,7 +133,6 @@ private extension POSCatalogFullSyncService { let downloadURL: String? if let url = response.downloadURL { downloadURL = url - DDLogInfo("đŸŸŖ Catalog ready for download: \(url)") } else { // 2. Polls for completion until download URL is available. downloadURL = try await pollForCatalogCompletion(siteID: siteID, syncRemote: syncRemote) @@ -143,6 +142,7 @@ private extension POSCatalogFullSyncService { guard let downloadURL else { throw POSCatalogSyncError.invalidData } + DDLogInfo("đŸŸŖ Catalog ready for download: \(downloadURL)") return try await syncRemote.downloadCatalog(for: siteID, downloadURL: downloadURL) } From 8694085932282478c1844263ee46c6a117cf000d Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 31 Oct 2025 08:30:33 +0800 Subject: [PATCH 09/16] Remove periphery comments as the code is used. --- .../Sources/Networking/Remote/POSCatalogSyncRemote.swift | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift index 51ba5c61b0d..c478d260392 100644 --- a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift +++ b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift @@ -32,7 +32,6 @@ public protocol POSCatalogSyncRemoteProtocol { /// - forceGeneration: Whether to always generate a catalog. /// - Returns: Catalog job response with job ID. /// - // periphery:ignore - TODO - remove this periphery ignore comment when this endpoint is integrated with catalog sync func requestCatalogGeneration(for siteID: Int64, forceGeneration: Bool) async throws -> POSCatalogRequestResponse /// Downloads the generated catalog at the specified download URL. @@ -40,7 +39,6 @@ public protocol POSCatalogSyncRemoteProtocol { /// - siteID: Site ID to download catalog for. /// - downloadURL: Download URL of the catalog file. /// - Returns: List of products and variations in the POS catalog. - // periphery:ignore - TODO - remove this periphery ignore comment when this method is integrated with catalog sync func downloadCatalog(for siteID: Int64, downloadURL: String) async throws -> POSCatalogResponse /// Loads POS products for full sync. @@ -153,7 +151,6 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol { /// - siteID: Site ID to generate catalog for. /// - Returns: Catalog job response with job ID. /// - // periphery:ignore - TODO - remove this periphery ignore comment when this endpoint is integrated with catalog sync public func requestCatalogGeneration(for siteID: Int64, forceGeneration: Bool) async throws -> POSCatalogRequestResponse { let path = "products/catalog" let parameters: [String: Any] = [ @@ -177,7 +174,6 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol { /// - siteID: Site ID to download catalog for. /// - downloadURL: Download URL of the catalog file. /// - Returns: List of products and variations in the POS catalog. - // periphery:ignore - TODO - remove this periphery ignore comment when this method is integrated with catalog sync public func downloadCatalog(for siteID: Int64, downloadURL: String) async throws -> POSCatalogResponse { // TODO: WOOMOB-1173 - move download task to the background using `URLSessionConfiguration.background` guard let url = URL(string: downloadURL) else { @@ -331,7 +327,6 @@ private extension POSCatalogSyncRemote { // MARK: - Response Models /// Response from catalog generation request. -// periphery:ignore - TODO - remove this periphery ignore comment when the corresponding endpoint is integrated with catalog sync public struct POSCatalogRequestResponse: Decodable { /// Current status of the catalog generation job. public let status: POSCatalogStatus @@ -353,7 +348,6 @@ public enum POSCatalogStatus: String, Decodable { } /// POS catalog from download. -// periphery:ignore - TODO - remove this periphery ignore comment when the corresponding endpoint is integrated with catalog sync public struct POSCatalogResponse { public let products: [POSProduct] public let variations: [POSProductVariation] From d9124087f6b4be1fed10bd49fa0776e8036ffcd0 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 31 Oct 2025 13:48:13 +0800 Subject: [PATCH 10/16] Nit: update feature flag comment and remove unused constant. --- Modules/Sources/Experiments/FeatureFlag.swift | 2 +- .../Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Modules/Sources/Experiments/FeatureFlag.swift b/Modules/Sources/Experiments/FeatureFlag.swift index 80dc7ae890b..677f6ee9ada 100644 --- a/Modules/Sources/Experiments/FeatureFlag.swift +++ b/Modules/Sources/Experiments/FeatureFlag.swift @@ -212,7 +212,7 @@ public enum FeatureFlag: Int { /// case pointOfSaleSettingsCardReaderFlow - /// Enables using the catalog API endpoint for Point of Sale catalog sync + /// Enables using the catalog API endpoint for Point of Sale catalog full sync /// case pointOfSaleCatalogAPI } diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift index 4cff2f6641a..914dcc4e8eb 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift @@ -345,7 +345,6 @@ public enum POSCatalogSyncState: Equatable { private extension POSCatalogSyncCoordinator { enum Constants { - static let defaultSizeLimitForPOSCatalog = 1000 static let maxDaysSinceLastOpened = 30 } From ed9b1a86ad287ad7d88ed41224da051f983cda87 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 31 Oct 2025 14:08:25 +0800 Subject: [PATCH 11/16] Fix unit tests build failures. --- .../Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift | 2 +- .../Mocks/MockPOSCatalogSyncCoordinator.swift | 2 +- .../Tools/POS/POSCatalogSyncCoordinatorTests.swift | 8 +++++++- .../Tools/ForegroundPOSCatalogSyncDispatcherTests.swift | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift index 914dcc4e8eb..eeb846c44cf 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift @@ -9,7 +9,7 @@ public protocol POSCatalogSyncCoordinatorProtocol { /// - Parameters: /// - siteID: The site ID to sync catalog for /// - maxAge: Maximum age before a sync is considered stale - /// - regenerateCatalog: Whether to regenerate the catalog regardless of maxAge or other conditions (default false) + /// - regenerateCatalog: Whether to always generate a new catalog. If false, a cached catalog will be used if available. /// - Throws: POSCatalogSyncError.syncAlreadyInProgress if a sync is already running for this site func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval, regenerateCatalog: Bool) async throws diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift index d0da6326193..c4c9095cfd1 100644 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift @@ -21,7 +21,7 @@ final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { var onPerformFullSyncCalled: (() -> Void)? - func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval) async throws { + func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval, regenerateCatalog: Bool) async throws { performFullSyncInvocationCount += 1 performFullSyncSiteID = siteID diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift index 1b163f79d76..a36bbbb4ae8 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift @@ -592,7 +592,7 @@ final class MockPOSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol { private(set) var startFullSyncCallCount = 0 private(set) var lastSyncSiteID: Int64? - func startFullSync(for siteID: Int64) async throws -> POSCatalog { + func startFullSync(for siteID: Int64, regenerateCatalog: Bool) async throws -> POSCatalog { startFullSyncCallCount += 1 lastSyncSiteID = siteID @@ -858,3 +858,9 @@ extension POSCatalogSyncCoordinatorTests { #expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 1) } } + +extension POSCatalogSyncCoordinator { + func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval) async throws { + try await performFullSyncIfApplicable(for: siteID, maxAge: maxAge, regenerateCatalog: false) + } +} diff --git a/WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift b/WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift index 1987a72bd13..6c85248ab79 100644 --- a/WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift +++ b/WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift @@ -284,7 +284,7 @@ private final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProt } } - func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval) async throws { + func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval, regenerateCatalog: Bool) async throws { // Not used } From 527ad515b3ba6f144b210e17f4912adac0bb3eae Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 31 Oct 2025 14:21:38 +0800 Subject: [PATCH 12/16] Filter variations without a parent product in the catalog, as the API currently returns all products and variations with public status. --- .../POS/POSCatalogPersistenceService.swift | 18 ++++++++++ .../POSCatalogPersistenceServiceTests.swift | 36 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift index 3f2df716726..61d69e4d9e8 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift @@ -27,6 +27,8 @@ final class POSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol { func replaceAllCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws { DDLogInfo("💾 Persisting catalog with \(catalog.products.count) products and \(catalog.variations.count) variations") + let catalog = filterOrphanedVariations(from: catalog) + try await grdbManager.databaseConnection.write { db in DDLogInfo("đŸ—‘ī¸ Clearing catalog data for site \(siteID)") try PersistedSite.deleteOne(db, key: siteID) @@ -154,6 +156,22 @@ final class POSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol { } } +private extension POSCatalogPersistenceService { + /// Filters out variations whose parent products are not in the catalog. + /// This can happen when the API returns public variations but their parent products are not public. + func filterOrphanedVariations(from catalog: POSCatalog) -> POSCatalog { + let productIDs = catalog.products.map { $0.productID } + let variations = catalog.variations.filter { variation in + let parentExists = productIDs.contains { $0 == variation.productID } + if !parentExists { + DDLogWarn("Variation \(variation.productVariationID) references missing product \(variation.productID)") + } + return parentExists + } + return POSCatalog(products: catalog.products, variations: variations, syncDate: catalog.syncDate) + } +} + private extension POSCatalog { var productsToPersist: [PersistedProduct] { products.map { PersistedProduct(from: $0) } diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogPersistenceServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogPersistenceServiceTests.swift index 5846459e11c..58b22c21a45 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogPersistenceServiceTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogPersistenceServiceTests.swift @@ -564,6 +564,42 @@ struct POSCatalogPersistenceServiceTests { #expect(site?.id == sampleSiteID) } } + + // MARK: - Orphaned Variation Filtering Tests + + @Test func replaceAllCatalogData_filters_out_variations_without_parent_products() async throws { + // Given + let product = POSProduct.fake().copy(siteID: sampleSiteID, productID: 10) + let validVariation = POSProductVariation.fake() + .copy(siteID: sampleSiteID, productID: 10, productVariationID: 1, image: ProductImage.fake().copy(imageID: 100)) + let orphanedVariation1 = POSProductVariation.fake() + .copy(siteID: sampleSiteID, productID: 20, productVariationID: 2, image: ProductImage.fake().copy(imageID: 200)) + let orphanedVariation2 = POSProductVariation.fake() + .copy(siteID: sampleSiteID, productID: 30, productVariationID: 3, image: ProductImage.fake().copy(imageID: 300)) + + let catalog = POSCatalog( + products: [product], + variations: [validVariation, orphanedVariation1, orphanedVariation2], + syncDate: .now + ) + + // When + try await sut.replaceAllCatalogData(catalog, siteID: sampleSiteID) + + // Then + try await db.read { db in + let variationCount = try PersistedProductVariation.fetchCount(db) + #expect(variationCount == 1) + let variationImageCount = try PersistedProductVariationImage.fetchCount(db) + #expect(variationImageCount == 1) + + let variation = try PersistedProductVariation.fetchOne(db) + #expect(variation?.id == 1) + #expect(variation?.productID == 10) + let variationImage = try PersistedProductVariationImage.fetchOne(db) + #expect(variationImage?.imageID == 100) + } + } } private extension POSCatalogPersistenceServiceTests { From 4f183c141f9c59443cbbc463f66148c70d549b1e Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 31 Oct 2025 15:03:04 +0800 Subject: [PATCH 13/16] Add test cases for `POSCatalogFullSyncService` changes. --- .../MockPOSCatalogPersistenceService.swift | 12 ++- .../Mocks/MockPOSCatalogSyncRemote.swift | 17 +++- .../POS/POSCatalogFullSyncServiceTests.swift | 85 +++++++++++++++++++ 3 files changed, 111 insertions(+), 3 deletions(-) diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogPersistenceService.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogPersistenceService.swift index 1dcdb01760e..ff945a5cb81 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogPersistenceService.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogPersistenceService.swift @@ -2,6 +2,11 @@ import Foundation final class MockPOSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol { + // MARK: - replaceAllCatalogData tracking + private(set) var replaceAllCatalogDataCallCount = 0 + private(set) var replaceAllCatalogDataLastPersistedCatalog: POSCatalog? + var replaceAllCatalogDataError: Error? + // MARK: - persistIncrementalCatalogData tracking private(set) var persistIncrementalCatalogDataCallCount = 0 private(set) var persistIncrementalCatalogDataLastPersistedCatalog: POSCatalog? @@ -10,7 +15,12 @@ final class MockPOSCatalogPersistenceService: POSCatalogPersistenceServiceProtoc // MARK: - Protocol Implementation func replaceAllCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws { - // Not used in current tests + replaceAllCatalogDataCallCount += 1 + replaceAllCatalogDataLastPersistedCatalog = catalog + + if let error = replaceAllCatalogDataError { + throw error + } } func persistIncrementalCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws { diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift index 9051d6fd97f..2a4f536f7aa 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift @@ -8,6 +8,9 @@ final class MockPOSCatalogSyncRemote: POSCatalogSyncRemoteProtocol { private(set) var incrementalProductResults: [Int: Result, Error>] = [:] private(set) var incrementalVariationResults: [Int: Result, Error>] = [:] + var catalogRequestResult: Result = .success(.init(status: .complete, downloadURL: "https://example.com/catalog.json")) + var catalogDownloadResult: Result = .success(.init(products: [], variations: [])) + let loadProductsCallCount = Counter() let loadProductVariationsCallCount = Counter() let loadIncrementalProductsCallCount = Counter() @@ -129,11 +132,21 @@ final class MockPOSCatalogSyncRemote: POSCatalogSyncRemoteProtocol { // MARK: - Protocol Methods - Catalog API func requestCatalogGeneration(for siteID: Int64, forceGeneration: Bool) async throws -> POSCatalogRequestResponse { - .init(status: .pending, downloadURL: nil) + switch catalogRequestResult { + case .success(let response): + return response + case .failure(let error): + throw error + } } func downloadCatalog(for siteID: Int64, downloadURL: String) async throws -> POSCatalogResponse { - .init(products: [], variations: []) + switch catalogDownloadResult { + case .success(let response): + return response + case .failure(let error): + throw error + } } // MARK: - Protocol Methods - Catalog size diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogFullSyncServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogFullSyncServiceTests.swift index 515ac3d0b75..ea30218932e 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogFullSyncServiceTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogFullSyncServiceTests.swift @@ -180,4 +180,89 @@ struct POSCatalogFullSyncServiceTests { #expect(await mockSyncRemote.loadProductsCallCount.value == 5) #expect(await mockSyncRemote.loadProductVariationsCallCount.value == 5) } + + // MARK: - Catalog API Tests + + @Test func startFullSync_with_catalog_API_downloads_and_persists_catalog() async throws { + // Given + let expectedProduct = POSProduct.fake().copy(siteID: sampleSiteID, productID: 1) + let expectedVariation = POSProductVariation.fake().copy(siteID: sampleSiteID, productID: 1, productVariationID: 1) + + mockSyncRemote.catalogRequestResult = .success(.init(status: .complete, downloadURL: "https://example.com/catalog.json")) + mockSyncRemote.catalogDownloadResult = .success(.init(products: [expectedProduct], variations: [expectedVariation])) + + let sut = POSCatalogFullSyncService( + syncRemote: mockSyncRemote, + batchSize: 2, + persistenceService: mockPersistenceService, + usesCatalogAPI: true + ) + + // When + let result = try await sut.startFullSync(for: sampleSiteID) + + // Then + #expect(result.products.count == 1) + #expect(result.variations.count == 1) + #expect(mockPersistenceService.replaceAllCatalogDataCallCount == 1) + #expect(mockPersistenceService.replaceAllCatalogDataLastPersistedCatalog?.products.count == 1) + #expect(mockPersistenceService.replaceAllCatalogDataLastPersistedCatalog?.variations.count == 1) + } + + @Test func startFullSync_with_catalog_API_propagates_catalog_request_error() async throws { + // Given + let expectedError = NSError(domain: "catalog", code: 500, userInfo: [NSLocalizedDescriptionKey: "Catalog request failed"]) + mockSyncRemote.catalogRequestResult = .failure(expectedError) + + let sut = POSCatalogFullSyncService( + syncRemote: mockSyncRemote, + batchSize: 2, + persistenceService: mockPersistenceService, + usesCatalogAPI: true + ) + + // When/Then + await #expect(throws: expectedError) { + _ = try await sut.startFullSync(for: sampleSiteID) + } + } + + @Test func startFullSync_with_catalog_API_propagates_catalog_download_error() async throws { + // Given + let expectedError = NSError(domain: "catalog", code: 404, userInfo: [NSLocalizedDescriptionKey: "Catalog download failed"]) + mockSyncRemote.catalogRequestResult = .success(.init(status: .complete, downloadURL: "https://example.com/catalog.json")) + mockSyncRemote.catalogDownloadResult = .failure(expectedError) + + let sut = POSCatalogFullSyncService( + syncRemote: mockSyncRemote, + batchSize: 2, + persistenceService: mockPersistenceService, + usesCatalogAPI: true + ) + + // When/Then + await #expect(throws: expectedError) { + _ = try await sut.startFullSync(for: sampleSiteID) + } + } + + @Test func startFullSync_with_catalog_API_propagates_persistence_error() async throws { + // Given + let expectedError = NSError(domain: "persistence", code: 1000, userInfo: [NSLocalizedDescriptionKey: "Persistence failed"]) + mockSyncRemote.catalogRequestResult = .success(.init(status: .complete, downloadURL: "https://example.com/catalog.json")) + mockSyncRemote.catalogDownloadResult = .success(.init(products: [], variations: [])) + mockPersistenceService.replaceAllCatalogDataError = expectedError + + let sut = POSCatalogFullSyncService( + syncRemote: mockSyncRemote, + batchSize: 2, + persistenceService: mockPersistenceService, + usesCatalogAPI: true + ) + + // When/Then + await #expect(throws: expectedError) { + _ = try await sut.startFullSync(for: sampleSiteID) + } + } } From 1166f87216f8ed7cabb25129e19b28572a228315 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 31 Oct 2025 15:10:24 +0800 Subject: [PATCH 14/16] Add a test case for `regenerateCatalog` parameter being passed to the catalog remote. --- .../Mocks/MockPOSCatalogSyncRemote.swift | 2 ++ .../POS/POSCatalogFullSyncServiceTests.swift | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift index 2a4f536f7aa..5e560f2611d 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift @@ -18,6 +18,7 @@ final class MockPOSCatalogSyncRemote: POSCatalogSyncRemoteProtocol { private(set) var lastIncrementalProductsModifiedAfter: Date? private(set) var lastIncrementalVariationsModifiedAfter: Date? + private(set) var lastCatalogRequestForceGeneration: Bool? // Fallback result when no specific page result is configured private let fallbackResult = PagedItems(items: [] as [POSProduct], hasMorePages: false, totalItems: 0) @@ -132,6 +133,7 @@ final class MockPOSCatalogSyncRemote: POSCatalogSyncRemoteProtocol { // MARK: - Protocol Methods - Catalog API func requestCatalogGeneration(for siteID: Int64, forceGeneration: Bool) async throws -> POSCatalogRequestResponse { + lastCatalogRequestForceGeneration = forceGeneration switch catalogRequestResult { case .success(let response): return response diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogFullSyncServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogFullSyncServiceTests.swift index ea30218932e..38d130a34fd 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogFullSyncServiceTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogFullSyncServiceTests.swift @@ -265,4 +265,24 @@ struct POSCatalogFullSyncServiceTests { _ = try await sut.startFullSync(for: sampleSiteID) } } + + @Test(arguments: [true, false]) + func startFullSync_with_catalog_API_passes_regenerateCatalog_to_remote(regenerateCatalog: Bool) async throws { + // Given + mockSyncRemote.catalogRequestResult = .success(.init(status: .complete, downloadURL: "https://example.com/catalog.json")) + mockSyncRemote.catalogDownloadResult = .success(.init(products: [], variations: [])) + + let sut = POSCatalogFullSyncService( + syncRemote: mockSyncRemote, + batchSize: 2, + persistenceService: mockPersistenceService, + usesCatalogAPI: true + ) + + // When + _ = try await sut.startFullSync(for: sampleSiteID, regenerateCatalog: regenerateCatalog) + + // Then + #expect(mockSyncRemote.lastCatalogRequestForceGeneration == regenerateCatalog) + } } From 786a89a006186a51601fdb58c57f96f8b0711d96 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Mon, 3 Nov 2025 16:31:01 +0800 Subject: [PATCH 15/16] Nit: update variation missing parent product comment to make it clear that the product is not available in POS. --- .../Yosemite/Tools/POS/POSCatalogPersistenceService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift index 61d69e4d9e8..e449b93a073 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift @@ -164,7 +164,7 @@ private extension POSCatalogPersistenceService { let variations = catalog.variations.filter { variation in let parentExists = productIDs.contains { $0 == variation.productID } if !parentExists { - DDLogWarn("Variation \(variation.productVariationID) references missing product \(variation.productID)") + DDLogWarn("Variation \(variation.productVariationID) references missing product \(variation.productID) - it will not be available in POS.") } return parentExists } From e1990ed5dcdcd7d823e3bdf8c418c636086cf774 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Mon, 3 Nov 2025 16:32:06 +0800 Subject: [PATCH 16/16] Only log polling info every 10th attempt to avoid flooding logs for large catalogs. --- .../Yosemite/Tools/POS/POSCatalogFullSyncService.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift index 1faad68ae72..fad9dd05d8a 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift @@ -161,7 +161,10 @@ private extension POSCatalogFullSyncService { } return downloadURL case .pending, .processing: - DDLogInfo("đŸŸŖ Catalog request \(response.status)... (attempt \(attempts + 1)/\(maxAttempts))") + // Only logs every 10th attempt to avoid flooding logs for large catalogs. + if attempts % 10 == 0 { + DDLogInfo("đŸŸŖ Catalog request \(response.status)... (attempt \(attempts + 1)/\(maxAttempts))") + } try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second attempts += 1 case .failed: