Updated on 2026-08-14
This commit is contained in:
parent
c8ded3b7d5
commit
bff165de00
7 changed files with 253 additions and 54 deletions
|
|
@ -21,12 +21,12 @@ internal class DefaultHotAccessCodeRequestComponent @AssistedInject constructor(
|
|||
|
||||
private val model: HotAccessCodeRequestModel = getOrCreateModel(params)
|
||||
|
||||
override suspend fun wrongPassword() {
|
||||
model.wrongAccessCode()
|
||||
override suspend fun wrongPassword(attemptRequest: HotWalletPasswordRequester.AttemptRequest) {
|
||||
model.wrongAccessCode(attemptRequest)
|
||||
}
|
||||
|
||||
override suspend fun successfulAuthentication() {
|
||||
model.successfulAuthentication()
|
||||
override suspend fun successfulAuthentication(attemptRequest: HotWalletPasswordRequester.AttemptRequest) {
|
||||
model.successfulAuthentication(attemptRequest)
|
||||
}
|
||||
|
||||
override suspend fun requestPassword(
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ internal class HotAccessCodeRequestModel @Inject constructor(
|
|||
|
||||
currentRequest.value = attemptRequest
|
||||
result.value = null // Reset the result when showing the dialog
|
||||
subscribeToAttempts(id = attemptRequest.attemptId)
|
||||
subscribeToAttempts(attemptRequest)
|
||||
uiState.update {
|
||||
it.copy(
|
||||
isShown = true,
|
||||
|
|
@ -88,31 +88,37 @@ internal class HotAccessCodeRequestModel @Inject constructor(
|
|||
dismissState()
|
||||
}
|
||||
|
||||
suspend fun wrongAccessCode() {
|
||||
val currentRequest = currentRequest.value ?: return
|
||||
hotAccessCodeAttemptsRepository.incrementAttempts(currentRequest.attemptId)
|
||||
uiState.update {
|
||||
it.copy(
|
||||
accessCodeColor = PinTextColor.WrongCode,
|
||||
onAccessCodeChange = {},
|
||||
useBiometricVisible = currentRequest.isBiometryButtonVisible(),
|
||||
)
|
||||
suspend fun wrongAccessCode(attemptRequest: HotWalletPasswordRequester.AttemptRequest) {
|
||||
hotAccessCodeAttemptsRepository.incrementAttempts(attemptRequest.attemptId)
|
||||
// Reflect the wrong-code UI only if this request still owns the visible dialog.
|
||||
if (isCurrentRequest(attemptRequest)) {
|
||||
uiState.update { state ->
|
||||
state.copy(
|
||||
accessCodeColor = PinTextColor.WrongCode,
|
||||
onAccessCodeChange = {},
|
||||
useBiometricVisible = attemptRequest.isBiometryButtonVisible(),
|
||||
)
|
||||
}
|
||||
}
|
||||
delay(timeMillis = 500) // Delay to show the wrong access code state
|
||||
}
|
||||
|
||||
suspend fun successfulAuthentication() {
|
||||
val currentRequest = currentRequest.value ?: return
|
||||
hotAccessCodeAttemptsRepository.resetAttempts(currentRequest.hotWalletId)
|
||||
uiState.update {
|
||||
it.copy(
|
||||
accessCodeColor = PinTextColor.Success,
|
||||
onAccessCodeChange = {},
|
||||
)
|
||||
suspend fun successfulAuthentication(attemptRequest: HotWalletPasswordRequester.AttemptRequest) {
|
||||
hotAccessCodeAttemptsRepository.resetAttempts(attemptRequest.hotWalletId)
|
||||
if (isCurrentRequest(attemptRequest)) {
|
||||
uiState.update {
|
||||
it.copy(
|
||||
accessCodeColor = PinTextColor.Success,
|
||||
onAccessCodeChange = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
delay(timeMillis = 200) // Delay to show the success state
|
||||
}
|
||||
|
||||
private fun isCurrentRequest(attemptRequest: HotWalletPasswordRequester.AttemptRequest): Boolean =
|
||||
currentRequest.value?.requestId == attemptRequest.requestId
|
||||
|
||||
private fun getInitialState() = HotAccessCodeRequestUM(
|
||||
onDismiss = ::dismiss,
|
||||
onAccessCodeChange = ::onAccessCodeChange,
|
||||
|
|
@ -146,7 +152,8 @@ internal class HotAccessCodeRequestModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun subscribeToAttempts(id: HotWalletAccessCodeAttemptsRepository.AttemptId) {
|
||||
private fun subscribeToAttempts(attemptRequest: HotWalletPasswordRequester.AttemptRequest) {
|
||||
val id = attemptRequest.attemptId
|
||||
fun remainingSecondsToText(remainingSeconds: Int): TextReference? {
|
||||
return if (remainingSeconds > 0) {
|
||||
resourceReference(
|
||||
|
|
@ -202,7 +209,7 @@ internal class HotAccessCodeRequestModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
}
|
||||
Attempts.Deletion -> deleteUserWallet()
|
||||
Attempts.Deletion -> deleteUserWallet(attemptRequest)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -217,8 +224,10 @@ internal class HotAccessCodeRequestModel @Inject constructor(
|
|||
.any { it is UserWallet.Hot && it.hotWalletId == id }
|
||||
}
|
||||
|
||||
private suspend fun deleteUserWallet() {
|
||||
private suspend fun deleteUserWallet(expectedRequest: HotWalletPasswordRequester.AttemptRequest) {
|
||||
val currentRequest = currentRequest.value ?: return
|
||||
// Only delete if the request whose threshold was crossed is still the one owning the dialog.
|
||||
if (currentRequest.requestId != expectedRequest.requestId) return
|
||||
val userWallet = userWalletsListRepository.userWalletsSync()
|
||||
.firstOrNull { it is UserWallet.Hot && it.hotWalletId == currentRequest.hotWalletId } ?: return
|
||||
|
||||
|
|
|
|||
|
|
@ -13,9 +13,11 @@ class HotWalletPasswordRequesterProxy @Inject constructor() : HotWalletPasswordR
|
|||
|
||||
val componentRequester = MutableStateFlow<HotWalletPasswordRequester?>(null)
|
||||
|
||||
override suspend fun wrongPassword() = call { wrongPassword() }
|
||||
override suspend fun wrongPassword(attemptRequest: HotWalletPasswordRequester.AttemptRequest) =
|
||||
call { wrongPassword(attemptRequest) }
|
||||
|
||||
override suspend fun successfulAuthentication() = call { successfulAuthentication() }
|
||||
override suspend fun successfulAuthentication(attemptRequest: HotWalletPasswordRequester.AttemptRequest) =
|
||||
call { successfulAuthentication(attemptRequest) }
|
||||
|
||||
override suspend fun requestPassword(
|
||||
attemptRequest: HotWalletPasswordRequester.AttemptRequest,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
package com.tangem.features.hotwallet.accesscoderequest
|
||||
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.domain.assetsdiscovery.usecase.StartAssetsDiscoveryUseCase
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.settings.CanUseBiometryUseCase
|
||||
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
|
||||
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.AttemptId
|
||||
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Attempts
|
||||
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester.AttemptRequest
|
||||
import com.tangem.domain.wallets.usecase.DeleteWalletUseCase
|
||||
import com.tangem.features.hotwallet.HotWalletFeatureToggles
|
||||
import com.tangem.hot.sdk.model.HotWalletId
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class HotAccessCodeRequestModelTest {
|
||||
|
||||
private val hotAccessCodeAttemptsRepository: HotWalletAccessCodeAttemptsRepository = mockk(relaxed = true)
|
||||
private val userWalletsListRepository: UserWalletsListRepository = mockk()
|
||||
private val deleteWalletUseCase: DeleteWalletUseCase = mockk(relaxed = true)
|
||||
private val canUseBiometryUseCase: CanUseBiometryUseCase = mockk(relaxed = true)
|
||||
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true)
|
||||
private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase = mockk(relaxed = true)
|
||||
private val hotWalletFeatureToggles: HotWalletFeatureToggles = mockk {
|
||||
every { isAssetsDiscoveryEnabled } returns false
|
||||
}
|
||||
|
||||
private val hotWalletIdA: HotWalletId = mockk()
|
||||
private val hotWalletIdB: HotWalletId = mockk()
|
||||
private val walletIdA = UserWalletId("A")
|
||||
private val walletIdB = UserWalletId("B")
|
||||
|
||||
private val userWalletA: UserWallet.Hot = mockk {
|
||||
every { hotWalletId } returns hotWalletIdA
|
||||
every { walletId } returns walletIdA
|
||||
}
|
||||
private val userWalletB: UserWallet.Hot = mockk {
|
||||
every { hotWalletId } returns hotWalletIdB
|
||||
every { walletId } returns walletIdB
|
||||
}
|
||||
|
||||
private val requestA = AttemptRequest(hotWalletId = hotWalletIdA, authMode = true, hasBiometry = false)
|
||||
private val requestB = AttemptRequest(hotWalletId = hotWalletIdB, authMode = true, hasBiometry = false)
|
||||
|
||||
private val attemptIdA = AttemptId(hotWalletId = hotWalletIdA, auth = true)
|
||||
private val attemptIdB = AttemptId(hotWalletId = hotWalletIdB, auth = true)
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
clearMocks(hotAccessCodeAttemptsRepository, deleteWalletUseCase, answers = false)
|
||||
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWalletA, userWalletB)
|
||||
every { hotAccessCodeAttemptsRepository.getAttempts(any()) } returns emptyFlow()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN request A replaced by B WHEN late wrong code of A THEN attempt attributed to A not B`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel(this)
|
||||
model.show(requestA)
|
||||
advanceUntilIdle()
|
||||
model.show(requestB) // B now owns the dialog
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.wrongAccessCode(requestA) // late callback belonging to A
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) { hotAccessCodeAttemptsRepository.incrementAttempts(attemptIdA) }
|
||||
coVerify(exactly = 0) { hotAccessCodeAttemptsRepository.incrementAttempts(attemptIdB) }
|
||||
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN request A replaced by B WHEN late success of A THEN reset attributed to A not B`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel(this)
|
||||
model.show(requestA)
|
||||
advanceUntilIdle()
|
||||
model.show(requestB)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.successfulAuthentication(requestA) // late callback belonging to A
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) { hotAccessCodeAttemptsRepository.resetAttempts(hotWalletIdA) }
|
||||
coVerify(exactly = 0) { hotAccessCodeAttemptsRepository.resetAttempts(hotWalletIdB) }
|
||||
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN request A owns the dialog WHEN wrong code of A THEN attempt incremented for A`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel(this)
|
||||
model.show(requestA)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.wrongAccessCode(requestA)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) { hotAccessCodeAttemptsRepository.incrementAttempts(attemptIdA) }
|
||||
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN B reaches deletion threshold WHEN B owns the dialog THEN only B is deleted`() = runTest {
|
||||
// Arrange
|
||||
every { hotAccessCodeAttemptsRepository.getAttempts(attemptIdB) } returns flowOf(Attempts.Deletion)
|
||||
val model = createModel(this)
|
||||
|
||||
// Act
|
||||
model.show(requestB)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) { deleteWalletUseCase(walletIdB) }
|
||||
coVerify(exactly = 0) { deleteWalletUseCase(walletIdA) }
|
||||
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
private fun createModel(testScope: TestScope): HotAccessCodeRequestModel {
|
||||
return HotAccessCodeRequestModel(
|
||||
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
|
||||
hotAccessCodeAttemptsRepository = hotAccessCodeAttemptsRepository,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
deleteWalletUseCase = deleteWalletUseCase,
|
||||
canUseBiometryUseCase = canUseBiometryUseCase,
|
||||
analyticsEventHandler = analyticsEventHandler,
|
||||
startAssetsDiscoveryUseCase = startAssetsDiscoveryUseCase,
|
||||
hotWalletFeatureToggles = hotWalletFeatureToggles,
|
||||
)
|
||||
}
|
||||
|
||||
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
|
||||
val testDispatcher = StandardTestDispatcher(testScheduler)
|
||||
return TestingCoroutineDispatcherProvider(
|
||||
main = testDispatcher,
|
||||
mainImmediate = testDispatcher,
|
||||
io = testDispatcher,
|
||||
default = testDispatcher,
|
||||
single = testDispatcher,
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue