From 45a199ee3bdea368bb573759f72946ad4a7ab0c0 Mon Sep 17 00:00:00 2001 From: stechiu Date: Fri, 22 Mar 2024 11:52:54 -0700 Subject: [PATCH 1/8] WIP --- Sources/BraintreePayPal/BTPayPalClient.swift | 40 ++++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/Sources/BraintreePayPal/BTPayPalClient.swift b/Sources/BraintreePayPal/BTPayPalClient.swift index c1a045e236..c8c12260f6 100644 --- a/Sources/BraintreePayPal/BTPayPalClient.swift +++ b/Sources/BraintreePayPal/BTPayPalClient.swift @@ -15,7 +15,11 @@ import BraintreeDataCollector /// Exposed for testing to get the instance of BTAPIClient var apiClient: BTAPIClient - + + /// Defaults to `UIApplication.shared`, but exposed for unit tests to inject test doubles + /// to prevent calls to openURL. Subclassing UIApplication is not possible, since it enforces that only one instance can ever exist. + var application: URLOpener = UIApplication.shared + /// Exposed for testing the approvalURL construction var approvalURL: URL? = nil @@ -83,7 +87,12 @@ import BraintreeDataCollector _ request: BTPayPalVaultRequest, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void ) { - tokenize(request: request, completion: completion) + // Check if PayPal app is installed + if request.enablePayPalAppSwitch == true { + appSwitch(completion: completion) + } else { + tokenize(request: request, completion: completion) + } } /// Tokenize a PayPal request to be used with the PayPal Vault flow. @@ -152,7 +161,32 @@ import BraintreeDataCollector } // MARK: - Internal Methods - + + func appSwitch(completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void) { + // TODO: Implement app switch flow + if isPayPalAppInstalled() { + apiClient.post("/v1/payment_methods/paypal_accounts") { body, response, error in + if let error { + self.notifyFailure(with: error, completion: completion) + return + } + + guard let payPalAccount = body?["paypalAccounts"].asArray()?.first, + let tokenizedAccount = BTPayPalAccountNonce(json: payPalAccount) else { + self.notifyFailure(with: BTPayPalError.failedToCreateNonce, completion: completion) + return + } + + return self.notifySuccess(with: tokenizedAccount, completion: completion) + } + } + } + + private func isPayPalAppInstalled() -> Bool { + let paypalURL = URL(string: "paypal://")! + return application.canOpenURL(paypalURL) + } + func handleBrowserSwitchReturn( _ url: URL?, paymentType: BTPayPalPaymentType, From 173244688218f00dd2e00e59762ed2dddae5fe8e Mon Sep 17 00:00:00 2001 From: stechiu Date: Mon, 25 Mar 2024 16:25:05 -0700 Subject: [PATCH 2/8] Added app install check and unit tests --- Sources/BraintreePayPal/BTPayPalClient.swift | 42 ++++++------------- .../BTPayPalClient_Tests.swift | 21 ++++++++++ 2 files changed, 34 insertions(+), 29 deletions(-) diff --git a/Sources/BraintreePayPal/BTPayPalClient.swift b/Sources/BraintreePayPal/BTPayPalClient.swift index c8c12260f6..e52932906a 100644 --- a/Sources/BraintreePayPal/BTPayPalClient.swift +++ b/Sources/BraintreePayPal/BTPayPalClient.swift @@ -49,6 +49,9 @@ import BraintreeDataCollector /// In the PayPal flow this will be either an EC token or a Billing Agreement token private var payPalContextID: String? = nil + /// URL Scheme for PayPal In-App Checkout + private let payPalInAppScheme: String = "paypal-in-app-checkout://" + // MARK: - Initializer /// Initialize a new PayPal client instance. @@ -87,12 +90,7 @@ import BraintreeDataCollector _ request: BTPayPalVaultRequest, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void ) { - // Check if PayPal app is installed - if request.enablePayPalAppSwitch == true { - appSwitch(completion: completion) - } else { - tokenize(request: request, completion: completion) - } + tokenize(request: request, completion: completion) } /// Tokenize a PayPal request to be used with the PayPal Vault flow. @@ -159,34 +157,16 @@ import BraintreeDataCollector } } } - - // MARK: - Internal Methods - func appSwitch(completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void) { - // TODO: Implement app switch flow - if isPayPalAppInstalled() { - apiClient.post("/v1/payment_methods/paypal_accounts") { body, response, error in - if let error { - self.notifyFailure(with: error, completion: completion) - return - } - - guard let payPalAccount = body?["paypalAccounts"].asArray()?.first, - let tokenizedAccount = BTPayPalAccountNonce(json: payPalAccount) else { - self.notifyFailure(with: BTPayPalError.failedToCreateNonce, completion: completion) - return - } - - return self.notifySuccess(with: tokenizedAccount, completion: completion) - } + @objc public func isPayPalAppInstalled() -> Bool { + guard let paypalURL = URL(string: payPalInAppScheme) else { + return false } - } - - private func isPayPalAppInstalled() -> Bool { - let paypalURL = URL(string: "paypal://")! return application.canOpenURL(paypalURL) } + // MARK: - Internal Methods + func handleBrowserSwitchReturn( _ url: URL?, paymentType: BTPayPalPaymentType, @@ -292,6 +272,10 @@ import BraintreeDataCollector return } + if !self.isPayPalAppInstalled() { + (request as? BTPayPalVaultRequest)?.enablePayPalAppSwitch = false + } + self.payPalRequest = request self.apiClient.post(request.hermesPath, parameters: request.parameters(with: configuration)) { body, response, error in if let error = error as? NSError { diff --git a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift index 9f7956ed1d..1905dfd929 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift @@ -671,6 +671,27 @@ class BTPayPalClient_Tests: XCTestCase { XCTAssertNil(BTPayPalClient.payPalClient) } + func testIsiOSAppSwitchAvailable_whenApplicationCanOpenPayPalInAppURL_returnsTrue() { + let payPalClient = BTPayPalClient(apiClient: mockAPIClient) + BTAppContextSwitcher.sharedInstance.returnURLScheme = "scheme" + let fakeApplication = FakeApplication() + fakeApplication.cannedCanOpenURL = false + fakeApplication.canOpenURLWhitelist.append(URL(string: "paypal-in-app-checkout://")!) + payPalClient.application = fakeApplication + + XCTAssertTrue(payPalClient.isPayPalAppInstalled()) + } + + func testIsiOSAppSwitchAvailable_whenApplicationCantOpenPayPalInAppURL_returnsFalse() { + let payPalClient = BTPayPalClient(apiClient: mockAPIClient) + BTAppContextSwitcher.sharedInstance.returnURLScheme = "scheme" + let fakeApplication = FakeApplication() + fakeApplication.cannedCanOpenURL = false + payPalClient.application = fakeApplication + + XCTAssertFalse(payPalClient.isPayPalAppInstalled()) + } + // MARK: - Analytics func testAPIClientMetadata_hasIntegrationSetToCustom() { From 50f5f76fdb7bc4fbbaa5834492ed68e97d35d3a1 Mon Sep 17 00:00:00 2001 From: stechiu Date: Tue, 2 Apr 2024 14:10:16 -0700 Subject: [PATCH 3/8] Revert change --- CHANGELOG.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c24a7a060..d4238494c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,6 @@ # Braintree iOS SDK Release Notes ## unreleased -* BraintreeCore - * Add property `BTAppContextSwitcher.sharedInstance.universalLink` for the PayPal app switch flow -* BraintreePayPal - * Add `BTPayPalVault.enablePayPalAppSwitch` - * If set to `true` we will attempt to use the PayPal App Switch flow * Require Xcode 15.0+ (per [App Store requirements](https://developer.apple.com/news/?id=khzvxn8a)) * BraintreePayPal * Add `BTPayPalVaultRequest(userAuthenticationEmail:enablePayPalAppSwitch:universalLink:offerCredit:)` From a70059355e7033b1f3d440f9585c8dd49a990883 Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Wed, 3 Apr 2024 11:43:05 -0500 Subject: [PATCH 4/8] Fix Failing `UnitTest`s (#1244) * fix urlString comparison to behave as expected * remove unneeded cannedErrorResponse causing tokenize to return before expected --- Sources/BraintreeCore/BTJSON.swift | 2 +- .../BTSEPADirectDebitClient_Tests.swift | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/Sources/BraintreeCore/BTJSON.swift b/Sources/BraintreeCore/BTJSON.swift index fc15208f51..5f7abbef48 100644 --- a/Sources/BraintreeCore/BTJSON.swift +++ b/Sources/BraintreeCore/BTJSON.swift @@ -219,7 +219,7 @@ import Foundation /// The `BTJSON` as a `URL` /// - Returns: A `URL` representing the `BTJSON` instance public func asURL() -> URL? { - guard let urlString = value as? String else { + guard let urlString = value as? String, urlString.utf8.count == urlString.utf16.count else { return nil } return URL(string: urlString) diff --git a/UnitTests/BraintreeSEPADirectDebitTests/BTSEPADirectDebitClient_Tests.swift b/UnitTests/BraintreeSEPADirectDebitTests/BTSEPADirectDebitClient_Tests.swift index d1fa0e53b2..76bb5d1060 100644 --- a/UnitTests/BraintreeSEPADirectDebitTests/BTSEPADirectDebitClient_Tests.swift +++ b/UnitTests/BraintreeSEPADirectDebitTests/BTSEPADirectDebitClient_Tests.swift @@ -179,12 +179,6 @@ class BTSEPADirectDebitClient_Tests: XCTestCase { ] ) - mockWebAuthenticationSession.cannedErrorResponse = NSError( - domain: BTSEPADirectDebitError.errorDomain, - code: BTSEPADirectDebitError.approvalURLInvalid.errorCode, - userInfo: ["Description": "Mock approvalURLInvalid error description."] - ) - let sepaDirectDebitClient = BTSEPADirectDebitClient( apiClient: mockAPIClient, webAuthenticationSession: mockWebAuthenticationSession, From afd723b51f3ecf0661aa45b7af3bf109eb8121b7 Mon Sep 17 00:00:00 2001 From: stechiu Date: Wed, 3 Apr 2024 13:19:42 -0700 Subject: [PATCH 5/8] Updated unit tests --- Sources/BraintreePayPal/BTPayPalClient.swift | 23 ++++---- .../BTPayPalClient_Tests.swift | 54 +++++++++++++++---- 2 files changed, 59 insertions(+), 18 deletions(-) diff --git a/Sources/BraintreePayPal/BTPayPalClient.swift b/Sources/BraintreePayPal/BTPayPalClient.swift index 836f2da2c8..568bfec657 100644 --- a/Sources/BraintreePayPal/BTPayPalClient.swift +++ b/Sources/BraintreePayPal/BTPayPalClient.swift @@ -37,6 +37,9 @@ import BraintreeDataCollector /// This allows us to set and return a completion in our methods that otherwise cannot require a completion. var appSwitchCompletion: (BTPayPalAccountNonce?, Error?) -> Void = { _, _ in } + /// Exposed for testing to check if the PayPal app is installed + var payPalAppInstalled: Bool = false + // MARK: - Static Properties /// This static instance of `BTPayPalClient` is used during the app switch process. @@ -162,13 +165,6 @@ import BraintreeDataCollector } } - @objc public func isPayPalAppInstalled() -> Bool { - guard let paypalURL = URL(string: payPalInAppScheme) else { - return false - } - return application.canOpenURL(paypalURL) - } - // MARK: - Internal Methods func handleReturn( @@ -286,7 +282,9 @@ import BraintreeDataCollector return } - if !self.isPayPalAppInstalled() { + self.payPalAppInstalled = self.isPayPalAppInstalled() + + if !self.payPalAppInstalled { (request as? BTPayPalVaultRequest)?.enablePayPalAppSwitch = false } @@ -324,7 +322,14 @@ import BraintreeDataCollector } } } - + + private func isPayPalAppInstalled() -> Bool { + guard let paypalURL = URL(string: payPalInAppScheme) else { + return false + } + return application.canOpenURL(paypalURL) + } + private func launchPayPalApp(with payPalAppRedirectURL: URL, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void) { var urlComponents = URLComponents(url: payPalAppRedirectURL, resolvingAgainstBaseURL: true) urlComponents?.queryItems = [ diff --git a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift index 2ad57a3c9f..e1ecc106d7 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift @@ -858,24 +858,60 @@ class BTPayPalClient_Tests: XCTestCase { } func testIsiOSAppSwitchAvailable_whenApplicationCanOpenPayPalInAppURL_returnsTrue() { - let payPalClient = BTPayPalClient(apiClient: mockAPIClient) - BTAppContextSwitcher.sharedInstance.returnURLScheme = "scheme" let fakeApplication = FakeApplication() - fakeApplication.cannedCanOpenURL = false - fakeApplication.canOpenURLWhitelist.append(URL(string: "paypal-in-app-checkout://")!) payPalClient.application = fakeApplication + payPalClient.payPalAppInstalled = true - XCTAssertTrue(payPalClient.isPayPalAppInstalled()) + let vaultRequest = BTPayPalVaultRequest( + userAuthenticationEmail: "fake@gmail.com", + enablePayPalAppSwitch: true, + universalLink: URL(string: "https://paypal.com")! + ) + + mockAPIClient.cannedResponseBody = BTJSON(value: [ + "paymentResource": [ + "paypalAppApprovalUrl": "https://www.some-url.com/some-path?token=value1", + "redirectUrl": "https://www.other-url.com/" + ] + ]) + + payPalClient.tokenize(vaultRequest) { _, _ in } + + XCTAssertEqual("v1/paypal_hermes/setup_billing_agreement", mockAPIClient.lastPOSTPath) + guard let lastPostParameters = mockAPIClient.lastPOSTParameters else { XCTFail(); return } + + XCTAssertEqual(lastPostParameters["launch_paypal_app"] as? Bool, true) + XCTAssertTrue((lastPostParameters["os_version"] as! String).matches("\\d+\\.\\d+")) + XCTAssertTrue((lastPostParameters["os_type"] as! String).matches("iOS|iPadOS")) + XCTAssertEqual(lastPostParameters["merchant_app_return_url"] as? String, "https://paypal.com") } func testIsiOSAppSwitchAvailable_whenApplicationCantOpenPayPalInAppURL_returnsFalse() { - let payPalClient = BTPayPalClient(apiClient: mockAPIClient) - BTAppContextSwitcher.sharedInstance.returnURLScheme = "scheme" let fakeApplication = FakeApplication() - fakeApplication.cannedCanOpenURL = false payPalClient.application = fakeApplication - XCTAssertFalse(payPalClient.isPayPalAppInstalled()) + let vaultRequest = BTPayPalVaultRequest( + userAuthenticationEmail: "fake@gmail.com", + enablePayPalAppSwitch: payPalClient.payPalAppInstalled, + universalLink: URL(string: "https://paypal.com")! + ) + + mockAPIClient.cannedResponseBody = BTJSON(value: [ + "paymentResource": [ + "paypalAppApprovalUrl": "https://www.some-url.com/some-path?token=value1", + "redirectUrl": "https://www.other-url.com/" + ] + ]) + + payPalClient.tokenize(vaultRequest) { _, _ in } + + XCTAssertEqual("v1/paypal_hermes/setup_billing_agreement", mockAPIClient.lastPOSTPath) + guard let lastPostParameters = mockAPIClient.lastPOSTParameters else { XCTFail(); return } + + XCTAssertNil(lastPostParameters["launch_paypal_app"] as? Bool) + XCTAssertNil(lastPostParameters["os_version"] as? String) + XCTAssertNil(lastPostParameters["os_type"] as? String) + XCTAssertNil(lastPostParameters["merchant_app_return_url"] as? String) } // MARK: - Analytics From f872643b0032500e7eb5464ba2cf91a5832301f6 Mon Sep 17 00:00:00 2001 From: stechiu Date: Wed, 3 Apr 2024 13:24:49 -0700 Subject: [PATCH 6/8] Updated unit tests --- Sources/BraintreePayPal/BTPayPalClient.swift | 23 +++++++++++-------- .../BTPayPalClient_Tests.swift | 11 ++++----- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/Sources/BraintreePayPal/BTPayPalClient.swift b/Sources/BraintreePayPal/BTPayPalClient.swift index 836f2da2c8..568bfec657 100644 --- a/Sources/BraintreePayPal/BTPayPalClient.swift +++ b/Sources/BraintreePayPal/BTPayPalClient.swift @@ -37,6 +37,9 @@ import BraintreeDataCollector /// This allows us to set and return a completion in our methods that otherwise cannot require a completion. var appSwitchCompletion: (BTPayPalAccountNonce?, Error?) -> Void = { _, _ in } + /// Exposed for testing to check if the PayPal app is installed + var payPalAppInstalled: Bool = false + // MARK: - Static Properties /// This static instance of `BTPayPalClient` is used during the app switch process. @@ -162,13 +165,6 @@ import BraintreeDataCollector } } - @objc public func isPayPalAppInstalled() -> Bool { - guard let paypalURL = URL(string: payPalInAppScheme) else { - return false - } - return application.canOpenURL(paypalURL) - } - // MARK: - Internal Methods func handleReturn( @@ -286,7 +282,9 @@ import BraintreeDataCollector return } - if !self.isPayPalAppInstalled() { + self.payPalAppInstalled = self.isPayPalAppInstalled() + + if !self.payPalAppInstalled { (request as? BTPayPalVaultRequest)?.enablePayPalAppSwitch = false } @@ -324,7 +322,14 @@ import BraintreeDataCollector } } } - + + private func isPayPalAppInstalled() -> Bool { + guard let paypalURL = URL(string: payPalInAppScheme) else { + return false + } + return application.canOpenURL(paypalURL) + } + private func launchPayPalApp(with payPalAppRedirectURL: URL, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void) { var urlComponents = URLComponents(url: payPalAppRedirectURL, resolvingAgainstBaseURL: true) urlComponents?.queryItems = [ diff --git a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift index 2ad57a3c9f..b4174d79a8 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift @@ -859,13 +859,12 @@ class BTPayPalClient_Tests: XCTestCase { func testIsiOSAppSwitchAvailable_whenApplicationCanOpenPayPalInAppURL_returnsTrue() { let payPalClient = BTPayPalClient(apiClient: mockAPIClient) - BTAppContextSwitcher.sharedInstance.returnURLScheme = "scheme" - let fakeApplication = FakeApplication() - fakeApplication.cannedCanOpenURL = false - fakeApplication.canOpenURLWhitelist.append(URL(string: "paypal-in-app-checkout://")!) - payPalClient.application = fakeApplication + let payPalInAppScheme: String = "paypal-in-app-checkout://" - XCTAssertTrue(payPalClient.isPayPalAppInstalled()) + XCTAssertEqual("v1/paypal_hermes/create_payment_resource", mockAPIClient.lastPOSTPath) + guard let lastPostParameters = mockAPIClient.lastPOSTParameters else { XCTFail(); return } + XCTAssertEqual(lastPostParameters["return_url"] as? String, "aypal-in-app-checkout://v1/success") + XCTAssertEqual(lastPostParameters["cancel_url"] as? String, "aypal-in-app-checkout://v1/cancel") } func testIsiOSAppSwitchAvailable_whenApplicationCantOpenPayPalInAppURL_returnsFalse() { From e0d960ee32e7a58a02878c438e42a54dfa167431 Mon Sep 17 00:00:00 2001 From: stechiu Date: Wed, 3 Apr 2024 14:31:33 -0700 Subject: [PATCH 7/8] Revert "Fix Failing `UnitTest`s (#1244)" This reverts commit a70059355e7033b1f3d440f9585c8dd49a990883. --- Sources/BraintreeCore/BTJSON.swift | 2 +- .../BTSEPADirectDebitClient_Tests.swift | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/BraintreeCore/BTJSON.swift b/Sources/BraintreeCore/BTJSON.swift index 5f7abbef48..fc15208f51 100644 --- a/Sources/BraintreeCore/BTJSON.swift +++ b/Sources/BraintreeCore/BTJSON.swift @@ -219,7 +219,7 @@ import Foundation /// The `BTJSON` as a `URL` /// - Returns: A `URL` representing the `BTJSON` instance public func asURL() -> URL? { - guard let urlString = value as? String, urlString.utf8.count == urlString.utf16.count else { + guard let urlString = value as? String else { return nil } return URL(string: urlString) diff --git a/UnitTests/BraintreeSEPADirectDebitTests/BTSEPADirectDebitClient_Tests.swift b/UnitTests/BraintreeSEPADirectDebitTests/BTSEPADirectDebitClient_Tests.swift index 76bb5d1060..d1fa0e53b2 100644 --- a/UnitTests/BraintreeSEPADirectDebitTests/BTSEPADirectDebitClient_Tests.swift +++ b/UnitTests/BraintreeSEPADirectDebitTests/BTSEPADirectDebitClient_Tests.swift @@ -179,6 +179,12 @@ class BTSEPADirectDebitClient_Tests: XCTestCase { ] ) + mockWebAuthenticationSession.cannedErrorResponse = NSError( + domain: BTSEPADirectDebitError.errorDomain, + code: BTSEPADirectDebitError.approvalURLInvalid.errorCode, + userInfo: ["Description": "Mock approvalURLInvalid error description."] + ) + let sepaDirectDebitClient = BTSEPADirectDebitClient( apiClient: mockAPIClient, webAuthenticationSession: mockWebAuthenticationSession, From b9edf9dd11e4b0e20082e3ca96019a9b20acafc7 Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Thu, 4 Apr 2024 08:42:10 -0500 Subject: [PATCH 8/8] Update UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift --- UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift index e1ecc106d7..ff3f78c928 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift @@ -888,11 +888,12 @@ class BTPayPalClient_Tests: XCTestCase { func testIsiOSAppSwitchAvailable_whenApplicationCantOpenPayPalInAppURL_returnsFalse() { let fakeApplication = FakeApplication() + fakeApplication.cannedCanOpenURL = false payPalClient.application = fakeApplication let vaultRequest = BTPayPalVaultRequest( userAuthenticationEmail: "fake@gmail.com", - enablePayPalAppSwitch: payPalClient.payPalAppInstalled, + enablePayPalAppSwitch: true, universalLink: URL(string: "https://paypal.com")! )