mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-07-21 04:54:41 +01:00
Add donation checkout spec test suite.
Co-authored-by: Cody Henthorne <cody@signal.org>
This commit is contained in:
committed by
Greyson Parrelli
parent
fe6a74eb56
commit
5e3a34e1bc
@@ -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
|
||||
|
||||
+8
@@ -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()
|
||||
|
||||
+21
-68
@@ -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<CheckoutFlowActivity>(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
|
||||
}
|
||||
}
|
||||
|
||||
+99
@@ -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<CheckoutFlowActivity>(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()
|
||||
}
|
||||
}
|
||||
+280
@@ -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<CheckoutFlowActivity>, 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<CheckoutFlowActivity>, 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<CheckoutFlowActivity>, 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 <T : Fragment> awaitDialogAndSetResult(
|
||||
scenario: ActivityScenario<CheckoutFlowActivity>,
|
||||
dialogClass: Class<T>,
|
||||
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<NavHostFragment>()
|
||||
.firstOrNull()
|
||||
?.childFragmentManager
|
||||
}
|
||||
|
||||
private fun TestPaymentMethod.usesStripe(): Boolean {
|
||||
return this != TestPaymentMethod.PAYPAL
|
||||
}
|
||||
}
|
||||
+36
-3
@@ -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<CheckoutFlowActivity>.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 {
|
||||
|
||||
+282
@@ -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<CheckoutFlowActivity>(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<InAppPaymentReceiptRecord> {
|
||||
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<DonationsCheckoutTestSpec> = 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<DonationsCheckoutTestSpec> = 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
|
||||
)
|
||||
)
|
||||
}
|
||||
+159
@@ -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
|
||||
}
|
||||
+58
-3
@@ -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<SignalServiceConfiguration>,
|
||||
libSignalNetworkSupplier: Supplier<Network>
|
||||
): SignalWebSocket.AuthenticatedWebSocket {
|
||||
return SignalWebSocket.AuthenticatedWebSocket(
|
||||
connectionFactory = { ResponderWebSocketConnection(MockEndpoints.responder) },
|
||||
canConnect = { true },
|
||||
sleepTimer = UptimeSleepTimer(),
|
||||
disconnectTimeoutMs = 15.seconds.inWholeMilliseconds
|
||||
)
|
||||
}
|
||||
|
||||
override fun provideUnauthWebSocket(
|
||||
signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>,
|
||||
libSignalNetworkSupplier: Supplier<Network>
|
||||
): 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(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -78,7 +78,6 @@ class NoOpJob(parameters: Parameters) : Job(parameters) {
|
||||
StoryOnboardingDownloadJob.KEY
|
||||
)
|
||||
|
||||
fun replaceFactories(factories: Map<String, Job.Factory<*>>): Map<String, Job.Factory<*>> =
|
||||
factories.mapValues { if (it.key in STARTUP_NETWORK_JOB_KEYS) Factory() else it.value }
|
||||
fun replaceFactories(factories: Map<String, Job.Factory<*>>): Map<String, Job.Factory<*>> = factories.mapValues { if (it.key in STARTUP_NETWORK_JOB_KEYS) Factory() else it.value }
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+29
@@ -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"
|
||||
}
|
||||
+7
@@ -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)
|
||||
}
|
||||
|
||||
+6
@@ -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)
|
||||
}
|
||||
|
||||
+3
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+9
@@ -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());
|
||||
|
||||
+1
-5
@@ -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 {
|
||||
|
||||
+5
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()) }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<String, String>,
|
||||
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<String, String> = 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<Handler>()
|
||||
|
||||
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()
|
||||
}
|
||||
+66
@@ -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<String> = 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()
|
||||
}
|
||||
}
|
||||
+112
@@ -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<WebSocketConnectionState>()
|
||||
|
||||
override fun connect(): Observable<WebSocketConnectionState> {
|
||||
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<WebsocketResponse> {
|
||||
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<WebSocketRequestMessage> = 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<String>): Map<String, String> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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()) }
|
||||
}
|
||||
Reference in New Issue
Block a user