diff --git a/link/api/link.api b/link/api/link.api index a220d8b4daf..adbb62e0afe 100644 --- a/link/api/link.api +++ b/link/api/link.api @@ -123,14 +123,12 @@ public final class com/stripe/android/link/ui/ComposableSingletons$LinkContentKt public static field lambda-3 Lkotlin/jvm/functions/Function4; public static field lambda-4 Lkotlin/jvm/functions/Function4; public static field lambda-5 Lkotlin/jvm/functions/Function4; - public static field lambda-6 Lkotlin/jvm/functions/Function4; public fun ()V public final fun getLambda-1$link_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda-2$link_release ()Lkotlin/jvm/functions/Function4; public final fun getLambda-3$link_release ()Lkotlin/jvm/functions/Function4; public final fun getLambda-4$link_release ()Lkotlin/jvm/functions/Function4; public final fun getLambda-5$link_release ()Lkotlin/jvm/functions/Function4; - public final fun getLambda-6$link_release ()Lkotlin/jvm/functions/Function4; } public final class com/stripe/android/link/ui/ComposableSingletons$LinkTermsKt { diff --git a/link/src/main/java/com/stripe/android/link/LinkActivityViewModel.kt b/link/src/main/java/com/stripe/android/link/LinkActivityViewModel.kt index 557846046a0..2a96b4d17a3 100644 --- a/link/src/main/java/com/stripe/android/link/LinkActivityViewModel.kt +++ b/link/src/main/java/com/stripe/android/link/LinkActivityViewModel.kt @@ -17,6 +17,7 @@ import com.stripe.android.link.account.LinkAccountManager import com.stripe.android.link.injection.DaggerNativeLinkComponent import com.stripe.android.link.injection.NativeLinkComponent import com.stripe.android.link.model.AccountStatus +import com.stripe.android.link.model.LinkAccount import com.stripe.android.link.ui.LinkAppBarState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -37,6 +38,10 @@ internal class LinkActivityViewModel @Inject constructor( ) ) val linkState: StateFlow = _linkState + + val linkAccount: LinkAccount? + get() = linkAccountManager.linkAccount.value + var navController: NavHostController? = null var dismissWithResult: ((LinkActivityResult) -> Unit)? = null @@ -54,6 +59,23 @@ internal class LinkActivityViewModel @Inject constructor( } } + fun navigate(screen: LinkScreen, clearStack: Boolean) { + val navController = navController ?: return + navController.navigate(screen.route) { + if (clearStack) { + popUpTo(navController.graph.startDestinationId) { + inclusive = true + } + } + } + } + + fun goBack() { + if (navController?.popBackStack() == false) { + dismissWithResult?.invoke(LinkActivityResult.Canceled()) + } + } + fun unregisterActivity() { navController = null dismissWithResult = null diff --git a/link/src/main/java/com/stripe/android/link/NoLinkAccountFound.kt b/link/src/main/java/com/stripe/android/link/NoLinkAccountFound.kt new file mode 100644 index 00000000000..91720bcac22 --- /dev/null +++ b/link/src/main/java/com/stripe/android/link/NoLinkAccountFound.kt @@ -0,0 +1,3 @@ +package com.stripe.android.link + +internal class NoLinkAccountFound : IllegalStateException("No link account found") diff --git a/link/src/main/java/com/stripe/android/link/account/DefaultLinkAccountManager.kt b/link/src/main/java/com/stripe/android/link/account/DefaultLinkAccountManager.kt index f91956f7f29..f96ec5c6afa 100644 --- a/link/src/main/java/com/stripe/android/link/account/DefaultLinkAccountManager.kt +++ b/link/src/main/java/com/stripe/android/link/account/DefaultLinkAccountManager.kt @@ -221,6 +221,7 @@ internal class DefaultLinkAccountManager @Inject constructor( override suspend fun startVerification(): Result { val clientSecret = linkAccount.value?.clientSecret ?: return Result.failure(Throwable("no link account found")) + linkEventsReporter.on2FAStart() return linkRepository.startVerification(clientSecret, consumerPublishableKey) .onFailure { linkEventsReporter.on2FAStartFailure() @@ -232,7 +233,11 @@ internal class DefaultLinkAccountManager @Inject constructor( override suspend fun confirmVerification(code: String): Result { val clientSecret = linkAccount.value?.clientSecret ?: return Result.failure(Throwable("no link account found")) return linkRepository.confirmVerification(code, clientSecret, consumerPublishableKey) - .map { consumerSession -> + .onSuccess { + linkEventsReporter.on2FAComplete() + }.onFailure { + linkEventsReporter.on2FAFailure() + }.map { consumerSession -> setAccount(consumerSession, null) } } diff --git a/link/src/main/java/com/stripe/android/link/analytics/DefaultLinkEventsReporter.kt b/link/src/main/java/com/stripe/android/link/analytics/DefaultLinkEventsReporter.kt index 6b9785ca5e4..da5547406a2 100644 --- a/link/src/main/java/com/stripe/android/link/analytics/DefaultLinkEventsReporter.kt +++ b/link/src/main/java/com/stripe/android/link/analytics/DefaultLinkEventsReporter.kt @@ -71,10 +71,26 @@ internal class DefaultLinkEventsReporter @Inject constructor( fireEvent(LinkEvent.AccountLookupFailure, params) } + override fun on2FAStart() { + fireEvent(LinkEvent.TwoFAStart) + } + override fun on2FAStartFailure() { fireEvent(LinkEvent.TwoFAStartFailure) } + override fun on2FAComplete() { + fireEvent(LinkEvent.TwoFAComplete) + } + + override fun on2FAFailure() { + fireEvent(LinkEvent.TwoFAFailure) + } + + override fun on2FACancel() { + fireEvent(LinkEvent.TwoFACancel) + } + override fun onPopupShow() { fireEvent(LinkEvent.PopupShow) } diff --git a/link/src/main/java/com/stripe/android/link/analytics/LinkEvent.kt b/link/src/main/java/com/stripe/android/link/analytics/LinkEvent.kt index 81b033f52c2..d859bf0411b 100644 --- a/link/src/main/java/com/stripe/android/link/analytics/LinkEvent.kt +++ b/link/src/main/java/com/stripe/android/link/analytics/LinkEvent.kt @@ -32,10 +32,26 @@ internal sealed class LinkEvent : AnalyticsEvent { override val eventName = "link.account_lookup.failure" } + object TwoFAStart : LinkEvent() { + override val eventName = "link.2fa.start" + } + object TwoFAStartFailure : LinkEvent() { override val eventName = "link.2fa.start_failure" } + object TwoFAComplete : LinkEvent() { + override val eventName = "link.2fa.complete" + } + + object TwoFAFailure : LinkEvent() { + override val eventName = "link.2fa.failure" + } + + object TwoFACancel : LinkEvent() { + override val eventName = "link.2fa.cancel" + } + object PopupShow : LinkEvent() { override val eventName = "link.popup.show" } diff --git a/link/src/main/java/com/stripe/android/link/analytics/LinkEventsReporter.kt b/link/src/main/java/com/stripe/android/link/analytics/LinkEventsReporter.kt index a329ab0bcd7..5fa88496651 100644 --- a/link/src/main/java/com/stripe/android/link/analytics/LinkEventsReporter.kt +++ b/link/src/main/java/com/stripe/android/link/analytics/LinkEventsReporter.kt @@ -13,7 +13,11 @@ internal interface LinkEventsReporter { fun onSignupFailure(isInline: Boolean = false, error: Throwable) fun onAccountLookupFailure(error: Throwable) + fun on2FAStart() fun on2FAStartFailure() + fun on2FAComplete() + fun on2FAFailure() + fun on2FACancel() fun onPopupShow() fun onPopupSuccess() diff --git a/link/src/main/java/com/stripe/android/link/ui/LinkContent.kt b/link/src/main/java/com/stripe/android/link/ui/LinkContent.kt index a703ed2b07c..40d932ee949 100644 --- a/link/src/main/java/com/stripe/android/link/ui/LinkContent.kt +++ b/link/src/main/java/com/stripe/android/link/ui/LinkContent.kt @@ -21,9 +21,12 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import com.stripe.android.link.LinkAction +import com.stripe.android.link.LinkActivityResult import com.stripe.android.link.LinkActivityViewModel import com.stripe.android.link.LinkScreen +import com.stripe.android.link.NoLinkAccountFound import com.stripe.android.link.linkViewModel +import com.stripe.android.link.model.LinkAccount import com.stripe.android.link.theme.DefaultLinkTheme import com.stripe.android.link.theme.linkColors import com.stripe.android.link.theme.linkShapes @@ -32,6 +35,7 @@ import com.stripe.android.link.ui.paymentmenthod.PaymentMethodScreen import com.stripe.android.link.ui.signup.SignUpScreen import com.stripe.android.link.ui.signup.SignUpViewModel import com.stripe.android.link.ui.verification.VerificationScreen +import com.stripe.android.link.ui.verification.VerificationViewModel import com.stripe.android.link.ui.wallet.WalletScreen import com.stripe.android.ui.core.CircularProgressIndicator import kotlinx.coroutines.launch @@ -86,7 +90,19 @@ internal fun LinkContent( } ) - Screens(navController) + Screens( + navController = navController, + goBack = viewModel::goBack, + navigateAndClearStack = { screen -> + viewModel.navigate(screen, clearStack = true) + }, + dismissWithResult = { result -> + viewModel.dismissWithResult?.invoke(result) + }, + getLinkAccount = { + viewModel.linkAccount + } + ) } } } @@ -94,7 +110,11 @@ internal fun LinkContent( @Composable private fun Screens( - navController: NavHostController + navController: NavHostController, + getLinkAccount: () -> LinkAccount?, + goBack: () -> Unit, + navigateAndClearStack: (route: LinkScreen) -> Unit, + dismissWithResult: (LinkActivityResult) -> Unit ) { NavHost( navController = navController, @@ -121,7 +141,17 @@ private fun Screens( } composable(LinkScreen.Verification.route) { - VerificationScreen() + val linkAccount = getLinkAccount() + ?: return@composable dismissWithResult(LinkActivityResult.Failed(NoLinkAccountFound())) + val viewModel: VerificationViewModel = linkViewModel { parentComponent -> + VerificationViewModel.factory( + parentComponent = parentComponent, + goBack = goBack, + navigateAndClearStack = navigateAndClearStack, + linkAccount = linkAccount + ) + } + VerificationScreen(viewModel) } composable(LinkScreen.Wallet.route) { diff --git a/link/src/main/java/com/stripe/android/link/ui/verification/VerificationScreen.kt b/link/src/main/java/com/stripe/android/link/ui/verification/VerificationScreen.kt index 6140c9ffa63..ecc60d0998d 100644 --- a/link/src/main/java/com/stripe/android/link/ui/verification/VerificationScreen.kt +++ b/link/src/main/java/com/stripe/android/link/ui/verification/VerificationScreen.kt @@ -3,7 +3,10 @@ package com.stripe.android.link.ui.verification import androidx.compose.material.Text import androidx.compose.runtime.Composable +@SuppressWarnings("UnusedParameter") @Composable -internal fun VerificationScreen() { +internal fun VerificationScreen( + viewModel: VerificationViewModel +) { Text(text = "VerificationScreen") } diff --git a/link/src/main/java/com/stripe/android/link/ui/verification/VerificationViewModel.kt b/link/src/main/java/com/stripe/android/link/ui/verification/VerificationViewModel.kt new file mode 100644 index 00000000000..6108ef39b96 --- /dev/null +++ b/link/src/main/java/com/stripe/android/link/ui/verification/VerificationViewModel.kt @@ -0,0 +1,199 @@ +package com.stripe.android.link.ui.verification + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.stripe.android.core.Logger +import com.stripe.android.core.strings.ResolvableString +import com.stripe.android.core.strings.resolvableString +import com.stripe.android.link.LinkScreen +import com.stripe.android.link.account.LinkAccountManager +import com.stripe.android.link.analytics.LinkEventsReporter +import com.stripe.android.link.injection.NativeLinkComponent +import com.stripe.android.link.model.AccountStatus +import com.stripe.android.link.model.LinkAccount +import com.stripe.android.link.ui.ErrorMessage +import com.stripe.android.link.ui.getErrorMessage +import com.stripe.android.ui.core.elements.OTPSpec +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * ViewModel that handles user verification confirmation logic. + */ +internal class VerificationViewModel @Inject constructor( + private val linkAccount: LinkAccount, + private val linkAccountManager: LinkAccountManager, + private val linkEventsReporter: LinkEventsReporter, + private val logger: Logger, + private val goBack: () -> Unit, + private val navigateAndClearStack: (route: LinkScreen) -> Unit, +) : ViewModel() { + + private val _viewState = MutableStateFlow( + value = VerificationViewState( + redactedPhoneNumber = linkAccount.redactedPhoneNumber, + email = linkAccount.email, + isProcessing = false, + requestFocus = true, + errorMessage = null, + isSendingNewCode = false, + didSendNewCode = false + ) + ) + val viewState: StateFlow = _viewState + + val otpElement = OTPSpec.transform() + + private val otpCode: StateFlow = + otpElement.otpCompleteFlow.stateIn(viewModelScope, SharingStarted.Lazily, null) + + init { + setUp() + } + + private fun setUp() { + if (linkAccount.accountStatus != AccountStatus.VerificationStarted) { + startVerification() + } + + viewModelScope.launch { + otpCode.collect { code -> + code?.let { onVerificationCodeEntered(code) } + } + } + } + + suspend fun onVerificationCodeEntered(code: String) { + updateViewState { + it.copy( + isProcessing = true, + errorMessage = null, + ) + } + + linkAccountManager.confirmVerification(code).fold( + onSuccess = { + updateViewState { + it.copy(isProcessing = false) + } + navigateAndClearStack(LinkScreen.Wallet) + }, + onFailure = { + otpElement.controller.reset() + onError(it) + } + ) + } + + private fun startVerification() { + updateViewState { + it.copy(errorMessage = null) + } + + viewModelScope.launch { + val result = linkAccountManager.startVerification() + val error = result.exceptionOrNull() + + updateViewState { + it.copy( + isSendingNewCode = false, + didSendNewCode = error == null, + errorMessage = error?.getErrorMessage()?.resolvableString, + ) + } + } + } + + fun resendCode() { + updateViewState { it.copy(isSendingNewCode = true) } + startVerification() + } + + fun didShowCodeSentNotification() { + updateViewState { + it.copy(didSendNewCode = false) + } + } + + fun onBack() { + clearError() + goBack() + linkEventsReporter.on2FACancel() + viewModelScope.launch { + linkAccountManager.logOut() + } + } + + fun onChangeEmailClicked() { + clearError() + navigateAndClearStack(LinkScreen.SignUp) + viewModelScope.launch { + linkAccountManager.logOut() + } + } + + fun onFocusRequested() { + updateViewState { + it.copy(requestFocus = false) + } + } + + private fun clearError() { + updateViewState { + it.copy(errorMessage = null) + } + } + + private fun onError(error: Throwable) = error.getErrorMessage().let { message -> + logger.error("VerificationViewModel Error: ", error) + + updateViewState { + it.copy( + isProcessing = false, + errorMessage = message.resolvableString, + ) + } + } + + private fun updateViewState(block: (VerificationViewState) -> VerificationViewState) { + _viewState.update(block) + } + + private val ErrorMessage.resolvableString: ResolvableString + get() { + return when (this) { + is ErrorMessage.FromResources -> this.stringResId.resolvableString + is ErrorMessage.Raw -> this.errorMessage.resolvableString + } + } + + companion object { + fun factory( + parentComponent: NativeLinkComponent, + linkAccount: LinkAccount, + goBack: () -> Unit, + navigateAndClearStack: (route: LinkScreen) -> Unit, + ): ViewModelProvider.Factory { + return viewModelFactory { + initializer { + VerificationViewModel( + linkAccount = linkAccount, + linkAccountManager = parentComponent.linkAccountManager, + linkEventsReporter = parentComponent.linkEventsReporter, + logger = parentComponent.logger, + goBack = goBack, + navigateAndClearStack = navigateAndClearStack, + ) + } + } + } + } +} diff --git a/link/src/main/java/com/stripe/android/link/ui/verification/VerificationViewState.kt b/link/src/main/java/com/stripe/android/link/ui/verification/VerificationViewState.kt new file mode 100644 index 00000000000..138c196ae39 --- /dev/null +++ b/link/src/main/java/com/stripe/android/link/ui/verification/VerificationViewState.kt @@ -0,0 +1,15 @@ +package com.stripe.android.link.ui.verification + +import androidx.compose.runtime.Immutable +import com.stripe.android.core.strings.ResolvableString + +@Immutable +internal data class VerificationViewState( + val isProcessing: Boolean, + val requestFocus: Boolean, + val errorMessage: ResolvableString?, + val isSendingNewCode: Boolean, + val didSendNewCode: Boolean, + val redactedPhoneNumber: String, + val email: String +) diff --git a/link/src/test/java/com/stripe/android/link/LinkActivityViewModelTest.kt b/link/src/test/java/com/stripe/android/link/LinkActivityViewModelTest.kt index 4c3ce07967d..3808478ceaa 100644 --- a/link/src/test/java/com/stripe/android/link/LinkActivityViewModelTest.kt +++ b/link/src/test/java/com/stripe/android/link/LinkActivityViewModelTest.kt @@ -34,7 +34,8 @@ import kotlin.test.assertFailsWith @RunWith(RobolectricTestRunner::class) internal class LinkActivityViewModelTest { private val dispatcher = UnconfinedTestDispatcher() - private val vm = LinkActivityViewModel(mock(), FakeLinkAccountManager()) + private val linkAccountManager = FakeLinkAccountManager() + private val vm = LinkActivityViewModel(mock(), linkAccountManager) private val navController: NavHostController = mock() private val dismissWithResult: (LinkActivityResult) -> Unit = mock() @@ -111,6 +112,13 @@ internal class LinkActivityViewModelTest { assertThat(viewModel.activityRetainedComponent.configuration).isEqualTo(mockArgs.configuration) } + @Test + fun `linkAccount value returns latest value from link account manager`() = runTest(dispatcher) { + linkAccountManager.setLinkAccount(TestFactory.LINK_ACCOUNT) + + assertThat(vm.linkAccount).isEqualTo(TestFactory.LINK_ACCOUNT) + } + private fun creationExtras(): CreationExtras { val mockOwner = mock() val mockViewModelStoreOwner = mock() diff --git a/link/src/test/java/com/stripe/android/link/TestFactory.kt b/link/src/test/java/com/stripe/android/link/TestFactory.kt index 8ad511029d9..f8d1809480b 100644 --- a/link/src/test/java/com/stripe/android/link/TestFactory.kt +++ b/link/src/test/java/com/stripe/android/link/TestFactory.kt @@ -1,5 +1,6 @@ package com.stripe.android.link +import com.stripe.android.link.model.LinkAccount import com.stripe.android.model.CardParams import com.stripe.android.model.ConsumerPaymentDetails import com.stripe.android.model.ConsumerSession @@ -8,7 +9,7 @@ import com.stripe.android.model.ConsumerSessionSignup import com.stripe.android.model.PaymentMethodCreateParams import org.mockito.kotlin.mock -object TestFactory { +internal object TestFactory { fun consumerSessionLookUp( publishableKey: String, @@ -66,4 +67,6 @@ object TestFactory { paymentMethodCreateParams = PAYMENT_METHOD_CREATE_PARAMS, originalParams = mock() ) + + val LINK_ACCOUNT = LinkAccount(CONSUMER_SESSION) } diff --git a/link/src/test/java/com/stripe/android/link/account/DefaultLinkAccountManagerTest.kt b/link/src/test/java/com/stripe/android/link/account/DefaultLinkAccountManagerTest.kt index c3c5fecb828..965ee569ec8 100644 --- a/link/src/test/java/com/stripe/android/link/account/DefaultLinkAccountManagerTest.kt +++ b/link/src/test/java/com/stripe/android/link/account/DefaultLinkAccountManagerTest.kt @@ -567,12 +567,19 @@ class DefaultLinkAccountManagerTest { @Test fun `startVerification updates account`() = runSuspendTest { - val accountManager = accountManager() + val linkEventsReporter = object : AccountManagerEventsReporter() { + var callCount = 0 + override fun on2FAStart() { + callCount += 1 + } + } + val accountManager = accountManager(linkEventsReporter = linkEventsReporter) accountManager.setAccountNullable(TestFactory.CONSUMER_SESSION, null) accountManager.startVerification() assertThat(accountManager.linkAccount.value).isNotNull() + assertThat(linkEventsReporter.callCount).isEqualTo(1) } @Test @@ -621,7 +628,9 @@ class DefaultLinkAccountManagerTest { @Test fun `confirmVerification returns error when link account is null`() = runSuspendTest { val linkRepository = FakeLinkRepository() - val accountManager = accountManager(linkRepository = linkRepository) + val accountManager = accountManager( + linkRepository = linkRepository, + ) accountManager.setAccountNullable(null, null) val result = accountManager.confirmVerification("123") @@ -642,7 +651,13 @@ class DefaultLinkAccountManagerTest { return super.confirmVerification(verificationCode, consumerSessionClientSecret, consumerPublishableKey) } } - val accountManager = accountManager(linkRepository = linkRepository) + val linkEventsReporter = object : AccountManagerEventsReporter() { + var callCount = 0 + override fun on2FAComplete() { + callCount += 1 + } + } + val accountManager = accountManager(linkRepository = linkRepository, linkEventsReporter = linkEventsReporter) accountManager.setAccountNullable(TestFactory.CONSUMER_SESSION, null) linkRepository.confirmVerificationResult = Result.success(TestFactory.CONSUMER_SESSION) @@ -651,6 +666,7 @@ class DefaultLinkAccountManagerTest { assertThat(linkRepository.callCount).isEqualTo(1) assertThat(result.isSuccess).isTrue() + assertThat(linkEventsReporter.callCount).isEqualTo(1) } @Test @@ -667,13 +683,20 @@ class DefaultLinkAccountManagerTest { return Result.failure(error) } } - val accountManager = accountManager(linkRepository = linkRepository) + val linkEventsReporter = object : AccountManagerEventsReporter() { + var callCount = 0 + override fun on2FAFailure() { + callCount += 1 + } + } + val accountManager = accountManager(linkRepository = linkRepository, linkEventsReporter = linkEventsReporter) accountManager.setAccountNullable(TestFactory.CONSUMER_SESSION, null) val result = accountManager.confirmVerification("123") assertThat(linkRepository.callCount).isEqualTo(1) assertThat(result).isEqualTo(Result.failure(error)) + assertThat(linkEventsReporter.callCount).isEqualTo(1) } private fun runSuspendTest(testBody: suspend TestScope.() -> Unit) = runTest { @@ -724,4 +747,7 @@ private open class AccountManagerEventsReporter : FakeLinkEventsReporter() { override fun onSignupFailure(isInline: Boolean, error: Throwable) = Unit override fun onAccountLookupFailure(error: Throwable) = Unit override fun on2FAStartFailure() = Unit + override fun on2FAStart() = Unit + override fun on2FAComplete() = Unit + override fun on2FAFailure() = Unit } diff --git a/link/src/test/java/com/stripe/android/link/account/FakeLinkAccountManager.kt b/link/src/test/java/com/stripe/android/link/account/FakeLinkAccountManager.kt index 0f725490c5d..b054d13af7d 100644 --- a/link/src/test/java/com/stripe/android/link/account/FakeLinkAccountManager.kt +++ b/link/src/test/java/com/stripe/android/link/account/FakeLinkAccountManager.kt @@ -23,7 +23,7 @@ internal open class FakeLinkAccountManager : LinkAccountManager { var lookupConsumerResult: Result = Result.success(null) var startVerificationResult: Result = Result.success(LinkAccount(ConsumerSession("", "", "", ""))) - var confirmVerification: Result = Result.success(LinkAccount(ConsumerSession("", "", "", ""))) + var confirmVerificationResult: Result = Result.success(LinkAccount(ConsumerSession("", "", "", ""))) var signUpResult: Result = Result.success(LinkAccount(ConsumerSession("", "", "", ""))) var signInWithUserInputResult: Result = Result.success(LinkAccount(ConsumerSession("", "", "", ""))) var logOutResult: Result = Result.success(ConsumerSession("", "", "", "")) @@ -84,6 +84,6 @@ internal open class FakeLinkAccountManager : LinkAccountManager { } override suspend fun confirmVerification(code: String): Result { - return confirmVerification + return confirmVerificationResult } } diff --git a/link/src/test/java/com/stripe/android/link/analytics/FakeLinkEventsReporter.kt b/link/src/test/java/com/stripe/android/link/analytics/FakeLinkEventsReporter.kt index e13f64765d3..f6fc820f66c 100644 --- a/link/src/test/java/com/stripe/android/link/analytics/FakeLinkEventsReporter.kt +++ b/link/src/test/java/com/stripe/android/link/analytics/FakeLinkEventsReporter.kt @@ -30,10 +30,26 @@ internal open class FakeLinkEventsReporter : LinkEventsReporter { throw NotImplementedError() } + override fun on2FAStart() { + throw NotImplementedError() + } + override fun on2FAStartFailure() { throw NotImplementedError() } + override fun on2FAComplete() { + throw NotImplementedError() + } + + override fun on2FAFailure() { + throw NotImplementedError() + } + + override fun on2FACancel() { + throw NotImplementedError() + } + override fun onPopupShow() { throw NotImplementedError() } diff --git a/link/src/test/java/com/stripe/android/link/ui/verification/VerificationViewModelTest.kt b/link/src/test/java/com/stripe/android/link/ui/verification/VerificationViewModelTest.kt new file mode 100644 index 00000000000..96adaaa0f19 --- /dev/null +++ b/link/src/test/java/com/stripe/android/link/ui/verification/VerificationViewModelTest.kt @@ -0,0 +1,238 @@ +package com.stripe.android.link.ui.verification + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.stripe.android.core.Logger +import com.stripe.android.core.strings.resolvableString +import com.stripe.android.link.LinkScreen +import com.stripe.android.link.TestFactory +import com.stripe.android.link.account.FakeLinkAccountManager +import com.stripe.android.link.account.LinkAccountManager +import com.stripe.android.link.analytics.FakeLinkEventsReporter +import com.stripe.android.link.analytics.LinkEventsReporter +import com.stripe.android.link.model.LinkAccount +import com.stripe.android.model.ConsumerSession +import com.stripe.android.testing.FakeLogger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class VerificationViewModelTest { + private val dispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `init starts verification with link account manager`() = runTest(dispatcher) { + val linkAccountManager = object : FakeLinkAccountManager() { + var callCount = 0 + override suspend fun startVerification(): Result { + callCount += 1 + return Result.success(TestFactory.LINK_ACCOUNT) + } + } + + createViewModel( + linkAccountManager = linkAccountManager + ) + + assertThat(linkAccountManager.callCount).isEqualTo(1) + } + + @Test + fun `When startVerification fails then an error message is shown`() = runTest(dispatcher) { + val errorMessage = "Error message" + val linkAccountManager = FakeLinkAccountManager() + linkAccountManager.startVerificationResult = Result.failure(RuntimeException(errorMessage)) + + val viewModel = createViewModel(linkAccountManager = linkAccountManager) + + assertThat(viewModel.viewState.value.errorMessage).isEqualTo(errorMessage.resolvableString) + } + + @Test + fun `When confirmVerification succeeds then it navigates to Wallet`() = + runTest(dispatcher) { + val screens = arrayListOf() + fun navigateAndClearStack(screen: LinkScreen) { + screens.add(screen) + } + + val viewModel = createViewModel( + navigateAndClearStack = ::navigateAndClearStack + ) + viewModel.onVerificationCodeEntered("code") + + assertThat(screens).isEqualTo(listOf(LinkScreen.Wallet)) + } + + @Test + fun `When confirmVerification fails then an error message is shown`() = + runTest(dispatcher) { + val errorMessage = "Error message" + val linkAccountManager = FakeLinkAccountManager() + + linkAccountManager.confirmVerificationResult = Result.failure(RuntimeException(errorMessage)) + + val viewModel = createViewModel( + linkAccountManager = linkAccountManager + ) + viewModel.onVerificationCodeEntered("code") + + assertThat(viewModel.viewState.value.errorMessage).isEqualTo(errorMessage.resolvableString) + } + + @Test + fun `When confirmVerification fails then code is cleared`() = runTest(dispatcher) { + val linkEventsReporter = object : FakeLinkEventsReporter() { + override fun on2FAFailure() = Unit + } + val linkAccountManager = object : FakeLinkAccountManager() { + var codeUsed: String? = null + override suspend fun confirmVerification(code: String): Result { + codeUsed = code + return Result.failure(RuntimeException("Error")) + } + } + + val viewModel = createViewModel( + linkAccountManager = linkAccountManager, + linkEventsReporter = linkEventsReporter + ) + viewModel.onVerificationCodeEntered("555555") + + assertThat(linkAccountManager.codeUsed).isEqualTo("555555") + assertThat(viewModel.otpElement.controller.fieldValue.value).isEqualTo("") + } + + @Test + fun `onChangeEmailClicked triggers logout`() = runTest(dispatcher) { + val linkAccountManager = object : FakeLinkAccountManager() { + var callCount = 0 + override suspend fun logOut(): Result { + callCount += 1 + return super.logOut() + } + } + + val navScreens = arrayListOf() + fun navigateAndClearStack(screen: LinkScreen) { + navScreens.add(screen) + } + + createViewModel( + linkAccountManager = linkAccountManager, + navigateAndClearStack = ::navigateAndClearStack + ).onChangeEmailClicked() + + assertThat(linkAccountManager.callCount).isEqualTo(1) + assertThat(navScreens).isEqualTo(listOf(LinkScreen.SignUp)) + } + + @Test + fun `onBack triggers logout and sends analytics event`() = runTest(dispatcher) { + val linkEventsReporter = object : FakeLinkEventsReporter() { + var callCount = 0 + override fun on2FACancel() { + callCount += 1 + } + } + val linkAccountManager = object : FakeLinkAccountManager() { + var callCount = 0 + override suspend fun logOut(): Result { + callCount += 1 + return super.logOut() + } + } + createViewModel( + linkEventsReporter = linkEventsReporter, + linkAccountManager = linkAccountManager + ).onBack() + assertThat(linkEventsReporter.callCount).isEqualTo(1) + assertThat(linkAccountManager.callCount).isEqualTo(1) + } + + @Test + fun `Resending code is reflected in state`() = runTest(dispatcher) { + val linkAccountManager = object : FakeLinkAccountManager() { + override suspend fun startVerification(): Result { + delay(100) + return super.startVerification() + } + } + + val viewModel = createViewModel(linkAccountManager = linkAccountManager).apply { + resendCode() + } + + viewModel.viewState.test { + val intermediateState = awaitItem() + assertThat(intermediateState.isSendingNewCode).isTrue() + assertThat(intermediateState.didSendNewCode).isFalse() + + val finalState = awaitItem() + assertThat(finalState.isSendingNewCode).isFalse() + assertThat(finalState.didSendNewCode).isTrue() + } + } + + @Test + fun `Failing to resend code is reflected in state`() = runTest(dispatcher) { + val linkAccountManager = object : FakeLinkAccountManager() { + override suspend fun startVerification(): Result { + delay(100) + return Result.failure(RuntimeException("error")) + } + } + + val viewModel = createViewModel(linkAccountManager = linkAccountManager).apply { + resendCode() + } + + viewModel.viewState.test { + val intermediateState = awaitItem() + assertThat(intermediateState.isSendingNewCode).isTrue() + assertThat(intermediateState.didSendNewCode).isFalse() + assertThat(intermediateState.errorMessage).isNull() + + val finalState = awaitItem() + assertThat(finalState.isSendingNewCode).isFalse() + assertThat(finalState.didSendNewCode).isFalse() + assertThat(finalState.errorMessage).isEqualTo("error".resolvableString) + } + } + + private fun createViewModel( + linkAccountManager: LinkAccountManager = FakeLinkAccountManager(), + linkEventsReporter: LinkEventsReporter = FakeLinkEventsReporter(), + logger: Logger = FakeLogger(), + navigateAndClearStack: (LinkScreen) -> Unit = {}, + goBack: () -> Unit = {}, + ): VerificationViewModel { + return VerificationViewModel( + linkAccountManager = linkAccountManager, + linkEventsReporter = linkEventsReporter, + logger = logger, + navigateAndClearStack = navigateAndClearStack, + goBack = goBack, + linkAccount = TestFactory.LINK_ACCOUNT + ) + } +}