Skip to content

Commit 7b52291

Browse files
authored
PayPalWebCheckout HandleReturnURL for App Switch (#352)
* PayPalWebCheckout handleReturnURL * Steven PR feedback
1 parent 370c3e4 commit 7b52291

File tree

2 files changed

+179
-6
lines changed

2 files changed

+179
-6
lines changed

Sources/PayPalWebPayments/PayPalWebCheckoutClient.swift

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ public class PayPalWebCheckoutClient: NSObject {
88

99
let config: CoreConfig
1010

11+
var appSwitchCompletion: ((Result<PayPalWebCheckoutResult, CoreSDKError>) -> Void)?
12+
1113
private let clientConfigAPI: UpdateClientConfigAPI
1214
private let webAuthenticationSession: WebAuthenticationSession
1315
private let networkingClient: NetworkingClient
@@ -236,6 +238,42 @@ public class PayPalWebCheckoutClient: NSObject {
236238
}
237239
}
238240

241+
// MARK: - App Switch Method
242+
243+
public func handleReturnURL(_ url: URL) {
244+
245+
guard let completion = appSwitchCompletion else { return }
246+
defer { appSwitchCompletion = nil }
247+
248+
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
249+
let items = components?.queryItems ?? []
250+
func queryValue(_ name: String) -> String? {
251+
items.first { $0.name.compare(name) == .orderedSame }?.value
252+
}
253+
254+
let path = url.path.lowercased()
255+
256+
if path.contains("/cancel") {
257+
notifyCheckoutCancelWithError(with: PayPalError.checkoutCanceledError, completion: completion)
258+
return
259+
}
260+
261+
if path.contains("/success"),
262+
let orderID = queryValue("token"), !orderID.isEmpty,
263+
let payerID = queryValue("PayerID") ?? queryValue("payer_id") ?? queryValue("payerId"), !payerID.isEmpty {
264+
notifyCheckoutSuccess(for: PayPalWebCheckoutResult(orderID: orderID, payerID: payerID), completion: completion)
265+
return
266+
}
267+
268+
// TODO: Check for error in app switch returnURL, return PayPalError.webSessionError(Error)
269+
if path.contains("/fail") {
270+
notifyCheckoutFailure(with: PayPalError.malformedResultError, completion: completion)
271+
return
272+
}
273+
274+
notifyCheckoutFailure(with: PayPalError.malformedResultError, completion: completion)
275+
}
276+
239277
private func getQueryStringParameter(url: String, param: String) -> String? {
240278
guard let url = URLComponents(string: url) else { return nil }
241279
return url.queryItems?.first { $0.name == param }?.value

UnitTests/PayPalWebPaymentsTests/PayPalWebCheckoutClient_Tests.swift

Lines changed: 141 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import AuthenticationServices
44
@testable import PayPalWebPayments
55
@testable import TestShared
66

7-
// swiftlint: disable type_body_length
7+
// swiftlint: disable type_body_length file_length
88
class PayPalClient_Tests: XCTestCase {
99

1010
var config: CoreConfig!
@@ -42,7 +42,7 @@ class PayPalClient_Tests: XCTestCase {
4242

4343
XCTAssertEqual(mockWebAuthenticationSession.lastLaunchedURL?.absoluteString, "https://sandbox.paypal.com/agreements/approve?approval_session_id=fake-token")
4444
}
45-
45+
4646
func testVault_whenLive_launchesCorrectURLInWebSession() {
4747
config = CoreConfig(clientID: "testClientID", environment: .live)
4848
mockClientConfigAPI.stubUpdateClientConfigResponse = ClientConfigResponse(updateClientConfig: true)
@@ -56,14 +56,14 @@ class PayPalClient_Tests: XCTestCase {
5656
clientConfigAPI: mockClientConfigAPI,
5757
webAuthenticationSession: mockWebAuthenticationSession
5858
)
59-
59+
6060
let vaultRequest = PayPalVaultRequest(setupTokenID: "fake-token")
6161
payPalClient.vault(vaultRequest) { _ in }
6262
wait(for: [started], timeout: 1.0)
6363

6464
XCTAssertEqual(mockWebAuthenticationSession.lastLaunchedURL?.absoluteString, "https://paypal.com/agreements/approve?approval_session_id=fake-token")
6565
}
66-
66+
6767
func testVault_whenSuccessUrl_ReturnsVaultToken() {
6868

6969
mockWebAuthenticationSession.cannedResponseURL = URL(string: "sdk.ios.paypal://vault/success?approval_token_id=fakeTokenID&approval_session_id=fakeSessionID")
@@ -173,7 +173,7 @@ class PayPalClient_Tests: XCTestCase {
173173
domain: PayPalError.domain,
174174
errorDescription: PayPalError.payPalVaultResponseError.errorDescription
175175
)
176-
176+
177177
let vaultRequest = PayPalVaultRequest(setupTokenID: "fakeTokenID")
178178
payPalClient.vault(vaultRequest) { result in
179179
switch result {
@@ -185,7 +185,7 @@ class PayPalClient_Tests: XCTestCase {
185185
}
186186
expectation.fulfill()
187187
}
188-
188+
189189
waitForExpectations(timeout: 10)
190190
}
191191

@@ -326,5 +326,140 @@ class PayPalClient_Tests: XCTestCase {
326326
URL(string: "https://sandbox.paypal.com/checkoutnow?token=1234&redirect_uri=sdk.ios.paypal://x-callback-url/paypal-sdk/paypal-checkout&native_xo=1")
327327
)
328328
}
329+
330+
// MARK: - handleReturnURL tests
331+
332+
func testHandleReturnURL_success_callsAppSwitchCompletionWithResult() {
333+
var received: Result<PayPalWebCheckoutResult, CoreSDKError>?
334+
payPalClient.appSwitchCompletion = { received = $0 }
335+
336+
let url = URL(string:
337+
"https://appSwitchURL/success?token=ORDER123&PayerID=PAYER456&switch_initiated_time=1757431432185")!
338+
339+
payPalClient.handleReturnURL(url)
340+
341+
switch received {
342+
case .success(let result)?:
343+
XCTAssertEqual(result.orderID, "ORDER123")
344+
XCTAssertEqual(result.payerID, "PAYER456")
345+
default:
346+
XCTFail("Expected success with PayPalWebCheckoutResult")
347+
}
348+
349+
XCTAssertNil(payPalClient.appSwitchCompletion)
350+
}
351+
352+
func testHandleReturnURL_cancel_mapsToCheckoutCanceledError() {
353+
var received: Result<PayPalWebCheckoutResult, CoreSDKError>?
354+
payPalClient.appSwitchCompletion = { received = $0 }
355+
356+
let url = URL(string:
357+
"https://appSwitchURL/cancel?token=ORDER123&PayerID=PAYER456&switch_initiated_time=1757431432185"
358+
)!
359+
360+
payPalClient.handleReturnURL(url)
361+
362+
if case .failure(let error)? = received {
363+
XCTAssertTrue(PayPalError.isCheckoutCanceled(error))
364+
} else {
365+
XCTFail("Expected cancellation error")
366+
}
367+
XCTAssertNil(payPalClient.appSwitchCompletion)
368+
}
369+
370+
func testHandleReturnURL_failPath_mapsToUnknownError() {
371+
var received: Result<PayPalWebCheckoutResult, CoreSDKError>?
372+
payPalClient.appSwitchCompletion = { received = $0 }
373+
374+
let url = URL(string:
375+
"https://appSwitchURL/fail?token=ORDER123&PayerID=PAYER456&switch_initiated_time=1757431432185"
376+
)!
377+
378+
payPalClient.handleReturnURL(url)
379+
380+
if case .failure(let error)? = received {
381+
XCTAssertEqual(error.code, PayPalError.malformedResultError.code)
382+
XCTAssertEqual(error.domain, PayPalError.domain)
383+
} else {
384+
XCTFail("Expected unknown error")
385+
}
386+
XCTAssertNil(payPalClient.appSwitchCompletion)
387+
}
388+
389+
func testHandleReturnURL_successPathMissingPayerID_isMalformedResultError() {
390+
var received: Result<PayPalWebCheckoutResult, CoreSDKError>?
391+
payPalClient.appSwitchCompletion = { received = $0 }
392+
393+
// Missing PayerID
394+
let url = URL(string:
395+
"https://appSwitchURL/success?token=ORDER123&switch_initiated_time=1757431432185"
396+
)!
397+
398+
payPalClient.handleReturnURL(url)
399+
400+
if case .failure(let error)? = received {
401+
XCTAssertEqual(error.code, PayPalError.malformedResultError.code)
402+
XCTAssertEqual(error.domain, PayPalError.domain)
403+
} else {
404+
XCTFail("Expected malformedResultError")
405+
}
406+
XCTAssertNil(payPalClient.appSwitchCompletion)
407+
}
408+
409+
func testHandleReturnURL_successPathIncorrectPayerIdFormat_isMalformedResultError() {
410+
var received: Result<PayPalWebCheckoutResult, CoreSDKError>?
411+
payPalClient.appSwitchCompletion = { received = $0 }
412+
413+
// Should be PayerID
414+
let url = URL(string:
415+
"https://appSwitchURL/success?token=ORDER123&PayerId=PAYER456&switch_initiated_time=1757431432185"
416+
)!
417+
418+
payPalClient.handleReturnURL(url)
419+
420+
if case .failure(let error)? = received {
421+
XCTAssertEqual(error.code, PayPalError.malformedResultError.code)
422+
XCTAssertEqual(error.domain, PayPalError.domain)
423+
} else {
424+
XCTFail("Expected malformedResultError")
425+
}
426+
XCTAssertNil(payPalClient.appSwitchCompletion)
427+
}
428+
429+
func testHandleReturnURL_successPathIncorrectPayeridFormat_isMalformedResultError() {
430+
var received: Result<PayPalWebCheckoutResult, CoreSDKError>?
431+
payPalClient.appSwitchCompletion = { received = $0 }
432+
433+
// Should be PayerID
434+
let url = URL(string:
435+
"https://appSwitchURL/success?token=ORDER123&Payer_id=PAYER456&switch_initiated_time=1757431432185"
436+
)!
437+
438+
payPalClient.handleReturnURL(url)
439+
440+
if case .failure(let error)? = received {
441+
XCTAssertEqual(error.code, PayPalError.malformedResultError.code)
442+
XCTAssertEqual(error.domain, PayPalError.domain)
443+
} else {
444+
XCTFail("Expected malformedResultError")
445+
}
446+
XCTAssertNil(payPalClient.appSwitchCompletion)
447+
}
448+
449+
func testHandleReturnURL__onlyCompletesOnce() {
450+
var completionCount = 0
451+
payPalClient.appSwitchCompletion = { _ in completionCount += 1 }
452+
453+
let url = URL(string:
454+
"https://appSwitchURL/success?token=ORDER123&PayerID=PAYER456&switch_initiated_time=1757431432185"
455+
)!
456+
457+
payPalClient.handleReturnURL(url)
458+
// Second call should do nothing because appSwitchCompletion was cleared via defer
459+
payPalClient.handleReturnURL(url)
460+
461+
XCTAssertEqual(completionCount, 1, "Completion should be called exactly once")
462+
XCTAssertNil(payPalClient.appSwitchCompletion)
463+
}
329464
}
330465
// swiftlint:enable type_body_length

0 commit comments

Comments
 (0)