diff --git a/CHANGELOG.md b/CHANGELOG.md index 0993af83330..68d634f4280 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## XX.XX.XX - 2023-XX-XX +### Financial Connections +* [CHANGED][6919](https://github.com/stripe/stripe-android/pull/6919) Updated polling options for account retrieval and OAuth results to match other platforms. + ## 20.25.7 - 2023-06-20 ### Financial Connections diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/FetchNetworkedAccounts.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/FetchNetworkedAccounts.kt new file mode 100644 index 00000000000..814f03f8937 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/FetchNetworkedAccounts.kt @@ -0,0 +1,19 @@ +package com.stripe.android.financialconnections.domain + +import com.stripe.android.financialconnections.FinancialConnectionsSheet +import com.stripe.android.financialconnections.model.PartnerAccountsList +import com.stripe.android.financialconnections.repository.FinancialConnectionsAccountsRepository +import javax.inject.Inject + +internal class FetchNetworkedAccounts @Inject constructor( + private val repository: FinancialConnectionsAccountsRepository, + private val configuration: FinancialConnectionsSheet.Configuration +) { + + suspend operator fun invoke( + consumerSessionClientSecret: String, + ): PartnerAccountsList = repository.getNetworkedAccounts( + clientSecret = configuration.financialConnectionsSessionClientSecret, + consumerSessionClientSecret = consumerSessionClientSecret + ) +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PollAttachPaymentAccount.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PollAttachPaymentAccount.kt index 898a80f7fa4..cc91b89dab4 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PollAttachPaymentAccount.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PollAttachPaymentAccount.kt @@ -7,9 +7,11 @@ import com.stripe.android.financialconnections.model.FinancialConnectionsInstitu import com.stripe.android.financialconnections.model.LinkAccountSessionPaymentAccount import com.stripe.android.financialconnections.model.PaymentAccountParams import com.stripe.android.financialconnections.repository.FinancialConnectionsAccountsRepository +import com.stripe.android.financialconnections.utils.PollTimingOptions import com.stripe.android.financialconnections.utils.retryOnException import com.stripe.android.financialconnections.utils.shouldRetry import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds internal class PollAttachPaymentAccount @Inject constructor( private val repository: FinancialConnectionsAccountsRepository, @@ -25,8 +27,9 @@ internal class PollAttachPaymentAccount @Inject constructor( params: PaymentAccountParams ): LinkAccountSessionPaymentAccount { return retryOnException( - times = MAX_TRIES, - delayMilliseconds = POLLING_TIME_MS, + PollTimingOptions( + initialDelayMs = 1.seconds.inWholeMilliseconds, + ), retryCondition = { exception -> exception.shouldRetry } ) { try { @@ -58,11 +61,7 @@ internal class PollAttachPaymentAccount @Inject constructor( institution = institution, stripeException = this ) + else -> this } - - private companion object { - private const val POLLING_TIME_MS = 250L - private const val MAX_TRIES = 180 - } } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PollAuthorizationSessionAccounts.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PollAuthorizationSessionAccounts.kt index 86d8ba4b0be..1085cf60e72 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PollAuthorizationSessionAccounts.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PollAuthorizationSessionAccounts.kt @@ -6,18 +6,21 @@ import com.stripe.android.financialconnections.FinancialConnectionsSheet import com.stripe.android.financialconnections.exception.AccountLoadError import com.stripe.android.financialconnections.exception.AccountNoneEligibleForPaymentMethodError import com.stripe.android.financialconnections.features.common.getBusinessName +import com.stripe.android.financialconnections.model.FinancialConnectionsAuthorizationSession import com.stripe.android.financialconnections.model.FinancialConnectionsInstitution import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest import com.stripe.android.financialconnections.model.PartnerAccountsList import com.stripe.android.financialconnections.repository.FinancialConnectionsAccountsRepository +import com.stripe.android.financialconnections.utils.PollTimingOptions import com.stripe.android.financialconnections.utils.retryOnException import com.stripe.android.financialconnections.utils.shouldRetry import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds /** * Polls accounts from backend after authorization session completes. * - * Will retry upon 202 backend responses every [POLLING_TIME_MS] up to [MAX_TRIES] + * Will retry upon 202 backend responses. */ internal class PollAuthorizationSessionAccounts @Inject constructor( private val repository: FinancialConnectionsAccountsRepository, @@ -27,66 +30,80 @@ internal class PollAuthorizationSessionAccounts @Inject constructor( suspend operator fun invoke( canRetry: Boolean, manifest: FinancialConnectionsSessionManifest - ): PartnerAccountsList { - return retryOnException( - times = MAX_TRIES, - delayMilliseconds = POLLING_TIME_MS, + ): PartnerAccountsList = try { + val activeAuthSession = requireNotNull(manifest.activeAuthSession) + retryOnException( + PollTimingOptions( + initialDelayMs = activeAuthSession.flow.toPollIntervalMs(), + ), retryCondition = { exception -> exception.shouldRetry } ) { - try { - val authSession = requireNotNull(manifest.activeAuthSession) - val accounts = repository.postAuthorizationSessionAccounts( - clientSecret = configuration.financialConnectionsSessionClientSecret, - sessionId = authSession.id - ) - if (accounts.data.isEmpty()) { - throw AccountLoadError( - institution = requireNotNull(manifest.activeInstitution), - allowManualEntry = manifest.allowManualEntry, - canRetry = canRetry, - stripeException = APIException() - ) - } else { - accounts - } - } catch (@Suppress("SwallowedException") e: StripeException) { - throw e.toDomainException( - institution = manifest.activeInstitution, - businessName = manifest.getBusinessName(), + val accounts = repository.postAuthorizationSessionAccounts( + clientSecret = configuration.financialConnectionsSessionClientSecret, + sessionId = activeAuthSession.id + ) + if (accounts.data.isEmpty()) { + throw AccountLoadError( + institution = requireNotNull(manifest.activeInstitution), + allowManualEntry = manifest.allowManualEntry, canRetry = canRetry, - allowManualEntry = manifest.allowManualEntry + stripeException = APIException() ) + } else { + accounts } } + } catch (@Suppress("SwallowedException") e: StripeException) { + throw e.toDomainException( + institution = manifest.activeInstitution, + businessName = manifest.getBusinessName(), + canRetry = canRetry, + allowManualEntry = manifest.allowManualEntry + ) } +} - private fun StripeException.toDomainException( - institution: FinancialConnectionsInstitution?, - businessName: String?, - canRetry: Boolean, - allowManualEntry: Boolean - ): StripeException = - when { - institution == null -> this - stripeError?.extraFields?.get("reason") == "no_supported_payment_method_type_accounts_found" -> - AccountNoneEligibleForPaymentMethodError( - accountsCount = stripeError?.extraFields?.get("total_accounts_count")?.toInt() - ?: 0, - institution = institution, - merchantName = businessName ?: "", - stripeException = this - ) - - else -> AccountLoadError( - allowManualEntry = allowManualEntry, +private fun StripeException.toDomainException( + institution: FinancialConnectionsInstitution?, + businessName: String?, + canRetry: Boolean, + allowManualEntry: Boolean +): StripeException = + when { + institution == null -> this + stripeError?.extraFields?.get("reason") == "no_supported_payment_method_type_accounts_found" -> + AccountNoneEligibleForPaymentMethodError( + accountsCount = stripeError?.extraFields?.get("total_accounts_count")?.toInt() + ?: 0, institution = institution, - canRetry = canRetry, + merchantName = businessName ?: "", stripeException = this ) + + else -> AccountLoadError( + allowManualEntry = allowManualEntry, + institution = institution, + canRetry = canRetry, + stripeException = this + ) + } + +private fun FinancialConnectionsAuthorizationSession.Flow?.toPollIntervalMs(): Long { + val defaultInitialPollDelay: Long = 1.75.seconds.inWholeMilliseconds + return when (this) { + FinancialConnectionsAuthorizationSession.Flow.TESTMODE, + FinancialConnectionsAuthorizationSession.Flow.TESTMODE_OAUTH, + FinancialConnectionsAuthorizationSession.Flow.TESTMODE_OAUTH_WEBVIEW, + FinancialConnectionsAuthorizationSession.Flow.FINICITY_CONNECT_V2_LITE -> { + // Post auth flow, Finicity non-OAuth account retrieval latency is extremely quick - p90 < 1sec. + 0 + } + + FinancialConnectionsAuthorizationSession.Flow.MX_CONNECT -> { + // 10 account retrieval latency on MX non-OAuth sessions is currently 460 ms + 0.5.seconds.inWholeMilliseconds } - private companion object { - private const val POLLING_TIME_MS = 2000L - private const val MAX_TRIES = 10 + else -> defaultInitialPollDelay } } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PollAuthorizationSessionOAuthResults.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PollAuthorizationSessionOAuthResults.kt index c024a220bb8..749296d4f06 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PollAuthorizationSessionOAuthResults.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PollAuthorizationSessionOAuthResults.kt @@ -4,14 +4,16 @@ import com.stripe.android.financialconnections.FinancialConnectionsSheet import com.stripe.android.financialconnections.model.FinancialConnectionsAuthorizationSession import com.stripe.android.financialconnections.model.MixedOAuthParams import com.stripe.android.financialconnections.repository.FinancialConnectionsRepository +import com.stripe.android.financialconnections.utils.PollTimingOptions import com.stripe.android.financialconnections.utils.retryOnException import com.stripe.android.financialconnections.utils.shouldRetry import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds /** * Polls OAuth results from backend after user finishes authorization on web browser. * - * Will retry upon 202 backend responses every [POLLING_TIME_MS] up to [MAX_TRIES] + * Will retry upon 202 backend responses. */ internal class PollAuthorizationSessionOAuthResults @Inject constructor( private val repository: FinancialConnectionsRepository, @@ -22,8 +24,11 @@ internal class PollAuthorizationSessionOAuthResults @Inject constructor( session: FinancialConnectionsAuthorizationSession ): MixedOAuthParams { return retryOnException( - times = MAX_TRIES, - delayMilliseconds = POLLING_TIME_MS, + PollTimingOptions( + initialDelayMs = 0, + maxNumberOfRetries = 300, // Stripe.js has 600 second timeout, 600 / 2 = 300 retries + retryInterval = 2.seconds.inWholeMilliseconds + ), retryCondition = { exception -> exception.shouldRetry } ) { repository.postAuthorizationSessionOAuthResults( @@ -32,9 +37,4 @@ internal class PollAuthorizationSessionOAuthResults @Inject constructor( ) } } - - private companion object { - private const val POLLING_TIME_MS = 2000L - private const val MAX_TRIES = 300 - } } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PollNetworkedAccounts.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PollNetworkedAccounts.kt deleted file mode 100644 index b3a0c624e7d..00000000000 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/PollNetworkedAccounts.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.stripe.android.financialconnections.domain - -import com.stripe.android.financialconnections.FinancialConnectionsSheet -import com.stripe.android.financialconnections.domain.PollNetworkedAccounts.Companion.MAX_TRIES -import com.stripe.android.financialconnections.domain.PollNetworkedAccounts.Companion.POLLING_TIME_MS -import com.stripe.android.financialconnections.model.PartnerAccountsList -import com.stripe.android.financialconnections.repository.FinancialConnectionsAccountsRepository -import com.stripe.android.financialconnections.utils.retryOnException -import com.stripe.android.financialconnections.utils.shouldRetry -import javax.inject.Inject - -/** - * Polls for networked accounts. - * - * Will retry upon 202 backend responses every [POLLING_TIME_MS] up to [MAX_TRIES] - */ -internal class PollNetworkedAccounts @Inject constructor( - private val repository: FinancialConnectionsAccountsRepository, - private val configuration: FinancialConnectionsSheet.Configuration -) { - - suspend operator fun invoke( - consumerSessionClientSecret: String, - ): PartnerAccountsList = retryOnException( - times = MAX_TRIES, - delayMilliseconds = POLLING_TIME_MS, - retryCondition = { exception -> exception.shouldRetry } - ) { - repository.getNetworkedAccounts( - clientSecret = configuration.financialConnectionsSessionClientSecret, - consumerSessionClientSecret = consumerSessionClientSecret - ) - } - - private companion object { - private const val POLLING_TIME_MS = 2000L - private const val MAX_TRIES = 10 - } -} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerViewModel.kt index f5aa6627ddd..b8c9260ea92 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerViewModel.kt @@ -12,10 +12,10 @@ import com.stripe.android.financialconnections.analytics.FinancialConnectionsEve import com.stripe.android.financialconnections.analytics.FinancialConnectionsEvent.ClickLearnMoreDataAccess import com.stripe.android.financialconnections.analytics.FinancialConnectionsEvent.Error import com.stripe.android.financialconnections.analytics.FinancialConnectionsEvent.PaneLoaded +import com.stripe.android.financialconnections.domain.FetchNetworkedAccounts import com.stripe.android.financialconnections.domain.GetCachedConsumerSession import com.stripe.android.financialconnections.domain.GetManifest import com.stripe.android.financialconnections.domain.GoNext -import com.stripe.android.financialconnections.domain.PollNetworkedAccounts import com.stripe.android.financialconnections.domain.SelectNetworkedAccount import com.stripe.android.financialconnections.domain.UpdateCachedAccounts import com.stripe.android.financialconnections.domain.UpdateLocalManifest @@ -32,7 +32,7 @@ internal class LinkAccountPickerViewModel @Inject constructor( initialState: LinkAccountPickerState, private val eventTracker: FinancialConnectionsAnalyticsTracker, private val getCachedConsumerSession: GetCachedConsumerSession, - private val pollNetworkedAccounts: PollNetworkedAccounts, + private val fetchNetworkedAccounts: FetchNetworkedAccounts, private val selectNetworkedAccount: SelectNetworkedAccount, private val updateLocalManifest: UpdateLocalManifest, private val updateCachedAccounts: UpdateCachedAccounts, @@ -53,7 +53,7 @@ internal class LinkAccountPickerViewModel @Inject constructor( dataPolicyUrl = FinancialConnectionsUrlResolver.getDataPolicyUrl(manifest) ) val consumerSession = requireNotNull(getCachedConsumerSession()) - val accountsResponse = pollNetworkedAccounts(consumerSession.clientSecret) + val accountsResponse = fetchNetworkedAccounts(consumerSession.clientSecret) val accounts = accountsResponse .data // Override allow selection to disable disconnected accounts diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkverification/NetworkingLinkVerificationViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkverification/NetworkingLinkVerificationViewModel.kt index d8f9f569e43..2e4cbbb50e0 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkverification/NetworkingLinkVerificationViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinkverification/NetworkingLinkVerificationViewModel.kt @@ -20,11 +20,11 @@ import com.stripe.android.financialconnections.analytics.FinancialConnectionsEve import com.stripe.android.financialconnections.analytics.FinancialConnectionsEvent.VerificationSuccess import com.stripe.android.financialconnections.analytics.FinancialConnectionsEvent.VerificationSuccessNoAccounts import com.stripe.android.financialconnections.domain.ConfirmVerification +import com.stripe.android.financialconnections.domain.FetchNetworkedAccounts import com.stripe.android.financialconnections.domain.GetManifest import com.stripe.android.financialconnections.domain.GoNext import com.stripe.android.financialconnections.domain.LookupConsumerAndStartVerification import com.stripe.android.financialconnections.domain.MarkLinkVerified -import com.stripe.android.financialconnections.domain.PollNetworkedAccounts import com.stripe.android.financialconnections.features.networkinglinkverification.NetworkingLinkVerificationState.Payload import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane @@ -45,7 +45,7 @@ internal class NetworkingLinkVerificationViewModel @Inject constructor( private val getManifest: GetManifest, private val confirmVerification: ConfirmVerification, private val markLinkVerified: MarkLinkVerified, - private val pollNetworkedAccounts: PollNetworkedAccounts, + private val fetchNetworkedAccounts: FetchNetworkedAccounts, private val goNext: GoNext, private val analyticsTracker: FinancialConnectionsAnalyticsTracker, private val lookupConsumerAndStartVerification: LookupConsumerAndStartVerification, @@ -119,7 +119,7 @@ internal class NetworkingLinkVerificationViewModel @Inject constructor( verificationCode = otp ) val updatedManifest = markLinkVerified() - runCatching { pollNetworkedAccounts(payload.consumerSessionClientSecret) } + runCatching { fetchNetworkedAccounts(payload.consumerSessionClientSecret) } .fold( onSuccess = { onNetworkedAccountsSuccess(it, updatedManifest) }, onFailure = { onNetworkedAccountsFailed(it, updatedManifest) } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/utils/Errors.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/utils/Errors.kt index 9cf4f090415..e39297eb1e1 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/utils/Errors.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/utils/Errors.kt @@ -1,32 +1,38 @@ package com.stripe.android.financialconnections.utils import com.stripe.android.core.exception.StripeException +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.first import java.net.HttpURLConnection -import java.util.concurrent.TimeoutException +import kotlin.time.Duration.Companion.seconds /** * Executes and returns the result of the given [block]. * If the block execution fails, and [retryCondition] is met, the operation is retried. * Otherwise the resulting exception will be thrown. */ +@OptIn(ExperimentalCoroutinesApi::class) internal suspend fun retryOnException( - times: Int = Int.MAX_VALUE, - initialDelay: Long = 0, - delayMilliseconds: Long = 100, + options: PollTimingOptions, retryCondition: suspend (Throwable) -> Boolean, block: suspend () -> T ): T = channelFlow { - var remainingTimes = times - 1 + var remainingTimes = options.maxNumberOfRetries - 1 while (!isClosedForSend) { - delay(if (remainingTimes == times - 1) initialDelay else delayMilliseconds) + delay( + if (remainingTimes == options.maxNumberOfRetries - 1) { + options.initialDelayMs + } else { + options.retryInterval + } + ) val either = runCatching { block() } either.fold( onFailure = { exception -> when { - remainingTimes == 0 -> throw TimeoutException("reached max number of retries") + remainingTimes == 0 -> throw PollingReachedMaxRetriesException(options) retryCondition(exception).not() -> throw exception } }, @@ -36,6 +42,22 @@ internal suspend fun retryOnException( } }.first() +internal data class PollTimingOptions( + val initialDelayMs: Long = 1.75.seconds.inWholeMilliseconds, + val maxNumberOfRetries: Int = 180, + val retryInterval: Long = 0.25.seconds.inWholeMilliseconds +) + +/** + * Thrown when polling has reached the max number of retries. + */ +internal class PollingReachedMaxRetriesException( + pollingOptions: PollTimingOptions +) : StripeException( + message = "reached max number of retries ${pollingOptions.maxNumberOfRetries}.", + statusCode = HttpURLConnection.HTTP_ACCEPTED +) + /** * returns true if exception represents a [HttpURLConnection.HTTP_ACCEPTED] API response. */ diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/domain/PollAuthorizationSessionAccountsTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/domain/PollAuthorizationSessionAccountsTest.kt new file mode 100644 index 00000000000..19cdc2a9233 --- /dev/null +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/domain/PollAuthorizationSessionAccountsTest.kt @@ -0,0 +1,117 @@ +package com.stripe.android.financialconnections.domain + +import com.stripe.android.core.exception.APIException +import com.stripe.android.financialconnections.ApiKeyFixtures +import com.stripe.android.financialconnections.ApiKeyFixtures.authorizationSession +import com.stripe.android.financialconnections.ApiKeyFixtures.institution +import com.stripe.android.financialconnections.ApiKeyFixtures.partnerAccount +import com.stripe.android.financialconnections.ApiKeyFixtures.sessionManifest +import com.stripe.android.financialconnections.FinancialConnectionsSheet +import com.stripe.android.financialconnections.exception.AccountLoadError +import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest +import com.stripe.android.financialconnections.model.PartnerAccountsList +import com.stripe.android.financialconnections.repository.FinancialConnectionsAccountsRepository +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.net.HttpURLConnection +import kotlin.test.assertIs + +@OptIn(ExperimentalCoroutinesApi::class) +internal class PollAuthorizationSessionAccountsTest { + + private val repository: FinancialConnectionsAccountsRepository = mock() + private val configuration = FinancialConnectionsSheet.Configuration( + ApiKeyFixtures.DEFAULT_FINANCIAL_CONNECTIONS_SESSION_SECRET, + ApiKeyFixtures.DEFAULT_PUBLISHABLE_KEY + ) + private val pollAuthorizationSessionAccounts = + PollAuthorizationSessionAccounts(repository, configuration) + + @Test + fun `test successful account polling`() = runTest { + val manifest: FinancialConnectionsSessionManifest = sessionManifest().copy( + activeAuthSession = authorizationSession(), + activeInstitution = institution() + ) + val accountsList = PartnerAccountsList( + data = listOf( + partnerAccount() + ), + hasMore = false, + nextPane = FinancialConnectionsSessionManifest.Pane.CONSENT, + url = "" + ) + + whenever(repository.postAuthorizationSessionAccounts(any(), any())) + .doReturn(accountsList) + + val result = pollAuthorizationSessionAccounts.invoke(true, manifest) + + assertEquals(accountsList, result) + verify(repository).postAuthorizationSessionAccounts( + configuration.financialConnectionsSessionClientSecret, + manifest.activeAuthSession!!.id + ) + } + + @Test + fun `test reached too many failed account polling`() = runTest { + val manifest: FinancialConnectionsSessionManifest = sessionManifest().copy( + activeAuthSession = authorizationSession(), + activeInstitution = institution() + ) + + whenever(repository.postAuthorizationSessionAccounts(any(), any())) + .thenAnswer { throw retryException() } + + val exception: Throwable? = runCatching { + pollAuthorizationSessionAccounts.invoke(true, manifest) + }.exceptionOrNull() + + assertIs(exception) + + // Retries 180 times + verify(repository, times(180)).postAuthorizationSessionAccounts( + configuration.financialConnectionsSessionClientSecret, + manifest.activeAuthSession!!.id + ) + } + + @Test + fun `test empty account list retrieved`() = runTest { + val manifest: FinancialConnectionsSessionManifest = sessionManifest().copy( + activeAuthSession = authorizationSession(), + activeInstitution = institution() + ) + + val emptyList = PartnerAccountsList( + data = emptyList(), + hasMore = false, + nextPane = FinancialConnectionsSessionManifest.Pane.CONSENT, + url = "" + ) + + whenever(repository.postAuthorizationSessionAccounts(any(), any())).doReturn(emptyList) + + val exception: Throwable? = runCatching { + pollAuthorizationSessionAccounts.invoke(true, manifest) + }.exceptionOrNull() + + assertIs(exception) + + verify(repository, times(1)).postAuthorizationSessionAccounts( + configuration.financialConnectionsSessionClientSecret, + manifest.activeAuthSession!!.id + ) + } +} + +private fun retryException() = APIException(statusCode = HttpURLConnection.HTTP_ACCEPTED) diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerViewModelTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerViewModelTest.kt index fc44e8ae45b..899e5db67f7 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerViewModelTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerViewModelTest.kt @@ -9,10 +9,10 @@ import com.stripe.android.financialconnections.ApiKeyFixtures.partnerAccount import com.stripe.android.financialconnections.ApiKeyFixtures.partnerAccountList import com.stripe.android.financialconnections.ApiKeyFixtures.sessionManifest import com.stripe.android.financialconnections.TestFinancialConnectionsAnalyticsTracker +import com.stripe.android.financialconnections.domain.FetchNetworkedAccounts import com.stripe.android.financialconnections.domain.GetCachedConsumerSession import com.stripe.android.financialconnections.domain.GetManifest import com.stripe.android.financialconnections.domain.GoNext -import com.stripe.android.financialconnections.domain.PollNetworkedAccounts import com.stripe.android.financialconnections.domain.SelectNetworkedAccount import com.stripe.android.financialconnections.domain.UpdateCachedAccounts import com.stripe.android.financialconnections.domain.UpdateLocalManifest @@ -40,7 +40,7 @@ class LinkAccountPickerViewModelTest { private val getManifest = mock() private val goNext = mock() private val getCachedConsumerSession = mock() - private val pollNetworkedAccounts = mock() + private val fetchNetworkedAccounts = mock() private val updateLocalManifest = mock() private val updateCachedAccounts = mock() private val selectNetworkedAccount = mock() @@ -54,7 +54,7 @@ class LinkAccountPickerViewModelTest { logger = Logger.noop(), eventTracker = eventTracker, getCachedConsumerSession = getCachedConsumerSession, - pollNetworkedAccounts = pollNetworkedAccounts, + fetchNetworkedAccounts = fetchNetworkedAccounts, selectNetworkedAccount = selectNetworkedAccount, updateLocalManifest = updateLocalManifest, updateCachedAccounts = updateCachedAccounts, @@ -66,7 +66,7 @@ class LinkAccountPickerViewModelTest { val accounts = twoAccounts() whenever(getManifest()).thenReturn(sessionManifest()) whenever(getCachedConsumerSession()).thenReturn(consumerSession()) - whenever(pollNetworkedAccounts(any())).thenReturn(accounts) + whenever(fetchNetworkedAccounts(any())).thenReturn(accounts) val viewModel = buildViewModel(LinkAccountPickerState()) @@ -78,7 +78,7 @@ class LinkAccountPickerViewModelTest { fun `init - non active accounts are rendered as disabled`() = runTest { whenever(getManifest()).thenReturn(sessionManifest()) whenever(getCachedConsumerSession()).thenReturn(consumerSession()) - whenever(pollNetworkedAccounts(any())).thenReturn( + whenever(fetchNetworkedAccounts(any())).thenReturn( partnerAccountList().copy( count = 3, data = listOf( @@ -108,7 +108,7 @@ class LinkAccountPickerViewModelTest { val accounts = twoAccounts() whenever(getManifest()).thenReturn(sessionManifest()) whenever(getCachedConsumerSession()).thenReturn(consumerSession()) - whenever(pollNetworkedAccounts(any())).thenReturn(accounts) + whenever(fetchNetworkedAccounts(any())).thenReturn(accounts) val viewModel = buildViewModel(LinkAccountPickerState()) viewModel.onNewBankAccountClick() @@ -122,7 +122,7 @@ class LinkAccountPickerViewModelTest { val selectedAccount = accounts.data.first() whenever(getManifest()).thenReturn(sessionManifest()) whenever(getCachedConsumerSession()).thenReturn(consumerSession()) - whenever(pollNetworkedAccounts(any())).thenReturn(accounts) + whenever(fetchNetworkedAccounts(any())).thenReturn(accounts) whenever( selectNetworkedAccount( consumerSessionClientSecret = any(), @@ -148,7 +148,7 @@ class LinkAccountPickerViewModelTest { val selectedAccount = accounts.data.first() whenever(getManifest()).thenReturn(sessionManifest().copy(stepUpAuthenticationRequired = true)) whenever(getCachedConsumerSession()).thenReturn(consumerSession()) - whenever(pollNetworkedAccounts(any())).thenReturn(accounts) + whenever(fetchNetworkedAccounts(any())).thenReturn(accounts) whenever( selectNetworkedAccount( consumerSessionClientSecret = any(), diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinkverification/NetworkingLinkVerificationViewModelTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinkverification/NetworkingLinkVerificationViewModelTest.kt index 13ce70e85ef..1dee2c6499a 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinkverification/NetworkingLinkVerificationViewModelTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinkverification/NetworkingLinkVerificationViewModelTest.kt @@ -10,11 +10,11 @@ import com.stripe.android.financialconnections.ApiKeyFixtures.partnerAccountList import com.stripe.android.financialconnections.ApiKeyFixtures.sessionManifest import com.stripe.android.financialconnections.TestFinancialConnectionsAnalyticsTracker import com.stripe.android.financialconnections.domain.ConfirmVerification +import com.stripe.android.financialconnections.domain.FetchNetworkedAccounts import com.stripe.android.financialconnections.domain.GetManifest import com.stripe.android.financialconnections.domain.GoNext import com.stripe.android.financialconnections.domain.LookupConsumerAndStartVerification import com.stripe.android.financialconnections.domain.MarkLinkVerified -import com.stripe.android.financialconnections.domain.PollNetworkedAccounts import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane.INSTITUTION_PICKER import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane.LINK_ACCOUNT_PICKER import com.stripe.android.model.ConsumerSession @@ -40,7 +40,7 @@ class NetworkingLinkVerificationViewModelTest { private val getManifest = mock() private val goNext = mock() private val confirmVerification = mock() - private val pollNetworkedAccounts = mock() + private val fetchNetworkedAccounts = mock() private val lookupConsumerAndStartVerification = mock() private val markLinkVerified = mock() private val analyticsTracker = TestFinancialConnectionsAnalyticsTracker() @@ -53,7 +53,7 @@ class NetworkingLinkVerificationViewModelTest { lookupConsumerAndStartVerification = lookupConsumerAndStartVerification, confirmVerification = confirmVerification, markLinkVerified = markLinkVerified, - pollNetworkedAccounts = pollNetworkedAccounts, + fetchNetworkedAccounts = fetchNetworkedAccounts, analyticsTracker = analyticsTracker, logger = Logger.noop(), initialState = state @@ -145,7 +145,7 @@ class NetworkingLinkVerificationViewModelTest { // verify succeeds whenever(markLinkVerified()).thenReturn(linkVerifiedManifest) // polling returns no networked accounts - whenever(pollNetworkedAccounts(any())) + whenever(fetchNetworkedAccounts(any())) .thenReturn(partnerAccountList().copy(data = emptyList())) val viewModel = buildViewModel() @@ -189,7 +189,7 @@ class NetworkingLinkVerificationViewModelTest { // polling returns some networked accounts val partnerAccountsList = partnerAccountList().copy(data = (listOf(partnerAccount()))) - whenever(pollNetworkedAccounts(any())).thenReturn(partnerAccountsList) + whenever(fetchNetworkedAccounts(any())).thenReturn(partnerAccountsList) whenever(markLinkVerified()).thenReturn((linkVerifiedManifest)) val viewModel = buildViewModel() diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/utils/ErrorsKtTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/utils/ErrorsKtTest.kt index 7439c99dbe2..c4ad5eb2d35 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/utils/ErrorsKtTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/utils/ErrorsKtTest.kt @@ -2,25 +2,29 @@ package com.stripe.android.financialconnections.utils import com.google.common.truth.Truth.assertThat import com.stripe.android.core.exception.APIException +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Test import java.net.HttpURLConnection -import java.util.concurrent.TimeoutException +@OptIn(ExperimentalCoroutinesApi::class) internal class ErrorsKtTest { @Test fun `should throw timeout if reaches max times`() = runTest { val testResult = kotlin.runCatching { retryOnException( - times = 5, - delayMilliseconds = 1_000, + PollTimingOptions( + maxNumberOfRetries = 5, + initialDelayMs = 0, + retryInterval = 1000 + ), retryCondition = { exception -> exception.shouldRetry } ) { throw retryException() } } - assertThat(testResult.exceptionOrNull()!!).isInstanceOf(TimeoutException::class.java) + assertThat(testResult.exceptionOrNull()!!).isInstanceOf(PollingReachedMaxRetriesException::class.java) } private fun retryException() = APIException(statusCode = HttpURLConnection.HTTP_ACCEPTED) @@ -29,8 +33,11 @@ internal class ErrorsKtTest { fun `should emit once if block always succeeds`() = runTest { var counter = 0 val result = retryOnException( - times = 5, - delayMilliseconds = 1_000, + PollTimingOptions( + maxNumberOfRetries = 5, + initialDelayMs = 0, + retryInterval = 1000 + ), retryCondition = { exception -> exception.shouldRetry } ) { counter++ @@ -44,7 +51,10 @@ internal class ErrorsKtTest { fun `should retry and emit once when succeeds`() = runTest { var counter = 0 val result = retryOnException( - delayMilliseconds = 1_000, + PollTimingOptions( + initialDelayMs = 0, + retryInterval = 1000 + ), retryCondition = { exception -> exception.shouldRetry } ) { counter++ @@ -59,14 +69,17 @@ internal class ErrorsKtTest { val testResult = kotlin.runCatching { var counter = 0 retryOnException( - times = 2, - delayMilliseconds = 1_000, + PollTimingOptions( + maxNumberOfRetries = 2, + initialDelayMs = 0, + retryInterval = 1000 + ), retryCondition = { exception -> exception.shouldRetry } ) { counter++ if (counter == 3) true else throw retryException() } } - assertThat(testResult.exceptionOrNull()!!).isInstanceOf(TimeoutException::class.java) + assertThat(testResult.exceptionOrNull()!!).isInstanceOf(PollingReachedMaxRetriesException::class.java) } }