diff --git a/features/details/impl/build.gradle.kts b/features/details/impl/build.gradle.kts index e86fe43d33..f0cda1eba5 100644 --- a/features/details/impl/build.gradle.kts +++ b/features/details/impl/build.gradle.kts @@ -11,6 +11,10 @@ android { namespace = "com.tangem.features.details.impl" } +tasks.withType().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) } \ No newline at end of file diff --git a/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/UserWalletSaverTest.kt b/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/UserWalletSaverTest.kt new file mode 100644 index 0000000000..60fa5c7682 --- /dev/null +++ b/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/UserWalletSaverTest.kt @@ -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 { + 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 { + 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 { + 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 Unit>(ON_WALLET_NOT_CREATED_INDEX).invoke() + DISCLAIMER_WILL_SHOW -> arg<() -> Unit>(DISCLAIMER_WILL_SHOW_INDEX).invoke() + ON_CANCEL -> arg 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 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 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 + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt index e5698414d1..564379e137 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt @@ -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) }, diff --git a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModelTest.kt b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModelTest.kt new file mode 100644 index 0000000000..db8b3ae7df --- /dev/null +++ b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModelTest.kt @@ -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() } 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, + ) = 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 { true }) + } + verify(exactly = 0) { + analyticsEventHandler.send(match { 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 { true }) + } + verify(exactly = 0) { + analyticsEventHandler.send(match { 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 { 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.trackStack(): List { + val tracked = mutableListOf() + 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 = 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 { + 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 = 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, + ), + ) + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModelTest.kt b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModelTest.kt new file mode 100644 index 0000000000..92840e92da --- /dev/null +++ b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModelTest.kt @@ -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 = 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() } 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() + val model = createModel(this) + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect { events.add(it) } } + advanceUntilIdle() + + Assertions.assertEquals(emptyList(), 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() + 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() + 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() + 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) -> Unit>() + every { + backupService.proceedBackup(iconScanRes = null, callback = capture(callbackSlot)) + } just Runs + + val events = mutableListOf() + 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) -> Unit>() + every { + backupService.proceedBackup(iconScanRes = null, callback = capture(callbackSlot)) + } just Runs + + val events = mutableListOf() + 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(), 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) -> 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) -> 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().isValidBackupStatus(any()) } returns true + + val card: Card = mockk(relaxed = true) + val callbackSlot = slot<(CompletionResult) -> Unit>() + every { + backupService.proceedBackup(iconScanRes = null, callback = capture(callbackSlot)) + } just Runs + + val events = mutableListOf() + 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" + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModelTest.kt b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModelTest.kt new file mode 100644 index 0000000000..3b10fcbbbe --- /dev/null +++ b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModelTest.kt @@ -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 = 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() } 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 { 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() + 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() + 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() + 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, + ) + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/UtilsTest.kt b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/UtilsTest.kt new file mode 100644 index 0000000000..8c2e762454 --- /dev/null +++ b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/UtilsTest.kt @@ -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 = 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), + ), + ) + } +} \ No newline at end of file