Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-30 09:22:33 +01:00
parent 4613836a1b
commit 6a82ee1600
7 changed files with 1715 additions and 2 deletions

View file

@ -11,6 +11,10 @@ android {
namespace = "com.tangem.features.details.impl"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/* Project - API */
@ -80,4 +84,11 @@ dependencies {
implementation(deps.kotlin.immutable.collections)
implementation(deps.arrow.core)
implementation(deps.arrow.fx)
/* Test */
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.coroutine)
}

View file

@ -0,0 +1,337 @@
package com.tangem.features.details.utils
import arrow.core.Either
import com.tangem.common.core.TangemError
import com.tangem.common.routing.AppRoute
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.card.ScanCardProcessor
import com.tangem.domain.common.wallets.error.SaveWalletError
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.details.impl.R
import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import io.mockk.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class UserWalletSaverTest {
private val scanCardProcessor: ScanCardProcessor = mockk()
private val saveWalletUseCase: SaveWalletUseCase = mockk()
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory = mockk()
private val messageSender: UiMessageSender = mockk(relaxUnitFun = true)
private val router: Router = mockk(relaxUnitFun = true)
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles = mockk()
private val scanResponse: ScanResponse = mockk()
private val userWalletId: UserWalletId = UserWalletId("011")
private val userWallet: UserWallet.Cold = mockk {
every { walletId } returns userWalletId
}
@BeforeEach
fun setUp() {
every { onboardingV2FeatureToggles.isAddressSyncEnabled } returns false
}
@Test
fun `GIVEN onWalletNotCreated WHEN scanAndSaveUserWallet THEN no message AND no save`() = runTest {
mockScanCallback(callbackName = ON_WALLET_NOT_CREATED)
createSaver().scanAndSaveUserWallet(this)
verify(exactly = 0) { messageSender.send(any()) }
coVerify(exactly = 0) { saveWalletUseCase.invoke(any(), any(), any()) }
}
@Test
fun `GIVEN disclaimerWillShow WHEN scanAndSaveUserWallet THEN router pop AND no save`() = runTest {
mockScanCallback(callbackName = DISCLAIMER_WILL_SHOW)
createSaver().scanAndSaveUserWallet(this)
verify { router.pop(onComplete = any()) }
verify(exactly = 0) { messageSender.send(any()) }
coVerify(exactly = 0) { saveWalletUseCase.invoke(any(), any(), any()) }
}
@Test
fun `GIVEN onCancel WHEN scanAndSaveUserWallet THEN no message AND no save`() = runTest {
mockScanCallback(callbackName = ON_CANCEL)
createSaver().scanAndSaveUserWallet(this)
verify(exactly = 0) { messageSender.send(any()) }
coVerify(exactly = 0) { saveWalletUseCase.invoke(any(), any(), any()) }
}
@Test
fun `GIVEN onFailure with messageResId WHEN scanAndSaveUserWallet THEN SnackbarMessage with resource is sent`() =
runTest {
val tangemError = mockk<TangemError> {
every { silent } returns false
every { messageResId } returns R.string.common_unknown_error
every { customMessage } returns "any"
}
mockScanFailure(tangemError)
createSaver().scanAndSaveUserWallet(this)
verify {
messageSender.send(
message = SnackbarMessage(message = resourceReference(R.string.common_unknown_error)),
)
}
coVerify(exactly = 0) { saveWalletUseCase.invoke(any(), any(), any()) }
}
@Test
fun `GIVEN onFailure without messageResId WHEN scanAndSaveUserWallet THEN SnackbarMessage with custom message`() =
runTest {
val tangemError = mockk<TangemError> {
every { silent } returns false
every { messageResId } returns null
every { customMessage } returns "Custom error"
}
mockScanFailure(tangemError)
createSaver().scanAndSaveUserWallet(this)
verify {
messageSender.send(
message = SnackbarMessage(message = stringReference("Custom error")),
)
}
coVerify(exactly = 0) { saveWalletUseCase.invoke(any(), any(), any()) }
}
@Test
fun `GIVEN onFailure silent WHEN scanAndSaveUserWallet THEN no message is sent`() = runTest {
val tangemError = mockk<TangemError> {
every { silent } returns true
every { messageResId } returns null
every { customMessage } returns "any"
}
mockScanFailure(tangemError)
createSaver().scanAndSaveUserWallet(this)
verify(exactly = 0) { messageSender.send(any()) }
coVerify(exactly = 0) { saveWalletUseCase.invoke(any(), any(), any()) }
}
@Test
fun `GIVEN onSuccess AND save success AND addressSync disabled WHEN scanAndSaveUserWallet THEN popTo Wallet`() =
runTest {
every { onboardingV2FeatureToggles.isAddressSyncEnabled } returns false
mockScanSuccess(scanResponse)
mockBuilderReturns(userWallet)
coEvery { saveWalletUseCase.invoke(userWallet, false, any()) } returns Either.Right(Unit)
createSaver().scanAndSaveUserWallet(this)
verify { router.popTo(routeClass = AppRoute.Wallet::class, onComplete = any()) }
verify(exactly = 0) { router.push(route = any(), onComplete = any()) }
verify(exactly = 0) { messageSender.send(any()) }
}
@Test
fun `GIVEN onSuccess AND save success AND addressSync enabled WHEN scanAndSaveUserWallet THEN push Onboarding`() =
runTest {
every { onboardingV2FeatureToggles.isAddressSyncEnabled } returns true
mockScanSuccess(scanResponse)
mockBuilderReturns(userWallet)
coEvery { saveWalletUseCase.invoke(userWallet, false, any()) } returns Either.Right(Unit)
createSaver().scanAndSaveUserWallet(this)
verify {
router.push(
route = AppRoute.Onboarding(
scanResponse = scanResponse,
mode = AppRoute.Onboarding.Mode.AddressSync(
userWalletId = userWalletId,
isWalletStarted = true,
),
),
onComplete = any(),
)
}
verify(exactly = 0) { router.popTo(routeClass = any(), onComplete = any()) }
}
@Test
fun `GIVEN onSuccess AND createUserWallet returns null WHEN scanAndSaveUserWallet THEN unknown error message`() =
runTest {
mockScanSuccess(scanResponse)
mockBuilderReturns(null)
createSaver().scanAndSaveUserWallet(this)
verify {
messageSender.send(
message = SnackbarMessage(message = resourceReference(R.string.common_unknown_error)),
)
}
coVerify(exactly = 0) { saveWalletUseCase.invoke(any(), any(), any()) }
}
@Test
fun `GIVEN onSuccess AND save WalletAlreadySaved WHEN scanAndSaveUserWallet THEN DialogMessage is sent`() =
runTest {
mockScanSuccess(scanResponse)
mockBuilderReturns(userWallet)
coEvery { saveWalletUseCase.invoke(userWallet, false, any()) } returns Either.Left(
SaveWalletError.WalletAlreadySaved(messageId = R.string.user_wallet_list_error_wallet_already_saved),
)
createSaver().scanAndSaveUserWallet(this)
verify {
messageSender.send(
message = DialogMessage(
message = resourceReference(R.string.user_wallet_list_error_wallet_already_saved),
),
)
}
verify(exactly = 0) { router.popTo(routeClass = any(), onComplete = any()) }
verify(exactly = 0) { router.push(route = any(), onComplete = any()) }
}
@Test
fun `GIVEN onSuccess AND save DataError with messageId WHEN scanAndSaveUserWallet THEN snackbar with resource`() =
runTest {
mockScanSuccess(scanResponse)
mockBuilderReturns(userWallet)
coEvery { saveWalletUseCase.invoke(userWallet, false, any()) } returns Either.Left(
SaveWalletError.DataError(messageId = R.string.common_unknown_error),
)
createSaver().scanAndSaveUserWallet(this)
verify {
messageSender.send(
message = SnackbarMessage(message = resourceReference(R.string.common_unknown_error)),
)
}
verify(exactly = 0) { router.popTo(routeClass = any(), onComplete = any()) }
}
@Test
fun `GIVEN onSuccess AND save DataError without messageId WHEN scanAndSaveUserWallet THEN unknown error snackbar`() =
runTest {
mockScanSuccess(scanResponse)
mockBuilderReturns(userWallet)
coEvery { saveWalletUseCase.invoke(userWallet, false, any()) } returns Either.Left(
SaveWalletError.DataError(messageId = null),
)
createSaver().scanAndSaveUserWallet(this)
verify {
messageSender.send(
message = SnackbarMessage(message = resourceReference(R.string.common_unknown_error)),
)
}
verify(exactly = 0) { router.popTo(routeClass = any(), onComplete = any()) }
}
private fun mockScanCallback(callbackName: String) {
coEvery {
scanCardProcessor.scan(
analyticsSource = any(),
shouldCheckIsAlreadyActivated = any(),
cardId = any(),
onProgressStateChange = any(),
onWalletNotCreated = any(),
disclaimerWillShow = any(),
onCancel = any(),
onFailure = any(),
onSuccess = any(),
)
} coAnswers {
when (callbackName) {
ON_WALLET_NOT_CREATED -> arg<suspend () -> Unit>(ON_WALLET_NOT_CREATED_INDEX).invoke()
DISCLAIMER_WILL_SHOW -> arg<() -> Unit>(DISCLAIMER_WILL_SHOW_INDEX).invoke()
ON_CANCEL -> arg<suspend () -> Unit>(ON_CANCEL_INDEX).invoke()
}
}
}
private fun mockScanFailure(tangemError: TangemError) {
coEvery {
scanCardProcessor.scan(
analyticsSource = any(),
shouldCheckIsAlreadyActivated = any(),
cardId = any(),
onProgressStateChange = any(),
onWalletNotCreated = any(),
disclaimerWillShow = any(),
onCancel = any(),
onFailure = any(),
onSuccess = any(),
)
} coAnswers {
arg<suspend (TangemError) -> Unit>(ON_FAILURE_INDEX).invoke(tangemError)
}
}
private fun mockScanSuccess(scanResponse: ScanResponse) {
coEvery {
scanCardProcessor.scan(
analyticsSource = any(),
shouldCheckIsAlreadyActivated = any(),
cardId = any(),
onProgressStateChange = any(),
onWalletNotCreated = any(),
disclaimerWillShow = any(),
onCancel = any(),
onFailure = any(),
onSuccess = any(),
)
} coAnswers {
arg<suspend (ScanResponse) -> Unit>(ON_SUCCESS_INDEX).invoke(scanResponse)
}
}
private fun mockBuilderReturns(userWallet: UserWallet.Cold?) {
val builder: ColdUserWalletBuilder = mockk {
every { build() } returns userWallet
}
every { coldUserWalletBuilderFactory.create(scanResponse = any()) } returns builder
}
private fun createSaver(): UserWalletSaver {
return UserWalletSaver(
scanCardProcessor = scanCardProcessor,
saveWalletUseCase = saveWalletUseCase,
coldUserWalletBuilderFactory = coldUserWalletBuilderFactory,
messageSender = messageSender,
router = router,
onboardingV2FeatureToggles = onboardingV2FeatureToggles,
)
}
private companion object {
const val ON_WALLET_NOT_CREATED = "onWalletNotCreated"
const val DISCLAIMER_WILL_SHOW = "disclaimerWillShow"
const val ON_CANCEL = "onCancel"
const val ON_WALLET_NOT_CREATED_INDEX = 4
const val DISCLAIMER_WILL_SHOW_INDEX = 5
const val ON_CANCEL_INDEX = 6
const val ON_FAILURE_INDEX = 7
const val ON_SUCCESS_INDEX = 8
}
}

