Isolated tests for OneTimeInAppPaymentRepository.

This commit is contained in:
Alex Hart
2024-12-20 10:15:01 -04:00
committed by Greyson Parrelli
parent e650223487
commit c31780050f
8 changed files with 460 additions and 130 deletions

View File

@@ -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()
)
)
}
}

View File

@@ -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<Unit>(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<Unit>(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
}
}
}

View File

@@ -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<RecipientId>()) } 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<InAppPaymentSubscriberRecord?> {
val ref = AtomicReference(initialSubscriber)
every { InAppPaymentsRepository.getSubscriber(any()) } answers { ref.get() }