Skip to content

Commit b5045f4

Browse files
authored
Merge pull request #6227 from woocommerce/issue/6132-lazy-order-creation
Order Creation: Lazily create an order when a new order is modified.
2 parents 961f98d + b18185b commit b5045f4

File tree

3 files changed

+320
-2
lines changed

3 files changed

+320
-2
lines changed

Networking/Networking/Model/OrderItem.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,14 @@ public struct OrderItem: Decodable, Equatable, Hashable, GeneratedFakeable, Gene
7777
}
7878

7979
let quantity = try container.decode(Decimal.self, forKey: .quantity)
80-
let decimalPrice = try container.decodeIfPresent(Decimal.self, forKey: .price) ?? Decimal(0)
81-
let price = NSDecimalNumber(decimal: decimalPrice)
80+
81+
/// WC versions lower than `6.3` send the item price as a `number`.
82+
/// WC Versions equal or greater than `6.3` send the item price as a `string`.
83+
///
84+
let decimalPrice = container.failsafeDecodeIfPresent(targetType: String.self,
85+
forKey: .price,
86+
alternativeTypes: [.decimal { String(describing: $0) }])
87+
let price = NSDecimalNumber(string: decimalPrice)
8288

8389
let sku = try container.decodeIfPresent(String.self, forKey: .sku)
8490
let subtotal = try container.decode(String.self, forKey: .subtotal)

WooCommerce/Classes/ViewRelated/Orders/Order Creation/Synchronizer/RemoteOrderSynchronizer.swift

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ final class RemoteOrderSynchronizer: OrderSynchronizer {
4242
///
4343
private var baseSyncStatus: OrderStatusEnum = .pending
4444

45+
/// Subscriptions store.
46+
///
47+
private var subscriptions = Set<AnyCancellable>()
48+
4549
// MARK: Initializers
4650

4751
init(siteID: Int64, stores: StoresManager = ServiceLocator.stores) {
@@ -50,6 +54,7 @@ final class RemoteOrderSynchronizer: OrderSynchronizer {
5054

5155
updateBaseSyncOrderStatus()
5256
bindInputs()
57+
bindOrderSync()
5358
}
5459

5560
// MARK: Methods
@@ -101,4 +106,64 @@ private extension RemoteOrderSynchronizer {
101106
}
102107
.assign(to: &$order)
103108
}
109+
110+
/// Creates the order(if needed) when a significant order input occurs.
111+
///
112+
func bindOrderSync() {
113+
// Combine inputs that should trigger an order sync
114+
let syncTrigger: AnyPublisher<Void, Never> = setProduct.map { _ in () }
115+
.merge(with: setAddresses.map { _ in () })
116+
.merge(with: setShipping.map { _ in () })
117+
.merge(with: setFee.map { _ in () })
118+
.eraseToAnyPublisher()
119+
120+
// Creates a "draft" order if the order has not been created yet.
121+
syncTrigger
122+
.debounce(for: 0.5, scheduler: DispatchQueue.main) // Group & wait for 0.5s since the last signal was emitted.
123+
.withLatestFrom(orderPublisher)
124+
.filter { _, order in
125+
order.orderID == .zero // Only continue if the order has not been created.
126+
}
127+
.flatMap(maxPublishers: .max(1)) { [weak self] (_, order) -> AnyPublisher<Order, Error> in // Only allow one request at a time.
128+
guard let self = self else { return Empty().eraseToAnyPublisher() }
129+
self.state = .syncing
130+
return self.createOrderRemotely(order)
131+
}
132+
.catch { [weak self] error -> AnyPublisher<Order, Never> in // When an error occurs, update state & finish.
133+
self?.state = .error(error)
134+
return Empty().eraseToAnyPublisher()
135+
}
136+
.sink { [weak self] order in // When a value is received update state & order
137+
self?.state = .synced
138+
self?.order = order
139+
}
140+
.store(in: &subscriptions)
141+
}
142+
143+
/// Returns a publisher that creates an order remotely using the `baseSyncStatus`.
144+
/// The later emitted order is delivered with the latest selected status.
145+
///
146+
func createOrderRemotely(_ order: Order) -> AnyPublisher<Order, Error> {
147+
Future<Order, Error> { [weak self] promise in
148+
guard let self = self else { return }
149+
150+
// Creates the order with the `draft` status
151+
let draftOrder = order.copy(status: self.baseSyncStatus)
152+
let action = OrderAction.createOrder(siteID: self.siteID, order: draftOrder) { [weak self] result in
153+
guard let self = self else { return }
154+
155+
switch result {
156+
case .success(let remoteOrder):
157+
// Return the order with the current selected status.
158+
let newLocalOrder = remoteOrder.copy(status: self.order.status)
159+
promise(.success(newLocalOrder))
160+
161+
case .failure(let error):
162+
promise(.failure(error))
163+
}
164+
}
165+
self.stores.dispatch(action)
166+
}
167+
.eraseToAnyPublisher()
168+
}
104169
}

