Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-13 10:02:00 +02:00
parent 8b49dc2f7f
commit 6fdbff6fa8
11 changed files with 379 additions and 44 deletions

View file

@ -0,0 +1,16 @@
package com.tangem.domain.marketing.models
import java.math.BigDecimal
/**
* USD min/max eligibility gate. Applies only to swap/onramp campaigns and only when [amountUsd] is known;
* otherwise the campaign passes (non-amount screens and the "amount unknown" case are not gated).
*/
fun MarketingCampaign.matchesUsdAmount(amountUsd: BigDecimal?): Boolean {
val isAmountScreen = type == MarketingScreenType.SWAP || type == MarketingScreenType.ONRAMP
if (!isAmountScreen || amountUsd == null) return true
if (minAmount != null && amountUsd < minAmount) return false
if (maxAmount != null && amountUsd > maxAmount) return false
return true
}

View file

@ -0,0 +1,64 @@
package com.tangem.domain.marketing.models
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class MarketingCampaignAmountTest {
private fun campaign(
type: MarketingScreenType,
minAmount: BigDecimal? = null,
maxAmount: BigDecimal? = null,
) = MarketingCampaign(
id = 1, type = type, priority = 1, startAt = null, endAt = null,
minAmount = minAmount, maxAmount = maxAmount, providerIds = null,
banner = MarketingBanner(
uiType = MarketingBanner.UiType.STANDALONE, text = "t", iconUrl = null,
iconAlign = null, bgColor = null, deeplink = null, isDismissible = false,
),
targets = emptyList(),
)
@Test
fun `GIVEN non swap-onramp type WHEN matchesUsdAmount THEN always true`() {
val c = campaign(MarketingScreenType.TOKEN_DETAILS, minAmount = BigDecimal(50), maxAmount = BigDecimal(300))
assertThat(c.matchesUsdAmount(BigDecimal(10))).isTrue()
assertThat(c.matchesUsdAmount(null)).isTrue()
}
@Test
fun `GIVEN swap with null amount WHEN matchesUsdAmount THEN true`() {
val c = campaign(MarketingScreenType.SWAP, minAmount = BigDecimal(50))
assertThat(c.matchesUsdAmount(null)).isTrue()
}
@Test
fun `GIVEN swap amount below min WHEN matchesUsdAmount THEN false`() {
val c = campaign(MarketingScreenType.SWAP, minAmount = BigDecimal(50), maxAmount = BigDecimal(300))
assertThat(c.matchesUsdAmount(BigDecimal(49))).isFalse()
}
@Test
fun `GIVEN swap amount above max WHEN matchesUsdAmount THEN false`() {
val c = campaign(MarketingScreenType.ONRAMP, minAmount = BigDecimal(50), maxAmount = BigDecimal(300))
assertThat(c.matchesUsdAmount(BigDecimal(301))).isFalse()
}
@Test
fun `GIVEN amount on boundaries WHEN matchesUsdAmount THEN true`() {
val c = campaign(MarketingScreenType.SWAP, minAmount = BigDecimal(50), maxAmount = BigDecimal(300))
assertThat(c.matchesUsdAmount(BigDecimal(50))).isTrue()
assertThat(c.matchesUsdAmount(BigDecimal(300))).isTrue()
}
@Test
fun `GIVEN nullable bounds WHEN matchesUsdAmount THEN only present bound applies`() {
val onlyMin = campaign(MarketingScreenType.SWAP, minAmount = BigDecimal(50), maxAmount = null)
assertThat(onlyMin.matchesUsdAmount(BigDecimal(10_000))).isTrue()
assertThat(onlyMin.matchesUsdAmount(BigDecimal(10))).isFalse()
val onlyMax = campaign(MarketingScreenType.SWAP, minAmount = null, maxAmount = BigDecimal(300))
assertThat(onlyMax.matchesUsdAmount(BigDecimal(1))).isTrue()
assertThat(onlyMax.matchesUsdAmount(BigDecimal(301))).isFalse()
}
}

View file

