diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivityTest__OneTimeDonations.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivityTest__OneTimeDonations.kt new file mode 100644 index 0000000000..92ed103c73 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivityTest__OneTimeDonations.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.subscription.donate + +import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.isClickable +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.hamcrest.Matchers.allOf +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.signal.donations.InAppPaymentType +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.testing.InAppPaymentsRule +import org.thoughtcrime.securesms.testing.RxTestSchedulerRule +import org.thoughtcrime.securesms.testing.SignalActivityRule +import org.thoughtcrime.securesms.testing.actions.RecyclerViewScrollToBottomAction + +@Suppress("ClassName") +@RunWith(AndroidJUnit4::class) +class CheckoutFlowActivityTest__OneTimeDonations { + @get:Rule + val harness = SignalActivityRule(othersCount = 10) + + @get:Rule + val iapRule = InAppPaymentsRule() + + @get:Rule + val rxRule = RxTestSchedulerRule() + + @get:Rule + val googlePayRule = GooglePayTestRule() + + @get:Rule + val composeRule = createEmptyComposeRule() + + private val intent = CheckoutFlowActivity.createIntent(InstrumentationRegistry.getInstrumentation().targetContext, InAppPaymentType.ONE_TIME_DONATION) + + @Before + fun setUp() { + startJobLoopForTests() + } + + @Test + fun givenPermitAcquisitionFails_whenIDonateOnce_thenIExpectPaymentSetupErrorDialog() { + failDonationPermitAcquisition() + + val scenario = ActivityScenario.launch(intent) + rxRule.defaultTestScheduler.triggerActions() + + onView(allOf(withId(R.id.boost_1), isClickable())).perform(ViewActions.click()) + rxRule.defaultTestScheduler.triggerActions() + + onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction) + onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click()) + rxRule.defaultTestScheduler.triggerActions() + + scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.ONE_TIME_DONATION) + + awaitDonationErrorDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment) + onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed())) + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivityTest__RecurringDonations.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivityTest__RecurringDonations.kt index 2e8f34d734..b16e2dee98 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivityTest__RecurringDonations.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivityTest__RecurringDonations.kt @@ -1,9 +1,11 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate +import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.RootMatchers.isDialog import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isNotEnabled import androidx.test.espresso.matcher.ViewMatchers.isSelected @@ -12,13 +14,20 @@ import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import org.junit.After +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.signal.donations.InAppPaymentType +import org.signal.libsignal.net.RequestResult import org.signal.network.NetworkResult +import org.signal.network.exceptions.NonSuccessfulResponseCodeException import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData import org.thoughtcrime.securesms.dependencies.AppDependencies @@ -46,8 +55,24 @@ class CheckoutFlowActivityTest__RecurringDonations { @get:Rule val rxRule = RxTestSchedulerRule() + @get:Rule + val googlePayRule = GooglePayTestRule() + + @get:Rule + val composeRule = createEmptyComposeRule() + private val intent = CheckoutFlowActivity.createIntent(InstrumentationRegistry.getInstrumentation().targetContext, InAppPaymentType.RECURRING_DONATION) + @Before + fun setUp() { + startJobLoopForTests() + } + + @After + fun tearDown() { + unmockkObject(DonationPermits) + } + @Test fun givenRecurringDonations_whenILoadScreen_thenIExpectMonthlySelected() { ActivityScenario.launch(intent) @@ -102,6 +127,43 @@ class CheckoutFlowActivityTest__RecurringDonations { onView(withText(R.string.SubscribeFragment__update_subscription)).check(matches(isNotEnabled())) } + @Test + fun givenSubscriberPermitAcquisitionFails_whenISubscribe_thenIExpectPaymentSetupErrorDialog() { + failDonationPermitAcquisition() + + val scenario = ActivityScenario.launch(intent) + rxRule.defaultTestScheduler.triggerActions() + + onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction) + onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click()) + rxRule.defaultTestScheduler.triggerActions() + + scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.RECURRING_DONATION) + + awaitDonationErrorDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment) + onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed())) + } + + @Test + fun givenSubscriptionPaymentMethodPermitRejected_whenISubscribe_thenIExpectPaymentSetupErrorDialog() { + AppDependencies.donationPermitsRepository.clearPermits() + mockkObject(DonationPermits) + every { DonationPermits.getDonationPermit() } returns RequestResult.Success("permit") + every { AppDependencies.donationsApi.createStripeSubscriptionPaymentMethod(any(), any(), any()) } returns NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(402)) + + val scenario = ActivityScenario.launch(intent) + rxRule.defaultTestScheduler.triggerActions() + + onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction) + onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click()) + rxRule.defaultTestScheduler.triggerActions() + + scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.RECURRING_DONATION) + + awaitDonationErrorDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment) + onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed())) + } + private fun initialiseActiveSubscription() { val currency = Currency.getInstance("USD") val subscriber = InAppPaymentSubscriberRecord( diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowPermitTestSupport.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowPermitTestSupport.kt new file mode 100644 index 0000000000..2e4056f489 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowPermitTestSupport.kt @@ -0,0 +1,159 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.subscription.donate + +import android.app.Activity +import android.content.Intent +import android.os.SystemClock +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.platform.app.InstrumentationRegistry +import com.google.android.gms.wallet.PaymentData +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.unmockkConstructor +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.schedulers.TestScheduler +import org.junit.rules.ExternalResource +import org.signal.donations.GooglePayApi +import org.signal.donations.InAppPaymentType +import org.signal.libsignal.net.RequestResult +import org.signal.network.rest.RestStatusCodeError +import org.thoughtcrime.securesms.SignalInstrumentationApplicationContext +import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorTestTags +import org.thoughtcrime.securesms.dependencies.AppDependencies + +/** + * Makes Google Pay appear available and return a fake [com.google.android.gms.wallet.PaymentData] without + * launching the real Google Pay sheet, allowing checkout to be driven to the payment pipeline in instrumentation. + */ +class GooglePayTestRule : ExternalResource() { + override fun before() { + val paymentData = mockk { + every { toJson() } returns GOOGLE_PAY_PAYMENT_DATA_JSON + } + + mockkConstructor(GooglePayApi::class) + every { anyConstructed().queryIsReadyToPay() } returns Completable.complete() + every { anyConstructed().requestPayment(any(), any(), any()) } just Runs + every { anyConstructed().onActivityResult(any(), any(), any(), any(), any()) } answers { + arg(4).onSuccess(paymentData) + } + } + + override fun after() { + unmockkConstructor(GooglePayApi::class) + } +} + +/** + * Minimal but well-formed Google Pay payload. [org.signal.donations.GooglePayPaymentSource] parses + * `paymentMethodData.tokenizationData.token` (itself a JSON object with an `id`) when the source is + * serialized into the setup job, so a relaxed mock that returns an empty body fails before the job runs. + */ +private const val GOOGLE_PAY_PAYMENT_DATA_JSON = """{"paymentMethodData":{"tokenizationData":{"token":"{\"id\":\"tok_test\"}"}},"email":"test@signal.org"}""" + +/** + * Forces real donation-permit acquisition to fail at the issuer, exercising the permit code path through + * [org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits]. + */ +fun failDonationPermitAcquisition(statusCode: Int = 500) { + AppDependencies.donationPermitsRepository.clearPermits() + every { AppDependencies.donationsApi.createDonationPermits(any()) } returns RequestResult.NonSuccess(RestStatusCodeError(statusCode, emptyMap(), null)) +} + +/** + * The instrumentation app stubs [org.thoughtcrime.securesms.ApplicationContext.beginJobLoop] to a no-op, so enqueued + * jobs never run. The checkout pipeline drives a real setup job through the JobManager, so the loop must be started. + */ +fun startJobLoopForTests() { + (AppDependencies.application as SignalInstrumentationApplicationContext).beginJobLoopForTests() +} + +/** + * Selects Google Pay in the Compose gateway selector and feeds the stubbed Google Pay result back into the + * checkout, navigating to the payment-in-progress screen where the pipeline runs. + */ +fun ActivityScenario.selectGooglePay( + composeRule: ComposeTestRule, + scheduler: TestScheduler, + inAppPaymentType: InAppPaymentType +) { + val deadline = SystemClock.uptimeMillis() + 10_000 + var present = false + while (SystemClock.uptimeMillis() < deadline && !present) { + scheduler.triggerActions() + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + present = try { + composeRule.onAllNodesWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).fetchSemanticsNodes().isNotEmpty() + } catch (e: IllegalStateException) { + false + } + if (!present) { + Thread.sleep(100) + } + } + check(present) { "Google Pay button never appeared in the gateway selector." } + + composeRule.onNodeWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).performClick() + pump(scheduler, iterations = 20) + + onActivity { activity -> + (activity as GooglePayComponent).googlePayResultPublisher.onNext( + GooglePayComponent.GooglePayResult( + requestCode = InAppPaymentsRepository.getGooglePayRequestCode(inAppPaymentType), + resultCode = Activity.RESULT_OK, + data = Intent() + ) + ) + } +} + +/** + * Advances the Rx [scheduler] and pumps the main looper until the checkout error dialog with [titleResId] is + * displayed, bridging the real JobManager-backed setup job, the Rx observers, and dialog rendering. + */ +fun awaitDonationErrorDialog(scheduler: TestScheduler, titleResId: Int, timeoutMillis: Long = 15_000) { + val deadline = SystemClock.uptimeMillis() + timeoutMillis + var lastFailure: Throwable? = null + + while (SystemClock.uptimeMillis() < deadline) { + scheduler.triggerActions() + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + + try { + onView(withText(titleResId)).inRoot(isDialog()).check(matches(isDisplayed())) + return + } catch (t: Throwable) { + lastFailure = t + } + + Thread.sleep(100) + } + + throw AssertionError("Donation error dialog ($titleResId) was not displayed within ${timeoutMillis}ms.", lastFailure) +} + +private fun pump(scheduler: TestScheduler, iterations: Int, intervalMs: Long = 100) { + repeat(iterations) { + scheduler.triggerActions() + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + Thread.sleep(intervalMs) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentStripeRecurringSetupJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentStripeRecurringSetupJob.kt index d15c6c487e..a753c3a1cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentStripeRecurringSetupJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentStripeRecurringSetupJob.kt @@ -13,6 +13,7 @@ import org.signal.donations.StripeApi import org.signal.donations.StripeIntentAccessor 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.toErrorSource import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository @@ -58,14 +59,20 @@ class InAppPaymentStripeRecurringSetupJob private constructor( } override fun performPreUserAction(inAppPayment: InAppPaymentTable.InAppPayment): RequiredUserAction { - info("Ensuring the subscriber id is set on the server.") - RecurringInAppPaymentRepository.ensureSubscriberIdSync(inAppPayment.type.requireSubscriberType()) - info("Canceling active subscription (if necessary).") - RecurringInAppPaymentRepository.cancelActiveSubscriptionIfNecessarySync(inAppPayment.type.requireSubscriberType()) - info("Creating and confirming setup intent.") - return when (val action = StripeRepository.createAndConfirmSetupIntent(inAppPayment.type, data.inAppPaymentSource!!.toPaymentSource(), inAppPayment.data.paymentMethodType.toPaymentSourceType() as PaymentSourceType.Stripe)) { - is StripeApi.Secure3DSAction.ConfirmRequired -> RequiredUserAction.StripeActionRequired(action) - is StripeApi.Secure3DSAction.NotNeeded -> RequiredUserAction.StripeActionNotRequired(action) + try { + info("Ensuring the subscriber id is set on the server.") + RecurringInAppPaymentRepository.ensureSubscriberIdSync(inAppPayment.type.requireSubscriberType()) + info("Canceling active subscription (if necessary).") + RecurringInAppPaymentRepository.cancelActiveSubscriptionIfNecessarySync(inAppPayment.type.requireSubscriberType()) + info("Creating and confirming setup intent.") + return when (val action = StripeRepository.createAndConfirmSetupIntent(inAppPayment.type, data.inAppPaymentSource!!.toPaymentSource(), inAppPayment.data.paymentMethodType.toPaymentSourceType() as PaymentSourceType.Stripe)) { + is StripeApi.Secure3DSAction.ConfirmRequired -> RequiredUserAction.StripeActionRequired(action) + is StripeApi.Secure3DSAction.NotNeeded -> RequiredUserAction.StripeActionNotRequired(action) + } + } catch (e: DonationError) { + throw e + } catch (e: Exception) { + throw DonationError.getPaymentSetupError(inAppPayment.type.toErrorSource(), e, inAppPayment.data.paymentMethodType.toPaymentSourceType()) } }