WooCommerce/WooCommerceTests/ViewRelated/Orders/Order Creation/Synchronizer/RemoteOrderSynchronizerTests.swift

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import XCTest
22
import TestKit
33
import Fakes
4+
import Combine
45

56
@testable import WooCommerce
67
@testable import Yosemite
@@ -11,6 +12,12 @@ class RemoteOrderSynchronizerTests: XCTestCase {
1112
private let sampleProductID: Int64 = 234
1213
private let sampleInputID: Int64 = 345
1314
private let sampleShippingID: Int64 = 456
15+
private var subscriptions = Set<AnyCancellable>()
16+
17+
override func setUp() {
18+
super.setUp()
19+
subscriptions.removeAll()
20+
}
1421

1522
func test_sending_status_input_updates_local_order() throws {
1623
// Given
@@ -134,4 +141,244 @@ class RemoteOrderSynchronizerTests: XCTestCase {
134141
// Then
135142
XCTAssertEqual(synchronizer.order.shippingLines, [])
136143
}
144+
145+
func test_sending_product_input_triggers_order_creation() {
146+
// Given
147+
let product = Product.fake().copy(productID: sampleProductID)
148+
let stores = MockStoresManager(sessionManager: .testingInstance)
149+
let synchronizer = RemoteOrderSynchronizer(siteID: sampleSiteID, stores: stores)
150+
151+
// When
152+
let orderCreationInvoked: Bool = waitFor { promise in
153+
stores.whenReceivingAction(ofType: OrderAction.self) { action in
154+
switch action {
155+
case .createOrder:
156+
promise(true)
157+
default:
158+
promise(false)
159+
}
160+
}
161+
162+
let input = OrderSyncProductInput(product: .product(product), quantity: 1)
163+
synchronizer.setProduct.send(input)
164+
}
165+
166+
// Then
167+
XCTAssertTrue(orderCreationInvoked)
168+
}
169+
170+
func test_sending_addresses_input_triggers_order_creation() {
171+
// Given
172+
let address = Address.fake().copy(firstName: "Woo", lastName: "Customer")
173+
let stores = MockStoresManager(sessionManager: .testingInstance)
174+
let synchronizer = RemoteOrderSynchronizer(siteID: sampleSiteID, stores: stores)
175+
176+
// When
177+
let orderCreationInvoked: Bool = waitFor { promise in
178+
stores.whenReceivingAction(ofType: OrderAction.self) { action in
179+
switch action {
180+
case .createOrder:
181+
promise(true)
182+
default:
183+
promise(false)
184+
}
185+
}
186+
187+
let input = OrderSyncAddressesInput(billing: address, shipping: address)
188+
synchronizer.setAddresses.send(input)
189+
}
190+
191+
// Then
192+
XCTAssertTrue(orderCreationInvoked)
193+
}
194+
195+
func test_sending_shipping_input_triggers_order_creation() {
196+
// Given
197+
let shippingLine = ShippingLine.fake().copy(shippingID: sampleShippingID)
198+
let stores = MockStoresManager(sessionManager: .testingInstance)
199+
let synchronizer = RemoteOrderSynchronizer(siteID: sampleSiteID, stores: stores)
200+
201+
// When
202+
let orderCreationInvoked: Bool = waitFor { promise in
203+
stores.whenReceivingAction(ofType: OrderAction.self) { action in
204+
switch action {
205+
case .createOrder:
206+
promise(true)
207+
default:
208+
promise(false)
209+
}
210+
}
211+
212+
synchronizer.setShipping.send(shippingLine)
213+
}
214+
215+
// Then
216+
XCTAssertTrue(orderCreationInvoked)
217+
}
218+
219+
func test_sending_fee_input_triggers_order_creation() {
220+
// Given
221+
let fee = OrderFeeLine.fake().copy()
222+
let stores = MockStoresManager(sessionManager: .testingInstance)
223+
let synchronizer = RemoteOrderSynchronizer(siteID: sampleSiteID, stores: stores)
224+
225+
// When
226+
let orderCreationInvoked: Bool = waitFor { promise in
227+
stores.whenReceivingAction(ofType: OrderAction.self) { action in
228+
switch action {
229+
case .createOrder:
230+
promise(true)
231+
default:
232+
promise(false)
233+
}
234+
}
235+
236+
synchronizer.setFee.send(fee)
237+
}
238+
239+
// Then
240+
XCTAssertTrue(orderCreationInvoked)
241+
}
242+
243+
func test_states_are_properly_set_upon_success_order_creation() {
244+
// Given
245+
let product = Product.fake().copy(productID: sampleProductID)
246+
let stores = MockStoresManager(sessionManager: .testingInstance)
247+
let synchronizer = RemoteOrderSynchronizer(siteID: sampleSiteID, stores: stores)
248+
stores.whenReceivingAction(ofType: OrderAction.self) { action in
249+
switch action {
250+
case .createOrder(_, _, let completion):
251+
completion(.success(.fake()))
252+
default:
253+
XCTFail("Unexpected action: \(action)")
254+
}
255+
}
256+
257+
// When
258+
let input = OrderSyncProductInput(product: .product(product), quantity: 1)
259+
synchronizer.setProduct.send(input)
260+
261+
let states: [OrderSyncState] = waitFor { promise in
262+
synchronizer.statePublisher
263+
.dropFirst()
264+
.collect(2)
265+
.sink { states in
266+
promise(states)
267+
}
268+
.store(in: &self.subscriptions)
269+
}
270+
271+
// Then
272+
XCTAssertEqual(states, [.syncing, .synced])
273+
}
274+
275+
func test_states_are_properly_set_upon_failing_order_creation() {
276+
// Given
277+
let product = Product.fake().copy(productID: sampleProductID)
278+
let error = NSError(domain: "", code: 0, userInfo: nil)
279+
let stores = MockStoresManager(sessionManager: .testingInstance)
280+
let synchronizer = RemoteOrderSynchronizer(siteID: sampleSiteID, stores: stores)
281+
stores.whenReceivingAction(ofType: OrderAction.self) { action in
282+
switch action {
283+
case .createOrder(_, _, let completion):
284+
completion(.failure(error))
285+
default:
286+
XCTFail("Unexpected action: \(action)")
287+
}
288+
}
289+
290+
// When
291+
let input = OrderSyncProductInput(product: .product(product), quantity: 1)
292+
synchronizer.setProduct.send(input)
293+
294+
let states: [OrderSyncState] = waitFor { promise in
295+
synchronizer.statePublisher
296+
.dropFirst()
297+
.collect(2)
298+
.sink { states in
299+
promise(states)
300+
}
301+
.store(in: &self.subscriptions)
302+
}
303+
304+
// Then
305+
assertEqual(states, [.syncing, .error(error)])
306+
}
307+
308+
func test_sending_double_input_triggers_only_one_order_creation() {
309+
// Given
310+
let product = Product.fake().copy(productID: sampleProductID)
311+
let stores = MockStoresManager(sessionManager: .testingInstance)
312+
let synchronizer = RemoteOrderSynchronizer(siteID: sampleSiteID, stores: stores)
313+
314+
// When
315+
let exp = expectation(description: #function)
316+
exp.expectedFulfillmentCount = 1
317+
exp.assertForOverFulfill = true
318+
319+
stores.whenReceivingAction(ofType: OrderAction.self) { action in
320+
switch action {
321+
case .createOrder:
322+
exp.fulfill()
323+
default:
324+
break
325+
}
326+
}
327+
328+
let input1 = OrderSyncProductInput(product: .product(product), quantity: 1)
329+
synchronizer.setProduct.send(input1)
330+
331+
let input2 = OrderSyncProductInput(product: .product(product), quantity: 2)
332+
synchronizer.setProduct.send(input2)
333+
334+
// Then
335+
wait(for: [exp], timeout: 1.0)
336+
}
337+
338+
func test_order_is_created_with_draft_status_and_returned_with_selected_status() {
339+
// Given
340+
let stores = MockStoresManager(sessionManager: .testingInstance)
341+
stores.whenReceivingAction(ofType: SystemStatusAction.self) { action in // Set version that supports auto-draft
342+
switch action {
343+
case let .fetchSystemPlugin(_, _, onCompletion):
344+
onCompletion(.fake().copy(version: "6.3.0"))
345+
default:
346+
XCTFail("Unexpected action received: \(action)")
347+
}
348+
}
349+
let synchronizer = RemoteOrderSynchronizer(siteID: sampleSiteID, stores: stores)
350+
XCTAssertEqual(synchronizer.order.status, .pending) // initial status
351+
352+
// When
353+
let submittedStatus: OrderStatusEnum = waitFor { promise in
354+
stores.whenReceivingAction(ofType: OrderAction.self) { action in
355+
switch action {
356+
case let .createOrder(_, order, onCompletion):
357+
onCompletion(.success(order))
358+
promise(order.status)
359+
default:
360+
XCTFail("Unexpected action: \(action)")
361+
}
362+
}
363+
364+
synchronizer.setFee.send(.fake())
365+
}
366+
367+
// Then
368+
XCTAssertEqual(submittedStatus, .autoDraft) // Submitted Status
369+
XCTAssertEqual(synchronizer.order.status, .pending) // Selected status
370+
}
371+
}
372+
373+
extension OrderSyncState: Equatable {
374+
public static func == (lhs: OrderSyncState, rhs: OrderSyncState) -> Bool {
375+
switch (lhs, rhs) {
376+
case (.syncing, .syncing), (.synced, .synced):
377+
return true
378+
case (.error(let error1), .error(let error2)):
379+
return error1 as NSError == error2 as NSError
380+
default:
381+
return false
382+
}
383+
}
137384
}

0 commit comments

Comments
 (0)