Updated on 2026-08-14

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

View file

@ -14,5 +14,7 @@ data class CreatePromotionRegistrationBody(
data class TokenRewardDto( data class TokenRewardDto(
@Json(name = "tokenAddress") val tokenAddress: String, @Json(name = "tokenAddress") val tokenAddress: String,
@Json(name = "networkId") val networkId: String, @Json(name = "networkId") val networkId: String,
@Json(name = "userAddress") val userAddress: String,
@Json(name = "tokenId") val tokenId: String,
) )
} }

View file

@ -14,6 +14,13 @@ data class PromotionRegistrationResponse(
data class RegistrationData( data class RegistrationData(
@Json(name = "campaignId") val campaignId: String, @Json(name = "campaignId") val campaignId: String,
@Json(name = "registeredAt") val registeredAt: String?, @Json(name = "registeredAt") val registeredAt: String?,
@Json(name = "tokenReward") val tokenReward: CreatePromotionRegistrationBody.TokenRewardDto, @Json(name = "tokenReward") val tokenReward: RegisteredTokenRewardDto,
)
@JsonClass(generateAdapter = true)
data class RegisteredTokenRewardDto(
@Json(name = "tokenAddress") val tokenAddress: String,
@Json(name = "networkId") val networkId: String,
@Json(name = "tokenId") val tokenId: String,
) )
} }

View file

