Skip to content

Commit e866a81

Browse files
committed
Add catalog API endpoints to POSCatalogSyncRemote.
1 parent 62d085a commit e866a81

File tree

5 files changed

+273
-30
lines changed

5 files changed

+273
-30
lines changed

Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,23 @@ public protocol POSCatalogSyncRemoteProtocol {
2424
// periphery:ignore
2525
func loadProductVariations(modifiedAfter: Date, siteID: Int64, pageNumber: Int) async throws -> PagedItems<POSProductVariation>
2626

27+
/// Starts generation of a POS catalog.
28+
/// The catalog is generated asynchronously and a download URL may be returned when the file is ready.
29+
///
30+
/// - Parameters:
31+
/// - siteID: Site ID to generate catalog for.
32+
/// - forceGeneration: Whether to always generate a catalog.
33+
/// - Returns: Catalog job response with job ID.
34+
///
35+
func requestCatalogGeneration(for siteID: Int64, forceGeneration: Bool) async throws -> POSCatalogRequestResponse
36+
37+
/// Downloads the generated catalog at the specified download URL.
38+
/// - Parameters:
39+
/// - siteID: Site ID to download catalog for.
40+
/// - downloadURL: Download URL of the catalog file.
41+
/// - Returns: List of products and variations in the POS catalog.
42+
func downloadCatalog(for siteID: Int64, downloadURL: String) async throws -> POSCatalogResponse
43+
2744
/// Loads POS products for full sync.
2845
///
2946
/// - Parameters:
@@ -127,6 +144,51 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol {
127144

128145
// MARK: - Full Sync Endpoints
129146

147+
/// Starts generation of a POS catalog.
148+
/// The catalog is generated asynchronously and a download URL may be returned immediately or via the status response endpoint associated with a job ID.
149+
///
150+
/// - Parameters:
151+
/// - siteID: Site ID to generate catalog for.
152+
/// - Returns: Catalog job response with job ID.
153+
///
154+
public func requestCatalogGeneration(for siteID: Int64, forceGeneration: Bool) async throws -> POSCatalogRequestResponse {
155+
let path = "products/catalog"
156+
let parameters: [String: Any] = [
157+
ParameterKey.fullSyncFields: POSProduct.requestFields,
158+
ParameterKey.forceGenerate: forceGeneration
159+
]
160+
let request = JetpackRequest(
161+
wooApiVersion: .mark3,
162+
method: .post,
163+
siteID: siteID,
164+
path: path,
165+
parameters: parameters,
166+
availableAsRESTRequest: true
167+
)
168+
let mapper = SingleItemMapper<POSCatalogRequestResponse>(siteID: siteID)
169+
return try await enqueue(request, mapper: mapper)
170+
}
171+
172+
/// Downloads the generated catalog at the specified download URL.
173+
/// - Parameters:
174+
/// - siteID: Site ID to download catalog for.
175+
/// - downloadURL: Download URL of the catalog file.
176+
/// - Returns: List of products and variations in the POS catalog.
177+
public func downloadCatalog(for siteID: Int64, downloadURL: String) async throws -> POSCatalogResponse {
178+
// TODO: WOOMOB-1173 - move download task to the background using `URLSessionConfiguration.background`
179+
guard let url = URL(string: downloadURL) else {
180+
throw NetworkError.invalidURL
181+
}
182+
let request = URLRequest(url: url)
183+
let mapper = ListMapper<POSProduct>(siteID: siteID)
184+
let items = try await enqueue(request, mapper: mapper)
185+
let variationProductTypeKey = "variation"
186+
let products = items.filter { $0.productTypeKey != variationProductTypeKey }
187+
let variations = items.filter { $0.productTypeKey == variationProductTypeKey }
188+
.map { $0.toVariation }
189+
return POSCatalogResponse(products: products, variations: variations)
190+
}
191+
130192
/// Loads POS products for full sync.
131193
///
132194
/// - Parameters:
@@ -252,10 +314,67 @@ private extension POSCatalogSyncRemote {
252314
static let page = "page"
253315
static let perPage = "per_page"
254316
static let fields = "_fields"
317+
static let fullSyncFields = "fields"
318+
static let forceGenerate = "force_generate"
255319
}
256320

257321
enum Path {
258322
static let products = "products"
259323
static let variations = "variations"
260324
}
261325
}
326+
327+
// MARK: - Response Models
328+
329+
/// Response from catalog generation request.
330+
public struct POSCatalogRequestResponse: Decodable {
331+
/// Current status of the catalog generation job.
332+
public let status: POSCatalogStatus
333+
/// Download URL when it is already available.
334+
public let downloadURL: String?
335+
336+
private enum CodingKeys: String, CodingKey {
337+
case status
338+
case downloadURL = "download_url"
339+
}
340+
}
341+
342+
/// Catalog generation status.
343+
public enum POSCatalogStatus: String, Decodable {
344+
case pending
345+
case processing
346+
case complete
347+
case failed
348+
}
349+
350+
/// POS catalog from download.
351+
public struct POSCatalogResponse {
352+
public let products: [POSProduct]
353+
public let variations: [POSProductVariation]
354+
}
355+
356+
private extension POSProduct {
357+
var toVariation: POSProductVariation {
358+
let variationAttributes = attributes.compactMap { attribute in
359+
try? attribute.toProductVariationAttribute()
360+
}
361+
362+
let firstImage = images.first
363+
364+
return .init(
365+
siteID: siteID,
366+
productID: parentID,
367+
productVariationID: productID,
368+
attributes: variationAttributes,
369+
image: firstImage,
370+
fullDescription: fullDescription,
371+
sku: sku,
372+
globalUniqueID: globalUniqueID,
373+
price: price,
374+
downloadable: downloadable,
375+
manageStock: manageStock,
376+
stockQuantity: stockQuantity,
377+
stockStatusKey: stockStatusKey
378+
)
379+
}
380+
}

Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,4 +590,142 @@ struct POSCatalogSyncRemoteTests {
590590
#expect(requests.contains { $0.path.contains("products") })
591591
#expect(requests.contains { $0.path.contains("variations") })
592592
}
593+
594+
// MARK: - `requestCatalogGeneration` Tests
595+
596+
@Test func generateCatalog_sets_correct_parameters() async throws {
597+
// Given
598+
let remote = POSCatalogSyncRemote(network: network)
599+
600+
// When
601+
_ = try? await remote.requestCatalogGeneration(for: sampleSiteID, forceGeneration: false)
602+
603+
// Then
604+
let queryParametersDictionary = try #require(network.queryParametersDictionary as? [String: any Hashable])
605+
#expect(queryParametersDictionary["fields"] as? [String] == POSProduct.requestFields)
606+
#expect(queryParametersDictionary["force_generate"] as? Bool == false)
607+
}
608+
609+
@Test func generateCatalog_returns_parsed_response() async throws {
610+
// Given
611+
let remote = POSCatalogSyncRemote(network: network)
612+
613+
// When
614+
network.simulateResponse(requestUrlSuffix: "catalog", filename: "pos-catalog-generation")
615+
let response = try await remote.requestCatalogGeneration(for: sampleSiteID, forceGeneration: false)
616+
617+
// Then
618+
#expect(response.status == .complete)
619+
#expect(response.downloadURL == "https://example.com/wp-content/uploads/catalog.json")
620+
}
621+
622+
@Test func generateCatalog_relays_networking_error() async throws {
623+
// Given
624+
let remote = POSCatalogSyncRemote(network: network)
625+
626+
// When/Then
627+
await #expect(throws: NetworkError.notFound()) {
628+
try await remote.requestCatalogGeneration(for: sampleSiteID, forceGeneration: false)
629+
}
630+
}
631+
632+
// MARK: - `downloadCatalog` Tests
633+
634+
@Test func downloadCatalog_returns_parsed_catalog_with_products_and_variations() async throws {
635+
// Given
636+
let remote = POSCatalogSyncRemote(network: network)
637+
let downloadURL = "https://example.com/catalog.json"
638+
639+
// When
640+
network.simulateResponse(requestUrlSuffix: "", filename: "pos-catalog-download-mixed")
641+
let catalog = try await remote.downloadCatalog(for: sampleSiteID, downloadURL: downloadURL)
642+
643+
// Then
644+
#expect(catalog.products.count == 2)
645+
#expect(catalog.variations.count == 2)
646+
647+
let simpleProduct = try #require(catalog.products.first { $0.productType == .simple })
648+
#expect(simpleProduct.siteID == sampleSiteID)
649+
#expect(simpleProduct.productID == 48)
650+
#expect(simpleProduct.sku == "synergistic-copper-clock-61732018")
651+
#expect(simpleProduct.globalUniqueID == "61732018")
652+
#expect(simpleProduct.name == "Synergistic Copper Clock")
653+
#expect(simpleProduct.price == "220")
654+
#expect(simpleProduct.stockStatusKey == "instock")
655+
#expect(simpleProduct.images.count == 1)
656+
#expect(simpleProduct.images.first?.src == "https://example.com/wp-content/uploads/2025/08/img-ad.png")
657+
658+
let variableProduct = try #require(catalog.products.first { $0.productType == .variable })
659+
#expect(variableProduct.siteID == sampleSiteID)
660+
#expect(variableProduct.productID == 31)
661+
#expect(variableProduct.sku == "incredible-silk-chair-13060312")
662+
#expect(variableProduct.globalUniqueID == "")
663+
#expect(variableProduct.name == "Incredible Silk Chair")
664+
#expect(variableProduct.price == "134.58")
665+
#expect(variableProduct.stockQuantity == -83)
666+
#expect(variableProduct.images.count == 1)
667+
#expect(variableProduct.images.first?.src == "https://example.com/wp-content/uploads/2025/08/img-harum.png")
668+
#expect(variableProduct.attributes == [
669+
.init(siteID: sampleSiteID, attributeID: 1, name: "Size", position: 0, visible: true, variation: true, options: ["Earum"]),
670+
.init(siteID: sampleSiteID, attributeID: 0, name: "Ab", position: 1, visible: true, variation: true, options: ["deserunt", "ea", "ut"]),
671+
.init(siteID: sampleSiteID,
672+
attributeID: 2,
673+
name: "Numeric Size",
674+
position: 2,
675+
visible: true,
676+
variation: true,
677+
options: ["19", "8", "9", "At", "Reiciendis"])
678+
])
679+
680+
let variation = try #require(catalog.variations.first)
681+
#expect(variation.siteID == sampleSiteID)
682+
#expect(variation.productVariationID == 32)
683+
#expect(variation.productID == 31)
684+
#expect(variation.sku == "")
685+
#expect(variation.globalUniqueID == "")
686+
#expect(variation.price == "330.34")
687+
#expect(variation.attributes.count == 3)
688+
#expect(variation.image?.src == "https://example.com/wp-content/uploads/2025/08/img-quae.png")
689+
#expect(variation.attributes == [
690+
.init(id: 1, name: "Size", option: "Earum"),
691+
.init(id: 0, name: "ab", option: "deserunt"),
692+
.init(id: 2, name: "Numeric Size", option: "19")
693+
])
694+
}
695+
696+
@Test func downloadCatalog_handles_empty_catalog() async throws {
697+
// Given
698+
let remote = POSCatalogSyncRemote(network: network)
699+
let downloadURL = "https://example.com/catalog.json"
700+
701+
// When
702+
network.simulateResponse(requestUrlSuffix: "", filename: "empty-data-array")
703+
let catalog = try await remote.downloadCatalog(for: sampleSiteID, downloadURL: downloadURL)
704+
705+
// Then
706+
#expect(catalog.products.count == 0)
707+
#expect(catalog.variations.count == 0)
708+
}
709+
710+
@Test func downloadCatalog_throws_error_for_empty_url() async throws {
711+
// Given
712+
let remote = POSCatalogSyncRemote(network: network)
713+
let emptyURL = ""
714+
715+
// When/Then
716+
await #expect(throws: NetworkError.invalidURL) {
717+
try await remote.downloadCatalog(for: sampleSiteID, downloadURL: emptyURL)
718+
}
719+
}
720+
721+
@Test func downloadCatalog_relays_networking_error() async throws {
722+
// Given
723+
let remote = POSCatalogSyncRemote(network: network)
724+
let downloadURL = "https://example.com/catalog.json"
725+
726+
// When/Then
727+
await #expect(throws: NetworkError.notFound()) {
728+
try await remote.downloadCatalog(for: sampleSiteID, downloadURL: downloadURL)
729+
}
730+
}
593731
}

