diff --git a/features/promo-banners/impl/build.gradle.kts b/features/promo-banners/impl/build.gradle.kts index 8d084ddf38..9dbea1af14 100644 --- a/features/promo-banners/impl/build.gradle.kts +++ b/features/promo-banners/impl/build.gradle.kts @@ -56,7 +56,6 @@ dependencies { kapt(deps.hilt.kapt) /** Tests */ - testImplementation(deps.test.junit5) - testImplementation(deps.test.truth) - testImplementation(deps.kotlin.serialization) + testImplementation(projects.test.core) + testImplementation(projects.common.test) } \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/analytics/PromoCampaignsAnalyticsEvent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/analytics/PromoCampaignsAnalyticsEvent.kt new file mode 100644 index 0000000000..95be1d4c64 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/analytics/PromoCampaignsAnalyticsEvent.kt @@ -0,0 +1,36 @@ +package com.tangem.features.promobanners.impl.campaigns.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType + +internal sealed class PromoCampaignsAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "Promotion", event = event, params = params) { + + class PromotionScreenOpened(campaignType: CampaignType) : PromoCampaignsAnalyticsEvent( + event = "Promotion Screen Opened", + params = mapOf("Screen" to campaignType.analyticsName), + ) + + class EnrollButtonClicked( + campaignType: CampaignType, + token: String, + blockchain: String, + ) : PromoCampaignsAnalyticsEvent( + event = "Enroll Button Clicked", + params = mapOf( + "Campaign" to campaignType.analyticsName, + "Token" to token, + "Blockchain" to blockchain, + ), + ) + + class AlreadyEnrolledScreenOpened : PromoCampaignsAnalyticsEvent(event = "Already Enrolled Screen Opened") +} + +private val CampaignType.analyticsName: String + get() = when (this) { + is CampaignType.WhaleSwapCashback -> "Cashback" + is CampaignType.ReactivationCashback -> "Reactivation" + } \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModel.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModel.kt index a4be2f2a3b..2a1f4b9dfc 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModel.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModel.kt @@ -3,6 +3,7 @@ package com.tangem.features.promobanners.impl.campaigns.model import com.tangem.common.ui.account.AccountIconItemStateConverter import com.tangem.common.ui.account.toUM import com.tangem.common.ui.tokens.TokenItemStateConverter +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -13,13 +14,12 @@ import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList -import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.core.ui.message.ToastMessage import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.promo.models.EnrollResult import com.tangem.domain.promo.models.PromoCampaignId @@ -28,6 +28,7 @@ import com.tangem.domain.promo.usecase.EnrollPromoCampaignUseCase import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult import com.tangem.features.promobanners.impl.R +import com.tangem.features.promobanners.impl.campaigns.analytics.PromoCampaignsAnalyticsEvent import com.tangem.features.promobanners.impl.campaigns.component.ActivateCampaignBottomSheetComponent import com.tangem.features.promobanners.impl.campaigns.entity.ActivateCampaignUM import com.tangem.features.promobanners.impl.campaigns.entity.CampaignTypeToContentConverter @@ -57,6 +58,7 @@ internal class ActivateCampaignsModel @Inject constructor( private val enrollPromoCampaignUseCase: EnrollPromoCampaignUseCase, private val urlOpener: UrlOpener, @GlobalUiMessageSender private val messageSender: UiMessageSender, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { private val params = paramsContainer.require() @@ -80,6 +82,8 @@ internal class ActivateCampaignsModel @Inject constructor( ) init { + analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.PromotionScreenOpened(campaignType)) + getSelectedAppCurrencyUseCase.invokeOrDefault() .onEach { appCurrency = it } .launchIn(modelScope) @@ -120,20 +124,26 @@ internal class ActivateCampaignsModel @Inject constructor( uiState.update { it.copy(isChoosingToken = false) } } - private fun onEnrollClick(selectedWalletId: UserWalletId, selectedCurrencyStatus: CryptoCurrencyStatus) { - val token = selectedCurrencyStatus.currency as? CryptoCurrency.Token ?: return + private fun onEnrollClick(selectedWalletId: UserWalletId, selectedToken: CryptoCurrency.Token) { + analyticsEventHandler.send( + PromoCampaignsAnalyticsEvent.EnrollButtonClicked( + campaignType = campaignType, + token = selectedToken.symbol, + blockchain = selectedToken.network.name, + ), + ) modelScope.launch { enrollPromoCampaignUseCase.invoke( campaign = campaignId, tokenReward = TokenReward( - tokenAddress = token.contractAddress, - networkId = token.network.rawId, + tokenAddress = selectedToken.contractAddress, + networkId = selectedToken.network.rawId, ), walletIds = listOf(selectedWalletId), ).onLeft { error -> TangemLogger.e("Error enrolling campaign ${campaignType.campaignId}", error) - messageSender.send(SnackbarMessage(message = resourceReference(R.string.common_unknown_error))) + messageSender.send(ToastMessage(message = resourceReference(R.string.common_unknown_error))) }.onRight { handleEnrollResponse(it) } @@ -156,6 +166,8 @@ internal class ActivateCampaignsModel @Inject constructor( } private fun onTokenChosen(result: ChooseTokenResult) { + val selectedToken = result.currency.currency as? CryptoCurrency.Token ?: return + modelScope.launch { val selectedAccountUM = if (isAccountsModeEnabledUseCase.invokeSync()) { when (val account = result.account.account) { @@ -185,7 +197,7 @@ internal class ActivateCampaignsModel @Inject constructor( onPrimaryButtonClick = { onEnrollClick( selectedWalletId = result.walletId, - selectedCurrencyStatus = result.currency, + selectedToken = selectedToken, ) }, terms = TermsUM( diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignsModel.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignsModel.kt index 9bef5d07e1..e309a9d8f1 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignsModel.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignsModel.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.unit.dp import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -15,6 +16,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.promo.models.PromoCampaignState import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase import com.tangem.features.promobanners.impl.R +import com.tangem.features.promobanners.impl.campaigns.analytics.PromoCampaignsAnalyticsEvent import com.tangem.features.promobanners.impl.campaigns.converters.CampaignIdConverter import com.tangem.features.promobanners.impl.campaigns.entity.CampaignsBottomSheetConfig import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType @@ -36,6 +38,7 @@ internal class CampaignsModel @Inject constructor( campaignsService: CampaignsService, private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase, @GlobalUiMessageSender private val messageSender: UiMessageSender, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { val bottomSheetNavigation: SlotNavigation = SlotNavigation() @@ -102,6 +105,7 @@ internal class CampaignsModel @Inject constructor( } fun onAlreadyActivated(campaignType: CampaignType) { + analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.AlreadyEnrolledScreenOpened()) footerExtraHeightState.value = 0.dp bottomSheetNavigation.activate(CampaignsBottomSheetConfig.AlreadyActivated(campaignType = campaignType)) } diff --git a/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/analytics/PromoCampaignsAnalyticsEventTest.kt b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/analytics/PromoCampaignsAnalyticsEventTest.kt new file mode 100644 index 0000000000..6e27185205 --- /dev/null +++ b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/analytics/PromoCampaignsAnalyticsEventTest.kt @@ -0,0 +1,90 @@ +package com.tangem.features.promobanners.impl.campaigns.analytics + +import com.google.common.truth.Truth.assertThat +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class PromoCampaignsAnalyticsEventTest { + + @Test + fun `GIVEN any event WHEN created THEN category is Promotion`() { + // Arrange + val events = listOf( + PromoCampaignsAnalyticsEvent.PromotionScreenOpened(campaignType = whaleSwap), + PromoCampaignsAnalyticsEvent.EnrollButtonClicked( + campaignType = whaleSwap, + token = "USDT", + blockchain = "Ethereum", + ), + PromoCampaignsAnalyticsEvent.AlreadyEnrolledScreenOpened(), + ) + + // Assert + assertThat(events.map { it.category }).containsExactly("Promotion", "Promotion", "Promotion") + } + + @ParameterizedTest + @MethodSource("provideCampaignTypes") + fun `GIVEN campaign type WHEN PromotionScreenOpened THEN event name and Screen param are correct`( + model: CampaignNameModel, + ) { + // Act + val event = PromoCampaignsAnalyticsEvent.PromotionScreenOpened(campaignType = model.campaignType) + + // Assert + assertThat(event.event).isEqualTo("Promotion Screen Opened") + assertThat(event.params).containsExactly("Screen", model.expectedAnalyticsName) + } + + @ParameterizedTest + @MethodSource("provideCampaignTypes") + fun `GIVEN campaign type WHEN EnrollButtonClicked THEN event name and params are correct`( + model: CampaignNameModel, + ) { + // Act + val event = PromoCampaignsAnalyticsEvent.EnrollButtonClicked( + campaignType = model.campaignType, + token = "USDT", + blockchain = "Ethereum", + ) + + // Assert + assertThat(event.event).isEqualTo("Enroll Button Clicked") + assertThat(event.params).containsExactly( + "Campaign", model.expectedAnalyticsName, + "Token", "USDT", + "Blockchain", "Ethereum", + ) + } + + @Test + fun `GIVEN AlreadyEnrolledScreenOpened WHEN created THEN event name is correct and no params`() { + // Act + val event = PromoCampaignsAnalyticsEvent.AlreadyEnrolledScreenOpened() + + // Assert + assertThat(event.event).isEqualTo("Already Enrolled Screen Opened") + assertThat(event.params).isEmpty() + } + + private fun provideCampaignTypes() = listOf( + CampaignNameModel(campaignType = whaleSwap, expectedAnalyticsName = "Cashback"), + CampaignNameModel(campaignType = reactivation, expectedAnalyticsName = "Reactivation"), + ) + + internal data class CampaignNameModel( + val campaignType: CampaignType, + val expectedAnalyticsName: String, + ) { + override fun toString(): String = "${campaignType::class.simpleName} -> $expectedAnalyticsName" + } + + private companion object { + val whaleSwap = CampaignType.WhaleSwapCashback(campaignId = "whale") + val reactivation = CampaignType.ReactivationCashback(campaignId = "reactivation") + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/converters/CampaignIdConverterTest.kt b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/converters/CampaignIdConverterTest.kt new file mode 100644 index 0000000000..b80b33fbbd --- /dev/null +++ b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/converters/CampaignIdConverterTest.kt @@ -0,0 +1,38 @@ +package com.tangem.features.promobanners.impl.campaigns.converters + +import com.google.common.truth.Truth.assertThat +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class CampaignIdConverterTest { + + private val converter = CampaignIdConverter() + + @ParameterizedTest + @MethodSource("provideConvertModels") + fun `GIVEN campaign id WHEN convert THEN correct campaign type is returned`(model: ConvertModel) { + // Act + val actual = converter.convert(model.id) + + // Assert + assertThat(actual).isEqualTo(model.expected) + } + + private fun provideConvertModels() = listOf( + ConvertModel(id = "1", expected = CampaignType.WhaleSwapCashback(campaignId = "1")), + ConvertModel(id = "2", expected = CampaignType.ReactivationCashback(campaignId = "2")), + ConvertModel(id = "0", expected = null), + ConvertModel(id = "unknown", expected = null), + ConvertModel(id = "", expected = null), + ) + + internal data class ConvertModel( + val id: String, + val expected: CampaignType?, + ) { + override fun toString(): String = "\"$id\" -> ${expected?.let { it::class.simpleName } ?: "null"}" + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModelTest.kt b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModelTest.kt new file mode 100644 index 0000000000..0c3f8c94c0 --- /dev/null +++ b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModelTest.kt @@ -0,0 +1,266 @@ +package com.tangem.features.promobanners.impl.campaigns.model + +import arrow.core.Either +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.promo.models.EnrollResult +import com.tangem.domain.promo.models.PromoCampaignId +import com.tangem.domain.promo.models.TokenReward +import com.tangem.domain.promo.usecase.EnrollPromoCampaignUseCase +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult +import com.tangem.features.promobanners.impl.campaigns.analytics.PromoCampaignsAnalyticsEvent +import com.tangem.features.promobanners.impl.campaigns.component.ActivateCampaignBottomSheetComponent +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +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 kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class ActivateCampaignsModelTest { + + private val chooseTokenBridgeFactory: ChooseTokenBridge.Factory = mockk(relaxed = true) + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk() + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk() + private val enrollPromoCampaignUseCase: EnrollPromoCampaignUseCase = mockk() + private val urlOpener: UrlOpener = mockk(relaxed = true) + private val messageSender: UiMessageSender = mockk(relaxed = true) + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + private val modelCallbacks: ActivateCampaignBottomSheetComponent.ActivateCampaignModelCallbacks = + mockk(relaxed = true) + + private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() + + private lateinit var onCurrencyChosen: Channel + + @BeforeEach + fun setup() { + clearMocks( + getSelectedAppCurrencyUseCase, + isAccountsModeEnabledUseCase, + enrollPromoCampaignUseCase, + messageSender, + analyticsEventHandler, + modelCallbacks, + ) + } + + @ParameterizedTest + @MethodSource("provideCampaignTypes") + fun `GIVEN campaign type WHEN model created THEN PromotionScreenOpened is sent`(campaignType: CampaignType) = + runTest { + // Act + val model = createModel(campaignType) + advanceUntilIdle() + + // Assert + verify(exactly = 1) { + analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.PromotionScreenOpened(campaignType)) + } + model.onDestroy() + } + + @Test + fun `WHEN choose token clicked and dismissed THEN isChoosingToken toggles`() = runTest { + // Arrange + val model = createModel(CampaignType.WhaleSwapCashback(campaignId = "1")) + advanceUntilIdle() + + // Act & Assert + model.uiState.value.onChooseTokenClick() + assertThat(model.uiState.value.isChoosingToken).isTrue() + + model.uiState.value.onChooseTokenDismiss() + assertThat(model.uiState.value.isChoosingToken).isFalse() + model.onDestroy() + } + + @Test + fun `GIVEN a coin is chosen WHEN currency chosen THEN it is ignored`() = runTest { + // Arrange + val model = createModel(CampaignType.WhaleSwapCashback(campaignId = "1")) + advanceUntilIdle() + + // Act + onCurrencyChosen.send(chooseTokenResult(currency = coin())) + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value.selectedToken).isNull() + model.onDestroy() + } + + @Test + fun `GIVEN a token chosen and enroll succeeds WHEN enroll clicked THEN event carries symbol and blockchain`() = + runTest { + // Arrange + val token = token() + val campaignType = CampaignType.WhaleSwapCashback(campaignId = "1") + coEvery { enrollPromoCampaignUseCase.invoke(any(), any(), any()) } returns + Either.Right(EnrollResult.Success(TokenReward(tokenAddress = "a", networkId = "b"))) + val model = createModel(campaignType) + advanceUntilIdle() + + // Act + onCurrencyChosen.send(chooseTokenResult(currency = token)) + advanceUntilIdle() + model.uiState.value.footerUM.onPrimaryButtonClick() + advanceUntilIdle() + + // Assert — Token param is the symbol (TTK), Blockchain param is the network name (Ethereum) + val events = mutableListOf() + verify { analyticsEventHandler.send(capture(events)) } + val enrollEvent = events.filterIsInstance().single() + assertThat(enrollEvent.event).isEqualTo("Enroll Button Clicked") + assertThat(enrollEvent.params).containsExactly( + "Campaign", "Cashback", + "Token", "TTK", + "Blockchain", "Ethereum", + ) + + coVerify(exactly = 1) { + enrollPromoCampaignUseCase.invoke( + campaign = PromoCampaignId.WhaleSwapCashback, + tokenReward = TokenReward(tokenAddress = token.contractAddress, networkId = token.network.rawId), + walletIds = listOf(userWalletId), + ) + } + verify(exactly = 1) { modelCallbacks.onActivated(campaignType) } + model.onDestroy() + } + + @Test + fun `GIVEN enroll returns AlreadyEnrolled WHEN enroll clicked THEN onAlreadyActivated called`() = runTest { + // Arrange + val campaignType = CampaignType.WhaleSwapCashback(campaignId = "1") + coEvery { enrollPromoCampaignUseCase.invoke(any(), any(), any()) } returns + Either.Right(EnrollResult.AlreadyEnrolled(TokenReward(tokenAddress = "a", networkId = "b"))) + val model = createModel(campaignType) + advanceUntilIdle() + + // Act + onCurrencyChosen.send(chooseTokenResult(currency = token())) + advanceUntilIdle() + model.uiState.value.footerUM.onPrimaryButtonClick() + advanceUntilIdle() + + // Assert + verify(exactly = 1) { modelCallbacks.onAlreadyActivated(campaignType) } + verify(exactly = 0) { modelCallbacks.onActivated(any()) } + model.onDestroy() + } + + @Test + fun `GIVEN enroll fails WHEN enroll clicked THEN error message is sent and no callback`() = runTest { + // Arrange + coEvery { enrollPromoCampaignUseCase.invoke(any(), any(), any()) } returns + Either.Left(RuntimeException("network")) + val model = createModel(CampaignType.WhaleSwapCashback(campaignId = "1")) + advanceUntilIdle() + + // Act + onCurrencyChosen.send(chooseTokenResult(currency = token())) + advanceUntilIdle() + model.uiState.value.footerUM.onPrimaryButtonClick() + advanceUntilIdle() + + // Assert + verify(exactly = 1) { messageSender.send(any()) } + verify(exactly = 0) { modelCallbacks.onActivated(any()) } + verify(exactly = 0) { modelCallbacks.onAlreadyActivated(any()) } + model.onDestroy() + } + + private fun chooseTokenResult(currency: CryptoCurrency): ChooseTokenResult { + val status = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.MissedDerivation(priceChange = null, fiatRate = null), + ) + val wallet: UserWallet = mockk { + every { walletId } returns userWalletId + } + return ChooseTokenResult(currency = status, account = mockk(relaxed = true), wallet = wallet) + } + + private fun TestScope.createModel(campaignType: CampaignType): ActivateCampaignsModel { + onCurrencyChosen = Channel(capacity = Channel.UNLIMITED) + val bridge = mockk(relaxed = true) { + every { onCurrencyChosen } returns this@ActivateCampaignsModelTest.onCurrencyChosen + every { onClose } returns Channel() + } + every { chooseTokenBridgeFactory.create(any(), any(), any()) } returns bridge + every { getSelectedAppCurrencyUseCase.invokeOrDefault() } returns flowOf(AppCurrency.Default) + coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false + return ActivateCampaignsModel( + paramsContainer = MutableParamsContainer( + ActivateCampaignBottomSheetComponent.Params( + campaignType = campaignType, + modelCallbacks = modelCallbacks, + ), + ), + dispatchers = createTestingCoroutineDispatcherProvider(), + chooseTokenBridgeFactory = chooseTokenBridgeFactory, + getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, + isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase, + enrollPromoCampaignUseCase = enrollPromoCampaignUseCase, + urlOpener = urlOpener, + messageSender = messageSender, + analyticsEventHandler = analyticsEventHandler, + ) + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } + + // Override name/symbol so analytics assertions pin the symbol ("TTK"), not the name ("TEST_TOKEN"). + private fun token(): CryptoCurrency.Token = cryptoCurrencyFactory + .createToken(blockchain = Blockchain.Ethereum, contractAddress = "0xToken") + .copy(name = "TEST_TOKEN", symbol = "TTK") + + private fun coin(): CryptoCurrency.Coin = cryptoCurrencyFactory.ethereum + + private fun provideCampaignTypes() = listOf( + CampaignType.WhaleSwapCashback(campaignId = "1"), + CampaignType.ReactivationCashback(campaignId = "2"), + ) + + private companion object { + val userWalletId = UserWalletId("0011223344556677") + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignsModelTest.kt b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignsModelTest.kt new file mode 100644 index 0000000000..27e2b8cb00 --- /dev/null +++ b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignsModelTest.kt @@ -0,0 +1,197 @@ +package com.tangem.features.promobanners.impl.campaigns.model + +import androidx.compose.ui.unit.dp +import arrow.core.Either +import com.google.common.truth.Truth.assertThat +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.promo.models.PromoCampaignId +import com.tangem.domain.promo.models.PromoCampaignState +import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase +import com.tangem.features.promobanners.impl.campaigns.analytics.PromoCampaignsAnalyticsEvent +import com.tangem.features.promobanners.impl.campaigns.converters.CampaignIdConverter +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType +import com.tangem.features.promobanners.impl.campaigns.service.CampaignRequest +import com.tangem.features.promobanners.impl.campaigns.service.CampaignsService +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.Called +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 kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class CampaignsModelTest { + + private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase = mockk() + private val messageSender: UiMessageSender = mockk(relaxed = true) + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + + // Real converter: "1" -> WhaleSwapCashback, "2" -> ReactivationCashback, else null. + private val campaignIdConverter = CampaignIdConverter() + + @BeforeEach + fun setup() { + clearMocks(getPromoCampaignStateUseCase, messageSender, analyticsEventHandler) + } + + @ParameterizedTest + @MethodSource("provideKnownCampaignModels") + fun `GIVEN known campaign request WHEN emitted THEN campaign state is checked with mapped id`( + model: KnownCampaignModel, + ) = runTest { + // Arrange + coEvery { getPromoCampaignStateUseCase.invoke(any(), any(), any()) } returns + Either.Right(PromoCampaignState.NotActive(model.expectedPromoId)) + val campaignsModel = createModel( + campaignFlow = flowOf(CampaignRequest(campaignId = model.campaignId, userWalletId = userWalletId)), + ) + + // Act + advanceUntilIdle() + + // Assert + coVerify(exactly = 1) { + getPromoCampaignStateUseCase.invoke( + campaign = model.expectedPromoId, + userWalletId = userWalletId, + forceRefresh = any(), + ) + } + verify { messageSender wasNot Called } + campaignsModel.onDestroy() + } + + @Test + fun `GIVEN campaign state fails WHEN emitted THEN error message is sent`() = runTest { + // Arrange + coEvery { getPromoCampaignStateUseCase.invoke(any(), any(), any()) } returns + Either.Left(RuntimeException("network")) + val model = createModel(campaignFlow = flowOf(CampaignRequest(campaignId = "1", userWalletId = userWalletId))) + + // Act + advanceUntilIdle() + + // Assert + verify(exactly = 1) { messageSender.send(any()) } + model.onDestroy() + } + + @Test + fun `GIVEN unknown campaign id WHEN emitted THEN campaign state is not checked`() = runTest { + // Arrange + val model = createModel(campaignFlow = flowOf(CampaignRequest(campaignId = "unknown", userWalletId = userWalletId))) + + // Act + advanceUntilIdle() + + // Assert + coVerify(exactly = 0) { getPromoCampaignStateUseCase.invoke(any(), any(), any()) } + verify { messageSender wasNot Called } + model.onDestroy() + } + + @Test + fun `GIVEN footer height set WHEN onAlreadyActivated THEN analytics sent and height reset`() = runTest { + // Arrange + val model = createModel(campaignFlow = emptyFlow()) + advanceUntilIdle() + model.onFooterExtraHeightReady(100.dp) + + // Act + model.onAlreadyActivated(CampaignType.WhaleSwapCashback(campaignId = "1")) + + // Assert + verify(exactly = 1) { + analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.AlreadyEnrolledScreenOpened()) + } + assertThat(model.footerExtraHeightState.value).isEqualTo(0.dp) + model.onDestroy() + } + + @Test + fun `GIVEN footer height set WHEN onActivated THEN no analytics and height reset`() = runTest { + // Arrange + val model = createModel(campaignFlow = emptyFlow()) + advanceUntilIdle() + model.onFooterExtraHeightReady(100.dp) + + // Act + model.onActivated(CampaignType.WhaleSwapCashback(campaignId = "1")) + + // Assert + verify { analyticsEventHandler wasNot Called } + assertThat(model.footerExtraHeightState.value).isEqualTo(0.dp) + model.onDestroy() + } + + @Test + fun `WHEN onFooterExtraHeightReady THEN height state is updated`() = runTest { + // Arrange + val model = createModel(campaignFlow = emptyFlow()) + advanceUntilIdle() + + // Act + model.onFooterExtraHeightReady(42.dp) + + // Assert + assertThat(model.footerExtraHeightState.value).isEqualTo(42.dp) + model.onDestroy() + } + + private fun TestScope.createModel(campaignFlow: Flow): CampaignsModel { + val campaignsService: CampaignsService = mockk { + every { this@mockk.campaignFlow } returns campaignFlow + } + return CampaignsModel( + dispatchers = createTestingCoroutineDispatcherProvider(), + campaignIdConverter = campaignIdConverter, + campaignsService = campaignsService, + getPromoCampaignStateUseCase = getPromoCampaignStateUseCase, + messageSender = messageSender, + analyticsEventHandler = analyticsEventHandler, + ) + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } + + private fun provideKnownCampaignModels() = listOf( + KnownCampaignModel(campaignId = "1", expectedPromoId = PromoCampaignId.WhaleSwapCashback), + KnownCampaignModel(campaignId = "2", expectedPromoId = PromoCampaignId.ReactivationCashback), + ) + + internal data class KnownCampaignModel( + val campaignId: String, + val expectedPromoId: PromoCampaignId, + ) { + override fun toString(): String = "\"$campaignId\" -> $expectedPromoId" + } + + private companion object { + val userWalletId = UserWalletId("0011223344556677") + } +} \ No newline at end of file