diff --git a/Modules/Sources/Networking/Remote/ReceiptRemote.swift b/Modules/Sources/Networking/Remote/ReceiptRemote.swift index 01246007b02..4a657b43dd8 100644 --- a/Modules/Sources/Networking/Remote/ReceiptRemote.swift +++ b/Modules/Sources/Networking/Remote/ReceiptRemote.swift @@ -53,27 +53,31 @@ public final class ReceiptRemote: Remote { /// - Parameters: /// - siteID: Site which hosts the Order. /// - orderID: ID of the order that the receipt is associated to. - public func sendPOSReceipt(siteID: Int64, orderID: Int64) async throws { + public func sendPOSReceipt(siteID: Int64, orderID: Int64, emailAddress: String) async throws { let sendEmailPath = "\(Constants.ordersPath)/\(orderID)/\(Constants.actionsPath)/send_email" let sendEmailRequest = JetpackRequest(wooApiVersion: .mark3, method: .post, siteID: siteID, path: sendEmailPath, parameters: [ - ParameterKeys.templateID: POSConstants.receiptTemplateID + ParameterKeys.templateID: POSConstants.receiptTemplateID, + ParameterKeys.email: emailAddress, + ParameterKeys.forceEmailUpdate: true ], availableAsRESTRequest: true) try await enqueue(sendEmailRequest) } } -extension ReceiptRemote: POSReceiptsRemoteProtocol { } +extension ReceiptRemote: POSReceiptsRemoteProtocol {} private extension ReceiptRemote { enum ParameterKeys { static let expirationDays: String = "expiration_days" static let forceRegenerate: String = "force_new" static let templateID: String = "template_id" + static let forceEmailUpdate: String = "force_email_update" + static let email: String = "email" } enum Constants { diff --git a/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift b/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift index 29c7a03e4a5..2d9cfea38b5 100644 --- a/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift +++ b/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift @@ -451,6 +451,24 @@ extension OrdersRemote: POSOrdersRemoteProtocol { } } + public func updatePOSOrderEmail(siteID: Int64, orderID: Int64, emailAddress: String) async throws { + let parameters: [String: Any] = [ + "billing": [ + "email": emailAddress + ] + ] + + let path = "\(Constants.ordersPath)/\(orderID)" + let request = JetpackRequest(wooApiVersion: .mark3, + method: .post, + siteID: siteID, + path: path, + parameters: parameters, + availableAsRESTRequest: true) + + try await enqueue(request) + } + public func loadPOSOrders(siteID: Int64, pageNumber: Int, pageSize: Int) async throws -> PagedItems { let parameters: [String: Any] = [ ParameterKeys.page: String(pageNumber), diff --git a/Modules/Sources/NetworkingCore/Remote/POSOrdersRemoteProtocol.swift b/Modules/Sources/NetworkingCore/Remote/POSOrdersRemoteProtocol.swift index 3d301afa3ac..b228c264a64 100644 --- a/Modules/Sources/NetworkingCore/Remote/POSOrdersRemoteProtocol.swift +++ b/Modules/Sources/NetworkingCore/Remote/POSOrdersRemoteProtocol.swift @@ -2,7 +2,7 @@ import Foundation public protocol POSReceiptsRemoteProtocol { func sendReceipt(siteID: Int64, orderID: Int64) async throws - func sendPOSReceipt(siteID: Int64, orderID: Int64) async throws + func sendPOSReceipt(siteID: Int64, orderID: Int64, emailAddress: String) async throws } public protocol POSOrdersRemoteProtocol { @@ -11,6 +11,10 @@ public protocol POSOrdersRemoteProtocol { cashPaymentChangeDueAmount: String?, fields: [OrdersRemote.UpdateOrderField]) async throws -> Order + func updatePOSOrderEmail(siteID: Int64, + orderID: Int64, + emailAddress: String) async throws + func createPOSOrder(siteID: Int64, order: Order, fields: [OrdersRemote.CreateOrderField]) async throws -> Order diff --git a/Modules/Sources/Yosemite/Tools/POS/POSOrderService.swift b/Modules/Sources/Yosemite/Tools/POS/POSOrderService.swift index 5f3338e0743..cddca87befa 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSOrderService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSOrderService.swift @@ -9,7 +9,7 @@ public protocol POSOrderServiceProtocol { /// - cart: Cart with different types of items and quantities. /// - Returns: Order from the remote sync. func syncOrder(cart: POSCart, currency: CurrencyCode) async throws -> Order - func updatePOSOrder(order: Order, recipientEmail: String) async throws + func updatePOSOrder(orderID: Int64, recipientEmail: String) async throws func markOrderAsCompletedWithCashPayment(order: Order, changeDueAmount: String?) async throws } @@ -46,20 +46,9 @@ public final class POSOrderService: POSOrderServiceProtocol { return try await ordersRemote.createPOSOrder(siteID: siteID, order: order, fields: [.items, .status, .currency, .couponLines]) } - public func updatePOSOrder(order: Order, recipientEmail: String) async throws { - guard order.billingAddress?.email == nil || order.billingAddress?.email == "" else { - throw POSOrderServiceError.emailAlreadySet - } - let updatedBillingAddress = order.billingAddress?.copy(email: recipientEmail) - let updatedOrder = order.copy(billingAddress: updatedBillingAddress) - + public func updatePOSOrder(orderID: Int64, recipientEmail: String) async throws { do { - let _ = try await ordersRemote.updatePOSOrder( - siteID: siteID, - order: updatedOrder, - cashPaymentChangeDueAmount: nil, - fields: [.billingAddress] - ) + try await ordersRemote.updatePOSOrderEmail(siteID: siteID, orderID: orderID, emailAddress: recipientEmail) } catch { throw POSOrderServiceError.updateOrderFailed } @@ -105,7 +94,6 @@ private extension Order { private extension POSOrderService { enum POSOrderServiceError: Error { - case emailAlreadySet case updateOrderFailed } } diff --git a/Modules/Sources/Yosemite/Tools/POS/POSReceiptService.swift b/Modules/Sources/Yosemite/Tools/POS/POSReceiptService.swift index 5f304badf82..3a9eaa0f3e4 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSReceiptService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSReceiptService.swift @@ -2,7 +2,7 @@ import SwiftUI import Networking public protocol POSReceiptServiceProtocol { - func sendReceipt(order: Order, recipientEmail: String, isEligibleForPOSReceipt: Bool) async throws + func sendReceipt(orderID: Int64, recipientEmail: String, isEligibleForPOSReceipt: Bool) async throws } public final class POSReceiptService: POSReceiptServiceProtocol { @@ -25,12 +25,12 @@ public final class POSReceiptService: POSReceiptServiceProtocol { self.receiptsRemote = receiptsRemote } - public func sendReceipt(order: Yosemite.Order, recipientEmail: String, isEligibleForPOSReceipt: Bool) async throws { + public func sendReceipt(orderID: Int64, recipientEmail: String, isEligibleForPOSReceipt: Bool) async throws { do { if isEligibleForPOSReceipt { - try await receiptsRemote.sendPOSReceipt(siteID: siteID, orderID: order.orderID) + try await receiptsRemote.sendPOSReceipt(siteID: siteID, orderID: orderID, emailAddress: recipientEmail) } else { - try await receiptsRemote.sendReceipt(siteID: siteID, orderID: order.orderID) + try await receiptsRemote.sendReceipt(siteID: siteID, orderID: orderID) } } catch { throw POSReceiptServiceError.sendReceiptFailed(underlyingError: error as NSError) diff --git a/Modules/Tests/NetworkingTests/Remote/ReceiptRemoteTests.swift b/Modules/Tests/NetworkingTests/Remote/ReceiptRemoteTests.swift index 87bb6c14c1d..597f9ec48e5 100644 --- a/Modules/Tests/NetworkingTests/Remote/ReceiptRemoteTests.swift +++ b/Modules/Tests/NetworkingTests/Remote/ReceiptRemoteTests.swift @@ -129,6 +129,7 @@ final class ReceiptRemoteTests: XCTestCase { // Given let remote = ReceiptRemote(network: network) let posReceiptTemplateID = "customer_pos_completed_order" + let testEmail = "test@example.com" network.simulateResponse( requestUrlSuffix: "orders/\(sampleOrderID)/actions/send_email", @@ -136,22 +137,25 @@ final class ReceiptRemoteTests: XCTestCase { ) // When - try await remote.sendPOSReceipt(siteID: sampleSiteID, orderID: sampleOrderID) + try await remote.sendPOSReceipt(siteID: sampleSiteID, orderID: sampleOrderID, emailAddress: testEmail) // Then the send email request was made with correct parameters. let sendEmailRequest = try XCTUnwrap(network.requestsForResponseData.last as? JetpackRequest) XCTAssertEqual(sendEmailRequest.method, .post) XCTAssertEqual(sendEmailRequest.path, "orders/\(sampleOrderID)/actions/send_email") XCTAssertEqual(sendEmailRequest.parameters["template_id"] as? String, posReceiptTemplateID) + XCTAssertEqual(sendEmailRequest.parameters["email"] as? String, testEmail) + XCTAssertEqual(sendEmailRequest.parameters["force_email_update"] as? Bool, true) } func test_sendPOSReceipt_when_no_reponse_exist_throws_error() async { // Given let remote = ReceiptRemote(network: network) + let testEmail = "test@example.com" await assertThrowsError({ // When - try await remote.sendPOSReceipt(siteID: sampleSiteID, orderID: sampleOrderID) + try await remote.sendPOSReceipt(siteID: sampleSiteID, orderID: sampleOrderID, emailAddress: testEmail) }, errorAssert: { error in // Then return error is NetworkError diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift index 9a1cdaba963..5111033a235 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift @@ -22,6 +22,25 @@ final class MockPOSOrdersRemote: POSOrdersRemoteProtocol { } } + var updatePOSOrderEmailCalled: Bool = false + var spyUpdatePOSOrderEmailSiteID: Int64? + var spyUpdatePOSOrderEmailOrderID: Int64? + var spyUpdatePOSOrderEmailAddress: String? + var updatePOSOrderEmailResult: Result = .success(()) + + func updatePOSOrderEmail(siteID: Int64, orderID: Int64, emailAddress: String) async throws { + updatePOSOrderEmailCalled = true + spyUpdatePOSOrderEmailSiteID = siteID + spyUpdatePOSOrderEmailOrderID = orderID + spyUpdatePOSOrderEmailAddress = emailAddress + switch updatePOSOrderEmailResult { + case .success: + return + case .failure(let error): + throw error + } + } + var createPOSOrderCalled: Bool = false var spyCreatePOSOrder: Order? var spyCreatePOSOrderFields: [OrdersRemote.CreateOrderField]? diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSReceiptsRemote.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSReceiptsRemote.swift index 2e343acc729..e11dc6ba087 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockPOSReceiptsRemote.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSReceiptsRemote.swift @@ -5,6 +5,7 @@ final class MockPOSReceiptsRemote: POSReceiptsRemoteProtocol { var sendPOSReceiptCalled = false var spySiteID: Int64? var spyOrderID: Int64? + var spyEmail: String? var shouldThrowError: Error? func sendReceipt(siteID: Int64, orderID: Int64) async throws { @@ -17,7 +18,14 @@ final class MockPOSReceiptsRemote: POSReceiptsRemoteProtocol { } } - func sendPOSReceipt(siteID: Int64, orderID: Int64) async throws { + func sendPOSReceipt(siteID: Int64, orderID: Int64, emailAddress: String) async throws { sendPOSReceiptCalled = true + spySiteID = siteID + spyOrderID = orderID + spyEmail = emailAddress + + if let shouldThrowError { + throw shouldThrowError + } } } diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSOrderServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSOrderServiceTests.swift index d2ded1ebadd..e7e09015375 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSOrderServiceTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSOrderServiceTests.swift @@ -166,6 +166,35 @@ struct POSOrderServiceTests { return true }) } + + @Test func updatePOSOrder_calls_remote_updatePOSOrderEmail_with_correct_parameters() async throws { + // Given + let siteID: Int64 = 123 + let orderID: Int64 = 456 + let recipientEmail = "test@example.com" + + // When + try await sut.updatePOSOrder(orderID: orderID, recipientEmail: recipientEmail) + + // Then + #expect(mockOrdersRemote.updatePOSOrderEmailCalled == true) + #expect(mockOrdersRemote.spyUpdatePOSOrderEmailSiteID == siteID) + #expect(mockOrdersRemote.spyUpdatePOSOrderEmailOrderID == orderID) + #expect(mockOrdersRemote.spyUpdatePOSOrderEmailAddress == recipientEmail) + } + + @Test func updatePOSOrder_throws_error_when_remote_call_fails() async throws { + // Given + mockOrdersRemote.updatePOSOrderEmailResult = .failure(NSError(domain: "", code: 0)) + + // When/Then + await #expect(performing: { + try await sut.updatePOSOrder(orderID: 456, recipientEmail: "test@example.com") + }, throws: { _ in + // The actual error `POSOrderServiceError.updateOrderFailed` is private, thus we cannot check against the exact error. + return true + }) + } } private func makePOSCartItem( diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSReceiptServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSReceiptServiceTests.swift index 7f8ddcc3738..ee627877d53 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSReceiptServiceTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSReceiptServiceTests.swift @@ -20,7 +20,7 @@ struct POSReceiptServiceTests { let email = "test@example.com" // When - try await sut.sendReceipt(order: order, recipientEmail: email, isEligibleForPOSReceipt: false) + try await sut.sendReceipt(orderID: order.orderID, recipientEmail: email, isEligibleForPOSReceipt: false) // Then #expect(receiptsRemote.sendReceiptCalled) @@ -36,7 +36,7 @@ struct POSReceiptServiceTests { // When/Then do { - try await sut.sendReceipt(order: order, recipientEmail: "test@example.com", isEligibleForPOSReceipt: false) + try await sut.sendReceipt(orderID: order.orderID, recipientEmail: "test@example.com", isEligibleForPOSReceipt: false) XCTFail("Expected error to be thrown") } catch { guard case POSReceiptService.POSReceiptServiceError.sendReceiptFailed = error else { @@ -48,10 +48,17 @@ struct POSReceiptServiceTests { @Test func sendReceipt_calls_remote_when_isEligibleForPOSReceipt_is_true() async throws { + // Given + let email = "test@example.com" + let orderID: Int64 = 789 + // When - try await sut.sendReceipt(order: Order.fake(), recipientEmail: "test@example.com", isEligibleForPOSReceipt: true) + try await sut.sendReceipt(orderID: orderID, recipientEmail: email, isEligibleForPOSReceipt: true) // Then #expect(receiptsRemote.sendPOSReceiptCalled) + #expect(receiptsRemote.spySiteID == 123) + #expect(receiptsRemote.spyOrderID == orderID) + #expect(receiptsRemote.spyEmail == email) } } diff --git a/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift index 091d09a5476..e3eb8f3a060 100644 --- a/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift +++ b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift @@ -1,10 +1,7 @@ import Foundation import Observation -import protocol Experiments.FeatureFlagService import protocol Yosemite.StoresManager import protocol Yosemite.POSOrderServiceProtocol -import protocol Yosemite.POSReceiptServiceProtocol -import protocol Yosemite.PluginsServiceProtocol import struct Yosemite.Order import struct Yosemite.POSCart import struct Yosemite.POSCartItem @@ -12,10 +9,8 @@ import struct Yosemite.POSCoupon import struct Yosemite.CouponsError import enum Yosemite.OrderAction import enum Yosemite.OrderUpdateField -import enum Yosemite.Plugin import class WooFoundation.CurrencyFormatter import class WooFoundation.CurrencySettings -import class Yosemite.PluginsService import enum WooFoundation.CurrencyCode import protocol WooFoundation.Analytics import enum Alamofire.AFError @@ -41,34 +36,28 @@ protocol PointOfSaleOrderControllerProtocol { @Observable final class PointOfSaleOrderController: PointOfSaleOrderControllerProtocol { init(orderService: POSOrderServiceProtocol, - receiptService: POSReceiptServiceProtocol, + receiptSender: POSReceiptSending, stores: StoresManager = ServiceLocator.stores, currencySettings: CurrencySettings = ServiceLocator.currencySettings, analytics: Analytics = ServiceLocator.analytics, - featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, - pluginsService: PluginsServiceProtocol = PluginsService(storageManager: ServiceLocator.storageManager), celebration: PaymentCaptureCelebrationProtocol = PaymentCaptureCelebration()) { self.orderService = orderService - self.receiptService = receiptService + self.receiptSender = receiptSender self.stores = stores self.storeCurrency = currencySettings.currencyCode self.currencyFormatter = CurrencyFormatter(currencySettings: currencySettings) self.analytics = analytics - self.featureFlagService = featureFlagService - self.pluginsService = pluginsService self.celebration = celebration } private let orderService: POSOrderServiceProtocol - private let receiptService: POSReceiptServiceProtocol + private let receiptSender: POSReceiptSending private let currencyFormatter: CurrencyFormatter private let celebration: PaymentCaptureCelebrationProtocol private let storeCurrency: CurrencyCode private let analytics: Analytics private let stores: StoresManager - private let featureFlagService: FeatureFlagService - private let pluginsService: PluginsServiceProtocol private(set) var orderState: PointOfSaleInternalOrderState = .idle private var order: Order? = nil @@ -110,36 +99,11 @@ protocol PointOfSaleOrderControllerProtocol { @MainActor func sendReceipt(recipientEmail: String) async throws { - var isEligibleForPOSReceipt: Bool? - do { - guard let order else { - throw PointOfSaleOrderControllerError.noOrder - } - - try await orderService.updatePOSOrder(order: order, recipientEmail: recipientEmail) - - let posReceiptEligibility: Bool - if featureFlagService.isFeatureFlagEnabled(.pointOfSaleReceipts) { - posReceiptEligibility = isPluginSupported( - .wooCommerce, - minimumVersion: POSReceiptEligibilityConstants.wcPluginMinimumVersion, - siteID: order.siteID - ) - } else { - posReceiptEligibility = false - } - isEligibleForPOSReceipt = posReceiptEligibility - - try await receiptService.sendReceipt(order: order, recipientEmail: recipientEmail, isEligibleForPOSReceipt: posReceiptEligibility) - - analytics.track(.receiptEmailSuccess, withProperties: ["eligible_for_pos_receipt": posReceiptEligibility]) - } catch { - let properties = [ - "eligible_for_pos_receipt": isEligibleForPOSReceipt - ].compactMapValues( { $0 }) - analytics.track(.receiptEmailFailed, properties: properties, error: error) - throw error + guard let order else { + throw PointOfSaleOrderControllerError.noOrder } + + try await receiptSender.sendReceipt(orderID: order.orderID, recipientEmail: recipientEmail) } func clearOrder() { @@ -208,22 +172,6 @@ private extension PointOfSaleOrderController { } } -private extension PointOfSaleOrderController { - @MainActor - func isPluginSupported(_ plugin: Plugin, minimumVersion: String, siteID: Int64) -> Bool { - // Plugin must be installed and active - guard let systemPlugin = pluginsService.loadPluginInStorage(siteID: siteID, plugin: plugin, isActive: true), - systemPlugin.active else { - return false - } - - // If plugin version is higher than minimum required version, mark as eligible - let isSupported = VersionHelpers.isVersionSupported(version: systemPlugin.version, - minimumRequired: minimumVersion, - includesDevAndBetaVersions: true) - return isSupported - } -} // MARK: - Error Handling @@ -240,11 +188,6 @@ private extension PointOfSaleOrderController { } } -private extension PointOfSaleOrderController { - enum POSReceiptEligibilityConstants { - static let wcPluginMinimumVersion = "10.0.0" - } -} // This is named to note that it is for use within the AggregateModel and OrderController. // Conversely, PointOfSaleOrderState is available to the Views, as it doesn't include the Order. diff --git a/WooCommerce/Classes/POS/Models/PointOfSaleOrderListModel.swift b/WooCommerce/Classes/POS/Models/PointOfSaleOrderListModel.swift index 43d29811d51..af4d94e7bc7 100644 --- a/WooCommerce/Classes/POS/Models/PointOfSaleOrderListModel.swift +++ b/WooCommerce/Classes/POS/Models/PointOfSaleOrderListModel.swift @@ -1,10 +1,18 @@ import Foundation import Observation +import struct Yosemite.POSOrder @Observable final class PointOfSaleOrderListModel { let ordersController: PointOfSaleSearchingOrderListControllerProtocol + let receiptSender: POSReceiptSending - init(ordersController: PointOfSaleSearchingOrderListControllerProtocol) { + init(ordersController: PointOfSaleSearchingOrderListControllerProtocol, + receiptSender: POSReceiptSending) { self.ordersController = ordersController + self.receiptSender = receiptSender + } + + func sendReceipt(order: POSOrder, email: String) async throws { + try await receiptSender.sendReceipt(orderID: order.id, recipientEmail: email) } } diff --git a/WooCommerce/Classes/POS/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSalePaymentSuccessView.swift b/WooCommerce/Classes/POS/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSalePaymentSuccessView.swift index 1c77d2ddccd..e6bceb11497 100644 --- a/WooCommerce/Classes/POS/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSalePaymentSuccessView.swift +++ b/WooCommerce/Classes/POS/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSalePaymentSuccessView.swift @@ -12,10 +12,12 @@ struct PointOfSalePaymentSuccessView: View { var body: some View { VStack { if isShowingSendReceiptView { - POSSendReceiptView(isShowingSendReceiptView: $isShowingSendReceiptView) - .transition(.asymmetric( - insertion: .move(edge: .trailing).combined(with: .opacity), - removal: .move(edge: .trailing).combined(with: .opacity))) + POSSendReceiptView(isShowingSendReceiptView: $isShowingSendReceiptView) { email in + try await posModel.sendReceipt(to: email) + } + .transition(.asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .trailing).combined(with: .opacity))) } else { HStack(alignment: .center) { Spacer() diff --git a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift index 5dec5225a25..51fdca001fe 100644 --- a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift +++ b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift @@ -10,11 +10,20 @@ struct PointOfSaleOrderDetailsView: View { let onBack: () -> Void @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.siteTimezone) private var siteTimezone + @Environment(PointOfSaleOrderListModel.self) private var orderListModel + @State private var isShowingEmailReceiptView: Bool = false private var shouldShowBackButton: Bool { horizontalSizeClass == .compact } + private var dateFormatter: DateFormatter { + let formatter = DateFormatter.dateAndTimeFormatter + formatter.timeZone = siteTimezone + return formatter + } + var body: some View { VStack(spacing: 0) { POSPageHeaderView( @@ -26,6 +35,10 @@ struct PointOfSaleOrderDetailsView: View { ScrollView { VStack(alignment: .leading, spacing: POSSpacing.medium) { + if actions.isNotEmpty { + actionsSection(actions) + } + if !order.lineItems.isEmpty { productsSection(order) } @@ -36,6 +49,12 @@ struct PointOfSaleOrderDetailsView: View { } .background(Color.posSurface) .navigationBarHidden(true) + .posFullScreenCover(isPresented: $isShowingEmailReceiptView) { + POSSendReceiptView(isShowingSendReceiptView: $isShowingEmailReceiptView) { email in + try await orderListModel.sendReceipt(order: order, email: email) + } + .posHeaderBackButtonIcon(systemName: "xmark") + } } } @@ -93,7 +112,7 @@ private extension PointOfSaleOrderDetailsView { @ViewBuilder func headerBottomContent(for order: POSOrder) -> some View { VStack(alignment: .leading, spacing: POSSpacing.xSmall) { - Text(DateFormatter.dateAndTimeFormatter.string(from: order.dateCreated)) + Text(dateFormatter.string(from: order.dateCreated)) .font(.posBodySmallRegular()) .foregroundStyle(Color.posOnSurfaceVariantHighest) .fixedSize(horizontal: false, vertical: true) @@ -278,6 +297,52 @@ private extension PointOfSaleOrderDetailsView { } } +// MARK: - Actions + +private extension PointOfSaleOrderDetailsView { + enum POSOrderDetailsAction: Identifiable, CaseIterable { + case emailReceipt + + var id: String { title } + + var title: String { + switch self { + case .emailReceipt: + Localization.emailReceiptActionTitle + } + } + + func available(for order: POSOrder) -> Bool { + switch self { + case .emailReceipt: + order.status == .completed + } + } + } + + var actions: [POSOrderDetailsAction] { + POSOrderDetailsAction.allCases.filter { $0.available(for: order) } + } + + @ViewBuilder + func actionsSection(_ actions: [POSOrderDetailsAction]) -> some View { + HStack { + ForEach(actions) { action in + Button(action: { + switch action { + case .emailReceipt: + isShowingEmailReceiptView = true + } + }) { + Text(action.title) + } + .buttonStyle(POSOutlinedButtonStyle(size: .extraSmall)) + } + } + .padding(.vertical) + } +} + // MARK: - Localization private enum Localization { @@ -361,6 +426,12 @@ private enum Localization { value: "Net Payment", comment: "Label for net payment amount after refunds" ) + + static let emailReceiptActionTitle = NSLocalizedString( + "pos.orderDetailsView.emailReceiptAction.title", + value: "Email receipt", + comment: "Label for email receipt action on order details view" + ) } #if DEBUG diff --git a/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift b/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift index 093317f8b7c..dca2d462486 100644 --- a/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift +++ b/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift @@ -7,6 +7,7 @@ struct PointOfSaleEntryPointView: View { @StateObject private var posModalManager = POSModalManager() @StateObject private var posSheetManager = POSSheetManager() @StateObject private var posCoverManager = POSFullScreenCoverManager() + @State private var orderListModel: PointOfSaleOrderListModel @State private var posEntryPointController: POSEntryPointController @Environment(\.horizontalSizeClass) private var horizontalSizeClass @@ -15,7 +16,6 @@ struct PointOfSaleEntryPointView: View { private let purchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol private let couponsController: PointOfSaleCouponsControllerProtocol private let couponsSearchController: PointOfSaleSearchingItemsControllerProtocol - private let ordersController: PointOfSaleSearchingOrderListControllerProtocol private let cardPresentPaymentService: CardPresentPaymentFacade private let orderController: PointOfSaleOrderControllerProtocol private let settingsController: PointOfSaleSettingsControllerProtocol @@ -23,6 +23,7 @@ struct PointOfSaleEntryPointView: View { private let searchHistoryService: POSSearchHistoryProviding private let popularPurchasableItemsController: PointOfSaleItemsControllerProtocol private let barcodeScanService: PointOfSaleBarcodeScanServiceProtocol + private let siteTimezone: TimeZone init(itemsController: PointOfSaleItemsControllerProtocol, purchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol, @@ -32,12 +33,14 @@ struct PointOfSaleEntryPointView: View { onPointOfSaleModeActiveStateChange: @escaping ((Bool) -> Void), cardPresentPaymentService: CardPresentPaymentFacade, orderController: PointOfSaleOrderControllerProtocol, + receiptSender: POSReceiptSending, settingsController: PointOfSaleSettingsControllerProtocol, collectOrderPaymentAnalyticsTracker: POSCollectOrderPaymentAnalyticsTracking, searchHistoryService: POSSearchHistoryProviding, popularPurchasableItemsController: PointOfSaleItemsControllerProtocol, barcodeScanService: PointOfSaleBarcodeScanServiceProtocol, - posEligibilityChecker: POSEntryPointEligibilityCheckerProtocol) { + posEligibilityChecker: POSEntryPointEligibilityCheckerProtocol, + siteTimezone: TimeZone = .current) { self.onPointOfSaleModeActiveStateChange = onPointOfSaleModeActiveStateChange self.itemsController = itemsController @@ -50,9 +53,10 @@ struct PointOfSaleEntryPointView: View { self.collectOrderPaymentAnalyticsTracker = collectOrderPaymentAnalyticsTracker self.searchHistoryService = searchHistoryService self.popularPurchasableItemsController = popularPurchasableItemsController - self.ordersController = ordersController self.barcodeScanService = barcodeScanService self.posEntryPointController = POSEntryPointController(eligibilityChecker: posEligibilityChecker) + self.orderListModel = PointOfSaleOrderListModel(ordersController: ordersController, receiptSender: receiptSender) + self.siteTimezone = siteTimezone } var body: some View { @@ -85,7 +89,8 @@ struct PointOfSaleEntryPointView: View { .environmentObject(posModalManager) .environmentObject(posSheetManager) .environmentObject(posCoverManager) - .environment(PointOfSaleOrderListModel(ordersController: ordersController)) + .environment(orderListModel) + .environment(\.siteTimezone, siteTimezone) .injectKeyboardObserver() .onAppear { onPointOfSaleModeActiveStateChange(true) @@ -108,6 +113,7 @@ struct PointOfSaleEntryPointView: View { onPointOfSaleModeActiveStateChange: { _ in }, cardPresentPaymentService: CardPresentPaymentPreviewService(), orderController: PointOfSalePreviewOrderController(), + receiptSender: POSReceiptSenderPreview(), settingsController: PointOfSaleSettingsPreviewController(), collectOrderPaymentAnalyticsTracker: POSCollectOrderPaymentPreviewAnalytics(), searchHistoryService: PointOfSalePreviewHistoryService(), diff --git a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderBackButton.swift b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderBackButton.swift index d56b0c2b3d1..ae81cac56ac 100644 --- a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderBackButton.swift +++ b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderBackButton.swift @@ -2,13 +2,14 @@ import SwiftUI struct POSPageHeaderBackButton: View { private let configuration: POSPageHeaderBackButtonConfiguration + @Environment(\.posHeaderBackButtonIcon) private var environmentIcon init(configuration: POSPageHeaderBackButtonConfiguration) { self.configuration = configuration } private var buttonIcon: String { - configuration.buttonIcon ?? Constants.defaultBackButtonIcon + environmentIcon ?? configuration.buttonIcon ?? Constants.defaultBackButtonIcon } var body: some View { diff --git a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderView.swift b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderView.swift index c8bbbabf990..8628de52074 100644 --- a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderView.swift +++ b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderView.swift @@ -44,13 +44,18 @@ struct POSPageHeaderView some View { + environment(\.posHeaderBackButtonIcon, systemName) + } +} + // MARK: - Previews #Preview { diff --git a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSendReceiptView.swift b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSendReceiptView.swift index 2b06fe75e1c..904afcb6825 100644 --- a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSendReceiptView.swift +++ b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSendReceiptView.swift @@ -4,19 +4,23 @@ import WooFoundation import class WordPressShared.EmailFormatValidator struct POSSendReceiptView: View { - @Environment(PointOfSaleAggregateModel.self) private var posModel - @Environment(\.dynamicTypeSize) var dynamicTypeSize @State private var textFieldInput: String = "" @State private var isLoading: Bool = false @State private var errorMessage: String? @FocusState private var isTextFieldFocused: Bool @Binding private(set) var isShowingSendReceiptView: Bool + private let onSendReceipt: (String) async throws -> Void @State private var buttonFrame: CGRect = .zero @State private var keyboardFrame: CGRect = .zero @State private var shouldMinimizePadding: Bool = false + init(isShowingSendReceiptView: Binding, onSendReceipt: @escaping (String) async throws -> Void) { + self._isShowingSendReceiptView = isShowingSendReceiptView + self.onSendReceipt = onSendReceipt + } + private var isEmailValid: Bool { EmailFormatValidator.validate(string: textFieldInput) } @@ -101,7 +105,7 @@ struct POSSendReceiptView: View { isLoading = true do { errorMessage = nil - try await posModel.sendReceipt(to: textFieldInput) + try await onSendReceipt(textFieldInput) withAnimation { isShowingSendReceiptView = false isTextFieldFocused = false @@ -154,7 +158,8 @@ private extension POSSendReceiptView { #if DEBUG #Preview { - POSSendReceiptView(isShowingSendReceiptView: .constant(true)) - .environment(POSPreviewHelpers.makePreviewAggregateModel()) + POSSendReceiptView(isShowingSendReceiptView: .constant(true)) { email in + try await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + } } #endif diff --git a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift index cfb95b28abf..f22a57e4016 100644 --- a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift +++ b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift @@ -97,11 +97,16 @@ private extension POSTabCoordinator { credentials: credentials, storage: storageManager) let pluginsService = PluginsService(storageManager: storageManager) + let siteTimezone = storesManager.sessionManager.defaultSite?.siteTimezone ?? .current + if let receiptService = POSReceiptService(siteID: siteID, credentials: credentials), let orderService = POSOrderService(siteID: siteID, credentials: credentials), #available(iOS 17.0, *) { + let receiptSender = POSReceiptSender(siteID: siteID, + orderService: orderService, + receiptService: receiptService) let posView = PointOfSaleEntryPointView( itemsController: PointOfSaleItemsController( itemProvider: PointOfSaleItemService( @@ -130,7 +135,8 @@ private extension POSTabCoordinator { }, cardPresentPaymentService: cardPresentPaymentService, orderController: PointOfSaleOrderController(orderService: orderService, - receiptService: receiptService), + receiptSender: receiptSender), + receiptSender: receiptSender, settingsController: PointOfSaleSettingsController(siteID: siteID, settingsService: settingsService, cardPresentPaymentService: cardPresentPaymentService, @@ -142,8 +148,10 @@ private extension POSTabCoordinator { itemFetchStrategyFactory: posPopularItemFetchStrategyFactory ), barcodeScanService: barcodeScanService, - posEligibilityChecker: eligibilityChecker + posEligibilityChecker: eligibilityChecker, + siteTimezone: siteTimezone ) + let hostingController = UIHostingController(rootView: posView) hostingController.modalPresentationStyle = .fullScreen viewControllerToPresent.present(hostingController, animated: true) diff --git a/WooCommerce/Classes/POS/Utils/POSEnvironmentKeys.swift b/WooCommerce/Classes/POS/Utils/POSEnvironmentKeys.swift new file mode 100644 index 00000000000..932bfea871a --- /dev/null +++ b/WooCommerce/Classes/POS/Utils/POSEnvironmentKeys.swift @@ -0,0 +1,13 @@ +import Foundation +import SwiftUI + +struct SiteTimezoneKey: EnvironmentKey { + static let defaultValue: TimeZone = .current +} + +extension EnvironmentValues { + var siteTimezone: TimeZone { + get { self[SiteTimezoneKey.self] } + set { self[SiteTimezoneKey.self] = newValue } + } +} diff --git a/WooCommerce/Classes/POS/Utils/POSReceiptSender.swift b/WooCommerce/Classes/POS/Utils/POSReceiptSender.swift new file mode 100644 index 00000000000..e9aac43b82b --- /dev/null +++ b/WooCommerce/Classes/POS/Utils/POSReceiptSender.swift @@ -0,0 +1,94 @@ +import Foundation +import protocol Experiments.FeatureFlagService +import protocol Yosemite.StoresManager +import protocol Yosemite.POSOrderServiceProtocol +import protocol Yosemite.POSReceiptServiceProtocol +import protocol Yosemite.PluginsServiceProtocol +import struct Yosemite.Order +import enum Yosemite.Plugin +import protocol WooFoundation.Analytics +import class Yosemite.PluginsService + +protocol POSReceiptSending { + func sendReceipt(orderID: Int64, recipientEmail: String) async throws +} + +final class POSReceiptSender: POSReceiptSending { + init(siteID: Int64, + orderService: POSOrderServiceProtocol, + receiptService: POSReceiptServiceProtocol, + analytics: Analytics = ServiceLocator.analytics, + featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, + pluginsService: PluginsServiceProtocol = PluginsService(storageManager: ServiceLocator.storageManager)) { + self.siteID = siteID + self.orderService = orderService + self.receiptService = receiptService + self.analytics = analytics + self.featureFlagService = featureFlagService + self.pluginsService = pluginsService + } + + private let siteID: Int64 + private let orderService: POSOrderServiceProtocol + private let receiptService: POSReceiptServiceProtocol + private let analytics: Analytics + private let featureFlagService: FeatureFlagService + private let pluginsService: PluginsServiceProtocol + + @MainActor + func sendReceipt(orderID: Int64, recipientEmail: String) async throws { + var isEligibleForPOSReceipt: Bool? + do { + let posReceiptEligibility: Bool + if featureFlagService.isFeatureFlagEnabled(.pointOfSaleReceipts) { + posReceiptEligibility = isPluginSupported( + .wooCommerce, + minimumVersion: POSReceiptEligibilityConstants.wcPluginMinimumVersion, + siteID: siteID + ) + } else { + posReceiptEligibility = false + } + isEligibleForPOSReceipt = posReceiptEligibility + + // Only update order email for previous POS receipt API version + // POS receipt now handles email update internally + if !posReceiptEligibility { + try await orderService.updatePOSOrder(orderID: orderID, recipientEmail: recipientEmail) + } + + try await receiptService.sendReceipt(orderID: orderID, recipientEmail: recipientEmail, isEligibleForPOSReceipt: posReceiptEligibility) + + analytics.track(.receiptEmailSuccess, withProperties: ["eligible_for_pos_receipt": posReceiptEligibility]) + } catch { + let properties = [ + "eligible_for_pos_receipt": isEligibleForPOSReceipt + ].compactMapValues( { $0 }) + analytics.track(.receiptEmailFailed, properties: properties, error: error) + throw error + } + } +} + +private extension POSReceiptSender { + @MainActor + func isPluginSupported(_ plugin: Plugin, minimumVersion: String, siteID: Int64) -> Bool { + // Plugin must be installed and active + guard let systemPlugin = pluginsService.loadPluginInStorage(siteID: siteID, plugin: plugin, isActive: true), + systemPlugin.active else { + return false + } + + // If plugin version is higher than minimum required version, mark as eligible + let isSupported = VersionHelpers.isVersionSupported(version: systemPlugin.version, + minimumRequired: minimumVersion, + includesDevAndBetaVersions: true) + return isSupported + } +} + +private extension POSReceiptSender { + enum POSReceiptEligibilityConstants { + static let wcPluginMinimumVersion = "10.0.0" + } +} diff --git a/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift b/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift index b3807f17d4a..1a4ca90336d 100644 --- a/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift +++ b/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift @@ -238,7 +238,7 @@ struct POSPreviewHelpers { } static func makePreviewOrdersModel() -> PointOfSaleOrderListModel { - return PointOfSaleOrderListModel(ordersController: PointOfSalePreviewOrderListController()) + return PointOfSaleOrderListModel(ordersController: PointOfSalePreviewOrderListController(), receiptSender: POSReceiptSenderPreview()) } static func makePreviewOrder() -> POSOrder { @@ -392,6 +392,10 @@ final class PointOfSalePreviewBarcodeScanService: PointOfSaleBarcodeScanServiceP } } +final class POSReceiptSenderPreview: POSReceiptSending { + func sendReceipt(orderID: Int64, recipientEmail: String) async throws {} +} + final class POSCollectOrderPaymentPreviewAnalytics: POSCollectOrderPaymentAnalyticsTracking { func trackCustomerInteractionStarted() {} diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index d2924ca63cb..3de330f8a68 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -50,6 +50,7 @@ 015456CE2DB0341D0071C3C4 /* POSPageHeaderActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 015456CD2DB033FF0071C3C4 /* POSPageHeaderActionButton.swift */; }; 0157A9962C4FEA7200866FFD /* PointOfSaleLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0157A9952C4FEA7200866FFD /* PointOfSaleLoadingView.swift */; }; 015D99AA2C58C780001D7186 /* PointOfSaleCardPresentPaymentLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 015D99A92C58C780001D7186 /* PointOfSaleCardPresentPaymentLayout.swift */; }; + 0161EFE22E734B2B006F27B4 /* POSEnvironmentKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0161EFE12E734B2B006F27B4 /* POSEnvironmentKeys.swift */; }; 01620C4E2C5394B200D3EA2F /* POSProgressViewStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01620C4D2C5394B200D3EA2F /* POSProgressViewStyle.swift */; }; 01664F9E2C50E685007CB5DD /* POSFontStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01664F9D2C50E685007CB5DD /* POSFontStyle.swift */; }; 016910982E1D019500B731DA /* GameControllerBarcodeObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016910972E1D019500B731DA /* GameControllerBarcodeObserver.swift */; }; @@ -75,6 +76,9 @@ 019130212CF5B0FF008C0C88 /* TapToPayEducationViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019130202CF5B0FF008C0C88 /* TapToPayEducationViewModelTests.swift */; }; 01929C342CEF6354006C79ED /* CardPresentModalErrorWithoutEmail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01929C332CEF634E006C79ED /* CardPresentModalErrorWithoutEmail.swift */; }; 01929C362CEF6D6E006C79ED /* CardPresentModalNonRetryableErrorWithoutEmail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01929C352CEF6D6A006C79ED /* CardPresentModalNonRetryableErrorWithoutEmail.swift */; }; + 019460DE2E700DF800FCB9AB /* POSReceiptSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019460DD2E700DF800FCB9AB /* POSReceiptSender.swift */; }; + 019460E02E700E3D00FCB9AB /* POSReceiptSenderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019460DF2E700E3D00FCB9AB /* POSReceiptSenderTests.swift */; }; + 019460E22E70121A00FCB9AB /* MockPOSReceiptController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019460E12E70121A00FCB9AB /* MockPOSReceiptController.swift */; }; 019630B42D01DB4800219D80 /* TapToPayAwarenessMomentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019630B32D01DB4000219D80 /* TapToPayAwarenessMomentView.swift */; }; 019630B62D02018C00219D80 /* TapToPayAwarenessMomentDeterminer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019630B52D02018400219D80 /* TapToPayAwarenessMomentDeterminer.swift */; }; 019630B82D0211F400219D80 /* TapToPayAwarenessMomentDeterminerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019630B72D0211F400219D80 /* TapToPayAwarenessMomentDeterminerTests.swift */; }; @@ -3267,6 +3271,7 @@ 015456CD2DB033FF0071C3C4 /* POSPageHeaderActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSPageHeaderActionButton.swift; sourceTree = ""; }; 0157A9952C4FEA7200866FFD /* PointOfSaleLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleLoadingView.swift; sourceTree = ""; }; 015D99A92C58C780001D7186 /* PointOfSaleCardPresentPaymentLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentLayout.swift; sourceTree = ""; }; + 0161EFE12E734B2B006F27B4 /* POSEnvironmentKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSEnvironmentKeys.swift; sourceTree = ""; }; 01620C4D2C5394B200D3EA2F /* POSProgressViewStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSProgressViewStyle.swift; sourceTree = ""; }; 01664F9D2C50E685007CB5DD /* POSFontStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSFontStyle.swift; sourceTree = ""; }; 016910972E1D019500B731DA /* GameControllerBarcodeObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameControllerBarcodeObserver.swift; sourceTree = ""; }; @@ -3292,6 +3297,9 @@ 019130202CF5B0FF008C0C88 /* TapToPayEducationViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapToPayEducationViewModelTests.swift; sourceTree = ""; }; 01929C332CEF634E006C79ED /* CardPresentModalErrorWithoutEmail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalErrorWithoutEmail.swift; sourceTree = ""; }; 01929C352CEF6D6A006C79ED /* CardPresentModalNonRetryableErrorWithoutEmail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalNonRetryableErrorWithoutEmail.swift; sourceTree = ""; }; + 019460DD2E700DF800FCB9AB /* POSReceiptSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSReceiptSender.swift; sourceTree = ""; }; + 019460DF2E700E3D00FCB9AB /* POSReceiptSenderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSReceiptSenderTests.swift; sourceTree = ""; }; + 019460E12E70121A00FCB9AB /* MockPOSReceiptController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPOSReceiptController.swift; sourceTree = ""; }; 019630B32D01DB4000219D80 /* TapToPayAwarenessMomentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapToPayAwarenessMomentView.swift; sourceTree = ""; }; 019630B52D02018400219D80 /* TapToPayAwarenessMomentDeterminer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapToPayAwarenessMomentDeterminer.swift; sourceTree = ""; }; 019630B72D0211F400219D80 /* TapToPayAwarenessMomentDeterminerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapToPayAwarenessMomentDeterminerTests.swift; sourceTree = ""; }; @@ -7203,6 +7211,8 @@ 026826972BF59D9E0036F959 /* Utils */ = { isa = PBXGroup; children = ( + 0161EFE12E734B2B006F27B4 /* POSEnvironmentKeys.swift */, + 019460DD2E700DF800FCB9AB /* POSReceiptSender.swift */, 01806E122E2F7F400033363C /* POSBrightnessControl.swift */, 68E33B2D2E66AAAE00CBE921 /* POSConstants.swift */, 689F29192DE4557D004DF52B /* POSStockFormatter.swift */, @@ -7798,6 +7808,7 @@ 02CD3BFC2C35D01600E575C4 /* Mocks */ = { isa = PBXGroup; children = ( + 019460E12E70121A00FCB9AB /* MockPOSReceiptController.swift */, 012ACB812E5D8DCD00A49458 /* MockPointOfSaleOrderListFetchStrategyFactory.swift */, 012ACB792E5C84D200A49458 /* MockPointOfSaleOrderListService.swift */, 01F935582DFC0D4800B50B03 /* MockPointOfSaleSoundPlayer.swift */, @@ -8389,6 +8400,7 @@ isa = PBXGroup; children = ( 20A130EA2C5A27190058022F /* PointOfSaleAssetsTests.swift */, + 019460DF2E700E3D00FCB9AB /* POSReceiptSenderTests.swift */, ); path = Tools; sourceTree = ""; @@ -15782,6 +15794,7 @@ E1E636BB26FB467A00C9D0D7 /* Comparable+Woo.swift in Sources */, CE315DC42CC91A4A00A06748 /* WooShippingServiceViewModel.swift in Sources */, 450C2CB024CF006A00D570DD /* ProductTagsDataSource.swift in Sources */, + 0161EFE22E734B2B006F27B4 /* POSEnvironmentKeys.swift in Sources */, 0139BB522D91B45800C78FDE /* CouponRowView.swift in Sources */, DEB3879E2C34FE620025256E /* GoogleAdsCampaignCoordinator.swift in Sources */, EE45E2BA2A409BA40085F227 /* TooltipPresenter.swift in Sources */, @@ -16635,6 +16648,7 @@ DE6906E327D7121800735E3B /* GhostTableViewController.swift in Sources */, 02EEB5C42424AFAA00B8A701 /* TextFieldTableViewCell.swift in Sources */, 453326FD2C3C5315000E4862 /* ProductCreationAIPromptProgressBarViewModel.swift in Sources */, + 019460DE2E700DF800FCB9AB /* POSReceiptSender.swift in Sources */, 26E7EE6A292D688900793045 /* AnalyticsHubViewModel.swift in Sources */, AE457813275644590092F687 /* OrderStatusSection.swift in Sources */, B57C744E20F56E3800EEFC87 /* UITableViewCell+Helpers.swift in Sources */, @@ -17545,6 +17559,7 @@ EE2A57D929E39A9C009F61E1 /* CaseIterable+HelpersTests.swift in Sources */, D8AB131E225DC25F002BB5D1 /* MockOrders.swift in Sources */, EEBDF7E72A31A59F00EFEF47 /* FirstProductCreatedViewModelTests.swift in Sources */, + 019460E22E70121A00FCB9AB /* MockPOSReceiptController.swift in Sources */, DE74F2A727E47F620002FE59 /* EnableAnalyticsViewModelTests.swift in Sources */, EE289AF52C9D9C3B004AB1A6 /* ProductCreationAIStartingInfoViewModelTests.swift in Sources */, 8697AFBD2B60F56A00EFAF21 /* BlazeAdDestinationSettingViewModelTests.swift in Sources */, @@ -17733,6 +17748,7 @@ 02077F72253816FF005A78EF /* ProductFormActionsFactory+ReadonlyProductTests.swift in Sources */, D8C11A6222E24C4A00D4A88D /* LedgerTableViewCellTests.swift in Sources */, 027111422913B9FC00F5269A /* AccountCreationFormViewModelTests.swift in Sources */, + 019460E02E700E3D00FCB9AB /* POSReceiptSenderTests.swift in Sources */, 2084B7A82C776E1000EFBD2E /* PointOfSaleCardPresentPaymentFoundMultipleReadersAlertViewModelTests.swift in Sources */, 02B8E41B2DFBC33D001D01FD /* MockPOSEligibilityChecker.swift in Sources */, DE50295328BF4A8A00551736 /* JetpackConnectionWebViewModelTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift index 8be5311ce21..14d94bc3681 100644 --- a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift +++ b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift @@ -16,15 +16,15 @@ import enum Networking.NetworkError struct PointOfSaleOrderControllerTests { let mockOrderService = MockPOSOrderService() - let mockReceiptService = MockReceiptService() + let mockReceiptSender = MockPOSReceiptSender() @Test func syncOrder_without_items_doesnt_call_orderService() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptSender: mockReceiptSender) // When - await sut.syncOrder(for: .init(), retryHandler: {}) + await sut.syncOrder(for: Cart(), retryHandler: {}) // Then #expect(mockOrderService.syncOrderWasCalled == false) @@ -33,17 +33,17 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_with_cart_matching_order_doesnt_call_orderService() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptSender: mockReceiptSender) let orderItem = OrderItem.fake().copy(quantity: 1) let fakeOrder = Order.fake().copy(items: [orderItem]) let cartItem = makeItem(orderItemsToMatch: [orderItem]) mockOrderService.orderToReturn = fakeOrder - await sut.syncOrder(for: .init(purchasableItems: [cartItem]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [cartItem]), retryHandler: {}) mockOrderService.syncOrderWasCalled = false // When - await sut.syncOrder(for: .init(purchasableItems: [cartItem]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [cartItem]), retryHandler: {}) // Then #expect(mockOrderService.syncOrderWasCalled == false) @@ -52,16 +52,16 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_already_syncing_doesnt_call_orderService() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptSender: mockReceiptSender) mockOrderService.simulateSyncing = true Task { - await sut.syncOrder(for: .init(purchasableItems: [makeItem(quantity: 1)]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [makeItem(quantity: 1)]), retryHandler: {}) } try await Task.sleep(nanoseconds: UInt64(100 * Double(NSEC_PER_MSEC))) mockOrderService.syncOrderWasCalled = false // When - await sut.syncOrder(for: .init(purchasableItems: [makeItem(quantity: 2), + await sut.syncOrder(for: Cart(purchasableItems: [makeItem(quantity: 2), makeItem(quantity: 5)]), retryHandler: {}) @@ -77,11 +77,11 @@ struct PointOfSaleOrderControllerTests { decimalSeparator: ".", numberOfDecimals: 2) let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService, + receiptSender: mockReceiptSender, currencySettings: currencySettings) // When - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [makeItem()]), retryHandler: {}) // Then #expect(mockOrderService.spySyncOrderCurrency == .AUD) @@ -90,7 +90,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_with_changes_from_previous_order_calls_orderService() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptSender: mockReceiptSender) let cartItem = makeItem(quantity: 1) let orderItem = OrderItem.fake().copy(quantity: 1) let fakeOrder = Order.fake().copy(items: [orderItem]) @@ -99,7 +99,7 @@ struct PointOfSaleOrderControllerTests { let futureOrderItem = OrderItem.fake().copy(quantity: 5) // When - await sut.syncOrder(for: .init(purchasableItems: [cartItem, + await sut.syncOrder(for: Cart(purchasableItems: [cartItem, makeItem(quantity: 5, orderItemsToMatch: [futureOrderItem])]), retryHandler: {}) @@ -110,7 +110,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_with_no_previous_order_sets_orderState_syncing_then_loaded() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptSender: mockReceiptSender) let fakeOrder = Order.fake() mockOrderService.orderToReturn = fakeOrder var orderStates: [PointOfSaleInternalOrderState] = [sut.orderState] @@ -130,7 +130,7 @@ struct PointOfSaleOrderControllerTests { observeOrderState() // When - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [makeItem()]), retryHandler: {}) } await orderStateAppendTask?.value @@ -147,7 +147,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_with_order_sync_failure_sets_orderState_syncing_then_error() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptSender: mockReceiptSender) mockOrderService.orderToReturn = nil var orderStates: [PointOfSaleInternalOrderState] = [sut.orderState] @@ -167,7 +167,7 @@ struct PointOfSaleOrderControllerTests { observeOrderState() // When - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [makeItem()]), retryHandler: {}) } await orderStateAppendTask?.value @@ -180,13 +180,10 @@ struct PointOfSaleOrderControllerTests { ]) } - @Test func sendReceipt_when_there_is_no_order_then_will_not_trigger() async throws { + @Test func sendReceipt_when_there_is_no_order_then_throws_noOrder_error() async throws { // Given - let mockFeatureFlagService = MockFeatureFlagService() - mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService, - featureFlagService: mockFeatureFlagService) + receiptSender: mockReceiptSender) let email = "test@example.com" // When @@ -195,38 +192,35 @@ struct PointOfSaleOrderControllerTests { } catch { // Then #expect(error as? PointOfSaleOrderController.PointOfSaleOrderControllerError == .noOrder) - #expect(!mockOrderService.updateOrderWasCalled) - #expect(mockReceiptService.sendReceiptWasCalled == nil) + #expect(!mockReceiptSender.sendReceiptWasCalled) } } - @Test func sendReceipt_calls_both_updateOrder_and_sendReceipt() async throws { + @Test func sendReceipt_with_order_delegates_to_receiptSender() async throws { // Given - let mockFeatureFlagService = MockFeatureFlagService() - mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService, - featureFlagService: mockFeatureFlagService) + receiptSender: mockReceiptSender) let order = Order.fake() let recipientEmail = "test@fake.com" mockOrderService.orderToReturn = order - // We need an existing order before we can update its email, and send a receipt: - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: { }) + // We need an existing order before we can send a receipt: + await sut.syncOrder(for: Cart(purchasableItems: [makeItem()]), retryHandler: { }) // When try await sut.sendReceipt(recipientEmail: recipientEmail) // Then - #expect(mockOrderService.updateOrderWasCalled) - #expect(mockOrderService.orderToReturn?.billingAddress?.email == recipientEmail) + #expect(mockReceiptSender.sendReceiptWasCalled) + #expect(mockReceiptSender.sendReceiptCalledWithOrderID == order.orderID) + #expect(mockReceiptSender.sendReceiptCalledWithEmail == recipientEmail) } @Test func collectCashPayment_when_no_order_then_fails_with_noOrder_error() async throws { do { // Given/When let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService, + receiptSender: mockReceiptSender, celebration: MockPaymentCaptureCelebration()) try await sut.collectCashPayment(changeDueAmount: nil) } catch let error as PointOfSaleOrderController.PointOfSaleOrderControllerError { @@ -240,13 +234,13 @@ struct PointOfSaleOrderControllerTests { // Given let mockPaymentCelebration = MockPaymentCaptureCelebration() let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService, + receiptSender: mockReceiptSender, celebration: mockPaymentCelebration) let orderItem = OrderItem.fake() let fakeOrder = Order.fake().copy(items: [orderItem]) mockOrderService.orderToReturn = fakeOrder - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [makeItem()]), retryHandler: {}) mockOrderService.resultToReturn = .success(()) @@ -260,13 +254,13 @@ struct PointOfSaleOrderControllerTests { @Test func collectCashPayment_passes_changeDueAmount_to_order_service() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService, + receiptSender: mockReceiptSender, celebration: MockPaymentCaptureCelebration()) let orderItem = OrderItem.fake() let fakeOrder = Order.fake().copy(items: [orderItem]) mockOrderService.orderToReturn = fakeOrder - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [makeItem()]), retryHandler: {}) mockOrderService.resultToReturn = .success(()) @@ -280,14 +274,14 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_successful_returns_newOrder_result() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptSender: mockReceiptSender) let fakeOrderItem = OrderItem.fake().copy(quantity: 1) let fakeOrder = Order.fake() let fakeCartItem = makeItem(orderItemsToMatch: [fakeOrderItem]) mockOrderService.orderToReturn = fakeOrder // When - let result = await sut.syncOrder(for: .init(purchasableItems: [fakeCartItem]), retryHandler: { }) + let result = await sut.syncOrder(for: Cart(purchasableItems: [fakeCartItem]), retryHandler: { }) // Then if case .success(let state) = result { @@ -300,16 +294,16 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_updating_existing_order_returns_newOrder_result() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptSender: mockReceiptSender) let fakeOrder = Order.fake() mockOrderService.orderToReturn = fakeOrder // When // 1. Initial order - _ = await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: {}) + _ = await sut.syncOrder(for: Cart(purchasableItems: [makeItem()]), retryHandler: {}) // 2. Sync existing order - let result = await sut.syncOrder(for: .init(purchasableItems: [makeItem(), makeItem()]), retryHandler: {}) + let result = await sut.syncOrder(for: Cart(purchasableItems: [makeItem(), makeItem()]), retryHandler: {}) // Then if case .success(let state) = result { @@ -322,7 +316,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_cart_matching_order_then_returns_orderNotChanged_result() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptSender: mockReceiptSender) let orderItem = OrderItem.fake().copy(quantity: 1) let fakeOrder = Order.fake().copy(items: [orderItem]) let cartItem = makeItem(orderItemsToMatch: [orderItem]) @@ -330,10 +324,10 @@ struct PointOfSaleOrderControllerTests { // When // 1. Initial order - _ = await sut.syncOrder(for: .init(purchasableItems: [cartItem]), retryHandler: {}) + _ = await sut.syncOrder(for: Cart(purchasableItems: [cartItem]), retryHandler: {}) // 2. Syncing existing order with same cart should not update order - let result = await sut.syncOrder(for: .init(purchasableItems: [cartItem]), retryHandler: {}) + let result = await sut.syncOrder(for: Cart(purchasableItems: [cartItem]), retryHandler: {}) // Then if case .success(let state) = result { @@ -345,12 +339,12 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_orderService_fails_then_returns_syncOrderState_failure() async throws { let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptSender: mockReceiptSender) let cartItem = makeItem(quantity: 1) // When mockOrderService.orderToReturn = nil - let result = await sut.syncOrder(for: .init(purchasableItems: [cartItem]), retryHandler: {}) + let result = await sut.syncOrder(for: Cart(purchasableItems: [cartItem]), retryHandler: {}) // Then if case .failure(let error) = result { @@ -363,7 +357,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_with_cart_matching_order_and_coupons_doesnt_call_orderService() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptSender: mockReceiptSender) let orderItem = OrderItem.fake().copy(quantity: 1) let couponCode = "SAVE10" let coupon = OrderCouponLine.fake().copy(code: couponCode) @@ -372,12 +366,12 @@ struct PointOfSaleOrderControllerTests { mockOrderService.orderToReturn = fakeOrder // Initial sync to set up the order - await sut.syncOrder(for: .init(purchasableItems: [cartItem], coupons: [.init(id: UUID(), code: couponCode, summary: "")]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [cartItem], coupons: [.init(id: UUID(), code: couponCode, summary: "")]), retryHandler: {}) mockOrderService.syncOrderWasCalled = false // When - sync with same items and coupons - await sut.syncOrder(for: .init(purchasableItems: [cartItem], coupons: [.init(id: UUID(), code: couponCode, summary: "")]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [cartItem], coupons: [.init(id: UUID(), code: couponCode, summary: "")]), retryHandler: {}) // Then #expect(mockOrderService.syncOrderWasCalled == false) @@ -386,7 +380,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_with_matching_items_but_different_coupons_calls_orderService() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptSender: mockReceiptSender) let orderItem = OrderItem.fake().copy(quantity: 1) let initialCouponCode = "SAVE10" let initialCoupon = OrderCouponLine.fake().copy(code: initialCouponCode) @@ -395,12 +389,12 @@ struct PointOfSaleOrderControllerTests { mockOrderService.orderToReturn = fakeOrder // Initial sync - await sut.syncOrder(for: .init(purchasableItems: [cartItem], coupons: [.init(id: UUID(), code: initialCouponCode, summary: "")]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [cartItem], coupons: [.init(id: UUID(), code: initialCouponCode, summary: "")]), retryHandler: {}) mockOrderService.syncOrderWasCalled = false // When - sync with same items but different coupon - await sut.syncOrder(for: .init(purchasableItems: [cartItem], coupons: [.init(id: UUID(), code: "DIFFERENT20", summary: "")]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [cartItem], coupons: [.init(id: UUID(), code: "DIFFERENT20", summary: "")]), retryHandler: {}) // Then #expect(mockOrderService.syncOrderWasCalled == true) @@ -409,7 +403,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_with_matching_items_but_removed_coupon_calls_orderService() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptSender: mockReceiptSender) let orderItem = OrderItem.fake().copy(quantity: 1) let couponCode = "SAVE10" let coupon = OrderCouponLine.fake().copy(code: couponCode) @@ -418,12 +412,12 @@ struct PointOfSaleOrderControllerTests { mockOrderService.orderToReturn = fakeOrder // Initial sync with coupon - await sut.syncOrder(for: .init(purchasableItems: [cartItem], coupons: [.init(id: UUID(), code: couponCode, summary: "")]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [cartItem], coupons: [.init(id: UUID(), code: couponCode, summary: "")]), retryHandler: {}) mockOrderService.syncOrderWasCalled = false // When - sync with same items but no coupons - await sut.syncOrder(for: .init(purchasableItems: [cartItem], coupons: []), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [cartItem], coupons: []), retryHandler: {}) // Then #expect(mockOrderService.syncOrderWasCalled == true) @@ -432,7 +426,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_orderService_fails_with_couponsError_then_sets_invalidCoupon_error() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptSender: mockReceiptSender) let errorMessage = "Invalid coupon code" mockOrderService.errorToReturn = DotcomError.unknown(code: "woocommerce_rest_invalid_coupon", message: errorMessage) @@ -453,7 +447,7 @@ struct PointOfSaleOrderControllerTests { observeOrderState() // When - await sut.syncOrder(for: .init(purchasableItems: [makeItem()], + await sut.syncOrder(for: Cart(purchasableItems: [makeItem()], coupons: [.init(id: UUID(), code: "INVALID", summary: "")]), retryHandler: {}) } @@ -471,7 +465,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_orderService_fails_with_networkError_containing_couponsError_then_sets_invalidCoupon_error() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptSender: mockReceiptSender) let errorMessage = "Coupon INVALID does not exist" let errorJSON = """ { @@ -499,7 +493,7 @@ struct PointOfSaleOrderControllerTests { observeOrderState() // When - await sut.syncOrder(for: .init(purchasableItems: [makeItem()], + await sut.syncOrder(for: Cart(purchasableItems: [makeItem()], coupons: [.init(id: UUID(), code: "INVALID", summary: "")]), retryHandler: {}) } @@ -517,7 +511,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_fails_sets_order_to_nil() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptSender: mockReceiptSender) // First create a successful order let orderItem = OrderItem.fake().copy(quantity: 1) @@ -526,7 +520,7 @@ struct PointOfSaleOrderControllerTests { mockOrderService.orderToReturn = fakeOrder // Initial sync succeeds - let initialResult = await sut.syncOrder(for: .init(purchasableItems: [cartItem]), retryHandler: {}) + let initialResult = await sut.syncOrder(for: Cart(purchasableItems: [cartItem]), retryHandler: {}) switch initialResult { case .success(.newOrder): break @@ -536,7 +530,7 @@ struct PointOfSaleOrderControllerTests { // Then simulate a failure mockOrderService.errorToReturn = SyncOrderStateError.syncFailure - let failureResult = await sut.syncOrder(for: .init(purchasableItems: [cartItem, cartItem]), retryHandler: {}) + let failureResult = await sut.syncOrder(for: Cart(purchasableItems: [cartItem, cartItem]), retryHandler: {}) switch failureResult { case .failure(SyncOrderStateError.syncFailure): break @@ -547,7 +541,7 @@ struct PointOfSaleOrderControllerTests { // When - try syncing with the same cart again mockOrderService.errorToReturn = nil mockOrderService.orderToReturn = fakeOrder // Restore mock to return success - let subsequentResult = await sut.syncOrder(for: .init(purchasableItems: [cartItem]), retryHandler: {}) + let subsequentResult = await sut.syncOrder(for: Cart(purchasableItems: [cartItem]), retryHandler: {}) // Then - should be treated as new order since previous order was cleared switch subsequentResult { @@ -563,7 +557,8 @@ struct PointOfSaleOrderControllerTests { private let analytics: WooAnalytics private let analyticsProvider = MockAnalyticsProvider() private let orderService = MockPOSOrderService() - private let receiptService = MockReceiptService() + private let receiptSender = MockReceiptService() + private let mockReceiptSender = MockPOSReceiptSender() init() { analytics = WooAnalytics(analyticsProvider: analyticsProvider) @@ -572,7 +567,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_create_order_then_tracks_order_creation_success_event() async throws { // Given let sut = PointOfSaleOrderController(orderService: orderService, - receiptService: receiptService, + receiptSender: mockReceiptSender, analytics: analytics) let fakeOrderItem = OrderItem.fake().copy(quantity: 1) let fakeOrder = Order.fake() @@ -580,7 +575,7 @@ struct PointOfSaleOrderControllerTests { orderService.orderToReturn = fakeOrder // When - await sut.syncOrder(for: .init(purchasableItems: [fakeCartItem]), retryHandler: { }) + await sut.syncOrder(for: Cart(purchasableItems: [fakeCartItem]), retryHandler: { }) // Then #expect(analyticsProvider.receivedEvents.first(where: { $0 == "order_creation_success" }) != nil) @@ -589,12 +584,12 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_create_order_fails_with_order_service_error_then_tracks_order_creation_failure_event() async throws { // Given let sut = PointOfSaleOrderController(orderService: orderService, - receiptService: receiptService, + receiptSender: mockReceiptSender, analytics: analytics) orderService.orderToReturn = nil // When - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [makeItem()]), retryHandler: {}) // Then #expect(analyticsProvider.receivedEvents.first(where: { $0 == "order_creation_failed" }) != nil) @@ -607,7 +602,7 @@ struct PointOfSaleOrderControllerTests { let mockAnalytics = WooAnalytics(analyticsProvider: mockAnalyticsProvider) let sut = PointOfSaleOrderController(orderService: orderService, - receiptService: MockReceiptService(), + receiptSender: mockReceiptSender, analytics: mockAnalytics, celebration: MockPaymentCaptureCelebration()) @@ -615,13 +610,13 @@ struct PointOfSaleOrderControllerTests { let orderItem = OrderItem.fake() let fakeOrder = Order.fake().copy(items: [orderItem]) orderService.orderToReturn = fakeOrder - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [makeItem()]), retryHandler: {}) orderService.resultToReturn = .failure(NSError(domain: "test", code: 0, userInfo: nil)) // When await #expect(performing: { - try await sut.collectCashPayment(changeDueAmount: nil) + try await sut.collectCashPayment(changeDueAmount: nil as String?) }, throws: { _ in return true }) @@ -630,228 +625,8 @@ struct PointOfSaleOrderControllerTests { #expect(mockAnalyticsProvider.receivedEvents.first(where: { $0 == "cash_payment_failed" }) != nil) } - @Test func sendReceipt_tracks_success_with_eligible_for_pos_receipt() async throws { - // Given - let mockPluginsService = MockPluginsService() - mockPluginsService.setMockPlugin(.wooCommerce, - systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", - version: "10.0.0-dev", - active: true)) - - let mockFeatureFlagService = MockFeatureFlagService() - mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true - - let sut = PointOfSaleOrderController(orderService: orderService, - receiptService: receiptService, - analytics: analytics, - featureFlagService: mockFeatureFlagService, - pluginsService: mockPluginsService) - let order = Order.fake() - orderService.orderToReturn = order - - // We need an existing order before we can send a receipt - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: { }) - analyticsProvider.receivedEvents.removeAll() - analyticsProvider.receivedProperties.removeAll() - - // When - try await sut.sendReceipt(recipientEmail: "test@example.com") - - // Then - let indexOfEvent = try #require(analyticsProvider.receivedEvents.firstIndex(where: { $0 == "receipt_email_success" })) - #expect(analyticsProvider.receivedProperties[indexOfEvent]["eligible_for_pos_receipt"] as? Bool == true) - } - @Test func sendReceipt_without_order_tracks_failure_without_eligible_for_pos_receipt() async throws { - // Given - let mockFeatureFlagService = MockFeatureFlagService() - mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true - let sut = PointOfSaleOrderController(orderService: orderService, - receiptService: receiptService, - analytics: analytics, - featureFlagService: mockFeatureFlagService) - - // When - do { - try await sut.sendReceipt(recipientEmail: "test@example.com") - } catch { - // Then - let indexOfEvent = try #require(analyticsProvider.receivedEvents.firstIndex(where: { $0 == "receipt_email_failed" })) - #expect(analyticsProvider.receivedProperties[indexOfEvent]["eligible_for_pos_receipt"] == nil) - } - } - - @Test func sendReceipt_tracks_failure_with_eligible_for_pos_receipt() async throws { - // Given - let mockPluginsService = MockPluginsService() - mockPluginsService.setMockPlugin(.wooCommerce, - systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", - version: "10.0.0-dev", - active: true)) - - let mockFeatureFlagService = MockFeatureFlagService() - mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true - let sut = PointOfSaleOrderController(orderService: orderService, - receiptService: receiptService, - analytics: analytics, - featureFlagService: mockFeatureFlagService, - pluginsService: mockPluginsService) - - receiptService.sendReceiptResult = .failure(DotcomError.unknown(code: "test_error", message: "Test error")) - - let order = Order.fake() - orderService.orderToReturn = order - - // We need an existing order before we can send a receipt - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: { }) - analyticsProvider.receivedEvents.removeAll() - analyticsProvider.receivedProperties.removeAll() - - // When - do { - try await sut.sendReceipt(recipientEmail: "test@example.com") - } catch { - // Then - let indexOfEvent = try #require(analyticsProvider.receivedEvents.firstIndex(where: { $0 == "receipt_email_failed" })) - #expect(analyticsProvider.receivedProperties[indexOfEvent]["eligible_for_pos_receipt"] as? Bool == true) - #expect(analyticsProvider.receivedProperties[indexOfEvent]["error_description"] as? String != nil) - } - } - } - - @MainActor - struct ReceiptTests { - private let mockOrderService = MockPOSOrderService() - - @Test("Eligible core plugin versions with feature flag enabled", arguments: Constants.eligibleWCPluginVersions) - func sendReceipt_when_feature_flag_enabled_and_eligible_plugin_version_sets_isEligibleForPOSReceipt_true(wcPluginVersion: String) async throws { - // Given - let mockReceiptService = MockReceiptService() - let mockFeatureFlagService = MockFeatureFlagService() - let mockPluginsService = MockPluginsService() - mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true - mockPluginsService.setMockPlugin(.wooCommerce, - systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", - version: wcPluginVersion, - active: true)) - - let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService, - analytics: ServiceLocator.analytics, - featureFlagService: mockFeatureFlagService, - pluginsService: mockPluginsService) - mockOrderService.orderToReturn = Order.fake() - - // We need an existing order before we can update its email, and send a receipt: - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: { }) - - // When - try await sut.sendReceipt(recipientEmail: "test@example.com") - // Then - #expect(mockReceiptService.sendReceiptWasCalled == true) - #expect(mockReceiptService.spyIsEligibleForPOSReceipt == true) - } - - @Test( - "All core plugin versions with feature flag disabled", - arguments: Constants.eligibleWCPluginVersions + Constants.ineligibleWCPluginVersions - ) - func sendReceipt_when_feature_flag_disabled_and_eligible_plugin_version_sets_isEligibleForPOSReceipt_false(wcPluginVersion: String) async throws { - // Given - let mockReceiptService = MockReceiptService() - let mockFeatureFlagService = MockFeatureFlagService() - let mockPluginsService = MockPluginsService() - mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = false - // Plugin setup is irrelevant when feature flag is disabled - mockPluginsService.setMockPlugin(.wooCommerce, - systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", - version: wcPluginVersion, - active: true)) - - let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService, - analytics: ServiceLocator.analytics, - featureFlagService: mockFeatureFlagService, - pluginsService: mockPluginsService) - mockOrderService.orderToReturn = Order.fake() - - // We need an existing order before we can update its email, and send a receipt: - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: { }) - - // When - try await sut.sendReceipt(recipientEmail: "test@example.com") - - // Then - #expect(mockReceiptService.sendReceiptWasCalled == true) - #expect(mockReceiptService.spyIsEligibleForPOSReceipt == false) - } - - @Test("Ineligible core plugin versions with feature flag enabled", arguments: Constants.ineligibleWCPluginVersions) - func sendReceipt_when_feature_flag_enabled_and_ineligible_plugin_version_sets_isEligibleForPOSReceipt_false(wcPluginVersion: String) async throws { - // Given - let mockReceiptService = MockReceiptService() - let mockFeatureFlagService = MockFeatureFlagService() - let mockPluginsService = MockPluginsService() - mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true - mockPluginsService.setMockPlugin(.wooCommerce, - systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", - version: wcPluginVersion, - active: true)) - - let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService, - analytics: ServiceLocator.analytics, - featureFlagService: mockFeatureFlagService, - pluginsService: mockPluginsService) - mockOrderService.orderToReturn = Order.fake() - - // We need an existing order before we can update its email, and send a receipt: - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: { }) - - // When - try await sut.sendReceipt(recipientEmail: "test@example.com") - - // Then - #expect(mockReceiptService.sendReceiptWasCalled == true) - #expect(mockReceiptService.spyIsEligibleForPOSReceipt == false) - } - - @Test("Unavailable core plugin with feature flag enabled", - arguments: [ - SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", active: false), - nil - ]) - func sendReceipt_when_feature_flag_enabled_and_plugin_unavailable_sets_isEligibleForPOSReceipt_false(plugin: SystemPlugin?) async throws { - // Given - let mockReceiptService = MockReceiptService() - let mockFeatureFlagService = MockFeatureFlagService() - let mockPluginsService = MockPluginsService() - mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true - mockPluginsService.setMockPlugin(.wooCommerce, systemPlugin: plugin) - - let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService, - analytics: ServiceLocator.analytics, - featureFlagService: mockFeatureFlagService, - pluginsService: mockPluginsService) - mockOrderService.orderToReturn = Order.fake() - - // We need an existing order before we can update its email, and send a receipt: - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: { }) - - // When - try await sut.sendReceipt(recipientEmail: "test@example.com") - - // Then - #expect(mockReceiptService.sendReceiptWasCalled == true) - #expect(mockReceiptService.spyIsEligibleForPOSReceipt == false) - } - - private enum Constants { - static let eligibleWCPluginVersions = ["10.0.0", "10.0.0-dev", "10.0.0-beta", "10.0.1", "10.1"] - static let ineligibleWCPluginVersions = ["9.9.0", "9.9.9", "9.9.9-beta.9", "9.9.9-dev"] - } } } diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSOrderService.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSOrderService.swift index 90bf222bd07..6c33349ebed 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSOrderService.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSOrderService.swift @@ -33,7 +33,7 @@ class MockPOSOrderService: POSOrderServiceProtocol { return order } - func updatePOSOrder(order: Order, recipientEmail: String) async throws { + func updatePOSOrder(orderID: Int64, recipientEmail: String) async throws { updateOrderWasCalled = true let orderWithUpdatedEmail = MockOrders().sampleOrder().copy(billingAddress: .init(firstName: "", diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSReceiptController.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSReceiptController.swift new file mode 100644 index 00000000000..87dbc88dec2 --- /dev/null +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSReceiptController.swift @@ -0,0 +1,20 @@ +import Foundation +@testable import WooCommerce +import struct Yosemite.Order + +final class MockPOSReceiptSender: POSReceiptSending { + var sendReceiptErrorToThrow: Error? + var sendReceiptWasCalled: Bool = false + var sendReceiptCalledWithOrderID: Int64? + var sendReceiptCalledWithEmail: String? + + func sendReceipt(orderID: Int64, recipientEmail: String) async throws { + sendReceiptWasCalled = true + sendReceiptCalledWithOrderID = orderID + sendReceiptCalledWithEmail = recipientEmail + + if let sendReceiptErrorToThrow { + throw sendReceiptErrorToThrow + } + } +} diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift index 575935eb212..33bb4bd1e30 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift @@ -9,12 +9,8 @@ final class MockPointOfSaleOrderController: PointOfSaleOrderControllerProtocol { // no-op } - var orderStatePublisher: AnyPublisher { - $orderState.eraseToAnyPublisher() - } - @Published var orderState: PointOfSaleInternalOrderState = .idle + var orderState: PointOfSaleInternalOrderState = .idle var orderStateToReturn: PointOfSaleInternalOrderState? - var syncOrderWasCalled: Bool = false var spyCartProducts: [Cart.PurchasableItem]? var spyRetryHandler: (() async -> Void)? @@ -41,12 +37,12 @@ final class MockPointOfSaleOrderController: PointOfSaleOrderControllerProtocol { clearOrderWasCalled = true } - var shouldThrowReceiptError: Bool = false + var sendReceiptErrorToThrow: Error? var sendReceiptWasCalled: Bool = false func sendReceipt(recipientEmail: String) async throws { sendReceiptWasCalled = true - if shouldThrowReceiptError { - throw NSError(domain: "some error", code: -1) + if let sendReceiptErrorToThrow { + throw sendReceiptErrorToThrow } } } diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockReceiptService.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockReceiptService.swift index 76bb2c0f7c1..177b93cbdf2 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockReceiptService.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockReceiptService.swift @@ -6,7 +6,7 @@ final class MockReceiptService: POSReceiptServiceProtocol { var spyIsEligibleForPOSReceipt: Bool? var sendReceiptResult: Result = .success(()) - func sendReceipt(order: Yosemite.Order, recipientEmail: String, isEligibleForPOSReceipt: Bool) async throws { + func sendReceipt(orderID: Int64, recipientEmail: String, isEligibleForPOSReceipt: Bool) async throws { sendReceiptWasCalled = true spyIsEligibleForPOSReceipt = isEligibleForPOSReceipt switch sendReceiptResult { diff --git a/WooCommerce/WooCommerceTests/POS/Models/PointOfSaleAggregateModelTests.swift b/WooCommerce/WooCommerceTests/POS/Models/PointOfSaleAggregateModelTests.swift index 2410f5529ae..aa19da1792e 100644 --- a/WooCommerce/WooCommerceTests/POS/Models/PointOfSaleAggregateModelTests.swift +++ b/WooCommerce/WooCommerceTests/POS/Models/PointOfSaleAggregateModelTests.swift @@ -301,8 +301,8 @@ struct PointOfSaleAggregateModelTests { @Test func sendReceipt_when_invoked_with_error_then_returns_error() async throws { // Given let orderController = MockPointOfSaleOrderController() - orderController.shouldThrowReceiptError = true let expectedError = NSError(domain: "some error", code: -1) + orderController.sendReceiptErrorToThrow = expectedError let sut = makePointOfSaleAggregateModel(orderController: orderController) diff --git a/WooCommerce/WooCommerceTests/POS/Tools/POSReceiptSenderTests.swift b/WooCommerce/WooCommerceTests/POS/Tools/POSReceiptSenderTests.swift new file mode 100644 index 00000000000..969320fab62 --- /dev/null +++ b/WooCommerce/WooCommerceTests/POS/Tools/POSReceiptSenderTests.swift @@ -0,0 +1,217 @@ +import Testing +import Foundation + +@testable import WooCommerce +import struct Yosemite.Order +import struct Yosemite.SystemPlugin +import protocol WooFoundation.Analytics +import enum Networking.DotcomError + +struct POSReceiptSenderTests { + private let mockOrderService = MockPOSOrderService() + private let mockReceiptService = MockReceiptService() + private let mockAnalyticsProvider = MockAnalyticsProvider() + private let mockFeatureFlagService = MockFeatureFlagService() + private let mockPluginsService = MockPluginsService() + private let sut: POSReceiptSender + + init() { + self.sut = POSReceiptSender(siteID: 123, + orderService: mockOrderService, + receiptService: mockReceiptService, + analytics: MockAnalytics(), + featureFlagService: mockFeatureFlagService, + pluginsService: mockPluginsService) + } + + @Test func sendReceipt_tracks_success_with_eligible_for_pos_receipt() async throws { + // Given + mockPluginsService.setMockPlugin(.wooCommerce, + systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", + version: "10.0.0-dev", + active: true)) + mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true + let order = Order.fake() + + // When + try await sut.sendReceipt(orderID: order.orderID, recipientEmail: "test@example.com") + + // Then + #expect(mockReceiptService.sendReceiptWasCalled == true) + } + + @Test func sendReceipt_tracks_failure_with_eligible_for_pos_receipt() async throws { + // Given + mockPluginsService.setMockPlugin(.wooCommerce, + systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", + version: "10.0.0-dev", + active: true)) + mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true + mockReceiptService.sendReceiptResult = .failure(DotcomError.unknown(code: "test_error", message: "Test error")) + let order = Order.fake() + + // When + do { + try await sut.sendReceipt(orderID: order.orderID, recipientEmail: "test@example.com") + #expect(Bool(false), "Expected error to be thrown") + } catch { + // Then - error was thrown as expected + #expect(!mockOrderService.updateOrderWasCalled) // Should not update order for POS receipts even on failure + } + } + + @MainActor + struct PluginEligibilityTests { + private let mockOrderService = MockPOSOrderService() + + @Test("Eligible core plugin versions with feature flag enabled", arguments: Constants.eligibleWCPluginVersions) + func sendReceipt_when_feature_flag_enabled_and_eligible_plugin_version_sets_isEligibleForPOSReceipt_true(wcPluginVersion: String) async throws { + // Given + let mockReceiptService = MockReceiptService() + let mockFeatureFlagService = MockFeatureFlagService() + let mockPluginsService = MockPluginsService() + mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true + mockPluginsService.setMockPlugin(.wooCommerce, + systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", + version: wcPluginVersion, + active: true)) + let sut = POSReceiptSender(siteID: 123, + orderService: mockOrderService, + receiptService: mockReceiptService, + analytics: MockAnalytics(), + featureFlagService: mockFeatureFlagService, + pluginsService: mockPluginsService) + let order = Order.fake() + + // When + try await sut.sendReceipt(orderID: order.orderID, recipientEmail: "test@example.com") + + // Then + #expect(mockReceiptService.sendReceiptWasCalled == true) + #expect(mockReceiptService.spyIsEligibleForPOSReceipt == true) + } + + @Test( + "All core plugin versions with feature flag disabled", + arguments: Constants.eligibleWCPluginVersions + Constants.ineligibleWCPluginVersions + ) + func sendReceipt_when_feature_flag_disabled_and_eligible_plugin_version_sets_isEligibleForPOSReceipt_false(wcPluginVersion: String) async throws { + // Given + let mockReceiptService = MockReceiptService() + let mockFeatureFlagService = MockFeatureFlagService() + let mockPluginsService = MockPluginsService() + mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = false + // Plugin setup is irrelevant when feature flag is disabled + mockPluginsService.setMockPlugin(.wooCommerce, + systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", + version: wcPluginVersion, + active: true)) + let sut = POSReceiptSender(siteID: 123, + orderService: mockOrderService, + receiptService: mockReceiptService, + analytics: MockAnalytics(), + featureFlagService: mockFeatureFlagService, + pluginsService: mockPluginsService) + let order = Order.fake() + + // When + try await sut.sendReceipt(orderID: order.orderID, recipientEmail: "test@example.com") + + // Then + #expect(mockReceiptService.sendReceiptWasCalled == true) + #expect(mockReceiptService.spyIsEligibleForPOSReceipt == false) + } + + @Test("Ineligible core plugin versions with feature flag enabled", arguments: Constants.ineligibleWCPluginVersions) + func sendReceipt_when_feature_flag_enabled_and_ineligible_plugin_version_sets_isEligibleForPOSReceipt_false(wcPluginVersion: String) async throws { + // Given + let mockReceiptService = MockReceiptService() + let mockFeatureFlagService = MockFeatureFlagService() + let mockPluginsService = MockPluginsService() + mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true + mockPluginsService.setMockPlugin(.wooCommerce, + systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", + version: wcPluginVersion, + active: true)) + let sut = POSReceiptSender(siteID: 123, + orderService: mockOrderService, + receiptService: mockReceiptService, + analytics: MockAnalytics(), + featureFlagService: mockFeatureFlagService, + pluginsService: mockPluginsService) + let order = Order.fake() + + // When + try await sut.sendReceipt(orderID: order.orderID, recipientEmail: "test@example.com") + + // Then + #expect(mockReceiptService.sendReceiptWasCalled == true) + #expect(mockReceiptService.spyIsEligibleForPOSReceipt == false) + } + + @Test("Unavailable core plugin with feature flag enabled", + arguments: [ + SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", active: false), + nil + ]) + func sendReceipt_when_feature_flag_enabled_and_plugin_unavailable_sets_isEligibleForPOSReceipt_false(plugin: SystemPlugin?) async throws { + // Given + let mockReceiptService = MockReceiptService() + let mockFeatureFlagService = MockFeatureFlagService() + let mockPluginsService = MockPluginsService() + mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true + mockPluginsService.setMockPlugin(.wooCommerce, systemPlugin: plugin) + let sut = POSReceiptSender(siteID: 123, + orderService: mockOrderService, + receiptService: mockReceiptService, + analytics: MockAnalytics(), + featureFlagService: mockFeatureFlagService, + pluginsService: mockPluginsService) + let order = Order.fake() + + // When + try await sut.sendReceipt(orderID: order.orderID, recipientEmail: "test@example.com") + + // Then + #expect(mockReceiptService.sendReceiptWasCalled == true) + #expect(mockReceiptService.spyIsEligibleForPOSReceipt == false) + } + + private enum Constants { + static let eligibleWCPluginVersions = ["10.0.0", "10.0.0-dev", "10.0.0-beta", "10.0.1", "10.1"] + static let ineligibleWCPluginVersions = ["9.9.0", "9.9.9", "9.9.9-beta.9", "9.9.9-dev"] + } + } + + @Test func sendReceipt_calls_sendReceipt_but_not_updateOrder_for_POS_receipts() async throws { + // Given + mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true + mockPluginsService.setMockPlugin(.wooCommerce, + systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", + version: "10.0.0", + active: true)) + let order = Order.fake() + let recipientEmail = "test@fake.com" + + // When + try await sut.sendReceipt(orderID: order.orderID, recipientEmail: recipientEmail) + + // Then + #expect(!mockOrderService.updateOrderWasCalled) // Should not update order for POS receipts + #expect(mockReceiptService.sendReceiptWasCalled == true) + } + + @Test func sendReceipt_calls_both_updateOrder_and_sendReceipt_for_legacy_POS_receipts() async throws { + // Given - feature flag disabled or plugin not eligible + mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = false + let order = Order.fake() + let recipientEmail = "test@fake.com" + + // When + try await sut.sendReceipt(orderID: order.orderID, recipientEmail: recipientEmail) + + // Then + #expect(mockOrderService.updateOrderWasCalled) // Should update order for traditional receipts + #expect(mockReceiptService.sendReceiptWasCalled == true) + } +}