Skip to content

Commit 7e36659

Browse files
authored
[Local Catalog] Implement POS catalog download & parsing for full sync (#16042)
2 parents b9ec354 + 287a8ec commit 7e36659

File tree

3 files changed

+375
-0
lines changed

3 files changed

+375
-0
lines changed

Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,27 @@ public class POSCatalogSyncRemote: Remote {
9292
let mapper = SingleItemMapper<POSCatalogStatusResponse>(siteID: siteID)
9393
return try await enqueue(request, mapper: mapper)
9494
}
95+
96+
/// Downloads the generated catalog at the specified download URL.
97+
/// - Parameters:
98+
/// - siteID: Site ID to download catalog for.
99+
/// - downloadURL: Download URL of the catalog file.
100+
/// - Returns: List of products and variations in the POS catalog.
101+
// periphery:ignore - TODO - remove this periphery ignore comment when this method is integrated with catalog sync
102+
public func downloadCatalog(for siteID: Int64, downloadURL: String) async throws -> POSCatalog {
103+
// TODO: WOOMOB-1173 - move download task to the background using `URLSessionConfiguration.background`
104+
guard let url = URL(string: downloadURL) else {
105+
throw NetworkError.invalidURL
106+
}
107+
let request = URLRequest(url: url)
108+
let mapper = ListMapper<POSProduct>(siteID: siteID)
109+
let items = try await enqueue(request, mapper: mapper)
110+
let variationProductTypeKey = "variation"
111+
let products = items.filter { $0.productTypeKey != variationProductTypeKey }
112+
let variations = items.filter { $0.productTypeKey == variationProductTypeKey }
113+
.map { $0.toVariation }
114+
return POSCatalog(products: products, variations: variations)
115+
}
95116
}
96117

