Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-08 20:13:28 +05:00
parent 7472e7b1dc
commit 2debaafcbb
33 changed files with 1386 additions and 7 deletions

View file

@ -0,0 +1,46 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.data.promo"
}
dependencies {
// region Kotlin
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.datetime)
// endregion
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region Core
api(projects.core.datasource)
api(projects.core.utils)
// endregion
// region Domain
api(projects.domain.promo)
// endregion
// region Domain models
implementation(projects.domain.models)
implementation(projects.domain.promo.models)
// endregion
// region Tests
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.moshi.kotlin)
// endregion
}

View file

@ -0,0 +1,97 @@
package com.tangem.data.promo
import com.squareup.moshi.Moshi
import com.tangem.data.promo.converter.PromoCampaignConverter
import com.tangem.data.promo.store.PromoEnrollmentStore
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.promotion.models.CreatePromotionRegistrationBody
import com.tangem.datasource.api.promotion.models.PromotionRegistrationResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.promotion.PromotionsSupplier
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.promo.models.EnrollResult
import com.tangem.domain.promo.models.PromoCampaignId
import com.tangem.domain.promo.models.PromoCampaignState
import com.tangem.domain.promo.models.TokenReward
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
internal class DefaultPromoRepository(
private val promotionsSupplier: PromotionsSupplier,
private val tangemApi: TangemTechApi,
private val enrollmentStore: PromoEnrollmentStore,
private val moshi: Moshi,
private val dispatchers: CoroutineDispatcherProvider,
) : PromoRepository {
override suspend fun getCampaignState(
campaign: PromoCampaignId,
userWalletId: UserWalletId,
forceRefresh: Boolean,
): PromoCampaignState = withContext(dispatchers.io) {
enrollmentStore.getSyncOrNull(campaign)?.let {
return@withContext PromoCampaignState.Enrolled(campaign, it)
}
val all = promotionsSupplier.getPromotions(userWalletId, forceRefresh)
.promotions.firstOrNull { it.name == campaign.slug }?.all
when {
all == null -> PromoCampaignState.NotActive(campaign)
all.status == ACTIVE_STATUS -> PromoCampaignConverter.toAvailable(campaign, all)
else -> PromoCampaignState.NotActive(campaign)
}
}
override suspend fun enroll(
campaign: PromoCampaignId,
tokenReward: TokenReward,
walletIds: List<UserWalletId>,
): EnrollResult = withContext(dispatchers.io) {
val body = CreatePromotionRegistrationBody(
campaignId = campaign.slug,
walletIds = walletIds.map { it.stringValue },
tokenReward = tokenReward.toDto(),
)
when (val response = tangemApi.createPromotionRegistration(body)) {
is ApiResponse.Success -> {
val saved = response.data.data.tokenReward.toDomain()
enrollmentStore.store(campaign, saved)
EnrollResult.Success(saved)
}
is ApiResponse.Error -> {
val cause = response.cause
val conflict = (cause as? ApiResponseError.HttpException)
?.takeIf { it.code == ApiResponseError.HttpException.Code.CONFLICT }
if (conflict != null) {
val existing = parseConflict(conflict.errorBody)?.data?.tokenReward?.toDomain() ?: tokenReward
enrollmentStore.store(campaign, existing)
EnrollResult.AlreadyEnrolled(existing)
} else {
throw cause
}
}
}
}
private fun parseConflict(body: String?): PromotionRegistrationResponse? {
if (body.isNullOrBlank()) return null
return runCatching {
moshi.adapter(PromotionRegistrationResponse::class.java).fromJson(body)
}.getOrNull()
}
private fun TokenReward.toDto() = CreatePromotionRegistrationBody.TokenRewardDto(
tokenAddress = tokenAddress,
networkId = networkId,
)
private fun CreatePromotionRegistrationBody.TokenRewardDto.toDomain() = TokenReward(
tokenAddress = tokenAddress,
networkId = networkId,
)
private companion object {
const val ACTIVE_STATUS = "active"
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.data.promo.converter
import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.All
import com.tangem.domain.promo.models.PromoCampaignId
import com.tangem.domain.promo.models.PromoCampaignState
import com.tangem.domain.promo.models.PromoPayoutToken
import com.tangem.domain.promo.models.PromoTimeline
import kotlinx.datetime.Instant
internal object PromoCampaignConverter {
fun toAvailable(campaign: PromoCampaignId, all: All): PromoCampaignState.Available {
return PromoCampaignState.Available(
campaign = campaign,
payoutTokens = all.tokens.orEmpty().map { token ->
PromoPayoutToken(
tokenAddress = token.tokenAddress,
tokenSymbol = token.tokenSymbol,
tokenName = token.tokenName,
networkId = token.networkId,
)
},
timeline = PromoTimeline(
start = Instant.parse(all.timeline.start),
end = Instant.parse(all.timeline.end),
),
)
}
}

View file

@ -0,0 +1,46 @@
package com.tangem.data.promo.di
import com.squareup.moshi.Moshi
import com.tangem.data.promo.DefaultPromoRepository
import com.tangem.data.promo.store.DefaultPromoEnrollmentStore
import com.tangem.data.promo.store.PromoEnrollmentStore
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.promotion.PromotionsSupplier
import com.tangem.domain.promo.PromoRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object PromoDataModule {
@Provides
@Singleton
fun providePromoEnrollmentStore(appPreferencesStore: AppPreferencesStore): PromoEnrollmentStore {
return DefaultPromoEnrollmentStore(appPreferencesStore)
}
@Provides
@Singleton
fun providePromoRepository(
promotionsSupplier: PromotionsSupplier,
tangemApi: TangemTechApi,
enrollmentStore: PromoEnrollmentStore,
@NetworkMoshi moshi: Moshi,
dispatchers: CoroutineDispatcherProvider,
): PromoRepository {
return DefaultPromoRepository(
promotionsSupplier = promotionsSupplier,
tangemApi = tangemApi,
enrollmentStore = enrollmentStore,
moshi = moshi,
dispatchers = dispatchers,
)
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.data.promo.store
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
import com.tangem.domain.promo.models.PromoCampaignId
import com.tangem.domain.promo.models.TokenReward
internal class DefaultPromoEnrollmentStore(
private val appPreferencesStore: AppPreferencesStore,
) : PromoEnrollmentStore {
override suspend fun getSyncOrNull(campaign: PromoCampaignId): TokenReward? {
return appPreferencesStore
.getObjectMapSync<TokenReward>(PreferencesKeys.PROMO_ENROLLMENTS_KEY)[campaign.slug]
}
override suspend fun store(campaign: PromoCampaignId, tokenReward: TokenReward) {
appPreferencesStore.editData { mutablePreferences ->
val current = mutablePreferences.getObjectMap<TokenReward>(PreferencesKeys.PROMO_ENROLLMENTS_KEY)
mutablePreferences.setObjectMap(
key = PreferencesKeys.PROMO_ENROLLMENTS_KEY,
value = current + (campaign.slug to tokenReward),
)
}
}
}

View file

@ -0,0 +1,11 @@
package com.tangem.data.promo.store
import com.tangem.domain.promo.models.PromoCampaignId
import com.tangem.domain.promo.models.TokenReward
interface PromoEnrollmentStore {
suspend fun getSyncOrNull(campaign: PromoCampaignId): TokenReward?
suspend fun store(campaign: PromoCampaignId, tokenReward: TokenReward)
}

View file

@ -0,0 +1,208 @@
package com.tangem.data.promo
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import com.tangem.data.promo.store.PromoEnrollmentStore
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.promotion.models.CreatePromotionRegistrationBody
import com.tangem.datasource.api.promotion.models.PromotionRegistrationResponse
import com.tangem.datasource.api.promotion.models.PromotionsResponse
import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto
import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.All
import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.PromoToken
import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.Timeline
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.promotion.PromotionsSupplier
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.promo.models.EnrollResult
import com.tangem.domain.promo.models.PromoCampaignId
import com.tangem.domain.promo.models.PromoCampaignState
import com.tangem.domain.promo.models.TokenReward
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.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 DefaultPromoRepositoryTest {
private val promotionsSupplier: PromotionsSupplier = mockk()
private val tangemApi: TangemTechApi = mockk()
private val enrollmentStore: PromoEnrollmentStore = mockk(relaxed = true)
private val moshi: Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
private val repository = DefaultPromoRepository(
promotionsSupplier = promotionsSupplier,
tangemApi = tangemApi,
enrollmentStore = enrollmentStore,
moshi = moshi,
dispatchers = TestingCoroutineDispatcherProvider(),
)
private val campaign = PromoCampaignId.WhaleSwapCashback
private val userWalletId = UserWalletId("abcdef012345")
private val tokenReward = TokenReward("0xToken", "ethereum")
private fun activeDto() = PromotionDto(
name = campaign.slug,
all = All(
timeline = Timeline("2026-06-23T00:00:00.000Z", "2026-08-31T20:59:59.000Z"),
tokens = listOf(PromoToken("0xToken", "USDT", "Tether USD", "ethereum")),
status = "active",
link = "",
),
)
@BeforeEach
fun setUp() = clearMocks(promotionsSupplier, tangemApi, enrollmentStore)
@Test
fun `GIVEN locally enrolled WHEN getCampaignState THEN Enrolled without api`() = runTest {
// Arrange
coEvery { enrollmentStore.getSyncOrNull(campaign) } returns tokenReward
// Act
val result = repository.getCampaignState(campaign, userWalletId)
// Assert
assertThat(result).isEqualTo(PromoCampaignState.Enrolled(campaign, tokenReward))
coVerify(exactly = 0) { promotionsSupplier.getPromotions(any(), any()) }
}
@Test
fun `GIVEN active campaign present and not enrolled WHEN getCampaignState THEN Available`() = runTest {
// Arrange
coEvery { enrollmentStore.getSyncOrNull(campaign) } returns null
coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns
PromotionsResponse(promotions = listOf(activeDto()))
// Act
val result = repository.getCampaignState(campaign, userWalletId)
// Assert
assertThat(result).isInstanceOf(PromoCampaignState.Available::class.java)
}
@Test
fun `GIVEN campaign absent WHEN getCampaignState THEN NotActive`() = runTest {
// Arrange
coEvery { enrollmentStore.getSyncOrNull(campaign) } returns null
coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns
PromotionsResponse(promotions = emptyList())
// Act
val result = repository.getCampaignState(campaign, userWalletId)
// Assert
assertThat(result).isEqualTo(PromoCampaignState.NotActive(campaign))
}
@Test
fun `GIVEN campaign present but finished WHEN getCampaignState THEN NotActive`() = runTest {
// Arrange
coEvery { enrollmentStore.getSyncOrNull(campaign) } returns null
val finished = activeDto().copy(all = activeDto().all!!.copy(status = "finished"))
coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns
PromotionsResponse(promotions = listOf(finished))
// Act
val result = repository.getCampaignState(campaign, userWalletId)
// Assert
assertThat(result).isEqualTo(PromoCampaignState.NotActive(campaign))
}
@Test
fun `GIVEN api returns 201 with canonical token WHEN enroll THEN Success and persists backend token`() = runTest {
// Arrange
val data = PromotionRegistrationResponse.RegistrationData(
campaignId = campaign.slug,
registeredAt = "2026-07-06T09:27:13.363Z",
tokenReward = CreatePromotionRegistrationBody.TokenRewardDto("0xCanonical", "ethereum"),
)
coEvery { tangemApi.createPromotionRegistration(any()) } returns ApiResponse.Success(
PromotionRegistrationResponse(status = "saved", message = null, data = data),
)
// Act
val result = repository.enroll(campaign, tokenReward, listOf(userWalletId))
// Assert
val backendToken = TokenReward("0xCanonical", "ethereum")
assertThat(result).isEqualTo(EnrollResult.Success(backendToken))
coVerify(exactly = 1) { enrollmentStore.store(campaign, backendToken) }
}
@Test
fun `GIVEN api returns 409 WHEN enroll THEN AlreadyEnrolled with existing token`() = runTest {
// Arrange
val existing = """
{"status":"already_exists","message":"exists","data":{"campaignId":"${campaign.slug}",
"registeredAt":"2026-07-01T10:00:00.000Z","tokenReward":{"tokenAddress":"0xOther",
"networkId":"base","userAddress":"0xExisting"}}}
""".trimIndent()
@Suppress("UNCHECKED_CAST")
coEvery { tangemApi.createPromotionRegistration(any()) } returns ApiResponse.Error(
ApiResponseError.HttpException(
code = ApiResponseError.HttpException.Code.CONFLICT,
message = "conflict",
errorBody = existing,
),
) as ApiResponse<PromotionRegistrationResponse>
// Act
val result = repository.enroll(campaign, tokenReward, listOf(userWalletId))
// Assert
val expectedToken = TokenReward("0xOther", "base")
assertThat(result).isEqualTo(EnrollResult.AlreadyEnrolled(expectedToken))
coVerify(exactly = 1) { enrollmentStore.store(campaign, expectedToken) }
}
@Test
fun `GIVEN 409 with null errorBody WHEN enroll THEN AlreadyEnrolled with submitted token`() = runTest {
// Arrange
@Suppress("UNCHECKED_CAST")
coEvery { tangemApi.createPromotionRegistration(any()) } returns ApiResponse.Error(
ApiResponseError.HttpException(
code = ApiResponseError.HttpException.Code.CONFLICT,
message = "conflict",
errorBody = null,
),
) as ApiResponse<PromotionRegistrationResponse>
// Act
val result = repository.enroll(campaign, tokenReward, listOf(userWalletId))
// Assert
assertThat(result).isEqualTo(EnrollResult.AlreadyEnrolled(tokenReward))
coVerify(exactly = 1) { enrollmentStore.store(campaign, tokenReward) }
}
@Test
fun `GIVEN api returns 500 WHEN enroll THEN throws`() = runTest {
// Arrange
@Suppress("UNCHECKED_CAST")
coEvery { tangemApi.createPromotionRegistration(any()) } returns ApiResponse.Error(
ApiResponseError.HttpException(
code = ApiResponseError.HttpException.Code.INTERNAL_SERVER_ERROR,
message = "server",
errorBody = null,
),
) as ApiResponse<PromotionRegistrationResponse>
// Act
val error = runCatching { repository.enroll(campaign, tokenReward, listOf(userWalletId)) }.exceptionOrNull()
// Assert
assertThat(error).isNotNull()
coVerify(exactly = 0) { enrollmentStore.store(any(), any()) }
}
}

View file

@ -0,0 +1,54 @@
package com.tangem.data.promo.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.All
import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.PromoToken
import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.Timeline
import com.tangem.domain.promo.models.PromoCampaignId
import com.tangem.domain.promo.models.PromoPayoutToken
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Test
internal class PromoCampaignConverterTest {
private val campaign = PromoCampaignId.WhaleSwapCashback
@Test
fun `GIVEN dto with tokens WHEN toAvailable THEN maps tokens and timeline`() {
// Arrange
val all = All(
timeline = Timeline(start = "2026-06-23T00:00:00.000Z", end = "2026-08-31T20:59:59.000Z"),
tokens = listOf(PromoToken("0xdac1", "USDT", "Tether USD", "ethereum")),
status = "active",
link = "",
)
// Act
val result = PromoCampaignConverter.toAvailable(campaign, all)
// Assert
assertThat(result.campaign).isEqualTo(campaign)
assertThat(result.payoutTokens).containsExactly(
PromoPayoutToken("0xdac1", "USDT", "Tether USD", "ethereum"),
)
assertThat(result.timeline.start).isEqualTo(Instant.parse("2026-06-23T00:00:00.000Z"))
assertThat(result.timeline.end).isEqualTo(Instant.parse("2026-08-31T20:59:59.000Z"))
}
@Test
fun `GIVEN dto with null tokens WHEN toAvailable THEN empty payout list`() {
// Arrange
val all = All(
timeline = Timeline(start = "2026-06-23T00:00:00.000Z", end = "2026-08-31T20:59:59.000Z"),
tokens = null,
status = "active",
link = null,
)
// Act
val result = PromoCampaignConverter.toAvailable(campaign, all)
// Assert
assertThat(result.payoutTokens).isEmpty()
}
}

View file

@ -9,6 +9,7 @@ import com.tangem.data.yield.supply.promo.DefaultYieldPromoRepository
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.YieldSupplyApi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.promotion.PromotionsSupplier
import com.tangem.datasource.local.yieldsupply.YieldMarketsStore
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore
@ -83,12 +84,14 @@ internal object YieldSupplyDataModule {
@Singleton
fun provideYieldPromoRepository(
tangemApi: TangemTechApi,
promotionsSupplier: PromotionsSupplier,
promoStore: YieldBoostPromoStore,
statusStore: YieldBoostStatusStore,
dispatchers: CoroutineDispatcherProvider,
): YieldPromoRepository {
return DefaultYieldPromoRepository(
tangemApi = tangemApi,
promotionsSupplier = promotionsSupplier,
promoStore = promoStore,
statusStore = statusStore,
dispatchers = dispatchers,

View file

@ -4,6 +4,7 @@ import com.tangem.data.yield.supply.promo.converter.YieldBoostPromoConverter
import com.tangem.data.yield.supply.promo.converter.YieldBoostStatusConverter
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.promotion.PromotionsSupplier
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore
import com.tangem.domain.models.wallet.UserWalletId
@ -15,6 +16,7 @@ import kotlinx.coroutines.withContext
internal class DefaultYieldPromoRepository(
private val tangemApi: TangemTechApi,
private val promotionsSupplier: PromotionsSupplier,
private val promoStore: YieldBoostPromoStore,
private val statusStore: YieldBoostStatusStore,
private val dispatchers: CoroutineDispatcherProvider,
@ -25,7 +27,7 @@ internal class DefaultYieldPromoRepository(
promoStore.getSyncOrNull(userWalletId)?.let { return it }
}
return try {
val fresh = fetchPromo(userWalletId)
val fresh = fetchPromo(userWalletId, forceRefresh)
promoStore.store(userWalletId, fresh)
fresh
} catch (e: Exception) {
@ -46,11 +48,13 @@ internal class DefaultYieldPromoRepository(
}
}
private suspend fun fetchPromo(userWalletId: UserWalletId): YieldBoostPromo = withContext(dispatchers.io) {
val response = tangemApi.getPromotions(walletId = userWalletId.stringValue).getOrThrow()
val dto = response.promotions.firstOrNull { it.name == PROMO_NAME } ?: return@withContext YieldBoostPromo.None
YieldBoostPromoConverter.convert(dto)
}
private suspend fun fetchPromo(userWalletId: UserWalletId, forceRefresh: Boolean): YieldBoostPromo =
withContext(dispatchers.io) {
val response = promotionsSupplier.getPromotions(userWalletId, forceRefresh)
val dto = response.promotions.firstOrNull { it.name == PROMO_NAME }
?: return@withContext YieldBoostPromo.None
YieldBoostPromoConverter.convert(dto)
}
private suspend fun fetchStatus(userWalletId: UserWalletId): YieldBoostStatus = withContext(dispatchers.io) {
val response = tangemApi.getYieldBoostStatus(walletId = userWalletId.stringValue).getOrThrow()

View file

@ -0,0 +1,248 @@
package com.tangem.data.yield.supply.promo
import com.google.common.truth.Truth.assertThat
import com.tangem.data.yield.supply.promo.converter.YieldBoostPromoConverter
import com.tangem.data.yield.supply.promo.converter.YieldBoostStatusConverter
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.promotion.models.PromotionsResponse
import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.promotion.PromotionsSupplier
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.yield.supply.models.YieldBoostPromo
import com.tangem.domain.yield.supply.models.YieldBoostStatus
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.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.io.IOException
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultYieldPromoRepositoryTest {
private val tangemApi: TangemTechApi = mockk()
private val promotionsSupplier: PromotionsSupplier = mockk()
private val promoStore: YieldBoostPromoStore = mockk(relaxed = true)
private val statusStore: YieldBoostStatusStore = mockk(relaxed = true)
private val repository = DefaultYieldPromoRepository(
tangemApi = tangemApi,
promotionsSupplier = promotionsSupplier,
promoStore = promoStore,
statusStore = statusStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
private val userWalletId = UserWalletId("abcdef012345")
@BeforeEach
fun setUp() {
clearMocks(tangemApi, promotionsSupplier, promoStore, statusStore)
}
// region getYieldBoostPromo
@Test
fun `GIVEN cached promo and no refresh WHEN getYieldBoostPromo THEN returns cache without api`() = runTest {
// Arrange
val cached = YieldBoostPromo.None
coEvery { promoStore.getSyncOrNull(userWalletId) } returns cached
// Act
val result = repository.getYieldBoostPromo(userWalletId, forceRefresh = false)
// Assert
assertThat(result).isEqualTo(cached)
coVerify(exactly = 0) { promotionsSupplier.getPromotions(any(), any()) }
}
@Test
fun `GIVEN no cache WHEN getYieldBoostPromo THEN fetches stores and returns converted`() = runTest {
// Arrange
val dto = matchingPromoDto()
coEvery { promoStore.getSyncOrNull(userWalletId) } returns null
coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns PromotionsResponse(
promotions = listOf(dto),
)
val expected = YieldBoostPromoConverter.convert(dto)
// Act
val result = repository.getYieldBoostPromo(userWalletId, forceRefresh = false)
// Assert
assertThat(result).isEqualTo(expected)
coVerify(exactly = 1) { promoStore.store(userWalletId, expected) }
}
@Test
fun `GIVEN cached promo and force refresh WHEN getYieldBoostPromo THEN fetches anyway`() = runTest {
// Arrange
coEvery { promoStore.getSyncOrNull(userWalletId) } returns YieldBoostPromo.None
coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns PromotionsResponse(
promotions = listOf(matchingPromoDto()),
)
// Act
repository.getYieldBoostPromo(userWalletId, forceRefresh = true)
// Assert
coVerify(exactly = 1) { promotionsSupplier.getPromotions(any(), any()) }
}
@Test
fun `GIVEN no matching promo name WHEN getYieldBoostPromo THEN returns None`() = runTest {
// Arrange
coEvery { promoStore.getSyncOrNull(userWalletId) } returns null
coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns PromotionsResponse(
promotions = listOf(PromotionsResponse.PromotionDto(name = "other", all = null)),
)
// Act
val result = repository.getYieldBoostPromo(userWalletId, forceRefresh = false)
// Assert
assertThat(result).isEqualTo(YieldBoostPromo.None)
coVerify(exactly = 1) { promoStore.store(userWalletId, YieldBoostPromo.None) }
}
@Test
fun `GIVEN fetch fails and cache present WHEN getYieldBoostPromo THEN falls back to cache`() = runTest {
// Arrange — force refresh so the initial cache check is skipped and the fetch is attempted
val cached = YieldBoostPromo.None
coEvery { promotionsSupplier.getPromotions(any(), any()) } throws IOException("network")
coEvery { promoStore.getSyncOrNull(userWalletId) } returns cached
// Act
val result = repository.getYieldBoostPromo(userWalletId, forceRefresh = true)
// Assert
assertThat(result).isEqualTo(cached)
coVerify(exactly = 0) { promoStore.store(any(), any()) }
}
@Test
fun `GIVEN fetch fails and no cache WHEN getYieldBoostPromo THEN rethrows`() = runTest {
// Arrange
coEvery { promotionsSupplier.getPromotions(any(), any()) } throws IOException("network")
coEvery { promoStore.getSyncOrNull(userWalletId) } returns null
// Act
val error = runCatching { repository.getYieldBoostPromo(userWalletId, forceRefresh = true) }
.exceptionOrNull()
// Assert
assertThat(error).isInstanceOf(IOException::class.java)
}
// endregion
// region getYieldBoostStatus
@Test
fun `GIVEN cached status and no refresh WHEN getYieldBoostStatus THEN returns cache without api`() = runTest {
// Arrange
val cached = YieldBoostStatus.NotStarted
coEvery { statusStore.getSyncOrNull(userWalletId) } returns cached
// Act
val result = repository.getYieldBoostStatus(userWalletId, forceRefresh = false)
// Assert
assertThat(result).isEqualTo(cached)
coVerify(exactly = 0) { tangemApi.getYieldBoostStatus(any()) }
}
@Test
fun `GIVEN no cache WHEN getYieldBoostStatus THEN fetches stores and returns converted`() = runTest {
// Arrange
val response = statusResponse()
coEvery { statusStore.getSyncOrNull(userWalletId) } returns null
coEvery { tangemApi.getYieldBoostStatus(any()) } returns ApiResponse.Success(response)
val expected = YieldBoostStatusConverter.convert(response)
// Act
val result = repository.getYieldBoostStatus(userWalletId, forceRefresh = false)
// Assert
assertThat(result).isEqualTo(expected)
coVerify(exactly = 1) { statusStore.store(userWalletId, expected) }
}
@Test
fun `GIVEN cached status and force refresh WHEN getYieldBoostStatus THEN fetches anyway`() = runTest {
// Arrange
coEvery { statusStore.getSyncOrNull(userWalletId) } returns YieldBoostStatus.NotStarted
coEvery { tangemApi.getYieldBoostStatus(any()) } returns ApiResponse.Success(statusResponse())
// Act
repository.getYieldBoostStatus(userWalletId, forceRefresh = true)
// Assert
coVerify(exactly = 1) { tangemApi.getYieldBoostStatus(any()) }
}
@Test
fun `GIVEN fetch fails and cache present WHEN getYieldBoostStatus THEN falls back to cache`() = runTest {
// Arrange — force refresh so the initial cache check is skipped and the fetch is attempted
val cached = YieldBoostStatus.NotStarted
coEvery { tangemApi.getYieldBoostStatus(any()) } throws IOException("network")
coEvery { statusStore.getSyncOrNull(userWalletId) } returns cached
// Act
val result = repository.getYieldBoostStatus(userWalletId, forceRefresh = true)
// Assert
assertThat(result).isEqualTo(cached)
coVerify(exactly = 0) { statusStore.store(any(), any()) }
}
@Test
fun `GIVEN fetch fails and no cache WHEN getYieldBoostStatus THEN rethrows`() = runTest {
// Arrange
coEvery { tangemApi.getYieldBoostStatus(any()) } throws IOException("network")
coEvery { statusStore.getSyncOrNull(userWalletId) } returns null
// Act
val error = runCatching { repository.getYieldBoostStatus(userWalletId, forceRefresh = true) }
.exceptionOrNull()
// Assert
assertThat(error).isInstanceOf(IOException::class.java)
}
// endregion
private fun matchingPromoDto() = PromotionsResponse.PromotionDto(
name = "yield-apr-boost",
all = PromotionsResponse.PromotionDto.All(
timeline = PromotionsResponse.PromotionDto.Timeline(
start = "2026-06-15T00:00:00.000Z",
end = "2027-06-15T22:00:00.000Z",
),
tokens = listOf(
PromotionsResponse.PromotionDto.PromoToken(
tokenAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
tokenSymbol = "USDC",
tokenName = "USD Coin",
networkId = "ethereum",
),
),
status = "active",
link = "https://example.com/terms",
),
)
private fun statusResponse() = YieldBoostStatusResponse(
tokenName = "USD Coin",
networkId = "ethereum",
moduleAddress = "0xModule",
userAddress = "0xUser",
contractAddress = "0xContract",
promoEnrollmentStatus = "NOT_STARTED",
qualificationEndDate = null,
disqualificationReason = null,
)
}