@ -129,8 +129,6 @@ object PreferencesKeys {
val PENDING_ASSETS_DISCOVERY_KEY by lazy { stringPreferencesKey(name = "pendingAssetsDiscovery") } val PENDING_ASSETS_DISCOVERY_KEY by lazy { stringPreferencesKey(name = "pendingAssetsDiscovery") }
val PROMO_ENROLLMENTS_KEY by lazy { stringPreferencesKey(name = "promoEnrollments") }
// region Notifications // region Notifications
val NOTIFICATIONS_APPLICATION_ID_KEY by lazy { stringPreferencesKey(name = "notificationsApplicationId") } val NOTIFICATIONS_APPLICATION_ID_KEY by lazy { stringPreferencesKey(name = "notificationsApplicationId") }

View file

@ -2,7 +2,6 @@ package com.tangem.data.promo
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.tangem.data.promo.converter.PromoCampaignConverter import com.tangem.data.promo.converter.PromoCampaignConverter
import com.tangem.data.promo.store.PromoEnrollmentStore
import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.promotion.models.CreatePromotionRegistrationBody import com.tangem.datasource.api.promotion.models.CreatePromotionRegistrationBody
@ -12,6 +11,7 @@ import com.tangem.datasource.local.promotion.PromotionsSupplier
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.promo.PromoRepository import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.promo.models.EnrollResult 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.PromoCampaignId
import com.tangem.domain.promo.models.PromoCampaignState import com.tangem.domain.promo.models.PromoCampaignState
import com.tangem.domain.promo.models.TokenReward import com.tangem.domain.promo.models.TokenReward
@ -21,7 +21,6 @@ import kotlinx.coroutines.withContext
internal class DefaultPromoRepository( internal class DefaultPromoRepository(
private val promotionsSupplier: PromotionsSupplier, private val promotionsSupplier: PromotionsSupplier,
private val tangemApi: TangemTechApi, private val tangemApi: TangemTechApi,
private val enrollmentStore: PromoEnrollmentStore,
private val moshi: Moshi, private val moshi: Moshi,
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
) : PromoRepository { ) : PromoRepository {
@ -31,9 +30,6 @@ internal class DefaultPromoRepository(
userWalletId: UserWalletId, userWalletId: UserWalletId,
forceRefresh: Boolean, forceRefresh: Boolean,
): PromoCampaignState = withContext(dispatchers.io) { ): PromoCampaignState = withContext(dispatchers.io) {
enrollmentStore.getSyncOrNull(campaign)?.let {
return@withContext PromoCampaignState.Enrolled(campaign, it)
}
val all = promotionsSupplier.getPromotions(userWalletId, forceRefresh) val all = promotionsSupplier.getPromotions(userWalletId, forceRefresh)
.promotions.firstOrNull { it.name == campaign.slug }?.all .promotions.firstOrNull { it.name == campaign.slug }?.all
when { when {
@ -56,7 +52,6 @@ internal class DefaultPromoRepository(
when (val response = tangemApi.createPromotionRegistration(body)) { when (val response = tangemApi.createPromotionRegistration(body)) {
is ApiResponse.Success -> { is ApiResponse.Success -> {
val saved = response.data.data.tokenReward.toDomain() val saved = response.data.data.tokenReward.toDomain()
enrollmentStore.store(campaign, saved)
EnrollResult.Success(saved) EnrollResult.Success(saved)
} }
is ApiResponse.Error -> { is ApiResponse.Error -> {
@ -64,8 +59,10 @@ internal class DefaultPromoRepository(
val conflict = (cause as? ApiResponseError.HttpException) val conflict = (cause as? ApiResponseError.HttpException)
?.takeIf { it.code == ApiResponseError.HttpException.Code.CONFLICT } ?.takeIf { it.code == ApiResponseError.HttpException.Code.CONFLICT }
if (conflict != null) { if (conflict != null) {
val existing = parseConflict(conflict.errorBody)?.data?.tokenReward?.toDomain() ?: tokenReward val existing = parseConflict(conflict.errorBody)?.data
enrollmentStore.store(campaign, existing) ?.tokenReward
?.toDomain()
?: tokenReward.toEnrolledTokenReward()
EnrollResult.AlreadyEnrolled(existing) EnrollResult.AlreadyEnrolled(existing)
} else { } else {
throw cause throw cause
@ -84,11 +81,20 @@ internal class DefaultPromoRepository(
private fun TokenReward.toDto() = CreatePromotionRegistrationBody.TokenRewardDto( private fun TokenReward.toDto() = CreatePromotionRegistrationBody.TokenRewardDto(
tokenAddress = tokenAddress, tokenAddress = tokenAddress,
networkId = networkId, networkId = networkId,
userAddress = userAddress,
tokenId = tokenId,
) )
private fun CreatePromotionRegistrationBody.TokenRewardDto.toDomain() = TokenReward( private fun TokenReward.toEnrolledTokenReward() = EnrolledTokenReward(
tokenAddress = tokenAddress, tokenAddress = tokenAddress,
networkId = networkId, networkId = networkId,
tokenId = tokenId,
)
private fun PromotionRegistrationResponse.RegisteredTokenRewardDto.toDomain() = EnrolledTokenReward(
tokenAddress = tokenAddress,
networkId = networkId,
tokenId = tokenId,
) )
private companion object { private companion object {

View file

@ -2,11 +2,8 @@ package com.tangem.data.promo.di
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.tangem.data.promo.DefaultPromoRepository import com.tangem.data.promo.DefaultPromoRepository
import com.tangem.data.promo.store.DefaultPromoEnrollmentStore
import com.tangem.data.promo.store.PromoEnrollmentStore
import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.promotion.PromotionsSupplier import com.tangem.datasource.local.promotion.PromotionsSupplier
import com.tangem.domain.promo.PromoRepository import com.tangem.domain.promo.PromoRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -20,25 +17,17 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object PromoDataModule { object PromoDataModule {
@Provides
@Singleton
fun providePromoEnrollmentStore(appPreferencesStore: AppPreferencesStore): PromoEnrollmentStore {
return DefaultPromoEnrollmentStore(appPreferencesStore)
}
@Provides @Provides
@Singleton @Singleton
fun providePromoRepository( fun providePromoRepository(
promotionsSupplier: PromotionsSupplier, promotionsSupplier: PromotionsSupplier,
tangemApi: TangemTechApi, tangemApi: TangemTechApi,
enrollmentStore: PromoEnrollmentStore,
@NetworkMoshi moshi: Moshi, @NetworkMoshi moshi: Moshi,
dispatchers: CoroutineDispatcherProvider, dispatchers: CoroutineDispatcherProvider,
): PromoRepository { ): PromoRepository {
return DefaultPromoRepository( return DefaultPromoRepository(
promotionsSupplier = promotionsSupplier, promotionsSupplier = promotionsSupplier,
tangemApi = tangemApi, tangemApi = tangemApi,
enrollmentStore = enrollmentStore,
moshi = moshi, moshi = moshi,
dispatchers = dispatchers, dispatchers = dispatchers,
) )

View file

@ -1,27 +0,0 @@
package com.tangem.data.promo.store
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
import com.tangem.domain.promo.models.PromoCampaignId
import com.tangem.domain.promo.models.TokenReward
internal class DefaultPromoEnrollmentStore(
private val appPreferencesStore: AppPreferencesStore,
) : PromoEnrollmentStore {
override suspend fun getSyncOrNull(campaign: PromoCampaignId): TokenReward? {
return appPreferencesStore
.getObjectMapSync<TokenReward>(PreferencesKeys.PROMO_ENROLLMENTS_KEY)[campaign.slug]
}
override suspend fun store(campaign: PromoCampaignId, tokenReward: TokenReward) {
appPreferencesStore.editData { mutablePreferences ->
val current = mutablePreferences.getObjectMap<TokenReward>(PreferencesKeys.PROMO_ENROLLMENTS_KEY)
mutablePreferences.setObjectMap(
key = PreferencesKeys.PROMO_ENROLLMENTS_KEY,
value = current + (campaign.slug to tokenReward),
)
}
}
}

View file

@ -1,11 +0,0 @@
package com.tangem.data.promo.store
import com.tangem.domain.promo.models.PromoCampaignId
import com.tangem.domain.promo.models.TokenReward
interface PromoEnrollmentStore {
suspend fun getSyncOrNull(campaign: PromoCampaignId): TokenReward?
suspend fun store(campaign: PromoCampaignId, tokenReward: TokenReward)
}

View file

@ -3,10 +3,8 @@ package com.tangem.data.promo
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import com.tangem.data.promo.store.PromoEnrollmentStore
import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.promotion.models.CreatePromotionRegistrationBody
import com.tangem.datasource.api.promotion.models.PromotionRegistrationResponse import com.tangem.datasource.api.promotion.models.PromotionRegistrationResponse
import com.tangem.datasource.api.promotion.models.PromotionsResponse import com.tangem.datasource.api.promotion.models.PromotionsResponse
import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto
@ -17,13 +15,13 @@ import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.promotion.PromotionsSupplier import com.tangem.datasource.local.promotion.PromotionsSupplier
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.EnrolledTokenReward
import com.tangem.domain.promo.models.PromoCampaignId import com.tangem.domain.promo.models.PromoCampaignId
import com.tangem.domain.promo.models.PromoCampaignState import com.tangem.domain.promo.models.PromoCampaignState
import com.tangem.domain.promo.models.TokenReward import com.tangem.domain.promo.models.TokenReward
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks import io.mockk.clearMocks
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
@ -35,20 +33,21 @@ internal class DefaultPromoRepositoryTest {
private val promotionsSupplier: PromotionsSupplier = mockk() private val promotionsSupplier: PromotionsSupplier = mockk()
private val tangemApi: TangemTechApi = mockk() private val tangemApi: TangemTechApi = mockk()
private val enrollmentStore: PromoEnrollmentStore = mockk(relaxed = true)
private val moshi: Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() private val moshi: Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
private val repository = DefaultPromoRepository( private val repository = DefaultPromoRepository(
promotionsSupplier = promotionsSupplier, promotionsSupplier = promotionsSupplier,
tangemApi = tangemApi, tangemApi = tangemApi,
enrollmentStore = enrollmentStore,
moshi = moshi, moshi = moshi,
dispatchers = TestingCoroutineDispatcherProvider(), dispatchers = TestingCoroutineDispatcherProvider(),
) )
private val campaign = PromoCampaignId.WhaleSwapCashback private val campaign = PromoCampaignId.WhaleSwapCashback
private val userWalletId = UserWalletId("abcdef012345") private val userWalletId = UserWalletId("abcdef012345")
private val tokenReward = TokenReward("0xToken", "ethereum") private val tokenReward = TokenReward("0xToken", "ethereum", "0xUser", "tether")
// The enroll result drops userAddress — this is what the submitted tokenReward collapses to.
private val resultTokenReward = EnrolledTokenReward("0xToken", "ethereum", "tether")
private fun activeDto() = PromotionDto( private fun activeDto() = PromotionDto(
name = campaign.slug, name = campaign.slug,
@ -70,25 +69,11 @@ internal class DefaultPromoRepositoryTest {
) )
@BeforeEach @BeforeEach
fun setUp() = clearMocks(promotionsSupplier, tangemApi, enrollmentStore) fun setUp() = clearMocks(promotionsSupplier, tangemApi)
@Test
fun `GIVEN locally enrolled WHEN getCampaignState THEN Enrolled without api`() = runTest {
// Arrange
coEvery { enrollmentStore.getSyncOrNull(campaign) } returns tokenReward
// Act
val result = repository.getCampaignState(campaign, userWalletId)
// Assert
assertThat(result).isEqualTo(PromoCampaignState.Enrolled(campaign, tokenReward))
coVerify(exactly = 0) { promotionsSupplier.getPromotions(any(), any()) }
}
@Test @Test
fun `GIVEN active campaign present and not enrolled WHEN getCampaignState THEN Available`() = runTest { fun `GIVEN active campaign present and not enrolled WHEN getCampaignState THEN Available`() = runTest {
// Arrange // Arrange
coEvery { enrollmentStore.getSyncOrNull(campaign) } returns null
coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns
PromotionsResponse(promotions = listOf(activeDto())) PromotionsResponse(promotions = listOf(activeDto()))
@ -102,7 +87,6 @@ internal class DefaultPromoRepositoryTest {
@Test @Test
fun `GIVEN campaign absent WHEN getCampaignState THEN NotActive`() = runTest { fun `GIVEN campaign absent WHEN getCampaignState THEN NotActive`() = runTest {
// Arrange // Arrange
coEvery { enrollmentStore.getSyncOrNull(campaign) } returns null
coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns
PromotionsResponse(promotions = emptyList()) PromotionsResponse(promotions = emptyList())
@ -116,7 +100,6 @@ internal class DefaultPromoRepositoryTest {
@Test @Test
fun `GIVEN campaign present but finished WHEN getCampaignState THEN NotActive`() = runTest { fun `GIVEN campaign present but finished WHEN getCampaignState THEN NotActive`() = runTest {
// Arrange // Arrange
coEvery { enrollmentStore.getSyncOrNull(campaign) } returns null
val finished = activeDto().copy(all = activeDto().all!!.copy(status = "finished")) val finished = activeDto().copy(all = activeDto().all!!.copy(status = "finished"))
coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns
PromotionsResponse(promotions = listOf(finished)) PromotionsResponse(promotions = listOf(finished))
@ -129,12 +112,16 @@ internal class DefaultPromoRepositoryTest {
} }
@Test @Test
fun `GIVEN api returns 201 with canonical token WHEN enroll THEN Success and persists backend token`() = runTest { fun `GIVEN api returns 201 with canonical token WHEN enroll THEN Success with backend token`() = runTest {
// Arrange // Arrange
val data = PromotionRegistrationResponse.RegistrationData( val data = PromotionRegistrationResponse.RegistrationData(
campaignId = campaign.slug, campaignId = campaign.slug,
registeredAt = "2026-07-06T09:27:13.363Z", registeredAt = "2026-07-06T09:27:13.363Z",
tokenReward = CreatePromotionRegistrationBody.TokenRewardDto("0xCanonical", "ethereum"), tokenReward = PromotionRegistrationResponse.RegisteredTokenRewardDto(
tokenAddress = "0xCanonical",
networkId = "ethereum",
tokenId = "tether",
),
) )
coEvery { tangemApi.createPromotionRegistration(any()) } returns ApiResponse.Success( coEvery { tangemApi.createPromotionRegistration(any()) } returns ApiResponse.Success(
PromotionRegistrationResponse(status = "saved", message = null, data = data), PromotionRegistrationResponse(status = "saved", message = null, data = data),
@ -144,9 +131,8 @@ internal class DefaultPromoRepositoryTest {
val result = repository.enroll(campaign, tokenReward, listOf(userWalletId)) val result = repository.enroll(campaign, tokenReward, listOf(userWalletId))
// Assert // Assert
val backendToken = TokenReward("0xCanonical", "ethereum") val backendToken = EnrolledTokenReward("0xCanonical", "ethereum", "tether")
assertThat(result).isEqualTo(EnrollResult.Success(backendToken)) assertThat(result).isEqualTo(EnrollResult.Success(backendToken))
coVerify(exactly = 1) { enrollmentStore.store(campaign, backendToken) }
} }
@Test @Test
@ -155,7 +141,7 @@ internal class DefaultPromoRepositoryTest {
val existing = """ val existing = """
{"status":"already_exists","message":"exists","data":{"campaignId":"${campaign.slug}", {"status":"already_exists","message":"exists","data":{"campaignId":"${campaign.slug}",
"registeredAt":"2026-07-01T10:00:00.000Z","tokenReward":{"tokenAddress":"0xOther", "registeredAt":"2026-07-01T10:00:00.000Z","tokenReward":{"tokenAddress":"0xOther",
"networkId":"base","userAddress":"0xExisting"}}} "networkId":"base","userAddress":"0xExisting","tokenId":"usd-coin"}}}
""".trimIndent() """.trimIndent()
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
coEvery { tangemApi.createPromotionRegistration(any()) } returns ApiResponse.Error( coEvery { tangemApi.createPromotionRegistration(any()) } returns ApiResponse.Error(
@ -170,9 +156,8 @@ internal class DefaultPromoRepositoryTest {
val result = repository.enroll(campaign, tokenReward, listOf(userWalletId)) val result = repository.enroll(campaign, tokenReward, listOf(userWalletId))
// Assert // Assert
val expectedToken = TokenReward("0xOther", "base") val expectedToken = EnrolledTokenReward("0xOther", "base", "usd-coin")
assertThat(result).isEqualTo(EnrollResult.AlreadyEnrolled(expectedToken)) assertThat(result).isEqualTo(EnrollResult.AlreadyEnrolled(expectedToken))
coVerify(exactly = 1) { enrollmentStore.store(campaign, expectedToken) }
} }
@Test @Test
@ -191,8 +176,7 @@ internal class DefaultPromoRepositoryTest {
val result = repository.enroll(campaign, tokenReward, listOf(userWalletId)) val result = repository.enroll(campaign, tokenReward, listOf(userWalletId))
// Assert // Assert
assertThat(result).isEqualTo(EnrollResult.AlreadyEnrolled(tokenReward)) assertThat(result).isEqualTo(EnrollResult.AlreadyEnrolled(resultTokenReward))
coVerify(exactly = 1) { enrollmentStore.store(campaign, tokenReward) }
} }
@Test @Test
@ -212,6 +196,5 @@ internal class DefaultPromoRepositoryTest {
// Assert // Assert
assertThat(error).isNotNull() assertThat(error).isNotNull()
coVerify(exactly = 0) { enrollmentStore.store(any(), any()) }
} }
} }

View file

@ -10,11 +10,6 @@ sealed interface PromoCampaignState {
val timeline: PromoTimeline, val timeline: PromoTimeline,
) : PromoCampaignState ) : PromoCampaignState
data class Enrolled(
override val campaign: PromoCampaignId,
val tokenReward: TokenReward,
) : PromoCampaignState
data class NotActive( data class NotActive(
override val campaign: PromoCampaignId, override val campaign: PromoCampaignId,
) : PromoCampaignState ) : PromoCampaignState

View file

@ -19,11 +19,19 @@ data class PromoTimeline(
data class TokenReward( data class TokenReward(
val tokenAddress: String, val tokenAddress: String,
val networkId: String, val networkId: String,
val userAddress: String,
val tokenId: String,
)
data class EnrolledTokenReward(
val tokenAddress: String,
val networkId: String,
val tokenId: String,
) )
sealed interface EnrollResult { sealed interface EnrollResult {
val tokenReward: TokenReward val tokenReward: EnrolledTokenReward
data class Success(override val tokenReward: TokenReward) : EnrollResult data class Success(override val tokenReward: EnrolledTokenReward) : EnrollResult
data class AlreadyEnrolled(override val tokenReward: TokenReward) : EnrollResult data class AlreadyEnrolled(override val tokenReward: EnrolledTokenReward) : EnrollResult
} }

View file

@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.promo.PromoRepository import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.promo.models.EnrollResult 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.PromoCampaignId
import com.tangem.domain.promo.models.TokenReward import com.tangem.domain.promo.models.TokenReward
import com.tangem.test.core.assertEitherLeft import com.tangem.test.core.assertEitherLeft
@ -24,7 +25,8 @@ internal class EnrollPromoCampaignUseCaseTest {
private val campaign = PromoCampaignId.WhaleSwapCashback private val campaign = PromoCampaignId.WhaleSwapCashback
private val walletIds = listOf(UserWalletId("abcdef012345")) private val walletIds = listOf(UserWalletId("abcdef012345"))
private val tokenReward = TokenReward("0xToken", "ethereum") private val tokenReward = TokenReward("0xToken", "ethereum", "0xUser", "tether")
private val resultTokenReward = EnrolledTokenReward("0xToken", "ethereum", "tether")
@BeforeEach @BeforeEach
fun setUp() = clearMocks(repository) fun setUp() = clearMocks(repository)
@ -32,7 +34,7 @@ internal class EnrollPromoCampaignUseCaseTest {
@Test @Test
fun `GIVEN repo returns Success WHEN invoke THEN Right Success`() = runTest { fun `GIVEN repo returns Success WHEN invoke THEN Right Success`() = runTest {
// Arrange // Arrange
val expected = EnrollResult.Success(tokenReward) val expected = EnrollResult.Success(resultTokenReward)
coEvery { repository.enroll(campaign, tokenReward, walletIds) } returns expected coEvery { repository.enroll(campaign, tokenReward, walletIds) } returns expected
// Act // Act

View file

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

View file

@ -83,9 +83,7 @@ internal class CampaignsModel @Inject constructor(
}, },
ifRight = { campaignState -> ifRight = { campaignState ->
when (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 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.appcurrency.model.AppCurrency
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.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
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.EnrolledTokenReward
import com.tangem.domain.promo.models.PromoCampaignId import com.tangem.domain.promo.models.PromoCampaignId
import com.tangem.domain.promo.models.TokenReward import com.tangem.domain.promo.models.TokenReward
import com.tangem.domain.promo.usecase.EnrollPromoCampaignUseCase import com.tangem.domain.promo.usecase.EnrollPromoCampaignUseCase
import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase 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.ChooseTokenBridge
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
import com.tangem.features.promobanners.impl.campaigns.analytics.PromoCampaignsAnalyticsEvent 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 messageSender: UiMessageSender = mockk(relaxed = true)
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase = mockk() private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase = mockk()
private val getWalletsUseCase: GetWalletsUseCase = mockk()
private val predefinedTokenResolver: PredefinedTokenResolver = mockk(relaxed = true) private val predefinedTokenResolver: PredefinedTokenResolver = mockk(relaxed = true)
private val modelCallbacks: ActivateCampaignBottomSheetComponent.ActivateCampaignModelCallbacks = private val modelCallbacks: ActivateCampaignBottomSheetComponent.ActivateCampaignModelCallbacks =
mockk(relaxed = true) mockk(relaxed = true)
@ -70,6 +74,7 @@ internal class ActivateCampaignsModelTest {
getSelectedAppCurrencyUseCase, getSelectedAppCurrencyUseCase,
isAccountsModeEnabledUseCase, isAccountsModeEnabledUseCase,
enrollPromoCampaignUseCase, enrollPromoCampaignUseCase,
getWalletsUseCase,
messageSender, messageSender,
analyticsEventHandler, analyticsEventHandler,
modelCallbacks, modelCallbacks,
@ -128,7 +133,7 @@ internal class ActivateCampaignsModelTest {
val token = token() val token = token()
val campaignType = CampaignType.WhaleSwapCashback(campaignId = "1") val campaignType = CampaignType.WhaleSwapCashback(campaignId = "1")
coEvery { enrollPromoCampaignUseCase.invoke(any(), any(), any()) } returns 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) val model = createModel(campaignType)
advanceUntilIdle() advanceUntilIdle()
@ -152,20 +157,45 @@ internal class ActivateCampaignsModelTest {
coVerify(exactly = 1) { coVerify(exactly = 1) {
enrollPromoCampaignUseCase.invoke( enrollPromoCampaignUseCase.invoke(
campaign = PromoCampaignId.WhaleSwapCashback, campaign = PromoCampaignId.WhaleSwapCashback,
tokenReward = TokenReward(tokenAddress = token.contractAddress, networkId = token.network.rawId), tokenReward = TokenReward(
walletIds = listOf(userWalletId), tokenAddress = token.contractAddress,
networkId = token.network.rawId,
userAddress = userAddress,
tokenId = token.id.rawCurrencyId?.value.orEmpty(),
),
walletIds = allWalletIds,
) )
} }
verify(exactly = 1) { modelCallbacks.onActivated(campaignType) } verify(exactly = 1) { modelCallbacks.onActivated(campaignType) }
model.onDestroy() 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 @Test
fun `GIVEN enroll returns AlreadyEnrolled WHEN enroll clicked THEN onAlreadyActivated called`() = runTest { fun `GIVEN enroll returns AlreadyEnrolled WHEN enroll clicked THEN onAlreadyActivated called`() = runTest {
// Arrange // Arrange
val campaignType = CampaignType.WhaleSwapCashback(campaignId = "1") val campaignType = CampaignType.WhaleSwapCashback(campaignId = "1")
coEvery { enrollPromoCampaignUseCase.invoke(any(), any(), any()) } returns 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) val model = createModel(campaignType)
advanceUntilIdle() advanceUntilIdle()
@ -205,7 +235,14 @@ internal class ActivateCampaignsModelTest {
private fun chooseTokenResult(currency: CryptoCurrency): ChooseTokenResult { private fun chooseTokenResult(currency: CryptoCurrency): ChooseTokenResult {
val status = CryptoCurrencyStatus( val status = CryptoCurrencyStatus(
currency = currency, 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 { val wallet: UserWallet = mockk {
every { walletId } returns userWalletId every { walletId } returns userWalletId
@ -223,6 +260,9 @@ internal class ActivateCampaignsModelTest {
every { getSelectedAppCurrencyUseCase.invokeOrDefault() } returns flowOf(AppCurrency.Default) every { getSelectedAppCurrencyUseCase.invokeOrDefault() } returns flowOf(AppCurrency.Default)
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false
coEvery { getPromoCampaignStateUseCase(any(), any(), any()) } returns Either.Left(Throwable()) 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( return ActivateCampaignsModel(
paramsContainer = MutableParamsContainer( paramsContainer = MutableParamsContainer(
ActivateCampaignBottomSheetComponent.Params( ActivateCampaignBottomSheetComponent.Params(
@ -241,6 +281,7 @@ internal class ActivateCampaignsModelTest {
analyticsEventHandler = analyticsEventHandler, analyticsEventHandler = analyticsEventHandler,
getPromoCampaignStateUseCase = getPromoCampaignStateUseCase, getPromoCampaignStateUseCase = getPromoCampaignStateUseCase,
predefinedTokenResolver = predefinedTokenResolver, predefinedTokenResolver = predefinedTokenResolver,
getWalletsUseCase = getWalletsUseCase,
) )
} }
@ -269,5 +310,15 @@ internal class ActivateCampaignsModelTest {
private companion object { private companion object {
val userWalletId = UserWalletId("0011223344556677") 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"),
)
} }
} }