// // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import XCTest @testable import Signal @testable import SignalServiceKit final class DonateViewControllerTest: SignalBaseTest { typealias State = DonateViewController.State // MARK: - One time fixtures private struct OneTimeFixtures { static let badge: ProfileBadge = try! ProfileBadge(jsonDictionary: [ "id": "BOOST", "category": "donor", "name": "A Boost", "description": "A boost badge!", "sprites6": ["ldpi.png", "mdpi.png", "hdpi.png", "xhdpi.png", "xxhdpi.png", "xxxhdpi.png"] ]) static let minimums: [Currency.Code: FiatMoney] = [ "USD": 10.as("USD"), "AUD": 30.as("AUD") ] static let presets: [Currency.Code: DonationUtilities.Preset] = [ "USD": .init(currencyCode: "USD", amounts: [10.as("USD"), 20.as("USD")]), "AUD": .init(currencyCode: "AUD", amounts: [30.as("AUD"), 40.as("AUD")]) ] static func configWithDefaults( level: UInt = 12, badge: ProfileBadge = badge, minimums: [Currency.Code: FiatMoney] = minimums, presets: [Currency.Code: DonationUtilities.Preset] = presets ) -> State.OneTimeConfiguration { .init( level: level, badge: badge, presetAmounts: presets, minimumAmountsByCurrency: minimums, maximumAmountViaSepa: FiatMoney(currencyCode: "EUR", value: 10.000) ) } } // MARK: - Monthly fixtures private struct MonthlyFixtures { static let badgeOne: ProfileBadge = try! .init(jsonDictionary: [ "id": "test-badge-1", "category": "donor", "name": "Test Badge 1", "description": "First test badge", "sprites6": ["ldpi.png", "mdpi.png", "hdpi.png", "xhdpi.png", "xxhdpi.png", "xxxhdpi.png"] ]) static let badgeTwo: ProfileBadge = try! .init(jsonDictionary: [ "id": "test-badge-2", "category": "donor", "name": "Test Badge 2", "description": "Second test badge", "sprites6": ["ldpi.png", "mdpi.png", "hdpi.png", "xhdpi.png", "xxhdpi.png", "xxxhdpi.png"] ]) static let amountsOne: [Currency.Code: FiatMoney] = [ "USD": 1.as("USD"), "GBP": 2.as("GBP"), "EUR": 3.as("EUR") ] static let amountsTwo: [Currency.Code: FiatMoney] = [ "USD": 4.as("USD"), "EUR": 5.as("EUR") ] static func levelOneWithDefaults( level: UInt = 1, name: String = "First Level", badge: ProfileBadge = badgeOne, amounts: [Currency.Code: FiatMoney] = amountsOne ) -> DonationSubscriptionLevel { .init( level: level, badge: badge, amounts: amounts ) } static func levelTwoWithDefaults( level: UInt = 2, name: String = "Second Level", badge: ProfileBadge = badgeTwo, amounts: [Currency.Code: FiatMoney] = amountsTwo ) -> DonationSubscriptionLevel { .init( level: level, badge: badge, amounts: amounts ) } static let subscriptionLevels: [DonationSubscriptionLevel] = [ levelOneWithDefaults(), levelTwoWithDefaults() ] static func configWithDefaults( subscriptionLevels: [DonationSubscriptionLevel] = subscriptionLevels ) -> State.MonthlyConfiguration { .init(levels: subscriptionLevels) } } // MARK: - Payment methods fixtures private struct PaymentMethodsFixtures { static let supportedPaymentMethodsByCurrency: [Currency.Code: Set] = [ "USD": [.applePay, .creditOrDebitCard, .paypal], "AUD": [.applePay, .creditOrDebitCard, .paypal], "EUR": [.applePay, .creditOrDebitCard] ] static func configWithDefaults( paymentMethods: [Currency.Code: Set] = supportedPaymentMethodsByCurrency ) -> State.PaymentMethodsConfiguration { .init(supportedPaymentMethodsByCurrency: paymentMethods) } } // MARK: - Subscription fixtures private static func subscription( at level: UInt, isPaymentProcessing: Bool = false, paymentMethod: String = "CARD" ) -> Subscription { try! .init( subscriptionDict: [ "level": level, "currency": isPaymentProcessing ? "EUR" : "USD", "amount": 12, "endOfCurrentPeriod": TimeInterval(1234), "billingCycleAnchor": TimeInterval(5678), "active": true, "cancelAtPeriodEnd": false, "status": "active", "processor": "STRIPE", "paymentMethod": paymentMethod, "paymentProcessing": isPaymentProcessing ], chargeFailureDict: nil ) } // MARK: - private static let defaultOneTimeConfig = OneTimeFixtures.configWithDefaults() private static let defaultMonthlyConfig = MonthlyFixtures.configWithDefaults() private static let defaultPaymentMethodsConfig = PaymentMethodsFixtures.configWithDefaults() private static var localNumber: String = "+17735550100" private var initializing: State { .init(donateMode: .oneTime) } private var loading: State { initializing.loading() } private var loadFailed: State { loading.loadFailed() } private var loadedWithoutSubscription: State { loading.loaded( oneTimeConfig: Self.defaultOneTimeConfig, monthlyConfig: Self.defaultMonthlyConfig, paymentMethodsConfig: Self.defaultPaymentMethodsConfig, currentMonthlySubscription: nil, subscriberID: nil, previousMonthlySubscriptionCurrencyCode: nil, previousMonthlySubscriptionPaymentMethod: nil, oneTimeBoostReceiptCredentialRequestError: nil, recurringSubscriptionReceiptCredentialRequestError: nil, pendingIDEALOneTimeDonation: nil, pendingIDEALSubscription: nil, locale: Locale(identifier: "en-US"), localNumber: Self.localNumber ) } private var loadedWithSubscription: State { loading.loaded( oneTimeConfig: Self.defaultOneTimeConfig, monthlyConfig: Self.defaultMonthlyConfig, paymentMethodsConfig: Self.defaultPaymentMethodsConfig, currentMonthlySubscription: Self.subscription(at: 2), subscriberID: Data([1, 2, 3]), previousMonthlySubscriptionCurrencyCode: "USD", previousMonthlySubscriptionPaymentMethod: .applePay, oneTimeBoostReceiptCredentialRequestError: nil, recurringSubscriptionReceiptCredentialRequestError: nil, pendingIDEALOneTimeDonation: nil, pendingIDEALSubscription: nil, locale: Locale(identifier: "en-US"), localNumber: Self.localNumber ) } func loadWithDefaults( oneTimeConfig: State.OneTimeConfiguration = defaultOneTimeConfig, monthlyConfig: State.MonthlyConfiguration = defaultMonthlyConfig, paymentMethodsConfig: State.PaymentMethodsConfiguration = defaultPaymentMethodsConfig, currentMonthlySubscription: Subscription? = subscription(at: 2), subscriberID: Data? = Data([1, 2, 3]), previousMonthlySubscriptionCurrencyCode: Currency.Code? = nil, locale: Locale = Locale(identifier: "en-US") ) -> State { loading.loaded( oneTimeConfig: oneTimeConfig, monthlyConfig: monthlyConfig, paymentMethodsConfig: paymentMethodsConfig, currentMonthlySubscription: currentMonthlySubscription, subscriberID: subscriberID, previousMonthlySubscriptionCurrencyCode: previousMonthlySubscriptionCurrencyCode, previousMonthlySubscriptionPaymentMethod: .applePay, oneTimeBoostReceiptCredentialRequestError: nil, recurringSubscriptionReceiptCredentialRequestError: nil, pendingIDEALOneTimeDonation: nil, pendingIDEALSubscription: nil, locale: locale, localNumber: Self.localNumber ) } func loadWithPaymentsProcessing( recurringProcessingViaSubscription: Bool, recurringProcessingViaError: Bool ) -> State { let recurringError: DonationReceiptCredentialRequestError? = { guard recurringProcessingViaError else { return nil } return DonationReceiptCredentialRequestError( errorCode: .paymentStillProcessing, chargeFailureCodeIfPaymentFailed: nil, badge: MonthlyFixtures.badgeOne, amount: FiatMoney(currencyCode: "EUR", value: 5), paymentMethod: .sepa ) }() return loading.loaded( oneTimeConfig: Self.defaultOneTimeConfig, monthlyConfig: Self.defaultMonthlyConfig, paymentMethodsConfig: Self.defaultPaymentMethodsConfig, currentMonthlySubscription: Self.subscription( at: 2, isPaymentProcessing: recurringProcessingViaSubscription, paymentMethod: "SEPA_DEBIT" ), subscriberID: Data([1, 2, 3]), previousMonthlySubscriptionCurrencyCode: nil, previousMonthlySubscriptionPaymentMethod: .applePay, oneTimeBoostReceiptCredentialRequestError: DonationReceiptCredentialRequestError( errorCode: .paymentStillProcessing, chargeFailureCodeIfPaymentFailed: nil, badge: OneTimeFixtures.badge, amount: FiatMoney(currencyCode: "EUR", value: 100), paymentMethod: .sepa ), recurringSubscriptionReceiptCredentialRequestError: recurringError, pendingIDEALOneTimeDonation: nil, pendingIDEALSubscription: nil, locale: Locale(identifier: "en-US"), localNumber: Self.localNumber ) } // MARK: - Initialization func testInitialization() { XCTAssertEqual(initializing.loadState, .initializing) } // MARK: - Top-level getters func testOneTime() { XCTAssertNil(initializing.oneTime) XCTAssertNil(loading.oneTime) XCTAssertNil(loadFailed.oneTime) XCTAssertNotNil(loadedWithoutSubscription.oneTime) } func testMonthly() { XCTAssertNil(initializing.monthly) XCTAssertNil(loading.monthly) XCTAssertNil(loadFailed.monthly) XCTAssertNotNil(loadedWithoutSubscription.monthly) } func testSelectedCurrencyCode() { XCTAssertNil(initializing.selectedCurrencyCode) XCTAssertEqual(loadedWithoutSubscription.selectedCurrencyCode, "USD") } func testSupportedCurrencyCodes() { XCTAssertTrue(initializing.supportedCurrencyCodes.isEmpty) let onOneTime = loadedWithoutSubscription.selectDonateMode(.oneTime) XCTAssertEqual(onOneTime.supportedCurrencyCodes, Set(["USD", "AUD"])) let onMonthly = loadedWithoutSubscription.selectDonateMode(.monthly) XCTAssertEqual(onMonthly.supportedCurrencyCodes, Set(["USD", "EUR"])) } func testSelectedProfileBadge() { XCTAssertNil(initializing.selectedCurrencyCode) let onOneTime = loadedWithoutSubscription.selectDonateMode(.oneTime) XCTAssertEqual(onOneTime.selectedProfileBadge, Self.defaultOneTimeConfig.badge) let onMonthly = loadedWithoutSubscription.selectDonateMode(.monthly) let levels = Self.defaultMonthlyConfig.levels let selectedSecond = onMonthly.selectSubscriptionLevel(levels.last!) XCTAssertEqual(onMonthly.selectedProfileBadge, levels.first!.badge) XCTAssertEqual(selectedSecond.selectedProfileBadge, levels.last!.badge) } func testLoadedDonateMode() { XCTAssertNil(initializing.loadedDonateMode) XCTAssertNil(loadFailed.loadedDonateMode) let onOneTime = loadedWithoutSubscription.selectDonateMode(.oneTime) XCTAssertEqual(onOneTime.loadedDonateMode, .oneTime) let onMonthly = loadedWithoutSubscription.selectDonateMode(.monthly) XCTAssertEqual(onMonthly.loadedDonateMode, .monthly) } // MARK: - Top-level state changes func testLoading() { XCTAssertEqual(loading.loadState, .loading) } func testLoadFailed() { XCTAssertEqual(loadFailed.loadState, .loadFailed) } func testLoadedBoringSettingOfProperties() { let oneTime = loadedWithSubscription.oneTime let monthly = loadedWithSubscription.monthly XCTAssertEqual(oneTime?.selectedPreset, Self.defaultOneTimeConfig.presetAmounts["USD"]) XCTAssertEqual(oneTime?.selectedAmount, .nothingSelected(currencyCode: "USD")) XCTAssertEqual(oneTime?.profileBadge, Self.defaultOneTimeConfig.badge) XCTAssertEqual(monthly?.subscriptionLevels, Self.defaultMonthlyConfig.levels) XCTAssertEqual(monthly?.currentSubscription, Self.subscription(at: 2)) XCTAssertEqual(monthly?.subscriberID, Data([1, 2, 3])) } func testLoadedDefaultOneTimeCurrency() { let american = loadWithDefaults(locale: Locale(identifier: "en-US")) let australian = loadWithDefaults(locale: Locale(identifier: "en-AU")) let spanish = loadWithDefaults(locale: Locale(identifier: "es-ES")) let korean = loadWithDefaults(locale: Locale(identifier: "kr-KR")) XCTAssertEqual(american.oneTime?.selectedCurrencyCode, "USD") XCTAssertEqual(australian.oneTime?.selectedCurrencyCode, "AUD") XCTAssertEqual(spanish.oneTime?.selectedCurrencyCode, "USD") XCTAssertEqual(korean.oneTime?.selectedCurrencyCode, "USD") } func testLoadedDefaultMonthlyCurrency() { let withPreviousCurrency = loadWithDefaults(previousMonthlySubscriptionCurrencyCode: "EUR") XCTAssertEqual(withPreviousCurrency.monthly?.selectedCurrencyCode, "EUR") let withSubscription = loadWithDefaults(locale: Locale(identifier: "kr-KR")) XCTAssertEqual(withSubscription.monthly?.selectedCurrencyCode, "USD") let withSupportedLocaleCurrency = loadWithDefaults( currentMonthlySubscription: nil, locale: Locale(identifier: "es-ES") ) XCTAssertEqual(withSupportedLocaleCurrency.monthly?.selectedCurrencyCode, "EUR") let withUnsupportedLocaleCurrency = loadWithDefaults( currentMonthlySubscription: nil, locale: Locale(identifier: "kr-KR") ) XCTAssertEqual(withUnsupportedLocaleCurrency.monthly?.selectedCurrencyCode, "USD") } func testLoadedSubscriptionLevelWithNoSubscription() { XCTAssertEqual( loadedWithoutSubscription.monthly?.selectedSubscriptionLevel, Self.defaultMonthlyConfig.levels.first! ) } func testLoadedSubscriptionLevelWithStillSupportedSubscription() { XCTAssertEqual( loadedWithSubscription.monthly?.selectedSubscriptionLevel, Self.defaultMonthlyConfig.levels[1] ) } func testLoadedSubscriptionLevelWithSubscriptionTheServerNoLongerLists() { let state = loadWithDefaults(currentMonthlySubscription: Self.subscription(at: 99)) XCTAssertEqual( state.monthly?.selectedSubscriptionLevel, Self.defaultMonthlyConfig.levels.first! ) } func testSelectCurrencyCode() { let selectedUsd = loadedWithoutSubscription.selectCurrencyCode("USD") XCTAssertEqual(selectedUsd.selectDonateMode(.oneTime).selectedCurrencyCode, "USD") XCTAssertEqual(selectedUsd.selectDonateMode(.monthly).selectedCurrencyCode, "USD") let triedToSelectEur = loadedWithoutSubscription.selectCurrencyCode("EUR") XCTAssertEqual(triedToSelectEur.selectDonateMode(.oneTime).selectedCurrencyCode, "USD") XCTAssertEqual(triedToSelectEur.selectDonateMode(.monthly).selectedCurrencyCode, "EUR") let triedToSelectAud = loadedWithoutSubscription.selectCurrencyCode("AUD") XCTAssertEqual(triedToSelectAud.selectDonateMode(.oneTime).selectedCurrencyCode, "AUD") XCTAssertEqual(triedToSelectAud.selectDonateMode(.monthly).selectedCurrencyCode, "USD") } func testSupportedPaymentMethods() { let oneTimeConfig = OneTimeFixtures.configWithDefaults( minimums: [ "USD": 10.as("USD"), "EUR": 200.as("EUR") ], presets: [ "USD": .init(currencyCode: "USD", amounts: [10.as("USD"), 20.as("USD")]), "EUR": .init(currencyCode: "EUR", amounts: [200.as("EUR"), 400.as("EUR")]) ] ) let monthlyConfig = MonthlyFixtures.configWithDefaults( subscriptionLevels: [ MonthlyFixtures.levelOneWithDefaults( amounts: [ "USD": 15.as("USD"), "EUR": 250.as("EUR") ] ) ] ) let paymentMethodsConfig = PaymentMethodsFixtures.configWithDefaults( paymentMethods: [ "USD": [.paypal, .applePay], "EUR": [] ] ) var state = loadWithDefaults( oneTimeConfig: oneTimeConfig, monthlyConfig: monthlyConfig, paymentMethodsConfig: paymentMethodsConfig ) // If a currency has no payment methods, it should not be supported. state = state.selectDonateMode(.oneTime) XCTAssertEqual( state.supportedCurrencyCodes, ["USD"], "Only USD should be supported for one-time, as only it has any payment methods!" ) state = state.selectDonateMode(.monthly) XCTAssertEqual( state.supportedCurrencyCodes, ["USD"], "Only USD should be supported for monthly, as only it has any payment methods!" ) // If the selected currency has supported payment methods, payment // requests should return them - unless they are disabled globally // for that type of payment. state = state.selectCurrencyCode("USD") state = state.selectOneTimeAmount(.choseCustomAmount(amount: 123.as("USD"))) state = state.selectSubscriptionLevel(monthlyConfig.levels.first!) let oneTimePaymentRequest = state.oneTime!.paymentRequest switch oneTimePaymentRequest { case let .canContinue(amount, supportedPaymentMethods): XCTAssertEqual(amount, 123.as("USD")) XCTAssertEqual(supportedPaymentMethods, [.paypal, .applePay]) default: XCTFail("Unexpectedly invalid one-time payment request! \(oneTimePaymentRequest)") } let monthlyPaymentRequest = state.monthly!.paymentRequest! XCTAssertEqual(monthlyPaymentRequest.amount, 15.as("USD")) XCTAssertEqual(monthlyPaymentRequest.supportedPaymentMethods, [.paypal, .applePay]) } func testPaymentProcessing() { let oneTimeProcessing = loadWithPaymentsProcessing( recurringProcessingViaSubscription: false, recurringProcessingViaError: false ) switch oneTimeProcessing.oneTime!.paymentRequest { case let .alreadyHasPaymentProcessing(paymentMethod): XCTAssertEqual(paymentMethod, .sepa) case .noAmountSelected, .amountIsTooSmall, .canContinue, .awaitingIDEALAuthorization: XCTFail("Should be payment processing!") } /// Simulates a payment that has not yet processed. let recurringProcessingViaSubscription = loadWithPaymentsProcessing( recurringProcessingViaSubscription: true, recurringProcessingViaError: true ) XCTAssertEqual( recurringProcessingViaSubscription.monthly!.paymentMethodIfPaymentProcessing, .sepa ) /// Simulates a payment that has processed, but our client hasn't yet /// redeemed a badge for it. let recurringProcessingOnlyViaError = loadWithPaymentsProcessing( recurringProcessingViaSubscription: false, recurringProcessingViaError: true ) XCTAssertEqual( recurringProcessingOnlyViaError.monthly!.paymentMethodIfPaymentProcessing, .sepa ) } } fileprivate extension Int { func `as`(_ currencyCode: Currency.Code) -> FiatMoney { FiatMoney(currencyCode: currencyCode, value: Decimal(self)) } }