diff --git a/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift b/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift index 142a622178e..709ceddeb71 100644 --- a/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift +++ b/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift @@ -450,6 +450,30 @@ extension OrdersRemote: POSOrdersRemoteProtocol { } } } + + public func loadPOSOrders(siteID: Int64, pageNumber: Int, pageSize: Int) async throws -> PagedItems { + let parameters: [String: Any] = [ + ParameterKeys.page: String(pageNumber), + ParameterKeys.perPage: String(pageSize), + ParameterKeys.statusKey: Defaults.statusAny, + ParameterKeys.usesGMTDates: true, + ParameterKeys.fields: ParameterValues.fieldValues, + ParameterKeys.createdVia: "pos-rest-api" + ] + + let path = Constants.ordersPath + let request = JetpackRequest(wooApiVersion: .mark3, + method: .get, + siteID: siteID, + path: path, + parameters: parameters, + availableAsRESTRequest: true) + let mapper = OrderListMapper(siteID: siteID) + + let orders: [Order] = try await enqueue(request, mapper: mapper) + let hasMorePages = orders.count == pageSize + return PagedItems(items: orders, hasMorePages: hasMorePages, totalItems: nil) + } } diff --git a/Modules/Sources/NetworkingCore/Remote/POSOrdersRemoteProtocol.swift b/Modules/Sources/NetworkingCore/Remote/POSOrdersRemoteProtocol.swift index a0f16ef522f..60e7f8dff5e 100644 --- a/Modules/Sources/NetworkingCore/Remote/POSOrdersRemoteProtocol.swift +++ b/Modules/Sources/NetworkingCore/Remote/POSOrdersRemoteProtocol.swift @@ -14,4 +14,8 @@ public protocol POSOrdersRemoteProtocol { func createPOSOrder(siteID: Int64, order: Order, fields: [OrdersRemote.CreateOrderField]) async throws -> Order + + func loadPOSOrders(siteID: Int64, + pageNumber: Int, + pageSize: Int) async throws -> PagedItems } diff --git a/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrder.swift b/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrder.swift new file mode 100644 index 00000000000..43076d28b52 --- /dev/null +++ b/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrder.swift @@ -0,0 +1,73 @@ +import Foundation +import struct NetworkingCore.Address +import struct NetworkingCore.OrderItem +import struct NetworkingCore.OrderRefundCondensed +import struct NetworkingCore.MetaData +import enum NetworkingCore.OrderStatusEnum +import struct NetworkingCore.Order + +public struct POSOrder: Equatable, Hashable { + public let id: Int64 + public let number: String + public let dateCreated: Date + public let status: OrderStatusEnum + public let total: String + public let customerEmail: String? + public let paymentMethodTitle: String + public let lineItems: [POSOrderItem] + public let refunds: [POSOrderRefund] + public let currency: String + public let currencySymbol: String + + public init(id: Int64, + number: String, + dateCreated: Date, + status: OrderStatusEnum, + total: String, + customerEmail: String? = nil, + paymentMethodTitle: String, + lineItems: [POSOrderItem] = [], + refunds: [POSOrderRefund] = [], + currency: String, + currencySymbol: String) { + self.id = id + self.number = number + self.dateCreated = dateCreated + self.status = status + self.total = total + self.customerEmail = customerEmail + self.paymentMethodTitle = paymentMethodTitle + self.lineItems = lineItems + self.refunds = refunds + self.currency = currency + self.currencySymbol = currencySymbol + } +} + +// MARK: - Conversion from NetworkingCore.Order +public extension POSOrder { + init(from order: NetworkingCore.Order) { + // Extract customer email from billing address + let customerEmail = order.billingAddress?.email + + // Convert line items to POS format + let posLineItems = order.items.map { POSOrderItem(from: $0) } + + // Convert refunds to POS format + let posRefunds = order.refunds.map { POSOrderRefund(from: $0) } + + self.init( + id: order.orderID, + number: order.number, + dateCreated: order.dateCreated, + status: order.status, + total: order.total, + customerEmail: customerEmail, + paymentMethodTitle: order.paymentMethodTitle, + lineItems: posLineItems, + refunds: posRefunds, + currency: order.currency, + currencySymbol: order.currencySymbol + ) + } +} diff --git a/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrderItem.swift b/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrderItem.swift new file mode 100644 index 00000000000..6913fc821f4 --- /dev/null +++ b/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrderItem.swift @@ -0,0 +1,31 @@ +import Foundation +import struct NetworkingCore.OrderItem + +public struct POSOrderItem: Equatable, Hashable { + public let itemID: Int64 + public let name: String + public let quantity: Decimal + public let total: String + + public init(itemID: Int64, + name: String, + quantity: Decimal, + total: String) { + self.itemID = itemID + self.name = name + self.quantity = quantity + self.total = total + } +} + +// MARK: - Conversion from NetworkingCore.OrderItem +public extension POSOrderItem { + init(from orderItem: OrderItem) { + self.init( + itemID: orderItem.itemID, + name: orderItem.name, + quantity: orderItem.quantity, + total: orderItem.total + ) + } +} diff --git a/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrderRefund.swift b/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrderRefund.swift new file mode 100644 index 00000000000..53c2fd1d9dc --- /dev/null +++ b/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrderRefund.swift @@ -0,0 +1,27 @@ +import Foundation +import struct NetworkingCore.OrderRefundCondensed + +public struct POSOrderRefund: Equatable, Hashable { + public let refundID: Int64 + public let total: String + public let reason: String? + + public init(refundID: Int64, + total: String, + reason: String? = nil) { + self.refundID = refundID + self.total = total + self.reason = reason + } +} + +// MARK: - Conversion from NetworkingCore.OrderRefundCondensed +public extension POSOrderRefund { + init(from refund: OrderRefundCondensed) { + self.init( + refundID: refund.refundID, + total: refund.total, + reason: refund.reason + ) + } +} diff --git a/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListFetchStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListFetchStrategy.swift new file mode 100644 index 00000000000..f59b1931f39 --- /dev/null +++ b/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListFetchStrategy.swift @@ -0,0 +1,18 @@ +import Foundation +import struct NetworkingCore.PagedItems + +public protocol PointOfSaleOrderListFetchStrategy { + func fetchOrders(pageNumber: Int) async throws -> PagedItems +} + +struct PointOfSaleDefaultOrderListFetchStrategy: PointOfSaleOrderListFetchStrategy { + private let orderListService: PointOfSaleOrderListServiceProtocol + + init(orderListService: PointOfSaleOrderListServiceProtocol) { + self.orderListService = orderListService + } + + func fetchOrders(pageNumber: Int) async throws -> PagedItems { + try await orderListService.providePointOfSaleOrders(pageNumber: pageNumber) + } +} diff --git a/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListFetchStrategyFactory.swift b/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListFetchStrategyFactory.swift new file mode 100644 index 00000000000..51df3241284 --- /dev/null +++ b/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListFetchStrategyFactory.swift @@ -0,0 +1,24 @@ +import Foundation +import class Networking.AlamofireNetwork +import class Networking.OrdersRemote + +public protocol PointOfSaleOrderListFetchStrategyFactoryProtocol { + func defaultStrategy() -> PointOfSaleOrderListFetchStrategy +} + +public final class PointOfSaleOrderListFetchStrategyFactory: PointOfSaleOrderListFetchStrategyFactoryProtocol { + private let siteID: Int64 + private let ordersRemote: OrdersRemote + + public init(siteID: Int64, + credentials: Credentials?) { + self.siteID = siteID + let network = AlamofireNetwork(credentials: credentials) + self.ordersRemote = OrdersRemote(network: network) + } + + public func defaultStrategy() -> PointOfSaleOrderListFetchStrategy { + PointOfSaleDefaultOrderListFetchStrategy(orderListService: PointOfSaleOrderListService(siteID: siteID, + ordersRemote: ordersRemote)) + } +} diff --git a/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListService.swift b/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListService.swift new file mode 100644 index 00000000000..9b8ad7ff7a9 --- /dev/null +++ b/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListService.swift @@ -0,0 +1,40 @@ +import Foundation +import enum Alamofire.AFError +import struct NetworkingCore.PagedItems +import struct NetworkingCore.Order +import protocol NetworkingCore.POSOrdersRemoteProtocol + +public final class PointOfSaleOrderListService: PointOfSaleOrderListServiceProtocol { + private let ordersRemote: POSOrdersRemoteProtocol + private let siteID: Int64 + + public init(siteID: Int64, ordersRemote: POSOrdersRemoteProtocol) { + self.siteID = siteID + self.ordersRemote = ordersRemote + } + + public func providePointOfSaleOrders(pageNumber: Int = 1) async throws -> PagedItems { + do { + let pagedOrders = try await ordersRemote.loadPOSOrders( + siteID: siteID, + pageNumber: pageNumber, + pageSize: 25 + ) + + if pageNumber != 1 && pagedOrders.items.count == 0 { + return .init(items: [], hasMorePages: false, totalItems: 0) + } + + // Convert Order objects to POSOrder objects + let posOrders = pagedOrders.items.map { POSOrder(from: $0) } + + return .init(items: posOrders, + hasMorePages: pagedOrders.hasMorePages, + totalItems: pagedOrders.totalItems) + } catch AFError.explicitlyCancelled { + throw PointOfSaleOrderListServiceError.requestCancelled + } catch { + throw PointOfSaleOrderListServiceError.requestFailed + } + } +} diff --git a/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListServiceProtocol.swift b/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListServiceProtocol.swift new file mode 100644 index 00000000000..c80cfe9a0e3 --- /dev/null +++ b/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListServiceProtocol.swift @@ -0,0 +1,11 @@ +import Foundation +import struct NetworkingCore.PagedItems + +public enum PointOfSaleOrderListServiceError: Error, Equatable { + case requestFailed + case requestCancelled +} + +public protocol PointOfSaleOrderListServiceProtocol { + func providePointOfSaleOrders(pageNumber: Int) async throws -> PagedItems +} diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift index 0170cb4ddc2..64927dddf4b 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift @@ -33,4 +33,24 @@ final class MockPOSOrdersRemote: POSOrdersRemoteProtocol { spyCreatePOSOrderFields = fields return Order.fake() } + + var mockPagedOrdersResult: Result, Error> = .success(PagedItems(items: [], hasMorePages: false, totalItems: 0)) + var loadPOSOrdersCalled = false + var spySiteID: Int64? + var spyPageNumber: Int? + var spyPageSize: Int? + + func loadPOSOrders(siteID: Int64, pageNumber: Int, pageSize: Int) async throws -> PagedItems { + loadPOSOrdersCalled = true + spySiteID = siteID + spyPageNumber = pageNumber + spyPageSize = pageSize + + switch mockPagedOrdersResult { + case .success(let pagedOrders): + return pagedOrders + case .failure(let error): + throw error + } + } } diff --git a/Modules/Tests/YosemiteTests/PointOfSale/PointOfSaleOrderServiceTests.swift b/Modules/Tests/YosemiteTests/PointOfSale/PointOfSaleOrderServiceTests.swift new file mode 100644 index 00000000000..cda5bc77cab --- /dev/null +++ b/Modules/Tests/YosemiteTests/PointOfSale/PointOfSaleOrderServiceTests.swift @@ -0,0 +1,146 @@ +import XCTest +@testable import Yosemite +import struct NetworkingCore.PagedItems +import struct NetworkingCore.Order +import enum NetworkingCore.OrderStatusEnum + +final class PointOfSaleOrderServiceTests: XCTestCase { + private let siteID: Int64 = 13092 + private var orderProvider: PointOfSaleOrderListServiceProtocol! + private var mockOrdersRemote: MockPOSOrdersRemote! + + override func setUp() { + super.setUp() + mockOrdersRemote = MockPOSOrdersRemote() + orderProvider = PointOfSaleOrderListService(siteID: siteID, ordersRemote: mockOrdersRemote) + } + + override func tearDown() { + orderProvider = nil + mockOrdersRemote = nil + super.tearDown() + } + + func test_PointOfSaleOrderServiceProtocol_when_fails_request_with_requestFailed_then_throws_error() async throws { + let expectedError = PointOfSaleOrderListServiceError.requestFailed + mockOrdersRemote.mockPagedOrdersResult = .failure(expectedError) + + do { + _ = try await orderProvider.providePointOfSaleOrders(pageNumber: 1) + XCTFail("Expected an error, but got success.") + } catch { + XCTAssertEqual(error as? PointOfSaleOrderListServiceError, expectedError) + } + } + + func test_PointOfSaleOrderServiceProtocol_when_empty_data_for_non_first_page_of_orders_then_returns_empty_orders_and_no_next_page() async throws { + mockOrdersRemote.mockPagedOrdersResult = .success(PagedItems(items: [], hasMorePages: false, totalItems: 0)) + + let pagedOrders = try await orderProvider.providePointOfSaleOrders(pageNumber: 2) + + XCTAssertTrue(pagedOrders.items.isEmpty) + XCTAssertFalse(pagedOrders.hasMorePages) + } + + func test_PointOfSaleOrderServiceProtocol_provides_no_orders_when_store_has_no_orders() async throws { + mockOrdersRemote.mockPagedOrdersResult = .success(PagedItems(items: [], hasMorePages: false, totalItems: 0)) + + let pagedOrders = try await orderProvider.providePointOfSaleOrders(pageNumber: 1) + + XCTAssertTrue(pagedOrders.items.isEmpty) + XCTAssertTrue(mockOrdersRemote.loadPOSOrdersCalled) + XCTAssertEqual(mockOrdersRemote.spyPageNumber, 1) + XCTAssertEqual(mockOrdersRemote.spyPageSize, 25) + } + + func test_PointOfSaleOrderServiceProtocol_provides_orders_when_store_has_orders() async throws { + let mockOrder = Order.fake().copy( + orderID: 1001, + number: "1001", + status: .completed, + total: "25.99" + ) + mockOrdersRemote.mockPagedOrdersResult = .success(PagedItems(items: [mockOrder], + hasMorePages: false, + totalItems: 1)) + + let pagedOrders = try await orderProvider.providePointOfSaleOrders(pageNumber: 1) + + XCTAssertEqual(pagedOrders.items.count, 1) + XCTAssertTrue(mockOrdersRemote.loadPOSOrdersCalled) + XCTAssertEqual(mockOrdersRemote.spyPageNumber, 1) + XCTAssertEqual(mockOrdersRemote.spyPageSize, 25) + XCTAssertEqual(pagedOrders.items.first?.id, 1001) + } + + func test_PointOfSaleOrderServiceProtocol_returns_correct_pagination_when_more_pages_available() async throws { + let mockOrders = [ + Order.fake().copy(orderID: 1001, number: "1001", status: .completed, total: "25.99"), + Order.fake().copy(orderID: 1002, number: "1002", status: .completed, total: "15.50") + ] + mockOrdersRemote.mockPagedOrdersResult = .success(PagedItems(items: mockOrders, + hasMorePages: true, + totalItems: 10)) + + let pagedOrders = try await orderProvider.providePointOfSaleOrders(pageNumber: 1) + + XCTAssertEqual(pagedOrders.items.count, 2) + XCTAssertTrue(pagedOrders.hasMorePages) + XCTAssertEqual(pagedOrders.totalItems, 10) + XCTAssertTrue(mockOrdersRemote.loadPOSOrdersCalled) + } + + func test_providePointOfSaleOrders_uses_passed_page_number() async throws { + mockOrdersRemote.mockPagedOrdersResult = .success(PagedItems(items: [], hasMorePages: false, totalItems: 0)) + + _ = try await orderProvider.providePointOfSaleOrders(pageNumber: 5) + + XCTAssertTrue(mockOrdersRemote.loadPOSOrdersCalled) + XCTAssertEqual(mockOrdersRemote.spyPageNumber, 5) + XCTAssertEqual(mockOrdersRemote.spyPageSize, 25) + } + + func test_providePointOfSaleOrders_handles_fetch_error() async { + mockOrdersRemote.mockPagedOrdersResult = .failure(TestError.expectedError) + + do { + _ = try await orderProvider.providePointOfSaleOrders(pageNumber: 1) + XCTFail("Expected error to be thrown") + } catch PointOfSaleOrderListServiceError.requestFailed { + // Expected + } catch { + XCTFail("Unexpected error occurred: \(error)") + } + } + + func test_providePointOfSaleOrders_handles_empty_results() async throws { + mockOrdersRemote.mockPagedOrdersResult = .success(PagedItems(items: [], hasMorePages: false, totalItems: 0)) + + let pagedOrders = try await orderProvider.providePointOfSaleOrders(pageNumber: 1) + + XCTAssertEqual(pagedOrders.items.count, 0) + XCTAssertTrue(mockOrdersRemote.loadPOSOrdersCalled) + } + + func test_providePointOfSaleOrders_passes_correct_site_id() async throws { + mockOrdersRemote.mockPagedOrdersResult = .success(PagedItems(items: [], hasMorePages: false, totalItems: 0)) + + _ = try await orderProvider.providePointOfSaleOrders(pageNumber: 1) + + XCTAssertEqual(mockOrdersRemote.spySiteID, siteID) + } + + func test_providePointOfSaleOrders_uses_default_page_size() async throws { + mockOrdersRemote.mockPagedOrdersResult = .success(PagedItems(items: [], hasMorePages: false, totalItems: 0)) + + _ = try await orderProvider.providePointOfSaleOrders(pageNumber: 0) + + XCTAssertEqual(mockOrdersRemote.spyPageSize, 25) + } +} + +private extension PointOfSaleOrderServiceTests { + enum TestError: Error { + case expectedError + } +} diff --git a/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderListController.swift b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderListController.swift new file mode 100644 index 00000000000..0a110f83441 --- /dev/null +++ b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderListController.swift @@ -0,0 +1,121 @@ +import Foundation +import Observation +import enum Yosemite.PointOfSaleOrderListServiceError +import protocol Yosemite.PointOfSaleOrderListServiceProtocol +import protocol Yosemite.PointOfSaleOrderListFetchStrategyFactoryProtocol +import protocol Yosemite.PointOfSaleOrderListFetchStrategy +import struct Yosemite.POSOrder +import struct Yosemite.POSOrderItem +import struct Yosemite.POSOrderRefund +import class Yosemite.Store + +protocol PointOfSaleOrderListControllerProtocol { + var ordersViewState: OrderListState { get } + func loadOrders() async + func refreshOrders() async + func loadNextOrders() async +} + +@Observable final class PointOfSaleOrderListController: PointOfSaleOrderListControllerProtocol { + var ordersViewState: OrderListState + private let paginationTracker: AsyncPaginationTracker + private var fetchStrategy: PointOfSaleOrderListFetchStrategy + private var cachedOrders: [POSOrder] = [] + + init(orderListFetchStrategyFactory: PointOfSaleOrderListFetchStrategyFactoryProtocol, + initialState: OrderListState = .loading([])) { + self.ordersViewState = initialState + self.paginationTracker = .init() + self.fetchStrategy = orderListFetchStrategyFactory.defaultStrategy() + } + + @MainActor + func loadOrders() async { + setCachedData() + setLoadingState() + await loadFirstPage() + } + + @MainActor + func refreshOrders() async { + await loadFirstPage() + } + + @MainActor + func loadNextOrders() async { + guard paginationTracker.hasNextPage else { + return + } + let currentOrders = ordersViewState.orders + ordersViewState = .loading(currentOrders) + do { + _ = try await paginationTracker.ensureNextPageIsSynced { [weak self] pageNumber in + guard let self else { return true } + return try await fetchOrders(pageNumber: pageNumber) + } + } catch { + ordersViewState = .inlineError(currentOrders, + error: .errorOnLoadingOrdersNextPage(error: error), + context: OrderListState.InlineErrorContext.pagination) + } + } + + @MainActor + private func loadFirstPage() async { + do { + try await paginationTracker.resync { [weak self] pageNumber in + guard let self else { return true } + return try await fetchOrders(pageNumber: pageNumber, appendToExistingOrders: false) + } + } catch { + let orders = ordersViewState.orders + if orders.isEmpty { + ordersViewState = .error(.errorOnLoadingOrders(error: error)) + } else { + ordersViewState = .inlineError(orders, + error: .errorOnLoadingOrders(error: error), + context: OrderListState.InlineErrorContext.refresh) + } + } + } + + private func setLoadingState() { + let orders = ordersViewState.orders + let isInitialState = ordersViewState.isLoading && orders.isEmpty + if !isInitialState { + ordersViewState = .loading(orders) + } + } + + @MainActor + private func fetchOrders(pageNumber: Int, appendToExistingOrders: Bool = true) async throws -> Bool { + do { + let pagedOrders = try await fetchStrategy.fetchOrders(pageNumber: pageNumber) + + let existingOrders = appendToExistingOrders ? ordersViewState.orders : [] + let uniqueNewOrders = pagedOrders.items.filter { newOrder in + !existingOrders.contains(where: { $0.id == newOrder.id }) + } + let allOrders = appendToExistingOrders ? existingOrders + uniqueNewOrders : uniqueNewOrders + + ordersViewState = allOrders.isEmpty ? .empty : .loaded(allOrders, hasMoreItems: pagedOrders.hasMorePages) + + if pageNumber == 1 && !appendToExistingOrders { + cachedOrders = allOrders + } + + return pagedOrders.hasMorePages + } catch PointOfSaleOrderListServiceError.requestCancelled { + return true + } + } + + @MainActor + private func setCachedData() { + guard !ordersViewState.orders.isEmpty || !cachedOrders.isEmpty else { + return + } + + ordersViewState = .loading(cachedOrders) + } +} diff --git a/WooCommerce/Classes/POS/Models/OrdersViewState.swift b/WooCommerce/Classes/POS/Models/OrdersViewState.swift new file mode 100644 index 00000000000..a60bb7b2260 --- /dev/null +++ b/WooCommerce/Classes/POS/Models/OrdersViewState.swift @@ -0,0 +1,38 @@ +import Foundation +import struct Yosemite.POSOrder +import struct Yosemite.POSOrderItem +import struct Yosemite.POSOrderRefund + +enum OrderListState: Equatable { + case loading([POSOrder]) + case loaded([POSOrder], hasMoreItems: Bool) + case inlineError([POSOrder], error: PointOfSaleErrorState, context: InlineErrorContext) + case error(PointOfSaleErrorState) + case empty + + enum InlineErrorContext { + case refresh + case pagination + } + + var isLoading: Bool { + switch self { + case .loading: + return true + default: + return false + } + } + + + var orders: [POSOrder] { + switch self { + case .loading(let orders), + .loaded(let orders, _), + .inlineError(let orders, _, _): + return orders + case .error, .empty: + return [] + } + } +} diff --git a/WooCommerce/Classes/POS/Models/PointOfSaleErrorState.swift b/WooCommerce/Classes/POS/Models/PointOfSaleErrorState.swift index 04d72b4df6b..7d4c7a3d352 100644 --- a/WooCommerce/Classes/POS/Models/PointOfSaleErrorState.swift +++ b/WooCommerce/Classes/POS/Models/PointOfSaleErrorState.swift @@ -12,6 +12,8 @@ struct PointOfSaleErrorState: Equatable { case couponsDisabled case couponsNextPageError case couponsRefreshError + case ordersLoadError + case ordersNextPageError } let errorType: ErrorType @@ -91,6 +93,22 @@ struct PointOfSaleErrorState: Equatable { buttonText: Constants.retryButtonTitle) } + static func errorOnLoadingOrders(error: Error? = nil) -> Self { + PointOfSaleErrorState( + errorType: .ordersLoadError, + title: Constants.failedToLoadOrdersTitle, + subtitle: subtitle(for: error), + buttonText: Constants.retryButtonTitle) + } + + static func errorOnLoadingOrdersNextPage(error: Error? = nil) -> Self { + PointOfSaleErrorState( + errorType: .ordersNextPageError, + title: Constants.failedToLoadOrdersNextPageTitle, + subtitle: subtitle(for: error), + buttonText: Constants.retryButtonTitle) + } + private static func subtitle(for error: Error?) -> String { if let error, error.isConnectivityError { return Constants.connectivityErrorSubtitle @@ -173,5 +191,16 @@ struct PointOfSaleErrorState: Equatable { value: "Please check your internet connection and try again.", comment: "Subtitle appearing on error screens when there is a network connectivity error." ) + static let failedToLoadOrdersTitle = NSLocalizedString( + "pos.orderList.failedToLoadOrdersTitle", + value: "Unable to load orders", + comment: "Text appearing on the order list screen when there's an error loading orders." + ) + static let failedToLoadOrdersNextPageTitle = NSLocalizedString( + "pos.orderList.failedToLoadOrdersNextPageTitle", + value: "Unable to load more orders", + comment: "Text appearing on the order list screen when there's an error loading a page of orders after " + + "the first. Shown inline with the previously loaded orders above." + ) } } diff --git a/WooCommerce/Classes/POS/Models/PointOfSaleOrderListModel.swift b/WooCommerce/Classes/POS/Models/PointOfSaleOrderListModel.swift new file mode 100644 index 00000000000..ffc0b28d6e4 --- /dev/null +++ b/WooCommerce/Classes/POS/Models/PointOfSaleOrderListModel.swift @@ -0,0 +1,10 @@ +import Foundation +import Observation + +@Observable final class PointOfSaleOrderListModel { + let ordersController: PointOfSaleOrderListControllerProtocol + + init(ordersController: PointOfSaleOrderListControllerProtocol) { + self.ordersController = ordersController + } +} diff --git a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift index 3470c85ee4e..a1b3c2c9d31 100644 --- a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift +++ b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift @@ -1,23 +1,23 @@ import SwiftUI +import struct Yosemite.POSOrder +import struct Yosemite.POSOrderItem +import struct Yosemite.POSOrderRefund struct PointOfSaleOrderDetailsView: View { - let orderID: String + let orderID: String? let onBack: () -> Void - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - private var orderTitle: String { - switch orderID { - case "order1": - return "Order 1" - case "order2": - return "Order 2" - default: - return "Unknown Order" - } + @Environment(PointOfSaleOrderListModel.self) private var orderListModel + + private var order: POSOrder? { + guard let orderID = orderID, + let orderIDInt = Int64(orderID) else { return nil } + return orderListModel.ordersController.ordersViewState.orders.first { $0.id == orderIDInt } } // Show back button when in compact mode (phone) where the detail view // is presented as a pushed view, not when in regular mode (tablet split view) + @Environment(\.horizontalSizeClass) private var horizontalSizeClass private var shouldShowBackButton: Bool { horizontalSizeClass == .compact } @@ -25,35 +25,204 @@ struct PointOfSaleOrderDetailsView: View { var body: some View { VStack(spacing: 0) { POSPageHeaderView( - title: orderTitle, - backButtonConfiguration: shouldShowBackButton ? - .init(state: .enabled, action: onBack) : nil + title: order.map { "Order #\($0.number)" } ?? "Order Details", + backButtonConfiguration: shouldShowBackButton ? .init(state: .enabled, action: onBack) : nil ) - VStack(alignment: .leading, spacing: 20) { - Text("Order details will be displayed here") + if let order = order { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + orderSummarySection(order) + productsSection(order) + totalsSection(order) + } + .padding() + } + } else { + VStack { + Spacer() + Text("Order not found") + Spacer() + } + } + } + .background(Color.posSurface) + .navigationBarHidden(true) + } + + + + @ViewBuilder + private func orderSummarySection(_ order: POSOrder) -> some View { + VStack(spacing: 12) { + HStack { + Text("Order #\(order.number)") + .font(.headline) + Spacer() + Text(order.status.rawValue) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.gray.opacity(0.2)) + .cornerRadius(4) + } + + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Date & Time") + .font(.caption) + .foregroundColor(.secondary) + Text(order.dateCreated, style: .date) + Text(order.dateCreated, style: .time) + } + Spacer() + VStack(alignment: .trailing, spacing: 4) { + Text("Total") + .font(.caption) + .foregroundColor(.secondary) + Text("\(order.currencySymbol)\(order.total)") + .font(.title2) + .fontWeight(.semibold) + } + } + + if let customerEmail = order.customerEmail { + HStack { + Text("Customer Email") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text(customerEmail) + } + } + + HStack { + Text("Payment Method") + .font(.caption) .foregroundColor(.secondary) + Spacer() + Text(order.paymentMethodTitle) + } + HStack { + Text("Currency") + .font(.caption) + .foregroundColor(.secondary) Spacer() + Text(order.currency) } - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } + .padding() .background(Color.posSurface) - .navigationBarHidden(true) + .cornerRadius(12) } -} -#if DEBUG -#Preview("Details - Order 1") { - NavigationStack { - PointOfSaleOrderDetailsView(orderID: "order1", onBack: {}) + @ViewBuilder + private func productsSection(_ order: POSOrder) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("Products") + .font(.headline) + + VStack(spacing: 8) { + ForEach(order.lineItems, id: \.itemID) { item in + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(item.name) + .fontWeight(.medium) + Text("Qty: \(item.quantity.intValue)") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + Text("\(order.currencySymbol)\(item.total)") + .fontWeight(.semibold) + } + .padding() + .background(Color.posSurface) + .cornerRadius(8) + } + } + } } -} -#Preview("Details - Order 2") { - NavigationStack { - PointOfSaleOrderDetailsView(orderID: "order2", onBack: {}) + @ViewBuilder + private func totalsSection(_ order: POSOrder) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("Totals") + .font(.headline) + + VStack(spacing: 8) { + HStack { + Text("Subtotal") + Spacer() + Text("\(order.currencySymbol)\(order.total)") + } + + Divider() + + HStack { + Text("Total") + .fontWeight(.semibold) + Spacer() + Text("\(order.currencySymbol)\(order.total)") + .fontWeight(.semibold) + } + + HStack { + Text("Paid") + Spacer() + Text("\(order.currencySymbol)\(order.total)") + } + + if !order.refunds.isEmpty { + let refundedTotal = order.refunds.reduce(0.0) { $0 + (Double($1.total) ?? 0.0) } + HStack { + Text("Refunded") + Spacer() + Text("-\(order.currencySymbol)\(String(format: "%.2f", refundedTotal))") + .foregroundColor(.red) + } + + ForEach(order.refunds, id: \.refundID) { refund in + VStack(alignment: .leading, spacing: 2) { + HStack { + Text("Refund #\(refund.refundID)") + .font(.caption) + Spacer() + Text("-\(order.currencySymbol)\(refund.total)") + .font(.caption) + .foregroundColor(.red) + } + if let reason = refund.reason, !reason.isEmpty { + Text("Reason: \(reason)") + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + + let netPayment = (Double(order.total) ?? 0.0) - refundedTotal + HStack { + Text("Net Payment") + .fontWeight(.semibold) + Spacer() + Text("\(order.currencySymbol)\(String(format: "%.2f", netPayment))") + .fontWeight(.semibold) + } + } + } + .padding() + .background(Color.posSurface) + .cornerRadius(8) + } } + +} + + +#if DEBUG +#Preview("Order Details") { + PointOfSaleOrderDetailsView(orderID: "1", onBack: {}) + .environment(POSPreviewHelpers.makePreviewOrdersModel()) } #endif diff --git a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderListView.swift b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderListView.swift new file mode 100644 index 00000000000..9bb0d2c9d71 --- /dev/null +++ b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderListView.swift @@ -0,0 +1,145 @@ +import SwiftUI +import struct Yosemite.POSOrder + +struct PointOfSaleOrderListView: View { + @Binding var selectedOrderID: String? + let onClose: () -> Void + + @Environment(PointOfSaleOrderListModel.self) private var orderListModel + @StateObject private var infiniteScrollTriggerDeterminer = ThresholdInfiniteScrollTriggerDeterminer() + + private var ordersViewState: OrderListState { + orderListModel.ordersController.ordersViewState + } + + var body: some View { + VStack(spacing: 0) { + POSPageHeaderView( + title: "Orders", + isLoading: { + if case .loading(let orders) = ordersViewState { + return !orders.isEmpty + } + return false + }(), + backButtonConfiguration: .init(state: .enabled, action: onClose, buttonIcon: "xmark") + ) + + InfiniteScrollView( + triggerDeterminer: infiniteScrollTriggerDeterminer, + loadMore: { + guard case .loaded(_, let hasMoreItems) = ordersViewState, hasMoreItems else { return } + await orderListModel.ordersController.loadNextOrders() + }, + content: { + LazyVStack(spacing: 8) { + headerRows + + switch ordersViewState { + case .empty: + Text("No orders") + case .error(let errorState): + ItemListErrorCardView(errorState: errorState) { + Task { @MainActor in + await orderListModel.ordersController.loadOrders() + } + } + default: + let orders = ordersViewState.orders + ForEach(orders, id: \.id) { order in + Button(action: { + selectedOrderID = String(order.id) + }) { + OrderRowView(order: order, isSelected: selectedOrderID == String(order.id)) + } + .buttonStyle(PlainButtonStyle()) + } + } + + footerRows + } + .padding(.horizontal) + } + ) + } + .background(Color.posSurfaceBright) + .navigationBarHidden(true) + .refreshable { + await orderListModel.ordersController.refreshOrders() + } + .task { + await orderListModel.ordersController.loadOrders() + } + } + + @ViewBuilder + private var headerRows: some View { + switch ordersViewState { + case .inlineError(_, let errorState, .refresh): + ItemListErrorCardView(errorState: errorState) { + Task { @MainActor in + await orderListModel.ordersController.loadOrders() + } + } + default: + EmptyView() + } + } + + @ViewBuilder + private var footerRows: some View { + switch ordersViewState { + case .loading(let orders): + if orders.isEmpty { + ForEach(0..<8, id: \.self) { _ in + GhostItemCardView() + } + } else { + GhostItemCardView() + } + case .inlineError(_, let errorState, .pagination): + ItemListErrorCardView(errorState: errorState) { + Task { @MainActor in + await orderListModel.ordersController.loadNextOrders() + } + } + default: + EmptyView() + } + } +} + +private struct OrderRowView: View { + let order: POSOrder + let isSelected: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Order #\(order.number)") + Spacer() + Text("\(order.currencySymbol)\(order.total)") + } + + Text(order.dateCreated, style: .date) + } + .padding() + .background(isSelected ? Color.accentColor.opacity(0.1) : Color.posSurface) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2) + ) + } +} + +#if DEBUG +#Preview("List") { + NavigationSplitView { + PointOfSaleOrderListView(selectedOrderID: .constant("1"), onClose: {}) + .environment(POSPreviewHelpers.makePreviewOrdersModel()) + } detail: { + Text("Detail View") + } +} +#endif diff --git a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrdersListView.swift b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrdersListView.swift deleted file mode 100644 index 587e904e04c..00000000000 --- a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrdersListView.swift +++ /dev/null @@ -1,45 +0,0 @@ -import SwiftUI - -struct PointOfSaleOrdersListView: View { - @Binding var selectedOrderID: String? - let onClose: () -> Void - - private let orders = [ - Order(id: "order1", title: "Order 1"), - Order(id: "order2", title: "Order 2") - ] - - var body: some View { - VStack(spacing: 0) { - POSPageHeaderView( - title: "Orders", - backButtonConfiguration: .init(state: .enabled, action: onClose, buttonIcon: "xmark") - ) - - List(orders, id: \.id, selection: $selectedOrderID) { order in - NavigationLink(value: order.id) { - Text(order.title) - .padding(.vertical, 8) - } - } - .listStyle(.plain) - } - .background(Color.posSurfaceBright) - .navigationBarHidden(true) - } -} - -private struct Order { - let id: String - let title: String -} - -#if DEBUG -#Preview("List") { - NavigationSplitView { - PointOfSaleOrdersListView(selectedOrderID: .constant("order1"), onClose: {}) - } detail: { - Text("Detail View") - } -} -#endif diff --git a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrdersView.swift b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrdersView.swift index 7ff5c4ceb62..1929b233b79 100644 --- a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrdersView.swift +++ b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrdersView.swift @@ -4,10 +4,12 @@ import UIKit struct PointOfSaleOrdersView: View { @Binding var isPresented: Bool @State private var selectedOrderID: String? + @Environment(PointOfSaleOrderListModel.self) private var orderListModel + @Environment(\.horizontalSizeClass) private var horizontalSizeClass var body: some View { CustomNavigationSplitView(selection: $selectedOrderID) { _ in - PointOfSaleOrdersListView(selectedOrderID: $selectedOrderID) { + PointOfSaleOrderListView(selectedOrderID: $selectedOrderID) { isPresented = false } } detail: { selection in @@ -18,7 +20,23 @@ struct PointOfSaleOrdersView: View { } ) } setDefaultValue: { - selectedOrderID = "order1" + if selectedOrderID == nil, + let firstOrder = orderListModel.ordersController.ordersViewState.orders.first { + selectedOrderID = String(firstOrder.id) + } + } + .onChange(of: orderListModel.ordersController.ordersViewState.orders) { oldOrders, newOrders in + guard horizontalSizeClass == .regular else { return } + + guard let firstOrder = newOrders.first else { + return + } + + if let selectedOrderID, newOrders.map(\.number).contains(selectedOrderID) { + return + } + + self.selectedOrderID = String(firstOrder.id) } } } @@ -92,5 +110,6 @@ private enum Constants { #if DEBUG #Preview("Orders View") { PointOfSaleOrdersView(isPresented: .constant(true)) + .environment(POSPreviewHelpers.makePreviewOrdersModel()) } #endif diff --git a/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift b/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift index c3ebc36978e..c2c464010f7 100644 --- a/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift +++ b/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift @@ -15,6 +15,7 @@ struct PointOfSaleEntryPointView: View { private let purchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol private let couponsController: PointOfSaleCouponsControllerProtocol private let couponsSearchController: PointOfSaleSearchingItemsControllerProtocol + private let ordersController: PointOfSaleOrderListControllerProtocol private let cardPresentPaymentService: CardPresentPaymentFacade private let orderController: PointOfSaleOrderControllerProtocol private let settingsController: PointOfSaleSettingsControllerProtocol @@ -27,6 +28,7 @@ struct PointOfSaleEntryPointView: View { purchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol, couponsController: PointOfSaleCouponsControllerProtocol, couponsSearchController: PointOfSaleSearchingItemsControllerProtocol, + ordersController: PointOfSaleOrderListControllerProtocol, onPointOfSaleModeActiveStateChange: @escaping ((Bool) -> Void), cardPresentPaymentService: CardPresentPaymentFacade, orderController: PointOfSaleOrderControllerProtocol, @@ -48,6 +50,7 @@ struct PointOfSaleEntryPointView: View { self.collectOrderPaymentAnalyticsTracker = collectOrderPaymentAnalyticsTracker self.searchHistoryService = searchHistoryService self.popularPurchasableItemsController = popularPurchasableItemsController + self.ordersController = ordersController self.barcodeScanService = barcodeScanService self.posEntryPointController = POSEntryPointController(eligibilityChecker: posEligibilityChecker) } @@ -82,6 +85,7 @@ struct PointOfSaleEntryPointView: View { .environmentObject(posModalManager) .environmentObject(posSheetManager) .environmentObject(posCoverManager) + .environment(PointOfSaleOrderListModel(ordersController: ordersController)) .injectKeyboardObserver() .onAppear { onPointOfSaleModeActiveStateChange(true) @@ -100,6 +104,7 @@ struct PointOfSaleEntryPointView: View { purchasableItemsSearchController: PointOfSalePreviewItemsController(), couponsController: PointOfSalePreviewCouponsController(), couponsSearchController: PointOfSalePreviewCouponsController(), + ordersController: POSPreviewHelpers.makePreviewOrdersModel().ordersController, onPointOfSaleModeActiveStateChange: { _ in }, cardPresentPaymentService: CardPresentPaymentPreviewService(), orderController: PointOfSalePreviewOrderController(), diff --git a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderView.swift b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderView.swift index 9403eae9faa..35218b1c616 100644 --- a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderView.swift +++ b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderView.swift @@ -24,12 +24,14 @@ struct POSPageHeaderItem: Identifiable { let title: String let subtitle: String? let isSelected: Bool + let isLoading: Bool let action: (() -> Void)? - init(title: String, subtitle: String? = nil, isSelected: Bool, action: (() -> Void)? = nil) { + init(title: String, subtitle: String? = nil, isSelected: Bool, isLoading: Bool = false, action: (() -> Void)? = nil) { self.title = title self.subtitle = subtitle self.isSelected = isSelected + self.isLoading = isLoading self.action = action } } @@ -52,10 +54,11 @@ struct POSPageHeaderView: View { init( title: String, subtitle: String? = nil, + isLoading: Bool = false, backButtonConfiguration: POSPageHeaderBackButtonConfiguration? = nil, @ViewBuilder trailingContent: () -> TrailingContent = { EmptyView() } ) { - self.items = [.init(title: title, subtitle: subtitle, isSelected: true)] + self.items = [.init(title: title, subtitle: subtitle, isSelected: true, isLoading: isLoading)] self.backButtonConfiguration = backButtonConfiguration self.trailingContent = trailingContent() } @@ -79,20 +82,29 @@ struct POSPageHeaderView: View { HStack(alignment: hStackAlignment, spacing: POSSpacing.large) { ForEach(0.. PointOfSaleOrderListModel { + return PointOfSaleOrderListModel(ordersController: PointOfSalePreviewOrderListController()) + } +} + +// MARK: - Preview Orders Controller +final class PointOfSalePreviewOrderListController: PointOfSaleOrderListControllerProtocol { + var ordersViewState: OrderListState { + .loaded( + [ + POSOrder( + id: 1, + number: "1001", + dateCreated: Date(), + status: .completed, + total: "25.00", + customerEmail: "customer1@example.com", + paymentMethodTitle: "Credit Card", + lineItems: [], + refunds: [], + currency: "USD", + currencySymbol: "$" + ), + POSOrder( + id: 2, + number: "1002", + dateCreated: Date().addingTimeInterval(-3600), + status: .processing, + total: "45.50", + customerEmail: "customer2@example.com", + paymentMethodTitle: "Cash", + lineItems: [], + refunds: [], + currency: "USD", + currencySymbol: "$" + ), + POSOrder( + id: 3, + number: "1003", + dateCreated: Date().addingTimeInterval(-7200), + status: .completed, + total: "12.75", + customerEmail: nil, + paymentMethodTitle: "Credit Card", + lineItems: [], + refunds: [], + currency: "USD", + currencySymbol: "$" + ) + ], + hasMoreItems: false + ) + } + + func loadOrders() async {} + func loadNextOrders() async {} + func refreshOrders() async { } } // MARK: - Barcode Scan Service diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 13771c2125c..78cb9b7e50a 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -31,6 +31,12 @@ 011D7A352CEC87B70007C187 /* CardPresentModalErrorEmailSent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011D7A342CEC87B60007C187 /* CardPresentModalErrorEmailSent.swift */; }; 011DF3442C53A5CF000AFDD9 /* PointOfSaleCardPresentPaymentValidatingOrderMessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011DF3432C53A5CF000AFDD9 /* PointOfSaleCardPresentPaymentValidatingOrderMessageViewModel.swift */; }; 011DF3462C53A919000AFDD9 /* PointOfSaleCardPresentPaymentActivityIndicatingMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011DF3452C53A919000AFDD9 /* PointOfSaleCardPresentPaymentActivityIndicatingMessageView.swift */; }; + 012ACB742E5C830500A49458 /* PointOfSaleOrderListController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 012ACB732E5C830500A49458 /* PointOfSaleOrderListController.swift */; }; + 012ACB762E5C83EC00A49458 /* PointOfSaleOrderListControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 012ACB752E5C83EC00A49458 /* PointOfSaleOrderListControllerTests.swift */; }; + 012ACB782E5C84A200A49458 /* OrdersViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 012ACB772E5C84A200A49458 /* OrdersViewState.swift */; }; + 012ACB7A2E5C84D200A49458 /* MockPointOfSaleOrderListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 012ACB792E5C84D200A49458 /* MockPointOfSaleOrderListService.swift */; }; + 012ACB7C2E5C9BD400A49458 /* PointOfSaleOrderListModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 012ACB7B2E5C9BD400A49458 /* PointOfSaleOrderListModel.swift */; }; + 012ACB822E5D8DCD00A49458 /* MockPointOfSaleOrderListFetchStrategyFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 012ACB812E5D8DCD00A49458 /* MockPointOfSaleOrderListFetchStrategyFactory.swift */; }; 01309A7F2DC4F39E00B77527 /* PointOfSaleCardPresentPaymentCardInsertedMessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01309A7E2DC4F39A00B77527 /* PointOfSaleCardPresentPaymentCardInsertedMessageViewModel.swift */; }; 01309A812DC4F45300B77527 /* CardPresentModalCardInserted.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01309A802DC4F44700B77527 /* CardPresentModalCardInserted.swift */; }; 01309A832DC4F89D00B77527 /* PointOfSaleCardPresentPaymentCardInsertedMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01309A822DC4F89400B77527 /* PointOfSaleCardPresentPaymentCardInsertedMessageView.swift */; }; @@ -81,7 +87,7 @@ 01AB2D122DDC7AD300AA67FD /* PointOfSaleItemListAnalyticsTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01AB2D112DDC7AD100AA67FD /* PointOfSaleItemListAnalyticsTrackerTests.swift */; }; 01AB2D142DDC7CD200AA67FD /* POSItemActionHandlerFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01AB2D132DDC7CD000AA67FD /* POSItemActionHandlerFactoryTests.swift */; }; 01AB2D162DDC8CDA00AA67FD /* MockAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01AB2D152DDC8CD600AA67FD /* MockAnalytics.swift */; }; - 01ABA0282E57579300829DC0 /* PointOfSaleOrdersListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01ABA0252E57579300829DC0 /* PointOfSaleOrdersListView.swift */; }; + 01ABA0282E57579300829DC0 /* PointOfSaleOrderListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01ABA0252E57579300829DC0 /* PointOfSaleOrderListView.swift */; }; 01ABA0292E57579300829DC0 /* PointOfSaleOrderDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01ABA0242E57579300829DC0 /* PointOfSaleOrderDetailsView.swift */; }; 01ABA02A2E57579300829DC0 /* PointOfSaleOrdersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01ABA0262E57579300829DC0 /* PointOfSaleOrdersView.swift */; }; 01ADC1362C9AB4810036F7D2 /* PointOfSaleCardPresentPaymentIntentCreationErrorMessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01ADC1352C9AB4810036F7D2 /* PointOfSaleCardPresentPaymentIntentCreationErrorMessageViewModel.swift */; }; @@ -3230,6 +3236,12 @@ 011D7A342CEC87B60007C187 /* CardPresentModalErrorEmailSent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalErrorEmailSent.swift; sourceTree = ""; }; 011DF3432C53A5CF000AFDD9 /* PointOfSaleCardPresentPaymentValidatingOrderMessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentValidatingOrderMessageViewModel.swift; sourceTree = ""; }; 011DF3452C53A919000AFDD9 /* PointOfSaleCardPresentPaymentActivityIndicatingMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentActivityIndicatingMessageView.swift; sourceTree = ""; }; + 012ACB732E5C830500A49458 /* PointOfSaleOrderListController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleOrderListController.swift; sourceTree = ""; }; + 012ACB752E5C83EC00A49458 /* PointOfSaleOrderListControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleOrderListControllerTests.swift; sourceTree = ""; }; + 012ACB772E5C84A200A49458 /* OrdersViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrdersViewState.swift; sourceTree = ""; }; + 012ACB792E5C84D200A49458 /* MockPointOfSaleOrderListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPointOfSaleOrderListService.swift; sourceTree = ""; }; + 012ACB7B2E5C9BD400A49458 /* PointOfSaleOrderListModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleOrderListModel.swift; sourceTree = ""; }; + 012ACB812E5D8DCD00A49458 /* MockPointOfSaleOrderListFetchStrategyFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPointOfSaleOrderListFetchStrategyFactory.swift; sourceTree = ""; }; 01309A7E2DC4F39A00B77527 /* PointOfSaleCardPresentPaymentCardInsertedMessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentCardInsertedMessageViewModel.swift; sourceTree = ""; }; 01309A802DC4F44700B77527 /* CardPresentModalCardInserted.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalCardInserted.swift; sourceTree = ""; }; 01309A822DC4F89400B77527 /* PointOfSaleCardPresentPaymentCardInsertedMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentCardInsertedMessageView.swift; sourceTree = ""; }; @@ -3281,7 +3293,7 @@ 01AB2D132DDC7CD000AA67FD /* POSItemActionHandlerFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSItemActionHandlerFactoryTests.swift; sourceTree = ""; }; 01AB2D152DDC8CD600AA67FD /* MockAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAnalytics.swift; sourceTree = ""; }; 01ABA0242E57579300829DC0 /* PointOfSaleOrderDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleOrderDetailsView.swift; sourceTree = ""; }; - 01ABA0252E57579300829DC0 /* PointOfSaleOrdersListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleOrdersListView.swift; sourceTree = ""; }; + 01ABA0252E57579300829DC0 /* PointOfSaleOrderListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleOrderListView.swift; sourceTree = ""; }; 01ABA0262E57579300829DC0 /* PointOfSaleOrdersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleOrdersView.swift; sourceTree = ""; }; 01ADC1352C9AB4810036F7D2 /* PointOfSaleCardPresentPaymentIntentCreationErrorMessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentIntentCreationErrorMessageViewModel.swift; sourceTree = ""; }; 01ADC1372C9AB6050036F7D2 /* PointOfSaleCardPresentPaymentIntentCreationErrorMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentIntentCreationErrorMessageView.swift; sourceTree = ""; }; @@ -6527,7 +6539,7 @@ isa = PBXGroup; children = ( 01ABA0242E57579300829DC0 /* PointOfSaleOrderDetailsView.swift */, - 01ABA0252E57579300829DC0 /* PointOfSaleOrdersListView.swift */, + 01ABA0252E57579300829DC0 /* PointOfSaleOrderListView.swift */, 01ABA0262E57579300829DC0 /* PointOfSaleOrdersView.swift */, ); path = Orders; @@ -7759,6 +7771,8 @@ 02CD3BFC2C35D01600E575C4 /* Mocks */ = { isa = PBXGroup; children = ( + 012ACB812E5D8DCD00A49458 /* MockPointOfSaleOrderListFetchStrategyFactory.swift */, + 012ACB792E5C84D200A49458 /* MockPointOfSaleOrderListService.swift */, 01F935582DFC0D4800B50B03 /* MockPointOfSaleSoundPlayer.swift */, 01AB2D152DDC8CD600AA67FD /* MockAnalytics.swift */, 686A71B72DC9EB6D0006E835 /* MockPOSSearchHistoryService.swift */, @@ -8182,6 +8196,7 @@ 68B681152D92577F0098D5CD /* PointOfSaleCouponsController.swift */, 200BA1582CF092280006DC5B /* PointOfSaleItemsController.swift */, 20CF75B92CF4E69000ACCF4A /* PointOfSaleOrderController.swift */, + 012ACB732E5C830500A49458 /* PointOfSaleOrderListController.swift */, ); path = Controllers; sourceTree = ""; @@ -8194,6 +8209,7 @@ 68D7480F2E5DB6D20048CFE9 /* PointOfSaleSettingsControllerTests.swift */, 200BA15D2CF0A9EB0006DC5B /* PointOfSaleItemsControllerTests.swift */, 02E4F26F2E0F2C75003A31E7 /* POSEntryPointControllerTests.swift */, + 012ACB752E5C83EC00A49458 /* PointOfSaleOrderListControllerTests.swift */, ); path = Controllers; sourceTree = ""; @@ -9986,6 +10002,7 @@ 68F151DF2C0DA7800082AEC8 /* Models */ = { isa = PBXGroup; children = ( + 012ACB7B2E5C9BD400A49458 /* PointOfSaleOrderListModel.swift */, 01B3A1F12DB6D48800286B7F /* ItemListType.swift */, 20FCBCDC2CE223340082DCA3 /* PointOfSaleAggregateModel.swift */, 68E9F7002E5C499000D45747 /* PointOfSaleSettingsController.swift */, @@ -10003,6 +10020,7 @@ 204415902CE622BA0070BF54 /* PointOfSaleOrderTotals.swift */, 209B7A672CEB6742003BDEF0 /* PointOfSalePaymentState.swift */, 209566242D4CF00100977124 /* PointOfSalePaymentMethod.swift */, + 012ACB772E5C84A200A49458 /* OrdersViewState.swift */, ); path = Models; sourceTree = ""; @@ -15215,6 +15233,7 @@ 02D681AB29C3F8AC00348510 /* StoreOnboardingPaymentsSetupCoordinator.swift in Sources */, B59D49CD219B587E006BF0AD /* UILabel+OrderStatus.swift in Sources */, 265BCA0C2430E741004E53EE /* ProductCategoryTableViewCell.swift in Sources */, + 012ACB742E5C830500A49458 /* PointOfSaleOrderListController.swift in Sources */, 02ACD25A2852E11700EC928E /* CloseAccountCoordinator.swift in Sources */, 0191301B2CF4E782008C0C88 /* TapToPayEducationStepViewModel.swift in Sources */, 0258D9492B68E7FE00D280D0 /* ProductsSplitViewWrapperController.swift in Sources */, @@ -15226,7 +15245,7 @@ 016910982E1D019500B731DA /* GameControllerBarcodeObserver.swift in Sources */, EE9D031B2B89E4470077CED1 /* FilterOrdersByProduct+Analytics.swift in Sources */, 20C6E7512CDE4AEA00CD124C /* ItemListState.swift in Sources */, - 01ABA0282E57579300829DC0 /* PointOfSaleOrdersListView.swift in Sources */, + 01ABA0282E57579300829DC0 /* PointOfSaleOrderListView.swift in Sources */, 01ABA0292E57579300829DC0 /* PointOfSaleOrderDetailsView.swift in Sources */, 01ABA02A2E57579300829DC0 /* PointOfSaleOrdersView.swift in Sources */, 2DB891662E27F0830001B175 /* Address+Shared.swift in Sources */, @@ -15412,6 +15431,7 @@ CCD2F51C26D697860010E679 /* ShippingLabelServicePackageListViewModel.swift in Sources */, 03076D38290C223E008EE839 /* WooNavigationSheet.swift in Sources */, 022CE91A29BB143000F210E0 /* ProductSelectorNavigationView.swift in Sources */, + 012ACB7C2E5C9BD400A49458 /* PointOfSaleOrderListModel.swift in Sources */, B99686E02A13C8CC00D1AF62 /* ScanToPayView.swift in Sources */, 02B191502CCF27F300CF38C9 /* PointOfSaleCardPresentPaymentOnboardingView.swift in Sources */, CE070A3E2BBC608A00017578 /* GiftCardsReportCardViewModel.swift in Sources */, @@ -15734,6 +15754,7 @@ B58B4AC02108FF6100076FDD /* Array+Helpers.swift in Sources */, B90C65CD29ACE2D6004CAB9E /* CardPresentPaymentOnboardingStateCache.swift in Sources */, 028AFFB32484ED2800693C09 /* Dictionary+Logging.swift in Sources */, + 012ACB782E5C84A200A49458 /* OrdersViewState.swift in Sources */, 026CAF802AC2B7FF002D23BB /* ConfigurableBundleProductView.swift in Sources */, 45BBFBC1274FD94300213001 /* HubMenuCoordinator.swift in Sources */, 01E62EC82DFADF56003A6D9E /* Cart+BarcodeScanError.swift in Sources */, @@ -17099,6 +17120,7 @@ 3198A1E82694DC7200597213 /* MockKnownReadersProvider.swift in Sources */, DEC51B04276B30F6009F3DF4 /* SystemStatusReportViewModelTests.swift in Sources */, 26B119C224D1CD3500FED5C7 /* WooConstantsTests.swift in Sources */, + 012ACB822E5D8DCD00A49458 /* MockPointOfSaleOrderListFetchStrategyFactory.swift in Sources */, DE96844D2A332CC2000FBF4E /* ShareProductCoordinatorTests.swift in Sources */, 26100B202722FCAD00473045 /* MockCardPresentPaymentsOnboardingUseCase.swift in Sources */, 263491D5299C923400594566 /* SupportFormViewModelTests.swift in Sources */, @@ -17202,6 +17224,7 @@ DEF657AA2C8AC25C00ACD61E /* BlazeCampaignObjectivePickerViewModelTests.swift in Sources */, EE2EDFE12987A189004E702B /* MockABTestVariationProvider.swift in Sources */, 0273707E24C0047800167204 /* SequenceHelpersTests.swift in Sources */, + 012ACB762E5C83EC00A49458 /* PointOfSaleOrderListControllerTests.swift in Sources */, DE9A02A32A44441200193ABF /* RequirementsCheckerTests.swift in Sources */, D802547326551D0F001B2CC1 /* CardPresentModalTapCardTests.swift in Sources */, B55BC1F321A8790F0011A0C0 /* StringHTMLTests.swift in Sources */, @@ -17647,6 +17670,7 @@ 02A275C023FE58F6005C560F /* MockImageCache.swift in Sources */, EE6C6B6E2C65DC4100632BDA /* WordPressMediaLibraryPickerDataSourceTests.swift in Sources */, 20BCF6F02B0E48CC00954840 /* WooPaymentsPayoutsOverviewViewModelTests.swift in Sources */, + 012ACB7A2E5C84D200A49458 /* MockPointOfSaleOrderListService.swift in Sources */, 261AA30E275506DE009530FE /* PaymentMethodsViewModelTests.swift in Sources */, 4524CDA1242D045C00B2F20A /* ProductStatusSettingListSelectorCommandTests.swift in Sources */, 26A280D72B46027A00ACEE87 /* OrderNotificationView.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderListControllerTests.swift b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderListControllerTests.swift new file mode 100644 index 00000000000..df6fbf32b69 --- /dev/null +++ b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderListControllerTests.swift @@ -0,0 +1,248 @@ +import Testing +import Foundation +@testable import WooCommerce +import enum Yosemite.PointOfSaleOrderListServiceError +import struct NetworkingCore.Order +import Observation + +final class PointOfSaleOrderListControllerTests { + private let orderListService = MockPointOfSaleOrderListService() + private lazy var fetchStrategyFactory = MockPointOfSaleOrderListFetchStrategyFactory(orderService: orderListService) + private lazy var sut = PointOfSaleOrderListController(orderListFetchStrategyFactory: fetchStrategyFactory) + + @Test func loadOrders_requests_first_page_after_loading_two_pages() async throws { + try #require(sut.ordersViewState.isLoading) + orderListService.shouldSimulateTwoPages = true + await sut.loadOrders() + + await sut.loadNextOrders() + try #require(orderListService.spyLastRequestedPageNumber == 2) + + await sut.loadOrders() + + #expect(orderListService.spyLastRequestedPageNumber == 1) + } + + @Test func loadOrders_results_in_loaded_state() async throws { + let expectedOrders = MockPointOfSaleOrderListService.makeInitialOrders() + orderListService.orderPages = [expectedOrders] + try #require(sut.ordersViewState.isLoading) + + await sut.loadOrders() + + #expect(sut.ordersViewState == .loaded(expectedOrders, hasMoreItems: false)) + } + + @Test func loadOrders_with_more_pages_sets_hasMoreItems() async throws { + let expectedOrders = MockPointOfSaleOrderListService.makeInitialOrders() + try #require(sut.ordersViewState.isLoading) + orderListService.shouldSimulateTwoPages = true + + await sut.loadOrders() + + #expect(sut.ordersViewState == .loaded(expectedOrders, hasMoreItems: true)) + } + + @Test func loadOrders_when_called_multiple_times_then_orders_are_not_duplicated() async throws { + try #require(sut.ordersViewState.isLoading) + let expectedOrders = MockPointOfSaleOrderListService.makeInitialOrders() + orderListService.orderPages = [expectedOrders] + + await sut.loadOrders() + await sut.loadOrders() + await sut.loadOrders() + + guard case .loaded(let orders, _) = sut.ordersViewState else { + Issue.record("Expected loaded OrderList state, but got \(sut.ordersViewState)") + return + } + #expect(orders.count == expectedOrders.count) + } + + @Test func container_state_starts_as_loading() { + #expect(sut.ordersViewState.isLoading) + } + + @Test func loadNextOrders_when_initial_orders_empty_then_container_state_is_content_and_orders_state_is_empty() async throws { + orderListService.shouldReturnZeroOrders = true + + try #require(sut.ordersViewState.isLoading) + + await sut.loadNextOrders() + + #expect(!sut.ordersViewState.isLoading) + #expect(sut.ordersViewState == .empty) + } + + @Test func loadOrders_when_initial_orders_has_orders_but_no_more_pages_then_state_is_loaded_with_initial_orders() async throws { + let initialOrders = MockPointOfSaleOrderListService.makeInitialOrders() + orderListService.orderPages = [initialOrders] + + try #require(sut.ordersViewState.isLoading) + + await sut.loadNextOrders() + + #expect(sut.ordersViewState == .loaded(initialOrders, hasMoreItems: false)) + } + + @Test func loadNextOrders_when_simulateFetchNextPage_then_state_is_loaded_with_expected_orders() async throws { + orderListService.shouldSimulateTwoPages = true + await sut.loadOrders() + + await sut.loadNextOrders() + + guard case .loaded(let orders, _) = sut.ordersViewState else { + Issue.record("Expected loaded OrderList state, but got \(sut.ordersViewState)") + return + } + #expect(orders.count == 4) + } + + @Test func loadNextOrders_requests_second_page() async throws { + try #require(sut.ordersViewState.isLoading) + orderListService.shouldSimulateTwoPages = true + await sut.loadOrders() + + await sut.loadNextOrders() + + #expect(orderListService.spyLastRequestedPageNumber == 2) + } + + @Test func loadNextOrders_when_simulateFetchNextPage_then_state_is_loaded_with_hasMoreItems() async throws { + orderListService.shouldSimulateTwoPages = true + orderListService.shouldSimulateThreePages = true + await sut.loadOrders() + + await sut.loadNextOrders() + + guard case .loaded(let orders, let hasMoreItems) = sut.ordersViewState else { + Issue.record("Expected loaded OrderList state, but got \(sut.ordersViewState)") + return + } + #expect(orders.count == 4) + #expect(hasMoreItems == true) + } + + @Test func loadNextOrders_when_hasNextPage_is_false_then_does_not_fetch_next_page() async throws { + let expectedOrders = MockPointOfSaleOrderListService.makeInitialOrders() + orderListService.orderPages = [expectedOrders] + await sut.loadOrders() + + let spyCallCountBeforeLoadNext = orderListService.spyCallCount + await sut.loadNextOrders() + + #expect(orderListService.spyCallCount == spyCallCountBeforeLoadNext) + } + + @Test func refreshOrders_requests_first_page() async throws { + orderListService.shouldSimulateTwoPages = true + await sut.loadOrders() + await sut.loadNextOrders() + + try #require(orderListService.spyLastRequestedPageNumber == 2) + + await sut.refreshOrders() + + #expect(orderListService.spyLastRequestedPageNumber == 1) + } + + @Test func loadOrders_when_error_occurs_then_shows_error_state() async throws { + orderListService.shouldThrowError = true + + await sut.loadOrders() + + guard case .error = sut.ordersViewState else { + Issue.record("Expected error OrderList state, but got \(sut.ordersViewState)") + return + } + #expect(!sut.ordersViewState.isLoading) + } + + @Test func loadOrders_when_error_occurs_with_existing_orders_then_shows_inline_error() async throws { + let initialOrders = MockPointOfSaleOrderListService.makeInitialOrders() + orderListService.orderPages = [initialOrders] + await sut.loadOrders() + + orderListService.shouldThrowError = true + await sut.refreshOrders() + + guard case .inlineError(let orders, _, let context) = sut.ordersViewState else { + Issue.record("Expected inlineError OrderList state, but got \(sut.ordersViewState)") + return + } + #expect(orders == initialOrders) + #expect(context == .refresh) + } + + @Test func loadOrders_when_cached_data_available_then_shows_cached_data_with_loading_state() async throws { + let initialOrders = MockPointOfSaleOrderListService.makeInitialOrders() + orderListService.orderPages = [initialOrders] + + // First load - should cache the data + await sut.loadOrders() + + guard case .loaded(let firstLoadOrders, _) = sut.ordersViewState else { + Issue.record("Expected loaded state after first load, but got \(sut.ordersViewState)") + return + } + #expect(firstLoadOrders == initialOrders) + + // Second load - should show cached data immediately with loading state + await sut.loadOrders() + + // Should show cached data in loading state, then switch to loaded + guard case .loaded(let cachedOrders, _) = sut.ordersViewState else { + Issue.record("Expected loaded state with cached data, but got \(sut.ordersViewState)") + return + } + #expect(cachedOrders == initialOrders) + } + + @Test func loadOrders_when_no_cached_data_then_starts_with_empty_loading_state() async throws { + let initialOrders = MockPointOfSaleOrderListService.makeInitialOrders() + orderListService.orderPages = [initialOrders] + + // Initial state should be loading with empty orders + try #require(sut.ordersViewState.isLoading) + guard case .loading(let orders) = sut.ordersViewState else { + Issue.record("Expected loading state with empty orders, but got \(sut.ordersViewState)") + return + } + #expect(orders.isEmpty) + + await sut.loadOrders() + + // Should end up in loaded state + guard case .loaded(let loadedOrders, _) = sut.ordersViewState else { + Issue.record("Expected loaded state, but got \(sut.ordersViewState)") + return + } + #expect(loadedOrders == initialOrders) + } + + @Test func loadOrders_cached_data_is_replaced_with_fresh_data() async throws { + let initialOrders = MockPointOfSaleOrderListService.makeInitialOrders() + let freshOrders = MockPointOfSaleOrderListService.makeSecondPageOrders() + + // First load + orderListService.orderPages = [initialOrders] + await sut.loadOrders() + + guard case .loaded(let firstLoadOrders, _) = sut.ordersViewState else { + Issue.record("Expected loaded state after first load, but got \(sut.ordersViewState)") + return + } + #expect(firstLoadOrders == initialOrders) + + // Second load with different data + orderListService.orderPages = [freshOrders] + await sut.loadOrders() + + // Should end up showing fresh data, not cached data + guard case .loaded(let finalOrders, _) = sut.ordersViewState else { + Issue.record("Expected loaded state with fresh data, but got \(sut.ordersViewState)") + return + } + #expect(finalOrders == freshOrders) + } +} diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderListFetchStrategyFactory.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderListFetchStrategyFactory.swift new file mode 100644 index 00000000000..157a75945a0 --- /dev/null +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderListFetchStrategyFactory.swift @@ -0,0 +1,27 @@ +import Foundation +@testable import WooCommerce +import protocol Yosemite.PointOfSaleOrderListFetchStrategyFactoryProtocol +import protocol Yosemite.PointOfSaleOrderListFetchStrategy +import protocol Yosemite.PointOfSaleOrderListServiceProtocol +import struct NetworkingCore.PagedItems +import struct Yosemite.POSOrder + +final class MockPointOfSaleOrderListFetchStrategyFactory: PointOfSaleOrderListFetchStrategyFactoryProtocol { + private let orderService: PointOfSaleOrderListServiceProtocol + + init(orderService: PointOfSaleOrderListServiceProtocol) { + self.orderService = orderService + } + + func defaultStrategy() -> PointOfSaleOrderListFetchStrategy { + MockPointOfSaleOrderListFetchStrategy(orderService: orderService) + } +} + +private struct MockPointOfSaleOrderListFetchStrategy: PointOfSaleOrderListFetchStrategy { + let orderService: PointOfSaleOrderListServiceProtocol + + func fetchOrders(pageNumber: Int) async throws -> PagedItems { + try await orderService.providePointOfSaleOrders(pageNumber: pageNumber) + } +} diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderListService.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderListService.swift new file mode 100644 index 00000000000..ddb5d399fa5 --- /dev/null +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderListService.swift @@ -0,0 +1,126 @@ +import Foundation +@testable import Yosemite +import struct NetworkingCore.PagedItems +import struct NetworkingCore.Order +import enum NetworkingCore.OrderStatusEnum +import WooFoundation + +final class MockPointOfSaleOrderListService: PointOfSaleOrderListServiceProtocol { + var orderPages: [[POSOrder]] = [] + var errorToThrow: Error? + var shouldReturnZeroOrders = false + var shouldSimulateTwoPages = false + var shouldSimulateThreePages = false + var shouldThrowError = false + + var spyLastRequestedPageNumber: Int? + var spyCallCount = 0 + + func providePointOfSaleOrders(pageNumber: Int) async throws -> PagedItems { + spyLastRequestedPageNumber = pageNumber + spyCallCount += 1 + + if shouldThrowError { + throw PointOfSaleOrderListServiceError.requestFailed + } + + if let errorToThrow { + throw errorToThrow + } + + if shouldReturnZeroOrders { + return .init(items: [], hasMorePages: false, totalItems: 0) + } + + if shouldSimulateTwoPages { + if shouldSimulateThreePages && pageNumber > 1 { + return .init(items: MockPointOfSaleOrderListService.makeSecondPageOrders(), hasMorePages: true, totalItems: 6) + } else if pageNumber > 1 { + return .init(items: MockPointOfSaleOrderListService.makeSecondPageOrders(), hasMorePages: false, totalItems: 4) + } else { + return .init(items: MockPointOfSaleOrderListService.makeInitialOrders(), hasMorePages: shouldSimulateTwoPages, totalItems: 4) + } + } + + return .init(items: (orderPages[safe: pageNumber - 1] ?? []), hasMorePages: orderPages.count > pageNumber, totalItems: 2) + } +} + +extension MockPointOfSaleOrderListService { + static func makeInitialOrders() -> [POSOrder] { + let baseDate = Date(timeIntervalSince1970: 1672531200) // Fixed date: Jan 1, 2023 + + let order1 = POSOrder( + id: 1001, + number: "1001", + dateCreated: baseDate, + status: .completed, + total: "25.99", + customerEmail: "customer1@example.com", + paymentMethodTitle: "Cash", + lineItems: [ + POSOrderItem(itemID: 1, name: "Coffee", quantity: 2, total: "20.00"), + POSOrderItem(itemID: 2, name: "Muffin", quantity: 1, total: "5.99") + ], + currency: "USD", + currencySymbol: "$" + ) + + let order2 = POSOrder( + id: 1002, + number: "1002", + dateCreated: baseDate.addingTimeInterval(3600), + status: .completed, + total: "15.50", + customerEmail: "customer2@example.com", + paymentMethodTitle: "Card", + lineItems: [ + POSOrderItem(itemID: 3, name: "Tea", quantity: 1, total: "15.50") + ], + currency: "USD", + currencySymbol: "$" + ) + + return [order1, order2] + } + + static func makeSecondPageOrders() -> [POSOrder] { + let baseDate = Date(timeIntervalSince1970: 1672531200) // Fixed date: Jan 1, 2023 + + let order3 = POSOrder( + id: 1003, + number: "1003", + dateCreated: baseDate.addingTimeInterval(7200), + status: .completed, + total: "42.75", + customerEmail: "customer3@example.com", + paymentMethodTitle: "Cash", + lineItems: [ + POSOrderItem(itemID: 4, name: "Sandwich", quantity: 1, total: "12.00"), + POSOrderItem(itemID: 5, name: "Soup", quantity: 2, total: "30.75") + ], + currency: "USD", + currencySymbol: "$" + ) + + let order4 = POSOrder( + id: 1004, + number: "1004", + dateCreated: baseDate.addingTimeInterval(10800), + status: .refunded, + total: "12.00", + customerEmail: "customer4@example.com", + paymentMethodTitle: "Card", + lineItems: [ + POSOrderItem(itemID: 6, name: "Cookies", quantity: 1, total: "12.00") + ], + refunds: [ + POSOrderRefund(refundID: 1001, total: "12.00", reason: "Customer request") + ], + currency: "USD", + currencySymbol: "$" + ) + + return [order3, order4] + } +}