From ee5566333722284aee315c4bf0634e162098b4e4 Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Tue, 19 May 2026 15:00:01 +0200 Subject: [PATCH] Refactor mobile money channel resolution and inject provider per transaction Replaces the implicit MPesa-only assumption with an explicit SupportedChannel model and a configurable provider allowlist on ChargeViewModel. The selected MobileMoneyChannel is now threaded through ChargePaymentType, MPesaChargeView, and MPesaChrageViewModel so the charge request uses the chosen provider's key instead of always picking the first channelOption. - Adds Sources/PaystackUI/Charge/Models/SupportedChannel.swift with id, title, and image per channel (card + per-provider mobile money). - Renames verifyAccessCodeAndProceedWithCard -> verifyAccessCodeAndProceed and splits it into resolveSupportedChannels / nextState helpers; auto-routes single-channel cases and falls back to channel selection otherwise. - Drops the SupportedChannels enum / PaymentChannel wrapper in ChannelSelectionView in favour of iterating SupportedChannel directly. - Adds resolver coverage in ChargeViewModelTests (allowlist filtering, auto-route, channel selection, transactionDetails regression guard) and updates MPesaChrageViewModelTests to verify the injected provider key is forwarded to the repository. --- Sources/PaystackUI/Charge/ChargeView.swift | 8 +- .../PaystackUI/Charge/ChargeViewModel.swift | 101 +++++++----- .../Viewmodels/MPesaChrageViewModel.swift | 5 +- .../Views/ChannelSelectionView.swift | 44 ++--- .../MobileMoney/Views/MPesaChargeView.swift | 7 +- .../Charge/Models/ChargePaymentType.swift | 3 +- .../Charge/Models/ChargeState.swift | 2 +- .../Charge/Models/SupportedChannel.swift | 50 ++++++ .../UI/Charge/ChargeViewModelTests.swift | 153 +++++++++++++++++- .../MPesaChrageViewModelTests.swift | 23 ++- 10 files changed, 301 insertions(+), 95 deletions(-) create mode 100644 Sources/PaystackUI/Charge/Models/SupportedChannel.swift diff --git a/Sources/PaystackUI/Charge/ChargeView.swift b/Sources/PaystackUI/Charge/ChargeView.swift index 85509e5..abb5f4d 100644 --- a/Sources/PaystackUI/Charge/ChargeView.swift +++ b/Sources/PaystackUI/Charge/ChargeView.swift @@ -48,7 +48,7 @@ struct ChargeView: View { .aspectRatio(contentMode: .fit) .frame(width: 140) } - .task(viewModel.verifyAccessCodeAndProceedWithCard) + .task(viewModel.verifyAccessCodeAndProceed) .modalCancelButton(showConfirmation: viewModel.displayCloseButtonConfirmation, onCancelled: chargeCancelled) } @@ -59,8 +59,10 @@ struct ChargeView: View { case .card(let transactionInformation): ChargeCardView(transactionDetails: transactionInformation, chargeContainer: viewModel) - case .mobileMoney(transactionInformation: let transactionInformation): - MPesaChargeView(chargeCardContainer: viewModel, transactionDetails: transactionInformation) + case .mobileMoney(let transactionInformation, let provider): + MPesaChargeView(chargeCardContainer: viewModel, + transactionDetails: transactionInformation, + provider: provider) } } diff --git a/Sources/PaystackUI/Charge/ChargeViewModel.swift b/Sources/PaystackUI/Charge/ChargeViewModel.swift index 98e2277..9097020 100644 --- a/Sources/PaystackUI/Charge/ChargeViewModel.swift +++ b/Sources/PaystackUI/Charge/ChargeViewModel.swift @@ -19,37 +19,22 @@ class ChargeViewModel: ObservableObject { } @MainActor - func verifyAccessCodeAndProceedWithCard() async { - var supportedChannels: [SupportedChannels] = [] + func verifyAccessCodeAndProceed() async { do { transactionState = .loading() - let accessCodeResponse = try await repository.verifyAccessCode(accessCode) - guard accessCodeResponse.paymentChannels.contains(where: { $0 == .card || $0 == .mobileMoney }) else { - let message = "Card/MPesa payments are not supported. " + + let response = try await repository.verifyAccessCode(accessCode) + let supported = resolveSupportedChannels(from: response) + + guard !supported.isEmpty else { + let message = "No supported payment methods. " + "Please reach out to your merchant for further information" - let cause = "There are currently no payment channels on " + - "your integration that are supported by the SDK" + let cause = "No payment channels on this integration " + + "are supported by the SDK" throw ChargeError(displayMessage: message, causeMessage: cause) } - let mobileMoneyChannel = accessCodeResponse.channelOptions?.mobileMoney?.contains(where: { $0.key == SupportedChannels.MPESA.rawValue }) ?? false - - accessCodeResponse.paymentChannels.forEach { - if $0 == .card { - supportedChannels.append(.CARD) - } - if $0 == .mobileMoney && accessCodeResponse.channelOptions?.mobileMoney?.contains(where: { $0.key == SupportedChannels.MPESA.rawValue }) ?? false { - supportedChannels.append(.MPESA) - } - } - - if mobileMoneyChannel { - transactionState = .channelSelection( - transactionInformation: accessCodeResponse, supportedChannels: supportedChannels) - } else { - self.transactionDetails = accessCodeResponse - transactionState = .payment(type: .card(transactionInformation: accessCodeResponse)) - } + transactionDetails = response + transactionState = nextState(for: supported, response: response) } catch { let error = ChargeError(error: error) Logger.error("Verify access code failed with error: %@", @@ -59,24 +44,62 @@ class ChargeViewModel: ObservableObject { } } -} + private func resolveSupportedChannels(from response: VerifyAccessCode) -> [SupportedChannel] { + var result: [SupportedChannel] = [] + + if response.paymentChannels.contains(.card) { + result.append(.card) + } + + if response.paymentChannels.contains(.mobileMoney), + let providers = response.channelOptions?.mobileMoney, !providers.isEmpty { + let allowed = filtered(providers) + result.append(contentsOf: allowed.map { .mobileMoney($0) }) + } + + return result + } -enum SupportedChannels: String, CaseIterable { - case CARD = "CARD" - case MPESA = "MPESA" - case unsupportedChannel - - var image: Image { - switch self { - case .CARD: - return Image("cardLogo", bundle: .current) - case .MPESA: - return Image("kenyaSHLogo", bundle: .current) - case .unsupportedChannel: - return Image(systemName: "exclamationmark.triangle.fill") + private func filtered(_ providers: [MobileMoneyChannel]) -> [MobileMoneyChannel] { + guard let allowlist = Self.supportedMobileMoneyProviders else { + return providers } + return providers.filter { allowlist.contains($0.key.uppercased()) } } + private func nextState(for channels: [SupportedChannel], + response: VerifyAccessCode) -> ChargeState { + if channels.count == 1, case .card = channels[0] { + return .payment(type: .card(transactionInformation: response)) + } + + if !channels.contains(.card), + channels.count == 1, + case .mobileMoney(let provider) = channels[0] { + return .payment(type: .mobileMoney(transactionInformation: response, + provider: provider)) + } + + return .channelSelection(transactionInformation: response, + supportedChannels: channels) + } + +} + +// MARK: - Mobile money provider allowlist +extension ChargeViewModel { + + /// Mobile money provider keys (`MobileMoneyChannel.key`, uppercased) that + /// the SDK is allowed to route to. The API may return providers we don't + /// yet have logos, copy, or phone formatters for — listing them here is + /// what opts them into the UI. + /// + /// Set to `nil` to accept every provider the API returns (no filtering). + /// Add a new key here when you've added its logo to `SupportedChannel.image` + /// and its country code / phone formatter to the relevant helpers. + static var supportedMobileMoneyProviders: Set? = [ + "MPESA" + ] } // MARK: - Charge Container diff --git a/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaChrageViewModel.swift b/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaChrageViewModel.swift index 3299a15..aaa535b 100644 --- a/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaChrageViewModel.swift +++ b/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaChrageViewModel.swift @@ -13,6 +13,7 @@ class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer { var chargeCardContainer: ChargeContainer var repository: ChargeMobileMoneyRepository var transactionDetails: VerifyAccessCode + let provider: MobileMoneyChannel @Published var phoneNumber: String = "" @@ -21,10 +22,12 @@ class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer { init(chargeCardContainer: ChargeContainer, transactionDetails: VerifyAccessCode, + provider: MobileMoneyChannel, repository: ChargeMobileMoneyRepository = ChargeMobileMoneyRepositoryImplementation()) { self.chargeCardContainer = chargeCardContainer self.repository = repository self.transactionDetails = transactionDetails + self.provider = provider } var isValid: Bool { @@ -37,7 +40,7 @@ class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer { let authenticationResult = try await repository.chargeMobileMoney( phone: phoneNumber.formattedKenyanPhoneNumber, transactionId: "\(transactionDetails.transactionId ?? 0)", - provider: transactionDetails.channelOptions?.mobileMoney?.first?.key ?? "") + provider: provider.key) transactionState = .processTransaction(transaction: authenticationResult) } catch { displayTransactionError(ChargeError(error: error)) diff --git a/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift b/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift index c5731bb..76f48d2 100644 --- a/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift +++ b/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift @@ -14,15 +14,14 @@ struct ChannelSelectionView: View { var visibilityContainer: ViewVisibilityContainer @StateObject var viewModel: ChannelSelectionViewModel + let supportedChannels: [SupportedChannel] let columns = [GridItem(.flexible()), GridItem(.flexible())] - var items: [PaymentChannel] = [] + init(state: Binding, - supportedChannels: [SupportedChannels], + supportedChannels: [SupportedChannel], information: VerifyAccessCode) { self._viewModel = StateObject(wrappedValue: ChannelSelectionViewModel(state: state, information: information)) - items = supportedChannels.map { - PaymentChannel(channel: $0) - } + self.supportedChannels = supportedChannels } var body: some View { @@ -35,13 +34,13 @@ struct ChannelSelectionView: View { .multilineTextAlignment(.center) GeometryReader { geo in LazyVGrid(columns: columns) { - ForEach(items) { value in - ChannelView(channelTitle: value.title, image: value.image) + ForEach(supportedChannels) { channel in + ChannelView(channelTitle: channel.displayTitle, image: channel.image) .padding(.singlePadding) .onTapGesture { - viewModel.chooseChannel(channel: value.channel) + viewModel.choose(channel) } - .frame(width: (geo.size.width / CGFloat(items.count)).rounded()) + .frame(width: (geo.size.width / CGFloat(supportedChannels.count)).rounded()) } } } @@ -59,31 +58,14 @@ class ChannelSelectionViewModel: ObservableObject { self.information = information } - func chooseChannel(channel: SupportedChannels) { - let message = "Card/MPesa payments are not supported. " + - "Please reach out to your merchant for further information" - let cause = "There are currently no payment channels on " + - "your integration that are supported by the SDK" + func choose(_ channel: SupportedChannel) { switch channel { - case .CARD: + case .card: state = .payment(type: .card(transactionInformation: self.information)) - case .MPESA: - state = .payment(type: .mobileMoney(transactionInformation: self.information)) - case .unsupportedChannel: - state = .error(ChargeError(displayMessage: message, causeMessage: cause)) + case .mobileMoney(let provider): + state = .payment(type: .mobileMoney(transactionInformation: self.information, + provider: provider)) } - - } -} - -struct PaymentChannel: Identifiable { - var id: String = UUID().uuidString - let channel: SupportedChannels - var title: String { - channel.rawValue - } - var image: Image { - channel.image } } diff --git a/Sources/PaystackUI/Charge/MobileMoney/Views/MPesaChargeView.swift b/Sources/PaystackUI/Charge/MobileMoney/Views/MPesaChargeView.swift index 04239cc..bb0ba7b 100644 --- a/Sources/PaystackUI/Charge/MobileMoney/Views/MPesaChargeView.swift +++ b/Sources/PaystackUI/Charge/MobileMoney/Views/MPesaChargeView.swift @@ -10,9 +10,12 @@ struct MPesaChargeView: View { init( chargeCardContainer: ChargeContainer, - transactionDetails: VerifyAccessCode) { + transactionDetails: VerifyAccessCode, + provider: MobileMoneyChannel) { self._viewModel = StateObject(wrappedValue: MPesaChrageViewModel( - chargeCardContainer: chargeCardContainer, transactionDetails: transactionDetails)) + chargeCardContainer: chargeCardContainer, + transactionDetails: transactionDetails, + provider: provider)) } var body: some View { diff --git a/Sources/PaystackUI/Charge/Models/ChargePaymentType.swift b/Sources/PaystackUI/Charge/Models/ChargePaymentType.swift index 61b82a8..53de317 100644 --- a/Sources/PaystackUI/Charge/Models/ChargePaymentType.swift +++ b/Sources/PaystackUI/Charge/Models/ChargePaymentType.swift @@ -3,5 +3,6 @@ import Foundation // TODO: Add an extension to map from payment channels once those are defined enum ChargePaymentType: Equatable { case card(transactionInformation: VerifyAccessCode) - case mobileMoney(transactionInformation: VerifyAccessCode) + case mobileMoney(transactionInformation: VerifyAccessCode, + provider: MobileMoneyChannel) } diff --git a/Sources/PaystackUI/Charge/Models/ChargeState.swift b/Sources/PaystackUI/Charge/Models/ChargeState.swift index 62e4130..1c274e5 100644 --- a/Sources/PaystackUI/Charge/Models/ChargeState.swift +++ b/Sources/PaystackUI/Charge/Models/ChargeState.swift @@ -4,7 +4,7 @@ import PaystackCore enum ChargeState { case loading(message: String? = nil) case payment(type: ChargePaymentType) - case channelSelection (transactionInformation: VerifyAccessCode, supportedChannels: [SupportedChannels]) + case channelSelection (transactionInformation: VerifyAccessCode, supportedChannels: [SupportedChannel]) case error(ChargeError) case success(amount: AmountCurrency, merchant: String, details: ChargeCompletionDetails) diff --git a/Sources/PaystackUI/Charge/Models/SupportedChannel.swift b/Sources/PaystackUI/Charge/Models/SupportedChannel.swift new file mode 100644 index 0000000..9cb863d --- /dev/null +++ b/Sources/PaystackUI/Charge/Models/SupportedChannel.swift @@ -0,0 +1,50 @@ +import SwiftUI +import PaystackCore + +/// A resolved payment channel that the SDK is willing to route to for the +/// current transaction. One entry per card option, plus one entry per +/// supported mobile money provider returned by `verifyAccessCode`. +enum SupportedChannel: Equatable, Identifiable { + case card + case mobileMoney(MobileMoneyChannel) + + var id: String { + switch self { + case .card: + return "card" + case .mobileMoney(let channel): + return "mobile_money.\(channel.key)" + } + } + + var displayTitle: String { + switch self { + case .card: + return "Card" + case .mobileMoney(let channel): + return channel.value + } + } + + var image: Image { + switch self { + case .card: + return Image("cardLogo", bundle: .current) + case .mobileMoney(let channel): + return Self.image(forMobileMoneyKey: channel.key) + } + } + + /// Maps known Paystack mobile money provider keys to a bundled logo. + /// Falls back to a generic SF Symbol when the SDK has no logo for the + /// provider yet — keeps the channel-selection screen renderable when a + /// future provider lights up via the allowlist. + private static func image(forMobileMoneyKey key: String) -> Image { + switch key.uppercased() { + case "MPESA": + return Image("kenyaSHLogo", bundle: .current) + default: + return Image(systemName: "creditcard") + } + } +} diff --git a/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift index 779c1b7..ebb6b24 100644 --- a/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift @@ -1,4 +1,5 @@ import XCTest +import PaystackCore @testable import PaystackUI final class ChargeViewModelTests: PSTestCase { @@ -6,12 +7,23 @@ final class ChargeViewModelTests: PSTestCase { var serviceUnderTest: ChargeViewModel! var mockRepo: MockChargeRepository! + /// Snapshot the production allowlist on entry so individual tests can + /// mutate `supportedMobileMoneyProviders` freely and we restore the + /// original value in `tearDown`. Avoids order-dependent test leakage. + private static let productionAllowlist = ChargeViewModel.supportedMobileMoneyProviders + override func setUpWithError() throws { try super.setUpWithError() + ChargeViewModel.supportedMobileMoneyProviders = Self.productionAllowlist mockRepo = MockChargeRepository() serviceUnderTest = ChargeViewModel(accessCode: "access_code_test", repository: mockRepo) } + override func tearDownWithError() throws { + ChargeViewModel.supportedMobileMoneyProviders = Self.productionAllowlist + try super.tearDownWithError() + } + func testVerifyAccessCodeSetsViewStateAsCardDetailsWhenSuccessful() async { let cardOnlyAccessCode = VerifyAccessCode(amount: 10000, currency: "USD", @@ -23,19 +35,19 @@ final class ChargeViewModelTests: PSTestCase { reference: "test_reference", channelOptions: nil) mockRepo.expectedVerifyAccessCode = cardOnlyAccessCode - await serviceUnderTest.verifyAccessCodeAndProceedWithCard() + await serviceUnderTest.verifyAccessCodeAndProceed() XCTAssertEqual(serviceUnderTest.transactionState, .payment(type: .card(transactionInformation: cardOnlyAccessCode))) } func testVerifyAccessCodeSetsViewStateAsErrorWhenUnsuccessful() async { mockRepo.expectedErrorResponse = ChargeError.generic - await serviceUnderTest.verifyAccessCodeAndProceedWithCard() + await serviceUnderTest.verifyAccessCodeAndProceed() XCTAssertEqual(serviceUnderTest.transactionState, .error(.generic)) } func testVerifyAccessCodeSetsViewStateAsErrorWhenCardIsNotASupportedPaymentChannel() async { - let expectedMessage = "Card payments are not supported. " + + let expectedMessage = "No supported payment methods. " + "Please reach out to your merchant for further information" mockRepo.expectedVerifyAccessCode = .init(amount: 10000, currency: "USD", @@ -45,11 +57,108 @@ final class ChargeViewModelTests: PSTestCase { merchantName: "Test Merchant", publicEncryptionKey: "test_encryption_key", reference: "test_reference", channelOptions: .example) - await serviceUnderTest.verifyAccessCodeAndProceedWithCard() + await serviceUnderTest.verifyAccessCodeAndProceed() XCTAssertEqual(serviceUnderTest.transactionState, .error(.init(message: expectedMessage))) } + // MARK: - Resolver and auto-route + + func testAutoRoutesToMobileMoneyWhenCardUnsupportedAndExactlyOneAllowlistedProvider() async { + ChargeViewModel.supportedMobileMoneyProviders = ["MPESA"] + let response = VerifyAccessCode.with(channels: [.mobileMoney], + mobileMoney: [.mpesaFixture]) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + XCTAssertEqual(serviceUnderTest.transactionState, + .payment(type: .mobileMoney(transactionInformation: response, + provider: .mpesaFixture))) + } + + func testShowsChannelSelectionWhenMultipleMobileMoneyProvidersAndNoCard() async { + ChargeViewModel.supportedMobileMoneyProviders = nil // accept everything + let response = VerifyAccessCode.with(channels: [.mobileMoney], + mobileMoney: [.mtnFixture, .vodafoneFixture]) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + XCTAssertEqual(serviceUnderTest.transactionState, + .channelSelection(transactionInformation: response, + supportedChannels: [.mobileMoney(.mtnFixture), + .mobileMoney(.vodafoneFixture)])) + } + + func testShowsChannelSelectionWhenCardAndMobileMoneyBothSupported() async { + ChargeViewModel.supportedMobileMoneyProviders = ["MPESA"] + let response = VerifyAccessCode.with(channels: [.card, .mobileMoney], + mobileMoney: [.mpesaFixture]) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + XCTAssertEqual(serviceUnderTest.transactionState, + .channelSelection(transactionInformation: response, + supportedChannels: [.card, + .mobileMoney(.mpesaFixture)])) + } + + func testAllowlistFiltersOutUnknownProvidersAndResultsInError() async { + ChargeViewModel.supportedMobileMoneyProviders = ["MPESA"] + let response = VerifyAccessCode.with(channels: [.mobileMoney], + mobileMoney: [.mtnFixture, .vodafoneFixture]) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + let expectedMessage = "No supported payment methods. " + + "Please reach out to your merchant for further information" + XCTAssertEqual(serviceUnderTest.transactionState, + .error(.init(message: expectedMessage))) + } + + func testAllowlistGatesAutoRouteWhenMerchantHasMoreProvidersThanSdkSupports() async { + ChargeViewModel.supportedMobileMoneyProviders = ["MPESA"] + let response = VerifyAccessCode.with(channels: [.mobileMoney], + mobileMoney: [.mpesaFixture, .mtnFixture]) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + XCTAssertEqual(serviceUnderTest.transactionState, + .payment(type: .mobileMoney(transactionInformation: response, + provider: .mpesaFixture))) + } + + func testNilAllowlistAcceptsEveryMobileMoneyProvider() async { + ChargeViewModel.supportedMobileMoneyProviders = nil + let response = VerifyAccessCode.with(channels: [.mobileMoney], + mobileMoney: [.mtnFixture, .vodafoneFixture]) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + XCTAssertEqual(serviceUnderTest.transactionState, + .channelSelection(transactionInformation: response, + supportedChannels: [.mobileMoney(.mtnFixture), + .mobileMoney(.vodafoneFixture)])) + } + + func testTransactionDetailsIsSetAfterResolvingMobileMoneyAutoRoute() async { + // Regression guard: pre-PR-2 the MM branch never set `transactionDetails`, + // so downstream UI checks (inTestMode, chargeCancelled) saw nil. + ChargeViewModel.supportedMobileMoneyProviders = ["MPESA"] + let response = VerifyAccessCode.with(channels: [.mobileMoney], + mobileMoney: [.mpesaFixture]) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + XCTAssertEqual(serviceUnderTest.transactionDetails, response) + } + func testViewShouldBeCenteredForSpecifiedStates() { serviceUnderTest.transactionState = .loading() XCTAssertFalse(serviceUnderTest.centerView) @@ -107,6 +216,8 @@ extension ChargeState: Equatable { return firstAmount == secondAmount && firstMerchant == secondMerchant && firstDetails == secondDetails case (.payment(let first), .payment(let second)): return first == second + case (.channelSelection(let firstInfo, let firstChannels), .channelSelection(let secondInfo, let secondChannels)): + return firstInfo == secondInfo && firstChannels == secondChannels case (.error(let first), .error(let second)): return first.localizedDescription == second.localizedDescription default: @@ -121,3 +232,37 @@ extension ChargeCompletionDetails: Equatable { lhs.reference == rhs.reference } } + +// MARK: - Test fixtures + +private extension MobileMoneyChannel { + static let mpesaFixture = MobileMoneyChannel(key: "MPESA", + value: "M-PESA", + isNew: true, + phoneNumberRegex: "") + static let mtnFixture = MobileMoneyChannel(key: "MTN", + value: "MTN", + isNew: false, + phoneNumberRegex: "") + static let vodafoneFixture = MobileMoneyChannel(key: "VOD", + value: "Vodafone", + isNew: false, + phoneNumberRegex: "") +} + +private extension VerifyAccessCode { + /// Compact helper for building `VerifyAccessCode` fixtures for resolver tests. + /// Most fields are irrelevant to channel resolution and get sensible defaults. + static func with(channels: [PaystackCore.Channel], + mobileMoney: [MobileMoneyChannel]? = nil) -> Self { + VerifyAccessCode(amount: 10000, + currency: "USD", + accessCode: "test_access", + paymentChannels: channels, + domain: .test, + merchantName: "Test Merchant", + publicEncryptionKey: "test_encryption_key", + reference: "test_reference", + channelOptions: mobileMoney.map { PaystackUI.ChannelOptions(mobileMoney: $0) }) + } +} diff --git a/Tests/PaystackSDKTests/UI/Charge/MPesaCharge/MPesaChrageViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/MPesaCharge/MPesaChrageViewModelTests.swift index da143d5..4c9895f 100644 --- a/Tests/PaystackSDKTests/UI/Charge/MPesaCharge/MPesaChrageViewModelTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/MPesaCharge/MPesaChrageViewModelTests.swift @@ -14,6 +14,7 @@ final class MPesaChrageViewModelTests: XCTestCase { mockRepository = MockChargeMobileMoneyRepository() serviceUnderTest = MPesaChrageViewModel(chargeCardContainer: mockChargeContainer, transactionDetails: .example, + provider: .example, repository: mockRepository) } @@ -51,6 +52,7 @@ final class MPesaChrageViewModelTests: XCTestCase { channelOptions: .example) serviceUnderTest = MPesaChrageViewModel(chargeCardContainer: mockChargeContainer, transactionDetails: transactionDetails, + provider: .example, repository: mockRepository) mockRepository.expectedMobileMoneyTransaction = .mPesaExample @@ -89,26 +91,21 @@ final class MPesaChrageViewModelTests: XCTestCase { XCTAssertEqual(mockRepository.chargeMobileMoneySubmitted.transactionId, "0") } - func testSubmitPhoneNumberWithMissingChannelOptionsDefaultsToEmptyProvider() async { - let transactionDetails = VerifyAccessCode(amount: 10000, - currency: "USD", - accessCode: "test_access", - paymentChannels: [.mobileMoney], - domain: .live, - merchantName: "Test Merchant", - publicEncryptionKey: "test_encryption_key", - reference: "test_reference", - transactionId: 1, - channelOptions: nil) + func testSubmitPhoneNumberForwardsTheInjectedProviderKeyEvenWhenNotMPesa() async { + let mtnProvider = MobileMoneyChannel(key: "MTN", + value: "MTN", + isNew: false, + phoneNumberRegex: "") serviceUnderTest = MPesaChrageViewModel(chargeCardContainer: mockChargeContainer, - transactionDetails: transactionDetails, + transactionDetails: .example, + provider: mtnProvider, repository: mockRepository) mockRepository.expectedMobileMoneyTransaction = .mPesaExample serviceUnderTest.phoneNumber = "0703362111" await serviceUnderTest.submitPhoneNumber() - XCTAssertEqual(mockRepository.chargeMobileMoneySubmitted.provider, "") + XCTAssertEqual(mockRepository.chargeMobileMoneySubmitted.provider, "MTN") } func testSubmitPhoneNumberOnSuccessSetsStateToProcessTransaction() async {