View file

@ -2,7 +2,6 @@ package com.tangem.features.onboarding.v2.addresssync
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -117,7 +116,6 @@ internal class DefaultAddressSyncComponent(
AddressSyncState.Loading -> AddressSyncLoading()
is AddressSyncState.Success -> AddressSyncButtonScreen(
state = state as AddressSyncState.Success,
modifier = Modifier.fillMaxSize(),
onSyncClick = {
model.onIntent(AddressSyncIntent.Sync)
},

View file

@ -0,0 +1,344 @@
package com.tangem.features.onboarding.v2.entry.impl.model
import com.arkivanov.decompose.router.stack.StackNavigation
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.scan.ProductType
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.features.onboarding.v2.common.ui.CantLeaveBackupDialog
import com.tangem.features.onboarding.v2.done.api.OnboardingDoneComponent
import com.tangem.features.onboarding.v2.entry.OnboardingEntryComponent
import com.tangem.features.onboarding.v2.entry.impl.routing.OnboardingRoute
import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent
import com.tangem.features.onboarding.v2.twin.api.OnboardingTwinComponent
import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
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.Assertions
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
import kotlin.reflect.KClass
@OptIn(ExperimentalCoroutinesApi::class)
internal class OnboardingEntryModelTest {
private val router: Router = mockk(relaxUnitFun = true)
private val tangemSdkManager: TangemSdkManager = mockk()
private val settingsRepository: SettingsRepository = mockk()
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true)
private val uiMessageSender: UiMessageSender = mockk(relaxUnitFun = true)
private val userWalletsListRepository: UserWalletsListRepository = mockk()
private val paramsContainer: ParamsContainer = mockk()
private val scanResponse: ScanResponse = mockk()
private val params: OnboardingEntryComponent.Params = mockk {
every { scanResponse } returns this@OnboardingEntryModelTest.scanResponse
}
@BeforeEach
fun setUp() {
every { paramsContainer.require<OnboardingEntryComponent.Params>() } returns params
every { params.mode } returns OnboardingEntryComponent.Mode.Onboarding
every { scanResponse.productType } returns ProductType.Wallet
coEvery { tangemSdkManager.checkCanUseBiometry() } returns false
coEvery { settingsRepository.shouldShowAskBiometry() } returns false
}
@ParameterizedTest
@MethodSource("provideStartRouteByProductType")
fun `GIVEN product type WHEN model is created THEN startRoute is of expected type`(
productType: ProductType,
expectedRouteClass: KClass<out OnboardingRoute>,
) = runTest {
every { scanResponse.productType } returns productType
every { params.mode } returns OnboardingEntryComponent.Mode.Onboarding
val model = createModel(this)
Assertions.assertTrue(
expectedRouteClass.isInstance(model.startRoute),
"Expected ${expectedRouteClass.simpleName} but got ${model.startRoute::class.simpleName}",
)
}
@ParameterizedTest
@MethodSource("provideWallet2ModeMappings")
fun `GIVEN Wallet2 AND entry mode WHEN model is created THEN multi-wallet mode is mapped`(
entryMode: OnboardingEntryComponent.Mode,
expectedMultiWalletMode: OnboardingMultiWalletComponent.Mode,
) = runTest {
every { scanResponse.productType } returns ProductType.Wallet2
every { params.mode } returns entryMode
val model = createModel(this)
val route = model.startRoute as OnboardingRoute.MultiWallet
Assertions.assertEquals(expectedMultiWalletMode, route.mode)
Assertions.assertEquals(true, route.withSeedPhraseFlow)
}
@ParameterizedTest
@MethodSource("provideTwinsModeMappings")
fun `GIVEN Twins AND entry mode WHEN model is created THEN twin mode is mapped`(
entryMode: OnboardingEntryComponent.Mode,
expectedTwinMode: OnboardingTwinComponent.Params.Mode,
) = runTest {
every { scanResponse.productType } returns ProductType.Twins
every { params.mode } returns entryMode
val model = createModel(this)
val route = model.startRoute as OnboardingRoute.Twins
Assertions.assertEquals(expectedTwinMode, route.mode)
}
@Test
fun `GIVEN Wallet WHEN model is created THEN withSeedPhraseFlow is false`() = runTest {
every { scanResponse.productType } returns ProductType.Wallet
every { params.mode } returns OnboardingEntryComponent.Mode.Onboarding
val model = createModel(this)
val route = model.startRoute as OnboardingRoute.MultiWallet
Assertions.assertEquals(false, route.withSeedPhraseFlow)
}
@Test
fun `GIVEN biometry available AND should ask WHEN onManageTokensDone THEN AskBiometry replaces stack`() = runTest {
coEvery { tangemSdkManager.checkCanUseBiometry() } returns true
coEvery { settingsRepository.shouldShowAskBiometry() } returns true
val model = createModel(this)
val stack = model.stackNavigation.trackStack()
model.onManageTokensDone()
advanceUntilIdle()
Assertions.assertEquals(1, stack.size)
Assertions.assertTrue(stack.first() is OnboardingRoute.AskBiometry)
}
@Test
fun `GIVEN biometry not available WHEN onManageTokensDone THEN Done WalletCreated replaces stack`() = runTest {
coEvery { tangemSdkManager.checkCanUseBiometry() } returns false
val model = createModel(this)
val stack = model.stackNavigation.trackStack()
model.onManageTokensDone()
advanceUntilIdle()
Assertions.assertEquals(1, stack.size)
val route = stack.first()
Assertions.assertTrue(route is OnboardingRoute.Done)
Assertions.assertEquals(OnboardingDoneComponent.Mode.WalletCreated, (route as OnboardingRoute.Done).mode)
}
@Test
fun `GIVEN biometry available AND should not ask WHEN onManageTokensDone THEN Done replaces stack`() = runTest {
coEvery { tangemSdkManager.checkCanUseBiometry() } returns true
coEvery { settingsRepository.shouldShowAskBiometry() } returns false
val model = createModel(this)
val stack = model.stackNavigation.trackStack()
model.onManageTokensDone()
advanceUntilIdle()
Assertions.assertEquals(1, stack.size)
Assertions.assertTrue(stack.first() is OnboardingRoute.Done)
}
@Test
fun `GIVEN Visa AND biometry available WHEN onManageTokensDone THEN BiometricScreenOpened analytics is sent`() =
runTest {
every { scanResponse.productType } returns ProductType.Visa
coEvery { tangemSdkManager.checkCanUseBiometry() } returns true
coEvery { settingsRepository.shouldShowAskBiometry() } returns true
val model = createModel(this)
model.onManageTokensDone()
advanceUntilIdle()
verify {
analyticsEventHandler.send(match<OnboardingVisaAnalyticsEvent.BiometricScreenOpened> { true })
}
verify(exactly = 0) {
analyticsEventHandler.send(match<OnboardingVisaAnalyticsEvent.SuccessScreenOpened> { true })
}
}
@Test
fun `GIVEN Visa AND biometry not available WHEN onManageTokensDone THEN SuccessScreenOpened analytics is sent`() =
runTest {
every { scanResponse.productType } returns ProductType.Visa
coEvery { tangemSdkManager.checkCanUseBiometry() } returns false
val model = createModel(this)
model.onManageTokensDone()
advanceUntilIdle()
verify {
analyticsEventHandler.send(match<OnboardingVisaAnalyticsEvent.SuccessScreenOpened> { true })
}
verify(exactly = 0) {
analyticsEventHandler.send(match<OnboardingVisaAnalyticsEvent.BiometricScreenOpened> { true })
}
}
@Test
fun `GIVEN non-Visa WHEN onManageTokensDone THEN no Visa analytics sent`() = runTest {
every { scanResponse.productType } returns ProductType.Wallet2
coEvery { tangemSdkManager.checkCanUseBiometry() } returns true
coEvery { settingsRepository.shouldShowAskBiometry() } returns true
val model = createModel(this)
model.onManageTokensDone()
advanceUntilIdle()
verify(exactly = 0) {
analyticsEventHandler.send(match<OnboardingVisaAnalyticsEvent> { true })
}
}
@Test
fun `WHEN onBack THEN CantLeaveBackupDialog is sent`() = runTest {
val model = createModel(this)
model.onBack()
verify { uiMessageSender.send(CantLeaveBackupDialog) }
}
@Test
fun `WHEN onboardingTwinModelCallbacks onBack THEN router pop is called`() = runTest {
val model = createModel(this)
model.onboardingTwinModelCallbacks.onBack()
verify { router.pop(onComplete = any()) }
}
@Test
fun `WHEN onboardingTwinModelCallbacks onDone THEN navigateToFinalScreenFlow runs`() = runTest {
coEvery { tangemSdkManager.checkCanUseBiometry() } returns false
val model = createModel(this)
val stack = model.stackNavigation.trackStack()
model.onboardingTwinModelCallbacks.onDone()
advanceUntilIdle()
Assertions.assertEquals(1, stack.size)
val route = stack.first()
Assertions.assertTrue(route is OnboardingRoute.Done)
Assertions.assertEquals(OnboardingDoneComponent.Mode.WalletCreated, (route as OnboardingRoute.Done).mode)
}
private fun StackNavigation<OnboardingRoute>.trackStack(): List<OnboardingRoute> {
val tracked = mutableListOf<OnboardingRoute>()
subscribe { event ->
val newStack = event.transformer(tracked.toList())
tracked.clear()
tracked.addAll(newStack)
}
return tracked
}
private fun createModel(testScope: TestScope): OnboardingEntryModel {
return OnboardingEntryModel(
paramsContainer = paramsContainer,
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
router = router,
tangemSdkManager = tangemSdkManager,
settingsRepository = settingsRepository,
analyticsEventHandler = analyticsEventHandler,
uiMessageSender = uiMessageSender,
userWalletsListRepository = userWalletsListRepository,
)
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
companion object {
@JvmStatic
fun provideStartRouteByProductType(): List<Arguments> = listOf(
Arguments.of(ProductType.Wallet, OnboardingRoute.MultiWallet::class),
Arguments.of(ProductType.Wallet2, OnboardingRoute.MultiWallet::class),
Arguments.of(ProductType.Ring, OnboardingRoute.MultiWallet::class),
Arguments.of(ProductType.Note, OnboardingRoute.Note::class),
Arguments.of(ProductType.Twins, OnboardingRoute.Twins::class),
Arguments.of(ProductType.Visa, OnboardingRoute.Visa::class),
)
@JvmStatic
fun provideWallet2ModeMappings(): List<Arguments> {
val userWalletId = UserWalletId("011")
return listOf(
Arguments.of(
OnboardingEntryComponent.Mode.Onboarding,
OnboardingMultiWalletComponent.Mode.Onboarding,
),
Arguments.of(
OnboardingEntryComponent.Mode.AddBackupWallet1,
OnboardingMultiWalletComponent.Mode.AddBackup,
),
Arguments.of(
OnboardingEntryComponent.Mode.ContinueFinalize,
OnboardingMultiWalletComponent.Mode.ContinueFinalize,
),
Arguments.of(
OnboardingEntryComponent.Mode.UpgradeHotWallet(userWalletId),
OnboardingMultiWalletComponent.Mode.UpgradeHotWallet(userWalletId),
),
Arguments.of(
OnboardingEntryComponent.Mode.AddressSync(userWalletId, isWalletStarted = true),
OnboardingMultiWalletComponent.Mode.AddressSync(userWalletId, isWalletStarted = true),
),
)
}
@JvmStatic
fun provideTwinsModeMappings(): List<Arguments> = listOf(
Arguments.of(
OnboardingEntryComponent.Mode.Onboarding,
OnboardingTwinComponent.Params.Mode.CreateWallet,
),
Arguments.of(
OnboardingEntryComponent.Mode.WelcomeOnlyTwin,
OnboardingTwinComponent.Params.Mode.WelcomeOnly,
),
Arguments.of(
OnboardingEntryComponent.Mode.RecreateWalletTwin,
OnboardingTwinComponent.Params.Mode.RecreateWallet,
),
)
}
}

View file

@ -0,0 +1,503 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.model
import com.tangem.common.CompletionResult
import com.tangem.common.card.Card
import com.tangem.common.core.TangemSdkError
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.onboarding.repository.OnboardingRepository
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.domain.wallets.usecase.SyncWalletWithRemoteUseCase
import com.tangem.domain.wallets.usecase.UpdateWalletUseCase
import com.tangem.features.onboarding.v2.common.ui.CantLeaveBackupDialog
import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent
import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams
import com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.MultiWalletFinalizeComponent
import com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.ui.state.MultiWalletFinalizeUM
import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState
import com.tangem.operations.backup.BackupService
import com.tangem.sdk.api.BackupServiceHolder
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
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.Assertions
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.lang.ref.WeakReference
@OptIn(ExperimentalCoroutinesApi::class)
internal class MultiWalletFinalizeModelTest {
private val backupServiceHolder: BackupServiceHolder = mockk()
private val backupService: BackupService = mockk()
private val backupServiceWeakRef: WeakReference<BackupService> = WeakReference(backupService)
private val tangemSdkManager: TangemSdkManager = mockk(relaxUnitFun = true)
private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase = mockk()
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase = mockk()
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory = mockk()
private val saveWalletUseCase: SaveWalletUseCase = mockk()
private val getUserWalletsUseCase: GetWalletsUseCase = mockk()
private val updateWalletUseCase: UpdateWalletUseCase = mockk()
private val syncWalletWithRemoteUseCase: SyncWalletWithRemoteUseCase = mockk()
private val cardRepository: CardRepository = mockk()
private val onboardingRepository: OnboardingRepository = mockk(relaxUnitFun = true)
private val walletsRepository: WalletsRepository = mockk()
private val uiMessageSender: UiMessageSender = mockk(relaxUnitFun = true)
private val backupValidator: BackupValidator = mockk()
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true)
private val paramsContainer: ParamsContainer = mockk()
private val scanResponse: ScanResponse = mockk()
private val multiWalletStateFlow = MutableStateFlow(
OnboardingMultiWalletState(
currentStep = OnboardingMultiWalletState.Step.Finalize,
accessCode = null,
isThreeCards = true,
currentScanResponse = scanResponse,
startFromFinalize = null,
resultUserWallet = null,
),
)
private val parentParams: OnboardingMultiWalletComponent.Params = mockk {
every { mode } returns OnboardingMultiWalletComponent.Mode.Onboarding
every { scanResponse } returns this@MultiWalletFinalizeModelTest.scanResponse
}
private val params: MultiWalletChildParams = mockk {
every { multiWalletState } returns multiWalletStateFlow
every { parentParams } returns this@MultiWalletFinalizeModelTest.parentParams
}
@BeforeEach
fun setUp() {
every { paramsContainer.require<MultiWalletChildParams>() } returns params
every { backupServiceHolder.backupService } returns backupServiceWeakRef
every { backupService.primaryCardId } returns "primary-id-aaaa"
every { backupService.primaryCardBatchId } returns NON_RING_BATCH_ID
every { backupService.backupCardIds } returns listOf("backup-1-bbbb", "backup-2-cccc")
every { backupService.backupCardsBatchIds } returns listOf(NON_RING_BATCH_ID, NON_RING_BATCH_ID)
every { backupService.currentState } returns BackupService.State.FinalizingPrimaryCard
coEvery { onboardingRepository.saveUnfinishedFinalizeOnboarding(any()) } just Runs
}
@Test
fun `WHEN init AND startFromFinalize is null THEN no events emitted`() = runTest {
multiWalletStateFlow.value = multiWalletStateFlow.value.copy(startFromFinalize = null)
val events = mutableListOf<MultiWalletFinalizeComponent.Event>()
val model = createModel(this)
backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect { events.add(it) } }
advanceUntilIdle()
Assertions.assertEquals(emptyList<MultiWalletFinalizeComponent.Event>(), events)
}
@Test
fun `WHEN init AND startFromFinalize is ScanBackupFirstCard THEN OneBackupCardAdded emitted`() = runTest {
multiWalletStateFlow.value = multiWalletStateFlow.value.copy(
startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupFirstCard,
)
val events = mutableListOf<MultiWalletFinalizeComponent.Event>()
val model = createModel(this)
backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect { events.add(it) } }
advanceUntilIdle()
Assertions.assertEquals(
listOf(MultiWalletFinalizeComponent.Event.OneBackupCardAdded),
events,
)
}
@Test
fun `WHEN init AND startFromFinalize is ScanBackupSecondCard THEN both events emitted in order`() = runTest {
multiWalletStateFlow.value = multiWalletStateFlow.value.copy(
startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupSecondCard,
)
val events = mutableListOf<MultiWalletFinalizeComponent.Event>()
val model = createModel(this)
backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect { events.add(it) } }
advanceUntilIdle()
Assertions.assertEquals(
listOf(
MultiWalletFinalizeComponent.Event.OneBackupCardAdded,
MultiWalletFinalizeComponent.Event.TwoBackupCardsAdded,
),
events,
)
}
@Test
fun `GIVEN backupService is null WHEN model is created THEN initial state is default`() = runTest {
every { backupServiceHolder.backupService } returns WeakReference(null)
val model = createModel(this)
advanceUntilIdle()
val state = model.uiState.value
Assertions.assertEquals(MultiWalletFinalizeUM.Step.Primary, state.step)
Assertions.assertEquals(true, state.scanPrimary)
Assertions.assertEquals("", state.cardNumber)
Assertions.assertEquals(false, state.isRing)
}
@Test
fun `GIVEN startFromFinalize null AND non-Ring primary WHEN model is created THEN state is Primary non-Ring`() =
runTest {
multiWalletStateFlow.value = multiWalletStateFlow.value.copy(startFromFinalize = null)
every { backupService.primaryCardBatchId } returns NON_RING_BATCH_ID
val model = createModel(this)
advanceUntilIdle()
val state = model.uiState.value
Assertions.assertEquals(MultiWalletFinalizeUM.Step.Primary, state.step)
Assertions.assertEquals(true, state.scanPrimary)
Assertions.assertEquals(false, state.isRing)
Assertions.assertEquals("primary-id-aaaa".lastMaskedExpected(), state.cardNumber)
}
@Test
fun `GIVEN startFromFinalize ScanPrimaryCard AND Ring primary WHEN model is created THEN state is Primary Ring`() =
runTest {
multiWalletStateFlow.value = multiWalletStateFlow.value.copy(
startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanPrimaryCard,
)
every { backupService.primaryCardBatchId } returns RING_BATCH_ID_AC17
val model = createModel(this)
advanceUntilIdle()
val state = model.uiState.value
Assertions.assertEquals(MultiWalletFinalizeUM.Step.Primary, state.step)
Assertions.assertEquals(true, state.scanPrimary)
Assertions.assertEquals(true, state.isRing)
}
@Test
fun `GIVEN startFromFinalize ScanBackupFirstCard WHEN model is created THEN state is BackupDevice1`() = runTest {
multiWalletStateFlow.value = multiWalletStateFlow.value.copy(
startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupFirstCard,
)
val model = createModel(this)
backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect {} }
advanceUntilIdle()
val state = model.uiState.value
Assertions.assertEquals(MultiWalletFinalizeUM.Step.BackupDevice1, state.step)
Assertions.assertEquals(false, state.scanPrimary)
Assertions.assertEquals("backup-1-bbbb".lastMaskedExpected(), state.cardNumber)
}
@Test
fun `GIVEN startFromFinalize ScanBackupSecondCard WHEN model is created THEN state is BackupDevice2`() = runTest {
multiWalletStateFlow.value = multiWalletStateFlow.value.copy(
startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupSecondCard,
)
val model = createModel(this)
backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect {} }
advanceUntilIdle()
val state = model.uiState.value
Assertions.assertEquals(MultiWalletFinalizeUM.Step.BackupDevice2, state.step)
Assertions.assertEquals(false, state.scanPrimary)
Assertions.assertEquals("backup-2-cccc".lastMaskedExpected(), state.cardNumber)
}
@Test
fun `GIVEN scanPrimary true WHEN onBack THEN onBackFlow emits Unit`() = runTest {
multiWalletStateFlow.value = multiWalletStateFlow.value.copy(startFromFinalize = null)
val model = createModel(this)
val received = mutableListOf<Unit>()
backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onBackFlow.collect { received.add(it) } }
advanceUntilIdle()
model.onBack()
advanceUntilIdle()
Assertions.assertEquals(listOf(Unit), received)
verify(exactly = 0) { uiMessageSender.send(CantLeaveBackupDialog) }
}
@Test
fun `GIVEN scanPrimary false WHEN onBack THEN CantLeaveBackupDialog is sent`() = runTest {
multiWalletStateFlow.value = multiWalletStateFlow.value.copy(
startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupFirstCard,
)
val model = createModel(this)
backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect {} }
advanceUntilIdle()
model.onBack()
advanceUntilIdle()
verify { uiMessageSender.send(CantLeaveBackupDialog) }
}
@Test
fun `GIVEN primary batchId is null WHEN onScanClick THEN proceedBackup is not called`() = runTest {
every { backupService.primaryCardBatchId } returns null
val model = createModel(this)
advanceUntilIdle()
model.uiState.value.onScanClick.invoke()
advanceUntilIdle()
verify(exactly = 0) { backupService.proceedBackup(iconScanRes = any(), callback = any()) }
verify(exactly = 0) { tangemSdkManager.changeProductType(any()) }
}
@Test
fun `GIVEN non-Ring primary AND success WHEN onScanClick THEN state moves to BackupDevice1`() = runTest {
every { backupService.primaryCardBatchId } returns NON_RING_BATCH_ID
val callbackSlot = slot<(CompletionResult<Card>) -> Unit>()
every {
backupService.proceedBackup(iconScanRes = null, callback = capture(callbackSlot))
} just Runs
val events = mutableListOf<MultiWalletFinalizeComponent.Event>()
val model = createModel(this)
backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect { events.add(it) } }
advanceUntilIdle()
model.uiState.value.onScanClick.invoke()
advanceUntilIdle()
verify { tangemSdkManager.changeProductType(false) }
verify { backupService.proceedBackup(iconScanRes = null, callback = any()) }
callbackSlot.captured.invoke(CompletionResult.Success(mockk()))
advanceUntilIdle()
verify { tangemSdkManager.clearProductType() }
coVerify { onboardingRepository.saveUnfinishedFinalizeOnboarding(scanResponse = scanResponse) }
val state = model.uiState.value
Assertions.assertEquals(MultiWalletFinalizeUM.Step.BackupDevice1, state.step)
Assertions.assertEquals(false, state.scanPrimary)
Assertions.assertEquals("backup-1-bbbb".lastMaskedExpected(), state.cardNumber)
Assertions.assertEquals(false, state.isRing)
Assertions.assertEquals(
listOf(MultiWalletFinalizeComponent.Event.OneBackupCardAdded),
events,
)
}
@Test
fun `GIVEN Ring primary WHEN onScanClick THEN changeProductType is true and ring icon is used`() = runTest {
every { backupService.primaryCardBatchId } returns RING_BATCH_ID_AC17
every {
backupService.proceedBackup(iconScanRes = any(), callback = any())
} just Runs
val model = createModel(this)
advanceUntilIdle()
model.uiState.value.onScanClick.invoke()
advanceUntilIdle()
verify { tangemSdkManager.changeProductType(true) }
verify {
backupService.proceedBackup(
iconScanRes = com.tangem.features.onboarding.v2.impl.R.drawable.img_hand_scan_ring,
callback = any(),
)
}
}
@Test
fun `GIVEN primary AND failure WHEN onScanClick THEN state is unchanged AND no event emitted`() = runTest {
every { backupService.primaryCardBatchId } returns NON_RING_BATCH_ID
val callbackSlot = slot<(CompletionResult<Card>) -> Unit>()
every {
backupService.proceedBackup(iconScanRes = null, callback = capture(callbackSlot))
} just Runs
val events = mutableListOf<MultiWalletFinalizeComponent.Event>()
val model = createModel(this)
backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect { events.add(it) } }
advanceUntilIdle()
val stateBefore = model.uiState.value
model.uiState.value.onScanClick.invoke()
advanceUntilIdle()
callbackSlot.captured.invoke(CompletionResult.Failure(TangemSdkError.UserCancelled()))
advanceUntilIdle()
verify { tangemSdkManager.clearProductType() }
coVerify(exactly = 0) { onboardingRepository.saveUnfinishedFinalizeOnboarding(any()) }
Assertions.assertEquals(stateBefore.step, model.uiState.value.step)
Assertions.assertEquals(stateBefore.scanPrimary, model.uiState.value.scanPrimary)
Assertions.assertEquals(emptyList<MultiWalletFinalizeComponent.Event>(), events)
}
@Test
fun `GIVEN BackupDevice1 AND batchId null WHEN onScanClick THEN proceedBackup is not called`() = runTest {
multiWalletStateFlow.value = multiWalletStateFlow.value.copy(
startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupFirstCard,
)
every { backupService.backupCardsBatchIds } returns emptyList()
val model = createModel(this)
backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect {} }
advanceUntilIdle()
model.uiState.value.onScanClick.invoke()
advanceUntilIdle()
verify(exactly = 0) { backupService.proceedBackup(iconScanRes = any(), callback = any()) }
}
@Test
fun `GIVEN BackupDevice1 AND failure WalletAlreadyCreated WHEN onScanClick THEN dialog is set`() = runTest {
multiWalletStateFlow.value = multiWalletStateFlow.value.copy(
startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupFirstCard,
)
val callbackSlot = slot<(CompletionResult<Card>) -> Unit>()
every {
backupService.proceedBackup(iconScanRes = null, callback = capture(callbackSlot))
} just Runs
val model = createModel(this)
backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect {} }
advanceUntilIdle()
model.uiState.value.onScanClick.invoke()
advanceUntilIdle()
callbackSlot.captured.invoke(CompletionResult.Failure(TangemSdkError.WalletAlreadyCreated()))
advanceUntilIdle()
Assertions.assertNotNull(model.uiState.value.dialog)
verify { tangemSdkManager.clearProductType() }
}
@Test
fun `GIVEN BackupDevice1 AND failure other error WHEN onScanClick THEN no dialog is set`() = runTest {
multiWalletStateFlow.value = multiWalletStateFlow.value.copy(
startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupFirstCard,
)
val callbackSlot = slot<(CompletionResult<Card>) -> Unit>()
every {
backupService.proceedBackup(iconScanRes = null, callback = capture(callbackSlot))
} just Runs
val model = createModel(this)
backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect {} }
advanceUntilIdle()
model.uiState.value.onScanClick.invoke()
advanceUntilIdle()
callbackSlot.captured.invoke(CompletionResult.Failure(TangemSdkError.UserCancelled()))
advanceUntilIdle()
Assertions.assertNull(model.uiState.value.dialog)
verify { tangemSdkManager.clearProductType() }
}
@Test
fun `GIVEN BackupDevice1 AND success AND not Finished WHEN onScanClick THEN state moves to BackupDevice2`() =
runTest {
multiWalletStateFlow.value = multiWalletStateFlow.value.copy(
startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupFirstCard,
)
every { backupService.currentState } returns BackupService.State.FinalizingBackupCard(index = 1)
mockkConstructor(BackupValidator::class)
every { anyConstructed<BackupValidator>().isValidBackupStatus(any()) } returns true
val card: Card = mockk(relaxed = true)
val callbackSlot = slot<(CompletionResult<Card>) -> Unit>()
every {
backupService.proceedBackup(iconScanRes = null, callback = capture(callbackSlot))
} just Runs
val events = mutableListOf<MultiWalletFinalizeComponent.Event>()
val model = createModel(this)
backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) {
model.onEvent.collect { events.add(it) }
}
advanceUntilIdle()
model.uiState.value.onScanClick.invoke()
advanceUntilIdle()
callbackSlot.captured.invoke(CompletionResult.Success(card))
advanceUntilIdle()
val state = model.uiState.value
Assertions.assertEquals(MultiWalletFinalizeUM.Step.BackupDevice2, state.step)
Assertions.assertEquals("backup-2-cccc".lastMaskedExpected(), state.cardNumber)
Assertions.assertTrue(events.contains(MultiWalletFinalizeComponent.Event.TwoBackupCardsAdded))
unmockkConstructor(BackupValidator::class)
}
private fun String.lastMaskedExpected(): String {
val space = ' '
val last4 = takeLast(4)
return "$space*$space*$space*$space$last4"
}
private fun createModel(testScope: TestScope): MultiWalletFinalizeModel {
return MultiWalletFinalizeModel(
paramsContainer = paramsContainer,
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
backupServiceHolder = backupServiceHolder,
tangemSdkManager = tangemSdkManager,
getWalletMetaInfoUseCase = getWalletMetaInfoUseCase,
sendFeedbackEmailUseCase = sendFeedbackEmailUseCase,
coldUserWalletBuilderFactory = coldUserWalletBuilderFactory,
saveWalletUseCase = saveWalletUseCase,
getUserWalletsUseCase = getUserWalletsUseCase,
updateWalletUseCase = updateWalletUseCase,
syncWalletWithRemoteUseCase = syncWalletWithRemoteUseCase,
cardRepository = cardRepository,
onboardingRepository = onboardingRepository,
walletsRepository = walletsRepository,
uiMessageSender = uiMessageSender,
backupValidator = backupValidator,
analyticsEventHandler = analyticsEventHandler,
)
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
private companion object {
private const val NON_RING_BATCH_ID = "AC02"
private const val RING_BATCH_ID_AC17 = "AC17"
}
}

