diff --git a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift index a2e5e5520d5..35d4094386b 100644 --- a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift +++ b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift @@ -55,6 +55,43 @@ 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. + /// + /// - 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. + /// + // 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 + ] + + 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) + } } // MARK: - Constants @@ -69,5 +106,43 @@ 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 +} diff --git a/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift b/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift index 6ba56cd3af6..d4b98e4a24b 100644 --- a/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift +++ b/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift @@ -280,4 +280,97 @@ struct POSCatalogSyncRemoteTests { let queryParametersDictionary = try #require(network.queryParametersDictionary as? [String: any Hashable]) #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 + let remote = POSCatalogSyncRemote(network: network) + let expectedJobID = "export_1756177061_7885" + + // When + network.simulateResponse(requestUrlSuffix: "catalog", filename: "pos-catalog-generation") + let response = try await remote.generateCatalog(for: sampleSiteID) + + // Then + #expect(response.jobID == expectedJobID) + } + + @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) + } + } + + @Test func checkCatalogStatus_returns_parsed_response_when_status_is_complete() async throws { + // Given + let remote = POSCatalogSyncRemote(network: network) + let jobID = "job_12345" + + // When + network.simulateResponse(requestUrlSuffix: "catalog/status/\(jobID)", filename: "pos-catalog-status-complete") + let response = try await remote.checkCatalogStatus(for: sampleSiteID, jobID: jobID) + + // Then + #expect(response.status == .complete) + #expect(response.progress == 100.0) + #expect(response.downloadURL != nil) + } + + @Test func checkCatalogStatus_returns_parsed_response_when_status_is_pending() async throws { + // Given + let remote = POSCatalogSyncRemote(network: network) + let jobID = "job_12345" + + // When + network.simulateResponse(requestUrlSuffix: "catalog/status/\(jobID)", filename: "pos-catalog-status-pending") + let response = try await remote.checkCatalogStatus(for: sampleSiteID, jobID: jobID) + + // 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) + + // Then + #expect(response.status == .processing) + #expect(response.progress == 5.0) + #expect(response.downloadURL == nil) + } + + @Test func checkCatalogStatus_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) + } + } } diff --git a/Modules/Tests/NetworkingTests/Responses/pos-catalog-generation.json b/Modules/Tests/NetworkingTests/Responses/pos-catalog-generation.json new file mode 100644 index 00000000000..3dd10b5127f --- /dev/null +++ b/Modules/Tests/NetworkingTests/Responses/pos-catalog-generation.json @@ -0,0 +1,8 @@ +{ + "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" +} diff --git a/Modules/Tests/NetworkingTests/Responses/pos-catalog-status-complete.json b/Modules/Tests/NetworkingTests/Responses/pos-catalog-status-complete.json new file mode 100644 index 00000000000..818a55d22ed --- /dev/null +++ b/Modules/Tests/NetworkingTests/Responses/pos-catalog-status-complete.json @@ -0,0 +1,11 @@ +{ + "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 new file mode 100644 index 00000000000..735dff1a1b7 --- /dev/null +++ b/Modules/Tests/NetworkingTests/Responses/pos-catalog-status-pending.json @@ -0,0 +1,10 @@ +{ + "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 new file mode 100644 index 00000000000..96f70200327 --- /dev/null +++ b/Modules/Tests/NetworkingTests/Responses/pos-catalog-status-processing.json @@ -0,0 +1,10 @@ +{ + "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" +}