diff --git a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift index 35d4094386b..8b43c9ef4de 100644 --- a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift +++ b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift @@ -92,6 +92,27 @@ public class POSCatalogSyncRemote: Remote { 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 -> POSCatalog { + // 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 POSCatalog(products: products, variations: variations) + } } // MARK: - Constants @@ -146,3 +167,38 @@ public enum POSCatalogStatus: String, Decodable { case processing case complete } + +/// POS catalog from download. +// periphery:ignore - TODO - remove this periphery ignore comment when the corresponding endpoint is integrated with catalog sync +public struct POSCatalog { + 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, + sku: sku, + globalUniqueID: globalUniqueID, + price: price, + regularPrice: regularPrice, + salePrice: salePrice, + onSale: onSale, + 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 d4b98e4a24b..bbd2f07fffd 100644 --- a/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift +++ b/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift @@ -373,4 +373,108 @@ struct POSCatalogSyncRemoteTests { try await remote.checkCatalogStatus(for: sampleSiteID, jobID: jobID) } } + + // MARK: - Download Catalog 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.regularPrice == "230.04") + #expect(simpleProduct.onSale == true) + #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.regularPrice == "") + #expect(variableProduct.onSale == false) + #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.regularPrice == "330.34") + #expect(variation.onSale == false) + #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 new file mode 100644 index 00000000000..be42322befb --- /dev/null +++ b/Modules/Tests/NetworkingTests/Responses/pos-catalog-download-mixed.json @@ -0,0 +1,215 @@ +[ + { + "id": 48, + "type": "simple", + "sku": "synergistic-copper-clock-61732018", + "global_unique_id": "61732018", + "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", + "images": [ + { + "id": 77, + "date_created": "2025-08-06T02:37:12", + "date_created_gmt": "2025-08-06T02:37:12", + "date_modified": "2025-08-06T02:37:12", + "date_modified_gmt": "2025-08-06T02:37:12", + "src": "https://example.com/wp-content/uploads/2025/08/img-ad.png", + "name": "img-ad.png", + "alt": "", + "srcset": "https://example.com/wp-content/uploads/2025/08/img-ad.png 700w, https://example.com/wp-content/uploads/2025/08/img-ad-300x300.png 300w, https://example.com/wp-content/uploads/2025/08/img-ad-150x150.png 150w, https://example.com/wp-content/uploads/2025/08/img-ad-600x600.png 600w, https://example.com/wp-content/uploads/2025/08/img-ad-100x100.png 100w", + "sizes": "(max-width: 700px) 100vw, 700px", + "thumbnail": "https://example.com/wp-content/uploads/2025/08/img-ad-300x300.png" + } + ], + "parent_id": 0, + "attributes": [], + "downloadable": false + }, + { + "id": 31, + "type": "variable", + "sku": "incredible-silk-chair-13060312", + "global_unique_id": "", + "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": "", + "images": [ + { + "id": 61, + "date_created": "2025-08-06T02:37:03", + "date_created_gmt": "2025-08-06T02:37:03", + "date_modified": "2025-08-06T02:37:03", + "date_modified_gmt": "2025-08-06T02:37:03", + "src": "https://example.com/wp-content/uploads/2025/08/img-harum.png", + "name": "img-harum.png", + "alt": "", + "srcset": "https://example.com/wp-content/uploads/2025/08/img-harum.png 700w, https://example.com/wp-content/uploads/2025/08/img-harum-300x300.png 300w, https://example.com/wp-content/uploads/2025/08/img-harum-150x150.png 150w, https://example.com/wp-content/uploads/2025/08/img-harum-600x600.png 600w, https://example.com/wp-content/uploads/2025/08/img-harum-100x100.png 100w", + "sizes": "(max-width: 700px) 100vw, 700px", + "thumbnail": "https://example.com/wp-content/uploads/2025/08/img-harum-300x300.png" + } + ], + "parent_id": 0, + "attributes": [ + { + "id": 1, + "name": "Size", + "position": 0, + "visible": true, + "variation": true, + "options": [ + "Earum" + ] + }, + { + "id": 0, + "name": "Ab", + "position": 1, + "visible": true, + "variation": true, + "options": [ + "deserunt", + "ea", + "ut" + ] + }, + { + "id": 2, + "name": "Numeric Size", + "position": 2, + "visible": true, + "variation": true, + "options": [ + "19", + "8", + "9", + "At", + "Reiciendis" + ] + } + ], + "downloadable": false + }, + { + "id": 32, + "type": "variation", + "sku": "", + "global_unique_id": "", + "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", + "images": [ + { + "id": 62, + "date_created": "2025-08-06T02:37:04", + "date_created_gmt": "2025-08-06T02:37:04", + "date_modified": "2025-08-06T02:37:04", + "date_modified_gmt": "2025-08-06T02:37:04", + "src": "https://example.com/wp-content/uploads/2025/08/img-quae.png", + "name": "img-quae.png", + "alt": "", + "srcset": "https://example.com/wp-content/uploads/2025/08/img-quae.png 700w, https://example.com/wp-content/uploads/2025/08/img-quae-300x300.png 300w, https://example.com/wp-content/uploads/2025/08/img-quae-150x150.png 150w, https://example.com/wp-content/uploads/2025/08/img-quae-600x600.png 600w, https://example.com/wp-content/uploads/2025/08/img-quae-100x100.png 100w", + "sizes": "(max-width: 700px) 100vw, 700px", + "thumbnail": "https://example.com/wp-content/uploads/2025/08/img-quae-300x300.png" + } + ], + "parent_id": 31, + "attributes": [ + { + "id": 1, + "name": "Size", + "option": "Earum" + }, + { + "id": 0, + "name": "ab", + "option": "deserunt" + }, + { + "id": 2, + "name": "Numeric Size", + "option": "19" + } + ], + "downloadable": false + }, + { + "id": 33, + "type": "variation", + "sku": "", + "global_unique_id": "", + "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", + "images": [ + { + "id": 63, + "date_created": "2025-08-06T02:37:05", + "date_created_gmt": "2025-08-06T02:37:05", + "date_modified": "2025-08-06T02:37:05", + "date_modified_gmt": "2025-08-06T02:37:05", + "src": "https://example.com/wp-content/uploads/2025/08/img-delectus-1.png", + "name": "img-delectus-1.png", + "alt": "", + "srcset": "https://example.com/wp-content/uploads/2025/08/img-delectus-1.png 700w, https://example.com/wp-content/uploads/2025/08/img-delectus-1-300x300.png 300w, https://example.com/wp-content/uploads/2025/08/img-delectus-1-150x150.png 150w, https://example.com/wp-content/uploads/2025/08/img-delectus-1-600x600.png 600w, https://example.com/wp-content/uploads/2025/08/img-delectus-1-100x100.png 100w", + "sizes": "(max-width: 700px) 100vw, 700px", + "thumbnail": "https://example.com/wp-content/uploads/2025/08/img-delectus-1-300x300.png" + } + ], + "parent_id": 31, + "attributes": [ + { + "id": 1, + "name": "Size", + "option": "Earum" + }, + { + "id": 0, + "name": "ab", + "option": "deserunt" + }, + { + "id": 2, + "name": "Numeric Size", + "option": "8" + } + ], + "downloadable": false + }, +]