Isolated tests for RecurringInAppPaymentRepository.
This commit is contained in:
parent
68d4eafedd
commit
8aee19b3dd
3 changed files with 391 additions and 24 deletions
|
@ -110,20 +110,22 @@ object RecurringInAppPaymentRepository {
|
|||
}
|
||||
|
||||
fun ensureSubscriberId(subscriberType: InAppPaymentSubscriberRecord.Type, isRotation: Boolean = false): Completable {
|
||||
return Single.fromCallable {
|
||||
Log.d(TAG, "Ensuring SubscriberId for type $subscriberType exists on Signal service {isRotation?$isRotation}...", true)
|
||||
val subscriberId: SubscriberId = if (isRotation) {
|
||||
|
||||
if (isRotation) {
|
||||
SubscriberId.generate()
|
||||
} else {
|
||||
InAppPaymentsRepository.getSubscriber(subscriberType)?.subscriberId ?: SubscriberId.generate()
|
||||
}
|
||||
|
||||
return Single
|
||||
}.flatMap { subscriberId ->
|
||||
Single
|
||||
.fromCallable {
|
||||
donationsService.putSubscription(subscriberId)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement()
|
||||
.doOnComplete {
|
||||
.flatMap(ServiceResponse<EmptyResponse>::flattenResult)
|
||||
.map { subscriberId }
|
||||
}.doOnSuccess { subscriberId ->
|
||||
Log.d(TAG, "Successfully set SubscriberId exists on Signal service.", true)
|
||||
|
||||
InAppPaymentsRepository.setSubscriber(
|
||||
|
@ -138,7 +140,7 @@ object RecurringInAppPaymentRepository {
|
|||
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}
|
||||
}.ignoreElement().subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun cancelActiveSubscriptionSync(subscriberType: InAppPaymentSubscriberRecord.Type) {
|
||||
|
|
|
@ -0,0 +1,319 @@
|
|||
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
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
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.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
|
||||
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.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
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()
|
||||
|
||||
@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.getSubscriptionCurrency(any()) } returns Currency.getInstance("USD")
|
||||
every { SignalStore.Companion.inAppPayments.updateLocalStateForManualCancellation(any()) } returns Unit
|
||||
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
|
||||
|
||||
every { AppDependencies.donationsService.putSubscription(any()) } returns ServiceResponse.forResult(EmptyResponse.INSTANCE, 200, "")
|
||||
every { AppDependencies.donationsService.updateSubscriptionLevel(any(), any(), any(), any(), any()) } returns ServiceResponse.forResult(EmptyResponse.INSTANCE, 200, "")
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
@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)
|
||||
|
||||
val testObserver = RecurringInAppPaymentRepository.getSubscriptions().test()
|
||||
rxRule.defaultScheduler.triggerActions()
|
||||
|
||||
testObserver
|
||||
.assertComplete()
|
||||
.assertValueCount(1)
|
||||
.assertValue { it.size == 3 }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given I do not need to rotate my subscriber id, when I ensureSubscriberId, then I use the same subscriber id`() {
|
||||
val initialSubscriber = createSubscriber()
|
||||
val ref = mockLocalSubscriberAccess(initialSubscriber)
|
||||
|
||||
val testObserver = RecurringInAppPaymentRepository.ensureSubscriberId(
|
||||
subscriberType = InAppPaymentSubscriberRecord.Type.DONATION,
|
||||
isRotation = false
|
||||
).test()
|
||||
|
||||
rxRule.defaultScheduler.triggerActions()
|
||||
testObserver.assertComplete()
|
||||
|
||||
val newSubscriber = ref.get()
|
||||
|
||||
newSubscriber assertIsNot initialSubscriber
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given I need to rotate my subscriber id, when I ensureSubscriberId, then I generate and set a new subscriber id`() {
|
||||
val initialSubscriber = createSubscriber()
|
||||
val ref = mockLocalSubscriberAccess(initialSubscriber)
|
||||
|
||||
val testObserver = RecurringInAppPaymentRepository.ensureSubscriberId(
|
||||
subscriberType = InAppPaymentSubscriberRecord.Type.DONATION,
|
||||
isRotation = true
|
||||
).test()
|
||||
|
||||
rxRule.defaultScheduler.triggerActions()
|
||||
testObserver.assertComplete()
|
||||
|
||||
val newSubscriber = ref.get()
|
||||
|
||||
newSubscriber assertIsNot initialSubscriber
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given no current subscriber, when I rotateSubscriberId, then I do not try to cancel subscription`() {
|
||||
val ref = mockLocalSubscriberAccess()
|
||||
|
||||
val testObserver = RecurringInAppPaymentRepository.rotateSubscriberId(InAppPaymentSubscriberRecord.Type.DONATION).test()
|
||||
|
||||
rxRule.defaultScheduler.triggerActions()
|
||||
testObserver.assertComplete()
|
||||
|
||||
ref.get() assertIsNot null
|
||||
verify(inverse = true) {
|
||||
AppDependencies.donationsService.cancelSubscription(any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given current subscriber, when I rotateSubscriberId, then I do not try to cancel subscription`() {
|
||||
val initialSubscriber = createSubscriber()
|
||||
val ref = mockLocalSubscriberAccess(initialSubscriber)
|
||||
|
||||
val testObserver = RecurringInAppPaymentRepository.rotateSubscriberId(InAppPaymentSubscriberRecord.Type.DONATION).test()
|
||||
|
||||
rxRule.defaultScheduler.triggerActions()
|
||||
testObserver.assertComplete()
|
||||
|
||||
ref.get() assertIsNot null
|
||||
verify {
|
||||
AppDependencies.donationsService.cancelSubscription(any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given no delays, when I setSubscriptionLevel, then I expect happy path`() {
|
||||
val paymentSourceType = PaymentSourceType.Stripe.CreditCard
|
||||
val inAppPayment = createInAppPayment(paymentSourceType)
|
||||
mockLocalSubscriberAccess(createSubscriber())
|
||||
|
||||
every { SignalStore.inAppPayments.getLevelOperation("500") } returns LevelUpdateOperation(IdempotencyKey.generate(), "500")
|
||||
every { SignalDatabase.inAppPayments.getById(any()) } returns inAppPayment
|
||||
every { InAppPaymentsRepository.observeUpdates(inAppPayment.id) } returns Flowable.just(inAppPayment.copy(state = InAppPaymentTable.State.END))
|
||||
|
||||
val testObserver = RecurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, paymentSourceType).test()
|
||||
val processingUpdate = LevelUpdate.isProcessing.test()
|
||||
|
||||
rxRule.defaultScheduler.triggerActions()
|
||||
|
||||
processingUpdate.assertValues(false, true, false)
|
||||
|
||||
testObserver.assertComplete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given 10s delay, when I setSubscriptionLevel, then I expect timeout`() {
|
||||
val paymentSourceType = PaymentSourceType.Stripe.CreditCard
|
||||
val inAppPayment = createInAppPayment(paymentSourceType)
|
||||
mockLocalSubscriberAccess(createSubscriber())
|
||||
|
||||
every { SignalStore.inAppPayments.getLevelOperation("500") } returns LevelUpdateOperation(IdempotencyKey.generate(), "500")
|
||||
every { SignalDatabase.inAppPayments.getById(any()) } returns inAppPayment
|
||||
|
||||
val testObserver = RecurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, paymentSourceType).test()
|
||||
val processingUpdate = LevelUpdate.isProcessing.test()
|
||||
|
||||
rxRule.defaultScheduler.triggerActions()
|
||||
|
||||
processingUpdate.assertValues(false, true, false)
|
||||
|
||||
rxRule.defaultScheduler.advanceTimeBy(10, TimeUnit.SECONDS)
|
||||
rxRule.defaultScheduler.triggerActions()
|
||||
|
||||
testObserver.assertError {
|
||||
it is DonationError.BadgeRedemptionError.TimeoutWaitingForTokenError
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
mockLocalSubscriberAccess(createSubscriber())
|
||||
|
||||
every { SignalStore.inAppPayments.getLevelOperation("500") } returns LevelUpdateOperation(IdempotencyKey.generate(), "500")
|
||||
every { SignalDatabase.inAppPayments.getById(any()) } returns inAppPayment
|
||||
|
||||
val testObserver = RecurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, paymentSourceType).test()
|
||||
val processingUpdate = LevelUpdate.isProcessing.test()
|
||||
|
||||
rxRule.defaultScheduler.triggerActions()
|
||||
|
||||
processingUpdate.assertValues(false, true, false)
|
||||
|
||||
rxRule.defaultScheduler.advanceTimeBy(10, TimeUnit.SECONDS)
|
||||
rxRule.defaultScheduler.triggerActions()
|
||||
|
||||
testObserver.assertError {
|
||||
it is DonationError.BadgeRedemptionError.DonationPending
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
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)
|
||||
mockLocalSubscriberAccess(createSubscriber())
|
||||
|
||||
every { SignalStore.inAppPayments.getLevelOperation("500") } returns LevelUpdateOperation(IdempotencyKey.generate(), "500")
|
||||
every { SignalDatabase.inAppPayments.getById(any()) } returns inAppPayment
|
||||
every { AppDependencies.donationsService.updateSubscriptionLevel(any(), any(), any(), any(), any()) } returns ServiceResponse.forExecutionError(expected)
|
||||
|
||||
val testObserver = RecurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, paymentSourceType).test()
|
||||
val processingUpdate = LevelUpdate.isProcessing.distinctUntilChanged().test()
|
||||
|
||||
rxRule.defaultScheduler.triggerActions()
|
||||
|
||||
processingUpdate.assertValues(false, true, false)
|
||||
|
||||
rxRule.defaultScheduler.advanceTimeBy(10, TimeUnit.SECONDS)
|
||||
rxRule.defaultScheduler.triggerActions()
|
||||
|
||||
testObserver.assertError(expected)
|
||||
}
|
||||
|
||||
private fun createSubscriber(): InAppPaymentSubscriberRecord {
|
||||
return InAppPaymentSubscriberRecord(
|
||||
subscriberId = SubscriberId.generate(),
|
||||
currency = Currency.getInstance("USD"),
|
||||
type = InAppPaymentSubscriberRecord.Type.DONATION,
|
||||
requiresCancel = false,
|
||||
paymentMethodType = InAppPaymentData.PaymentMethodType.CARD
|
||||
)
|
||||
}
|
||||
|
||||
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() }
|
||||
every { InAppPaymentsRepository.setSubscriber(any()) } answers { ref.set(firstArg()) }
|
||||
|
||||
return ref
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.testutil
|
||||
|
||||
import io.reactivex.rxjava3.android.plugins.RxAndroidPlugins
|
||||
import io.reactivex.rxjava3.plugins.RxJavaPlugins
|
||||
import io.reactivex.rxjava3.schedulers.TestScheduler
|
||||
import org.junit.rules.ExternalResource
|
||||
|
||||
/**
|
||||
* Sets up RxJava / RxAndroid scheduler overrides.
|
||||
*/
|
||||
class RxPluginsRule(
|
||||
val defaultScheduler: TestScheduler = TestScheduler(),
|
||||
val computationScheduler: TestScheduler = defaultScheduler,
|
||||
val ioScheduler: TestScheduler = defaultScheduler,
|
||||
val singleScheduler: TestScheduler = defaultScheduler,
|
||||
val newThreadScheduler: TestScheduler = defaultScheduler,
|
||||
val mainThreadScheduler: TestScheduler = defaultScheduler
|
||||
) : ExternalResource() {
|
||||
|
||||
override fun before() {
|
||||
RxJavaPlugins.setInitComputationSchedulerHandler { computationScheduler }
|
||||
RxJavaPlugins.setComputationSchedulerHandler { computationScheduler }
|
||||
|
||||
RxJavaPlugins.setInitIoSchedulerHandler { ioScheduler }
|
||||
RxJavaPlugins.setIoSchedulerHandler { ioScheduler }
|
||||
|
||||
RxJavaPlugins.setInitSingleSchedulerHandler { singleScheduler }
|
||||
RxJavaPlugins.setSingleSchedulerHandler { singleScheduler }
|
||||
|
||||
RxJavaPlugins.setInitNewThreadSchedulerHandler { newThreadScheduler }
|
||||
RxJavaPlugins.setNewThreadSchedulerHandler { newThreadScheduler }
|
||||
|
||||
RxAndroidPlugins.setInitMainThreadSchedulerHandler { mainThreadScheduler }
|
||||
RxAndroidPlugins.setMainThreadSchedulerHandler { mainThreadScheduler }
|
||||
}
|
||||
|
||||
override fun after() {
|
||||
RxJavaPlugins.reset()
|
||||
RxAndroidPlugins.reset()
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue