diff --git a/payments-core/src/main/java/com/stripe/android/payments/core/analytics/ErrorReporter.kt b/payments-core/src/main/java/com/stripe/android/payments/core/analytics/ErrorReporter.kt index 57ac226f69e..826ab8fe8a7 100644 --- a/payments-core/src/main/java/com/stripe/android/payments/core/analytics/ErrorReporter.kt +++ b/payments-core/src/main/java/com/stripe/android/payments/core/analytics/ErrorReporter.kt @@ -192,6 +192,9 @@ interface ErrorReporter : FraudDetectionErrorReporter { LINK_ATTACH_CARD_WITH_NULL_ACCOUNT( partialEventName = "link.create_new_card.missing_link_account" ), + LINK_WEB_FAILED_TO_PARSE_RESULT_URI( + partialEventName = "link.web.result.parsing_failed" + ), PAYMENT_SHEET_AUTHENTICATORS_NOT_FOUND( partialEventName = "paymentsheet.authenticators.not_found" ), diff --git a/paymentsheet/src/main/java/com/stripe/android/link/LinkActivityContract.kt b/paymentsheet/src/main/java/com/stripe/android/link/LinkActivityContract.kt index 7c32a8ee3ff..e442fd51ebf 100644 --- a/paymentsheet/src/main/java/com/stripe/android/link/LinkActivityContract.kt +++ b/paymentsheet/src/main/java/com/stripe/android/link/LinkActivityContract.kt @@ -3,50 +3,29 @@ package com.stripe.android.link import android.content.Context import android.content.Intent import androidx.activity.result.contract.ActivityResultContract -import com.stripe.android.PaymentConfiguration import com.stripe.android.core.utils.FeatureFlags -import com.stripe.android.link.serialization.PopupPayload -import com.stripe.android.networking.StripeRepository import javax.inject.Inject internal class LinkActivityContract @Inject internal constructor( - private val stripeRepository: StripeRepository, + private val nativeLinkActivityContract: NativeLinkActivityContract, + private val webLinkActivityContract: WebLinkActivityContract ) : ActivityResultContract() { override fun createIntent(context: Context, input: Args): Intent { return if (FeatureFlags.nativeLinkEnabled.isEnabled) { - nativeIntent(context, input) + nativeLinkActivityContract.createIntent(context, input) } else { - webIntent(context, input) + webLinkActivityContract.createIntent(context, input) } } override fun parseResult(resultCode: Int, intent: Intent?): LinkActivityResult { - return createLinkActivityResult(resultCode, intent) - } - - private fun webIntent(context: Context, input: Args): Intent { - val paymentConfiguration = PaymentConfiguration.getInstance(context) - val payload = PopupPayload.create( - configuration = input.configuration, - context = context, - publishableKey = paymentConfiguration.publishableKey, - stripeAccount = paymentConfiguration.stripeAccountId, - paymentUserAgent = stripeRepository.buildPaymentUserAgent(), - ) - return LinkForegroundActivity.createIntent(context, payload.toUrl()) - } - - private fun nativeIntent(context: Context, input: Args): Intent { - val paymentConfiguration = PaymentConfiguration.getInstance(context) - return LinkActivity.createIntent( - context = context, - args = NativeLinkArgs( - configuration = input.configuration, - stripeAccountId = paymentConfiguration.stripeAccountId, - publishableKey = paymentConfiguration.publishableKey - ) - ) + val redirectUri = intent?.data + return if (redirectUri != null) { + webLinkActivityContract.parseResult(resultCode, intent) + } else { + nativeLinkActivityContract.parseResult(resultCode, intent) + } } data class Args internal constructor( diff --git a/paymentsheet/src/main/java/com/stripe/android/link/LinkActivityResult.kt b/paymentsheet/src/main/java/com/stripe/android/link/LinkActivityResult.kt index d960b7e3504..dcf4e7d2cab 100644 --- a/paymentsheet/src/main/java/com/stripe/android/link/LinkActivityResult.kt +++ b/paymentsheet/src/main/java/com/stripe/android/link/LinkActivityResult.kt @@ -1,14 +1,8 @@ package com.stripe.android.link -import android.app.Activity -import android.content.Intent import android.os.Parcelable -import android.util.Base64 -import androidx.core.os.BundleCompat import com.stripe.android.model.PaymentMethod -import com.stripe.android.model.parsers.PaymentMethodJsonParser import kotlinx.parcelize.Parcelize -import org.json.JSONObject internal sealed class LinkActivityResult : Parcelable { /** @@ -47,73 +41,3 @@ internal sealed class LinkActivityResult : Parcelable { val error: Throwable ) : LinkActivityResult() } - -internal fun createLinkActivityResult(resultCode: Int, intent: Intent?): LinkActivityResult { - return when (resultCode) { - Activity.RESULT_CANCELED -> { - LinkActivityResult.Canceled() - } - - LinkForegroundActivity.RESULT_FAILURE -> { - val exception = intent?.extras?.let { - BundleCompat.getSerializable( - it, - LinkForegroundActivity.EXTRA_FAILURE, - Exception::class.java - ) - } - if (exception != null) { - LinkActivityResult.Failed(exception) - } else { - LinkActivityResult.Canceled() - } - } - - LinkForegroundActivity.RESULT_COMPLETE -> { - val redirectUri = intent?.data ?: return LinkActivityResult.Canceled() - when (redirectUri.getQueryParameter("link_status")) { - "complete" -> { - val paymentMethod = redirectUri.getQueryParameter("pm") - ?.parsePaymentMethod() - if (paymentMethod == null) { - LinkActivityResult.Canceled() - } else { - LinkActivityResult.PaymentMethodObtained(paymentMethod) - } - } - - "logout" -> { - LinkActivityResult.Canceled(LinkActivityResult.Canceled.Reason.LoggedOut) - } - - else -> { - LinkActivityResult.Canceled() - } - } - } - - LinkActivity.RESULT_COMPLETE -> { - handleNativeLinkResult(intent) - } - - else -> { - LinkActivityResult.Canceled() - } - } -} - -private fun handleNativeLinkResult(intent: Intent?): LinkActivityResult { - val result = intent?.extras?.let { - BundleCompat.getParcelable(it, LinkActivityContract.EXTRA_RESULT, LinkActivityResult::class.java) - } - return result ?: LinkActivityResult.Canceled() -} - -private fun String.parsePaymentMethod(): PaymentMethod? = try { - val decodedPaymentMethod = String(Base64.decode(this, 0), Charsets.UTF_8) - val paymentMethod = PaymentMethodJsonParser() - .parse(JSONObject(decodedPaymentMethod)) - paymentMethod -} catch (e: Exception) { - null -} diff --git a/paymentsheet/src/main/java/com/stripe/android/link/NativeLinkActivityContract.kt b/paymentsheet/src/main/java/com/stripe/android/link/NativeLinkActivityContract.kt new file mode 100644 index 00000000000..7e360ce8c67 --- /dev/null +++ b/paymentsheet/src/main/java/com/stripe/android/link/NativeLinkActivityContract.kt @@ -0,0 +1,43 @@ +package com.stripe.android.link + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.activity.result.contract.ActivityResultContract +import androidx.core.os.BundleCompat +import com.stripe.android.PaymentConfiguration +import javax.inject.Inject + +internal class NativeLinkActivityContract @Inject constructor() : + ActivityResultContract() { + override fun createIntent(context: Context, input: LinkActivityContract.Args): Intent { + val paymentConfiguration = PaymentConfiguration.getInstance(context) + return LinkActivity.createIntent( + context = context, + args = NativeLinkArgs( + configuration = input.configuration, + stripeAccountId = paymentConfiguration.stripeAccountId, + publishableKey = paymentConfiguration.publishableKey + ) + ) + } + + override fun parseResult(resultCode: Int, intent: Intent?): LinkActivityResult { + return when (resultCode) { + Activity.RESULT_CANCELED -> { + LinkActivityResult.Canceled() + } + + LinkActivity.RESULT_COMPLETE -> { + val result = intent?.extras?.let { + BundleCompat.getParcelable(it, LinkActivityContract.EXTRA_RESULT, LinkActivityResult::class.java) + } + return result ?: LinkActivityResult.Canceled() + } + + else -> { + LinkActivityResult.Canceled() + } + } + } +} diff --git a/paymentsheet/src/main/java/com/stripe/android/link/WebLinkActivityContract.kt b/paymentsheet/src/main/java/com/stripe/android/link/WebLinkActivityContract.kt new file mode 100644 index 00000000000..efe6b80b9a6 --- /dev/null +++ b/paymentsheet/src/main/java/com/stripe/android/link/WebLinkActivityContract.kt @@ -0,0 +1,98 @@ +package com.stripe.android.link + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.util.Base64 +import androidx.activity.result.contract.ActivityResultContract +import androidx.core.os.BundleCompat +import com.stripe.android.PaymentConfiguration +import com.stripe.android.core.exception.StripeException +import com.stripe.android.link.serialization.PopupPayload +import com.stripe.android.model.PaymentMethod +import com.stripe.android.model.parsers.PaymentMethodJsonParser +import com.stripe.android.networking.StripeRepository +import com.stripe.android.payments.core.analytics.ErrorReporter +import org.json.JSONObject +import javax.inject.Inject + +internal class WebLinkActivityContract @Inject internal constructor( + private val stripeRepository: StripeRepository, + private val errorReporter: ErrorReporter +) : ActivityResultContract() { + + override fun createIntent(context: Context, input: LinkActivityContract.Args): Intent { + val paymentConfiguration = PaymentConfiguration.getInstance(context) + val payload = PopupPayload.create( + configuration = input.configuration, + context = context, + publishableKey = paymentConfiguration.publishableKey, + stripeAccount = paymentConfiguration.stripeAccountId, + paymentUserAgent = stripeRepository.buildPaymentUserAgent(), + ) + return LinkForegroundActivity.createIntent(context, payload.toUrl()) + } + + override fun parseResult(resultCode: Int, intent: Intent?): LinkActivityResult { + return when (resultCode) { + LinkForegroundActivity.RESULT_FAILURE -> { + val exception = intent?.extras?.let { + BundleCompat.getSerializable( + it, + LinkForegroundActivity.EXTRA_FAILURE, + Exception::class.java + ) + } + if (exception != null) { + LinkActivityResult.Failed(exception) + } else { + LinkActivityResult.Canceled() + } + } + + LinkForegroundActivity.RESULT_COMPLETE -> { + val redirectUri = intent?.data ?: return LinkActivityResult.Canceled() + when (redirectUri.getQueryParameter("link_status")) { + "complete" -> { + val paymentMethod = redirectUri.getQueryParameter("pm") + ?.parsePaymentMethod() + if (paymentMethod == null) { + LinkActivityResult.Canceled() + } else { + LinkActivityResult.PaymentMethodObtained(paymentMethod) + } + } + + "logout" -> { + LinkActivityResult.Canceled(LinkActivityResult.Canceled.Reason.LoggedOut) + } + + else -> { + LinkActivityResult.Canceled() + } + } + } + + Activity.RESULT_CANCELED -> { + LinkActivityResult.Canceled() + } + else -> { + LinkActivityResult.Canceled() + } + } + } + + @SuppressWarnings("TooGenericExceptionCaught") + private fun String.parsePaymentMethod(): PaymentMethod? = try { + val decodedPaymentMethod = String(Base64.decode(this, 0), Charsets.UTF_8) + val paymentMethod = PaymentMethodJsonParser() + .parse(JSONObject(decodedPaymentMethod)) + paymentMethod + } catch (e: Throwable) { + errorReporter.report( + errorEvent = ErrorReporter.UnexpectedErrorEvent.LINK_WEB_FAILED_TO_PARSE_RESULT_URI, + stripeException = object : StripeException(cause = e) {} + ) + null + } +} diff --git a/paymentsheet/src/test/java/com/stripe/android/link/LinkActivityContractTest.kt b/paymentsheet/src/test/java/com/stripe/android/link/LinkActivityContractTest.kt index c26b8a80869..d3d64a06726 100644 --- a/paymentsheet/src/test/java/com/stripe/android/link/LinkActivityContractTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/link/LinkActivityContractTest.kt @@ -1,114 +1,105 @@ package com.stripe.android.link -import androidx.core.os.BundleCompat +import android.content.Intent +import android.net.Uri import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat -import com.stripe.android.PaymentConfiguration import com.stripe.android.core.utils.FeatureFlags -import com.stripe.android.model.PaymentIntentFixtures -import com.stripe.android.networking.StripeRepository import com.stripe.android.testing.FeatureFlagTestRule -import org.junit.After -import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito.mock -import org.mockito.kotlin.any +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class LinkActivityContractTest { + private val args = LinkActivityContract.Args(TestFactory.LINK_CONFIGURATION) + @get:Rule val featureFlagTestRule = FeatureFlagTestRule( featureFlag = FeatureFlags.nativeLinkEnabled, isEnabled = false ) - @Before - fun before() { - PaymentConfiguration.init( - context = ApplicationProvider.getApplicationContext(), - publishableKey = "pk_test_abcdefg", - ) - } + @Test + fun `LinkActivityContract creates intent with URL with native link disabled`() { + featureFlagTestRule.setEnabled(false) + + val expectedIntent = Intent() + val webLinkActivityContract = mock() + whenever(webLinkActivityContract.createIntent(ApplicationProvider.getApplicationContext(), args)) + .thenReturn(expectedIntent) + + val contract = linkActivityContract(webLinkActivityContract = webLinkActivityContract) - @After - fun after() { - PaymentConfiguration.clearInstance() + val actualIntent = contract.createIntent(ApplicationProvider.getApplicationContext(), args) + + assertThat(expectedIntent).isEqualTo(actualIntent) } @Test - fun `LinkActivityContract creates intent with URL with native link disabled`() { + fun `LinkActivityContract parses result with webLinkActivityContract`() { featureFlagTestRule.setEnabled(false) - val config = LinkConfiguration( - stripeIntent = PaymentIntentFixtures.PI_SUCCEEDED, - merchantName = "Merchant, Inc", - merchantCountryCode = "US", - customerInfo = LinkConfiguration.CustomerInfo( - name = "Name", - email = "customer@email.com", - phone = "1234567890", - billingCountryCode = "US", - ), - shippingDetails = null, - passthroughModeEnabled = false, - flags = emptyMap(), - cardBrandChoice = null, - ) - val args = LinkActivityContract.Args( - config, - ) - val stripeRepository = mock() - whenever(stripeRepository.buildPaymentUserAgent(any())).thenReturn("test") - val contract = LinkActivityContract(stripeRepository) + val expectedIntent = Intent() + val args = LinkActivityContract.Args(TestFactory.LINK_CONFIGURATION) + val webLinkActivityContract = mock() + whenever(webLinkActivityContract.createIntent(ApplicationProvider.getApplicationContext(), args)) + .thenReturn(expectedIntent) + + val contract = linkActivityContract(webLinkActivityContract = webLinkActivityContract) + val intent = contract.createIntent(ApplicationProvider.getApplicationContext(), args) - assertThat(intent.component?.className).isEqualTo(LinkForegroundActivity::class.java.name) - assertThat(intent.extras?.getString(LinkForegroundActivity.EXTRA_POPUP_URL)).startsWith( - "https://checkout.link.com/#" - ) + intent.data = Uri.EMPTY + + contract.parseResult(0, intent) + + verify(webLinkActivityContract).parseResult(0, intent) } @Test fun `LinkActivityContract creates intent with with NativeLinkArgs when native link is enabled`() { featureFlagTestRule.setEnabled(true) - val config = LinkConfiguration( - stripeIntent = PaymentIntentFixtures.PI_SUCCEEDED, - merchantName = "Merchant, Inc", - merchantCountryCode = "US", - customerInfo = LinkConfiguration.CustomerInfo( - name = "Name", - email = "customer@email.com", - phone = "1234567890", - billingCountryCode = "US", - ), - shippingDetails = null, - passthroughModeEnabled = false, - flags = emptyMap(), - cardBrandChoice = null, - ) - val args = LinkActivityContract.Args( - config, - ) - val stripeRepository = mock() - whenever(stripeRepository.buildPaymentUserAgent(any())).thenReturn("test") - val contract = LinkActivityContract(stripeRepository) + val expectedIntent = Intent() + val nativeLinkActivityContract = mock() + whenever(nativeLinkActivityContract.createIntent(ApplicationProvider.getApplicationContext(), args)) + .thenReturn(expectedIntent) + + val contract = linkActivityContract(nativeLinkActivityContract = nativeLinkActivityContract) + + val actualIntent = contract.createIntent(ApplicationProvider.getApplicationContext(), args) + + assertThat(expectedIntent).isEqualTo(actualIntent) + } + + @Test + fun `LinkActivityContract parses result with nativeLinkActivityContract`() { + featureFlagTestRule.setEnabled(true) + + val nativeLinkActivityContract = mock() + whenever(nativeLinkActivityContract.createIntent(ApplicationProvider.getApplicationContext(), args)) + .thenReturn(Intent()) + + val contract = linkActivityContract(nativeLinkActivityContract = nativeLinkActivityContract) + val intent = contract.createIntent(ApplicationProvider.getApplicationContext(), args) - assertThat(intent.component?.className).isEqualTo(LinkActivity::class.java.name) - - val actualArg = intent.extras?.let { - BundleCompat.getParcelable(it, LinkActivity.EXTRA_ARGS, NativeLinkArgs::class.java) - } - assertThat(actualArg).isEqualTo( - NativeLinkArgs( - configuration = config, - publishableKey = "pk_test_abcdefg", - stripeAccountId = null - ) + contract.parseResult(0, intent) + + verify(nativeLinkActivityContract).parseResult(0, intent) + } + + private fun linkActivityContract( + webLinkActivityContract: WebLinkActivityContract = mock(), + nativeLinkActivityContract: NativeLinkActivityContract = mock() + ): LinkActivityContract { + return LinkActivityContract( + nativeLinkActivityContract = nativeLinkActivityContract, + webLinkActivityContract = webLinkActivityContract ) } } diff --git a/paymentsheet/src/test/java/com/stripe/android/link/LinkActivityResultTest.kt b/paymentsheet/src/test/java/com/stripe/android/link/LinkActivityResultTest.kt deleted file mode 100644 index db342cd5852..00000000000 --- a/paymentsheet/src/test/java/com/stripe/android/link/LinkActivityResultTest.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.stripe.android.link - -import android.app.Activity -import android.content.Intent -import androidx.core.net.toUri -import androidx.core.os.bundleOf -import com.google.common.truth.Truth.assertThat -import com.stripe.android.model.PaymentMethodFixtures -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -class LinkActivityResultTest { - - @Test - fun `complete with payment method`() { - val redirectUrl = - "link-popup://complete?link_status=complete&pm=eyJpZCI6InBtXzFOSmVFckx1NW8zUDE4WnBtWHBDdElyUiIsIm9iamVjdCI6InBheW1lbnRfbWV0aG9kIiwiYmlsbGluZ19kZXRhaWxzIjp7ImFkZHJlc3MiOnsiY2l0eSI6bnVsbCwiY291bnRyeSI6bnVsbCwibGluZTEiOm51bGwsImxpbmUyIjpudWxsLCJwb3N0YWxfY29kZSI6bnVsbCwic3RhdGUiOm51bGx9LCJlbWFpbCI6bnVsbCwibmFtZSI6bnVsbCwicGhvbmUiOm51bGx9LCJjYXJkIjp7ImJyYW5kIjoidmlzYSIsImNoZWNrcyI6eyJhZGRyZXNzX2xpbmUxX2NoZWNrIjpudWxsLCJhZGRyZXNzX3Bvc3RhbF9jb2RlX2NoZWNrIjpudWxsLCJjdmNfY2hlY2siOm51bGx9LCJjb3VudHJ5IjpudWxsLCJleHBfbW9udGgiOjEyLCJleHBfeWVhciI6MjAzNCwiZnVuZGluZyI6ImNyZWRpdCIsImdlbmVyYXRlZF9mcm9tIjpudWxsLCJsYXN0NCI6IjAwMDAiLCJuZXR3b3JrcyI6eyJhdmFpbGFibGUiOlsidmlzYSJdLCJwcmVmZXJyZWQiOm51bGx9LCJ0aHJlZV9kX3NlY3VyZV91c2FnZSI6eyJzdXBwb3J0ZWQiOnRydWV9LCJ3YWxsZXQiOnsiZHluYW1pY19sYXN0NCI6bnVsbCwibGluayI6e30sInR5cGUiOiJsaW5rIn19LCJjcmVhdGVkIjoxNjg2OTI4MDIxLCJjdXN0b21lciI6bnVsbCwibGl2ZW1vZGUiOmZhbHNlLCJ0eXBlIjoiY2FyZCJ9ICAg" - val intent = Intent() - intent.data = redirectUrl.toUri() - val result = createLinkActivityResult(LinkForegroundActivity.RESULT_COMPLETE, intent) - assertThat(result).isInstanceOf(LinkActivityResult.PaymentMethodObtained::class.java) - val paymentMethodObtained = result as LinkActivityResult.PaymentMethodObtained - assertThat(paymentMethodObtained.paymentMethod.type?.code).isEqualTo("card") - assertThat(paymentMethodObtained.paymentMethod.card?.last4).isEqualTo("0000") - assertThat(paymentMethodObtained.paymentMethod.id).isEqualTo("pm_1NJeErLu5o3P18ZpmXpCtIrR") - } - - @Test - fun `complete with logout`() { - val redirectUrl = "link-popup://complete?link_status=logout" - val intent = Intent() - intent.data = redirectUrl.toUri() - val result = createLinkActivityResult(LinkForegroundActivity.RESULT_COMPLETE, intent) - assertThat(result).isInstanceOf(LinkActivityResult.Canceled::class.java) - val canceled = result as LinkActivityResult.Canceled - assertThat(canceled.reason).isEqualTo(LinkActivityResult.Canceled.Reason.LoggedOut) - } - - @Test - fun `complete with unknown link_status results in canceled`() { - val redirectUrl = "link-popup://complete?link_status=shrug" - val intent = Intent() - intent.data = redirectUrl.toUri() - val result = createLinkActivityResult(LinkForegroundActivity.RESULT_COMPLETE, intent) - assertThat(result).isInstanceOf(LinkActivityResult.Canceled::class.java) - val canceled = result as LinkActivityResult.Canceled - assertThat(canceled.reason).isEqualTo(LinkActivityResult.Canceled.Reason.BackPressed) - } - - @Test - fun `canceled result code`() { - val result = createLinkActivityResult(Activity.RESULT_CANCELED, null) - assertThat(result).isInstanceOf(LinkActivityResult.Canceled::class.java) - val canceled = result as LinkActivityResult.Canceled - assertThat(canceled.reason).isEqualTo(LinkActivityResult.Canceled.Reason.BackPressed) - } - - @Test - fun `failure with data`() { - val intent = Intent() - intent.putExtra(LinkForegroundActivity.EXTRA_FAILURE, IllegalStateException("Foobar!")) - val result = createLinkActivityResult(LinkForegroundActivity.RESULT_FAILURE, intent) - assertThat(result).isInstanceOf(LinkActivityResult.Failed::class.java) - val failed = result as LinkActivityResult.Failed - assertThat(failed.error).hasMessageThat().isEqualTo("Foobar!") - } - - @Test - fun `failure without data results in canceled`() { - val result = createLinkActivityResult(LinkForegroundActivity.RESULT_FAILURE, null) - assertThat(result).isInstanceOf(LinkActivityResult.Canceled::class.java) - val canceled = result as LinkActivityResult.Canceled - assertThat(canceled.reason).isEqualTo(LinkActivityResult.Canceled.Reason.BackPressed) - } - - @Test - fun `unknown result code results in canceled`() { - val result = createLinkActivityResult(42, null) - assertThat(result).isInstanceOf(LinkActivityResult.Canceled::class.java) - val canceled = result as LinkActivityResult.Canceled - assertThat(canceled.reason).isEqualTo(LinkActivityResult.Canceled.Reason.BackPressed) - } - - @Test - fun `complete with malformed payment method results in canceled`() { - val redirectUrl = - "link-popup://complete?link_status=complete&pm=🤷‍" - val intent = Intent() - intent.data = redirectUrl.toUri() - val result = createLinkActivityResult(LinkForegroundActivity.RESULT_COMPLETE, intent) - assertThat(result).isInstanceOf(LinkActivityResult.Canceled::class.java) - val canceled = result as LinkActivityResult.Canceled - assertThat(canceled.reason).isEqualTo(LinkActivityResult.Canceled.Reason.BackPressed) - } - - @Test - fun `complete with result from native link`() { - val bundle = bundleOf( - LinkActivityContract.EXTRA_RESULT to LinkActivityResult.PaymentMethodObtained( - paymentMethod = PaymentMethodFixtures.CARD_PAYMENT_METHOD - ) - ) - val intent = Intent() - intent.putExtras(bundle) - val result = createLinkActivityResult(LinkActivity.RESULT_COMPLETE, intent) - assertThat(result) - .isEqualTo(LinkActivityResult.PaymentMethodObtained(PaymentMethodFixtures.CARD_PAYMENT_METHOD)) - } - - @Test - fun `complete with canceled result when native link result not found`() { - val intent = Intent() - val result = createLinkActivityResult(LinkActivity.RESULT_COMPLETE, intent) - assertThat(result).isEqualTo(LinkActivityResult.Canceled()) - } -} diff --git a/paymentsheet/src/test/java/com/stripe/android/link/NativeLinkActivityContractTest.kt b/paymentsheet/src/test/java/com/stripe/android/link/NativeLinkActivityContractTest.kt new file mode 100644 index 00000000000..e43c3fb3d03 --- /dev/null +++ b/paymentsheet/src/test/java/com/stripe/android/link/NativeLinkActivityContractTest.kt @@ -0,0 +1,108 @@ +package com.stripe.android.link + +import android.app.Activity +import android.content.Intent +import androidx.core.os.BundleCompat +import androidx.core.os.bundleOf +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.stripe.android.PaymentConfiguration +import com.stripe.android.model.PaymentMethodFixtures +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class NativeLinkActivityContractTest { + + @Before + fun before() { + PaymentConfiguration.init( + context = ApplicationProvider.getApplicationContext(), + publishableKey = "pk_test_abcdefg", + ) + } + + @After + fun after() { + PaymentConfiguration.clearInstance() + } + + @Test + fun `intent is created correctly`() { + val contract = NativeLinkActivityContract() + val args = LinkActivityContract.Args(TestFactory.LINK_CONFIGURATION) + + val intent = contract.createIntent(ApplicationProvider.getApplicationContext(), args) + + assertThat(intent.component?.className).isEqualTo(LinkActivity::class.java.name) + + val actualArg = intent.extras?.let { + BundleCompat.getParcelable(it, LinkActivity.EXTRA_ARGS, NativeLinkArgs::class.java) + } + assertThat(actualArg).isEqualTo( + NativeLinkArgs( + configuration = TestFactory.LINK_CONFIGURATION, + publishableKey = "pk_test_abcdefg", + stripeAccountId = null + ) + ) + } + + @Test + fun `complete with result from native link`() { + val expectedResult = LinkActivityResult.PaymentMethodObtained( + paymentMethod = PaymentMethodFixtures.CARD_PAYMENT_METHOD + ) + + val contract = NativeLinkActivityContract() + + val result = contract.parseResult( + resultCode = LinkActivity.RESULT_COMPLETE, + intent = intent(expectedResult) + ) + + assertThat(result).isEqualTo(expectedResult) + } + + @Test + fun `complete with canceled result when result not found`() { + val contract = NativeLinkActivityContract() + + val result = contract.parseResult( + resultCode = LinkActivity.RESULT_COMPLETE, + intent = Intent() + ) + + assertThat(result).isEqualTo(LinkActivityResult.Canceled()) + } + + @Test + fun `unknown result code results in canceled`() { + val contract = NativeLinkActivityContract() + + val result = contract.parseResult(42, Intent()) + + assertThat(result).isEqualTo(LinkActivityResult.Canceled()) + } + + @Test + fun `canceled result code is handled correctly`() { + val contract = NativeLinkActivityContract() + + val result = contract.parseResult(Activity.RESULT_CANCELED, Intent()) + + assertThat(result).isEqualTo(LinkActivityResult.Canceled()) + } + + private fun intent(result: LinkActivityResult): Intent { + val bundle = bundleOf( + LinkActivityContract.EXTRA_RESULT to result + ) + return Intent().apply { + putExtras(bundle) + } + } +} diff --git a/paymentsheet/src/test/java/com/stripe/android/link/WebLinkActivityContractTest.kt b/paymentsheet/src/test/java/com/stripe/android/link/WebLinkActivityContractTest.kt new file mode 100644 index 00000000000..37b5aff9747 --- /dev/null +++ b/paymentsheet/src/test/java/com/stripe/android/link/WebLinkActivityContractTest.kt @@ -0,0 +1,182 @@ +package com.stripe.android.link + +import android.app.Activity +import android.content.Intent +import androidx.core.net.toUri +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.stripe.android.PaymentConfiguration +import com.stripe.android.customersheet.FakeStripeRepository +import com.stripe.android.networking.StripeRepository +import com.stripe.android.payments.core.analytics.ErrorReporter +import com.stripe.android.testing.AbsFakeStripeRepository +import com.stripe.android.testing.FakeErrorReporter +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class WebLinkActivityContractTest { + + @Before + fun before() { + PaymentConfiguration.init( + context = ApplicationProvider.getApplicationContext(), + publishableKey = "pk_test_abcdefg", + ) + } + + @After + fun after() { + PaymentConfiguration.clearInstance() + } + + @Test + fun `intent is created correctly`() { + val stripeRepository = object : AbsFakeStripeRepository() { + override fun buildPaymentUserAgent(attribution: Set) = "test" + } + val contract = contract(stripeRepository) + val args = LinkActivityContract.Args(TestFactory.LINK_CONFIGURATION) + + val intent = contract.createIntent(ApplicationProvider.getApplicationContext(), args) + + assertThat(intent.component?.className).isEqualTo(LinkForegroundActivity::class.java.name) + assertThat(intent.extras?.getString(LinkForegroundActivity.EXTRA_POPUP_URL)).startsWith( + "https://checkout.link.com/#" + ) + } + + @Test + fun `parse paymentMethodObtained result correctly`() { + val redirectUrl = + "link-popup://complete?link_status=complete&pm=eyJpZCI6InBtXzFOSmVFckx1NW8zUDE4WnBtWHBDdElyUiIsIm9iamVjdC" + + "I6InBheW1lbnRfbWV0aG9kIiwiYmlsbGluZ19kZXRhaWxzIjp7ImFkZHJlc3MiOnsiY2l0eSI6bnVsbCwiY291bnRyeSI6bnV" + + "sbCwibGluZTEiOm51bGwsImxpbmUyIjpudWxsLCJwb3N0YWxfY29kZSI6bnVsbCwic3RhdGUiOm51bGx9LCJlbWFpbCI6bnVs" + + "bCwibmFtZSI6bnVsbCwicGhvbmUiOm51bGx9LCJjYXJkIjp7ImJyYW5kIjoidmlzYSIsImNoZWNrcyI6eyJhZGRyZXNzX2xpb" + + "mUxX2NoZWNrIjpudWxsLCJhZGRyZXNzX3Bvc3RhbF9jb2RlX2NoZWNrIjpudWxsLCJjdmNfY2hlY2siOm51bGx9LCJjb3VudHJ" + + "5IjpudWxsLCJleHBfbW9udGgiOjEyLCJleHBfeWVhciI6MjAzNCwiZnVuZGluZyI6ImNyZWRpdCIsImdlbmVyYXRlZF9mcm9t" + + "IjpudWxsLCJsYXN0NCI6IjAwMDAiLCJuZXR3b3JrcyI6eyJhdmFpbGFibGUiOlsidmlzYSJdLCJwcmVmZXJyZWQiOm51bGx9LC" + + "J0aHJlZV9kX3NlY3VyZV91c2FnZSI6eyJzdXBwb3J0ZWQiOnRydWV9LCJ3YWxsZXQiOnsiZHluYW1pY19sYXN0NCI6bnVsbCw" + + "ibGluayI6e30sInR5cGUiOiJsaW5rIn19LCJjcmVhdGVkIjoxNjg2OTI4MDIxLCJjdXN0b21lciI6bnVsbCwibGl2ZW1vZGU" + + "iOmZhbHNlLCJ0eXBlIjoiY2FyZCJ9ICAg" + val intent = Intent() + intent.data = redirectUrl.toUri() + + val contract = contract() + + val result = contract.parseResult(LinkForegroundActivity.RESULT_COMPLETE, intent) + + assertThat(result).isInstanceOf(LinkActivityResult.PaymentMethodObtained::class.java) + val paymentMethodObtained = result as LinkActivityResult.PaymentMethodObtained + assertThat(paymentMethodObtained.paymentMethod.type?.code).isEqualTo("card") + assertThat(paymentMethodObtained.paymentMethod.card?.last4).isEqualTo("0000") + assertThat(paymentMethodObtained.paymentMethod.id).isEqualTo("pm_1NJeErLu5o3P18ZpmXpCtIrR") + } + + @Test + fun `parse logout result correctly`() { + val redirectUrl = "link-popup://complete?link_status=logout" + val intent = Intent() + intent.data = redirectUrl.toUri() + + val contract = contract() + + val result = contract.parseResult(LinkForegroundActivity.RESULT_COMPLETE, intent) + + assertThat(result).isInstanceOf(LinkActivityResult.Canceled::class.java) + val canceled = result as LinkActivityResult.Canceled + assertThat(canceled.reason).isEqualTo(LinkActivityResult.Canceled.Reason.LoggedOut) + } + + @Test + fun `parse unknown status result correctly`() { + val redirectUrl = "link-popup://complete?link_status=shrug" + val intent = Intent() + intent.data = redirectUrl.toUri() + + val contract = contract() + + val result = contract.parseResult(LinkForegroundActivity.RESULT_COMPLETE, intent) + + assertThat(result).isInstanceOf(LinkActivityResult.Canceled::class.java) + val canceled = result as LinkActivityResult.Canceled + assertThat(canceled.reason).isEqualTo(LinkActivityResult.Canceled.Reason.BackPressed) + } + + @Test + fun `canceled result code`() { + val contract = contract() + + val result = contract.parseResult(Activity.RESULT_CANCELED, null) + + assertThat(result).isInstanceOf(LinkActivityResult.Canceled::class.java) + val canceled = result as LinkActivityResult.Canceled + assertThat(canceled.reason).isEqualTo(LinkActivityResult.Canceled.Reason.BackPressed) + } + + @Test + fun `failure with data`() { + val intent = Intent() + intent.putExtra(LinkForegroundActivity.EXTRA_FAILURE, IllegalStateException("Foobar!")) + + val contract = contract() + + val result = contract.parseResult(LinkForegroundActivity.RESULT_FAILURE, intent) + + assertThat(result).isInstanceOf(LinkActivityResult.Failed::class.java) + val failed = result as LinkActivityResult.Failed + assertThat(failed.error).hasMessageThat().isEqualTo("Foobar!") + } + + @Test + fun `failure without data results in canceled`() { + val contract = contract() + + val result = contract.parseResult(LinkForegroundActivity.RESULT_FAILURE, null) + + assertThat(result).isInstanceOf(LinkActivityResult.Canceled::class.java) + val canceled = result as LinkActivityResult.Canceled + assertThat(canceled.reason).isEqualTo(LinkActivityResult.Canceled.Reason.BackPressed) + } + + @Test + fun `unknown result code results in canceled`() { + val contract = contract() + + val result = contract.parseResult(42, null) + + assertThat(result).isInstanceOf(LinkActivityResult.Canceled::class.java) + val canceled = result as LinkActivityResult.Canceled + assertThat(canceled.reason).isEqualTo(LinkActivityResult.Canceled.Reason.BackPressed) + } + + @Test + fun `complete with malformed payment method results in canceled`() { + val redirectUrl = + "link-popup://complete?link_status=complete&pm=🤷‍" + val intent = Intent() + intent.data = redirectUrl.toUri() + + val errorReporter = FakeErrorReporter() + + val contract = contract(errorReporter = errorReporter) + + val result = contract.parseResult(LinkForegroundActivity.RESULT_COMPLETE, intent) + + assertThat(result).isInstanceOf(LinkActivityResult.Canceled::class.java) + val canceled = result as LinkActivityResult.Canceled + assertThat(canceled.reason).isEqualTo(LinkActivityResult.Canceled.Reason.BackPressed) + assertThat(errorReporter.getLoggedErrors()) + .containsExactly(ErrorReporter.UnexpectedErrorEvent.LINK_WEB_FAILED_TO_PARSE_RESULT_URI.eventName) + } + + private fun contract( + stripeRepository: StripeRepository = FakeStripeRepository(), + errorReporter: FakeErrorReporter = FakeErrorReporter() + ): WebLinkActivityContract { + return WebLinkActivityContract(stripeRepository, errorReporter) + } +}