From e866a817db67884d5261a447690e65fb3627250c Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 31 Oct 2025 11:19:00 +0800 Subject: [PATCH 1/4] Add catalog API endpoints to `POSCatalogSyncRemote`. --- .../Remote/POSCatalogSyncRemote.swift | 119 +++++++++++++++ .../Remote/POSCatalogSyncRemoteTests.swift | 138 ++++++++++++++++++ .../Responses/pos-catalog-download-mixed.json | 28 +--- .../Responses/pos-catalog-generation.json | 8 +- .../Mocks/MockPOSCatalogSyncRemote.swift | 10 ++ 5 files changed, 273 insertions(+), 30 deletions(-) diff --git a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift index b7854e37543..c478d260392 100644 --- a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift +++ b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift @@ -24,6 +24,23 @@ public protocol POSCatalogSyncRemoteProtocol { // periphery:ignore func loadProductVariations(modifiedAfter: Date, siteID: Int64, pageNumber: Int) async throws -> PagedItems + /// 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. + /// - forceGeneration: Whether to always generate a catalog. + /// - Returns: Catalog job response with job ID. + /// + func requestCatalogGeneration(for siteID: Int64, forceGeneration: Bool) async throws -> POSCatalogRequestResponse + + /// 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. + func downloadCatalog(for siteID: Int64, downloadURL: String) async throws -> POSCatalogResponse + /// Loads POS products for full sync. /// /// - Parameters: @@ -127,6 +144,51 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol { // MARK: - Full Sync Endpoints + /// 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. + /// - Returns: Catalog job response with job ID. + /// + public func requestCatalogGeneration(for siteID: Int64, forceGeneration: Bool) async throws -> POSCatalogRequestResponse { + let path = "products/catalog" + let parameters: [String: Any] = [ + ParameterKey.fullSyncFields: POSProduct.requestFields, + ParameterKey.forceGenerate: forceGeneration + ] + 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) + } + + /// 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. + 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 +314,8 @@ private extension POSCatalogSyncRemote { static let page = "page" static let perPage = "per_page" static let fields = "_fields" + static let fullSyncFields = "fields" + static let forceGenerate = "force_generate" } enum Path { @@ -259,3 +323,58 @@ private extension POSCatalogSyncRemote { static let variations = "variations" } } + +// MARK: - Response Models + +/// Response from catalog generation request. +public struct POSCatalogRequestResponse: Decodable { + /// Current status of the catalog generation job. + public let status: POSCatalogStatus + /// Download URL when it is already available. + public let downloadURL: String? + + private enum CodingKeys: String, CodingKey { + case status + case downloadURL = "download_url" + } +} + +/// Catalog generation status. +public enum POSCatalogStatus: String, Decodable { + case pending + case processing + case complete + case failed +} + +/// POS catalog from download. +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/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift b/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift index c7f9e4aadbc..1db4277e553 100644 --- a/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift +++ b/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift @@ -590,4 +590,142 @@ struct POSCatalogSyncRemoteTests { #expect(requests.contains { $0.path.contains("products") }) #expect(requests.contains { $0.path.contains("variations") }) } + + // MARK: - `requestCatalogGeneration` Tests + + @Test func generateCatalog_sets_correct_parameters() async throws { + // Given + let remote = POSCatalogSyncRemote(network: network) + + // When + _ = try? await remote.requestCatalogGeneration(for: sampleSiteID, forceGeneration: false) + + // Then + let queryParametersDictionary = try #require(network.queryParametersDictionary as? [String: any Hashable]) + #expect(queryParametersDictionary["fields"] as? [String] == POSProduct.requestFields) + #expect(queryParametersDictionary["force_generate"] as? Bool == false) + } + + @Test func generateCatalog_returns_parsed_response() async throws { + // Given + let remote = POSCatalogSyncRemote(network: network) + + // When + network.simulateResponse(requestUrlSuffix: "catalog", filename: "pos-catalog-generation") + let response = try await remote.requestCatalogGeneration(for: sampleSiteID, forceGeneration: false) + + // Then + #expect(response.status == .complete) + #expect(response.downloadURL == "https://example.com/wp-content/uploads/catalog.json") + } + + @Test func generateCatalog_relays_networking_error() async throws { + // Given + let remote = POSCatalogSyncRemote(network: network) + + // When/Then + await #expect(throws: NetworkError.notFound()) { + try await remote.requestCatalogGeneration(for: sampleSiteID, forceGeneration: false) + } + } + + // MARK: - `downloadCatalog` Tests + + @Test func downloadCatalog_returns_parsed_catalog_with_products_and_variations() async throws { + // Given + let remote = POSCatalogSyncRemote(network: network) + let downloadURL = "https://example.com/catalog.json" + + // When + network.simulateResponse(requestUrlSuffix: "", filename: "pos-catalog-download-mixed") + let catalog = try await remote.downloadCatalog(for: sampleSiteID, downloadURL: downloadURL) + + // Then + #expect(catalog.products.count == 2) + #expect(catalog.variations.count == 2) + + let simpleProduct = try #require(catalog.products.first { $0.productType == .simple }) + #expect(simpleProduct.siteID == sampleSiteID) + #expect(simpleProduct.productID == 48) + #expect(simpleProduct.sku == "synergistic-copper-clock-61732018") + #expect(simpleProduct.globalUniqueID == "61732018") + #expect(simpleProduct.name == "Synergistic Copper Clock") + #expect(simpleProduct.price == "220") + #expect(simpleProduct.stockStatusKey == "instock") + #expect(simpleProduct.images.count == 1) + #expect(simpleProduct.images.first?.src == "https://example.com/wp-content/uploads/2025/08/img-ad.png") + + let variableProduct = try #require(catalog.products.first { $0.productType == .variable }) + #expect(variableProduct.siteID == sampleSiteID) + #expect(variableProduct.productID == 31) + #expect(variableProduct.sku == "incredible-silk-chair-13060312") + #expect(variableProduct.globalUniqueID == "") + #expect(variableProduct.name == "Incredible Silk Chair") + #expect(variableProduct.price == "134.58") + #expect(variableProduct.stockQuantity == -83) + #expect(variableProduct.images.count == 1) + #expect(variableProduct.images.first?.src == "https://example.com/wp-content/uploads/2025/08/img-harum.png") + #expect(variableProduct.attributes == [ + .init(siteID: sampleSiteID, attributeID: 1, name: "Size", position: 0, visible: true, variation: true, options: ["Earum"]), + .init(siteID: sampleSiteID, attributeID: 0, name: "Ab", position: 1, visible: true, variation: true, options: ["deserunt", "ea", "ut"]), + .init(siteID: sampleSiteID, + attributeID: 2, + name: "Numeric Size", + position: 2, + visible: true, + variation: true, + options: ["19", "8", "9", "At", "Reiciendis"]) + ]) + + let variation = try #require(catalog.variations.first) + #expect(variation.siteID == sampleSiteID) + #expect(variation.productVariationID == 32) + #expect(variation.productID == 31) + #expect(variation.sku == "") + #expect(variation.globalUniqueID == "") + #expect(variation.price == "330.34") + #expect(variation.attributes.count == 3) + #expect(variation.image?.src == "https://example.com/wp-content/uploads/2025/08/img-quae.png") + #expect(variation.attributes == [ + .init(id: 1, name: "Size", option: "Earum"), + .init(id: 0, name: "ab", option: "deserunt"), + .init(id: 2, name: "Numeric Size", option: "19") + ]) + } + + @Test func downloadCatalog_handles_empty_catalog() async throws { + // Given + let remote = POSCatalogSyncRemote(network: network) + let downloadURL = "https://example.com/catalog.json" + + // When + network.simulateResponse(requestUrlSuffix: "", filename: "empty-data-array") + let catalog = try await remote.downloadCatalog(for: sampleSiteID, downloadURL: downloadURL) + + // Then + #expect(catalog.products.count == 0) + #expect(catalog.variations.count == 0) + } + + @Test func downloadCatalog_throws_error_for_empty_url() async throws { + // Given + let remote = POSCatalogSyncRemote(network: network) + let emptyURL = "" + + // When/Then + await #expect(throws: NetworkError.invalidURL) { + try await remote.downloadCatalog(for: sampleSiteID, downloadURL: emptyURL) + } + } + + @Test func downloadCatalog_relays_networking_error() async throws { + // Given + let remote = POSCatalogSyncRemote(network: network) + let downloadURL = "https://example.com/catalog.json" + + // When/Then + await #expect(throws: NetworkError.notFound()) { + try await remote.downloadCatalog(for: sampleSiteID, downloadURL: downloadURL) + } + } } diff --git a/Modules/Tests/NetworkingTests/Responses/pos-catalog-download-mixed.json b/Modules/Tests/NetworkingTests/Responses/pos-catalog-download-mixed.json index be42322befb..472245e715b 100644 --- a/Modules/Tests/NetworkingTests/Responses/pos-catalog-download-mixed.json +++ b/Modules/Tests/NetworkingTests/Responses/pos-catalog-download-mixed.json @@ -7,15 +7,10 @@ "name": "Synergistic Copper Clock", "short_description": "Aut nulla accusantium mollitia aut dolor. Nesciunt dolor eligendi enim voluptas.", "description": "Assumenda id quidem iste incidunt velit. Illo quae voluptatem voluptatum tempore in fuga.", - "status": "publish", - "on_sale": true, "stock_status": "instock", - "backorders_allowed": false, "manage_stock": false, "stock_quantity": null, - "price": "220", - "sale_price": "220", - "regular_price": "230.04", + "price": 220, "images": [ { "id": 77, @@ -43,15 +38,10 @@ "name": "Incredible Silk Chair", "short_description": "", "description": "", - "status": "publish", - "on_sale": false, "stock_status": "onbackorder", - "backorders_allowed": true, "manage_stock": true, "stock_quantity": -83, - "price": "134.58", - "sale_price": "", - "regular_price": "", + "price": 134.58, "images": [ { "id": 61, @@ -116,15 +106,10 @@ "name": "Incredible Silk Chair", "short_description": "", "description": "", - "status": "publish", - "on_sale": false, "stock_status": "instock", - "backorders_allowed": false, "manage_stock": true, "stock_quantity": 69, - "price": "330.34", - "sale_price": "", - "regular_price": "330.34", + "price": 330.34, "images": [ { "id": 62, @@ -168,15 +153,10 @@ "name": "Incredible Silk Chair", "short_description": "", "description": "", - "status": "publish", - "on_sale": false, "stock_status": "instock", - "backorders_allowed": false, "manage_stock": true, "stock_quantity": 64, - "price": "580.05", - "sale_price": "", - "regular_price": "580.05", + "price": 580.05, "images": [ { "id": 63, diff --git a/Modules/Tests/NetworkingTests/Responses/pos-catalog-generation.json b/Modules/Tests/NetworkingTests/Responses/pos-catalog-generation.json index 3dd10b5127f..cf3b9281ec0 100644 --- a/Modules/Tests/NetworkingTests/Responses/pos-catalog-generation.json +++ b/Modules/Tests/NetworkingTests/Responses/pos-catalog-generation.json @@ -1,8 +1,4 @@ { - "job_id": "export_1756177061_7885", - "status": "pending", - "format": "json", - "filename": "wc-product-export-2025-08-26-02-57-41.json", - "created_at": "2025-08-26 02:57:41", - "status_url": "https://example.com/wp-json/wc/v3/catalog/status/export_1756177061_7885" + "status": "complete", + "download_url": "https://example.com/wp-content/uploads/catalog.json" } diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift index 203ddcec350..9051d6fd97f 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift @@ -126,6 +126,16 @@ final class MockPOSCatalogSyncRemote: POSCatalogSyncRemoteProtocol { return fallbackVariationResult } + // MARK: - Protocol Methods - Catalog API + + func requestCatalogGeneration(for siteID: Int64, forceGeneration: Bool) async throws -> POSCatalogRequestResponse { + .init(status: .pending, downloadURL: nil) + } + + func downloadCatalog(for siteID: Int64, downloadURL: String) async throws -> POSCatalogResponse { + .init(products: [], variations: []) + } + // MARK: - Protocol Methods - Catalog size // MARK: - getProductCount tracking From ad7033dad21d2f8611cf317781473ff3aabada32 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 31 Oct 2025 11:33:09 +0800 Subject: [PATCH 2/4] Remove unused catalog status mock responses. --- .../Responses/pos-catalog-status-complete.json | 11 ----------- .../Responses/pos-catalog-status-pending.json | 10 ---------- .../Responses/pos-catalog-status-processing.json | 10 ---------- 3 files changed, 31 deletions(-) delete mode 100644 Modules/Tests/NetworkingTests/Responses/pos-catalog-status-complete.json delete mode 100644 Modules/Tests/NetworkingTests/Responses/pos-catalog-status-pending.json delete mode 100644 Modules/Tests/NetworkingTests/Responses/pos-catalog-status-processing.json diff --git a/Modules/Tests/NetworkingTests/Responses/pos-catalog-status-complete.json b/Modules/Tests/NetworkingTests/Responses/pos-catalog-status-complete.json deleted file mode 100644 index 818a55d22ed..00000000000 --- a/Modules/Tests/NetworkingTests/Responses/pos-catalog-status-complete.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "job_id": "export_1756177061_7885", - "status": "complete", - "format": "json", - "filename": "wc-product-export-2025-08-26-02-57-41.json", - "created_at": "2025-08-26 02:57:41", - "progress": 100, - "current_batch": 2, - "action_scheduler_status": "complete", - "download_url": "https://example.com/wp-json/wc/v3/catalog/download?filename=wc-product-export-2025-08-26-02-57-41.json&format=json" -} diff --git a/Modules/Tests/NetworkingTests/Responses/pos-catalog-status-pending.json b/Modules/Tests/NetworkingTests/Responses/pos-catalog-status-pending.json deleted file mode 100644 index 735dff1a1b7..00000000000 --- a/Modules/Tests/NetworkingTests/Responses/pos-catalog-status-pending.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "job_id": "export_1756177181_6269", - "status": "pending", - "format": "json", - "filename": "wc-product-export-2025-08-26-02-59-41.json", - "created_at": "2025-08-26 02:59:41", - "progress": 0, - "current_batch": 1, - "action_scheduler_status": "pending" -} diff --git a/Modules/Tests/NetworkingTests/Responses/pos-catalog-status-processing.json b/Modules/Tests/NetworkingTests/Responses/pos-catalog-status-processing.json deleted file mode 100644 index 96f70200327..00000000000 --- a/Modules/Tests/NetworkingTests/Responses/pos-catalog-status-processing.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "job_id": "export_1756177181_6269", - "status": "processing", - "format": "json", - "filename": "wc-product-export-2025-08-26-02-59-41.json", - "created_at": "2025-08-26 02:59:41", - "progress": 5, - "current_batch": 2, - "action_scheduler_status": "pending" -} From e6c1513588f47700b52a04cd174a92f03078b5cb Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 31 Oct 2025 12:16:22 +0800 Subject: [PATCH 3/4] Add periphery ignore comments to unused methods. --- Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift index c478d260392..3eec99fdc50 100644 --- a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift +++ b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift @@ -32,6 +32,7 @@ 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. @@ -39,6 +40,7 @@ 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 endpoint is integrated with catalog sync func downloadCatalog(for siteID: Int64, downloadURL: String) async throws -> POSCatalogResponse /// Loads POS products for full sync. @@ -151,6 +153,7 @@ 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] = [ @@ -174,6 +177,7 @@ 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 endpoint 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 { From e0ea350201e098076313dde17f11b5e687cb3661 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 31 Oct 2025 12:29:25 +0800 Subject: [PATCH 4/4] Add more periphery ignore comments. --- Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift index 3eec99fdc50..ce48de11dcb 100644 --- a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift +++ b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift @@ -331,6 +331,7 @@ private extension POSCatalogSyncRemote { // MARK: - Response Models /// Response from catalog generation request. +// periphery:ignore - TODO - remove this periphery ignore comment when this endpoint is integrated with catalog sync public struct POSCatalogRequestResponse: Decodable { /// Current status of the catalog generation job. public let status: POSCatalogStatus @@ -352,6 +353,7 @@ public enum POSCatalogStatus: String, Decodable { } /// POS catalog from download. +// periphery:ignore - TODO - remove this periphery ignore comment when this endpoint is integrated with catalog sync public struct POSCatalogResponse { public let products: [POSProduct] public let variations: [POSProductVariation]