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
28 changes: 28 additions & 0 deletions Modules/Sources/Networking/Mapper/BookingMapper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Foundation

/// Mapper: Booking
///
struct BookingMapper: Mapper {
let siteID: Int64

func map(response: Data) throws -> Booking {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(DateFormatter.Defaults.dateTimeFormatter)
decoder.userInfo = [
.siteID: siteID
]
if hasDataEnvelope(in: response) {
return try decoder.decode(BookingEnvelope.self, from: response).booking
} else {
return try decoder.decode(Booking.self, from: response)
}
}
}

private struct BookingEnvelope: Decodable {
let booking: Booking

private enum CodingKeys: String, CodingKey {
case booking = "data"
}
}
21 changes: 21 additions & 0 deletions Modules/Sources/Networking/Remote/BookingsRemote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ public protocol BookingsRemoteProtocol {
startDateBefore: String?,
startDateAfter: String?,
searchQuery: String?) async throws -> [Booking]

func loadBooking(bookingID: Int64,
siteID: Int64) async throws -> Booking?
}

/// Booking: Remote Endpoints
Expand Down Expand Up @@ -59,6 +62,24 @@ public final class BookingsRemote: Remote, BookingsRemoteProtocol {

return try await enqueue(request, mapper: mapper)
}

public func loadBooking(
bookingID: Int64,
siteID: Int64
) async throws -> Booking? {
let path = "\(Path.bookings)/\(bookingID)"
let request = JetpackRequest(
wooApiVersion: .wcBookings,
method: .get,
siteID: siteID,
path: path,
availableAsRESTRequest: true
)

let mapper = BookingMapper(siteID: siteID)

return try await enqueue(request, mapper: mapper)
}
}

// MARK: - Constants
Expand Down
45 changes: 44 additions & 1 deletion Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import Foundation

public protocol OrdersRemoteProtocol {
func loadOrders(
for siteID: Int64,
orderIDs: [Int64]
) async throws -> [Order]
}

