Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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 {
Expand Down Expand Up @@ -670,6 +680,7 @@ final class ApplePayAuthorizationDelegateTests: XCTestCase {

var presentCallCount = 0
var presentCalledWith: URL?
var onCheckoutDismiss: (() -> Void)?

init() {
let config = ShopifyAcceleratedCheckouts.Configuration.testConfiguration
Expand All @@ -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

Expand All @@ -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?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,37 @@ 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_afterCompletionFinishedApplePay_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, [])
}

// MARK: - WalletController Inheritance

func test_configuration_whenInitialized_usesCorrectStorefront() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
)
Expand Down Expand Up @@ -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
Expand Down
Loading