From 2a483044c895094d334fc51422cbf4f22e2d0af0 Mon Sep 17 00:00:00 2001 From: Samer Alabi Date: Fri, 22 Nov 2024 02:21:31 -0500 Subject: [PATCH 1/2] Move `Google Pay` to a `ConfirmationDefinition` type --- .../DefaultConfirmationHandler.kt | 251 +-------- .../gpay/GooglePayConfirmationDefinition.kt | 179 +++++++ .../GooglePayConfirmationDefinitionTest.kt | 484 ++++++++++++++++++ .../gpay/GooglePayConfirmationFlowTest.kt | 123 +++++ .../DefaultFlowControllerTest.kt | 13 +- ...ngGooglePayPaymentMethodLauncherFactory.kt | 43 ++ 6 files changed, 868 insertions(+), 225 deletions(-) create mode 100644 paymentsheet/src/main/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayConfirmationDefinition.kt create mode 100644 paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayConfirmationDefinitionTest.kt create mode 100644 paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayConfirmationFlowTest.kt diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentelement/confirmation/DefaultConfirmationHandler.kt b/paymentsheet/src/main/java/com/stripe/android/paymentelement/confirmation/DefaultConfirmationHandler.kt index d1f374fa7c2..f416395e85a 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentelement/confirmation/DefaultConfirmationHandler.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentelement/confirmation/DefaultConfirmationHandler.kt @@ -12,16 +12,11 @@ import com.stripe.android.PaymentConfiguration import com.stripe.android.core.exception.StripeException import com.stripe.android.core.strings.resolvableString import com.stripe.android.core.utils.UserFacingLogger -import com.stripe.android.googlepaylauncher.GooglePayEnvironment -import com.stripe.android.googlepaylauncher.GooglePayPaymentMethodLauncher -import com.stripe.android.googlepaylauncher.GooglePayPaymentMethodLauncherContractV2 import com.stripe.android.googlepaylauncher.injection.GooglePayPaymentMethodLauncherFactory -import com.stripe.android.model.PaymentIntent -import com.stripe.android.model.SetupIntent import com.stripe.android.model.StripeIntent import com.stripe.android.paymentelement.confirmation.bacs.BacsConfirmationDefinition import com.stripe.android.paymentelement.confirmation.epms.ExternalPaymentMethodConfirmationDefinition -import com.stripe.android.paymentelement.confirmation.gpay.GooglePayConfirmationOption +import com.stripe.android.paymentelement.confirmation.gpay.GooglePayConfirmationDefinition import com.stripe.android.paymentelement.confirmation.intent.IntentConfirmationDefinition import com.stripe.android.paymentelement.confirmation.intent.IntentConfirmationInterceptor import com.stripe.android.payments.core.analytics.ErrorReporter @@ -29,10 +24,8 @@ import com.stripe.android.payments.paymentlauncher.PaymentLauncher import com.stripe.android.payments.paymentlauncher.PaymentLauncherContract import com.stripe.android.payments.paymentlauncher.StripePaymentLauncherAssistedFactory import com.stripe.android.paymentsheet.ExternalPaymentMethodInterceptor -import com.stripe.android.paymentsheet.PaymentSheet import com.stripe.android.paymentsheet.R import com.stripe.android.paymentsheet.paymentdatacollection.bacs.BacsMandateConfirmationLauncherFactory -import com.stripe.android.paymentsheet.state.PaymentElementLoader import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -61,8 +54,8 @@ internal class DefaultConfirmationHandler( private val errorReporter: ErrorReporter, private val logger: UserFacingLogger? ) : ConfirmationHandler { - private val intentConfirmationRegistry = ConfirmationRegistry( - confirmationDefinitions = listOf( + private val confirmationRegistry = ConfirmationRegistry( + confirmationDefinitions = listOfNotNull( IntentConfirmationDefinition( intentConfirmationInterceptor = intentConfirmationInterceptor, paymentLauncherFactory = paymentLauncherFactory, @@ -75,20 +68,17 @@ internal class DefaultConfirmationHandler( ), BacsConfirmationDefinition( bacsMandateConfirmationLauncherFactory = bacsMandateConfirmationLauncherFactory, - ) + ), + googlePayPaymentMethodLauncherFactory?.let { + GooglePayConfirmationDefinition( + googlePayPaymentMethodLauncherFactory = it, + userFacingLogger = logger, + ) + } ) ) - private val confirmationMediators = intentConfirmationRegistry.createConfirmationMediators(savedStateHandle) - - private var googlePayPaymentMethodLauncher: - ActivityResultLauncher? = null - - private var currentArguments: ConfirmationHandler.Args? - get() = savedStateHandle[ARGUMENTS_KEY] - set(value) { - savedStateHandle[ARGUMENTS_KEY] = value - } + private val confirmationMediators = confirmationRegistry.createConfirmationMediators(savedStateHandle) private val isAwaitingForResultData = retrieveIsAwaitingForResultData() @@ -114,7 +104,7 @@ internal class DefaultConfirmationHandler( _state.value is ConfirmationHandler.State.Confirming && isAwaitingForResultData?.receivesResultInProcess != true ) { - onIntentResult( + onHandlerResult( ConfirmationHandler.Result.Canceled( action = ConfirmationHandler.Result.Canceled.Action.None, ) @@ -135,18 +125,12 @@ internal class DefaultConfirmationHandler( mediator.register(activityResultCaller, ::onResult) } - googlePayPaymentMethodLauncher = activityResultCaller.registerForActivityResult( - GooglePayPaymentMethodLauncherContractV2(), - ::onGooglePayResult - ) - lifecycleOwner.lifecycle.addObserver( object : DefaultLifecycleObserver { override fun onDestroy(owner: LifecycleOwner) { confirmationMediators.forEach { mediator -> mediator.unregister() } - googlePayPaymentMethodLauncher = null super.onDestroy(owner) } } @@ -154,9 +138,9 @@ internal class DefaultConfirmationHandler( } /** - * Starts the confirmation process with a given [Args] instance. Result from this method can be received - * from [awaitIntentResult]. This method cannot return a result since the confirmation process can be handed - * off to another [Activity] to handle after starting it. + * Starts the confirmation process with a given [ConfirmationHandler.Args] instance. Result from this method can + * be received from [awaitIntentResult]. This method cannot return a result since the confirmation process can be + * handed off to another [Activity] to handle after starting it. * * @param arguments arguments required to confirm a Stripe intent */ @@ -171,10 +155,11 @@ internal class DefaultConfirmationHandler( _state.value = ConfirmationHandler.State.Confirming(arguments.confirmationOption) - currentArguments = arguments - coroutineScope.launch { - confirm(arguments) + confirm( + intent = arguments.intent, + confirmationOption = arguments.confirmationOption, + ) } } @@ -196,26 +181,12 @@ internal class DefaultConfirmationHandler( } } - private suspend fun confirm( - arguments: ConfirmationHandler.Args, - ) { - currentArguments = arguments - - _state.value = ConfirmationHandler.State.Confirming(arguments.confirmationOption) - - when (val confirmationOption = arguments.confirmationOption) { - is GooglePayConfirmationOption -> launchGooglePay( - googlePay = confirmationOption, - intent = arguments.intent, - ) - else -> confirm(confirmationOption, arguments.intent) - } - } - private suspend fun confirm( confirmationOption: ConfirmationHandler.Option, intent: StripeIntent, ) { + _state.value = ConfirmationHandler.State.Confirming(confirmationOption) + val mediator = confirmationMediators.find { mediator -> mediator.canConfirm(confirmationOption) } ?: run { @@ -230,7 +201,7 @@ internal class DefaultConfirmationHandler( ), ) - onIntentResult( + onHandlerResult( ConfirmationHandler.Result.Failed( cause = IllegalStateException( "Attempted to confirm invalid ${confirmationOption::class.qualifiedName} confirmation type" @@ -253,7 +224,7 @@ internal class DefaultConfirmationHandler( action.launch() } is ConfirmationMediator.Action.Fail -> { - onIntentResult( + onHandlerResult( ConfirmationHandler.Result.Failed( cause = action.cause, message = action.message, @@ -262,7 +233,7 @@ internal class DefaultConfirmationHandler( ) } is ConfirmationMediator.Action.Complete -> { - onIntentResult( + onHandlerResult( ConfirmationHandler.Result.Succeeded( intent = intent, deferredIntentConfirmationType = action.deferredIntentConfirmationType, @@ -272,163 +243,13 @@ internal class DefaultConfirmationHandler( } } - private fun launchGooglePay( - googlePay: GooglePayConfirmationOption, - intent: StripeIntent, - ) { - if (googlePay.config.merchantCurrencyCode == null && !googlePay.initializationMode.isProcessingPayment) { - val message = "GooglePayConfig.currencyCode is required in order to use " + - "Google Pay when processing a Setup Intent" - - logger?.logWarningWithoutPii(message) - - onIntentResult( - ConfirmationHandler.Result.Failed( - cause = IllegalStateException(message), - message = R.string.stripe_something_went_wrong.resolvableString, - type = ConfirmationHandler.Result.Failed.ErrorType.MerchantIntegration, - ) - ) - - return - } - - val activityLauncher = runCatching { - requireNotNull(googlePayPaymentMethodLauncher) - }.getOrElse { - onIntentResult( - ConfirmationHandler.Result.Failed( - cause = it, - message = R.string.stripe_something_went_wrong.resolvableString, - type = ConfirmationHandler.Result.Failed.ErrorType.Internal - ) - ) - - return - } - - val factory = runCatching { - requireNotNull(googlePayPaymentMethodLauncherFactory) - }.getOrElse { - onIntentResult( - ConfirmationHandler.Result.Failed( - cause = it, - message = R.string.stripe_something_went_wrong.resolvableString, - type = ConfirmationHandler.Result.Failed.ErrorType.Internal - ) - ) - - return - } - - val config = googlePay.config - - val launcher = createGooglePayLauncher( - factory = factory, - activityLauncher = activityLauncher, - config = config, - ) - - storeIsAwaitingForResult( - option = googlePay, - receivesResultInProcess = true, - ) - - launcher.present( - currencyCode = intent.asPaymentIntent()?.currency - ?: config.merchantCurrencyCode.orEmpty(), - amount = when (intent) { - is PaymentIntent -> intent.amount ?: 0L - is SetupIntent -> config.customAmount ?: 0L - }, - transactionId = intent.id, - label = config.customLabel, - ) - } - - private fun createGooglePayLauncher( - factory: GooglePayPaymentMethodLauncherFactory, - activityLauncher: ActivityResultLauncher, - config: GooglePayConfirmationOption.Config, - ): GooglePayPaymentMethodLauncher { - return factory.create( - lifecycleScope = coroutineScope, - config = GooglePayPaymentMethodLauncher.Config( - environment = when (config.environment) { - PaymentSheet.GooglePayConfiguration.Environment.Production -> GooglePayEnvironment.Production - else -> GooglePayEnvironment.Test - }, - merchantCountryCode = config.merchantCountryCode, - merchantName = config.merchantName, - isEmailRequired = config.billingDetailsCollectionConfiguration.collectsEmail, - billingAddressConfig = config.billingDetailsCollectionConfiguration.toBillingAddressConfig(), - ), - readyCallback = { - // Do nothing since we are skipping the ready check below - }, - activityResultLauncher = activityLauncher, - skipReadyCheck = true, - cardBrandFilter = config.cardBrandFilter - ) - } - - private fun onGooglePayResult(result: GooglePayPaymentMethodLauncher.Result) { - coroutineScope.launch { - removeIsAwaitingForResult() - - when (result) { - is GooglePayPaymentMethodLauncher.Result.Completed -> { - val arguments = currentArguments - val paymentMethod = arguments?.confirmationOption as? GooglePayConfirmationOption - - paymentMethod?.let { option -> - val confirmationOption = PaymentMethodConfirmationOption.Saved( - paymentMethod = result.paymentMethod, - initializationMode = option.initializationMode, - shippingDetails = option.shippingDetails, - optionsParams = null, - ) - - confirm( - arguments.copy( - confirmationOption = confirmationOption, - ) - ) - } - } - is GooglePayPaymentMethodLauncher.Result.Failed -> { - onIntentResult( - ConfirmationHandler.Result.Failed( - cause = result.error, - message = when (result.errorCode) { - GooglePayPaymentMethodLauncher.NETWORK_ERROR -> - com.stripe.android.R.string.stripe_failure_connection_error.resolvableString - else -> com.stripe.android.R.string.stripe_internal_error.resolvableString - }, - type = ConfirmationHandler.Result.Failed.ErrorType.GooglePay(result.errorCode), - ) - ) - } - is GooglePayPaymentMethodLauncher.Result.Canceled -> { - onIntentResult( - ConfirmationHandler.Result.Canceled( - action = ConfirmationHandler.Result.Canceled.Action.InformCancellation, - ) - ) - } - } - } - } - private fun onResult(result: ConfirmationDefinition.Result) { val confirmationResult = when (result) { is ConfirmationDefinition.Result.NextStep -> { coroutineScope.launch { confirm( - arguments = ConfirmationHandler.Args( - intent = result.intent, - confirmationOption = result.confirmationOption, - ) + intent = result.intent, + confirmationOption = result.confirmationOption, ) } @@ -448,12 +269,10 @@ internal class DefaultConfirmationHandler( ) } - onIntentResult(confirmationResult) + onHandlerResult(confirmationResult) } - private fun onIntentResult(result: ConfirmationHandler.Result) { - currentArguments = null - + private fun onHandlerResult(result: ConfirmationHandler.Result) { _state.value = ConfirmationHandler.State.Complete(result) removeIsAwaitingForResult() @@ -477,25 +296,12 @@ internal class DefaultConfirmationHandler( return savedStateHandle.get(AWAITING_CONFIRMATION_RESULT_KEY) } - private fun StripeIntent.asPaymentIntent(): PaymentIntent? { - return this as? PaymentIntent - } - private suspend inline fun Flow<*>.firstInstanceOf(): T { return first { it is T } as T } - private val PaymentElementLoader.InitializationMode.isProcessingPayment: Boolean - get() = when (this) { - is PaymentElementLoader.InitializationMode.PaymentIntent -> true - is PaymentElementLoader.InitializationMode.SetupIntent -> false - is PaymentElementLoader.InitializationMode.DeferredIntent -> { - intentConfiguration.mode is PaymentSheet.IntentConfiguration.Mode.Payment - } - } - @Parcelize data class AwaitingConfirmationResultData( val confirmationOption: ConfirmationHandler.Option, @@ -543,6 +349,5 @@ internal class DefaultConfirmationHandler( internal companion object { private const val AWAITING_CONFIRMATION_RESULT_KEY = "AwaitingConfirmationResult" - private const val ARGUMENTS_KEY = "PaymentConfirmationArguments" } } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayConfirmationDefinition.kt b/paymentsheet/src/main/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayConfirmationDefinition.kt new file mode 100644 index 00000000000..da6a05e2d79 --- /dev/null +++ b/paymentsheet/src/main/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayConfirmationDefinition.kt @@ -0,0 +1,179 @@ +package com.stripe.android.paymentelement.confirmation.gpay + +import androidx.activity.result.ActivityResultCaller +import androidx.activity.result.ActivityResultLauncher +import com.stripe.android.core.strings.resolvableString +import com.stripe.android.core.utils.UserFacingLogger +import com.stripe.android.googlepaylauncher.GooglePayEnvironment +import com.stripe.android.googlepaylauncher.GooglePayPaymentMethodLauncher +import com.stripe.android.googlepaylauncher.GooglePayPaymentMethodLauncherContractV2 +import com.stripe.android.googlepaylauncher.injection.GooglePayPaymentMethodLauncherFactory +import com.stripe.android.model.PaymentIntent +import com.stripe.android.model.SetupIntent +import com.stripe.android.model.StripeIntent +import com.stripe.android.paymentelement.confirmation.ConfirmationDefinition +import com.stripe.android.paymentelement.confirmation.ConfirmationHandler +import com.stripe.android.paymentelement.confirmation.PaymentMethodConfirmationOption +import com.stripe.android.paymentelement.confirmation.intent.DeferredIntentConfirmationType +import com.stripe.android.paymentsheet.PaymentSheet +import com.stripe.android.paymentsheet.R +import com.stripe.android.paymentsheet.state.PaymentElementLoader +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import com.stripe.android.R as PaymentsCoreR + +internal class GooglePayConfirmationDefinition( + private val googlePayPaymentMethodLauncherFactory: GooglePayPaymentMethodLauncherFactory, + private val userFacingLogger: UserFacingLogger?, +) : ConfirmationDefinition< + GooglePayConfirmationOption, + ActivityResultLauncher, + Unit, + GooglePayPaymentMethodLauncher.Result, + > { + override val key: String = "GooglePay" + + override fun option(confirmationOption: ConfirmationHandler.Option): GooglePayConfirmationOption? { + return confirmationOption as? GooglePayConfirmationOption + } + + override suspend fun action( + confirmationOption: GooglePayConfirmationOption, + intent: StripeIntent + ): ConfirmationDefinition.Action { + if ( + confirmationOption.config.merchantCurrencyCode == null && + !confirmationOption.initializationMode.isProcessingPayment + ) { + val message = "GooglePayConfig.currencyCode is required in order to use " + + "Google Pay when processing a Setup Intent" + + userFacingLogger?.logWarningWithoutPii(message) + + return ConfirmationDefinition.Action.Fail( + cause = IllegalStateException(message), + message = R.string.stripe_something_went_wrong.resolvableString, + errorType = ConfirmationHandler.Result.Failed.ErrorType.MerchantIntegration, + ) + } + + return ConfirmationDefinition.Action.Launch( + launcherArguments = Unit, + receivesResultInProcess = true, + deferredIntentConfirmationType = null, + ) + } + + override fun createLauncher( + activityResultCaller: ActivityResultCaller, + onResult: (GooglePayPaymentMethodLauncher.Result) -> Unit + ): ActivityResultLauncher { + return activityResultCaller.registerForActivityResult( + GooglePayPaymentMethodLauncherContractV2(), + onResult, + ) + } + + override fun launch( + launcher: ActivityResultLauncher, + arguments: Unit, + confirmationOption: GooglePayConfirmationOption, + intent: StripeIntent, + ) { + val config = confirmationOption.config + val googlePayLauncher = createGooglePayLauncher( + factory = googlePayPaymentMethodLauncherFactory, + activityLauncher = launcher, + config = confirmationOption.config, + ) + + googlePayLauncher.present( + currencyCode = intent.asPaymentIntent()?.currency + ?: config.merchantCurrencyCode.orEmpty(), + amount = when (intent) { + is PaymentIntent -> intent.amount ?: 0L + is SetupIntent -> config.customAmount ?: 0L + }, + transactionId = intent.id, + label = config.customLabel, + ) + } + + override fun toResult( + confirmationOption: GooglePayConfirmationOption, + deferredIntentConfirmationType: DeferredIntentConfirmationType?, + intent: StripeIntent, + result: GooglePayPaymentMethodLauncher.Result, + ): ConfirmationDefinition.Result { + return when (result) { + is GooglePayPaymentMethodLauncher.Result.Completed -> { + val nextConfirmationOption = PaymentMethodConfirmationOption.Saved( + paymentMethod = result.paymentMethod, + initializationMode = confirmationOption.initializationMode, + shippingDetails = confirmationOption.shippingDetails, + optionsParams = null, + ) + + ConfirmationDefinition.Result.NextStep( + confirmationOption = nextConfirmationOption, + intent = intent, + ) + } + is GooglePayPaymentMethodLauncher.Result.Failed -> { + ConfirmationDefinition.Result.Failed( + cause = result.error, + message = when (result.errorCode) { + GooglePayPaymentMethodLauncher.NETWORK_ERROR -> + PaymentsCoreR.string.stripe_failure_connection_error.resolvableString + else -> PaymentsCoreR.string.stripe_internal_error.resolvableString + }, + type = ConfirmationHandler.Result.Failed.ErrorType.GooglePay(result.errorCode), + ) + } + is GooglePayPaymentMethodLauncher.Result.Canceled -> { + ConfirmationDefinition.Result.Canceled( + action = ConfirmationHandler.Result.Canceled.Action.InformCancellation, + ) + } + } + } + + private fun createGooglePayLauncher( + factory: GooglePayPaymentMethodLauncherFactory, + activityLauncher: ActivityResultLauncher, + config: GooglePayConfirmationOption.Config, + ): GooglePayPaymentMethodLauncher { + return factory.create( + lifecycleScope = CoroutineScope(Dispatchers.Default), + config = GooglePayPaymentMethodLauncher.Config( + environment = when (config.environment) { + PaymentSheet.GooglePayConfiguration.Environment.Production -> GooglePayEnvironment.Production + else -> GooglePayEnvironment.Test + }, + merchantCountryCode = config.merchantCountryCode, + merchantName = config.merchantName, + isEmailRequired = config.billingDetailsCollectionConfiguration.collectsEmail, + billingAddressConfig = config.billingDetailsCollectionConfiguration.toBillingAddressConfig(), + ), + readyCallback = { + // Do nothing since we are skipping the ready check below + }, + activityResultLauncher = activityLauncher, + skipReadyCheck = true, + cardBrandFilter = config.cardBrandFilter + ) + } + + private fun StripeIntent.asPaymentIntent(): PaymentIntent? { + return this as? PaymentIntent + } + + private val PaymentElementLoader.InitializationMode.isProcessingPayment: Boolean + get() = when (this) { + is PaymentElementLoader.InitializationMode.PaymentIntent -> true + is PaymentElementLoader.InitializationMode.SetupIntent -> false + is PaymentElementLoader.InitializationMode.DeferredIntent -> { + intentConfiguration.mode is PaymentSheet.IntentConfiguration.Mode.Payment + } + } +} diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayConfirmationDefinitionTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayConfirmationDefinitionTest.kt new file mode 100644 index 00000000000..d9fd24d1563 --- /dev/null +++ b/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayConfirmationDefinitionTest.kt @@ -0,0 +1,484 @@ +package com.stripe.android.paymentelement.confirmation.gpay + +import androidx.activity.result.ActivityResultCallback +import com.google.common.truth.Truth.assertThat +import com.stripe.android.DefaultCardBrandFilter +import com.stripe.android.core.strings.resolvableString +import com.stripe.android.core.utils.UserFacingLogger +import com.stripe.android.googlepaylauncher.GooglePayEnvironment +import com.stripe.android.googlepaylauncher.GooglePayPaymentMethodLauncher +import com.stripe.android.googlepaylauncher.GooglePayPaymentMethodLauncherContractV2 +import com.stripe.android.googlepaylauncher.injection.GooglePayPaymentMethodLauncherFactory +import com.stripe.android.isInstanceOf +import com.stripe.android.model.wallets.Wallet +import com.stripe.android.paymentelement.confirmation.ConfirmationDefinition +import com.stripe.android.paymentelement.confirmation.ConfirmationHandler +import com.stripe.android.paymentelement.confirmation.FakeConfirmationOption +import com.stripe.android.paymentelement.confirmation.PaymentMethodConfirmationOption +import com.stripe.android.paymentelement.confirmation.asCallbackFor +import com.stripe.android.paymentelement.confirmation.asCanceled +import com.stripe.android.paymentelement.confirmation.asFail +import com.stripe.android.paymentelement.confirmation.asFailed +import com.stripe.android.paymentelement.confirmation.asLaunch +import com.stripe.android.paymentelement.confirmation.asNextStep +import com.stripe.android.paymentsheet.PaymentSheet +import com.stripe.android.paymentsheet.R +import com.stripe.android.paymentsheet.state.PaymentElementLoader +import com.stripe.android.paymentsheet.utils.FakeUserFacingLogger +import com.stripe.android.paymentsheet.utils.RecordingGooglePayPaymentMethodLauncherFactory +import com.stripe.android.testing.PaymentIntentFactory +import com.stripe.android.testing.PaymentMethodFactory +import com.stripe.android.testing.SetupIntentFactory +import com.stripe.android.utils.DummyActivityResultCaller +import com.stripe.android.utils.FakeActivityResultLauncher +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import com.stripe.android.R as PaymentsCoreR + +class GooglePayConfirmationDefinitionTest { + @Test + fun `'key' should be 'GooglePay`() { + val definition = createGooglePayConfirmationDefinition() + + assertThat(definition.key).isEqualTo("GooglePay") + } + + @Test + fun `'option' return casted 'GooglePayConfirmationOption'`() { + val definition = createGooglePayConfirmationDefinition() + + assertThat(definition.option(GOOGLE_PAY_CONFIRMATION_OPTION)).isEqualTo(GOOGLE_PAY_CONFIRMATION_OPTION) + } + + @Test + fun `'option' return null for unknown option`() { + val definition = createGooglePayConfirmationDefinition() + + assertThat(definition.option(FakeConfirmationOption())).isNull() + } + + @Test + fun `'createLauncher' should register launcher properly for activity result`() = runTest { + val definition = createGooglePayConfirmationDefinition() + + var onResultCalled = false + val onResult: (GooglePayPaymentMethodLauncher.Result) -> Unit = { onResultCalled = true } + DummyActivityResultCaller.test { + definition.createLauncher( + activityResultCaller = activityResultCaller, + onResult = onResult, + ) + + val call = awaitRegisterCall() + + assertThat(awaitNextRegisteredLauncher()).isNotNull() + + assertThat(call.contract).isInstanceOf() + assertThat(call.callback).isInstanceOf>() + + val callback = call.callback.asCallbackFor() + + callback.onActivityResult(GooglePayPaymentMethodLauncher.Result.Completed(PaymentMethodFactory.card())) + + assertThat(onResultCalled).isTrue() + } + } + + @Test + fun `'toResult' should return 'NextStep' when ' GooglePayLauncherResult' is 'Completed'`() = runTest { + val definition = createGooglePayConfirmationDefinition() + + val paymentMethod = PaymentMethodFactory.card().run { + copy( + card = card?.copy( + wallet = Wallet.GooglePayWallet(dynamicLast4 = card?.last4), + ) + ) + } + val result = definition.toResult( + confirmationOption = GOOGLE_PAY_CONFIRMATION_OPTION, + intent = PAYMENT_INTENT, + deferredIntentConfirmationType = null, + result = GooglePayPaymentMethodLauncher.Result.Completed( + paymentMethod = paymentMethod, + ), + ) + + assertThat(result).isInstanceOf() + + val successResult = result.asNextStep() + + assertThat(successResult.intent).isEqualTo(PAYMENT_INTENT) + assertThat(successResult.confirmationOption).isInstanceOf() + } + + @Test + fun `'toResult' should return 'Failed' when 'GooglePayLauncherResult' is 'Failed'`() = runTest { + val definition = createGooglePayConfirmationDefinition() + + val exception = IllegalStateException("Failed!") + val result = definition.toResult( + confirmationOption = GOOGLE_PAY_CONFIRMATION_OPTION, + intent = PAYMENT_INTENT, + deferredIntentConfirmationType = null, + result = GooglePayPaymentMethodLauncher.Result.Failed( + errorCode = 400, + error = exception + ), + ) + + assertThat(result).isInstanceOf() + + val failedResult = result.asFailed() + + assertThat(failedResult.cause).isEqualTo(exception) + assertThat(failedResult.message).isEqualTo(PaymentsCoreR.string.stripe_internal_error.resolvableString) + assertThat(failedResult.type).isEqualTo(ConfirmationHandler.Result.Failed.ErrorType.GooglePay(400)) + } + + @Test + fun `'toResult' should return 'Failed' with network error message if network error code is returned`() = runTest { + val definition = createGooglePayConfirmationDefinition() + + val exception = IllegalStateException("Failed!") + val result = definition.toResult( + confirmationOption = GOOGLE_PAY_CONFIRMATION_OPTION, + intent = PAYMENT_INTENT, + deferredIntentConfirmationType = null, + result = GooglePayPaymentMethodLauncher.Result.Failed( + errorCode = GooglePayPaymentMethodLauncher.NETWORK_ERROR, + error = exception + ), + ) + + assertThat(result).isInstanceOf() + + val failedResult = result.asFailed() + + assertThat(failedResult.cause).isEqualTo(exception) + assertThat(failedResult.message) + .isEqualTo(PaymentsCoreR.string.stripe_failure_connection_error.resolvableString) + assertThat(failedResult.type).isEqualTo( + ConfirmationHandler.Result.Failed.ErrorType.GooglePay(GooglePayPaymentMethodLauncher.NETWORK_ERROR) + ) + } + + @Test + fun `'toResult' should return 'Canceled' when 'GooglePayLauncherResult' is 'Canceled'`() = runTest { + val definition = createGooglePayConfirmationDefinition() + + val result = definition.toResult( + confirmationOption = GOOGLE_PAY_CONFIRMATION_OPTION, + intent = PAYMENT_INTENT, + deferredIntentConfirmationType = null, + result = GooglePayPaymentMethodLauncher.Result.Canceled, + ) + + assertThat(result).isInstanceOf() + + val canceledResult = result.asCanceled() + + assertThat(canceledResult.action).isEqualTo(ConfirmationHandler.Result.Canceled.Action.InformCancellation) + } + + @Test + fun `'Fail' action should be returned if currency code is not provided with a setup intent`() = + runActionTest( + initializationMode = PaymentElementLoader.InitializationMode.SetupIntent( + clientSecret = "si_123_secret_123", + ), + shouldHaveCurrencyCodeFailure = true, + merchantCurrencyCode = null, + ) + + @Test + fun `'Fail' action should be returned if currency code is not provided with a deferred intent in setup mode`() = + runActionTest( + initializationMode = PaymentElementLoader.InitializationMode.DeferredIntent( + intentConfiguration = PaymentSheet.IntentConfiguration( + mode = PaymentSheet.IntentConfiguration.Mode.Setup(), + ), + ), + shouldHaveCurrencyCodeFailure = true, + merchantCurrencyCode = null, + ) + + @Test + fun `'Launch' action should be returned if currency code is provided with a setup intent`() = + runActionTest( + initializationMode = PaymentElementLoader.InitializationMode.SetupIntent( + clientSecret = "si_123_secret_123", + ), + shouldHaveCurrencyCodeFailure = false, + merchantCurrencyCode = "USD", + ) + + @Test + fun `'Launch' action should be returned if currency code is provided with a deferred intent in setup mode`() = + runActionTest( + initializationMode = PaymentElementLoader.InitializationMode.DeferredIntent( + intentConfiguration = PaymentSheet.IntentConfiguration( + mode = PaymentSheet.IntentConfiguration.Mode.Setup(), + ), + ), + shouldHaveCurrencyCodeFailure = false, + merchantCurrencyCode = "USD", + ) + + @Test + fun `'Launch' action should be returned if currency code is not provided with a payment intent`() = + runActionTest( + initializationMode = PaymentElementLoader.InitializationMode.PaymentIntent( + clientSecret = "pi_123_secret_123", + ), + shouldHaveCurrencyCodeFailure = false, + merchantCurrencyCode = null, + ) + + @Test + fun `'Launch' action should be returned if currency code is provided with a payment intent`() = + runActionTest( + initializationMode = PaymentElementLoader.InitializationMode.PaymentIntent( + clientSecret = "pi_123_secret_123", + ), + shouldHaveCurrencyCodeFailure = false, + merchantCurrencyCode = "USD", + ) + + @Test + fun `'Launch' action should be returned if currency code is not provided with deferred intent in payment mode`() = + runActionTest( + initializationMode = PaymentElementLoader.InitializationMode.DeferredIntent( + intentConfiguration = PaymentSheet.IntentConfiguration( + mode = PaymentSheet.IntentConfiguration.Mode.Payment( + amount = 1099, + currency = "USD", + ), + ), + ), + shouldHaveCurrencyCodeFailure = false, + merchantCurrencyCode = null, + ) + + @Test + fun `'Launch' action should be returned if currency code is provided with deferred intent in payment mode`() = + runActionTest( + initializationMode = PaymentElementLoader.InitializationMode.DeferredIntent( + intentConfiguration = PaymentSheet.IntentConfiguration( + mode = PaymentSheet.IntentConfiguration.Mode.Payment( + amount = 1099, + currency = "USD", + ), + ), + ), + shouldHaveCurrencyCodeFailure = false, + merchantCurrencyCode = "USD", + ) + + @Test + fun `On 'launch', should create google pay launcher properly`() = runTest { + RecordingGooglePayPaymentMethodLauncherFactory.test(mock()) { + val definition = createGooglePayConfirmationDefinition(factory) + val launcher = FakeActivityResultLauncher() + + definition.launch( + confirmationOption = GOOGLE_PAY_CONFIRMATION_OPTION, + intent = PAYMENT_INTENT, + arguments = Unit, + launcher = launcher, + ) + + val createGooglePayLauncherCall = createGooglePayPaymentMethodLauncherCalls.awaitItem() + + assertThat(createGooglePayLauncherCall.activityResultLauncher).isEqualTo(launcher) + assertThat(createGooglePayLauncherCall.skipReadyCheck).isTrue() + assertThat(createGooglePayLauncherCall.cardBrandFilter).isEqualTo(DefaultCardBrandFilter) + + assertThat(createGooglePayLauncherCall.config.environment).isEqualTo(GooglePayEnvironment.Test) + assertThat(createGooglePayLauncherCall.config.merchantName).isEqualTo("Test merchant Inc.") + assertThat(createGooglePayLauncherCall.config.merchantCountryCode).isEqualTo("US") + assertThat(createGooglePayLauncherCall.config.allowCreditCards).isTrue() + assertThat(createGooglePayLauncherCall.config.existingPaymentMethodRequired).isTrue() + assertThat(createGooglePayLauncherCall.config.isEmailRequired).isFalse() + assertThat(createGooglePayLauncherCall.config.billingAddressConfig.isRequired).isTrue() + assertThat(createGooglePayLauncherCall.config.billingAddressConfig.isPhoneNumberRequired).isFalse() + assertThat(createGooglePayLauncherCall.config.billingAddressConfig.format) + .isEqualTo(GooglePayPaymentMethodLauncher.BillingAddressConfig.Format.Full) + } + } + + @Test + fun `On 'launch', should use payment intent currency code if available`() = runTest { + val googlePayLauncher = mock() + + RecordingGooglePayPaymentMethodLauncherFactory.test(googlePayLauncher) { + val definition = createGooglePayConfirmationDefinition(factory) + val launcher = FakeActivityResultLauncher() + + definition.launch( + confirmationOption = GOOGLE_PAY_CONFIRMATION_OPTION.copy( + config = GOOGLE_PAY_CONFIRMATION_OPTION.config.copy( + merchantCurrencyCode = "USD", + ), + ), + intent = PAYMENT_INTENT.copy(currency = "CAD"), + arguments = Unit, + launcher = launcher, + ) + + assertThat(createGooglePayPaymentMethodLauncherCalls.awaitItem()).isNotNull() + + verify(googlePayLauncher, times(1)).present( + currencyCode = "CAD", + amount = 1000L, + transactionId = "pi_12345", + label = null + ) + } + } + + @Test + fun `On 'launch', should use payment intent currency & amount`() = runTest { + val googlePayLauncher = mock() + + RecordingGooglePayPaymentMethodLauncherFactory.test(googlePayLauncher) { + val definition = createGooglePayConfirmationDefinition(factory) + val launcher = FakeActivityResultLauncher() + + definition.launch( + confirmationOption = GOOGLE_PAY_CONFIRMATION_OPTION.copy( + config = GOOGLE_PAY_CONFIRMATION_OPTION.config.copy( + merchantCurrencyCode = "USD", + customLabel = "Merchant Inc." + ), + ), + intent = PAYMENT_INTENT.copy(currency = "CAD"), + arguments = Unit, + launcher = launcher, + ) + + assertThat(createGooglePayPaymentMethodLauncherCalls.awaitItem()).isNotNull() + + verify(googlePayLauncher, times(1)).present( + currencyCode = "CAD", + amount = 1000L, + transactionId = "pi_12345", + label = "Merchant Inc." + ) + } + } + + @Test + fun `On 'launch', should use set currency & custom amount when using setup intent`() = runTest { + val googlePayLauncher = mock() + + RecordingGooglePayPaymentMethodLauncherFactory.test(googlePayLauncher) { + val definition = createGooglePayConfirmationDefinition(factory) + val launcher = FakeActivityResultLauncher() + + definition.launch( + confirmationOption = GOOGLE_PAY_CONFIRMATION_OPTION.copy( + config = GOOGLE_PAY_CONFIRMATION_OPTION.config.copy( + merchantCurrencyCode = "USD", + customAmount = 2099L, + customLabel = "Merchant Inc." + ), + ), + intent = SetupIntentFactory.create(), + arguments = Unit, + launcher = launcher, + ) + + assertThat(createGooglePayPaymentMethodLauncherCalls.awaitItem()).isNotNull() + + verify(googlePayLauncher, times(1)).present( + currencyCode = "USD", + amount = 2099L, + transactionId = "pi_12345", + label = "Merchant Inc." + ) + } + } + + private fun runActionTest( + initializationMode: PaymentElementLoader.InitializationMode, + merchantCurrencyCode: String?, + shouldHaveCurrencyCodeFailure: Boolean, + ) = runTest { + val userFacingLogger = FakeUserFacingLogger() + val definition = createGooglePayConfirmationDefinition(userFacingLogger = userFacingLogger) + + val action = definition.action( + confirmationOption = GOOGLE_PAY_CONFIRMATION_OPTION.copy( + initializationMode = initializationMode, + config = GOOGLE_PAY_CONFIRMATION_OPTION.config.copy( + merchantCurrencyCode = merchantCurrencyCode, + ), + ), + intent = SetupIntentFactory.create(), + ) + + if (shouldHaveCurrencyCodeFailure) { + assertThat(action).isInstanceOf>() + + val failAction = action.asFail() + val failureMessage = "GooglePayConfig.currencyCode is required in order to use " + + "Google Pay when processing a Setup Intent" + + assertThat(userFacingLogger.getLoggedMessages()).containsExactly(failureMessage) + + assertThat(failAction.cause).isInstanceOf() + assertThat(failAction.cause.message).isEqualTo(failureMessage) + assertThat(failAction.message).isEqualTo(R.string.stripe_something_went_wrong.resolvableString) + assertThat(failAction.errorType) + .isEqualTo(ConfirmationHandler.Result.Failed.ErrorType.MerchantIntegration) + } else { + assertThat(action).isInstanceOf>() + + val launchAction = action.asLaunch() + + assertThat(launchAction.receivesResultInProcess).isTrue() + assertThat(launchAction.deferredIntentConfirmationType).isNull() + assertThat(launchAction.launcherArguments).isEqualTo(Unit) + } + } + + private fun createGooglePayConfirmationDefinition( + googlePayPaymentMethodLauncherFactory: GooglePayPaymentMethodLauncherFactory = + RecordingGooglePayPaymentMethodLauncherFactory( + googlePayPaymentMethodLauncher = mock() + ), + userFacingLogger: UserFacingLogger = FakeUserFacingLogger() + ): GooglePayConfirmationDefinition { + return GooglePayConfirmationDefinition( + googlePayPaymentMethodLauncherFactory = googlePayPaymentMethodLauncherFactory, + userFacingLogger = userFacingLogger, + ) + } + + private companion object { + private val GOOGLE_PAY_CONFIRMATION_OPTION = GooglePayConfirmationOption( + initializationMode = PaymentElementLoader.InitializationMode.PaymentIntent( + clientSecret = "pi_123_secret_123", + ), + shippingDetails = null, + config = GooglePayConfirmationOption.Config( + environment = PaymentSheet.GooglePayConfiguration.Environment.Test, + merchantName = "Test merchant Inc.", + merchantCountryCode = "US", + merchantCurrencyCode = "CA", + customAmount = 1099, + customLabel = null, + billingDetailsCollectionConfiguration = PaymentSheet.BillingDetailsCollectionConfiguration( + address = PaymentSheet.BillingDetailsCollectionConfiguration.AddressCollectionMode.Full, + ), + cardBrandFilter = DefaultCardBrandFilter, + ) + ) + + private val PAYMENT_INTENT = PaymentIntentFactory.create() + } +} diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayConfirmationFlowTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayConfirmationFlowTest.kt new file mode 100644 index 00000000000..aee018c510e --- /dev/null +++ b/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayConfirmationFlowTest.kt @@ -0,0 +1,123 @@ +package com.stripe.android.paymentelement.confirmation.gpay + +import androidx.lifecycle.SavedStateHandle +import com.google.common.truth.Truth.assertThat +import com.stripe.android.DefaultCardBrandFilter +import com.stripe.android.googlepaylauncher.GooglePayPaymentMethodLauncher +import com.stripe.android.isInstanceOf +import com.stripe.android.paymentelement.confirmation.ConfirmationDefinition +import com.stripe.android.paymentelement.confirmation.ConfirmationMediator +import com.stripe.android.paymentelement.confirmation.ConfirmationMediator.Parameters +import com.stripe.android.paymentelement.confirmation.PaymentMethodConfirmationOption +import com.stripe.android.paymentelement.confirmation.asLaunch +import com.stripe.android.paymentelement.confirmation.runResultTest +import com.stripe.android.paymentsheet.PaymentSheet +import com.stripe.android.paymentsheet.state.PaymentElementLoader +import com.stripe.android.paymentsheet.utils.RecordingGooglePayPaymentMethodLauncherFactory +import com.stripe.android.testing.PaymentIntentFactory +import com.stripe.android.testing.PaymentMethodFactory +import com.stripe.android.utils.DummyActivityResultCaller +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify + +class GooglePayConfirmationFlowTest { + @Test + fun `on launch, should persist parameters & launch using launcher as expected`() = runTest { + val googlePayPaymentMethodLauncher = mock() + val savedStateHandle = SavedStateHandle() + val mediator = ConfirmationMediator( + savedStateHandle = savedStateHandle, + definition = GooglePayConfirmationDefinition( + googlePayPaymentMethodLauncherFactory = RecordingGooglePayPaymentMethodLauncherFactory( + googlePayPaymentMethodLauncher = googlePayPaymentMethodLauncher, + ), + userFacingLogger = null, + ), + ) + + DummyActivityResultCaller.test { + mediator.register( + activityResultCaller = activityResultCaller, + onResult = {} + ) + + assertThat(awaitRegisterCall()).isNotNull() + assertThat(awaitNextRegisteredLauncher()).isNotNull() + + val action = mediator.action( + option = GOOGLE_PAY_CONFIRMATION_OPTION, + intent = PAYMENT_INTENT, + ) + + assertThat(action).isInstanceOf() + + val launchAction = action.asLaunch() + + launchAction.launch() + + val parameters = savedStateHandle + .get>("GooglePayParameters") + + assertThat(parameters?.confirmationOption).isEqualTo(GOOGLE_PAY_CONFIRMATION_OPTION) + assertThat(parameters?.intent).isEqualTo(PAYMENT_INTENT) + assertThat(parameters?.deferredIntentConfirmationType).isNull() + + verify(googlePayPaymentMethodLauncher, times(1)).present( + currencyCode = "usd", + amount = 1000L, + transactionId = "pi_12345", + label = null, + ) + } + } + + @Test + fun `on result, should return confirmation result as expected`() = runResultTest( + confirmationOption = GOOGLE_PAY_CONFIRMATION_OPTION, + intent = PAYMENT_INTENT, + definition = GooglePayConfirmationDefinition( + googlePayPaymentMethodLauncherFactory = RecordingGooglePayPaymentMethodLauncherFactory(mock()), + userFacingLogger = null, + ), + launcherResult = GooglePayPaymentMethodLauncher.Result.Completed(PAYMENT_METHOD), + definitionResult = ConfirmationDefinition.Result.NextStep( + intent = PAYMENT_INTENT, + confirmationOption = PaymentMethodConfirmationOption.Saved( + initializationMode = PaymentElementLoader.InitializationMode.PaymentIntent( + clientSecret = "pi_123_secret_123", + ), + paymentMethod = PAYMENT_METHOD, + optionsParams = null, + shippingDetails = null, + ) + ) + ) + + private companion object { + private val GOOGLE_PAY_CONFIRMATION_OPTION = GooglePayConfirmationOption( + initializationMode = PaymentElementLoader.InitializationMode.PaymentIntent( + clientSecret = "pi_123_secret_123", + ), + shippingDetails = null, + config = GooglePayConfirmationOption.Config( + environment = PaymentSheet.GooglePayConfiguration.Environment.Test, + merchantName = "Test merchant Inc.", + merchantCountryCode = "US", + merchantCurrencyCode = "CA", + customAmount = 1099, + customLabel = null, + billingDetailsCollectionConfiguration = PaymentSheet.BillingDetailsCollectionConfiguration( + address = PaymentSheet.BillingDetailsCollectionConfiguration.AddressCollectionMode.Full, + ), + cardBrandFilter = DefaultCardBrandFilter, + ) + ) + + private val PAYMENT_METHOD = PaymentMethodFactory.card() + + private val PAYMENT_INTENT = PaymentIntentFactory.create() + } +} diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/flowcontroller/DefaultFlowControllerTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/flowcontroller/DefaultFlowControllerTest.kt index 637e336b488..211fb4a9e8d 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/flowcontroller/DefaultFlowControllerTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/flowcontroller/DefaultFlowControllerTest.kt @@ -357,10 +357,14 @@ internal class DefaultFlowControllerTest { val viewModel = createViewModel() val flowController = createFlowController(viewModel = viewModel) - flowController.configureExpectingSuccess() + flowController.configureExpectingSuccess( + configuration = PaymentSheetFixtures.CONFIG_CUSTOMER_WITH_GOOGLEPAY, + ) viewModel.paymentSelection = PaymentSelection.GooglePay + flowController.confirm() + val errorCode = GooglePayPaymentMethodLauncher.INTERNAL_ERROR googlePayLauncherResultCallback?.invoke( @@ -1113,12 +1117,17 @@ internal class DefaultFlowControllerTest { fun `onGooglePayResult() when canceled should invoke callback with canceled result`() = runTest { verifyNoInteractions(eventReporter) - val flowController = createFlowController() + val viewModel = createViewModel() + val flowController = createFlowController(viewModel = viewModel) flowController.configureExpectingSuccess( configuration = PaymentSheetFixtures.CONFIG_CUSTOMER_WITH_GOOGLEPAY ) + viewModel.paymentSelection = PaymentSelection.GooglePay + + flowController.confirm() + googlePayLauncherResultCallback?.invoke( GooglePayPaymentMethodLauncher.Result.Canceled ) diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/utils/RecordingGooglePayPaymentMethodLauncherFactory.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/utils/RecordingGooglePayPaymentMethodLauncherFactory.kt index 78cf4dc4e7c..f063a84333a 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/utils/RecordingGooglePayPaymentMethodLauncherFactory.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/utils/RecordingGooglePayPaymentMethodLauncherFactory.kt @@ -1,15 +1,19 @@ package com.stripe.android.paymentsheet.utils import androidx.activity.result.ActivityResultLauncher +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.Turbine import com.stripe.android.CardBrandFilter import com.stripe.android.googlepaylauncher.GooglePayPaymentMethodLauncher import com.stripe.android.googlepaylauncher.GooglePayPaymentMethodLauncherContractV2 import com.stripe.android.googlepaylauncher.injection.GooglePayPaymentMethodLauncherFactory import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.runTest internal class RecordingGooglePayPaymentMethodLauncherFactory( private val googlePayPaymentMethodLauncher: GooglePayPaymentMethodLauncher, ) : GooglePayPaymentMethodLauncherFactory { + private val calls = Turbine() var config: GooglePayPaymentMethodLauncher.Config? = null private set @@ -22,7 +26,46 @@ internal class RecordingGooglePayPaymentMethodLauncherFactory( skipReadyCheck: Boolean, cardBrandFilter: CardBrandFilter ): GooglePayPaymentMethodLauncher { + calls.add( + Call( + config = config, + activityResultLauncher = activityResultLauncher, + skipReadyCheck = skipReadyCheck, + cardBrandFilter = cardBrandFilter, + ) + ) + this.config = config return googlePayPaymentMethodLauncher } + + data class Call( + val config: GooglePayPaymentMethodLauncher.Config, + val activityResultLauncher: ActivityResultLauncher, + val skipReadyCheck: Boolean, + val cardBrandFilter: CardBrandFilter, + ) + + class Scenario( + val factory: GooglePayPaymentMethodLauncherFactory, + val createGooglePayPaymentMethodLauncherCalls: ReceiveTurbine + ) + + companion object { + fun test( + launcher: GooglePayPaymentMethodLauncher, + test: suspend Scenario.() -> Unit + ) = runTest { + val factory = RecordingGooglePayPaymentMethodLauncherFactory(launcher) + + test( + Scenario( + factory = factory, + createGooglePayPaymentMethodLauncherCalls = factory.calls, + ) + ) + + factory.calls.ensureAllEventsConsumed() + } + } } From 3de4cb6d9e550518929f0bccebabbb7f2377f534 Mon Sep 17 00:00:00 2001 From: Samer Alabi Date: Fri, 22 Nov 2024 13:21:51 -0500 Subject: [PATCH 2/2] Make GPay test factory private & add additional tests --- .../GooglePayConfirmationDefinitionTest.kt | 195 +++++++++++++++--- .../gpay/GooglePayConfirmationFlowTest.kt | 75 +++---- .../DefaultFlowControllerTest.kt | 2 +- ...ngGooglePayPaymentMethodLauncherFactory.kt | 6 +- 4 files changed, 209 insertions(+), 69 deletions(-) diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayConfirmationDefinitionTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayConfirmationDefinitionTest.kt index d9fd24d1563..adbfe0eb1de 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayConfirmationDefinitionTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayConfirmationDefinitionTest.kt @@ -2,6 +2,7 @@ package com.stripe.android.paymentelement.confirmation.gpay import androidx.activity.result.ActivityResultCallback import com.google.common.truth.Truth.assertThat +import com.stripe.android.CardBrandFilter import com.stripe.android.DefaultCardBrandFilter import com.stripe.android.core.strings.resolvableString import com.stripe.android.core.utils.UserFacingLogger @@ -10,6 +11,7 @@ import com.stripe.android.googlepaylauncher.GooglePayPaymentMethodLauncher import com.stripe.android.googlepaylauncher.GooglePayPaymentMethodLauncherContractV2 import com.stripe.android.googlepaylauncher.injection.GooglePayPaymentMethodLauncherFactory import com.stripe.android.isInstanceOf +import com.stripe.android.model.CardBrand import com.stripe.android.model.wallets.Wallet import com.stripe.android.paymentelement.confirmation.ConfirmationDefinition import com.stripe.android.paymentelement.confirmation.ConfirmationHandler @@ -32,6 +34,7 @@ import com.stripe.android.testing.SetupIntentFactory import com.stripe.android.utils.DummyActivityResultCaller import com.stripe.android.utils.FakeActivityResultLauncher import kotlinx.coroutines.test.runTest +import kotlinx.parcelize.Parcelize import org.junit.Test import org.mockito.kotlin.mock import org.mockito.kotlin.times @@ -77,7 +80,7 @@ class GooglePayConfirmationDefinitionTest { assertThat(awaitNextRegisteredLauncher()).isNotNull() assertThat(call.contract).isInstanceOf() - assertThat(call.callback).isInstanceOf>() + assertThat(call.callback).isInstanceOf>() val callback = call.callback.asCallbackFor() @@ -88,7 +91,7 @@ class GooglePayConfirmationDefinitionTest { } @Test - fun `'toResult' should return 'NextStep' when ' GooglePayLauncherResult' is 'Completed'`() = runTest { + fun `'toResult' should return 'NextStep' when 'GooglePayLauncherResult' is 'Completed'`() = runTest { val definition = createGooglePayConfirmationDefinition() val paymentMethod = PaymentMethodFactory.card().run { @@ -190,8 +193,8 @@ class GooglePayConfirmationDefinitionTest { initializationMode = PaymentElementLoader.InitializationMode.SetupIntent( clientSecret = "si_123_secret_123", ), - shouldHaveCurrencyCodeFailure = true, merchantCurrencyCode = null, + test = ::assertFailActionFromCurrencyFailure, ) @Test @@ -202,8 +205,8 @@ class GooglePayConfirmationDefinitionTest { mode = PaymentSheet.IntentConfiguration.Mode.Setup(), ), ), - shouldHaveCurrencyCodeFailure = true, merchantCurrencyCode = null, + test = ::assertFailActionFromCurrencyFailure, ) @Test @@ -212,8 +215,8 @@ class GooglePayConfirmationDefinitionTest { initializationMode = PaymentElementLoader.InitializationMode.SetupIntent( clientSecret = "si_123_secret_123", ), - shouldHaveCurrencyCodeFailure = false, merchantCurrencyCode = "USD", + test = ::assertLaunchAction, ) @Test @@ -224,8 +227,8 @@ class GooglePayConfirmationDefinitionTest { mode = PaymentSheet.IntentConfiguration.Mode.Setup(), ), ), - shouldHaveCurrencyCodeFailure = false, merchantCurrencyCode = "USD", + test = ::assertLaunchAction, ) @Test @@ -234,8 +237,8 @@ class GooglePayConfirmationDefinitionTest { initializationMode = PaymentElementLoader.InitializationMode.PaymentIntent( clientSecret = "pi_123_secret_123", ), - shouldHaveCurrencyCodeFailure = false, merchantCurrencyCode = null, + test = ::assertLaunchAction, ) @Test @@ -244,8 +247,8 @@ class GooglePayConfirmationDefinitionTest { initializationMode = PaymentElementLoader.InitializationMode.PaymentIntent( clientSecret = "pi_123_secret_123", ), - shouldHaveCurrencyCodeFailure = false, merchantCurrencyCode = "USD", + test = ::assertLaunchAction, ) @Test @@ -259,8 +262,8 @@ class GooglePayConfirmationDefinitionTest { ), ), ), - shouldHaveCurrencyCodeFailure = false, merchantCurrencyCode = null, + test = ::assertLaunchAction, ) @Test @@ -274,8 +277,8 @@ class GooglePayConfirmationDefinitionTest { ), ), ), - shouldHaveCurrencyCodeFailure = false, merchantCurrencyCode = "USD", + test = ::assertLaunchAction, ) @Test @@ -310,6 +313,64 @@ class GooglePayConfirmationDefinitionTest { } } + @Test + fun `On 'launch', should create google pay launcher properly with excepted parameters`() = + runLaunchParametersTest( + confirmationOption = GOOGLE_PAY_CONFIRMATION_OPTION, + merchantNameShouldBe = "Test merchant Inc.", + merchantCountryCodeShouldBe = "US", + emailShouldBeRequired = false, + billingAddressShouldBeRequired = true, + phoneNumberShouldBeRequired = false, + billingAddressFormatShouldBe = GooglePayPaymentMethodLauncher.BillingAddressConfig.Format.Full, + environmentShouldBe = GooglePayEnvironment.Test, + cardBrandFilterShouldBe = DefaultCardBrandFilter, + ) + + @Test + fun `On 'launch', should create launcher with required billing parameters, prod env, and expected card filter`() = + runLaunchParametersTest( + confirmationOption = GOOGLE_PAY_CONFIRMATION_OPTION.copy( + config = GOOGLE_PAY_CONFIRMATION_OPTION.config.copy( + merchantName = "Another merchant Inc.", + merchantCountryCode = "CA", + environment = PaymentSheet.GooglePayConfiguration.Environment.Production, + billingDetailsCollectionConfiguration = PaymentSheet.BillingDetailsCollectionConfiguration( + email = PaymentSheet.BillingDetailsCollectionConfiguration.CollectionMode.Always, + phone = PaymentSheet.BillingDetailsCollectionConfiguration.CollectionMode.Always, + address = PaymentSheet.BillingDetailsCollectionConfiguration.AddressCollectionMode.Never, + ), + cardBrandFilter = FakeCardBrandFilter, + ) + ), + merchantNameShouldBe = "Another merchant Inc.", + merchantCountryCodeShouldBe = "CA", + emailShouldBeRequired = true, + billingAddressShouldBeRequired = true, + phoneNumberShouldBeRequired = true, + billingAddressFormatShouldBe = GooglePayPaymentMethodLauncher.BillingAddressConfig.Format.Min, + environmentShouldBe = GooglePayEnvironment.Production, + cardBrandFilterShouldBe = FakeCardBrandFilter, + ) + + @Test + fun `On 'launch', should create google pay launcher properly with no billing parameters`() = + runLaunchParametersTest( + confirmationOption = GOOGLE_PAY_CONFIRMATION_OPTION.copy( + config = GOOGLE_PAY_CONFIRMATION_OPTION.config.copy( + billingDetailsCollectionConfiguration = PaymentSheet.BillingDetailsCollectionConfiguration(), + ) + ), + merchantNameShouldBe = "Test merchant Inc.", + merchantCountryCodeShouldBe = "US", + emailShouldBeRequired = false, + billingAddressShouldBeRequired = false, + phoneNumberShouldBeRequired = false, + billingAddressFormatShouldBe = GooglePayPaymentMethodLauncher.BillingAddressConfig.Format.Min, + environmentShouldBe = GooglePayEnvironment.Test, + cardBrandFilterShouldBe = DefaultCardBrandFilter, + ) + @Test fun `On 'launch', should use payment intent currency code if available`() = runTest { val googlePayLauncher = mock() @@ -406,7 +467,7 @@ class GooglePayConfirmationDefinitionTest { private fun runActionTest( initializationMode: PaymentElementLoader.InitializationMode, merchantCurrencyCode: String?, - shouldHaveCurrencyCodeFailure: Boolean, + test: (scenario: ActionScenario) -> Unit, ) = runTest { val userFacingLogger = FakeUserFacingLogger() val definition = createGooglePayConfirmationDefinition(userFacingLogger = userFacingLogger) @@ -421,36 +482,96 @@ class GooglePayConfirmationDefinitionTest { intent = SetupIntentFactory.create(), ) - if (shouldHaveCurrencyCodeFailure) { - assertThat(action).isInstanceOf>() + test( + ActionScenario( + action = action, + userFacingLogger = userFacingLogger, + ) + ) + } - val failAction = action.asFail() - val failureMessage = "GooglePayConfig.currencyCode is required in order to use " + - "Google Pay when processing a Setup Intent" + private fun runLaunchParametersTest( + confirmationOption: GooglePayConfirmationOption, + environmentShouldBe: GooglePayEnvironment, + merchantNameShouldBe: String, + merchantCountryCodeShouldBe: String?, + billingAddressShouldBeRequired: Boolean, + phoneNumberShouldBeRequired: Boolean, + emailShouldBeRequired: Boolean, + billingAddressFormatShouldBe: GooglePayPaymentMethodLauncher.BillingAddressConfig.Format, + cardBrandFilterShouldBe: CardBrandFilter + ) { + RecordingGooglePayPaymentMethodLauncherFactory.test(mock()) { + val definition = createGooglePayConfirmationDefinition(factory) + val launcher = FakeActivityResultLauncher() - assertThat(userFacingLogger.getLoggedMessages()).containsExactly(failureMessage) + definition.launch( + confirmationOption = confirmationOption, + intent = PAYMENT_INTENT, + arguments = Unit, + launcher = launcher, + ) - assertThat(failAction.cause).isInstanceOf() - assertThat(failAction.cause.message).isEqualTo(failureMessage) - assertThat(failAction.message).isEqualTo(R.string.stripe_something_went_wrong.resolvableString) - assertThat(failAction.errorType) - .isEqualTo(ConfirmationHandler.Result.Failed.ErrorType.MerchantIntegration) - } else { - assertThat(action).isInstanceOf>() + val createGooglePayLauncherCall = createGooglePayPaymentMethodLauncherCalls.awaitItem() - val launchAction = action.asLaunch() + // Should always be the same value + assertThat(createGooglePayLauncherCall.activityResultLauncher).isEqualTo(launcher) + assertThat(createGooglePayLauncherCall.skipReadyCheck).isTrue() + assertThat(createGooglePayLauncherCall.config.allowCreditCards).isTrue() + assertThat(createGooglePayLauncherCall.config.existingPaymentMethodRequired).isTrue() - assertThat(launchAction.receivesResultInProcess).isTrue() - assertThat(launchAction.deferredIntentConfirmationType).isNull() - assertThat(launchAction.launcherArguments).isEqualTo(Unit) + // Can vary on merchant's config + assertThat(createGooglePayLauncherCall.cardBrandFilter).isEqualTo(cardBrandFilterShouldBe) + assertThat(createGooglePayLauncherCall.config.environment).isEqualTo(environmentShouldBe) + assertThat(createGooglePayLauncherCall.config.merchantName).isEqualTo(merchantNameShouldBe) + assertThat(createGooglePayLauncherCall.config.merchantCountryCode).isEqualTo(merchantCountryCodeShouldBe) + assertThat(createGooglePayLauncherCall.config.isEmailRequired).isEqualTo(emailShouldBeRequired) + assertThat(createGooglePayLauncherCall.config.billingAddressConfig.isRequired) + .isEqualTo(billingAddressShouldBeRequired) + assertThat(createGooglePayLauncherCall.config.billingAddressConfig.isPhoneNumberRequired) + .isEqualTo(phoneNumberShouldBeRequired) + assertThat(createGooglePayLauncherCall.config.billingAddressConfig.format) + .isEqualTo(billingAddressFormatShouldBe) } } + private fun assertFailActionFromCurrencyFailure( + scenario: ActionScenario, + ) { + val action = scenario.action + + assertThat(action).isInstanceOf>() + + val failAction = action.asFail() + val failureMessage = "GooglePayConfig.currencyCode is required in order to use " + + "Google Pay when processing a Setup Intent" + + assertThat(scenario.userFacingLogger.getLoggedMessages()).containsExactly(failureMessage) + + assertThat(failAction.cause).isInstanceOf() + assertThat(failAction.cause.message).isEqualTo(failureMessage) + assertThat(failAction.message).isEqualTo(R.string.stripe_something_went_wrong.resolvableString) + assertThat(failAction.errorType) + .isEqualTo(ConfirmationHandler.Result.Failed.ErrorType.MerchantIntegration) + } + + private fun assertLaunchAction( + scenario: ActionScenario, + ) { + val action = scenario.action + + assertThat(action).isInstanceOf>() + + val launchAction = action.asLaunch() + + assertThat(launchAction.receivesResultInProcess).isTrue() + assertThat(launchAction.deferredIntentConfirmationType).isNull() + assertThat(launchAction.launcherArguments).isEqualTo(Unit) + } + private fun createGooglePayConfirmationDefinition( googlePayPaymentMethodLauncherFactory: GooglePayPaymentMethodLauncherFactory = - RecordingGooglePayPaymentMethodLauncherFactory( - googlePayPaymentMethodLauncher = mock() - ), + RecordingGooglePayPaymentMethodLauncherFactory.noOp(launcher = mock()), userFacingLogger: UserFacingLogger = FakeUserFacingLogger() ): GooglePayConfirmationDefinition { return GooglePayConfirmationDefinition( @@ -459,6 +580,18 @@ class GooglePayConfirmationDefinitionTest { ) } + @Parcelize + private object FakeCardBrandFilter : CardBrandFilter { + override fun isAccepted(cardBrand: CardBrand): Boolean { + return false + } + } + + private class ActionScenario( + val action: ConfirmationDefinition.Action, + val userFacingLogger: FakeUserFacingLogger, + ) + private companion object { private val GOOGLE_PAY_CONFIRMATION_OPTION = GooglePayConfirmationOption( initializationMode = PaymentElementLoader.InitializationMode.PaymentIntent( diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayConfirmationFlowTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayConfirmationFlowTest.kt index aee018c510e..9c5da9eaf74 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayConfirmationFlowTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayConfirmationFlowTest.kt @@ -27,50 +27,53 @@ class GooglePayConfirmationFlowTest { @Test fun `on launch, should persist parameters & launch using launcher as expected`() = runTest { val googlePayPaymentMethodLauncher = mock() - val savedStateHandle = SavedStateHandle() - val mediator = ConfirmationMediator( - savedStateHandle = savedStateHandle, - definition = GooglePayConfirmationDefinition( - googlePayPaymentMethodLauncherFactory = RecordingGooglePayPaymentMethodLauncherFactory( - googlePayPaymentMethodLauncher = googlePayPaymentMethodLauncher, - ), - userFacingLogger = null, - ), - ) - DummyActivityResultCaller.test { - mediator.register( - activityResultCaller = activityResultCaller, - onResult = {} - ) + RecordingGooglePayPaymentMethodLauncherFactory.test(googlePayPaymentMethodLauncher) { + DummyActivityResultCaller.test { + val savedStateHandle = SavedStateHandle() + val mediator = ConfirmationMediator( + savedStateHandle = savedStateHandle, + definition = GooglePayConfirmationDefinition( + googlePayPaymentMethodLauncherFactory = factory, + userFacingLogger = null, + ), + ) - assertThat(awaitRegisterCall()).isNotNull() - assertThat(awaitNextRegisteredLauncher()).isNotNull() + mediator.register( + activityResultCaller = activityResultCaller, + onResult = {} + ) - val action = mediator.action( - option = GOOGLE_PAY_CONFIRMATION_OPTION, - intent = PAYMENT_INTENT, - ) + assertThat(awaitRegisterCall()).isNotNull() + assertThat(awaitNextRegisteredLauncher()).isNotNull() - assertThat(action).isInstanceOf() + val action = mediator.action( + option = GOOGLE_PAY_CONFIRMATION_OPTION, + intent = PAYMENT_INTENT, + ) - val launchAction = action.asLaunch() + assertThat(action).isInstanceOf() - launchAction.launch() + val launchAction = action.asLaunch() - val parameters = savedStateHandle - .get>("GooglePayParameters") + launchAction.launch() - assertThat(parameters?.confirmationOption).isEqualTo(GOOGLE_PAY_CONFIRMATION_OPTION) - assertThat(parameters?.intent).isEqualTo(PAYMENT_INTENT) - assertThat(parameters?.deferredIntentConfirmationType).isNull() + assertThat(createGooglePayPaymentMethodLauncherCalls.awaitItem()).isNotNull() - verify(googlePayPaymentMethodLauncher, times(1)).present( - currencyCode = "usd", - amount = 1000L, - transactionId = "pi_12345", - label = null, - ) + val parameters = savedStateHandle + .get>("GooglePayParameters") + + assertThat(parameters?.confirmationOption).isEqualTo(GOOGLE_PAY_CONFIRMATION_OPTION) + assertThat(parameters?.intent).isEqualTo(PAYMENT_INTENT) + assertThat(parameters?.deferredIntentConfirmationType).isNull() + + verify(googlePayPaymentMethodLauncher, times(1)).present( + currencyCode = "usd", + amount = 1000L, + transactionId = "pi_12345", + label = null, + ) + } } } @@ -79,7 +82,7 @@ class GooglePayConfirmationFlowTest { confirmationOption = GOOGLE_PAY_CONFIRMATION_OPTION, intent = PAYMENT_INTENT, definition = GooglePayConfirmationDefinition( - googlePayPaymentMethodLauncherFactory = RecordingGooglePayPaymentMethodLauncherFactory(mock()), + googlePayPaymentMethodLauncherFactory = RecordingGooglePayPaymentMethodLauncherFactory.noOp(mock()), userFacingLogger = null, ), launcherResult = GooglePayPaymentMethodLauncher.Result.Completed(PAYMENT_METHOD), diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/flowcontroller/DefaultFlowControllerTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/flowcontroller/DefaultFlowControllerTest.kt index 211fb4a9e8d..b3075fc77cf 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/flowcontroller/DefaultFlowControllerTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/flowcontroller/DefaultFlowControllerTest.kt @@ -150,7 +150,7 @@ internal class DefaultFlowControllerTest { private val googlePayPaymentMethodLauncher = mock() private val googlePayPaymentMethodLauncherFactory = - RecordingGooglePayPaymentMethodLauncherFactory(googlePayPaymentMethodLauncher) + RecordingGooglePayPaymentMethodLauncherFactory.noOp(googlePayPaymentMethodLauncher) private val linkActivityResultLauncher = mock>() diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/utils/RecordingGooglePayPaymentMethodLauncherFactory.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/utils/RecordingGooglePayPaymentMethodLauncherFactory.kt index f063a84333a..f39d7306aa8 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/utils/RecordingGooglePayPaymentMethodLauncherFactory.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/utils/RecordingGooglePayPaymentMethodLauncherFactory.kt @@ -10,7 +10,7 @@ import com.stripe.android.googlepaylauncher.injection.GooglePayPaymentMethodLaun import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.runTest -internal class RecordingGooglePayPaymentMethodLauncherFactory( +internal class RecordingGooglePayPaymentMethodLauncherFactory private constructor( private val googlePayPaymentMethodLauncher: GooglePayPaymentMethodLauncher, ) : GooglePayPaymentMethodLauncherFactory { private val calls = Turbine() @@ -67,5 +67,9 @@ internal class RecordingGooglePayPaymentMethodLauncherFactory( factory.calls.ensureAllEventsConsumed() } + + fun noOp(launcher: GooglePayPaymentMethodLauncher): RecordingGooglePayPaymentMethodLauncherFactory { + return RecordingGooglePayPaymentMethodLauncherFactory(launcher) + } } }