diff --git a/Modules/Sources/Networking/Mapper/BookingMapper.swift b/Modules/Sources/Networking/Mapper/BookingMapper.swift new file mode 100644 index 00000000000..ea7315587ff --- /dev/null +++ b/Modules/Sources/Networking/Mapper/BookingMapper.swift @@ -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" + } +} diff --git a/Modules/Sources/Networking/Remote/BookingsRemote.swift b/Modules/Sources/Networking/Remote/BookingsRemote.swift index 4820d1e254f..7686696a42c 100644 --- a/Modules/Sources/Networking/Remote/BookingsRemote.swift +++ b/Modules/Sources/Networking/Remote/BookingsRemote.swift @@ -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 @@ -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 diff --git a/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift b/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift index 3fabefa46fc..76eb6f07bad 100644 --- a/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift +++ b/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift @@ -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 @@ -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: @@ -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" diff --git a/Modules/Sources/Yosemite/Actions/BookingAction.swift b/Modules/Sources/Yosemite/Actions/BookingAction.swift index b2d6087dfeb..ea1f9fdabba 100644 --- a/Modules/Sources/Yosemite/Actions/BookingAction.swift +++ b/Modules/Sources/Yosemite/Actions/BookingAction.swift @@ -17,6 +17,13 @@ public enum BookingAction: Action { startDateAfter: String? = nil, shouldClearCache: Bool = false, onCompletion: (Result) -> 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) /// Checks if the store already has any bookings. /// Returns `false` if the store has no bookings. diff --git a/Modules/Sources/Yosemite/Stores/BookingStore.swift b/Modules/Sources/Yosemite/Stores/BookingStore.swift index 0708c64702d..81f49f8f0e6 100644 --- a/Modules/Sources/Yosemite/Stores/BookingStore.swift +++ b/Modules/Sources/Yosemite/Stores/BookingStore.swift @@ -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) } @@ -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): @@ -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 ) @@ -92,6 +105,45 @@ private extension BookingStore { } } + func synchronizeBooking( + siteID: Int64, + bookingID: Int64, + onCompletion: @escaping (Result) -> 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. /// @@ -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() @@ -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) { @@ -183,7 +239,7 @@ extension BookingStore { if shouldDeleteExistingBookings { storage.deleteBookings(siteID: siteID) } - upsertStoredBookings(readOnlyBookings: readOnlyBookings, in: storage) + upsertStoredBookings(readOnlyBookings: readOnlyBookings, readOnlyOrders: readOnlyOrders, in: storage) }, completion: onCompletion, on: .main) } @@ -191,9 +247,10 @@ extension BookingStore { /// /// - 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 @@ -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) } } diff --git a/Modules/Tests/NetworkingTests/Remote/OrdersRemoteTests.swift b/Modules/Tests/NetworkingTests/Remote/OrdersRemoteTests.swift index 8ffe84f3bd0..d69eb7e96a8 100644 --- a/Modules/Tests/NetworkingTests/Remote/OrdersRemoteTests.swift +++ b/Modules/Tests/NetworkingTests/Remote/OrdersRemoteTests.swift @@ -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. diff --git a/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift b/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift index a435c581556..2289af6a3e5 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift @@ -5,11 +5,16 @@ import Foundation /// final class MockBookingsRemote: BookingsRemoteProtocol { private var loadAllBookingsResult: Result<[Booking], Error>? + private var loadBookingResult: Result? func whenLoadingAllBookings(thenReturn result: Result<[Booking], Error>) { loadAllBookingsResult = result } + func whenLoadingBooking(thenReturn result: Result) { + loadBookingResult = result + } + func loadAllBookings(for siteID: Int64, pageNumber: Int, pageSize: Int, @@ -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() + } } diff --git a/Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift b/Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift index 2733258724b..b325b51ac31 100644 --- a/Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift +++ b/Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift @@ -12,6 +12,7 @@ struct BookingStoreTests { /// Spy remote to check request parameter use /// private var remote: MockBookingsRemote + private var ordersRemote: MockOrdersRemote /// Mock Storage: InMemory /// @@ -51,6 +52,7 @@ struct BookingStoreTests { network = MockNetwork() storageManager = MockStorageManager() remote = MockBookingsRemote() + ordersRemote = MockOrdersRemote() } // MARK: - synchronizeBookings @@ -61,7 +63,8 @@ struct BookingStoreTests { let store = BookingStore(dispatcher: Dispatcher(), storageManager: storageManager, network: network, - remote: remote) + remote: remote, + ordersRemote: ordersRemote) // When let result = await withCheckedContinuation { continuation in @@ -85,7 +88,8 @@ struct BookingStoreTests { let store = BookingStore(dispatcher: Dispatcher(), storageManager: storageManager, network: network, - remote: remote) + remote: remote, + ordersRemote: ordersRemote) // When let result = await withCheckedContinuation { continuation in @@ -108,7 +112,8 @@ struct BookingStoreTests { let store = BookingStore(dispatcher: Dispatcher(), storageManager: storageManager, network: network, - remote: remote) + remote: remote, + ordersRemote: ordersRemote) // When let result = await withCheckedContinuation { continuation in @@ -126,6 +131,68 @@ struct BookingStoreTests { #expect(error == .timeout()) } + @Test func test_synchronizeBookings_when_invoked_fetches_orders_for_bookings() async throws { + // Given + let booking1 = Booking.fake().copy(orderID: 1) + let booking2 = Booking.fake().copy(orderID: 2) + remote.whenLoadingAllBookings(thenReturn: .success([booking1, booking2])) + + let order1 = Order.fake().copy(orderID: 1) + let order2 = Order.fake().copy(orderID: 2) + ordersRemote.whenLoadingOrders(thenReturn: .success([order1, order2])) + + let store = BookingStore(dispatcher: Dispatcher(), + storageManager: storageManager, + network: network, + remote: remote, + ordersRemote: ordersRemote) + + // When + let result = await withCheckedContinuation { continuation in + store.onAction(BookingAction.synchronizeBookings(siteID: sampleSiteID, + pageNumber: defaultPageNumber, + pageSize: defaultPageSize, + onCompletion: { result in + continuation.resume(returning: result) + })) + } + + // Then + #expect(result.isSuccess) + #expect(ordersRemote.invokedLoadOrders) + #expect(ordersRemote.invokedLoadOrdersParameters?.orderIDs == [1, 2]) + } + + @Test func test_synchronizeBooking_when_invoked_fetches_order_for_booking() async throws { + // Given + let booking = Booking.fake().copy(bookingID: 1, orderID: 10) + remote.whenLoadingBooking(thenReturn: .success(booking)) + ordersRemote.whenLoadingOrders(thenReturn: .success([])) + + let store = BookingStore(dispatcher: Dispatcher(), + storageManager: storageManager, + network: network, + remote: remote, + ordersRemote: ordersRemote) + + // When + let result = await withCheckedContinuation { continuation in + store.onAction( + BookingAction.synchronizeBooking( + siteID: sampleSiteID, + bookingID: 1 + ) { result in + continuation.resume(returning: result) + } + ) + } + + // Then + #expect(result.isSuccess) + #expect(ordersRemote.invokedLoadOrders) + #expect(ordersRemote.invokedLoadOrdersParameters?.orderIDs == [10]) + } + @Test func synchronizeBookings_stores_bookings_upon_success() async throws { // Given let booking = Booking.fake().copy(siteID: sampleSiteID, bookingID: 123) @@ -133,7 +200,8 @@ struct BookingStoreTests { let store = BookingStore(dispatcher: Dispatcher(), storageManager: storageManager, network: network, - remote: remote) + remote: remote, + ordersRemote: ordersRemote) #expect(storedBookingCount == 0) // When @@ -162,7 +230,8 @@ struct BookingStoreTests { let store = BookingStore(dispatcher: Dispatcher(), storageManager: storageManager, network: network, - remote: remote) + remote: remote, + ordersRemote: ordersRemote) // When let result = await withCheckedContinuation { continuation in @@ -189,7 +258,8 @@ struct BookingStoreTests { let store = BookingStore(dispatcher: Dispatcher(), storageManager: storageManager, network: network, - remote: remote) + remote: remote, + ordersRemote: ordersRemote) #expect(storedBookingCount == 0) // When @@ -218,7 +288,8 @@ struct BookingStoreTests { let store = BookingStore(dispatcher: Dispatcher(), storageManager: storageManager, network: network, - remote: remote) + remote: remote, + ordersRemote: ordersRemote) // When let result = await withCheckedContinuation { continuation in @@ -253,7 +324,8 @@ struct BookingStoreTests { let store = BookingStore(dispatcher: Dispatcher(), storageManager: storageManager, network: network, - remote: remote) + remote: remote, + ordersRemote: ordersRemote) // When let result = await withCheckedContinuation { continuation in @@ -289,7 +361,8 @@ struct BookingStoreTests { let store = BookingStore(dispatcher: Dispatcher(), storageManager: storageManager, network: network, - remote: remote) + remote: remote, + ordersRemote: ordersRemote) // When let result = await withCheckedContinuation { continuation in @@ -313,7 +386,8 @@ struct BookingStoreTests { let store = BookingStore(dispatcher: Dispatcher(), storageManager: storageManager, network: network, - remote: remote) + remote: remote, + ordersRemote: ordersRemote) // When let result = await withCheckedContinuation { continuation in @@ -336,7 +410,8 @@ struct BookingStoreTests { let store = BookingStore(dispatcher: Dispatcher(), storageManager: storageManager, network: network, - remote: remote) + remote: remote, + ordersRemote: ordersRemote) // When let result = await withCheckedContinuation { continuation in @@ -359,7 +434,8 @@ struct BookingStoreTests { let store = BookingStore(dispatcher: Dispatcher(), storageManager: storageManager, network: network, - remote: remote) + remote: remote, + ordersRemote: ordersRemote) // When let result = await withCheckedContinuation { continuation in @@ -385,7 +461,8 @@ struct BookingStoreTests { let store = BookingStore(dispatcher: Dispatcher(), storageManager: storageManager, network: network, - remote: remote) + remote: remote, + ordersRemote: ordersRemote) // When let result = await withCheckedContinuation { continuation in @@ -411,7 +488,8 @@ struct BookingStoreTests { let store = BookingStore(dispatcher: Dispatcher(), storageManager: storageManager, network: network, - remote: remote) + remote: remote, + ordersRemote: ordersRemote) // When let result = await withCheckedContinuation { continuation in @@ -437,7 +515,8 @@ struct BookingStoreTests { let store = BookingStore(dispatcher: Dispatcher(), storageManager: storageManager, network: network, - remote: remote) + remote: remote, + ordersRemote: ordersRemote) #expect(storedBookingCount == 0) // When @@ -465,3 +544,28 @@ private extension BookingStoreTests { return storedBooking } } + +private class MockOrdersRemote: OrdersRemoteProtocol { + var invokedLoadOrders = false + var invokedLoadOrdersParameters: (siteID: Int64, orderIDs: [Int64])? + private var loadOrdersResult: Result<[Yosemite.Order], Error> = .success([]) + + func whenLoadingOrders(thenReturn result: Result<[Yosemite.Order], Error>) { + loadOrdersResult = result + } + + func loadOrders(for siteID: Int64, orderIDs: [Int64]) async throws -> [Yosemite.Order] { + invokedLoadOrders = true + invokedLoadOrdersParameters = (siteID, orderIDs) + switch loadOrdersResult { + case .success(let orders): + return orders + case .failure(let error): + throw error + } + } + + func loadOrder(for siteID: Int64, orderID: Int64, completion: @escaping (NetworkingCore.Order?, (any Error)?) -> Void) { + return + } +}