Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-13 13:02:00 +05:00
parent 499b7c04ea
commit 1e25b19264
11 changed files with 379 additions and 44 deletions

View file

@ -4,6 +4,7 @@ import com.tangem.domain.marketing.DismissMarketingBannerUseCase
import com.tangem.domain.marketing.GetMarketingBannerUseCase import com.tangem.domain.marketing.GetMarketingBannerUseCase
import com.tangem.domain.marketing.MarketingFeatureToggles import com.tangem.domain.marketing.MarketingFeatureToggles
import com.tangem.domain.marketing.MarketingRepository import com.tangem.domain.marketing.MarketingRepository
import com.tangem.domain.marketing.WarmUpMarketingCampaignsUseCase
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -25,4 +26,11 @@ internal object MarketingDomainModule {
@Singleton @Singleton
fun provideDismissMarketingBannerUseCase(repository: MarketingRepository): DismissMarketingBannerUseCase = fun provideDismissMarketingBannerUseCase(repository: MarketingRepository): DismissMarketingBannerUseCase =
DismissMarketingBannerUseCase(repository) DismissMarketingBannerUseCase(repository)
@Provides
@Singleton
fun provideWarmUpMarketingCampaignsUseCase(
repository: MarketingRepository,
featureToggles: MarketingFeatureToggles,
): WarmUpMarketingCampaignsUseCase = WarmUpMarketingCampaignsUseCase(repository, featureToggles)
} }

View file

