Skip to content

Shipping Labels: Sync shipments #15899

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jul 15, 2025
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
7 changes: 7 additions & 0 deletions Modules/Sources/Storage/Tools/StorageType+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,13 @@ public extension StorageType {
return allObjects(ofType: WooShippingCustomPackage.self, matching: predicate, sortedBy: [descriptor])
}

/// Returns all stored shipments for a site and order.
///
func loadAllShipments(siteID: Int64, orderID: Int64) -> [WooShippingShipment] {
let predicate = \WooShippingShipment.siteID == siteID && \WooShippingShipment.orderID == orderID
return allObjects(ofType: WooShippingShipment.self, matching: predicate, sortedBy: nil)
}

// MARK: - BlazeCampaignListItem

/// Returns a single BlazeCampaignListItem given a `siteID` and `campaignID`
Expand Down
10 changes: 5 additions & 5 deletions Modules/Sources/Yosemite/Actions/WooShippingAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,13 @@ public enum WooShippingAction: Action {
orderID: Int64,
completion: (Result<WooShippingConfig, Error>) -> Void)

/// Sync shipping labels for a given order.
/// This uses the same endpoint as `loadConfig` but also stores shipping labels to the storage
/// Sync shipments for a given order.
/// This uses the same endpoint as `loadConfig` but also stores shipments and shipping labels to the storage
/// and returns them in the completion closure.
///
case syncShippingLabels(siteID: Int64,
orderID: Int64,
completion: (Result<[ShippingLabel], Error>) -> Void)
case syncShipments(siteID: Int64,
orderID: Int64,
completion: (Result<[WooShippingShipment], Error>) -> Void)

