Updated on 2026-08-14
This commit is contained in:
parent
499b7c04ea
commit
1e25b19264
11 changed files with 379 additions and 44 deletions
|
|
@ -4,6 +4,7 @@ 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 com.tangem.domain.marketing.WarmUpMarketingCampaignsUseCase
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -25,4 +26,11 @@ internal object MarketingDomainModule {
|
|||
@Singleton
|
||||
fun provideDismissMarketingBannerUseCase(repository: MarketingRepository): DismissMarketingBannerUseCase =
|
||||
DismissMarketingBannerUseCase(repository)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideWarmUpMarketingCampaignsUseCase(
|
||||
repository: MarketingRepository,
|
||||
featureToggles: MarketingFeatureToggles,
|
||||
): WarmUpMarketingCampaignsUseCase = WarmUpMarketingCampaignsUseCase(repository, featureToggles)
|
||||
}
|
||||
|
|
@ -12,9 +12,15 @@ 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.domain.marketing.models.MarketingScreenType
|
||||
import com.tangem.utils.SupportedLanguages
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal class DefaultMarketingRepository(
|
||||
|
|
@ -25,32 +31,82 @@ internal class DefaultMarketingRepository(
|
|||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : MarketingRepository {
|
||||
|
||||
// In-memory per-session cache for background (cacheable) types. Serves repeated reads within a session
|
||||
// without hitting the network; DataStore ETag cache remains the cross-session layer inside fetchAndCacheByType.
|
||||
private val sessionCache = MutableStateFlow<Map<MarketingScreenType, List<MarketingCampaign>>>(emptyMap())
|
||||
private val cacheMutex = Mutex()
|
||||
|
||||
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)
|
||||
if (screen.type.isCacheable) {
|
||||
loadCacheableByType(screen.type)
|
||||
} else {
|
||||
// swap/onramp — always fresh, never cached
|
||||
when (val response = requestCampaigns(screen, eTag = null)) {
|
||||
is ApiResponse.Success -> convert(response.data)
|
||||
is ApiResponse.Error -> emptyList()
|
||||
}
|
||||
is ApiResponse.Error -> handleError(cached)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun prefetchBackgroundCampaigns(type: MarketingScreenType) {
|
||||
if (!type.isCacheable) return
|
||||
try {
|
||||
loadCacheableByType(type)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// Fire-and-forget warm-up: failures are non-fatal, the next getCampaigns() call will retry.
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getDismissedBannerIds(): Set<Int> = dismissStore.getDismissedIds()
|
||||
|
||||
override suspend fun dismissBanner(campaignId: Int) = dismissStore.dismiss(campaignId)
|
||||
|
||||
private suspend fun loadCacheableByType(type: MarketingScreenType): List<MarketingCampaign> {
|
||||
sessionCache.value[type]?.let { return it }
|
||||
return cacheMutex.withLock {
|
||||
sessionCache.value[type]?.let { return@withLock it } // double-check under lock
|
||||
val result = fetchAndCacheByType(type)
|
||||
// Only cache authoritative results. A pure error fallback (error + no DataStore cache -> null)
|
||||
// must NOT poison the session cache, so a later screen open still retries the network.
|
||||
if (result != null) {
|
||||
sessionCache.update { it + (type to result) }
|
||||
}
|
||||
result.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchAndCacheByType(type: MarketingScreenType): List<MarketingCampaign>? {
|
||||
val cached = cacheStore.get(type.value)
|
||||
return when (val response = requestByType(type, eTag = cached?.eTag)) {
|
||||
is ApiResponse.Success -> {
|
||||
// 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(type.value, MarketingCampaignsCacheEntry(eTag, response.data))
|
||||
convert(response.data) // authoritative (may be empty = real "no banners")
|
||||
}
|
||||
// Cached fallback is authoritative-ish; null when there is nothing cached (do not session-cache).
|
||||
is ApiResponse.Error -> cached?.response?.let(::convert)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun requestByType(
|
||||
type: MarketingScreenType,
|
||||
eTag: String?,
|
||||
): ApiResponse<MarketingCampaignsResponse> {
|
||||
return tangemTechApi.getMarketingCampaigns(
|
||||
type = type.value,
|
||||
language = SupportedLanguages.getCurrentSupportedLanguageCode(),
|
||||
eTag = eTag,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun requestCampaigns(
|
||||
screen: MarketingScreen,
|
||||
eTag: String?,
|
||||
|
|
@ -77,19 +133,10 @@ internal class DefaultMarketingRepository(
|
|||
is MarketingScreen.TokenMarkets,
|
||||
is MarketingScreen.Staking,
|
||||
is MarketingScreen.Yield,
|
||||
-> tangemTechApi.getMarketingCampaigns(type = screen.type.value, language = language, eTag = eTag)
|
||||
-> requestByType(screen.type, 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}")
|
||||
|
|
|
|||
|
|
@ -14,12 +14,16 @@ 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.domain.marketing.models.MarketingScreenType
|
||||
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.CompletableDeferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
|
@ -34,17 +38,20 @@ internal class DefaultMarketingRepositoryTest {
|
|||
|
||||
private val language = SupportedLanguages.getCurrentSupportedLanguageCode()
|
||||
|
||||
private val repository = DefaultMarketingRepository(
|
||||
tangemTechApi = tangemTechApi,
|
||||
cacheStore = cacheStore,
|
||||
dismissStore = dismissStore,
|
||||
converter = MarketingCampaignConverter(),
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
// Recreated per test (not a val): DefaultMarketingRepository now holds mutable in-memory session-cache
|
||||
// state, which would otherwise leak between tests sharing this PER_CLASS instance.
|
||||
private lateinit var repository: DefaultMarketingRepository
|
||||
|
||||
@BeforeEach
|
||||
fun reset() {
|
||||
clearMocks(tangemTechApi, cacheStore, dismissStore)
|
||||
repository = DefaultMarketingRepository(
|
||||
tangemTechApi = tangemTechApi,
|
||||
cacheStore = cacheStore,
|
||||
dismissStore = dismissStore,
|
||||
converter = MarketingCampaignConverter(),
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun response(id: Int) = MarketingCampaignsResponse(
|
||||
|
|
@ -118,6 +125,24 @@ internal class DefaultMarketingRepositoryTest {
|
|||
assertThat(result.getOrNull()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN 5xx without cache WHEN getCampaigns twice THEN not session-cached and retried`() = runTest {
|
||||
// Arrange
|
||||
coEvery { cacheStore.get("staking") } returns null
|
||||
coEvery { tangemTechApi.getMarketingCampaigns(type = "staking", language = language, eTag = null) } returns
|
||||
httpError(Code.SERVICE_UNAVAILABLE)
|
||||
val screen = MarketingScreen.Staking(networkId = "ethereum", contractAddress = "0x")
|
||||
|
||||
// Act
|
||||
val first = repository.getCampaigns(screen)
|
||||
val second = repository.getCampaigns(screen)
|
||||
|
||||
// Assert
|
||||
assertThat(first.getOrNull()).isEmpty()
|
||||
assertThat(second.getOrNull()).isEmpty()
|
||||
coVerify(exactly = 2) { tangemTechApi.getMarketingCampaigns(type = "staking", language = language, eTag = null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN swap screen WHEN getCampaigns THEN sends pair params and does not touch cache`() = runTest {
|
||||
// Arrange
|
||||
|
|
@ -143,6 +168,89 @@ internal class DefaultMarketingRepositoryTest {
|
|||
coVerify(exactly = 0) { cacheStore.store(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN cached in session WHEN getCampaigns twice THEN api called once`() = 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))
|
||||
|
||||
// Act
|
||||
val screen = MarketingScreen.TokenDetails(networkId = "ethereum", contractAddress = "0x")
|
||||
val first = repository.getCampaigns(screen)
|
||||
val second = repository.getCampaigns(screen)
|
||||
|
||||
// Assert
|
||||
assertThat(first.getOrNull()!!.map { it.id }).containsExactly(7)
|
||||
assertThat(second.getOrNull()!!.map { it.id }).containsExactly(7)
|
||||
coVerify(exactly = 1) { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN two concurrent getCampaigns for same type WHEN both in flight THEN api called once`() = runTest {
|
||||
// Arrange
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
coEvery { cacheStore.get("token_details") } returns null
|
||||
coEvery { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = null) } coAnswers {
|
||||
gate.await() // first caller suspends inside the lock, second blocks on the mutex
|
||||
ApiResponse.Success(data = response(id = 7))
|
||||
}
|
||||
val screen = MarketingScreen.TokenDetails(networkId = "ethereum", contractAddress = "0x")
|
||||
|
||||
// Act — launch both before either completes, then release the API
|
||||
val a = async { repository.getCampaigns(screen) }
|
||||
val b = async { repository.getCampaigns(screen) }
|
||||
runCurrent()
|
||||
gate.complete(Unit)
|
||||
val first = a.await()
|
||||
val second = b.await()
|
||||
|
||||
// Assert
|
||||
assertThat(first.getOrNull()!!.map { it.id }).containsExactly(7)
|
||||
assertThat(second.getOrNull()!!.map { it.id }).containsExactly(7)
|
||||
coVerify(exactly = 1) { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN prefetch WHEN getCampaigns THEN served from session cache without extra api call`() = runTest {
|
||||
// Arrange
|
||||
coEvery { cacheStore.get("markets_token") } returns null
|
||||
coEvery { tangemTechApi.getMarketingCampaigns(type = "markets_token", language = language, eTag = null) } returns
|
||||
ApiResponse.Success(data = response(id = 3))
|
||||
|
||||
// Act
|
||||
repository.prefetchBackgroundCampaigns(MarketingScreenType.TOKEN_MARKETS)
|
||||
val result = repository.getCampaigns(MarketingScreen.TokenMarkets(coingeckoId = "id"))
|
||||
|
||||
// Assert
|
||||
assertThat(result.getOrNull()!!.map { it.id }).containsExactly(3)
|
||||
coVerify(exactly = 1) { tangemTechApi.getMarketingCampaigns(type = "markets_token", language = language, eTag = null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN swap WHEN getCampaigns twice THEN never session-cached (api called each time)`() = runTest {
|
||||
// Arrange
|
||||
val swap = MarketingScreen.Swap("eth", "0xF", "btc", "0xT")
|
||||
coEvery {
|
||||
tangemTechApi.getMarketingCampaigns(
|
||||
type = "swap", language = language,
|
||||
fromNetwork = "eth", fromContractAddress = "0xF", toNetwork = "btc", toContractAddress = "0xT",
|
||||
)
|
||||
} returns ApiResponse.Success(data = response(id = 1))
|
||||
|
||||
// Act
|
||||
repository.getCampaigns(swap)
|
||||
repository.getCampaigns(swap)
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 2) {
|
||||
tangemTechApi.getMarketingCampaigns(
|
||||
type = "swap", language = language,
|
||||
fromNetwork = "eth", fromContractAddress = "0xF", toNetwork = "btc", toContractAddress = "0xT",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN dismiss WHEN dismissBanner THEN delegates to dismiss store`() = runTest {
|
||||
// Act
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -93,6 +93,7 @@ dependencies {
|
|||
implementation(projects.domain.feedback)
|
||||
implementation(projects.domain.feedback.models)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.domain.marketing)
|
||||
implementation(projects.domain.markets.models)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.networks)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import com.tangem.domain.apptheme.model.AppThemeMode
|
|||
import com.tangem.domain.assetsdiscovery.usecase.StartAssetsDiscoveryUseCase
|
||||
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.marketing.WarmUpMarketingCampaignsUseCase
|
||||
import com.tangem.domain.models.wallet.*
|
||||
import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUseCase
|
||||
import com.tangem.domain.notifications.repository.NotificationsRepository
|
||||
|
|
@ -127,6 +128,7 @@ internal class WalletModel @Inject constructor(
|
|||
private val walletFeatureToggles: WalletFeatureToggles,
|
||||
private val pushNotificationSettingsFeatureToggles: PushNotificationSettingsFeatureToggles,
|
||||
private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase,
|
||||
private val warmUpMarketingCampaignsUseCase: WarmUpMarketingCampaignsUseCase,
|
||||
val screenLifecycleProvider: ScreenLifecycleProvider,
|
||||
val innerWalletRouter: InnerWalletRouter,
|
||||
) : Model() {
|
||||
|
|
@ -151,6 +153,7 @@ internal class WalletModel @Inject constructor(
|
|||
maybeMigrateNames()
|
||||
maybeSetWalletFirstTimeUsage()
|
||||
preloadPushNotificationPreferences()
|
||||
warmUpMarketingCampaigns()
|
||||
updateYieldSupplyApy()
|
||||
subscribeToUserWalletsUpdates()
|
||||
subscribeOnBalanceHiding()
|
||||
|
|
@ -198,6 +201,12 @@ internal class WalletModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun warmUpMarketingCampaigns() {
|
||||
modelScope.launch(dispatchers.io) {
|
||||
warmUpMarketingCampaignsUseCase()
|
||||
}
|
||||
}
|
||||
|
||||
private fun preloadPushNotificationPreferences() {
|
||||
if (!pushNotificationSettingsFeatureToggles.isPushNotificationSettingsEnabled) return
|
||||
getWalletsUseCase()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue