From 9d117321e87e261101a9968ed5d7a1042112573d Mon Sep 17 00:00:00 2001 From: Niall Date: Tue, 23 Jul 2024 17:36:30 +0200 Subject: [PATCH 01/34] Added poc of config caching --- .../PrimerAPIConfigurationModule.swift | 104 ++++++++++++++++-- 1 file changed, 94 insertions(+), 10 deletions(-) diff --git a/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift b/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift index 8d2ee07e82..d651113529 100644 --- a/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift +++ b/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift @@ -8,6 +8,9 @@ internal protocol PrimerAPIConfigurationModuleProtocol { static var clientToken: JWTToken? { get } static var decodedJWTToken: DecodedJWTToken? { get } static var apiConfiguration: PrimerAPIConfiguration? { get } + +// static var cache: NSCache { get } + static func resetSession() func setupSession( @@ -23,8 +26,12 @@ internal protocol PrimerAPIConfigurationModuleProtocol { // swiftlint:disable type_body_length internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtocol { + private static let cache = NSCache() static var apiClient: PrimerAPIClientProtocol? + private static let queue = DispatchQueue(label: "com.primer.configurationQueue") + private static var pendingPromises: [String: Promise] = [:] + static var clientToken: JWTToken? { get { if PrimerAPIConfigurationModule.decodedJWTToken == nil { @@ -254,18 +261,67 @@ internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtoco return } - let requestParameters = Request.URLParameters.Configuration( - skipPaymentMethodTypes: [], - requestDisplayMetadata: requestDisplayMetadata) + let cacheKey = "\(clientToken)-\(requestDisplayMetadata)" as NSString + + PrimerAPIConfigurationModule.queue.sync { + if let cachedConfig = PrimerAPIConfigurationModule.cache.object(forKey: cacheKey), + validateCachedConfig(key: cacheKey, cachedData: cachedConfig) { + + print("CACHE HIT") + let event = Analytics.Event.message( + message: "Configuration cache hit with key: \(cacheKey)", + messageType: .info, + severity: .info + ) + Analytics.Service.record(event: event) + seal.fulfill(cachedConfig.config) + return + } else { + // New clientToken, clear the cache? + Self.clearCache() + } - let apiClient: PrimerAPIClientProtocol = PrimerAPIConfigurationModule.apiClient ?? PrimerAPIClient() - apiClient.fetchConfiguration(clientToken: clientToken, requestParameters: requestParameters) { (result) in - switch result { - case .failure(let err): - seal.reject(err) - case .success(let config): - _ = ImageFileProcessor().process(configuration: config).ensure { + if let pendingPromise = PrimerAPIConfigurationModule.pendingPromises[cacheKey as String] { + pendingPromise.done { config in seal.fulfill(config) + }.catch { error in + seal.reject(error) + } + return + } + + let promise = Promise { innerSeal in + let requestParameters = Request.URLParameters.Configuration( + skipPaymentMethodTypes: [], + requestDisplayMetadata: requestDisplayMetadata) + + let apiClient: PrimerAPIClientProtocol = PrimerAPIConfigurationModule.apiClient ?? PrimerAPIClient() + apiClient.fetchConfiguration(clientToken: clientToken, requestParameters: requestParameters) { (result) in + switch result { + case .failure(let err): + innerSeal.reject(err) + case .success(let config): + _ = ImageFileProcessor().process(configuration: config).ensure { + // Cache the result + let cachedData = CachedData(config: config) + PrimerAPIConfigurationModule.cache.setObject(cachedData, forKey: cacheKey) + innerSeal.fulfill(config) + } + } + } + } + + PrimerAPIConfigurationModule.pendingPromises[cacheKey as String] = promise + + promise.done { config in + seal.fulfill(config) + }.catch { error in + seal.reject(error) + } + + promise.ensure { + PrimerAPIConfigurationModule.queue.async { + PrimerAPIConfigurationModule.pendingPromises.removeValue(forKey: cacheKey as String) } } } @@ -307,5 +363,33 @@ internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtoco ) Analytics.Service.record(event: event) } + + private static func clearCache() { + Self.cache.removeAllObjects() + } + + private static let CacheExpiration: TimeInterval = 60 * 60 * 1000 + private func validateCachedConfig(key: NSString, cachedData: CachedData) -> Bool { + let timestamp = cachedData.timestamp + let now = Date().timeIntervalSince1970 + let timeInterval = now - timestamp + + if (now - timestamp) > Self.CacheExpiration { + Self.cache.removeObject(forKey: key) + return false + } + + return true + } + + private class CachedData { + let config: PrimerAPIConfiguration + let timestamp: TimeInterval + + init(config: PrimerAPIConfiguration) { + self.config = config + self.timestamp = Date().timeIntervalSince1970 + } + } } // swiftlint:enable type_body_length From 29d48dcc81ac563f31c82db2e4d02551cad7e2d1 Mon Sep 17 00:00:00 2001 From: Niall Date: Wed, 31 Jul 2024 11:41:50 +0200 Subject: [PATCH 02/34] Extend network service to bubble up headers when wanted --- .../Data Models/PrimerConfiguration.swift | 1 + .../PCI/Services/DefaultNetworkService.swift | 22 ++++++++++------- .../Services/Network/NetworkService.swift | 4 ++++ .../Services/Network/PrimerAPIClient.swift | 24 +++++++++++++++---- .../Network/PrimerAPIClientProtocol.swift | 5 ++-- 5 files changed, 42 insertions(+), 14 deletions(-) diff --git a/Sources/PrimerSDK/Classes/Data Models/PrimerConfiguration.swift b/Sources/PrimerSDK/Classes/Data Models/PrimerConfiguration.swift index 2cd3c15d20..880bcb61db 100644 --- a/Sources/PrimerSDK/Classes/Data Models/PrimerConfiguration.swift +++ b/Sources/PrimerSDK/Classes/Data Models/PrimerConfiguration.swift @@ -11,6 +11,7 @@ import Foundation import PassKit typealias PrimerAPIConfiguration = Response.Body.Configuration +typealias PrimerAPIConfigurationResponse = (config: Response.Body.Configuration, ttl: TimeInterval) extension Request.URLParameters { diff --git a/Sources/PrimerSDK/Classes/PCI/Services/DefaultNetworkService.swift b/Sources/PrimerSDK/Classes/PCI/Services/DefaultNetworkService.swift index 3df211b43e..1e6d978303 100644 --- a/Sources/PrimerSDK/Classes/PCI/Services/DefaultNetworkService.swift +++ b/Sources/PrimerSDK/Classes/PCI/Services/DefaultNetworkService.swift @@ -57,8 +57,7 @@ class DefaultNetworkService: NetworkService, LogReporter { self.reportingService = DefaultNetworkReportingService(analyticsService: analyticsService) } - func request(_ endpoint: Endpoint, completion: @escaping ResponseCompletion) -> PrimerCancellable? where T: Decodable { - + func request(_ endpoint: any Endpoint, completion: @escaping ResponseCompletionWithHeaders) -> (any PrimerCancellable)? where T : Decodable { do { let request = try requestFactory.request(for: endpoint) @@ -76,7 +75,7 @@ class DefaultNetworkService: NetworkService, LogReporter { case .success(let theResponse): response = theResponse case .failure(let error): - completion(.failure(error)) + completion(.failure(error), nil) return } @@ -87,29 +86,36 @@ class DefaultNetworkService: NetworkService, LogReporter { if let error = response.error { completion(.failure(InternalError.underlyingErrors(errors: [error], userInfo: .errorUserInfoDictionary(), - diagnosticsId: UUID().uuidString))) + diagnosticsId: UUID().uuidString)), nil) return } self.logger.debug(message: response.metadata.description) guard let data = response.data else { completion(.failure(InternalError.noData(userInfo: .errorUserInfoDictionary(), - diagnosticsId: UUID().uuidString))) + diagnosticsId: UUID().uuidString)), nil) return } do { + let responseHeaders = response.metadata.headers let response: T = try endpoint.responseFactory.model(for: data, forMetadata: response.metadata) - completion(.success(response)) + completion(.success(response), responseHeaders) } catch { - completion(.failure(error)) + completion(.failure(error), nil) } } } catch { ErrorHandler.handle(error: error) - completion(.failure(error)) + completion(.failure(error), nil) return nil } } + + func request(_ endpoint: Endpoint, completion: @escaping ResponseCompletion) -> PrimerCancellable? where T: Decodable { + request(endpoint) { result, _ in + completion(result) + } + } } diff --git a/Sources/PrimerSDK/Classes/Services/Network/NetworkService.swift b/Sources/PrimerSDK/Classes/Services/Network/NetworkService.swift index 936fb79f1d..9ff6d60f39 100644 --- a/Sources/PrimerSDK/Classes/Services/Network/NetworkService.swift +++ b/Sources/PrimerSDK/Classes/Services/Network/NetworkService.swift @@ -8,8 +8,12 @@ import Foundation typealias ResponseCompletion = (Result) -> Void +typealias ResponseCompletionWithHeaders = (Result, [String: String]?) -> Void internal protocol NetworkService { @discardableResult func request(_ endpoint: Endpoint, completion: @escaping ResponseCompletion) -> PrimerCancellable? + + @discardableResult + func request(_ endpoint: Endpoint, completion: @escaping ResponseCompletionWithHeaders) -> PrimerCancellable? } diff --git a/Sources/PrimerSDK/Classes/Services/Network/PrimerAPIClient.swift b/Sources/PrimerSDK/Classes/Services/Network/PrimerAPIClient.swift index a1441bd586..3184d09508 100644 --- a/Sources/PrimerSDK/Classes/Services/Network/PrimerAPIClient.swift +++ b/Sources/PrimerSDK/Classes/Services/Network/PrimerAPIClient.swift @@ -76,9 +76,17 @@ internal class PrimerAPIClient: PrimerAPIClientProtocol { func fetchConfiguration(clientToken: DecodedJWTToken, requestParameters: Request.URLParameters.Configuration?, - completion: @escaping APICompletion) { + completion: @escaping ConfigurationCompletion) { let endpoint = PrimerAPI.fetchConfiguration(clientToken: clientToken, requestParameters: requestParameters) - execute(endpoint, completion: completion) + networkService.request(endpoint) { (result: Result, headers) in + switch result { + case .success(let result): + completion(.success(result), headers) + case .failure(let error): + ErrorHandler.shared.handle(error: error) + completion(.failure(error), nil) + } + } } func createPayPalOrderSession(clientToken: DecodedJWTToken, @@ -157,10 +165,18 @@ internal class PrimerAPIClient: PrimerAPIClientProtocol { func requestPrimerConfigurationWithActions(clientToken: DecodedJWTToken, request: ClientSessionUpdateRequest, - completion: @escaping APICompletion) { + completion: @escaping ConfigurationCompletion) { let endpoint = PrimerAPI.requestPrimerConfigurationWithActions(clientToken: clientToken, request: request) - execute(endpoint, completion: completion) + networkService.request(endpoint) { (result: Result, headers) in + switch result { + case .success(let result): + completion(.success(result), headers) + case .failure(let error): + ErrorHandler.shared.handle(error: error) + completion(.failure(error), nil) + } + } } func sendAnalyticsEvents(clientToken: DecodedJWTToken?, diff --git a/Sources/PrimerSDK/Classes/Services/Network/PrimerAPIClientProtocol.swift b/Sources/PrimerSDK/Classes/Services/Network/PrimerAPIClientProtocol.swift index 365c794f77..b3ff60ecf1 100644 --- a/Sources/PrimerSDK/Classes/Services/Network/PrimerAPIClientProtocol.swift +++ b/Sources/PrimerSDK/Classes/Services/Network/PrimerAPIClientProtocol.swift @@ -9,6 +9,7 @@ import Foundation typealias APIResult = Result typealias APICompletion = (APIResult) -> Void +typealias ConfigurationCompletion = (Result, [String: String]?) -> Void protocol PrimerAPIClientProtocol: PrimerAPIClientAnalyticsProtocol, @@ -23,7 +24,7 @@ protocol PrimerAPIClientProtocol: func fetchConfiguration( clientToken: DecodedJWTToken, requestParameters: Request.URLParameters.Configuration?, - completion: @escaping APICompletion) + completion: @escaping ConfigurationCompletion) func validateClientToken( request: Request.Body.ClientTokenValidation, @@ -31,7 +32,7 @@ protocol PrimerAPIClientProtocol: func requestPrimerConfigurationWithActions(clientToken: DecodedJWTToken, request: ClientSessionUpdateRequest, - completion: @escaping APICompletion) + completion: @escaping ConfigurationCompletion) // MARK: Klarna From b29480d48eb728d481f919ae69d998dad69f5e2d Mon Sep 17 00:00:00 2001 From: Niall Date: Wed, 31 Jul 2024 11:42:07 +0200 Subject: [PATCH 03/34] Dont utilise headers in klarna configs --- .../Klarna/Components/KlarnaTokenizationComponent.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Components/KlarnaTokenizationComponent.swift b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Components/KlarnaTokenizationComponent.swift index a8e745b260..d78392968c 100644 --- a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Components/KlarnaTokenizationComponent.swift +++ b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Components/KlarnaTokenizationComponent.swift @@ -183,7 +183,7 @@ private extension KlarnaTokenizationComponent { private func requestPrimerConfiguration(decodedJWTToken: DecodedJWTToken, request: ClientSessionUpdateRequest) -> Promise { return Promise { seal in apiClient.requestPrimerConfigurationWithActions(clientToken: decodedJWTToken, - request: request) { [weak self] result in + request: request) { [weak self] result, _ in guard let self = self else { return } switch result { case .success(let configuration): From daae31c614f44cc5783901f2d0b2342e535893dd Mon Sep 17 00:00:00 2001 From: Niall Date: Wed, 31 Jul 2024 13:14:54 +0200 Subject: [PATCH 04/34] Added Swift-friendly Cache --- .../PrimerSDK/Classes/Core/Cache/Cache.swift | 50 +++++++++++++++++++ .../ConfigurationCache.swift | 8 +++ 2 files changed, 58 insertions(+) create mode 100644 Sources/PrimerSDK/Classes/Core/Cache/Cache.swift create mode 100644 Sources/PrimerSDK/Classes/Core/Cache/ConfigurationCache/ConfigurationCache.swift diff --git a/Sources/PrimerSDK/Classes/Core/Cache/Cache.swift b/Sources/PrimerSDK/Classes/Core/Cache/Cache.swift new file mode 100644 index 0000000000..c2fea1640d --- /dev/null +++ b/Sources/PrimerSDK/Classes/Core/Cache/Cache.swift @@ -0,0 +1,50 @@ +// +// Cache.swift +// PrimerSDK +// +// Created by Niall Quinn on 31/07/24. +// + +import Foundation + +class Cache { + private let cache = NSCache() + + func insert(_ value: Value, forKey key: Key) { + cache.setObject(Entry(value: value), forKey: WrappedKey(key)) + } + + func value(forKey key: Key) -> Value? { + guard let entry = cache.object(forKey: WrappedKey(key)) else { + return nil + } + return entry.value + } + + func removeValue(forKey key: Key) { + cache.removeObject(forKey: WrappedKey(key)) + } +} + +private extension Cache { + final class WrappedKey: NSObject { + let key: Key + init(_ key: Key) { self.key = key } + + override var hash: Int { return key.hashValue } + + override func isEqual(_ object: Any?) -> Bool { + guard let value = object as? WrappedKey else { + return false + } + return value.key == key + } + } +} + +private extension Cache { + final class Entry { + let value: Value + init(value: Value) { self.value = value } + } +} diff --git a/Sources/PrimerSDK/Classes/Core/Cache/ConfigurationCache/ConfigurationCache.swift b/Sources/PrimerSDK/Classes/Core/Cache/ConfigurationCache/ConfigurationCache.swift new file mode 100644 index 0000000000..e6da8e8552 --- /dev/null +++ b/Sources/PrimerSDK/Classes/Core/Cache/ConfigurationCache/ConfigurationCache.swift @@ -0,0 +1,8 @@ +// +// ConfigurationCache.swift +// PrimerSDK +// +// Created by Niall Quinn on 31/07/24. +// + +import Foundation From 0e7114ceb250e2a7d117aa8aa8a56559aa7f30f9 Mon Sep 17 00:00:00 2001 From: Niall Date: Wed, 31 Jul 2024 13:15:08 +0200 Subject: [PATCH 05/34] Refactored caching logic to dedicated class --- .../ConfigurationCache.swift | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/Sources/PrimerSDK/Classes/Core/Cache/ConfigurationCache/ConfigurationCache.swift b/Sources/PrimerSDK/Classes/Core/Cache/ConfigurationCache/ConfigurationCache.swift index e6da8e8552..7aaf9e341c 100644 --- a/Sources/PrimerSDK/Classes/Core/Cache/ConfigurationCache/ConfigurationCache.swift +++ b/Sources/PrimerSDK/Classes/Core/Cache/ConfigurationCache/ConfigurationCache.swift @@ -6,3 +6,68 @@ // import Foundation + +protocol ConfigurationCaching { + func clearCache() + func setData(_ data: ConfigurationCachedData, forKey key: String) + func data(forKey key: String) -> ConfigurationCachedData? +} + +class ConfigurationCache: ConfigurationCaching { + static let shared = ConfigurationCache() + private var cache = Cache() + + func clearCache() { + cache = Cache() + } + + func data(forKey key: String) -> ConfigurationCachedData? { + if let cachedData = cache.value(forKey: key) { + if validateCachedConfig(key: key, cachedData: cachedData) == false { + cache.removeValue(forKey: key) + return nil + } + return cachedData + } + return nil + } + + func setData(_ data: ConfigurationCachedData, forKey key: String) { + // Cache includes at most one cached configuration + clearCache() + cache.insert(data, forKey: key) + } + + private func validateCachedConfig(key: String, cachedData: ConfigurationCachedData) -> Bool { + let timestamp = cachedData.timestamp + let now = Date().timeIntervalSince1970 + let timeInterval = now - timestamp + + if timeInterval > cachedData.ttl { + return false + } + + return true + } +} + +class ConfigurationCachedData { + let config: PrimerAPIConfiguration + let timestamp: TimeInterval + let ttl: TimeInterval + + init(config: PrimerAPIConfiguration, ttl: TimeInterval) { + self.config = config + self.timestamp = Date().timeIntervalSince1970 + self.ttl = ttl + } + + init(config: PrimerAPIConfiguration, headers: [String: String]? = nil) { + //Extract ttl from headers + self.config = config + self.timestamp = Date().timeIntervalSince1970 + self.ttl = Self.FallbackCacheExpiration + } + + static let FallbackCacheExpiration: TimeInterval = 60 * 60 * 1000 +} From da839d807ad16fa77b265c34d75399349e35ccc0 Mon Sep 17 00:00:00 2001 From: Niall Date: Wed, 31 Jul 2024 13:15:24 +0200 Subject: [PATCH 06/34] use refactored cache --- .../PrimerAPIConfigurationModule.swift | 70 ++++++------------- 1 file changed, 23 insertions(+), 47 deletions(-) diff --git a/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift b/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift index d651113529..3da0745bd5 100644 --- a/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift +++ b/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift @@ -9,8 +9,6 @@ internal protocol PrimerAPIConfigurationModuleProtocol { static var decodedJWTToken: DecodedJWTToken? { get } static var apiConfiguration: PrimerAPIConfiguration? { get } -// static var cache: NSCache { get } - static func resetSession() func setupSession( @@ -26,12 +24,13 @@ internal protocol PrimerAPIConfigurationModuleProtocol { // swiftlint:disable type_body_length internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtocol { - private static let cache = NSCache() static var apiClient: PrimerAPIClientProtocol? private static let queue = DispatchQueue(label: "com.primer.configurationQueue") private static var pendingPromises: [String: Promise] = [:] + private let logger = PrimerLogging.shared.logger + static var clientToken: JWTToken? { get { if PrimerAPIConfigurationModule.decodedJWTToken == nil { @@ -73,6 +72,13 @@ internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtoco return decodedJWTToken } + static var cacheKey: String? { + guard let cacheKey = Self.clientToken else { + return nil + } + return cacheKey + } + static func resetSession() { AppState.current.clientToken = nil AppState.current.apiConfiguration = nil @@ -109,7 +115,8 @@ internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtoco func updateSession(withActions actionsRequest: ClientSessionUpdateRequest) -> Promise { return Promise { seal in - guard let decodedJWTToken = PrimerAPIConfigurationModule.decodedJWTToken else { + guard let decodedJWTToken = PrimerAPIConfigurationModule.decodedJWTToken, + let cacheKey = Self.cacheKey else { let err = PrimerError.invalidClientToken(userInfo: .errorUserInfoDictionary(), diagnosticsId: UUID().uuidString) ErrorHandler.handle(error: err) @@ -119,10 +126,12 @@ internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtoco let apiClient: PrimerAPIClientProtocol = PrimerAPIConfigurationModule.apiClient ?? PrimerAPIClient() apiClient.requestPrimerConfigurationWithActions(clientToken: decodedJWTToken, - request: actionsRequest) { result in + request: actionsRequest) { result, responseHeaders in switch result { case .success(let configuration): PrimerAPIConfigurationModule.apiConfiguration?.clientSession = configuration.clientSession + let cachedData = ConfigurationCachedData(config: configuration, headers: responseHeaders) + ConfigurationCache.shared.setData(cachedData, forKey: cacheKey) seal.fulfill() case .failure(let err): seal.reject(err) @@ -251,9 +260,11 @@ internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtoco } } + // swiftlint:disable:next function_body_length private func fetchConfiguration(requestDisplayMetadata: Bool) -> Promise { return Promise { seal in - guard let clientToken = PrimerAPIConfigurationModule.decodedJWTToken else { + guard let clientToken = PrimerAPIConfigurationModule.decodedJWTToken, + let cacheKey = Self.cacheKey else { let err = PrimerError.invalidClientToken(userInfo: .errorUserInfoDictionary(), diagnosticsId: UUID().uuidString) ErrorHandler.handle(error: err) @@ -261,24 +272,17 @@ internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtoco return } - let cacheKey = "\(clientToken)-\(requestDisplayMetadata)" as NSString - PrimerAPIConfigurationModule.queue.sync { - if let cachedConfig = PrimerAPIConfigurationModule.cache.object(forKey: cacheKey), - validateCachedConfig(key: cacheKey, cachedData: cachedConfig) { - - print("CACHE HIT") + if let cachedConfig = ConfigurationCache.shared.data(forKey: cacheKey) { let event = Analytics.Event.message( message: "Configuration cache hit with key: \(cacheKey)", messageType: .info, severity: .info ) Analytics.Service.record(event: event) + logger.debug(message: "Cached config used") seal.fulfill(cachedConfig.config) return - } else { - // New clientToken, clear the cache? - Self.clearCache() } if let pendingPromise = PrimerAPIConfigurationModule.pendingPromises[cacheKey as String] { @@ -294,17 +298,17 @@ internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtoco let requestParameters = Request.URLParameters.Configuration( skipPaymentMethodTypes: [], requestDisplayMetadata: requestDisplayMetadata) - + let apiClient: PrimerAPIClientProtocol = PrimerAPIConfigurationModule.apiClient ?? PrimerAPIClient() - apiClient.fetchConfiguration(clientToken: clientToken, requestParameters: requestParameters) { (result) in + apiClient.fetchConfiguration(clientToken: clientToken, requestParameters: requestParameters) { (result, responseHeaders) in switch result { case .failure(let err): innerSeal.reject(err) case .success(let config): _ = ImageFileProcessor().process(configuration: config).ensure { // Cache the result - let cachedData = CachedData(config: config) - PrimerAPIConfigurationModule.cache.setObject(cachedData, forKey: cacheKey) + let cachedData = ConfigurationCachedData(config: config, headers: responseHeaders) + ConfigurationCache.shared.setData(cachedData, forKey: cacheKey) innerSeal.fulfill(config) } } @@ -363,33 +367,5 @@ internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtoco ) Analytics.Service.record(event: event) } - - private static func clearCache() { - Self.cache.removeAllObjects() - } - - private static let CacheExpiration: TimeInterval = 60 * 60 * 1000 - private func validateCachedConfig(key: NSString, cachedData: CachedData) -> Bool { - let timestamp = cachedData.timestamp - let now = Date().timeIntervalSince1970 - let timeInterval = now - timestamp - - if (now - timestamp) > Self.CacheExpiration { - Self.cache.removeObject(forKey: key) - return false - } - - return true - } - - private class CachedData { - let config: PrimerAPIConfiguration - let timestamp: TimeInterval - - init(config: PrimerAPIConfiguration) { - self.config = config - self.timestamp = Date().timeIntervalSince1970 - } - } } // swiftlint:enable type_body_length From f9d54db2d8d7f7e2661151f4356f155750c7f9c2 Mon Sep 17 00:00:00 2001 From: Niall Date: Thu, 1 Aug 2024 15:59:54 +0200 Subject: [PATCH 07/34] Extract ttl from headers --- .../ConfigurationCache.swift | 21 ++++++++++++------- .../Utils/ConfigurationCacheTests.swift | 8 +++++++ 2 files changed, 21 insertions(+), 8 deletions(-) create mode 100644 Tests/Primer/Utils/ConfigurationCacheTests.swift diff --git a/Sources/PrimerSDK/Classes/Core/Cache/ConfigurationCache/ConfigurationCache.swift b/Sources/PrimerSDK/Classes/Core/Cache/ConfigurationCache/ConfigurationCache.swift index 7aaf9e341c..3542689ced 100644 --- a/Sources/PrimerSDK/Classes/Core/Cache/ConfigurationCache/ConfigurationCache.swift +++ b/Sources/PrimerSDK/Classes/Core/Cache/ConfigurationCache/ConfigurationCache.swift @@ -52,22 +52,27 @@ class ConfigurationCache: ConfigurationCaching { } class ConfigurationCachedData { + let config: PrimerAPIConfiguration let timestamp: TimeInterval let ttl: TimeInterval - init(config: PrimerAPIConfiguration, ttl: TimeInterval) { - self.config = config - self.timestamp = Date().timeIntervalSince1970 - self.ttl = ttl - } - init(config: PrimerAPIConfiguration, headers: [String: String]? = nil) { //Extract ttl from headers self.config = config self.timestamp = Date().timeIntervalSince1970 - self.ttl = Self.FallbackCacheExpiration + self.ttl = Self.extractTtlFromHeaders(headers) } - static let FallbackCacheExpiration: TimeInterval = 60 * 60 * 1000 + static let FallbackCacheExpiration: TimeInterval = 3600 + static let CacheHeaderKey = "x-primer-session-cache-ttl" + + private static func extractTtlFromHeaders(_ headers: [String: String]?) -> TimeInterval { + guard let headers, + let ttlHeaderValue = headers[Self.CacheHeaderKey], + let ttlInt = Int(ttlHeaderValue) else { + return Self.FallbackCacheExpiration + } + return TimeInterval(ttlInt) + } } diff --git a/Tests/Primer/Utils/ConfigurationCacheTests.swift b/Tests/Primer/Utils/ConfigurationCacheTests.swift new file mode 100644 index 0000000000..6fd4fb7504 --- /dev/null +++ b/Tests/Primer/Utils/ConfigurationCacheTests.swift @@ -0,0 +1,8 @@ +// +// File.swift +// +// +// Created by Niall Quinn on 01/08/24. +// + +import Foundation From 21a438e8c92b0cf165ee907bc7b679f80e5573f0 Mon Sep 17 00:00:00 2001 From: Niall Date: Thu, 1 Aug 2024 16:00:07 +0200 Subject: [PATCH 08/34] Process images also for actions --- .../PrimerAPIConfigurationModule.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift b/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift index 3da0745bd5..36f868e5bc 100644 --- a/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift +++ b/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift @@ -129,10 +129,12 @@ internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtoco request: actionsRequest) { result, responseHeaders in switch result { case .success(let configuration): - PrimerAPIConfigurationModule.apiConfiguration?.clientSession = configuration.clientSession - let cachedData = ConfigurationCachedData(config: configuration, headers: responseHeaders) - ConfigurationCache.shared.setData(cachedData, forKey: cacheKey) - seal.fulfill() + _ = ImageFileProcessor().process(configuration: configuration).ensure { + PrimerAPIConfigurationModule.apiConfiguration?.clientSession = configuration.clientSession + let cachedData = ConfigurationCachedData(config: configuration, headers: responseHeaders) + ConfigurationCache.shared.setData(cachedData, forKey: cacheKey) + seal.fulfill() + } case .failure(let err): seal.reject(err) } From 5d3c7e44029b198ee083c45498f4b159f78ff28c Mon Sep 17 00:00:00 2001 From: Niall Date: Thu, 1 Aug 2024 16:01:17 +0200 Subject: [PATCH 09/34] Upgrade config endpoints to 2.3 --- .../PrimerSDK/Classes/PCI/Services/API/Primer/PrimerAPI.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/PrimerSDK/Classes/PCI/Services/API/Primer/PrimerAPI.swift b/Sources/PrimerSDK/Classes/PCI/Services/API/Primer/PrimerAPI.swift index f055a42bd3..5ffb089358 100644 --- a/Sources/PrimerSDK/Classes/PCI/Services/API/Primer/PrimerAPI.swift +++ b/Sources/PrimerSDK/Classes/PCI/Services/API/Primer/PrimerAPI.swift @@ -166,7 +166,7 @@ internal extension PrimerAPI { case .exchangePaymentMethodToken: tmpHeaders["X-Api-Version"] = "2.2" case .fetchConfiguration: - tmpHeaders["X-Api-Version"] = "2.2" + tmpHeaders["X-Api-Version"] = "2.3" case .fetchVaultedPaymentMethods: tmpHeaders["X-Api-Version"] = "2.2" case .deleteVaultedPaymentMethod: @@ -190,7 +190,7 @@ internal extension PrimerAPI { case .listRetailOutlets: break case .requestPrimerConfigurationWithActions: - tmpHeaders["X-Api-Version"] = "2.2" + tmpHeaders["X-Api-Version"] = "2.3" case .begin3DSRemoteAuth: tmpHeaders["X-Api-Version"] = "2.1" case .continue3DSRemoteAuth: From f4e130e25190daa1ba30f7c3d73707865d3798a8 Mon Sep 17 00:00:00 2001 From: Niall Date: Thu, 1 Aug 2024 16:01:26 +0200 Subject: [PATCH 10/34] Add config cache tests --- .../Utils/ConfigurationCacheTests.swift | 61 ++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/Tests/Primer/Utils/ConfigurationCacheTests.swift b/Tests/Primer/Utils/ConfigurationCacheTests.swift index 6fd4fb7504..7464dce803 100644 --- a/Tests/Primer/Utils/ConfigurationCacheTests.swift +++ b/Tests/Primer/Utils/ConfigurationCacheTests.swift @@ -5,4 +5,63 @@ // Created by Niall Quinn on 01/08/24. // -import Foundation +import XCTest +@testable import PrimerSDK + +final class ConfigurationCacheTests: XCTestCase { + func test_useHeadersTTL() throws { + let headers = [ConfigurationCachedData.CacheHeaderKey: "2000"] + let cacheData = ConfigurationCachedData(config: PrimerAPIConfiguration.mock, headers: headers) + + XCTAssert(cacheData.ttl == 2000) + } + + + func test_useFallbackTTL() throws { + let headers = ["content-type": "application/json"] + let cacheData = ConfigurationCachedData(config: PrimerAPIConfiguration.mock, headers: headers) + + XCTAssert(cacheData.ttl == ConfigurationCachedData.FallbackCacheExpiration) + } + + func test_clearCache() throws { + let cache = ConfigurationCache() + + let headers = [ConfigurationCachedData.CacheHeaderKey: "2000"] + let cacheData = ConfigurationCachedData(config: PrimerAPIConfiguration.mock, headers: headers) + let cacheKey = "cache-key" + cache.setData(cacheData, forKey: cacheKey) + + XCTAssertNotNil(cache.data(forKey: cacheKey)) + + cache.clearCache() + XCTAssertNil(cache.data(forKey: cacheKey)) + } + + func test_expiredTTL() throws { + let cache = ConfigurationCache() + + let headers = [ConfigurationCachedData.CacheHeaderKey: "0"] + let cacheData = ConfigurationCachedData(config: PrimerAPIConfiguration.mock, headers: headers) + let cacheKey = "cache-key" + + cache.setData(cacheData, forKey: cacheKey) + + XCTAssertNil(cache.data(forKey: cacheKey)) + } +} + + +private extension PrimerAPIConfiguration { + static var mock: PrimerAPIConfiguration { + .init(coreUrl: "", + pciUrl: "", + binDataUrl: "", + assetsUrl: "", + clientSession: nil, + paymentMethods: nil, + primerAccountId: "", + keys: nil, + checkoutModules: nil) + } +} From 64f86cea90cd02e467116145d9c6b3368553881f Mon Sep 17 00:00:00 2001 From: Niall Date: Thu, 1 Aug 2024 16:01:42 +0200 Subject: [PATCH 11/34] Fix tests for new interface --- .../Mocks/Services/MockAPIClient.swift | 18 ++++++++---------- .../Mocks/Services/NetworkService.swift | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/Tests/Utilities/Mocks/Services/MockAPIClient.swift b/Tests/Utilities/Mocks/Services/MockAPIClient.swift index 4c9112ca86..f7010df536 100644 --- a/Tests/Utilities/Mocks/Services/MockAPIClient.swift +++ b/Tests/Utilities/Mocks/Services/MockAPIClient.swift @@ -60,11 +60,9 @@ class MockPrimerAPIClient: PrimerAPIClientProtocol { } } - func fetchConfiguration( - clientToken: DecodedJWTToken, - requestParameters: Request.URLParameters.Configuration?, - completion: @escaping (_ result: Result) -> Void - ) { + func fetchConfiguration(clientToken: PrimerSDK.DecodedJWTToken, + requestParameters: PrimerSDK.Request.URLParameters.Configuration?, + completion: @escaping PrimerSDK.ConfigurationCompletion) { guard let result = fetchConfigurationResult, result.0 != nil || result.1 != nil else { @@ -74,9 +72,9 @@ class MockPrimerAPIClient: PrimerAPIClientProtocol { DispatchQueue.main.asyncAfter(deadline: .now() + self.mockedNetworkDelay) { if let err = result.1 { - completion(.failure(err)) + completion(.failure(err), nil) } else if let successResult = result.0 { - completion(.success(successResult)) + completion(.success(successResult), [:]) } } } @@ -421,7 +419,7 @@ class MockPrimerAPIClient: PrimerAPIClientProtocol { func requestPrimerConfigurationWithActions( clientToken: DecodedJWTToken, request: ClientSessionUpdateRequest, - completion: @escaping (_ result: Result) -> Void + completion: @escaping PrimerSDK.ConfigurationCompletion ) { guard let result = fetchConfigurationWithActionsResult, result.0 != nil || result.1 != nil @@ -432,9 +430,9 @@ class MockPrimerAPIClient: PrimerAPIClientProtocol { DispatchQueue.main.asyncAfter(deadline: .now() + self.mockedNetworkDelay) { if let err = result.1 { - completion(.failure(err)) + completion(.failure(err), nil) } else if let successResult = result.0 { - completion(.success(successResult)) + completion(.success(successResult), [:]) } } } diff --git a/Tests/Utilities/Mocks/Services/NetworkService.swift b/Tests/Utilities/Mocks/Services/NetworkService.swift index c9bca02440..16e2acb771 100644 --- a/Tests/Utilities/Mocks/Services/NetworkService.swift +++ b/Tests/Utilities/Mocks/Services/NetworkService.swift @@ -10,6 +10,22 @@ import XCTest @testable import PrimerSDK class MockNetworkService: NetworkService { + func request(_ endpoint: any PrimerSDK.Endpoint, completion: @escaping PrimerSDK.ResponseCompletionWithHeaders) -> (any PrimerSDK.PrimerCancellable)? where T : Decodable { + onReceiveEndpoint?(endpoint) + + DispatchQueue.main.asyncAfter(deadline: .now() + mockedNetworkDelay) { + if let error = self.mockedError { + completion(.failure(error), nil) + } else if let result = self.mockedResult as? T { + completion(.success(result), [:]) + } else { + XCTFail("Failed to produce either a valid result or an error for requested endpoint") + } + } + + return nil + } + var mockedResult: Decodable? From 661f8dee0c72f17f3c198ff9ec984d954a4dd0e7 Mon Sep 17 00:00:00 2001 From: Niall Date: Thu, 1 Aug 2024 16:35:29 +0200 Subject: [PATCH 12/34] update fallback to 0 --- .../Core/Cache/ConfigurationCache/ConfigurationCache.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/PrimerSDK/Classes/Core/Cache/ConfigurationCache/ConfigurationCache.swift b/Sources/PrimerSDK/Classes/Core/Cache/ConfigurationCache/ConfigurationCache.swift index 3542689ced..6f2038095b 100644 --- a/Sources/PrimerSDK/Classes/Core/Cache/ConfigurationCache/ConfigurationCache.swift +++ b/Sources/PrimerSDK/Classes/Core/Cache/ConfigurationCache/ConfigurationCache.swift @@ -64,7 +64,7 @@ class ConfigurationCachedData { self.ttl = Self.extractTtlFromHeaders(headers) } - static let FallbackCacheExpiration: TimeInterval = 3600 + static let FallbackCacheExpiration: TimeInterval = 0 static let CacheHeaderKey = "x-primer-session-cache-ttl" private static func extractTtlFromHeaders(_ headers: [String: String]?) -> TimeInterval { From 25220ffc151078491468d961429e865bf01ba744 Mon Sep 17 00:00:00 2001 From: Niall Date: Thu, 1 Aug 2024 16:35:38 +0200 Subject: [PATCH 13/34] add tests for config module --- .../PrimerAPIConfigurationModuleTests.swift | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) diff --git a/Tests/Primer/Modules/PrimerAPIConfigurationModuleTests.swift b/Tests/Primer/Modules/PrimerAPIConfigurationModuleTests.swift index e6a6766253..51b806112c 100644 --- a/Tests/Primer/Modules/PrimerAPIConfigurationModuleTests.swift +++ b/Tests/Primer/Modules/PrimerAPIConfigurationModuleTests.swift @@ -49,4 +49,185 @@ class PrimerAPIConfigurationModuleTests: XCTestCase { wait(for: [expectation], timeout: 30.0) } + + func test_usesCachedConfig() throws { + let expectation = XCTestExpectation(description: "Uses Cached Config") + + let proxyId = "proxy-identifier" + + let config_pre = PrimerAPIConfiguration( + coreUrl: proxyId, + pciUrl: "https://pci.primer.io", + binDataUrl: "https://bindata.primer.io", + assetsUrl: "https://assets.staging.core.primer.io", + clientSession: nil, + paymentMethods: [], + primerAccountId: nil, + keys: nil, + checkoutModules: nil) + + let config_post = PrimerAPIConfiguration( + coreUrl: "https://core.primer.io", + pciUrl: "https://pci.primer.io", + binDataUrl: "https://bindata.primer.io", + assetsUrl: "https://assets.staging.core.primer.io", + clientSession: nil, + paymentMethods: [], + primerAccountId: nil, + keys: nil, + checkoutModules: nil) + + let mockApiClient = MockPrimerAPIClient(responseHeaders: [ConfigurationCachedData.CacheHeaderKey: "3600"]) + mockApiClient.validateClientTokenResult = (SuccessResponse(), nil) + mockApiClient.fetchConfigurationResult = (config_pre, nil) + + PrimerAPIConfigurationModule.apiClient = mockApiClient + + let apiConfigurationModule = PrimerAPIConfigurationModule() + + firstly { + apiConfigurationModule.setupSession(forClientToken: MockAppState.mockClientToken) + } + .then { () -> Promise in + XCTAssert(MockAppState.mockClientToken == PrimerAPIConfigurationModule.clientToken) + XCTAssert(PrimerAPIConfigurationModule.apiConfiguration?.coreUrl == config_pre.coreUrl) + + mockApiClient.fetchConfigurationResult = (config_post, nil) + + return apiConfigurationModule.setupSession(forClientToken: MockAppState.mockClientToken) + } + .done { + XCTAssert(PrimerAPIConfigurationModule.apiConfiguration?.coreUrl == config_pre.coreUrl) + expectation.fulfill() + + }.catch { err in + XCTAssert(false, err.localizedDescription) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 30.0) + } + + func test_clearsCache() throws { + let expectation = XCTestExpectation(description: "Uses Cached Config") + + let proxyId = "proxy-identifier" + + let config_pre = PrimerAPIConfiguration( + coreUrl: proxyId, + pciUrl: "https://pci.primer.io", + binDataUrl: "https://bindata.primer.io", + assetsUrl: "https://assets.staging.core.primer.io", + clientSession: nil, + paymentMethods: [], + primerAccountId: nil, + keys: nil, + checkoutModules: nil) + + let config_post = PrimerAPIConfiguration( + coreUrl: "https://core.primer.io", + pciUrl: "https://pci.primer.io", + binDataUrl: "https://bindata.primer.io", + assetsUrl: "https://assets.staging.core.primer.io", + clientSession: nil, + paymentMethods: [], + primerAccountId: nil, + keys: nil, + checkoutModules: nil) + + let mockApiClient = MockPrimerAPIClient(responseHeaders: [ConfigurationCachedData.CacheHeaderKey: "3600"]) + mockApiClient.validateClientTokenResult = (SuccessResponse(), nil) + mockApiClient.fetchConfigurationResult = (config_pre, nil) + + PrimerAPIConfigurationModule.apiClient = mockApiClient + + let apiConfigurationModule = PrimerAPIConfigurationModule() + + firstly { + apiConfigurationModule.setupSession(forClientToken: MockAppState.mockClientToken) + } + .then { () -> Promise in + XCTAssert(MockAppState.mockClientToken == PrimerAPIConfigurationModule.clientToken) + XCTAssert(PrimerAPIConfigurationModule.apiConfiguration?.coreUrl == config_pre.coreUrl) + ConfigurationCache.shared.clearCache() + + mockApiClient.fetchConfigurationResult = (config_post, nil) + + return apiConfigurationModule.setupSession(forClientToken: MockAppState.mockClientToken) + } + .done { + XCTAssert(PrimerAPIConfigurationModule.apiConfiguration?.coreUrl == config_post.coreUrl) + expectation.fulfill() + }.catch { err in + XCTAssert(false, err.localizedDescription) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 30.0) + } + + func test_actionsUsesCache() throws { + let expectation = XCTestExpectation(description: "Uses Cached Config") + + let proxyId = "proxy-identifier" + + let config_pre = PrimerAPIConfiguration( + coreUrl: proxyId, + pciUrl: "https://pci.primer.io", + binDataUrl: "https://bindata.primer.io", + assetsUrl: "https://assets.staging.core.primer.io", + clientSession: nil, + paymentMethods: [], + primerAccountId: nil, + keys: nil, + checkoutModules: nil) + + let config_post = PrimerAPIConfiguration( + coreUrl: "https://core.primer.io", + pciUrl: "https://pci.primer.io", + binDataUrl: "https://bindata.primer.io", + assetsUrl: "https://assets.staging.core.primer.io", + clientSession: nil, + paymentMethods: [], + primerAccountId: nil, + keys: nil, + checkoutModules: nil) + + let mockApiClient = MockPrimerAPIClient(responseHeaders: [ConfigurationCachedData.CacheHeaderKey: "3600"]) + mockApiClient.validateClientTokenResult = (SuccessResponse(), nil) + + mockApiClient.fetchConfigurationResult = (config_pre, nil) + mockApiClient.fetchConfigurationWithActionsResult = (config_post, nil) + + PrimerAPIConfigurationModule.apiClient = mockApiClient + + let apiConfigurationModule = PrimerAPIConfigurationModule() + + firstly { + apiConfigurationModule.setupSession(forClientToken: MockAppState.mockClientToken) + } + .then { () -> Promise in + XCTAssert(PrimerAPIConfigurationModule.apiConfiguration?.coreUrl == config_pre.coreUrl) + return apiConfigurationModule.updateSession(withActions: ClientSessionUpdateRequest(actions: ClientSessionAction(actions: [ClientSession.Action(type: .selectPaymentMethod, params: ["":""])]))) + } + .then { () -> Promise in + return apiConfigurationModule.setupSession(forClientToken: MockAppState.mockClientToken) + XCTAssert(PrimerAPIConfigurationModule.apiConfiguration?.coreUrl == config_post.coreUrl) + } + .done { + expectation.fulfill() + }.catch { err in + XCTAssert(false, err.localizedDescription) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 30.0) + } +} + +extension MockPrimerAPIClient { + convenience init(responseHeaders: [String: String]) { + self.init() + self.responseHeaders = responseHeaders + } } From 228c93094d03f7114ad1097fba4028a35b6d4aa9 Mon Sep 17 00:00:00 2001 From: Niall Date: Thu, 1 Aug 2024 16:35:47 +0200 Subject: [PATCH 14/34] Add responses to mocks --- Tests/Utilities/Mocks/Services/MockAPIClient.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Tests/Utilities/Mocks/Services/MockAPIClient.swift b/Tests/Utilities/Mocks/Services/MockAPIClient.swift index f7010df536..ce3737716a 100644 --- a/Tests/Utilities/Mocks/Services/MockAPIClient.swift +++ b/Tests/Utilities/Mocks/Services/MockAPIClient.swift @@ -39,6 +39,7 @@ class MockPrimerAPIClient: PrimerAPIClientProtocol { private var currentPollingIteration: Int = 0 var testFetchNolSdkSecretResult: (Response.Body.NolPay.NolPaySecretDataResponse?, Error?)? var phoneMetadataResult = Response.Body.PhoneMetadata.PhoneMetadataDataResponse(isValid: true, countryCode: "+111", nationalNumber: "12341234") + var responseHeaders: [String: String]? = nil func validateClientToken( request: Request.Body.ClientTokenValidation, @@ -74,7 +75,7 @@ class MockPrimerAPIClient: PrimerAPIClientProtocol { if let err = result.1 { completion(.failure(err), nil) } else if let successResult = result.0 { - completion(.success(successResult), [:]) + completion(.success(successResult), self.responseHeaders ?? [:]) } } } @@ -432,7 +433,7 @@ class MockPrimerAPIClient: PrimerAPIClientProtocol { if let err = result.1 { completion(.failure(err), nil) } else if let successResult = result.0 { - completion(.success(successResult), [:]) + completion(.success(successResult), self.responseHeaders ?? [:]) } } } From 3558a8c4028d47dafdcc203c617ea3b2738956bc Mon Sep 17 00:00:00 2001 From: Niall Quinn Date: Thu, 1 Aug 2024 16:46:40 +0200 Subject: [PATCH 15/34] Formatting: Update ConfigurationCache.swift --- .../Core/Cache/ConfigurationCache/ConfigurationCache.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/PrimerSDK/Classes/Core/Cache/ConfigurationCache/ConfigurationCache.swift b/Sources/PrimerSDK/Classes/Core/Cache/ConfigurationCache/ConfigurationCache.swift index 6f2038095b..494194d2e6 100644 --- a/Sources/PrimerSDK/Classes/Core/Cache/ConfigurationCache/ConfigurationCache.swift +++ b/Sources/PrimerSDK/Classes/Core/Cache/ConfigurationCache/ConfigurationCache.swift @@ -58,7 +58,7 @@ class ConfigurationCachedData { let ttl: TimeInterval init(config: PrimerAPIConfiguration, headers: [String: String]? = nil) { - //Extract ttl from headers + // Extract ttl from headers self.config = config self.timestamp = Date().timeIntervalSince1970 self.ttl = Self.extractTtlFromHeaders(headers) From 9787b5f4a04a29a03309fcad6a47fa214b97b3b8 Mon Sep 17 00:00:00 2001 From: Niall Quinn Date: Thu, 1 Aug 2024 16:47:07 +0200 Subject: [PATCH 16/34] Formatting: Update PrimerAPIConfigurationModule.swift --- .../Core/Payment Services/PrimerAPIConfigurationModule.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift b/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift index 36f868e5bc..fd315c3f5d 100644 --- a/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift +++ b/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift @@ -8,7 +8,7 @@ internal protocol PrimerAPIConfigurationModuleProtocol { static var clientToken: JWTToken? { get } static var decodedJWTToken: DecodedJWTToken? { get } static var apiConfiguration: PrimerAPIConfiguration? { get } - + static func resetSession() func setupSession( From 4b75f7b945c1e36b6c05c2e2d25a15d9591c6272 Mon Sep 17 00:00:00 2001 From: Niall Quinn Date: Thu, 1 Aug 2024 16:48:14 +0200 Subject: [PATCH 17/34] Formatting: Update PrimerAPIConfigurationModule.swift From cf0b551fbcb2719729bbcca07928f555aee667bc Mon Sep 17 00:00:00 2001 From: Niall Quinn Date: Thu, 1 Aug 2024 16:48:53 +0200 Subject: [PATCH 18/34] Formatting: Update DefaultNetworkService.swift --- .../PrimerSDK/Classes/PCI/Services/DefaultNetworkService.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/PrimerSDK/Classes/PCI/Services/DefaultNetworkService.swift b/Sources/PrimerSDK/Classes/PCI/Services/DefaultNetworkService.swift index 1e6d978303..96d9c8c296 100644 --- a/Sources/PrimerSDK/Classes/PCI/Services/DefaultNetworkService.swift +++ b/Sources/PrimerSDK/Classes/PCI/Services/DefaultNetworkService.swift @@ -57,7 +57,8 @@ class DefaultNetworkService: NetworkService, LogReporter { self.reportingService = DefaultNetworkReportingService(analyticsService: analyticsService) } - func request(_ endpoint: any Endpoint, completion: @escaping ResponseCompletionWithHeaders) -> (any PrimerCancellable)? where T : Decodable { + func request(_ endpoint: any Endpoint, + completion: @escaping ResponseCompletionWithHeaders) -> (any PrimerCancellable)? where T: Decodable { do { let request = try requestFactory.request(for: endpoint) From f9915e25c2928f6be22365076035dba73e216019 Mon Sep 17 00:00:00 2001 From: Niall Quinn Date: Thu, 1 Aug 2024 16:49:13 +0200 Subject: [PATCH 19/34] Formatting: Update NetworkService.swift --- Sources/PrimerSDK/Classes/Services/Network/NetworkService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/PrimerSDK/Classes/Services/Network/NetworkService.swift b/Sources/PrimerSDK/Classes/Services/Network/NetworkService.swift index 9ff6d60f39..77fb898b96 100644 --- a/Sources/PrimerSDK/Classes/Services/Network/NetworkService.swift +++ b/Sources/PrimerSDK/Classes/Services/Network/NetworkService.swift @@ -13,7 +13,7 @@ typealias ResponseCompletionWithHeaders = (Result, [String: String] internal protocol NetworkService { @discardableResult func request(_ endpoint: Endpoint, completion: @escaping ResponseCompletion) -> PrimerCancellable? - + @discardableResult func request(_ endpoint: Endpoint, completion: @escaping ResponseCompletionWithHeaders) -> PrimerCancellable? } From 6ff4a5632a7a62285084e121593ed36031724e78 Mon Sep 17 00:00:00 2001 From: Niall Date: Thu, 1 Aug 2024 17:03:43 +0200 Subject: [PATCH 20/34] Clear cache after tests --- Tests/Primer/Modules/PrimerAPIConfigurationModuleTests.swift | 4 ++++ Tests/Primer/Utils/ConfigurationCacheTests.swift | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/Tests/Primer/Modules/PrimerAPIConfigurationModuleTests.swift b/Tests/Primer/Modules/PrimerAPIConfigurationModuleTests.swift index 51b806112c..f5a9d644f0 100644 --- a/Tests/Primer/Modules/PrimerAPIConfigurationModuleTests.swift +++ b/Tests/Primer/Modules/PrimerAPIConfigurationModuleTests.swift @@ -11,6 +11,10 @@ import XCTest class PrimerAPIConfigurationModuleTests: XCTestCase { + override func tearDown() { + ConfigurationCache.shared.clearCache() + } + func test_successful_api_configuration_setup() throws { let expectation = XCTestExpectation(description: "Poll URL | Success") diff --git a/Tests/Primer/Utils/ConfigurationCacheTests.swift b/Tests/Primer/Utils/ConfigurationCacheTests.swift index 7464dce803..bb8cb2eaf8 100644 --- a/Tests/Primer/Utils/ConfigurationCacheTests.swift +++ b/Tests/Primer/Utils/ConfigurationCacheTests.swift @@ -9,6 +9,11 @@ import XCTest @testable import PrimerSDK final class ConfigurationCacheTests: XCTestCase { + + override func tearDown() { + ConfigurationCache.shared.clearCache() + } + func test_useHeadersTTL() throws { let headers = [ConfigurationCachedData.CacheHeaderKey: "2000"] let cacheData = ConfigurationCachedData(config: PrimerAPIConfiguration.mock, headers: headers) From e0ad8b49938019c881b8fa1a6fbb04e1dcd3292a Mon Sep 17 00:00:00 2001 From: Niall Date: Fri, 2 Aug 2024 09:20:30 +0200 Subject: [PATCH 21/34] Update apiVersion in tests --- Tests/Primer/Network/Factories/NetworkRequestFactoryTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Primer/Network/Factories/NetworkRequestFactoryTests.swift b/Tests/Primer/Network/Factories/NetworkRequestFactoryTests.swift index 5a8b6655d1..f8f6f6a8d6 100644 --- a/Tests/Primer/Network/Factories/NetworkRequestFactoryTests.swift +++ b/Tests/Primer/Network/Factories/NetworkRequestFactoryTests.swift @@ -60,7 +60,7 @@ final class NetworkRequestFactoryTests: XCTestCase { XCTAssertEqual(request.httpMethod, "GET") XCTAssertEqual(request.url?.absoluteString, (Mocks.decodedJWTToken.configurationUrl ?? "") + "?withDisplayMetadata=true") - XCTAssertEqual(request.allHTTPHeaderFields, defaultHeaders(jwt: "bla")) + XCTAssertEqual(request.allHTTPHeaderFields, defaultHeaders(apiVersion: "2.3", jwt: "bla")) } func testRequestCreation_paymentInstruments() throws { From 3ff3058952d536a01d995b1048eed2543a05251c Mon Sep 17 00:00:00 2001 From: Niall Date: Mon, 5 Aug 2024 08:28:30 +0200 Subject: [PATCH 22/34] Added events for loading --- .../Core/Analytics/AnalyticsEvent.swift | 58 ++++++++++++++++++- .../PrimerAPIConfigurationModule.swift | 10 ++++ .../Classes/Core/Primer/PrimerInternal.swift | 12 ++++ 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/Sources/PrimerSDK/Classes/Core/Analytics/AnalyticsEvent.swift b/Sources/PrimerSDK/Classes/Core/Analytics/AnalyticsEvent.swift index f778ac71fd..46d86039ee 100644 --- a/Sources/PrimerSDK/Classes/Core/Analytics/AnalyticsEvent.swift +++ b/Sources/PrimerSDK/Classes/Core/Analytics/AnalyticsEvent.swift @@ -537,19 +537,27 @@ struct TimerEventProperties: AnalyticsEventProperties { var momentType: Analytics.Event.Property.TimerType var id: String? var params: [String: AnyCodable]? + var duration: TimeInterval? + var context: [String: Any]? private enum CodingKeys: String, CodingKey { case momentType case id case params + case duration + case context } fileprivate init( momentType: Analytics.Event.Property.TimerType, - id: String? + id: String?, + duration: TimeInterval? = nil, + context: [String: Any]? = nil ) { self.momentType = momentType self.id = id + self.duration = duration + self.context = context let sdkProperties = SDKProperties() if let sdkPropertiesDict = try? sdkProperties.asDictionary(), @@ -568,6 +576,8 @@ struct TimerEventProperties: AnalyticsEventProperties { self.momentType = try container.decode(Analytics.Event.Property.TimerType.self, forKey: .momentType) self.id = try container.decodeIfPresent(String.self, forKey: .id) self.params = try container.decodeIfPresent([String: AnyCodable].self, forKey: .params) + self.duration = try container.decodeIfPresent(TimeInterval.self, forKey: .duration) + self.context = try container.decodeIfPresent([String: Any].self, forKey: .context) } func encode(to encoder: Encoder) throws { @@ -575,6 +585,8 @@ struct TimerEventProperties: AnalyticsEventProperties { try container.encode(momentType, forKey: .momentType) try container.encodeIfPresent(id, forKey: .id) try container.encodeIfPresent(params, forKey: .params) + try container.encodeIfPresent(duration, forKey: .duration) + try container.encodeIfPresent(context, forKey: .context) } } @@ -661,6 +673,7 @@ struct SDKProperties: Codable { let sdkSettings: [String: AnyCodable]? let sdkType: String? let sdkVersion: String? + let context: [String: AnyCodable]? private enum CodingKeys: String, CodingKey { case clientToken @@ -673,6 +686,7 @@ struct SDKProperties: Codable { case sdkSettings case sdkType case sdkVersion + case context } fileprivate init() { @@ -690,6 +704,7 @@ struct SDKProperties: Codable { self.sdkType = Primer.shared.integrationOptions?.reactNativeVersion == nil ? "IOS_NATIVE" : "RN_IOS" self.sdkVersion = VersionUtils.releaseVersionNumber + self.context = nil if let settingsData = try? JSONEncoder().encode(PrimerSettings.current) { let decoder = JSONDecoder() @@ -714,6 +729,8 @@ struct SDKProperties: Codable { self.sdkSettings = try container.decodeIfPresent([String: AnyCodable].self, forKey: .sdkSettings) self.sdkType = try container.decodeIfPresent(String.self, forKey: .sdkType) self.sdkVersion = try container.decodeIfPresent(String.self, forKey: .sdkVersion) + self.context = try container.decodeIfPresent([String: AnyCodable].self, forKey: .context) + } func encode(to encoder: Encoder) throws { @@ -728,6 +745,7 @@ struct SDKProperties: Codable { try container.encodeIfPresent(sdkSettings, forKey: .sdkSettings) try container.encodeIfPresent(sdkType, forKey: .sdkType) try container.encodeIfPresent(sdkVersion, forKey: .sdkVersion) + try container.encodeIfPresent(context, forKey: .context) } } @@ -809,16 +827,50 @@ extension Analytics.Event { } static func timer(momentType: Property.TimerType, - id: String?) -> Self { + id: String?, + duration: TimeInterval? = nil, + context: [String: Any]? = nil) -> Self { return .init( eventType: .timerEvent, properties: TimerEventProperties( momentType: momentType, - id: id + id: id, + duration: duration, + context: context ) ) } + enum DropInLoadingSource: String { + case universalCheckout = "UNIVERSAL_CHECKOUT" + case showPaymentMethod = "SHOW_PAYMENT_METHOD" + } + + static func dropInLoading(duration: Int, + source: DropInLoadingSource) -> Self { + .timer(momentType: .end, + id: "DROP_IN_LOADING", + duration: TimeInterval(duration), + context: ["source": source.rawValue]) + } + + static func headlessLoading(duration: Int) -> Self { + .timer(momentType: .end, id: "HEADLESS_LOADING", duration: TimeInterval(duration)) + } + + enum ConfigurationLoadingSource: String { + case cache = "CACHE" + case network = "NETWORK" + } + + static func configurationLoading(duration: Int, + source: ConfigurationLoadingSource) -> Self { + .timer(momentType: .end, id: "CONFIGURATION_LOADING", + duration: TimeInterval(duration), + context: ["source": source.rawValue]) + } + + static func allImagesLoading(momentType: Property.TimerType, id: String?) -> Self { return .init( diff --git a/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift b/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift index fd315c3f5d..75ac2dd8bb 100644 --- a/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift +++ b/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift @@ -264,6 +264,7 @@ internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtoco // swiftlint:disable:next function_body_length private func fetchConfiguration(requestDisplayMetadata: Bool) -> Promise { + let start = Date().millisecondsSince1970 return Promise { seal in guard let clientToken = PrimerAPIConfigurationModule.decodedJWTToken, let cacheKey = Self.cacheKey else { @@ -283,6 +284,7 @@ internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtoco ) Analytics.Service.record(event: event) logger.debug(message: "Cached config used") + self.recordLoadedEvent(start, source: .cache) seal.fulfill(cachedConfig.config) return } @@ -311,6 +313,7 @@ internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtoco // Cache the result let cachedData = ConfigurationCachedData(config: config, headers: responseHeaders) ConfigurationCache.shared.setData(cachedData, forKey: cacheKey) + self.recordLoadedEvent(start, source: .network) innerSeal.fulfill(config) } } @@ -334,6 +337,13 @@ internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtoco } } + private func recordLoadedEvent(_ start: Int, source: Analytics.Event.ConfigurationLoadingSource) { + let end = Date().millisecondsSince1970 + let interval = end - start + let showEvent = Analytics.Event.configurationLoading(duration: interval, source: source) + Analytics.Service.record(events: [showEvent]) + } + private func fetchConfigurationAndVaultedPaymentMethodsIfNeeded( requestDisplayMetadata: Bool, requestVaultedPaymentMethods: Bool diff --git a/Sources/PrimerSDK/Classes/Core/Primer/PrimerInternal.swift b/Sources/PrimerSDK/Classes/Core/Primer/PrimerInternal.swift index c322249897..a989f630d7 100644 --- a/Sources/PrimerSDK/Classes/Core/Primer/PrimerInternal.swift +++ b/Sources/PrimerSDK/Classes/Core/Primer/PrimerInternal.swift @@ -141,6 +141,7 @@ internal class PrimerInternal: LogReporter { events = [sdkEvent, connectivityEvent, timingStartEvent] Analytics.Service.record(events: events) + let start = Date().millisecondsSince1970 firstly { PrimerUIManager.preparePresentation(clientToken: clientToken) } @@ -149,6 +150,7 @@ internal class PrimerInternal: LogReporter { let currencyLoader = CurrencyLoader(storage: DefaultCurrencyStorage(), networkService: CurrencyNetworkService()) currencyLoader.updateCurrenciesFromAPI() + self.recordLoadedEvent(start, source: .universalCheckout) completion?(nil) } .catch { err in @@ -230,11 +232,14 @@ internal class PrimerInternal: LogReporter { events = [sdkEvent, connectivityEvent, timingStartEvent] Analytics.Service.record(events: events) + let start = Date().millisecondsSince1970 + firstly { PrimerUIManager.preparePresentation(clientToken: clientToken) } .done { PrimerUIManager.presentPaymentUI() + self.recordLoadedEvent(start, source: .showPaymentMethod) completion?(nil) } .catch { err in @@ -251,6 +256,13 @@ internal class PrimerInternal: LogReporter { } } + private func recordLoadedEvent(_ start: Int, source: Analytics.Event.DropInLoadingSource) { + let end = Date().millisecondsSince1970 + let interval = end - start + let showEvent = Analytics.Event.dropInLoading(duration: interval, source: source) + Analytics.Service.record(events: [showEvent]) + } + /** Dismisses any opened checkout sheet view. */ internal func dismiss(paymentMethodManagerCategories: [PrimerPaymentMethodManagerCategory] = []) { let sdkEvent = Analytics.Event.sdk(name: #function, params: nil) From ca93d3e043985000e78a4b306f0f2aeb63e5411c Mon Sep 17 00:00:00 2001 From: Niall Date: Mon, 5 Aug 2024 08:37:08 +0200 Subject: [PATCH 23/34] Clear cache on cleanup --- .../Core/Payment Services/PrimerAPIConfigurationModule.swift | 1 + .../PrimerHeadlessUniversalCheckout.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift b/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift index 75ac2dd8bb..ef686bf951 100644 --- a/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift +++ b/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift @@ -82,6 +82,7 @@ internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtoco static func resetSession() { AppState.current.clientToken = nil AppState.current.apiConfiguration = nil + ConfigurationCache.shared.clearCache() } func setupSession( diff --git a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/PrimerHeadlessUniversalCheckout.swift b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/PrimerHeadlessUniversalCheckout.swift index 6baddd82d4..cdfe4e7aeb 100644 --- a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/PrimerHeadlessUniversalCheckout.swift +++ b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/PrimerHeadlessUniversalCheckout.swift @@ -132,6 +132,7 @@ public class PrimerHeadlessUniversalCheckout: LogReporter { public func cleanUp() { PrimerAPIConfigurationModule.resetSession() + PrimerInternal.shared.checkoutSessionId = nil } // MARK: - HELPERS From 5de40a6ac49fdf6cf2fa56431f9541357bab489a Mon Sep 17 00:00:00 2001 From: Niall Date: Mon, 5 Aug 2024 08:39:13 +0200 Subject: [PATCH 24/34] Add checkout session active --- Sources/PrimerSDK/Classes/Core/Primer/Primer.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/PrimerSDK/Classes/Core/Primer/Primer.swift b/Sources/PrimerSDK/Classes/Core/Primer/Primer.swift index d424d92c26..8f830aefd5 100644 --- a/Sources/PrimerSDK/Classes/Core/Primer/Primer.swift +++ b/Sources/PrimerSDK/Classes/Core/Primer/Primer.swift @@ -109,4 +109,11 @@ public class Primer { public func dismiss() { PrimerInternal.shared.dismiss() } + + /** + Checkout Session is active? + */ + public func checkoutSessionIsActive() -> Bool { + PrimerInternal.shared.checkoutSessionId != nil + } } From 6c03ba03439e0534d2a7e0e26cac7786466068eb Mon Sep 17 00:00:00 2001 From: Niall Date: Mon, 5 Aug 2024 08:44:45 +0200 Subject: [PATCH 25/34] Record load of vault manager --- Sources/PrimerSDK/Classes/Core/Analytics/AnalyticsEvent.swift | 1 + Sources/PrimerSDK/Classes/Core/Primer/PrimerInternal.swift | 3 +++ 2 files changed, 4 insertions(+) diff --git a/Sources/PrimerSDK/Classes/Core/Analytics/AnalyticsEvent.swift b/Sources/PrimerSDK/Classes/Core/Analytics/AnalyticsEvent.swift index 46d86039ee..ab90bc6ad3 100644 --- a/Sources/PrimerSDK/Classes/Core/Analytics/AnalyticsEvent.swift +++ b/Sources/PrimerSDK/Classes/Core/Analytics/AnalyticsEvent.swift @@ -844,6 +844,7 @@ extension Analytics.Event { enum DropInLoadingSource: String { case universalCheckout = "UNIVERSAL_CHECKOUT" case showPaymentMethod = "SHOW_PAYMENT_METHOD" + case vaultManager = "VAULT_MANAGER" } static func dropInLoading(duration: Int, diff --git a/Sources/PrimerSDK/Classes/Core/Primer/PrimerInternal.swift b/Sources/PrimerSDK/Classes/Core/Primer/PrimerInternal.swift index a989f630d7..3aad5d8487 100644 --- a/Sources/PrimerSDK/Classes/Core/Primer/PrimerInternal.swift +++ b/Sources/PrimerSDK/Classes/Core/Primer/PrimerInternal.swift @@ -190,11 +190,14 @@ internal class PrimerInternal: LogReporter { events = [sdkEvent, connectivityEvent, timingStartEvent] Analytics.Service.record(events: events) + let start = Date().millisecondsSince1970 + firstly { PrimerUIManager.preparePresentation(clientToken: clientToken) } .done { PrimerUIManager.presentPaymentUI() + self.recordLoadedEvent(start, source: .vaultManager) completion?(nil) } .catch { err in From 5914d1761e029323267adbeb0c433c9d33acb100 Mon Sep 17 00:00:00 2001 From: Niall Date: Mon, 5 Aug 2024 17:09:54 +0200 Subject: [PATCH 26/34] Record headless loading event --- .../PrimerHeadlessUniversalCheckout.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/PrimerHeadlessUniversalCheckout.swift b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/PrimerHeadlessUniversalCheckout.swift index cdfe4e7aeb..3da3897135 100644 --- a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/PrimerHeadlessUniversalCheckout.swift +++ b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/PrimerHeadlessUniversalCheckout.swift @@ -53,6 +53,8 @@ public class PrimerHeadlessUniversalCheckout: LogReporter { uiDelegate: PrimerHeadlessUniversalCheckoutUIDelegate? = nil, completion: @escaping (_ paymentMethods: [PrimerHeadlessUniversalCheckout.PaymentMethod]?, _ err: Error?) -> Void ) { + let start = Date().millisecondsSince1970 + PrimerInternal.shared.sdkIntegrationType = .headless PrimerInternal.shared.intent = .checkout @@ -119,6 +121,7 @@ public class PrimerHeadlessUniversalCheckout: LogReporter { let availablePaymentMethods = PrimerHeadlessUniversalCheckout.PaymentMethod.availablePaymentMethods let delegate = PrimerHeadlessUniversalCheckout.current.delegate delegate?.primerHeadlessUniversalCheckoutDidLoadAvailablePaymentMethods?(availablePaymentMethods) + self.recordLoadedEvent(start) completion(availablePaymentMethods, nil) } } @@ -130,6 +133,13 @@ public class PrimerHeadlessUniversalCheckout: LogReporter { } } + private func recordLoadedEvent(_ start: Int) { + let end = Date().millisecondsSince1970 + let interval = end - start + let showEvent = Analytics.Event.headlessLoading(duration: interval) + Analytics.Service.record(events: [showEvent]) + } + public func cleanUp() { PrimerAPIConfigurationModule.resetSession() PrimerInternal.shared.checkoutSessionId = nil From 9e43a6fbb8ae12a6826ff5e6c3cdde86b6d7ebce Mon Sep 17 00:00:00 2001 From: Niall Quinn Date: Tue, 6 Aug 2024 10:31:21 +0200 Subject: [PATCH 27/34] fix: Validate PENDING in resume if showSuccessCheckoutOnPendingPayment flag is true (#957) * Validate PENDING in resume if flag is true * Remove unused code * Added unit tests for CreateResumeService * Make new flag a bool * Added strings for fintechture * Remove redundant enum * Split out createresume into seperate protocol --- .../Classes/Core/Constants/Strings.swift | 12 + .../CreateResumePaymentService.swift | 28 +- .../Data Models/API/PaymentAPIModel.swift | 3 +- .../Data Models/PrimerPaymentMethodType.swift | 109 +++--- .../Classes/Modules/UserInterfaceModule.swift | 8 + .../Network/PrimerAPIClientProtocol.swift | 16 +- ...APIClientCreateResumePaymentProtocol.swift | 22 ++ .../Localizable/en.lproj/Localizable.strings | Bin 22404 -> 22728 bytes .../Localizable/es.lproj/Localizable.strings | Bin 22328 -> 22692 bytes .../Localizable/fr.lproj/Localizable.strings | Bin 22512 -> 22736 bytes .../Localizable/it.lproj/Localizable.strings | Bin 22080 -> 22420 bytes .../CreateResumePaymentServiceTests.swift | 326 ++++++++++++++++++ 12 files changed, 436 insertions(+), 88 deletions(-) create mode 100644 Sources/PrimerSDK/Classes/Services/Network/Protocols/PrimerAPIClientCreateResumePaymentProtocol.swift create mode 100644 Tests/Primer/Services/CreateResumePaymentServiceTests.swift diff --git a/Sources/PrimerSDK/Classes/Core/Constants/Strings.swift b/Sources/PrimerSDK/Classes/Core/Constants/Strings.swift index 4b6548b434..765d2160b4 100644 --- a/Sources/PrimerSDK/Classes/Core/Constants/Strings.swift +++ b/Sources/PrimerSDK/Classes/Core/Constants/Strings.swift @@ -125,6 +125,18 @@ extension Strings { value: "Confirm to pay", comment: "Confirm button title text") + static let payBySmartTransfer = NSLocalizedString("payBySmartTransfer", + tableName: nil, + bundle: Bundle.primerResources, + value: "Pay by Smart transfer", + comment: "Pay by Smart transfer button title text") + + static let payByImmediateTransfer = NSLocalizedString("payByImmediateTransfer", + tableName: nil, + bundle: Bundle.primerResources, + value: "Pay by Immediate transfer", + comment: "Pay by Immediate transfer button title text") + } } diff --git a/Sources/PrimerSDK/Classes/Core/Payment Services/CreateResumePaymentService.swift b/Sources/PrimerSDK/Classes/Core/Payment Services/CreateResumePaymentService.swift index 57c20801ee..9af6c79108 100644 --- a/Sources/PrimerSDK/Classes/Core/Payment Services/CreateResumePaymentService.swift +++ b/Sources/PrimerSDK/Classes/Core/Payment Services/CreateResumePaymentService.swift @@ -19,12 +19,12 @@ private enum CreateResumePaymentCallType: String { internal class CreateResumePaymentService: CreateResumePaymentServiceProtocol { - let apiClient: PrimerAPIClientProtocol + let apiClient: PrimerAPIClientCreateResumePaymentProtocol let paymentMethodType: String init(paymentMethodType: String, - apiClient: PrimerAPIClientProtocol = PrimerAPIClient()) { + apiClient: PrimerAPIClientCreateResumePaymentProtocol = PrimerAPIClient()) { self.paymentMethodType = paymentMethodType self.apiClient = apiClient } @@ -45,7 +45,7 @@ internal class CreateResumePaymentService: CreateResumePaymentServiceProtocol { seal.reject(error) case .success(let paymentResponse): do { - try self.validateResponse(paymentResponse: paymentResponse, callType: "create") + try self.validateResponse(paymentResponse: paymentResponse, callType: .create) seal.fulfill(paymentResponse) } catch { seal.reject(error) @@ -55,23 +55,10 @@ internal class CreateResumePaymentService: CreateResumePaymentServiceProtocol { } } - private func error(forCallType callType: CreateResumePaymentCallType) -> Error { - switch callType { - case .create: - return PrimerError.failedToCreatePayment(paymentMethodType: paymentMethodType, - description: "Failed to create payment", - userInfo: .errorUserInfoDictionary(), - diagnosticsId: UUID().uuidString) - case .resume: - return PrimerError.failedToResumePayment(paymentMethodType: paymentMethodType, - description: "Failed to resume payment", - userInfo: .errorUserInfoDictionary(), - diagnosticsId: UUID().uuidString) - } - } + private func validateResponse(paymentResponse: Response.Body.Payment, callType: CreateResumePaymentCallType) throws { - private func validateResponse(paymentResponse: Response.Body.Payment, callType: String) throws { - if paymentResponse.id == nil || paymentResponse.status == .failed { + if paymentResponse.id == nil || paymentResponse.status == .failed || + (callType == .resume && paymentResponse.status == .pending && paymentResponse.showSuccessCheckoutOnPendingPayment == false) { let err = PrimerError.paymentFailed( paymentMethodType: self.paymentMethodType, paymentId: paymentResponse.id ?? "unknown", @@ -80,7 +67,6 @@ internal class CreateResumePaymentService: CreateResumePaymentServiceProtocol { diagnosticsId: UUID().uuidString) ErrorHandler.handle(error: err) throw err - } } @@ -101,7 +87,7 @@ internal class CreateResumePaymentService: CreateResumePaymentServiceProtocol { seal.reject(error) case .success(let paymentResponse): do { - try self.validateResponse(paymentResponse: paymentResponse, callType: "resume") + try self.validateResponse(paymentResponse: paymentResponse, callType: .resume) seal.fulfill(paymentResponse) } catch { seal.reject(error) diff --git a/Sources/PrimerSDK/Classes/Data Models/API/PaymentAPIModel.swift b/Sources/PrimerSDK/Classes/Data Models/API/PaymentAPIModel.swift index 83f48431a3..3ce4cb4d1b 100644 --- a/Sources/PrimerSDK/Classes/Data Models/API/PaymentAPIModel.swift +++ b/Sources/PrimerSDK/Classes/Data Models/API/PaymentAPIModel.swift @@ -118,10 +118,11 @@ extension Response.Body { public let requiredAction: Response.Body.Payment.RequiredAction? public let status: Status public let paymentFailureReason: PrimerPaymentErrorCode.RawValue? + public var showSuccessCheckoutOnPendingPayment: Bool? = false // swiftlint:disable:next nesting public enum CodingKeys: String, CodingKey { - case id, paymentId, amount, currencyCode, customer, customerId, order, orderId, requiredAction, status, paymentFailureReason + case id, paymentId, amount, currencyCode, customer, customerId, order, orderId, requiredAction, status, paymentFailureReason, showSuccessCheckoutOnPendingPayment case dateStr = "date" } diff --git a/Sources/PrimerSDK/Classes/Data Models/PrimerPaymentMethodType.swift b/Sources/PrimerSDK/Classes/Data Models/PrimerPaymentMethodType.swift index e8d53348d7..e2b3b234f7 100644 --- a/Sources/PrimerSDK/Classes/Data Models/PrimerPaymentMethodType.swift +++ b/Sources/PrimerSDK/Classes/Data Models/PrimerPaymentMethodType.swift @@ -1,58 +1,60 @@ import Foundation internal enum PrimerPaymentMethodType: String, Codable, CaseIterable, Equatable, Hashable { - case adyenAlipay = "ADYEN_ALIPAY" - case adyenBlik = "ADYEN_BLIK" - case adyenBancontactCard = "ADYEN_BANCONTACT_CARD" - case adyenDotPay = "ADYEN_DOTPAY" - case adyenGiropay = "ADYEN_GIROPAY" - case adyenIDeal = "ADYEN_IDEAL" - case adyenInterac = "ADYEN_INTERAC" - case adyenMobilePay = "ADYEN_MOBILEPAY" - case adyenMBWay = "ADYEN_MBWAY" - case adyenMultibanco = "ADYEN_MULTIBANCO" - case adyenPayTrail = "ADYEN_PAYTRAIL" - case adyenPayshop = "ADYEN_PAYSHOP" - case adyenSofort = "ADYEN_SOFORT" - case adyenTrustly = "ADYEN_TRUSTLY" - case adyenTwint = "ADYEN_TWINT" - case adyenVipps = "ADYEN_VIPPS" - case applePay = "APPLE_PAY" - case atome = "ATOME" - case buckarooBancontact = "BUCKAROO_BANCONTACT" - case buckarooEps = "BUCKAROO_EPS" - case buckarooGiropay = "BUCKAROO_GIROPAY" - case buckarooIdeal = "BUCKAROO_IDEAL" - case buckarooSofort = "BUCKAROO_SOFORT" - case coinbase = "COINBASE" - case goCardless = "GOCARDLESS" - case googlePay = "GOOGLE_PAY" - case hoolah = "HOOLAH" - case iPay88Card = "IPAY88_CARD" - case klarna = "KLARNA" - case mollieBankcontact = "MOLLIE_BANCONTACT" - case mollieIdeal = "MOLLIE_IDEAL" - case opennode = "OPENNODE" - case payNLBancontact = "PAY_NL_BANCONTACT" - case payNLGiropay = "PAY_NL_GIROPAY" - case payNLIdeal = "PAY_NL_IDEAL" - case payNLPayconiq = "PAY_NL_PAYCONIQ" - case paymentCard = "PAYMENT_CARD" - case payPal = "PAYPAL" - case primerTestKlarna = "PRIMER_TEST_KLARNA" - case primerTestPayPal = "PRIMER_TEST_PAYPAL" - case primerTestSofort = "PRIMER_TEST_SOFORT" - case rapydFast = "RAPYD_FAST" - case rapydGCash = "RAPYD_GCASH" - case rapydGrabPay = "RAPYD_GRABPAY" - case rapydPromptPay = "RAPYD_PROMPTPAY" - case rapydPoli = "RAPYD_POLI" - case omisePromptPay = "OMISE_PROMPTPAY" - case twoCtwoP = "TWOC2P" - case xenditOvo = "XENDIT_OVO" - case xenditRetailOutlets = "XENDIT_RETAIL_OUTLETS" - case xfersPayNow = "XFERS_PAYNOW" - case nolPay = "NOL_PAY" + case adyenAlipay = "ADYEN_ALIPAY" + case adyenBlik = "ADYEN_BLIK" + case adyenBancontactCard = "ADYEN_BANCONTACT_CARD" + case adyenDotPay = "ADYEN_DOTPAY" + case adyenGiropay = "ADYEN_GIROPAY" + case adyenIDeal = "ADYEN_IDEAL" + case adyenInterac = "ADYEN_INTERAC" + case adyenMobilePay = "ADYEN_MOBILEPAY" + case adyenMBWay = "ADYEN_MBWAY" + case adyenMultibanco = "ADYEN_MULTIBANCO" + case adyenPayTrail = "ADYEN_PAYTRAIL" + case adyenPayshop = "ADYEN_PAYSHOP" + case adyenSofort = "ADYEN_SOFORT" + case adyenTrustly = "ADYEN_TRUSTLY" + case adyenTwint = "ADYEN_TWINT" + case adyenVipps = "ADYEN_VIPPS" + case applePay = "APPLE_PAY" + case atome = "ATOME" + case buckarooBancontact = "BUCKAROO_BANCONTACT" + case buckarooEps = "BUCKAROO_EPS" + case buckarooGiropay = "BUCKAROO_GIROPAY" + case buckarooIdeal = "BUCKAROO_IDEAL" + case buckarooSofort = "BUCKAROO_SOFORT" + case coinbase = "COINBASE" + case goCardless = "GOCARDLESS" + case googlePay = "GOOGLE_PAY" + case hoolah = "HOOLAH" + case iPay88Card = "IPAY88_CARD" + case klarna = "KLARNA" + case mollieBankcontact = "MOLLIE_BANCONTACT" + case mollieIdeal = "MOLLIE_IDEAL" + case opennode = "OPENNODE" + case payNLBancontact = "PAY_NL_BANCONTACT" + case payNLGiropay = "PAY_NL_GIROPAY" + case payNLIdeal = "PAY_NL_IDEAL" + case payNLPayconiq = "PAY_NL_PAYCONIQ" + case paymentCard = "PAYMENT_CARD" + case payPal = "PAYPAL" + case primerTestKlarna = "PRIMER_TEST_KLARNA" + case primerTestPayPal = "PRIMER_TEST_PAYPAL" + case primerTestSofort = "PRIMER_TEST_SOFORT" + case rapydFast = "RAPYD_FAST" + case rapydGCash = "RAPYD_GCASH" + case rapydGrabPay = "RAPYD_GRABPAY" + case rapydPromptPay = "RAPYD_PROMPTPAY" + case rapydPoli = "RAPYD_POLI" + case omisePromptPay = "OMISE_PROMPTPAY" + case twoCtwoP = "TWOC2P" + case xenditOvo = "XENDIT_OVO" + case xenditRetailOutlets = "XENDIT_RETAIL_OUTLETS" + case xfersPayNow = "XFERS_PAYNOW" + case nolPay = "NOL_PAY" + case fintechtureSmartTransfer = "FINTECTURE_SMART_TRANSFER" + case fintechtureImmediateTransfer = "FINTECHTURE_IMMEDIATE_TRANSFER" var provider: String { switch self { @@ -130,6 +132,9 @@ internal enum PrimerPaymentMethodType: String, Codable, CaseIterable, Equatable, return "XFERS" case .nolPay: return "NOL_PAY" + + case .fintechtureSmartTransfer, .fintechtureImmediateTransfer: + return "FINTECHTURE" } } } diff --git a/Sources/PrimerSDK/Classes/Modules/UserInterfaceModule.swift b/Sources/PrimerSDK/Classes/Modules/UserInterfaceModule.swift index bdd5b95a0d..53df834c28 100644 --- a/Sources/PrimerSDK/Classes/Modules/UserInterfaceModule.swift +++ b/Sources/PrimerSDK/Classes/Modules/UserInterfaceModule.swift @@ -757,6 +757,8 @@ class UserInterfaceModule: NSObject, UserInterfaceModuleProtocol { textColor: nil)) case .nolPay: return nil + case .fintechtureSmartTransfer, .fintechtureImmediateTransfer: + return nil } } @@ -801,6 +803,12 @@ class UserInterfaceModule: NSObject, UserInterfaceModuleProtocol { case PrimerPaymentMethodType.twoCtwoP.rawValue: return Strings.PaymentButton.payInInstallments + case PrimerPaymentMethodType.fintechtureSmartTransfer.rawValue: + return Strings.PaymentButton.payBySmartTransfer + + case PrimerPaymentMethodType.fintechtureImmediateTransfer.rawValue: + return Strings.PaymentButton.payByImmediateTransfer + default: return metadataButtonText } diff --git a/Sources/PrimerSDK/Classes/Services/Network/PrimerAPIClientProtocol.swift b/Sources/PrimerSDK/Classes/Services/Network/PrimerAPIClientProtocol.swift index b3ff60ecf1..152806cc4d 100644 --- a/Sources/PrimerSDK/Classes/Services/Network/PrimerAPIClientProtocol.swift +++ b/Sources/PrimerSDK/Classes/Services/Network/PrimerAPIClientProtocol.swift @@ -17,7 +17,8 @@ protocol PrimerAPIClientProtocol: PrimerAPIClientBanksProtocol, PrimerAPIClientPayPalProtocol, PrimerAPIClientVaultProtocol, - PrimerAPIClientXenditProtocol { + PrimerAPIClientXenditProtocol, + PrimerAPIClientCreateResumePaymentProtocol { // MARK: Configuration @@ -92,19 +93,6 @@ protocol PrimerAPIClientProtocol: url: URL, completion: @escaping APICompletion) - // MARK: Payments - - func createPayment( - clientToken: DecodedJWTToken, - paymentRequestBody: Request.Body.Payment.Create, - completion: @escaping APICompletion) - - func resumePayment( - clientToken: DecodedJWTToken, - paymentId: String, - paymentResumeRequest: Request.Body.Payment.Resume, - completion: @escaping APICompletion) - // MARK: NolPay func fetchNolSdkSecret(clientToken: DecodedJWTToken, diff --git a/Sources/PrimerSDK/Classes/Services/Network/Protocols/PrimerAPIClientCreateResumePaymentProtocol.swift b/Sources/PrimerSDK/Classes/Services/Network/Protocols/PrimerAPIClientCreateResumePaymentProtocol.swift new file mode 100644 index 0000000000..3a23729e37 --- /dev/null +++ b/Sources/PrimerSDK/Classes/Services/Network/Protocols/PrimerAPIClientCreateResumePaymentProtocol.swift @@ -0,0 +1,22 @@ +// +// PrimerAPIClientCreateResumePaymentProtocol.swift +// PrimerSDK +// +// Created by Niall Quinn on 05/08/24. +// + +import Foundation + + +protocol PrimerAPIClientCreateResumePaymentProtocol { + func createPayment( + clientToken: DecodedJWTToken, + paymentRequestBody: Request.Body.Payment.Create, + completion: @escaping APICompletion) + + func resumePayment( + clientToken: DecodedJWTToken, + paymentId: String, + paymentResumeRequest: Request.Body.Payment.Resume, + completion: @escaping APICompletion) +} diff --git a/Sources/PrimerSDK/Resources/Localizable/en.lproj/Localizable.strings b/Sources/PrimerSDK/Resources/Localizable/en.lproj/Localizable.strings index b3334b835cefb7705a0c979de9f2ccb9b64411b9..069bb4b4b521cbaa9ec33b08a1a0f341f28e95d1 100644 GIT binary patch delta 272 zcmZo!&v;@ZBh&wXlO2uxCSPTi+L$dFK3PDTKQ@V>l0kvNlOY!fQyEeiG8qyXN`Nc{ zAXx-t=P?vBqyhOw3|b8O3|tIK3WlVoY5jsCp**>4}?`F}wxpz%(xv vXm&E#J_R6|hhl%?WIYveUx?d+fij6eb4#$8f$1iMYAkM%C*YRJ?<}PO79K=Y delta 17 ZcmZ3ok#Wa5My7xNCp+rr{fiRUJg&`9tRsv*& zFcbkG7qRq0mujGdkNR4#9+<9g<^6rP(Bf;s|1^g M{AealF0hgW0Mj@sjQ{`u delta 17 Zcmcbxk@3TNMy7xNCvOb&-xx4I8~{`?2>k#6 diff --git a/Sources/PrimerSDK/Resources/Localizable/it.lproj/Localizable.strings b/Sources/PrimerSDK/Resources/Localizable/it.lproj/Localizable.strings index 07bbbb1957c041c9e81b67080e48524cbe038306..a8608a54fc0ae9a4b5eb916ea8ae3dd5c41894a0 100644 GIT binary patch delta 278 zcmX@GhH=VzMyCJ&CN~E7P5#U*wK4l@=wv=7_R03TeBqu9xj>l8kiwA3kjPL1WGMj2 zA|N}Dp_m~J$S-2hV$f&cVo+iz0E$*JH~}%Ht`Jl`llMr;Dkm}I15L>Unv%(o3}h<+ tX;jx(5SA6M_0mh_V}@8jGD8n06+C{gMo}Art5dB?fDtZvd1#JqiE- delta 17 ZcmbQTp7FpMMy7xNCkt}=Zwz=J3IIpk2l4;_ diff --git a/Tests/Primer/Services/CreateResumePaymentServiceTests.swift b/Tests/Primer/Services/CreateResumePaymentServiceTests.swift new file mode 100644 index 0000000000..1a513d3486 --- /dev/null +++ b/Tests/Primer/Services/CreateResumePaymentServiceTests.swift @@ -0,0 +1,326 @@ +// +// File.swift +// +// +// Created by Niall Quinn on 01/08/24. +// + +import XCTest +@testable import PrimerSDK + +final class CreateResumePaymentServiceTests: XCTestCase { + + typealias Payment = Response.Body.Payment + + func test_createNoJWT() throws { + let response = Payment.successResponse + let apiClient = MockCreateResumeAPI(createResponse: response) + + let createResumeService = CreateResumePaymentService(paymentMethodType: "PAYMENT_CARD", + apiClient: apiClient) + + AppState.current.clientToken = nil + let expectation = self.expectation(description: "Promise fulfilled") + let createRequest = Request.Body.Payment.Create(token: "123") + createResumeService.createPayment(paymentRequest: createRequest).done { payment in + }.catch { error in + expectation.fulfill() + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func test_createSuccess() throws { + let response = Payment.successResponse + let apiClient = MockCreateResumeAPI(createResponse: response) + + let createResumeService = CreateResumePaymentService(paymentMethodType: "PAYMENT_CARD", + apiClient: apiClient) + + AppState.current.clientToken = MockAppState.mockClientToken + let expectation = self.expectation(description: "Promise fulfilled") + let createRequest = Request.Body.Payment.Create(token: "123") + createResumeService.createPayment(paymentRequest: createRequest).done { payment in + XCTAssert(payment.status == .success) + expectation.fulfill() + }.catch { error in + XCTFail("Promise rejected: \(error)") + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func test_createFailure() throws { + let response = Payment.failedStatusResponse + let apiClient = MockCreateResumeAPI(createResponse: response) + + let createResumeService = CreateResumePaymentService(paymentMethodType: "PAYMENT_CARD", + apiClient: apiClient) + + AppState.current.clientToken = MockAppState.mockClientToken + let expectation = self.expectation(description: "Promise fulfilled") + let createRequest = Request.Body.Payment.Create(token: "123") + createResumeService.createPayment(paymentRequest: createRequest).done { payment in + XCTFail("Succeeded when it should have failed") + }.catch { error in + expectation.fulfill() + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func test_createPending() throws { + let response = Payment.pendingStatusResponse + let apiClient = MockCreateResumeAPI(createResponse: response) + + let createResumeService = CreateResumePaymentService(paymentMethodType: "PAYMENT_CARD", + apiClient: apiClient) + + AppState.current.clientToken = MockAppState.mockClientToken + let expectation = self.expectation(description: "Promise fulfilled") + let createRequest = Request.Body.Payment.Create(token: "123") + createResumeService.createPayment(paymentRequest: createRequest).done { payment in + XCTAssert(payment.status == .pending) + expectation.fulfill() + }.catch { error in + XCTFail("Promise rejected: \(error)") + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func test_createError() throws { + let response = Payment.errorResponse + let apiClient = MockCreateResumeAPI(createResponse: response) + + let createResumeService = CreateResumePaymentService(paymentMethodType: "PAYMENT_CARD", + apiClient: apiClient) + + AppState.current.clientToken = MockAppState.mockClientToken + let expectation = self.expectation(description: "Promise fulfilled") + let createRequest = Request.Body.Payment.Create(token: "123") + createResumeService.createPayment(paymentRequest: createRequest).done { payment in + XCTFail("Succeeded when it should have failed") + }.catch { error in + expectation.fulfill() + } + + waitForExpectations(timeout: 5, handler: nil) + } + + + func test_resumeNoJWT() throws { + let response = Payment.successResponse + let apiClient = MockCreateResumeAPI(resumeResponse: response) + + let createResumeService = CreateResumePaymentService(paymentMethodType: "PAYMENT_CARD", + apiClient: apiClient) + + AppState.current.clientToken = nil + let expectation = self.expectation(description: "Promise fulfilled") + let resumeRequest = Request.Body.Payment.Resume(token: "") + createResumeService.resumePaymentWithPaymentId("", paymentResumeRequest: resumeRequest).done { payment in + }.catch { error in + expectation.fulfill() + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func test_resumeSuccess() throws { + let response = Payment.successResponse + let apiClient = MockCreateResumeAPI(resumeResponse: response) + + let createResumeService = CreateResumePaymentService(paymentMethodType: "PAYMENT_CARD", + apiClient: apiClient) + + AppState.current.clientToken = MockAppState.mockClientToken + let expectation = self.expectation(description: "Promise fulfilled") + let resumeRequest = Request.Body.Payment.Resume(token: "") + createResumeService.resumePaymentWithPaymentId("", paymentResumeRequest: resumeRequest).done { payment in + XCTAssert(payment.status == .success) + expectation.fulfill() + }.catch { error in + XCTFail("Promise rejected: \(error)") + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func test_resumeFailure() throws { + let response = Payment.failedStatusResponse + let apiClient = MockCreateResumeAPI(resumeResponse: response) + + let createResumeService = CreateResumePaymentService(paymentMethodType: "PAYMENT_CARD", + apiClient: apiClient) + + AppState.current.clientToken = MockAppState.mockClientToken + let expectation = self.expectation(description: "Promise fulfilled") + let resumeRequest = Request.Body.Payment.Resume(token: "") + createResumeService.resumePaymentWithPaymentId("", paymentResumeRequest: resumeRequest).done { payment in + XCTFail("Succeeded when it should have failed") + }.catch { error in + expectation.fulfill() + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func test_resumePending() throws { + let response = Payment.pendingStatusResponse + let apiClient = MockCreateResumeAPI(resumeResponse: response) + + let createResumeService = CreateResumePaymentService(paymentMethodType: "PAYMENT_CARD", + apiClient: apiClient) + + AppState.current.clientToken = MockAppState.mockClientToken + let expectation = self.expectation(description: "Promise fulfilled") + let resumeRequest = Request.Body.Payment.Resume(token: "") + createResumeService.resumePaymentWithPaymentId("", paymentResumeRequest: resumeRequest).done { payment in + XCTFail("Succeeded when it should have failed") + }.catch { error in + expectation.fulfill() + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func test_resumeError() throws { + let response = Payment.errorResponse + let apiClient = MockCreateResumeAPI(resumeResponse: response) + + let createResumeService = CreateResumePaymentService(paymentMethodType: "PAYMENT_CARD", + apiClient: apiClient) + + AppState.current.clientToken = MockAppState.mockClientToken + let expectation = self.expectation(description: "Promise fulfilled") + let resumeRequest = Request.Body.Payment.Resume(token: "") + createResumeService.resumePaymentWithPaymentId("", paymentResumeRequest: resumeRequest).done { payment in + XCTFail("Succeeded when it should have failed") + }.catch { error in + expectation.fulfill() + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func test_resumePending_showSuccessCheckoutOnPendingPayment() throws { + let response = Payment.pendingStatusResponseWithShowCheckoutSuccessOnPending + let apiClient = MockCreateResumeAPI(resumeResponse: response) + + let createResumeService = CreateResumePaymentService(paymentMethodType: "PAYMENT_CARD", + apiClient: apiClient) + + AppState.current.clientToken = MockAppState.mockClientToken + let expectation = self.expectation(description: "Promise fulfilled") + let resumeRequest = Request.Body.Payment.Resume(token: "") + createResumeService.resumePaymentWithPaymentId("", paymentResumeRequest: resumeRequest).done { payment in + XCTAssert(payment.status == .pending) + expectation.fulfill() + }.catch { error in + XCTFail("Failed, but flag should result in success") + expectation.fulfill() + } + + waitForExpectations(timeout: 5, handler: nil) + } + +} + + +private class MockCreateResumeAPI: PrimerAPIClientCreateResumePaymentProtocol { + + var resumeResponse: APIResult? + var createResponse: APIResult? + + init(resumeResponse: APIResult? = nil, createResponse: APIResult? = nil) { + self.resumeResponse = resumeResponse + self.createResponse = createResponse + } + + func createPayment(clientToken: DecodedJWTToken, paymentRequestBody: Request.Body.Payment.Create, completion: @escaping APICompletion) { + guard let createResponse else { + XCTFail("No create response set") + return + } + completion(createResponse) + } + + func resumePayment(clientToken: DecodedJWTToken, paymentId: String, paymentResumeRequest: Request.Body.Payment.Resume, completion: @escaping APICompletion) { + guard let resumeResponse else { + XCTFail("No resume response set") + return + } + completion(resumeResponse) + } +} + +private extension Response.Body.Payment { + static var successResponse: APIResult { + .success(.init(id: "id", + paymentId: "paymentId", + amount: 1, + currencyCode: "EUR", + customer: nil, + customerId: nil, + dateStr: nil, + order: nil, + orderId: nil, + requiredAction: nil, + status: .success, + paymentFailureReason: nil)) + } + + static var failedStatusResponse: APIResult { + .success(.init(id: "id", + paymentId: "paymentId", + amount: 1, + currencyCode: "EUR", + customer: nil, + customerId: nil, + dateStr: nil, + order: nil, + orderId: nil, + requiredAction: nil, + status: .failed, + paymentFailureReason: nil)) + } + + static var pendingStatusResponse: APIResult { + .success(.init(id: "id", + paymentId: "paymentId", + amount: 1, + currencyCode: "EUR", + customer: nil, + customerId: nil, + dateStr: nil, + order: nil, + orderId: nil, + requiredAction: nil, + status: .pending, + paymentFailureReason: nil)) + } + + static var pendingStatusResponseWithShowCheckoutSuccessOnPending: APIResult { + .success(.init(id: "id", + paymentId: "paymentId", + amount: 1, + currencyCode: "EUR", + customer: nil, + customerId: nil, + dateStr: nil, + order: nil, + orderId: nil, + requiredAction: nil, + status: .pending, + paymentFailureReason: nil, + showSuccessCheckoutOnPendingPayment: true)) + } + + static var errorResponse: APIResult { + .failure(PrimerError.failedToCreatePayment(paymentMethodType: "PAYMENT_CARD", + description: "", + userInfo: [:], + diagnosticsId: "")) + } +} From 4352203079398d5cba92da32748ce3fa7800c59a Mon Sep 17 00:00:00 2001 From: Niall Date: Wed, 7 Aug 2024 12:56:06 +0200 Subject: [PATCH 28/34] Fix conflicts --- .../Classes/Core/Primer/Primer.swift | 2 +- .../Classes/Core/Primer/PrimerInternal.swift | 4 +++ Tests/Primer/Utils/CheckoutSessionTests.swift | 26 +++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 Tests/Primer/Utils/CheckoutSessionTests.swift diff --git a/Sources/PrimerSDK/Classes/Core/Primer/Primer.swift b/Sources/PrimerSDK/Classes/Core/Primer/Primer.swift index 8f830aefd5..13c4d445fa 100644 --- a/Sources/PrimerSDK/Classes/Core/Primer/Primer.swift +++ b/Sources/PrimerSDK/Classes/Core/Primer/Primer.swift @@ -114,6 +114,6 @@ public class Primer { Checkout Session is active? */ public func checkoutSessionIsActive() -> Bool { - PrimerInternal.shared.checkoutSessionId != nil + PrimerInternal.shared.checkoutSessionIsActive() } } diff --git a/Sources/PrimerSDK/Classes/Core/Primer/PrimerInternal.swift b/Sources/PrimerSDK/Classes/Core/Primer/PrimerInternal.swift index 3aad5d8487..a65ad864f3 100644 --- a/Sources/PrimerSDK/Classes/Core/Primer/PrimerInternal.swift +++ b/Sources/PrimerSDK/Classes/Core/Primer/PrimerInternal.swift @@ -291,4 +291,8 @@ internal class PrimerInternal: LogReporter { } } } + + internal func checkoutSessionIsActive() -> Bool { + checkoutSessionId != nil + } } diff --git a/Tests/Primer/Utils/CheckoutSessionTests.swift b/Tests/Primer/Utils/CheckoutSessionTests.swift new file mode 100644 index 0000000000..0ae493eae6 --- /dev/null +++ b/Tests/Primer/Utils/CheckoutSessionTests.swift @@ -0,0 +1,26 @@ +// +// File.swift +// +// +// Created by Niall Quinn on 06/08/24. +// + +import XCTest +@testable import PrimerSDK + +final class CheckoutSessionTests: XCTestCase { + func test_headless_cleanup() { + XCTAssertFalse(Primer.shared.checkoutSessionIsActive()) + + let expectation = self.expectation(description: "Wait for headless load") + + PrimerHeadlessUniversalCheckout.current.start(withClientToken: "") { paymentMethods, err in + XCTAssertTrue(Primer.shared.checkoutSessionIsActive()) + expectation.fulfill() + } + + waitForExpectations(timeout: 30) + PrimerHeadlessUniversalCheckout.current.cleanUp() + XCTAssertFalse(Primer.shared.checkoutSessionIsActive()) + } +} From 0d4e82528ff12ec36b75de8e9de9320d79f22e84 Mon Sep 17 00:00:00 2001 From: Security Integrations Date: Tue, 6 Aug 2024 14:36:40 +0200 Subject: [PATCH 29/34] Release 2.28.0 (#963) [create-pull-request] automated change Co-authored-by: NQuinn27 <3179752+NQuinn27@users.noreply.github.com> --- .cz.toml | 2 +- CHANGELOG.md | 14 ++++++++++++++ PrimerSDK.podspec | 2 +- Sources/PrimerSDK/Classes/version.swift | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.cz.toml b/.cz.toml index aa12f2eea5..92c7dfc70d 100644 --- a/.cz.toml +++ b/.cz.toml @@ -1,6 +1,6 @@ [tool.commitizen] version_scheme = "semver" -version = "2.27.0" +version = "2.28.0" version_files = [ "Sources/PrimerSDK/Classes/version.swift:let PrimerSDKVersion", "PrimerSDK.podspec:s.version" diff --git a/CHANGELOG.md b/CHANGELOG.md index 21b6590104..9ddc271395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## 2.28.0 (2024-08-06) + +### Feat + +- session is active (#962) +- **apple-pay**: Prefer using merchantName from Configuration over th… (#958) + +### Fix + +- Validate PENDING in resume if showSuccessCheckoutOnPendingPayment flag is true (#957) +- Concurrent dependency access (#950) +- Add duration tracking for network requests (#952) +- Fix crash when attempting to clean up 3DS prior to initialisation (#937) + ## 2.27.0 (2024-07-10) ### Feat diff --git a/PrimerSDK.podspec b/PrimerSDK.podspec index 7ef0ca569a..f49755c223 100644 --- a/PrimerSDK.podspec +++ b/PrimerSDK.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "PrimerSDK" - s.version = "2.27.0" + s.version = "2.28.0" s.summary = "Official iOS SDK for Primer" s.description = <<-DESC This library contains the official iOS SDK for Primer. Install this Cocoapod to seemlessly integrate the Primer Checkout & API platform in your app. diff --git a/Sources/PrimerSDK/Classes/version.swift b/Sources/PrimerSDK/Classes/version.swift index 3b6703b1a0..ec89e64ad2 100644 --- a/Sources/PrimerSDK/Classes/version.swift +++ b/Sources/PrimerSDK/Classes/version.swift @@ -1,2 +1,2 @@ // swiftlint:disable:next identifier_name -public let PrimerSDKVersion = "2.27.0" +public let PrimerSDKVersion = "2.28.0" From dcc59982ff64c0713c2d0a07d2f57c80e8948681 Mon Sep 17 00:00:00 2001 From: Niall Date: Wed, 7 Aug 2024 12:43:31 +0200 Subject: [PATCH 30/34] Add clientSessionCachingEnabled flag --- .../ConfigurationCache/ConfigurationCache.swift | 10 ++++++++++ .../PrimerAPIConfigurationModule.swift | 12 +++++++++--- .../Classes/Data Models/PrimerSettings.swift | 5 ++++- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/Sources/PrimerSDK/Classes/Core/Cache/ConfigurationCache/ConfigurationCache.swift b/Sources/PrimerSDK/Classes/Core/Cache/ConfigurationCache/ConfigurationCache.swift index 494194d2e6..1cbf46b058 100644 --- a/Sources/PrimerSDK/Classes/Core/Cache/ConfigurationCache/ConfigurationCache.swift +++ b/Sources/PrimerSDK/Classes/Core/Cache/ConfigurationCache/ConfigurationCache.swift @@ -22,6 +22,9 @@ class ConfigurationCache: ConfigurationCaching { } func data(forKey key: String) -> ConfigurationCachedData? { + guard cachingEnabled else { + return nil + } if let cachedData = cache.value(forKey: key) { if validateCachedConfig(key: key, cachedData: cachedData) == false { cache.removeValue(forKey: key) @@ -33,6 +36,9 @@ class ConfigurationCache: ConfigurationCaching { } func setData(_ data: ConfigurationCachedData, forKey key: String) { + guard cachingEnabled else { + return + } // Cache includes at most one cached configuration clearCache() cache.insert(data, forKey: key) @@ -49,6 +55,10 @@ class ConfigurationCache: ConfigurationCaching { return true } + + private var cachingEnabled: Bool { + PrimerSettings.current.clientSessionCachingEnabled + } } class ConfigurationCachedData { diff --git a/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift b/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift index ef686bf951..c56fc8a975 100644 --- a/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift +++ b/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift @@ -277,7 +277,7 @@ internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtoco } PrimerAPIConfigurationModule.queue.sync { - if let cachedConfig = ConfigurationCache.shared.data(forKey: cacheKey) { + if cachingEnabled, let cachedConfig = ConfigurationCache.shared.data(forKey: cacheKey) { let event = Analytics.Event.message( message: "Configuration cache hit with key: \(cacheKey)", messageType: .info, @@ -312,8 +312,10 @@ internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtoco case .success(let config): _ = ImageFileProcessor().process(configuration: config).ensure { // Cache the result - let cachedData = ConfigurationCachedData(config: config, headers: responseHeaders) - ConfigurationCache.shared.setData(cachedData, forKey: cacheKey) + if self.cachingEnabled { + let cachedData = ConfigurationCachedData(config: config, headers: responseHeaders) + ConfigurationCache.shared.setData(cachedData, forKey: cacheKey) + } self.recordLoadedEvent(start, source: .network) innerSeal.fulfill(config) } @@ -380,5 +382,9 @@ internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtoco ) Analytics.Service.record(event: event) } + + private var cachingEnabled: Bool { + PrimerSettings.current.clientSessionCachingEnabled + } } // swiftlint:enable type_body_length diff --git a/Sources/PrimerSDK/Classes/Data Models/PrimerSettings.swift b/Sources/PrimerSDK/Classes/Data Models/PrimerSettings.swift index 77b5c9a574..2278e1a797 100644 --- a/Sources/PrimerSDK/Classes/Data Models/PrimerSettings.swift +++ b/Sources/PrimerSDK/Classes/Data Models/PrimerSettings.swift @@ -22,6 +22,7 @@ public class PrimerSettings: PrimerSettingsProtocol, Codable { let paymentMethodOptions: PrimerPaymentMethodOptions let uiOptions: PrimerUIOptions let debugOptions: PrimerDebugOptions + let clientSessionCachingEnabled: Bool public init( paymentHandling: PrimerPaymentHandling = .auto, @@ -29,13 +30,15 @@ public class PrimerSettings: PrimerSettingsProtocol, Codable { paymentMethodOptions: PrimerPaymentMethodOptions? = nil, uiOptions: PrimerUIOptions? = nil, threeDsOptions: PrimerThreeDsOptions? = nil, - debugOptions: PrimerDebugOptions? = nil + debugOptions: PrimerDebugOptions? = nil, + clientSessionCachingEnabled: Bool = false ) { self.paymentHandling = paymentHandling self.localeData = localeData ?? PrimerLocaleData() self.paymentMethodOptions = paymentMethodOptions ?? PrimerPaymentMethodOptions() self.uiOptions = uiOptions ?? PrimerUIOptions() self.debugOptions = debugOptions ?? PrimerDebugOptions() + self.clientSessionCachingEnabled = clientSessionCachingEnabled } } From 56c8a105843c9ca7049b6d309d9d8ff4ee7faf64 Mon Sep 17 00:00:00 2001 From: Niall Date: Wed, 7 Aug 2024 12:54:41 +0200 Subject: [PATCH 31/34] Add unit test to test for flag --- .../Utils/ConfigurationCacheTests.swift | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/Tests/Primer/Utils/ConfigurationCacheTests.swift b/Tests/Primer/Utils/ConfigurationCacheTests.swift index bb8cb2eaf8..47b17f43c0 100644 --- a/Tests/Primer/Utils/ConfigurationCacheTests.swift +++ b/Tests/Primer/Utils/ConfigurationCacheTests.swift @@ -30,6 +30,14 @@ final class ConfigurationCacheTests: XCTestCase { } func test_clearCache() throws { + let settings = PrimerSettings(clientSessionCachingEnabled: true) + let exp = self.expectation(description: "Wait for headless start") + PrimerHeadlessUniversalCheckout.current.start(withClientToken: "", settings: settings) { paymentMethods, err in + exp.fulfill() + } + + wait(for: [exp]) + let cache = ConfigurationCache() let headers = [ConfigurationCachedData.CacheHeaderKey: "2000"] @@ -54,6 +62,26 @@ final class ConfigurationCacheTests: XCTestCase { XCTAssertNil(cache.data(forKey: cacheKey)) } + + func test_respectsPrimerSettingsFlag() { + let settings = PrimerSettings(clientSessionCachingEnabled: false) + let exp = self.expectation(description: "Wait for headless start") + PrimerHeadlessUniversalCheckout.current.start(withClientToken: "", settings: settings) { paymentMethods, err in + exp.fulfill() + } + + wait(for: [exp]) + + let cache = ConfigurationCache() + + let headers = [ConfigurationCachedData.CacheHeaderKey: "2000"] + let cacheData = ConfigurationCachedData(config: PrimerAPIConfiguration.mock, headers: headers) + let cacheKey = "cache-key" + cache.setData(cacheData, forKey: cacheKey) + + XCTAssertNil(cache.data(forKey: cacheKey)) + } + } From 0e20c4922da8c7c3623bf788e0293602f13bfa0f Mon Sep 17 00:00:00 2001 From: Niall Date: Wed, 7 Aug 2024 13:25:11 +0200 Subject: [PATCH 32/34] move cache clean to cleanup --- .../Core/Payment Services/PrimerAPIConfigurationModule.swift | 1 - .../PrimerHeadlessUniversalCheckout.swift | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift b/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift index c56fc8a975..a49c230cb0 100644 --- a/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift +++ b/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift @@ -82,7 +82,6 @@ internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtoco static func resetSession() { AppState.current.clientToken = nil AppState.current.apiConfiguration = nil - ConfigurationCache.shared.clearCache() } func setupSession( diff --git a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/PrimerHeadlessUniversalCheckout.swift b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/PrimerHeadlessUniversalCheckout.swift index 3da3897135..2ad9cb05ec 100644 --- a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/PrimerHeadlessUniversalCheckout.swift +++ b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/PrimerHeadlessUniversalCheckout.swift @@ -142,6 +142,7 @@ public class PrimerHeadlessUniversalCheckout: LogReporter { public func cleanUp() { PrimerAPIConfigurationModule.resetSession() + ConfigurationCache.shared.clearCache() PrimerInternal.shared.checkoutSessionId = nil } From 68e7282bcdd1181dcec74dbd4a99456e4749b27c Mon Sep 17 00:00:00 2001 From: Niall Date: Wed, 7 Aug 2024 13:39:34 +0200 Subject: [PATCH 33/34] Implement LogReporter --- .../Core/Payment Services/PrimerAPIConfigurationModule.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift b/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift index a49c230cb0..28a1f14790 100644 --- a/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift +++ b/Sources/PrimerSDK/Classes/Core/Payment Services/PrimerAPIConfigurationModule.swift @@ -22,15 +22,13 @@ internal protocol PrimerAPIConfigurationModuleProtocol { } // swiftlint:disable type_body_length -internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtocol { +internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtocol, LogReporter { static var apiClient: PrimerAPIClientProtocol? private static let queue = DispatchQueue(label: "com.primer.configurationQueue") private static var pendingPromises: [String: Promise] = [:] - private let logger = PrimerLogging.shared.logger - static var clientToken: JWTToken? { get { if PrimerAPIConfigurationModule.decodedJWTToken == nil { From 9663e6d3c7a8f0ad67846202baa52d79f53dc74a Mon Sep 17 00:00:00 2001 From: Niall Date: Wed, 7 Aug 2024 14:27:21 +0200 Subject: [PATCH 34/34] Fix use cached config test --- .../Primer/Modules/PrimerAPIConfigurationModuleTests.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Tests/Primer/Modules/PrimerAPIConfigurationModuleTests.swift b/Tests/Primer/Modules/PrimerAPIConfigurationModuleTests.swift index f5a9d644f0..24aaaef94f 100644 --- a/Tests/Primer/Modules/PrimerAPIConfigurationModuleTests.swift +++ b/Tests/Primer/Modules/PrimerAPIConfigurationModuleTests.swift @@ -56,9 +56,16 @@ class PrimerAPIConfigurationModuleTests: XCTestCase { func test_usesCachedConfig() throws { let expectation = XCTestExpectation(description: "Uses Cached Config") + let headlessExpectation = self.expectation(description: "Headless Loaded") let proxyId = "proxy-identifier" + let settings = PrimerSettings(clientSessionCachingEnabled: true) + PrimerHeadlessUniversalCheckout.current.start(withClientToken: "", settings: settings) { paymentMethods, err in + headlessExpectation.fulfill() + } + + wait(for: [headlessExpectation]) let config_pre = PrimerAPIConfiguration( coreUrl: proxyId, pciUrl: "https://pci.primer.io",