diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/CreatePromotionRegistrationBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/CreatePromotionRegistrationBody.kt index dd02a67af8..0b2d9523f2 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/CreatePromotionRegistrationBody.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/CreatePromotionRegistrationBody.kt @@ -14,5 +14,7 @@ data class CreatePromotionRegistrationBody( data class TokenRewardDto( @Json(name = "tokenAddress") val tokenAddress: String, @Json(name = "networkId") val networkId: String, + @Json(name = "userAddress") val userAddress: String, + @Json(name = "tokenId") val tokenId: String, ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionRegistrationResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionRegistrationResponse.kt index 7f6da1f3ec..8909098866 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionRegistrationResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionRegistrationResponse.kt @@ -14,6 +14,13 @@ data class PromotionRegistrationResponse( data class RegistrationData( @Json(name = "campaignId") val campaignId: 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, ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index 8f90fc3a0a..83ca0108f2 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -144,8 +144,6 @@ object PreferencesKeys { val PENDING_ASSETS_DISCOVERY_KEY by lazy { stringPreferencesKey(name = "pendingAssetsDiscovery") } - val PROMO_ENROLLMENTS_KEY by lazy { stringPreferencesKey(name = "promoEnrollments") } - // region Notifications val NOTIFICATIONS_APPLICATION_ID_KEY by lazy { stringPreferencesKey(name = "notificationsApplicationId") } diff --git a/data/promo/src/main/kotlin/com/tangem/data/promo/DefaultPromoRepository.kt b/data/promo/src/main/kotlin/com/tangem/data/promo/DefaultPromoRepository.kt index f1f3d5d51a..cfe29ea4d4 100644 --- a/data/promo/src/main/kotlin/com/tangem/data/promo/DefaultPromoRepository.kt +++ b/data/promo/src/main/kotlin/com/tangem/data/promo/DefaultPromoRepository.kt @@ -2,7 +2,6 @@ package com.tangem.data.promo import com.squareup.moshi.Moshi 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.ApiResponseError 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.promo.PromoRepository 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.PromoCampaignState import com.tangem.domain.promo.models.TokenReward @@ -21,7 +21,6 @@ import kotlinx.coroutines.withContext internal class DefaultPromoRepository( private val promotionsSupplier: PromotionsSupplier, private val tangemApi: TangemTechApi, - private val enrollmentStore: PromoEnrollmentStore, private val moshi: Moshi, private val dispatchers: CoroutineDispatcherProvider, ) : PromoRepository { @@ -31,9 +30,6 @@ internal class DefaultPromoRepository( userWalletId: UserWalletId, forceRefresh: Boolean, ): PromoCampaignState = withContext(dispatchers.io) { - enrollmentStore.getSyncOrNull(campaign)?.let { - return@withContext PromoCampaignState.Enrolled(campaign, it) - } val all = promotionsSupplier.getPromotions(userWalletId, forceRefresh) .promotions.firstOrNull { it.name == campaign.slug }?.all when { @@ -56,7 +52,6 @@ internal class DefaultPromoRepository( when (val response = tangemApi.createPromotionRegistration(body)) { is ApiResponse.Success -> { val saved = response.data.data.tokenReward.toDomain() - enrollmentStore.store(campaign, saved) EnrollResult.Success(saved) } is ApiResponse.Error -> { @@ -64,8 +59,10 @@ internal class DefaultPromoRepository( val conflict = (cause as? ApiResponseError.HttpException) ?.takeIf { it.code == ApiResponseError.HttpException.Code.CONFLICT } if (conflict != null) { - val existing = parseConflict(conflict.errorBody)?.data?.tokenReward?.toDomain() ?: tokenReward - enrollmentStore.store(campaign, existing) + val existing = parseConflict(conflict.errorBody)?.data + ?.tokenReward + ?.toDomain() + ?: tokenReward.toEnrolledTokenReward() EnrollResult.AlreadyEnrolled(existing) } else { throw cause @@ -84,11 +81,20 @@ internal class DefaultPromoRepository( private fun TokenReward.toDto() = CreatePromotionRegistrationBody.TokenRewardDto( tokenAddress = tokenAddress, networkId = networkId, + userAddress = userAddress, + tokenId = tokenId, ) - private fun CreatePromotionRegistrationBody.TokenRewardDto.toDomain() = TokenReward( + private fun TokenReward.toEnrolledTokenReward() = EnrolledTokenReward( tokenAddress = tokenAddress, networkId = networkId, + tokenId = tokenId, + ) + + private fun PromotionRegistrationResponse.RegisteredTokenRewardDto.toDomain() = EnrolledTokenReward( + tokenAddress = tokenAddress, + networkId = networkId, + tokenId = tokenId, ) private companion object { diff --git a/data/promo/src/main/kotlin/com/tangem/data/promo/di/PromoDataModule.kt b/data/promo/src/main/kotlin/com/tangem/data/promo/di/PromoDataModule.kt index 86123f8e4a..905c2597a7 100644 --- a/data/promo/src/main/kotlin/com/tangem/data/promo/di/PromoDataModule.kt +++ b/data/promo/src/main/kotlin/com/tangem/data/promo/di/PromoDataModule.kt @@ -2,11 +2,8 @@ package com.tangem.data.promo.di import com.squareup.moshi.Moshi 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.di.NetworkMoshi -import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.promotion.PromotionsSupplier import com.tangem.domain.promo.PromoRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -20,25 +17,17 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object PromoDataModule { - @Provides - @Singleton - fun providePromoEnrollmentStore(appPreferencesStore: AppPreferencesStore): PromoEnrollmentStore { - return DefaultPromoEnrollmentStore(appPreferencesStore) - } - @Provides @Singleton fun providePromoRepository( promotionsSupplier: PromotionsSupplier, tangemApi: TangemTechApi, - enrollmentStore: PromoEnrollmentStore, @NetworkMoshi moshi: Moshi, dispatchers: CoroutineDispatcherProvider, ): PromoRepository { return DefaultPromoRepository( promotionsSupplier = promotionsSupplier, tangemApi = tangemApi, - enrollmentStore = enrollmentStore, moshi = moshi, dispatchers = dispatchers, ) diff --git a/data/promo/src/main/kotlin/com/tangem/data/promo/store/DefaultPromoEnrollmentStore.kt b/data/promo/src/main/kotlin/com/tangem/data/promo/store/DefaultPromoEnrollmentStore.kt deleted file mode 100644 index 9266118a8b..0000000000 --- a/data/promo/src/main/kotlin/com/tangem/data/promo/store/DefaultPromoEnrollmentStore.kt +++ /dev/null @@ -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(PreferencesKeys.PROMO_ENROLLMENTS_KEY)[campaign.slug] - } - - override suspend fun store(campaign: PromoCampaignId, tokenReward: TokenReward) { - appPreferencesStore.editData { mutablePreferences -> - val current = mutablePreferences.getObjectMap(PreferencesKeys.PROMO_ENROLLMENTS_KEY) - mutablePreferences.setObjectMap( - key = PreferencesKeys.PROMO_ENROLLMENTS_KEY, - value = current + (campaign.slug to tokenReward), - ) - } - } -} \ No newline at end of file diff --git a/data/promo/src/main/kotlin/com/tangem/data/promo/store/PromoEnrollmentStore.kt b/data/promo/src/main/kotlin/com/tangem/data/promo/store/PromoEnrollmentStore.kt deleted file mode 100644 index 2f770ef638..0000000000 --- a/data/promo/src/main/kotlin/com/tangem/data/promo/store/PromoEnrollmentStore.kt +++ /dev/null @@ -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) -} \ No newline at end of file diff --git a/data/promo/src/test/kotlin/com/tangem/data/promo/DefaultPromoRepositoryTest.kt b/data/promo/src/test/kotlin/com/tangem/data/promo/DefaultPromoRepositoryTest.kt index f2586d9b50..faa0e46217 100644 --- a/data/promo/src/test/kotlin/com/tangem/data/promo/DefaultPromoRepositoryTest.kt +++ b/data/promo/src/test/kotlin/com/tangem/data/promo/DefaultPromoRepositoryTest.kt @@ -3,10 +3,8 @@ package com.tangem.data.promo import com.google.common.truth.Truth.assertThat import com.squareup.moshi.Moshi 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.ApiResponseError -import com.tangem.datasource.api.promotion.models.CreatePromotionRegistrationBody import com.tangem.datasource.api.promotion.models.PromotionRegistrationResponse import com.tangem.datasource.api.promotion.models.PromotionsResponse 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.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.PromoCampaignState import com.tangem.domain.promo.models.TokenReward import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.clearMocks import io.mockk.coEvery -import io.mockk.coVerify import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach @@ -35,20 +33,21 @@ internal class DefaultPromoRepositoryTest { private val promotionsSupplier: PromotionsSupplier = mockk() private val tangemApi: TangemTechApi = mockk() - private val enrollmentStore: PromoEnrollmentStore = mockk(relaxed = true) private val moshi: Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() private val repository = DefaultPromoRepository( promotionsSupplier = promotionsSupplier, tangemApi = tangemApi, - enrollmentStore = enrollmentStore, moshi = moshi, dispatchers = TestingCoroutineDispatcherProvider(), ) private val campaign = PromoCampaignId.WhaleSwapCashback 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( name = campaign.slug, @@ -70,25 +69,11 @@ internal class DefaultPromoRepositoryTest { ) @BeforeEach - fun setUp() = clearMocks(promotionsSupplier, tangemApi, enrollmentStore) - - @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()) } - } + fun setUp() = clearMocks(promotionsSupplier, tangemApi) @Test fun `GIVEN active campaign present and not enrolled WHEN getCampaignState THEN Available`() = runTest { // Arrange - coEvery { enrollmentStore.getSyncOrNull(campaign) } returns null coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns PromotionsResponse(promotions = listOf(activeDto())) @@ -102,7 +87,6 @@ internal class DefaultPromoRepositoryTest { @Test fun `GIVEN campaign absent WHEN getCampaignState THEN NotActive`() = runTest { // Arrange - coEvery { enrollmentStore.getSyncOrNull(campaign) } returns null coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns PromotionsResponse(promotions = emptyList()) @@ -116,7 +100,6 @@ internal class DefaultPromoRepositoryTest { @Test fun `GIVEN campaign present but finished WHEN getCampaignState THEN NotActive`() = runTest { // Arrange - coEvery { enrollmentStore.getSyncOrNull(campaign) } returns null val finished = activeDto().copy(all = activeDto().all!!.copy(status = "finished")) coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns PromotionsResponse(promotions = listOf(finished)) @@ -129,12 +112,16 @@ internal class DefaultPromoRepositoryTest { } @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 val data = PromotionRegistrationResponse.RegistrationData( campaignId = campaign.slug, 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( PromotionRegistrationResponse(status = "saved", message = null, data = data), @@ -144,9 +131,8 @@ internal class DefaultPromoRepositoryTest { val result = repository.enroll(campaign, tokenReward, listOf(userWalletId)) // Assert - val backendToken = TokenReward("0xCanonical", "ethereum") + val backendToken = EnrolledTokenReward("0xCanonical", "ethereum", "tether") assertThat(result).isEqualTo(EnrollResult.Success(backendToken)) - coVerify(exactly = 1) { enrollmentStore.store(campaign, backendToken) } } @Test @@ -155,7 +141,7 @@ internal class DefaultPromoRepositoryTest { val existing = """ {"status":"already_exists","message":"exists","data":{"campaignId":"${campaign.slug}", "registeredAt":"2026-07-01T10:00:00.000Z","tokenReward":{"tokenAddress":"0xOther", - "networkId":"base","userAddress":"0xExisting"}}} + "networkId":"base","userAddress":"0xExisting","tokenId":"usd-coin"}}} """.trimIndent() @Suppress("UNCHECKED_CAST") coEvery { tangemApi.createPromotionRegistration(any()) } returns ApiResponse.Error( @@ -170,9 +156,8 @@ internal class DefaultPromoRepositoryTest { val result = repository.enroll(campaign, tokenReward, listOf(userWalletId)) // Assert - val expectedToken = TokenReward("0xOther", "base") + val expectedToken = EnrolledTokenReward("0xOther", "base", "usd-coin") assertThat(result).isEqualTo(EnrollResult.AlreadyEnrolled(expectedToken)) - coVerify(exactly = 1) { enrollmentStore.store(campaign, expectedToken) } } @Test @@ -191,8 +176,7 @@ internal class DefaultPromoRepositoryTest { val result = repository.enroll(campaign, tokenReward, listOf(userWalletId)) // Assert - assertThat(result).isEqualTo(EnrollResult.AlreadyEnrolled(tokenReward)) - coVerify(exactly = 1) { enrollmentStore.store(campaign, tokenReward) } + assertThat(result).isEqualTo(EnrollResult.AlreadyEnrolled(resultTokenReward)) } @Test @@ -212,6 +196,5 @@ internal class DefaultPromoRepositoryTest { // Assert assertThat(error).isNotNull() - coVerify(exactly = 0) { enrollmentStore.store(any(), any()) } } } \ No newline at end of file diff --git a/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignState.kt b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignState.kt index 6e580f4fcd..e741682340 100644 --- a/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignState.kt +++ b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignState.kt @@ -10,11 +10,6 @@ sealed interface PromoCampaignState { val timeline: PromoTimeline, ) : PromoCampaignState - data class Enrolled( - override val campaign: PromoCampaignId, - val tokenReward: TokenReward, - ) : PromoCampaignState - data class NotActive( override val campaign: PromoCampaignId, ) : PromoCampaignState diff --git a/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoModels.kt b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoModels.kt index e2a3013893..c55f3a92b2 100644 --- a/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoModels.kt +++ b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoModels.kt @@ -19,11 +19,19 @@ data class PromoTimeline( data class TokenReward( val tokenAddress: 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 { - val tokenReward: TokenReward + val tokenReward: EnrolledTokenReward - data class Success(override val tokenReward: TokenReward) : EnrollResult - data class AlreadyEnrolled(override val tokenReward: TokenReward) : EnrollResult + data class Success(override val tokenReward: EnrolledTokenReward) : EnrollResult + data class AlreadyEnrolled(override val tokenReward: EnrolledTokenReward) : EnrollResult } \ No newline at end of file diff --git a/domain/promo/src/test/kotlin/com/tangem/domain/promo/usecase/EnrollPromoCampaignUseCaseTest.kt b/domain/promo/src/test/kotlin/com/tangem/domain/promo/usecase/EnrollPromoCampaignUseCaseTest.kt index a308dc7335..89e8244638 100644 --- a/domain/promo/src/test/kotlin/com/tangem/domain/promo/usecase/EnrollPromoCampaignUseCaseTest.kt +++ b/domain/promo/src/test/kotlin/com/tangem/domain/promo/usecase/EnrollPromoCampaignUseCaseTest.kt @@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.promo.PromoRepository 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.test.core.assertEitherLeft @@ -24,7 +25,8 @@ internal class EnrollPromoCampaignUseCaseTest { private val campaign = PromoCampaignId.WhaleSwapCashback 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 fun setUp() = clearMocks(repository) @@ -32,7 +34,7 @@ internal class EnrollPromoCampaignUseCaseTest { @Test fun `GIVEN repo returns Success WHEN invoke THEN Right Success`() = runTest { // Arrange - val expected = EnrollResult.Success(tokenReward) + val expected = EnrollResult.Success(resultTokenReward) coEvery { repository.enroll(campaign, tokenReward, walletIds) } returns expected // Act 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 083f621067..835a2f1642 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 @@ -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() @@ -76,6 +79,8 @@ internal class ActivateCampaignsModel @Inject constructor( private val predefinedTokensFlow = MutableStateFlow>(emptyList()) + private var enrollJob: Job? = null + val uiState: StateFlow 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( 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 fd1a8721a9..91406341ff 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 @@ -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 } }, 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 index a1c0568648..ac1a70953b 100644 --- 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 @@ -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 { 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"), + ) } } \ No newline at end of file