View file

@ -0,0 +1,454 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.model
import com.tangem.common.card.Card
import com.tangem.common.card.FirmwareVersion as SdkFirmwareVersion
import com.tangem.common.routing.AppRoute
import com.tangem.common.ui.userwallet.converter.ArtworkUMConverter
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.components.artwork.ArtworkUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.domain.models.ArtworkModel
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ProductType
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.onboarding.repository.OnboardingRepository
import com.tangem.domain.wallets.usecase.GetCardImageUseCase
import com.tangem.features.onboarding.v2.TitleProvider
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.impl.R
import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent
import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams
import com.tangem.features.onboarding.v2.title.OnboardingTitle
import com.tangem.operations.attestation.ArtworkSize
import com.tangem.operations.backup.BackupService
import com.tangem.sdk.api.BackupServiceHolder
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
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.Assertions
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.lang.ref.WeakReference
import java.util.Date
@OptIn(ExperimentalCoroutinesApi::class)
internal class OnboardingMultiWalletModelTest {
private val analyticsHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true)
private val router: Router = mockk(relaxUnitFun = true)
private val backupServiceHolder: BackupServiceHolder = mockk()
private val backupServiceWeakRef: WeakReference<BackupService> = WeakReference(null)
private val onboardingRepository: OnboardingRepository = mockk(relaxUnitFun = true)
private val getCardImageUseCase: GetCardImageUseCase = mockk()
private val uiMessageSender: UiMessageSender = mockk(relaxUnitFun = true)
private val artworkUMConverter: ArtworkUMConverter = mockk()
private val paramsContainer: ParamsContainer = mockk()
private val titleProvider: TitleProvider = mockk(relaxUnitFun = true)
private val card1Id = "card-id-1"
private val card1PublicKey = byteArrayOf(1, 2, 3)
private val card1ManufacturerName = "Tangem"
private val card1Manufacturer = CardDTO.Manufacturer(
name = card1ManufacturerName,
manufactureDate = Date(0),
signature = null,
)
private val card1FirmwareVersionDto = CardDTO.FirmwareVersion(
major = 6,
minor = 33,
patch = 0,
type = SdkFirmwareVersion.FirmwareType.Release,
)
private val card1SdkFirmwareVersion = SdkFirmwareVersion(major = 6, minor = 33)
private val cardDto: CardDTO = mockk()
private val scanResponse: ScanResponse = mockk()
private val artwork1Model = ArtworkModel(verifiedArtwork = null, defaultUrl = "default-url-1")
private val artwork1Um = ArtworkUM(verifiedArtwork = null, defaultUrl = "default-url-1")
private val params: OnboardingMultiWalletComponent.Params = mockk {
every { titleProvider } returns this@OnboardingMultiWalletModelTest.titleProvider
every { scanResponse } returns this@OnboardingMultiWalletModelTest.scanResponse
}
@BeforeEach
fun setUp() {
every { paramsContainer.require<OnboardingMultiWalletComponent.Params>() } returns params
every { params.mode } returns OnboardingMultiWalletComponent.Mode.Onboarding
every { cardDto.cardId } returns card1Id
every { cardDto.cardPublicKey } returns card1PublicKey
every { cardDto.manufacturer } returns card1Manufacturer
every { cardDto.firmwareVersion } returns card1FirmwareVersionDto
every { cardDto.wallets } returns emptyList()
every { cardDto.backupStatus } returns null
every { scanResponse.card } returns cardDto
every { scanResponse.productType } returns ProductType.Wallet
every { scanResponse.primaryCard } returns null
every { backupServiceHolder.backupService } returns backupServiceWeakRef
coEvery {
getCardImageUseCase.invoke(
cardId = card1Id,
cardPublicKey = card1PublicKey,
size = ArtworkSize.LARGE,
manufacturerName = card1ManufacturerName,
firmwareVersion = card1SdkFirmwareVersion,
)
} returns artwork1Model
every { artworkUMConverter.convert(artwork1Model) } returns artwork1Um
}
@Test
fun `WHEN model is created THEN OnboardingEvent Started is sent`() = runTest {
createModel(this)
advanceUntilIdle()
verify { analyticsHandler.send(match<OnboardingEvent.Started> { true }) }
}
@Test
fun `GIVEN UpgradeHotWallet mode WHEN model is created THEN title is common_tangem`() = runTest {
every { params.mode } returns OnboardingMultiWalletComponent.Mode.UpgradeHotWallet(
userWalletId = UserWalletId("011"),
)
createModel(this)
advanceUntilIdle()
verify {
titleProvider.changeTitle(
title = OnboardingTitle(text = resourceReference(R.string.common_tangem)),
)
}
}
@Test
fun `GIVEN ContinueFinalize mode WHEN model is created THEN title is finalize_backup`() = runTest {
every { params.mode } returns OnboardingMultiWalletComponent.Mode.ContinueFinalize
createModel(this)
advanceUntilIdle()
verify {
titleProvider.changeTitle(
title = OnboardingTitle(text = resourceReference(R.string.onboarding_button_finalize_backup)),
)
}
}
@Test
fun `GIVEN AddressSync mode WHEN model is created THEN title is biometrics`() = runTest {
every { params.mode } returns OnboardingMultiWalletComponent.Mode.AddressSync(
userWalletId = UserWalletId("011"),
isWalletStarted = false,
)
createModel(this)
advanceUntilIdle()
verify {
titleProvider.changeTitle(
title = OnboardingTitle(text = resourceReference(R.string.onboarding_navbar_title_biometrics)),
)
}
}
@Test
fun `GIVEN wallets present AND NoBackup AND no primary card WHEN created THEN title is creating_backup`() = runTest {
every { cardDto.wallets } returns listOf(mockk())
every { cardDto.backupStatus } returns CardDTO.BackupStatus.NoBackup
every { scanResponse.primaryCard } returns null
createModel(this)
advanceUntilIdle()
verify {
titleProvider.changeTitle(
title = OnboardingTitle(text = resourceReference(R.string.onboarding_navbar_title_creating_backup)),
)
}
}
@Test
fun `GIVEN wallets present AND NoBackup AND Wallet productType WHEN created THEN title is getting_started`() =
runTest {
every { cardDto.wallets } returns listOf(mockk())
every { cardDto.backupStatus } returns CardDTO.BackupStatus.NoBackup
every { scanResponse.primaryCard } returns mockk()
every { scanResponse.productType } returns ProductType.Wallet
createModel(this)
advanceUntilIdle()
verify {
titleProvider.changeTitle(
title = OnboardingTitle(text = resourceReference(R.string.onboarding_getting_started)),
)
}
}
@Test
fun `GIVEN wallets present AND NoBackup AND non-Wallet productType WHEN created THEN title is creating_backup`() =
runTest {
every { cardDto.wallets } returns listOf(mockk())
every { cardDto.backupStatus } returns CardDTO.BackupStatus.NoBackup
every { scanResponse.primaryCard } returns mockk()
every { scanResponse.productType } returns ProductType.Wallet2
createModel(this)
advanceUntilIdle()
verify {
titleProvider.changeTitle(
title = OnboardingTitle(text = resourceReference(R.string.onboarding_navbar_title_creating_backup)),
)
}
}
@Test
fun `GIVEN wallets present AND active backup WHEN created THEN title is finalize_backup`() = runTest {
every { cardDto.wallets } returns listOf(mockk())
every { cardDto.backupStatus } returns CardDTO.BackupStatus.Active(cardCount = 2)
createModel(this)
advanceUntilIdle()
verify {
titleProvider.changeTitle(
title = OnboardingTitle(text = resourceReference(R.string.onboarding_button_finalize_backup)),
)
}
}
@Test
fun `GIVEN no wallets WHEN model is created THEN title is create_wallet_header`() = runTest {
every { cardDto.wallets } returns emptyList()
createModel(this)
advanceUntilIdle()
verify {
titleProvider.changeTitle(
title = OnboardingTitle(text = resourceReference(R.string.onboarding_create_wallet_header)),
)
}
}
@Test
fun `WHEN model is created THEN loadCardArtwork updates artwork1 in uiState`() = runTest {
val model = createModel(this)
advanceUntilIdle()
coVerify {
getCardImageUseCase.invoke(
cardId = card1Id,
cardPublicKey = card1PublicKey,
size = ArtworkSize.LARGE,
manufacturerName = card1ManufacturerName,
firmwareVersion = card1SdkFirmwareVersion,
)
}
verify { artworkUMConverter.convert(artwork1Model) }
Assertions.assertEquals(artwork1Um, model.uiState.value.artwork1)
}
@Test
fun `GIVEN backups emit card2 WHEN subscribeToBackups THEN artwork2 is loaded and updated`() = runTest {
val card2Info = card2BackupInfo()
val artwork2Model = ArtworkModel(verifiedArtwork = null, defaultUrl = "default-url-2")
val artwork2Um = ArtworkUM(verifiedArtwork = null, defaultUrl = "default-url-2")
coEvery {
getCardImageUseCase.invoke(
cardId = card2Info.cardId,
cardPublicKey = card2Info.cardPublicKey,
size = ArtworkSize.LARGE,
manufacturerName = card2Info.manufacturer.name,
firmwareVersion = card2Info.firmwareVersion,
)
} returns artwork2Model
every { artworkUMConverter.convert(artwork2Model) } returns artwork2Um
val model = createModel(this)
advanceUntilIdle()
model.backups.value = MultiWalletChildParams.Backup(card2 = card2Info)
advanceUntilIdle()
coVerify {
getCardImageUseCase.invoke(
cardId = card2Info.cardId,
cardPublicKey = card2Info.cardPublicKey,
size = ArtworkSize.LARGE,
manufacturerName = card2Info.manufacturer.name,
firmwareVersion = card2Info.firmwareVersion,
)
}
Assertions.assertEquals(artwork2Um, model.uiState.value.artwork2)
}
@Test
fun `GIVEN backups emit card3 after card2 WHEN subscribeToBackups THEN artwork3 is loaded and updated`() = runTest {
val card2Info = card2BackupInfo()
val card3Info = card3BackupInfo()
val artwork2Model = ArtworkModel(verifiedArtwork = null, defaultUrl = "default-url-2")
val artwork2Um = ArtworkUM(verifiedArtwork = null, defaultUrl = "default-url-2")
val artwork3Model = ArtworkModel(verifiedArtwork = null, defaultUrl = "default-url-3")
val artwork3Um = ArtworkUM(verifiedArtwork = null, defaultUrl = "default-url-3")
coEvery {
getCardImageUseCase.invoke(
cardId = card2Info.cardId,
cardPublicKey = card2Info.cardPublicKey,
size = ArtworkSize.LARGE,
manufacturerName = card2Info.manufacturer.name,
firmwareVersion = card2Info.firmwareVersion,
)
} returns artwork2Model
every { artworkUMConverter.convert(artwork2Model) } returns artwork2Um
coEvery {
getCardImageUseCase.invoke(
cardId = card3Info.cardId,
cardPublicKey = card3Info.cardPublicKey,
size = ArtworkSize.LARGE,
manufacturerName = card3Info.manufacturer.name,
firmwareVersion = card3Info.firmwareVersion,
)
} returns artwork3Model
every { artworkUMConverter.convert(artwork3Model) } returns artwork3Um
val model = createModel(this)
advanceUntilIdle()
model.backups.value = MultiWalletChildParams.Backup(card2 = card2Info)
advanceUntilIdle()
model.backups.value = MultiWalletChildParams.Backup(card2 = card2Info, card3 = card3Info)
advanceUntilIdle()
coVerify {
getCardImageUseCase.invoke(
cardId = card3Info.cardId,
cardPublicKey = card3Info.cardPublicKey,
size = ArtworkSize.LARGE,
manufacturerName = card3Info.manufacturer.name,
firmwareVersion = card3Info.firmwareVersion,
)
}
Assertions.assertEquals(artwork3Um, model.uiState.value.artwork3)
}
@Test
fun `GIVEN non-AddressSync mode WHEN onBack confirmed THEN router pop is called`() = runTest {
every { params.mode } returns OnboardingMultiWalletComponent.Mode.Onboarding
coEvery { onboardingRepository.clearUnfinishedFinalizeOnboarding() } just Runs
val dialogSlot = slot<DialogMessage>()
every { uiMessageSender.send(capture(dialogSlot)) } just Runs
val model = createModel(this)
advanceUntilIdle()
model.onBack()
dialogSlot.captured.firstAction.onClick.invoke()
advanceUntilIdle()
coVerify { onboardingRepository.clearUnfinishedFinalizeOnboarding() }
verify { router.pop(onComplete = any()) }
verify(exactly = 0) { router.popTo(route = any(), onComplete = any()) }
verify(exactly = 0) { router.replaceAll(routes = anyVararg(), onComplete = any()) }
}
@Test
fun `GIVEN AddressSync mode AND wallet started WHEN onBack confirmed THEN popTo Wallet is called`() = runTest {
every { params.mode } returns OnboardingMultiWalletComponent.Mode.AddressSync(
userWalletId = UserWalletId("011"),
isWalletStarted = true,
)
coEvery { onboardingRepository.clearUnfinishedFinalizeOnboarding() } just Runs
val dialogSlot = slot<DialogMessage>()
every { uiMessageSender.send(capture(dialogSlot)) } just Runs
val model = createModel(this)
advanceUntilIdle()
model.onBack()
dialogSlot.captured.firstAction.onClick.invoke()
advanceUntilIdle()
coVerify { onboardingRepository.clearUnfinishedFinalizeOnboarding() }
verify { router.popTo(route = AppRoute.Wallet, onComplete = any()) }
verify(exactly = 0) { router.pop(onComplete = any()) }
verify(exactly = 0) { router.replaceAll(routes = anyVararg(), onComplete = any()) }
}
@Test
fun `GIVEN AddressSync mode AND wallet not started WHEN onBack confirmed THEN replaceAll Wallet is called`() =
runTest {
every { params.mode } returns OnboardingMultiWalletComponent.Mode.AddressSync(
userWalletId = UserWalletId("011"),
isWalletStarted = false,
)
coEvery { onboardingRepository.clearUnfinishedFinalizeOnboarding() } just Runs
val dialogSlot = slot<DialogMessage>()
every { uiMessageSender.send(capture(dialogSlot)) } just Runs
val model = createModel(this)
advanceUntilIdle()
model.onBack()
dialogSlot.captured.firstAction.onClick.invoke()
advanceUntilIdle()
coVerify { onboardingRepository.clearUnfinishedFinalizeOnboarding() }
verify { router.replaceAll(routes = arrayOf(AppRoute.Wallet), onComplete = any()) }
verify(exactly = 0) { router.pop(onComplete = any()) }
verify(exactly = 0) { router.popTo(route = any(), onComplete = any()) }
}
private fun card2BackupInfo() = MultiWalletChildParams.Backup.BackupCardInfo(
cardId = "card-id-2",
cardPublicKey = byteArrayOf(4, 5, 6),
manufacturer = Card.Manufacturer(name = "Tangem2", manufactureDate = Date(0), signature = null),
firmwareVersion = SdkFirmwareVersion(major = 6, minor = 34),
)
private fun card3BackupInfo() = MultiWalletChildParams.Backup.BackupCardInfo(
cardId = "card-id-3",
cardPublicKey = byteArrayOf(7, 8, 9),
manufacturer = Card.Manufacturer(name = "Tangem3", manufactureDate = Date(0), signature = null),
firmwareVersion = SdkFirmwareVersion(major = 6, minor = 35),
)
private fun createModel(testScope: TestScope): OnboardingMultiWalletModel {
return OnboardingMultiWalletModel(
paramsContainer = paramsContainer,
analyticsHandler = analyticsHandler,
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
router = router,
backupServiceHolder = backupServiceHolder,
onboardingRepository = onboardingRepository,
getCardImageUseCase = getCardImageUseCase,
uiMessageSender = uiMessageSender,
artworkUMConverter = artworkUMConverter,
)
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
}

