Updated on 2026-08-14
This commit is contained in:
parent
33efe2ae52
commit
499b7c04ea
33 changed files with 1502 additions and 0 deletions
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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue