diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5a4feed03c..06af283694 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -155,6 +155,8 @@ dependencies { implementation(projects.domain.onramp) implementation(projects.domain.stories) implementation(projects.domain.stories.models) + implementation(projects.domain.marketing) + implementation(projects.domain.marketing.models) implementation(projects.domain.networks) implementation(projects.domain.quotes) implementation(projects.domain.notifications) @@ -209,6 +211,7 @@ dependencies { implementation(projects.data.transaction) implementation(projects.data.visa) implementation(projects.data.stories) + implementation(projects.data.marketing) implementation(projects.data.onboarding) implementation(projects.data.dynamicAddresses) implementation(projects.data.feedback) diff --git a/app/src/main/java/com/tangem/tap/di/domain/MarketingDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/MarketingDomainModule.kt new file mode 100644 index 0000000000..7e927f9d61 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/MarketingDomainModule.kt @@ -0,0 +1,28 @@ +package com.tangem.tap.di.domain + +import com.tangem.domain.marketing.DismissMarketingBannerUseCase +import com.tangem.domain.marketing.GetMarketingBannerUseCase +import com.tangem.domain.marketing.MarketingFeatureToggles +import com.tangem.domain.marketing.MarketingRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object MarketingDomainModule { + + @Provides + @Singleton + fun provideGetMarketingBannerUseCase( + repository: MarketingRepository, + featureToggles: MarketingFeatureToggles, + ): GetMarketingBannerUseCase = GetMarketingBannerUseCase(repository, featureToggles) + + @Provides + @Singleton + fun provideDismissMarketingBannerUseCase(repository: MarketingRepository): DismissMarketingBannerUseCase = + DismissMarketingBannerUseCase(repository) +} \ No newline at end of file diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 0f7b86fa5d..d20c48faa5 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -178,5 +178,9 @@ { "name": "TWI_1637_CASHBACK_AND_REACTIVATION_CAMPAIGNS_ENABLED", "version": "6.0.1" + }, + { + "name": "TWI_1522_MARKETING_BANNERS_ENABLED", + "version": "6.0.1" } ] diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/marketing/models/MarketingCampaignsCacheEntry.kt b/core/datasource/src/main/java/com/tangem/datasource/api/marketing/models/MarketingCampaignsCacheEntry.kt new file mode 100644 index 0000000000..fed65f16a5 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/marketing/models/MarketingCampaignsCacheEntry.kt @@ -0,0 +1,11 @@ +package com.tangem.datasource.api.marketing.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** Cached campaigns response plus its ETag, persisted per [CampaignDto.type] for revalidation. */ +@JsonClass(generateAdapter = true) +data class MarketingCampaignsCacheEntry( + @Json(name = "eTag") val eTag: String?, + @Json(name = "response") val response: MarketingCampaignsResponse, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/marketing/models/MarketingCampaignsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/marketing/models/MarketingCampaignsResponse.kt new file mode 100644 index 0000000000..4340898419 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/marketing/models/MarketingCampaignsResponse.kt @@ -0,0 +1,42 @@ +package com.tangem.datasource.api.marketing.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import java.math.BigDecimal + +@JsonClass(generateAdapter = true) +data class MarketingCampaignsResponse( + @Json(name = "campaigns") val campaigns: List, +) + +@JsonClass(generateAdapter = true) +data class CampaignDto( + @Json(name = "id") val id: Int, + @Json(name = "type") val type: String, + @Json(name = "priority") val priority: Int, + @Json(name = "startAt") val startAt: String? = null, + @Json(name = "endAt") val endAt: String? = null, + @Json(name = "minAmount") val minAmount: BigDecimal? = null, + @Json(name = "maxAmount") val maxAmount: BigDecimal? = null, + @Json(name = "providerIds") val providerIds: List? = null, + @Json(name = "tokens") val tokens: List? = null, + @Json(name = "banner") val banner: BannerDto, +) + +@JsonClass(generateAdapter = true) +data class CampaignTokenDto( + @Json(name = "networkId") val networkId: String? = null, + @Json(name = "contractAddress") val contractAddress: String? = null, + @Json(name = "id") val id: String? = null, +) + +@JsonClass(generateAdapter = true) +data class BannerDto( + @Json(name = "uiType") val uiType: String, + @Json(name = "text") val text: String? = null, + @Json(name = "icon") val icon: String? = null, + @Json(name = "iconAlign") val iconAlign: String? = null, + @Json(name = "bgColor") val bgColor: String? = null, + @Json(name = "deeplink") val deeplink: String? = null, + @Json(name = "dismissible") val isDismissible: Boolean = false, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index ba7b268cdf..1289a1471c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -3,6 +3,7 @@ package com.tangem.datasource.api.tangemTech import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.promotion.models.CreatePromotionRegistrationBody import com.tangem.datasource.api.promotion.models.PromotionRegistrationResponse +import com.tangem.datasource.api.marketing.models.MarketingCampaignsResponse import com.tangem.datasource.api.promotion.models.PromotionsResponse import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse import com.tangem.datasource.api.stories.models.StoryContentResponse @@ -242,4 +243,18 @@ interface TangemTechApi { @GET("v1/earn/networks") suspend fun getEarnNetworks(@Query("type") type: String? = null): ApiResponse // endregion + + // region marketing + @GET("api/v1/marketing/campaigns") + suspend fun getMarketingCampaigns( + @Query("type") type: String, + @Query("language") language: String? = null, + @Query("fromNetwork") fromNetwork: String? = null, + @Query("fromContractAddress") fromContractAddress: String? = null, + @Query("toNetwork") toNetwork: String? = null, + @Query("toContractAddress") toContractAddress: String? = null, + @Query("fromFiat") fromFiat: String? = null, + @Header("If-None-Match") eTag: String? = null, + ): ApiResponse + // endregion } \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/marketing/MarketingCampaignsResponseTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/marketing/MarketingCampaignsResponseTest.kt new file mode 100644 index 0000000000..db902e7e69 --- /dev/null +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/marketing/MarketingCampaignsResponseTest.kt @@ -0,0 +1,75 @@ +package com.tangem.datasource.api.marketing + +import com.google.common.truth.Truth.assertThat +import com.squareup.moshi.Moshi +import com.tangem.datasource.api.common.adapter.BigDecimalAdapter +import com.tangem.datasource.api.marketing.models.MarketingCampaignsResponse +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class MarketingCampaignsResponseTest { + + private val moshi = Moshi.Builder().add(BigDecimalAdapter()).build() + private val adapter = moshi.adapter(MarketingCampaignsResponse::class.java) + + @Test + fun `GIVEN swap response json WHEN parsed THEN fields mapped`() { + // Arrange + val json = """ + {"campaigns":[{"id":12,"type":"swap","priority":1,"minAmount":50,"maxAmount":300, + "providerIds":["provider1"], + "banner":{"uiType":"linked_to_provider","text":"Cashback 4 U","icon":"https://x/star.webp", + "bgColor":"#FF0011","deeplink":"https://tangem.com","dismissible":true}}]} + """.trimIndent() + + // Act + val result = adapter.fromJson(json)!! + + // Assert + val campaign = result.campaigns.single() + assertThat(campaign.id).isEqualTo(12) + assertThat(campaign.type).isEqualTo("swap") + assertThat(campaign.minAmount).isEqualTo(BigDecimal(50)) + assertThat(campaign.providerIds).containsExactly("provider1") + assertThat(campaign.banner.uiType).isEqualTo("linked_to_provider") + assertThat(campaign.banner.isDismissible).isTrue() + assertThat(campaign.tokens).isNull() + } + + @Test + fun `GIVEN token_details response WHEN parsed THEN network targets mapped`() { + // Arrange + val json = """ + {"campaigns":[{"id":12,"type":"token_details","priority":1, + "tokens":[{"networkId":"ethereum","contractAddress":"0xA0b8"}], + "banner":{"uiType":"standalone","dismissible":false}}]} + """.trimIndent() + + // Act + val campaign = adapter.fromJson(json)!!.campaigns.single() + + // Assert + val token = campaign.tokens!!.single() + assertThat(token.networkId).isEqualTo("ethereum") + assertThat(token.contractAddress).isEqualTo("0xA0b8") + assertThat(token.id).isNull() + assertThat(campaign.minAmount).isNull() + } + + @Test + fun `GIVEN markets response WHEN parsed THEN coingecko ids mapped`() { + // Arrange + val json = """ + {"campaigns":[{"id":12,"type":"markets_token","priority":1, + "tokens":[{"id":"1696501400"},{"id":"3296501412"}], + "banner":{"uiType":"standalone","dismissible":true}}]} + """.trimIndent() + + // Act + val tokens = adapter.fromJson(json)!!.campaigns.single().tokens!! + + // Assert + assertThat(tokens.map { it.id }).containsExactly("1696501400", "3296501412") + assertThat(tokens.all { it.networkId == null }).isTrue() + } +} \ No newline at end of file diff --git a/data/marketing/build.gradle.kts b/data/marketing/build.gradle.kts new file mode 100644 index 0000000000..ac7fab8510 --- /dev/null +++ b/data/marketing/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.data.marketing" +} + +dependencies { + implementation(deps.androidx.datastore) + + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + + implementation(deps.kotlin.coroutines) + implementation(deps.arrow.core) + + implementation(projects.domain.marketing) + implementation(projects.domain.marketing.models) + + implementation(projects.core.datasource) + implementation(projects.core.utils) + implementation(projects.core.configToggles) + + testImplementation(projects.test.core) +} \ No newline at end of file diff --git a/data/marketing/src/main/kotlin/com/tangem/data/marketing/DefaultMarketingRepository.kt b/data/marketing/src/main/kotlin/com/tangem/data/marketing/DefaultMarketingRepository.kt new file mode 100644 index 0000000000..1c27200732 --- /dev/null +++ b/data/marketing/src/main/kotlin/com/tangem/data/marketing/DefaultMarketingRepository.kt @@ -0,0 +1,97 @@ +package com.tangem.data.marketing + +import arrow.core.Either +import com.tangem.data.marketing.converter.MarketingCampaignConverter +import com.tangem.data.marketing.store.MarketingCampaignsCacheStore +import com.tangem.data.marketing.store.MarketingDismissStore +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ETAG_HEADER +import com.tangem.datasource.api.marketing.models.MarketingCampaignsCacheEntry +import com.tangem.datasource.api.marketing.models.MarketingCampaignsResponse +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.domain.marketing.MarketingRepository +import com.tangem.domain.marketing.models.MarketingCampaign +import com.tangem.domain.marketing.models.MarketingScreen +import com.tangem.utils.SupportedLanguages +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.withContext + +internal class DefaultMarketingRepository( + private val tangemTechApi: TangemTechApi, + private val cacheStore: MarketingCampaignsCacheStore, + private val dismissStore: MarketingDismissStore, + private val converter: MarketingCampaignConverter, + private val dispatchers: CoroutineDispatcherProvider, +) : MarketingRepository { + + override suspend fun getCampaigns(screen: MarketingScreen): Either> = + withContext(dispatchers.io) { + Either.catch { + val isCacheable = screen.type.isCacheable + val cached = if (isCacheable) cacheStore.get(screen.type.value) else null + + when (val response = requestCampaigns(screen, eTag = cached?.eTag)) { + is ApiResponse.Success -> { + if (isCacheable) { + // eTag may be null if the server omits it; we still cache the body for the 5xx + // fallback path. A null eTag simply means the next request sends no If-None-Match + // (Retrofit omits null headers) and receives a fresh 200. + val eTag = response.headers[ETAG_HEADER]?.firstOrNull() + cacheStore.store(screen.type.value, MarketingCampaignsCacheEntry(eTag, response.data)) + } + convert(response.data) + } + is ApiResponse.Error -> handleError(cached) + } + } + } + + override suspend fun getDismissedBannerIds(): Set = dismissStore.getDismissedIds() + + override suspend fun dismissBanner(campaignId: Int) = dismissStore.dismiss(campaignId) + + private suspend fun requestCampaigns( + screen: MarketingScreen, + eTag: String?, + ): ApiResponse { + val language = SupportedLanguages.getCurrentSupportedLanguageCode() + return when (screen) { + is MarketingScreen.Swap -> tangemTechApi.getMarketingCampaigns( + type = screen.type.value, + language = language, + fromNetwork = screen.fromNetwork, + // Omit the contract for a native coin (blank) — Retrofit drops null query params. + fromContractAddress = screen.fromContractAddress.ifBlank { null }, + toNetwork = screen.toNetwork, + toContractAddress = screen.toContractAddress.ifBlank { null }, + ) + is MarketingScreen.Onramp -> tangemTechApi.getMarketingCampaigns( + type = screen.type.value, + language = language, + fromFiat = screen.fromFiat, + toNetwork = screen.toNetwork, + toContractAddress = screen.toContractAddress.ifBlank { null }, + ) + is MarketingScreen.TokenDetails, + is MarketingScreen.TokenMarkets, + is MarketingScreen.Staking, + is MarketingScreen.Yield, + -> tangemTechApi.getMarketingCampaigns(type = screen.type.value, language = language, eTag = eTag) + } + } + + /** + * All error cases (304 not-modified, 5xx, network failure) degrade gracefully to the cached + * response. Returning an empty list when there is no cache is intentional — "no banner" is a + * normal state, not an error the caller needs to handle. + */ + private fun handleError(cached: MarketingCampaignsCacheEntry?): List { + return cached?.response?.let(::convert).orEmpty() + } + + private fun convert(response: MarketingCampaignsResponse): List = + converter.convertListIgnoreErrors(response.campaigns) { throwable -> + TangemLogger.w("Skipped invalid marketing campaign: ${throwable.message}") + } +} \ No newline at end of file diff --git a/data/marketing/src/main/kotlin/com/tangem/data/marketing/converter/MarketingCampaignConverter.kt b/data/marketing/src/main/kotlin/com/tangem/data/marketing/converter/MarketingCampaignConverter.kt new file mode 100644 index 0000000000..9a4ec1e436 --- /dev/null +++ b/data/marketing/src/main/kotlin/com/tangem/data/marketing/converter/MarketingCampaignConverter.kt @@ -0,0 +1,66 @@ +package com.tangem.data.marketing.converter + +import com.tangem.datasource.api.marketing.models.BannerDto +import com.tangem.datasource.api.marketing.models.CampaignDto +import com.tangem.datasource.api.marketing.models.CampaignTokenDto +import com.tangem.domain.marketing.models.MarketingBanner +import com.tangem.domain.marketing.models.MarketingCampaign +import com.tangem.domain.marketing.models.MarketingCampaignTarget +import com.tangem.domain.marketing.models.MarketingScreenType +import com.tangem.utils.converter.Converter + +internal class MarketingCampaignConverter : Converter { + + override fun convert(value: CampaignDto): MarketingCampaign { + val type = requireNotNull(MarketingScreenType.fromValue(value.type)) { "Unknown campaign type: ${value.type}" } + val banner = convertBanner(value.banner) + + require(banner.uiType != MarketingBanner.UiType.LINKED_TO_PROVIDER || !value.providerIds.isNullOrEmpty()) { + "linked_to_provider campaign ${value.id} has no providerIds" + } + + return MarketingCampaign( + id = value.id, + type = type, + priority = value.priority, + startAt = value.startAt, + endAt = value.endAt, + minAmount = value.minAmount, + maxAmount = value.maxAmount, + providerIds = value.providerIds, + banner = banner, + targets = value.tokens.orEmpty().mapNotNull(::convertTarget), + ) + } + + private fun convertBanner(dto: BannerDto) = MarketingBanner( + uiType = when (dto.uiType) { + "linked_to_provider" -> MarketingBanner.UiType.LINKED_TO_PROVIDER + else -> MarketingBanner.UiType.STANDALONE + }, + text = dto.text, + iconUrl = dto.icon, + iconAlign = when (dto.iconAlign) { + "left" -> MarketingBanner.IconAlign.LEFT + "right" -> MarketingBanner.IconAlign.RIGHT + else -> null + }, + bgColor = dto.bgColor, + deeplink = dto.deeplink, + isDismissible = dto.isDismissible, + ) + + private fun convertTarget(dto: CampaignTokenDto): MarketingCampaignTarget? { + val coingeckoId = dto.id + val networkId = dto.networkId + return when { + coingeckoId != null -> MarketingCampaignTarget.CoingeckoId(id = coingeckoId) + // contractAddress may be null — that's a native coin of [networkId], not an invalid target. + networkId != null -> MarketingCampaignTarget.NetworkContract( + networkId = networkId, + contractAddress = dto.contractAddress, + ) + else -> null + } + } +} \ No newline at end of file diff --git a/data/marketing/src/main/kotlin/com/tangem/data/marketing/di/MarketingDataModule.kt b/data/marketing/src/main/kotlin/com/tangem/data/marketing/di/MarketingDataModule.kt new file mode 100644 index 0000000000..a2b4987850 --- /dev/null +++ b/data/marketing/src/main/kotlin/com/tangem/data/marketing/di/MarketingDataModule.kt @@ -0,0 +1,91 @@ +package com.tangem.data.marketing.di + +import android.content.Context +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile +import com.squareup.moshi.Moshi +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.data.marketing.DefaultMarketingRepository +import com.tangem.data.marketing.converter.MarketingCampaignConverter +import com.tangem.data.marketing.featuretoggle.DefaultMarketingFeatureToggles +import com.tangem.data.marketing.store.DefaultMarketingCampaignsCacheStore +import com.tangem.data.marketing.store.DefaultMarketingDismissStore +import com.tangem.data.marketing.store.MarketingCampaignsCacheStore +import com.tangem.data.marketing.store.MarketingDismissStore +import com.tangem.datasource.api.marketing.models.MarketingCampaignsCacheEntry +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.di.NetworkMoshi +import com.tangem.datasource.utils.MoshiDataStoreSerializer +import com.tangem.datasource.utils.mapWithStringKeyTypes +import com.tangem.datasource.utils.setTypes +import com.tangem.domain.marketing.MarketingFeatureToggles +import com.tangem.domain.marketing.MarketingRepository +import com.tangem.utils.coroutines.AppCoroutineScope +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object MarketingDataModule { + + @Provides + @Singleton + fun provideMarketingCampaignsCacheStore( + @NetworkMoshi moshi: Moshi, + @ApplicationContext context: Context, + appScope: AppCoroutineScope, + ): MarketingCampaignsCacheStore = DefaultMarketingCampaignsCacheStore( + dataStore = DataStoreFactory.create( + serializer = MoshiDataStoreSerializer( + moshi = moshi, + types = mapWithStringKeyTypes(), + defaultValue = emptyMap(), + ), + produceFile = { context.dataStoreFile(fileName = "marketing_campaigns_cache") }, + scope = appScope, + ), + ) + + @Provides + @Singleton + fun provideMarketingDismissStore( + @NetworkMoshi moshi: Moshi, + @ApplicationContext context: Context, + appScope: AppCoroutineScope, + ): MarketingDismissStore = DefaultMarketingDismissStore( + dataStore = DataStoreFactory.create( + serializer = MoshiDataStoreSerializer( + moshi = moshi, + types = setTypes(), + defaultValue = emptySet(), + ), + produceFile = { context.dataStoreFile(fileName = "marketing_dismissed_banner_ids") }, + scope = appScope, + ), + ) + + @Provides + @Singleton + fun provideMarketingFeatureToggles(featureTogglesManager: FeatureTogglesManager): MarketingFeatureToggles = + DefaultMarketingFeatureToggles(featureTogglesManager) + + @Provides + @Singleton + fun provideMarketingRepository( + tangemTechApi: TangemTechApi, + cacheStore: MarketingCampaignsCacheStore, + dismissStore: MarketingDismissStore, + dispatchers: CoroutineDispatcherProvider, + ): MarketingRepository = DefaultMarketingRepository( + tangemTechApi = tangemTechApi, + cacheStore = cacheStore, + dismissStore = dismissStore, + converter = MarketingCampaignConverter(), + dispatchers = dispatchers, + ) +} \ No newline at end of file diff --git a/data/marketing/src/main/kotlin/com/tangem/data/marketing/featuretoggle/DefaultMarketingFeatureToggles.kt b/data/marketing/src/main/kotlin/com/tangem/data/marketing/featuretoggle/DefaultMarketingFeatureToggles.kt new file mode 100644 index 0000000000..80beb5b373 --- /dev/null +++ b/data/marketing/src/main/kotlin/com/tangem/data/marketing/featuretoggle/DefaultMarketingFeatureToggles.kt @@ -0,0 +1,13 @@ +package com.tangem.data.marketing.featuretoggle + +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.domain.marketing.MarketingFeatureToggles + +internal class DefaultMarketingFeatureToggles( + private val featureTogglesManager: FeatureTogglesManager, +) : MarketingFeatureToggles { + + override val isMarketingBannersEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1522_MARKETING_BANNERS_ENABLED) +} \ No newline at end of file diff --git a/data/marketing/src/main/kotlin/com/tangem/data/marketing/store/MarketingCampaignsCacheStore.kt b/data/marketing/src/main/kotlin/com/tangem/data/marketing/store/MarketingCampaignsCacheStore.kt new file mode 100644 index 0000000000..86dc96da65 --- /dev/null +++ b/data/marketing/src/main/kotlin/com/tangem/data/marketing/store/MarketingCampaignsCacheStore.kt @@ -0,0 +1,21 @@ +package com.tangem.data.marketing.store + +import androidx.datastore.core.DataStore +import com.tangem.datasource.api.marketing.models.MarketingCampaignsCacheEntry +import kotlinx.coroutines.flow.first + +interface MarketingCampaignsCacheStore { + suspend fun get(type: String): MarketingCampaignsCacheEntry? + suspend fun store(type: String, entry: MarketingCampaignsCacheEntry) +} + +internal class DefaultMarketingCampaignsCacheStore( + private val dataStore: DataStore>, +) : MarketingCampaignsCacheStore { + + override suspend fun get(type: String): MarketingCampaignsCacheEntry? = dataStore.data.first()[type] + + override suspend fun store(type: String, entry: MarketingCampaignsCacheEntry) { + dataStore.updateData { it + (type to entry) } + } +} \ No newline at end of file diff --git a/data/marketing/src/main/kotlin/com/tangem/data/marketing/store/MarketingDismissStore.kt b/data/marketing/src/main/kotlin/com/tangem/data/marketing/store/MarketingDismissStore.kt new file mode 100644 index 0000000000..71665fd706 --- /dev/null +++ b/data/marketing/src/main/kotlin/com/tangem/data/marketing/store/MarketingDismissStore.kt @@ -0,0 +1,20 @@ +package com.tangem.data.marketing.store + +import androidx.datastore.core.DataStore +import kotlinx.coroutines.flow.first + +interface MarketingDismissStore { + suspend fun getDismissedIds(): Set + suspend fun dismiss(id: Int) +} + +internal class DefaultMarketingDismissStore( + private val dataStore: DataStore>, +) : MarketingDismissStore { + + override suspend fun getDismissedIds(): Set = dataStore.data.first() + + override suspend fun dismiss(id: Int) { + dataStore.updateData { it + id } + } +} \ No newline at end of file diff --git a/data/marketing/src/test/kotlin/com/tangem/data/marketing/DefaultMarketingRepositoryTest.kt b/data/marketing/src/test/kotlin/com/tangem/data/marketing/DefaultMarketingRepositoryTest.kt new file mode 100644 index 0000000000..e752efa9e5 --- /dev/null +++ b/data/marketing/src/test/kotlin/com/tangem/data/marketing/DefaultMarketingRepositoryTest.kt @@ -0,0 +1,154 @@ +package com.tangem.data.marketing + +import com.google.common.truth.Truth.assertThat +import com.tangem.data.marketing.converter.MarketingCampaignConverter +import com.tangem.data.marketing.store.MarketingCampaignsCacheStore +import com.tangem.data.marketing.store.MarketingDismissStore +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code +import com.tangem.datasource.api.common.response.ETAG_HEADER +import com.tangem.datasource.api.marketing.models.BannerDto +import com.tangem.datasource.api.marketing.models.CampaignDto +import com.tangem.datasource.api.marketing.models.MarketingCampaignsCacheEntry +import com.tangem.datasource.api.marketing.models.MarketingCampaignsResponse +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.domain.marketing.models.MarketingScreen +import com.tangem.utils.SupportedLanguages +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 +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultMarketingRepositoryTest { + + private val tangemTechApi: TangemTechApi = mockk() + private val cacheStore: MarketingCampaignsCacheStore = mockk(relaxed = true) + private val dismissStore: MarketingDismissStore = mockk(relaxed = true) + + private val language = SupportedLanguages.getCurrentSupportedLanguageCode() + + private val repository = DefaultMarketingRepository( + tangemTechApi = tangemTechApi, + cacheStore = cacheStore, + dismissStore = dismissStore, + converter = MarketingCampaignConverter(), + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @BeforeEach + fun reset() { + clearMocks(tangemTechApi, cacheStore, dismissStore) + } + + private fun response(id: Int) = MarketingCampaignsResponse( + campaigns = listOf(CampaignDto(id = id, type = "token_details", priority = 1, banner = BannerDto(uiType = "standalone"))), + ) + + @Suppress("UNCHECKED_CAST") + private fun httpError(code: Code): ApiResponse = ApiResponse.Error( + cause = ApiResponseError.HttpException(code = code, message = null, errorBody = null), + ) as ApiResponse + + @Test + fun `GIVEN 200 for background type WHEN getCampaigns THEN stores etag and returns campaigns`() = runTest { + // Arrange + coEvery { cacheStore.get("token_details") } returns null + coEvery { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = null) } returns + ApiResponse.Success(data = response(id = 7), headers = mapOf(ETAG_HEADER to listOf("new-etag"))) + + // Act + val result = repository.getCampaigns(MarketingScreen.TokenDetails(networkId = "ethereum", contractAddress = "0x")) + + // Assert + assertThat(result.getOrNull()!!.map { it.id }).containsExactly(7) + coVerify(exactly = 1) { + cacheStore.store("token_details", MarketingCampaignsCacheEntry(eTag = "new-etag", response = response(id = 7))) + } + } + + @Test + fun `GIVEN 304 for background type WHEN getCampaigns THEN returns cached campaigns`() = runTest { + // Arrange + coEvery { cacheStore.get("token_details") } returns + MarketingCampaignsCacheEntry(eTag = "etag", response = response(id = 9)) + coEvery { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = "etag") } returns + httpError(Code.NOT_MODIFIED) + + // Act + val result = repository.getCampaigns(MarketingScreen.TokenDetails(networkId = "ethereum", contractAddress = "0x")) + + // Assert + assertThat(result.getOrNull()!!.map { it.id }).containsExactly(9) + coVerify(exactly = 0) { cacheStore.store(any(), any()) } + } + + @Test + fun `GIVEN 5xx with cache WHEN getCampaigns THEN returns cached`() = runTest { + // Arrange + coEvery { cacheStore.get("staking") } returns + MarketingCampaignsCacheEntry(eTag = "etag", response = response(id = 5)) + coEvery { tangemTechApi.getMarketingCampaigns(type = "staking", language = language, eTag = "etag") } returns + httpError(Code.SERVICE_UNAVAILABLE) + + // Act + val result = repository.getCampaigns(MarketingScreen.Staking(networkId = "ethereum", contractAddress = "0x")) + + // Assert + assertThat(result.getOrNull()!!.map { it.id }).containsExactly(5) + } + + @Test + fun `GIVEN 5xx without cache WHEN getCampaigns THEN returns empty`() = runTest { + // Arrange + coEvery { cacheStore.get("staking") } returns null + coEvery { tangemTechApi.getMarketingCampaigns(type = "staking", language = language, eTag = null) } returns + httpError(Code.INTERNAL_SERVER_ERROR) + + // Act + val result = repository.getCampaigns(MarketingScreen.Staking(networkId = "ethereum", contractAddress = "0x")) + + // Assert + assertThat(result.getOrNull()).isEmpty() + } + + @Test + fun `GIVEN swap screen WHEN getCampaigns THEN sends pair params and does not touch cache`() = runTest { + // Arrange + coEvery { + tangemTechApi.getMarketingCampaigns( + type = "swap", language = language, + fromNetwork = "ethereum", fromContractAddress = "0xFrom", + toNetwork = "bitcoin", toContractAddress = "0xTo", + ) + } returns ApiResponse.Success(data = response(id = 3)) + + // Act + val result = repository.getCampaigns( + MarketingScreen.Swap( + fromNetwork = "ethereum", fromContractAddress = "0xFrom", + toNetwork = "bitcoin", toContractAddress = "0xTo", + ), + ) + + // Assert + assertThat(result.getOrNull()!!.map { it.id }).containsExactly(3) + coVerify(exactly = 0) { cacheStore.get(any()) } + coVerify(exactly = 0) { cacheStore.store(any(), any()) } + } + + @Test + fun `GIVEN dismiss WHEN dismissBanner THEN delegates to dismiss store`() = runTest { + // Act + repository.dismissBanner(42) + + // Assert + coVerify(exactly = 1) { dismissStore.dismiss(42) } + } +} \ No newline at end of file diff --git a/data/marketing/src/test/kotlin/com/tangem/data/marketing/converter/MarketingCampaignConverterTest.kt b/data/marketing/src/test/kotlin/com/tangem/data/marketing/converter/MarketingCampaignConverterTest.kt new file mode 100644 index 0000000000..dd865ecad0 --- /dev/null +++ b/data/marketing/src/test/kotlin/com/tangem/data/marketing/converter/MarketingCampaignConverterTest.kt @@ -0,0 +1,125 @@ +package com.tangem.data.marketing.converter + +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.api.marketing.models.BannerDto +import com.tangem.datasource.api.marketing.models.CampaignDto +import com.tangem.datasource.api.marketing.models.CampaignTokenDto +import com.tangem.domain.marketing.models.MarketingBanner +import com.tangem.domain.marketing.models.MarketingCampaignTarget +import com.tangem.domain.marketing.models.MarketingScreenType +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class MarketingCampaignConverterTest { + + private val converter = MarketingCampaignConverter() + + private fun banner(uiType: String = "standalone", isDismissible: Boolean = true) = BannerDto( + uiType = uiType, + text = "Cashback", + icon = "https://x/star.webp", + iconAlign = "left", + bgColor = "#FF0011", + deeplink = "https://tangem.com", + isDismissible = isDismissible, + ) + + @Test + fun `GIVEN swap campaign WHEN convert THEN mapped with amounts and no targets`() { + // Arrange + val dto = CampaignDto( + id = 12, type = "swap", priority = 1, + minAmount = BigDecimal(50), maxAmount = BigDecimal(300), + providerIds = listOf("provider1"), tokens = null, + banner = banner(uiType = "linked_to_provider"), + ) + + // Act + val result = converter.convert(dto) + + // Assert + assertThat(result.id).isEqualTo(12) + assertThat(result.type).isEqualTo(MarketingScreenType.SWAP) + assertThat(result.minAmount).isEqualTo(BigDecimal(50)) + assertThat(result.banner.uiType).isEqualTo(MarketingBanner.UiType.LINKED_TO_PROVIDER) + assertThat(result.banner.iconAlign).isEqualTo(MarketingBanner.IconAlign.LEFT) + assertThat(result.targets).isEmpty() + } + + @Test + fun `GIVEN token_details campaign WHEN convert THEN network targets mapped`() { + // Arrange + val dto = CampaignDto( + id = 1, type = "token_details", priority = 2, banner = banner(), + tokens = listOf(CampaignTokenDto(networkId = "ethereum", contractAddress = "0xA0b8")), + ) + + // Act + val targets = converter.convert(dto).targets + + // Assert + assertThat(targets).containsExactly( + MarketingCampaignTarget.NetworkContract(networkId = "ethereum", contractAddress = "0xA0b8"), + ) + } + + @Test + fun `GIVEN native coin token (null contract) WHEN convert THEN network target with null contract`() { + // Arrange + val dto = CampaignDto( + id = 1, type = "yield", priority = 1, banner = banner(), + tokens = listOf(CampaignTokenDto(networkId = "bitcoin", contractAddress = null)), + ) + + // Act + val targets = converter.convert(dto).targets + + // Assert + assertThat(targets).containsExactly( + MarketingCampaignTarget.NetworkContract(networkId = "bitcoin", contractAddress = null), + ) + } + + @Test + fun `GIVEN markets campaign WHEN convert THEN coingecko targets mapped`() { + // Arrange + val dto = CampaignDto( + id = 1, type = "markets_token", priority = 1, banner = banner(), + tokens = listOf(CampaignTokenDto(id = "1696501400")), + ) + + // Act + val targets = converter.convert(dto).targets + + // Assert + assertThat(targets).containsExactly(MarketingCampaignTarget.CoingeckoId(id = "1696501400")) + } + + @Test + fun `GIVEN linked_to_provider without providerIds WHEN convertListIgnoreErrors THEN dropped`() { + // Arrange + val invalid = CampaignDto( + id = 1, type = "onramp", priority = 1, providerIds = emptyList(), + banner = banner(uiType = "linked_to_provider"), + ) + val valid = CampaignDto(id = 2, type = "onramp", priority = 2, banner = banner()) + + // Act + val result = converter.convertListIgnoreErrors(listOf(invalid, valid)) + + // Assert + assertThat(result.map { it.id }).containsExactly(2) + } + + @Test + fun `GIVEN unknown type WHEN convertListIgnoreErrors THEN dropped`() { + // Arrange + val dto = CampaignDto(id = 1, type = "carousel", priority = 1, banner = banner()) + + // Act + val result = converter.convertListIgnoreErrors(listOf(dto)) + + // Assert + assertThat(result).isEmpty() + } +} \ No newline at end of file diff --git a/data/marketing/src/test/kotlin/com/tangem/data/marketing/featuretoggle/DefaultMarketingFeatureTogglesTest.kt b/data/marketing/src/test/kotlin/com/tangem/data/marketing/featuretoggle/DefaultMarketingFeatureTogglesTest.kt new file mode 100644 index 0000000000..9f241091d2 --- /dev/null +++ b/data/marketing/src/test/kotlin/com/tangem/data/marketing/featuretoggle/DefaultMarketingFeatureTogglesTest.kt @@ -0,0 +1,32 @@ +package com.tangem.data.marketing.featuretoggle + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test + +internal class DefaultMarketingFeatureTogglesTest { + + private val featureTogglesManager: FeatureTogglesManager = mockk() + private val featureToggles = DefaultMarketingFeatureToggles(featureTogglesManager) + + @Test + fun `GIVEN toggle enabled WHEN isMarketingBannersEnabled THEN true`() { + // Arrange + every { featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1522_MARKETING_BANNERS_ENABLED) } returns true + + // Assert + assertThat(featureToggles.isMarketingBannersEnabled).isTrue() + } + + @Test + fun `GIVEN toggle disabled WHEN isMarketingBannersEnabled THEN false`() { + // Arrange + every { featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1522_MARKETING_BANNERS_ENABLED) } returns false + + // Assert + assertThat(featureToggles.isMarketingBannersEnabled).isFalse() + } +} \ No newline at end of file diff --git a/data/marketing/src/test/kotlin/com/tangem/data/marketing/store/MarketingStoresTest.kt b/data/marketing/src/test/kotlin/com/tangem/data/marketing/store/MarketingStoresTest.kt new file mode 100644 index 0000000000..eb5458f533 --- /dev/null +++ b/data/marketing/src/test/kotlin/com/tangem/data/marketing/store/MarketingStoresTest.kt @@ -0,0 +1,64 @@ +package com.tangem.data.marketing.store + +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.api.marketing.models.BannerDto +import com.tangem.datasource.api.marketing.models.CampaignDto +import com.tangem.datasource.api.marketing.models.MarketingCampaignsCacheEntry +import com.tangem.datasource.api.marketing.models.MarketingCampaignsResponse +import com.tangem.test.core.datastore.MockStateDataStore +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +internal class MarketingStoresTest { + + private val cacheStore = DefaultMarketingCampaignsCacheStore( + dataStore = MockStateDataStore>(default = emptyMap()), + ) + private val dismissStore = DefaultMarketingDismissStore( + dataStore = MockStateDataStore>(default = emptySet()), + ) + + private fun entry(eTag: String?) = MarketingCampaignsCacheEntry( + eTag = eTag, + response = MarketingCampaignsResponse( + campaigns = listOf( + CampaignDto(id = 1, type = "token_details", priority = 1, banner = BannerDto(uiType = "standalone")), + ), + ), + ) + + @Test + fun `GIVEN no cache WHEN get THEN null`() = runTest { + assertThat(cacheStore.get("token_details")).isNull() + } + + @Test + fun `GIVEN stored entry WHEN get same type THEN returns it`() = runTest { + // Arrange + cacheStore.store("token_details", entry(eTag = "abc")) + + // Act + val result = cacheStore.get("token_details") + + // Assert + assertThat(result?.eTag).isEqualTo("abc") + assertThat(result?.response?.campaigns).hasSize(1) + assertThat(cacheStore.get("staking")).isNull() + } + + @Test + fun `GIVEN no dismissed WHEN getDismissedIds THEN empty`() = runTest { + assertThat(dismissStore.getDismissedIds()).isEmpty() + } + + @Test + fun `GIVEN dismissed ids WHEN dismiss again THEN accumulates without duplicates`() = runTest { + // Act + dismissStore.dismiss(12) + dismissStore.dismiss(12) + dismissStore.dismiss(34) + + // Assert + assertThat(dismissStore.getDismissedIds()).containsExactly(12, 34) + } +} \ No newline at end of file diff --git a/domain/marketing/build.gradle.kts b/domain/marketing/build.gradle.kts new file mode 100644 index 0000000000..7492f40f48 --- /dev/null +++ b/domain/marketing/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + id("configuration") +} + +dependencies { + implementation(projects.domain.marketing.models) + + implementation(deps.kotlin.coroutines) + implementation(deps.arrow.core) + + testImplementation(projects.test.core) +} \ No newline at end of file diff --git a/domain/marketing/models/build.gradle.kts b/domain/marketing/models/build.gradle.kts new file mode 100644 index 0000000000..166cd65bb7 --- /dev/null +++ b/domain/marketing/models/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + id("configuration") +} + +dependencies { + testImplementation(projects.test.core) +} \ No newline at end of file diff --git a/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingBanner.kt b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingBanner.kt new file mode 100644 index 0000000000..c5ba29beb2 --- /dev/null +++ b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingBanner.kt @@ -0,0 +1,16 @@ +package com.tangem.domain.marketing.models + +data class MarketingBanner( + val uiType: UiType, + val text: String?, + val iconUrl: String?, + val iconAlign: IconAlign?, + val bgColor: String?, + val deeplink: String?, + val isDismissible: Boolean, +) { + + enum class UiType { STANDALONE, LINKED_TO_PROVIDER } + + enum class IconAlign { LEFT, RIGHT } +} \ No newline at end of file diff --git a/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaign.kt b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaign.kt new file mode 100644 index 0000000000..2465834ecc --- /dev/null +++ b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaign.kt @@ -0,0 +1,16 @@ +package com.tangem.domain.marketing.models + +import java.math.BigDecimal + +data class MarketingCampaign( + val id: Int, + val type: MarketingScreenType, + val priority: Int, + val startAt: String?, + val endAt: String?, + val minAmount: BigDecimal?, + val maxAmount: BigDecimal?, + val providerIds: List?, + val banner: MarketingBanner, + val targets: List, +) \ No newline at end of file diff --git a/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaignTarget.kt b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaignTarget.kt new file mode 100644 index 0000000000..a3dab4ee6e --- /dev/null +++ b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaignTarget.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.marketing.models + +sealed interface MarketingCampaignTarget { + + /** + * token_details / staking / yield campaigns target a network + contract address. + * [contractAddress] is `null` for native coins (the backend omits it), so a `null`/blank contract + * matches the coin of that network. + */ + data class NetworkContract(val networkId: String, val contractAddress: String?) : MarketingCampaignTarget + + /** markets_token campaigns target a CoinGecko token id. */ + data class CoingeckoId(val id: String) : MarketingCampaignTarget +} \ No newline at end of file diff --git a/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingScreen.kt b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingScreen.kt new file mode 100644 index 0000000000..f01e2bf695 --- /dev/null +++ b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingScreen.kt @@ -0,0 +1,43 @@ +package com.tangem.domain.marketing.models + +/** + * Screen-specific request context. swap/onramp carry the pair params sent to the backend; background types + * carry the on-screen token identity used for client-side target matching (the request itself sends only [type]). + */ +sealed interface MarketingScreen { + + val type: MarketingScreenType + + data class Swap( + val fromNetwork: String, + val fromContractAddress: String, + val toNetwork: String, + val toContractAddress: String, + ) : MarketingScreen { + override val type: MarketingScreenType = MarketingScreenType.SWAP + } + + data class Onramp( + val fromFiat: String, + val toNetwork: String, + val toContractAddress: String, + ) : MarketingScreen { + override val type: MarketingScreenType = MarketingScreenType.ONRAMP + } + + data class TokenDetails(val networkId: String, val contractAddress: String) : MarketingScreen { + override val type: MarketingScreenType = MarketingScreenType.TOKEN_DETAILS + } + + data class TokenMarkets(val coingeckoId: String) : MarketingScreen { + override val type: MarketingScreenType = MarketingScreenType.TOKEN_MARKETS + } + + data class Staking(val networkId: String, val contractAddress: String) : MarketingScreen { + override val type: MarketingScreenType = MarketingScreenType.STAKING + } + + data class Yield(val networkId: String, val contractAddress: String) : MarketingScreen { + override val type: MarketingScreenType = MarketingScreenType.YIELD + } +} \ No newline at end of file diff --git a/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingScreenType.kt b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingScreenType.kt new file mode 100644 index 0000000000..80c38779bf --- /dev/null +++ b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingScreenType.kt @@ -0,0 +1,19 @@ +package com.tangem.domain.marketing.models + +enum class MarketingScreenType(val value: String) { + SWAP("swap"), + ONRAMP("onramp"), + TOKEN_DETAILS("token_details"), + TOKEN_MARKETS("markets_token"), + STAKING("staking"), + YIELD("yield"), + ; + + /** Background types are ETag-cached; swap/onramp are always re-requested per pair selection. */ + val isCacheable: Boolean + get() = this != SWAP && this != ONRAMP + + companion object { + fun fromValue(value: String): MarketingScreenType? = entries.firstOrNull { it.value == value } + } +} \ No newline at end of file diff --git a/domain/marketing/models/src/test/kotlin/com/tangem/domain/marketing/models/MarketingScreenTypeTest.kt b/domain/marketing/models/src/test/kotlin/com/tangem/domain/marketing/models/MarketingScreenTypeTest.kt new file mode 100644 index 0000000000..cb6f375027 --- /dev/null +++ b/domain/marketing/models/src/test/kotlin/com/tangem/domain/marketing/models/MarketingScreenTypeTest.kt @@ -0,0 +1,36 @@ +package com.tangem.domain.marketing.models + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +internal class MarketingScreenTypeTest { + + @Test + fun `GIVEN screen types WHEN read value THEN matches backend snake_case contract`() { + // Assert + assertThat(MarketingScreenType.SWAP.value).isEqualTo("swap") + assertThat(MarketingScreenType.ONRAMP.value).isEqualTo("onramp") + assertThat(MarketingScreenType.TOKEN_DETAILS.value).isEqualTo("token_details") + assertThat(MarketingScreenType.TOKEN_MARKETS.value).isEqualTo("markets_token") + assertThat(MarketingScreenType.STAKING.value).isEqualTo("staking") + assertThat(MarketingScreenType.YIELD.value).isEqualTo("yield") + } + + @Test + fun `GIVEN known value WHEN fromValue THEN returns type ELSE null`() { + // Assert + assertThat(MarketingScreenType.fromValue("token_details")).isEqualTo(MarketingScreenType.TOKEN_DETAILS) + assertThat(MarketingScreenType.fromValue("unknown")).isNull() + } + + @Test + fun `GIVEN screen type WHEN isCacheable THEN only background types cached`() { + // Assert + assertThat(MarketingScreenType.SWAP.isCacheable).isFalse() + assertThat(MarketingScreenType.ONRAMP.isCacheable).isFalse() + assertThat(MarketingScreenType.TOKEN_DETAILS.isCacheable).isTrue() + assertThat(MarketingScreenType.TOKEN_MARKETS.isCacheable).isTrue() + assertThat(MarketingScreenType.STAKING.isCacheable).isTrue() + assertThat(MarketingScreenType.YIELD.isCacheable).isTrue() + } +} \ No newline at end of file diff --git a/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/DismissMarketingBannerUseCase.kt b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/DismissMarketingBannerUseCase.kt new file mode 100644 index 0000000000..7acde6814a --- /dev/null +++ b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/DismissMarketingBannerUseCase.kt @@ -0,0 +1,12 @@ +package com.tangem.domain.marketing + +import arrow.core.Either + +class DismissMarketingBannerUseCase( + private val repository: MarketingRepository, +) { + + suspend operator fun invoke(campaignId: Int): Either = Either.catch { + repository.dismissBanner(campaignId) + } +} \ No newline at end of file diff --git a/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCase.kt b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCase.kt new file mode 100644 index 0000000000..29107a7c3f --- /dev/null +++ b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCase.kt @@ -0,0 +1,85 @@ +package com.tangem.domain.marketing + +import arrow.core.Either +import com.tangem.domain.marketing.models.MarketingCampaign +import com.tangem.domain.marketing.models.MarketingCampaignTarget +import com.tangem.domain.marketing.models.MarketingScreen +import com.tangem.domain.marketing.models.MarketingScreenType +import java.math.BigDecimal + +class GetMarketingBannerUseCase( + private val repository: MarketingRepository, + private val featureToggles: MarketingFeatureToggles, +) { + + /** + * Returns campaigns for [screen], filtered (dismissed, target match, USD amount range) and sorted by priority. + * + * @param amountUsd USD equivalent of the entered amount (swap/onramp only). When null, the amount filter is skipped. + */ + suspend operator fun invoke( + screen: MarketingScreen, + amountUsd: BigDecimal? = null, + ): Either> { + if (!featureToggles.isMarketingBannersEnabled) return Either.Right(emptyList()) + + return repository.getCampaigns(screen).map { campaigns -> + val dismissed = repository.getDismissedBannerIds() + campaigns.asSequence() + .filterNot { it.id in dismissed } + .filter { matchesTarget(it, screen) } + .filter { matchesAmount(it, amountUsd) } + .sortedBy { it.priority } + .toList() + } + } + + private fun matchesTarget(campaign: MarketingCampaign, screen: MarketingScreen): Boolean = when (screen) { + is MarketingScreen.Swap, is MarketingScreen.Onramp -> true // matched server-side by pair params + is MarketingScreen.TokenDetails -> matchesNetworkContract(campaign, screen.networkId, screen.contractAddress) + is MarketingScreen.Staking -> matchesNetworkContract(campaign, screen.networkId, screen.contractAddress) + is MarketingScreen.Yield -> matchesNetworkContract(campaign, screen.networkId, screen.contractAddress) + is MarketingScreen.TokenMarkets -> campaign.targets.any { target -> + target is MarketingCampaignTarget.CoingeckoId && target.id == screen.coingeckoId + } + } + + private fun matchesNetworkContract( + campaign: MarketingCampaign, + networkId: String, + contractAddress: String, + ): Boolean { + return campaign.targets.any { target -> + target is MarketingCampaignTarget.NetworkContract && + target.networkId == networkId && + contractAddressMatches(target = target.contractAddress, screen = contractAddress) + } + } + + /** + * Native coins have no contract address: the backend sends `contractAddress: null` and the screen + * passes an empty string, so blank/null on both sides is a native-coin match. Otherwise the + * contracts must match case-insensitively. + */ + private fun contractAddressMatches(target: String?, screen: String): Boolean { + val normalizedTarget = target?.takeIf { it.isNotBlank() } + val normalizedScreen = screen.takeIf { it.isNotBlank() } + return when { + normalizedTarget == null && normalizedScreen == null -> true + normalizedTarget != null && normalizedScreen != null -> + normalizedTarget.equals(normalizedScreen, ignoreCase = true) + else -> false + } + } + + private fun matchesAmount(campaign: MarketingCampaign, amountUsd: BigDecimal?): Boolean { + val isAmountScreen = campaign.type == MarketingScreenType.SWAP || campaign.type == MarketingScreenType.ONRAMP + if (!isAmountScreen || amountUsd == null) return true + + val min = campaign.minAmount + val max = campaign.maxAmount + if (min != null && amountUsd < min) return false + if (max != null && amountUsd > max) return false + return true + } +} \ No newline at end of file diff --git a/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/MarketingFeatureToggles.kt b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/MarketingFeatureToggles.kt new file mode 100644 index 0000000000..03f3ea2efb --- /dev/null +++ b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/MarketingFeatureToggles.kt @@ -0,0 +1,5 @@ +package com.tangem.domain.marketing + +interface MarketingFeatureToggles { + val isMarketingBannersEnabled: Boolean +} \ No newline at end of file diff --git a/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/MarketingRepository.kt b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/MarketingRepository.kt new file mode 100644 index 0000000000..77476650b3 --- /dev/null +++ b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/MarketingRepository.kt @@ -0,0 +1,16 @@ +package com.tangem.domain.marketing + +import arrow.core.Either +import com.tangem.domain.marketing.models.MarketingCampaign +import com.tangem.domain.marketing.models.MarketingScreen + +interface MarketingRepository { + + /** Fetches campaigns for [screen]. Returns Right(emptyList()) when there is nothing to show (incl. 5xx without cache). */ + suspend fun getCampaigns(screen: MarketingScreen): Either> + + /** Ids of campaigns whose banner the user has dismissed (stored client-side). */ + suspend fun getDismissedBannerIds(): Set + + suspend fun dismissBanner(campaignId: Int) +} \ No newline at end of file diff --git a/domain/marketing/src/test/kotlin/com/tangem/domain/marketing/DismissMarketingBannerUseCaseTest.kt b/domain/marketing/src/test/kotlin/com/tangem/domain/marketing/DismissMarketingBannerUseCaseTest.kt new file mode 100644 index 0000000000..eb8d00610c --- /dev/null +++ b/domain/marketing/src/test/kotlin/com/tangem/domain/marketing/DismissMarketingBannerUseCaseTest.kt @@ -0,0 +1,34 @@ +package com.tangem.domain.marketing + +import com.google.common.truth.Truth.assertThat +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 +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DismissMarketingBannerUseCaseTest { + + private val repository: MarketingRepository = mockk() + private val useCase = DismissMarketingBannerUseCase(repository) + + @BeforeEach + fun reset() = clearMocks(repository) + + @Test + fun `GIVEN campaign id WHEN invoke THEN repository dismiss called and Right returned`() = runTest { + // Arrange + coEvery { repository.dismissBanner(7) } returns Unit + + // Act + val result = useCase(7) + + // Assert + assertThat(result.isRight()).isTrue() + coVerify(exactly = 1) { repository.dismissBanner(7) } + } +} \ No newline at end of file diff --git a/domain/marketing/src/test/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCaseTest.kt b/domain/marketing/src/test/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCaseTest.kt new file mode 100644 index 0000000000..ddef65713a --- /dev/null +++ b/domain/marketing/src/test/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCaseTest.kt @@ -0,0 +1,291 @@ +package com.tangem.domain.marketing + +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.marketing.models.MarketingBanner +import com.tangem.domain.marketing.models.MarketingCampaign +import com.tangem.domain.marketing.models.MarketingCampaignTarget +import com.tangem.domain.marketing.models.MarketingScreen +import com.tangem.domain.marketing.models.MarketingScreenType +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class GetMarketingBannerUseCaseTest { + + private val repository: MarketingRepository = mockk() + private val featureToggles: MarketingFeatureToggles = mockk() + private val useCase = GetMarketingBannerUseCase(repository, featureToggles) + + @BeforeEach + fun reset() { + clearMocks(repository, featureToggles) + every { featureToggles.isMarketingBannersEnabled } returns true + coEvery { repository.getDismissedBannerIds() } returns emptySet() + } + + private fun banner() = MarketingBanner( + uiType = MarketingBanner.UiType.STANDALONE, text = null, iconUrl = null, + iconAlign = null, bgColor = null, deeplink = null, isDismissible = true, + ) + + private fun campaign( + id: Int, + type: MarketingScreenType, + priority: Int, + minAmount: BigDecimal? = null, + maxAmount: BigDecimal? = null, + targets: List = emptyList(), + ) = MarketingCampaign( + id = id, + type = type, + priority = priority, + startAt = null, + endAt = null, + minAmount = minAmount, + maxAmount = maxAmount, + providerIds = null, + banner = banner(), + targets = targets, + ) + + private val swapScreen = MarketingScreen.Swap("eth", "0xF", "btc", "0xT") + private val tokenScreen = MarketingScreen.TokenDetails(networkId = "ethereum", contractAddress = "0xA0b8") + private val stakingScreen = MarketingScreen.Staking(networkId = "ethereum", contractAddress = "0xA0b8") + private val yieldScreen = MarketingScreen.Yield(networkId = "ethereum", contractAddress = "0xA0b8") + + @Test + fun `GIVEN toggle disabled WHEN invoke THEN empty without touching repository`() = runTest { + // Arrange + every { featureToggles.isMarketingBannersEnabled } returns false + + // Act + val result = useCase(swapScreen) + + // Assert + assertThat(result.getOrNull()).isEmpty() + coVerify(exactly = 0) { repository.getCampaigns(any()) } + coVerify(exactly = 0) { repository.getDismissedBannerIds() } + } + + @Test + fun `GIVEN several campaigns WHEN invoke THEN sorted by priority ascending`() = runTest { + // Arrange + coEvery { repository.getCampaigns(swapScreen) } returns listOf( + campaign(id = 1, type = MarketingScreenType.SWAP, priority = 3), + campaign(id = 2, type = MarketingScreenType.SWAP, priority = 1), + campaign(id = 3, type = MarketingScreenType.SWAP, priority = 2), + ).right() + + // Act + val result = useCase(swapScreen) + + // Assert + assertThat(result.getOrNull()?.map { it.id }).containsExactly(2, 3, 1).inOrder() + } + + @Test + fun `GIVEN dismissed id WHEN invoke THEN dismissed campaign filtered out`() = runTest { + // Arrange + coEvery { repository.getDismissedBannerIds() } returns setOf(2) + coEvery { repository.getCampaigns(swapScreen) } returns listOf( + campaign(id = 1, type = MarketingScreenType.SWAP, priority = 1), + campaign(id = 2, type = MarketingScreenType.SWAP, priority = 2), + ).right() + + // Act + val result = useCase(swapScreen) + + // Assert + assertThat(result.getOrNull()?.map { it.id }).containsExactly(1) + } + + @Test + fun `GIVEN amount below min WHEN invoke swap THEN campaign filtered out`() = runTest { + // Arrange + coEvery { repository.getCampaigns(swapScreen) } returns listOf( + campaign(id = 1, type = MarketingScreenType.SWAP, priority = 1, minAmount = BigDecimal(50), maxAmount = BigDecimal(300)), + ).right() + + // Act + val result = useCase(swapScreen, amountUsd = BigDecimal(25)) + + // Assert + assertThat(result.getOrNull()).isEmpty() + } + + @Test + fun `GIVEN amount within range WHEN invoke swap THEN campaign kept`() = runTest { + // Arrange + coEvery { repository.getCampaigns(swapScreen) } returns listOf( + campaign(id = 1, type = MarketingScreenType.SWAP, priority = 1, minAmount = BigDecimal(50), maxAmount = BigDecimal(300)), + ).right() + + // Act + val result = useCase(swapScreen, amountUsd = BigDecimal(100)) + + // Assert + assertThat(result.getOrNull()?.map { it.id }).containsExactly(1) + } + + @Test + fun `GIVEN amount above max WHEN invoke swap THEN campaign filtered out`() = runTest { + // Arrange + coEvery { repository.getCampaigns(swapScreen) } returns listOf( + campaign(id = 1, type = MarketingScreenType.SWAP, priority = 1, minAmount = BigDecimal(50), maxAmount = BigDecimal(300)), + ).right() + + // Act + val result = useCase(swapScreen, amountUsd = BigDecimal(500)) + + // Assert + assertThat(result.getOrNull()).isEmpty() + } + + @Test + fun `GIVEN null amount WHEN invoke swap THEN amount filter skipped`() = runTest { + // Arrange + coEvery { repository.getCampaigns(swapScreen) } returns listOf( + campaign(id = 1, type = MarketingScreenType.SWAP, priority = 1, minAmount = BigDecimal(50)), + ).right() + + // Act + val result = useCase(swapScreen, amountUsd = null) + + // Assert + assertThat(result.getOrNull()?.map { it.id }).containsExactly(1) + } + + @Test + fun `GIVEN background type WHEN invoke THEN only campaigns matching the on-screen token kept`() = runTest { + // Arrange + coEvery { repository.getCampaigns(tokenScreen) } returns listOf( + campaign( + id = 1, type = MarketingScreenType.TOKEN_DETAILS, priority = 1, + targets = listOf(MarketingCampaignTarget.NetworkContract("ethereum", "0xA0b8")), + ), + campaign( + id = 2, type = MarketingScreenType.TOKEN_DETAILS, priority = 2, + targets = listOf(MarketingCampaignTarget.NetworkContract("bitcoin", "0xOther")), + ), + ).right() + + // Act + val result = useCase(tokenScreen) + + // Assert + assertThat(result.getOrNull()?.map { it.id }).containsExactly(1) + } + + @Test + fun `GIVEN token markets screen WHEN invoke THEN only campaigns matching the coingecko id kept`() = runTest { + // Arrange + val marketsScreen = MarketingScreen.TokenMarkets(coingeckoId = "1696501400") + coEvery { repository.getCampaigns(marketsScreen) } returns listOf( + campaign( + id = 1, type = MarketingScreenType.TOKEN_MARKETS, priority = 1, + targets = listOf(MarketingCampaignTarget.CoingeckoId("1696501400")), + ), + campaign( + id = 2, type = MarketingScreenType.TOKEN_MARKETS, priority = 2, + targets = listOf(MarketingCampaignTarget.CoingeckoId("other")), + ), + ).right() + + // Act + val result = useCase(marketsScreen) + + // Assert + assertThat(result.getOrNull()?.map { it.id }).containsExactly(1) + } + + @Test + fun `GIVEN staking screen WHEN invoke THEN only campaigns matching the on-screen token kept`() = runTest { + // Arrange + coEvery { repository.getCampaigns(stakingScreen) } returns listOf( + campaign( + id = 1, type = MarketingScreenType.STAKING, priority = 1, + targets = listOf(MarketingCampaignTarget.NetworkContract("ethereum", "0xA0b8")), + ), + campaign( + id = 2, type = MarketingScreenType.STAKING, priority = 2, + targets = listOf(MarketingCampaignTarget.NetworkContract("bitcoin", "0xOther")), + ), + ).right() + + // Act + val result = useCase(stakingScreen) + + // Assert + assertThat(result.getOrNull()?.map { it.id }).containsExactly(1) + } + + @Test + fun `GIVEN yield screen WHEN invoke THEN only campaigns matching the on-screen token kept`() = runTest { + // Arrange + coEvery { repository.getCampaigns(yieldScreen) } returns listOf( + campaign( + id = 1, type = MarketingScreenType.YIELD, priority = 1, + targets = listOf(MarketingCampaignTarget.NetworkContract("ethereum", "0xA0b8")), + ), + campaign( + id = 2, type = MarketingScreenType.YIELD, priority = 2, + targets = listOf(MarketingCampaignTarget.NetworkContract("bitcoin", "0xOther")), + ), + ).right() + + // Act + val result = useCase(yieldScreen) + + // Assert + assertThat(result.getOrNull()?.map { it.id }).containsExactly(1) + } + + @Test + fun `GIVEN contract address differing only in case WHEN invoke THEN campaign matched`() = runTest { + // Arrange + val screen = MarketingScreen.TokenDetails(networkId = "ethereum", contractAddress = "0xA0B8") + coEvery { repository.getCampaigns(screen) } returns listOf( + campaign( + id = 1, type = MarketingScreenType.TOKEN_DETAILS, priority = 1, + targets = listOf(MarketingCampaignTarget.NetworkContract("ethereum", "0xa0b8")), + ), + ).right() + + // Act + val result = useCase(screen) + + // Assert + assertThat(result.getOrNull()?.map { it.id }).containsExactly(1) + } + + @Test + fun `GIVEN native coin screen WHEN campaign targets that coin with null contract THEN matched`() = runTest { + // Arrange — native coin: screen contract is blank, target contract is null + val screen = MarketingScreen.Yield(networkId = "bitcoin", contractAddress = "") + coEvery { repository.getCampaigns(screen) } returns listOf( + campaign( + id = 1, type = MarketingScreenType.YIELD, priority = 1, + targets = listOf(MarketingCampaignTarget.NetworkContract("bitcoin", contractAddress = null)), + ), + campaign( + id = 2, type = MarketingScreenType.YIELD, priority = 2, + targets = listOf(MarketingCampaignTarget.NetworkContract("ethereum", contractAddress = null)), + ), + ).right() + + // Act + val result = useCase(screen) + + // Assert — only the native coin of the on-screen network matches + assertThat(result.getOrNull()?.map { it.id }).containsExactly(1) + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index aac87637cc..11a94fe444 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -414,6 +414,8 @@ include(":domain:onramp:models") include(":domain:offramp") include(":domain:stories") include(":domain:stories:models") +include(":domain:marketing") +include(":domain:marketing:models") include(":domain:nft") include(":domain:nft:models") include(":domain:hot-wallet") @@ -459,6 +461,7 @@ include(":data:visa") include(":data:payment") include(":data:virtual-account") include(":data:stories") +include(":data:marketing") include(":data:onboarding") include(":data:dynamic-addresses") include(":data:feedback")