97118
// MARK: - Constants
@@ -146,3 +167,38 @@ public enum POSCatalogStatus: String, Decodable {
146167
case processing
147168
case complete
148169
}
170+
171+
/// POS catalog from download.
172+
// periphery:ignore - TODO - remove this periphery ignore comment when the corresponding endpoint is integrated with catalog sync
173+
public struct POSCatalog {
174+
public let products: [POSProduct]
175+
public let variations: [POSProductVariation]
176+
}
177+
178+
private extension POSProduct {
179+
var toVariation: POSProductVariation {
180+
let variationAttributes = attributes.compactMap { attribute in
181+
try? attribute.toProductVariationAttribute()
182+
}
183+
184+
let firstImage = images.first
185+
186+
return .init(
187+
siteID: siteID,
188+
productID: parentID,
189+
productVariationID: productID,
190+
attributes: variationAttributes,
191+
image: firstImage,
192+
sku: sku,
193+
globalUniqueID: globalUniqueID,
194+
price: price,
195+
regularPrice: regularPrice,
196+
salePrice: salePrice,
197+
onSale: onSale,
198+
downloadable: downloadable,
199+
manageStock: manageStock,
200+
stockQuantity: stockQuantity,
201+
stockStatusKey: stockStatusKey
202+
)
203+
}
204+
}

Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,4 +373,108 @@ struct POSCatalogSyncRemoteTests {
373373
try await remote.checkCatalogStatus(for: sampleSiteID, jobID: jobID)
374374
}
375375
}
376+
377+
// MARK: - Download Catalog Tests
378+
379+
@Test func downloadCatalog_returns_parsed_catalog_with_products_and_variations() async throws {
380+
// Given
381+
let remote = POSCatalogSyncRemote(network: network)
382+
let downloadURL = "https://example.com/catalog.json"
383+
384+
// When
385+
network.simulateResponse(requestUrlSuffix: "", filename: "pos-catalog-download-mixed")
386+
let catalog = try await remote.downloadCatalog(for: sampleSiteID, downloadURL: downloadURL)
387+
388+
// Then
389+
#expect(catalog.products.count == 2)
390+
#expect(catalog.variations.count == 2)
391+
392+
let simpleProduct = try #require(catalog.products.first { $0.productType == .simple })
393+
#expect(simpleProduct.siteID == sampleSiteID)
394+
#expect(simpleProduct.productID == 48)
395+
#expect(simpleProduct.sku == "synergistic-copper-clock-61732018")
396+
#expect(simpleProduct.globalUniqueID == "61732018")
397+
#expect(simpleProduct.name == "Synergistic Copper Clock")
398+
#expect(simpleProduct.price == "220")
399+
#expect(simpleProduct.regularPrice == "230.04")
400+
#expect(simpleProduct.onSale == true)
401+
#expect(simpleProduct.images.count == 1)
402+
#expect(simpleProduct.images.first?.src == "https://example.com/wp-content/uploads/2025/08/img-ad.png")
403+
404+
let variableProduct = try #require(catalog.products.first { $0.productType == .variable })
405+
#expect(variableProduct.siteID == sampleSiteID)
406+
#expect(variableProduct.productID == 31)
407+
#expect(variableProduct.sku == "incredible-silk-chair-13060312")
408+
#expect(variableProduct.globalUniqueID == "")
409+
#expect(variableProduct.name == "Incredible Silk Chair")
410+
#expect(variableProduct.price == "134.58")
411+
#expect(variableProduct.regularPrice == "")
412+
#expect(variableProduct.onSale == false)
413+
#expect(variableProduct.images.count == 1)
414+
#expect(variableProduct.images.first?.src == "https://example.com/wp-content/uploads/2025/08/img-harum.png")
415+
#expect(variableProduct.attributes == [
416+
.init(siteID: sampleSiteID, attributeID: 1, name: "Size", position: 0, visible: true, variation: true, options: ["Earum"]),
417+
.init(siteID: sampleSiteID, attributeID: 0, name: "Ab", position: 1, visible: true, variation: true, options: ["deserunt", "ea", "ut"]),
418+
.init(siteID: sampleSiteID,
419+
attributeID: 2,
420+
name: "Numeric Size",
421+
position: 2,
422+
visible: true,
423+
variation: true,
424+
options: ["19", "8", "9", "At", "Reiciendis"])
425+
])
426+
427+
let variation = try #require(catalog.variations.first)
428+
#expect(variation.siteID == sampleSiteID)
429+
#expect(variation.productVariationID == 32)
430+
#expect(variation.productID == 31)
431+
#expect(variation.sku == "")
432+
#expect(variation.globalUniqueID == "")
433+
#expect(variation.price == "330.34")
434+
#expect(variation.regularPrice == "330.34")
435+
#expect(variation.onSale == false)
436+
#expect(variation.attributes.count == 3)
437+
#expect(variation.image?.src == "https://example.com/wp-content/uploads/2025/08/img-quae.png")
438+
#expect(variation.attributes == [
439+
.init(id: 1, name: "Size", option: "Earum"),
440+
.init(id: 0, name: "ab", option: "deserunt"),
441+
.init(id: 2, name: "Numeric Size", option: "19")
442+
])
443+
}
444+
445+
@Test func downloadCatalog_handles_empty_catalog() async throws {
446+
// Given
447+
let remote = POSCatalogSyncRemote(network: network)
448+
let downloadURL = "https://example.com/catalog.json"
449+
450+
// When
451+
network.simulateResponse(requestUrlSuffix: "", filename: "empty-data-array")
452+
let catalog = try await remote.downloadCatalog(for: sampleSiteID, downloadURL: downloadURL)
453+
454+
// Then
455+
#expect(catalog.products.count == 0)
456+
#expect(catalog.variations.count == 0)
457+
}
458+
459+
@Test func downloadCatalog_throws_error_for_empty_url() async throws {
460+
// Given
461+
let remote = POSCatalogSyncRemote(network: network)
462+
let emptyURL = ""
463+
464+
// When/Then
465+
await #expect(throws: NetworkError.invalidURL) {
466+
try await remote.downloadCatalog(for: sampleSiteID, downloadURL: emptyURL)
467+
}
468+
}
469+
470+
@Test func downloadCatalog_relays_networking_error() async throws {
471+
// Given
472+
let remote = POSCatalogSyncRemote(network: network)
473+
let downloadURL = "https://example.com/catalog.json"
474+
475+
// When/Then
476+
await #expect(throws: NetworkError.notFound()) {
477+
try await remote.downloadCatalog(for: sampleSiteID, downloadURL: downloadURL)
478+
}
479+
}
376480
}
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
[
2+
{
3+
"id": 48,
4+
"type": "simple",
5+
"sku": "synergistic-copper-clock-61732018",
6+
"global_unique_id": "61732018",
7+
"name": "Synergistic Copper Clock",
8+
"short_description": "Aut nulla accusantium mollitia aut dolor. Nesciunt dolor eligendi enim voluptas.",
9+
"description": "Assumenda id quidem iste incidunt velit. Illo quae voluptatem voluptatum tempore in fuga.",
10+
"status": "publish",
11+
"on_sale": true,
12+
"stock_status": "instock",
13+
"backorders_allowed": false,
14+
"manage_stock": false,
15+
"stock_quantity": null,
16+
"price": "220",
17+
"sale_price": "220",
18+
"regular_price": "230.04",
19+
"images": [
20+
{
21+
"id": 77,
22+
"date_created": "2025-08-06T02:37:12",
23+
"date_created_gmt": "2025-08-06T02:37:12",
24+
"date_modified": "2025-08-06T02:37:12",
25+
"date_modified_gmt": "2025-08-06T02:37:12",
26+
"src": "https://example.com/wp-content/uploads/2025/08/img-ad.png",
27+
"name": "img-ad.png",
28+
"alt": "",
29+
"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",
30+
"sizes": "(max-width: 700px) 100vw, 700px",
31+
"thumbnail": "https://example.com/wp-content/uploads/2025/08/img-ad-300x300.png"
32+
}
33+
],
34+
"parent_id": 0,
35+
"attributes": [],
36+
"downloadable": false
37+
},
38+
{
39+
"id": 31,
40+
"type": "variable",
41+
"sku": "incredible-silk-chair-13060312",
42+
"global_unique_id": "",
43+
"name": "Incredible Silk Chair",
44+
"short_description": "",
45+
"description": "",
46+
"status": "publish",
47+
"on_sale": false,
48+
"stock_status": "onbackorder",
49+
"backorders_allowed": true,
50+
"manage_stock": true,
51+
"stock_quantity": -83,
52+
"price": "134.58",
53+
"sale_price": "",
54+
"regular_price": "",
55+
"images": [
56+
{
57+
"id": 61,
58+
"date_created": "2025-08-06T02:37:03",
59+
"date_created_gmt": "2025-08-06T02:37:03",
60+
"date_modified": "2025-08-06T02:37:03",
61+
"date_modified_gmt": "2025-08-06T02:37:03",
62+
"src": "https://example.com/wp-content/uploads/2025/08/img-harum.png",
63+
"name": "img-harum.png",
64+
"alt": "",
65+
"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",
66+
"sizes": "(max-width: 700px) 100vw, 700px",
67+
"thumbnail": "https://example.com/wp-content/uploads/2025/08/img-harum-300x300.png"
68+
}
69+
],
70+
"parent_id": 0,
71+
"attributes": [
72+
{
73+
"id": 1,
74+
"name": "Size",
75+
"position": 0,
76+
"visible": true,
77+
"variation": true,
78+
"options": [
79+
"Earum"
80+
]
81+
},
82+
{
83+
"id": 0,
84+
"name": "Ab",
85+
"position": 1,
86+
"visible": true,
87+
"variation": true,
88+
"options": [
89+
"deserunt",
90+
"ea",
91+
"ut"
92+
]
93+
},
94+
{
95+
"id": 2,
96+
"name": "Numeric Size",
97+
"position": 2,
98+
"visible": true,
99+
"variation": true,
100+
"options": [
101+
"19",
102+
"8",
103+
"9",
104+
"At",
105+
"Reiciendis"
106+
]
107+
}
108+
],
109+
"downloadable": false
110+
},
111+
{
112+
"id": 32,
113+
"type": "variation",
114+
"sku": "",
115+
"global_unique_id": "",
116+
"name": "Incredible Silk Chair",
117+
"short_description": "",
118+
"description": "",
119+
"status": "publish",
120+
"on_sale": false,
121+
"stock_status": "instock",
122+
"backorders_allowed": false,
123+
"manage_stock": true,
124+
"stock_quantity": 69,
125+
"price": "330.34",
126+
"sale_price": "",
127+
"regular_price": "330.34",
128+
"images": [
129+
{
130+
"id": 62,
131+
"date_created": "2025-08-06T02:37:04",
132+
"date_created_gmt": "2025-08-06T02:37:04",
133+
"date_modified": "2025-08-06T02:37:04",
134+
"date_modified_gmt": "2025-08-06T02:37:04",
135+
"src": "https://example.com/wp-content/uploads/2025/08/img-quae.png",
136+
"name": "img-quae.png",
137+
"alt": "",
138+
"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",
139+
"sizes": "(max-width: 700px) 100vw, 700px",
140+
"thumbnail": "https://example.com/wp-content/uploads/2025/08/img-quae-300x300.png"
141+
}
142+
],
143+
"parent_id": 31,
144+
"attributes": [
145+
{
146+
"id": 1,
147+
"name": "Size",
148+
"option": "Earum"
149+
},
150+
{
151+
"id": 0,
152+
"name": "ab",
153+
"option": "deserunt"
154+
},
155+
{
156+
"id": 2,
157+
"name": "Numeric Size",
158+
"option": "19"
159+
}
160+
],
161+
"downloadable": false
162+
},
163+
{
164+
"id": 33,
165+
"type": "variation",
166+
"sku": "",
167+
"global_unique_id": "",
168+
"name": "Incredible Silk Chair",
169+
"short_description": "",
170+
"description": "",
171+
"status": "publish",
172+
"on_sale": false,
173+
"stock_status": "instock",
174+
"backorders_allowed": false,
175+
"manage_stock": true,
176+
"stock_quantity": 64,
177+
"price": "580.05",
178+
"sale_price": "",
179+
"regular_price": "580.05",
180+
"images": [
181+
{
182+
"id": 63,
183+
"date_created": "2025-08-06T02:37:05",
184+
"date_created_gmt": "2025-08-06T02:37:05",
185+
"date_modified": "2025-08-06T02:37:05",
186+
"date_modified_gmt": "2025-08-06T02:37:05",
187+
"src": "https://example.com/wp-content/uploads/2025/08/img-delectus-1.png",
188+
"name": "img-delectus-1.png",
189+
"alt": "",
190+
"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",
191+
"sizes": "(max-width: 700px) 100vw, 700px",
192+
"thumbnail": "https://example.com/wp-content/uploads/2025/08/img-delectus-1-300x300.png"
193+
}
194+
],
195+
"parent_id": 31,
196+
"attributes": [
197+
{
198+
"id": 1,
199+
"name": "Size",
200+
"option": "Earum"
201+
},
202+
{
203+
"id": 0,
204+
"name": "ab",
205+
"option": "deserunt"
206+
},
207+
{
208+
"id": 2,
209+
"name": "Numeric Size",
210+
"option": "8"
211+
}
212+
],
213+
"downloadable": false
214+
},
215+
]

0 commit comments

Comments
 (0)