Skip to content

Commit 5ea1539

Browse files
[Bookings] Add Bookings remote requests and actions (#16243)
2 parents 1b6ac43 + 103da58 commit 5ea1539

File tree

8 files changed

+350
-21
lines changed

8 files changed

+350
-21
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import Foundation
2+
3+
/// Mapper: Booking
4+
///
5+
struct BookingMapper: Mapper {
6+
let siteID: Int64
7+
8+
func map(response: Data) throws -> Booking {
9+
let decoder = JSONDecoder()
10+
decoder.dateDecodingStrategy = .formatted(DateFormatter.Defaults.dateTimeFormatter)
11+
decoder.userInfo = [
12+
.siteID: siteID
13+
]
14+
if hasDataEnvelope(in: response) {
15+
return try decoder.decode(BookingEnvelope.self, from: response).booking
16+
} else {
17+
return try decoder.decode(Booking.self, from: response)
18+
}
19+
}
20+
}
21+
22+
private struct BookingEnvelope: Decodable {
23+
let booking: Booking
24+
25+
private enum CodingKeys: String, CodingKey {
26+
case booking = "data"
27+
}
28+
}

Modules/Sources/Networking/Remote/BookingsRemote.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ public protocol BookingsRemoteProtocol {
1212
startDateBefore: String?,
1313
startDateAfter: String?,
1414
searchQuery: String?) async throws -> [Booking]
15+
16+
func loadBooking(bookingID: Int64,
17+
siteID: Int64) async throws -> Booking?
1518
}
1619

1720
/// Booking: Remote Endpoints
@@ -59,6 +62,24 @@ public final class BookingsRemote: Remote, BookingsRemoteProtocol {
5962

6063
return try await enqueue(request, mapper: mapper)
6164
}
65+
66+
public func loadBooking(
67+
bookingID: Int64,
68+
siteID: Int64
69+
) async throws -> Booking? {
70+
let path = "\(Path.bookings)/\(bookingID)"
71+
let request = JetpackRequest(
72+
wooApiVersion: .wcBookings,
73+
method: .get,
74+
siteID: siteID,
75+
path: path,
76+
availableAsRESTRequest: true
77+
)
78+
79+
let mapper = BookingMapper(siteID: siteID)
80+
81+
return try await enqueue(request, mapper: mapper)
82+
}
6283
}
6384

6485
// MARK: - Constants

Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
import Foundation
22

