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

@ -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}")

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.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