diff --git a/Debug App/Sources/View Controllers/MerchantHeadlessCheckoutAvailablePaymentMethodsViewController.swift b/Debug App/Sources/View Controllers/MerchantHeadlessCheckoutAvailablePaymentMethodsViewController.swift index 457b6362b6..b50ea0823e 100644 --- a/Debug App/Sources/View Controllers/MerchantHeadlessCheckoutAvailablePaymentMethodsViewController.swift +++ b/Debug App/Sources/View Controllers/MerchantHeadlessCheckoutAvailablePaymentMethodsViewController.swift @@ -175,8 +175,12 @@ extension MerchantHeadlessCheckoutAvailablePaymentMethodsViewController: UITable break #endif default: - redirectManager = try? PrimerHeadlessUniversalCheckout.NativeUIManager(paymentMethodType: paymentMethodType) - try? redirectManager?.showPaymentMethod(intent: sessionIntent) + do { + redirectManager = try PrimerHeadlessUniversalCheckout.NativeUIManager(paymentMethodType: paymentMethodType) + try redirectManager?.showPaymentMethod(intent: sessionIntent) + } catch { + print("\n\nMERCHANT APP\n\(#function)\nerror: \(error)") + } } } } diff --git a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Managers/Payment Method Managers/RawDataManager.swift b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Managers/Payment Method Managers/RawDataManager.swift index 61eb768a62..38b03979e5 100644 --- a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Managers/Payment Method Managers/RawDataManager.swift +++ b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Managers/Payment Method Managers/RawDataManager.swift @@ -137,6 +137,9 @@ extension PrimerHeadlessUniversalCheckout { case PrimerPaymentMethodType.xenditRetailOutlets.rawValue: self.rawDataTokenizationBuilder = PrimerRawRetailerDataTokenizationBuilder(paymentMethodType: paymentMethodType) + case PrimerPaymentMethodType.adyenBlik.rawValue: + self.rawDataTokenizationBuilder = PrimerRawOTPDataTokenizationBuilder(paymentMethodType: paymentMethodType) + default: let err = PrimerError.unsupportedPaymentMethod(paymentMethodType: paymentMethodType, userInfo: .errorUserInfoDictionary(), diagnosticsId: UUID().uuidString) diff --git a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Managers/PrimerRawOTPDataTokenizationBuilder.swift b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Managers/PrimerRawOTPDataTokenizationBuilder.swift new file mode 100644 index 0000000000..d774c22642 --- /dev/null +++ b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Managers/PrimerRawOTPDataTokenizationBuilder.swift @@ -0,0 +1,147 @@ +// +// PrimerRawOTPDataTokenizationBuilder.swift +// PrimerSDK +// +// Created by Boris on 26.9.24.. +// + +// swiftlint:disable function_body_length + +import Foundation + +class PrimerRawOTPDataTokenizationBuilder: PrimerRawDataTokenizationBuilderProtocol { + + var rawData: PrimerRawData? { + didSet { + if let rawOTPInput = self.rawData as? PrimerOTPData { + rawOTPInput.onDataDidChange = { + _ = self.validateRawData(rawOTPInput) + } + } + + if let rawData = self.rawData { + _ = self.validateRawData(rawData) + } + } + } + + weak var rawDataManager: PrimerHeadlessUniversalCheckout.RawDataManager? + var isDataValid: Bool = false + var paymentMethodType: String + var delegate: PrimerHeadlessUniversalCheckoutRawDataManagerDelegate? + + var requiredInputElementTypes: [PrimerInputElementType] { + [.otp] + } + + required init(paymentMethodType: String) { + self.paymentMethodType = paymentMethodType + } + + func configure(withRawDataManager rawDataManager: PrimerHeadlessUniversalCheckout.RawDataManager) { + self.rawDataManager = rawDataManager + } + + func makeRequestBodyWithRawData(_ data: PrimerRawData) -> Promise { + return Promise { seal in + + guard let paymentMethod = PrimerPaymentMethod.getPaymentMethod(withType: paymentMethodType), let paymentMethodId = paymentMethod.id else { + let err = PrimerError.unsupportedPaymentMethod(paymentMethodType: paymentMethodType, userInfo: .errorUserInfoDictionary(), + diagnosticsId: UUID().uuidString) + ErrorHandler.handle(error: err) + seal.reject(err) + return + } + + guard let rawData = data as? PrimerOTPData else { + let err = PrimerError.invalidValue(key: "rawData", + value: nil, + userInfo: .errorUserInfoDictionary(), + diagnosticsId: UUID().uuidString) + ErrorHandler.handle(error: err) + seal.reject(err) + return + } + + let sessionInfo = BlikSessionInfo(blikCode: rawData.otp, + locale: PrimerSettings.current.localeData.localeCode) + + let paymentInstrument = OffSessionPaymentInstrument( + paymentMethodConfigId: paymentMethodId, + paymentMethodType: paymentMethodType, + sessionInfo: sessionInfo) + + let requestBody = Request.Body.Tokenization(paymentInstrument: paymentInstrument) + seal.fulfill(requestBody) + } + } + + func validateRawData(_ data: PrimerRawData) -> Promise { + return Promise { seal in + DispatchQueue.global(qos: .userInteractive).async { + var errors: [PrimerValidationError] = [] + + guard let rawData = data as? PrimerOTPData else { + let err = PrimerValidationError.invalidRawData( + userInfo: .errorUserInfoDictionary(), + diagnosticsId: UUID().uuidString) + errors.append(err) + ErrorHandler.handle(error: err) + + self.isDataValid = false + + DispatchQueue.main.async { + if let rawDataManager = self.rawDataManager { + self.rawDataManager?.delegate?.primerRawDataManager?(rawDataManager, + dataIsValid: self.isDataValid, + errors: errors.count == 0 ? nil : errors) + } + + seal.reject(err) + } + return + } + + if !rawData.otp.isValidOTP { + errors.append(PrimerValidationError.invalidOTPCode( + message: "OTP is not valid.", + userInfo: .errorUserInfoDictionary(), + diagnosticsId: UUID().uuidString)) + } + + if !errors.isEmpty { + let err = PrimerError.underlyingErrors( + errors: errors, + userInfo: .errorUserInfoDictionary(), + diagnosticsId: UUID().uuidString) + ErrorHandler.handle(error: err) + + self.isDataValid = false + + DispatchQueue.main.async { + if let rawDataManager = self.rawDataManager { + self.rawDataManager?.delegate?.primerRawDataManager?(rawDataManager, + dataIsValid: self.isDataValid, + errors: errors.count == 0 ? nil : errors) + } + + seal.reject(err) + } + } else { + self.isDataValid = true + + DispatchQueue.main.async { + if let rawDataManager = self.rawDataManager { + self.rawDataManager?.delegate?.primerRawDataManager?(rawDataManager, + dataIsValid: self.isDataValid, + errors: errors.count == 0 ? nil : errors) + } + + seal.fulfill() + } + } + } + } + } +} +// swiftlint:enable function_body_length diff --git a/Sources/PrimerSDK/Classes/Data Models/PrimerPaymentMethod.swift b/Sources/PrimerSDK/Classes/Data Models/PrimerPaymentMethod.swift index 43602bf9b3..2b268e63d7 100644 --- a/Sources/PrimerSDK/Classes/Data Models/PrimerPaymentMethod.swift +++ b/Sources/PrimerSDK/Classes/Data Models/PrimerPaymentMethod.swift @@ -240,8 +240,10 @@ class PrimerPaymentMethod: Codable, LogReporter { case .stripeAch: categories.append(PrimerPaymentMethodManagerCategory.nativeUI) - default: - break + case .adyenBlik: + categories.append(PrimerPaymentMethodManagerCategory.rawData) + + default: break } return categories.isEmpty ? nil : categories diff --git a/Sources/PrimerSDK/Classes/PCI/Checkout Components/PrimerOTPData.swift b/Sources/PrimerSDK/Classes/PCI/Checkout Components/PrimerOTPData.swift new file mode 100644 index 0000000000..a2158a6ce3 --- /dev/null +++ b/Sources/PrimerSDK/Classes/PCI/Checkout Components/PrimerOTPData.swift @@ -0,0 +1,28 @@ +// +// PrimerOTPData.swift +// PrimerSDK +// +// Created by Boris on 26.9.24.. +// + +import Foundation + +public class PrimerOTPData: PrimerRawData { + + public var otp: String { + didSet { + self.onDataDidChange?() + } + } + + private enum CodingKeys: String, CodingKey { + case otp + } + + public required init( + otp: String + ) { + self.otp = otp + super.init() + } +} diff --git a/Tests/Primer/Data Models/PrimerPaymentMethodTests.swift b/Tests/Primer/Data Models/PrimerPaymentMethodTests.swift index db484bb1a7..9d1964a4a6 100644 --- a/Tests/Primer/Data Models/PrimerPaymentMethodTests.swift +++ b/Tests/Primer/Data Models/PrimerPaymentMethodTests.swift @@ -56,6 +56,49 @@ final class PrimerPaymentMethodTests: XCTestCase { XCTAssertEqual(paymentMethod.logo!, UIImage(systemName: "sun.min")) } + func testInvertedLogo_NoImage() { + XCTAssertNil(paymentMethod.invertedLogo) + } + + func testInvertedLogo_NilImages() { + paymentMethod.baseLogoImage = PrimerTheme.BaseImage(colored: nil, light: nil, dark: nil) + XCTAssertNil(paymentMethod.invertedLogo) + } + + func testInvertedLogo_ReturnsCorrectImage() { + // Given + paymentMethod.baseLogoImage = PrimerTheme.BaseImage( + colored: UIImage(systemName: "colored"), + light: UIImage(systemName: "light"), + dark: UIImage(systemName: "dark") + ) + let isDarkModeEnabled = UIScreen.isDarkModeEnabled + + // When + let invertedLogo = paymentMethod.invertedLogo + + // Then + if isDarkModeEnabled { + // When dark mode is enabled, invertedLogo should return the light image if available + if let lightImage = paymentMethod.baseLogoImage?.light { + XCTAssertEqual(invertedLogo, lightImage) + } else if let coloredImage = paymentMethod.baseLogoImage?.colored { + XCTAssertEqual(invertedLogo, coloredImage) + } else { + XCTAssertNil(invertedLogo) + } + } else { + // When dark mode is disabled, invertedLogo should return the dark image if available + if let darkImage = paymentMethod.baseLogoImage?.dark { + XCTAssertEqual(invertedLogo, darkImage) + } else if let coloredImage = paymentMethod.baseLogoImage?.colored { + XCTAssertEqual(invertedLogo, coloredImage) + } else { + XCTAssertNil(invertedLogo) + } + } + } + func testViewModels() { let nativeSDKPaymentMethod = createPaymentMethod(withImplementationType: .webRedirect) XCTAssertTrue(nativeSDKPaymentMethod.tokenizationViewModel is WebRedirectPaymentMethodTokenizationViewModel) diff --git a/Tests/Primer/Managers/PrimerOTPDataTests.swift b/Tests/Primer/Managers/PrimerOTPDataTests.swift new file mode 100644 index 0000000000..7e0e1dabf6 --- /dev/null +++ b/Tests/Primer/Managers/PrimerOTPDataTests.swift @@ -0,0 +1,47 @@ +// +// PrimerOTPDataTests.swift +// PrimerSDK +// +// Created by Boris on 1.10.24.. +// + +import XCTest +@testable import PrimerSDK + +class PrimerOTPDataTests: XCTestCase { + + // Test initialization with OTP + func test_initialization_with_otp() { + let otp = "123456" + let otpData = PrimerOTPData(otp: otp) + XCTAssertEqual(otpData.otp, otp, "OTP should be initialized correctly") + } + + // Test that onDataDidChange is called when OTP is changed + func test_onDataDidChange_called_when_otp_changes() { + let otpData = PrimerOTPData(otp: "123456") + let exp = expectation(description: "onDataDidChange should be called") + + otpData.onDataDidChange = { + exp.fulfill() + } + + otpData.otp = "654321" // Change OTP to trigger onDataDidChange + + wait(for: [exp], timeout: 1.0) + } + + // Test that onDataDidChange is not nil + func test_onDataDidChange_is_not_nil_after_setting() { + let otpData = PrimerOTPData(otp: "123456") + otpData.onDataDidChange = {} + + XCTAssertNotNil(otpData.onDataDidChange, "onDataDidChange should not be nil after setting") + } + + // Test that onDataDidChange is nil by default + func test_onDataDidChange_is_nil_by_default() { + let otpData = PrimerOTPData(otp: "123456") + XCTAssertNil(otpData.onDataDidChange, "onDataDidChange should be nil by default") + } +} diff --git a/Tests/Primer/Managers/PrimerRawOTPDataTokenizationBuilderTests.swift b/Tests/Primer/Managers/PrimerRawOTPDataTokenizationBuilderTests.swift new file mode 100644 index 0000000000..035e5580a2 --- /dev/null +++ b/Tests/Primer/Managers/PrimerRawOTPDataTokenizationBuilderTests.swift @@ -0,0 +1,332 @@ +// +// PrimerRawOTPDataTokenizationBuilderTests.swift +// +// Created by Boris on 26/9/24. +// + +import XCTest +@testable import PrimerSDK + +class PrimerRawOTPDataTokenizationBuilderTests: XCTestCase { + + static let validationTimeout = 3.0 + var mockApiClient: MockPrimerAPIClient! + + override func setUp() { + super.setUp() + SDKSessionHelper.setUp() + mockApiClient = MockPrimerAPIClient() + } + + override func tearDown() { + resetPrimerConfiguration() + SDKSessionHelper.tearDown() + super.tearDown() + } + + // MARK: - Test 'configure(withRawDataManager:)' + + func test_configure_withRawDataManager_sets_rawDataManager() { + // Arrange + let tokenizationBuilder = PrimerRawOTPDataTokenizationBuilder(paymentMethodType: "ADYEN_BLIK") + XCTAssertNil(tokenizationBuilder.rawDataManager, "rawDataManager should initially be nil") + + prepareConfigurations(paymentMethodType: "ADYEN_BLIK") + + // Initialize a mock RawDataManager + var mockRawDataManager: PrimerHeadlessUniversalCheckout.RawDataManager? + do { + mockRawDataManager = try PrimerHeadlessUniversalCheckout.RawDataManager(paymentMethodType: "ADYEN_BLIK") + } catch { + XCTFail("Failed to initialize RawDataManager: \(error)") + return + } + + // Act + tokenizationBuilder.configure(withRawDataManager: mockRawDataManager!) + + // Assert + XCTAssertNotNil(tokenizationBuilder.rawDataManager, "rawDataManager should be set after configure") + XCTAssertTrue(tokenizationBuilder.rawDataManager === mockRawDataManager, "rawDataManager should be the same instance passed to configure") + } + + // Test invalid OTP: Empty string + func test_invalid_otp_in_raw_otp_data_empty() throws { + let exp = expectation(description: "Await validation") + + let rawOTPData = PrimerOTPData(otp: "") + let tokenizationBuilder = PrimerRawOTPDataTokenizationBuilder(paymentMethodType: "ADYEN_BLIK") + + firstly { + return tokenizationBuilder.validateRawData(rawOTPData) + } + .done { + XCTAssert(false, "OTP data should not pass validation when OTP is empty") + exp.fulfill() + } + .catch { _ in + exp.fulfill() + } + wait(for: [exp], timeout: Self.validationTimeout) + } + + // Test invalid OTP: Contains letters + func test_invalid_otp_in_raw_otp_data_non_numeric() throws { + let exp = expectation(description: "Await validation") + + let rawOTPData = PrimerOTPData(otp: "abc123") + let tokenizationBuilder = PrimerRawOTPDataTokenizationBuilder(paymentMethodType: "ADYEN_BLIK") + + firstly { + return tokenizationBuilder.validateRawData(rawOTPData) + } + .done { + XCTAssert(false, "OTP data should not pass validation when OTP contains letters") + exp.fulfill() + } + .catch { _ in + exp.fulfill() + } + wait(for: [exp], timeout: Self.validationTimeout) + } + + // Test invalid OTP: Too short + func test_invalid_otp_in_raw_otp_data_too_short() throws { + let exp = expectation(description: "Await validation") + + let rawOTPData = PrimerOTPData(otp: "12345") // 5 digits + let tokenizationBuilder = PrimerRawOTPDataTokenizationBuilder(paymentMethodType: "ADYEN_BLIK") + + firstly { + return tokenizationBuilder.validateRawData(rawOTPData) + } + .done { + XCTAssert(false, "OTP data should not pass validation when OTP is too short") + exp.fulfill() + } + .catch { _ in + exp.fulfill() + } + wait(for: [exp], timeout: Self.validationTimeout) + } + + // Test invalid OTP: Too long + func test_invalid_otp_in_raw_otp_data_too_long() throws { + let exp = expectation(description: "Await validation") + + let rawOTPData = PrimerOTPData(otp: "1234567") // 7 digits + let tokenizationBuilder = PrimerRawOTPDataTokenizationBuilder(paymentMethodType: "ADYEN_BLIK") + + firstly { + return tokenizationBuilder.validateRawData(rawOTPData) + } + .done { + XCTAssert(false, "OTP data should not pass validation when OTP is too long") + exp.fulfill() + } + .catch { _ in + exp.fulfill() + } + wait(for: [exp], timeout: Self.validationTimeout) + } + + // Test valid OTP + func test_valid_otp_in_raw_otp_data() throws { + let exp = expectation(description: "Await validation") + + let rawOTPData = PrimerOTPData(otp: "123456") // 6 digits + let tokenizationBuilder = PrimerRawOTPDataTokenizationBuilder(paymentMethodType: "ADYEN_BLIK") + + firstly { + return tokenizationBuilder.validateRawData(rawOTPData) + } + .done { + exp.fulfill() + } + .catch { error in + XCTAssert(false, "OTP data should pass validation but failed with error: \(error)") + exp.fulfill() + } + wait(for: [exp], timeout: Self.validationTimeout) + } + + // Test making request body with invalid payment method type + func test_make_request_body_with_raw_data_invalid_payment_method_type() throws { + let exp = expectation(description: "Await making request body") + + let rawOTPData = PrimerOTPData(otp: "123456") + let invalidPaymentMethodType = "INVALID_PAYMENT_METHOD" + let tokenizationBuilder = PrimerRawOTPDataTokenizationBuilder(paymentMethodType: invalidPaymentMethodType) + + firstly { + tokenizationBuilder.makeRequestBodyWithRawData(rawOTPData) + } + .done { _ in + XCTAssert(false, "Should not have succeeded with invalid payment method type") + exp.fulfill() + } + .catch { _ in + exp.fulfill() + } + wait(for: [exp], timeout: Self.validationTimeout) + } + + // MARK: - Test 'requiredInputElementTypes' + + func test_requiredInputElementTypes() { + let tokenizationBuilder = PrimerRawOTPDataTokenizationBuilder(paymentMethodType: "ADYEN_BLIK") + XCTAssertEqual(tokenizationBuilder.requiredInputElementTypes, [.otp]) + } + + // MARK: - Test 'makeRequestBodyWithRawData' with invalid rawData + + func test_makeRequestBodyWithRawData_with_invalid_data_type() { + let exp = expectation(description: "Await making request body") + + // Using PrimerCardData instead of PrimerOTPData + let invalidRawData = PrimerCardData( + cardNumber: "4242424242424242", + expiryDate: "12/2030", + cvv: "123", + cardholderName: "John Doe" + ) + + let tokenizationBuilder = PrimerRawOTPDataTokenizationBuilder(paymentMethodType: "ADYEN_BLIK") + + firstly { + tokenizationBuilder.makeRequestBodyWithRawData(invalidRawData) + } + .done { _ in + XCTFail("Expected failure when raw data is invalid") + exp.fulfill() + } + .catch { error in + XCTAssert(error is PrimerError) + exp.fulfill() + } + + wait(for: [exp], timeout: Self.validationTimeout) + } + + // MARK: - Test 'validateRawData' with invalid data type + + func test_validateRawData_with_invalid_data_type() { + let exp = expectation(description: "Await validation") + + // Using PrimerCardData instead of PrimerOTPData + let invalidRawData = PrimerCardData( + cardNumber: "4242424242424242", + expiryDate: "12/2030", + cvv: "123", + cardholderName: "John Doe" + ) + + let tokenizationBuilder = PrimerRawOTPDataTokenizationBuilder(paymentMethodType: "ADYEN_BLIK") + + firstly { + tokenizationBuilder.validateRawData(invalidRawData) + } + .done { + XCTFail("Expected validation to fail with invalid raw data type") + exp.fulfill() + } + .catch { error in + XCTAssert(error is PrimerValidationError) + exp.fulfill() + } + + wait(for: [exp], timeout: Self.validationTimeout) + } + + // MARK: - Test 'makeRequestBodyWithRawData' with valid rawData + + func test_makeRequestBodyWithRawData_with_valid_data() { + let exp = expectation(description: "Await making request body") + + let rawOTPData = PrimerOTPData(otp: "123456") + let tokenizationBuilder = PrimerRawOTPDataTokenizationBuilder(paymentMethodType: "ADYEN_BLIK") + + // Prepare the client session with the payment method + prepareConfigurations(paymentMethodType: "ADYEN_BLIK") + + firstly { + tokenizationBuilder.makeRequestBodyWithRawData(rawOTPData) + } + .done { requestBody in + // Assert that requestBody is correct + XCTAssertNotNil(requestBody.paymentInstrument) + if let paymentInstrument = requestBody.paymentInstrument as? OffSessionPaymentInstrument { + XCTAssertEqual(paymentInstrument.paymentMethodConfigId, "payment_method_id") + XCTAssertEqual(paymentInstrument.paymentMethodType, "ADYEN_BLIK") + if let sessionInfo = paymentInstrument.sessionInfo as? BlikSessionInfo { + XCTAssertEqual(sessionInfo.blikCode, "123456") + } else { + XCTFail("Expected sessionInfo to be BlikSessionInfo") + } + } else { + XCTFail("Expected paymentInstrument to be OffSessionPaymentInstrument") + } + exp.fulfill() + } + .catch { error in + XCTFail("Expected success but got error: \(error)") + exp.fulfill() + } + + wait(for: [exp], timeout: Self.validationTimeout) + } + + // MARK: - Helper Methods + + private func prepareConfigurations(paymentMethodType: String) { + mockApiClient = MockPrimerAPIClient() + PrimerInternal.shared.sdkIntegrationType = .headless + PrimerInternal.shared.intent = .checkout + + let paymentMethod = PrimerPaymentMethod( + id: "payment_method_id", + implementationType: .nativeSdk, + type: paymentMethodType, + name: "Adyen Blik", + processorConfigId: nil, + surcharge: nil, + options: nil, + displayMetadata: nil + ) + + let mockAPIConfiguration = createMockAPIConfiguration(paymentMethods: [paymentMethod]) + setupPrimerConfiguration(apiConfiguration: mockAPIConfiguration) + } + + private func createMockAPIConfiguration(paymentMethods: [PrimerPaymentMethod]) -> PrimerAPIConfiguration { + let mockAPIConfiguration = PrimerAPIConfiguration( + coreUrl: "https://core.primer.io", + pciUrl: "https://pci.primer.io", + binDataUrl: "https://primer.io/bindata", + assetsUrl: "https://assets.staging.core.primer.io", + clientSession: nil, + paymentMethods: paymentMethods, + primerAccountId: nil, + keys: nil, + checkoutModules: nil) + return mockAPIConfiguration + } + + private func setupPrimerConfiguration(apiConfiguration: PrimerAPIConfiguration) { + // Ensure that mockApiClient is of type MockPrimerAPIClient + mockApiClient.fetchConfigurationResult = (apiConfiguration, nil) + + AppState.current.clientToken = MockAppState.mockClientToken + PrimerAPIConfigurationModule.apiClient = mockApiClient + PrimerAPIConfigurationModule.clientToken = MockAppState.mockClientToken + PrimerAPIConfigurationModule.apiConfiguration = apiConfiguration + } + + private func resetPrimerConfiguration() { + mockApiClient = nil + PrimerAPIConfigurationModule.apiClient = nil + PrimerAPIConfigurationModule.clientToken = nil + PrimerAPIConfigurationModule.apiConfiguration = nil + } +} +