/// Order: Remote Endpoints
///
public class OrdersRemote: Remote {
public class OrdersRemote: Remote, OrdersRemoteProtocol {
/// The source of the order creation.
public enum OrderCreationSource {
case storeManagement
Expand Down Expand Up @@ -111,6 +118,41 @@ public class OrdersRemote: Remote {
enqueue(request, mapper: mapper, completion: completion)
}

/// Retrieves specific `Order`s.
///
/// - Parameters:
/// - siteID: Site for which we'll fetch remote orders.
/// - orderIDs: The IDs of the orders to fetch.
/// - Returns: Array of orders.
/// - Throws: Network or parsing errors.
///
public func loadOrders(
for siteID: Int64,
orderIDs: [Int64]
) async throws -> [Order] {
guard !orderIDs.isEmpty else {
return []
}

let parameters: [String: Any] = [
ParameterKeys.include: Set(orderIDs).map(String.init).joined(separator: ","),
ParameterKeys.fields: ParameterValues.fieldValues
]

let path = Constants.ordersPath
let request = JetpackRequest(
wooApiVersion: .mark3,
method: .get,
siteID: siteID,
path: path,
parameters: parameters,
availableAsRESTRequest: true
)
let mapper = OrderListMapper(siteID: siteID)

return try await enqueue(request, mapper: mapper)
}

/// Retrieves the notes for a specific `Order`
///
/// - Parameters:
Expand Down Expand Up @@ -534,6 +576,7 @@ public extension OrdersRemote {
static let addedByUser: String = "added_by_user"
static let customerNote: String = "customer_note"
static let keyword: String = "search"
static let include: String = "include"
static let note: String = "note"
static let page: String = "page"
static let perPage: String = "per_page"
Expand Down
7 changes: 7 additions & 0 deletions Modules/Sources/Yosemite/Actions/BookingAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ public enum BookingAction: Action {
startDateAfter: String? = nil,
shouldClearCache: Bool = false,
onCompletion: (Result<Bool, Error>) -> Void)
/// Synchronizes the Booking matching the specified criteria.
///
/// - Parameter onCompletion: called when sync completes, returns an error in case of a failure or empty in case of success.
///
case synchronizeBooking(siteID: Int64,
bookingID: Int64,
onCompletion: (Result<Void, Error>) -> Void)

/// Checks if the store already has any bookings.
/// Returns `false` if the store has no bookings.
Expand Down
75 changes: 70 additions & 5 deletions Modules/Sources/Yosemite/Stores/BookingStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,21 @@ import Storage
//
public class BookingStore: Store {
private let remote: BookingsRemoteProtocol
private let ordersRemote: OrdersRemoteProtocol

public override convenience init(dispatcher: Dispatcher, storageManager: StorageManagerType, network: Network) {
let remote = BookingsRemote(network: network)
self.init(dispatcher: dispatcher, storageManager: storageManager, network: network, remote: remote)
let ordersRemote = OrdersRemote(network: network)
self.init(dispatcher: dispatcher, storageManager: storageManager, network: network, remote: remote, ordersRemote: ordersRemote)
}

public init(dispatcher: Dispatcher,
storageManager: StorageManagerType,
network: Network,
remote: BookingsRemoteProtocol) {
remote: BookingsRemoteProtocol,
ordersRemote: OrdersRemoteProtocol) {
self.remote = remote
self.ordersRemote = ordersRemote
super.init(dispatcher: dispatcher, storageManager: storageManager, network: network)
}

Expand All @@ -43,6 +47,8 @@ public class BookingStore: Store {
startDateAfter: startDateAfter,
shouldClearCache: shouldClearCache,
onCompletion: onCompletion)
case .synchronizeBooking(siteID: let siteID, bookingID: let bookingID, onCompletion: let onCompletion):
synchronizeBooking(siteID: siteID, bookingID: bookingID, onCompletion: onCompletion)
case let .checkIfStoreHasBookings(siteID, onCompletion):
checkIfStoreHasBookings(siteID: siteID, onCompletion: onCompletion)
case let .searchBookings(siteID, searchQuery, pageNumber, pageSize, startDateBefore, startDateAfter, onCompletion):
Expand Down Expand Up @@ -79,8 +85,15 @@ private extension BookingStore {
startDateBefore: startDateBefore,
startDateAfter: startDateAfter,
searchQuery: nil)

let orders = try await ordersRemote.loadOrders(
for: siteID,
orderIDs: bookings.map { $0.orderID }
)

await upsertStoredBookingsInBackground(
readOnlyBookings: bookings,
readOnlyOrders: orders,
siteID: siteID,
shouldDeleteExistingBookings: shouldClearCache
)
Expand All @@ -92,6 +105,45 @@ private extension BookingStore {
}
}

func synchronizeBooking(
siteID: Int64,
bookingID: Int64,
onCompletion: @escaping (Result<Void, Error>) -> Void
) {
enum SynchronizeBookingError: Error {
case bookingIsMissing
}

Task { @MainActor in
do {
let booking = try await remote.loadBooking(
bookingID: bookingID,
siteID: siteID
)

guard let booking else {
onCompletion(.failure(SynchronizeBookingError.bookingIsMissing))
return
}

let orders = try await ordersRemote.loadOrders(
for: siteID,
orderIDs: [booking.orderID]
)

await upsertStoredBookingsInBackground(
readOnlyBookings: [booking],
readOnlyOrders: orders,
siteID: siteID
)

onCompletion(.success(()))
} catch {
onCompletion(.failure(error))
}
}
}

/// Checks if the store already has any bookings.
/// Returns `false` if the store has no bookings.
///
Expand Down Expand Up @@ -149,18 +201,21 @@ private extension BookingStore {

// MARK: - Storage: Booking
//
extension BookingStore {
private extension BookingStore {

/// Updates (OR Inserts) the specified ReadOnly Booking Entities *in a background thread* async.
/// Also deletes existing bookings if requested.
func upsertStoredBookingsInBackground(readOnlyBookings: [Yosemite.Booking],
readOnlyOrders: [Yosemite.Order],
siteID: Int64,
shouldDeleteExistingBookings: Bool = false) async {
await withCheckedContinuation { [weak self] continuation in
guard let self else {
return continuation.resume()
}

upsertStoredBookingsInBackground(readOnlyBookings: readOnlyBookings,
readOnlyOrders: readOnlyOrders,
siteID: siteID,
shouldDeleteExistingBookings: shouldDeleteExistingBookings) {
continuation.resume()
Expand All @@ -173,6 +228,7 @@ extension BookingStore {
/// `onCompletion` will be called on the main thread!
///
func upsertStoredBookingsInBackground(readOnlyBookings: [Yosemite.Booking],
readOnlyOrders: [Yosemite.Order],
siteID: Int64,
shouldDeleteExistingBookings: Bool = false,
onCompletion: @escaping () -> Void) {
Expand All @@ -183,17 +239,18 @@ extension BookingStore {
if shouldDeleteExistingBookings {
storage.deleteBookings(siteID: siteID)
}
upsertStoredBookings(readOnlyBookings: readOnlyBookings, in: storage)
upsertStoredBookings(readOnlyBookings: readOnlyBookings, readOnlyOrders: readOnlyOrders, in: storage)
}, completion: onCompletion, on: .main)
}

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

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

storageBooking.update(with: readOnlyBooking)
}
}
Expand Down
49 changes: 49 additions & 0 deletions Modules/Tests/NetworkingTests/Remote/OrdersRemoteTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,55 @@ final class OrdersRemoteTests: XCTestCase {
XCTAssertTrue(queryParameters.contains(expectedParam), "Expected to have param: \(expectedParam)")
}

// MARK: - Load Orders by IDs Tests

func test_loadOrders_by_ids_when_request_succeeds_returns_parsed_orders() async throws {
// Given
let remote = OrdersRemote(network: network)
let orderIDs: [Int64] = [1, 2, 3]
network.simulateResponse(requestUrlSuffix: "orders", filename: "orders-load-all")

// When
let orders = try await remote.loadOrders(for: sampleSiteID, orderIDs: orderIDs)

// Then
XCTAssertEqual(orders.count, 4) // The sample file has 4 orders
}

func test_loadOrders_by_ids_when_invoked_sends_correct_parameters() async throws {
// Given
let remote = OrdersRemote(network: network)
let orderIDs: [Int64] = [1, 2, 3, 2] // with duplicate
network.simulateResponse(requestUrlSuffix: "orders", filename: "orders-load-all")

// When
_ = try await remote.loadOrders(for: sampleSiteID, orderIDs: orderIDs)

// Then
let request = try XCTUnwrap(network.requestsForResponseData.last as? JetpackRequest)
let parameters = request.parameters

let includeValue = parameters["include"] as? String
let includedIDs = includeValue?.split(separator: ",").map { String($0) }
XCTAssertNotNil(includeValue)
XCTAssertEqual(Set(includedIDs ?? []), Set(["1", "2", "3"])) // check for unique ids

XCTAssertNotNil(parameters["_fields"])
XCTAssertNil(parameters["per_page"]) // verify per_page is not sent
}

func test_loadOrders_by_ids_with_empty_ids_returns_empty_array_and_makes_no_request() async throws {
// Given
let remote = OrdersRemote(network: network)

// When
let orders = try await remote.loadOrders(for: sampleSiteID, orderIDs: [])

// Then
XCTAssertTrue(orders.isEmpty)
XCTAssertTrue(network.requestsForResponseData.isEmpty) // No network request should be made
}

// MARK: - Load Order Tests

/// Verifies that loadOrder properly parses the `order` sample response.
Expand Down
12 changes: 12 additions & 0 deletions Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@ import Foundation
///
final class MockBookingsRemote: BookingsRemoteProtocol {
private var loadAllBookingsResult: Result<[Booking], Error>?
private var loadBookingResult: Result<Booking?, Error>?

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

func whenLoadingBooking(thenReturn result: Result<Booking?, Error>) {
loadBookingResult = result
}

func loadAllBookings(for siteID: Int64,
pageNumber: Int,
pageSize: Int,
Expand All @@ -21,4 +26,11 @@ final class MockBookingsRemote: BookingsRemoteProtocol {
}
return try result.get()
}

func loadBooking(bookingID: Int64, siteID: Int64) async throws -> Networking.Booking? {
guard let result = loadBookingResult else {
throw NetworkError.timeout()
}
return try result.get()
}
}
Loading
Loading