From bf50e374e13247876e9f848479dab7536d368c06 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 11 Jun 2026 12:04:22 +0400 Subject: [PATCH] Updated on 2026-08-14 --- .../component/impl/DefaultRoutingComponent.kt | 2 +- .../datasource/local/card/UsedCardInfo.kt | 2 + .../card/UsedCardInfoSerializationTest.kt | 107 +++++++ data/card/build.gradle.kts | 4 + .../tangem/data/card/DefaultCardRepository.kt | 32 +- .../data/card/DefaultCardRepositoryTest.kt | 287 ++++++++++++++++++ .../domain/card/repository/CardRepository.kt | 7 +- domain/wallets/build.gradle.kts | 6 +- domain/wallets/detekt-baseline-debug.xml | 2 - .../wallets/builder/ColdUserWalletBuilder.kt | 37 ++- .../builder/ColdUserWalletBuilderTest.kt | 174 +++++++++++ .../CreateWalletStartModelTest.kt | 4 +- .../features/details/utils/UserWalletSaver.kt | 2 +- .../details/utils/UserWalletSaverTest.kt | 2 +- .../model/Wallet1ChooseOptionModel.kt | 2 +- .../model/MultiWalletFinalizeModel.kt | 7 +- .../model/MultiWalletSeedPhraseModel.kt | 2 +- .../model/MultiWalletUpgradeWalletModel.kt | 2 +- 18 files changed, 617 insertions(+), 64 deletions(-) create mode 100644 core/datasource/src/test/kotlin/com/tangem/datasource/local/card/UsedCardInfoSerializationTest.kt create mode 100644 data/card/src/test/kotlin/com/tangem/data/card/DefaultCardRepositoryTest.kt create mode 100644 domain/wallets/src/test/java/com/tangem/domain/wallets/builder/ColdUserWalletBuilderTest.kt diff --git a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt index 00c73a1027..6e5488d67b 100644 --- a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt +++ b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt @@ -395,7 +395,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor( componentScope.launch(dispatchers.main) { backupServiceHolder.backupService.get()?.discardSavedBackup() val unfinishedBackup = onboardingRepository.getUnfinishedFinalizeOnboarding() ?: return@launch - cardRepository.finishCardActivation(unfinishedBackup.card.cardId) + cardRepository.finishCardActivation(cardId = unfinishedBackup.card.cardId, hasBackupError = true) onboardingRepository.clearUnfinishedFinalizeOnboarding() analyticsEventHandler.send(OnboardingAnalyticsEvent.Onboarding.Finished()) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/card/UsedCardInfo.kt b/core/datasource/src/main/java/com/tangem/datasource/local/card/UsedCardInfo.kt index 2e0064720d..c08dc2592c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/card/UsedCardInfo.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/card/UsedCardInfo.kt @@ -13,4 +13,6 @@ data class UsedCardInfo( val isActivationStarted: Boolean = false, @Json(name = "isActivationFinished") val isActivationFinished: Boolean = false, + @Json(name = "hasBackupError") + val hasBackupError: Boolean = false, ) \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/local/card/UsedCardInfoSerializationTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/local/card/UsedCardInfoSerializationTest.kt new file mode 100644 index 0000000000..8a5236188c --- /dev/null +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/local/card/UsedCardInfoSerializationTest.kt @@ -0,0 +1,107 @@ +package com.tangem.datasource.local.card + +import com.google.common.truth.Truth.assertThat +import com.squareup.moshi.JsonDataException +import com.squareup.moshi.Moshi +import com.tangem.test.core.ProvideTestModels +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +/** + * Tests Moshi serialization/deserialization of [UsedCardInfo]. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class UsedCardInfoSerializationTest { + + private val adapter = Moshi.Builder().build().adapter(UsedCardInfo::class.java) + + @Test + fun `GIVEN full model WHEN toJson THEN all fields serialized in declaration order`() { + // Arrange + val model = UsedCardInfo( + cardId = "card-1", + isScanned = true, + isActivationStarted = true, + isActivationFinished = false, + hasBackupError = true, + ) + + // Act + val json = adapter.toJson(model) + + // Assert + assertThat(json).isEqualTo( + """{"cardId":"card-1","isScanned":true,"isActivationStarted":true,""" + + """"isActivationFinished":false,"hasBackupError":true}""", + ) + } + + @Test + fun `GIVEN full json WHEN fromJson THEN model fully populated`() { + // Arrange + val json = """{"cardId":"card-2","isScanned":false,"isActivationStarted":true,""" + + """"isActivationFinished":true,"hasBackupError":false}""" + + // Act + val result = adapter.fromJson(json) + + // Assert + assertThat(result).isEqualTo( + UsedCardInfo( + cardId = "card-2", + isScanned = false, + isActivationStarted = true, + isActivationFinished = true, + hasBackupError = false, + ), + ) + } + + @Test + fun `GIVEN json with only cardId WHEN fromJson THEN boolean fields fall back to defaults`() { + // Arrange + val json = """{"cardId":"card-3"}""" + + // Act + val result = adapter.fromJson(json) + + // Assert + assertThat(result).isEqualTo(UsedCardInfo(cardId = "card-3")) + } + + @Test + fun `GIVEN json without cardId WHEN fromJson THEN throws`() { + // Arrange + val json = """{"isScanned":true}""" + + // Act + val error = runCatching { adapter.fromJson(json) }.exceptionOrNull() + + // Assert + assertThat(error).isInstanceOf(JsonDataException::class.java) + } + + @ParameterizedTest + @ProvideTestModels + fun roundTrip(model: UsedCardInfo) { + // Act + val restored = adapter.fromJson(adapter.toJson(model)) + + // Assert + assertThat(restored).isEqualTo(model) + } + + private fun provideTestModels() = listOf( + UsedCardInfo(cardId = "default-only"), + UsedCardInfo( + cardId = "all-true", + isScanned = true, + isActivationStarted = true, + isActivationFinished = true, + hasBackupError = true, + ), + UsedCardInfo(cardId = "activation-in-progress", isScanned = true, isActivationStarted = true), + UsedCardInfo(cardId = "backup-error", hasBackupError = true), + ) +} \ No newline at end of file diff --git a/data/card/build.gradle.kts b/data/card/build.gradle.kts index 6d546bc04d..2f07418317 100644 --- a/data/card/build.gradle.kts +++ b/data/card/build.gradle.kts @@ -30,4 +30,8 @@ dependencies { implementation(projects.domain.card) implementation(projects.domain.models) + + // region Tests + testImplementation(projects.test.core) + // end } \ No newline at end of file diff --git a/data/card/src/main/java/com/tangem/data/card/DefaultCardRepository.kt b/data/card/src/main/java/com/tangem/data/card/DefaultCardRepository.kt index db200bd7e9..782f633d92 100644 --- a/data/card/src/main/java/com/tangem/data/card/DefaultCardRepository.kt +++ b/data/card/src/main/java/com/tangem/data/card/DefaultCardRepository.kt @@ -32,33 +32,9 @@ internal class DefaultCardRepository( appPreferencesStore.editUsedCards(cardId) { it.copy(isActivationStarted = true) } } - override suspend fun finishCardActivation(cardId: String) { + override suspend fun finishCardActivation(cardId: String, hasBackupError: Boolean) { appPreferencesStore.editUsedCards(cardId) { - it.copy(isActivationStarted = true, isActivationFinished = true) - } - } - - override suspend fun finishCardsActivation(cardIds: List) { - appPreferencesStore.editData { mutablePreferences -> - val usedCards = mutablePreferences.getUsedCards() - - val newCards = cardIds.mapNotNull { newCardId -> - if (usedCards.none { it.cardId == newCardId }) { - createDefaultUsedCardInfo(cardId = newCardId) - } else { - null - } - } - - val updatedUsedCards = (usedCards + newCards).map { card -> - if (cardIds.contains(card.cardId)) { - card.copy(isActivationStarted = true, isActivationFinished = true) - } else { - card - } - } - - mutablePreferences.setObjectList(key = PreferencesKeys.USED_CARDS_INFO_KEY, value = updatedUsedCards) + it.copy(isActivationStarted = true, isActivationFinished = true, hasBackupError = hasBackupError) } } @@ -76,6 +52,10 @@ internal class DefaultCardRepository( return card.isActivationStarted && !card.isActivationFinished } + override suspend fun hasBackupError(cardId: String): Boolean { + return getUsedCardSync(cardId)?.hasBackupError == true + } + override suspend fun isTangemTOSAccepted(): Boolean { return appPreferencesStore.getSyncOrDefault(key = PreferencesKeys.IS_TANGEM_TOS_ACCEPTED_KEY, default = false) } diff --git a/data/card/src/test/kotlin/com/tangem/data/card/DefaultCardRepositoryTest.kt b/data/card/src/test/kotlin/com/tangem/data/card/DefaultCardRepositoryTest.kt new file mode 100644 index 0000000000..9e5f634fbb --- /dev/null +++ b/data/card/src/test/kotlin/com/tangem/data/card/DefaultCardRepositoryTest.kt @@ -0,0 +1,287 @@ +package com.tangem.data.card + +import androidx.datastore.preferences.core.emptyPreferences +import com.google.common.truth.Truth.assertThat +import com.squareup.moshi.Moshi +import com.tangem.datasource.local.card.UsedCardInfo +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.preferences.utils.getObjectListSync +import com.tangem.datasource.local.preferences.utils.storeObjectList +import com.tangem.test.core.ProvideTestModels +import com.tangem.test.core.datastore.MockStateDataStore +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +/** + * Tests for [DefaultCardRepository]. + * + * Uses a real [AppPreferencesStore] backed by an in-memory [MockStateDataStore] and a real [Moshi] + * instance, so the JSON round-trip through preferences is exercised end-to-end. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultCardRepositoryTest { + + // Only the in-memory store's content is mutable, so it is the single thing reset per test. + private val dataStore = MockStateDataStore(default = emptyPreferences()) + private val appPreferencesStore = AppPreferencesStore( + moshi = Moshi.Builder().build(), + dispatchers = TestingCoroutineDispatcherProvider(), + preferencesDataStore = dataStore, + ) + private val repository = DefaultCardRepository(appPreferencesStore) + + @BeforeEach + fun resetStore() { + runBlocking { dataStore.updateData { emptyPreferences() } } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class WasCardScanned { + + @Test + fun `GIVEN card present WHEN wasCardScanned THEN emits true`() = runTest { + // Arrange + seedCards(UsedCardInfo(cardId = CARD_ID)) + + // Act + val result = repository.wasCardScanned(CARD_ID).first() + + // Assert + assertThat(result).isTrue() + } + + @Test + fun `GIVEN card absent WHEN wasCardScanned THEN emits false`() = runTest { + // Act + val result = repository.wasCardScanned(CARD_ID).first() + + // Assert + assertThat(result).isFalse() + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class SetCardWasScanned { + + @Test + fun `GIVEN empty store WHEN setCardWasScanned THEN creates entry with isScanned true`() = runTest { + // Act + repository.setCardWasScanned(CARD_ID) + + // Assert + assertThat(storedCards()).containsExactly(UsedCardInfo(cardId = CARD_ID, isScanned = true)) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class StartCardActivation { + + @Test + fun `GIVEN empty store WHEN startCardActivation THEN creates entry with isActivationStarted true`() = runTest { + // Act + repository.startCardActivation(CARD_ID) + + // Assert + assertThat(storedCards()).containsExactly(UsedCardInfo(cardId = CARD_ID, isActivationStarted = true)) + } + + @Test + fun `GIVEN other cards present WHEN editing one card THEN others are preserved`() = runTest { + // Arrange + val other = UsedCardInfo(cardId = OTHER_CARD_ID, isScanned = true) + seedCards(other) + + // Act + repository.startCardActivation(CARD_ID) + + // Assert + assertThat(storedCards()).containsExactly( + other, + UsedCardInfo(cardId = CARD_ID, isActivationStarted = true), + ) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class FinishCardActivation { + + @Test + fun `GIVEN empty store WHEN finishCardActivation with backup error THEN entry marked finished with error`() = + runTest { + // Act + repository.finishCardActivation(cardId = CARD_ID, hasBackupError = true) + + // Assert + assertThat(storedCards()).containsExactly( + UsedCardInfo( + cardId = CARD_ID, + isActivationStarted = true, + isActivationFinished = true, + hasBackupError = true, + ), + ) + } + + @Test + fun `GIVEN empty store WHEN finishCardActivation without backup error THEN entry marked finished without error`() = + runTest { + // Act + repository.finishCardActivation(cardId = CARD_ID, hasBackupError = false) + + // Assert + assertThat(storedCards()).containsExactly( + UsedCardInfo( + cardId = CARD_ID, + isActivationStarted = true, + isActivationFinished = true, + hasBackupError = false, + ), + ) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class IsActivationStarted { + + @Test + fun `GIVEN activation started WHEN isActivationStarted THEN true`() = runTest { + // Arrange + seedCards(UsedCardInfo(cardId = CARD_ID, isActivationStarted = true)) + + // Act & Assert + assertThat(repository.isActivationStarted(CARD_ID)).isTrue() + } + + @Test + fun `GIVEN card absent WHEN isActivationStarted THEN false`() = runTest { + assertThat(repository.isActivationStarted(CARD_ID)).isFalse() + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class IsActivationFinished { + + @Test + fun `GIVEN activation finished WHEN isActivationFinished THEN true`() = runTest { + // Arrange + seedCards(UsedCardInfo(cardId = CARD_ID, isActivationFinished = true)) + + // Act & Assert + assertThat(repository.isActivationFinished(CARD_ID)).isTrue() + } + + @Test + fun `GIVEN card absent WHEN isActivationFinished THEN false`() = runTest { + assertThat(repository.isActivationFinished(CARD_ID)).isFalse() + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class IsActivationInProgress { + + @ParameterizedTest + @ProvideTestModels + fun isActivationInProgress(model: ActivationInProgressModel) = runTest { + // Arrange + model.stored?.let { seedCards(it) } + + // Act + val result = repository.isActivationInProgress(CARD_ID) + + // Assert + assertThat(result).isEqualTo(model.expected) + } + + private fun provideTestModels() = listOf( + ActivationInProgressModel(stored = null, expected = false), + ActivationInProgressModel( + stored = UsedCardInfo(cardId = CARD_ID, isActivationStarted = false, isActivationFinished = false), + expected = false, + ), + ActivationInProgressModel( + stored = UsedCardInfo(cardId = CARD_ID, isActivationStarted = true, isActivationFinished = false), + expected = true, + ), + ActivationInProgressModel( + stored = UsedCardInfo(cardId = CARD_ID, isActivationStarted = true, isActivationFinished = true), + expected = false, + ), + ActivationInProgressModel( + stored = UsedCardInfo(cardId = CARD_ID, isActivationStarted = false, isActivationFinished = true), + expected = false, + ), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class HasBackupError { + + @Test + fun `GIVEN backup error WHEN hasBackupError THEN true`() = runTest { + // Arrange + seedCards(UsedCardInfo(cardId = CARD_ID, hasBackupError = true)) + + // Act & Assert + assertThat(repository.hasBackupError(CARD_ID)).isTrue() + } + + @Test + fun `GIVEN no backup error WHEN hasBackupError THEN false`() = runTest { + // Arrange + seedCards(UsedCardInfo(cardId = CARD_ID, hasBackupError = false)) + + // Act & Assert + assertThat(repository.hasBackupError(CARD_ID)).isFalse() + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class TangemTos { + + @Test + fun `GIVEN nothing stored WHEN isTangemTOSAccepted THEN false by default`() = runTest { + assertThat(repository.isTangemTOSAccepted()).isFalse() + } + + @Test + fun `GIVEN TOS accepted WHEN isTangemTOSAccepted THEN true`() = runTest { + // Arrange + repository.acceptTangemTOS() + + // Act & Assert + assertThat(repository.isTangemTOSAccepted()).isTrue() + } + } + + private suspend fun seedCards(vararg cards: UsedCardInfo) { + appPreferencesStore.storeObjectList(key = PreferencesKeys.USED_CARDS_INFO_KEY, value = cards.toList()) + } + + private suspend fun storedCards(): List { + return appPreferencesStore.getObjectListSync(key = PreferencesKeys.USED_CARDS_INFO_KEY) + } + + internal data class ActivationInProgressModel(val stored: UsedCardInfo?, val expected: Boolean) + + private companion object { + const val CARD_ID = "card-1" + const val OTHER_CARD_ID = "card-2" + } +} \ No newline at end of file diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/repository/CardRepository.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/repository/CardRepository.kt index 5e01929fa1..28d4822af4 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/repository/CardRepository.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/repository/CardRepository.kt @@ -10,9 +10,7 @@ interface CardRepository { suspend fun startCardActivation(cardId: String) - suspend fun finishCardActivation(cardId: String) - - suspend fun finishCardsActivation(cardIds: List) + suspend fun finishCardActivation(cardId: String, hasBackupError: Boolean = false) @Throws suspend fun isActivationStarted(cardId: String): Boolean @@ -23,6 +21,9 @@ interface CardRepository { @Throws suspend fun isActivationInProgress(cardId: String): Boolean + @Throws + suspend fun hasBackupError(cardId: String): Boolean + @Throws suspend fun isTangemTOSAccepted(): Boolean diff --git a/domain/wallets/build.gradle.kts b/domain/wallets/build.gradle.kts index 5eb9549012..4663162f38 100644 --- a/domain/wallets/build.gradle.kts +++ b/domain/wallets/build.gradle.kts @@ -59,9 +59,7 @@ dependencies { // end // region Tests - testImplementation(deps.test.junit5) - testImplementation(deps.test.coroutine) - testImplementation(deps.test.truth) - testImplementation(deps.test.mockk) + testImplementation(projects.test.core) + testImplementation(projects.common.test) // end } \ No newline at end of file diff --git a/domain/wallets/detekt-baseline-debug.xml b/domain/wallets/detekt-baseline-debug.xml index b3275efcc5..2c5c9855e4 100644 --- a/domain/wallets/detekt-baseline-debug.xml +++ b/domain/wallets/detekt-baseline-debug.xml @@ -6,11 +6,9 @@ BooleanPropertyNaming:HotWalletPasswordRequester.kt$HotWalletPasswordRequester.AttemptRequest$val authMode: Boolean BooleanPropertyNaming:SaveWalletUseCase.kt$SaveWalletUseCase$val newUserWallet = userWalletsListRepository.userWalletsSync().none { it.walletId == userWallet.walletId } MaxChainedCallsOnSameLine:UserWalletExtensions.kt$wallets.orEmpty().first { it.curve == primaryCurve }.derivedKeys.keys.any { it == dp } - MultilineLambdaItParameter:ColdUserWalletBuilder.kt$ColdUserWalletBuilder${ UserWallet.Cold( walletId = it, name = generateWalletNameUseCase( card = card, productType = productType, isStartToCoin = cardTypesResolver.isStart2Coin(), ), cardsInWallet = backupCardsIds.plus(card.cardId), scanResponse = this, isMultiCurrency = cardTypesResolver.isMultiwalletAllowed(), hasBackupError = hasBackupError, ) } MultilineLambdaItParameter:HotUserWalletBuilder.kt$HotUserWalletBuilder${ MobileWallet( publicKey = it.seedKey.publicKey, chainCode = it.seedKey.chainCode, curve = it.curve, derivedKeys = it.publicKeys, ) } MultilineLambdaItParameter:HotUserWalletBuilder.kt$HotUserWalletBuilder${ val derivationPath = it.derivationPath(DerivationStyle.V3) ?: return@mapNotNull null if (it == Blockchain.Cardano) { val extendedDerivationPath = CardanoUtils.extendedDerivationPath(derivationPath) listOf(derivationPath, extendedDerivationPath) } else { listOf(derivationPath) } } MultilineLambdaItParameter:UpdateWalletUseCase.kt$UpdateWalletUseCase${ when (it) { is SaveWalletError.DataError -> DataError( IllegalStateException("Failed to update wallet: ${it.messageId}"), ) is SaveWalletError.WalletAlreadySaved -> UpdateWalletError.NameAlreadyExists } } - NestedScopeFunctions:ColdUserWalletBuilder.kt$ColdUserWalletBuilder$let { UserWallet.Cold( walletId = it, name = generateWalletNameUseCase( card = card, productType = productType, isStartToCoin = cardTypesResolver.isStart2Coin(), ), cardsInWallet = backupCardsIds.plus(card.cardId), scanResponse = this, isMultiCurrency = cardTypesResolver.isMultiwalletAllowed(), hasBackupError = hasBackupError, ) } NoNameShadowing:SaveWalletUseCase.kt$SaveWalletUseCase$userWallet RedundantSuspendModifier:GetHotWalletContextualUnlockUseCase.kt$GetHotWalletContextualUnlockUseCase$suspend SuspendFunSwallowedCancellation:RenameWalletUseCase.kt$RenameWalletUseCase$runCatching diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/builder/ColdUserWalletBuilder.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/builder/ColdUserWalletBuilder.kt index 9c9e04a643..1f827df1d4 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/builder/ColdUserWalletBuilder.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/builder/ColdUserWalletBuilder.kt @@ -1,6 +1,7 @@ package com.tangem.domain.wallets.builder import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase @@ -11,6 +12,7 @@ import dagger.assisted.AssistedInject class ColdUserWalletBuilder @AssistedInject constructor( @Assisted private val scanResponse: ScanResponse, private val generateWalletNameUseCase: GenerateWalletNameUseCase, + private val cardRepository: CardRepository, ) { private var backupCardsIds: Set = emptySet() private var hasBackupError: Boolean = false @@ -32,25 +34,22 @@ class ColdUserWalletBuilder @AssistedInject constructor( this.hasBackupError = hasBackupError } - fun build(): UserWallet.Cold? { - return with(scanResponse) { - UserWalletIdBuilder.scanResponse(scanResponse) - .build() - ?.let { - UserWallet.Cold( - walletId = it, - name = generateWalletNameUseCase( - card = card, - productType = productType, - isStartToCoin = cardTypesResolver.isStart2Coin(), - ), - cardsInWallet = backupCardsIds.plus(card.cardId), - scanResponse = this, - isMultiCurrency = cardTypesResolver.isMultiwalletAllowed(), - hasBackupError = hasBackupError, - ) - } - } + suspend fun build(): UserWallet.Cold? { + val walletId = UserWalletIdBuilder.scanResponse(scanResponse).build() + ?: return null + + return UserWallet.Cold( + walletId = walletId, + name = generateWalletNameUseCase( + card = scanResponse.card, + productType = scanResponse.productType, + isStartToCoin = scanResponse.cardTypesResolver.isStart2Coin(), + ), + cardsInWallet = backupCardsIds.plus(scanResponse.card.cardId), + scanResponse = scanResponse, + isMultiCurrency = scanResponse.cardTypesResolver.isMultiwalletAllowed(), + hasBackupError = hasBackupError || cardRepository.hasBackupError(scanResponse.card.cardId), + ) } @AssistedFactory diff --git a/domain/wallets/src/test/java/com/tangem/domain/wallets/builder/ColdUserWalletBuilderTest.kt b/domain/wallets/src/test/java/com/tangem/domain/wallets/builder/ColdUserWalletBuilderTest.kt new file mode 100644 index 0000000000..1e5c7c4146 --- /dev/null +++ b/domain/wallets/src/test/java/com/tangem/domain/wallets/builder/ColdUserWalletBuilderTest.kt @@ -0,0 +1,174 @@ +package com.tangem.domain.wallets.builder + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.domain.card.MockScanResponseFactory +import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.card.configs.MultiWalletCardConfig +import com.tangem.domain.card.repository.CardRepository +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase +import com.tangem.test.core.ProvideTestModels +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import kotlinx.coroutines.test.runTest + +/** + * Tests for [ColdUserWalletBuilder]. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class ColdUserWalletBuilderTest { + + private val generateWalletNameUseCase: GenerateWalletNameUseCase = mockk() + private val cardRepository: CardRepository = mockk() + + private val scanResponse = MockScanResponseFactory.create( + cardConfig = MultiWalletCardConfig, + derivedKeys = emptyMap(), + ) + + private val primaryCardId = scanResponse.card.cardId + private val expectedWalletId = UserWalletIdBuilder.scanResponse(scanResponse).build() + + @BeforeEach + fun setup() { + clearMocks(generateWalletNameUseCase, cardRepository) + every { generateWalletNameUseCase(any(), any(), any()) } returns WALLET_NAME + coEvery { cardRepository.hasBackupError(any()) } returns false + } + + @Test + fun `GIVEN valid scan response WHEN build THEN returns cold wallet with expected fields`() = runTest { + // Act + val result = createBuilder().build() + + // Assert + assertThat(result).isEqualTo( + UserWallet.Cold( + name = WALLET_NAME, + walletId = requireNotNull(expectedWalletId), + cardsInWallet = setOf(primaryCardId), + isMultiCurrency = scanResponse.cardTypesResolver.isMultiwalletAllowed(), + hasBackupError = false, + scanResponse = scanResponse, + ), + ) + } + + @Test + fun `GIVEN backup card ids WHEN build THEN cards in wallet include primary and backup`() = runTest { + // Act + val result = createBuilder() + .backupCardsIds(setOf("backup-1", "backup-2")) + .build() + + // Assert + assertThat(result?.cardsInWallet).containsExactly(primaryCardId, "backup-1", "backup-2") + } + + @Test + fun `GIVEN null backup card ids WHEN build THEN cards in wallet contain only primary`() = runTest { + // Act + val result = createBuilder() + .backupCardsIds(null) + .build() + + // Assert + assertThat(result?.cardsInWallet).containsExactly(primaryCardId) + } + + @Test + fun `GIVEN scan response without wallets WHEN build THEN returns null`() = runTest { + // Arrange + val scanResponseWithoutWallets = scanResponse.copy( + card = scanResponse.card.copy(wallets = emptyList()), + ) + + // Act + val result = createBuilder(scanResponseWithoutWallets).build() + + // Assert + assertThat(result).isNull() + } + + @Test + fun `GIVEN scan response WHEN build THEN wallet name generated from resolver data`() = runTest { + // Act + createBuilder().build() + + // Assert + verify(exactly = 1) { + generateWalletNameUseCase( + scanResponse.productType, + scanResponse.card, + scanResponse.cardTypesResolver.isStart2Coin(), + ) + } + } + + @ParameterizedTest + @ProvideTestModels + fun hasBackupError(model: HasBackupErrorModel) = runTest { + // Arrange + coEvery { cardRepository.hasBackupError(primaryCardId) } returns model.repositoryReturns + + // Act + val result = createBuilder() + .hasBackupError(model.builderFlag) + .build() + + // Assert + assertThat(result?.hasBackupError).isEqualTo(model.expected) + // The externally set flag short-circuits `||`: the repository is queried only when the flag is not set. + coVerify(exactly = model.expectedRepositoryCalls) { cardRepository.hasBackupError(primaryCardId) } + } + + private fun createBuilder(scanResponse: ScanResponse = this.scanResponse) = ColdUserWalletBuilder( + scanResponse = scanResponse, + generateWalletNameUseCase = generateWalletNameUseCase, + cardRepository = cardRepository, + ) + + internal data class HasBackupErrorModel( + val builderFlag: Boolean, + val repositoryReturns: Boolean, + val expected: Boolean, + val expectedRepositoryCalls: Int, + ) + + private fun provideTestModels() = listOf( + // Flag not set externally -> repository is queried and is the source of truth. + HasBackupErrorModel( + builderFlag = false, + repositoryReturns = false, + expected = false, + expectedRepositoryCalls = 1, + ), + HasBackupErrorModel( + builderFlag = false, + repositoryReturns = true, + expected = true, + expectedRepositoryCalls = 1, + ), + // Flag set externally -> `||` short-circuits, repository is NOT queried regardless of its value. + HasBackupErrorModel( + builderFlag = true, + repositoryReturns = false, + expected = true, + expectedRepositoryCalls = 0, + ), + HasBackupErrorModel(builderFlag = true, repositoryReturns = true, expected = true, expectedRepositoryCalls = 0), + ) + + private companion object { + const val WALLET_NAME = "Wallet" + } +} \ No newline at end of file diff --git a/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt b/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt index 0673e8d9f2..e2358cc73f 100644 --- a/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt +++ b/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt @@ -79,7 +79,7 @@ internal class CreateWalletStartModelTest { coEvery { appsFlyerStore.get() } returns null coEvery { settingsRepository.shouldSaveAccessCodes() } returns false every { coldUserWalletBuilderFactory.create(any()) } returns coldUserWalletBuilder - every { coldUserWalletBuilder.build() } returns testColdWallet + coEvery { coldUserWalletBuilder.build() } returns testColdWallet every { onboardingV2FeatureToggles.isAddressSyncEnabled } returns false coEvery { scanCardProcessor.scan( @@ -356,7 +356,7 @@ internal class CreateWalletStartModelTest { @Test fun `GIVEN builder returns null WHEN proceedWithScanResponse THEN saveWalletUseCase not called`() = runTest { - every { coldUserWalletBuilder.build() } returns null + coEvery { coldUserWalletBuilder.build() } returns null coEvery { scanCardProcessor.scan( analyticsSource = any(), diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletSaver.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletSaver.kt index b72e34a848..0b612c9e33 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletSaver.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletSaver.kt @@ -119,7 +119,7 @@ internal class UserWalletSaver @Inject constructor( ) } - private fun Raise.createUserWallet(response: ScanResponse): UserWallet { + private suspend fun Raise.createUserWallet(response: ScanResponse): UserWallet { val userWallet = coldUserWalletBuilderFactory.create(scanResponse = response).build() return ensureNotNull(userWallet) { Error.Unknown } 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 index 614ff92ea4..4c5e001144 100644 --- 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 @@ -292,7 +292,7 @@ internal class UserWalletSaverTest { private fun mockBuilderReturns(userWallet: UserWallet.Cold?) { val builder: ColdUserWalletBuilder = mockk { - every { build() } returns userWallet + coEvery { build() } returns userWallet } every { coldUserWalletBuilderFactory.create(scanResponse = any()) } returns builder } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/chooseoption/model/Wallet1ChooseOptionModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/chooseoption/model/Wallet1ChooseOptionModel.kt index 32c406b3b1..3d4097b33b 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/chooseoption/model/Wallet1ChooseOptionModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/chooseoption/model/Wallet1ChooseOptionModel.kt @@ -68,7 +68,7 @@ internal class Wallet1ChooseOptionModel @Inject constructor( } } - private fun createUserWallet(scanResponse: ScanResponse): UserWallet.Cold { + private suspend fun createUserWallet(scanResponse: ScanResponse): UserWallet.Cold { return requireNotNull( value = coldUserWalletBuilderFactory.create(scanResponse = scanResponse).build(), lazyMessage = { "User wallet not created" }, diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt index b6431a21af..8876926bd2 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt @@ -334,13 +334,16 @@ internal class MultiWalletFinalizeModel @Inject constructor( // to prevent showing finalize screen dialog on next app start onboardingRepository.clearUnfinishedFinalizeOnboarding() - cardRepository.finishCardActivation(scanResponse.card.cardId) + cardRepository.finishCardActivation( + cardId = scanResponse.card.cardId, + hasBackupError = hasWalletBackupError, + ) backupServiceHolder.backupService.get()?.discardSavedBackup() onEvent.emit(MultiWalletFinalizeComponent.Event.ThreeBackupCardsAdded) } } - private fun createUserWallet(scanResponse: ScanResponse): UserWallet.Cold { + private suspend fun createUserWallet(scanResponse: ScanResponse): UserWallet.Cold { return requireNotNull( value = coldUserWalletBuilderFactory.create(scanResponse = scanResponse) .backupCardsIds(backupCardIds.toSet()) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MultiWalletSeedPhraseModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MultiWalletSeedPhraseModel.kt index 646ff7ad23..0f86734bc1 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MultiWalletSeedPhraseModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MultiWalletSeedPhraseModel.kt @@ -299,7 +299,7 @@ internal class MultiWalletSeedPhraseModel @Inject constructor( } } - private fun createUserWallet(scanResponse: ScanResponse): UserWallet.Cold { + private suspend fun createUserWallet(scanResponse: ScanResponse): UserWallet.Cold { return requireNotNull( value = coldUserWalletBuilderFactory.create(scanResponse = scanResponse).build(), lazyMessage = { "User wallet not created" }, diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/model/MultiWalletUpgradeWalletModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/model/MultiWalletUpgradeWalletModel.kt index c5a2989d7c..ce97098c8b 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/model/MultiWalletUpgradeWalletModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/model/MultiWalletUpgradeWalletModel.kt @@ -155,7 +155,7 @@ internal class MultiWalletUpgradeWalletModel @Inject constructor( } } - private fun createUserWallet(scanResponse: ScanResponse): UserWallet.Cold { + private suspend fun createUserWallet(scanResponse: ScanResponse): UserWallet.Cold { return requireNotNull( value = coldUserWalletBuilderFactory.create(scanResponse = scanResponse).build(), lazyMessage = { "User wallet not created" },