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 @@ -497,6 +497,7 @@ class ShopifyCheckoutKitTests: XCTestCase {
wait(for: [presentAttemptCompleted], timeout: 1)
}

@MainActor
func testCheckoutDidDismissDismissesCheckoutSheetFromRCTWrapper() {
let dismissCompleted = expectation(description: "checkout sheet dismissed")
let checkoutSheet = DismissTrackingViewController()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,11 @@ final class CartResettingCheckoutDelegate: CheckoutDelegate {
completed = true
}

nonisolated func checkoutDidDismiss() {
MainActor.assumeIsolated {
guard completed else { return }
completed = false
CartManager.shared.resetCart()
}
func checkoutDidDismiss() {
guard completed else { return }
completed = false
CartManager.shared.resetCart()
}

nonisolated func checkoutDidFail(error _: CheckoutError) {}
func checkoutDidFail(error _: CheckoutError) {}
}
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 All @@ -19,6 +20,10 @@ class ApplePayViewController: WalletController, PayController {

var cart: StorefrontAPI.Types.Cart?

override var checkoutDelegate: (any CheckoutDelegate)? {
self
}

var client: (any CheckoutCommunicationProtocol)?

// MARK: - Callback Properties
Expand Down Expand Up @@ -158,3 +163,18 @@ class ApplePayViewController: WalletController, PayController {
try await present(url: url, client: client)
}
}

@available(iOS 16.0, *)
extension ApplePayViewController: CheckoutDelegate {
func checkoutDidDismiss() {
onCheckoutDismiss?()
Task { @MainActor [weak self] in
guard let self else { return }
try? await authorizationDelegate.transition(to: .completed)
}
}

func checkoutDidFail(error: CheckoutError) {
onCheckoutFail?(error)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ class ShopPayViewController: WalletController {
var eventHandlers: EventHandlers
var client: (any CheckoutCommunicationProtocol)?

override var checkoutDelegate: (any CheckoutDelegate)? {
self
}

init(
identifier: CheckoutIdentifier,
configuration: ShopifyAcceleratedCheckouts.Configuration,
Expand Down Expand Up @@ -42,3 +46,14 @@ class ShopPayViewController: WalletController {
}
}
}

@available(iOS 16.0, *)
extension ShopPayViewController: CheckoutDelegate {
func checkoutDidDismiss() {
eventHandlers.checkoutDidDismiss?()
}

func checkoutDidFail(error: CheckoutError) {
eventHandlers.checkoutDidFail?(error)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ class WalletController: ObservableObject {
@Published var checkoutViewController: CheckoutViewController?
@Published var configuration: ShopifyAcceleratedCheckouts.Configuration

var checkoutDelegate: (any CheckoutDelegate)? {
nil
}

init(identifier: CheckoutIdentifier, storefront: StorefrontAPIProtocol, configuration: ShopifyAcceleratedCheckouts.Configuration) {
self.identifier = identifier
self.storefront = storefront
Expand Down Expand Up @@ -43,6 +47,7 @@ class WalletController: ObservableObject {
checkout: url,
from: topViewController,
entryPoint: .acceleratedCheckouts,
delegate: checkoutDelegate,
client: client
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation

/// A delegate protocol for managing checkout lifecycle events.
@MainActor
public protocol CheckoutDelegate: AnyObject {
/// Tells the delegate that the buyer dismissed checkout.
func checkoutDidDismiss()
Expand Down
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,5 +1,5 @@
@testable import ShopifyAcceleratedCheckouts
import ShopifyCheckoutKit
@testable import ShopifyCheckoutKit
import UIKit
import XCTest

Expand Down Expand Up @@ -141,6 +141,38 @@ class ApplePayViewControllerTests: XCTestCase {
await fulfillment(of: [dismissCallbackExpectation], timeout: 1.0)
}

func test_checkoutDidDismiss_whenPresentedCheckoutDismisses_invokesOnDismissCallback() async throws {
let dismissCallbackExpectation = expectation(description: "Dismiss callback should be invoked")
viewController.onCheckoutDismiss = { dismissCallbackExpectation.fulfill() }
viewController.mockTopViewController = UIViewController()

let checkoutURL = try XCTUnwrap(URL(string: "https://test-shop.myshopify.com/checkout"))
try await viewController.present(url: checkoutURL, client: nil)

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_fromDetachedTask_invokesCallbackOnMainActor() async throws {
let dismissCallbackExpectation = expectation(description: "Dismiss callback should run on the main actor")
viewController.onCheckoutDismiss = {
MainActor.preconditionIsolated()
dismissCallbackExpectation.fulfill()
}
let controller = try XCTUnwrap(viewController)

await Task.detached {
await controller.checkoutDidDismiss()
}.value

await fulfillment(of: [dismissCallbackExpectation], timeout: 1.0)
}

// 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,47 @@ 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, client: nil)

let checkoutViewController = try XCTUnwrap(viewController.checkoutViewController)
let webViewController = try XCTUnwrap(
checkoutViewController.viewControllers.first as? CheckoutWebViewController
)
webViewController.close()

await fulfillment(of: [dismissExpectation], timeout: 1.0)
}

func testCheckoutDelegateCallbacksFromDetachedTaskRunOnMainActor() async throws {
let dismissExpectation = expectation(description: "Dismiss callback should run on the main actor")
let failExpectation = expectation(description: "Fail callback should run on the main actor")
viewController.eventHandlers = EventHandlers(
checkoutDidFail: { _ in
MainActor.preconditionIsolated()
failExpectation.fulfill()
},
checkoutDidDismiss: {
MainActor.preconditionIsolated()
dismissExpectation.fulfill()
}
)
let controller = try XCTUnwrap(viewController)

await Task.detached {
await controller.checkoutDidDismiss()
await controller.checkoutDidFail(error: CheckoutError(code: .sdkError, message: "Test error"))
}.value

await fulfillment(of: [dismissExpectation, failExpectation], timeout: 1.0)
}

// MARK: - Delegate Tests

@MainActor
Expand Down
9 changes: 9 additions & 0 deletions platforms/swift/api/ShopifyCheckoutKit.json
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,9 @@
"moduleName": "ShopifyCheckoutKit",
"genericSig": "<Self where Self : ShopifyCheckoutKit.CheckoutDelegate>",
"protocolReq": true,
"declAttributes": [
"Custom"
],
"reqNewWitnessTableEntry": true,
"funcSelfKind": "NonMutating"
},
Expand All @@ -262,6 +265,9 @@
"moduleName": "ShopifyCheckoutKit",
"genericSig": "<Self where Self : ShopifyCheckoutKit.CheckoutDelegate>",
"protocolReq": true,
"declAttributes": [
"Custom"
],
"reqNewWitnessTableEntry": true,
"funcSelfKind": "NonMutating"
}
Expand All @@ -271,6 +277,9 @@
"mangledName": "$s18ShopifyCheckoutKit0B8DelegateP",
"moduleName": "ShopifyCheckoutKit",
"genericSig": "<Self : AnyObject>",
"declAttributes": [
"Custom"
],
"conformances": [
{
"kind": "Conformance",
Expand Down
Loading