Modules/Tests/NetworkingTests/Responses/pos-catalog-download-mixed.json

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,10 @@
77
"name": "Synergistic Copper Clock",
88
"short_description": "Aut nulla accusantium mollitia aut dolor. Nesciunt dolor eligendi enim voluptas.",
99
"description": "Assumenda id quidem iste incidunt velit. Illo quae voluptatem voluptatum tempore in fuga.",
10-
"status": "publish",
11-
"on_sale": true,
1210
"stock_status": "instock",
13-
"backorders_allowed": false,
1411
"manage_stock": false,
1512
"stock_quantity": null,
16-
"price": "220",
17-
"sale_price": "220",
18-
"regular_price": "230.04",
13+
"price": 220,
1914
"images": [
2015
{
2116
"id": 77,
@@ -43,15 +38,10 @@
4338
"name": "Incredible Silk Chair",
4439
"short_description": "",
4540
"description": "",
46-
"status": "publish",
47-
"on_sale": false,
4841
"stock_status": "onbackorder",
49-
"backorders_allowed": true,
5042
"manage_stock": true,
5143
"stock_quantity": -83,
52-
"price": "134.58",
53-
"sale_price": "",
54-
"regular_price": "",
44+
"price": 134.58,
5545
"images": [
5646
{
5747
"id": 61,
@@ -116,15 +106,10 @@
116106
"name": "Incredible Silk Chair",
117107
"short_description": "",
118108
"description": "",
119-
"status": "publish",
120-
"on_sale": false,
121109
"stock_status": "instock",
122-
"backorders_allowed": false,
123110
"manage_stock": true,
124111
"stock_quantity": 69,
125-
"price": "330.34",
126-
"sale_price": "",
127-
"regular_price": "330.34",
112+
"price": 330.34,
128113
"images": [
129114
{
130115
"id": 62,
@@ -168,15 +153,10 @@
168153
"name": "Incredible Silk Chair",
169154
"short_description": "",
170155
"description": "",
171-
"status": "publish",
172-
"on_sale": false,
173156
"stock_status": "instock",
174-
"backorders_allowed": false,
175157
"manage_stock": true,
176158
"stock_quantity": 64,
177-
"price": "580.05",
178-
"sale_price": "",
179-
"regular_price": "580.05",
159+
"price": 580.05,
180160
"images": [
181161
{
182162
"id": 63,
Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
11
{
2-
"job_id": "export_1756177061_7885",
3-
"status": "pending",
4-
"format": "json",
5-
"filename": "wc-product-export-2025-08-26-02-57-41.json",
6-
"created_at": "2025-08-26 02:57:41",
7-
"status_url": "https://example.com/wp-json/wc/v3/catalog/status/export_1756177061_7885"
2+
"status": "complete",
3+
"download_url": "https://example.com/wp-content/uploads/catalog.json"
84
}

Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,16 @@ final class MockPOSCatalogSyncRemote: POSCatalogSyncRemoteProtocol {
126126
return fallbackVariationResult
127127
}
128128

129+
// MARK: - Protocol Methods - Catalog API
130+
131+
func requestCatalogGeneration(for siteID: Int64, forceGeneration: Bool) async throws -> POSCatalogRequestResponse {
132+
.init(status: .pending, downloadURL: nil)
133+
}
134+
135+
func downloadCatalog(for siteID: Int64, downloadURL: String) async throws -> POSCatalogResponse {
136+
.init(products: [], variations: [])
137+
}
138+
129139
// MARK: - Protocol Methods - Catalog size
130140

131141
// MARK: - getProductCount tracking

0 commit comments

Comments
 (0)