/// Updates shipments for given order
///
Expand Down
129 changes: 88 additions & 41 deletions Modules/Sources/Yosemite/Stores/WooShippingStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ public final class WooShippingStore: Store {
updateDestinationAddress(siteID: siteID, orderID: orderID, address: address, completion: completion)
case let .loadConfig(siteID, orderID, completion):
loadConfig(siteID: siteID, orderID: orderID, completion: completion)
case let .syncShippingLabels(siteID, orderID, completion):
syncShippingLabels(siteID: siteID, orderID: orderID, completion: completion)
case let .syncShipments(siteID, orderID, completion):
syncShipments(siteID: siteID, orderID: orderID, completion: completion)
case let .updateShipment(siteID, orderID, shipmentToUpdate, completion):
updateShipment(siteID: siteID,
orderID: orderID,
Expand Down Expand Up @@ -290,26 +290,23 @@ private extension WooShippingStore {
remote.acceptUPSTermsOfService(siteID: siteID, originAddress: originAddress, completion: completion)
}

func syncShippingLabels(siteID: Int64,
orderID: Int64,
completion: @escaping (Result<[ShippingLabel], Error>) -> Void) {
remote.loadConfig(siteID: siteID, orderID: orderID, completion: { [weak self] result in
func syncShipments(siteID: Int64,
orderID: Int64,
completion: @escaping (Result<[WooShippingShipment], Error>) -> Void) {
remote.loadConfig(siteID: siteID, orderID: orderID) { [weak self] result in
guard let self else { return }

switch result {
case .failure(let error):
completion(.failure(error))
case .success(let config):
guard let labels = config.shippingLabelData?.currentOrderLabels else {
return completion(.success([]))
}
upsertShippingLabelsInBackground(siteID: siteID,
orderID: orderID,
shippingLabels: labels) {
completion(.success(labels))
let shipments = config.shipments
upsertShipmentsInBackground(siteID: siteID,
orderID: orderID,
shipments: shipments) {
completion(.success(shipments))
}
}
})
}
}
}

Expand Down Expand Up @@ -679,35 +676,60 @@ private extension WooShippingStore {
}, completion: nil, on: .main)
}

/// Updates/inserts the specified readonly shipping label entities *in a background thread*.
/// Updates/inserts the specified readonly shipments entities *in a background thread*.
/// `onCompletion` will be called on the main thread!
func upsertShippingLabelsInBackground(siteID: Int64,
orderID: Int64,
shippingLabels: [ShippingLabel],
onCompletion: @escaping () -> Void) {
if shippingLabels.isEmpty {
return onCompletion()
}

func upsertShipmentsInBackground(siteID: Int64,
orderID: Int64,
shipments: [WooShippingShipment],
onCompletion: @escaping () -> Void) {
storageManager.performAndSave ({ [weak self] storage in
guard let self else { return }
guard let order = storage.loadOrder(siteID: siteID, orderID: orderID) else {
return
}
upsertShippingLabels(siteID: siteID, orderID: orderID, shippingLabels: shippingLabels, storageOrder: order, using: storage)
upsertShipments(siteID: siteID,
orderID: orderID,
shipments: shipments,
storageOrder: order,
using: storage)
}, completion: onCompletion, on: .main)
}

/// Updates/inserts the specified readonly ShippingLabel entities in the current thread.
func upsertShippingLabels(siteID: Int64,
orderID: Int64,
shippingLabels: [ShippingLabel],
storageOrder: StorageOrder,
using storage: StorageType) {
let storedLabels = storage.loadAllShippingLabels(siteID: siteID, orderID: orderID)
for shippingLabel in shippingLabels {
let storageShippingLabel = storedLabels.first(where: { $0.shippingLabelID == shippingLabel.shippingLabelID }) ??
storage.insertNewObject(ofType: Storage.ShippingLabel.self)
/// Updates/inserts the specified readonly WooShippingShipments entities in the current thread.
func upsertShipments(siteID: Int64,
orderID: Int64,
shipments: [WooShippingShipment],
storageOrder: StorageOrder,
using storage: StorageType) {
let storedShipments = storage.loadAllShipments(siteID: siteID, orderID: orderID)
for shipment in shipments {
let storageShipment = storedShipments.first(where: { $0.index == shipment.index }) ??
storage.insertNewObject(ofType: Storage.WooShippingShipment.self)
storageShipment.update(with: shipment)
storageShipment.order = storageOrder

handleShipmentItems(shipment, storageShipment, storage)
update(storageShipment: storageShipment,
storageOrder: storageOrder,
shippingLabel: shipment.shippingLabel,
using: storage)
}

// Now, remove any objects that exist in storage but not in shipments
let shipmentIndices = shipments.map(\.index)
storedShipments.filter {
!shipmentIndices.contains($0.index)
}.forEach {
storage.deleteObject($0)
}
}

func update(storageShipment: StorageWooShippingShipment,
storageOrder: StorageOrder,
shippingLabel: ShippingLabel?,
using storage: StorageType) {
if let shippingLabel {
let storageShippingLabel = storageShipment.shippingLabel ?? storage.insertNewObject(ofType: Storage.ShippingLabel.self)
storageShippingLabel.update(with: shippingLabel)
storageShippingLabel.order = storageOrder

Expand All @@ -720,14 +742,39 @@ private extension WooShippingStore {
let destinationAddress = storageShippingLabel.destinationAddress ?? storage.insertNewObject(ofType: Storage.ShippingLabelAddress.self)
destinationAddress.update(with: shippingLabel.destinationAddress)
storageShippingLabel.destinationAddress = destinationAddress

/// Set the shipping label to the shipment's relationship
storageShipment.shippingLabel = storageShippingLabel
} else {
storageShipment.shippingLabel = nil
}
}

// Now, remove any objects that exist in storage but not in shippingLabels
let shippingLabelIDs = shippingLabels.map(\.shippingLabelID)
storedLabels.filter {
!shippingLabelIDs.contains($0.shippingLabelID)
}.forEach {
storage.deleteObject($0)
/// Updates, inserts, or prunes the provided StorageWooShippingShipment's items using the provided read-only WooShippingShipment's items
///
private func handleShipmentItems(_ readOnlyShipment: Networking.WooShippingShipment,
_ storageShipment: Storage.WooShippingShipment,
_ storage: StorageType) {

let storageItemsArray = Array(storageShipment.items ?? [])

// Upsert the items from the read-only shipment
for readOnlyItem in readOnlyShipment.items {
if let existingStorageItem = storageItemsArray.first(where: { $0.id == readOnlyItem.id }) {
existingStorageItem.update(with: readOnlyItem)
} else {
let newStorageItem = storage.insertNewObject(ofType: Storage.WooShippingShipmentItem.self)
newStorageItem.update(with: readOnlyItem)
storageShipment.addToItems(newStorageItem)
}
}

// Now, remove any objects that exist in storageShipment.items but not in readOnlyShipment.items
storageItemsArray.forEach { storageItem in
if readOnlyShipment.items.first(where: { $0.id == storageItem.id } ) == nil {
storageShipment.removeFromItems(storageItem)
storage.deleteObject(storageItem)
}
}
}

Expand Down
164 changes: 90 additions & 74 deletions Modules/Tests/YosemiteTests/Stores/WooShippingStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1035,80 +1035,6 @@ final class WooShippingStoreTests: XCTestCase {
XCTAssertEqual(error as? NetworkError, expectedError)
}

// MARK: `syncShippingLabels`

func test_syncShippingLabels_persists_shipping_labels_on_success() throws {
// Given
let remote = MockWooShippingRemote()
let orderID: Int64 = 22
let expectedShippingLabel: Yosemite.ShippingLabel = {
let origin = ShippingLabelAddress(company: "fun testing",
name: "Woo seller",
phone: "6501234567",
country: "US",
state: "CA",
address1: "9999 19TH AVE",
address2: "",
city: "SAN FRANCISCO",
postcode: "94121-2303")
let destination = ShippingLabelAddress(company: "",
name: "Woo buyer",
phone: "1650345689",
country: "TW",
state: "Taiwan",
address1: "No 70 RA St",
address2: "",
city: "Taipei",
postcode: "100")
let refund = ShippingLabelRefund(dateRequested: Date(timeIntervalSince1970: 1603716266.809), status: .pending)
return ShippingLabel(siteID: sampleSiteID,
orderID: orderID,
shippingLabelID: 1149,
carrierID: "usps",
shipmentID: "0",
dateCreated: Date(timeIntervalSince1970: 1603716274.809),
packageName: "box",
rate: 58.81,
currency: "USD",
trackingNumber: "CM199912222US",
serviceName: "USPS - Priority Mail International",
refundableAmount: 58.81,
status: .purchased,
refund: refund,
originAddress: origin,
destinationAddress: destination,
productIDs: [3013],
productNames: ["Password protected!"],
commercialInvoiceURL: nil,
usedDate: nil,
expiryDate: nil)
}()
let expectedResponse = WooShippingConfig.fake().copy(
shipments: [WooShippingShipment.fake()],
shippingLabelData: WooShippingLabelData(currentOrderLabels: [expectedShippingLabel])
)
remote.whenLoadingConfig(siteID: sampleSiteID, thenReturn: .success(expectedResponse))

let store = WooShippingStore(dispatcher: dispatcher, storageManager: storageManager, network: network, remote: remote)
insertOrder(siteID: sampleSiteID, orderID: orderID)

// When
let result: Result<[Yosemite.ShippingLabel], Error> = waitFor { promise in
let action = WooShippingAction.syncShippingLabels(siteID: self.sampleSiteID, orderID: orderID) { result in
promise(result)
}
store.onAction(action)
}

// Then
XCTAssertTrue(result.isSuccess)

let persistedOrder = try XCTUnwrap(viewStorage.loadOrder(siteID: sampleSiteID, orderID: orderID))
let persistedShippingLabels = try XCTUnwrap(viewStorage.loadAllShippingLabels(siteID: sampleSiteID, orderID: orderID))
XCTAssertEqual(persistedOrder.shippingLabels, Set(persistedShippingLabels))
XCTAssertEqual(persistedShippingLabels.map { $0.toReadOnly() }, [expectedShippingLabel])
}

// MARK: `updateShipment`

func test_updateShipment_returns_success_response() throws {
Expand Down Expand Up @@ -1309,6 +1235,96 @@ final class WooShippingStoreTests: XCTestCase {
let error = try XCTUnwrap(result.failure)
XCTAssertEqual(error as? NetworkError, expectedError)
}

// MARK: `syncShipments`

func test_syncShipments_returns_shipments_on_success() throws {
// Given
let remote = MockWooShippingRemote()
let expectedShipments = [WooShippingShipment.fake(), WooShippingShipment.fake()]
let config = WooShippingConfig.fake().copy(shipments: expectedShipments)
remote.whenLoadingConfig(siteID: sampleSiteID, thenReturn: .success(config))
let store = WooShippingStore(dispatcher: dispatcher, storageManager: storageManager, network: network, remote: remote)

// When
let result: Result<[WooShippingShipment], Error> = waitFor { promise in
let action = WooShippingAction.syncShipments(siteID: self.sampleSiteID,
orderID: self.sampleOrderID) { result in
promise(result)
}
store.onAction(action)
}

// Then
let actualShipments = try XCTUnwrap(result.get())
XCTAssertEqual(actualShipments, expectedShipments)
}

func test_syncShipments_returns_error_on_failure() throws {
// Given
let remote = MockWooShippingRemote()
let expectedError = NetworkError.timeout()
remote.whenLoadingConfig(siteID: sampleSiteID, thenReturn: .failure(expectedError))
let store = WooShippingStore(dispatcher: dispatcher, storageManager: storageManager, network: network, remote: remote)

// When
let result: Result<[WooShippingShipment], Error> = waitFor { promise in
let action = WooShippingAction.syncShipments(siteID: self.sampleSiteID,
orderID: self.sampleOrderID) { result in
promise(result)
}
store.onAction(action)
}

// Then
let error = try XCTUnwrap(result.failure)
XCTAssertEqual(error as? NetworkError, expectedError)
}

func test_syncShipments_persists_shipments_to_storage_on_success() throws {
// Given
let remote = MockWooShippingRemote()
insertOrder(siteID: sampleSiteID, orderID: sampleOrderID)

let shippingLabel = ShippingLabel.fake().copy(
siteID: sampleSiteID,
orderID: sampleOrderID,
shippingLabelID: 123
)
let item = WooShippingShipmentItem(id: 11, subItems: [])
let expectedShipments = [WooShippingShipment.fake().copy(
siteID: sampleSiteID,
orderID: sampleOrderID,
index: "0",
items: [item],
shippingLabel: shippingLabel
)]
let config = WooShippingConfig.fake().copy(shipments: expectedShipments)
remote.whenLoadingConfig(siteID: sampleSiteID, thenReturn: .success(config))

let store = WooShippingStore(dispatcher: dispatcher,
storageManager: storageManager,
network: network,
remote: remote)

// When
let result: Result<[WooShippingShipment], Error> = waitFor { promise in
let action = WooShippingAction.syncShipments(siteID: self.sampleSiteID,
orderID: self.sampleOrderID) { result in
promise(result)
}
store.onAction(action)
}

// Then
XCTAssertTrue(result.isSuccess)
XCTAssertEqual(viewStorage.countObjects(ofType: StorageWooShippingShipment.self), expectedShipments.count)
let object = viewStorage.loadAllShipments(siteID: sampleSiteID, orderID: sampleOrderID).first
XCTAssertEqual(object?.order?.orderID, sampleOrderID)
XCTAssertEqual(object?.shippingLabel?.shippingLabelID, shippingLabel.shippingLabelID)
XCTAssertEqual(object?.items?.count, 1)
XCTAssertEqual(object?.items?.first?.id, item.id)
}
}

private extension WooShippingStoreTests {
Expand Down
Loading