Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-05 15:12:57 +01:00
parent 4e05219c25
commit fd2fcc4735
22 changed files with 1114 additions and 4 deletions

View file

@ -19,6 +19,7 @@ dependencies {
implementation(projects.features.tester.api)
implementation(projects.features.createWalletSelection.api)
implementation(projects.features.onboardingV2.api)
implementation(projects.features.addressBook.api)
/* Project - Core */
implementation(projects.core.decompose)

View file

@ -29,6 +29,7 @@ internal class PreviewDetailsComponent : DetailsComponent {
},
).buildAll(
isWalletConnectAvailable = true,
isAddressBookAvailable = true,
isSupportChatAvailable = true,
hasAnyMobileWallet = true,
userWalletId = UserWalletId(""),

View file

@ -25,6 +25,15 @@ internal sealed class DetailsItemUM {
override val id: String = "wallet_connect"
}
data class WalletConnectAddressBookBlock(val items: List<Item>) : DetailsItemUM() {
override val id: String = "wallet_connect_address_book"
sealed class Item(open val onClick: () -> Unit) {
data class WalletConnect(override val onClick: () -> Unit) : Item(onClick)
data class AddressBook(override val onClick: () -> Unit) : Item(onClick)
}
}
data object UserWalletList : DetailsItemUM() {
override val id: String = "user_wallet_list"
}

View file

@ -26,6 +26,7 @@ import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.addressbook.AddressBookFeatureToggles
import com.tangem.features.details.component.DetailsComponent
import com.tangem.features.details.entity.DetailsFooterUM
import com.tangem.features.details.entity.DetailsItemUM
@ -51,6 +52,7 @@ internal class DetailsModel @Inject constructor(
socialsBuilder: SocialsBuilder,
paramsContainer: ParamsContainer,
feedbackFeatureToggles: FeedbackFeatureToggles,
addressBookFeatureToggles: AddressBookFeatureToggles,
private val itemsBuilder: ItemsBuilder,
private val appInfoProvider: AppInfoProvider,
private val checkIsWalletConnectAvailableUseCase: CheckIsWalletConnectAvailableUseCase,
@ -86,6 +88,7 @@ internal class DetailsModel @Inject constructor(
items = MutableStateFlow(
itemsBuilder.buildAll(
isWalletConnectAvailable = isWalletConnectAvailable,
isAddressBookAvailable = addressBookFeatureToggles.isAddressBookEnabled,
isSupportChatAvailable = feedbackFeatureToggles.isUsedeskEnabled,
hasAnyMobileWallet = getWalletsUseCase.invokeSync().any { it is UserWallet.Hot },
userWalletId = params.userWalletId,

View file

@ -2,10 +2,10 @@ package com.tangem.features.details.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
@ -13,17 +13,21 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.components.block.BlockItem
import com.tangem.core.ui.components.inputrow.InputRowImageBase
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
@ -155,10 +159,44 @@ private fun Block(
onClick = model.onClick,
)
}
is DetailsItemUM.WalletConnectAddressBookBlock -> {
BlockCard {
WalletConnectAddressBookBlockItems(
items = model.items,
modifier = itemModifier.padding(12.dp),
)
}
}
is DetailsItemUM.UserWalletList -> {
userWalletListBlockContent.Content(modifier = itemModifier)
}
is DetailsItemUM.UnderSectionText -> { /* Handled above */ }
is DetailsItemUM.UnderSectionText -> { /* Handled above */
}
}
}
}
@Composable
private fun WalletConnectAddressBookBlockItems(
items: List<DetailsItemUM.WalletConnectAddressBookBlock.Item>,
modifier: Modifier = Modifier,
) {
items.fastForEach { item ->
when (item) {
is DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect -> InputRowImageBase(
modifier = modifier.clickable(onClick = item.onClick),
iconResVector = R.drawable.ic_wallet_connect_24,
iconTint = TangemTheme.colors.icon.primary1,
subtitle = TextReference.Res(R.string.wallet_connect_title),
caption = TextReference.Res(R.string.wallet_connect_subtitle),
)
is DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook -> InputRowImageBase(
modifier = modifier.clickable(onClick = item.onClick),
iconResVector = R.drawable.ic_contact_20,
iconTint = TangemTheme.colors.icon.accent,
subtitle = TextReference.Res(R.string.address_book_title),
caption = TextReference.Res(R.string.address_book_description),
)
}
}
}

View file

@ -26,6 +26,7 @@ internal class ItemsBuilder @Inject constructor(
@Suppress("LongParameterList")
fun buildAll(
isWalletConnectAvailable: Boolean,
isAddressBookAvailable: Boolean,
isSupportChatAvailable: Boolean,
hasAnyMobileWallet: Boolean,
userWalletId: UserWalletId,
@ -33,7 +34,11 @@ internal class ItemsBuilder @Inject constructor(
onSupportChatClick: () -> Unit,
onBuyClick: () -> Unit,
): ImmutableList<DetailsItemUM> = buildList {
buildWalletConnectBlock(isWalletConnectAvailable, userWalletId)?.let(::add)
if (isAddressBookAvailable) {
buildWalletConnectAddressBookBlock(isWalletConnectAvailable, userWalletId)
} else {
buildWalletConnectBlock(isWalletConnectAvailable, userWalletId)?.let(::add)
}
buildUserWalletListBlock().let(::add)
if (hotWalletRestrictionManager.isCreationEnabledSync() && hasAnyMobileWallet) {
@ -86,6 +91,33 @@ internal class ItemsBuilder @Inject constructor(
}
}
private fun MutableList<DetailsItemUM>.buildWalletConnectAddressBookBlock(
isWalletConnectAvailable: Boolean,
userWalletId: UserWalletId,
) {
val walletConnectAddressBookItems = buildList {
if (isWalletConnectAvailable) add(buildWalletConnectButton(userWalletId))
add(buildAddressBookButton())
}
if (walletConnectAddressBookItems.isNotEmpty()) {
add(DetailsItemUM.WalletConnectAddressBookBlock(walletConnectAddressBookItems))
}
}
private fun buildWalletConnectButton(
userWalletId: UserWalletId,
): DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect {
return DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect(
onClick = { router.push(AppRoute.WalletConnectSessions(userWalletId)) },
)
}
private fun buildAddressBookButton(): DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook {
return DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook(
onClick = { },
)
}
private fun buildUserWalletListBlock(): DetailsItemUM = DetailsItemUM.UserWalletList
private fun buildShopBlock(onBuyClick: () -> Unit): DetailsItemUM = DetailsItemUM.Basic(

View file

@ -0,0 +1,191 @@
package com.tangem.features.details.model
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.core.analytics.models.Basic
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.feedback.models.WalletMetaInfo
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.features.details.entity.SelectEmailFeedbackTypeBS
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class DetailsModelFeedbackTest : DetailsModelTestBase() {
@Test
fun `GIVEN all wallets hot WHEN support email clicked THEN DirectUserRequest sent`() = runTest {
// Arrange
val wallet = hotWallet(wallet1)
val meta = metaInfo(wallet1, isVisa = false)
every { getWalletsUseCase.invokeSync() } returns listOf(wallet)
every { getSelectedWalletSyncUseCase() } returns wallet.right()
coEvery { getWalletMetaInfoUseCase(wallet1) } returns meta.right()
every { getTangemPayCustomerIdUseCase(wallet1) } returns "".right()
// Act
val model = createModel(this)
advanceUntilIdle()
onEmailSlot.captured.invoke()
advanceUntilIdle()
// Assert
verify { analyticsEventHandler.send(any<Basic.ButtonSupport>()) }
coVerify { sendFeedbackEmailUseCase(FeedbackEmailType.DirectUserRequest(meta)) }
model.onDestroy()
}
@Test
fun `GIVEN all wallets cold visa with customerId WHEN support email clicked THEN Visa request sent`() = runTest {
val wallet = coldWallet(wallet1, isVisa = true)
val meta = metaInfo(wallet1, isVisa = true)
every { getWalletsUseCase.invokeSync() } returns listOf(wallet)
every { getSelectedWalletSyncUseCase() } returns wallet.right()
coEvery { getWalletMetaInfoUseCase(wallet1) } returns meta.right()
every { getTangemPayCustomerIdUseCase(wallet1) } returns customerId.right()
val model = createModel(this)
advanceUntilIdle()
onEmailSlot.captured.invoke()
advanceUntilIdle()
verify { analyticsEventHandler.send(any<Basic.ButtonSupport>()) }
coVerify { sendFeedbackEmailUseCase(FeedbackEmailType.Visa.DirectUserRequest(meta, customerId)) }
model.onDestroy()
}
@Test
fun `GIVEN mixed wallets WHEN support email clicked THEN bottom sheet shown and no email sent`() = runTest {
val selected = hotWallet(wallet1)
val meta = metaInfo(wallet1, isVisa = false)
every { getWalletsUseCase.invokeSync() } returns listOf(selected, coldWallet(wallet2, isVisa = true))
every { getSelectedWalletSyncUseCase() } returns selected.right()
coEvery { getWalletMetaInfoUseCase(wallet1) } returns meta.right()
every { getTangemPayCustomerIdUseCase(wallet1) } returns customerId.right()
val model = createModel(this)
advanceUntilIdle()
onEmailSlot.captured.invoke()
advanceUntilIdle()
val bsConfig = model.state.value.selectFeedbackEmailTypeBSConfig
assertThat(bsConfig.isShown).isTrue()
assertThat(bsConfig.content).isInstanceOf(SelectEmailFeedbackTypeBS::class.java)
coVerify(exactly = 0) { sendFeedbackEmailUseCase(any()) }
model.onDestroy()
}
@Test
fun `GIVEN meta info missing WHEN support email clicked THEN no email sent`() = runTest {
val wallet = hotWallet(wallet1)
every { getWalletsUseCase.invokeSync() } returns listOf(wallet)
every { getSelectedWalletSyncUseCase() } returns wallet.right()
coEvery { getWalletMetaInfoUseCase(wallet1) } returns Throwable().left()
every { getTangemPayCustomerIdUseCase(wallet1) } returns "".right()
val model = createModel(this)
advanceUntilIdle()
onEmailSlot.captured.invoke()
advanceUntilIdle()
coVerify(exactly = 0) { sendFeedbackEmailUseCase(any()) }
model.onDestroy()
}
@Test
fun `GIVEN General option AND selected meta not visa WHEN selected THEN DirectUserRequest with selected meta`() =
runTest {
val selectedMeta = metaInfo(wallet1, isVisa = false)
val content = openBottomSheet(selectedMeta = selectedMeta)
content.onOptionClick(SelectEmailFeedbackTypeBS.Option.General)
advanceUntilIdle()
coVerify { sendFeedbackEmailUseCase(FeedbackEmailType.DirectUserRequest(selectedMeta)) }
assertThat(currentModel.state.value.selectFeedbackEmailTypeBSConfig.isShown).isFalse()
verify { analyticsEventHandler.send(any<Basic.ButtonSupport>()) }
currentModel.onDestroy()
}
@Test
fun `GIVEN General option AND selected meta visa WHEN selected THEN picks non-visa wallet meta`() = runTest {
val selectedMeta = metaInfo(wallet1, isVisa = true)
val nonVisaMeta = metaInfo(wallet2, isVisa = false)
val content = openBottomSheet(
selectedMeta = selectedMeta,
wallets = listOf(coldWallet(wallet1, isVisa = true), hotWallet(wallet2)),
)
coEvery { getWalletMetaInfoUseCase(wallet2) } returns nonVisaMeta.right()
content.onOptionClick(SelectEmailFeedbackTypeBS.Option.General)
advanceUntilIdle()
coVerify { sendFeedbackEmailUseCase(FeedbackEmailType.DirectUserRequest(nonVisaMeta)) }
currentModel.onDestroy()
}
@Test
fun `GIVEN Visa option AND selected meta visa with customerId WHEN selected THEN Visa request with selected`() =
runTest {
val selectedMeta = metaInfo(wallet1, isVisa = true)
val content = openBottomSheet(selectedMeta = selectedMeta, customerId = customerId)
content.onOptionClick(SelectEmailFeedbackTypeBS.Option.Visa)
advanceUntilIdle()
coVerify {
sendFeedbackEmailUseCase(FeedbackEmailType.Visa.DirectUserRequest(selectedMeta, customerId))
}
currentModel.onDestroy()
}
@Test
fun `GIVEN Visa option AND selected meta not visa WHEN selected THEN picks cold visa wallet meta`() = runTest {
val selectedMeta = metaInfo(wallet1, isVisa = false)
val visaMeta = metaInfo(wallet2, isVisa = true)
val content = openBottomSheet(
selectedMeta = selectedMeta,
wallets = listOf(hotWallet(wallet1), coldWallet(wallet2, isVisa = true)),
)
coEvery { getWalletMetaInfoUseCase(wallet2) } returns visaMeta.right()
every { getTangemPayCustomerIdUseCase(wallet2) } returns customerId.right()
content.onOptionClick(SelectEmailFeedbackTypeBS.Option.Visa)
advanceUntilIdle()
coVerify { sendFeedbackEmailUseCase(FeedbackEmailType.Visa.DirectUserRequest(visaMeta, customerId)) }
currentModel.onDestroy()
}
private lateinit var currentModel: DetailsModel
/**
* Drives the model into the "mixed wallets" state so the bottom sheet is shown, then returns its content.
* [selectedMeta] is what [getWalletMetaInfoUseCase] returns for the selected wallet ([wallet1]).
*/
private fun TestScope.openBottomSheet(
selectedMeta: WalletMetaInfo,
wallets: List<UserWallet> = listOf(hotWallet(wallet1), coldWallet(wallet2, isVisa = true)),
customerId: String = "",
): SelectEmailFeedbackTypeBS {
every { getWalletsUseCase.invokeSync() } returns wallets
every { getSelectedWalletSyncUseCase() } returns wallets.first().right()
coEvery { getWalletMetaInfoUseCase(wallet1) } returns selectedMeta.right()
every { getTangemPayCustomerIdUseCase(wallet1) } returns customerId.right()
currentModel = createModel(this)
advanceUntilIdle()
onEmailSlot.captured.invoke()
advanceUntilIdle()
return currentModel.state.value.selectFeedbackEmailTypeBSConfig.content as SelectEmailFeedbackTypeBS
}
}

View file

@ -0,0 +1,172 @@
package com.tangem.features.details.model
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.features.details.entity.DetailsFooterUM
import com.tangem.features.details.entity.DetailsItemUM
import io.mockk.coEvery
import io.mockk.every
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class DetailsModelInitTest : DetailsModelTestBase() {
@Test
fun `GIVEN walletConnect available WHEN init THEN buildAll receives isWalletConnectAvailable true`() = runTest {
// Arrange
coEvery { checkIsWalletConnectAvailableUseCase(userWalletId) } returns true.right()
// Act
createModel(this).also { advanceUntilIdle() }.onDestroy()
// Assert
assertThat(wcSlot.captured).isTrue()
}
@Test
fun `GIVEN walletConnect unavailable WHEN init THEN buildAll receives isWalletConnectAvailable false`() = runTest {
coEvery { checkIsWalletConnectAvailableUseCase(userWalletId) } returns false.right()
createModel(this).also { advanceUntilIdle() }.onDestroy()
assertThat(wcSlot.captured).isFalse()
}
@Test
fun `GIVEN walletConnect check fails WHEN init THEN isWalletConnectAvailable falls back to false`() = runTest {
coEvery { checkIsWalletConnectAvailableUseCase(userWalletId) } returns Throwable("boom").left()
createModel(this).also { advanceUntilIdle() }.onDestroy()
assertThat(wcSlot.captured).isFalse()
}
@Test
fun `GIVEN addressBook enabled WHEN init THEN buildAll receives isAddressBookAvailable true`() = runTest {
every { addressBookFeatureToggles.isAddressBookEnabled } returns true
createModel(this).also { advanceUntilIdle() }.onDestroy()
assertThat(abSlot.captured).isTrue()
}
@Test
fun `GIVEN addressBook disabled WHEN init THEN buildAll receives isAddressBookAvailable false`() = runTest {
every { addressBookFeatureToggles.isAddressBookEnabled } returns false
createModel(this).also { advanceUntilIdle() }.onDestroy()
assertThat(abSlot.captured).isFalse()
}
@Test
fun `GIVEN usedesk enabled WHEN init THEN buildAll receives isSupportChatAvailable true`() = runTest {
every { feedbackFeatureToggles.isUsedeskEnabled } returns true
createModel(this).also { advanceUntilIdle() }.onDestroy()
assertThat(chatSlot.captured).isTrue()
}
@Test
fun `GIVEN usedesk disabled WHEN init THEN buildAll receives isSupportChatAvailable false`() = runTest {
every { feedbackFeatureToggles.isUsedeskEnabled } returns false
createModel(this).also { advanceUntilIdle() }.onDestroy()
assertThat(chatSlot.captured).isFalse()
}
@Test
fun `GIVEN a hot wallet present WHEN init THEN buildAll receives hasAnyMobileWallet true`() = runTest {
every { getWalletsUseCase.invokeSync() } returns listOf(hotWallet(wallet1))
createModel(this).also { advanceUntilIdle() }.onDestroy()
assertThat(mobileSlot.captured).isTrue()
}
@Test
fun `GIVEN only cold wallets WHEN init THEN buildAll receives hasAnyMobileWallet false`() = runTest {
every { getWalletsUseCase.invokeSync() } returns listOf(coldWallet(wallet1, isVisa = false))
createModel(this).also { advanceUntilIdle() }.onDestroy()
assertThat(mobileSlot.captured).isFalse()
}
@Test
fun `GIVEN params userWalletId WHEN init THEN buildAll receives it`() = runTest {
createModel(this).also { advanceUntilIdle() }.onDestroy()
assertThat(walletIdSlot.captured).isEqualTo(userWalletId)
}
@Test
fun `GIVEN app info WHEN init THEN footer appVersion combines version and code`() = runTest {
val model = createModel(this)
advanceUntilIdle()
assertThat(model.state.value.footer.appVersion).isEqualTo("1.2.3 (456)")
model.onDestroy()
}
@Test
fun `GIVEN socials WHEN init THEN footer socials come from SocialsBuilder`() = runTest {
val socials = persistentListOf(DetailsFooterUM.Social(id = "tw", iconResId = 0, onClick = {}))
every { socialsBuilder.buildAll() } returns socials
val model = createModel(this)
advanceUntilIdle()
assertThat(model.state.value.footer.socials).isEqualTo(socials)
model.onDestroy()
}
@Test
fun `GIVEN popBack WHEN invoked THEN router pop is called`() = runTest {
val model = createModel(this)
advanceUntilIdle()
model.state.value.popBack()
verify { router.pop(onComplete = any()) }
model.onDestroy()
}
@Test
fun `GIVEN buildAll result WHEN not eligible for tangem pay THEN state items equal buildAll result`() = runTest {
val items = persistentListOf<DetailsItemUM>(DetailsItemUM.UserWalletList)
stubBuildAllReturns(items)
coEvery { tangemPayEligibilityManager.getEligibleWallets(any(), any()) } returns emptyList()
val model = createModel(this)
advanceUntilIdle()
assertThat(model.state.value.items).isEqualTo(items)
model.onDestroy()
}
@Test
fun `GIVEN items flow updates WHEN tangem pay item added THEN new items propagate to state`() = runTest {
val initial = persistentListOf<DetailsItemUM>()
val withTangemPay = persistentListOf<DetailsItemUM>(DetailsItemUM.UserWalletList)
stubBuildAllReturns(initial)
every { itemsBuilder.addTangemPayItem(any(), any()) } returns withTangemPay
coEvery { tangemPayEligibilityManager.getEligibleWallets(any(), any()) } returns listOf(hotWallet(wallet1))
val model = createModel(this)
advanceUntilIdle()
assertThat(model.state.value.items).isEqualTo(withTangemPay)
model.onDestroy()
}
}

View file

@ -0,0 +1,68 @@
package com.tangem.features.details.model
import arrow.core.left
import arrow.core.right
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.models.Basic
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import io.mockk.coEvery
import io.mockk.every
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class DetailsModelNavigationTest : DetailsModelTestBase() {
@Test
fun `GIVEN selected wallet and meta WHEN support chat clicked THEN router pushes Usedesk`() = runTest {
// Arrange
val wallet = hotWallet(wallet1)
val meta = metaInfo(wallet1)
every { getSelectedWalletSyncUseCase() } returns wallet.right()
coEvery { getWalletMetaInfoUseCase(wallet1) } returns meta.right()
// Act
val model = createModel(this)
advanceUntilIdle()
onChatSlot.captured.invoke()
advanceUntilIdle()
// Assert
verify { router.push(route = AppRoute.Usedesk(meta), onComplete = any()) }
model.onDestroy()
}
@Test
fun `GIVEN meta info missing WHEN support chat clicked THEN no navigation`() = runTest {
val wallet = hotWallet(wallet1)
every { getSelectedWalletSyncUseCase() } returns wallet.right()
coEvery { getWalletMetaInfoUseCase(wallet1) } returns Throwable().left()
val model = createModel(this)
advanceUntilIdle()
onChatSlot.captured.invoke()
advanceUntilIdle()
verify(exactly = 0) { router.push(route = any(), onComplete = any()) }
model.onDestroy()
}
@Test
fun `GIVEN buy link WHEN buy clicked THEN opens url and sends analytics`() = runTest {
coEvery {
generateBuyTangemCardLinkUseCase(GenerateBuyTangemCardLinkUseCase.Source.Settings)
} returns buyUrl
val model = createModel(this)
advanceUntilIdle()
onBuySlot.captured.invoke()
advanceUntilIdle()
verify { analyticsEventHandler.send(any<Basic.ButtonBuy>()) }
verify { urlOpener.openUrl(buyUrl) }
model.onDestroy()
}
}

View file

@ -0,0 +1,82 @@
package com.tangem.features.details.model
import com.tangem.common.routing.AppRoute
import com.tangem.domain.pay.model.TangemPayEntryPoint
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import io.mockk.coEvery
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class DetailsModelTangemPayTest : DetailsModelTestBase() {
@Test
fun `GIVEN eligible wallets WHEN init THEN tangem pay item added and analytics sent`() = runTest {
// Arrange
coEvery {
tangemPayEligibilityManager.getEligibleWallets(
shouldExcludePaeraCustomers = true,
entryPoint = TangemPayEntryPoint.DETAILS,
)
} returns listOf(hotWallet(wallet1))
// Act
val model = createModel(this)
advanceUntilIdle()
// Assert
verify { analyticsEventHandler.send(any<TangemPayAnalyticsEvents.PermanentButtonShowed>()) }
verify { itemsBuilder.addTangemPayItem(any(), any()) }
model.onDestroy()
}
@Test
fun `GIVEN no eligible wallets WHEN init THEN tangem pay item not added`() = runTest {
coEvery { tangemPayEligibilityManager.getEligibleWallets(any(), any()) } returns emptyList()
val model = createModel(this)
advanceUntilIdle()
verify(exactly = 0) { itemsBuilder.addTangemPayItem(any(), any()) }
verify(exactly = 0) { analyticsEventHandler.send(any<TangemPayAnalyticsEvents.PermanentButtonShowed>()) }
model.onDestroy()
}
@Test
fun `GIVEN tangem pay available WHEN item clicked THEN navigates to onboarding`() = runTest {
coEvery { tangemPayEligibilityManager.getEligibleWallets(any(), any()) } returns listOf(hotWallet(wallet1))
coEvery { tangemPayEligibilityManager.getTangemPayAvailability(TangemPayEntryPoint.DETAILS) } returns true
val model = createModel(this)
advanceUntilIdle()
onTangemPaySlot.captured.invoke()
advanceUntilIdle()
verify { analyticsEventHandler.send(any<TangemPayAnalyticsEvents.DetailsVisaPermanentButtonClicked>()) }
verify {
router.push(
route = AppRoute.TangemPayOnboarding(AppRoute.TangemPayOnboarding.Mode.FromBannerInSettings),
onComplete = any(),
)
}
model.onDestroy()
}
@Test
fun `GIVEN tangem pay unavailable WHEN item clicked THEN item removed and no navigation`() = runTest {
coEvery { tangemPayEligibilityManager.getEligibleWallets(any(), any()) } returns listOf(hotWallet(wallet1))
coEvery { tangemPayEligibilityManager.getTangemPayAvailability(TangemPayEntryPoint.DETAILS) } returns false
val model = createModel(this)
advanceUntilIdle()
onTangemPaySlot.captured.invoke()
advanceUntilIdle()
verify { itemsBuilder.removeTangemPayItem(any()) }
verify(exactly = 0) { router.push(route = any(), onComplete = any()) }
model.onDestroy()
}
}

View file

@ -0,0 +1,170 @@
package com.tangem.features.details.model
import arrow.core.right
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.card.common.visa.VisaUtilities
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.WalletMetaInfo
import com.tangem.domain.feedback.repository.FeedbackFeatureToggles
import com.tangem.domain.models.scan.CardDTO
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.pay.TangemPayEligibilityManager
import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.addressbook.AddressBookFeatureToggles
import com.tangem.features.details.component.DetailsComponent
import com.tangem.features.details.entity.DetailsItemUM
import com.tangem.features.details.utils.ItemsBuilder
import com.tangem.features.details.utils.SocialsBuilder
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import com.tangem.utils.info.AppInfoProvider
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.slot
import io.mockk.unmockkObject
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
@OptIn(ExperimentalCoroutinesApi::class)
internal abstract class DetailsModelTestBase {
protected val userWalletId = UserWalletId("011")
protected val wallet1 = UserWalletId("01")
protected val wallet2 = UserWalletId("02")
protected val customerId = "customer-1"
protected val buyUrl = "https://tangem.com/buy"
protected val socialsBuilder: SocialsBuilder = mockk()
protected val feedbackFeatureToggles: FeedbackFeatureToggles = mockk()
protected val addressBookFeatureToggles: AddressBookFeatureToggles = mockk()
protected val itemsBuilder: ItemsBuilder = mockk()
protected val appInfoProvider: AppInfoProvider = mockk()
protected val checkIsWalletConnectAvailableUseCase: CheckIsWalletConnectAvailableUseCase = mockk()
protected val router: Router = mockk(relaxUnitFun = true)
protected val urlOpener: UrlOpener = mockk(relaxUnitFun = true)
protected val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase = mockk()
protected val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase = mockk()
protected val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase = mockk()
protected val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase = mockk(relaxUnitFun = true)
protected val getWalletsUseCase: GetWalletsUseCase = mockk()
protected val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase = mockk()
protected val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true)
protected val tangemPayEligibilityManager: TangemPayEligibilityManager = mockk()
// Captured from itemsBuilder.buildAll(...) so the feature buttons can be driven.
protected val wcSlot = slot<Boolean>()
protected val abSlot = slot<Boolean>()
protected val chatSlot = slot<Boolean>()
protected val mobileSlot = slot<Boolean>()
protected val walletIdSlot = slot<UserWalletId>()
protected val onEmailSlot = slot<() -> Unit>()
protected val onChatSlot = slot<() -> Unit>()
protected val onBuySlot = slot<() -> Unit>()
protected val onTangemPaySlot = slot<() -> Unit>()
@BeforeEach
fun setUp() {
mockkObject(VisaUtilities)
// Defaults sufficient for model construction (init block).
coEvery { checkIsWalletConnectAvailableUseCase(any()) } returns false.right()
every { addressBookFeatureToggles.isAddressBookEnabled } returns false
every { feedbackFeatureToggles.isUsedeskEnabled } returns false
every { getWalletsUseCase.invokeSync() } returns emptyList()
every { socialsBuilder.buildAll() } returns persistentListOf()
every { appInfoProvider.appVersion } returns "1.2.3"
every { appInfoProvider.appVersionCode } returns 456
coEvery { tangemPayEligibilityManager.getEligibleWallets(any(), any()) } returns emptyList()
every {
itemsBuilder.buildAll(
isWalletConnectAvailable = capture(wcSlot),
isAddressBookAvailable = capture(abSlot),
isSupportChatAvailable = capture(chatSlot),
hasAnyMobileWallet = capture(mobileSlot),
userWalletId = capture(walletIdSlot),
onSupportEmailClick = capture(onEmailSlot),
onSupportChatClick = capture(onChatSlot),
onBuyClick = capture(onBuySlot),
)
} returns persistentListOf()
every { itemsBuilder.addTangemPayItem(any(), capture(onTangemPaySlot)) } answers { firstArg() }
every { itemsBuilder.removeTangemPayItem(any()) } answers { firstArg() }
}
@AfterEach
fun tearDown() {
unmockkObject(VisaUtilities)
}
protected fun createModel(testScope: TestScope): DetailsModel = DetailsModel(
socialsBuilder = socialsBuilder,
paramsContainer = MutableParamsContainer(DetailsComponent.Params(userWalletId)),
feedbackFeatureToggles = feedbackFeatureToggles,
addressBookFeatureToggles = addressBookFeatureToggles,
itemsBuilder = itemsBuilder,
appInfoProvider = appInfoProvider,
checkIsWalletConnectAvailableUseCase = checkIsWalletConnectAvailableUseCase,
router = router,
urlOpener = urlOpener,
getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase,
getWalletMetaInfoUseCase = getWalletMetaInfoUseCase,
getTangemPayCustomerIdUseCase = getTangemPayCustomerIdUseCase,
sendFeedbackEmailUseCase = sendFeedbackEmailUseCase,
getWalletsUseCase = getWalletsUseCase,
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
generateBuyTangemCardLinkUseCase = generateBuyTangemCardLinkUseCase,
analyticsEventHandler = analyticsEventHandler,
tangemPayEligibilityManager = tangemPayEligibilityManager,
)
protected fun stubBuildAllReturns(list: ImmutableList<DetailsItemUM>) {
every {
itemsBuilder.buildAll(any(), any(), any(), any(), any(), any(), any(), any())
} returns list
}
protected fun metaInfo(id: UserWalletId = userWalletId, isVisa: Boolean? = null): WalletMetaInfo =
WalletMetaInfo(userWalletId = id, isVisa = isVisa)
protected fun hotWallet(id: UserWalletId): UserWallet.Hot = mockk {
every { walletId } returns id
}
protected fun coldWallet(id: UserWalletId, isVisa: Boolean): UserWallet.Cold {
val cardMock = mockk<CardDTO>()
every { VisaUtilities.isVisaCard(cardMock) } returns isVisa
val scan = mockk<ScanResponse> { every { card } returns cardMock }
return mockk {
every { walletId } returns id
every { scanResponse } returns scan
}
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
}

View file

@ -0,0 +1,240 @@
package com.tangem.features.details.utils
import com.google.common.truth.Truth.assertThat
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.components.block.model.BlockUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.settings.HotWalletRestrictionManager
import com.tangem.features.details.entity.DetailsItemUM
import com.tangem.features.details.impl.R
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
internal class ItemsBuilderTest {
private val router: Router = mockk(relaxUnitFun = true)
private val hotWalletRestrictionManager: HotWalletRestrictionManager = mockk()
private val itemsBuilder = ItemsBuilder(
router = router,
hotWalletRestrictionManager = hotWalletRestrictionManager,
)
@BeforeEach
fun setUp() {
clearMocks(router, hotWalletRestrictionManager)
every { hotWalletRestrictionManager.isCreationEnabledSync() } returns false
}
@Test
fun `GIVEN walletConnect available AND addressBook unavailable WHEN buildAll THEN standalone WalletConnect block`() {
// Act
val result = buildAll(isWalletConnectAvailable = true, isAddressBookAvailable = false)
// Assert
assertThat(result.map { it.id }).containsExactly(
"wallet_connect",
"user_wallet_list",
"shop",
"settings",
"support",
).inOrder()
assertThat(result.first()).isInstanceOf(DetailsItemUM.WalletConnect::class.java)
}
@Test
fun `GIVEN walletConnect AND addressBook available WHEN buildAll THEN combined block with both items`() {
// Act
val result = buildAll(isWalletConnectAvailable = true, isAddressBookAvailable = true)
// Assert
assertThat(result.map { it.id }).containsExactly(
"wallet_connect_address_book",
"user_wallet_list",
"shop",
"settings",
"support",
).inOrder()
val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock
assertThat(block.items.map { it::class.java }).containsExactly(
DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect::class.java,
DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook::class.java,
).inOrder()
}
@Test
fun `GIVEN walletConnect unavailable AND addressBook available WHEN buildAll THEN combined block with addressBook only`() {
// Act
val result = buildAll(isWalletConnectAvailable = false, isAddressBookAvailable = true)
// Assert
assertThat(result.map { it.id }).containsExactly(
"wallet_connect_address_book",
"user_wallet_list",
"shop",
"settings",
"support",
).inOrder()
val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock
assertThat(block.items.map { it::class.java }).containsExactly(
DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook::class.java,
)
}
@Test
fun `GIVEN walletConnect AND addressBook unavailable WHEN buildAll THEN no walletConnect block`() {
// Act
val result = buildAll(isWalletConnectAvailable = false, isAddressBookAvailable = false)
// Assert
assertThat(result.map { it.id }).containsExactly(
"user_wallet_list",
"shop",
"settings",
"support",
).inOrder()
}
@Test
fun `GIVEN creation enabled AND has mobile wallet WHEN buildAll THEN under section text is added`() {
// Arrange
every { hotWalletRestrictionManager.isCreationEnabledSync() } returns true
// Act
val result = buildAll(hasAnyMobileWallet = true)
// Assert
val underSectionText = result.filterIsInstance<DetailsItemUM.UnderSectionText>().single()
assertThat(underSectionText).isEqualTo(
DetailsItemUM.UnderSectionText(
id = "only_one_mobile_wallet_explanation",
text = resourceReference(R.string.only_one_mobile_wallet_explanation),
),
)
}
@Test
fun `GIVEN creation disabled OR no mobile wallet WHEN buildAll THEN no under section text`() {
// Arrange — creation enabled but no mobile wallet
every { hotWalletRestrictionManager.isCreationEnabledSync() } returns true
// Act
val noMobileWallet = buildAll(hasAnyMobileWallet = false)
// Arrange — has mobile wallet but creation disabled
every { hotWalletRestrictionManager.isCreationEnabledSync() } returns false
val creationDisabled = buildAll(hasAnyMobileWallet = true)
// Assert
assertThat(noMobileWallet.filterIsInstance<DetailsItemUM.UnderSectionText>()).isEmpty()
assertThat(creationDisabled.filterIsInstance<DetailsItemUM.UnderSectionText>()).isEmpty()
}
@Test
fun `GIVEN any flags WHEN buildAll THEN unconditional blocks are always present`() {
// Act — most restrictive combination: nothing optional is added
val result = buildAll(
isWalletConnectAvailable = false,
isAddressBookAvailable = false,
isSupportChatAvailable = false,
hasAnyMobileWallet = false,
)
// Assert
assertThat(result.map { it.id }).containsAtLeast(
"user_wallet_list",
"shop",
"settings",
"support",
)
assertThat(result.filterIsInstance<DetailsItemUM.UserWalletList>()).hasSize(1)
val shop = result.single { it.id == "shop" } as DetailsItemUM.Basic
assertThat(shop.items.map { it.id }).containsExactly("buy_tangem_wallet")
val support = result.single { it.id == "support" } as DetailsItemUM.Basic
assertThat(support.items.map { it.id }).containsExactly("support_email", "disclaimer").inOrder()
}
@Test
fun `GIVEN shop block WHEN addTangemPayItem THEN tangem pay item appended to shop only`() {
// Arrange
val onClick: () -> Unit = {}
val items: ImmutableList<DetailsItemUM> = persistentListOf(
basicBlock(id = "shop", itemIds = listOf("buy_tangem_wallet")),
basicBlock(id = "settings", itemIds = listOf("app_settings")),
)
// Act
val result = itemsBuilder.addTangemPayItem(items = items, onClick = onClick)
// Assert
val shop = result.single { it.id == "shop" } as DetailsItemUM.Basic
assertThat(shop.items.map { it.id }).containsExactly("buy_tangem_wallet", "get_tangem_pay").inOrder()
val settings = result.single { it.id == "settings" } as DetailsItemUM.Basic
assertThat(settings.items.map { it.id }).containsExactly("app_settings")
}
@Test
fun `GIVEN shop block with tangem pay item WHEN removeTangemPayItem THEN item removed from shop only`() {
// Arrange
val items: ImmutableList<DetailsItemUM> = persistentListOf(
basicBlock(id = "shop", itemIds = listOf("buy_tangem_wallet", "get_tangem_pay")),
basicBlock(id = "settings", itemIds = listOf("app_settings")),
)
// Act
val result = itemsBuilder.removeTangemPayItem(items = items)
// Assert
val shop = result.single { it.id == "shop" } as DetailsItemUM.Basic
assertThat(shop.items.map { it.id }).containsExactly("buy_tangem_wallet")
val settings = result.single { it.id == "settings" } as DetailsItemUM.Basic
assertThat(settings.items.map { it.id }).containsExactly("app_settings")
}
private fun buildAll(
isWalletConnectAvailable: Boolean = false,
isAddressBookAvailable: Boolean = false,
isSupportChatAvailable: Boolean = false,
hasAnyMobileWallet: Boolean = false,
): ImmutableList<DetailsItemUM> = itemsBuilder.buildAll(
isWalletConnectAvailable = isWalletConnectAvailable,
isAddressBookAvailable = isAddressBookAvailable,
isSupportChatAvailable = isSupportChatAvailable,
hasAnyMobileWallet = hasAnyMobileWallet,
userWalletId = USER_WALLET_ID,
onSupportEmailClick = {},
onSupportChatClick = {},
onBuyClick = {},
)
private fun basicBlock(id: String, itemIds: List<String>): DetailsItemUM.Basic = DetailsItemUM.Basic(
id = id,
items = itemIds.map { itemId ->
DetailsItemUM.Basic.Item(
id = itemId,
block = BlockUM(
text = resourceReference(R.string.common_unknown_error),
iconRes = R.drawable.ic_tangem_24,
onClick = {},
),
)
}.toImmutableList(),
)
private companion object {
val USER_WALLET_ID = UserWalletId("011")
}
}