3+
public protocol OrdersRemoteProtocol {
4+
func loadOrders(
5+
for siteID: Int64,
6+
orderIDs: [Int64]
7+
) async throws -> [Order]
8+
}
9+
310
/// Order: Remote Endpoints
411
///
5-
public class OrdersRemote: Remote {
12+
public class OrdersRemote: Remote, OrdersRemoteProtocol {
613
/// The source of the order creation.
714
public enum OrderCreationSource {
815
case storeManagement
@@ -111,6 +118,41 @@ public class OrdersRemote: Remote {
111118
enqueue(request, mapper: mapper, completion: completion)
112119
}
113120

121+
/// Retrieves specific `Order`s.
122+
///
123+
/// - Parameters:
124+
/// - siteID: Site for which we'll fetch remote orders.
125+
/// - orderIDs: The IDs of the orders to fetch.
126+
/// - Returns: Array of orders.
127+
/// - Throws: Network or parsing errors.
128+
///
129+
public func loadOrders(
130+
for siteID: Int64,
131+
orderIDs: [Int64]
132+
) async throws -> [Order] {
133+
guard !orderIDs.isEmpty else {
134+
return []
135+
}
136+
137+
let parameters: [String: Any] = [
138+
ParameterKeys.include: Set(orderIDs).map(String.init).joined(separator: ","),
139+
ParameterKeys.fields: ParameterValues.fieldValues
140+
]
141+
142+
let path = Constants.ordersPath
143+
let request = JetpackRequest(
144+
wooApiVersion: .mark3,
145+
method: .get,
146+
siteID: siteID,
147+
path: path,
148+
parameters: parameters,
149+
availableAsRESTRequest: true
150+
)
151+
let mapper = OrderListMapper(siteID: siteID)
152+
153+
return try await enqueue(request, mapper: mapper)
154+
}
155+
114156
/// Retrieves the notes for a specific `Order`
115157
///
116158
/// - Parameters:
@@ -534,6 +576,7 @@ public extension OrdersRemote {
534576
static let addedByUser: String = "added_by_user"
535577
static let customerNote: String = "customer_note"
536578
static let keyword: String = "search"
579+
static let include: String = "include"
537580
static let note: String = "note"
538581
static let page: String = "page"
539582
static let perPage: String = "per_page"

Modules/Sources/Yosemite/Actions/BookingAction.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ public enum BookingAction: Action {
1717
startDateAfter: String? = nil,
1818
shouldClearCache: Bool = false,
1919
onCompletion: (Result<Bool, Error>) -> Void)
20+
/// Synchronizes the Booking matching the specified criteria.
21+
///
22+
/// - Parameter onCompletion: called when sync completes, returns an error in case of a failure or empty in case of success.
23+
///
24+
case synchronizeBooking(siteID: Int64,
25+
bookingID: Int64,
26+
onCompletion: (Result<Void, Error>) -> Void)
2027

2128
/// Checks if the store already has any bookings.
2229
/// Returns `false` if the store has no bookings.

Modules/Sources/Yosemite/Stores/BookingStore.swift

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,21 @@ import Storage
66
//
77
public class BookingStore: Store {
88
private let remote: BookingsRemoteProtocol
9+
private let ordersRemote: OrdersRemoteProtocol
910

1011
public override convenience init(dispatcher: Dispatcher, storageManager: StorageManagerType, network: Network) {
1112
let remote = BookingsRemote(network: network)
12-
self.init(dispatcher: dispatcher, storageManager: storageManager, network: network, remote: remote)
13+
let ordersRemote = OrdersRemote(network: network)
14+
self.init(dispatcher: dispatcher, storageManager: storageManager, network: network, remote: remote, ordersRemote: ordersRemote)
1315
}
1416

1517
public init(dispatcher: Dispatcher,
1618
storageManager: StorageManagerType,
1719
network: Network,
18-
remote: BookingsRemoteProtocol) {
20+
remote: BookingsRemoteProtocol,
21+
ordersRemote: OrdersRemoteProtocol) {
1922
self.remote = remote
23+
self.ordersRemote = ordersRemote
2024
super.init(dispatcher: dispatcher, storageManager: storageManager, network: network)
2125
}
2226

@@ -43,6 +47,8 @@ public class BookingStore: Store {
4347
startDateAfter: startDateAfter,
4448
shouldClearCache: shouldClearCache,
4549
onCompletion: onCompletion)
50+
case .synchronizeBooking(siteID: let siteID, bookingID: let bookingID, onCompletion: let onCompletion):
51+
synchronizeBooking(siteID: siteID, bookingID: bookingID, onCompletion: onCompletion)
4652
case let .checkIfStoreHasBookings(siteID, onCompletion):
4753
checkIfStoreHasBookings(siteID: siteID, onCompletion: onCompletion)
4854
case let .searchBookings(siteID, searchQuery, pageNumber, pageSize, startDateBefore, startDateAfter, onCompletion):
@@ -79,8 +85,15 @@ private extension BookingStore {
7985
startDateBefore: startDateBefore,
8086
startDateAfter: startDateAfter,
8187
searchQuery: nil)
88+
89+
let orders = try await ordersRemote.loadOrders(
90+
for: siteID,
91+
orderIDs: bookings.map { $0.orderID }
92+
)
93+
8294
await upsertStoredBookingsInBackground(
8395
readOnlyBookings: bookings,
96+
readOnlyOrders: orders,
8497
siteID: siteID,
8598
shouldDeleteExistingBookings: shouldClearCache
8699
)
@@ -92,6 +105,45 @@ private extension BookingStore {
92105
}
93106
}
94107

108+
func synchronizeBooking(
109+
siteID: Int64,
110+
bookingID: Int64,
111+
onCompletion: @escaping (Result<Void, Error>) -> Void
112+
) {
113+
enum SynchronizeBookingError: Error {
114+
case bookingIsMissing
115+
}
116+
117+
Task { @MainActor in
118+
do {
119+
let booking = try await remote.loadBooking(
120+
bookingID: bookingID,
121+
siteID: siteID
122+
)
123+
124+
guard let booking else {
125+
onCompletion(.failure(SynchronizeBookingError.bookingIsMissing))
126+
return
127+
}
128+
129+
let orders = try await ordersRemote.loadOrders(
130+
for: siteID,
131+
orderIDs: [booking.orderID]
132+
)
133+
134+
await upsertStoredBookingsInBackground(
135+
readOnlyBookings: [booking],
136+
readOnlyOrders: orders,
137+
siteID: siteID
138+
)
139+
140+
onCompletion(.success(()))
141+
} catch {
142+
onCompletion(.failure(error))
143+
}
144+
}
145+
}
146+
95147
/// Checks if the store already has any bookings.
96148
/// Returns `false` if the store has no bookings.
97149
///
@@ -149,18 +201,21 @@ private extension BookingStore {
149201

150202
// MARK: - Storage: Booking
151203
//
152-
extension BookingStore {
204+
private extension BookingStore {
153205

154206
/// Updates (OR Inserts) the specified ReadOnly Booking Entities *in a background thread* async.
155207
/// Also deletes existing bookings if requested.
156208
func upsertStoredBookingsInBackground(readOnlyBookings: [Yosemite.Booking],
209+
readOnlyOrders: [Yosemite.Order],
157210
siteID: Int64,
158211
shouldDeleteExistingBookings: Bool = false) async {
159212
await withCheckedContinuation { [weak self] continuation in
160213
guard let self else {
161214
return continuation.resume()
162215
}
216+
163217
upsertStoredBookingsInBackground(readOnlyBookings: readOnlyBookings,
218+
readOnlyOrders: readOnlyOrders,
164219
siteID: siteID,
165220
shouldDeleteExistingBookings: shouldDeleteExistingBookings) {
166221
continuation.resume()
@@ -173,6 +228,7 @@ extension BookingStore {
173228
/// `onCompletion` will be called on the main thread!
174229
///
175230
func upsertStoredBookingsInBackground(readOnlyBookings: [Yosemite.Booking],
231+
readOnlyOrders: [Yosemite.Order],
176232
siteID: Int64,
177233
shouldDeleteExistingBookings: Bool = false,
178234
onCompletion: @escaping () -> Void) {
@@ -183,17 +239,18 @@ extension BookingStore {
183239
if shouldDeleteExistingBookings {
184240
storage.deleteBookings(siteID: siteID)
185241
}
186-
upsertStoredBookings(readOnlyBookings: readOnlyBookings, in: storage)
242+
upsertStoredBookings(readOnlyBookings: readOnlyBookings, readOnlyOrders: readOnlyOrders, in: storage)
187243
}, completion: onCompletion, on: .main)
188244
}
189245

190246
/// Updates (OR Inserts) the specified ReadOnly Booking Entities into the Storage Layer.
191247
///
192248
/// - Parameters:
193249
/// - readOnlyBookings: Remote Bookings to be persisted.
250+
/// - readOnlyOrders: Remote Orders associated with bookings.
194251
/// - storage: Where we should save all the things!
195252
///
196-
func upsertStoredBookings(readOnlyBookings: [Networking.Booking], in storage: StorageType) {
253+
func upsertStoredBookings(readOnlyBookings: [Networking.Booking], readOnlyOrders: [Yosemite.Order], in storage: StorageType) {
197254
// Fetch all existing bookings for the site at once
198255
let bookingIDs = readOnlyBookings.map { $0.bookingID }
199256
let siteID = readOnlyBookings.first?.siteID ?? 0
@@ -204,6 +261,14 @@ extension BookingStore {
204261
let storageBooking = storedBookings.first { $0.bookingID == readOnlyBooking.bookingID } ??
205262
storage.insertNewObject(ofType: StorageBooking.self)
206263

264+
// TODO: - Apply new Booking specific models
265+
if let associatedOrder = readOnlyOrders.first(where: { $0.orderID == readOnlyBooking.orderID }) {
266+
/// 1. Convert `Order` into `Booking` specific order, product and customer
267+
/// 2. Obtain corresponding associated `Storage` models from `storageBooking` or create new ones.
268+
/// 3. Update the above models with values from `associatedOrder`
269+
print("The order for the booking \(readOnlyBooking.bookingID): \(associatedOrder)")
270+
}
271+
207272
storageBooking.update(with: readOnlyBooking)
208273
}
209274
}

Modules/Tests/NetworkingTests/Remote/OrdersRemoteTests.swift

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,55 @@ final class OrdersRemoteTests: XCTestCase {
146146
XCTAssertTrue(queryParameters.contains(expectedParam), "Expected to have param: \(expectedParam)")
147147
}
148148

149+
// MARK: - Load Orders by IDs Tests
150+
151+
func test_loadOrders_by_ids_when_request_succeeds_returns_parsed_orders() async throws {
152+
// Given
153+
let remote = OrdersRemote(network: network)
154+
let orderIDs: [Int64] = [1, 2, 3]
155+
network.simulateResponse(requestUrlSuffix: "orders", filename: "orders-load-all")
156+
157+
// When
158+
let orders = try await remote.loadOrders(for: sampleSiteID, orderIDs: orderIDs)
159+
160+
// Then
161+
XCTAssertEqual(orders.count, 4) // The sample file has 4 orders
162+
}
163+
164+
func test_loadOrders_by_ids_when_invoked_sends_correct_parameters() async throws {
165+
// Given
166+
let remote = OrdersRemote(network: network)
167+
let orderIDs: [Int64] = [1, 2, 3, 2] // with duplicate
168+
network.simulateResponse(requestUrlSuffix: "orders", filename: "orders-load-all")
169+
170+
// When
171+
_ = try await remote.loadOrders(for: sampleSiteID, orderIDs: orderIDs)
172+
173+
// Then
174+
let request = try XCTUnwrap(network.requestsForResponseData.last as? JetpackRequest)
175+
let parameters = request.parameters
176+
177+
let includeValue = parameters["include"] as? String
178+
let includedIDs = includeValue?.split(separator: ",").map { String($0) }
179+
XCTAssertNotNil(includeValue)
180+
XCTAssertEqual(Set(includedIDs ?? []), Set(["1", "2", "3"])) // check for unique ids
181+
182+
XCTAssertNotNil(parameters["_fields"])
183+
XCTAssertNil(parameters["per_page"]) // verify per_page is not sent
184+
}
185+
186+
func test_loadOrders_by_ids_with_empty_ids_returns_empty_array_and_makes_no_request() async throws {
187+
// Given
188+
let remote = OrdersRemote(network: network)
189+
190+
// When
191+
let orders = try await remote.loadOrders(for: sampleSiteID, orderIDs: [])
192+
193+
// Then
194+
XCTAssertTrue(orders.isEmpty)
195+
XCTAssertTrue(network.requestsForResponseData.isEmpty) // No network request should be made
196+
}
197+
149198
// MARK: - Load Order Tests
150199

151200
/// Verifies that loadOrder properly parses the `order` sample response.

Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@ import Foundation
55
///
66
final class MockBookingsRemote: BookingsRemoteProtocol {
77
private var loadAllBookingsResult: Result<[Booking], Error>?
8+
private var loadBookingResult: Result<Booking?, Error>?
89

910
func whenLoadingAllBookings(thenReturn result: Result<[Booking], Error>) {
1011
loadAllBookingsResult = result
1112
}
1213

14+
func whenLoadingBooking(thenReturn result: Result<Booking?, Error>) {
15+
loadBookingResult = result
16+
}
17+
1318
func loadAllBookings(for siteID: Int64,
1419
pageNumber: Int,
1520
pageSize: Int,
@@ -21,4 +26,11 @@ final class MockBookingsRemote: BookingsRemoteProtocol {
2126
}
2227
return try result.get()
2328
}
29+
30+
func loadBooking(bookingID: Int64, siteID: Int64) async throws -> Networking.Booking? {
31+
guard let result = loadBookingResult else {
32+
throw NetworkError.timeout()
33+
}
34+
return try result.get()
35+
}
2436
}

0 commit comments

Comments
 (0)