diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ee47c1fe61..5edbbda75f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -231,6 +231,8 @@ dependencies { implementation(projects.common.ui) /** Features */ + implementation(projects.features.addressBook.api) + implementation(projects.features.addressBook.impl) implementation(projects.features.rating.impl) implementation(projects.features.referral.impl) implementation(projects.features.referral.domain) diff --git a/core/ui/src/main/res/drawable/ic_contact_20.xml b/core/ui/src/main/res/drawable/ic_contact_20.xml new file mode 100644 index 0000000000..b6d762b21b --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_contact_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/address-book/api/.gitignore b/features/address-book/api/.gitignore new file mode 100644 index 0000000000..567609b123 --- /dev/null +++ b/features/address-book/api/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/features/address-book/api/build.gradle.kts b/features/address-book/api/build.gradle.kts new file mode 100644 index 0000000000..425593dae2 --- /dev/null +++ b/features/address-book/api/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.addressbook.api" +} + +dependencies { + + /* Project - Domain */ + implementation(projects.domain.models) + + /* Project - Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /* Compose */ + implementation(deps.compose.runtime) +} \ No newline at end of file diff --git a/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookFeatureToggles.kt b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookFeatureToggles.kt new file mode 100644 index 0000000000..73e8810fbe --- /dev/null +++ b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookFeatureToggles.kt @@ -0,0 +1,5 @@ +package com.tangem.features.addressbook + +interface AddressBookFeatureToggles { + val isAddressBookEnabled: Boolean +} \ No newline at end of file diff --git a/features/address-book/impl/.gitignore b/features/address-book/impl/.gitignore new file mode 100644 index 0000000000..567609b123 --- /dev/null +++ b/features/address-book/impl/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/features/address-book/impl/build.gradle.kts b/features/address-book/impl/build.gradle.kts new file mode 100644 index 0000000000..f91cde71b1 --- /dev/null +++ b/features/address-book/impl/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.addressbook.impl" +} + +dependencies { + /** Api */ + implementation(projects.features.addressBook.api) + + /** Core modules */ + implementation(projects.core.configToggles) + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /** Compose */ + implementation(deps.compose.runtime) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/DefaultAddressBookFeatureToggles.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/DefaultAddressBookFeatureToggles.kt new file mode 100644 index 0000000000..08247d086c --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/DefaultAddressBookFeatureToggles.kt @@ -0,0 +1,11 @@ +package com.tangem.features.addressbook + +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager + +internal class DefaultAddressBookFeatureToggles( + private val featureTogglesManager: FeatureTogglesManager, +) : AddressBookFeatureToggles { + override val isAddressBookEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_83_ADDRESS_BOOK_ENABLED) +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModule.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModule.kt new file mode 100644 index 0000000000..4594968b09 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModule.kt @@ -0,0 +1,21 @@ +package com.tangem.features.addressbook.di + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.addressbook.AddressBookFeatureToggles +import com.tangem.features.addressbook.DefaultAddressBookFeatureToggles +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object AddressBookModule { + + @Provides + @Singleton + fun provideAddressBookFeatureToggles(featureTogglesManager: FeatureTogglesManager): AddressBookFeatureToggles { + return DefaultAddressBookFeatureToggles(featureTogglesManager) + } +} \ No newline at end of file diff --git a/features/details/impl/build.gradle.kts b/features/details/impl/build.gradle.kts index 0b61f5c022..3b6e587c78 100644 --- a/features/details/impl/build.gradle.kts +++ b/features/details/impl/build.gradle.kts @@ -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) diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt index 15b152f01b..6b1b9e2655 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt @@ -29,6 +29,7 @@ internal class PreviewDetailsComponent : DetailsComponent { }, ).buildAll( isWalletConnectAvailable = true, + isAddressBookAvailable = true, isSupportChatAvailable = true, hasAnyMobileWallet = true, userWalletId = UserWalletId(""), diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsItemUM.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsItemUM.kt index 5e7ebe0773..e7849eebed 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsItemUM.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsItemUM.kt @@ -25,6 +25,15 @@ internal sealed class DetailsItemUM { override val id: String = "wallet_connect" } + data class WalletConnectAddressBookBlock(val items: List) : 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" } diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt index b359828377..e1538a81b3 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt @@ -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, diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt index 0afe3711b2..5d99380d75 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt @@ -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, + 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), + ) } } } diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt index 7bd5b7bd35..c309485bf6 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt @@ -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 = 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.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( diff --git a/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelFeedbackTest.kt b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelFeedbackTest.kt new file mode 100644 index 0000000000..bf6f2a1888 --- /dev/null +++ b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelFeedbackTest.kt @@ -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()) } + 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()) } + 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()) } + 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 = 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 + } +} \ No newline at end of file diff --git a/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelInitTest.kt b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelInitTest.kt new file mode 100644 index 0000000000..42af4c4f40 --- /dev/null +++ b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelInitTest.kt @@ -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.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() + val withTangemPay = persistentListOf(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() + } +} \ No newline at end of file diff --git a/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelNavigationTest.kt b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelNavigationTest.kt new file mode 100644 index 0000000000..3ec4b6770b --- /dev/null +++ b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelNavigationTest.kt @@ -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()) } + verify { urlOpener.openUrl(buyUrl) } + model.onDestroy() + } +} \ No newline at end of file diff --git a/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelTangemPayTest.kt b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelTangemPayTest.kt new file mode 100644 index 0000000000..8bf88ba4b5 --- /dev/null +++ b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelTangemPayTest.kt @@ -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()) } + 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()) } + 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()) } + 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() + } +} \ No newline at end of file diff --git a/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelTestBase.kt b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelTestBase.kt new file mode 100644 index 0000000000..6c48d43c14 --- /dev/null +++ b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelTestBase.kt @@ -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() + protected val abSlot = slot() + protected val chatSlot = slot() + protected val mobileSlot = slot() + protected val walletIdSlot = slot() + 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) { + 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() + every { VisaUtilities.isVisaCard(cardMock) } returns isVisa + val scan = mockk { 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, + ) + } +} \ No newline at end of file diff --git a/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/ItemsBuilderTest.kt b/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/ItemsBuilderTest.kt new file mode 100644 index 0000000000..5aa6f04da9 --- /dev/null +++ b/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/ItemsBuilderTest.kt @@ -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().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()).isEmpty() + assertThat(creationDisabled.filterIsInstance()).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()).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 = 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 = 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 = itemsBuilder.buildAll( + isWalletConnectAvailable = isWalletConnectAvailable, + isAddressBookAvailable = isAddressBookAvailable, + isSupportChatAvailable = isSupportChatAvailable, + hasAnyMobileWallet = hasAnyMobileWallet, + userWalletId = USER_WALLET_ID, + onSupportEmailClick = {}, + onSupportChatClick = {}, + onBuyClick = {}, + ) + + private fun basicBlock(id: String, itemIds: List): 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") + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 19a72b0acf..1e394ee095 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -188,6 +188,9 @@ include(":libs:tangem-sdk-api") // endregion Libs modules // region Feature modules +include(":features:address-book:api") +include(":features:address-book:impl") + include(":features:onboarding-v2:api") include(":features:onboarding-v2:impl")