From 1a799745bc2740c8d4ed4af6250436094d1fe836 Mon Sep 17 00:00:00 2001 From: amy-at-kickstarter <146007185+amy-at-kickstarter@users.noreply.github.com> Date: Mon, 10 Feb 2025 10:22:05 -0500 Subject: [PATCH] Create ApplePayTokenUseCase and tests (#2258) * Create ApplePayTokenUseCase * Tweak input/output protocols for ApplePayTokenUseCase * Make changes to make ApplePayTokenUseCase easier to integrate --- Kickstarter.xcodeproj/project.pbxproj | 8 + Library/ViewModels/ApplePayTokenUseCase.swift | 163 ++++++++++++++++++ .../ApplePayTokenUseCaseTests.swift | 155 +++++++++++++++++ 3 files changed, 326 insertions(+) create mode 100644 Library/ViewModels/ApplePayTokenUseCase.swift create mode 100644 Library/ViewModels/ApplePayTokenUseCaseTests.swift diff --git a/Kickstarter.xcodeproj/project.pbxproj b/Kickstarter.xcodeproj/project.pbxproj index a90db6c9d4..097160ccee 100644 --- a/Kickstarter.xcodeproj/project.pbxproj +++ b/Kickstarter.xcodeproj/project.pbxproj @@ -1595,6 +1595,8 @@ E13D76812D42CBA400FB58CE /* LoginSignupUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13D767E2D42C52900FB58CE /* LoginSignupUseCaseTests.swift */; }; E13D76712D4011BF00FB58CE /* PaymentMethodsUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13D76702D4011BF00FB58CE /* PaymentMethodsUseCase.swift */; }; E13D76742D404F1600FB58CE /* PaymentMethodsUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13D76722D40438100FB58CE /* PaymentMethodsUseCaseTests.swift */; }; + E13D76762D41698500FB58CE /* ApplePayTokenUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13D76752D41698000FB58CE /* ApplePayTokenUseCase.swift */; }; + E13D76792D419A5900FB58CE /* ApplePayTokenUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13D76772D4195E700FB58CE /* ApplePayTokenUseCaseTests.swift */; }; E16794282B7EAA5200064063 /* OAuthTokenExchange.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16794272B7EAA5200064063 /* OAuthTokenExchange.swift */; }; E167942A2B85136900064063 /* OAuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16794292B85136900064063 /* OAuthTests.swift */; }; E16ECA702C245A34002A1D25 /* PagedContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16ECA6F2C245A34002A1D25 /* PagedContainerViewController.swift */; }; @@ -3314,6 +3316,8 @@ E13D767E2D42C52900FB58CE /* LoginSignupUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginSignupUseCaseTests.swift; sourceTree = ""; }; E13D76702D4011BF00FB58CE /* PaymentMethodsUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentMethodsUseCase.swift; sourceTree = ""; }; E13D76722D40438100FB58CE /* PaymentMethodsUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentMethodsUseCaseTests.swift; sourceTree = ""; }; + E13D76752D41698000FB58CE /* ApplePayTokenUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplePayTokenUseCase.swift; sourceTree = ""; }; + E13D76772D4195E700FB58CE /* ApplePayTokenUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplePayTokenUseCaseTests.swift; sourceTree = ""; }; E16794272B7EAA5200064063 /* OAuthTokenExchange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthTokenExchange.swift; sourceTree = ""; }; E16794292B85136900064063 /* OAuthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthTests.swift; sourceTree = ""; }; E16ECA6F2C245A34002A1D25 /* PagedContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedContainerViewController.swift; sourceTree = ""; }; @@ -6608,6 +6612,8 @@ A757EAEF1D1ABE7400A5C978 /* ActivitySurveyResponseCellViewModel.swift */, A7F441921D005A9400FE6FC5 /* ActivityUpdateViewModel.swift */, A7ED1F7A1E831C5C00BFFA01 /* ActivityUpdateViewModelTests.swift */, + E13D76752D41698000FB58CE /* ApplePayTokenUseCase.swift */, + E13D76772D4195E700FB58CE /* ApplePayTokenUseCaseTests.swift */, 015572721E79C4FF005FB8CC /* BackerDashboardProjectCellViewModel.swift */, A7A627161E85BA5F004C931A /* BackerDashboardProjectCellViewModelTests.swift */, 014D629A1E6E31790033D2BD /* BackerDashboardProjectsViewModel.swift */, @@ -8259,6 +8265,7 @@ 606C45F529FACD78001BA067 /* RemoteConfigFeature+Helpers.swift in Sources */, 598D96C21D429756003F3F66 /* ActivitySampleStyles.swift in Sources */, 8AE8D86623466EB9005860C6 /* UpdateBackingInput+Constructor.swift in Sources */, + E13D76762D41698500FB58CE /* ApplePayTokenUseCase.swift in Sources */, 59B0E07E1D147F340081D2DC /* DashboardStyles.swift in Sources */, 0169F8C11D6CA27500C8D5C5 /* RootCategory.swift in Sources */, D04AAC21218BB70D00CF713E /* ChangePasswordViewModel.swift in Sources */, @@ -8507,6 +8514,7 @@ 8AE8D86823466EDB005860C6 /* UpdateBackingInput+ConstructorTests.swift in Sources */, D04AACAD218BB72100CF713E /* SettingsNotificationPickerViewModelTests.swift in Sources */, E13D76812D42CBA400FB58CE /* LoginSignupUseCaseTests.swift in Sources */, + E13D76792D419A5900FB58CE /* ApplePayTokenUseCaseTests.swift in Sources */, A7ED1FBF1E831C5C00BFFA01 /* SortPagerViewModelTests.swift in Sources */, D6AE53161FD1E05E00BEC788 /* String+Base64Tests.swift in Sources */, A7ED1FCB1E831C5C00BFFA01 /* ActivityFriendBackingViewModelTests.swift in Sources */, diff --git a/Library/ViewModels/ApplePayTokenUseCase.swift b/Library/ViewModels/ApplePayTokenUseCase.swift new file mode 100644 index 0000000000..0ee5cb31aa --- /dev/null +++ b/Library/ViewModels/ApplePayTokenUseCase.swift @@ -0,0 +1,163 @@ +import Foundation +import KsApi +import PassKit +import ReactiveSwift + +public protocol ApplePayTokenUseCaseType { + var uiInputs: ApplePayTokenUseCaseUIInputs { get } + var uiOutputs: ApplePayTokenUseCaseUIOutputs { get } + var dataOutputs: ApplePayTokenUseCaseDataOutputs { get } +} + +public protocol ApplePayTokenUseCaseUIInputs { + func applePayButtonTapped() + func paymentAuthorizationDidAuthorizePayment(paymentData: ( + displayName: String?, + network: String?, + transactionIdentifier: String + )) + func paymentAuthorizationViewControllerDidFinish() + func stripeTokenCreated(token: String?, error: Error?) -> PKPaymentAuthorizationStatus +} + +public protocol ApplePayTokenUseCaseUIOutputs { + var goToApplePayPaymentAuthorization: Signal { get } +} + +public protocol ApplePayTokenUseCaseDataOutputs { + var applePayParams: Signal { get } + var applePayAuthorizationStatus: Signal { get } + var paymentAuthorizationDidFinish: Signal { get } +} + +/** + A use case for ApplePay transactions in the regular (not post!) pledge flow. + + To complete an ApplePay payment, the use case should be used in this order: + - `uiInputs.applePayButtonTapped()` - The ApplePay button has been tapped + - `uiOutputs.goToApplePayPaymentAuthorization` - The view controller should display a `PKPaymentAuthorizationViewController` with the sent `PKPaymentRequest` + - `uiInputs.paymentAuthorizationDidAuthorizePayment(paymentData:)` - The `PKPaymentAuthorizationViewController` successfully authorized a payment + - `uiInputs.stripeTokenCreated(token:error:)` - Stripe successfully turned the `PKPayment` into a Stripe token. Returns a status, which is also sent by the `applePayAuthorizationStatus` signal. + - `uiInputs.paymentAuthorizationViewControllerDidFinish()` - The `PKPaymentAuthorizationViewController` was dismissed + - `dataOutputs.applePayParams` - Sends parameters which can be used in `CreateBacking` or `UpdateBacking`. Sends an initial `nil`value, by default. + + Other inputs and outputs: + + Data Inputs: + - `initialData` - An `initialData` event is required for any other signals to send. + + Data Outputs: + - `applePayAuthorizationStatus` - Sends an event indicating whether the ApplePay flow succeeded or failed. + - `paymentAuthorizationDidFinish` - Sends an event with the ApplePay spreadsheet closes. + */ + +public final class ApplePayTokenUseCase: ApplePayTokenUseCaseType, ApplePayTokenUseCaseUIInputs, + ApplePayTokenUseCaseUIOutputs, ApplePayTokenUseCaseDataOutputs { + init(initialData: Signal) { + self.goToApplePayPaymentAuthorization = initialData + .takeWhen(self.applePayButtonTappedSignal) + + let pkPaymentData = self.pkPaymentSignal + .map { pkPayment -> PKPaymentData? in + guard let displayName = pkPayment.displayName, let network = pkPayment.network else { + return nil + } + + return (displayName, network, pkPayment.transactionIdentifier) + } + + let applePayStatusSuccess = Signal.combineLatest( + self.stripeTokenSignal.skipNil(), + self.stripeErrorSignal.filter { $0 == nil }, + pkPaymentData.skipNil() + ) + .mapConst(PKPaymentAuthorizationStatus.success) + + let applePayStatusFailure = Signal.merge( + self.stripeErrorSignal.skipNil().ignoreValues(), + self.stripeTokenSignal.filter { $0 == nil }.ignoreValues(), + pkPaymentData.filter { $0 == nil }.ignoreValues() + ) + .mapConst(PKPaymentAuthorizationStatus.failure) + + self.createApplePayBackingStatusProperty <~ Signal.merge( + applePayStatusSuccess, + applePayStatusFailure + ) + + let applePayParams = Signal.combineLatest( + pkPaymentData.skipNil(), + self.stripeTokenSignal.skipNil() + ) + .map { paymentData, token in + ( + paymentData.displayName, + paymentData.network, + paymentData.transactionIdentifier, + token + ) + } + .map(ApplePayParams.init) + .takeWhen(self.paymentAuthorizationDidFinishSignal) + + self.applePayParams = Signal.merge( + initialData.mapConst(nil), + applePayParams.wrapInOptional() + ) + + self.applePayAuthorizationStatus = self.createApplePayBackingStatusProperty.signal + } + + // MARK: - Inputs + + private let (applePayButtonTappedSignal, applePayButtonTappedObserver) = Signal.pipe() + public func applePayButtonTapped() { + self.applePayButtonTappedObserver.send(value: ()) + } + + private let (pkPaymentSignal, pkPaymentObserver) = Signal<( + displayName: String?, + network: String?, + transactionIdentifier: String + ), Never>.pipe() + public func paymentAuthorizationDidAuthorizePayment(paymentData: ( + displayName: String?, + network: String?, + transactionIdentifier: String + )) { + self.pkPaymentObserver.send(value: paymentData) + } + + private let (paymentAuthorizationDidFinishSignal, paymentAuthorizationDidFinishObserver) + = Signal.pipe() + public func paymentAuthorizationViewControllerDidFinish() { + self.paymentAuthorizationDidFinishObserver.send(value: ()) + } + + public var paymentAuthorizationDidFinish: Signal { + return self.paymentAuthorizationDidFinishSignal + } + + private let (stripeTokenSignal, stripeTokenObserver) = Signal.pipe() + private let (stripeErrorSignal, stripeErrorObserver) = Signal.pipe() + + private let createApplePayBackingStatusProperty = MutableProperty(.failure) + public func stripeTokenCreated(token: String?, error: Error?) -> PKPaymentAuthorizationStatus { + self.stripeTokenObserver.send(value: token) + self.stripeErrorObserver.send(value: error) + + return self.createApplePayBackingStatusProperty.value + } + + // MARK: - Outputs + + public let goToApplePayPaymentAuthorization: Signal + public let applePayParams: Signal + public let applePayAuthorizationStatus: Signal + + // MARK: - Interface + + public var uiInputs: ApplePayTokenUseCaseUIInputs { return self } + public var uiOutputs: ApplePayTokenUseCaseUIOutputs { return self } + public var dataOutputs: ApplePayTokenUseCaseDataOutputs { return self } +} diff --git a/Library/ViewModels/ApplePayTokenUseCaseTests.swift b/Library/ViewModels/ApplePayTokenUseCaseTests.swift new file mode 100644 index 0000000000..03d766894d --- /dev/null +++ b/Library/ViewModels/ApplePayTokenUseCaseTests.swift @@ -0,0 +1,155 @@ +@testable import KsApi +@testable import Library +import PassKit +import ReactiveExtensions_TestHelpers +import ReactiveSwift +import XCTest + +final class ApplePayTokenUseCaseTests: TestCase { + let (initialDataSignal, initialDataObserver) = Signal.pipe() + let (allRewardsTotalSignal, allRewardsTotalObserver) = Signal.pipe() + let (additionalPledgeAmountSignal, additionalPledgeAmountObserver) = Signal.pipe() + let (allRewardsShippingTotalSignal, allRewardsShippingTotalObserver) = Signal.pipe() + + var useCase: ApplePayTokenUseCase! + + let goToApplePayPaymentAuthorization = TestObserver() + let applePayParams = TestObserver() + let applePayAuthorizationStatus = TestObserver() + + override func setUp() { + super.setUp() + + self.useCase = ApplePayTokenUseCase( + initialData: self.initialDataSignal + ) + + self.useCase.uiOutputs.goToApplePayPaymentAuthorization + .observe(self.goToApplePayPaymentAuthorization.observer) + self.useCase.dataOutputs.applePayAuthorizationStatus.observe(self.applePayAuthorizationStatus.observer) + self.useCase.dataOutputs.applePayParams.observe(self.applePayParams.observer) + } + + func testUseCase_GoesToPaymentAuthorization_WhenApplePayButtonIsTapped() { + let data = PaymentAuthorizationData( + project: Project.template, + reward: Reward.template, + allRewardsTotal: 92.0, + additionalPledgeAmount: 15.0, + allRewardsShippingTotal: 33.0, + merchantIdentifier: "foo.bar.baz" + ) + + self.initialDataObserver.send(value: data) + + self.goToApplePayPaymentAuthorization.assertDidNotEmitValue() + + self.useCase.uiInputs.applePayButtonTapped() + + self.goToApplePayPaymentAuthorization.assertDidEmitValue() + } + + func testUseCase_ApplePayParams_DefaultToNil() { + let data = PaymentAuthorizationData( + project: Project.template, + reward: Reward.template, + allRewardsTotal: 92.0, + additionalPledgeAmount: 15.0, + allRewardsShippingTotal: 33.0, + merchantIdentifier: "foo.bar.baz" + ) + + self.initialDataObserver.send(value: data) + self.applePayParams.assertLastValue(nil) + } + + func testUseCase_CompletingApplePayFlow_SendsApplePayParamsAndStatus() { + let data = PaymentAuthorizationData( + project: Project.template, + reward: Reward.template, + allRewardsTotal: 92.0, + additionalPledgeAmount: 15.0, + allRewardsShippingTotal: 33.0, + merchantIdentifier: "foo.bar.baz" + ) + + self.initialDataObserver.send(value: data) + + self.useCase.uiInputs.applePayButtonTapped() + self.goToApplePayPaymentAuthorization.assertDidEmitValue() + + self.useCase.uiInputs.paymentAuthorizationDidAuthorizePayment(paymentData: ( + "Display Name", + "Network", + "Transaction Identifier" + )) + self.applePayParams.assertLastValue(nil, "Params shouldn't emit until transaction is finished") + + let status = self.useCase.uiInputs.stripeTokenCreated(token: "some_stripe_token", error: nil) + XCTAssertEqual(status, PKPaymentAuthorizationStatus.success) + + self.applePayParams.assertLastValue(nil, "Params shouldn't emit until transaction is finished") + + self.useCase.uiInputs.paymentAuthorizationViewControllerDidFinish() + + self.applePayAuthorizationStatus.assertLastValue(PKPaymentAuthorizationStatus.success) + self.applePayParams.assertDidEmitValue() + + XCTAssertNotNil(self.applePayParams.lastValue as Any) + + let params = self.applePayParams.lastValue!! + XCTAssertEqual(params.token, "some_stripe_token") + XCTAssertEqual(params.paymentInstrumentName, "Display Name") + XCTAssertEqual(params.paymentNetwork, "Network") + XCTAssertEqual(params.transactionIdentifier, "Transaction Identifier") + } + + func testUseCase_StripeError_SendsFailedStatus() { + let data = PaymentAuthorizationData( + project: Project.template, + reward: Reward.template, + allRewardsTotal: 92.0, + additionalPledgeAmount: 15.0, + allRewardsShippingTotal: 33.0, + merchantIdentifier: "foo.bar.baz" + ) + + self.initialDataObserver.send(value: data) + + self.useCase.uiInputs.applePayButtonTapped() + self.useCase.uiInputs.paymentAuthorizationDidAuthorizePayment(paymentData: ( + "Display Name", + "Network", + "Transaction Identifier" + )) + + let status = self.useCase.uiInputs.stripeTokenCreated(token: nil, error: TestError()) + XCTAssertEqual(status, PKPaymentAuthorizationStatus.failure) + + self.useCase.uiInputs.paymentAuthorizationViewControllerDidFinish() + + self.applePayAuthorizationStatus.assertLastValue(PKPaymentAuthorizationStatus.failure) + self.applePayParams.assertLastValue(nil, "Params shouldn't emit when Stripe fails") + } + + func testUseCase_ApplePayIsCanceled_DoesNotSendParams() { + let data = PaymentAuthorizationData( + project: Project.template, + reward: Reward.template, + allRewardsTotal: 92.0, + additionalPledgeAmount: 15.0, + allRewardsShippingTotal: 33.0, + merchantIdentifier: "foo.bar.baz" + ) + + self.initialDataObserver.send(value: data) + + self.useCase.uiInputs.applePayButtonTapped() + self.useCase.uiInputs.paymentAuthorizationViewControllerDidFinish() + + self.applePayAuthorizationStatus.assertDidNotEmitValue() + self.applePayParams.assertLastValue(nil, "Params shouldn't emit when ApplePay is canceled") + } +} + +private class TestError: Error {}