diff --git a/Checkout/Samples/CocoapodsSample/Podfile b/Checkout/Samples/CocoapodsSample/Podfile index 806658a6..979c17ab 100644 --- a/Checkout/Samples/CocoapodsSample/Podfile +++ b/Checkout/Samples/CocoapodsSample/Podfile @@ -5,6 +5,7 @@ target 'CheckoutCocoapodsSample' do use_frameworks! # Pods for CheckoutSDKCocoapodsSample - pod 'Checkout', '4.3.6' +# pod 'Checkout', '4.3.7' + pod 'Checkout', :git => 'https://github.com/checkout/frames-ios.git', :branch => 'feature/risk-sdk-timeout-recovery' end diff --git a/Checkout/Source/Logging/CheckoutLogEvent.swift b/Checkout/Source/Logging/CheckoutLogEvent.swift index c48d4162..01dae811 100644 --- a/Checkout/Source/Logging/CheckoutLogEvent.swift +++ b/Checkout/Source/Logging/CheckoutLogEvent.swift @@ -19,6 +19,7 @@ enum CheckoutLogEvent: Equatable { case cvvRequested(SecurityCodeTokenRequestData) case cvvResponse(SecurityCodeTokenRequestData, TokenResponseData) case riskSDKCompletion + case riskSDKTimeOut func event(date: Date) -> Event { Event( @@ -58,6 +59,8 @@ enum CheckoutLogEvent: Equatable { return "card_validator_cvv" case .riskSDKCompletion: return "risk_sdk_completion" + case .riskSDKTimeOut: + return "risk_sdk_time_out" } } @@ -70,7 +73,8 @@ enum CheckoutLogEvent: Equatable { .validateExpiryInteger, .validateCVV, .cvvRequested, - .riskSDKCompletion: + .riskSDKCompletion, + .riskSDKTimeOut: return .info case .tokenResponse(_, let tokenResponseData), .cvvResponse(_, let tokenResponseData): @@ -93,7 +97,8 @@ enum CheckoutLogEvent: Equatable { .validateExpiryString, .validateExpiryInteger, .validateCVV, - .riskSDKCompletion: + .riskSDKCompletion, + .riskSDKTimeOut: return [:] case let .tokenRequested(tokenRequestData): return [ diff --git a/Checkout/Source/Tokenisation/CheckoutAPIService.swift b/Checkout/Source/Tokenisation/CheckoutAPIService.swift index 6699e2ce..0a86ddd9 100644 --- a/Checkout/Source/Tokenisation/CheckoutAPIService.swift +++ b/Checkout/Source/Tokenisation/CheckoutAPIService.swift @@ -145,6 +145,10 @@ final public class CheckoutAPIService: CheckoutAPIProtocol { } } + let timeoutInterval: TimeInterval = 5.0 + private let taskCompletionQueue = DispatchQueue(label: "taskCompletionQueue", qos: .userInitiated) + private var isTaskCompleted = false + private func createToken(requestParameters: NetworkManager.RequestParameters, paymentType: TokenRequest.TokenType, completion: @escaping (Result) -> Void) { @@ -164,19 +168,10 @@ final public class CheckoutAPIService: CheckoutAPIProtocol { return } - self.riskSDK.configure { configurationResult in - switch configurationResult { - case .failure: - completion(.success(tokenDetails)) - logManager.resetCorrelationID() - case .success(): - self.riskSDK.publishData(cardToken: tokenDetails.token) { _ in - logManager.queue(event: .riskSDKCompletion) - completion(.success(tokenDetails)) - logManager.resetCorrelationID() - } - } - } + self.callRiskSDK(tokenDetails: tokenDetails) { + completion(.success(tokenDetails)) + } + case .errorResponse(let errorResponse): completion(.failure(.serverError(errorResponse))) logManager.resetCorrelationID() @@ -187,6 +182,48 @@ final public class CheckoutAPIService: CheckoutAPIProtocol { } } + private func callRiskSDK(tokenDetails: TokenDetails, + completion: @escaping () -> Void) { + + /* Risk SDK calls can be finalised in 3 different ways + 1. When Risk SDK's configure(...) function completed successfully and publishData(...) completed successfully or not + 2. When Risk SDK's configure(...) function completed with failure + 3. When Risk SDK's configure(...) or publishData(...) functions hang and don't call their completion blocks. + In this case, we wait for `self.timeoutInterval` amount of time and call the completion block anyway. + + All these operations are done synchronously to avoid the completion closure getting called multiple times. + */ + + let finaliseRiskSDKCalls = { + self.taskCompletionQueue.sync { + if !self.isTaskCompleted { + self.isTaskCompleted = true + completion() + } + } + } + + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + timeoutInterval) { + finaliseRiskSDKCalls() + self.logManager.queue(event: .riskSDKTimeOut) + } + + self.riskSDK.configure { [weak self] configurationResult in + guard let self else { return } + switch configurationResult { + case .failure: + finaliseRiskSDKCalls() + self.logManager.resetCorrelationID() + case .success(): + self.riskSDK.publishData(cardToken: tokenDetails.token) { _ in + self.logManager.queue(event: .riskSDKCompletion) + finaliseRiskSDKCalls() + self.logManager.resetCorrelationID() + } + } + } + } + private func logTokenResponse(tokenResponseResult: NetworkRequestResult, paymentType: TokenRequest.TokenType, httpURLResponse: HTTPURLResponse?) { diff --git a/CheckoutTests/Stubs/StubRisk.swift b/CheckoutTests/Stubs/StubRisk.swift index b7985603..d2103a69 100644 --- a/CheckoutTests/Stubs/StubRisk.swift +++ b/CheckoutTests/Stubs/StubRisk.swift @@ -14,15 +14,24 @@ class StubRisk: RiskProtocol { var configureCalledCount = 0 var publishDataCalledCount = 0 - + + // If set to false, Risk SDK will hang and not call the completion block for that specific function. + // It will mimic the behaviour of a bug we have. We need to call Frames's completion block after the defined timeout period in that case. + var shouldConfigureFunctionCallCompletion: Bool = true + var shouldPublishFunctionCallCompletion: Bool = true + func configure(completion: @escaping (Result) -> Void) { configureCalledCount += 1 - completion(.success(())) + if shouldConfigureFunctionCallCompletion { + completion(.success(())) + } } func publishData (cardToken: String? = nil, completion: @escaping (Result) -> Void) { publishDataCalledCount += 1 - completion(.success(PublishRiskData(deviceSessionId: "dsid_testDeviceSessionId"))) + if shouldPublishFunctionCallCompletion { + completion(.success(PublishRiskData(deviceSessionId: "dsid_testDeviceSessionId"))) + } } } diff --git a/CheckoutTests/Tokenisation/CheckoutAPIServiceTests.swift b/CheckoutTests/Tokenisation/CheckoutAPIServiceTests.swift index 98780039..a33ec53d 100644 --- a/CheckoutTests/Tokenisation/CheckoutAPIServiceTests.swift +++ b/CheckoutTests/Tokenisation/CheckoutAPIServiceTests.swift @@ -354,3 +354,67 @@ extension CheckoutAPIServiceTests { } } } + +// Risk SDK Timeout Recovery Tests +extension CheckoutAPIServiceTests { + func testWhenRiskSDKCallsCompletionThenFramesReturnsSuccess() { + let card = StubProvider.createCard() + let tokenRequest = StubProvider.createTokenRequest() + let requestParameters = StubProvider.createRequestParameters() + let tokenResponse = StubProvider.createTokenResponse() + let tokenDetails = StubProvider.createTokenDetails() + + stubTokenRequestFactory.createToReturn = .success(tokenRequest) + stubRequestFactory.createToReturn = .success(requestParameters) + stubTokenDetailsFactory.createToReturn = tokenDetails + + var result: Result? + subject.createToken(.card(card)) { result = $0 } + stubRequestExecutor.executeCalledWithCompletion?(.response(tokenResponse), HTTPURLResponse()) + + XCTAssertEqual(stubRisk.configureCalledCount, 1) + XCTAssertEqual(stubRisk.publishDataCalledCount, 1) + XCTAssertEqual(result, .success(tokenDetails)) + } + + func testWhenRiskSDKConfigureHangsThenFramesSDKCancelsWaitingRiskSDKAndCallsCompletionBlockAnywayAfterTimeout() { + stubRisk.shouldConfigureFunctionCallCompletion = false // Configure function will hang forever before it calls its completion closure + verifyRiskSDKTimeoutRecovery(timeoutAddition: 1, expectedConfigureCallCount: 1, expectedPublishDataCallCount: 0) + } + + func testWhenRiskSDKPublishHangsThenFramesSDKCancelsWaitingRiskSDKAndCallsCompletionBlockAnywayAfterTimeout() { + stubRisk.shouldPublishFunctionCallCompletion = false // Publish data function will hang forever before it calls its completion closure + verifyRiskSDKTimeoutRecovery(timeoutAddition: 1, expectedConfigureCallCount: 1, expectedPublishDataCallCount: 1) + } + + func verifyRiskSDKTimeoutRecovery(timeoutAddition: Double, + expectedConfigureCallCount: Int, + expectedPublishDataCallCount: Int, + file: StaticString = #file, + line: UInt = #line) { + let card = StubProvider.createCard() + let tokenRequest = StubProvider.createTokenRequest() + let tokenResponse = StubProvider.createTokenResponse() + let requestParameters = StubProvider.createRequestParameters() + let tokenDetails = StubProvider.createTokenDetails() + + stubTokenRequestFactory.createToReturn = .success(tokenRequest) + stubRequestFactory.createToReturn = .success(requestParameters) + stubTokenDetailsFactory.createToReturn = tokenDetails + + let expectation = self.expectation(description: "Frames will time out awaiting Risk SDK result") + + var _: Result? + subject.createToken(.card(card)) { + + XCTAssertEqual(self.stubRisk.configureCalledCount, expectedConfigureCallCount, file: file, line: line) + XCTAssertEqual(self.stubRisk.publishDataCalledCount, expectedPublishDataCallCount, file: file, line: line) + XCTAssertEqual($0, .success(tokenDetails), file: file, line: line) + + expectation.fulfill() + } + stubRequestExecutor.executeCalledWithCompletion?(.response(tokenResponse), HTTPURLResponse()) + + waitForExpectations(timeout: subject.timeoutInterval + timeoutAddition) + } +} diff --git a/iOS Example Frame SPM/iOS Example Frame SPM.xcodeproj/project.pbxproj b/iOS Example Frame SPM/iOS Example Frame SPM.xcodeproj/project.pbxproj index 66595624..49aed8d5 100644 --- a/iOS Example Frame SPM/iOS Example Frame SPM.xcodeproj/project.pbxproj +++ b/iOS Example Frame SPM/iOS Example Frame SPM.xcodeproj/project.pbxproj @@ -1238,8 +1238,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/checkout/frames-ios"; requirement = { - kind = exactVersion; - version = 4.3.6; + branch = "feature/risk-sdk-timeout-recovery"; + kind = branch; }; }; 16C3F83E2A7927ED00690639 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */ = { diff --git a/iOS Example Frame/Podfile b/iOS Example Frame/Podfile index aba082ef..92650ef9 100644 --- a/iOS Example Frame/Podfile +++ b/iOS Example Frame/Podfile @@ -6,7 +6,8 @@ target 'iOS Example Frame' do use_frameworks! # Pods for iOS Example Custom - pod 'Frames', '4.3.6' +# pod 'Frames', '4.3.6' + pod 'Frames', :git => 'https://github.com/checkout/frames-ios.git', :branch => 'feature/risk-sdk-timeout-recovery' end