From c31780050f6ed2bda0500162ae4b6b0a605d7c53 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 20 Dec 2024 10:15:01 -0400 Subject: [PATCH] Isolated tests for OneTimeInAppPaymentRepository. --- .../OneTimeInAppPaymentRepository.kt | 92 +++--- .../donate/DonateToSignalViewModel.kt | 13 +- .../PayPalPaymentInProgressViewModel.kt | 16 +- .../StripePaymentInProgressViewModel.kt | 16 +- .../jobs/InAppPaymentOneTimeContextJob.kt | 12 +- .../app/subscription/DonationsTestRule.kt | 99 +++++++ .../OneTimeInAppPaymentRepositoryTest.kt | 277 ++++++++++++++++++ .../RecurringInAppPaymentRepositoryTest.kt | 65 +--- 8 files changed, 460 insertions(+), 130 deletions(-) create mode 100644 app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsTestRule.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeInAppPaymentRepositoryTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeInAppPaymentRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeInAppPaymentRepository.kt index 526e070323..6600889eee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeInAppPaymentRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeInAppPaymentRepository.kt @@ -7,7 +7,7 @@ import org.signal.core.util.logging.Log import org.signal.core.util.money.FiatMoney import org.signal.donations.PaymentSourceType import org.thoughtcrime.securesms.badges.models.Badge -import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentError import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError @@ -16,52 +16,61 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.database.RecipientTable import org.thoughtcrime.securesms.database.SignalDatabase -import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.jobs.InAppPaymentOneTimeContextJob import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId -import org.whispersystems.signalservice.api.services.DonationsService import java.util.Currency import java.util.Locale import java.util.concurrent.TimeUnit -class OneTimeInAppPaymentRepository(private val donationsService: DonationsService) { +object OneTimeInAppPaymentRepository { - companion object { - private val TAG = Log.tag(OneTimeInAppPaymentRepository::class.java) + private val TAG = Log.tag(OneTimeInAppPaymentRepository::class.java) - fun handleCreatePaymentIntentError(throwable: Throwable, badgeRecipient: RecipientId, paymentSourceType: PaymentSourceType): Single { - return if (throwable is DonationError) { - Single.error(throwable) - } else { - val recipient = Recipient.resolved(badgeRecipient) - val errorSource = if (recipient.isSelf) DonationErrorSource.ONE_TIME else DonationErrorSource.GIFT - Single.error(DonationError.getPaymentSetupError(errorSource, throwable, paymentSourceType)) - } - } - - fun verifyRecipientIsAllowedToReceiveAGift(badgeRecipient: RecipientId): Completable { - return Completable.fromAction { - Log.d(TAG, "Verifying badge recipient $badgeRecipient", true) - val recipient = Recipient.resolved(badgeRecipient) - - if (recipient.isSelf) { - Log.d(TAG, "Cannot send a gift to self.", true) - throw DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid - } - - if (recipient.isGroup || recipient.isDistributionList || recipient.registered != RecipientTable.RegisteredState.REGISTERED) { - Log.w(TAG, "Invalid badge recipient $badgeRecipient. Verification failed.", true) - throw DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid - } - }.subscribeOn(Schedulers.io()) + /** + * Translates the given Throwable into a DonationError + * + * If the throwable is already a DonationError, it's returned as is. Otherwise we will return an adequate payment setup error. + */ + fun handleCreatePaymentIntentError(throwable: Throwable, badgeRecipient: RecipientId, paymentSourceType: PaymentSourceType): Single { + return if (throwable is DonationError) { + Single.error(throwable) + } else { + val recipient = Recipient.resolved(badgeRecipient) + val errorSource = if (recipient.isSelf) DonationErrorSource.ONE_TIME else DonationErrorSource.GIFT + Single.error(DonationError.getPaymentSetupError(errorSource, throwable, paymentSourceType)) } } + /** + * Checks whether the recipient for the given ID is allowed to receive a gift. Returns + * normally if they are and emits an error otherwise. + */ + fun verifyRecipientIsAllowedToReceiveAGift(badgeRecipient: RecipientId): Completable { + return Completable.fromAction { + Log.d(TAG, "Verifying badge recipient $badgeRecipient", true) + val recipient = Recipient.resolved(badgeRecipient) + + if (recipient.isSelf) { + Log.d(TAG, "Cannot send a gift to self.", true) + throw DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid + } + + if (!recipient.isIndividual || recipient.registered != RecipientTable.RegisteredState.REGISTERED) { + Log.w(TAG, "Invalid badge recipient $badgeRecipient. Verification failed.", true) + throw DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid + } + }.subscribeOn(Schedulers.io()) + } + + /** + * Parses the donations configuration and returns any boost information from it. Also maps and filters out currencies + * based on platform and payment method availability. + */ fun getBoosts(): Single>> { - return Single.fromCallable { donationsService.getDonationsConfiguration(Locale.getDefault()) } + return Single.fromCallable { AppDependencies.donationsService.getDonationsConfiguration(Locale.getDefault()) } .subscribeOn(Schedulers.io()) .flatMap { it.flattenResult() } .map { config -> @@ -85,7 +94,7 @@ class OneTimeInAppPaymentRepository(private val donationsService: DonationsServi } fun getMinimumDonationAmounts(): Single> { - return Single.fromCallable { donationsService.getDonationsConfiguration(Locale.getDefault()) } + return Single.fromCallable { AppDependencies.donationsService.getDonationsConfiguration(Locale.getDefault()) } .flatMap { it.flattenResult() } .subscribeOn(Schedulers.io()) .map { it.getMinimumDonationAmounts() } @@ -93,10 +102,9 @@ class OneTimeInAppPaymentRepository(private val donationsService: DonationsServi fun waitForOneTimeRedemption( inAppPayment: InAppPaymentTable.InAppPayment, - paymentIntentId: String, - paymentSourceType: PaymentSourceType + paymentIntentId: String ): Completable { - val isLongRunning = paymentSourceType == PaymentSourceType.Stripe.SEPADebit + val isLongRunning = inAppPayment.data.paymentMethodType.toPaymentSourceType() == PaymentSourceType.Stripe.SEPADebit val isBoost = inAppPayment.data.recipientId?.let { RecipientId.from(it) } == Recipient.self().id val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.ONE_TIME else DonationErrorSource.GIFT @@ -107,17 +115,7 @@ class OneTimeInAppPaymentRepository(private val donationsService: DonationsServi } return Single.fromCallable { - val inAppPaymentReceiptRecord = if (isBoost) { - InAppPaymentReceiptRecord.createForBoost(inAppPayment.data.amount!!.toFiatMoney()) - } else { - InAppPaymentReceiptRecord.createForGift(inAppPayment.data.amount!!.toFiatMoney()) - } - - val donationTypeLabel = inAppPaymentReceiptRecord.type.code.replaceFirstChar { c -> if (c.isLowerCase()) c.titlecase(Locale.US) else c.toString() } - - Log.d(TAG, "Confirmed payment intent. Recording $donationTypeLabel receipt and submitting badge reimbursement job chain.", true) - SignalDatabase.donationReceipts.addReceipt(inAppPaymentReceiptRecord) - + Log.d(TAG, "Confirmed payment intent. Submitting badge reimbursement job chain.", true) SignalDatabase.inAppPayments.update( inAppPayment = inAppPayment.copy( data = inAppPayment.data.copy( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt index 0c959081fb..37007b79c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt @@ -30,7 +30,6 @@ import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation import org.thoughtcrime.securesms.database.model.isExpired -import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.subscription.LevelUpdate @@ -51,8 +50,7 @@ import java.util.Optional * only in charge of rendering our "current view of the world." */ class DonateToSignalViewModel( - startType: InAppPaymentType, - private val oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository + startType: InAppPaymentType ) : ViewModel() { companion object { @@ -73,7 +71,7 @@ class DonateToSignalViewModel( val inAppPaymentId: Flowable = _inAppPaymentId.onBackpressureLatest().distinctUntilChanged() init { - initializeOneTimeDonationState(oneTimeInAppPaymentRepository) + initializeOneTimeDonationState(OneTimeInAppPaymentRepository) initializeMonthlyDonationState(RecurringInAppPaymentRepository) networkDisposable += InternetConnectionObserver @@ -97,7 +95,7 @@ class DonateToSignalViewModel( fun retryOneTimeDonationState() { if (!oneTimeDonationDisposables.isDisposed && store.state.oneTimeDonationState.donationStage == DonateToSignalState.DonationStage.FAILURE) { store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(donationStage = DonateToSignalState.DonationStage.INIT)) } - initializeOneTimeDonationState(oneTimeInAppPaymentRepository) + initializeOneTimeDonationState(OneTimeInAppPaymentRepository) } } @@ -428,11 +426,10 @@ class DonateToSignalViewModel( } class Factory( - private val startType: InAppPaymentType, - private val oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository = OneTimeInAppPaymentRepository(AppDependencies.donationsService) + private val startType: InAppPaymentType ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return modelClass.cast(DonateToSignalViewModel(startType, oneTimeInAppPaymentRepository)) as T + return modelClass.cast(DonateToSignalViewModel(startType)) as T } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt index 80d01a6b78..9ca5bb15e7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt @@ -16,6 +16,7 @@ import org.signal.donations.PaymentSourceType import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.requireSubscriberType +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeInAppPaymentRepository import org.thoughtcrime.securesms.components.settings.app.subscription.PayPalRepository import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository @@ -35,8 +36,7 @@ import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentMet import org.whispersystems.signalservice.api.util.Preconditions class PayPalPaymentInProgressViewModel( - private val payPalRepository: PayPalRepository, - private val oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository + private val payPalRepository: PayPalRepository ) : ViewModel() { companion object { @@ -122,6 +122,8 @@ class PayPalPaymentInProgressViewModel( inAppPayment: InAppPaymentTable.InAppPayment, routeToPaypalConfirmation: (PayPalCreatePaymentIntentResponse) -> Single ) { + check(inAppPayment.data.paymentMethodType.toPaymentSourceType() == PaymentSourceType.PayPal) + Log.d(TAG, "Proceeding with one-time payment pipeline...", true) store.update { InAppPaymentProcessorStage.PAYMENT_PIPELINE } val verifyUser = if (inAppPayment.type == InAppPaymentType.ONE_TIME_GIFT) { @@ -148,10 +150,9 @@ class PayPalPaymentInProgressViewModel( ) } .flatMapCompletable { response -> - oneTimeInAppPaymentRepository.waitForOneTimeRedemption( + OneTimeInAppPaymentRepository.waitForOneTimeRedemption( inAppPayment = inAppPayment, - paymentIntentId = response.paymentId, - paymentSourceType = PaymentSourceType.PayPal + paymentIntentId = response.paymentId ) } .subscribeOn(Schedulers.io()) @@ -193,11 +194,10 @@ class PayPalPaymentInProgressViewModel( } class Factory( - private val payPalRepository: PayPalRepository = PayPalRepository(AppDependencies.donationsService), - private val oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository = OneTimeInAppPaymentRepository(AppDependencies.donationsService) + private val payPalRepository: PayPalRepository = PayPalRepository(AppDependencies.donationsService) ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return modelClass.cast(PayPalPaymentInProgressViewModel(payPalRepository, oneTimeInAppPaymentRepository)) as T + return modelClass.cast(PayPalPaymentInProgressViewModel(payPalRepository)) as T } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt index aaf117e8f3..ee1ece7223 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt @@ -32,7 +32,6 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.to import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData -import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.util.rx.RxStore @@ -40,8 +39,7 @@ import org.whispersystems.signalservice.api.util.Preconditions import org.whispersystems.signalservice.internal.push.exceptions.InAppPaymentProcessorError class StripePaymentInProgressViewModel( - private val stripeRepository: StripeRepository, - private val oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository + private val stripeRepository: StripeRepository ) : ViewModel() { companion object { @@ -200,6 +198,8 @@ class StripePaymentInProgressViewModel( paymentSourceProvider: PaymentSourceProvider, nextActionHandler: StripeNextActionHandler ) { + check(inAppPayment.data.paymentMethodType.toPaymentSourceType() == paymentSourceProvider.paymentSourceType) + Log.w(TAG, "Beginning one-time payment pipeline...", true) val amount = inAppPayment.data.amount!!.toFiatMoney() @@ -233,10 +233,9 @@ class StripePaymentInProgressViewModel( .flatMap { stripeRepository.getStatusAndPaymentMethodId(it, action.paymentMethodId) } } .flatMapCompletable { - oneTimeInAppPaymentRepository.waitForOneTimeRedemption( + OneTimeInAppPaymentRepository.waitForOneTimeRedemption( inAppPayment = inAppPayment, - paymentIntentId = paymentIntent.intentId, - paymentSourceType = paymentSource.type + paymentIntentId = paymentIntent.intentId ) } }.subscribeBy( @@ -304,11 +303,10 @@ class StripePaymentInProgressViewModel( } class Factory( - private val stripeRepository: StripeRepository, - private val oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository = OneTimeInAppPaymentRepository(AppDependencies.donationsService) + private val stripeRepository: StripeRepository ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return modelClass.cast(StripePaymentInProgressViewModel(stripeRepository, oneTimeInAppPaymentRepository)) as T + return modelClass.cast(StripePaymentInProgressViewModel(stripeRepository)) as T } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentOneTimeContextJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentOneTimeContextJob.kt index f12220adcd..db6986d031 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentOneTimeContextJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentOneTimeContextJob.kt @@ -12,10 +12,12 @@ import org.signal.donations.InAppPaymentType import org.signal.libsignal.zkgroup.VerificationFailedException import org.signal.libsignal.zkgroup.receipts.ReceiptCredential import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequestContext +import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toDonationProcessor import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.jobmanager.Job @@ -138,7 +140,15 @@ class InAppPaymentOneTimeContextJob private constructor( throw InAppPaymentRetryException(e) } - info("Got presentation. Updating state and completing.") + info("Got presentation. Updating state, recording receipt, and completing.") + val inAppPaymentReceiptRecord = if (inAppPayment.type == InAppPaymentType.ONE_TIME_DONATION) { + InAppPaymentReceiptRecord.createForBoost(inAppPayment.data.amount!!.toFiatMoney()) + } else { + InAppPaymentReceiptRecord.createForGift(inAppPayment.data.amount!!.toFiatMoney()) + } + + SignalDatabase.donationReceipts.addReceipt(inAppPaymentReceiptRecord) + SignalDatabase.inAppPayments.update( inAppPayment.copy( data = inAppPayment.data.copy( diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsTestRule.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsTestRule.kt new file mode 100644 index 0000000000..45acff567d --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsTestRule.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.subscription + +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.unmockkObject +import io.mockk.unmockkStatic +import org.junit.rules.ExternalResource +import org.signal.core.util.money.FiatMoney +import org.signal.donations.InAppPaymentType +import org.signal.donations.PaymentSourceType +import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatValue +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentMethodType +import org.thoughtcrime.securesms.database.InAppPaymentTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.util.RemoteConfig +import org.whispersystems.signalservice.internal.ServiceResponse +import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration +import org.whispersystems.signalservice.internal.util.JsonUtil +import java.math.BigDecimal +import java.util.Currency +import kotlin.time.Duration.Companion.milliseconds + +/** + * Common setup between different tests that rely on donations infrastructure. + */ +class DonationsTestRule : ExternalResource() { + + private val configuration: SubscriptionsConfiguration by lazy { + val testConfigJsonData = javaClass.classLoader!!.getResourceAsStream("donations_configuration_test_data.json").bufferedReader().readText() + + JsonUtil.fromJson(testConfigJsonData, SubscriptionsConfiguration::class.java) + } + + override fun before() { + mockkStatic(RemoteConfig::class) + every { RemoteConfig.init() } just runs + + mockkStatic(InAppPaymentsRepository::class) + mockkObject(InAppPaymentsRepository) + every { InAppPaymentsRepository.scheduleSyncForAccountRecordChange() } returns Unit + + mockkObject(InAppDonations) + every { InAppDonations.isPayPalAvailable() } returns true + every { InAppDonations.isGooglePayAvailable() } returns true + every { InAppDonations.isSEPADebitAvailable() } returns true + every { InAppDonations.isCreditCardAvailable() } returns true + every { InAppDonations.isIDEALAvailable() } returns true + + mockkObject(SignalDatabase.Companion) + every { SignalDatabase.Companion.inAppPayments } returns mockk { + every { SignalDatabase.Companion.inAppPayments.update(any()) } returns Unit + } + } + + override fun after() { + unmockkStatic(RemoteConfig::class, InAppPaymentsRepository::class) + unmockkObject(InAppDonations, SignalDatabase.Companion) + } + + /** + * Because this initialisation requires reading from disk, we only want to do it in the exact tests that actually need it. + */ + fun initializeDonationsConfigurationMock() { + every { AppDependencies.donationsService.getDonationsConfiguration(any()) } returns ServiceResponse(200, "", configuration, null, null) + } + + fun createInAppPayment( + type: InAppPaymentType, + paymentSourceType: PaymentSourceType + ): InAppPaymentTable.InAppPayment { + return InAppPaymentTable.InAppPayment( + id = InAppPaymentTable.InAppPaymentId(1), + state = InAppPaymentTable.State.CREATED, + insertedAt = System.currentTimeMillis().milliseconds, + updatedAt = System.currentTimeMillis().milliseconds, + notified = true, + subscriberId = null, + endOfPeriod = 0.milliseconds, + type = type, + data = InAppPaymentData( + badge = null, + level = 500, + paymentMethodType = paymentSourceType.toPaymentMethodType(), + amount = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD")).toFiatValue() + ) + ) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeInAppPaymentRepositoryTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeInAppPaymentRepositoryTest.kt new file mode 100644 index 0000000000..340240d4b5 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeInAppPaymentRepositoryTest.kt @@ -0,0 +1,277 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription + +import android.app.Application +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.reactivex.rxjava3.core.Flowable +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.donations.InAppPaymentType +import org.signal.donations.PaymentSourceType +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource +import org.thoughtcrime.securesms.database.InAppPaymentTable +import org.thoughtcrime.securesms.database.RecipientTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.DistributionListId +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId +import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule +import org.thoughtcrime.securesms.testutil.RxPluginsRule +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class OneTimeInAppPaymentRepositoryTest { + + @get:Rule + val rxRule = RxPluginsRule() + + @get:Rule + val appDependencies = MockAppDependenciesRule() + + @get:Rule + val donationsTestRule = DonationsTestRule() + + @Test + fun `Given a throwable and self, when I handleCreatePaymentIntentError, then I expect a ONE_TIME error`() { + val throwable = Exception() + val selfId = RecipientId.from(1) + val self = Recipient( + id = selfId, + isSelf = true + ) + + mockkStatic(Recipient::class) + every { Recipient.resolved(selfId) } returns self + + val testObserver = OneTimeInAppPaymentRepository.handleCreatePaymentIntentError(throwable, selfId, PaymentSourceType.Stripe.CreditCard).test() + rxRule.defaultScheduler.triggerActions() + + testObserver.assertError { + it is DonationError && it.source == DonationErrorSource.ONE_TIME + } + } + + @Test + fun `Given a throwable and not self, when I handleCreatePaymentIntentError, then I expect a GIFT error`() { + val throwable = Exception() + val otherId = RecipientId.from(1) + val other = Recipient( + id = otherId, + isSelf = false, + registeredValue = RecipientTable.RegisteredState.REGISTERED + ) + + mockkStatic(Recipient::class) + every { Recipient.resolved(otherId) } returns other + + val testObserver = OneTimeInAppPaymentRepository.handleCreatePaymentIntentError(throwable, otherId, PaymentSourceType.Stripe.CreditCard).test() + rxRule.defaultScheduler.triggerActions() + + testObserver.assertError { + it is DonationError && it.source == DonationErrorSource.GIFT + } + } + + @Test + fun `Given a registered non-self individual, when I verifyRecipientIsAllowedToReceiveAGift, then I expect completion`() { + val recipientId = RecipientId.from(1L) + val recipient = Recipient( + id = recipientId, + isSelf = false, + registeredValue = RecipientTable.RegisteredState.REGISTERED + ) + + mockkStatic(Recipient::class) + every { Recipient.resolved(recipientId) } returns recipient + + val testObserver = OneTimeInAppPaymentRepository.verifyRecipientIsAllowedToReceiveAGift(recipientId).test() + rxRule.defaultScheduler.triggerActions() + + testObserver.assertComplete() + } + + @Test + fun `Given self, when I verifyRecipientIsAllowedToReceiveAGift, then I expect SelectedRecipientIsInvalid`() { + val recipientId = RecipientId.from(1L) + val recipient = Recipient( + id = recipientId, + isSelf = true + ) + + verifyRecipientIsNotAllowedToBeGiftedBadges(recipient) + } + + @Test + fun `Given an unregistered individual, when I verifyRecipientIsAllowedToReceiveAGift, then I expect SelectedRecipientIsInvalid`() { + val recipientId = RecipientId.from(1L) + val recipient = Recipient( + id = recipientId, + isSelf = false, + registeredValue = RecipientTable.RegisteredState.NOT_REGISTERED + ) + + verifyRecipientIsNotAllowedToBeGiftedBadges(recipient) + } + + @Test + fun `Given a group, when I verifyRecipientIsAllowedToReceiveAGift, then I expect SelectedRecipientIsInvalid`() { + val recipientId = RecipientId.from(1L) + val recipient = Recipient( + id = recipientId, + isSelf = false, + registeredValue = RecipientTable.RegisteredState.REGISTERED, + groupIdValue = mockk() + ) + + verifyRecipientIsNotAllowedToBeGiftedBadges(recipient) + } + + @Test + fun `Given a call link, when I verifyRecipientIsAllowedToReceiveAGift, then I expect SelectedRecipientIsInvalid`() { + val recipientId = RecipientId.from(1L) + val recipient = Recipient( + id = recipientId, + isSelf = false, + registeredValue = RecipientTable.RegisteredState.REGISTERED, + callLinkRoomId = CallLinkRoomId.fromBytes(byteArrayOf()) + ) + + verifyRecipientIsNotAllowedToBeGiftedBadges(recipient) + } + + @Test + fun `Given a distribution list, when I verifyRecipientIsAllowedToReceiveAGift, then I expect SelectedRecipientIsInvalid`() { + val recipientId = RecipientId.from(1L) + val recipient = Recipient( + id = recipientId, + isSelf = false, + registeredValue = RecipientTable.RegisteredState.REGISTERED, + distributionListIdValue = DistributionListId.from(1L) + ) + + verifyRecipientIsNotAllowedToBeGiftedBadges(recipient) + } + + @Test + fun `Given release notes, when I verifyRecipientIsAllowedToReceiveAGift, then I expect SelectedRecipientIsInvalid`() { + val recipientId = RecipientId.from(1L) + val recipient = Recipient( + id = recipientId, + isSelf = false, + registeredValue = RecipientTable.RegisteredState.REGISTERED, + isReleaseNotes = true + ) + + verifyRecipientIsNotAllowedToBeGiftedBadges(recipient) + } + + @Test + fun `When I getBoosts, then I expect a filtered set of boost objects`() { + donationsTestRule.initializeDonationsConfigurationMock() + + val testObserver = OneTimeInAppPaymentRepository.getBoosts().test() + rxRule.defaultScheduler.triggerActions() + + testObserver + .assertValue { + it.size == 3 + } + .assertComplete() + } + + @Test + fun `When I getBoostBadge, then I expect a boost badge`() { + donationsTestRule.initializeDonationsConfigurationMock() + + val testObserver = OneTimeInAppPaymentRepository.getBoostBadge().test() + rxRule.defaultScheduler.triggerActions() + + testObserver + .assertValue { it.isBoost() } + .assertComplete() + } + + @Test + fun `When I getMinimumDonationAmounts, then I expect a map of 3 currencies`() { + donationsTestRule.initializeDonationsConfigurationMock() + + val testObserver = OneTimeInAppPaymentRepository.getMinimumDonationAmounts().test() + rxRule.defaultScheduler.triggerActions() + + testObserver + .assertValue { it.size == 3 } + .assertComplete() + } + + @Test + fun `Given a long running transaction, when I waitForOneTimeRedemption, then I expect DonationPending`() { + val inAppPayment = donationsTestRule.createInAppPayment(InAppPaymentType.ONE_TIME_DONATION, PaymentSourceType.Stripe.SEPADebit) + + every { SignalDatabase.Companion.inAppPayments.getById(inAppPayment.id) } returns inAppPayment + + val testObserver = OneTimeInAppPaymentRepository + .waitForOneTimeRedemption(inAppPayment, "test-intent-id") + .test() + + rxRule.defaultScheduler.triggerActions() + rxRule.defaultScheduler.advanceTimeBy(10, TimeUnit.SECONDS) + rxRule.defaultScheduler.triggerActions() + + testObserver + .assertError { it is DonationError.BadgeRedemptionError.DonationPending } + } + + @Test + fun `Given a non long running transaction, when I waitForOneTimeRedemption, then I expect TimeoutWaitingForTokenError`() { + val inAppPayment = donationsTestRule.createInAppPayment(InAppPaymentType.ONE_TIME_DONATION, PaymentSourceType.Stripe.CreditCard) + + every { SignalDatabase.Companion.inAppPayments.getById(inAppPayment.id) } returns inAppPayment + + val testObserver = OneTimeInAppPaymentRepository + .waitForOneTimeRedemption(inAppPayment, "test-intent-id") + .test() + + rxRule.defaultScheduler.triggerActions() + rxRule.defaultScheduler.advanceTimeBy(10, TimeUnit.SECONDS) + rxRule.defaultScheduler.triggerActions() + + testObserver + .assertError { it is DonationError.BadgeRedemptionError.TimeoutWaitingForTokenError } + } + + @Test + fun `Given no delays, when I waitForOneTimeRedemption, then I expect happy path`() { + val inAppPayment = donationsTestRule.createInAppPayment(InAppPaymentType.ONE_TIME_DONATION, PaymentSourceType.Stripe.CreditCard) + + every { InAppPaymentsRepository.observeUpdates(inAppPayment.id) } returns Flowable.just(inAppPayment.copy(state = InAppPaymentTable.State.END)) + every { SignalDatabase.Companion.inAppPayments.getById(inAppPayment.id) } returns inAppPayment + + val testObserver = OneTimeInAppPaymentRepository + .waitForOneTimeRedemption(inAppPayment, "test-intent-id") + .test() + + rxRule.defaultScheduler.triggerActions() + + testObserver + .assertComplete() + } + + private fun verifyRecipientIsNotAllowedToBeGiftedBadges(recipient: Recipient) { + mockkStatic(Recipient::class) + every { Recipient.resolved(recipient.id) } returns recipient + + val testObserver = OneTimeInAppPaymentRepository.verifyRecipientIsAllowedToReceiveAGift(recipient.id).test() + rxRule.defaultScheduler.triggerActions() + + testObserver.assertError { + it is DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid + } + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepositoryTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepositoryTest.kt index 1eb069b9fb..69646d01b7 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepositoryTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepositoryTest.kt @@ -3,11 +3,9 @@ package org.thoughtcrime.securesms.components.settings.app.subscription import android.app.Application import androidx.lifecycle.AtomicReference import io.mockk.every -import io.mockk.just import io.mockk.mockk import io.mockk.mockkObject import io.mockk.mockkStatic -import io.mockk.runs import io.mockk.unmockkAll import io.mockk.verify import io.reactivex.rxjava3.core.Flowable @@ -21,7 +19,6 @@ import org.robolectric.annotation.Config import org.signal.donations.InAppPaymentType import org.signal.donations.PaymentSourceType import org.thoughtcrime.securesms.assertIsNot -import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentMethodType import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.database.SignalDatabase @@ -35,50 +32,29 @@ import org.thoughtcrime.securesms.subscription.LevelUpdate import org.thoughtcrime.securesms.subscription.LevelUpdateOperation import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule import org.thoughtcrime.securesms.testutil.RxPluginsRule -import org.thoughtcrime.securesms.util.RemoteConfig import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey import org.whispersystems.signalservice.api.subscriptions.SubscriberId import org.whispersystems.signalservice.internal.EmptyResponse import org.whispersystems.signalservice.internal.ServiceResponse -import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration -import org.whispersystems.signalservice.internal.util.JsonUtil import java.util.Currency import java.util.concurrent.TimeUnit -import kotlin.time.Duration.Companion.milliseconds @RunWith(RobolectricTestRunner::class) @Config(manifest = Config.NONE, application = Application::class) class RecurringInAppPaymentRepositoryTest { - private val testConfigData: SubscriptionsConfiguration by lazy { - val testConfigJsonData = javaClass.classLoader!!.getResourceAsStream("donations_configuration_test_data.json").bufferedReader().readText() - - JsonUtil.fromJson(testConfigJsonData, SubscriptionsConfiguration::class.java) - } - @get:Rule val rxRule = RxPluginsRule() @get:Rule val appDependencies = MockAppDependenciesRule() + @get:Rule + val donationsTestRule = DonationsTestRule() + @Before fun setUp() { - mockkStatic(RemoteConfig::class) - every { RemoteConfig.init() } just runs - - mockkObject(InAppDonations) - every { InAppDonations.isPayPalAvailable() } returns true - every { InAppDonations.isGooglePayAvailable() } returns true - every { InAppDonations.isSEPADebitAvailable() } returns true - every { InAppDonations.isCreditCardAvailable() } returns true - every { InAppDonations.isIDEALAvailable() } returns true - - mockkStatic(InAppPaymentsRepository::class) - mockkObject(InAppPaymentsRepository) - every { InAppPaymentsRepository.scheduleSyncForAccountRecordChange() } returns Unit - mockkObject(SignalStore.Companion) every { SignalStore.Companion.inAppPayments } returns mockk { every { SignalStore.Companion.inAppPayments.getRecurringDonationCurrency() } returns Currency.getInstance("USD") @@ -86,15 +62,10 @@ class RecurringInAppPaymentRepositoryTest { every { SignalStore.Companion.inAppPayments.updateLocalStateForLocalSubscribe(any()) } returns Unit } - mockkObject(SignalDatabase.Companion) every { SignalDatabase.Companion.recipients } returns mockk { every { SignalDatabase.Companion.recipients.markNeedsSync(any()) } returns Unit } - every { SignalDatabase.Companion.inAppPayments } returns mockk { - every { SignalDatabase.Companion.inAppPayments.update(any()) } returns Unit - } - mockkStatic(StorageSyncHelper::class) every { StorageSyncHelper.scheduleSyncForDataChange() } returns Unit @@ -109,7 +80,7 @@ class RecurringInAppPaymentRepositoryTest { @Test fun `when I getDonationsConfiguration then I expect a set of three Subscription objects`() { - every { AppDependencies.donationsService.getDonationsConfiguration(any()) } returns ServiceResponse(200, "", testConfigData, null, null) + donationsTestRule.initializeDonationsConfigurationMock() val testObserver = RecurringInAppPaymentRepository.getSubscriptions().test() rxRule.defaultScheduler.triggerActions() @@ -190,7 +161,7 @@ class RecurringInAppPaymentRepositoryTest { @Test fun `given no delays, when I setSubscriptionLevel, then I expect happy path`() { val paymentSourceType = PaymentSourceType.Stripe.CreditCard - val inAppPayment = createInAppPayment(paymentSourceType) + val inAppPayment = donationsTestRule.createInAppPayment(InAppPaymentType.RECURRING_DONATION, paymentSourceType) mockLocalSubscriberAccess(createSubscriber()) every { SignalStore.inAppPayments.getLevelOperation("500") } returns LevelUpdateOperation(IdempotencyKey.generate(), "500") @@ -210,7 +181,7 @@ class RecurringInAppPaymentRepositoryTest { @Test fun `given 10s delay, when I setSubscriptionLevel, then I expect timeout`() { val paymentSourceType = PaymentSourceType.Stripe.CreditCard - val inAppPayment = createInAppPayment(paymentSourceType) + val inAppPayment = donationsTestRule.createInAppPayment(InAppPaymentType.RECURRING_DONATION, paymentSourceType) mockLocalSubscriberAccess(createSubscriber()) every { SignalStore.inAppPayments.getLevelOperation("500") } returns LevelUpdateOperation(IdempotencyKey.generate(), "500") @@ -234,7 +205,7 @@ class RecurringInAppPaymentRepositoryTest { @Test fun `given long running payment type with 10s delay, when I setSubscriptionLevel, then I expect pending`() { val paymentSourceType = PaymentSourceType.Stripe.SEPADebit - val inAppPayment = createInAppPayment(paymentSourceType) + val inAppPayment = donationsTestRule.createInAppPayment(InAppPaymentType.RECURRING_DONATION, paymentSourceType) mockLocalSubscriberAccess(createSubscriber()) every { SignalStore.inAppPayments.getLevelOperation("500") } returns LevelUpdateOperation(IdempotencyKey.generate(), "500") @@ -259,7 +230,7 @@ class RecurringInAppPaymentRepositoryTest { fun `given an execution error, when I setSubscriptionLevel, then I expect the same error`() { val expected = NonSuccessfulResponseCodeException(404) val paymentSourceType = PaymentSourceType.Stripe.SEPADebit - val inAppPayment = createInAppPayment(paymentSourceType) + val inAppPayment = donationsTestRule.createInAppPayment(InAppPaymentType.RECURRING_DONATION, paymentSourceType) mockLocalSubscriberAccess(createSubscriber()) every { SignalStore.inAppPayments.getLevelOperation("500") } returns LevelUpdateOperation(IdempotencyKey.generate(), "500") @@ -290,26 +261,6 @@ class RecurringInAppPaymentRepositoryTest { ) } - private fun createInAppPayment( - paymentSourceType: PaymentSourceType - ): InAppPaymentTable.InAppPayment { - return InAppPaymentTable.InAppPayment( - id = InAppPaymentTable.InAppPaymentId(1), - state = InAppPaymentTable.State.CREATED, - insertedAt = System.currentTimeMillis().milliseconds, - updatedAt = System.currentTimeMillis().milliseconds, - notified = true, - subscriberId = null, - endOfPeriod = 0.milliseconds, - type = InAppPaymentType.RECURRING_DONATION, - data = InAppPaymentData( - badge = null, - level = 500, - paymentMethodType = paymentSourceType.toPaymentMethodType() - ) - ) - } - private fun mockLocalSubscriberAccess(initialSubscriber: InAppPaymentSubscriberRecord? = null): AtomicReference { val ref = AtomicReference(initialSubscriber) every { InAppPaymentsRepository.getSubscriber(any()) } answers { ref.get() }