diff --git a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift index 8b43c9ef4de..8f5b757a786 100644 --- a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift +++ b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift @@ -5,6 +5,8 @@ import Foundation public class POSCatalogSyncRemote: Remote { private let dateFormatter = ISO8601DateFormatter() + // MARK: - Incremental Sync Endpoints + /// Loads POS products modified after the specified date. /// /// - Parameters: @@ -16,7 +18,7 @@ public class POSCatalogSyncRemote: Remote { // periphery:ignore - TODO - remove this periphery ignore comment when this endpoint is integrated with catalog sync public func loadProducts(modifiedAfter: Date, siteID: Int64, pageNumber: Int) async throws -> PagedItems { - let path = "products" + let path = Path.products let parameters = [ ParameterKey.modifiedAfter: dateFormatter.string(from: modifiedAfter), ParameterKey.page: String(pageNumber), @@ -41,7 +43,7 @@ public class POSCatalogSyncRemote: Remote { /// // periphery:ignore - TODO - remove this periphery ignore comment when this endpoint is integrated with catalog sync public func loadProductVariations(modifiedAfter: Date, siteID: Int64, pageNumber: Int) async throws -> PagedItems { - let path = "variations" + let path = Path.variations let parameters = [ ParameterKey.modifiedAfter: dateFormatter.string(from: modifiedAfter), ParameterKey.page: String(pageNumber), @@ -56,62 +58,52 @@ public class POSCatalogSyncRemote: Remote { return createPagedItems(items: variations, responseHeaders: responseHeaders, currentPageNumber: pageNumber) } - /// 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. + // MARK: - Full Sync Endpoints + + /// Loads POS products for full sync. /// /// - Parameters: - /// - siteID: Site ID to generate catalog for. - /// - fields: Optional array of fields to include in catalog. - /// - forceGenerate: Whether to force generation of a new catalog. - /// - Returns: Catalog job response with job ID. + /// - siteID: Site ID to load products from. + /// - pageNumber: Page number for pagination. + /// - Returns: Paginated list of POS products. /// // periphery:ignore - TODO - remove this periphery ignore comment when this endpoint is integrated with catalog sync - public func generateCatalog(for siteID: Int64, forceGenerate: Bool = false) async throws -> POSCatalogGenerationResponse { - let path = "catalog" - let parameters: [String: Any] = [ - ParameterKey.fullSyncFields: POSProduct.requestFields + public func loadProducts(siteID: Int64, pageNumber: Int) async throws -> PagedItems { + let path = Path.products + let parameters = [ + ParameterKey.page: String(pageNumber), + ParameterKey.perPage: String(Constants.defaultPageSize), + ParameterKey.fields: POSProduct.requestFields.joined(separator: ",") ] - 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) + let request = JetpackRequest(wooApiVersion: .mark3, method: .get, siteID: siteID, path: path, parameters: parameters) + let mapper = ListMapper(siteID: siteID) + let (products, responseHeaders) = try await enqueueWithResponseHeaders(request, mapper: mapper) + + return createPagedItems(items: products, responseHeaders: responseHeaders, currentPageNumber: pageNumber) } - /// Checks the status of a catalog generation job. A download URL is returned when the job is complete. + /// Loads POS product variations for full sync. /// /// - Parameters: - /// - siteID: Site ID for the catalog job. - /// - jobID: Job ID to check status for. - /// - Returns: Catalog status response. + /// - siteID: Site ID to load variations from. + /// - pageNumber: Page number for pagination. + /// - Returns: Paginated list of POS product variations. /// // 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)" + public func loadProductVariations(siteID: Int64, pageNumber: Int) async throws -> PagedItems { + let path = Path.variations + let parameters = [ + ParameterKey.page: String(pageNumber), + ParameterKey.perPage: String(Constants.defaultPageSize), + ParameterKey.fields: POSProductVariation.requestFields.joined(separator: ",") + ] - 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) - } + let request = JetpackRequest(wooApiVersion: .wcAnalytics, method: .get, siteID: siteID, path: path, parameters: parameters) + let mapper = ListMapper(siteID: siteID) + let (variations, responseHeaders) = try await enqueueWithResponseHeaders(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) + return createPagedItems(items: variations, responseHeaders: responseHeaders, currentPageNumber: pageNumber) } } @@ -127,78 +119,10 @@ private extension POSCatalogSyncRemote { static let page = "page" static let perPage = "per_page" static let fields = "_fields" - static let fullSyncFields = "fields" } -} - -// 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 -} - -/// 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 - ) + enum Path { + static let products = "products" + static let variations = "variations" } } diff --git a/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift b/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift index bbd2f07fffd..0dcad6b6b5b 100644 --- a/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift +++ b/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift @@ -281,200 +281,120 @@ struct POSCatalogSyncRemoteTests { #expect(queryParametersDictionary["page"] as? String == String(largePageNumber)) } - // MARK: - Catalog Generation Tests - - @Test func generateCatalog_sets_correct_parameters() async throws { - // Given - let remote = POSCatalogSyncRemote(network: network) - - // When - _ = try? await remote.generateCatalog(for: sampleSiteID) - - // Then - let queryParametersDictionary = try #require(network.queryParametersDictionary as? [String: any Hashable]) - #expect(queryParametersDictionary["fields"] as? [String] == POSProduct.requestFields) - } - - @Test func generateCatalog_returns_parsed_response() async throws { - // Given + @Test func loadProducts_fullSync_returns_hasMorePages_based_on_header() async throws { + // Given a response with 3 pages let remote = POSCatalogSyncRemote(network: network) - let expectedJobID = "export_1756177061_7885" + network.responseHeaders = ["X-WP-TotalPages": "3"] + network.simulateResponse(requestUrlSuffix: "products", filename: "empty-data-array") - // When - network.simulateResponse(requestUrlSuffix: "catalog", filename: "pos-catalog-generation") - let response = try await remote.generateCatalog(for: sampleSiteID) + // When loading page 1 + let pagedProducts = try await remote.loadProducts(siteID: sampleSiteID, pageNumber: 1) - // Then - #expect(response.jobID == expectedJobID) + // Then there are more pages + #expect(pagedProducts.hasMorePages == true) } - @Test func generateCatalog_relays_networking_error() async throws { - // Given - let remote = POSCatalogSyncRemote(network: network) - - // When/Then - await #expect(throws: NetworkError.notFound()) { - try await remote.generateCatalog(for: sampleSiteID) - } - } + // MARK: - Full Sync Product Tests - @Test func checkCatalogStatus_returns_parsed_response_when_status_is_complete() async throws { + @Test func loadProducts_fullSync_sets_correct_parameters() async throws { // Given let remote = POSCatalogSyncRemote(network: network) - let jobID = "job_12345" + let pageNumber = 2 // When - network.simulateResponse(requestUrlSuffix: "catalog/status/\(jobID)", filename: "pos-catalog-status-complete") - let response = try await remote.checkCatalogStatus(for: sampleSiteID, jobID: jobID) + _ = try? await remote.loadProducts(siteID: sampleSiteID, pageNumber: pageNumber) // Then - #expect(response.status == .complete) - #expect(response.progress == 100.0) - #expect(response.downloadURL != nil) + let queryParametersDictionary = try #require(network.queryParametersDictionary as? [String: any Hashable]) + #expect(queryParametersDictionary["page"] as? String == String(pageNumber)) + #expect(queryParametersDictionary["per_page"] as? String == "100") + #expect(queryParametersDictionary["_fields"] as? String == POSProduct.requestFields.joined(separator: ",")) + #expect(queryParametersDictionary["modified_after"] == nil) } - @Test func checkCatalogStatus_returns_parsed_response_when_status_is_pending() async throws { + @Test func loadProducts_fullSync_returns_parsed_products() async throws { // Given let remote = POSCatalogSyncRemote(network: network) - let jobID = "job_12345" + let expectedProductsCount = 2 // When - network.simulateResponse(requestUrlSuffix: "catalog/status/\(jobID)", filename: "pos-catalog-status-pending") - let response = try await remote.checkCatalogStatus(for: sampleSiteID, jobID: jobID) + network.simulateResponse(requestUrlSuffix: "products", filename: "products-load-pos") + let pagedProducts = try await remote.loadProducts(siteID: sampleSiteID, pageNumber: 1) // Then - #expect(response.status == .pending) - #expect(response.progress == 0.0) - #expect(response.downloadURL == nil) - } - - @Test func checkCatalogStatus_returns_parsed_response_when_status_is_processing() async throws { - // Given - let remote = POSCatalogSyncRemote(network: network) - let jobID = "job_12345" - - // When - network.simulateResponse(requestUrlSuffix: "catalog/status/\(jobID)", filename: "pos-catalog-status-processing") - let response = try await remote.checkCatalogStatus(for: sampleSiteID, jobID: jobID) + #expect(pagedProducts.items.count == expectedProductsCount) - // Then - #expect(response.status == .processing) - #expect(response.progress == 5.0) - #expect(response.downloadURL == nil) + let firstProduct = try #require(pagedProducts.items.first) + #expect(firstProduct.siteID == sampleSiteID) + #expect(firstProduct.productID == 168) + #expect(firstProduct.name == "Beanie") } - @Test func checkCatalogStatus_relays_networking_error() async throws { + @Test func loadProducts_fullSync_relays_networking_error() async throws { // Given let remote = POSCatalogSyncRemote(network: network) - let jobID = "job_12345" // When/Then await #expect(throws: NetworkError.notFound()) { - try await remote.checkCatalogStatus(for: sampleSiteID, jobID: jobID) + try await remote.loadProducts(siteID: sampleSiteID, pageNumber: 1) } } - // MARK: - Download Catalog Tests + // MARK: - Full Sync Product Variations Tests - @Test func downloadCatalog_returns_parsed_catalog_with_products_and_variations() async throws { + @Test func loadProductVariations_fullSync_sets_correct_parameters() async throws { // Given let remote = POSCatalogSyncRemote(network: network) - let downloadURL = "https://example.com/catalog.json" + let pageNumber = 3 // When - network.simulateResponse(requestUrlSuffix: "", filename: "pos-catalog-download-mixed") - let catalog = try await remote.downloadCatalog(for: sampleSiteID, downloadURL: downloadURL) + _ = try? await remote.loadProductVariations(siteID: sampleSiteID, pageNumber: pageNumber) // 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") - ]) + let queryParametersDictionary = try #require(network.queryParametersDictionary as? [String: any Hashable]) + #expect(queryParametersDictionary["page"] as? String == String(pageNumber)) + #expect(queryParametersDictionary["per_page"] as? String == "100") + #expect(queryParametersDictionary["_fields"] as? String == POSProductVariation.requestFields.joined(separator: ",")) + #expect(queryParametersDictionary["modified_after"] == nil) } - @Test func downloadCatalog_handles_empty_catalog() async throws { + @Test func loadProductVariations_fullSync_returns_parsed_variations() 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) + network.simulateResponse(requestUrlSuffix: "variations", filename: "product-variations-load-pos") + let pagedVariations = try await remote.loadProductVariations(siteID: sampleSiteID, pageNumber: 1) // Then - #expect(catalog.products.count == 0) - #expect(catalog.variations.count == 0) + #expect(pagedVariations.items.count == 1) + + let firstVariation = try #require(pagedVariations.items.first) + #expect(firstVariation.siteID == sampleSiteID) + #expect(firstVariation.productVariationID == 123) + #expect(firstVariation.productID == 119) } - @Test func downloadCatalog_throws_error_for_empty_url() async throws { + @Test func loadProductVariations_fullSync_relays_networking_error() 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) + await #expect(throws: NetworkError.notFound()) { + try await remote.loadProductVariations(siteID: sampleSiteID, pageNumber: 1) } } - @Test func downloadCatalog_relays_networking_error() async throws { - // Given + @Test func loadProductVariations_fullSync_returns_hasMorePages_based_on_header() async throws { + // Given a response with 3 pages let remote = POSCatalogSyncRemote(network: network) - let downloadURL = "https://example.com/catalog.json" + network.responseHeaders = ["X-WP-TotalPages": "3"] + network.simulateResponse(requestUrlSuffix: "variations", filename: "empty-data-array") - // When/Then - await #expect(throws: NetworkError.notFound()) { - try await remote.downloadCatalog(for: sampleSiteID, downloadURL: downloadURL) - } + // When loading page 1 + let pagedVariations = try await remote.loadProductVariations(siteID: sampleSiteID, pageNumber: 1) + + // Then there are more pages + #expect(pagedVariations.hasMorePages == true) } }