Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,25 @@ public protocol POSCatalogSyncRemoteProtocol {
// periphery:ignore
func loadProductVariations(modifiedAfter: Date, siteID: Int64, pageNumber: Int) async throws -> PagedItems<POSProductVariation>

/// Starts generation of a POS catalog.
/// The catalog is generated asynchronously and a download URL may be returned when the file is ready.
///
/// - Parameters:
/// - siteID: Site ID to generate catalog for.
/// - forceGeneration: Whether to always generate a catalog.
/// - Returns: Catalog job response with job ID.
///
// periphery:ignore - TODO - remove this periphery ignore comment when this endpoint is integrated with catalog sync
func requestCatalogGeneration(for siteID: Int64, forceGeneration: Bool) async throws -> POSCatalogRequestResponse

/// 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 endpoint is integrated with catalog sync
func downloadCatalog(for siteID: Int64, downloadURL: String) async throws -> POSCatalogResponse

/// Loads POS products for full sync.
///
/// - Parameters:
Expand Down Expand Up @@ -127,6 +146,53 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol {

// MARK: - Full Sync Endpoints

/// Starts generation of a POS catalog.
/// The catalog is generated asynchronously and a download URL may be returned immediately or via the status response endpoint associated with a job ID.
///
/// - Parameters:
/// - siteID: Site ID to generate catalog for.
/// - 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 requestCatalogGeneration(for siteID: Int64, forceGeneration: Bool) async throws -> POSCatalogRequestResponse {
let path = "products/catalog"
let parameters: [String: Any] = [
ParameterKey.fullSyncFields: POSProduct.requestFields,
ParameterKey.forceGenerate: forceGeneration
]
let request = JetpackRequest(
wooApiVersion: .mark3,
method: .post,
siteID: siteID,
path: path,
parameters: parameters,
availableAsRESTRequest: true
)
let mapper = SingleItemMapper<POSCatalogRequestResponse>(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 endpoint is integrated with catalog sync
public func downloadCatalog(for siteID: Int64, downloadURL: String) async throws -> POSCatalogResponse {
// TODO: WOOMOB-1173 - move download task to the background using `URLSessionConfiguration.background`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Just what I was going to say 😊

guard let url = URL(string: downloadURL) else {
throw NetworkError.invalidURL
}
let request = URLRequest(url: url)
let mapper = ListMapper<POSProduct>(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 POSCatalogResponse(products: products, variations: variations)
}

/// Loads POS products for full sync.
///
/// - Parameters:
Expand Down Expand Up @@ -252,10 +318,69 @@ private extension POSCatalogSyncRemote {
static let page = "page"
static let perPage = "per_page"
static let fields = "_fields"
static let fullSyncFields = "fields"
static let forceGenerate = "force_generate"
}

enum Path {
static let products = "products"
static let variations = "variations"
}
}

// MARK: - Response Models

/// Response from catalog generation request.
// periphery:ignore - TODO - remove this periphery ignore comment when this endpoint is integrated with catalog sync
public struct POSCatalogRequestResponse: Decodable {
/// Current status of the catalog generation job.
public let status: POSCatalogStatus
/// Download URL when it is already available.
public let downloadURL: String?

private enum CodingKeys: String, CodingKey {
case status
case downloadURL = "download_url"
}
}

/// Catalog generation status.
public enum POSCatalogStatus: String, Decodable {
case pending
case processing
case complete
case failed
}

/// POS catalog from download.
// periphery:ignore - TODO - remove this periphery ignore comment when this endpoint is integrated with catalog sync
public struct POSCatalogResponse {
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,
fullDescription: fullDescription,
sku: sku,
globalUniqueID: globalUniqueID,
price: price,
downloadable: downloadable,
manageStock: manageStock,
stockQuantity: stockQuantity,
stockStatusKey: stockStatusKey
)
}
}
138 changes: 138 additions & 0 deletions Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -590,4 +590,142 @@ struct POSCatalogSyncRemoteTests {
#expect(requests.contains { $0.path.contains("products") })
#expect(requests.contains { $0.path.contains("variations") })
}

// MARK: - `requestCatalogGeneration` Tests

@Test func generateCatalog_sets_correct_parameters() async throws {
// Given
let remote = POSCatalogSyncRemote(network: network)

// When
_ = try? await remote.requestCatalogGeneration(for: sampleSiteID, forceGeneration: false)

// Then
let queryParametersDictionary = try #require(network.queryParametersDictionary as? [String: any Hashable])
#expect(queryParametersDictionary["fields"] as? [String] == POSProduct.requestFields)
#expect(queryParametersDictionary["force_generate"] as? Bool == false)
}

@Test func generateCatalog_returns_parsed_response() async throws {
// Given
let remote = POSCatalogSyncRemote(network: network)

// When
network.simulateResponse(requestUrlSuffix: "catalog", filename: "pos-catalog-generation")
let response = try await remote.requestCatalogGeneration(for: sampleSiteID, forceGeneration: false)

// Then
#expect(response.status == .complete)
#expect(response.downloadURL == "https://example.com/wp-content/uploads/catalog.json")
}

@Test func generateCatalog_relays_networking_error() async throws {
// Given
let remote = POSCatalogSyncRemote(network: network)

// When/Then
await #expect(throws: NetworkError.notFound()) {
try await remote.requestCatalogGeneration(for: sampleSiteID, forceGeneration: false)
}
}

// MARK: - `downloadCatalog` 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.stockStatusKey == "instock")
#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.stockQuantity == -83)
#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.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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,10 @@
"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",
"price": 220,
"images": [
{
"id": 77,
Expand Down Expand Up @@ -43,15 +38,10 @@
"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": "",
"price": 134.58,
"images": [
{
"id": 61,
Expand Down Expand Up @@ -116,15 +106,10 @@
"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",
"price": 330.34,
"images": [
{
"id": 62,
Expand Down Expand Up @@ -168,15 +153,10 @@
"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",
"price": 580.05,
"images": [
{
"id": 63,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
{
"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"
"status": "complete",
"download_url": "https://example.com/wp-content/uploads/catalog.json"
}

This file was deleted.

This file was deleted.

Loading