Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-15 11:02:46 +02:00
parent 2ee2edeeef
commit 6256f4ed23
14 changed files with 132 additions and 117 deletions

View file

@ -20,13 +20,14 @@ 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.wallet.UserWalletId
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.promo.models.EnrollResult
import com.tangem.domain.promo.models.PromoCampaignId
import com.tangem.domain.promo.models.PromoCampaignState
import com.tangem.domain.promo.models.TokenReward
import com.tangem.domain.promo.usecase.EnrollPromoCampaignUseCase
import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
import com.tangem.features.commonfeatures.api.choosetoken.ChooserBlock
@ -42,6 +43,7 @@ import com.tangem.features.promobanners.impl.campaigns.entity.TermsUM
import com.tangem.features.promobanners.impl.campaigns.entity.toPromoCampaignId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
@ -65,6 +67,7 @@ internal class ActivateCampaignsModel @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase,
private val predefinedTokenResolver: PredefinedTokenResolver,
private val getWalletsUseCase: GetWalletsUseCase,
) : Model() {
private val params = paramsContainer.require<ActivateCampaignBottomSheetComponent.Params>()
@ -76,6 +79,8 @@ internal class ActivateCampaignsModel @Inject constructor(
private val predefinedTokensFlow = MutableStateFlow<List<PredefinedTokenToAdd>>(emptyList())
private var enrollJob: Job? = null
val uiState: StateFlow<ActivateCampaignUM>
field = MutableStateFlow(buildInitialModel())
@ -144,7 +149,9 @@ internal class ActivateCampaignsModel @Inject constructor(
uiState.update { it.copy(isChoosingToken = false) }
}
private fun onEnrollClick(selectedWalletId: UserWalletId, selectedToken: CryptoCurrency.Token) {
private fun onEnrollClick(selectedToken: CryptoCurrency.Token, networkAddress: NetworkAddress) {
if (enrollJob?.isActive == true) return
analyticsEventHandler.send(
PromoCampaignsAnalyticsEvent.EnrollButtonClicked(
campaignType = campaignType,
@ -153,14 +160,16 @@ internal class ActivateCampaignsModel @Inject constructor(
),
)
modelScope.launch {
enrollJob = modelScope.launch {
enrollPromoCampaignUseCase.invoke(
campaign = campaignId,
tokenReward = TokenReward(
tokenAddress = selectedToken.contractAddress,
networkId = selectedToken.network.rawId,
tokenId = selectedToken.id.rawCurrencyId?.value.orEmpty(),
userAddress = networkAddress.defaultAddress.value,
),
walletIds = listOf(selectedWalletId),
walletIds = getAllUserWalletIds(),
).onLeft { error ->
TangemLogger.e("Error enrolling campaign ${campaignType.campaignId}", error)
messageSender.send(ToastMessage(message = resourceReference(R.string.common_unknown_error)))
@ -170,6 +179,10 @@ internal class ActivateCampaignsModel @Inject constructor(
}
}
private fun getAllUserWalletIds() = getWalletsUseCase
.invokeSync()
.map { it.walletId }
private fun handleEnrollResponse(enrollResult: EnrollResult) {
when (enrollResult) {
is EnrollResult.AlreadyEnrolled -> params.modelCallbacks.onAlreadyActivated(campaignType)
@ -187,6 +200,7 @@ internal class ActivateCampaignsModel @Inject constructor(
private fun onTokenChosen(result: ChooseTokenResult) {
val selectedToken = result.currency.currency as? CryptoCurrency.Token ?: return
val networkAddress = result.currency.value.networkAddress ?: return
modelScope.launch {
val selectedAccountUM = if (isAccountsModeEnabledUseCase.invokeSync()) {
@ -216,8 +230,8 @@ internal class ActivateCampaignsModel @Inject constructor(
label = resourceReference(R.string.promo_campaign_enroll),
onPrimaryButtonClick = {
onEnrollClick(
selectedWalletId = result.walletId,
selectedToken = selectedToken,
networkAddress = networkAddress,
)
},
terms = TermsUM(

View file

@ -83,9 +83,7 @@ internal class CampaignsModel @Inject constructor(
},
ifRight = { campaignState ->
when (campaignState) {
is PromoCampaignState.Enrolled,
is PromoCampaignState.Available,
-> CampaignsBottomSheetConfig.Activate(campaignType, userWalletId)
is PromoCampaignState.Available -> CampaignsBottomSheetConfig.Activate(campaignType, userWalletId)
is PromoCampaignState.NotActive -> CampaignsBottomSheetConfig.NotActive
}
},

View file

@ -14,13 +14,16 @@ 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.network.NetworkAddress
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.EnrolledTokenReward
import com.tangem.domain.promo.models.PromoCampaignId
import com.tangem.domain.promo.models.TokenReward
import com.tangem.domain.promo.usecase.EnrollPromoCampaignUseCase
import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
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
@ -56,6 +59,7 @@ internal class ActivateCampaignsModelTest {
private val messageSender: UiMessageSender = mockk(relaxed = true)
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase = mockk()
private val getWalletsUseCase: GetWalletsUseCase = mockk()
private val predefinedTokenResolver: PredefinedTokenResolver = mockk(relaxed = true)
private val modelCallbacks: ActivateCampaignBottomSheetComponent.ActivateCampaignModelCallbacks =
mockk(relaxed = true)
@ -70,6 +74,7 @@ internal class ActivateCampaignsModelTest {
getSelectedAppCurrencyUseCase,
isAccountsModeEnabledUseCase,
enrollPromoCampaignUseCase,
getWalletsUseCase,
messageSender,
analyticsEventHandler,
modelCallbacks,
@ -128,7 +133,7 @@ internal class ActivateCampaignsModelTest {
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")))
Either.Right(EnrollResult.Success(EnrolledTokenReward(tokenAddress = "a", networkId = "b", tokenId = "d")))
val model = createModel(campaignType)
advanceUntilIdle()
@ -152,20 +157,45 @@ internal class ActivateCampaignsModelTest {
coVerify(exactly = 1) {
enrollPromoCampaignUseCase.invoke(
campaign = PromoCampaignId.WhaleSwapCashback,
tokenReward = TokenReward(tokenAddress = token.contractAddress, networkId = token.network.rawId),
walletIds = listOf(userWalletId),
tokenReward = TokenReward(
tokenAddress = token.contractAddress,
networkId = token.network.rawId,
userAddress = userAddress,
tokenId = token.id.rawCurrencyId?.value.orEmpty(),
),
walletIds = allWalletIds,
)
}
verify(exactly = 1) { modelCallbacks.onActivated(campaignType) }
model.onDestroy()
}
@Test
fun `GIVEN enroll in progress WHEN enroll clicked again THEN use case invoked once`() = runTest {
// Arrange
coEvery { enrollPromoCampaignUseCase.invoke(any(), any(), any()) } returns
Either.Right(EnrollResult.Success(EnrolledTokenReward(tokenAddress = "a", networkId = "b", tokenId = "d")))
val model = createModel(CampaignType.WhaleSwapCashback(campaignId = "1"))
advanceUntilIdle()
onCurrencyChosen.send(chooseTokenResult(currency = token()))
advanceUntilIdle()
// Act — click twice before the in-flight enroll coroutine gets a chance to run
model.uiState.value.footerUM.onPrimaryButtonClick()
model.uiState.value.footerUM.onPrimaryButtonClick()
advanceUntilIdle()
// Assert — the re-entrant click is ignored: enroll is triggered only once
coVerify(exactly = 1) { enrollPromoCampaignUseCase.invoke(any(), any(), any()) }
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")))
Either.Right(EnrollResult.AlreadyEnrolled(EnrolledTokenReward(tokenAddress = "a", networkId = "b", tokenId = "d")))
val model = createModel(campaignType)
advanceUntilIdle()
@ -205,7 +235,14 @@ internal class ActivateCampaignsModelTest {
private fun chooseTokenResult(currency: CryptoCurrency): ChooseTokenResult {
val status = CryptoCurrencyStatus(
currency = currency,
value = CryptoCurrencyStatus.MissedDerivation(priceChange = null, fiatRate = null),
// Must carry a networkAddress: the model resolves userAddress from it and otherwise drops the token.
value = CryptoCurrencyStatus.Unreachable(
priceChange = null,
fiatRate = null,
networkAddress = NetworkAddress.Single(
NetworkAddress.Address(value = userAddress, type = NetworkAddress.Address.Type.Primary),
),
),
)
val wallet: UserWallet = mockk {
every { walletId } returns userWalletId
@ -223,6 +260,9 @@ internal class ActivateCampaignsModelTest {
every { getSelectedAppCurrencyUseCase.invokeOrDefault() } returns flowOf(AppCurrency.Default)
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false
coEvery { getPromoCampaignStateUseCase(any(), any(), any()) } returns Either.Left(Throwable())
every { getWalletsUseCase.invokeSync() } returns allWalletIds.map { walletId ->
mockk<UserWallet> { every { this@mockk.walletId } returns walletId }
}
return ActivateCampaignsModel(
paramsContainer = MutableParamsContainer(
ActivateCampaignBottomSheetComponent.Params(
@ -241,6 +281,7 @@ internal class ActivateCampaignsModelTest {
analyticsEventHandler = analyticsEventHandler,
getPromoCampaignStateUseCase = getPromoCampaignStateUseCase,
predefinedTokenResolver = predefinedTokenResolver,
getWalletsUseCase = getWalletsUseCase,
)
}
@ -269,5 +310,15 @@ internal class ActivateCampaignsModelTest {
private companion object {
val userWalletId = UserWalletId("0011223344556677")
// The user's payout address the model resolves from the chosen token's networkAddress.
const val userAddress = "0xUserPayoutAddress"
// Enrollment must target ALL user wallets ([REDACTED_TASK_KEY]), not only the currently selected one.
val allWalletIds = listOf(
userWalletId,
UserWalletId("8899aabbccddeeff"),
UserWalletId("a1b2c3d4e5f60718"),
)
}
}