@ -12,9 +12,15 @@ import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.domain.marketing.MarketingRepository import com.tangem.domain.marketing.MarketingRepository
import com.tangem.domain.marketing.models.MarketingCampaign import com.tangem.domain.marketing.models.MarketingCampaign
import com.tangem.domain.marketing.models.MarketingScreen import com.tangem.domain.marketing.models.MarketingScreen
import com.tangem.domain.marketing.models.MarketingScreenType
import com.tangem.utils.SupportedLanguages import com.tangem.utils.SupportedLanguages
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger 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 import kotlinx.coroutines.withContext
internal class DefaultMarketingRepository( internal class DefaultMarketingRepository(
@ -25,32 +31,82 @@ internal class DefaultMarketingRepository(
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
) : MarketingRepository { ) : 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>> = override suspend fun getCampaigns(screen: MarketingScreen): Either<Throwable, List<MarketingCampaign>> =
withContext(dispatchers.io) { withContext(dispatchers.io) {
Either.catch { Either.catch {
val isCacheable = screen.type.isCacheable if (screen.type.isCacheable) {
val cached = if (isCacheable) cacheStore.get(screen.type.value) else null loadCacheableByType(screen.type)
} else {
when (val response = requestCampaigns(screen, eTag = cached?.eTag)) { // swap/onramp — always fresh, never cached
is ApiResponse.Success -> { when (val response = requestCampaigns(screen, eTag = null)) {
if (isCacheable) { is ApiResponse.Success -> convert(response.data)
// eTag may be null if the server omits it; we still cache the body for the 5xx is ApiResponse.Error -> emptyList()
// 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 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 getDismissedBannerIds(): Set<Int> = dismissStore.getDismissedIds()
override suspend fun dismissBanner(campaignId: Int) = dismissStore.dismiss(campaignId) 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( private suspend fun requestCampaigns(
screen: MarketingScreen, screen: MarketingScreen,
eTag: String?, eTag: String?,
@ -77,19 +133,10 @@ internal class DefaultMarketingRepository(
is MarketingScreen.TokenMarkets, is MarketingScreen.TokenMarkets,
is MarketingScreen.Staking, is MarketingScreen.Staking,
is MarketingScreen.Yield, 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> = private fun convert(response: MarketingCampaignsResponse): List<MarketingCampaign> =
converter.convertListIgnoreErrors(response.campaigns) { throwable -> converter.convertListIgnoreErrors(response.campaigns) { throwable ->
TangemLogger.w("Skipped invalid marketing campaign: ${throwable.message}") TangemLogger.w("Skipped invalid marketing campaign: ${throwable.message}")

View file

@ -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.marketing.models.MarketingCampaignsResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.domain.marketing.models.MarketingScreen import com.tangem.domain.marketing.models.MarketingScreen
import com.tangem.domain.marketing.models.MarketingScreenType
import com.tangem.utils.SupportedLanguages import com.tangem.utils.SupportedLanguages
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks import io.mockk.clearMocks
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@ -34,17 +38,20 @@ internal class DefaultMarketingRepositoryTest {
private val language = SupportedLanguages.getCurrentSupportedLanguageCode() private val language = SupportedLanguages.getCurrentSupportedLanguageCode()
private val repository = DefaultMarketingRepository( // Recreated per test (not a val): DefaultMarketingRepository now holds mutable in-memory session-cache
tangemTechApi = tangemTechApi, // state, which would otherwise leak between tests sharing this PER_CLASS instance.
cacheStore = cacheStore, private lateinit var repository: DefaultMarketingRepository
dismissStore = dismissStore,
converter = MarketingCampaignConverter(),
dispatchers = TestingCoroutineDispatcherProvider(),
)
@BeforeEach @BeforeEach
fun reset() { fun reset() {
clearMocks(tangemTechApi, cacheStore, dismissStore) clearMocks(tangemTechApi, cacheStore, dismissStore)
repository = DefaultMarketingRepository(
tangemTechApi = tangemTechApi,
cacheStore = cacheStore,
dismissStore = dismissStore,
converter = MarketingCampaignConverter(),
dispatchers = TestingCoroutineDispatcherProvider(),
)
} }
private fun response(id: Int) = MarketingCampaignsResponse( private fun response(id: Int) = MarketingCampaignsResponse(
@ -118,6 +125,24 @@ internal class DefaultMarketingRepositoryTest {
assertThat(result.getOrNull()).isEmpty() 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 @Test
fun `GIVEN swap screen WHEN getCampaigns THEN sends pair params and does not touch cache`() = runTest { fun `GIVEN swap screen WHEN getCampaigns THEN sends pair params and does not touch cache`() = runTest {
// Arrange // Arrange
@ -143,6 +168,89 @@ internal class DefaultMarketingRepositoryTest {
coVerify(exactly = 0) { cacheStore.store(any(), any()) } 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 @Test
fun `GIVEN dismiss WHEN dismissBanner THEN delegates to dismiss store`() = runTest { fun `GIVEN dismiss WHEN dismissBanner THEN delegates to dismiss store`() = runTest {
// Act // Act

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.MarketingCampaign
import com.tangem.domain.marketing.models.MarketingCampaignTarget import com.tangem.domain.marketing.models.MarketingCampaignTarget
import com.tangem.domain.marketing.models.MarketingScreen 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 import java.math.BigDecimal
class GetMarketingBannerUseCase( class GetMarketingBannerUseCase(
@ -28,7 +28,7 @@ class GetMarketingBannerUseCase(
campaigns.asSequence() campaigns.asSequence()
.filterNot { it.id in dismissed } .filterNot { it.id in dismissed }
.filter { matchesTarget(it, screen) } .filter { matchesTarget(it, screen) }
.filter { matchesAmount(it, amountUsd) } .filter { it.matchesUsdAmount(amountUsd) }
.sortedBy { it.priority } .sortedBy { it.priority }
.toList() .toList()
} }
@ -71,15 +71,4 @@ class GetMarketingBannerUseCase(
else -> false 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 arrow.core.Either
import com.tangem.domain.marketing.models.MarketingCampaign import com.tangem.domain.marketing.models.MarketingCampaign
import com.tangem.domain.marketing.models.MarketingScreen import com.tangem.domain.marketing.models.MarketingScreen
import com.tangem.domain.marketing.models.MarketingScreenType
interface MarketingRepository { interface MarketingRepository {
/** Fetches campaigns for [screen]. Returns Right(emptyList()) when there is nothing to show (incl. 5xx without cache). */ /** 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>> 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). */ /** Ids of campaigns whose banner the user has dismissed (stored client-side). */
suspend fun getDismissedBannerIds(): Set<Int> 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()
}
}

View file

@ -93,6 +93,7 @@ dependencies {
implementation(projects.domain.feedback) implementation(projects.domain.feedback)
implementation(projects.domain.feedback.models) implementation(projects.domain.feedback.models)
implementation(projects.domain.legacy) implementation(projects.domain.legacy)
implementation(projects.domain.marketing)
implementation(projects.domain.markets.models) implementation(projects.domain.markets.models)
implementation(projects.domain.models) implementation(projects.domain.models)
implementation(projects.domain.networks) implementation(projects.domain.networks)

View file

@ -23,6 +23,7 @@ import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.assetsdiscovery.usecase.StartAssetsDiscoveryUseCase import com.tangem.domain.assetsdiscovery.usecase.StartAssetsDiscoveryUseCase
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.marketing.WarmUpMarketingCampaignsUseCase
import com.tangem.domain.models.wallet.* import com.tangem.domain.models.wallet.*
import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUseCase import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUseCase
import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.notifications.repository.NotificationsRepository
@ -127,6 +128,7 @@ internal class WalletModel @Inject constructor(
private val walletFeatureToggles: WalletFeatureToggles, private val walletFeatureToggles: WalletFeatureToggles,
private val pushNotificationSettingsFeatureToggles: PushNotificationSettingsFeatureToggles, private val pushNotificationSettingsFeatureToggles: PushNotificationSettingsFeatureToggles,
private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase, private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase,
private val warmUpMarketingCampaignsUseCase: WarmUpMarketingCampaignsUseCase,
val screenLifecycleProvider: ScreenLifecycleProvider, val screenLifecycleProvider: ScreenLifecycleProvider,
val innerWalletRouter: InnerWalletRouter, val innerWalletRouter: InnerWalletRouter,
) : Model() { ) : Model() {
@ -151,6 +153,7 @@ internal class WalletModel @Inject constructor(
maybeMigrateNames() maybeMigrateNames()
maybeSetWalletFirstTimeUsage() maybeSetWalletFirstTimeUsage()
preloadPushNotificationPreferences() preloadPushNotificationPreferences()
warmUpMarketingCampaigns()
updateYieldSupplyApy() updateYieldSupplyApy()
subscribeToUserWalletsUpdates() subscribeToUserWalletsUpdates()
subscribeOnBalanceHiding() subscribeOnBalanceHiding()
@ -198,6 +201,12 @@ internal class WalletModel @Inject constructor(
} }
} }
private fun warmUpMarketingCampaigns() {
modelScope.launch(dispatchers.io) {
warmUpMarketingCampaignsUseCase()
}
}
private fun preloadPushNotificationPreferences() { private fun preloadPushNotificationPreferences() {
if (!pushNotificationSettingsFeatureToggles.isPushNotificationSettingsEnabled) return if (!pushNotificationSettingsFeatureToggles.isPushNotificationSettingsEnabled) return
getWalletsUseCase() getWalletsUseCase()