Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-10 17:10:31 +02:00
parent f899b889e6
commit c0092847b7
8 changed files with 653 additions and 11 deletions

View file

@ -56,7 +56,6 @@ dependencies {
kapt(deps.hilt.kapt) kapt(deps.hilt.kapt)
/** Tests */ /** Tests */
testImplementation(deps.test.junit5) testImplementation(projects.test.core)
testImplementation(deps.test.truth) testImplementation(projects.common.test)
testImplementation(deps.kotlin.serialization)
} }

View file

@ -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<String, String> = 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"
}

View file

@ -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.AccountIconItemStateConverter
import com.tangem.common.ui.account.toUM import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.tokens.TokenItemStateConverter 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.GlobalUiMessageSender
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model 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.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList 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.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency 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.models.wallet.UserWalletId
import com.tangem.domain.promo.models.EnrollResult import com.tangem.domain.promo.models.EnrollResult
import com.tangem.domain.promo.models.PromoCampaignId 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.ChooseTokenBridge
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
import com.tangem.features.promobanners.impl.R 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.component.ActivateCampaignBottomSheetComponent
import com.tangem.features.promobanners.impl.campaigns.entity.ActivateCampaignUM import com.tangem.features.promobanners.impl.campaigns.entity.ActivateCampaignUM
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignTypeToContentConverter import com.tangem.features.promobanners.impl.campaigns.entity.CampaignTypeToContentConverter
@ -57,6 +58,7 @@ internal class ActivateCampaignsModel @Inject constructor(
private val enrollPromoCampaignUseCase: EnrollPromoCampaignUseCase, private val enrollPromoCampaignUseCase: EnrollPromoCampaignUseCase,
private val urlOpener: UrlOpener, private val urlOpener: UrlOpener,
@GlobalUiMessageSender private val messageSender: UiMessageSender, @GlobalUiMessageSender private val messageSender: UiMessageSender,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model() { ) : Model() {
private val params = paramsContainer.require<ActivateCampaignBottomSheetComponent.Params>() private val params = paramsContainer.require<ActivateCampaignBottomSheetComponent.Params>()
@ -80,6 +82,8 @@ internal class ActivateCampaignsModel @Inject constructor(
) )
init { init {
analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.PromotionScreenOpened(campaignType))
getSelectedAppCurrencyUseCase.invokeOrDefault() getSelectedAppCurrencyUseCase.invokeOrDefault()
.onEach { appCurrency = it } .onEach { appCurrency = it }
.launchIn(modelScope) .launchIn(modelScope)
@ -120,20 +124,26 @@ internal class ActivateCampaignsModel @Inject constructor(
uiState.update { it.copy(isChoosingToken = false) } uiState.update { it.copy(isChoosingToken = false) }
} }
private fun onEnrollClick(selectedWalletId: UserWalletId, selectedCurrencyStatus: CryptoCurrencyStatus) { private fun onEnrollClick(selectedWalletId: UserWalletId, selectedToken: CryptoCurrency.Token) {
val token = selectedCurrencyStatus.currency as? CryptoCurrency.Token ?: return analyticsEventHandler.send(
PromoCampaignsAnalyticsEvent.EnrollButtonClicked(
campaignType = campaignType,
token = selectedToken.symbol,
blockchain = selectedToken.network.name,
),
)
modelScope.launch { modelScope.launch {
enrollPromoCampaignUseCase.invoke( enrollPromoCampaignUseCase.invoke(
campaign = campaignId, campaign = campaignId,
tokenReward = TokenReward( tokenReward = TokenReward(
tokenAddress = token.contractAddress, tokenAddress = selectedToken.contractAddress,
networkId = token.network.rawId, networkId = selectedToken.network.rawId,
), ),
walletIds = listOf(selectedWalletId), walletIds = listOf(selectedWalletId),
).onLeft { error -> ).onLeft { error ->
TangemLogger.e("Error enrolling campaign ${campaignType.campaignId}", 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 { }.onRight {
handleEnrollResponse(it) handleEnrollResponse(it)
} }
@ -156,6 +166,8 @@ internal class ActivateCampaignsModel @Inject constructor(
} }
private fun onTokenChosen(result: ChooseTokenResult) { private fun onTokenChosen(result: ChooseTokenResult) {
val selectedToken = result.currency.currency as? CryptoCurrency.Token ?: return
modelScope.launch { modelScope.launch {
val selectedAccountUM = if (isAccountsModeEnabledUseCase.invokeSync()) { val selectedAccountUM = if (isAccountsModeEnabledUseCase.invokeSync()) {
when (val account = result.account.account) { when (val account = result.account.account) {
@ -185,7 +197,7 @@ internal class ActivateCampaignsModel @Inject constructor(
onPrimaryButtonClick = { onPrimaryButtonClick = {
onEnrollClick( onEnrollClick(
selectedWalletId = result.walletId, selectedWalletId = result.walletId,
selectedCurrencyStatus = result.currency, selectedToken = selectedToken,
) )
}, },
terms = TermsUM( terms = TermsUM(

View file

@ -5,6 +5,7 @@ import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss 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.GlobalUiMessageSender
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model 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.models.PromoCampaignState
import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase
import com.tangem.features.promobanners.impl.R 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.converters.CampaignIdConverter
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignsBottomSheetConfig import com.tangem.features.promobanners.impl.campaigns.entity.CampaignsBottomSheetConfig
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType
@ -36,6 +38,7 @@ internal class CampaignsModel @Inject constructor(
campaignsService: CampaignsService, campaignsService: CampaignsService,
private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase, private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase,
@GlobalUiMessageSender private val messageSender: UiMessageSender, @GlobalUiMessageSender private val messageSender: UiMessageSender,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model() { ) : Model() {
val bottomSheetNavigation: SlotNavigation<CampaignsBottomSheetConfig> = SlotNavigation() val bottomSheetNavigation: SlotNavigation<CampaignsBottomSheetConfig> = SlotNavigation()
@ -102,6 +105,7 @@ internal class CampaignsModel @Inject constructor(
} }
fun onAlreadyActivated(campaignType: CampaignType) { fun onAlreadyActivated(campaignType: CampaignType) {
analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.AlreadyEnrolledScreenOpened())
footerExtraHeightState.value = 0.dp footerExtraHeightState.value = 0.dp
bottomSheetNavigation.activate(CampaignsBottomSheetConfig.AlreadyActivated(campaignType = campaignType)) bottomSheetNavigation.activate(CampaignsBottomSheetConfig.AlreadyActivated(campaignType = campaignType))
} }

View file

@ -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")
}
}

View file

@ -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"}"
}
}

View file

@ -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<ChooseTokenResult>
@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<AnalyticsEvent>()
verify { analyticsEventHandler.send(capture(events)) }
val enrollEvent = events.filterIsInstance<PromoCampaignsAnalyticsEvent.EnrollButtonClicked>().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<ChooseTokenBridge>(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")
}
}

View file

@ -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<CampaignRequest>): 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")
}
}