Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-08 19:32:44 +05:00
parent 33efe2ae52
commit 499b7c04ea
33 changed files with 1502 additions and 0 deletions

View file

@ -155,6 +155,8 @@ dependencies {
implementation(projects.domain.onramp) implementation(projects.domain.onramp)
implementation(projects.domain.stories) implementation(projects.domain.stories)
implementation(projects.domain.stories.models) implementation(projects.domain.stories.models)
implementation(projects.domain.marketing)
implementation(projects.domain.marketing.models)
implementation(projects.domain.networks) implementation(projects.domain.networks)
implementation(projects.domain.quotes) implementation(projects.domain.quotes)
implementation(projects.domain.notifications) implementation(projects.domain.notifications)
@ -209,6 +211,7 @@ dependencies {
implementation(projects.data.transaction) implementation(projects.data.transaction)
implementation(projects.data.visa) implementation(projects.data.visa)
implementation(projects.data.stories) implementation(projects.data.stories)
implementation(projects.data.marketing)
implementation(projects.data.onboarding) implementation(projects.data.onboarding)
implementation(projects.data.dynamicAddresses) implementation(projects.data.dynamicAddresses)
implementation(projects.data.feedback) implementation(projects.data.feedback)

View file

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

View file

@ -178,5 +178,9 @@
{ {
"name": "TWI_1637_CASHBACK_AND_REACTIVATION_CAMPAIGNS_ENABLED", "name": "TWI_1637_CASHBACK_AND_REACTIVATION_CAMPAIGNS_ENABLED",
"version": "6.0.1" "version": "6.0.1"
},
{
"name": "TWI_1522_MARKETING_BANNERS_ENABLED",
"version": "6.0.1"
} }
] ]

View file

@ -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,
)

View file

@ -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<CampaignDto>,
)
@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<String>? = null,
@Json(name = "tokens") val tokens: List<CampaignTokenDto>? = 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,
)

View file

@ -3,6 +3,7 @@ package com.tangem.datasource.api.tangemTech
import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.promotion.models.CreatePromotionRegistrationBody import com.tangem.datasource.api.promotion.models.CreatePromotionRegistrationBody
import com.tangem.datasource.api.promotion.models.PromotionRegistrationResponse import com.tangem.datasource.api.promotion.models.PromotionRegistrationResponse
import com.tangem.datasource.api.marketing.models.MarketingCampaignsResponse
import com.tangem.datasource.api.promotion.models.PromotionsResponse import com.tangem.datasource.api.promotion.models.PromotionsResponse
import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse
import com.tangem.datasource.api.stories.models.StoryContentResponse import com.tangem.datasource.api.stories.models.StoryContentResponse
@ -242,4 +243,18 @@ interface TangemTechApi {
@GET("v1/earn/networks") @GET("v1/earn/networks")
suspend fun getEarnNetworks(@Query("type") type: String? = null): ApiResponse<EarnNetworkListResponse> suspend fun getEarnNetworks(@Query("type") type: String? = null): ApiResponse<EarnNetworkListResponse>
// endregion // 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<MarketingCampaignsResponse>
// endregion
} }

View file

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

View file

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

View file

@ -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<Throwable, List<MarketingCampaign>> =
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<Int> = dismissStore.getDismissedIds()
override suspend fun dismissBanner(campaignId: Int) = dismissStore.dismiss(campaignId)
private suspend fun requestCampaigns(
screen: MarketingScreen,
eTag: String?,
): ApiResponse<MarketingCampaignsResponse> {
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<MarketingCampaign> {
return cached?.response?.let(::convert).orEmpty()
}
private fun convert(response: MarketingCampaignsResponse): List<MarketingCampaign> =
converter.convertListIgnoreErrors(response.campaigns) { throwable ->
TangemLogger.w("Skipped invalid marketing campaign: ${throwable.message}")
}
}

View file

@ -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<CampaignDto, MarketingCampaign> {
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
}
}
}

View file

@ -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<MarketingCampaignsCacheEntry>(),
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<Int>(),
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,
)
}

View file

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

View file

@ -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<Map<String, MarketingCampaignsCacheEntry>>,
) : 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) }
}
}

View file

@ -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<Int>
suspend fun dismiss(id: Int)
}
internal class DefaultMarketingDismissStore(
private val dataStore: DataStore<Set<Int>>,
) : MarketingDismissStore {
override suspend fun getDismissedIds(): Set<Int> = dataStore.data.first()
override suspend fun dismiss(id: Int) {
dataStore.updateData { it + id }
}
}

View file

@ -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<MarketingCampaignsResponse> = ApiResponse.Error(
cause = ApiResponseError.HttpException(code = code, message = null, errorBody = null),
) as ApiResponse<MarketingCampaignsResponse>
@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) }
}
}

View file

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

View file

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

View file

@ -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<Map<String, MarketingCampaignsCacheEntry>>(default = emptyMap()),
)
private val dismissStore = DefaultMarketingDismissStore(
dataStore = MockStateDataStore<Set<Int>>(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)
}
}

View file

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

View file

@ -0,0 +1,8 @@
plugins {
alias(deps.plugins.kotlin.jvm)
id("configuration")
}
dependencies {
testImplementation(projects.test.core)
}

View file

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

View file

@ -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<String>?,
val banner: MarketingBanner,
val targets: List<MarketingCampaignTarget>,
)

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Throwable, Unit> = Either.catch {
repository.dismissBanner(campaignId)
}
}

View file

@ -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<Throwable, List<MarketingCampaign>> {
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
}
}

View file

@ -0,0 +1,5 @@
package com.tangem.domain.marketing
interface MarketingFeatureToggles {
val isMarketingBannersEnabled: Boolean
}

View file

@ -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<Throwable, List<MarketingCampaign>>
/** Ids of campaigns whose banner the user has dismissed (stored client-side). */
suspend fun getDismissedBannerIds(): Set<Int>
suspend fun dismissBanner(campaignId: Int)
}

View file

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

View file

@ -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<MarketingCampaignTarget> = 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)
}
}

View file

@ -414,6 +414,8 @@ include(":domain:onramp:models")
include(":domain:offramp") include(":domain:offramp")
include(":domain:stories") include(":domain:stories")
include(":domain:stories:models") include(":domain:stories:models")
include(":domain:marketing")
include(":domain:marketing:models")
include(":domain:nft") include(":domain:nft")
include(":domain:nft:models") include(":domain:nft:models")
include(":domain:hot-wallet") include(":domain:hot-wallet")
@ -459,6 +461,7 @@ include(":data:visa")
include(":data:payment") include(":data:payment")
include(":data:virtual-account") include(":data:virtual-account")
include(":data:stories") include(":data:stories")
include(":data:marketing")
include(":data:onboarding") include(":data:onboarding")
include(":data:dynamic-addresses") include(":data:dynamic-addresses")
include(":data:feedback") include(":data:feedback")