Skip to content

Commit

Permalink
Make embedded confirm handle in-flight and failed update calls. (#4174)
Browse files Browse the repository at this point in the history
Previous PR: #4168

Still to come:

- Restore previous customer input in form (2) + E2E tests (e.g. load PI
-> fill out card form -> update to SI -> expect form to be preserved but
w/o checkbox)
- (Bonus) Cancel network calls etc. from previous update to reduce
battery/network usage. Can apply this to FC.update as well.

## Motivation
https://jira.corp.stripe.com/browse/MOBILESDK-2583

## Testing
See unit tests

## Changelog
Not user facing
  • Loading branch information
yuki-stripe authored Oct 23, 2024
1 parent fce7874 commit 459b211
Show file tree
Hide file tree
Showing 4 changed files with 66 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -285,23 +285,30 @@ extension XCTestCase {
}

func waitForReload(_ app: XCUIApplication, settings: PaymentSheetTestPlaygroundSettings) {
if settings.uiStyle == .paymentSheet {
switch settings.uiStyle {
case .paymentSheet:
let presentButton = app.buttons["Present PaymentSheet"]
expectation(
for: NSPredicate(format: "enabled == true"),
evaluatedWith: presentButton,
handler: nil
)
waitForExpectations(timeout: 10, handler: nil)
} else {
case .flowController:
let confirm = app.buttons["Confirm"]
expectation(
for: NSPredicate(format: "enabled == true"),
evaluatedWith: confirm,
handler: nil
)
waitForExpectations(timeout: 10, handler: nil)
case .embedded:
let confirm = app.buttons["Present embedded payment element"]
expectation(
for: NSPredicate(format: "enabled == true"),
evaluatedWith: confirm,
handler: nil
)
}
waitForExpectations(timeout: 10, handler: nil)
}
func loadPlayground(_ app: XCUIApplication, _ settings: PaymentSheetTestPlaygroundSettings) {
if #available(iOS 15.0, *) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,25 @@ public final class EmbeddedPaymentElement {
/// Completes the payment or setup.
/// - Returns: The result of the payment after any presented view controllers are dismissed.
/// - Note: This method presents authentication screens on the instance's `presentingViewController` property.
/// - Note: This method requires that the last call to `update` succeeded. If the last `update` call failed, this call will fail. If this method is called while a call to `update` is in progress, it waits until the `update` call completes.
public func confirm() async -> EmbeddedPaymentElementResult {
// TODO
// Wait for the last update to finish and fail if didn't succeed. A failure means the view is out of sync with the intent and could e.g. not be showing a required mandate.
if let currentUpdateTask {
switch await currentUpdateTask.value {
case .succeeded:
// The view is in sync with the intent. Continue on with confirm!
break
case .failed(error: let error):
return .failed(error: error)
case .canceled:
let errorMessage = "confirm was called when the current update task is canceled. This shouldn't be possible; the current update task should only cancel if another task began."
stpAssertionFailure(errorMessage)
let error = PaymentSheetError.flowControllerConfirmFailed(message: errorMessage)
let errorAnalytic = ErrorAnalytic(event: .unexpectedPaymentSheetError, error: error)
STPAnalyticsClient.sharedClient.log(analytic: errorAnalytic)
return .failed(error: error)
}
}
return .canceled
}

Expand Down Expand Up @@ -286,6 +303,7 @@ extension EmbeddedPaymentElement {
/// Completes the payment or setup.
/// - Parameter completion: Called with the result of the payment after any presented view controllers are dismissed. Called on the mai thread.
/// - Note: This method presents authentication screens on the instance's `presentingViewController` property.
/// - Note: This method requires that the last call to `update` succeeded. If the last `update` call failed, this call will fail. If this method is called while a call to `update` is in progress, it waits until the `update` call completes.
public func confirm(completion: @escaping (EmbeddedPaymentElementResult) -> Void) {
Task {
let result = await confirm()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -341,12 +341,12 @@ extension PaymentSheet {

switch latestUpdateContext?.status {
case .inProgress:
assertionFailure("`confirmPayment` should only be called when the last update has completed.")
assertionFailure("`confirm` should only be called when the last update has completed.")
let error = PaymentSheetError.flowControllerConfirmFailed(message: "confirmPayment was called with an update API call in progress.")
completion(.failed(error: error))
return
case .failed:
assertionFailure("`confirmPayment` should only be called when the last update has completed without error.")
assertionFailure("`confirm` should only be called when the last update has completed without error.")
let error = PaymentSheetError.flowControllerConfirmFailed(message: "confirmPayment was called when the last update API call failed.")
completion(.failed(error: error))
return
Expand All @@ -355,7 +355,7 @@ extension PaymentSheet {
}

guard let paymentOption = _paymentOption else {
assertionFailure("`confirmPayment` should only be called when `paymentOption` is not nil")
assertionFailure("`confirm` should only be called when `paymentOption` is not nil")
let error = PaymentSheetError.flowControllerConfirmFailed(message: "confirmPayment was called with a nil paymentOption")
completion(.failed(error: error))
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,39 @@ class EmbeddedPaymentElementTest: XCTestCase {
XCTAssertEqual(updateResult2, .succeeded)
XCTAssertTrue(sut.loadResult.intent.isSettingUp)
}

func testConfirmHandlesInflightUpdateThatSucceeds() async throws {
// Given a EmbeddedPaymentElement instance...
let sut = try await EmbeddedPaymentElement.create(intentConfiguration: paymentIntentConfig, configuration: configuration)
// ...updating...
async let _updateResult = sut.update(intentConfiguration: paymentIntentConfig)
// ...and immediately calling confirm, before the 1st update finishes...
let confirmResult = await sut.confirm()
// ...should make the confirm call wait for the update and then
switch confirmResult {
case .canceled: // TODO: When confirm works, change this to .completed
break
default:
XCTFail("Expected confirm to succeed")
}
}

func testConfirmHandlesInflightUpdateThatFails() async throws {
// Given a EmbeddedPaymentElement instance...
let sut = try await EmbeddedPaymentElement.create(intentConfiguration: paymentIntentConfig, configuration: configuration)
// ...updating w/ a broken config...
let brokenConfig = EmbeddedPaymentElement.IntentConfiguration(mode: .payment(amount: -1000, currency: "bad currency"), confirmHandler: { _, _, _ in })
async let _ = sut.update(intentConfiguration: brokenConfig)
// ...and immediately calling confirm, before the 1st update finishes...
async let confirmResult = sut.confirm() // Note: If this is `await`, it runs *before* the `update` call above is run.
// ...should make the confirm call wait for the update and then fail b/c the update failed
switch await confirmResult {
case let .failed(error: error):
XCTAssertEqual(error.nonGenericDescription.prefix(101), "An error occurred in PaymentSheet. The amount in `PaymentSheet.IntentConfiguration` must be non-zero!")
default:
XCTFail("Expected confirm to fail")
}
}
}

extension EmbeddedPaymentElementTest: EmbeddedPaymentElementDelegate {
Expand Down

0 comments on commit 459b211

Please sign in to comment.