diff --git a/Package.swift b/Package.swift index 0efc075..1943515 100644 --- a/Package.swift +++ b/Package.swift @@ -50,7 +50,15 @@ let package = Package( .copy("API/Charge/Resources/PayWithTransferPusherCreditPending.json"), .copy("API/Charge/Resources/PayWithTransferPusherCreditRejected.json"), .copy("API/Charge/Resources/PayWithTransferPusherIncorrectAmount.json"), - .copy("API/Charge/Resources/ZapMandateResponse.json") + .copy("API/Charge/Resources/ZapMandateResponse.json"), + .copy("API/Charge/Resources/CapitecPayAuthenticateResponse.json"), + .copy("API/Charge/Resources/QRGenerateResponse.json"), + .copy("API/Charge/Resources/ZapPusherSuccess.json"), + .copy("API/Charge/Resources/ZapPusherFailed.json"), + .copy("API/Charge/Resources/CapitecPayPusherSuccess.json"), + .copy("API/Charge/Resources/CapitecPayPusherFailed.json"), + .copy("API/Charge/Resources/QRPusherSuccess.json"), + .copy("API/Charge/Resources/QRPusherFailed.json") ]) ] diff --git a/Sources/PaystackSDK/API/Charge/Zap.swift b/Sources/PaystackSDK/API/Charge/Zap.swift index 393cd71..6568173 100644 --- a/Sources/PaystackSDK/API/Charge/Zap.swift +++ b/Sources/PaystackSDK/API/Charge/Zap.swift @@ -23,21 +23,21 @@ public extension Paystack { } /// Listens for Zap status updates on the Pusher channel returned by - /// ``initiateZapMandate(_:)``. The status taxonomy is shared with - /// Pay-with-Transfer, so this - /// helper returns the existing `PayWithTransferPusherResponse` shape. + /// ``initiateZapMandate(_:)``. The server publishes only terminal + /// events (`success` / `failed`) on the Zap channel, so this helper + /// returns the narrow ``Charge3DSResponse`` shape shared with card + /// 3-D Secure and mobile money authorization. /// /// The underlying listener is single-shot per the existing - /// `PusherSubscriptionListener` contract ; callers that need to keep - /// listening through transient statuses must re-subscribe after each - /// event. + /// `PusherSubscriptionListener` contract — one event resolves the + /// listener. /// /// - Parameter channelName: The `pusherChannel` value returned from /// `initiateZapMandate` (e.g. `DBMAN_6222375579`). - /// - Returns: A ``Service`` carrying a ``PayWithTransferPusherResponse`` - /// on the first event the channel emits. + /// - Returns: A ``Service`` carrying a ``Charge3DSResponse`` on the + /// first event the channel emits. func listenForZapResponse(onChannel channelName: String) - -> Service { + -> Service { let subscription: any Subscription = PusherSubscription( channelName: channelName, eventName: "response") return Service(subscription) diff --git a/Sources/PaystackSDK/Core/Models/Models/EmptyRequest.swift b/Sources/PaystackSDK/Core/Models/Models/EmptyRequest.swift new file mode 100644 index 0000000..2b95589 --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/Models/EmptyRequest.swift @@ -0,0 +1,5 @@ +import Foundation + +public struct EmptyRequest: Encodable, Equatable { + public init() {} +} diff --git a/Sources/PaystackSDK/Core/Utils/Cryptography/Cryptography.swift b/Sources/PaystackSDK/Core/Utils/Cryptography/Cryptography.swift index b86229e..552c244 100644 --- a/Sources/PaystackSDK/Core/Utils/Cryptography/Cryptography.swift +++ b/Sources/PaystackSDK/Core/Utils/Cryptography/Cryptography.swift @@ -1,6 +1,12 @@ import Foundation -struct Cryptography { +public protocol CryptographyProtocol { + func encryptPKCS1(text: String, publicKey: String) throws -> String +} + +public struct Cryptography: CryptographyProtocol { + + public init() {} func encrypt(text: String, publicKey: String) throws -> String { let key = try createKey(from: publicKey, isPublic: true) @@ -21,6 +27,20 @@ struct Cryptography { } return try encrypt(text: jsonString, publicKey: publicKey) } + + /// RSA-encrypts `text` with PKCS#1 v1.5 padding and base64-encodes the result. + /// Mirrors Node's `crypto.publicEncrypt` with `constants.RSA_PKCS1_PADDING`. + public func encryptPKCS1(text: String, publicKey: String) throws -> String { + let key = try createKey(from: publicKey, isPublic: true) + + var encryptionError: Unmanaged? + guard let textData = text.data(using: .utf8), + let encryptedData = SecKeyCreateEncryptedData(key, .rsaEncryptionPKCS1, + textData as CFData, &encryptionError) as Data? else { + throw CryptographyError.encryptionFailed + } + return encryptedData.base64EncodedString() + } } // MARK: - Preparing Key diff --git a/Sources/PaystackUI/Charge/Zap/Repository/ZapRepository.swift b/Sources/PaystackUI/Charge/Zap/Repository/ZapRepository.swift index d38c2fa..c6bb20b 100644 --- a/Sources/PaystackUI/Charge/Zap/Repository/ZapRepository.swift +++ b/Sources/PaystackUI/Charge/Zap/Repository/ZapRepository.swift @@ -8,7 +8,7 @@ protocol ZapRepository { walletEmail: String) async throws -> ZapMandateResponse func listenForZapResponse(onChannel channelName: String) - async throws -> BankTransferTransactionUpdate + async throws -> ChargeCardTransaction } struct ZapRepositoryImplementation: ZapRepository { @@ -29,9 +29,9 @@ struct ZapRepositoryImplementation: ZapRepository { } func listenForZapResponse(onChannel channelName: String) - async throws -> BankTransferTransactionUpdate { + async throws -> ChargeCardTransaction { let response = try await paystack .listenForZapResponse(onChannel: channelName).async() - return BankTransferTransactionUpdate.from(response) + return ChargeCardTransaction.from(response) } } diff --git a/Sources/PaystackUI/Charge/Zap/Viewmodels/ZapViewModel.swift b/Sources/PaystackUI/Charge/Zap/Viewmodels/ZapViewModel.swift index 28d6383..0a356d1 100644 --- a/Sources/PaystackUI/Charge/Zap/Viewmodels/ZapViewModel.swift +++ b/Sources/PaystackUI/Charge/Zap/Viewmodels/ZapViewModel.swift @@ -97,33 +97,26 @@ class ZapViewModel: ObservableObject { } } - // MARK: - Pusher listen loop + // MARK: - Pusher (single-shot) private func startListeningForPusher(on details: ZapDetails) { pusherTask?.cancel() let channel = details.pusherChannel pusherTask = Task { [weak self] in - await self?.listenLoop(on: channel) - } - } - - private func listenLoop(on channel: String) async { - while !Task.isCancelled { + guard let self else { return } do { - let update = try await repository + let update = try await self.repository .listenForZapResponse(onChannel: channel) - await processTransactionUpdate(update) - if update.status.isTerminal { return } + await self.processTransactionUpdate(update) } catch { - Logger.error("Zap Pusher iteration failed: %@", + Logger.error("Zap Pusher await failed: %@", arguments: error.localizedDescription) - return } } } @MainActor - func processTransactionUpdate(_ update: BankTransferTransactionUpdate) async { + func processTransactionUpdate(_ update: ChargeCardTransaction) async { switch update.status { case .success: @@ -136,7 +129,7 @@ class ZapViewModel: ObservableObject { state = .error(ChargeError(message: message)) default: - Logger.info("Zap: unexpected Pusher status %@", + Logger.info("Zap: non-terminal transaction status %@", arguments: String(describing: update.status)) } } diff --git a/Sources/PaystackUI/Images/Images.xcassets/capitecPayLogo.imageset/CapitecPayMark.png b/Sources/PaystackUI/Images/Images.xcassets/capitecPayLogo.imageset/CapitecPayMark.png new file mode 100644 index 0000000..8c779bd Binary files /dev/null and b/Sources/PaystackUI/Images/Images.xcassets/capitecPayLogo.imageset/CapitecPayMark.png differ diff --git a/Sources/PaystackUI/Images/Images.xcassets/capitecPayLogo.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/capitecPayLogo.imageset/Contents.json new file mode 100644 index 0000000..3ad1127 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/capitecPayLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "CapitecPayMark.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/scanToPayLogo.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/scanToPayLogo.imageset/Contents.json new file mode 100644 index 0000000..70d8fea --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/scanToPayLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "scan-to-pay_logo-80-UG7U5qv_.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/scanToPayLogo.imageset/scan-to-pay_logo-80-UG7U5qv_.png b/Sources/PaystackUI/Images/Images.xcassets/scanToPayLogo.imageset/scan-to-pay_logo-80-UG7U5qv_.png new file mode 100644 index 0000000..d3b5953 Binary files /dev/null and b/Sources/PaystackUI/Images/Images.xcassets/scanToPayLogo.imageset/scan-to-pay_logo-80-UG7U5qv_.png differ diff --git a/Sources/PaystackUI/Images/Images.xcassets/snapScanLogo.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/snapScanLogo.imageset/Contents.json new file mode 100644 index 0000000..24e86a2 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/snapScanLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "snapscan_logo-BYsTYDYq.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/snapScanLogo.imageset/snapscan_logo-BYsTYDYq.svg b/Sources/PaystackUI/Images/Images.xcassets/snapScanLogo.imageset/snapscan_logo-BYsTYDYq.svg new file mode 100644 index 0000000..1124c4a --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/snapScanLogo.imageset/snapscan_logo-BYsTYDYq.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Sources/PaystackUI/Images/Images.xcassets/southAfricaFlagLogo.imageset/1f1ff-1f1e6.svg b/Sources/PaystackUI/Images/Images.xcassets/southAfricaFlagLogo.imageset/1f1ff-1f1e6.svg new file mode 100644 index 0000000..275c136 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/southAfricaFlagLogo.imageset/1f1ff-1f1e6.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Sources/PaystackUI/Images/Images.xcassets/southAfricaFlagLogo.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/southAfricaFlagLogo.imageset/Contents.json new file mode 100644 index 0000000..6a25e50 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/southAfricaFlagLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "1f1ff-1f1e6.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayAuthenticateResponse.json b/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayAuthenticateResponse.json new file mode 100644 index 0000000..ad64c25 --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayAuthenticateResponse.json @@ -0,0 +1,11 @@ +{ + "status": true, + "type": "success", + "code": "ok", + "data": { + "status": "success", + "timeToLive": 120, + "expiryDate": "2026-07-07T12:29:20.000Z" + }, + "message": "Charge pending" +} diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherFailed.json b/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherFailed.json new file mode 100644 index 0000000..390b7bf --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherFailed.json @@ -0,0 +1,6 @@ +{ + "status": "failed", + "trans": "5900549926", + "trxref": "T_ref_5900549926", + "message": "Bank declined" +} diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherSuccess.json b/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherSuccess.json new file mode 100644 index 0000000..cc8acc0 --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherSuccess.json @@ -0,0 +1,7 @@ +{ + "status": "success", + "trans": "5900549926", + "trxref": "T_ref_5900549926", + "reference": "T_ref_5900549926", + "message": "Payment approved" +} diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/QRGenerateResponse.json b/Tests/PaystackSDKTests/API/Charge/Resources/QRGenerateResponse.json new file mode 100644 index 0000000..af0f07a --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/QRGenerateResponse.json @@ -0,0 +1,11 @@ +{ + "status": true, + "message": "QR successfully generated", + "data": { + "errors": false, + "url": "https://s3.eu-west-1.amazonaws.com/files.paystack.co/qr/mpass_olti/518262/1783510266603.png?X-Amz-Signature=abc", + "qr_code": "1490884538", + "status": "success", + "channel": "api_mpass_olti_qr_51826223921246" + } +} diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/QRPusherFailed.json b/Tests/PaystackSDKTests/API/Charge/Resources/QRPusherFailed.json new file mode 100644 index 0000000..842c2e5 --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/QRPusherFailed.json @@ -0,0 +1,6 @@ +{ + "status": "failed", + "trans": "5900549926", + "trxref": "T_qr_5900549926", + "message": "Wallet declined the payment" +} diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/QRPusherSuccess.json b/Tests/PaystackSDKTests/API/Charge/Resources/QRPusherSuccess.json new file mode 100644 index 0000000..6eda0ea --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/QRPusherSuccess.json @@ -0,0 +1,7 @@ +{ + "status": "success", + "trans": "5900549926", + "trxref": "T_qr_5900549926", + "reference": "T_qr_5900549926", + "message": "Payment received" +} diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/ZapPusherFailed.json b/Tests/PaystackSDKTests/API/Charge/Resources/ZapPusherFailed.json new file mode 100644 index 0000000..1a89c55 --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/ZapPusherFailed.json @@ -0,0 +1,6 @@ +{ + "status": "failed", + "trans": "6222375579", + "trxref": "T_zap_6222375579", + "message": "Bank declined the mandate" +} diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/ZapPusherSuccess.json b/Tests/PaystackSDKTests/API/Charge/Resources/ZapPusherSuccess.json new file mode 100644 index 0000000..b5b107b --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/ZapPusherSuccess.json @@ -0,0 +1,7 @@ +{ + "status": "success", + "trans": "6222375579", + "trxref": "T_zap_6222375579", + "reference": "T_zap_6222375579", + "message": "Payment successful" +} diff --git a/Tests/PaystackSDKTests/API/Charge/ZapTests.swift b/Tests/PaystackSDKTests/API/Charge/ZapTests.swift index 2d2c811..0b568b4 100644 --- a/Tests/PaystackSDKTests/API/Charge/ZapTests.swift +++ b/Tests/PaystackSDKTests/API/Charge/ZapTests.swift @@ -56,11 +56,24 @@ final class ZapTests: PSTestCase { let channelName = "DBMAN_6222375579" mockSubscriptionListener .expectSubscription(PusherSubscription(channelName: channelName, eventName: "response")) - .andReturnString(fromJson: "PayWithTransferPusherSuccess") + .andReturnString(fromJson: "ZapPusherSuccess") let result = try await serviceUnderTest .listenForZapResponse(onChannel: channelName).async() - XCTAssertEqual(result.status, "success") + XCTAssertEqual(result.status, .success) + } + + func testListenForZapResponseDecodesFailedShape() async throws { + let channelName = "DBMAN_6222375579" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channelName, eventName: "response")) + .andReturnString(fromJson: "ZapPusherFailed") + + let result = try await serviceUnderTest + .listenForZapResponse(onChannel: channelName).async() + + XCTAssertEqual(result.status, .failed) + XCTAssertEqual(result.message, "Bank declined the mandate") } } diff --git a/Tests/PaystackSDKTests/Core/CryptographyEncryptPKCS1Tests.swift b/Tests/PaystackSDKTests/Core/CryptographyEncryptPKCS1Tests.swift new file mode 100644 index 0000000..20b6308 --- /dev/null +++ b/Tests/PaystackSDKTests/Core/CryptographyEncryptPKCS1Tests.swift @@ -0,0 +1,76 @@ +import XCTest +@testable import PaystackCore + +final class CryptographyEncryptPKCS1Tests: XCTestCase { + + private var privateKey: SecKey! + private var publicKeyBase64: String! + + override func setUpWithError() throws { + try super.setUpWithError() + let attributes: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeRSA, + kSecAttrKeySizeInBits as String: 2048 + ] + var error: Unmanaged? + guard let generated = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else { + throw XCTSkip("Could not generate RSA key pair: \(error.debugDescription)") + } + privateKey = generated + + guard let publicKey = SecKeyCopyPublicKey(privateKey) else { + throw XCTSkip("Could not copy public key from generated pair") + } + var exportError: Unmanaged? + guard let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, &exportError) as Data? else { + throw XCTSkip("Could not export public key: \(exportError.debugDescription)") + } + publicKeyBase64 = publicKeyData.base64EncodedString() + } + + func testEncryptPKCS1RoundTripsCellphoneIdentifierPlaintext() throws { + try assertRoundTrip("CELLPHONE*0609603632") + } + + func testEncryptPKCS1RoundTripsIDNumberIdentifierPlaintext() throws { + try assertRoundTrip("IDNUMBER*8001015009087") + } + + func testEncryptPKCS1RoundTripsAccountNumberIdentifierPlaintext() throws { + try assertRoundTrip("ACCOUNTNUMBER*123456789") + } + + func testEncryptPKCS1ProducesNonDeterministicCiphertext() throws { + let sut = Cryptography() + let first = try sut.encryptPKCS1(text: "CELLPHONE*0609603632", + publicKey: publicKeyBase64) + let second = try sut.encryptPKCS1(text: "CELLPHONE*0609603632", + publicKey: publicKeyBase64) + XCTAssertNotEqual(first, second, + "PKCS#1 v1.5 padding is random ; two encryptions of the same plaintext must not be byte-equal") + } + + private func assertRoundTrip(_ plaintext: String, + file: StaticString = #filePath, + line: UInt = #line) throws { + let sut = Cryptography() + let base64Ciphertext = try sut.encryptPKCS1(text: plaintext, + publicKey: publicKeyBase64) + let ciphertext = try XCTUnwrap(Data(base64Encoded: base64Ciphertext), + file: file, line: line) + + var decryptError: Unmanaged? + guard let decryptedCF = SecKeyCreateDecryptedData(privateKey, + .rsaEncryptionPKCS1, + ciphertext as CFData, + &decryptError) else { + XCTFail("Decryption failed: \(decryptError.debugDescription)", + file: file, line: line) + return + } + let decrypted = decryptedCF as Data + let recovered = try XCTUnwrap(String(data: decrypted, encoding: .utf8), + file: file, line: line) + XCTAssertEqual(recovered, plaintext, file: file, line: line) + } +} diff --git a/Tests/PaystackSDKTests/Core/CryptographyTests.swift b/Tests/PaystackSDKTests/Core/CryptographyTests.swift index bbbbcc4..bd3c052 100644 --- a/Tests/PaystackSDKTests/Core/CryptographyTests.swift +++ b/Tests/PaystackSDKTests/Core/CryptographyTests.swift @@ -30,6 +30,41 @@ final class CryptographyTests: XCTestCase { XCTAssertEqual(decryptedString, clearText) } + func testPKCS1EncryptionRoundTripsToOriginalText() throws { + let clearText = "Hello World" + + let encryptedData = try serviceUnderTest.encryptPKCS1(text: clearText, publicKey: publicKey) + let decryptedString = try serviceUnderTest.decryptPKCS1(base64String: encryptedData, + privateKey: privateKey) + + XCTAssertEqual(decryptedString, clearText) + } + + func testPKCS1EncryptionWithSpecialCharactersRoundTrips() throws { + let mockCardConcatenation = "1234567890123456*123*01*23" + + let encryptedData = try serviceUnderTest.encryptPKCS1(text: mockCardConcatenation, + publicKey: publicKey) + let decryptedString = try serviceUnderTest.decryptPKCS1(base64String: encryptedData, + privateKey: privateKey) + + XCTAssertEqual(decryptedString, mockCardConcatenation) + } + + func testPKCS1EncryptionOfTextOverLengthLimitThrowsError() { + let clearText = [String](repeating: "a", count: 200).joined(separator: "") + + XCTAssertThrowsError(try serviceUnderTest.encryptPKCS1(text: clearText, publicKey: publicKey)) { error in + XCTAssertEqual(error as? CryptographyError, CryptographyError.encryptionFailed) + } + } + + func testPKCS1EncryptionWithNonBase64PublicKeyThrowsError() { + XCTAssertThrowsError(try serviceUnderTest.encryptPKCS1(text: "Hello World", publicKey: "ABC")) { error in + XCTAssertEqual(error as? CryptographyError, CryptographyError.invalidBase64String) + } + } + func testEncryptionWithSpecialCharacters() { let mockCardConcatenation = "1234567890123456*123*01*23" guard let encryptedData = try? serviceUnderTest.encrypt(text: mockCardConcatenation, @@ -186,6 +221,22 @@ extension Cryptography { return decryptedString } + func decryptPKCS1(base64String: String, privateKey: String) throws -> String { + guard let data = Data(base64Encoded: base64String) else { + throw CryptographyError.invalidBase64String + } + let key = try createKey(from: privateKey, isPublic: false) + + var error: Unmanaged? + guard let decrypted = SecKeyCreateDecryptedData(key, .rsaEncryptionPKCS1, + data as CFData, &error), + let decryptedString = String(data: decrypted as Data, encoding: .utf8) else { + throw CryptographyError.decryptionFailed + } + + return decryptedString + } + func decrypt(base64String: String, privateKey: String) throws -> T { let decryptedString = try decrypt(base64String: base64String, privateKey: privateKey) guard let encodedData = decryptedString.data(using: .utf8), diff --git a/Tests/PaystackSDKTests/UI/Charge/Mocks/MockZapRepository.swift b/Tests/PaystackSDKTests/UI/Charge/Mocks/MockZapRepository.swift index 02e42c9..4328776 100644 --- a/Tests/PaystackSDKTests/UI/Charge/Mocks/MockZapRepository.swift +++ b/Tests/PaystackSDKTests/UI/Charge/Mocks/MockZapRepository.swift @@ -7,7 +7,7 @@ class MockZapRepository: ZapRepository { var expectedMandateResponse: ZapMandateResponse? var expectedErrorResponse: Error? - var expectedListenForZapResponses: [BankTransferTransactionUpdate] = [] + var expectedListenForZapResponses: [ChargeCardTransaction] = [] var expectedListenForZapError: Error? var initiateZapMandateSubmitted: (supportedBankId: Int, @@ -30,7 +30,7 @@ class MockZapRepository: ZapRepository { } func listenForZapResponse(onChannel channelName: String) - async throws -> BankTransferTransactionUpdate { + async throws -> ChargeCardTransaction { listenForZapResponseCallCount += 1 lastListenedChannel = channelName diff --git a/Tests/PaystackSDKTests/UI/Charge/Zap/ZapViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/Zap/ZapViewModelTests.swift index a3ee29c..ceb9a93 100644 --- a/Tests/PaystackSDKTests/UI/Charge/Zap/ZapViewModelTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/Zap/ZapViewModelTests.swift @@ -180,12 +180,11 @@ final class ZapViewModelTests: XCTestCase { XCTAssertEqual(serviceUnderTest.state, .error(error)) } - // MARK: - processTransactionUpdate — success + failed are the only - // statuses Zap emits ; everything else is logged + state unchanged. + // MARK: - processTransactionUpdate — success + failed only. func testProcessUpdateWithSuccessCallsContainerProcessSuccessfulTransaction() async { await serviceUnderTest.processTransactionUpdate( - .init(status: .success, message: nil, reference: nil, transactionId: nil)) + ChargeCardTransaction(status: .success)) XCTAssertTrue(mockChargeContainer.transactionSuccessful) } @@ -194,9 +193,7 @@ final class ZapViewModelTests: XCTestCase { await serviceUnderTest.initiateMandate() await serviceUnderTest.processTransactionUpdate( - .init(status: .failed, - message: "Bank declined", - reference: nil, transactionId: nil)) + ChargeCardTransaction(status: .failed, message: "Bank declined")) XCTAssertEqual(serviceUnderTest.state, .error(ChargeError(message: "Bank declined"))) @@ -204,45 +201,27 @@ final class ZapViewModelTests: XCTestCase { func testProcessUpdateWithFailedFallsBackToDefaultMessageWhenNil() async { await serviceUnderTest.processTransactionUpdate( - .init(status: .failed, message: nil, - reference: nil, transactionId: nil)) + ChargeCardTransaction(status: .failed)) XCTAssertEqual(serviceUnderTest.state, .error(ChargeError(message: ZapViewModel.failedFallbackMessage))) } - /// Zap doesn't emit any of the PWT-shared status cases beyond - /// `success` / `failed` ; if one ever arrives (forward compat with - /// shared types) the SDK logs + leaves state unchanged. Regression - /// guard against accidentally wiring a state change for these cases - /// in the future. - func testProcessUpdateWithUnexpectedStatusesDoesNotChangeState() async { - let unexpectedStatuses: [BankTransferStatus] = [ - .creditRequestPending, - .creditRequestReceived, - .creditRequestRejected, - .incorrectAmountSent, - .pending, - .requery, - .unknown("brand-new") - ] + func testProcessUpdateWithNonTerminalStatusDoesNotChangeState() async { mockRepository.expectedMandateResponse = .example await serviceUnderTest.initiateMandate() let stateBefore = serviceUnderTest.state - for status in unexpectedStatuses { - await serviceUnderTest.processTransactionUpdate( - .init(status: status, message: nil, - reference: nil, transactionId: nil)) - XCTAssertEqual(serviceUnderTest.state, stateBefore, - "Status \(status) unexpectedly changed state") - } + await serviceUnderTest.processTransactionUpdate( + ChargeCardTransaction(status: .pending)) + + XCTAssertEqual(serviceUnderTest.state, stateBefore) } - // MARK: - Listen loop + // MARK: - Listen loop (single-shot) - func testProvisioningStartsListenLoopOnReturnedChannel() async { + func testProvisioningStartsListenOnReturnedChannel() async { mockRepository.expectedMandateResponse = .example await serviceUnderTest.initiateMandate() @@ -252,11 +231,10 @@ final class ZapViewModelTests: XCTestCase { XCTAssertEqual(mockRepository.lastListenedChannel, "DBMAN_6222375579") } - func testListenLoopExitsOnSuccessAndRoutesToContainer() async { + func testListenResolvesOnSuccessAndRoutesToContainer() async { mockRepository.expectedMandateResponse = .example mockRepository.expectedListenForZapResponses = [ - .init(status: .success, message: nil, - reference: nil, transactionId: nil) + ChargeCardTransaction(status: .success) ] let expectation = expectation(description: "container receives success") @@ -269,11 +247,10 @@ final class ZapViewModelTests: XCTestCase { XCTAssertTrue(mockChargeContainer.transactionSuccessful) } - func testListenLoopExitsOnFailedStatusToErrorState() async { + func testListenResolvesOnFailedStatusToErrorState() async { mockRepository.expectedMandateResponse = .example mockRepository.expectedListenForZapResponses = [ - .init(status: .failed, message: "Bank declined", - reference: nil, transactionId: nil) + ChargeCardTransaction(status: .failed, message: "Bank declined") ] await serviceUnderTest.initiateMandate() @@ -284,7 +261,7 @@ final class ZapViewModelTests: XCTestCase { .error(ChargeError(message: "Bank declined"))) } - func testListenLoopExitsOnRepositoryErrorWithoutCrashing() async { + func testListenExitsOnRepositoryErrorWithoutCrashing() async { mockRepository.expectedMandateResponse = .example mockRepository.expectedListenForZapError = PaystackError.technical diff --git a/Tests/PaystackSDKTests/UI/Charge/ZapRepository/ZapRepositoryImplementationTests.swift b/Tests/PaystackSDKTests/UI/Charge/ZapRepository/ZapRepositoryImplementationTests.swift index c370dbe..35a9d74 100644 --- a/Tests/PaystackSDKTests/UI/Charge/ZapRepository/ZapRepositoryImplementationTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/ZapRepository/ZapRepositoryImplementationTests.swift @@ -102,47 +102,33 @@ final class ZapRepositoryImplementationTests: PSTestCase { // MARK: - listenForZapResponse - /// Subscribes to the Pusher channel with the same `eventName: "response"` - /// contract as Pay-with-Transfer, and the success payload maps cleanly - /// to a `BankTransferTransactionUpdate` (shared wire format). func testListenForZapResponseSubscribesToProvidedChannelAndMapsSuccess() async throws { let channel = "DBMAN_6222375579" mockSubscriptionListener .expectSubscription(PusherSubscription(channelName: channel, eventName: "response")) - .andReturnString(fromJson: "PayWithTransferPusherSuccess") + .andReturnString(fromJson: "ZapPusherSuccess") let result = try await serviceUnderTest.listenForZapResponse(onChannel: channel) XCTAssertEqual(result.status, .success) - XCTAssertEqual(result.message, "Payment Successful") - XCTAssertEqual(result.transactionId, "3818017015") - XCTAssertEqual(result.reference, "T3818017015I615243Sujjxh") } - /// `failed` is the second of the two statuses Zap is documented to - /// emit (the other being `success`). Reuses the existing PWT - /// "failed-shape" fixture since Zap shares the wire format. - func testListenForZapResponseMapsFailedStatusFromSharedWireFormat() async throws { + func testListenForZapResponseMapsFailedStatus() async throws { let channel = "DBMAN_6222375579" mockSubscriptionListener .expectSubscription(PusherSubscription(channelName: channel, eventName: "response")) - .andReturnString(fromJson: "PayWithTransferPusherIncorrectAmount") + .andReturnString(fromJson: "ZapPusherFailed") let result = try await serviceUnderTest.listenForZapResponse(onChannel: channel) XCTAssertEqual(result.status, .failed) - XCTAssertEqual(result.message, "incorrect amount sent") } - /// The channel name is passed through verbatim — the SDK doesn't - /// rewrite the `DBMAN_*` prefix or anything else. Mirror the PWT - /// repository's equivalent test so any future regression where the - /// channel name is mutated would fail loudly. func testListenForZapResponsePassesChannelNameThroughVerbatim() async throws { let channel = "DBMAN_arbitrary_123" mockSubscriptionListener .expectSubscription(PusherSubscription(channelName: channel, eventName: "response")) - .andReturnString(fromJson: "PayWithTransferPusherSuccess") + .andReturnString(fromJson: "ZapPusherSuccess") _ = try await serviceUnderTest.listenForZapResponse(onChannel: channel) }