From eef22622402955bbeddc4396fa5125207e1b86f5 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Wed, 7 Oct 2026 12:49:39 +0100 Subject: [PATCH] Finalize checkout dismissal lifecycle Assisted-By: devx/3f0e7f93-212d-45d8-b056-138b2da8389f --- platforms/android/README.md | 8 ++ .../checkoutkit/CheckoutBottomSheet.kt | 39 ++++--- .../shopify/checkoutkit/CheckoutListener.kt | 5 +- .../checkoutkit/CheckoutPresentation.kt | 5 +- .../checkoutkit/CheckoutBottomSheetTest.kt | 108 +++++++++++++++--- platforms/swift/README.md | 13 ++- .../Wallets/AcceleratedCheckoutButtons.swift | 7 +- .../ApplePay/ApplePayViewController.swift | 5 +- .../Wallets/Wallet.swift | 2 + .../ShopifyCheckoutKit/CheckoutDelegate.swift | 5 +- .../CheckoutViewController.swift | 4 + .../CheckoutWebViewController.swift | 13 ++- .../ApplePayViewControllerTests.swift | 44 +++++++ .../ShopPay/ShopPayCallbackTests.swift | 22 ++++ .../CheckoutWebViewControllerTests.swift | 81 ++++++++++++- 15 files changed, 313 insertions(+), 48 deletions(-) diff --git a/platforms/android/README.md b/platforms/android/README.md index cc93b74ef..7c648588e 100644 --- a/platforms/android/README.md +++ b/platforms/android/README.md @@ -141,6 +141,8 @@ val checkout = ShopifyCheckoutKit.present(checkoutUrl, activity) { checkout?.dismiss() ``` +Calling `CheckoutHandle.dismiss()` is caller-controlled teardown and does not invoke `onDismiss`. + ## Embed checkout Use `ShopifyCheckout` when your app owns the presentation container. The view owns the checkout header, close control, @@ -499,6 +501,12 @@ Register checkout callbacks directly when presenting or creating a checkout. Sta each provide a typed `Checkout` snapshot through `event.checkout`. Failures provide a `CheckoutException` through `event.error`. +Use `onFail` for terminal checkout failures and `onDismiss` for presentation lifecycle. For a Checkout Kit-managed +sheet, `onDismiss` runs after the sheet closes, independently of checkout outcome. When a terminal failure closes the +sheet, `onFail` runs first and `onDismiss` follows; these callbacks are not mutually exclusive. `onComplete` reports +that the order completed; it does not report presentation closure, and `onDismiss` follows when the buyer later closes +checkout. + ```kotlin ShopifyCheckoutKit.present(checkoutUrl, activity) { onStart { event -> diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutBottomSheet.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutBottomSheet.kt index 7f34a9f9e..db4256f06 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutBottomSheet.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutBottomSheet.kt @@ -34,6 +34,7 @@ internal class CheckoutBottomSheet( private var dismissNotified = false private var dismissing = false private var dismissFinalized = false + private var notifyDismissalOnFinalize = false /** * Invoked once when this sheet reaches its terminal dismissal state, before the dialog window @@ -56,6 +57,7 @@ internal class CheckoutBottomSheet( dismissNotified = false dismissing = false dismissFinalized = false + notifyDismissalOnFinalize = false setContentView(R.layout.checkout_sheet_content) val appearance = ShopifyCheckoutKit.configuration.appearance @@ -147,14 +149,13 @@ internal class CheckoutBottomSheet( } /** - * Dismisses checkout in response to a buyer action, notifies the listener once, and uses the - * normal sheet animation. + * Dismisses checkout in response to a buyer action, then notifies the listener once the sheet + * has closed. */ private fun dismissedByBuyer() { if (dismissing) return - notifyCheckoutDismissed() - dismiss(animate = true) + dismiss(animate = true, notifyOnFinalize = true) } /** @@ -167,7 +168,14 @@ internal class CheckoutBottomSheet( /** * Dismisses the sheet, optionally skipping animation for lifecycle teardown. */ - internal fun dismiss(animate: Boolean) { + internal fun dismiss(animate: Boolean, notifyOnFinalize: Boolean = false) { + if (notifyOnFinalize) { + notifyDismissalOnFinalize = true + if (dismissFinalized) { + notifyCheckoutDismissed() + } + } + val sheet = findViewById(R.id.checkoutKitSheet) if (dismissing) { if (!animate && !dismissFinalized) { @@ -196,8 +204,8 @@ internal class CheckoutBottomSheet( private fun dismissAfterSheetDismissAnimation() { if (dismissing) return - notifyCheckoutDismissed() dismissing = true + notifyDismissalOnFinalize = true finishDismiss() } @@ -212,17 +220,20 @@ internal class CheckoutBottomSheet( onDismissFinalized = null destroyPresentedCheckoutView() findViewById(R.id.checkoutKitSheet)?.onDismissRequested = null - if (!isShowing) return - - try { - super.dismiss() - } catch (_: IllegalArgumentException) { - log.w(LOG_TAG, "Window was already detached before dismissal completed.") + if (isShowing) { + try { + super.dismiss() + } catch (_: IllegalArgumentException) { + log.w(LOG_TAG, "Window was already detached before dismissal completed.") + } + } + if (notifyDismissalOnFinalize) { + notifyCheckoutDismissed() } } /** - * Sends the dismissal callback once across close button, back, outside touch, and gesture paths. + * Sends the dismissal callback once across all Checkout Kit-managed dismissal paths. */ private fun notifyCheckoutDismissed() { if (!dismissNotified) { @@ -262,7 +273,7 @@ internal class CheckoutBottomSheet( internal fun closeCheckoutWithError(exception: CheckoutException) { log.d(LOG_TAG, "Closing with error, calling onCheckoutFailed.") checkoutListener.onCheckoutFailed(CheckoutFailureEvent(exception)) - dismiss() + dismiss(animate = true, notifyOnFinalize = true) } } diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutListener.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutListener.kt index 5c057ef0c..e4d491b74 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutListener.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutListener.kt @@ -38,7 +38,10 @@ public interface CheckoutListener { public fun onCheckoutFailed(event: CheckoutFailureEvent) /** - * Event representing dismissal of checkout by the buyer. + * Called after a Checkout Kit presentation closes or when embedded checkout requests dismissal. + * + * Dismissal describes presentation lifecycle independently of checkout outcome. When a terminal + * failure closes a Checkout Kit presentation, [onCheckoutFailed] is called before this method. */ public fun onCheckoutDismissed() diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutPresentation.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutPresentation.kt index cffcf156c..4a6eecf31 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutPresentation.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutPresentation.kt @@ -61,7 +61,10 @@ public class CheckoutPresentation internal constructor() { } /** - * Called when the buyer dismisses checkout. + * Called after a Checkout Kit presentation closes or when embedded checkout requests dismissal. + * + * Dismissal describes presentation lifecycle independently of checkout outcome. When a terminal + * failure closes a Checkout Kit presentation, the [onFail] handler runs before this handler. */ public fun onDismiss(handler: () -> Unit) { onDismiss = handler diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetTest.kt index 1b0cfa761..20725182e 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetTest.kt @@ -28,7 +28,6 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.any -import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.timeout @@ -712,35 +711,112 @@ class CheckoutBottomSheetTest { } @Test - fun `calls onCheckoutDismissed if cancel is called`() { - val mockListener = mock() - val sheet = presentBottomSheet(checkoutListener = mockListener) + fun `cancel notifies dismissal after the bottom sheet closes`() { + var sheetWasShowingWhenDismissed: Boolean? = null + lateinit var sheet: CheckoutBottomSheet + val checkoutListener = object : DefaultCheckoutListener() { + override fun onCheckoutFailed(event: CheckoutFailureEvent) = Unit + + override fun onCheckoutDismissed() { + sheetWasShowingWhenDismissed = sheet.isShowing + } + } + sheet = presentBottomSheet(checkoutListener = checkoutListener) + sheet.findViewById(R.id.checkoutKitSheet)!! + .layout(0, 0, TEST_SHEET_SIZE, TEST_SHEET_SIZE) sheet.cancel() - shadowOf(Looper.getMainLooper()).idle() - verify(mockListener).onCheckoutDismissed() - verify(mockListener, never()).onCheckoutFailed(any()) + assertThat(sheetWasShowingWhenDismissed).isNull() + assertThat(sheet.isShowing).isTrue() + + runDismissAnimation() + + assertThat(sheetWasShowingWhenDismissed).isFalse() + assertThat(sheet.isShowing).isFalse() } @Test - fun `closeCheckoutWithError invokes onCheckoutFailed and dismisses the bottom sheet`() { - val mockListener = mock() - val checkoutSheet = presentBottomSheet(checkoutListener = mockListener) + fun `gesture dismissal notifies after the bottom sheet closes`() { + var sheetWasShowingWhenDismissed: Boolean? = null + lateinit var sheet: CheckoutBottomSheet + val checkoutListener = object : DefaultCheckoutListener() { + override fun onCheckoutFailed(event: CheckoutFailureEvent) = Unit + + override fun onCheckoutDismissed() { + sheetWasShowingWhenDismissed = sheet.isShowing + } + } + sheet = presentBottomSheet(checkoutListener = checkoutListener) - val error = checkoutException() + sheet.findViewById(R.id.checkoutKitSheet)!!.onDismissRequested?.invoke() - checkoutSheet.closeCheckoutWithError(error) - shadowOf(Looper.getMainLooper()).idle() + assertThat(sheetWasShowingWhenDismissed).isFalse() + assertThat(sheet.isShowing).isFalse() + } + + @Test + fun `programmatic dismissal does not notify checkout dismissal`() { + val mockListener = mock() + val sheet = presentBottomSheet(checkoutListener = mockListener) + + sheet.dismiss() runDismissAnimation() verify(mockListener, never()).onCheckoutDismissed() - val captor = argumentCaptor() - verify(mockListener).onCheckoutFailed(captor.capture()) - assertThat(captor.firstValue.error).isSameAs(error) + } + + @Test + fun `closeCheckoutWithError invokes failure then dismissal after closing the bottom sheet`() { + val lifecycleEvents = mutableListOf() + var sheetWasShowingWhenDismissed: Boolean? = null + lateinit var checkoutSheet: CheckoutBottomSheet + val checkoutListener = object : DefaultCheckoutListener() { + override fun onCheckoutFailed(event: CheckoutFailureEvent) { + lifecycleEvents += "fail" + } + + override fun onCheckoutDismissed() { + lifecycleEvents += "dismiss" + sheetWasShowingWhenDismissed = checkoutSheet.isShowing + } + } + checkoutSheet = presentBottomSheet(checkoutListener = checkoutListener) + + checkoutSheet.closeCheckoutWithError(checkoutException()) + runDismissAnimation() + + assertThat(lifecycleEvents).isEqualTo(listOf("fail", "dismiss")) + assertThat(sheetWasShowingWhenDismissed).isFalse() assertThat(checkoutSheet.isShowing).isFalse() } + @Test + fun `buyer dismissal followed by failure notifies failure before one finalized dismissal`() { + val lifecycleEvents = mutableListOf() + val checkoutListener = object : DefaultCheckoutListener() { + override fun onCheckoutFailed(event: CheckoutFailureEvent) { + lifecycleEvents += "fail" + } + + override fun onCheckoutDismissed() { + lifecycleEvents += "dismiss" + } + } + val checkoutSheet = presentBottomSheet(checkoutListener = checkoutListener) + checkoutSheet.findViewById(R.id.checkoutKitSheet)!! + .layout(0, 0, TEST_SHEET_SIZE, TEST_SHEET_SIZE) + + checkoutSheet.cancel() + checkoutSheet.closeCheckoutWithError(checkoutException()) + + assertThat(lifecycleEvents).containsExactly("fail") + + runDismissAnimation() + + assertThat(lifecycleEvents).containsExactly("fail", "dismiss") + } + @Test fun `calls onCheckoutDismissed if close menu item is clicked`() { val mockListener = mock() diff --git a/platforms/swift/README.md b/platforms/swift/README.md index cab24ac28..3f8936a21 100644 --- a/platforms/swift/README.md +++ b/platforms/swift/README.md @@ -116,7 +116,7 @@ final class CartViewController: UIViewController, CheckoutDelegate { } func checkoutDidDismiss() { - // The buyer dismissed checkout. + // The checkout presentation closed. } func checkoutDidFail(_ event: CheckoutFailureEvent) { @@ -322,9 +322,16 @@ the current `ShopifyCheckoutKit.Checkout` snapshot. Callbacks run on the main ac | Checkout is visible and ready for interaction. | `checkoutDidStart(_:)` | `.onStart` | | Checkout totals, line items, fulfillment, or messages change. | `checkoutDidUpdate(_:)` | `.onUpdate` | | Checkout completes. | `checkoutDidComplete(_:)` | `.onComplete` | -| The buyer dismisses checkout. | `checkoutDidDismiss()` | `.onDismiss` | +| The checkout presentation closes. | `checkoutDidDismiss()` | `.onDismiss` | | Checkout cannot continue. | `checkoutDidFail(_:)` | `.onFail` | +These callbacks are not mutually exclusive. When a terminal failure closes a presented checkout, +`checkoutDidFail(_:)` fires first and `checkoutDidDismiss()` follows after the presentation +closes. Calling `dismiss(animated:)` on the returned view controller is caller-controlled teardown +and does not invoke either callback. `checkoutDidComplete(_:)` reports that the order completed; it +does not report presentation closure, and `checkoutDidDismiss()` follows when the buyer later closes +checkout. + UIKit delegates must implement `checkoutDidDismiss()` and `checkoutDidFail(_:)`. The other lifecycle methods have default implementations. When migrating from `checkoutDidFail(error:)`, implement `checkoutDidFail(_ event: CheckoutFailureEvent)` and read the error from `event.error`. @@ -564,7 +571,7 @@ AcceleratedCheckoutButtons(cartID: cartID) // Handle checkout failure. } .onDismiss { - // The buyer dismissed the accelerated checkout flow. + // The accelerated checkout presentation closed. } ``` diff --git a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/AcceleratedCheckoutButtons.swift b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/AcceleratedCheckoutButtons.swift index 4f3399599..c6a9ba7ed 100644 --- a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/AcceleratedCheckoutButtons.swift +++ b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/AcceleratedCheckoutButtons.swift @@ -207,7 +207,10 @@ extension AcceleratedCheckoutButtons { return newView } - /// Adds an action to perform when the buyer dismisses the checkout experience. + /// Adds an action to perform after the accelerated checkout presentation closes. + /// + /// Dismissal describes presentation lifecycle independently of checkout outcome. When a + /// terminal failure closes checkout, the `onFail` action runs before this action. /// /// Use this modifier to handle checkout dismissal: /// @@ -219,7 +222,7 @@ extension AcceleratedCheckoutButtons { /// } /// ``` /// - /// - Parameter action: The action to perform when the buyer dismisses checkout + /// - Parameter action: The action to perform after the checkout presentation closes /// - Returns: A view with the checkout dismissal handler set public func onDismiss(_ action: @escaping () -> Void) -> AcceleratedCheckoutButtons { var newView = self diff --git a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift index efcf31418..db672624e 100644 --- a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift +++ b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift @@ -35,8 +35,9 @@ class ApplePayViewController: WalletController, PayController { @MainActor public var onCheckoutFail: ((CheckoutError) -> Void)? - /// Callback invoked when the buyer dismisses the checkout experience. - /// This closure is called on the main thread when the user dismisses the checkout. + /// Callback invoked when the active Apple Pay or Checkout Kit presentation closes. + /// This closure is called on the main thread independently of checkout outcome. When a + /// terminal failure closes Checkout Kit, ``onCheckoutFail`` is invoked before this closure. /// /// Example usage: /// ```swift diff --git a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/Wallet.swift b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/Wallet.swift index 4fcda6574..cd9cded48 100644 --- a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/Wallet.swift +++ b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/Wallet.swift @@ -14,6 +14,8 @@ public struct EventHandlers { public var checkoutDidComplete: ((CheckoutCompleteEvent) -> Void)? public var checkoutAction: ((CheckoutLink) -> CheckoutLinkAction)? public var checkoutDidFail: ((CheckoutError) -> Void)? + + /// Called after the accelerated checkout presentation closes, independently of checkout outcome. public var checkoutDidDismiss: (() -> Void)? public var renderStateDidChange: ((RenderState) -> Void)? diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutDelegate.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutDelegate.swift index e0e38285a..4ee0d3272 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutDelegate.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutDelegate.swift @@ -15,7 +15,10 @@ public protocol CheckoutDelegate: AnyObject { /// Asks the delegate how to handle a link clicked in checkout. func checkoutAction(for link: CheckoutLink) -> CheckoutLinkAction - /// Tells the delegate that the buyer dismissed checkout. + /// Tells the delegate after the presented checkout closes. + /// + /// Dismissal describes presentation lifecycle independently of checkout outcome. When a + /// terminal failure closes checkout, ``checkoutDidFail(_:)`` is called before this method. func checkoutDidDismiss() /// Tells the delegate that checkout cannot continue. diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift index abc86a5d2..bc77cff04 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift @@ -114,6 +114,10 @@ public struct ShopifyCheckout: UIViewControllerRepresentable, CheckoutConfigurab return copy } + /// Registers a handler called after the presented checkout closes. + /// + /// Dismissal describes presentation lifecycle independently of checkout outcome. When a + /// terminal failure closes checkout, the `onFail` handler runs before this handler. @discardableResult public func onDismiss(_ action: @escaping () -> Void) -> Self { var copy = self copy.onDismissAction = action diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift index a1a13cf20..bdaeafce8 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift @@ -62,6 +62,7 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl }() var progressObserver: NSKeyValueObservation? + private var hasNotifiedDismissal = false // MARK: Initializers @@ -170,8 +171,9 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl } @IBAction func close() { - didDismiss() - dismiss(animated: true) + dismiss(animated: true) { [weak self] in + self?.didDismiss() + } } public func presentationControllerDidDismiss(_: UIPresentationController) { @@ -179,6 +181,9 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl } private func didDismiss() { + guard !hasNotifiedDismissal else { return } + + hasNotifiedDismissal = true onDismiss?() delegate?.checkoutDidDismiss() } @@ -238,6 +243,8 @@ extension CheckoutWebViewController: CheckoutWebViewDelegate { let event = CheckoutFailureEvent(error: error) onFail?(event) delegate?.checkoutDidFail(event) - dismiss(animated: true) + dismiss(animated: true) { [weak self] in + self?.didDismiss() + } } } diff --git a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewControllerTests.swift b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewControllerTests.swift index 177cb5045..580b8b603 100644 --- a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewControllerTests.swift +++ b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewControllerTests.swift @@ -302,6 +302,50 @@ class ApplePayViewControllerTests: XCTestCase { XCTAssertEqual(controller.authorizationDelegate.state, .idle) } + func test_checkoutDidFail_whenPresentedCheckoutFails_forwardsFailureAndDismissalThenResetsAuthorizationState() async throws { + let controller = StatefulApplePayViewController( + identifier: .cart(cartID: "gid://Shopify/Cart/test-cart-id"), + configuration: mockConfiguration, + storefront: PersonalDataStorefrontAPI() + ) + let paymentController = SuccessfulPaymentAuthorizationController() + controller.authorizationDelegate.paymentControllerFactory = { _ in paymentController } + try controller.authorizationDelegate.setCart(to: StorefrontAPI.Cart.testCart()) + + try await controller.authorizationDelegate.transition(to: .startPaymentRequest) + try await controller.authorizationDelegate.transition(to: .paymentAuthorized(payment: PKPayment())) + let redirectURL = try XCTUnwrap(URL(string: "https://test-shop.myshopify.com/thank-you")) + try await controller.authorizationDelegate.transition(to: .cartSubmittedForCompletion(redirectURL: redirectURL)) + try await controller.authorizationDelegate.transition(to: .completed) + + guard case .presentingCheckoutKit = controller.authorizationDelegate.state else { + return XCTFail("Expected Checkout Kit to be presented") + } + + var failureWasForwarded = false + let dismissExpectation = expectation(description: "Checkout dismissal should be forwarded") + let idleExpectation = expectation(description: "Apple Pay should return to idle") + controller.eventHandlers = EventHandlers( + checkoutDidFail: { _ in failureWasForwarded = true }, + checkoutDidDismiss: { dismissExpectation.fulfill() } + ) + controller.onTransition = { state in + if state == .idle { idleExpectation.fulfill() } + } + let checkoutViewController = try XCTUnwrap(controller.checkoutViewController) + let webViewController = try XCTUnwrap( + checkoutViewController.viewControllers.first as? CheckoutWebViewController + ) + + webViewController.checkoutViewDidFailWithError( + error: CheckoutError(code: .sdkError, message: "Test error") + ) + + XCTAssertTrue(failureWasForwarded) + await fulfillment(of: [dismissExpectation, idleExpectation], timeout: 1.0, enforceOrder: true) + XCTAssertEqual(controller.authorizationDelegate.state, .idle) + } + // MARK: - WalletController Inheritance func test_configuration_whenInitialized_usesCorrectStorefront() { diff --git a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ShopPay/ShopPayCallbackTests.swift b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ShopPay/ShopPayCallbackTests.swift index de93ec6fb..c73ca586a 100644 --- a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ShopPay/ShopPayCallbackTests.swift +++ b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ShopPay/ShopPayCallbackTests.swift @@ -122,6 +122,28 @@ final class ShopPayCallbackTests: XCTestCase { await fulfillment(of: [dismissExpectation], timeout: 1.0) } + func testFailureAndDismissCallbacksInvokedWhenPresentedCheckoutFails() async throws { + let failExpectation = expectation(description: "Failure callback should be invoked") + let dismissExpectation = expectation(description: "Dismiss callback should be invoked") + viewController.eventHandlers = EventHandlers( + checkoutDidFail: { _ in failExpectation.fulfill() }, + checkoutDidDismiss: { dismissExpectation.fulfill() } + ) + + let checkoutURL = try XCTUnwrap(URL(string: "https://test-shop.myshopify.com/checkout")) + try await viewController.present(url: checkoutURL) + + let checkoutViewController = try XCTUnwrap(viewController.checkoutViewController) + let webViewController = try XCTUnwrap( + checkoutViewController.viewControllers.first as? CheckoutWebViewController + ) + webViewController.checkoutViewDidFailWithError( + error: CheckoutError(code: .sdkError, message: "Test error") + ) + + await fulfillment(of: [failExpectation, dismissExpectation], timeout: 1.0, enforceOrder: true) + } + // MARK: - Delegate Tests @MainActor diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift index 48c36392d..b0b29eaa4 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift @@ -7,11 +7,23 @@ import XCTest class TestableCheckoutWebViewController: CheckoutWebViewController { var dismissCalled = false var dismissAnimated: Bool = false + var completesDismissalImmediately = true var testIsBeingDismissed = false + private var dismissCompletion: (() -> Void)? override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { dismissCalled = true dismissAnimated = flag + if completesDismissalImmediately { + completion?() + } else { + dismissCompletion = completion + } + } + + func completeDismissal() { + let completion = dismissCompletion + dismissCompletion = nil completion?() } @@ -77,19 +89,42 @@ class CheckoutWebViewControllerTests: XCTestCase { XCTAssertEqual(topConstraint.secondAttribute, .top) } - func test_checkoutViewDidFailWithError_dismissesAndInvokesOnFail() { - var failCalled = false + func test_close_invokesDismissalAfterDismissCompletes() { + var lifecycleEvents: [String] = [] let viewController = TestableCheckoutWebViewController(checkoutURL: url, entryPoint: nil) - viewController.onFail = { _ in failCalled = true } + viewController.completesDismissalImmediately = false + viewController.onDismiss = { lifecycleEvents.append("dismiss") } + + viewController.close() + + XCTAssertTrue(viewController.dismissCalled) + XCTAssertTrue(viewController.dismissAnimated) + XCTAssertEqual(lifecycleEvents, []) + + viewController.completeDismissal() + + XCTAssertEqual(lifecycleEvents, ["dismiss"]) + } + + func test_checkoutViewDidFailWithError_invokesFailureThenDismissalAndDismisses() { + var lifecycleEvents: [String] = [] + let viewController = TestableCheckoutWebViewController(checkoutURL: url, entryPoint: nil) + viewController.completesDismissalImmediately = false + viewController.onFail = { _ in lifecycleEvents.append("fail") } + viewController.onDismiss = { lifecycleEvents.append("dismiss") } viewController.checkoutViewDidFailWithError(error: sampleError) - XCTAssertTrue(failCalled) + XCTAssertEqual(lifecycleEvents, ["fail"]) XCTAssertTrue(viewController.dismissCalled) XCTAssertTrue(viewController.dismissAnimated) + + viewController.completeDismissal() + + XCTAssertEqual(lifecycleEvents, ["fail", "dismiss"]) } - func test_checkoutViewDidFailWithError_invokesDelegate() { + func test_checkoutViewDidFailWithError_invokesFailureThenDismissalDelegateCallbacks() { let delegate = MockCheckoutDelegate() let viewController = TestableCheckoutWebViewController(checkoutURL: url, delegate: delegate, entryPoint: nil) @@ -97,6 +132,42 @@ class CheckoutWebViewControllerTests: XCTestCase { XCTAssertEqual(delegate.failureEvents.count, 1) XCTAssertEqual(delegate.failureEvents.first?.error.code, sampleError.code) + XCTAssertEqual(delegate.didDismissCount, 1) + } + + func test_checkoutViewDidFailWithError_notifiesDismissalOnceWhenPresentationAlsoReportsDismissal() { + let delegate = MockCheckoutDelegate() + let viewController = TestableCheckoutWebViewController(checkoutURL: url, delegate: delegate, entryPoint: nil) + + viewController.checkoutViewDidFailWithError(error: sampleError) + viewController.presentationControllerDidDismiss( + UIPresentationController(presentedViewController: viewController, presenting: nil) + ) + + XCTAssertEqual(delegate.didDismissCount, 1) + } + + func test_close_notifiesDismissalOnceWhenPresentationAlsoReportsDismissal() { + let delegate = MockCheckoutDelegate() + let viewController = TestableCheckoutWebViewController(checkoutURL: url, delegate: delegate, entryPoint: nil) + viewController.completesDismissalImmediately = false + + viewController.close() + viewController.presentationControllerDidDismiss( + UIPresentationController(presentedViewController: viewController, presenting: nil) + ) + viewController.completeDismissal() + + XCTAssertEqual(delegate.didDismissCount, 1) + } + + func test_programmaticDismissDoesNotNotifyDismissal() { + let delegate = MockCheckoutDelegate() + let viewController = TestableCheckoutWebViewController(checkoutURL: url, delegate: delegate, entryPoint: nil) + + viewController.dismiss(animated: true) + + XCTAssertEqual(delegate.didDismissCount, 0) } func test_presentationControllerDidDismiss_invokesDelegateCancel() {