diff --git a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegate.swift b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegate.swift index 2917d090e..b891ebb77 100644 --- a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegate.swift +++ b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegate.swift @@ -188,17 +188,27 @@ class ApplePayAuthorizationDelegate: NSObject, ObservableObject { try? await controller.present(url: url) } + /// Completion is a routing state and does not necessarily mean the buyer dismissed checkout. private func onCompleted(previousState: ApplePayState) async throws { switch previousState { case .paymentAuthorizationFailed, .unexpectedError, .interrupt: + // These states leave Apple Pay and recover in Checkout Kit, so the experience remains open. try await transition(to: .presentingCheckoutKit(url: createCheckoutKitURL(for: previousState))) case let .cartSubmittedForCompletion(redirectURL): + // Continue to the thank-you page or checkout fallback, so the experience remains open. try await transition(to: .presentingCheckoutKit(url: redirectURL)) + case .appleSheetPresented: + // PassKit finishing before the state advances uniquely identifies native Apple Pay dismissal. + controller.onCheckoutDismiss?() + try await transition(to: .reset) + default: + // Setup, error, and Checkout Kit lifecycle completion do not imply dismissal. + // Checkout Kit forwards actual dismissal through ApplePayViewController.checkoutDidDismiss(). try await transition(to: .reset) } } diff --git a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift index 24755f4ae..efcf31418 100644 --- a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift +++ b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift @@ -7,6 +7,7 @@ import SwiftUI protocol PayController: AnyObject { var cart: StorefrontAPI.Types.Cart? { get set } var storefront: StorefrontAPIProtocol { get set } + var onCheckoutDismiss: (() -> Void)? { get } /// Opens ShopifyCheckoutKit func present(url: URL) async throws @@ -150,4 +151,13 @@ class ApplePayViewController: WalletController, PayController { } super.checkoutDidComplete(event) } + + override func checkoutDidDismiss() { + Task { @MainActor in + // Completion may already have finished the Apple Pay flow before the buyer closes checkout. + guard case .presentingCheckoutKit = authorizationDelegate.state else { return } + try? await authorizationDelegate.transition(to: .completed) + } + super.checkoutDidDismiss() + } } diff --git a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateControllerTests.swift b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateControllerTests.swift index c7ed15aa2..cdc8b1dd1 100644 --- a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateControllerTests.swift +++ b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateControllerTests.swift @@ -554,6 +554,7 @@ final class ApplePayAuthorizationDelegateControllerTests: XCTestCase { private class MockPayController: PayController { var cart: StorefrontAPI.Types.Cart? var storefront: StorefrontAPIProtocol + var onCheckoutDismiss: (() -> Void)? init() { let cfg = ShopifyAcceleratedCheckouts.Configuration.testConfiguration diff --git a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateTests.swift b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateTests.swift index e9d5c312e..84be88da3 100644 --- a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateTests.swift +++ b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateTests.swift @@ -444,8 +444,8 @@ final class ApplePayAuthorizationDelegateTests: XCTestCase { } /// User cancels the sheet without authorizing payment - func test_onCompleted_withDefaultCase_shouldTransitionToReset() async throws { - // Start with appleSheetPresented (a state that falls into default case) + func test_onCompleted_withAppleSheetPresented_shouldTransitionToReset() async throws { + // Start with appleSheetPresented try await delegate.transition(to: .startPaymentRequest) XCTAssertEqual(delegate.state, .appleSheetPresented) @@ -459,6 +459,16 @@ final class ApplePayAuthorizationDelegateTests: XCTestCase { ) } + func test_onCompleted_withAppleSheetPresented_shouldNotifyDismissal() async throws { + var dismissalCount = 0 + mockController.onCheckoutDismiss = { dismissalCount += 1 } + + try await delegate.transition(to: .startPaymentRequest) + try await delegate.transition(to: .completed) + + XCTAssertEqual(dismissalCount, 1) + } + // MARK: onPresentingCheckoutKit() func test_onPresentingCheckoutKit_withValidURL_shouldCallPresentSuccessfully() async throws { @@ -670,6 +680,7 @@ final class ApplePayAuthorizationDelegateTests: XCTestCase { var presentCallCount = 0 var presentCalledWith: URL? + var onCheckoutDismiss: (() -> Void)? init() { let config = ShopifyAcceleratedCheckouts.Configuration.testConfiguration @@ -688,6 +699,7 @@ final class ApplePayAuthorizationDelegateTests: XCTestCase { private class FailingMockPayController: PayController { var cart: StorefrontAPI.Types.Cart? var storefront: StorefrontAPIProtocol + var onCheckoutDismiss: (() -> Void)? var presentCallCount = 0 @@ -708,6 +720,7 @@ final class ApplePayAuthorizationDelegateTests: XCTestCase { private class SpyPayController: PayController { var cart: StorefrontAPI.Types.Cart? var storefront: StorefrontAPIProtocol + var onCheckoutDismiss: (() -> Void)? var presentCallCount = 0 var presentCalledWith: URL? diff --git a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewControllerTests.swift b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewControllerTests.swift index c5d3b591e..177cb5045 100644 --- a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewControllerTests.swift +++ b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewControllerTests.swift @@ -1,3 +1,4 @@ +import PassKit #if !COCOAPODS import EmbeddedCheckoutProtocol #endif @@ -132,6 +133,64 @@ class ApplePayViewControllerTests: XCTestCase { } } + /// Drives the real Apple Pay state machine without network access or real-time retry delays. + class StatefulApplePayViewController: ApplePayViewController { + let mockTopViewController = UIViewController() + private var observingAuthorizationDelegate: ObservingApplePayAuthorizationDelegate! + + init( + identifier: CheckoutIdentifier, + configuration: ApplePayConfigurationWrapper, + storefront: StorefrontAPIProtocol + ) { + super.init(identifier: identifier, configuration: configuration) + self.storefront = storefront + observingAuthorizationDelegate = ObservingApplePayAuthorizationDelegate( + configuration: configuration, + controller: self, + clock: MockClock() + ) + } + + override var authorizationDelegate: ApplePayAuthorizationDelegate { + observingAuthorizationDelegate + } + + var onTransition: ((ApplePayState) -> Void)? { + get { observingAuthorizationDelegate.onTransition } + set { observingAuthorizationDelegate.onTransition = newValue } + } + + override func getTopViewController() -> UIViewController? { + mockTopViewController + } + } + + final class ObservingApplePayAuthorizationDelegate: ApplePayAuthorizationDelegate { + var onTransition: ((ApplePayState) -> Void)? + + override func transition(to nextState: ApplePayState) async throws { + try await super.transition(to: nextState) + onTransition?(nextState) + } + } + + final class PersonalDataStorefrontAPI: MockStorefrontAPI, @unchecked Sendable { + override func cartRemovePersonalData(id _: GraphQLScalars.ID) async throws {} + } + + final class SuccessfulPaymentAuthorizationController: PaymentAuthorizationController { + var delegate: (any PKPaymentAuthorizationControllerDelegate)? + + func present() async -> Bool { + true + } + + func dismiss(completion: (@Sendable () -> Void)?) { + completion?() + } + } + // MARK: - Callback Properties // MARK: - Delegate @@ -176,6 +235,73 @@ class ApplePayViewControllerTests: XCTestCase { XCTAssertEqual(mockAuthorizationDelegate.transitionHistory, [.completed]) } + func test_checkoutDidDismiss_whenPresentedCheckoutDismisses_invokesOnDismissCallback() async throws { + let dismissCallbackExpectation = expectation(description: "Dismiss callback should be invoked") + viewController.eventHandlers.checkoutDidDismiss = { dismissCallbackExpectation.fulfill() } + viewController.mockTopViewController = UIViewController() + + 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.close() + + await fulfillment(of: [dismissCallbackExpectation], timeout: 1.0) + } + + func test_checkoutDidDismiss_whenApplePayAlreadyIdle_forwardsDismissalWithoutTransitioning() async { + var dismissalCount = 0 + viewController.eventHandlers.checkoutDidDismiss = { dismissalCount += 1 } + XCTAssertEqual(mockAuthorizationDelegate.state, .idle) + + viewController.checkoutDidDismiss() + for _ in 0 ..< 10 { + await Task.yield() + } + + XCTAssertEqual(dismissalCount, 1) + XCTAssertEqual(mockAuthorizationDelegate.transitionHistory, []) + } + + func test_checkoutDidDismiss_whilePresentingCheckoutKit_forwardsDismissalAndResetsApplePayToIdle() 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 dismissalCount = 0 + var transitions: [ApplePayState] = [] + let idleExpectation = expectation(description: "Apple Pay should return to idle") + controller.eventHandlers.checkoutDidDismiss = { dismissalCount += 1 } + controller.onTransition = { state in + transitions.append(state) + if state == .idle { idleExpectation.fulfill() } + } + + controller.checkoutDidDismiss() + + XCTAssertEqual(dismissalCount, 1) + await fulfillment(of: [idleExpectation], timeout: 1.0) + // Transitions nest (completed -> reset -> idle) and the hook runs after each returns, so innermost reports first. + XCTAssertEqual(transitions, [.idle, .reset, .completed]) + 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 d6f3769cd..de93ec6fb 100644 --- a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ShopPay/ShopPayCallbackTests.swift +++ b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ShopPay/ShopPayCallbackTests.swift @@ -7,12 +7,20 @@ import XCTest final class ShopPayCallbackTests: XCTestCase { // MARK: - Properties - var viewController: ShopPayViewController! + var viewController: MockShopPayViewController! var mockConfiguration: ShopifyAcceleratedCheckouts.Configuration! var mockIdentifier: CheckoutIdentifier! var errorExpectation: XCTestExpectation! var dismissExpectation: XCTestExpectation! + final class MockShopPayViewController: ShopPayViewController { + let mockTopViewController = UIViewController() + + override func getTopViewController() -> UIViewController? { + mockTopViewController + } + } + // MARK: - Setup override func setUp() async throws { @@ -25,7 +33,7 @@ final class ShopPayCallbackTests: XCTestCase { mockIdentifier = .cart(cartID: "gid://Shopify/Cart/test-cart-id") - viewController = ShopPayViewController( + viewController = MockShopPayViewController( identifier: mockIdentifier, configuration: mockConfiguration ) @@ -96,6 +104,24 @@ final class ShopPayCallbackTests: XCTestCase { XCTAssertTrue(true, "Should not crash when callback is nil") } + func testDismissCallbackInvokedWhenPresentedCheckoutDismisses() async throws { + let dismissExpectation = expectation(description: "Dismiss callback should be invoked") + viewController.eventHandlers = EventHandlers( + 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.close() + + await fulfillment(of: [dismissExpectation], timeout: 1.0) + } + // MARK: - Delegate Tests @MainActor