Updated on 2026-08-14
This commit is contained in:
parent
8b49dc2f7f
commit
6fdbff6fa8
11 changed files with 379 additions and 44 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue