diff --git a/Modules/Sources/Networking/Model/Bookings/Booking.swift b/Modules/Sources/Networking/Model/Bookings/Booking.swift index 38ad4a68daf..c05ab7beb05 100644 --- a/Modules/Sources/Networking/Model/Bookings/Booking.swift +++ b/Modules/Sources/Networking/Model/Bookings/Booking.swift @@ -1,4 +1,3 @@ -// periphery:ignore:all import Codegen import Foundation @@ -23,8 +22,7 @@ public struct Booking: Codable, GeneratedCopiable, Equatable, GeneratedFakeable public let statusKey: String public let localTimezone: String - /// Computed Properties - /// + // periphery: ignore - to be used later public var bookingStatus: BookingStatus { return BookingStatus(rawValue: statusKey) ?? .unknown } @@ -173,8 +171,8 @@ enum BookingDecodingError: Error { // MARK: - Supporting Types // +// periphery: ignore /// Represents a Booking Status. -/// public enum BookingStatus: String, CaseIterable { case complete case paid diff --git a/Modules/Sources/Storage/Tools/StorageType+Deletions.swift b/Modules/Sources/Storage/Tools/StorageType+Deletions.swift index 52a1fcecc9b..9c317be95d1 100644 --- a/Modules/Sources/Storage/Tools/StorageType+Deletions.swift +++ b/Modules/Sources/Storage/Tools/StorageType+Deletions.swift @@ -243,4 +243,17 @@ public extension StorageType { deleteObject($0) } } + + // MARK: - Bookings + + /// Deletes all of the stored Bookings for the provided siteID. + /// + func deleteBookings(siteID: Int64) { + guard let bookings = loadBookings(siteID: siteID) else { + return + } + for booking in bookings { + deleteObject(booking) + } + } } diff --git a/Modules/Sources/Storage/Tools/StorageType+Extensions.swift b/Modules/Sources/Storage/Tools/StorageType+Extensions.swift index 903b2c4004c..ea9258e4e8b 100644 --- a/Modules/Sources/Storage/Tools/StorageType+Extensions.swift +++ b/Modules/Sources/Storage/Tools/StorageType+Extensions.swift @@ -940,4 +940,30 @@ public extension StorageType { let predicate = \MetaData.product?.siteID == siteID && \MetaData.product?.productID == productID return allObjects(ofType: MetaData.self, matching: predicate, sortedBy: nil) } + + // MARK: - Bookings + + /// Retrieves the Stored Bookings given the IDs. + /// + func loadBookings(siteID: Int64, bookingIDs: [Int64]) -> [Booking] { + let predicate = NSPredicate(format: "siteID == %lld && bookingID in %@", siteID, bookingIDs) + let descriptor = NSSortDescriptor(keyPath: \Booking.bookingID, ascending: false) + return allObjects(ofType: Booking.self, matching: predicate, sortedBy: [descriptor]) + } + + // periphery: ignore + /// Retrieves the Stored Booking. + func loadBooking(siteID: Int64, bookingID: Int64) -> Booking? { + let predicate = \Booking.bookingID == bookingID && \Booking.siteID == siteID + return firstObject(ofType: Booking.self, matching: predicate) + } + + /// Retrieves all stored bookings for a site. + /// + func loadBookings(siteID: Int64) -> [Booking]? { + let predicate = \Booking.siteID == siteID + let descriptor = NSSortDescriptor(keyPath: \Booking.bookingID, ascending: false) + let objects = allObjects(ofType: Booking.self, matching: predicate, sortedBy: [descriptor]) + return objects.isEmpty ? nil : objects + } } diff --git a/Modules/Sources/Yosemite/Actions/BookingAction.swift b/Modules/Sources/Yosemite/Actions/BookingAction.swift new file mode 100644 index 00000000000..90c908ff7d7 --- /dev/null +++ b/Modules/Sources/Yosemite/Actions/BookingAction.swift @@ -0,0 +1,17 @@ +import Foundation +import Networking + +// periphery: ignore +/// BookingAction: Defines all of the Actions supported by the BookingStore. +/// +public enum BookingAction: Action { + + /// Synchronizes the Bookings matching the specified criteria. + /// + /// - Parameter onCompletion: called when sync completes, returns an error or a boolean that indicates whether there might be more bookings to sync. + /// + case synchronizeBookings(siteID: Int64, + pageNumber: Int, + pageSize: Int = BookingsRemote.Default.pageSize, + onCompletion: (Result) -> Void) +} diff --git a/Modules/Sources/Yosemite/Model/Model.swift b/Modules/Sources/Yosemite/Model/Model.swift index f87a23a533d..621d92ab926 100644 --- a/Modules/Sources/Yosemite/Model/Model.swift +++ b/Modules/Sources/Yosemite/Model/Model.swift @@ -266,7 +266,7 @@ public typealias StorageBlazeCampaignListItem = Storage.BlazeCampaignListItem public typealias StorageBlazeTargetDevice = Storage.BlazeTargetDevice public typealias StorageBlazeTargetLanguage = Storage.BlazeTargetLanguage public typealias StorageBlazeTargetTopic = Storage.BlazeTargetTopic -// periphery:ignore - will be used later +// periphery: ignore public typealias StorageBooking = Storage.Booking public typealias StorageCardReaderType = Storage.CardReaderType public typealias StorageCoupon = Storage.Coupon diff --git a/Modules/Sources/Yosemite/Stores/BookingStore.swift b/Modules/Sources/Yosemite/Stores/BookingStore.swift new file mode 100644 index 00000000000..b36aa97bae9 --- /dev/null +++ b/Modules/Sources/Yosemite/Stores/BookingStore.swift @@ -0,0 +1,131 @@ +import Foundation +import Networking +import Storage + +// MARK: - BookingStore +// +public class BookingStore: Store { + private let remote: BookingsRemoteProtocol + + 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) + } + + public init(dispatcher: Dispatcher, + storageManager: StorageManagerType, + network: Network, + remote: BookingsRemoteProtocol) { + self.remote = remote + super.init(dispatcher: dispatcher, storageManager: storageManager, network: network) + } + + /// Registers for supported Actions. + /// + override public func registerSupportedActions(in dispatcher: Dispatcher) { + dispatcher.register(processor: self, for: BookingAction.self) + } + + /// Receives and executes Actions. + /// + override public func onAction(_ action: Action) { + guard let action = action as? BookingAction else { + assertionFailure("BookingStore received an unsupported action") + return + } + + switch action { + case let .synchronizeBookings(siteID, pageNumber, pageSize, onCompletion): + synchronizeBookings(siteID: siteID, pageNumber: pageNumber, pageSize: pageSize, onCompletion: onCompletion) + } + } +} + + +// MARK: - Services +// +private extension BookingStore { + + /// Synchronizes the bookings for the specified site. + /// + func synchronizeBookings(siteID: Int64, + pageNumber: Int, + pageSize: Int, + onCompletion: @escaping (Result) -> Void) { + Task { @MainActor in + do { + let bookings = try await remote.loadAllBookings(for: siteID, + pageNumber: pageNumber, + pageSize: pageSize) + await upsertStoredBookingsInBackground(readOnlyBookings: bookings, siteID: siteID) + let hasNextPage = bookings.count == pageSize + onCompletion(.success(hasNextPage)) + } catch { + onCompletion(.failure(error)) + } + } + } +} + + +// MARK: - Storage: Booking +// +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], + siteID: Int64, + shouldDeleteExistingBookings: Bool = false) async { + await withCheckedContinuation { [weak self] continuation in + guard let self else { + return continuation.resume() + } + upsertStoredBookingsInBackground(readOnlyBookings: readOnlyBookings, + siteID: siteID, + shouldDeleteExistingBookings: shouldDeleteExistingBookings) { + continuation.resume() + } + } + } + + /// Updates (OR Inserts) the specified ReadOnly Booking Entities *in a background thread*. + /// Also deletes existing bookings if requested. + /// `onCompletion` will be called on the main thread! + /// + func upsertStoredBookingsInBackground(readOnlyBookings: [Yosemite.Booking], + siteID: Int64, + shouldDeleteExistingBookings: Bool = false, + onCompletion: @escaping () -> Void) { + storageManager.performAndSave({ [weak self] storage in + guard let self else { + return onCompletion() + } + if shouldDeleteExistingBookings { + storage.deleteBookings(siteID: siteID) + } + upsertStoredBookings(readOnlyBookings: readOnlyBookings, 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. + /// - storage: Where we should save all the things! + /// + func upsertStoredBookings(readOnlyBookings: [Networking.Booking], in storage: StorageType) { + // Fetch all existing bookings for the site at once + let bookingIDs = readOnlyBookings.map { $0.bookingID } + let siteID = readOnlyBookings.first?.siteID ?? 0 + let storedBookings = storage.loadBookings(siteID: siteID, bookingIDs: bookingIDs) + + for readOnlyBooking in readOnlyBookings { + // Filter to find existing booking by booking ID + let storageBooking = storedBookings.first { $0.bookingID == readOnlyBooking.bookingID } ?? + storage.insertNewObject(ofType: StorageBooking.self) + + storageBooking.update(with: readOnlyBooking) + } + } +} diff --git a/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift b/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift new file mode 100644 index 00000000000..57065c586c6 --- /dev/null +++ b/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift @@ -0,0 +1,19 @@ +import Foundation +@testable import Networking + +/// Mock for BookingsRemoteProtocol +/// +final class MockBookingsRemote: BookingsRemoteProtocol { + private var loadAllBookingsResult: Result<[Booking], Error>? + + func whenLoadingAllBookings(thenReturn result: Result<[Booking], Error>) { + loadAllBookingsResult = result + } + + func loadAllBookings(for siteID: Int64, pageNumber: Int, pageSize: Int) async throws -> [Booking] { + guard let result = loadAllBookingsResult else { + throw NetworkError.timeout() + } + return try result.get() + } +} diff --git a/Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift b/Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift new file mode 100644 index 00000000000..cab379328e3 --- /dev/null +++ b/Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift @@ -0,0 +1,218 @@ +import Testing +@testable import Networking +@testable import Storage +@testable import Yosemite + +@MainActor +struct BookingStoreTests { + /// Mock network to inject responses + /// + private var network: MockNetwork + + /// Spy remote to check request parameter use + /// + private var remote: MockBookingsRemote + + /// Mock Storage: InMemory + /// + private var storageManager: MockStorageManager + + /// Storage + /// + private var storage: StorageType { + storageManager.viewStorage + } + + /// Convenience: returns the StorageType associated with the main thread + /// + private var viewStorage: StorageType { + return storageManager.viewStorage + } + + /// Convenience: returns the number of stored bookings + /// + private var storedBookingCount: Int { + return viewStorage.countObjects(ofType: StorageBooking.self) + } + + /// SiteID + /// + private let sampleSiteID: Int64 = 120934 + + /// Default page number + /// + private let defaultPageNumber = 1 + + /// Default page size + /// + private let defaultPageSize = 25 + + init() { + network = MockNetwork() + storageManager = MockStorageManager() + remote = MockBookingsRemote() + } + + // MARK: - synchronizeBookings + + @Test func synchronizeBookings_returns_false_for_hasNextPage_when_number_of_retrieved_results_is_zero() async throws { + // Given + remote.whenLoadingAllBookings(thenReturn: .success([])) + let store = BookingStore(dispatcher: Dispatcher(), + storageManager: storageManager, + network: network, + remote: remote) + + // 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 + let hasNextPage = try result.get() + #expect(hasNextPage == false) + } + + @Test func synchronizeBookings_returns_true_for_hasNextPage_when_number_of_retrieved_results_equals_pageSize() async throws { + // Given + let bookings = Array(repeating: Booking.fake(), count: defaultPageSize) + remote.whenLoadingAllBookings(thenReturn: .success(bookings)) + let store = BookingStore(dispatcher: Dispatcher(), + storageManager: storageManager, + network: network, + remote: remote) + + // 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 + let hasNextPage = try result.get() + #expect(hasNextPage == true) + } + + @Test func synchronizeBookings_returns_error_on_failure() async throws { + // Given + remote.whenLoadingAllBookings(thenReturn: .failure(NetworkError.timeout())) + let store = BookingStore(dispatcher: Dispatcher(), + storageManager: storageManager, + network: network, + remote: remote) + + // 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.isFailure) + let error = result.failure as? NetworkError + #expect(error == .timeout()) + } + + @Test func synchronizeBookings_stores_bookings_upon_success() async throws { + // Given + let booking = Booking.fake().copy(siteID: sampleSiteID, bookingID: 123) + remote.whenLoadingAllBookings(thenReturn: .success([booking])) + let store = BookingStore(dispatcher: Dispatcher(), + storageManager: storageManager, + network: network, + remote: remote) + #expect(storedBookingCount == 0) + + // 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(storedBookingCount == 1) + } + + @Test func synchronizeBookings_updates_existing_booking_when_booking_already_exists() async throws { + // Given + let originalBooking = Booking.fake().copy(siteID: sampleSiteID, bookingID: 123, statusKey: "pending") + storeBooking(originalBooking) + #expect(storedBookingCount == 1) + + let updatedBooking = originalBooking.copy(statusKey: "confirmed") + remote.whenLoadingAllBookings(thenReturn: .success([updatedBooking])) + let store = BookingStore(dispatcher: Dispatcher(), + storageManager: storageManager, + network: network, + remote: remote) + + // 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(storedBookingCount == 1) + let storedBooking = try #require(viewStorage.loadBooking(siteID: sampleSiteID, bookingID: 123)) + #expect(storedBooking.statusKey == "confirmed") + } + + @Test func synchronizeBookings_stores_multiple_bookings_upon_success() async throws { + // Given + let booking1 = Booking.fake().copy(siteID: sampleSiteID, bookingID: 123) + let booking2 = Booking.fake().copy(siteID: sampleSiteID, bookingID: 456) + remote.whenLoadingAllBookings(thenReturn: .success([booking1, booking2])) + let store = BookingStore(dispatcher: Dispatcher(), + storageManager: storageManager, + network: network, + remote: remote) + #expect(storedBookingCount == 0) + + // 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(storedBookingCount == 2) + } +} + +private extension BookingStoreTests { + @discardableResult + func storeBooking(_ booking: Networking.Booking) -> Storage.Booking { + let storedBooking = storage.insertNewObject(ofType: Storage.Booking.self) + storedBooking.update(with: booking) + return storedBooking + } +} diff --git a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift index 5177f8695a6..97e2f60286a 100644 --- a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift +++ b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift @@ -126,7 +126,8 @@ class AuthenticatedState: StoresManagerState { StoreOnboardingTasksStore(dispatcher: dispatcher, storageManager: storageManager, network: network), GoogleAdsStore(dispatcher: dispatcher, storageManager: storageManager, network: network), MetaDataStore(dispatcher: dispatcher, storageManager: storageManager, network: network), - WooShippingStore(dispatcher: dispatcher, storageManager: storageManager, network: network) + WooShippingStore(dispatcher: dispatcher, storageManager: storageManager, network: network), + BookingStore(dispatcher: dispatcher, storageManager: storageManager, network: network) ]