diff --git a/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift b/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift index 2d9cfea38b5..3fabefa46fc 100644 --- a/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift +++ b/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift @@ -598,6 +598,24 @@ public extension OrdersRemote { case customerID case currency } + + /// Loads a single order asynchronously for POS + /// - Parameters: + /// - siteID: Site for which we'll fetch the order. + /// - orderID: ID of the order to load. + /// - Returns: The loaded Order. + /// - Throws: Network or parsing errors. + func loadPOSOrder(siteID: Int64, orderID: Int64) async throws -> Order { + let path = "\(Constants.ordersPath)/\(orderID)" + let request = JetpackRequest(wooApiVersion: .mark3, + method: .get, + siteID: siteID, + path: path, + availableAsRESTRequest: true) + let mapper = OrderMapper(siteID: siteID) + + return try await enqueue(request, mapper: mapper) + } } private extension OrdersRemote.OrderCreationSource { diff --git a/Modules/Sources/NetworkingCore/Remote/POSOrdersRemoteProtocol.swift b/Modules/Sources/NetworkingCore/Remote/POSOrdersRemoteProtocol.swift index b228c264a64..9ac7e9804e0 100644 --- a/Modules/Sources/NetworkingCore/Remote/POSOrdersRemoteProtocol.swift +++ b/Modules/Sources/NetworkingCore/Remote/POSOrdersRemoteProtocol.swift @@ -19,6 +19,8 @@ public protocol POSOrdersRemoteProtocol { order: Order, fields: [OrdersRemote.CreateOrderField]) async throws -> Order + func loadPOSOrder(siteID: Int64, orderID: Int64) async throws -> Order + func loadPOSOrders(siteID: Int64, pageNumber: Int, pageSize: Int) async throws -> PagedItems diff --git a/Modules/Sources/Yosemite/Model/Copiable/Models+Copiable.generated.swift b/Modules/Sources/Yosemite/Model/Copiable/Models+Copiable.generated.swift index f7639a5621f..387e101dbae 100644 --- a/Modules/Sources/Yosemite/Model/Copiable/Models+Copiable.generated.swift +++ b/Modules/Sources/Yosemite/Model/Copiable/Models+Copiable.generated.swift @@ -4,6 +4,12 @@ import Codegen import Foundation import Networking import WooFoundation +import enum NetworkingCore.OrderStatusEnum +import struct NetworkingCore.Address +import struct NetworkingCore.MetaData +import struct NetworkingCore.Order +import struct NetworkingCore.OrderItem +import struct NetworkingCore.OrderRefundCondensed extension Yosemite.JustInTimeMessage { @@ -51,6 +57,60 @@ extension Yosemite.JustInTimeMessage { } } +extension Yosemite.POSOrder { + public func copy( + id: CopiableProp = .copy, + number: CopiableProp = .copy, + dateCreated: CopiableProp = .copy, + status: CopiableProp = .copy, + formattedTotal: CopiableProp = .copy, + formattedSubtotal: CopiableProp = .copy, + customerEmail: NullableCopiableProp = .copy, + paymentMethodID: CopiableProp = .copy, + paymentMethodTitle: CopiableProp = .copy, + lineItems: CopiableProp<[POSOrderItem]> = .copy, + refunds: CopiableProp<[POSOrderRefund]> = .copy, + formattedDiscountTotal: NullableCopiableProp = .copy, + formattedTotalTax: CopiableProp = .copy, + formattedPaymentTotal: CopiableProp = .copy, + formattedNetAmount: NullableCopiableProp = .copy + ) -> Yosemite.POSOrder { + let id = id ?? self.id + let number = number ?? self.number + let dateCreated = dateCreated ?? self.dateCreated + let status = status ?? self.status + let formattedTotal = formattedTotal ?? self.formattedTotal + let formattedSubtotal = formattedSubtotal ?? self.formattedSubtotal + let customerEmail = customerEmail ?? self.customerEmail + let paymentMethodID = paymentMethodID ?? self.paymentMethodID + let paymentMethodTitle = paymentMethodTitle ?? self.paymentMethodTitle + let lineItems = lineItems ?? self.lineItems + let refunds = refunds ?? self.refunds + let formattedDiscountTotal = formattedDiscountTotal ?? self.formattedDiscountTotal + let formattedTotalTax = formattedTotalTax ?? self.formattedTotalTax + let formattedPaymentTotal = formattedPaymentTotal ?? self.formattedPaymentTotal + let formattedNetAmount = formattedNetAmount ?? self.formattedNetAmount + + return Yosemite.POSOrder( + id: id, + number: number, + dateCreated: dateCreated, + status: status, + formattedTotal: formattedTotal, + formattedSubtotal: formattedSubtotal, + customerEmail: customerEmail, + paymentMethodID: paymentMethodID, + paymentMethodTitle: paymentMethodTitle, + lineItems: lineItems, + refunds: refunds, + formattedDiscountTotal: formattedDiscountTotal, + formattedTotalTax: formattedTotalTax, + formattedPaymentTotal: formattedPaymentTotal, + formattedNetAmount: formattedNetAmount + ) + } +} + extension Yosemite.POSSimpleProduct { public func copy( id: CopiableProp = .copy, diff --git a/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrder.swift b/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrder.swift index 93ccadbc5b9..7f6f18bf456 100644 --- a/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrder.swift +++ b/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrder.swift @@ -1,4 +1,5 @@ import Foundation +import Codegen import struct NetworkingCore.Address import struct NetworkingCore.OrderItem import struct NetworkingCore.OrderRefundCondensed @@ -6,7 +7,7 @@ import struct NetworkingCore.MetaData import enum NetworkingCore.OrderStatusEnum import struct NetworkingCore.Order -public struct POSOrder: Equatable, Hashable { +public struct POSOrder: Equatable, Hashable, GeneratedCopiable { public let id: Int64 public let number: String public let dateCreated: Date @@ -34,8 +35,8 @@ public struct POSOrder: Equatable, Hashable { paymentMethodTitle: String, lineItems: [POSOrderItem] = [], refunds: [POSOrderRefund] = [], - formattedTotalTax: String, formattedDiscountTotal: String?, + formattedTotalTax: String, formattedPaymentTotal: String, formattedNetAmount: String? = nil) { self.id = id @@ -49,8 +50,8 @@ public struct POSOrder: Equatable, Hashable { self.paymentMethodTitle = paymentMethodTitle self.lineItems = lineItems self.refunds = refunds - self.formattedTotalTax = formattedTotalTax self.formattedDiscountTotal = formattedDiscountTotal + self.formattedTotalTax = formattedTotalTax self.formattedPaymentTotal = formattedPaymentTotal self.formattedNetAmount = formattedNetAmount } diff --git a/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrderMapper.swift b/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrderMapper.swift index 86db81d7157..e55ae0565e4 100644 --- a/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrderMapper.swift +++ b/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrderMapper.swift @@ -45,8 +45,8 @@ struct POSOrderMapper { paymentMethodTitle: order.paymentMethodTitle, lineItems: posLineItems, refunds: posRefunds, - formattedTotalTax: currencyFormatter.formatAmount(order.totalTax, with: order.currency) ?? "", formattedDiscountTotal: formattedDiscountTotal, + formattedTotalTax: currencyFormatter.formatAmount(order.totalTax, with: order.currency) ?? "", formattedPaymentTotal: order.paymentTotal(currencyFormatter: currencyFormatter), formattedNetAmount: formattedNetAmount ) diff --git a/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListFetchStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListFetchStrategy.swift index 5641b32eadf..97a288a6a9d 100644 --- a/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListFetchStrategy.swift +++ b/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListFetchStrategy.swift @@ -3,6 +3,7 @@ import struct NetworkingCore.PagedItems public protocol PointOfSaleOrderListFetchStrategy { func fetchOrders(pageNumber: Int) async throws -> PagedItems + func loadOrder(orderID: Int64) async throws -> POSOrder var supportsCaching: Bool { get } var showsLoadingWithItems: Bool { get } var id: String { get } @@ -26,6 +27,10 @@ struct PointOfSaleDefaultOrderListFetchStrategy: PointOfSaleOrderListFetchStrate func fetchOrders(pageNumber: Int) async throws -> PagedItems { try await orderListService.providePointOfSaleOrders(pageNumber: pageNumber) } + + func loadOrder(orderID: Int64) async throws -> POSOrder { + try await orderListService.loadOrder(orderID: orderID) + } } struct PointOfSaleSearchOrderListFetchStrategy: PointOfSaleOrderListFetchStrategy { @@ -43,4 +48,8 @@ struct PointOfSaleSearchOrderListFetchStrategy: PointOfSaleOrderListFetchStrateg func fetchOrders(pageNumber: Int) async throws -> PagedItems { try await orderListService.searchPointOfSaleOrders(searchTerm: searchTerm, pageNumber: pageNumber) } + + func loadOrder(orderID: Int64) async throws -> POSOrder { + try await orderListService.loadOrder(orderID: orderID) + } } diff --git a/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListService.swift b/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListService.swift index d266e7ce410..d02178e4c84 100644 --- a/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListService.swift +++ b/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListService.swift @@ -70,4 +70,15 @@ public final class PointOfSaleOrderListService: PointOfSaleOrderListServiceProto throw PointOfSaleOrderListServiceError.requestFailed } } + + public func loadOrder(orderID: Int64) async throws -> POSOrder { + do { + let order = try await ordersRemote.loadPOSOrder(siteID: siteID, orderID: orderID) + return mapper.map(order: order) + } 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 index 110a8e4f6f9..8415555fd18 100644 --- a/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListServiceProtocol.swift +++ b/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListServiceProtocol.swift @@ -9,4 +9,5 @@ public enum PointOfSaleOrderListServiceError: Error, Equatable { public protocol PointOfSaleOrderListServiceProtocol { func providePointOfSaleOrders(pageNumber: Int) async throws -> PagedItems func searchPointOfSaleOrders(searchTerm: String, pageNumber: Int) async throws -> PagedItems + func loadOrder(orderID: Int64) async throws -> POSOrder } diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift index 5111033a235..f61bf1564ce 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift @@ -91,4 +91,21 @@ final class MockPOSOrdersRemote: POSOrdersRemoteProtocol { throw error } } + + var loadPOSOrderCalled = false + var spyLoadPOSOrderID: Int64? + var loadPOSOrderResult: Result = .success(Order.fake()) + + func loadPOSOrder(siteID: Int64, orderID: Int64) async throws -> Order { + loadPOSOrderCalled = true + spySiteID = siteID + spyLoadPOSOrderID = orderID + + switch loadPOSOrderResult { + case .success(let order): + return order + case .failure(let error): + throw error + } + } } diff --git a/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderListController.swift b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderListController.swift index 416a5dff61a..90d8f01935f 100644 --- a/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderListController.swift +++ b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderListController.swift @@ -16,6 +16,7 @@ protocol PointOfSaleOrderListControllerProtocol { func refreshOrders() async func loadNextOrders() async func selectOrder(_ order: POSOrder?) + func updateOrder(orderID: Int64) async throws } protocol PointOfSaleSearchingOrderListControllerProtocol: PointOfSaleOrderListControllerProtocol { @@ -122,6 +123,11 @@ protocol PointOfSaleSearchingOrderListControllerProtocol: PointOfSaleOrderListCo ordersViewState = allOrders.isEmpty ? .empty : .loaded(allOrders, hasMoreItems: pagedOrders.hasMorePages) + if let selectedOrderID = selectedOrder?.id, + let updatedSelectedOrder = allOrders.first(where: { $0.id == selectedOrderID }) { + selectedOrder = updatedSelectedOrder + } + if fetchStrategy.supportsCaching { cachedOrders = allOrders } @@ -169,4 +175,21 @@ protocol PointOfSaleSearchingOrderListControllerProtocol: PointOfSaleOrderListCo } } } + + @MainActor + func updateOrder(orderID: Int64) async throws { + let updatedOrder = try await fetchStrategy.loadOrder(orderID: orderID) + let updatedOrders = ordersViewState.orders.map { order in + order.id == orderID ? updatedOrder : order + } + + ordersViewState = ordersViewState.updatingOrders(with: updatedOrders) + cachedOrders = cachedOrders.map { order in + order.id == orderID ? updatedOrder : order + } + + if selectedOrder?.id == orderID { + selectedOrder = updatedOrder + } + } } diff --git a/WooCommerce/Classes/POS/Models/POSOrdersViewState.swift b/WooCommerce/Classes/POS/Models/POSOrdersViewState.swift index 06a488ab4d2..1becb90bcbc 100644 --- a/WooCommerce/Classes/POS/Models/POSOrdersViewState.swift +++ b/WooCommerce/Classes/POS/Models/POSOrdersViewState.swift @@ -45,4 +45,17 @@ enum POSOrderListState: Equatable { return [] } } + + func updatingOrders(with updatedOrders: [POSOrder]) -> POSOrderListState { + switch self { + case .loaded(_, let hasMoreItems): + return .loaded(updatedOrders, hasMoreItems: hasMoreItems) + case .loading: + return .loading(updatedOrders) + case .inlineError(_, let error, let context): + return .inlineError(updatedOrders, error: error, context: context) + case .empty, .error: + return self + } + } } diff --git a/WooCommerce/Classes/POS/Models/PointOfSaleOrderListModel.swift b/WooCommerce/Classes/POS/Models/PointOfSaleOrderListModel.swift index af4d94e7bc7..2162bfe1846 100644 --- a/WooCommerce/Classes/POS/Models/PointOfSaleOrderListModel.swift +++ b/WooCommerce/Classes/POS/Models/PointOfSaleOrderListModel.swift @@ -14,5 +14,6 @@ import struct Yosemite.POSOrder func sendReceipt(order: POSOrder, email: String) async throws { try await receiptSender.sendReceipt(orderID: order.id, recipientEmail: email) + try await ordersController.updateOrder(orderID: order.id) } } diff --git a/WooCommerce/Classes/POS/Presentation/Reusable Views/Buttons/POSButtonStyle.swift b/WooCommerce/Classes/POS/Presentation/Reusable Views/Buttons/POSButtonStyle.swift index b1a3ac0df3b..aaedd3974fb 100644 --- a/WooCommerce/Classes/POS/Presentation/Reusable Views/Buttons/POSButtonStyle.swift +++ b/WooCommerce/Classes/POS/Presentation/Reusable Views/Buttons/POSButtonStyle.swift @@ -6,18 +6,31 @@ enum POSButtonSize { case extraSmall } -/// Filled button style in POS that can show a loading indicator. +/// Button state for different visual presentations +enum POSButtonState { + case idle + case loading + case success +} + +/// Filled button style in POS that can show loading and success states. struct POSFilledButtonStyle: ButtonStyle { private let size: POSButtonSize - private let isLoading: Bool + private let state: POSButtonState init(size: POSButtonSize, isLoading: Bool = false) { self.size = size - self.isLoading = isLoading + self.state = isLoading ? .loading : .idle + } + + init(size: POSButtonSize, state: POSButtonState) { + self.size = size + self.state = state } func makeBody(configuration: Configuration) -> some View { - POSButton(configuration: configuration, variant: .filled, size: size, isLoading: isLoading) + POSButtonStyleInternal(configuration: configuration, variant: .filled, size: size, state: state) + .disabled(state != .idle) } } @@ -30,32 +43,35 @@ struct POSOutlinedButtonStyle: ButtonStyle { } func makeBody(configuration: Configuration) -> some View { - POSButton(configuration: configuration, variant: .outlined, size: size, isLoading: false) + POSButtonStyleInternal(configuration: configuration, variant: .outlined, size: size, state: .idle) } } + /// The visual variant of the POS button. fileprivate enum POSButtonVariant { case filled case outlined } -private struct POSButton: View { +private struct POSButtonStyleInternal: View { @Environment(\.isEnabled) var isEnabled let configuration: ButtonStyleConfiguration let variant: POSButtonVariant let size: POSButtonSize - let isLoading: Bool + let state: POSButtonState var body: some View { Group { containerView { ZStack(alignment: .center) { configuration.label - .opacity(isLoading ? 0 : 1) + .opacity(state == .idle ? 1 : 0) progressView - .renderedIf(isLoading) + .renderedIf(state == .loading) + successView + .renderedIf(state == .success) } } } @@ -91,12 +107,18 @@ private struct POSButton: View { .progressViewStyle(POSButtonProgressViewStyle(size: size.progressViewDimensions.size, lineWidth: size.progressViewDimensions.lineWidth)) } + private var successView: some View { + Image(systemName: "checkmark.circle") + .font(size == .normal ? .title2 : .body) + .foregroundColor(.posOnPrimaryContainer) + } + private var backgroundColor: Color { switch (variant, isEnabled) { case (.filled, true): .posPrimaryContainer case (.filled, false): - isLoading ? .posPrimaryContainer : .posDisabledContainer + state != .idle ? .posPrimaryContainer : .posDisabledContainer case (.outlined, _): .clear } @@ -125,9 +147,9 @@ private struct POSButton: View { } } -// MARK: - POSButton Constants +// MARK: - POSButtonStyleInternal Constants -private extension POSButton { +private extension POSButtonStyleInternal { enum Constants { static let cornerRadius: CGFloat = POSCornerRadiusStyle.medium.value static let borderStrokeWidth: CGFloat = 2.0 @@ -191,6 +213,10 @@ struct POSButtonStyle_Previews: View { LoadingPreviewSection(title: "Loading Buttons - Extra Small", size: .extraSmall) + LoadingStatePreviewSection(title: "Loading State Buttons - Normal", size: .normal) + + LoadingStatePreviewSection(title: "Loading State Buttons - Extra Small", size: .extraSmall) + // Example with long text VStack(alignment: .leading, spacing: POSSpacing.medium) { Text("Long Text Examples") @@ -262,6 +288,40 @@ private struct LoadingPreviewSection: View { } } +private struct LoadingStatePreviewSection: View { + let title: String + let size: POSButtonSize + @State private var currentState: POSButtonState = .idle + + var body: some View { + VStack(alignment: .leading, spacing: POSSpacing.medium) { + Text(title) + .font(.headline) + + Button("Cycle States") { + switch currentState { + case .idle: + currentState = .loading + case .loading: + currentState = .success + case .success: + currentState = .idle + } + } + .buttonStyle(POSFilledButtonStyle(size: size, state: currentState)) + + Button("Idle State") {} + .buttonStyle(POSFilledButtonStyle(size: size, state: .idle)) + + Button("Loading State") {} + .buttonStyle(POSFilledButtonStyle(size: size, state: .loading)) + + Button("Success State") {} + .buttonStyle(POSFilledButtonStyle(size: size, state: .success)) + } + } +} + #Preview("Button Styles") { POSButtonStyle_Previews() } diff --git a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSendReceiptView.swift b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSendReceiptView.swift index 904afcb6825..dcbf8e6a179 100644 --- a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSendReceiptView.swift +++ b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSendReceiptView.swift @@ -5,7 +5,7 @@ import class WordPressShared.EmailFormatValidator struct POSSendReceiptView: View { @State private var textFieldInput: String = "" - @State private var isLoading: Bool = false + @State private var buttonState: POSButtonState = .idle @State private var errorMessage: String? @FocusState private var isTextFieldFocused: Bool @@ -29,7 +29,7 @@ struct POSSendReceiptView: View { ScrollView { VStack(alignment: .center, spacing: conditionalPadding(POSSpacing.medium)) { POSPageHeaderView(title: Localization.emailReceiptNavigationText, - backButtonConfiguration: .init(state: isLoading ? .disabled: .enabled, + backButtonConfiguration: .init(state: buttonState != .idle ? .disabled: .enabled, action: { withAnimation { isShowingSendReceiptView = false @@ -74,10 +74,10 @@ struct POSSendReceiptView: View { .measureFrame { buttonFrame = $0 } - .buttonStyle(POSFilledButtonStyle(size: .normal, isLoading: isLoading)) + .buttonStyle(POSFilledButtonStyle(size: .normal, state: buttonState)) .dynamicTypeSize(...DynamicTypeSize.accessibility3) .frame(maxWidth: .infinity) - .disabled(isLoading) + .disabled(buttonState != .idle) } .padding([.horizontal]) .padding(.bottom, keyboardFrame.height) @@ -102,18 +102,21 @@ struct POSSendReceiptView: View { errorMessage = Localization.emailValidationErrorText return } - isLoading = true + buttonState = .loading do { errorMessage = nil try await onSendReceipt(textFieldInput) + withAnimation { + buttonState = .success + } completion: { isShowingSendReceiptView = false isTextFieldFocused = false } } catch { errorMessage = Localization.sendReceiptErrorText + buttonState = .idle } - isLoading = false } } } diff --git a/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift b/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift index 1a4ca90336d..0dff7f140a9 100644 --- a/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift +++ b/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift @@ -274,8 +274,8 @@ struct POSPreviewHelpers { ) ], refunds: [], - formattedTotalTax: "$3.76", formattedDiscountTotal: "$0.00", + formattedTotalTax: "$3.76", formattedPaymentTotal: "$45.75", formattedNetAmount: nil ) @@ -318,8 +318,8 @@ final class PointOfSalePreviewOrderListController: PointOfSaleSearchingOrderList ) ], refunds: [], - formattedTotalTax: "$4.75", formattedDiscountTotal: "-$5.24", + formattedTotalTax: "$4.75", formattedPaymentTotal: "$45.75", formattedNetAmount: nil ), @@ -363,8 +363,8 @@ final class PointOfSalePreviewOrderListController: PointOfSaleSearchingOrderList reason: "Customer requested partial refund" ) ], - formattedTotalTax: "$8.95", formattedDiscountTotal: "-$15.00", + formattedTotalTax: "$8.95", formattedPaymentTotal: "$89.50", formattedNetAmount: "$69.51" ) @@ -381,6 +381,7 @@ final class PointOfSalePreviewOrderListController: PointOfSaleSearchingOrderList func loadNextOrders() async {} func refreshOrders() async {} func selectOrder(_ order: POSOrder?) {} + func updateOrder(orderID: Int64) async throws {} func searchOrders(searchTerm: String) async {} func clearSearchOrders() {} } diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 3de330f8a68..c7089765d9b 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -98,6 +98,9 @@ 01ADC1382C9AB6050036F7D2 /* PointOfSaleCardPresentPaymentIntentCreationErrorMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01ADC1372C9AB6050036F7D2 /* PointOfSaleCardPresentPaymentIntentCreationErrorMessageView.swift */; }; 01B3A1F22DB6D48800286B7F /* ItemListType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B3A1F12DB6D48800286B7F /* ItemListType.swift */; }; 01B744E22D2FCA1400AEB3F4 /* PushNotificationBackgroundSynchronizerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B744E12D2FCA1300AEB3F4 /* PushNotificationBackgroundSynchronizerFactory.swift */; }; + 01B7AFBD2E707FB30004BE9D /* PointOfSaleOrderListModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B7AFBB2E707FB30004BE9D /* PointOfSaleOrderListModelTests.swift */; }; + 01B7AFBE2E707FB30004BE9D /* POSOrderListStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B7AFBC2E707FB30004BE9D /* POSOrderListStateTests.swift */; }; + 01B7AFC02E70801A0004BE9D /* MockPointOfSaleOrderListController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B7AFBF2E7080180004BE9D /* MockPointOfSaleOrderListController.swift */; }; 01BB6C072D09DC560094D55B /* CardPresentModalLocationPreAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01BB6C062D09DC470094D55B /* CardPresentModalLocationPreAlert.swift */; }; 01BB6C0A2D09E9630094D55B /* LocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01BB6C092D09E9630094D55B /* LocationService.swift */; }; 01BD77442C58CED400147191 /* PointOfSaleCardPresentPaymentProcessingMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01BD77432C58CED400147191 /* PointOfSaleCardPresentPaymentProcessingMessageView.swift */; }; @@ -3319,6 +3322,9 @@ 01ADC1372C9AB6050036F7D2 /* PointOfSaleCardPresentPaymentIntentCreationErrorMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentIntentCreationErrorMessageView.swift; sourceTree = ""; }; 01B3A1F12DB6D48800286B7F /* ItemListType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListType.swift; sourceTree = ""; }; 01B744E12D2FCA1300AEB3F4 /* PushNotificationBackgroundSynchronizerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationBackgroundSynchronizerFactory.swift; sourceTree = ""; }; + 01B7AFBB2E707FB30004BE9D /* PointOfSaleOrderListModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleOrderListModelTests.swift; sourceTree = ""; }; + 01B7AFBC2E707FB30004BE9D /* POSOrderListStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSOrderListStateTests.swift; sourceTree = ""; }; + 01B7AFBF2E7080180004BE9D /* MockPointOfSaleOrderListController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPointOfSaleOrderListController.swift; sourceTree = ""; }; 01BB6C062D09DC470094D55B /* CardPresentModalLocationPreAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalLocationPreAlert.swift; sourceTree = ""; }; 01BB6C092D09E9630094D55B /* LocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationService.swift; sourceTree = ""; }; 01BD77432C58CED400147191 /* PointOfSaleCardPresentPaymentProcessingMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentProcessingMessageView.swift; sourceTree = ""; }; @@ -7808,6 +7814,7 @@ 02CD3BFC2C35D01600E575C4 /* Mocks */ = { isa = PBXGroup; children = ( + 01B7AFBF2E7080180004BE9D /* MockPointOfSaleOrderListController.swift */, 019460E12E70121A00FCB9AB /* MockPOSReceiptController.swift */, 012ACB812E5D8DCD00A49458 /* MockPointOfSaleOrderListFetchStrategyFactory.swift */, 012ACB792E5C84D200A49458 /* MockPointOfSaleOrderListService.swift */, @@ -13193,6 +13200,8 @@ DAD988C72C4A9D49009DE9E3 /* Models */ = { isa = PBXGroup; children = ( + 01B7AFBB2E707FB30004BE9D /* PointOfSaleOrderListModelTests.swift */, + 01B7AFBC2E707FB30004BE9D /* POSOrderListStateTests.swift */, 685A305E2E608F29001E667B /* POSSettingsStoreViewModelTests.swift */, 20FCBCDE2CE241810082DCA3 /* PointOfSaleAggregateModelTests.swift */, ); @@ -17177,6 +17186,7 @@ 269098B627D2C09D001FEB07 /* ShippingInputTransformerTests.swift in Sources */, 02BA128B24616B48008D8325 /* ProductFormActionsFactory+VisibilityTests.swift in Sources */, DEA88F522AAAC1180037273B /* AddEditProductCategoryViewModelTests.swift in Sources */, + 01B7AFC02E70801A0004BE9D /* MockPointOfSaleOrderListController.swift in Sources */, DE2BF4FD2846192B00FBE68A /* CouponAllowedEmailsViewModelTests.swift in Sources */, FEEB2F6E268A2F7B0075A6E0 /* RoleEligibilityUseCaseTests.swift in Sources */, 31E906A326CC91A70099A985 /* CardReaderConnectionControllerTests.swift in Sources */, @@ -17406,6 +17416,8 @@ 45EF798624509B4C00B22BA2 /* ArrayIndexPathTests.swift in Sources */, D8610BDD256F5ABF00A5DF27 /* JetpackErrorViewModelTests.swift in Sources */, 746791632108D7C0007CF1DC /* WooAnalyticsTests.swift in Sources */, + 01B7AFBD2E707FB30004BE9D /* PointOfSaleOrderListModelTests.swift in Sources */, + 01B7AFBE2E707FB30004BE9D /* POSOrderListStateTests.swift in Sources */, 200BA15E2CF0A9EB0006DC5B /* PointOfSaleItemsControllerTests.swift in Sources */, 2667BFDD252F61C5008099D4 /* RefundShippingDetailsViewModelTests.swift in Sources */, DE7B479727A3C4980018742E /* CouponDetailsViewModelTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderListControllerTests.swift b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderListControllerTests.swift index 753b2fdcab4..10b15ab916a 100644 --- a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderListControllerTests.swift +++ b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderListControllerTests.swift @@ -4,6 +4,7 @@ import Foundation import enum Yosemite.PointOfSaleOrderListServiceError import struct NetworkingCore.Order import Observation +import struct Yosemite.POSOrder final class PointOfSaleOrderListControllerTests { private let orderListService = MockPointOfSaleOrderListService() @@ -315,4 +316,52 @@ final class PointOfSaleOrderListControllerTests { #expect(orders == searchOrders) #expect(orderListService.lastSearchTerm == "test") } + + @Test func updateOrder_when_order_loaded_from_API_then_order_list_updates() async throws { + // Given - load initial orders + let initialOrders = MockPointOfSaleOrderListService.makeInitialOrders() + orderListService.orderPages = [initialOrders] + await sut.loadOrders() + + // Setup updated order + let orderToUpdate = initialOrders[0] + let updatedOrder = orderToUpdate.copy(customerEmail: .some("updated@example.com")) + orderListService.loadOrderResult = updatedOrder + + // When + try await sut.updateOrder(orderID: orderToUpdate.id) + + // Then + guard case .loaded(let orders, _) = sut.ordersViewState else { + Issue.record("Expected loaded state after update, but got \(sut.ordersViewState)") + return + } + + let foundOrder = orders.first { $0.id == orderToUpdate.id } + #expect(foundOrder != nil) + #expect(foundOrder?.customerEmail == "updated@example.com") + #expect(orderListService.loadOrderWasCalled) + #expect(orderListService.lastLoadOrderID == orderToUpdate.id) + } + + @Test func updateOrder_when_order_loaded_from_API_then_selected_order_updates() async throws { + // Given + let initialOrders = MockPointOfSaleOrderListService.makeInitialOrders() + orderListService.orderPages = [initialOrders] + await sut.loadOrders() + + let orderToUpdate = initialOrders[0] + await sut.selectOrder(orderToUpdate) + #expect(sut.selectedOrder?.id == orderToUpdate.id) + + // Setup updated order + let updatedOrder = orderToUpdate.copy(customerEmail: .some("selected-updated@example.com")) + orderListService.loadOrderResult = updatedOrder + + // When + try await sut.updateOrder(orderID: orderToUpdate.id) + + // Then + #expect(sut.selectedOrder?.customerEmail == "selected-updated@example.com") + } } diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSReceiptController.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSReceiptController.swift index 87dbc88dec2..7e27749ff11 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSReceiptController.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSReceiptController.swift @@ -8,6 +8,10 @@ final class MockPOSReceiptSender: POSReceiptSending { var sendReceiptCalledWithOrderID: Int64? var sendReceiptCalledWithEmail: String? + enum TestError: Error { + case sendReceiptFailed + } + func sendReceipt(orderID: Int64, recipientEmail: String) async throws { sendReceiptWasCalled = true sendReceiptCalledWithOrderID = orderID diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderListController.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderListController.swift new file mode 100644 index 00000000000..dc4d87347fe --- /dev/null +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderListController.swift @@ -0,0 +1,38 @@ +import Foundation +@testable import WooCommerce +import struct Yosemite.POSOrder + +final class MockPointOfSaleOrderListController: PointOfSaleSearchingOrderListControllerProtocol { + var ordersViewState: POSOrderListState = .empty + var selectedOrder: POSOrder? + var updateOrderCalled = false + var spyUpdateOrderID: Int64? + var shouldThrowError = false + + enum TestError: Error { + case updateOrderFailed + } + + func loadOrders() async {} + + func refreshOrders() async {} + + func loadNextOrders() async {} + + func selectOrder(_ order: POSOrder?) { + selectedOrder = order + } + + func updateOrder(orderID: Int64) async throws { + updateOrderCalled = true + spyUpdateOrderID = orderID + + if shouldThrowError { + throw TestError.updateOrderFailed + } + } + + func searchOrders(searchTerm: String) async {} + + func clearSearchOrders() {} +} diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderListFetchStrategyFactory.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderListFetchStrategyFactory.swift index 7deb004d8a8..22a21c3a7c9 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderListFetchStrategyFactory.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderListFetchStrategyFactory.swift @@ -32,6 +32,10 @@ private struct MockPointOfSaleOrderListFetchStrategy: PointOfSaleOrderListFetchS func fetchOrders(pageNumber: Int) async throws -> PagedItems { try await orderService.providePointOfSaleOrders(pageNumber: pageNumber) } + + func loadOrder(orderID: Int64) async throws -> POSOrder { + try await orderService.loadOrder(orderID: orderID) + } } private struct MockPointOfSaleOrderListSearchFetchStrategy: PointOfSaleOrderListFetchStrategy { @@ -45,4 +49,8 @@ private struct MockPointOfSaleOrderListSearchFetchStrategy: PointOfSaleOrderList func fetchOrders(pageNumber: Int) async throws -> PagedItems { try await orderService.searchPointOfSaleOrders(searchTerm: searchTerm, pageNumber: pageNumber) } + + func loadOrder(orderID: Int64) async throws -> POSOrder { + try await orderService.loadOrder(orderID: orderID) + } } diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderListService.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderListService.swift index 977550c2366..81ecb14778d 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderListService.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderListService.swift @@ -19,6 +19,11 @@ final class MockPointOfSaleOrderListService: PointOfSaleOrderListServiceProtocol var spyLastSearchTerm: String? var lastSearchTerm: String? + // LoadOrder properties + var loadOrderResult: POSOrder? + var loadOrderWasCalled = false + var lastLoadOrderID: Int64? + func providePointOfSaleOrders(pageNumber: Int) async throws -> PagedItems { spyLastRequestedPageNumber = pageNumber spyCallCount += 1 @@ -105,6 +110,31 @@ final class MockPointOfSaleOrderListService: PointOfSaleOrderListServiceProtocol totalItems: filteredOrders.count ) } + + func loadOrder(orderID: Int64) async throws -> POSOrder { + loadOrderWasCalled = true + lastLoadOrderID = orderID + + if shouldThrowError { + throw PointOfSaleOrderListServiceError.requestFailed + } + + if let errorToThrow { + throw errorToThrow + } + + if let result = loadOrderResult { + return result + } + + // Fallback - find order from existing orders + let allOrders = MockPointOfSaleOrderListService.makeInitialOrders() + MockPointOfSaleOrderListService.makeSecondPageOrders() + if let foundOrder = allOrders.first(where: { $0.id == orderID }) { + return foundOrder + } + + throw PointOfSaleOrderListServiceError.requestFailed + } } extension MockPointOfSaleOrderListService { @@ -142,8 +172,8 @@ extension MockPointOfSaleOrderListService { ) ], refunds: [], - formattedTotalTax: "$0.00", formattedDiscountTotal: nil, + formattedTotalTax: "$0.00", formattedPaymentTotal: "$25.99", formattedNetAmount: nil ) @@ -170,8 +200,8 @@ extension MockPointOfSaleOrderListService { ) ], refunds: [], - formattedTotalTax: "$0.00", formattedDiscountTotal: nil, + formattedTotalTax: "$0.00", formattedPaymentTotal: "$15.50", formattedNetAmount: nil ) @@ -213,8 +243,8 @@ extension MockPointOfSaleOrderListService { ) ], refunds: [], - formattedTotalTax: "$0.00", formattedDiscountTotal: nil, + formattedTotalTax: "$0.00", formattedPaymentTotal: "$42.75", formattedNetAmount: nil ) @@ -243,8 +273,8 @@ extension MockPointOfSaleOrderListService { refunds: [ POSOrderRefund(refundID: 1001, formattedTotal: "-$12.00", reason: "Customer request") ], - formattedTotalTax: "$0.00", formattedDiscountTotal: nil, + formattedTotalTax: "$0.00", formattedPaymentTotal: "$12.00", formattedNetAmount: "$0.00" ) @@ -277,8 +307,8 @@ extension MockPointOfSaleOrderListService { ) ], refunds: [], - formattedTotalTax: "$0.00", formattedDiscountTotal: nil, + formattedTotalTax: "$0.00", formattedPaymentTotal: "$18.50", formattedNetAmount: nil ) diff --git a/WooCommerce/WooCommerceTests/POS/Models/POSOrderListStateTests.swift b/WooCommerce/WooCommerceTests/POS/Models/POSOrderListStateTests.swift new file mode 100644 index 00000000000..fca6ad4d129 --- /dev/null +++ b/WooCommerce/WooCommerceTests/POS/Models/POSOrderListStateTests.swift @@ -0,0 +1,137 @@ +import Foundation +import Testing +@testable import WooCommerce +import struct Yosemite.POSOrder +import enum NetworkingCore.OrderStatusEnum + +struct POSOrderListStateTests { + @Test + func updatingOrders_when_loaded_then_preserves_hasMoreItems_flag() { + // Given + let state = POSOrderListState.loaded(sampleOrders, hasMoreItems: true) + let updatedOrders = [sampleOrders[0]] + + // When + let newState = state.updatingOrders(with: updatedOrders) + + // Then + if case .loaded(let orders, let hasMoreItems) = newState { + #expect(orders.count == 1) + #expect(orders[0].id == 1) + #expect(hasMoreItems == true) + } else { + #expect(Bool(false), "Expected loaded state") + } + } + + @Test + func updatingOrders_when_loading_then_preserves_loading_state() { + // Given + let state = POSOrderListState.loading(sampleOrders) + let updatedOrders = [sampleOrders[1]] + + // When + let newState = state.updatingOrders(with: updatedOrders) + + // Then + if case .loading(let orders) = newState { + #expect(orders.count == 1) + #expect(orders[0].id == 2) + } else { + #expect(Bool(false), "Expected loading state") + } + } + + @Test + func updatingOrders_when_inline_error_then_preserves_error_and_context() { + // Given + let errorState = PointOfSaleErrorState.errorOnLoadingOrders(error: NSError(domain: "test", code: 0)) + let state = POSOrderListState.inlineError(sampleOrders, error: errorState, context: .refresh) + let updatedOrders = [sampleOrders[0]] + + // When + let newState = state.updatingOrders(with: updatedOrders) + + // Then + if case .inlineError(let orders, let error, let context) = newState { + #expect(orders.count == 1) + #expect(orders[0].id == 1) + #expect(error == errorState) + #expect(context == .refresh) + } else { + #expect(Bool(false), "Expected inlineError state") + } + } + + @Test + func updatingOrders_when_empty_then_returns_unchanged() { + // Given + let state = POSOrderListState.empty + let updatedOrders = sampleOrders + + // When + let newState = state.updatingOrders(with: updatedOrders) + + // Then + if case .empty = newState { + // Expected - state should remain empty + } else { + #expect(Bool(false), "Expected empty state to remain unchanged") + } + } + + @Test + func updatingOrders_when_error_then_returns_unchanged() { + // Given + let errorState = PointOfSaleErrorState.errorOnLoadingOrders(error: NSError(domain: "test", code: 0)) + let state = POSOrderListState.error(errorState) + let updatedOrders = sampleOrders + + // When + let newState = state.updatingOrders(with: updatedOrders) + + // Then + if case .error(let error) = newState { + #expect(error == errorState) + } else { + #expect(Bool(false), "Expected error state to remain unchanged") + } + } + + @Test + func orders_when_various_states_then_returns_correct_orders() { + // Given & When & Then + let loadedState = POSOrderListState.loaded(sampleOrders, hasMoreItems: false) + #expect(loadedState.orders.count == 2) + + let loadingState = POSOrderListState.loading(sampleOrders) + #expect(loadingState.orders.count == 2) + + let errorState = PointOfSaleErrorState.errorOnLoadingOrders(error: NSError(domain: "test", code: 0)) + let inlineErrorState = POSOrderListState.inlineError(sampleOrders, error: errorState, context: .refresh) + #expect(inlineErrorState.orders.count == 2) + + let emptyState = POSOrderListState.empty + #expect(emptyState.orders.count == 0) + + let fullErrorState = POSOrderListState.error(errorState) + #expect(fullErrorState.orders.count == 0) + } +} + +private extension POSOrderListStateTests { + private var sampleOrders: [POSOrder] { + [ + POSOrder(id: 1, number: "1001", dateCreated: Date(), status: .completed, + formattedTotal: "$10.00", formattedSubtotal: "$10.00", customerEmail: "test1@example.com", + paymentMethodID: "stripe", paymentMethodTitle: "Credit Card", lineItems: [], + refunds: [], formattedDiscountTotal: nil, formattedTotalTax: "$0.00", + formattedPaymentTotal: "$10.00", formattedNetAmount: nil), + POSOrder(id: 2, number: "1002", dateCreated: Date(), status: .completed, + formattedTotal: "$20.00", formattedSubtotal: "$20.00", customerEmail: "test2@example.com", + paymentMethodID: "cash", paymentMethodTitle: "Cash", lineItems: [], + refunds: [], formattedDiscountTotal: nil, formattedTotalTax: "$0.00", + formattedPaymentTotal: "$20.00", formattedNetAmount: nil) + ] + } +} diff --git a/WooCommerce/WooCommerceTests/POS/Models/PointOfSaleOrderListModelTests.swift b/WooCommerce/WooCommerceTests/POS/Models/PointOfSaleOrderListModelTests.swift new file mode 100644 index 00000000000..281d7e3b775 --- /dev/null +++ b/WooCommerce/WooCommerceTests/POS/Models/PointOfSaleOrderListModelTests.swift @@ -0,0 +1,87 @@ +import Testing +import Foundation +@testable import WooCommerce +import struct Yosemite.POSOrder +import enum NetworkingCore.OrderStatusEnum + +final class PointOfSaleOrderListModelTests { + private let mockOrdersController = MockPointOfSaleOrderListController() + private let mockReceiptSender = MockPOSReceiptSender() + private lazy var sut = PointOfSaleOrderListModel( + ordersController: mockOrdersController, + receiptSender: mockReceiptSender + ) + + @Test func sendReceipt_when_successful_then_calls_receipt_controller_and_updates_order() async throws { + // Given + let testOrder = makeTestOrder(id: 123, email: "original@example.com") + let testEmail = "updated@example.com" + + // When + try await sut.sendReceipt(order: testOrder, email: testEmail) + + // Then + #expect(mockReceiptSender.sendReceiptWasCalled == true) + #expect(mockReceiptSender.sendReceiptCalledWithOrderID == 123) + #expect(mockReceiptSender.sendReceiptCalledWithEmail == testEmail) + #expect(mockOrdersController.updateOrderCalled == true) + #expect(mockOrdersController.spyUpdateOrderID == 123) + } + + @Test func sendReceipt_when_receipt_fails_then_throws_error_and_does_not_update_order() async throws { + // Given + let testOrder = makeTestOrder(id: 789, email: "fail@example.com") + let testEmail = "error@example.com" + mockReceiptSender.sendReceiptErrorToThrow = MockPOSReceiptSender.TestError.sendReceiptFailed + + // When & Then + await #expect(throws: MockPOSReceiptSender.TestError.sendReceiptFailed) { + try await sut.sendReceipt(order: testOrder, email: testEmail) + } + + // Receipt controller should have been called + #expect(mockReceiptSender.sendReceiptWasCalled == true) + + // But order update should NOT have been called since receipt failed + #expect(mockOrdersController.updateOrderCalled == false) + } + + @Test func sendReceipt_when_updateOrder_fails_then_throws_error() async throws { + // Given + let testOrder = makeTestOrder(id: 999, email: "update-fail@example.com") + let testEmail = "success@example.com" + mockOrdersController.shouldThrowError = true + + // When & Then + await #expect(throws: MockPointOfSaleOrderListController.TestError.updateOrderFailed) { + try await sut.sendReceipt(order: testOrder, email: testEmail) + } + + // Receipt should have succeeded before update failed + #expect(mockReceiptSender.sendReceiptWasCalled == true) + #expect(mockReceiptSender.sendReceiptCalledWithOrderID == 999) + #expect(mockReceiptSender.sendReceiptCalledWithEmail == testEmail) + #expect(mockOrdersController.updateOrderCalled == true) + #expect(mockOrdersController.spyUpdateOrderID == 999) + } + + private func makeTestOrder(id: Int64, email: String) -> POSOrder { + POSOrder( + id: id, + number: "\(id)", + dateCreated: Date(), + status: .completed, + formattedTotal: "$10.00", + formattedSubtotal: "$10.00", + customerEmail: email, + paymentMethodID: "test", + paymentMethodTitle: "Test Payment", + lineItems: [], + refunds: [], + formattedDiscountTotal: nil, + formattedTotalTax: "$0.00", + formattedPaymentTotal: "$10.00", + formattedNetAmount: nil + ) + } +}