View file

@ -0,0 +1,66 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.model
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.onboarding.v2.impl.R
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
internal class UtilsTest {
@ParameterizedTest
@MethodSource("provideScreenTitleByStep")
fun `GIVEN step WHEN screenTitleByStep THEN expected text reference is returned`(
step: OnboardingMultiWalletState.Step,
expected: TextReference,
) {
val actual = screenTitleByStep(step)
Assertions.assertEquals(expected, actual)
}
companion object {
@JvmStatic
fun provideScreenTitleByStep(): List<Arguments> = listOf(
Arguments.of(
OnboardingMultiWalletState.Step.UpgradeWallet,
resourceReference(R.string.common_tangem),
),
Arguments.of(
OnboardingMultiWalletState.Step.CreateWallet,
resourceReference(R.string.onboarding_create_wallet_header),
),
Arguments.of(
OnboardingMultiWalletState.Step.SeedPhrase,
resourceReference(R.string.onboarding_create_wallet_header),
),
Arguments.of(
OnboardingMultiWalletState.Step.ChooseBackupOption,
resourceReference(R.string.onboarding_getting_started),
),
Arguments.of(
OnboardingMultiWalletState.Step.ScanPrimary,
resourceReference(R.string.onboarding_navbar_title_creating_backup),
),
Arguments.of(
OnboardingMultiWalletState.Step.AddBackupDevice,
resourceReference(R.string.onboarding_navbar_title_creating_backup),
),
Arguments.of(
OnboardingMultiWalletState.Step.AddressSync,
resourceReference(R.string.onboarding_navbar_title_biometrics),
),
Arguments.of(
OnboardingMultiWalletState.Step.Finalize,
resourceReference(R.string.onboarding_button_finalize_backup),
),
Arguments.of(
OnboardingMultiWalletState.Step.Done,
resourceReference(R.string.common_done),
),
)
}
}