@ -4,7 +4,7 @@ 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 com.tangem.domain.marketing.models.matchesUsdAmount
import java.math.BigDecimal
class GetMarketingBannerUseCase(
@ -28,7 +28,7 @@ class GetMarketingBannerUseCase(
campaigns.asSequence()
.filterNot { it.id in dismissed }
.filter { matchesTarget(it, screen) }
.filter { matchesAmount(it, amountUsd) }
.filter { it.matchesUsdAmount(amountUsd) }
.sortedBy { it.priority }
.toList()
}
@ -71,15 +71,4 @@ class GetMarketingBannerUseCase(
else -> false
}
}
private fun matchesAmount(campaign: MarketingCampaign, amountUsd: BigDecimal?): Boolean {
val isAmountScreen = campaign.type == MarketingScreenType.SWAP || campaign.type == MarketingScreenType.ONRAMP
if (!isAmountScreen || amountUsd == null) return true
val min = campaign.minAmount
val max = campaign.maxAmount
if (min != null && amountUsd < min) return false
if (max != null && amountUsd > max) return false
return true
}
}

View file

@ -3,12 +3,16 @@ package com.tangem.domain.marketing
import arrow.core.Either
import com.tangem.domain.marketing.models.MarketingCampaign
import com.tangem.domain.marketing.models.MarketingScreen
import com.tangem.domain.marketing.models.MarketingScreenType
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>>
/** Loads and caches campaigns for a background [type] into the in-memory session cache (fire-and-forget warm-up). */
suspend fun prefetchBackgroundCampaigns(type: MarketingScreenType)
/** Ids of campaigns whose banner the user has dismissed (stored client-side). */
suspend fun getDismissedBannerIds(): Set<Int>

View file

@ -0,0 +1,31 @@
package com.tangem.domain.marketing
import com.tangem.domain.marketing.models.MarketingScreenType
import kotlinx.coroutines.CancellationException
/**
* Warms the session cache for background campaign types shown outside a dedicated screen entry
* (token details & markets). Toggle-gated; failures are swallowed (fire-and-forget from the main screen).
*/
class WarmUpMarketingCampaignsUseCase(
private val repository: MarketingRepository,
private val featureToggles: MarketingFeatureToggles,
) {
suspend operator fun invoke() {
if (!featureToggles.isMarketingBannersEnabled) return
WARMED_TYPES.forEach { type ->
try {
repository.prefetchBackgroundCampaigns(type)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// fire-and-forget warm-up: ignore, next screen open retries
}
}
}
private companion object {
val WARMED_TYPES = listOf(MarketingScreenType.TOKEN_DETAILS, MarketingScreenType.TOKEN_MARKETS)
}
}

View file

@ -0,0 +1,58 @@
package com.tangem.domain.marketing
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
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class WarmUpMarketingCampaignsUseCaseTest {
private val repository: MarketingRepository = mockk(relaxed = true)
private val featureToggles: MarketingFeatureToggles = mockk()
private val useCase = WarmUpMarketingCampaignsUseCase(repository, featureToggles)
@BeforeEach
fun reset() = clearMocks(repository, featureToggles)
@Test
fun `GIVEN toggle off WHEN invoke THEN no prefetch`() = runTest {
// Arrange
every { featureToggles.isMarketingBannersEnabled } returns false
// Act
useCase()
// Assert
coVerify(exactly = 0) { repository.prefetchBackgroundCampaigns(any()) }
}
@Test
fun `GIVEN toggle on WHEN invoke THEN prefetch token_details and markets`() = runTest {
// Arrange
every { featureToggles.isMarketingBannersEnabled } returns true
// Act
useCase()
// Assert
coVerify(exactly = 1) { repository.prefetchBackgroundCampaigns(MarketingScreenType.TOKEN_DETAILS) }
coVerify(exactly = 1) { repository.prefetchBackgroundCampaigns(MarketingScreenType.TOKEN_MARKETS) }
}
@Test
fun `GIVEN prefetch throws WHEN invoke THEN swallowed`() = runTest {
// Arrange
every { featureToggles.isMarketingBannersEnabled } returns true
coEvery { repository.prefetchBackgroundCampaigns(any()) } throws RuntimeException("boom")
// Act + Assert (does not throw)
useCase()
}
}