From 5e3a34e1bc23fdcad8cbddaa4a015a3554ba4c3e Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Mon, 13 Jul 2026 14:24:41 -0300 Subject: [PATCH] Add donation checkout spec test suite. Co-authored-by: Cody Henthorne --- app/build.gradle.kts | 11 + ...ckoutFlowActivityTest__OneTimeDonations.kt | 8 + ...outFlowActivityTest__RecurringDonations.kt | 89 ++---- .../CheckoutFlowActivityTest__Redemption.kt | 99 ++++++ .../subscription/donate/CheckoutFlowDriver.kt | 280 +++++++++++++++++ .../donate/CheckoutFlowPermitTestSupport.kt | 39 ++- .../donate/DonationsCheckoutSpecTest.kt | 282 ++++++++++++++++++ .../donate/DonationsCheckoutTestSpec.kt | 159 ++++++++++ ...umentationApplicationDependencyProvider.kt | 61 +++- .../securesms/testing/InAppPaymentsRule.kt | 106 ++++--- .../benchmark/BenchmarkCommandReceiver.kt | 2 +- .../org/signal/benchmark/setup/NoOpJob.kt | 3 +- .../websocket/BenchmarkWebSocketConnection.kt | 2 +- .../transfer/DonationTransferTestTags.kt | 29 ++ .../details/BankTransferDetailsFragment.kt | 7 + .../ideal/IdealTransferDetailsFragment.kt | 6 + .../mandate/BankTransferMandateFragment.kt | 3 + .../securesms/dependencies/AppDependencies.kt | 1 + .../ApplicationDependencyProvider.java | 9 + .../dependencies/NetworkDependenciesModule.kt | 6 +- .../MockApplicationDependencyProvider.kt | 5 + .../securesms/database/FakeMessageRecords.kt | 2 +- .../testing/endpoints/DonationResponses.kt | 104 +++++++ .../testing/endpoints/DonationTestServer.kt | 53 ++++ .../testing/endpoints/EndpointResponder.kt | 111 +++++++ .../testing/endpoints/ResponderInterceptor.kt | 66 ++++ .../endpoints/ResponderWebSocketConnection.kt | 112 +++++++ .../testing/endpoints/StripeResponses.kt | 82 +++++ 28 files changed, 1618 insertions(+), 119 deletions(-) create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivityTest__Redemption.kt create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowDriver.kt create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationsCheckoutSpecTest.kt create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationsCheckoutTestSpec.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/DonationTransferTestTags.kt create mode 100644 app/src/testShared/org/thoughtcrime/securesms/testing/endpoints/DonationResponses.kt create mode 100644 app/src/testShared/org/thoughtcrime/securesms/testing/endpoints/DonationTestServer.kt create mode 100644 app/src/testShared/org/thoughtcrime/securesms/testing/endpoints/EndpointResponder.kt create mode 100644 app/src/testShared/org/thoughtcrime/securesms/testing/endpoints/ResponderInterceptor.kt create mode 100644 app/src/testShared/org/thoughtcrime/securesms/testing/endpoints/ResponderWebSocketConnection.kt create mode 100644 app/src/testShared/org/thoughtcrime/securesms/testing/endpoints/StripeResponses.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1c0477a24c..afad58f838 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -132,6 +132,17 @@ ktlint { version.set("1.5.0") } +// ktlint only scans convention source dirs, so the shared dirs added to the compile tasks are +// otherwise skipped. Add them to the base test/androidTest ktlint tasks so ktlintCheck/format cover them. +tasks.withType(org.jlleitschuh.gradle.ktlint.tasks.BaseKtLintCheckTask::class.java).configureEach { + if (name.endsWith("OverTestSourceSet") || name.endsWith("OverAndroidTestSourceSet")) { + source("$projectDir/src/testShared") + } + if (name.endsWith("OverAndroidTestSourceSet")) { + source("$projectDir/src/benchmarkShared/java") + } +} + screenshotTests { // Fraction of differing pixels tolerated before a screenshot test fails (0.0001 = 0.01%). imageDifferenceThreshold = 0.0001f 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 index 42515be967..45acd4724a 100644 --- 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 @@ -17,7 +17,9 @@ 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 io.mockk.unmockkObject import org.hamcrest.Matchers.allOf +import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test @@ -25,6 +27,7 @@ import org.junit.runner.RunWith import org.signal.core.util.deleteAll import org.signal.donations.InAppPaymentType import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.testing.GooglePayTestRule @@ -59,6 +62,11 @@ class CheckoutFlowActivityTest__OneTimeDonations { startJobLoopForTests() } + @After + fun tearDown() { + unmockkObject(DonationPermits) + } + @Test fun givenPermitAcquisitionFails_whenIDonateOnce_thenIExpectPaymentSetupErrorDialog() { failDonationPermitAcquisition() 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 b7816acab2..803e703916 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 @@ -13,8 +13,6 @@ 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 io.mockk.every -import io.mockk.mockkObject import io.mockk.unmockkObject import org.junit.After import org.junit.Before @@ -23,9 +21,6 @@ import org.junit.Test import org.junit.runner.RunWith import org.signal.core.util.deleteAll 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 @@ -33,19 +28,18 @@ 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.testing.GooglePayTestRule import org.thoughtcrime.securesms.testing.InAppPaymentsRule import org.thoughtcrime.securesms.testing.RxTestSchedulerRule import org.thoughtcrime.securesms.testing.SignalActivityRule import org.thoughtcrime.securesms.testing.actions.scrollToDescendant -import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription +import org.thoughtcrime.securesms.testing.endpoints.DonationResponses +import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints +import org.thoughtcrime.securesms.testing.endpoints.failure +import org.thoughtcrime.securesms.testing.endpoints.ok import org.whispersystems.signalservice.api.subscriptions.SubscriberId -import java.math.BigDecimal import java.util.Currency -import kotlin.time.Duration.Companion.days -import kotlin.time.Duration.Companion.milliseconds @Suppress("ClassName") @RunWith(AndroidJUnit4::class) @@ -153,10 +147,10 @@ class CheckoutFlowActivityTest__RecurringDonations { @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)) + succeedDonationPermitAcquisition() + MockEndpoints.responder.register({ it.method == "POST" && it.path.contains("/create_payment_method") }) { + failure(402) + } val scenario = ActivityScenario.launch(intent) rxRule.defaultTestScheduler.triggerActions() @@ -172,44 +166,22 @@ class CheckoutFlowActivityTest__RecurringDonations { } private fun initialiseActiveSubscription() { - val currency = Currency.getInstance("USD") - val subscriber = InAppPaymentSubscriberRecord( - subscriberId = SubscriberId.generate(), - currency = currency, - type = InAppPaymentSubscriberRecord.Type.DONATION, - requiresCancel = false, - paymentMethodType = InAppPaymentData.PaymentMethodType.CARD, - iapSubscriptionId = null - ) - - InAppPaymentsRepository.setSubscriber(subscriber) - SignalStore.inAppPayments.setRecurringDonationCurrency(currency) - - AppDependencies.donationsApi.apply { - every { getSubscription(subscriber.subscriberId) } returns NetworkResult.Success( - ActiveSubscription( - ActiveSubscription.Subscription( - 200, - currency.currencyCode, - BigDecimal.ONE, - System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds, - true, - System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds, - false, - "active", - "STRIPE", - "CARD", - false - ), - null - ) - ) - - every { deleteSubscription(subscriber.subscriberId) } returns NetworkResult.Success(Unit) + val subscriber = registerSubscriber() + val serialized = subscriber.subscriberId.serialize() + MockEndpoints.responder.register({ it.method == "GET" && it.path.contains(serialized) }) { + ok(DonationResponses.activeSubscription(status = "active", active = true)) } } private fun initialisePendingSubscription() { + val subscriber = registerSubscriber() + val serialized = subscriber.subscriberId.serialize() + MockEndpoints.responder.register({ it.method == "GET" && it.path.contains(serialized) }) { + ok(DonationResponses.activeSubscription(status = "incomplete", active = false)) + } + } + + private fun registerSubscriber(): InAppPaymentSubscriberRecord { val currency = Currency.getInstance("USD") val subscriber = InAppPaymentSubscriberRecord( subscriberId = SubscriberId.generate(), @@ -223,25 +195,6 @@ class CheckoutFlowActivityTest__RecurringDonations { InAppPaymentsRepository.setSubscriber(subscriber) SignalStore.inAppPayments.setRecurringDonationCurrency(currency) - AppDependencies.donationsApi.apply { - every { getSubscription(subscriber.subscriberId) } returns NetworkResult.Success( - ActiveSubscription( - ActiveSubscription.Subscription( - 200, - currency.currencyCode, - BigDecimal.ONE, - System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds, - false, - System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds, - false, - "incomplete", - "STRIPE", - "CARD", - false - ), - null - ) - ) - } + return subscriber } } diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivityTest__Redemption.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivityTest__Redemption.kt new file mode 100644 index 0000000000..ac7f58f5f0 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivityTest__Redemption.kt @@ -0,0 +1,99 @@ +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.matcher.ViewMatchers.isClickable +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 assertk.assertThat +import assertk.assertions.isNotEmpty +import io.mockk.unmockkObject +import org.hamcrest.Matchers.allOf +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.signal.core.util.deleteAll +import org.signal.donations.InAppPaymentType +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits +import org.thoughtcrime.securesms.database.DonationReceiptTable +import org.thoughtcrime.securesms.database.InAppPaymentTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord +import org.thoughtcrime.securesms.testing.GooglePayTestRule +import org.thoughtcrime.securesms.testing.InAppPaymentsRule +import org.thoughtcrime.securesms.testing.RxTestSchedulerRule +import org.thoughtcrime.securesms.testing.SignalActivityRule +import org.thoughtcrime.securesms.testing.actions.scrollToDescendant +import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints +import org.thoughtcrime.securesms.testing.endpoints.registerStripeHappyPath +import org.thoughtcrime.securesms.testing.flushUntil + +/** + * Milestone integration test: drives a Google Pay one-time donation through the entire lifecycle to + * receipt redemption, exercising all three outer boundaries at once — the Signal-service websocket + * responder (boost intent + receipt credential + redeem), the Stripe HTTP interceptor (payment + * method + confirm + intent status), and the test zk server (real client-side credential validation). + */ +@Suppress("ClassName") +@RunWith(AndroidJUnit4::class) +class CheckoutFlowActivityTest__Redemption { + @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() { + SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME) + SignalDatabase.donationReceipts.writableDatabase.deleteAll(DonationReceiptTable.TABLE_NAME) + startJobLoopForTests() + succeedDonationPermitAcquisition() + MockEndpoints.responder.registerStripeHappyPath() + } + + @After + fun tearDown() { + unmockkObject(DonationPermits) + } + + @Test + fun givenAllEndpointsSucceed_whenIDonateOnceWithGooglePay_thenIExpectAReceipt() { + val scenario = ActivityScenario.launch(intent) + rxRule.defaultTestScheduler.triggerActions() + + scrollToDescendant(R.id.recycler, withId(R.id.boost_1), rxRule.defaultTestScheduler) + onView(allOf(withId(R.id.boost_1), isClickable())).perform(ViewActions.click()) + rxRule.defaultTestScheduler.triggerActions() + + scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler) + onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click()) + rxRule.defaultTestScheduler.triggerActions() + + scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.ONE_TIME_DONATION) + + rxRule.defaultTestScheduler.flushUntil { + SignalDatabase.donationReceipts.getReceipts(InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION).isNotEmpty() + } + + assertThat(SignalDatabase.donationReceipts.getReceipts(InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION)).isNotEmpty() + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowDriver.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowDriver.kt new file mode 100644 index 0000000000..54b6aa9b11 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowDriver.kt @@ -0,0 +1,280 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate + +import android.os.Bundle +import androidx.compose.ui.test.hasTestTag +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.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTextInput +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager +import androidx.navigation.fragment.NavHostFragment +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.ViewMatchers.isClickable +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isEnabled +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import io.reactivex.rxjava3.schedulers.TestScheduler +import org.hamcrest.Matchers.allOf +import org.signal.donations.InAppPaymentType +import org.signal.donations.StripeIntentAccessor +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorTestTags +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal.PayPalConfirmationDialogFragment +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal.PayPalConfirmationResult +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSDialogFragment +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.DonationTransferTestTags +import org.thoughtcrime.securesms.testing.actions.scrollToDescendant +import org.thoughtcrime.securesms.testing.endpoints.StripeResponses +import org.thoughtcrime.securesms.testing.flushUntil + +/** + * Drives [CheckoutFlowActivity] through the UI "as a user" for a [DonationsCheckoutTestSpec]: selects + * an amount/tier, continues to the gateway selector, and completes the chosen payment method. Each + * payment method is a strategy dispatched on [DonationsCheckoutTestSpec.paymentMethod]. + * + * Webview approval steps (PayPal, Stripe 3DS) can't complete against a mocked network, so instead of + * loading the approval URL we inject the fragment result the webview would have posted — the same + * mechanism the production dialog uses on return. + */ +class CheckoutFlowDriver( + private val composeRule: ComposeTestRule, + private val scheduler: TestScheduler +) { + + companion object { + private const val TEST_NAME = "Test McTesterson" + private const val TEST_EMAIL = "test@signal.org" + + /** A structurally valid German IBAN (passes mod-97), for the SEPA form. */ + private const val TEST_IBAN = "DE89370400440532013000" + } + + fun drive(scenario: ActivityScenario, spec: DonationsCheckoutTestSpec) { + scheduler.triggerActions() + + selectAmountOrTier(spec.inAppPaymentType) + proceedToGateway() + + when (spec.paymentMethod) { + TestPaymentMethod.GOOGLE_PAY -> scenario.selectGooglePay(composeRule, scheduler, spec.inAppPaymentType) + TestPaymentMethod.CREDIT_CARD -> completeCreditCard() + TestPaymentMethod.PAYPAL -> completePayPal(scenario, spec.inAppPaymentType) + TestPaymentMethod.SEPA_DEBIT -> completeSepa() + TestPaymentMethod.IDEAL -> completeIdeal(spec.inAppPaymentType) + } + + if (spec.world.stripe == StripeOutcome.REQUIRES_3DS && spec.paymentMethod.usesStripe()) { + complete3ds(scenario, spec.inAppPaymentType) + } + } + + private fun selectAmountOrTier(type: InAppPaymentType) { + if (type == InAppPaymentType.ONE_TIME_DONATION) { + scrollToDescendant(R.id.recycler, withId(R.id.boost_1), scheduler) + onView(allOf(withId(R.id.boost_1), isClickable())).perform(ViewActions.click()) + scheduler.triggerActions() + } + // Recurring: the monthly tier is selected by default, so nothing to pick here. + } + + private fun proceedToGateway() { + scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), scheduler) + onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click()) + scheduler.triggerActions() + } + + /** + * Fills the [org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.CreditCardFragment] + * with a valid test card and continues, driving the flow into the Stripe payment pipeline. + */ + private fun completeCreditCard() { + clickGatewayButton(GatewaySelectorTestTags.CREDIT_CARD_BUTTON) + + scheduler.flushUntil { + onView(withId(R.id.card_number)).check(matches(isDisplayed())) + true + } + + onView(withId(R.id.card_number)).perform(ViewActions.typeText("4242424242424242"), ViewActions.closeSoftKeyboard()) + onView(withId(R.id.card_expiry)).perform(ViewActions.typeText("1228"), ViewActions.closeSoftKeyboard()) + onView(withId(R.id.card_cvv)).perform(ViewActions.typeText("123"), ViewActions.closeSoftKeyboard()) + + scheduler.flushUntil { + onView(withId(R.id.continue_button)).check(matches(isEnabled())) + true + } + onView(withId(R.id.continue_button)).perform(ViewActions.click()) + scheduler.triggerActions() + } + + /** + * Selects PayPal, then injects the approval result the webview would have returned. One-time uses a + * [PayPalConfirmationResult]; recurring posts `true`. + */ + private fun completePayPal(scenario: ActivityScenario, type: InAppPaymentType) { + clickGatewayButton(GatewaySelectorTestTags.PAYPAL_BUTTON) + + val result: Any = if (type == InAppPaymentType.ONE_TIME_DONATION) { + PayPalConfirmationResult(payerId = "test-payer", paymentId = null, paymentToken = "test-token") + } else { + true + } + + awaitDialogAndSetResult( + scenario = scenario, + dialogClass = PayPalConfirmationDialogFragment::class.java, + resultKey = PayPalConfirmationDialogFragment.REQUEST_KEY, + bundle = bundleOf(PayPalConfirmationDialogFragment.REQUEST_KEY to result) + ) + } + + /** Injects the 3DS return the Stripe webview would have posted, resuming the Stripe pipeline. */ + private fun complete3ds(scenario: ActivityScenario, type: InAppPaymentType) { + val accessor = if (type == InAppPaymentType.ONE_TIME_DONATION) { + StripeIntentAccessor(StripeIntentAccessor.ObjectType.PAYMENT_INTENT, StripeResponses.DEFAULT_PAYMENT_INTENT_ID, "${StripeResponses.DEFAULT_PAYMENT_INTENT_ID}_secret_test") + } else { + StripeIntentAccessor(StripeIntentAccessor.ObjectType.SETUP_INTENT, StripeResponses.DEFAULT_SETUP_INTENT_ID, "${StripeResponses.DEFAULT_SETUP_INTENT_ID}_secret_test") + } + + awaitDialogAndSetResult( + scenario = scenario, + dialogClass = Stripe3DSDialogFragment::class.java, + resultKey = Stripe3DSDialogFragment.REQUEST_KEY, + bundle = bundleOf(Stripe3DSDialogFragment.REQUEST_KEY to accessor) + ) + } + + /** + * Selects SEPA, accepts the mandate (its button scrolls the text to the bottom before it agrees, so + * it is clicked until the details form appears), fills the bank details, and donates. SEPA requires + * a EUR donation currency (see [WorldState.currencyCode]). + */ + private fun completeSepa() { + clickGatewayButton(GatewaySelectorTestTags.SEPA_BUTTON) + + scheduler.flushUntil { + val ibanPresent = runCatching { + composeRule.onAllNodesWithTag(DonationTransferTestTags.SEPA_IBAN_FIELD).fetchSemanticsNodes().isNotEmpty() + }.getOrDefault(false) + if (!ibanPresent) { + runCatching { composeRule.onNodeWithTag(DonationTransferTestTags.SEPA_MANDATE_CONTINUE_BUTTON).performClick() } + } + ibanPresent + } + + typeIntoField(DonationTransferTestTags.SEPA_DETAILS_LIST, DonationTransferTestTags.SEPA_IBAN_FIELD, TEST_IBAN) + typeIntoField(DonationTransferTestTags.SEPA_DETAILS_LIST, DonationTransferTestTags.SEPA_NAME_FIELD, TEST_NAME) + typeIntoField(DonationTransferTestTags.SEPA_DETAILS_LIST, DonationTransferTestTags.SEPA_EMAIL_FIELD, TEST_EMAIL) + + clickWhenReady(DonationTransferTestTags.SEPA_DONATE_BUTTON) + } + + /** + * Selects iDEAL, fills the bank details (email is recurring-only), and donates. iDEAL requires a EUR + * donation currency and, on success, lands in an awaiting-authorization state. + */ + private fun completeIdeal(type: InAppPaymentType) { + clickGatewayButton(GatewaySelectorTestTags.IDEAL_BUTTON) + + scheduler.flushUntil { + runCatching { + composeRule.onAllNodesWithTag(DonationTransferTestTags.IDEAL_NAME_FIELD).fetchSemanticsNodes().isNotEmpty() + }.getOrDefault(false) + } + + typeIntoField(DonationTransferTestTags.IDEAL_DETAILS_LIST, DonationTransferTestTags.IDEAL_NAME_FIELD, TEST_NAME) + if (type.recurring) { + typeIntoField(DonationTransferTestTags.IDEAL_DETAILS_LIST, DonationTransferTestTags.IDEAL_EMAIL_FIELD, TEST_EMAIL) + } + + clickWhenReady(DonationTransferTestTags.IDEAL_DONATE_BUTTON) + } + + /** + * Scrolls the [listTag] lazy list until the field tagged [fieldTag] is composed and on-screen, then types + * [text] into it. The transfer forms lay their fields out in a [androidx.compose.foundation.lazy.LazyColumn], + * so a lower field is not composed on a short screen until scrolled to — without this, the input silently + * matches nothing on Firebase's smaller devices. + */ + private fun typeIntoField(listTag: String, fieldTag: String, text: String) { + composeRule.onNodeWithTag(listTag).performScrollToNode(hasTestTag(fieldTag)) + composeRule.onNodeWithTag(fieldTag).performTextInput(text) + } + + /** Retries clicking a Compose node until the click lands (e.g. once a submit button becomes enabled). */ + private fun clickWhenReady(tag: String) { + scheduler.flushUntil { + runCatching { + composeRule.onNodeWithTag(tag).performClick() + true + }.getOrDefault(false) + } + scheduler.triggerActions() + } + + /** + * Clicks a Compose gateway-selector button and waits for the bottom sheet to dismiss. For methods + * other than Google Pay the checkout then navigates to the method's fragment; Google Pay instead + * requires a result injection (see `selectGooglePay`). + */ + private fun clickGatewayButton(tag: String) { + scheduler.flushUntil { + composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty() + } + + // The selector is a vertically scrolling bottom sheet; on a short screen the lower gateway buttons are + // off-screen, so bring the target on-screen before clicking (mirrors GatewaySelectorBottomSheetContentTest). + composeRule.onNodeWithTag(GatewaySelectorTestTags.CONTAINER).performScrollToNode(hasTestTag(tag)) + composeRule.onNodeWithTag(tag).performClick() + + scheduler.flushUntil { + runCatching { + composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isEmpty() + }.getOrDefault(true) + } + } + + /** + * Waits for a navigated dialog of [dialogClass] to be present, then posts [bundle] as its fragment + * result on the nav host's fragment manager (where the in-progress fragment registered its listener). + */ + private fun awaitDialogAndSetResult( + scenario: ActivityScenario, + dialogClass: Class, + resultKey: String, + bundle: Bundle + ) { + scheduler.flushUntil { + var delivered = false + scenario.onActivity { activity -> + val fragmentManager = navHostChildFragmentManager(activity) + if (fragmentManager != null && fragmentManager.fragments.any { dialogClass.isInstance(it) }) { + fragmentManager.setFragmentResult(resultKey, bundle) + delivered = true + } + } + delivered + } + scheduler.triggerActions() + } + + private fun navHostChildFragmentManager(activity: FragmentActivity): FragmentManager? { + return activity.supportFragmentManager.fragments + .filterIsInstance() + .firstOrNull() + ?.childFragmentManager + } + + private fun TestPaymentMethod.usesStripe(): Boolean { + return this != TestPaymentMethod.PAYPAL + } +} 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 index 5370a6f7cc..cc39a30559 100644 --- 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 @@ -7,10 +7,12 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate import android.app.Activity import android.content.Intent +import androidx.compose.ui.test.hasTestTag 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.compose.ui.test.performScrollToNode import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView import androidx.test.espresso.assertion.ViewAssertions.matches @@ -18,6 +20,7 @@ import androidx.test.espresso.matcher.RootMatchers.isDialog import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withText import io.mockk.every +import io.mockk.mockkObject import io.reactivex.rxjava3.schedulers.TestScheduler import org.signal.donations.InAppPaymentType import org.signal.libsignal.net.RequestResult @@ -26,23 +29,52 @@ 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.components.settings.app.subscription.permits.DonationPermits import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.testing.flushUntil /** - * 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]. + * Forces donation-permit acquisition to fail, so the checkout surfaces a payment-setup error before + * reaching the payment method. Stubs the [DonationPermits] object directly (the underlying + * `createDonationPermits` call rides the websocket, which the responder does not mint valid permits + * for). Call `unmockkObject(DonationPermits)` in teardown. */ fun failDonationPermitAcquisition(statusCode: Int = 500) { AppDependencies.donationPermitsRepository.clearPermits() - every { AppDependencies.donationsApi.createDonationPermits(any()) } returns RequestResult.NonSuccess(RestStatusCodeError(statusCode, emptyMap(), null)) + mockkObject(DonationPermits) + every { DonationPermits.getDonationPermit() } returns RequestResult.NonSuccess(RestStatusCodeError(statusCode, emptyMap(), null)) +} + +/** + * Makes donation-permit acquisition succeed with a placeholder permit, letting the checkout proceed + * past the permit gate to the payment method. Call `unmockkObject(DonationPermits)` in teardown. + */ +fun succeedDonationPermitAcquisition() { + AppDependencies.donationPermitsRepository.clearPermits() + mockkObject(DonationPermits) + every { DonationPermits.getDonationPermit() } returns RequestResult.Success("permit") } /** * 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. + * + * Before starting it, cancel any leftover in-app-payment jobs. A recurring setup job retries indefinitely over a + * one-day lifespan, so a spec that fails setup leaves one persisted in the JobManager. On Firebase Test Lab the + * orchestrator's `clearPackageData` does not reliably wipe the JobManager between parameterized specs (it does on a + * local emulator, which is why this only surfaces on-device), so that job survives into the next spec and runs after + * its InAppPayment row was deleted in setup — `InAppPaymentsRepository.resolveLock` then throws "Not found" from the + * job thread and crashes the whole app. Clearing the queues here makes each test start from a clean job state. */ fun startJobLoopForTests() { + AppDependencies.jobManager.cancelAllInQueues( + setOf( + InAppPaymentsRepository.getRecurringJobQueueKey(InAppPaymentType.RECURRING_DONATION), + InAppPaymentsRepository.getRecurringJobQueueKey(InAppPaymentType.RECURRING_BACKUP) + ) + ) + AppDependencies.jobManager.flush() + (AppDependencies.application as SignalInstrumentationApplicationContext).beginJobLoopForTests() } @@ -67,6 +99,7 @@ fun ActivityScenario.selectGooglePay( composeRule.onAllNodesWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).fetchSemanticsNodes().isNotEmpty() } + composeRule.onNodeWithTag(GatewaySelectorTestTags.CONTAINER).performScrollToNode(hasTestTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON)) composeRule.onNodeWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).performClick() scheduler.flushUntil { diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationsCheckoutSpecTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationsCheckoutSpecTest.kt new file mode 100644 index 0000000000..e407ca6c62 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationsCheckoutSpecTest.kt @@ -0,0 +1,282 @@ +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.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 assertk.assertThat +import assertk.assertions.isNotEmpty +import assertk.assertions.isNotNull +import assertk.assertions.isNull +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.junit.runners.Parameterized +import org.signal.core.util.deleteAll +import org.signal.donations.InAppPaymentType +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits +import org.thoughtcrime.securesms.database.DonationReceiptTable +import org.thoughtcrime.securesms.database.InAppPaymentTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord +import org.thoughtcrime.securesms.testing.GooglePayTestRule +import org.thoughtcrime.securesms.testing.InAppPaymentsRule +import org.thoughtcrime.securesms.testing.RawFlag +import org.thoughtcrime.securesms.testing.RemoteConfigForTest +import org.thoughtcrime.securesms.testing.RxTestSchedulerRule +import org.thoughtcrime.securesms.testing.SignalActivityRule +import org.thoughtcrime.securesms.testing.flushUntil + +/** + * Spec-driven integration test for donation checkout. Each [DonationsCheckoutTestSpec] declares a + * "world" (what each endpoint returns) and an expected outcome; this runner applies the world, drives + * [CheckoutFlowActivity] through the real UI via [CheckoutFlowDriver], and asserts the outcome. + * + * Adding coverage is data: append a spec to [DonationsCheckoutSpecs.ALL]. + */ +@RunWith(Parameterized::class) +@RemoteConfigForTest( + rawFlags = [ + RawFlag("android.sepa.debit.donations.5", "true"), + RawFlag("android.ideal.donations.5", "true"), + // Region codes are matched against the digit-only self E164 ("15555550101"), so no leading "+". + RawFlag("global.donations.sepaEnabledRegions", "1"), + RawFlag("global.donations.idealEnabledRegions", "1") + ] +) +class DonationsCheckoutSpecTest(private val spec: DonationsCheckoutTestSpec) { + + @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() + + @Before + fun setUp() { + SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME) + SignalDatabase.donationReceipts.writableDatabase.deleteAll(DonationReceiptTable.TABLE_NAME) + startJobLoopForTests() + spec.world.apply() + } + + @After + fun tearDown() { + unmockkObject(DonationPermits) + } + + @Test + fun runSpec() { + val intent = CheckoutFlowActivity.createIntent(InstrumentationRegistry.getInstrumentation().targetContext, spec.inAppPaymentType) + val scenario = ActivityScenario.launch(intent) + + CheckoutFlowDriver(composeRule, rxRule.defaultTestScheduler).drive(scenario, spec) + + when (val expected = spec.expected) { + is ExpectedOutcome.Redeemed -> { + rxRule.defaultTestScheduler.flushUntil { receipts().isNotEmpty() } + assertThat(receipts()).isNotEmpty() + } + + is ExpectedOutcome.ErrorDialog -> { + awaitDialog(rxRule.defaultTestScheduler, expected.titleRes) + if (expected.messageRes != null) { + onView(withText(expected.messageRes)).inRoot(isDialog()).check(matches(isDisplayed())) + } + } + + is ExpectedOutcome.PaymentSubmitted -> { + rxRule.defaultTestScheduler.flushUntil { submittedPayment() != null } + val payment = submittedPayment() + assertThat(payment).isNotNull() + assertThat(payment!!.data.error).isNull() + } + } + } + + /** The latest payment once it has moved past CREATED with no error, or null if not yet there. */ + private fun submittedPayment(): InAppPaymentTable.InAppPayment? { + return SignalDatabase.inAppPayments.getLatestInAppPaymentByType(spec.inAppPaymentType) + ?.takeIf { it.state != InAppPaymentTable.State.CREATED && it.data.error == null } + } + + private fun receipts(): List { + val type = if (spec.inAppPaymentType == InAppPaymentType.ONE_TIME_DONATION) { + InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION + } else { + InAppPaymentReceiptRecord.Type.RECURRING_DONATION + } + return SignalDatabase.donationReceipts.getReceipts(type) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun specs(): Collection = DonationsCheckoutSpecs.ALL + } +} + +/** + * The enumerated donation checkout scenarios. Currently seeded with Google Pay coverage; the other + * payment-method drivers (card, SEPA, iDEAL, PayPal) and their scenarios are added alongside their + * [CheckoutFlowDriver] strategies. + */ +object DonationsCheckoutSpecs { + + private val paymentErrorDialog = ExpectedOutcome.ErrorDialog( + titleRes = R.string.DonationsErrors__error_processing_payment, + messageRes = R.string.DonationsErrors__your_payment + ) + + val ALL: List = listOf( + DonationsCheckoutTestSpec( + name = "googlePay_oneTime_happyPath_isRedeemed", + inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION, + paymentMethod = TestPaymentMethod.GOOGLE_PAY, + world = WorldState(), + expected = ExpectedOutcome.Redeemed + ), + DonationsCheckoutTestSpec( + name = "googlePay_oneTime_permitFailure_showsError", + inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION, + paymentMethod = TestPaymentMethod.GOOGLE_PAY, + world = WorldState(permit = PermitOutcome.FAILURE), + expected = paymentErrorDialog + ), + DonationsCheckoutTestSpec( + name = "googlePay_oneTime_stripeDecline_showsError", + inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION, + paymentMethod = TestPaymentMethod.GOOGLE_PAY, + world = WorldState(stripe = StripeOutcome.DECLINE), + expected = paymentErrorDialog + ), + DonationsCheckoutTestSpec( + name = "googlePay_oneTime_signalCreateIntent402_showsError", + inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION, + paymentMethod = TestPaymentMethod.GOOGLE_PAY, + world = WorldState(signalCreatePaymentStatus = 402), + expected = paymentErrorDialog + ), + DonationsCheckoutTestSpec( + name = "googlePay_recurring_permitFailure_showsError", + inAppPaymentType = InAppPaymentType.RECURRING_DONATION, + paymentMethod = TestPaymentMethod.GOOGLE_PAY, + world = WorldState(permit = PermitOutcome.FAILURE), + expected = paymentErrorDialog + ), + DonationsCheckoutTestSpec( + name = "creditCard_oneTime_happyPath_isRedeemed", + inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION, + paymentMethod = TestPaymentMethod.CREDIT_CARD, + world = WorldState(), + expected = ExpectedOutcome.Redeemed + ), + DonationsCheckoutTestSpec( + name = "creditCard_oneTime_stripeDecline_showsError", + inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION, + paymentMethod = TestPaymentMethod.CREDIT_CARD, + world = WorldState(stripe = StripeOutcome.DECLINE), + expected = paymentErrorDialog + ), + DonationsCheckoutTestSpec( + name = "creditCard_oneTime_3dsApproved_isRedeemed", + inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION, + paymentMethod = TestPaymentMethod.CREDIT_CARD, + world = WorldState(stripe = StripeOutcome.REQUIRES_3DS), + expected = ExpectedOutcome.Redeemed + ), + DonationsCheckoutTestSpec( + name = "payPal_oneTime_happyPath_isRedeemed", + inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION, + paymentMethod = TestPaymentMethod.PAYPAL, + world = WorldState(), + expected = ExpectedOutcome.Redeemed + ), + DonationsCheckoutTestSpec( + name = "sepa_oneTime_happyPath_isSubmitted", + inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION, + paymentMethod = TestPaymentMethod.SEPA_DEBIT, + world = WorldState(currencyCode = "EUR"), + expected = ExpectedOutcome.PaymentSubmitted + ), + DonationsCheckoutTestSpec( + name = "ideal_oneTime_happyPath_isSubmitted", + inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION, + paymentMethod = TestPaymentMethod.IDEAL, + world = WorldState(currencyCode = "EUR"), + expected = ExpectedOutcome.PaymentSubmitted + ), + + // Recurring happy paths. Asserted as "submitted": the recurring-specific work is the setup + // pipeline (create subscriber, setup intent, set default, set level); receipt redemption is + // shared with the one-time path already covered above. + DonationsCheckoutTestSpec( + name = "googlePay_recurring_happyPath_isSubmitted", + inAppPaymentType = InAppPaymentType.RECURRING_DONATION, + paymentMethod = TestPaymentMethod.GOOGLE_PAY, + world = WorldState(recurringActivatesAfterSetup = true), + expected = ExpectedOutcome.PaymentSubmitted + ), + DonationsCheckoutTestSpec( + name = "creditCard_recurring_happyPath_isSubmitted", + inAppPaymentType = InAppPaymentType.RECURRING_DONATION, + paymentMethod = TestPaymentMethod.CREDIT_CARD, + world = WorldState(recurringActivatesAfterSetup = true), + expected = ExpectedOutcome.PaymentSubmitted + ), + DonationsCheckoutTestSpec( + name = "payPal_recurring_happyPath_isSubmitted", + inAppPaymentType = InAppPaymentType.RECURRING_DONATION, + paymentMethod = TestPaymentMethod.PAYPAL, + world = WorldState(recurringActivatesAfterSetup = true), + expected = ExpectedOutcome.PaymentSubmitted + ), + DonationsCheckoutTestSpec( + name = "sepa_recurring_happyPath_isSubmitted", + inAppPaymentType = InAppPaymentType.RECURRING_DONATION, + paymentMethod = TestPaymentMethod.SEPA_DEBIT, + world = WorldState(currencyCode = "EUR"), + expected = ExpectedOutcome.PaymentSubmitted + ), + + // Additional error cases. + DonationsCheckoutTestSpec( + name = "creditCard_oneTime_permitFailure_showsError", + inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION, + paymentMethod = TestPaymentMethod.CREDIT_CARD, + world = WorldState(permit = PermitOutcome.FAILURE), + expected = paymentErrorDialog + ), + DonationsCheckoutTestSpec( + name = "creditCard_recurring_stripeDecline_showsError", + inAppPaymentType = InAppPaymentType.RECURRING_DONATION, + paymentMethod = TestPaymentMethod.CREDIT_CARD, + world = WorldState(stripe = StripeOutcome.DECLINE), + expected = paymentErrorDialog + ), + DonationsCheckoutTestSpec( + name = "googlePay_recurring_signalCreatePaymentMethod402_showsError", + inAppPaymentType = InAppPaymentType.RECURRING_DONATION, + paymentMethod = TestPaymentMethod.GOOGLE_PAY, + world = WorldState(signalCreatePaymentStatus = 402), + expected = paymentErrorDialog + ) + ) +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationsCheckoutTestSpec.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationsCheckoutTestSpec.kt new file mode 100644 index 0000000000..fb5aeaf3b5 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationsCheckoutTestSpec.kt @@ -0,0 +1,159 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate + +import androidx.annotation.StringRes +import org.signal.donations.InAppPaymentType +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.testing.endpoints.DonationResponses +import org.thoughtcrime.securesms.testing.endpoints.EndpointResponse +import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints +import org.thoughtcrime.securesms.testing.endpoints.StripeResponses +import org.thoughtcrime.securesms.testing.endpoints.failure +import org.thoughtcrime.securesms.testing.endpoints.ok +import org.thoughtcrime.securesms.testing.endpoints.registerPayPalHappyPath +import org.thoughtcrime.securesms.testing.endpoints.registerStripeHappyPath +import java.util.Currency +import java.util.concurrent.atomic.AtomicBoolean + +/** + * A declarative "state of the world" + expected outcome for one donation checkout scenario. Specs + * are enumerated by [DonationsCheckoutSpecTest] and each one drives [CheckoutFlowActivity] end to end + * for its [paymentMethod]. + */ +data class DonationsCheckoutTestSpec( + val name: String, + val inAppPaymentType: InAppPaymentType, + val paymentMethod: TestPaymentMethod, + val world: WorldState = WorldState(), + val expected: ExpectedOutcome +) { + /** Used as the parameterized test label. */ + override fun toString(): String = name +} + +enum class TestPaymentMethod { + GOOGLE_PAY, + CREDIT_CARD, + SEPA_DEBIT, + IDEAL, + PAYPAL +} + +/** Whether donation-permit acquisition succeeds or fails. */ +enum class PermitOutcome { SUCCESS, FAILURE } + +/** The Stripe HTTP edge's behavior for a scenario. */ +enum class StripeOutcome { + SUCCESS, + DECLINE, + FAILURE, + REQUIRES_3DS +} + +/** Pre-existing recurring subscription state (recurring scenarios only). */ +enum class SubscriptionState { NONE, ACTIVE, PENDING } + +/** + * The knobs that define what each endpoint returns. [apply] registers the corresponding handlers on + * the shared responder (overriding the [org.thoughtcrime.securesms.testing.InAppPaymentsRule] + * defaults, since later registrations win) and sets the permit outcome. + */ +data class WorldState( + val permit: PermitOutcome = PermitOutcome.SUCCESS, + val stripe: StripeOutcome = StripeOutcome.SUCCESS, + val declineCode: String = "card_declined", + val signalCreatePaymentStatus: Int = 200, + val receiptCredentialStatus: Int = 200, + val redeemStatus: Int = 200, + val existingSubscription: SubscriptionState = SubscriptionState.NONE, + /** Donation currency to use. SEPA/iDEAL require EUR. */ + val currencyCode: String = "USD", + /** + * For a new recurring donation: report no active subscription until the subscription level is set + * during setup, then report an active one. This lets the checkout show "Continue" (new subscriber) + * while still letting the recurring receipt-credential context job see an active subscription to redeem. + */ + val recurringActivatesAfterSetup: Boolean = false +) { + fun apply() { + val currency = Currency.getInstance(currencyCode) + SignalStore.inAppPayments.setOneTimeCurrency(currency) + SignalStore.inAppPayments.setRecurringDonationCurrency(currency) + + when (permit) { + PermitOutcome.SUCCESS -> succeedDonationPermitAcquisition() + PermitOutcome.FAILURE -> failDonationPermitAcquisition() + } + + val responder = MockEndpoints.responder + + // Signal-service PayPal endpoints (harmless for non-PayPal specs; distinct paths). + responder.registerPayPalHappyPath() + + // Stripe HTTP edge. + responder.registerStripeHappyPath() + when (stripe) { + StripeOutcome.SUCCESS -> Unit + StripeOutcome.DECLINE -> responder.register({ it.method == "POST" && (it.path.endsWith("/confirm") || it.path.contains("/payment_methods")) }) { + failure(402, StripeResponses.declineError(declineCode)) + } + StripeOutcome.FAILURE -> responder.register({ it.method == "POST" && it.path.endsWith("/confirm") }) { + failure(402, StripeResponses.failureError("card_not_supported")) + } + StripeOutcome.REQUIRES_3DS -> responder.register({ it.method == "POST" && it.path.endsWith("/confirm") }) { + ok(StripeResponses.confirm3dsRequired()) + } + } + + // Signal-service edge overrides. + if (signalCreatePaymentStatus != 200) { + responder.register({ it.method == "POST" && (it.path.contains("/create_payment_method") || it.path.endsWith("/boost/create")) }) { + failure(signalCreatePaymentStatus) + } + } + if (receiptCredentialStatus != 200) { + responder.register({ it.method == "POST" && it.path.contains("/receipt_credentials") }) { failure(receiptCredentialStatus) } + } + if (redeemStatus != 200) { + responder.register({ it.method == "POST" && it.path.contains("/redeem-receipt") }) { failure(redeemStatus) } + } + + when (existingSubscription) { + SubscriptionState.NONE -> Unit + SubscriptionState.ACTIVE -> registerSubscription(status = "active", active = true) + SubscriptionState.PENDING -> registerSubscription(status = "incomplete", active = false) + } + + if (recurringActivatesAfterSetup) { + val activated = AtomicBoolean(false) + responder.register({ it.method == "PUT" && it.path.contains("/level/") }) { + activated.set(true) + ok() + } + responder.register({ it.method == "GET" && it.path.contains("/v1/subscription/") && !it.path.contains("configuration") && !it.path.contains("bank_mandate") }) { + if (activated.get()) ok(DonationResponses.activeSubscription()) else ok(DonationResponses.emptySubscription()) + } + } + } + + private fun registerSubscription(status: String, active: Boolean) { + MockEndpoints.responder.register({ it.method == "GET" && it.path.contains("/v1/subscription/") && !it.path.contains("configuration") }) { + EndpointResponse(status = 200, body = DonationResponses.activeSubscription(status = status, active = active)) + } + } +} + +/** The expected terminal result of driving a spec. */ +sealed interface ExpectedOutcome { + /** An error dialog with [titleRes] (and optionally [messageRes]) is shown. */ + data class ErrorDialog(@StringRes val titleRes: Int, @StringRes val messageRes: Int? = null) : ExpectedOutcome + + /** The full lifecycle completes and a donation receipt is recorded. */ + data object Redeemed : ExpectedOutcome + + /** + * Payment setup succeeded and the payment moved past [org.thoughtcrime.securesms.database.InAppPaymentTable.State.CREATED] + * with no error. Used for bank-transfer methods (SEPA/iDEAL), which don't redeem immediately — + * they land in a pending / awaiting-authorization state. + */ + data object PaymentSubmitted : ExpectedOutcome +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/dependencies/InstrumentationApplicationDependencyProvider.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/dependencies/InstrumentationApplicationDependencyProvider.kt index 5333f9f0a1..81479dbff5 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/dependencies/InstrumentationApplicationDependencyProvider.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/dependencies/InstrumentationApplicationDependencyProvider.kt @@ -3,19 +3,28 @@ package org.thoughtcrime.securesms.dependencies import android.app.Application import io.mockk.mockk import io.mockk.spyk +import okhttp3.OkHttpClient +import org.signal.core.util.UptimeSleepTimer import org.signal.core.util.billing.BillingApi +import org.signal.libsignal.net.Network +import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations import org.signal.network.api.ArchiveApi import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess import org.thoughtcrime.securesms.recipients.LiveRecipientCache +import org.thoughtcrime.securesms.testing.endpoints.DonationTestServer +import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints +import org.thoughtcrime.securesms.testing.endpoints.ResponderInterceptor +import org.thoughtcrime.securesms.testing.endpoints.ResponderWebSocketConnection import org.whispersystems.signalservice.api.SignalServiceDataStore import org.whispersystems.signalservice.api.SignalServiceMessageSender import org.whispersystems.signalservice.api.account.AccountApi -import org.whispersystems.signalservice.api.donations.DonationsApi import org.whispersystems.signalservice.api.keys.KeysApi import org.whispersystems.signalservice.api.message.MessageApi import org.whispersystems.signalservice.api.websocket.SignalWebSocket import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration import org.whispersystems.signalservice.internal.push.PushServiceSocket +import java.util.function.Supplier +import kotlin.time.Duration.Companion.seconds /** * Dependency provider used for instrumentation tests (aka androidTests). @@ -45,8 +54,54 @@ class InstrumentationApplicationDependencyProvider(val application: Application, return mockk() } - override fun provideDonationsApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): DonationsApi { - return mockk() + /** + * Adds the Stripe-matching [ResponderInterceptor] on top of the default client (which supplies the + * user agent + DNS), so `api.stripe.com` requests made by [org.signal.donations.StripeApi] are + * answered from the shared [MockEndpoints.responder] and never hit the real network. + */ + override fun provideOkHttpClient(): OkHttpClient { + return default.provideOkHttpClient() + .newBuilder() + .addInterceptor(ResponderInterceptor(MockEndpoints.responder)) + .build() + } + + /** + * Backs the real [org.whispersystems.signalservice.api.donations.DonationsApi] (and any other + * websocket API) with a [ResponderWebSocketConnection] driven by the shared [MockEndpoints.responder], + * so Signal-service requests are answered from the scenario's "world" and never hit the network. + */ + override fun provideAuthWebSocket( + signalServiceConfigurationSupplier: Supplier, + libSignalNetworkSupplier: Supplier + ): SignalWebSocket.AuthenticatedWebSocket { + return SignalWebSocket.AuthenticatedWebSocket( + connectionFactory = { ResponderWebSocketConnection(MockEndpoints.responder) }, + canConnect = { true }, + sleepTimer = UptimeSleepTimer(), + disconnectTimeoutMs = 15.seconds.inWholeMilliseconds + ) + } + + override fun provideUnauthWebSocket( + signalServiceConfigurationSupplier: Supplier, + libSignalNetworkSupplier: Supplier + ): SignalWebSocket.UnauthenticatedWebSocket { + return SignalWebSocket.UnauthenticatedWebSocket( + connectionFactory = { ResponderWebSocketConnection(MockEndpoints.responder) }, + canConnect = { true }, + sleepTimer = UptimeSleepTimer(), + disconnectTimeoutMs = 15.seconds.inWholeMilliseconds + ) + } + + /** + * Uses the test zk server's public params so credentials minted by [MockEndpoints] validate here. + * The real [ClientZkReceiptOperations.receiveReceiptCredential] validation still runs in the + * receipt-credential context job — only the params are swapped for test ones. + */ + override fun provideClientZkReceiptOperations(signalServiceConfiguration: SignalServiceConfiguration): ClientZkReceiptOperations { + return DonationTestServer.clientReceiptOperations } override fun provideSignalServiceMessageSender( diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/InAppPaymentsRule.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/InAppPaymentsRule.kt index 5d896d9687..77d639cfcb 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/InAppPaymentsRule.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/InAppPaymentsRule.kt @@ -7,43 +7,90 @@ package org.thoughtcrime.securesms.testing import androidx.test.platform.app.InstrumentationRegistry import io.mockk.every +import org.json.JSONObject import org.junit.rules.ExternalResource -import org.signal.core.util.JsonUtils import org.signal.network.NetworkResult -import org.signal.network.exceptions.NonSuccessfulResponseCodeException import org.thoughtcrime.securesms.dependencies.AppDependencies -import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription -import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration +import org.thoughtcrime.securesms.testing.endpoints.DonationResponses +import org.thoughtcrime.securesms.testing.endpoints.DonationTestServer +import org.thoughtcrime.securesms.testing.endpoints.EndpointResponse +import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints +import org.thoughtcrime.securesms.testing.endpoints.delete +import org.thoughtcrime.securesms.testing.endpoints.get +import org.thoughtcrime.securesms.testing.endpoints.ok +import org.thoughtcrime.securesms.testing.endpoints.post +import org.thoughtcrime.securesms.testing.endpoints.put import org.whispersystems.signalservice.internal.push.WhoAmIResponse /** - * Sets up some common infrastructure for on-device InAppPayment testing + * Sets up common infrastructure for on-device InAppPayment testing. + * + * The Signal-service edge is driven through the shared [MockEndpoints.responder] (the real + * [org.whispersystems.signalservice.api.donations.DonationsApi] runs against these handlers), while + * account/archive lookups that are not websocket-backed here stay mocked via mockk. A scenario can + * override any default by registering a more specific handler after this rule runs (last-registered + * wins). */ class InAppPaymentsRule : ExternalResource() { + override fun before() { - initialiseConfigurationResponse() - initialisePutSubscription() + MockEndpoints.responder.clear() + registerDonationServiceDefaults() initialiseSetArchiveBackupId() initialiseSetAccountAttributes() - initialiseAccountAndSubscriptionLookups() + initialiseAccountLookups() } - private fun initialiseConfigurationResponse() { - val assets = InstrumentationRegistry.getInstrumentation().context.resources.assets - val response = assets.open("inAppPaymentsTests/configuration.json").use { stream -> - NetworkResult.Success(JsonUtils.fromJson(stream, SubscriptionsConfiguration::class.java)) - } - - AppDependencies.donationsApi.apply { - every { getDonationsConfiguration(any()) } returns response - } + override fun after() { + MockEndpoints.responder.clear() } - private fun initialisePutSubscription() { - AppDependencies.donationsApi.apply { - every { putSubscription(any()) } returns NetworkResult.Success(Unit) - every { createSubscriber(any(), any()) } returns NetworkResult.Success(Unit) + /** + * Default happy-path responses for the Signal donations endpoints. Registered generic-to-specific + * so more specific paths registered later win. + */ + private fun registerDonationServiceDefaults() { + val responder = MockEndpoints.responder + + // Subscriber lifecycle: create/put/update-level (PUT), cancel (DELETE) all succeed by default. + responder.put("/v1/subscription/") { ok() } + responder.delete("/v1/subscription/") { ok() } + + // getSubscription defaults to "no active subscription". + responder.get("/v1/subscription/") { ok(DonationResponses.emptySubscription()) } + + // Donations configuration (registered after the generic GET so it wins for its path). + val configuration = InstrumentationRegistry.getInstrumentation().context.resources.assets + .open("inAppPaymentsTests/configuration.json") + .use { it.readBytes().decodeToString() } + responder.get("/v1/subscription/configuration") { ok(configuration) } + + // SEPA bank-transfer mandate text (registered after the generic GET so it wins for its path). + responder.get("/v1/subscription/bank_mandate/") { ok(DonationResponses.bankMandate()) } + + // One-time (boost) payment intent. + responder.post("/v1/subscription/boost/create") { ok(DonationResponses.stripeClientSecret()) } + + // Recurring payment method setup + default payment method + level are keyed by subscriber id in + // the middle of the path, so match on a distinctive suffix. + responder.register({ it.method == "POST" && it.path.contains("/create_payment_method") && !it.path.contains("paypal") }) { + ok(DonationResponses.setupIntentClientSecret()) } + responder.register({ it.method == "POST" && it.path.contains("/default_payment_method") }) { ok() } + + // Receipt credential submission (boost + recurring) — mint a valid credential from the client's request. + responder.register({ it.method == "POST" && it.path.contains("/receipt_credentials") }) { request -> + mintReceiptCredential(request.bodyString) + } + + // Redemption. + responder.post("/v1/donation/redeem-receipt") { ok() } + } + + private fun mintReceiptCredential(requestBody: String): EndpointResponse { + val requestBase64 = JSONObject(requestBody).getString("receiptCredentialRequest") + val minted = DonationTestServer.issueReceiptCredential(requestBase64) + return ok(DonationResponses.receiptCredential(minted)) } private fun initialiseSetArchiveBackupId() { @@ -59,23 +106,12 @@ class InAppPaymentsRule : ExternalResource() { } /** - * Starting the real job loop lets background jobs unrelated to the assertion under test run against the strict - * API mocks (e.g. [org.thoughtcrime.securesms.jobs.InAppPaymentRecurringContextJob] querying whoAmI, the - * active subscription, and then submitting receipt credentials). Stub these calls so those jobs hit a handled - * path and terminate quietly instead of throwing [io.mockk.MockKException] on a job thread, which crashes the - * whole app process (the test asserts nothing, so it surfaces as "Application crashed" with no failed test). - * Tests that supply an active subscription push the job past the [getSubscription] guard to - * [submitReceiptCredentials], so that must be stubbed too. End-to-end coverage of that pipeline is tracked - * separately; here we only keep the process alive and the logs clean. + * whoAmI is served by the (still mocked) account API rather than the websocket responder, so + * background jobs that resolve the account during a test hit a handled path instead of throwing. */ - private fun initialiseAccountAndSubscriptionLookups() { + private fun initialiseAccountLookups() { AppDependencies.accountApi.apply { every { whoAmI() } returns NetworkResult.Success(WhoAmIResponse(number = "+15555550123")) } - - AppDependencies.donationsApi.apply { - every { getSubscription(any()) } returns NetworkResult.Success(ActiveSubscription.EMPTY) - every { submitReceiptCredentials(any(), any()) } returns NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(402)) - } } } diff --git a/app/src/benchmarkShared/java/org/signal/benchmark/BenchmarkCommandReceiver.kt b/app/src/benchmarkShared/java/org/signal/benchmark/BenchmarkCommandReceiver.kt index 61be90fbc1..b5bc6040c1 100644 --- a/app/src/benchmarkShared/java/org/signal/benchmark/BenchmarkCommandReceiver.kt +++ b/app/src/benchmarkShared/java/org/signal/benchmark/BenchmarkCommandReceiver.kt @@ -16,6 +16,7 @@ import org.signal.benchmark.setup.Harness import org.signal.benchmark.setup.OtherClient import org.signal.core.util.ThreadUtil import org.signal.core.util.logging.Log +import org.signal.network.websocket.WebSocketRequestMessage import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.TestDbUtils import org.thoughtcrime.securesms.dependencies.AppDependencies @@ -23,7 +24,6 @@ import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.recipients.Recipient import org.whispersystems.signalservice.internal.push.Envelope import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection -import org.signal.network.websocket.WebSocketRequestMessage import kotlin.random.Random /** diff --git a/app/src/benchmarkShared/java/org/signal/benchmark/setup/NoOpJob.kt b/app/src/benchmarkShared/java/org/signal/benchmark/setup/NoOpJob.kt index 707dbe11ff..16f200094b 100644 --- a/app/src/benchmarkShared/java/org/signal/benchmark/setup/NoOpJob.kt +++ b/app/src/benchmarkShared/java/org/signal/benchmark/setup/NoOpJob.kt @@ -78,7 +78,6 @@ class NoOpJob(parameters: Parameters) : Job(parameters) { StoryOnboardingDownloadJob.KEY ) - fun replaceFactories(factories: Map>): Map> = - factories.mapValues { if (it.key in STARTUP_NETWORK_JOB_KEYS) Factory() else it.value } + fun replaceFactories(factories: Map>): Map> = factories.mapValues { if (it.key in STARTUP_NETWORK_JOB_KEYS) Factory() else it.value } } } diff --git a/app/src/benchmarkShared/java/org/whispersystems/signalservice/internal/websocket/BenchmarkWebSocketConnection.kt b/app/src/benchmarkShared/java/org/whispersystems/signalservice/internal/websocket/BenchmarkWebSocketConnection.kt index 0b894e274b..0977b2c814 100644 --- a/app/src/benchmarkShared/java/org/whispersystems/signalservice/internal/websocket/BenchmarkWebSocketConnection.kt +++ b/app/src/benchmarkShared/java/org/whispersystems/signalservice/internal/websocket/BenchmarkWebSocketConnection.kt @@ -8,10 +8,10 @@ package org.whispersystems.signalservice.internal.websocket import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.subjects.BehaviorSubject +import org.signal.core.util.JsonUtils import org.signal.network.websocket.WebSocketRequestMessage import org.signal.network.websocket.WebSocketResponseMessage import org.signal.network.websocket.WebsocketResponse -import org.signal.core.util.JsonUtils import org.thoughtcrime.securesms.util.SignalTrace import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState import org.whispersystems.signalservice.internal.push.SendMessageResponse diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/DonationTransferTestTags.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/DonationTransferTestTags.kt new file mode 100644 index 0000000000..731c167bc9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/DonationTransferTestTags.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer + +/** + * Test tags for the bank-transfer (SEPA) and iDEAL Compose entry forms, so instrumentation tests can + * drive them reliably. Follows the central-object convention used by the registration module's + * `TestTags`. + */ +object DonationTransferTestTags { + // SEPA bank transfer mandate + const val SEPA_MANDATE_CONTINUE_BUTTON = "sepa_mandate_continue_button" + + // SEPA bank transfer details + const val SEPA_DETAILS_LIST = "sepa_details_list" + const val SEPA_IBAN_FIELD = "sepa_iban_field" + const val SEPA_NAME_FIELD = "sepa_name_field" + const val SEPA_EMAIL_FIELD = "sepa_email_field" + const val SEPA_DONATE_BUTTON = "sepa_donate_button" + + // iDEAL transfer details + const val IDEAL_DETAILS_LIST = "ideal_details_list" + const val IDEAL_NAME_FIELD = "ideal_name_field" + const val IDEAL_EMAIL_FIELD = "ideal_email_field" + const val IDEAL_DONATE_BUTTON = "ideal_donate_button" +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt index 0b1fc45e3d..364b232079 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization @@ -63,6 +64,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.In import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.BankTransferRequestKeys +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.DonationTransferTestTags import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details.BankTransferDetailsViewModel.Field import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.payments.FiatMoneyUtil @@ -237,6 +239,7 @@ fun BankTransferDetailsContent( modifier = Modifier .weight(1f) .padding(horizontal = 24.dp) + .testTag(DonationTransferTestTags.SEPA_DETAILS_LIST) ) { item { val learnMore = stringResource(id = R.string.BankTransferDetailsFragment__learn_more) @@ -292,6 +295,7 @@ fun BankTransferDetailsContent( .defaultMinSize(minHeight = 78.dp) .onFocusChanged { onFocusChanged(Field.IBAN, it.hasFocus) } .focusRequester(focusRequester) + .testTag(DonationTransferTestTags.SEPA_IBAN_FIELD) ) } @@ -320,6 +324,7 @@ fun BankTransferDetailsContent( .padding(top = 16.dp) .defaultMinSize(minHeight = 78.dp) .onFocusChanged { onFocusChanged(Field.NAME, it.hasFocus) } + .testTag(DonationTransferTestTags.SEPA_NAME_FIELD) ) } @@ -348,6 +353,7 @@ fun BankTransferDetailsContent( .padding(top = 16.dp) .defaultMinSize(minHeight = 78.dp) .onFocusChanged { onFocusChanged(Field.EMAIL, it.hasFocus) } + .testTag(DonationTransferTestTags.SEPA_EMAIL_FIELD) ) } @@ -373,6 +379,7 @@ fun BankTransferDetailsContent( modifier = Modifier .defaultMinSize(minWidth = 220.dp) .padding(vertical = 16.dp) + .testTag(DonationTransferTestTags.SEPA_DONATE_BUTTON) ) { Text(text = donateLabel) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsFragment.kt index cdce4ae7d5..19b8c6a949 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsFragment.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization @@ -60,6 +61,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.ga import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.BankTransferRequestKeys +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.DonationTransferTestTags import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.ideal.IdealTransferDetailsViewModel.Field import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.payments.FiatMoneyUtil @@ -241,6 +243,7 @@ private fun IdealTransferDetailsContent( modifier = Modifier .weight(1f) .padding(horizontal = 24.dp) + .testTag(DonationTransferTestTags.IDEAL_DETAILS_LIST) ) { item { val learnMore = stringResource(id = R.string.IdealTransferDetailsFragment__learn_more) @@ -283,6 +286,7 @@ private fun IdealTransferDetailsContent( .padding(top = 16.dp) .defaultMinSize(minHeight = 78.dp) .onFocusChanged { onFocusChanged(Field.NAME, it.hasFocus) } + .testTag(DonationTransferTestTags.IDEAL_NAME_FIELD) ) } @@ -316,6 +320,7 @@ private fun IdealTransferDetailsContent( .padding(top = 16.dp) .defaultMinSize(minHeight = 78.dp) .onFocusChanged { onFocusChanged(Field.EMAIL, it.hasFocus) } + .testTag(DonationTransferTestTags.IDEAL_EMAIL_FIELD) ) } } @@ -327,6 +332,7 @@ private fun IdealTransferDetailsContent( modifier = Modifier .defaultMinSize(minWidth = 220.dp) .padding(bottom = 16.dp) + .testTag(DonationTransferTestTags.IDEAL_DONATE_BUTTON) ) { Text(text = donateLabel) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/mandate/BankTransferMandateFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/mandate/BankTransferMandateFragment.kt index 8c728b33d1..2d8c074b54 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/mandate/BankTransferMandateFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/mandate/BankTransferMandateFragment.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -58,6 +59,7 @@ import org.signal.core.ui.compose.SignalIcons import org.signal.core.ui.compose.Texts import org.signal.core.ui.compose.theme.SignalTheme import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.DonationTransferTestTags import org.thoughtcrime.securesms.compose.StatusBarColorAnimator import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData import org.thoughtcrime.securesms.util.SpanUtil @@ -267,6 +269,7 @@ fun BankTransferScreen( .wrapContentWidth() .padding(top = 16.dp, bottom = 16.dp) .defaultMinSize(minWidth = 220.dp) + .testTag(DonationTransferTestTags.SEPA_MANDATE_CONTINUE_BUTTON) ) { Text(text = if (listState.canScrollForward) stringResource(id = R.string.BankTransferMandateFragment__read_more) else stringResource(id = R.string.BankTransferMandateFragment__agree)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt index 32307e7440..b52bcd61a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt @@ -498,6 +498,7 @@ object AppDependencies { fun provideProfileService(profileOperations: ClientZkProfileOperations, authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): ProfileService fun provideDeadlockDetector(): DeadlockDetector fun provideClientZkReceiptOperations(signalServiceConfiguration: SignalServiceConfiguration): ClientZkReceiptOperations + fun provideOkHttpClient(): OkHttpClient fun provideScheduledMessageManager(): ScheduledMessageManager fun providePinnedMessageManager(): PinnedMessageManager fun provideLibsignalNetwork(config: SignalServiceConfiguration): Network diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index 33412891f1..bedbb4dd94 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -9,6 +9,7 @@ import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; import androidx.media3.exoplayer.ExoPlayer; +import okhttp3.OkHttpClient; import org.jetbrains.annotations.NotNull; import org.signal.billing.BillingFactory; import org.signal.core.models.ServiceId.ACI; @@ -542,6 +543,14 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider { return provideClientZkOperations(signalServiceConfiguration).getReceiptOperations(); } + @Override + public @NonNull OkHttpClient provideOkHttpClient() { + return new OkHttpClient.Builder() + .addInterceptor(new StandardUserAgentInterceptor()) + .dns(SignalServiceNetworkAccess.DNS) + .build(); + } + @Override public @NonNull BillingApi provideBillingApi() { return BillingFactory.create(GooglePlayBillingDependencies.INSTANCE, Environment.Backups.supportsGooglePlayBilling()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt index cc9fb8b430..eb0f9e306b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt @@ -37,7 +37,6 @@ import org.thoughtcrime.securesms.groups.GroupsV2Authorization import org.thoughtcrime.securesms.groups.GroupsV2AuthorizationMemoryValueCache import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.messages.IncomingMessageObserver -import org.thoughtcrime.securesms.net.StandardUserAgentInterceptor import org.thoughtcrime.securesms.payments.Payments import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess import org.thoughtcrime.securesms.push.SignalServiceTrustStore @@ -244,10 +243,7 @@ class NetworkDependenciesModule( } val okHttpClient: OkHttpClient by lazy { - OkHttpClient.Builder() - .addInterceptor(StandardUserAgentInterceptor()) - .dns(SignalServiceNetworkAccess.DNS) - .build() + provider.provideOkHttpClient() } val signalOkHttpClient: OkHttpClient by lazy { diff --git a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt index 4e8f6f5d8b..8fcc51d0f0 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.dependencies import androidx.media3.exoplayer.ExoPlayer import io.mockk.mockk +import okhttp3.OkHttpClient import org.signal.core.util.billing.BillingApi import org.signal.core.util.concurrent.DeadlockDetector import org.signal.core.util.contentproviders.BlobProvider @@ -78,6 +79,10 @@ class MockApplicationDependencyProvider : AppDependencies.Provider { return mockk(relaxed = true) } + override fun provideOkHttpClient(): OkHttpClient { + return mockk(relaxed = true) + } + override fun provideGroupsV2Operations(signalServiceConfiguration: SignalServiceConfiguration): GroupsV2Operations { return mockk(relaxed = true) } diff --git a/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt b/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt index 486c37654c..b0aa51c73f 100644 --- a/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt +++ b/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt @@ -1,8 +1,8 @@ package org.thoughtcrime.securesms.database import org.signal.blurhash.BlurHash -import org.signal.core.models.media.TransformProperties import org.signal.core.models.database.AttachmentId +import org.signal.core.models.media.TransformProperties import org.thoughtcrime.securesms.attachments.Cdn import org.thoughtcrime.securesms.attachments.DatabaseAttachment import org.thoughtcrime.securesms.audio.AudioHash diff --git a/app/src/testShared/org/thoughtcrime/securesms/testing/endpoints/DonationResponses.kt b/app/src/testShared/org/thoughtcrime/securesms/testing/endpoints/DonationResponses.kt new file mode 100644 index 0000000000..e538a23bcf --- /dev/null +++ b/app/src/testShared/org/thoughtcrime/securesms/testing/endpoints/DonationResponses.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.testing.endpoints + +/** + * Canned JSON bodies for the Signal-service (websocket) edge, matching the shapes parsed by + * [org.whispersystems.signalservice.api.donations.DonationsApi]. Used by handlers registered on an + * [EndpointResponder] to model "what the Signal service returns" for a scenario. + */ +object DonationResponses { + + /** + * A [org.whispersystems.signalservice.api.subscriptions.StripeClientSecret] body. The client + * derives the intent id by stripping `_secret...`, so the id embedded here must line up with the + * Stripe intent the [StripeResponses] handlers serve. + */ + fun stripeClientSecret(clientSecret: String = "${StripeResponses.DEFAULT_PAYMENT_INTENT_ID}_secret_test"): String { + return """{"clientSecret":"$clientSecret"}""" + } + + /** A setup-intent client secret keyed to the default Stripe setup intent. */ + fun setupIntentClientSecret(clientSecret: String = "${StripeResponses.DEFAULT_SETUP_INTENT_ID}_secret_test"): String { + return """{"clientSecret":"$clientSecret"}""" + } + + /** + * A [org.whispersystems.signalservice.api.donations.ReceiptCredentialResponseJson] body. The + * [base64Credential] must be a valid credential minted by the test zk server against the same + * params the client validates with. + */ + fun receiptCredential(base64Credential: String): String { + return """{"receiptCredentialResponse":"$base64Credential"}""" + } + + /** + * A [org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentIntentResponse]. The + * approval URL deliberately does NOT match PayPal's return URLs, so the confirmation webview stays + * up (rather than auto-dismissing) until the test injects the result. + */ + fun payPalCreateIntent(approvalUrl: String = "https://example.com/paypal/approve", paymentId: String = "PAYID-test"): String { + return """{"approvalUrl":"$approvalUrl","paymentId":"$paymentId"}""" + } + + /** A [org.whispersystems.signalservice.api.subscriptions.PayPalConfirmPaymentIntentResponse]. */ + fun payPalConfirmIntent(paymentId: String = "PAYID-test"): String = """{"paymentId":"$paymentId"}""" + + /** A [org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentMethodResponse]. */ + fun payPalCreatePaymentMethod(approvalUrl: String = "https://example.com/paypal/approve", token: String = "BA-test"): String { + return """{"approvalUrl":"$approvalUrl","token":"$token"}""" + } + + /** A [org.whispersystems.signalservice.internal.push.BankMandate] (SEPA legal text). */ + fun bankMandate(text: String = "Test SEPA mandate."): String = """{"mandate":"$text"}""" + + /** An empty [org.whispersystems.signalservice.api.subscriptions.ActiveSubscription] (no subscription). */ + fun emptySubscription(): String = """{"subscription":null,"chargeFailure":null}""" + + /** + * An [org.whispersystems.signalservice.api.subscriptions.ActiveSubscription] with an active or + * incomplete subscription. [status] is the Stripe subscription status ("active", "incomplete", ...); + * [active] whether the subscription is currently active. + */ + fun activeSubscription( + level: Int = 200, + currency: String = "USD", + amount: Long = 1, + status: String = "active", + active: Boolean = true, + endOfCurrentPeriodSeconds: Long = (System.currentTimeMillis() / 1000) + 30L * 24 * 60 * 60, + processor: String = "STRIPE", + paymentMethod: String = "CARD", + paymentPending: Boolean = false + ): String { + return """ + {"subscription":{ + "level":$level, + "currency":"$currency", + "amount":$amount, + "endOfCurrentPeriod":$endOfCurrentPeriodSeconds, + "active":$active, + "billingCycleAnchor":$endOfCurrentPeriodSeconds, + "cancelAtPeriodEnd":false, + "status":"$status", + "processor":"$processor", + "paymentMethod":"$paymentMethod", + "paymentPending":$paymentPending + },"chargeFailure":null} + """.trimIndent() + } +} + +/** + * Registers the Signal-service PayPal handlers for a successful flow: create/confirm one-time intent + * and create recurring payment method. (The default-payment-method and redeem handlers are covered by + * the [org.thoughtcrime.securesms.testing.InAppPaymentsRule] defaults.) + */ +fun EndpointResponder.registerPayPalHappyPath() { + post("/v1/subscription/boost/paypal/create") { ok(DonationResponses.payPalCreateIntent()) } + post("/v1/subscription/boost/paypal/confirm") { ok(DonationResponses.payPalConfirmIntent()) } + register({ it.method == "POST" && it.path.contains("/create_payment_method/paypal") }) { ok(DonationResponses.payPalCreatePaymentMethod()) } +} diff --git a/app/src/testShared/org/thoughtcrime/securesms/testing/endpoints/DonationTestServer.kt b/app/src/testShared/org/thoughtcrime/securesms/testing/endpoints/DonationTestServer.kt new file mode 100644 index 0000000000..7cdb98f12a --- /dev/null +++ b/app/src/testShared/org/thoughtcrime/securesms/testing/endpoints/DonationTestServer.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.testing.endpoints + +import org.signal.core.util.Base64 +import org.signal.libsignal.zkgroup.ServerSecretParams +import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations +import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest +import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations + +/** + * Test-side stand-in for the server's donation zk operations. + * + * Generates its own [ServerSecretParams] so it can issue receipt credentials that validate against + * [clientReceiptOperations]. The instrumentation dependency provider installs + * [clientReceiptOperations] in place of the production `ClientZkReceiptOperations`, so an integration + * test mints genuinely valid credentials and the real client-side validation in the receipt-credential + * context job runs for real — only the issuing params are swapped for test ones. + */ +object DonationTestServer { + + private const val SECONDS_PER_DAY = 86_400L + + private val serverSecretParams: ServerSecretParams = ServerSecretParams.generate() + private val serverReceiptOperations = ServerZkReceiptOperations(serverSecretParams) + + /** Installed by the instrumentation provider so client validation matches credentials issued here. */ + val clientReceiptOperations: ClientZkReceiptOperations = ClientZkReceiptOperations(serverSecretParams.publicParams) + + /** + * Issues a valid receipt credential for the client's serialized [requestBase64], returning the + * base64 body the client expects in a `receiptCredentialResponse` field. The expiration is aligned + * to a day boundary as libsignal requires. + */ + fun issueReceiptCredential( + requestBase64: String, + receiptLevel: Long = 1L, + receiptExpiration: Long = dayAlignedExpiration() + ): String { + val request = ReceiptCredentialRequest(Base64.decode(requestBase64)) + val response = serverReceiptOperations.issueReceiptCredential(request, receiptExpiration, receiptLevel) + return Base64.encodeWithPadding(response.serialize()) + } + + private fun dayAlignedExpiration(): Long { + val nowSeconds = System.currentTimeMillis() / 1000 + val ninetyDays = 90 * SECONDS_PER_DAY + return ((nowSeconds + ninetyDays) / SECONDS_PER_DAY) * SECONDS_PER_DAY + } +} diff --git a/app/src/testShared/org/thoughtcrime/securesms/testing/endpoints/EndpointResponder.kt b/app/src/testShared/org/thoughtcrime/securesms/testing/endpoints/EndpointResponder.kt new file mode 100644 index 0000000000..88da86a1f3 --- /dev/null +++ b/app/src/testShared/org/thoughtcrime/securesms/testing/endpoints/EndpointResponder.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.testing.endpoints + +import java.util.concurrent.CopyOnWriteArrayList + +/** + * Transport an [EndpointRequest] arrived on. Handlers may match on this to disambiguate the two + * outer network edges the donation pipeline talks to: Stripe over HTTP and the Signal service over + * the websocket. + */ +enum class EndpointTransport { + HTTP, + WEBSOCKET +} + +/** + * A normalized view of an outbound request, whether it originated from OkHttp (Stripe) or the + * websocket transport (Signal service). Handlers registered on an [EndpointResponder] are matched + * against this shape so a single "state of the world" can drive both edges. + */ +class EndpointRequest( + val method: String, + val path: String, + val host: String?, + val bodyBytes: ByteArray, + val headers: Map, + val transport: EndpointTransport +) { + val bodyString: String + get() = String(bodyBytes) +} + +/** + * The canned response a handler returns for a matched [EndpointRequest]. [simulateIoFailure] models + * a transport-level failure (OkHttp throws [java.io.IOException]; the websocket emits an error) + * rather than an HTTP status. + */ +class EndpointResponse( + val status: Int, + val body: String = "", + val headers: Map = emptyMap(), + val simulateIoFailure: Boolean = false +) + +/** + * A registry of `(request predicate) -> response` handlers shared by the HTTP interceptor + * ([ResponderInterceptor]) and the fake websocket ([ResponderWebSocketConnection]). This is the + * single source of truth for "what each endpoint returns" in an integration test — the successor to + * the removed MockWebServer `ResponseMocking` helpers, feeding an interceptor and a websocket fake + * instead of a real server. + * + * Handlers are consulted in reverse registration order; the last-registered match wins, so a base + * rule can register defaults and a test can override them by registering a more specific handler + * afterwards. An unmatched request yields a 500 so a missing stub surfaces loudly rather than + * silently succeeding. + */ +class EndpointResponder { + + private class Handler(val matcher: (EndpointRequest) -> Boolean, val factory: (EndpointRequest) -> EndpointResponse) + + private val handlers = CopyOnWriteArrayList() + + fun register(matcher: (EndpointRequest) -> Boolean, factory: (EndpointRequest) -> EndpointResponse) { + handlers += Handler(matcher, factory) + } + + fun respond(request: EndpointRequest): EndpointResponse { + val handler = handlers.lastOrNull { it.matcher(request) } + return handler?.factory?.invoke(request) + ?: EndpointResponse(status = 500, body = """{"error":"No endpoint handler for ${request.method} ${request.path}"}""") + } + + fun clear() { + handlers.clear() + } +} + +/** Register a handler matching [method] with a [path] that the request path starts with. */ +fun EndpointResponder.on(method: String, path: String, factory: (EndpointRequest) -> EndpointResponse) { + register({ it.method.equals(method, ignoreCase = true) && it.path.startsWith(path) }, factory) +} + +fun EndpointResponder.get(path: String, factory: (EndpointRequest) -> EndpointResponse) = on("GET", path, factory) + +fun EndpointResponder.post(path: String, factory: (EndpointRequest) -> EndpointResponse) = on("POST", path, factory) + +fun EndpointResponder.put(path: String, factory: (EndpointRequest) -> EndpointResponse) = on("PUT", path, factory) + +fun EndpointResponder.delete(path: String, factory: (EndpointRequest) -> EndpointResponse) = on("DELETE", path, factory) + +/** Convenience: 2xx with an optional JSON body. */ +fun ok(body: String = "{}"): EndpointResponse = EndpointResponse(status = 200, body = body) + +/** Convenience: a non-2xx status with an optional JSON body. */ +fun failure(status: Int, body: String = ""): EndpointResponse = EndpointResponse(status = status, body = body) + +/** Convenience: a transport-level failure (dropped connection / timeout). */ +fun ioFailure(): EndpointResponse = EndpointResponse(status = 0, simulateIoFailure = true) + +/** + * Process-wide shared responder. The instrumentation dependency provider installs adapters that read + * from [responder]; a test configures it in setup and clears it in teardown (mirroring the old + * `InstrumentationApplicationDependencyProvider.clearHandlers()`). + */ +object MockEndpoints { + val responder = EndpointResponder() +} diff --git a/app/src/testShared/org/thoughtcrime/securesms/testing/endpoints/ResponderInterceptor.kt b/app/src/testShared/org/thoughtcrime/securesms/testing/endpoints/ResponderInterceptor.kt new file mode 100644 index 0000000000..e6f7b8834c --- /dev/null +++ b/app/src/testShared/org/thoughtcrime/securesms/testing/endpoints/ResponderInterceptor.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.testing.endpoints + +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import okio.Buffer +import java.io.IOException + +/** + * OkHttp interceptor that short-circuits requests to [matchHosts] (Stripe) with responses from the + * shared [EndpointResponder], leaving all other requests untouched. Because it sits below + * [org.signal.donations.StripeApi], the real request building and response/error parsing + * (`checkResponseForErrors`, `getNextAction`) run against the canned bodies — so decline, failure, + * and 3DS `next_action` payloads flow through production code exactly as they would live. + */ +class ResponderInterceptor( + private val responder: EndpointResponder, + private val matchHosts: Set = setOf("api.stripe.com") +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + if (request.url.host !in matchHosts) { + return chain.proceed(request) + } + + val bodyBytes = request.body?.let { body -> + Buffer().use { buffer -> + body.writeTo(buffer) + buffer.readByteArray() + } + } ?: ByteArray(0) + + val endpointRequest = EndpointRequest( + method = request.method, + path = request.url.encodedPath, + host = request.url.host, + bodyBytes = bodyBytes, + headers = request.headers.names().associateWith { request.headers[it].orEmpty() }, + transport = EndpointTransport.HTTP + ) + + val response = responder.respond(endpointRequest) + if (response.simulateIoFailure) { + throw IOException("Simulated network failure for ${request.url}") + } + + val builder = Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(response.status) + .message(if (response.status in 200..299) "OK" else "Error") + .body(response.body.toResponseBody("application/json".toMediaType())) + + response.headers.forEach { (key, value) -> builder.header(key, value) } + + return builder.build() + } +} diff --git a/app/src/testShared/org/thoughtcrime/securesms/testing/endpoints/ResponderWebSocketConnection.kt b/app/src/testShared/org/thoughtcrime/securesms/testing/endpoints/ResponderWebSocketConnection.kt new file mode 100644 index 0000000000..e76f544080 --- /dev/null +++ b/app/src/testShared/org/thoughtcrime/securesms/testing/endpoints/ResponderWebSocketConnection.kt @@ -0,0 +1,112 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.testing.endpoints + +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.subjects.BehaviorSubject +import org.signal.network.websocket.WebSocketRequestMessage +import org.signal.network.websocket.WebSocketResponseMessage +import org.signal.network.websocket.WebsocketResponse +import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState +import org.whispersystems.signalservice.internal.websocket.WebSocketConnection +import java.io.IOException +import java.util.Optional +import java.util.concurrent.TimeoutException +import kotlin.time.Duration + +/** + * A [WebSocketConnection] that answers outbound requests from the shared [EndpointResponder] instead + * of a real socket, so the production [org.whispersystems.signalservice.api.donations.DonationsApi] + * (and any other websocket-backed API) runs for real against canned responses — real request + * building and JSON parsing included. + * + * There is no server-push side in the responder model: [readRequest] blocks (capped at + * [MAX_READ_BLOCK_MS] so the read thread stays responsive to disconnect during a test rather than + * parking for the caller's full keep-alive timeout) and then reports a timeout (the normal "no + * incoming messages" path), and [readRequestIfAvailable] returns empty. Model of the send side + * mirrors [org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection]. + */ +class ResponderWebSocketConnection( + private val responder: EndpointResponder +) : WebSocketConnection { + + override val name: String = "responder-${System.identityHashCode(this)}" + + private val state = BehaviorSubject.create() + + override fun connect(): Observable { + state.onNext(WebSocketConnectionState.CONNECTED) + return state + } + + override fun isDead(): Boolean = false + + override fun disconnect() { + state.onNext(WebSocketConnectionState.DISCONNECTED) + } + + override fun sendRequest(request: WebSocketRequestMessage, timeoutSeconds: Long): Single { + val response = responder.respond(request.toEndpointRequest()) + return if (response.simulateIoFailure) { + Single.error(IOException("Simulated websocket failure for ${request.path}")) + } else { + Single.just(response.toWebsocketResponse()) + } + } + + /** + * The coroutine counterpart to [sendRequest]. The suspend API backs, among others, + * [org.whispersystems.signalservice.api.profiles.ProfileApi]; background profile fetches use it, so + * without this override the interface default throws and takes the whole app process down — invisible + * on a warm device whose profile cache is populated, but fatal on a clean Firebase device where the + * orchestrator wipes app data before every test. + */ + override suspend fun sendRequestSuspend(request: WebSocketRequestMessage, timeout: Duration): WebsocketResponse { + val response = responder.respond(request.toEndpointRequest()) + if (response.simulateIoFailure) { + throw IOException("Simulated websocket failure for ${request.path}") + } + return response.toWebsocketResponse() + } + + private fun WebSocketRequestMessage.toEndpointRequest(): EndpointRequest { + return EndpointRequest( + method = verb ?: "GET", + path = path ?: "", + host = null, + bodyBytes = body?.toByteArray() ?: ByteArray(0), + headers = parseHeaders(headers), + transport = EndpointTransport.WEBSOCKET + ) + } + + private fun EndpointResponse.toWebsocketResponse(): WebsocketResponse { + return WebsocketResponse(status, body, headers, false) + } + + override fun sendKeepAlive() = Unit + + override fun readRequestIfAvailable(): Optional = Optional.empty() + + override fun readRequest(timeoutMillis: Long): WebSocketRequestMessage { + Thread.sleep(timeoutMillis.coerceAtMost(MAX_READ_BLOCK_MS)) + throw TimeoutException("No incoming requests") + } + + override fun sendResponse(response: WebSocketResponseMessage) = Unit + + private fun parseHeaders(headers: List): Map { + return headers.mapNotNull { header -> + val separator = header.indexOf(':') + if (separator < 0) null else header.substring(0, separator).trim() to header.substring(separator + 1).trim() + }.toMap() + } + + companion object { + private const val MAX_READ_BLOCK_MS = 1_000L + } +} diff --git a/app/src/testShared/org/thoughtcrime/securesms/testing/endpoints/StripeResponses.kt b/app/src/testShared/org/thoughtcrime/securesms/testing/endpoints/StripeResponses.kt new file mode 100644 index 0000000000..81ec218ee5 --- /dev/null +++ b/app/src/testShared/org/thoughtcrime/securesms/testing/endpoints/StripeResponses.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.testing.endpoints + +/** + * Canned JSON bodies for the Stripe HTTP edge (`api.stripe.com`), matching the shapes parsed by + * [org.signal.donations.StripeApi] / `org.signal.donations.json.*`. Used by handlers registered on + * an [EndpointResponder] to model "what Stripe returns" for a scenario. + */ +object StripeResponses { + + const val DEFAULT_PAYMENT_INTENT_ID = "pi_test" + const val DEFAULT_SETUP_INTENT_ID = "seti_test" + const val DEFAULT_PAYMENT_METHOD_ID = "pm_test" + const val DEFAULT_TOKEN_ID = "tok_test" + + /** A Stripe PaymentIntent object (GET /v1/payment_intents/{id}). */ + fun paymentIntent( + id: String = DEFAULT_PAYMENT_INTENT_ID, + status: String = "succeeded", + paymentMethod: String = DEFAULT_PAYMENT_METHOD_ID + ): String { + return """{"id":"$id","client_secret":"${id}_secret_test","status":"$status","payment_method":"$paymentMethod"}""" + } + + /** A Stripe SetupIntent object (GET /v1/setup_intents/{id}). */ + fun setupIntent( + id: String = DEFAULT_SETUP_INTENT_ID, + status: String = "succeeded", + paymentMethod: String = DEFAULT_PAYMENT_METHOD_ID + ): String { + return """{"id":"$id","client_secret":"${id}_secret_test","status":"$status","payment_method":"$paymentMethod","customer":"cus_test"}""" + } + + /** Response to POST /v1/payment_methods and POST /v1/tokens — the id is all the client parses. */ + fun paymentMethod(id: String = DEFAULT_PAYMENT_METHOD_ID): String = """{"id":"$id"}""" + + fun token(id: String = DEFAULT_TOKEN_ID): String = """{"id":"$id"}""" + + /** A confirm response with no `next_action` — the intent needs no 3DS. */ + fun confirmNoAction(): String = "{}" + + /** A confirm response carrying a 3DS `next_action.redirect_to_url`. */ + fun confirm3dsRequired( + url: String = "https://api.stripe.com/v1/3ds/test", + returnUrl: String = "sgnlpay://3DS" + ): String { + return """{"next_action":{"type":"redirect_to_url","redirect_to_url":{"url":"$url","return_url":"$returnUrl"}}}""" + } + + /** A Stripe error body carrying a decline code (e.g. `card_declined`, `insufficient_funds`). */ + fun declineError(declineCode: String = "card_declined"): String { + return """{"error":{"code":"card_declined","decline_code":"$declineCode","message":"Your card was declined."}}""" + } + + /** A Stripe error body carrying a bank-transfer failure code. */ + fun failureError(failureCode: String): String { + return """{"error":{"code":"$failureCode","failure_code":"$failureCode","message":"The payment failed."}}""" + } + + /** A generic Stripe error body carrying only an error code. */ + fun genericError(code: String = "processing_error"): String { + return """{"error":{"code":"$code","message":"An error occurred."}}""" + } +} + +/** + * Registers the Stripe HTTP handlers for a fully successful, no-3DS payment/setup: create payment + * method + token, confirm with no `next_action`, and a succeeded intent on lookup. A scenario can + * override any of these by registering a more specific handler afterwards (e.g. a decline). + */ +fun EndpointResponder.registerStripeHappyPath() { + post("/v1/payment_methods") { ok(StripeResponses.paymentMethod()) } + post("/v1/tokens") { ok(StripeResponses.token()) } + register({ it.method == "POST" && it.path.contains("/payment_intents/") && it.path.endsWith("/confirm") }) { ok(StripeResponses.confirmNoAction()) } + register({ it.method == "POST" && it.path.contains("/setup_intents/") && it.path.endsWith("/confirm") }) { ok(StripeResponses.confirmNoAction()) } + register({ it.method == "GET" && it.path.contains("/payment_intents/") }) { ok(StripeResponses.paymentIntent()) } + register({ it.method == "GET" && it.path.contains("/setup_intents/") }) { ok(StripeResponses.setupIntent()) } +}