diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.pbxproj b/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.pbxproj index 530edf4cc..9ed38fcd1 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.pbxproj +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.pbxproj @@ -69,6 +69,10 @@ CB05E6C32D4954E400466376 /* Storefront.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storefront.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 6A4895F72E4E069C00D4AE90 /* Common */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Common; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 4EBBA7642A5F0CE200193E19 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -126,6 +130,7 @@ 4EBBA77F2A5F0DA300193E19 /* Application */ = { isa = PBXGroup; children = ( + 6A4895F72E4E069C00D4AE90 /* Common */, 86250DE32AD5521C002E45C2 /* AppConfiguration.swift */, 4EBBA76A2A5F0CE200193E19 /* AppDelegate.swift */, 4EF54F232A6F456B00F5E407 /* CartManager.swift */, @@ -214,6 +219,9 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + 6A4895F72E4E069C00D4AE90 /* Common */, + ); name = MobileBuyIntegration; packageProductDependencies = ( 4EBBA7A22A5F0F5600193E19 /* Buy */, diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/AppConfiguration.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/AppConfiguration.swift index 5a579ff04..56dab6e36 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/AppConfiguration.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/AppConfiguration.swift @@ -29,23 +29,30 @@ public final class AppConfiguration: ObservableObject { public var storefrontDomain: String = InfoDictionary.shared.domain @Published public var universalLinks = UniversalLinks() - - /// Prefill buyer information @Published public var useVaultedState: Bool = false + @Published public var authenticated: Bool = false /// Logger to retain Web Pixel events let webPixelsLogger = FileLogger("analytics.txt") // Configure ShopifyAcceleratedCheckouts - let acceleratedCheckoutsStorefrontConfig = ShopifyAcceleratedCheckouts.Configuration( - storefrontDomain: InfoDictionary.shared.domain, - storefrontAccessToken: InfoDictionary.shared.accessToken - ) + var acceleratedCheckoutsStorefrontConfig: ShopifyAcceleratedCheckouts.Configuration { + return ShopifyAcceleratedCheckouts.Configuration( + storefrontDomain: InfoDictionary.shared.domain, + storefrontAccessToken: InfoDictionary.shared.accessToken, + customer: authenticated ? ShopifyAcceleratedCheckouts.Customer( + email: InfoDictionary.shared.email, + phoneNumber: InfoDictionary.shared.phone + ) : nil + ) + } - let acceleratedCheckoutsApplePayConfig = ShopifyAcceleratedCheckouts.ApplePayConfiguration( - merchantIdentifier: InfoDictionary.shared.merchantIdentifier, - contactFields: [.email] - ) + var acceleratedCheckoutsApplePayConfig: ShopifyAcceleratedCheckouts.ApplePayConfiguration { + return ShopifyAcceleratedCheckouts.ApplePayConfiguration( + merchantIdentifier: InfoDictionary.shared.merchantIdentifier, + contactFields: authenticated ? [] : [.email, .phone] + ) + } } public var appConfiguration = AppConfiguration() { diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/Common/Analytics.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/Common/Analytics.swift new file mode 100644 index 000000000..02efe10f5 --- /dev/null +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/Common/Analytics.swift @@ -0,0 +1,83 @@ +/* + MIT License + + Copyright 2023 - Present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import Foundation +import ShopifyCheckoutSheetKit + +class Analytics { + static func getUserId() -> String { + // return ID for user used in your existing analytics system + return "123" + } + + static func record(_ event: PixelEvent) { + switch event { + case let .customEvent(customEvent): + if let genericEvent = AnalyticsEvent.from(customEvent, userId: getUserId()) { + Analytics.record(genericEvent) + } + case let .standardEvent(standardEvent): + Analytics.record(AnalyticsEvent.from(standardEvent, userId: getUserId())) + } + } + + static func record(_ event: AnalyticsEvent) { + ShopifyCheckoutSheetKit.configuration.logger.log("[Web Pixel Event (\(event.type)] \(event.name)") + // send the event to an analytics system, e.g. via an analytics sdk + appConfiguration.webPixelsLogger.log(event.name) + } +} + +// example type, e.g. that may be defined by an analytics sdk +struct AnalyticsEvent { + var type: PixelEvent + var name = "" + var userId = "" + var timestamp = "" + var checkoutTotal: Double? = 0.0 + + static func from(_ event: StandardEvent, userId: String) -> AnalyticsEvent { + return AnalyticsEvent( + type: .standardEvent(event), + name: event.name!, + userId: userId, + timestamp: event.timestamp!, + checkoutTotal: event.data?.checkout?.totalPrice?.amount ?? 0.0 + ) + } + + static func from(_ event: CustomEvent, userId: String) -> AnalyticsEvent? { + guard event.name != nil else { + print("Failed to parse custom event", event) + return nil + } + + return AnalyticsEvent( + type: .customEvent(event), + name: event.name!, + userId: userId, + timestamp: event.timestamp!, + checkoutTotal: nil + ) + } +} diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/Common/ShopifyAcceleratedCheckoutHandlers.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/Common/ShopifyAcceleratedCheckoutHandlers.swift new file mode 100644 index 000000000..483c9c9e0 --- /dev/null +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/Common/ShopifyAcceleratedCheckoutHandlers.swift @@ -0,0 +1,38 @@ +/* + MIT License + + Copyright 2023 - Present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import ShopifyAcceleratedCheckouts + +/** + * Common event handlers that can be used across instances of AcceleratedCheckoutButtons() + * + * @example + * AcceleratedCheckoutButtons(cartID: cartId) + * .checkout(delegate: checkoutDelegate) + * .onError(AcceleratedCheckoutHandlers.handleError) + */ +enum AcceleratedCheckoutHandlers { + static func handleError(error: AcceleratedCheckoutError) { + print("[AcceleratedCheckoutHandlers] handleError: \(error)") + } +} diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/Common/ShopifyCheckoutDelegate.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/Common/ShopifyCheckoutDelegate.swift new file mode 100644 index 000000000..1eaed2530 --- /dev/null +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/Common/ShopifyCheckoutDelegate.swift @@ -0,0 +1,56 @@ +/* + MIT License + + Copyright 2023 - Present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import Foundation +import ShopifyAcceleratedCheckouts +import ShopifyCheckoutSheetKit +import UIKit + +/** + * Common CheckoutDelegate instance which can be reused across instances of Checkout Sheet Kit and Accelerated Checkouts. + * + * Use overrides to override the default behaviour. + */ +class CustomCheckoutDelegate: UIViewController, CheckoutDelegate { + func checkoutDidComplete(event _: CheckoutCompletedEvent) { + dismiss(animated: true) + } + + func checkoutDidClickLink(url: URL) { + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + } + + func checkoutDidFail(error: ShopifyCheckoutSheetKit.CheckoutError) { + ShopifyCheckoutSheetKit.configuration.logger.log("Checkout failed: \(error.localizedDescription), Recoverable: \(error.isRecoverable)") + } + + func checkoutDidEmitWebPixelEvent(event: ShopifyCheckoutSheetKit.PixelEvent) { + Analytics.record(event) + } + + func checkoutDidCancel() { + dismiss(animated: true) + } +} diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/Localizable.xcstrings b/Samples/MobileBuyIntegration/MobileBuyIntegration/Localizable.xcstrings index c57d16c9c..7757fdb45 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/Localizable.xcstrings +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/Localizable.xcstrings @@ -57,6 +57,9 @@ }, "Handle Product URLs" : { + }, + "If toggled on, customer information will be attached to cart from your app settings. When toggled off, customer information will be collected from the Apple Pay sheet." : { + }, "Loading products..." : { @@ -114,6 +117,9 @@ }, "Universal Links" : { + }, + "Use authenticated customer" : { + }, "Version" : { diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/ViewControllers/CartViewController.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/ViewControllers/CartViewController.swift index 65994e6a4..b34ddb6e5 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/ViewControllers/CartViewController.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/ViewControllers/CartViewController.swift @@ -148,6 +148,8 @@ class CartViewController: UIViewController, UITableViewDelegate, UITableViewData private var bag = Set() + private var checkoutDelegate = CustomCheckoutDelegate() + private var emptyView: UIView! private var tableView: UITableView! private var buttonContainerView: UIView! @@ -354,15 +356,17 @@ class CartViewController: UIViewController, UITableViewDelegate, UITableViewData let acceleratedCheckoutButtonsView = AcceleratedCheckoutButtons(cartID: cartId.rawValue) .wallets([.shopPay, .applePay]) .cornerRadius(10) - .onComplete { _ in - // Reset cart on successful checkout - CartManager.shared.resetCart() - } - .onFail { error in - print("Accelerated checkout failed: \(error)") - } - .onCancel { - print("Accelerated checkout cancelled") + .checkout(delegate: checkoutDelegate) + .onError { error in + // Handle validation errors + switch error { + case let .validation(validationError): + print("Validation failed: \(validationError.description)") + // You can access specific validation errors: + for userError in validationError.userErrors { + print("Field: \(userError.field ?? []), Message: \(userError.message)") + } + } } .environmentObject(appConfiguration.acceleratedCheckoutsStorefrontConfig) .environmentObject(appConfiguration.acceleratedCheckoutsApplePayConfig) @@ -508,7 +512,7 @@ class CartViewController: UIViewController, UITableViewDelegate, UITableViewData @objc private func presentCheckout() { guard let url = CartManager.shared.cart?.checkoutUrl else { return } - ShopifyCheckoutSheetKit.present(checkout: url, from: self, delegate: self) + ShopifyCheckoutSheetKit.present(checkout: url, from: self, delegate: checkoutDelegate) } @objc private func resetCart() { @@ -533,24 +537,19 @@ class CartViewController: UIViewController, UITableViewDelegate, UITableViewData } } -extension CartViewController: CheckoutDelegate { - func checkoutDidComplete(event: ShopifyCheckoutSheetKit.CheckoutCompletedEvent) { - resetCart() +class CustomDelegate: CustomCheckoutDelegate { + override func checkoutDidComplete(event: ShopifyCheckoutSheetKit.CheckoutCompletedEvent) { + super.checkoutDidComplete(event: event) + // Reset cart on successful checkout (for both regular and accelerated checkout) + CartManager.shared.resetCart() ShopifyCheckoutSheetKit.configuration.logger.log("Order created: \(event.orderDetails.id)") } - func checkoutDidCancel() { - dismiss(animated: true) - } + @MainActor + override func checkoutDidFail(error: ShopifyCheckoutSheetKit.CheckoutError) { + super.checkoutDidFail(error: error) - func checkoutDidClickContactLink(url: URL) { - if UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) - } - } - - func checkoutDidFail(error: ShopifyCheckoutSheetKit.CheckoutError) { var errorMessage = "" /// Internal Checkout SDK error @@ -590,88 +589,33 @@ extension CartViewController: CheckoutDelegate { } } - func checkoutDidEmitWebPixelEvent(event: ShopifyCheckoutSheetKit.PixelEvent) { - switch event { - case let .customEvent(customEvent): - print("[PIXEL - Custom]", customEvent.name!) - if let genericEvent = mapToGenericEvent(customEvent: customEvent) { - recordAnalyticsEvent(genericEvent) - } - case let .standardEvent(standardEvent): - print("[PIXEL - Standard]", standardEvent.name!) - recordAnalyticsEvent(mapToGenericEvent(standardEvent: standardEvent)) - } - } - private func handleUnrecoverableError(_ message: String = "Checkout unavailable") { DispatchQueue.main.async { - self.resetCart() - self.showAlert(message: message) + CartManager.shared.resetCart() + // Find the presenting view controller to show alert + if let topViewController = self.findTopViewController() { + self.showAlert(on: topViewController, message: message) + } } } -} - -extension CartViewController { - func showAlert(message: String) { - let alert = UIAlertController(title: "Checkout Failed", message: message, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "Default action"), style: .default, handler: { _ in })) - - present(alert, animated: true, completion: nil) - } -} - -// analytics examples -extension CartViewController { - private func mapToGenericEvent(standardEvent: StandardEvent) -> AnalyticsEvent { - return AnalyticsEvent( - name: standardEvent.name!, - userId: getUserId(), - timestamp: standardEvent.timestamp!, - checkoutTotal: standardEvent.data?.checkout?.totalPrice?.amount ?? 0.0 - ) - } - private func mapToGenericEvent(customEvent: CustomEvent) -> AnalyticsEvent? { - guard customEvent.name != nil else { - print("Failed to parse custom event", customEvent) + private func findTopViewController() -> UIViewController? { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first + else { return nil } - return AnalyticsEvent( - name: customEvent.name!, - userId: getUserId(), - timestamp: customEvent.timestamp!, - checkoutTotal: nil - ) - } - - private func decodeAndMap(event: CustomEvent, decoder _: JSONDecoder = JSONDecoder()) throws -> AnalyticsEvent { - return AnalyticsEvent( - name: event.name!, - userId: getUserId(), - timestamp: event.timestamp!, - checkoutTotal: nil - ) - } - private func getUserId() -> String { - // return ID for user used in your existing analytics system - return "123" + var topController = window.rootViewController + while let presentedController = topController?.presentedViewController { + topController = presentedController + } + return topController } - func recordAnalyticsEvent(_ event: AnalyticsEvent) { - // send the event to an analytics system, e.g. via an analytics sdk - appConfiguration.webPixelsLogger.log(event.name) + private func showAlert(on viewController: UIViewController, message: String) { + let alert = UIAlertController(title: "Checkout Failed", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "Default action"), style: .default, handler: { _ in })) + viewController.present(alert, animated: true, completion: nil) } } - -// example type, e.g. that may be defined by an analytics sdk -struct AnalyticsEvent: Codable { - var name = "" - var userId = "" - var timestamp = "" - var checkoutTotal: Double? = 0.0 -} - -struct CustomPixelEventData: Codable { - var customAttribute = 0.0 -} diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/Views/CartView.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/Views/CartView.swift index a4d1a91fe..23cb59df5 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/Views/CartView.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/Views/CartView.swift @@ -37,6 +37,8 @@ struct CartView: View { @ObservedObject var cartManager: CartManager = .shared @ObservedObject var config: AppConfiguration = appConfiguration + let checkoutDelegate = CustomCheckoutDelegate() + var body: some View { if let lines = cartManager.cart?.lines.nodes { ZStack(alignment: .bottom) { @@ -52,16 +54,8 @@ struct CartView: View { AcceleratedCheckoutButtons(cartID: cartId) .wallets([.shopPay, .applePay]) .cornerRadius(DesignSystem.cornerRadius) - .onComplete { _ in - // Reset cart on successful checkout - CartManager.shared.resetCart() - } - .onFail { error in - print("Accelerated checkout failed: \(error)") - } - .onCancel { - print("Accelerated checkout cancelled") - } + .checkout(delegate: checkoutDelegate) + .onError(AcceleratedCheckoutHandlers.handleError) .environmentObject(appConfiguration.acceleratedCheckoutsStorefrontConfig) .environmentObject(appConfiguration.acceleratedCheckoutsApplePayConfig) } diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/Views/ProductView.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/Views/ProductView.swift index 5399dacbd..e642f669b 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/Views/ProductView.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/Views/ProductView.swift @@ -38,6 +38,8 @@ struct ProductView: View { @State private var descriptionExpanded: Bool = false @State private var addedToCart: Bool = false + let checkoutDelegate = CustomCheckoutDelegate() + init(product: Storefront.Product) { _product = State(initialValue: product) } @@ -134,12 +136,8 @@ struct ProductView: View { AcceleratedCheckoutButtons(variantID: variant.id.rawValue, quantity: 1) .wallets([.applePay]) .cornerRadius(DesignSystem.cornerRadius) - .onFail { error in - print("Accelerated checkout failed: \(error)") - } - .onCancel { - print("Accelerated checkout cancelled") - } + .checkout(delegate: checkoutDelegate) + .onError(AcceleratedCheckoutHandlers.handleError) .environmentObject(appConfiguration.acceleratedCheckoutsStorefrontConfig) .environmentObject(appConfiguration.acceleratedCheckoutsApplePayConfig) } diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/Views/SettingsView.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/Views/SettingsView.swift index 16d0bb86b..ec1feb718 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/Views/SettingsView.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/Views/SettingsView.swift @@ -66,6 +66,13 @@ struct SettingsView: View { Toggle("Prefill buyer information", isOn: $config.useVaultedState) } + Section(header: Text("Accelerated Checkouts")) { + Toggle("Use authenticated customer", isOn: $config.authenticated) + Text("If toggled on, customer information will be attached to cart from your app settings. When toggled off, customer information will be collected from the Apple Pay sheet.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section(header: Text("Universal Links")) { Toggle("Handle Checkout URLs", isOn: $config.universalLinks.checkout) Toggle("Handle Cart URLs", isOn: $config.universalLinks.cart) @@ -79,6 +86,7 @@ struct SettingsView: View { "By default, the app will only handle the selections above and route everything else to Safari. Enabling the \"Handle all Universal Links\" setting will route all Universal Links to this app." ) .font(.caption) + .foregroundStyle(.secondary) } Section(header: Text("Theme")) { diff --git a/Samples/ShopifyAcceleratedCheckoutsApp/ShopifyAcceleratedCheckoutsApp/Views/Components/ButtonSet.swift b/Samples/ShopifyAcceleratedCheckoutsApp/ShopifyAcceleratedCheckoutsApp/Views/Components/ButtonSet.swift index 3e629f919..7edabca02 100644 --- a/Samples/ShopifyAcceleratedCheckoutsApp/ShopifyAcceleratedCheckoutsApp/Views/Components/ButtonSet.swift +++ b/Samples/ShopifyAcceleratedCheckoutsApp/ShopifyAcceleratedCheckoutsApp/Views/Components/ButtonSet.swift @@ -34,6 +34,15 @@ struct ButtonSet: View { @State private var cartRenderState: RenderState = .loading @State private var variantRenderState: RenderState = .loading + // Create CheckoutDelegate implementations + private var cartCheckoutDelegate: CheckoutDelegate { + CartCheckoutDelegate(onComplete: onComplete) + } + + private var variantCheckoutDelegate: CheckoutDelegate { + VariantCheckoutDelegate() + } + var body: some View { VStack(spacing: 16) { if let cartID = cart?.id { @@ -41,42 +50,12 @@ struct ButtonSet: View { title: "AcceleratedCheckoutButtons(cartID:)", renderState: $cartRenderState ) { - // Cart-based checkout example with event handlers + // Cart-based checkout example with CheckoutDelegate AcceleratedCheckoutButtons(cartID: cartID) .applePayLabel(.plain) - .onComplete { event in - print( - "✅ Checkout completed successfully. Order ID: \(event.orderDetails.id)" - ) - onComplete() - } - .onFail { error in - print("❌ Checkout failed: \(error)") - } - .onCancel { - print("🚫 Checkout cancelled") - } - .onShouldRecoverFromError { error in - print("🔄 Should recover from error: \(error)") - // Return true to attempt recovery, false to fail - return true - } - .onClickLink { url in - print("🔗 Link clicked: \(url)") - } - .onWebPixelEvent { event in - let eventName: String = { - switch event { - case let .customEvent(customEvent): - return customEvent.name ?? "Unknown custom event" - case let .standardEvent(standardEvent): - return standardEvent.name ?? "Unknown standard event" - } - }() - print("📊 Web pixel event: \(eventName)") - } - .onRenderStateChange { - cartRenderState = $0 + .checkout(delegate: cartCheckoutDelegate) + .onRenderStateChange { state in + cartRenderState = state } } } @@ -88,7 +67,7 @@ struct ButtonSet: View { title: "AcceleratedCheckoutButtons(variantID: quantity:)", renderState: $variantRenderState ) { - // Variant-based checkout with separate handlers and custom corner radius + // Variant-based checkout with CheckoutDelegate and custom corner radius AcceleratedCheckoutButtons( variantID: productVariant.id, quantity: firstVariantQuantity @@ -96,36 +75,9 @@ struct ButtonSet: View { .applePayLabel(.buy) .cornerRadius(24) .wallets([.applePay, .shopPay]) - .onComplete { event in - print("✅ Variant checkout completed") - print(" Order ID: \(event.orderDetails.id)") - } - .onFail { error in - print("❌ Variant checkout failed: \(error)") - } - .onCancel { - print("🚫 Variant checkout cancelled") - } - .onShouldRecoverFromError { error in - print("🔄 Variant - Should recover from error: \(error)") - return false // Example: don't recover for variant checkout - } - .onClickLink { url in - print("🔗 Variant - Link clicked: \(url)") - } - .onWebPixelEvent { event in - let eventName: String = { - switch event { - case let .customEvent(customEvent): - return customEvent.name ?? "Unknown custom event" - case let .standardEvent(standardEvent): - return standardEvent.name ?? "Unknown standard event" - } - }() - print("📊 Variant - Web pixel event: \(eventName)") - } - .onRenderStateChange { - variantRenderState = $0 + .checkout(delegate: variantCheckoutDelegate) + .onRenderStateChange { state in + variantRenderState = state } } } @@ -133,6 +85,89 @@ struct ButtonSet: View { } } +// MARK: - CheckoutDelegate Implementations + +/// CheckoutDelegate implementation for cart-based checkout +class CartCheckoutDelegate: CheckoutDelegate { + private let onComplete: () -> Void + + init(onComplete: @escaping () -> Void) { + self.onComplete = onComplete + } + + func checkoutDidComplete(event: CheckoutCompletedEvent) { + print("✅ Checkout completed successfully. Order ID: \(event.orderDetails.id)") + onComplete() + } + + func checkoutDidFail(error: CheckoutError) { + print("❌ Checkout failed: \(error)") + } + + func checkoutDidCancel() { + print("🚫 Checkout cancelled") + } + + func shouldRecoverFromError(error: CheckoutError) -> Bool { + print("🔄 Should recover from error: \(error)") + // Return true to attempt recovery, false to fail + return true + } + + func checkoutDidClickLink(url: URL) { + print("🔗 Link clicked: \(url)") + } + + func checkoutDidEmitWebPixelEvent(event: PixelEvent) { + let eventName: String = { + switch event { + case let .customEvent(customEvent): + return customEvent.name ?? "Unknown custom event" + case let .standardEvent(standardEvent): + return standardEvent.name ?? "Unknown standard event" + } + }() + print("📊 Web pixel event: \(eventName)") + } +} + +/// CheckoutDelegate implementation for variant-based checkout +class VariantCheckoutDelegate: CheckoutDelegate { + func checkoutDidComplete(event: CheckoutCompletedEvent) { + print("✅ Variant checkout completed") + print(" Order ID: \(event.orderDetails.id)") + } + + func checkoutDidFail(error: CheckoutError) { + print("❌ Variant checkout failed: \(error)") + } + + func checkoutDidCancel() { + print("🚫 Variant checkout cancelled") + } + + func shouldRecoverFromError(error: CheckoutError) -> Bool { + print("🔄 Variant - Should recover from error: \(error)") + return false // Example: don't recover for variant checkout + } + + func checkoutDidClickLink(url: URL) { + print("🔗 Variant - Link clicked: \(url)") + } + + func checkoutDidEmitWebPixelEvent(event: PixelEvent) { + let eventName: String = { + switch event { + case let .customEvent(customEvent): + return customEvent.name ?? "Unknown custom event" + case let .standardEvent(standardEvent): + return standardEvent.name ?? "Unknown standard event" + } + }() + print("📊 Variant - Web pixel event: \(eventName)") + } +} + // MARK: - Local Components private struct CheckoutSection: View { diff --git a/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI+Mutations.swift b/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI+Mutations.swift index afa0a6109..403a4ab35 100644 --- a/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI+Mutations.swift +++ b/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI+Mutations.swift @@ -68,9 +68,9 @@ extension StorefrontAPI { throw GraphQLError.invalidResponse } - let cart = try validateCart(payload.cart, requestName: "cartCreate") + try validateUserErrors(payload.userErrors) - try validateUserErrors(payload.userErrors, checkoutURL: cart.checkoutUrl.url) + let cart = try validateCart(payload.cart, requestName: "cartCreate") return cart } @@ -137,9 +137,9 @@ extension StorefrontAPI { throw GraphQLError.invalidResponse } - let cart = try validateCart(payload.cart, requestName: "cartBuyerIdentityUpdate") + try validateUserErrors(payload.userErrors) - try validateUserErrors(payload.userErrors, checkoutURL: cart.checkoutUrl.url) + let cart = try validateCart(payload.cart, requestName: "cartBuyerIdentityUpdate") return cart } @@ -176,9 +176,9 @@ extension StorefrontAPI { throw GraphQLError.invalidResponse } - let cart = try validateCart(payload.cart, requestName: "cartDeliveryAddressesAdd") + try validateUserErrors(payload.userErrors) - try validateUserErrors(payload.userErrors, checkoutURL: cart.checkoutUrl.url) + let cart = try validateCart(payload.cart, requestName: "cartDeliveryAddressesAdd") return cart } @@ -218,9 +218,9 @@ extension StorefrontAPI { throw GraphQLError.invalidResponse } - let cart = try validateCart(payload.cart, requestName: "cartDeliveryAddressesUpdate") + try validateUserErrors(payload.userErrors) - try validateUserErrors(payload.userErrors, checkoutURL: cart.checkoutUrl.url) + let cart = try validateCart(payload.cart, requestName: "cartDeliveryAddressesUpdate") return cart } @@ -247,9 +247,9 @@ extension StorefrontAPI { throw GraphQLError.invalidResponse } - let cart = try validateCart(payload.cart, requestName: "cartDeliveryAddressesRemove") + try validateUserErrors(payload.userErrors) - try validateUserErrors(payload.userErrors, checkoutURL: cart.checkoutUrl.url) + let cart = try validateCart(payload.cart, requestName: "cartDeliveryAddressesRemove") return cart } @@ -283,9 +283,9 @@ extension StorefrontAPI { throw GraphQLError.invalidResponse } - let cart = try validateCart(payload.cart, requestName: "cartSelectedDeliveryOptionsUpdate") + try validateUserErrors(payload.userErrors) - try validateUserErrors(payload.userErrors, checkoutURL: cart.checkoutUrl.url) + let cart = try validateCart(payload.cart, requestName: "cartSelectedDeliveryOptionsUpdate") return cart } @@ -354,9 +354,9 @@ extension StorefrontAPI { throw GraphQLError.invalidResponse } - let cart = try validateCart(payload.cart, requestName: "cartPaymentUpdate") + try validateUserErrors(payload.userErrors) - try validateUserErrors(payload.userErrors, checkoutURL: cart.checkoutUrl.url) + let cart = try validateCart(payload.cart, requestName: "cartPaymentUpdate") return cart } @@ -385,9 +385,9 @@ extension StorefrontAPI { throw GraphQLError.invalidResponse } - let cart = try validateCart(payload.cart, requestName: "cartBillingAddressUpdate") + try validateUserErrors(payload.userErrors) - try validateUserErrors(payload.userErrors, checkoutURL: cart.checkoutUrl.url) + let cart = try validateCart(payload.cart, requestName: "cartBillingAddressUpdate") return cart } @@ -408,9 +408,9 @@ extension StorefrontAPI { throw GraphQLError.invalidResponse } - let cart = try validateCart(payload.cart, requestName: "cartRemovePersonalData") + try validateUserErrors(payload.userErrors) - try validateUserErrors(payload.userErrors, checkoutURL: cart.checkoutUrl.url) + try validateCart(payload.cart, requestName: "cartRemovePersonalData") } /// Prepare cart for completion @@ -435,8 +435,8 @@ extension StorefrontAPI { switch result { case let .ready(ready): - let cart = try validateCart(ready.cart, requestName: "cartPrepareForCompletion") - try validateUserErrors(payload.userErrors, checkoutURL: cart.checkoutUrl.url) + try validateUserErrors(payload.userErrors) + try validateCart(ready.cart, requestName: "cartPrepareForCompletion") return ready case let .throttled(throttled): throw GraphQLError.networkError( @@ -465,7 +465,7 @@ extension StorefrontAPI { throw GraphQLError.invalidResponse } - try validateUserErrors(payload.userErrors, checkoutURL: nil) + try validateUserErrors(payload.userErrors) guard let result = payload.result else { throw GraphQLError.invalidResponse @@ -492,13 +492,14 @@ extension StorefrontAPI { @available(iOS 16.0, *) extension StorefrontAPI { - private func validateUserErrors(_ userErrors: [CartUserError], checkoutURL _: URL?) throws { + private func validateUserErrors(_ userErrors: [CartUserError]) throws { guard userErrors.isEmpty else { - // Always throw the actual CartUserError so the error handler can properly map it - throw userErrors.first! + // Throw a validation error that contains all user errors for comprehensive debugging + throw CartValidationError(userErrors: userErrors) } } + @discardableResult private func validateCart(_ cart: Cart?, requestName _: String) throws -> Cart { guard let cart else { throw GraphQLError.invalidResponse diff --git a/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI+Types.swift b/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI+Types.swift index 66b870a8d..780635c5f 100644 --- a/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI+Types.swift +++ b/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI+Types.swift @@ -418,6 +418,26 @@ extension StorefrontAPI { let field: [String]? } + /// Cart validation error that contains all user errors from a GraphQL response + struct CartValidationError: Error, CustomStringConvertible { + let userErrors: [CartUserError] + + var description: String { + if userErrors.count == 1 { + return userErrors[0].message + } else { + let errorMessages = userErrors.map { error in + guard let field = error.field, field.isEmpty == false else { + return error.message + } + + return error.message + " (field: \(field.joined(separator: ".")))" + } + return "\(userErrors.count) validation errors: " + errorMessages.joined(separator: "; ") + } + } + } + /// Cart error codes enum CartErrorCode: String, Codable { case addressFieldContainsEmojis = "ADDRESS_FIELD_CONTAINS_EMOJIS" diff --git a/Sources/ShopifyAcceleratedCheckouts/Wallets/AcceleratedCheckoutButtons.swift b/Sources/ShopifyAcceleratedCheckouts/Wallets/AcceleratedCheckoutButtons.swift index d02c81d13..882d98283 100644 --- a/Sources/ShopifyAcceleratedCheckouts/Wallets/AcceleratedCheckoutButtons.swift +++ b/Sources/ShopifyAcceleratedCheckouts/Wallets/AcceleratedCheckoutButtons.swift @@ -46,6 +46,7 @@ public struct AcceleratedCheckoutButtons: View { let identifier: CheckoutIdentifier var wallets: [Wallet] = [.shopPay, .applePay] var eventHandlers: EventHandlers = .init() + var checkoutDelegate: CheckoutDelegate? var cornerRadius: CGFloat? /// The Apple Pay button label style @@ -91,6 +92,7 @@ public struct AcceleratedCheckoutButtons: View { ApplePayButton( identifier: identifier, eventHandlers: eventHandlers, + checkoutDelegate: checkoutDelegate, cornerRadius: cornerRadius ) .label(applePayLabel) @@ -98,6 +100,7 @@ public struct AcceleratedCheckoutButtons: View { ShopPayButton( identifier: identifier, eventHandlers: eventHandlers, + checkoutDelegate: checkoutDelegate, cornerRadius: cornerRadius ) } @@ -165,129 +168,43 @@ extension AcceleratedCheckoutButtons { return newView } - /// Adds an action to perform when the checkout completes successfully. + /// Sets the checkout delegate for handling checkout flow events /// - /// Use this modifier to handle successful checkout events: + /// Use this modifier to provide a delegate for checkout completion, failure, and cancellation events: /// /// ```swift /// AcceleratedCheckoutButtons(cartID: cartId) - /// .onComplete { event in - /// // Navigate to success screen with order ID - /// showSuccessView(orderId: event.orderId) - /// } - /// ``` - /// - /// - Parameter action: The action to perform when checkout succeeds - /// - Returns: A view with the checkout success handler set - public func onComplete(_ action: @escaping (CheckoutCompletedEvent) -> Void) - -> AcceleratedCheckoutButtons - { - var newView = self - newView.eventHandlers.checkoutDidComplete = action - return newView - } - - /// Adds an action to perform when the checkout encounters an error. - /// - /// Use this modifier to handle checkout errors: - /// - /// ```swift - /// AcceleratedCheckoutButtons(cartID: cartId) - /// .onFail { error in - /// // Show error alert with details - /// showErrorAlert(error: error) - /// } - /// ``` - /// - /// - Parameter action: The action to perform when checkout fails - /// - Returns: A view with the checkout error handler set - public func onFail(_ action: @escaping (CheckoutError) -> Void) -> AcceleratedCheckoutButtons { - var newView = self - newView.eventHandlers.checkoutDidFail = action - return newView - } - - /// Adds an action to perform when the checkout is cancelled by the user. - /// - /// Use this modifier to handle checkout cancellation: - /// - /// ```swift - /// AcceleratedCheckoutButtons(cartID: cartId) - /// .onCancel { - /// // Reset checkout state - /// resetCheckoutState() - /// } + /// .checkout(delegate: MyCheckoutDelegate()) /// ``` /// - /// - Parameter action: The action to perform when checkout is cancelled - /// - Returns: A view with the checkout cancel handler set - public func onCancel(_ action: @escaping () -> Void) -> AcceleratedCheckoutButtons { + /// - Parameter delegate: The checkout delegate to handle checkout flow events + /// - Returns: A view with the checkout delegate set + public func checkout(delegate: CheckoutDelegate) -> AcceleratedCheckoutButtons { var newView = self - newView.eventHandlers.checkoutDidCancel = action + newView.checkoutDelegate = delegate return newView } - /// Adds an action to determine if checkout should recover from an error. + /// Adds an action to perform when validation or configuration errors occur /// - /// Use this modifier to handle error recovery decisions: + /// Use this modifier to handle validation errors and configuration issues: /// /// ```swift /// AcceleratedCheckoutButtons(cartID: cartId) - /// .onShouldRecoverFromError { error in - /// // Return true to attempt recovery, false to fail - /// return error.isRecoverable - /// } - /// ``` - /// - /// - Parameter action: The action to determine if recovery should be attempted - /// - Returns: A view with the error recovery handler set - public func onShouldRecoverFromError( - _ action: @escaping (CheckoutError) -> Bool - ) -> AcceleratedCheckoutButtons { - var newView = self - newView.eventHandlers.shouldRecoverFromError = action - return newView - } - - /// Adds an action to perform when a link is clicked during checkout. - /// - /// Use this modifier to handle link clicks: - /// - /// ```swift - /// AcceleratedCheckoutButtons(cartID: cartId) - /// .onClickLink { url in - /// // Handle external link - /// UIApplication.shared.open(url) - /// } - /// ``` - /// - /// - Parameter action: The action to perform when a link is clicked - /// - Returns: A view with the link click handler set - public func onClickLink(_ action: @escaping (URL) -> Void) -> AcceleratedCheckoutButtons { - var newView = self - newView.eventHandlers.checkoutDidClickLink = action - return newView - } - - /// Adds an action to perform when a web pixel event is emitted. - /// - /// Use this modifier to handle web pixel events: - /// - /// ```swift - /// AcceleratedCheckoutButtons(cartID: cartId) - /// .onWebPixelEvent { event in - /// // Track analytics event - /// Analytics.track(event) + /// .onError { error in + /// switch error { + /// case .validation(let validationError): + /// // Handle validation errors + /// print("Validation failed: \(validationError.description)") + /// } /// } /// ``` /// - /// - Parameter action: The action to perform when a pixel event is emitted - /// - Returns: A view with the web pixel event handler set - public func onWebPixelEvent(_ action: @escaping (PixelEvent) -> Void) - -> AcceleratedCheckoutButtons - { + /// - Parameter action: The action to perform when errors occur + /// - Returns: A view with the error handler set + public func onError(_ action: @escaping (AcceleratedCheckoutError) -> Void) -> AcceleratedCheckoutButtons { var newView = self - newView.eventHandlers.checkoutDidEmitWebPixelEvent = action + newView.eventHandlers.validationDidFail = action return newView } diff --git a/Sources/ShopifyAcceleratedCheckouts/Wallets/AcceleratedCheckoutError.swift b/Sources/ShopifyAcceleratedCheckouts/Wallets/AcceleratedCheckoutError.swift new file mode 100644 index 000000000..71a7a6315 --- /dev/null +++ b/Sources/ShopifyAcceleratedCheckouts/Wallets/AcceleratedCheckoutError.swift @@ -0,0 +1,119 @@ +/* + MIT License + + Copyright 2023 - Present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import ShopifyCheckoutSheetKit + +/// Public validation error that contains user-friendly error information +@available(iOS 16.0, *) +public struct ValidationError: Error, CustomStringConvertible { + /// Individual validation error details + public struct UserError { + /// Error message from the API + public let message: String + /// Field path that caused the error (e.g., ["shippingAddress", "countryCode"]) + public let field: [String]? + /// Error code identifier (e.g., "INVALID_COUNTRY_CODE") + public let code: String? + + public init(message: String, field: [String]? = nil, code: String? = nil) { + self.message = message + self.field = field + self.code = code + } + } + + /// All validation errors that occurred + public let userErrors: [UserError] + + /// Combined description of all errors + public var description: String { + userErrors.map(\.message).joined(separator: "; ") + } + + public init(userErrors: [UserError]) { + self.userErrors = userErrors + } + + /// Internal initializer to convert from StorefrontAPI type + internal init(from cartValidationError: StorefrontAPI.CartValidationError) { + userErrors = cartValidationError.userErrors.map { cartUserError in + UserError( + message: cartUserError.message, + field: cartUserError.field, + code: cartUserError.code?.rawValue + ) + } + } + + // MARK: - Utility methods + + /// All error messages as an array + public var messages: [String] { + userErrors.map(\.message) + } + + /// Check if contains a specific error code + public func hasErrorCode(_ code: String) -> Bool { + return userErrors.contains { $0.code == code } + } + + /// Get errors for a specific field path + public func errorsForField(_ fieldPath: [String]) -> [UserError] { + return userErrors.filter { $0.field == fieldPath } + } +} + +/// Error type for Accelerated Checkout validation operations +@available(iOS 16.0, *) +public enum AcceleratedCheckoutError: Error { + /// Cart validation failed - API correctly rejected input data + case validation(ValidationError) + + // MARK: - Convenience accessors + + /// Get validation error if this is a validation error + public var validationError: ValidationError? { + if case let .validation(error) = self { + return error + } + return nil + } + + // MARK: - Utility methods + + /// All validation error messages + public var validationMessages: [String] { + validationError?.messages ?? [] + } + + /// Check if this is a specific type of validation error + public func hasValidationErrorCode(_ code: String) -> Bool { + return validationError?.hasErrorCode(code) ?? false + } + + /// Check if this represents validation issues + public var isValidationError: Bool { + if case .validation = self { return true } + return false + } +} diff --git a/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayButton.swift b/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayButton.swift index b446699b0..d421638f7 100644 --- a/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayButton.swift +++ b/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayButton.swift @@ -46,6 +46,9 @@ struct ApplePayButton: View { /// The event handlers for checkout events private let eventHandlers: EventHandlers + /// The checkout delegate for handling checkout flow + private let checkoutDelegate: CheckoutDelegate? + /// The Apple Pay button label style private var label: PayWithApplePayButtonLabel = .plain @@ -55,10 +58,12 @@ struct ApplePayButton: View { public init( identifier: CheckoutIdentifier, eventHandlers: EventHandlers = EventHandlers(), + checkoutDelegate: CheckoutDelegate? = nil, cornerRadius: CGFloat? ) { self.identifier = identifier.parse() self.eventHandlers = eventHandlers + self.checkoutDelegate = checkoutDelegate self.cornerRadius = cornerRadius } @@ -75,6 +80,7 @@ struct ApplePayButton: View { shopSettings: shopSettings ), eventHandlers: eventHandlers, + checkoutDelegate: checkoutDelegate, cornerRadius: cornerRadius ) } @@ -112,21 +118,18 @@ struct Internal_ApplePayButton: View { label: PayWithApplePayButtonLabel, configuration: ApplePayConfigurationWrapper, eventHandlers: EventHandlers = EventHandlers(), + checkoutDelegate: CheckoutDelegate? = nil, cornerRadius: CGFloat? ) { controller = ApplePayViewController( identifier: identifier, - configuration: configuration + configuration: configuration, + checkoutDelegate: checkoutDelegate ) self.label = label self.cornerRadius = cornerRadius Task { @MainActor [controller] in - controller.onCheckoutComplete = eventHandlers.checkoutDidComplete - controller.onCheckoutFail = eventHandlers.checkoutDidFail - controller.onCheckoutCancel = eventHandlers.checkoutDidCancel - controller.onShouldRecoverFromError = eventHandlers.shouldRecoverFromError - controller.onCheckoutClickLink = eventHandlers.checkoutDidClickLink - controller.onCheckoutWebPixelEvent = eventHandlers.checkoutDidEmitWebPixelEvent + controller.onValidationFail = eventHandlers.validationDidFail } } diff --git a/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift b/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift index b5361c5dc..bbc4a7e4a 100644 --- a/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift +++ b/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift @@ -47,86 +47,23 @@ class ApplePayViewController: PayController, ObservableObject { var cart: StorefrontAPI.Types.Cart? - // MARK: - Callback Properties - - /// Callback invoked when the checkout process completes successfully. - /// This closure is called on the main thread after a successful payment. - /// - /// Example usage: - /// ```swift - /// applePayViewController.onCheckoutComplete = { [weak self] event in - /// self?.presentSuccessScreen() - /// self?.logAnalyticsEvent(.checkoutCompleted, orderId: event.orderId) - /// } - /// ``` - @MainActor - public var onCheckoutComplete: ((CheckoutCompletedEvent) -> Void)? - - /// Callback invoked when an error occurs during the checkout process. - /// This closure is called on the main thread when the payment fails. - /// - /// Example usage: - /// ```swift - /// applePayViewController.onCheckoutFail = { [weak self] error in - /// self?.showErrorAlert(for: error) - /// self?.logAnalyticsEvent(.checkoutFailed, error: error) - /// } - /// ``` - @MainActor - public var onCheckoutFail: ((CheckoutError) -> Void)? - - /// Callback invoked when the checkout process is cancelled by the user. - /// This closure is called on the main thread when the user dismisses the checkout. - /// - /// Example usage: - /// ```swift - /// applePayViewController.onCheckoutCancel = { [weak self] in - /// self?.resetCheckoutState() - /// self?.logAnalyticsEvent(.checkoutCancelled) - /// } - /// ``` - @MainActor - public var onCheckoutCancel: (() -> Void)? - - /// Callback invoked to determine if checkout should recover from an error. - /// This closure is called on the main thread when an error occurs. - /// Return true to attempt recovery, false to fail immediately. - /// - /// Example usage: - /// ```swift - /// applePayViewController.onShouldRecoverFromError = { [weak self] error in - /// // Custom error recovery logic - /// return error.isRecoverable - /// } - /// ``` - @MainActor - public var onShouldRecoverFromError: ((CheckoutError) -> Bool)? + /// The checkout delegate for handling checkout flow events + private weak var checkoutDelegate: CheckoutDelegate? - /// Callback invoked when the user clicks a link during checkout. - /// This closure is called on the main thread when a link is clicked. - /// - /// Example usage: - /// ```swift - /// applePayViewController.onCheckoutClickLink = { [weak self] url in - /// self?.handleExternalLink(url) - /// self?.logAnalyticsEvent(.linkClicked, url: url) - /// } - /// ``` - @MainActor - public var onCheckoutClickLink: ((URL) -> Void)? + // MARK: - Callback Properties - /// Callback invoked when a web pixel event is emitted during checkout. - /// This closure is called on the main thread when pixel events occur. + /// Callback invoked when cart validation fails. + /// This closure is called on the main thread when the input data is rejected by the API. /// /// Example usage: /// ```swift - /// applePayViewController.onCheckoutWebPixelEvent = { [weak self] event in - /// self?.trackPixelEvent(event) - /// self?.logAnalyticsEvent(.pixelFired, event: event) + /// applePayViewController.onValidationFail = { [weak self] validationError in + /// // Handle validation error + /// print("Validation failed: \(validationError)") /// } /// ``` @MainActor - public var onCheckoutWebPixelEvent: ((PixelEvent) -> Void)? + public var onValidationFail: ((AcceleratedCheckoutError) -> Void)? /// Initialization workaround for passing self to ApplePayAuthorizationDelegate private var __authorizationDelegate: ApplePayAuthorizationDelegate! @@ -136,10 +73,12 @@ class ApplePayViewController: PayController, ObservableObject { init( identifier: CheckoutIdentifier, - configuration: ApplePayConfigurationWrapper + configuration: ApplePayConfigurationWrapper, + checkoutDelegate: CheckoutDelegate? = nil ) { self.configuration = configuration self.identifier = identifier.parse() + self.checkoutDelegate = checkoutDelegate storefront = StorefrontAPI( storefrontDomain: configuration.common.storefrontDomain, storefrontAccessToken: configuration.common.storefrontAccessToken @@ -184,9 +123,16 @@ class ApplePayViewController: PayController, ObservableObject { } } catch let error as StorefrontAPI.Errors { return try await handleStorefrontError(error) + } catch let validationError as StorefrontAPI.CartValidationError { + // Direct path for validation errors - never becomes CheckoutError.sdkError + let publicValidationError = ValidationError(from: validationError) + let acceleratedError = AcceleratedCheckoutError.validation(publicValidationError) + await onValidationFail?(acceleratedError) + try? await authorizationDelegate.transition(to: .terminalError(error: validationError)) + throw validationError } catch { if let checkoutError = error as? CheckoutError { - await onCheckoutFail?(checkoutError) + checkoutDelegate?.checkoutDidFail(error: checkoutError) } try? await authorizationDelegate.transition(to: .terminalError(error: error)) throw error @@ -248,14 +194,14 @@ class ApplePayViewController: PayController, ObservableObject { extension ApplePayViewController: CheckoutDelegate { func checkoutDidComplete(event: CheckoutCompletedEvent) { Task { @MainActor in - self.onCheckoutComplete?(event) + self.checkoutDelegate?.checkoutDidComplete(event: event) try await authorizationDelegate.transition(to: .completed) } } func checkoutDidFail(error: CheckoutError) { Task { @MainActor in - self.onCheckoutFail?(error) + self.checkoutDelegate?.checkoutDidFail(error: error) } } @@ -263,24 +209,24 @@ extension ApplePayViewController: CheckoutDelegate { Task { @MainActor in /// x right button on CSK doesn't dismiss automatically checkoutViewController?.dismiss(animated: true) - self.onCheckoutCancel?() + self.checkoutDelegate?.checkoutDidCancel() try await authorizationDelegate.transition(to: .completed) } } - @MainActor func shouldRecoverFromError(error: CheckoutError) -> Bool { - return onShouldRecoverFromError?(error) ?? false + func shouldRecoverFromError(error: CheckoutError) -> Bool { + return checkoutDelegate?.shouldRecoverFromError(error: error) ?? false } func checkoutDidClickLink(url: URL) { Task { @MainActor in - self.onCheckoutClickLink?(url) + self.checkoutDelegate?.checkoutDidClickLink(url: url) } } func checkoutDidEmitWebPixelEvent(event: PixelEvent) { Task { @MainActor in - self.onCheckoutWebPixelEvent?(event) + self.checkoutDelegate?.checkoutDidEmitWebPixelEvent(event: event) } } } diff --git a/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ErrorHandler/ErrorHandler.swift b/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ErrorHandler/ErrorHandler.swift index 811b68cdd..e66ced649 100644 --- a/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ErrorHandler/ErrorHandler.swift +++ b/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ErrorHandler/ErrorHandler.swift @@ -71,7 +71,6 @@ class ErrorHandler { switch action { case .showError: - // We want to surface messages for all errors, not just the first one let allErrors = combinedErrors(actions: sortedActions) return .showError(errors: allErrors) default: @@ -89,33 +88,41 @@ class ErrorHandler { static func map(error: Error, cart: StorefrontAPI.Cart?) -> PaymentSheetAction? { let shippingCountry = getShippingCountry(cart: cart) switch error { + case let cartValidationError as StorefrontAPI.CartValidationError: + return ErrorHandler.map(errors: cartValidationError.userErrors, shippingCountry: shippingCountry, cart: nil) case let cartUserError as StorefrontAPI.CartUserError: - // Handle StorefrontAPI errors directly - we don't have the cart here but the checkout URL - // is already captured in the delegate return ErrorHandler.map(errors: [cartUserError], shippingCountry: shippingCountry, cart: nil) case let apiError as StorefrontAPI.Errors: - switch apiError { - case let .response(_, _, payload): - switch payload { - case let .cartSubmitForCompletion(submitPayload): - return ErrorHandler.map(payload: submitPayload, shippingCountry: shippingCountry) - case let .cartPrepareForCompletion(preparePayload): - return ErrorHandler.map(payload: preparePayload) - } - case let .userError(userErrors, cart): - return ErrorHandler.map(errors: userErrors, shippingCountry: shippingCountry, cart: cart) - case .currencyChanged: - return .interrupt(reason: .currencyChanged, checkoutURL: cart?.checkoutUrl.url) - case let .warning(type, cart): - return ErrorHandler.map(warningType: type, cart: cart) - default: - return nil - } + return mapApiError(apiError, shippingCountry: shippingCountry, cart: cart) + default: + return nil + } + } + + private static func mapApiError(_ apiError: StorefrontAPI.Errors, shippingCountry: String?, cart: StorefrontAPI.Cart?) -> PaymentSheetAction? { + switch apiError { + case let .response(_, _, payload): + return mapResponsePayload(payload, shippingCountry: shippingCountry) + case let .userError(userErrors, cart): + return ErrorHandler.map(errors: userErrors, shippingCountry: shippingCountry, cart: cart) + case .currencyChanged: + return .interrupt(reason: .currencyChanged, checkoutURL: cart?.checkoutUrl.url) + case let .warning(type, cart): + return ErrorHandler.map(warningType: type, cart: cart) default: return nil } } + private static func mapResponsePayload(_ payload: StorefrontAPI.CartApiPayload, shippingCountry: String?) -> PaymentSheetAction? { + switch payload { + case let .cartSubmitForCompletion(submitPayload): + return ErrorHandler.map(payload: submitPayload, shippingCountry: shippingCountry) + case let .cartPrepareForCompletion(preparePayload): + return ErrorHandler.map(payload: preparePayload) + } + } + private static func actionsSortedByPrecedence(actions: [PaymentSheetAction]) -> [PaymentSheetAction] { return actions.sorted { action1, action2 in let index1 = getPaymentSheetActionPrecedence(action: action1) diff --git a/Sources/ShopifyAcceleratedCheckouts/Wallets/ShopPay/ShopPayButton.swift b/Sources/ShopifyAcceleratedCheckouts/Wallets/ShopPay/ShopPayButton.swift index 050fec6f6..b458a27e6 100644 --- a/Sources/ShopifyAcceleratedCheckouts/Wallets/ShopPay/ShopPayButton.swift +++ b/Sources/ShopifyAcceleratedCheckouts/Wallets/ShopPay/ShopPayButton.swift @@ -30,15 +30,18 @@ internal struct ShopPayButton: View { let identifier: CheckoutIdentifier let eventHandlers: EventHandlers + let checkoutDelegate: CheckoutDelegate? let cornerRadius: CGFloat? init( identifier: CheckoutIdentifier, eventHandlers: EventHandlers = EventHandlers(), + checkoutDelegate: CheckoutDelegate? = nil, cornerRadius: CGFloat? ) { self.identifier = identifier.parse() self.eventHandlers = eventHandlers + self.checkoutDelegate = checkoutDelegate self.cornerRadius = cornerRadius } @@ -51,6 +54,7 @@ internal struct ShopPayButton: View { identifier: identifier, configuration: configuration, eventHandlers: eventHandlers, + checkoutDelegate: checkoutDelegate, cornerRadius: cornerRadius ) } @@ -68,12 +72,14 @@ internal struct Internal_ShopPayButton: View { identifier: CheckoutIdentifier, configuration: ShopifyAcceleratedCheckouts.Configuration, eventHandlers: EventHandlers = EventHandlers(), + checkoutDelegate: CheckoutDelegate? = nil, cornerRadius: CGFloat? ) { controller = ShopPayViewController( identifier: identifier, configuration: configuration, - eventHandlers: eventHandlers + eventHandlers: eventHandlers, + checkoutDelegate: checkoutDelegate ) self.cornerRadius = cornerRadius } diff --git a/Sources/ShopifyAcceleratedCheckouts/Wallets/ShopPay/ShopPayViewController.swift b/Sources/ShopifyAcceleratedCheckouts/Wallets/ShopPay/ShopPayViewController.swift index 1a99ebc2f..79618254a 100644 --- a/Sources/ShopifyAcceleratedCheckouts/Wallets/ShopPay/ShopPayViewController.swift +++ b/Sources/ShopifyAcceleratedCheckouts/Wallets/ShopPay/ShopPayViewController.swift @@ -31,15 +31,18 @@ class ShopPayViewController: ObservableObject { var identifier: CheckoutIdentifier var checkoutViewController: CheckoutViewController? var eventHandlers: EventHandlers + private weak var checkoutDelegate: CheckoutDelegate? init( identifier: CheckoutIdentifier, configuration: ShopifyAcceleratedCheckouts.Configuration, - eventHandlers: EventHandlers = EventHandlers() + eventHandlers: EventHandlers = EventHandlers(), + checkoutDelegate: CheckoutDelegate? = nil ) { self.configuration = configuration self.identifier = identifier.parse() self.eventHandlers = eventHandlers + self.checkoutDelegate = checkoutDelegate storefront = StorefrontAPI( storefrontDomain: configuration.storefrontDomain, storefrontAccessToken: configuration.storefrontAccessToken @@ -109,29 +112,29 @@ class ShopPayViewController: ObservableObject { @available(iOS 16.0, *) extension ShopPayViewController: CheckoutDelegate { func checkoutDidComplete(event: CheckoutCompletedEvent) { - eventHandlers.checkoutDidComplete?(event) + checkoutDelegate?.checkoutDidComplete(event: event) } func checkoutDidFail(error: CheckoutError) { checkoutViewController?.dismiss(animated: true) - eventHandlers.checkoutDidFail?(error) + checkoutDelegate?.checkoutDidFail(error: error) } func checkoutDidCancel() { /// x right button on CSK doesn't dismiss automatically checkoutViewController?.dismiss(animated: true) - eventHandlers.checkoutDidCancel?() + checkoutDelegate?.checkoutDidCancel() } func shouldRecoverFromError(error: CheckoutError) -> Bool { - return eventHandlers.shouldRecoverFromError?(error) ?? false + return checkoutDelegate?.shouldRecoverFromError(error: error) ?? false } func checkoutDidClickLink(url: URL) { - eventHandlers.checkoutDidClickLink?(url) + checkoutDelegate?.checkoutDidClickLink(url: url) } func checkoutDidEmitWebPixelEvent(event: PixelEvent) { - eventHandlers.checkoutDidEmitWebPixelEvent?(event) + checkoutDelegate?.checkoutDidEmitWebPixelEvent(event: event) } } diff --git a/Sources/ShopifyAcceleratedCheckouts/Wallets/Wallet.swift b/Sources/ShopifyAcceleratedCheckouts/Wallets/Wallet.swift index 693a37d2b..f475d9113 100644 --- a/Sources/ShopifyAcceleratedCheckouts/Wallets/Wallet.swift +++ b/Sources/ShopifyAcceleratedCheckouts/Wallets/Wallet.swift @@ -31,30 +31,16 @@ public enum Wallet { } /// Event handlers for wallet buttons +@available(iOS 16.0, *) public struct EventHandlers { - public var checkoutDidComplete: ((CheckoutCompletedEvent) -> Void)? - public var checkoutDidFail: ((CheckoutError) -> Void)? - public var checkoutDidCancel: (() -> Void)? - public var shouldRecoverFromError: ((CheckoutError) -> Bool)? - public var checkoutDidClickLink: ((URL) -> Void)? - public var checkoutDidEmitWebPixelEvent: ((PixelEvent) -> Void)? + public var validationDidFail: ((AcceleratedCheckoutError) -> Void)? public var renderStateDidChange: ((RenderState) -> Void)? public init( - checkoutDidComplete: ((CheckoutCompletedEvent) -> Void)? = nil, - checkoutDidFail: ((CheckoutError) -> Void)? = nil, - checkoutDidCancel: (() -> Void)? = nil, - shouldRecoverFromError: ((CheckoutError) -> Bool)? = nil, - checkoutDidClickLink: ((URL) -> Void)? = nil, - checkoutDidEmitWebPixelEvent: ((PixelEvent) -> Void)? = nil, + validationDidFail: ((AcceleratedCheckoutError) -> Void)? = nil, renderStateDidChange: ((RenderState) -> Void)? = nil ) { - self.checkoutDidComplete = checkoutDidComplete - self.checkoutDidFail = checkoutDidFail - self.checkoutDidCancel = checkoutDidCancel - self.shouldRecoverFromError = shouldRecoverFromError - self.checkoutDidClickLink = checkoutDidClickLink - self.checkoutDidEmitWebPixelEvent = checkoutDidEmitWebPixelEvent + self.validationDidFail = validationDidFail self.renderStateDidChange = renderStateDidChange } } diff --git a/Sources/ShopifyCheckoutSheetKit/CheckoutCompletedEvent.swift b/Sources/ShopifyCheckoutSheetKit/CheckoutCompletedEvent.swift index 757188bbf..2c06756b4 100644 --- a/Sources/ShopifyCheckoutSheetKit/CheckoutCompletedEvent.swift +++ b/Sources/ShopifyCheckoutSheetKit/CheckoutCompletedEvent.swift @@ -115,7 +115,7 @@ extension CheckoutCompletedEvent { } } -func createEmptyCheckoutCompletedEvent(id: String? = "") -> CheckoutCompletedEvent { +package func createEmptyCheckoutCompletedEvent(id: String? = "") -> CheckoutCompletedEvent { return CheckoutCompletedEvent( orderDetails: CheckoutCompletedEvent.OrderDetails( billingAddress: CheckoutCompletedEvent.Address( diff --git a/Tests/ShopifyAcceleratedCheckoutsTests/Internal/StorefrontAPI/StorefrontAPIMutationsTests.swift b/Tests/ShopifyAcceleratedCheckoutsTests/Internal/StorefrontAPI/StorefrontAPIMutationsTests.swift index d0df9ea16..a00fd74fb 100644 --- a/Tests/ShopifyAcceleratedCheckoutsTests/Internal/StorefrontAPI/StorefrontAPIMutationsTests.swift +++ b/Tests/ShopifyAcceleratedCheckoutsTests/Internal/StorefrontAPI/StorefrontAPIMutationsTests.swift @@ -281,12 +281,19 @@ final class StorefrontAPIMutationsTests: XCTestCase { """ mockJSONResponse(json) - await XCTAssertThrowsGraphQLError( - try await storefrontAPI - .cartCreate(with: [GraphQLScalars.ID("gid://shopify/ProductVariant/1")]), - { if case .invalidResponse = $0 { return true } else { return false } }, - "Expected GraphQLError.invalidResponse to be thrown" - ) + do { + _ = try await storefrontAPI + .cartCreate(with: [GraphQLScalars.ID("gid://shopify/ProductVariant/1")]) + XCTFail("Expected error to be thrown") + } catch let validationError as StorefrontAPI.CartValidationError { + // Expected - CartValidationError is thrown with all user errors + XCTAssertEqual(validationError.userErrors.count, 1) + XCTAssertEqual(validationError.userErrors[0].code, .notEnoughStock) + XCTAssertEqual(validationError.userErrors[0].message, "Product variant is out of stock") + XCTAssertEqual(validationError.userErrors[0].field, ["lines"]) + } catch { + XCTFail("Expected CartValidationError but got: \(error)") + } } func testCartCreateRequestValidation() async throws { @@ -459,14 +466,21 @@ final class StorefrontAPIMutationsTests: XCTestCase { """ mockJSONResponse(json) - await XCTAssertThrowsGraphQLError( - try await storefrontAPI.cartBuyerIdentityUpdate( + do { + _ = try await storefrontAPI.cartBuyerIdentityUpdate( id: GraphQLScalars.ID("gid://shopify/Cart/123"), input: .init(email: "invalid-email", phoneNumber: "") - ), - { if case .invalidResponse = $0 { return true } else { return false } }, - "Expected GraphQLError.invalidResponse to be thrown" - ) + ) + XCTFail("Expected error to be thrown") + } catch let validationError as StorefrontAPI.CartValidationError { + // Expected - CartValidationError is thrown with all user errors + XCTAssertEqual(validationError.userErrors.count, 1) + XCTAssertEqual(validationError.userErrors[0].code, .invalid) + XCTAssertEqual(validationError.userErrors[0].message, "Email is invalid") + XCTAssertEqual(validationError.userErrors[0].field, ["buyerIdentity", "email"]) + } catch { + XCTFail("Expected CartValidationError but got: \(error)") + } } func testCartBuyerIdentityUpdateWithAccessToken() async throws { @@ -597,14 +611,21 @@ final class StorefrontAPIMutationsTests: XCTestCase { """ mockJSONResponse(json) - await XCTAssertThrowsGraphQLError( - try await storefrontAPI.cartDeliveryAddressesRemove( + do { + _ = try await storefrontAPI.cartDeliveryAddressesRemove( id: GraphQLScalars.ID("gid://shopify/Cart/123"), addressId: GraphQLScalars.ID("gid://shopify/CartSelectableAddress/999") - ), - { if case .invalidResponse = $0 { return true } else { return false } }, - "Expected GraphQLError.invalidResponse to be thrown" - ) + ) + XCTFail("Expected error to be thrown") + } catch let validationError as StorefrontAPI.CartValidationError { + // Expected - CartValidationError is thrown with all user errors + XCTAssertEqual(validationError.userErrors.count, 1) + XCTAssertEqual(validationError.userErrors[0].code, .unknownValue) + XCTAssertEqual(validationError.userErrors[0].message, "Address not found") + XCTAssertEqual(validationError.userErrors[0].field, ["addressIds"]) + } catch { + XCTFail("Expected CartValidationError but got: \(error)") + } } func testCartDeliveryAddressesRemoveRequestValidation() async throws { @@ -753,17 +774,14 @@ final class StorefrontAPIMutationsTests: XCTestCase { validate: true ) XCTFail("Expected error to be thrown") + } catch let validationError as StorefrontAPI.CartValidationError { + // Expected - CartValidationError is thrown with all user errors + XCTAssertEqual(validationError.userErrors.count, 1) + XCTAssertEqual(validationError.userErrors[0].code, .invalid) + XCTAssertEqual(validationError.userErrors[0].message, "Zip code is invalid for country") + XCTAssertEqual(validationError.userErrors[0].field, ["addresses", "0", "address", "zip"]) } catch { - // Verify error type - XCTAssertTrue( - error is GraphQLError, - "Unexpected error type: \(type(of: error))" - ) - - guard case .invalidResponse = error as? GraphQLError else { - XCTFail("Expected GraphQLError.invalidResponse but got: \(error)") - return - } + XCTFail("Expected CartValidationError but got: \(error)") } } @@ -1514,7 +1532,7 @@ final class StorefrontAPIMutationsTests: XCTestCase { } } - func testUserErrorWithCheckoutURLThrowsCartUserError() async { + func testUserErrorWithCheckoutURLThrowsCartValidationError() async { let json = """ { "data": { @@ -1549,17 +1567,18 @@ final class StorefrontAPIMutationsTests: XCTestCase { do { _ = try await storefrontAPI.cartCreate() XCTFail("Expected error to be thrown") - } catch let cartError as StorefrontAPI.CartUserError { - // Expected - CartUserError is thrown directly - XCTAssertEqual(cartError.code, .tooManyLineItems) - XCTAssertEqual(cartError.message, "Cart limit exceeded") - XCTAssertEqual(cartError.field, ["input"]) + } catch let validationError as StorefrontAPI.CartValidationError { + // Expected - CartValidationError is thrown with all user errors + XCTAssertEqual(validationError.userErrors.count, 1) + XCTAssertEqual(validationError.userErrors[0].code, .tooManyLineItems) + XCTAssertEqual(validationError.userErrors[0].message, "Cart limit exceeded") + XCTAssertEqual(validationError.userErrors[0].field, ["input"]) } catch { - XCTFail("Expected CartUserError but got: \(error)") + XCTFail("Expected CartValidationError but got: \(error)") } } - func testUserErrorWithoutCheckoutURLThrowsCartUserError() async { + func testUserErrorWithoutCheckoutURLThrowsCartValidationError() async { let json = """ { "data": { @@ -1597,13 +1616,70 @@ final class StorefrontAPIMutationsTests: XCTestCase { input: .init(email: "bad-email", phoneNumber: "") ) XCTFail("Expected error to be thrown") - } catch let cartError as StorefrontAPI.CartUserError { - // Expected - CartUserError is thrown directly - XCTAssertEqual(cartError.code, .invalid) - XCTAssertEqual(cartError.message, "Email format is invalid") - XCTAssertEqual(cartError.field, ["buyerIdentity", "email"]) + } catch let validationError as StorefrontAPI.CartValidationError { + // Expected - CartValidationError is thrown with all user errors + XCTAssertEqual(validationError.userErrors.count, 1) + XCTAssertEqual(validationError.userErrors[0].code, .invalid) + XCTAssertEqual(validationError.userErrors[0].message, "Email format is invalid") + XCTAssertEqual(validationError.userErrors[0].field, ["buyerIdentity", "email"]) + } catch { + XCTFail("Expected CartValidationError but got: \(error)") + } + } + + func testMultipleUserErrors() async { + let json = """ + { + "data": { + "cartCreate": { + "cart": null, + "userErrors": [ + { + "field": ["lines"], + "message": "Product variant is out of stock", + "code": "NOT_ENOUGH_STOCK" + }, + { + "field": ["input", "email"], + "message": "Email is invalid", + "code": "INVALID" + }, + { + "field": ["input", "address"], + "message": "Address is required", + "code": "REQUIRED" + } + ] + } + } + } + """ + mockJSONResponse(json) + + do { + _ = try await storefrontAPI.cartCreate() + XCTFail("Expected error to be thrown") + } catch let validationError as StorefrontAPI.CartValidationError { + // Expected - CartValidationError contains all user errors + XCTAssertEqual(validationError.userErrors.count, 3) + + // Verify all errors are accessible + XCTAssertEqual(validationError.userErrors[0].code, .notEnoughStock) + XCTAssertEqual(validationError.userErrors[0].message, "Product variant is out of stock") + XCTAssertEqual(validationError.userErrors[0].field, ["lines"]) + XCTAssertEqual(validationError.userErrors[1].code, .invalid) + XCTAssertEqual(validationError.userErrors[1].message, "Email is invalid") + XCTAssertEqual(validationError.userErrors[2].code, .unknownValue) // REQUIRED maps to unknown + XCTAssertEqual(validationError.userErrors[2].message, "Address is required") + + // Verify description includes all errors + let description = validationError.description + XCTAssertTrue(description.contains("3 validation errors")) + XCTAssertTrue(description.contains("Product variant is out of stock")) + XCTAssertTrue(description.contains("Email is invalid")) + XCTAssertTrue(description.contains("Address is required")) } catch { - XCTFail("Expected CartUserError but got: \(error)") + XCTFail("Expected CartValidationError but got: \(error)") } } diff --git a/Tests/ShopifyAcceleratedCheckoutsTests/TestHelpers.swift b/Tests/ShopifyAcceleratedCheckoutsTests/TestHelpers.swift index 4718bd8e5..428869f24 100644 --- a/Tests/ShopifyAcceleratedCheckoutsTests/TestHelpers.swift +++ b/Tests/ShopifyAcceleratedCheckoutsTests/TestHelpers.swift @@ -23,6 +23,8 @@ import Foundation @testable import ShopifyAcceleratedCheckouts +@testable import ShopifyCheckoutSheetKit +import XCTest // MARK: - Configuration Helpers @@ -189,3 +191,121 @@ extension StorefrontAPI.Cart { ) } } + +// MARK: - Test Delegate Helper + +@available(iOS 17.0, *) +class TestCheckoutDelegate: CheckoutDelegate { + var completeCallbackInvoked = false + var failCallbackInvoked = false + var cancelCallbackInvoked = false + var linkCallbackInvoked = false + var webPixelCallbackInvoked = false + var errorRecoveryAsked = false + + var completeCount = 0 + var failCount = 0 + var cancelCount = 0 + var linkCount = 0 + var webPixelCount = 0 + + var receivedURL: URL? + var receivedEvent: PixelEvent? + var receivedURLs: [URL] = [] + var recoveryDecision = true + + var expectation: XCTestExpectation? + var completeExpectations: [XCTestExpectation] = [] + var failExpectations: [XCTestExpectation] = [] + var cancelExpectations: [XCTestExpectation] = [] + var expectations: [XCTestExpectation] = [] + var currentIndex = 0 + + func reset() { + completeCallbackInvoked = false + failCallbackInvoked = false + cancelCallbackInvoked = false + linkCallbackInvoked = false + webPixelCallbackInvoked = false + errorRecoveryAsked = false + + completeCount = 0 + failCount = 0 + cancelCount = 0 + linkCount = 0 + webPixelCount = 0 + + receivedURL = nil + receivedEvent = nil + receivedURLs = [] + recoveryDecision = true + + expectation = nil + completeExpectations = [] + failExpectations = [] + cancelExpectations = [] + expectations = [] + currentIndex = 0 + } + + func checkoutDidComplete(event _: CheckoutCompletedEvent) { + completeCallbackInvoked = true + completeCount += 1 + + expectation?.fulfill() + + if completeCount <= completeExpectations.count { + completeExpectations[completeCount - 1].fulfill() + } + } + + func checkoutDidFail(error _: CheckoutError) { + failCallbackInvoked = true + failCount += 1 + + expectation?.fulfill() + + if failCount <= failExpectations.count { + failExpectations[failCount - 1].fulfill() + } + } + + func checkoutDidCancel() { + cancelCallbackInvoked = true + cancelCount += 1 + + expectation?.fulfill() + + if cancelCount <= cancelExpectations.count { + cancelExpectations[cancelCount - 1].fulfill() + } + } + + func checkoutDidClickLink(url: URL) { + linkCallbackInvoked = true + linkCount += 1 + receivedURL = url + receivedURLs.append(url) + + expectation?.fulfill() + + if currentIndex < expectations.count { + expectations[currentIndex].fulfill() + currentIndex += 1 + } + } + + func checkoutDidEmitWebPixelEvent(event: PixelEvent) { + webPixelCallbackInvoked = true + webPixelCount += 1 + receivedEvent = event + + expectation?.fulfill() + } + + func shouldRecoverFromError(error _: CheckoutError) -> Bool { + errorRecoveryAsked = true + expectation?.fulfill() + return recoveryDecision + } +} diff --git a/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayCallbackTests.swift b/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayCallbackTests.swift index 42ee5fafa..2de94b849 100644 --- a/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayCallbackTests.swift +++ b/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayCallbackTests.swift @@ -32,9 +32,7 @@ final class ApplePayCallbackTests: XCTestCase { var viewController: ApplePayViewController! var mockConfiguration: ApplePayConfigurationWrapper! var mockIdentifier: CheckoutIdentifier! - var successExpectation: XCTestExpectation! - var errorExpectation: XCTestExpectation! - var cancelExpectation: XCTestExpectation! + var testDelegate: TestCheckoutDelegate! // MARK: - Setup @@ -69,334 +67,254 @@ final class ApplePayCallbackTests: XCTestCase { mockIdentifier = .cart(cartID: "gid://Shopify/Cart/test-cart-id") - // Create SUT - viewController = ApplePayViewController( - identifier: mockIdentifier, - configuration: mockConfiguration - ) + testDelegate = TestCheckoutDelegate() } override func tearDown() { viewController = nil mockConfiguration = nil mockIdentifier = nil - successExpectation = nil - errorExpectation = nil - cancelExpectation = nil + testDelegate = nil super.tearDown() } - // MARK: - Success Callback Tests - - func testSuccessCallbackInvoked() async { - successExpectation = expectation(description: "Success callback should be invoked") - let callbackInvokedExpectation = expectation(description: "Callback invoked") - - await MainActor.run { - viewController.onCheckoutComplete = { [weak self] _ in - callbackInvokedExpectation.fulfill() - self?.successExpectation.fulfill() - } - } - - await MainActor.run { - let mockEvent = createEmptyCheckoutCompletedEvent(id: "test-order-123") - viewController.onCheckoutComplete?(mockEvent) - } - - await fulfillment(of: [successExpectation, callbackInvokedExpectation], timeout: 1.0) - } - - func testSuccessCallbackNotInvokedWhenNil() async { - await MainActor.run { - XCTAssertNil(viewController.onCheckoutComplete) - } - - await MainActor.run { - let mockEvent = createEmptyCheckoutCompletedEvent(id: "test-order-123") - viewController.onCheckoutComplete?(mockEvent) // Should not crash - } - - // Wait a moment to ensure no crash occurs - try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds - XCTAssertTrue(true, "Should not crash when callback is nil") - } - - // MARK: - Error Callback Tests - - func testErrorCallbackInvoked() async { - errorExpectation = expectation(description: "Error callback should be invoked") - let callbackInvokedExpectation = expectation(description: "Error callback invoked") + // MARK: - CheckoutDelegate Tests - await MainActor.run { - viewController.onCheckoutFail = { [weak self] _ in - callbackInvokedExpectation.fulfill() - self?.errorExpectation.fulfill() - } - } - - await MainActor.run { - let mockError = CheckoutError.sdkError(underlying: NSError(domain: "TestError", code: 0, userInfo: nil), recoverable: false) - viewController.onCheckoutFail?(mockError) - } - - await fulfillment(of: [errorExpectation, callbackInvokedExpectation], timeout: 1.0) - } + @MainActor + func testCheckoutDidComplete_invokesDelegateMethod() async { + let expectation = XCTestExpectation(description: "Complete delegate method should be invoked") + testDelegate.expectation = expectation - func testErrorCallbackNotInvokedWhenNil() async { - await MainActor.run { - XCTAssertNil(viewController.onCheckoutFail) - } + // Create view controller with delegate + viewController = ApplePayViewController( + identifier: mockIdentifier, + configuration: mockConfiguration, + checkoutDelegate: testDelegate + ) - await MainActor.run { - let mockError = CheckoutError.sdkError(underlying: NSError(domain: "TestError", code: 0, userInfo: nil), recoverable: false) - viewController.onCheckoutFail?(mockError) // Should not crash - } + let completedEvent = createEmptyCheckoutCompletedEvent(id: "test-order") + viewController.checkoutDidComplete(event: completedEvent) - try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds - XCTAssertTrue(true, "Should not crash when callback is nil") + await fulfillment(of: [expectation], timeout: 1.0) + XCTAssertTrue(testDelegate.completeCallbackInvoked, "Complete delegate method should be invoked") } - // MARK: - Cancel Callback Tests - - func testCancelCallbackInvoked() async { - cancelExpectation = expectation(description: "Cancel callback should be invoked") - let callbackInvokedExpectation = expectation(description: "Cancel callback invoked") - - await MainActor.run { - viewController.onCheckoutCancel = { [weak self] in - callbackInvokedExpectation.fulfill() - self?.cancelExpectation.fulfill() - } - } - - await MainActor.run { - viewController.onCheckoutCancel?() - } - - await fulfillment(of: [cancelExpectation, callbackInvokedExpectation], timeout: 1.0) - } + @MainActor + func testCheckoutDidFail_invokesDelegateMethod() async { + testDelegate.reset() + let expectation = XCTestExpectation(description: "Fail delegate method should be invoked") + testDelegate.expectation = expectation - func testCancelCallbackNotInvokedWhenNil() async { - let isNil = await MainActor.run { - viewController.onCheckoutCancel == nil - } - XCTAssertTrue(isNil, "onCancel should be nil") + viewController = ApplePayViewController( + identifier: mockIdentifier, + configuration: mockConfiguration, + checkoutDelegate: testDelegate + ) - await MainActor.run { - viewController.onCheckoutCancel?() // Should not crash - } + let checkoutError = CheckoutError.sdkError( + underlying: NSError(domain: "TestError", code: 0, userInfo: nil), + recoverable: false + ) + viewController.checkoutDidFail(error: checkoutError) - try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds - XCTAssertTrue(true, "Should not crash when callback is nil") + await fulfillment(of: [expectation], timeout: 1.0) + XCTAssertTrue(testDelegate.failCallbackInvoked, "Fail delegate method should be invoked") } - // MARK: - No Callback Tests - @MainActor - func testNoCallbackWhenCheckoutCancelled() async { - var successInvoked = false - var errorInvoked = false - var cancelInvoked = false + func testCheckoutDidCancel_invokesDelegateMethod() async { + testDelegate.reset() + let expectation = XCTestExpectation(description: "Cancel delegate method should be invoked") + testDelegate.expectation = expectation - viewController.onCheckoutComplete = { _ in - successInvoked = true - } - viewController.onCheckoutFail = { _ in - errorInvoked = true - } - viewController.onCheckoutCancel = { - cancelInvoked = true - } + viewController = ApplePayViewController( + identifier: mockIdentifier, + configuration: mockConfiguration, + checkoutDelegate: testDelegate + ) - viewController.onCheckoutCancel?() + viewController.checkoutDidCancel() - try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds - XCTAssertFalse(successInvoked, "Success callback should not be invoked") - XCTAssertFalse(errorInvoked, "Error callback should not be invoked") - XCTAssertTrue(cancelInvoked, "Cancel callback should be invoked") + await fulfillment(of: [expectation], timeout: 1.0) + XCTAssertTrue(testDelegate.cancelCallbackInvoked, "Cancel delegate method should be invoked") } - // MARK: - Thread Safety Tests - @MainActor - func testCallbackThreadSafety() async { - let iterations = 12 // Multiple of 3 for even distribution - let successExpectations = (0 ..< iterations / 3).map { _ in expectation(description: "Success") } - let errorExpectations = (0 ..< iterations / 3).map { _ in expectation(description: "Error") } - let cancelExpectations = (0 ..< iterations / 3).map { _ in expectation(description: "Cancel") } - - var successIndex = 0 - var errorIndex = 0 - var cancelIndex = 0 - - viewController.onCheckoutComplete = { _ in - if successIndex < successExpectations.count { - successExpectations[successIndex].fulfill() - successIndex += 1 - } - } - viewController.onCheckoutFail = { _ in - if errorIndex < errorExpectations.count { - errorExpectations[errorIndex].fulfill() - errorIndex += 1 - } - } - viewController.onCheckoutCancel = { - if cancelIndex < cancelExpectations.count { - cancelExpectations[cancelIndex].fulfill() - cancelIndex += 1 - } - } + func testCheckoutDidClickLink_invokesDelegateMethod() async { + testDelegate.reset() + let expectation = XCTestExpectation(description: "Link click delegate method should be invoked") + testDelegate.expectation = expectation - for i in 0 ..< iterations { - if i % 3 == 0 { - let mockEvent = createEmptyCheckoutCompletedEvent(id: "test-order-123") - viewController.onCheckoutComplete?(mockEvent) - } else if i % 3 == 1 { - let mockError = CheckoutError.sdkError(underlying: NSError(domain: "TestError", code: 0, userInfo: nil), recoverable: false) - viewController.onCheckoutFail?(mockError) - } else { - viewController.onCheckoutCancel?() - } + viewController = ApplePayViewController( + identifier: mockIdentifier, + configuration: mockConfiguration, + checkoutDelegate: testDelegate + ) - // Give time for callback to execute - try? await Task.sleep(nanoseconds: 50_000_000) // 0.05 seconds - } + let testURL = URL(string: "https://test-shop.myshopify.com/products/test")! + viewController.checkoutDidClickLink(url: testURL) - // Wait for all expectations - await fulfillment(of: successExpectations + errorExpectations + cancelExpectations, timeout: 2.0) + await fulfillment(of: [expectation], timeout: 1.0) + XCTAssertTrue(testDelegate.linkCallbackInvoked, "Link click delegate method should be invoked") + XCTAssertEqual(testDelegate.receivedURL, testURL, "URL should be passed to delegate") } - // MARK: - Edge Case Tests + @MainActor + func testCheckoutDidEmitWebPixelEvent_invokesDelegateMethod() async { + testDelegate.reset() + let expectation = XCTestExpectation(description: "Web pixel delegate method should be invoked") + testDelegate.expectation = expectation - func testMultipleCallbackAssignments() async { - let firstCallbackExpectation = expectation(description: "First callback") - firstCallbackExpectation.isInverted = true - let secondCallbackExpectation = expectation(description: "Second callback") + viewController = ApplePayViewController( + identifier: mockIdentifier, + configuration: mockConfiguration, + checkoutDelegate: testDelegate + ) - await MainActor.run { - // First assignment - viewController.onCheckoutComplete = { _ in - firstCallbackExpectation.fulfill() - } + let customEvent = CustomEvent(context: nil, customData: nil, id: "test-id", name: "page_viewed", timestamp: nil) + let testEvent = PixelEvent.customEvent(customEvent) + viewController.checkoutDidEmitWebPixelEvent(event: testEvent) - // Second assignment (should replace first) - viewController.onCheckoutComplete = { _ in - secondCallbackExpectation.fulfill() + await fulfillment(of: [expectation], timeout: 1.0) + XCTAssertTrue(testDelegate.webPixelCallbackInvoked, "Web pixel delegate method should be invoked") + // Extract name from the PixelEvent enum + let expectedName: String? + switch testEvent { + case let .customEvent(customEvent): + expectedName = customEvent.name + case let .standardEvent(standardEvent): + expectedName = standardEvent.name + } + + let receivedName: String? + if let receivedEvent = testDelegate.receivedEvent { + switch receivedEvent { + case let .customEvent(customEvent): + receivedName = customEvent.name + case let .standardEvent(standardEvent): + receivedName = standardEvent.name } + } else { + receivedName = nil } - await MainActor.run { - let mockEvent = createEmptyCheckoutCompletedEvent(id: "test-order-123") - viewController.onCheckoutComplete?(mockEvent) - } - - await fulfillment(of: [secondCallbackExpectation], timeout: 1.0) - await fulfillment(of: [firstCallbackExpectation], timeout: 0.2) + XCTAssertEqual(receivedName, expectedName, "Event should be passed to delegate") } - func testMultipleCancelCallbackAssignments() async { - let firstCallbackExpectation = expectation(description: "First cancel callback") - firstCallbackExpectation.isInverted = true - let secondCallbackExpectation = expectation(description: "Second cancel callback") - - await MainActor.run { - // First assignment - viewController.onCheckoutCancel = { - firstCallbackExpectation.fulfill() - } + @MainActor + func testShouldRecoverFromError() async { + testDelegate.reset() + testDelegate.recoveryDecision = false + let expectation = XCTestExpectation(description: "Should recovery delegate method should be invoked") + testDelegate.expectation = expectation - // Second assignment (should replace first) - viewController.onCheckoutCancel = { - secondCallbackExpectation.fulfill() - } - } + viewController = ApplePayViewController( + identifier: mockIdentifier, + configuration: mockConfiguration, + checkoutDelegate: testDelegate + ) - await MainActor.run { - viewController.onCheckoutCancel?() - } + let checkoutError = CheckoutError.sdkError( + underlying: NSError(domain: "TestError", code: 0, userInfo: nil), + recoverable: true + ) + let shouldRecover = viewController.shouldRecoverFromError(error: checkoutError) - await fulfillment(of: [secondCallbackExpectation], timeout: 1.0) - await fulfillment(of: [firstCallbackExpectation], timeout: 0.2) + await fulfillment(of: [expectation], timeout: 1.0) + XCTAssertTrue(testDelegate.errorRecoveryAsked, "Error recovery delegate method should be invoked") + XCTAssertFalse(shouldRecover, "Should return delegate's decision") } - // MARK: - shouldRecoverFromError Callback Tests + // MARK: - No Delegate Tests @MainActor - func testShouldRecoverFromErrorCallbackInvoked() async { - let expectation = expectation(description: "shouldRecoverFromError callback should be invoked") + func testDelegateMethodsWorkWithoutDelegate() async { + // Create view controller without delegate + viewController = ApplePayViewController( + identifier: mockIdentifier, + configuration: mockConfiguration + ) - let testError = ShopifyCheckoutSheetKit.CheckoutError.checkoutUnavailable(message: "Test error", code: .clientError(code: .unknown), recoverable: true) - var capturedError: ShopifyCheckoutSheetKit.CheckoutError? + // These should not crash when called without a delegate + let completedEvent = createEmptyCheckoutCompletedEvent(id: "test-order") + viewController.checkoutDidComplete(event: completedEvent) - viewController.onShouldRecoverFromError = { error in - capturedError = error - expectation.fulfill() - return true - } + let checkoutError = CheckoutError.sdkError( + underlying: NSError(domain: "TestError", code: 0, userInfo: nil), + recoverable: false + ) + viewController.checkoutDidFail(error: checkoutError) - let result = viewController.shouldRecoverFromError(error: testError) + viewController.checkoutDidCancel() - await fulfillment(of: [expectation], timeout: 1.0) - XCTAssertNotNil(capturedError, "Error should be passed to callback") - XCTAssertTrue(result, "Should return true as returned by callback") + let testURL = URL(string: "https://test-shop.myshopify.com")! + viewController.checkoutDidClickLink(url: testURL) + + let customEvent = CustomEvent(context: nil, customData: nil, id: "test-id", name: "test_event", timestamp: nil) + let testEvent = PixelEvent.customEvent(customEvent) + viewController.checkoutDidEmitWebPixelEvent(event: testEvent) + + // Wait a moment to ensure no crash occurs + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + XCTAssertTrue(true, "Should not crash when delegate is nil") } - func testShouldRecoverFromErrorCallbackReturnsCorrectValue() async { - let callbackExpectation = expectation(description: "Callback invoked") + // MARK: - Multiple Event Tests - await MainActor.run { - viewController.onShouldRecoverFromError = { _ in - callbackExpectation.fulfill() - return true - } - } + @MainActor + func testMultipleEventsWithSameDelegate() async { + testDelegate.reset() - let testError = ShopifyCheckoutSheetKit.CheckoutError.checkoutUnavailable(message: "Test", code: .clientError(code: .unknown), recoverable: true) - let result = await MainActor.run { - viewController.shouldRecoverFromError(error: testError) - } + let completeExpectations = [XCTestExpectation(description: "Complete 1"), XCTestExpectation(description: "Complete 2")] + let failExpectations = [XCTestExpectation(description: "Fail 1"), XCTestExpectation(description: "Fail 2")] + let cancelExpectations = [XCTestExpectation(description: "Cancel 1"), XCTestExpectation(description: "Cancel 2")] - await fulfillment(of: [callbackExpectation], timeout: 1.0) - XCTAssertTrue(result, "Should return true as specified by callback") - } + testDelegate.completeExpectations = completeExpectations + testDelegate.failExpectations = failExpectations + testDelegate.cancelExpectations = cancelExpectations - // MARK: - checkoutDidClickLink Callback Tests + viewController = ApplePayViewController( + identifier: mockIdentifier, + configuration: mockConfiguration, + checkoutDelegate: testDelegate + ) - @MainActor - func testCheckoutDidClickLinkCallbackInvoked() async { - let expectation = expectation(description: "checkoutDidClickLink callback should be invoked") - let testURL = URL(string: "https://test-shop.myshopify.com/products/test")! - var capturedURL: URL? + // Trigger multiple events + for _ in 0 ..< 2 { + let completedEvent = createEmptyCheckoutCompletedEvent(id: "test-order") + viewController.checkoutDidComplete(event: completedEvent) - viewController.onCheckoutClickLink = { url in - capturedURL = url - expectation.fulfill() - } + let checkoutError = CheckoutError.sdkError( + underlying: NSError(domain: "TestError", code: 0, userInfo: nil), + recoverable: false + ) + viewController.checkoutDidFail(error: checkoutError) - viewController.checkoutDidClickLink(url: testURL) + viewController.checkoutDidCancel() - await fulfillment(of: [expectation], timeout: 1.0) - XCTAssertEqual(capturedURL, testURL, "URL should be passed to callback") - } + let testURL = URL(string: "https://test-shop.myshopify.com")! + viewController.checkoutDidClickLink(url: testURL) - func testCheckoutDidClickLinkCallbackNotInvokedWhenNil() async { - await MainActor.run { - XCTAssertNil(viewController.onCheckoutClickLink) + let customEvent = CustomEvent(context: nil, customData: nil, id: "test-id", name: "test_event", timestamp: nil) + let testEvent = PixelEvent.customEvent(customEvent) + viewController.checkoutDidEmitWebPixelEvent(event: testEvent) } - let testURL = URL(string: "https://test-shop.myshopify.com")! - await MainActor.run { - viewController.checkoutDidClickLink(url: testURL) // Should not crash - } + await fulfillment(of: completeExpectations + failExpectations + cancelExpectations, timeout: 2.0) - try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds - XCTAssertTrue(true, "Should not crash when callback is nil") + XCTAssertEqual(testDelegate.completeCount, 2, "Should have received 2 complete events") + XCTAssertEqual(testDelegate.failCount, 2, "Should have received 2 fail events") + XCTAssertEqual(testDelegate.cancelCount, 2, "Should have received 2 cancel events") + XCTAssertEqual(testDelegate.linkCount, 2, "Should have received 2 link events") + XCTAssertEqual(testDelegate.webPixelCount, 2, "Should have received 2 web pixel events") } + // MARK: - URL Variation Tests + @MainActor func testCheckoutDidClickLinkWithVariousURLs() async { + testDelegate.reset() + let testURLs = [ URL(string: "https://test-shop.myshopify.com/products/test")!, URL(string: "https://external-site.com/page")!, @@ -408,28 +326,19 @@ final class ApplePayCallbackTests: XCTestCase { expectation(description: "URL callback") } - var capturedURLs: [URL] = [] - var currentIndex = 0 + testDelegate.expectations = expectations - viewController.onCheckoutClickLink = { url in - capturedURLs.append(url) - if currentIndex < expectations.count { - expectations[currentIndex].fulfill() - currentIndex += 1 - } - } + viewController = ApplePayViewController( + identifier: mockIdentifier, + configuration: mockConfiguration, + checkoutDelegate: testDelegate + ) for url in testURLs { viewController.checkoutDidClickLink(url: url) } await fulfillment(of: expectations, timeout: 1.0) - XCTAssertEqual(capturedURLs, testURLs, "URLs should be captured in order") + XCTAssertEqual(testDelegate.receivedURLs, testURLs, "URLs should be captured in order") } - - // MARK: - checkoutDidEmitWebPixelEvent Callback Tests - - // MARK: - Thread Safety Tests for New Callbacks } - -// Mock types are no longer needed since we're testing callbacks directly diff --git a/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayIntegrationTests.swift b/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayIntegrationTests.swift index 0270a4bf5..4371edcbc 100644 --- a/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayIntegrationTests.swift +++ b/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayIntegrationTests.swift @@ -34,6 +34,7 @@ final class ApplePayIntegrationTests: XCTestCase { var mockCommonConfiguration: ShopifyAcceleratedCheckouts.Configuration! var mockApplePayConfiguration: ShopifyAcceleratedCheckouts.ApplePayConfiguration! var mockShopSettings: ShopSettings! + var testDelegate: TestCheckoutDelegate! // MARK: - Setup @@ -67,6 +68,8 @@ final class ApplePayIntegrationTests: XCTestCase { applePay: mockApplePayConfiguration, shopSettings: mockShopSettings ) + + testDelegate = TestCheckoutDelegate() } override func tearDown() { @@ -74,6 +77,7 @@ final class ApplePayIntegrationTests: XCTestCase { mockCommonConfiguration = nil mockApplePayConfiguration = nil mockShopSettings = nil + testDelegate = nil super.tearDown() } @@ -81,12 +85,12 @@ final class ApplePayIntegrationTests: XCTestCase { func testViewModifierWithButtonIntegration() async { await MainActor.run { + testDelegate.reset() + // Create a hosting controller to test SwiftUI integration let view = AcceleratedCheckoutButtons(cartID: "gid://Shopify/Cart/test-cart") .wallets([.applePay]) - .onComplete { _ in - // Callback exists but won't be called during view creation - } + .checkout(delegate: testDelegate) .environmentObject(mockCommonConfiguration) .environmentObject(mockApplePayConfiguration) .environmentObject(mockShopSettings) @@ -102,26 +106,28 @@ final class ApplePayIntegrationTests: XCTestCase { } } - func testViewModifierWithButtonIntegrationIncludingCancel() async { + func testViewModifierWithButtonIntegrationWithNewAPI() async { let completeExpectation = expectation(description: "Complete callback") completeExpectation.isInverted = true let failExpectation = expectation(description: "Fail callback") failExpectation.isInverted = true let cancelExpectation = expectation(description: "Cancel callback") cancelExpectation.isInverted = true + let errorExpectation = expectation(description: "Error callback") + errorExpectation.isInverted = true await MainActor.run { - // Create a hosting controller to test SwiftUI integration with all callbacks + testDelegate.reset() + testDelegate.completeExpectations = [completeExpectation] + testDelegate.failExpectations = [failExpectation] + testDelegate.cancelExpectations = [cancelExpectation] + + // Create a hosting controller to test SwiftUI integration with new API let view = AcceleratedCheckoutButtons(cartID: "gid://Shopify/Cart/test-cart") .wallets([.applePay]) - .onComplete { _ in - completeExpectation.fulfill() - } - .onFail { _ in - failExpectation.fulfill() - } - .onCancel { - cancelExpectation.fulfill() + .checkout(delegate: testDelegate) + .onError { _ in + errorExpectation.fulfill() } .environmentObject(mockCommonConfiguration) .environmentObject(mockApplePayConfiguration) @@ -131,12 +137,12 @@ final class ApplePayIntegrationTests: XCTestCase { _ = hostingController.view - XCTAssertNotNil(hostingController.view, "View should be created with all callbacks") + XCTAssertNotNil(hostingController.view, "View should be created with new API") XCTAssertNotNil(hostingController.rootView, "Root view should exist") } // Verify callbacks are not invoked during view creation - await fulfillment(of: [completeExpectation, failExpectation, cancelExpectation], timeout: 0.2) + await fulfillment(of: [completeExpectation, failExpectation, cancelExpectation, errorExpectation], timeout: 0.2) } // MARK: - Edge Case Tests @@ -159,14 +165,12 @@ final class ApplePayIntegrationTests: XCTestCase { } func testCallbackPersistenceAcrossViewUpdates() async { - var successCount = 0 - let successHandler = { (_: CheckoutCompletedEvent) in - successCount += 1 - } + testDelegate.reset() let button = await ApplePayButton( identifier: .cart(cartID: "gid://Shopify/Cart/test-cart"), - eventHandlers: EventHandlers(checkoutDidComplete: successHandler), + eventHandlers: EventHandlers(), + checkoutDelegate: testDelegate, cornerRadius: nil ) @@ -180,68 +184,31 @@ final class ApplePayIntegrationTests: XCTestCase { // This tests that the environment value propagates correctly XCTAssertNotNil(button, "Button should still exist after modifications") XCTAssertNotNil(modifiedView, "Modified view should exist") + XCTAssertEqual(testDelegate.completeCount, 0, "Success count should start at 0") } - // MARK: - Delegate Tests + // MARK: - CheckoutDelegate Integration Tests @MainActor - func testCheckoutDelegateCancelCallback() async { - var cancelCallbackInvoked = false + func testCheckoutDelegateIntegration() async { + testDelegate.reset() let viewController = ApplePayViewController( identifier: .cart(cartID: "gid://Shopify/Cart/test-cart"), - configuration: mockConfiguration + configuration: mockConfiguration, + checkoutDelegate: testDelegate ) - viewController.onCheckoutCancel = { - cancelCallbackInvoked = true - } - + // Test cancel delegation viewController.checkoutDidCancel() - - // Wait for the async callback to complete try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + XCTAssertTrue(testDelegate.cancelCallbackInvoked, "Cancel callback should be delegated") - XCTAssertTrue(cancelCallbackInvoked, "Cancel callback should be invoked when checkoutDidCancel is called") - } - - // MARK: - New Delegate Method Integration Tests - - @MainActor - func testCheckoutDidClickLinkDelegateIntegration() async { - var callbackInvoked = false - var receivedURL: URL? - - let viewController = ApplePayViewController( - identifier: .cart(cartID: "gid://Shopify/Cart/test-cart"), - configuration: mockConfiguration - ) - - viewController.onCheckoutClickLink = { url in - callbackInvoked = true - receivedURL = url - } - + // Test link click delegation let testURL = URL(string: "https://help.shopify.com/payment-terms")! viewController.checkoutDidClickLink(url: testURL) - - // Wait for the async callback to complete try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds - - XCTAssertTrue(callbackInvoked, "checkoutDidClickLink callback should be invoked") - XCTAssertEqual(receivedURL, testURL, "URL should be passed to callback") - } - - @MainActor - func testCheckoutDidEmitWebPixelEventDelegateIntegration() async { - let viewController = ApplePayViewController( - identifier: .cart(cartID: "gid://Shopify/Cart/test-cart"), - configuration: mockConfiguration - ) - - viewController.onCheckoutWebPixelEvent = { _ in - } - - XCTAssertNotNil(viewController.onCheckoutWebPixelEvent, "Web pixel event callback should be set") + XCTAssertTrue(testDelegate.linkCallbackInvoked, "Link click callback should be delegated") + XCTAssertEqual(testDelegate.receivedURL, testURL, "URL should be passed to delegate") } } diff --git a/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewControllerTests.swift b/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewControllerTests.swift index e307a36f3..9e0a974bd 100644 --- a/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewControllerTests.swift +++ b/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewControllerTests.swift @@ -30,6 +30,7 @@ import XCTest class ApplePayViewControllerTests: XCTestCase { var viewController: ApplePayViewController! var mockConfiguration: ApplePayConfigurationWrapper! + var testDelegate: TestCheckoutDelegate! override func setUp() { super.setUp() @@ -68,68 +69,95 @@ class ApplePayViewControllerTests: XCTestCase { identifier: identifier, configuration: mockConfiguration ) + + testDelegate = TestCheckoutDelegate() } override func tearDown() { viewController = nil mockConfiguration = nil + testDelegate = nil super.tearDown() } - // MARK: - Callback Properties Tests + // MARK: - CheckoutDelegate Tests - func testOnCheckoutSuccessCallback_defaultsToNil() async { - await MainActor.run { - XCTAssertNil(viewController.onCheckoutComplete) - } - } + @MainActor + func testCheckoutDidCancel_invokesDelegateMethod() async { + testDelegate.reset() + let expectation = XCTestExpectation(description: "Cancel delegate method should be invoked") + testDelegate.expectation = expectation - func testOnCheckoutErrorCallback_defaultsToNil() async { - await MainActor.run { - XCTAssertNil(viewController.onCheckoutFail) - } - } + // Create view controller with delegate + let identifier = CheckoutIdentifier.cart(cartID: "gid://Shopify/Cart/test-cart-id") + let viewControllerWithDelegate = ApplePayViewController( + identifier: identifier, + configuration: mockConfiguration, + checkoutDelegate: testDelegate + ) - func testOnCheckoutCancelCallback_defaultsToNil() async { - await MainActor.run { - XCTAssertNil(viewController.onCheckoutCancel) - } - } + viewControllerWithDelegate.checkoutDidCancel() - // MARK: - Delegate Tests + await fulfillment(of: [expectation], timeout: 1.0) + XCTAssertTrue(testDelegate.cancelCallbackInvoked, "Cancel delegate method should be invoked when checkoutDidCancel is called") + } @MainActor - func testCheckoutDidCancel_invokesOnCancelCallback() async { - var cancelCallbackInvoked = false - let expectation = XCTestExpectation(description: "Cancel callback should be invoked") + func testCheckoutDidComplete_invokesDelegateMethod() async { + testDelegate.reset() + let expectation = XCTestExpectation(description: "Complete delegate method should be invoked") + testDelegate.expectation = expectation - viewController.onCheckoutCancel = { - cancelCallbackInvoked = true - expectation.fulfill() - } + // Create view controller with delegate + let identifier = CheckoutIdentifier.cart(cartID: "gid://Shopify/Cart/test-cart-id") + let viewControllerWithDelegate = ApplePayViewController( + identifier: identifier, + configuration: mockConfiguration, + checkoutDelegate: testDelegate + ) + + // Create a mock checkout completed event + let completedEvent = createEmptyCheckoutCompletedEvent(id: "test-order") - viewController.checkoutDidCancel() + viewControllerWithDelegate.checkoutDidComplete(event: completedEvent) await fulfillment(of: [expectation], timeout: 1.0) - XCTAssertTrue(cancelCallbackInvoked, "Cancel callback should be invoked when checkoutDidCancel is called") + XCTAssertTrue(testDelegate.completeCallbackInvoked, "Complete delegate method should be invoked when checkoutDidComplete is called") } - func testCheckoutDidCancel_worksWithoutCheckoutViewController() async { - XCTAssertNil(viewController.checkoutViewController) + @MainActor + func testCheckoutDidFail_invokesDelegateMethod() async { + testDelegate.reset() + let expectation = XCTestExpectation(description: "Fail delegate method should be invoked") + testDelegate.expectation = expectation - await MainActor.run { - viewController.checkoutDidCancel() - } - } + // Create view controller with delegate + let identifier = CheckoutIdentifier.cart(cartID: "gid://Shopify/Cart/test-cart-id") + let viewControllerWithDelegate = ApplePayViewController( + identifier: identifier, + configuration: mockConfiguration, + checkoutDelegate: testDelegate + ) - func testCheckoutDidCancel_worksWithoutOnCancelCallback() async { - let isNil = await MainActor.run { - viewController.onCheckoutCancel == nil - } - XCTAssertTrue(isNil, "onCancel should be nil") + // Create a mock checkout error + let checkoutError = CheckoutError.configurationError( + message: "Test error", + code: .unknown, + recoverable: false + ) + + viewControllerWithDelegate.checkoutDidFail(error: checkoutError) + + await fulfillment(of: [expectation], timeout: 1.0) + XCTAssertTrue(testDelegate.failCallbackInvoked, "Fail delegate method should be invoked when checkoutDidFail is called") + } + func testCheckoutDidCancel_worksWithoutDelegate() async { + // Test that checkoutDidCancel works without a delegate (should not crash) await MainActor.run { viewController.checkoutDidCancel() } + // If we get here without crashing, the test passes + XCTAssertTrue(true, "checkoutDidCancel should work without a delegate") } } diff --git a/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewModifierTests.swift b/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewModifierTests.swift index fdf56dbb5..601dbc2e3 100644 --- a/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewModifierTests.swift +++ b/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewModifierTests.swift @@ -33,6 +33,7 @@ final class ApplePayViewModifierTests: XCTestCase { var mockConfiguration: ShopifyAcceleratedCheckouts.Configuration! var mockApplePayConfiguration: ShopifyAcceleratedCheckouts.ApplePayConfiguration! var mockShopSettings: ShopSettings! + var testDelegate: TestCheckoutDelegate! // MARK: - Setup @@ -57,150 +58,78 @@ final class ApplePayViewModifierTests: XCTestCase { ), paymentSettings: PaymentSettings(countryCode: "US", acceptedCardBrands: [.visa, .mastercard]) ) + + testDelegate = TestCheckoutDelegate() } override func tearDown() { mockConfiguration = nil mockApplePayConfiguration = nil mockShopSettings = nil + testDelegate = nil super.tearDown() } - // MARK: - onComplete Modifier Tests - - func testOnSuccessModifier() { - var successCallbackInvoked = false - let successAction = { (_: CheckoutCompletedEvent) in - successCallbackInvoked = true - } - - let view = AcceleratedCheckoutButtons(cartID: "gid://Shopify/Cart/test-cart-id") - .onComplete(successAction) - .environmentObject(mockConfiguration) - .environmentObject(mockApplePayConfiguration) - .environmentObject(mockShopSettings) - - XCTAssertNotNil(view, "View should be created successfully with success modifier") - - successAction(createEmptyCheckoutCompletedEvent()) - XCTAssertTrue(successCallbackInvoked, "Success callback should be invoked when called") - } - - func testOnSuccessModifierChaining() { - var firstCallbackInvoked = false - var secondCallbackInvoked = false + // MARK: - onError Modifier Tests - let firstAction = { (_: CheckoutCompletedEvent) in - firstCallbackInvoked = true - } - let secondAction = { (_: CheckoutCompletedEvent) in - secondCallbackInvoked = true - } - - _ = AcceleratedCheckoutButtons(cartID: "gid://Shopify/Cart/test-cart-id") - .onComplete(firstAction) - .onComplete(secondAction) // Should replace the first - .environmentObject(mockConfiguration) - .environmentObject(mockApplePayConfiguration) - .environmentObject(mockShopSettings) - - // The second handler should replace the first - secondAction(createEmptyCheckoutCompletedEvent()) - XCTAssertFalse(firstCallbackInvoked, "First callback should not be invoked") - XCTAssertTrue(secondCallbackInvoked, "Second callback should be invoked") - } - - // MARK: - onCancel Modifier Tests - - func testOnCancelModifier() { - var cancelCallbackInvoked = false - let cancelAction = { - cancelCallbackInvoked = true + func testOnErrorModifier() { + var errorCallbackInvoked = false + let errorAction = { (_: AcceleratedCheckoutError) in + errorCallbackInvoked = true } let view = AcceleratedCheckoutButtons(cartID: "gid://Shopify/Cart/test-cart-id") - .onCancel(cancelAction) + .onError(errorAction) .environmentObject(mockConfiguration) .environmentObject(mockApplePayConfiguration) .environmentObject(mockShopSettings) - XCTAssertNotNil(view, "View should be created successfully with cancel modifier") - - cancelAction() - XCTAssertTrue(cancelCallbackInvoked, "Cancel callback should be invoked when called") - } - - func testOnCancelModifierChaining() { - var firstCallbackInvoked = false - var secondCallbackInvoked = false - - _ = { (_: CheckoutCompletedEvent) in - firstCallbackInvoked = true - } - let secondAction = { (_: CheckoutCompletedEvent) in - secondCallbackInvoked = true - } - - _ = AcceleratedCheckoutButtons(cartID: "gid://Shopify/Cart/test-cart-id") - .onCancel { firstCallbackInvoked = true } - .onCancel { secondCallbackInvoked = true } // Should replace the first - .environmentObject(mockConfiguration) - .environmentObject(mockApplePayConfiguration) - .environmentObject(mockShopSettings) + XCTAssertNotNil(view, "View should be created successfully with error modifier") - // The second handler should replace the first - secondAction(createEmptyCheckoutCompletedEvent()) - XCTAssertFalse(firstCallbackInvoked, "First callback should not be invoked") - XCTAssertTrue(secondCallbackInvoked, "Second callback should be invoked") + // Test with validation error (only type supported by onError now) + let validationError = ValidationError(userErrors: [ + ValidationError.UserError(message: "Test error", code: "TEST") + ]) + errorAction(AcceleratedCheckoutError.validation(validationError)) + XCTAssertTrue(errorCallbackInvoked, "Error callback should be invoked when called") } - // MARK: - onFail Modifier Tests + // MARK: - Combined Modifiers Tests - func testOnErrorModifier() { - var errorCallbackInvoked = false - let errorAction = { (_: CheckoutError) in - errorCallbackInvoked = true - } + func testCheckoutDelegateModifier() { + testDelegate.reset() let view = AcceleratedCheckoutButtons(cartID: "gid://Shopify/Cart/test-cart-id") - .onFail(errorAction) + .checkout(delegate: testDelegate) .environmentObject(mockConfiguration) .environmentObject(mockApplePayConfiguration) .environmentObject(mockShopSettings) - XCTAssertNotNil(view, "View should be created successfully with error modifier") - - errorAction(CheckoutError.sdkError(underlying: NSError(domain: "Test", code: 0))) - XCTAssertTrue(errorCallbackInvoked, "Error callback should be invoked when called") + XCTAssertNotNil(view, "View should be created successfully with checkout delegate") } - // MARK: - Combined Modifiers Tests - - func testCombinedModifiers() { - var successInvoked = false + func testCombinedNewModifiers() { var errorInvoked = false + testDelegate.reset() - let successAction = { (_: CheckoutCompletedEvent) in - successInvoked = true - } - let errorAction = { (_: CheckoutError) in + let errorAction = { (_: AcceleratedCheckoutError) in errorInvoked = true } let view = AcceleratedCheckoutButtons(cartID: "gid://Shopify/Cart/test-cart-id") - .onComplete(successAction) - .onFail(errorAction) + .checkout(delegate: testDelegate) + .onError(errorAction) .environmentObject(mockConfiguration) .environmentObject(mockApplePayConfiguration) .environmentObject(mockShopSettings) - XCTAssertNotNil(view, "View should be created successfully with both modifiers") - - successAction(createEmptyCheckoutCompletedEvent()) - XCTAssertTrue(successInvoked, "Success callback should be invoked") - XCTAssertFalse(errorInvoked, "Error callback should not be invoked") + XCTAssertNotNil(view, "View should be created successfully with both new modifiers") - errorAction(CheckoutError.sdkError(underlying: NSError(domain: "Test", code: 0))) + // Test error callback + let validationError = ValidationError(userErrors: [ + ValidationError.UserError(message: "Test error", code: "TEST") + ]) + errorAction(AcceleratedCheckoutError.validation(validationError)) XCTAssertTrue(errorInvoked, "Error callback should be invoked") } @@ -215,57 +144,69 @@ final class ApplePayViewModifierTests: XCTestCase { XCTAssertNotNil(view, "View should be created successfully without handlers") } - // MARK: - Combined Modifier Tests - - func testAllCallbackModifiersCombined() { - var successInvoked = false - var errorInvoked = false - var cancelInvoked = false + // MARK: - ValidationError Tests - let successAction = { (_: CheckoutCompletedEvent) in successInvoked = true } - let errorAction = { (_: CheckoutError) in errorInvoked = true } - let cancelAction = { cancelInvoked = true } + func testValidationErrorInOnError() { + var receivedError: AcceleratedCheckoutError? + let errorAction = { (error: AcceleratedCheckoutError) in + receivedError = error + } let view = AcceleratedCheckoutButtons(cartID: "gid://Shopify/Cart/test-cart-id") - .onComplete(successAction) - .onFail(errorAction) - .onCancel(cancelAction) + .onError(errorAction) .environmentObject(mockConfiguration) .environmentObject(mockApplePayConfiguration) .environmentObject(mockShopSettings) - XCTAssertNotNil(view, "View should be created successfully with all modifiers") + XCTAssertNotNil(view, "View should be created successfully") + + // Test validation error + let validationError = ValidationError(userErrors: [ + ValidationError.UserError(message: "Email is invalid", field: ["email"], code: "INVALID"), + ValidationError.UserError(message: "Required field missing", field: ["name"], code: "BLANK") + ]) + let acceleratedError = AcceleratedCheckoutError.validation(validationError) - successAction(createEmptyCheckoutCompletedEvent()) - XCTAssertTrue(successInvoked, "Success callback should be invoked") - XCTAssertFalse(errorInvoked, "Error callback should not be invoked") - XCTAssertFalse(cancelInvoked, "Cancel callback should not be invoked") + errorAction(acceleratedError) - // Reset - successInvoked = false - errorAction(CheckoutError.sdkError(underlying: NSError(domain: "Test", code: 0))) - XCTAssertFalse(successInvoked, "Success callback should not be invoked") - XCTAssertTrue(errorInvoked, "Error callback should be invoked") - XCTAssertFalse(cancelInvoked, "Cancel callback should not be invoked") - - // Reset - errorInvoked = false - cancelAction() - XCTAssertFalse(successInvoked, "Success callback should not be invoked") - XCTAssertFalse(errorInvoked, "Error callback should not be invoked") - XCTAssertTrue(cancelInvoked, "Cancel callback should be invoked") + guard let error = receivedError else { + XCTFail("Error should be captured") + return + } + + XCTAssertTrue(error.isValidationError, "Should be validation error") + + guard let capturedValidationError = error.validationError else { + XCTFail("Should contain validation error") + return + } + + XCTAssertEqual(capturedValidationError.userErrors.count, 2) + XCTAssertEqual(capturedValidationError.userErrors[0].message, "Email is invalid") + XCTAssertEqual(capturedValidationError.userErrors[0].field, ["email"]) + XCTAssertEqual(capturedValidationError.userErrors[0].code, "INVALID") + + // Test utility methods + XCTAssertTrue(error.hasValidationErrorCode("INVALID")) + XCTAssertTrue(error.hasValidationErrorCode("BLANK")) + XCTAssertFalse(error.hasValidationErrorCode("OTHER")) + + let messages = error.validationMessages + XCTAssertEqual(messages.count, 2) + XCTAssertTrue(messages.contains("Email is invalid")) + XCTAssertTrue(messages.contains("Required field missing")) } // MARK: - Integration Tests - func testCompleteIntegrationWithAllModifiers() { - var successCount = 0 + func testCompleteIntegrationWithNewAPI() { var errorCount = 0 + testDelegate.reset() let view = VStack { AcceleratedCheckoutButtons(cartID: "gid://Shopify/Cart/test-cart-id") - .onComplete { _ in successCount += 1 } - .onFail { _ in errorCount += 1 } + .checkout(delegate: testDelegate) + .onError { _ in errorCount += 1 } } .environmentObject(mockConfiguration) .environmentObject(mockApplePayConfiguration) diff --git a/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ShopPay/ShopPayCallbackTests.swift b/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ShopPay/ShopPayCallbackTests.swift index 43c430cd5..3a2016938 100644 --- a/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ShopPay/ShopPayCallbackTests.swift +++ b/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ShopPay/ShopPayCallbackTests.swift @@ -32,9 +32,7 @@ final class ShopPayCallbackTests: XCTestCase { var viewController: ShopPayViewController! var mockConfiguration: ShopifyAcceleratedCheckouts.Configuration! var mockIdentifier: CheckoutIdentifier! - var successExpectation: XCTestExpectation! - var errorExpectation: XCTestExpectation! - var cancelExpectation: XCTestExpectation! + var testDelegate: TestCheckoutDelegate! // MARK: - Setup @@ -48,158 +46,386 @@ final class ShopPayCallbackTests: XCTestCase { mockIdentifier = .cart(cartID: "gid://Shopify/Cart/test-cart-id") - viewController = ShopPayViewController( - identifier: mockIdentifier, - configuration: mockConfiguration - ) + testDelegate = TestCheckoutDelegate() } override func tearDown() { viewController = nil mockConfiguration = nil mockIdentifier = nil - successExpectation = nil - errorExpectation = nil - cancelExpectation = nil + testDelegate = nil super.tearDown() } - // MARK: - Success Callback Tests + // MARK: - EventHandlers Tests (Valid Properties Only) @MainActor - func testSuccessCallbackInvoked() async { - successExpectation = expectation(description: "Success callback should be invoked") - let callbackInvokedExpectation = expectation(description: "Callback invoked") - - await MainActor.run { - viewController.eventHandlers = EventHandlers( - checkoutDidComplete: { [weak self] _ in - callbackInvokedExpectation.fulfill() - self?.successExpectation.fulfill() - } - ) - - let mockEvent = createEmptyCheckoutCompletedEvent(id: "test-order-123") - viewController.eventHandlers.checkoutDidComplete?(mockEvent) - } + func testEventHandlers_ValidationDidFailInvoked() async { + let validationExpectation = expectation(description: "Validation callback should be invoked") + + let eventHandlers = EventHandlers( + validationDidFail: { _ in + validationExpectation.fulfill() + } + ) - await fulfillment(of: [successExpectation, callbackInvokedExpectation], timeout: 1.0) + viewController = ShopPayViewController( + identifier: mockIdentifier, + configuration: mockConfiguration, + eventHandlers: eventHandlers + ) + + let userError = ValidationError.UserError(message: "Test error", field: nil, code: nil) + let mockValidationError = AcceleratedCheckoutError.validation(ValidationError(userErrors: [userError])) + viewController.eventHandlers.validationDidFail?(mockValidationError) + + await fulfillment(of: [validationExpectation], timeout: 1.0) } - func testSuccessCallbackNotInvokedWhenNil() { - XCTAssertNil(viewController.eventHandlers.checkoutDidComplete) + func testEventHandlers_ValidationDidFailNotInvokedWhenNil() { + viewController = ShopPayViewController( + identifier: mockIdentifier, + configuration: mockConfiguration + ) + + XCTAssertNil(viewController.eventHandlers.validationDidFail) - let mockEvent = createEmptyCheckoutCompletedEvent(id: "test-order-123") - viewController.eventHandlers.checkoutDidComplete?(mockEvent) // Should not crash + let userError = ValidationError.UserError(message: "Test error", field: nil, code: nil) + let mockValidationError = AcceleratedCheckoutError.validation(ValidationError(userErrors: [userError])) + viewController.eventHandlers.validationDidFail?(mockValidationError) // Should not crash XCTAssertTrue(true, "Should not crash when callback is nil") } - // MARK: - Error Callback Tests - @MainActor - func testErrorCallbackInvoked() async { - errorExpectation = expectation(description: "Error callback should be invoked") - let callbackInvokedExpectation = expectation(description: "Error callback invoked") - - viewController.eventHandlers = EventHandlers( - checkoutDidFail: { [weak self] _ in - callbackInvokedExpectation.fulfill() - self?.errorExpectation.fulfill() + func testEventHandlers_RenderStateDidChangeInvoked() async { + let renderStateExpectation = expectation(description: "Render state callback should be invoked") + + let eventHandlers = EventHandlers( + renderStateDidChange: { _ in + renderStateExpectation.fulfill() } ) - let mockError = CheckoutError.sdkError(underlying: NSError(domain: "TestError", code: 0, userInfo: nil), recoverable: false) - viewController.eventHandlers.checkoutDidFail?(mockError) + viewController = ShopPayViewController( + identifier: mockIdentifier, + configuration: mockConfiguration, + eventHandlers: eventHandlers + ) + + viewController.eventHandlers.renderStateDidChange?(.rendered) - await fulfillment(of: [errorExpectation, callbackInvokedExpectation], timeout: 1.0) + await fulfillment(of: [renderStateExpectation], timeout: 1.0) } - func testErrorCallbackNotInvokedWhenNil() { - XCTAssertNil(viewController.eventHandlers.checkoutDidFail) + func testEventHandlers_RenderStateDidChangeNotInvokedWhenNil() { + viewController = ShopPayViewController( + identifier: mockIdentifier, + configuration: mockConfiguration + ) - let mockError = CheckoutError.sdkError(underlying: NSError(domain: "TestError", code: 0, userInfo: nil), recoverable: false) - viewController.eventHandlers.checkoutDidFail?(mockError) // Should not crash + XCTAssertNil(viewController.eventHandlers.renderStateDidChange) + + viewController.eventHandlers.renderStateDidChange?(.rendered) // Should not crash XCTAssertTrue(true, "Should not crash when callback is nil") } - // MARK: - Cancel Callback Tests + // MARK: - CheckoutDelegate Tests + + @MainActor + func testCheckoutDelegate_CheckoutDidCompleteInvoked() async { + testDelegate.reset() + let expectation = XCTestExpectation(description: "Complete delegate method should be invoked") + testDelegate.expectation = expectation + + viewController = ShopPayViewController( + identifier: mockIdentifier, + configuration: mockConfiguration, + checkoutDelegate: testDelegate + ) + + let completedEvent = createEmptyCheckoutCompletedEvent(id: "test-order") + viewController.checkoutDidComplete(event: completedEvent) + + await fulfillment(of: [expectation], timeout: 1.0) + XCTAssertTrue(testDelegate.completeCallbackInvoked, "Complete delegate method should be invoked") + } + + @MainActor + func testCheckoutDelegate_CheckoutDidFailInvoked() async { + testDelegate.reset() + let expectation = XCTestExpectation(description: "Fail delegate method should be invoked") + testDelegate.expectation = expectation + + viewController = ShopPayViewController( + identifier: mockIdentifier, + configuration: mockConfiguration, + checkoutDelegate: testDelegate + ) + + let checkoutError = CheckoutError.sdkError( + underlying: NSError(domain: "TestError", code: 0, userInfo: nil), + recoverable: false + ) + viewController.checkoutDidFail(error: checkoutError) + + await fulfillment(of: [expectation], timeout: 1.0) + XCTAssertTrue(testDelegate.failCallbackInvoked, "Fail delegate method should be invoked") + } + + @MainActor + func testCheckoutDelegate_CheckoutDidCancelInvoked() async { + testDelegate.reset() + let expectation = XCTestExpectation(description: "Cancel delegate method should be invoked") + testDelegate.expectation = expectation + + viewController = ShopPayViewController( + identifier: mockIdentifier, + configuration: mockConfiguration, + checkoutDelegate: testDelegate + ) + + viewController.checkoutDidCancel() + + await fulfillment(of: [expectation], timeout: 1.0) + XCTAssertTrue(testDelegate.cancelCallbackInvoked, "Cancel delegate method should be invoked") + } + + @MainActor + func testCheckoutDelegate_CheckoutDidClickLinkInvoked() async { + testDelegate.reset() + let expectation = XCTestExpectation(description: "Link click delegate method should be invoked") + testDelegate.expectation = expectation + + viewController = ShopPayViewController( + identifier: mockIdentifier, + configuration: mockConfiguration, + checkoutDelegate: testDelegate + ) + + let testURL = URL(string: "https://test-shop.myshopify.com/products/test")! + viewController.checkoutDidClickLink(url: testURL) + + await fulfillment(of: [expectation], timeout: 1.0) + XCTAssertTrue(testDelegate.linkCallbackInvoked, "Link click delegate method should be invoked") + XCTAssertEqual(testDelegate.receivedURL, testURL, "URL should be passed to delegate") + } @MainActor - func testCancelCallbackInvoked() async { - cancelExpectation = expectation(description: "Cancel callback should be invoked") - let callbackInvokedExpectation = expectation(description: "Cancel callback invoked") - - viewController.eventHandlers = EventHandlers( - checkoutDidCancel: { [weak self] in - callbackInvokedExpectation.fulfill() - self?.cancelExpectation.fulfill() + func testCheckoutDelegate_CheckoutDidEmitWebPixelEventInvoked() async { + testDelegate.reset() + let expectation = XCTestExpectation(description: "Web pixel delegate method should be invoked") + testDelegate.expectation = expectation + + viewController = ShopPayViewController( + identifier: mockIdentifier, + configuration: mockConfiguration, + checkoutDelegate: testDelegate + ) + + let customEvent = CustomEvent(context: nil, customData: nil, id: "test-id", name: "page_viewed", timestamp: nil) + let testEvent = PixelEvent.customEvent(customEvent) + viewController.checkoutDidEmitWebPixelEvent(event: testEvent) + + await fulfillment(of: [expectation], timeout: 1.0) + XCTAssertTrue(testDelegate.webPixelCallbackInvoked, "Web pixel delegate method should be invoked") + // Extract name from the PixelEvent enum + let expectedName: String? + switch testEvent { + case let .customEvent(customEvent): + expectedName = customEvent.name + case let .standardEvent(standardEvent): + expectedName = standardEvent.name + } + + let receivedName: String? + if let receivedEvent = testDelegate.receivedEvent { + switch receivedEvent { + case let .customEvent(customEvent): + receivedName = customEvent.name + case let .standardEvent(standardEvent): + receivedName = standardEvent.name } + } else { + receivedName = nil + } + + XCTAssertEqual(receivedName, expectedName, "Event should be passed to delegate") + } + + @MainActor + func testCheckoutDelegate_ShouldRecoverFromErrorInvoked() async { + testDelegate.reset() + let expectation = XCTestExpectation(description: "shouldRecoverFromError delegate method should be invoked") + testDelegate.expectation = expectation + testDelegate.recoveryDecision = true + + viewController = ShopPayViewController( + identifier: mockIdentifier, + configuration: mockConfiguration, + checkoutDelegate: testDelegate ) - viewController.eventHandlers.checkoutDidCancel?() + let testError = CheckoutError.checkoutUnavailable( + message: "Test error", + code: .clientError(code: .unknown), + recoverable: true + ) + let result = viewController.shouldRecoverFromError(error: testError) - await fulfillment(of: [cancelExpectation, callbackInvokedExpectation], timeout: 1.0) + await fulfillment(of: [expectation], timeout: 1.0) + XCTAssertTrue(testDelegate.errorRecoveryAsked, "shouldRecoverFromError delegate method should be invoked") + XCTAssertTrue(result, "Should return true as specified by delegate") } - func testCancelCallbackNotInvokedWhenNil() { - XCTAssertNil(viewController.eventHandlers.checkoutDidCancel) + // MARK: - No Delegate/EventHandler Tests - viewController.eventHandlers.checkoutDidCancel?() // Should not crash + @MainActor + func testCheckoutDelegate_NoDelegate() async { + // Create view controller without delegate + viewController = ShopPayViewController( + identifier: mockIdentifier, + configuration: mockConfiguration + ) - XCTAssertTrue(true, "Should not crash when callback is nil") + // These should not crash when called without a delegate + let completedEvent = createEmptyCheckoutCompletedEvent(id: "test-order") + viewController.checkoutDidComplete(event: completedEvent) + + let checkoutError = CheckoutError.sdkError( + underlying: NSError(domain: "TestError", code: 0, userInfo: nil), + recoverable: false + ) + viewController.checkoutDidFail(error: checkoutError) + + viewController.checkoutDidCancel() + + let testURL = URL(string: "https://test-shop.myshopify.com")! + viewController.checkoutDidClickLink(url: testURL) + + let customEvent = CustomEvent(context: nil, customData: nil, id: "test-id", name: "test_event", timestamp: nil) + let testEvent = PixelEvent.customEvent(customEvent) + viewController.checkoutDidEmitWebPixelEvent(event: testEvent) + + let shouldRecoverResult = viewController.shouldRecoverFromError(error: checkoutError) + XCTAssertFalse(shouldRecoverResult, "Should return false when no delegate is present") + + // Wait a moment to ensure no crash occurs + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + XCTAssertTrue(true, "Should not crash when delegate is nil") } - // MARK: - Delegate Tests + // MARK: - Combined EventHandlers and CheckoutDelegate Tests @MainActor - func testCheckoutCompleteCallback() { - var completeInvoked = false - viewController.eventHandlers = EventHandlers( - checkoutDidComplete: { _ in completeInvoked = true } + func testBothEventHandlersAndCheckoutDelegateTogether() async { + class TestDelegate: CheckoutDelegate { + var completeCallbackInvoked = false + let expectation: XCTestExpectation + + init(expectation: XCTestExpectation) { + self.expectation = expectation + } + + func checkoutDidComplete(event _: CheckoutCompletedEvent) { + completeCallbackInvoked = true + expectation.fulfill() + } + + func checkoutDidFail(error _: CheckoutError) {} + func checkoutDidCancel() {} + func checkoutDidClickLink(url _: URL) {} + func checkoutDidEmitWebPixelEvent(event _: PixelEvent) {} + } + + var eventHandlerInvoked = false + let eventHandlers = EventHandlers( + validationDidFail: { _ in + eventHandlerInvoked = true + } ) - let mockEvent = createEmptyCheckoutCompletedEvent(id: "test-order-123") - viewController.eventHandlers.checkoutDidComplete?(mockEvent) + let expectation = XCTestExpectation(description: "Delegate should be invoked") + let delegate = TestDelegate(expectation: expectation) + + viewController = ShopPayViewController( + identifier: mockIdentifier, + configuration: mockConfiguration, + eventHandlers: eventHandlers, + checkoutDelegate: delegate + ) - XCTAssertTrue(completeInvoked, "Complete callback should be invoked") + // Trigger via EventHandlers (using actual supported property) + let userError = ValidationError.UserError(message: "Test error", field: nil, code: nil) + let mockValidationError = AcceleratedCheckoutError.validation(ValidationError(userErrors: [userError])) + viewController.eventHandlers.validationDidFail?(mockValidationError) + XCTAssertTrue(eventHandlerInvoked, "EventHandler should be invoked") + + // Trigger via CheckoutDelegate + let completedEvent = createEmptyCheckoutCompletedEvent(id: "test-order") + viewController.checkoutDidComplete(event: completedEvent) + await fulfillment(of: [expectation], timeout: 1.0) + XCTAssertTrue(delegate.completeCallbackInvoked, "Delegate should be invoked") } + // MARK: - Legacy EventHandlers Tests (using valid properties only) + @MainActor - func testCheckoutFailCallback() { - var failInvoked = false - viewController.eventHandlers = EventHandlers( - checkoutDidFail: { _ in failInvoked = true } + func testLegacy_ValidationFailCallback() { + var validationInvoked = false + let eventHandlers = EventHandlers( + validationDidFail: { _ in validationInvoked = true } + ) + + viewController = ShopPayViewController( + identifier: mockIdentifier, + configuration: mockConfiguration, + eventHandlers: eventHandlers ) - let mockError = CheckoutError.sdkError(underlying: NSError(domain: "TestError", code: 0, userInfo: nil), recoverable: false) - viewController.eventHandlers.checkoutDidFail?(mockError) + let userError = ValidationError.UserError(message: "Test error", field: nil, code: nil) + let mockValidationError = AcceleratedCheckoutError.validation(ValidationError(userErrors: [userError])) + viewController.eventHandlers.validationDidFail?(mockValidationError) - XCTAssertTrue(failInvoked, "Fail callback should be invoked") + XCTAssertTrue(validationInvoked, "Validation fail callback should be invoked") } @MainActor - func testCheckoutCancelCallback() { - var cancelInvoked = false - viewController.eventHandlers = EventHandlers( - checkoutDidCancel: { cancelInvoked = true } + func testLegacy_RenderStateChangeCallback() { + var renderStateChanged = false + let eventHandlers = EventHandlers( + renderStateDidChange: { _ in renderStateChanged = true } ) - viewController.eventHandlers.checkoutDidCancel?() + viewController = ShopPayViewController( + identifier: mockIdentifier, + configuration: mockConfiguration, + eventHandlers: eventHandlers + ) - XCTAssertTrue(cancelInvoked, "Cancel callback should be invoked") + viewController.eventHandlers.renderStateDidChange?(.rendered) + + XCTAssertTrue(renderStateChanged, "Render state change callback should be invoked") } @MainActor - func testCheckoutDidCancelDelegateBehavior() { - var cancelInvoked = false - viewController.eventHandlers = EventHandlers( - checkoutDidCancel: { cancelInvoked = true } + func testLegacy_EventHandlersIndependentFromDelegateBehavior() { + var validationInvoked = false + let eventHandlers = EventHandlers( + validationDidFail: { _ in validationInvoked = true } + ) + + viewController = ShopPayViewController( + identifier: mockIdentifier, + configuration: mockConfiguration, + eventHandlers: eventHandlers ) + // Trigger CheckoutDelegate method - should not affect EventHandlers viewController.checkoutDidCancel() - XCTAssertTrue(cancelInvoked, "Cancel callback should be invoked") + // EventHandlers should remain unaffected + XCTAssertFalse(validationInvoked, "EventHandlers should not be invoked by delegate methods") } }