Updated on 2026-08-14
This commit is contained in:
parent
03c5bdd053
commit
f829c90402
33 changed files with 1502 additions and 0 deletions
|
|
@ -156,6 +156,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)
|
||||
|
|
@ -211,6 +213,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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -151,6 +151,10 @@
|
|||
"name": "AND_14829_WARNINGS_REFACTORING_ENABLED",
|
||||
"version": "undefined"
|
||||
},
|
||||
{
|
||||
"name": "TWI_1522_MARKETING_BANNERS_ENABLED",
|
||||
"version": "undefined"
|
||||
},
|
||||
{
|
||||
"name": "TWI_1469_FOR_YOU_ENABLED",
|
||||
"version": "undefined"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.datasource.api.tangemTech
|
||||
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
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
|
||||
|
|
@ -238,4 +239,18 @@ interface TangemTechApi {
|
|||
@GET("v1/earn/networks")
|
||||
suspend fun getEarnNetworks(@Query("type") type: String? = null): ApiResponse<EarnNetworkListResponse>
|
||||
// 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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
30
data/marketing/build.gradle.kts
Normal file
30
data/marketing/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
13
domain/marketing/build.gradle.kts
Normal file
13
domain/marketing/build.gradle.kts
Normal 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)
|
||||
}
|
||||
8
domain/marketing/models/build.gradle.kts
Normal file
8
domain/marketing/models/build.gradle.kts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
plugins {
|
||||
alias(deps.plugins.kotlin.jvm)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation(projects.test.core)
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.tangem.domain.marketing
|
||||
|
||||
interface MarketingFeatureToggles {
|
||||
val isMarketingBannersEnabled: Boolean
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -455,6 +455,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")
|
||||
|
|
@ -500,6 +502,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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue