Skip to content

Commit

Permalink
Create ApplePayTokenUseCase and tests (#2258)
Browse files Browse the repository at this point in the history
* Create ApplePayTokenUseCase

* Tweak input/output protocols for ApplePayTokenUseCase

* Make changes to make ApplePayTokenUseCase easier to integrate
  • Loading branch information
amy-at-kickstarter authored Feb 10, 2025
1 parent dbf6b11 commit 1a79974
Show file tree
Hide file tree
Showing 3 changed files with 326 additions and 0 deletions.
8 changes: 8 additions & 0 deletions Kickstarter.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -3314,6 +3316,8 @@
E13D767E2D42C52900FB58CE /* LoginSignupUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginSignupUseCaseTests.swift; sourceTree = "<group>"; };
E13D76702D4011BF00FB58CE /* PaymentMethodsUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentMethodsUseCase.swift; sourceTree = "<group>"; };
E13D76722D40438100FB58CE /* PaymentMethodsUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentMethodsUseCaseTests.swift; sourceTree = "<group>"; };
E13D76752D41698000FB58CE /* ApplePayTokenUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplePayTokenUseCase.swift; sourceTree = "<group>"; };
E13D76772D4195E700FB58CE /* ApplePayTokenUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplePayTokenUseCaseTests.swift; sourceTree = "<group>"; };
E16794272B7EAA5200064063 /* OAuthTokenExchange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthTokenExchange.swift; sourceTree = "<group>"; };
E16794292B85136900064063 /* OAuthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthTests.swift; sourceTree = "<group>"; };
E16ECA6F2C245A34002A1D25 /* PagedContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedContainerViewController.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
163 changes: 163 additions & 0 deletions Library/ViewModels/ApplePayTokenUseCase.swift
Original file line number Diff line number Diff line change
@@ -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<PaymentAuthorizationData, Never> { get }
}

public protocol ApplePayTokenUseCaseDataOutputs {
var applePayParams: Signal<ApplePayParams?, Never> { get }
var applePayAuthorizationStatus: Signal<PKPaymentAuthorizationStatus, Never> { get }
var paymentAuthorizationDidFinish: Signal<Void, Never> { 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<PaymentAuthorizationData, Never>) {
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<Void, Never>.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<Void, Never>.pipe()
public func paymentAuthorizationViewControllerDidFinish() {
self.paymentAuthorizationDidFinishObserver.send(value: ())
}

public var paymentAuthorizationDidFinish: Signal<Void, Never> {
return self.paymentAuthorizationDidFinishSignal
}

private let (stripeTokenSignal, stripeTokenObserver) = Signal<String?, Never>.pipe()
private let (stripeErrorSignal, stripeErrorObserver) = Signal<Error?, Never>.pipe()

private let createApplePayBackingStatusProperty = MutableProperty<PKPaymentAuthorizationStatus>(.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<PaymentAuthorizationData, Never>
public let applePayParams: Signal<ApplePayParams?, Never>
public let applePayAuthorizationStatus: Signal<PKPaymentAuthorizationStatus, Never>

// MARK: - Interface

public var uiInputs: ApplePayTokenUseCaseUIInputs { return self }
public var uiOutputs: ApplePayTokenUseCaseUIOutputs { return self }
public var dataOutputs: ApplePayTokenUseCaseDataOutputs { return self }
}
155 changes: 155 additions & 0 deletions Library/ViewModels/ApplePayTokenUseCaseTests.swift
Original file line number Diff line number Diff line change
@@ -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<PaymentAuthorizationData, Never>.pipe()
let (allRewardsTotalSignal, allRewardsTotalObserver) = Signal<Double, Never>.pipe()
let (additionalPledgeAmountSignal, additionalPledgeAmountObserver) = Signal<Double, Never>.pipe()
let (allRewardsShippingTotalSignal, allRewardsShippingTotalObserver) = Signal<Double, Never>.pipe()

var useCase: ApplePayTokenUseCase!

let goToApplePayPaymentAuthorization = TestObserver<PaymentAuthorizationData, Never>()
let applePayParams = TestObserver<ApplePayParams?, Never>()
let applePayAuthorizationStatus = TestObserver<PKPaymentAuthorizationStatus, Never>()

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 {}

0 comments on commit 1a79974

Please sign in to comment.