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
@@ -1,3 +1,4 @@
import PassKit
#if !COCOAPODS
import EmbeddedCheckoutProtocol
#endif
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {
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