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