Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-08 20:13:28 +05:00
parent 67fb9ecf50
commit 8af068b74b
33 changed files with 1151 additions and 18 deletions

View file

@ -167,6 +167,7 @@ dependencies {
implementation(projects.domain.walletManager)
implementation(projects.domain.walletManager.models)
implementation(projects.domain.yieldSupply)
implementation(projects.domain.promo)
implementation(projects.domain.blockaid)
implementation(projects.domain.hotWallet)
implementation(projects.domain.news)
@ -231,6 +232,7 @@ dependencies {
implementation(projects.data.swap)
implementation(projects.data.walletManager)
implementation(projects.data.yieldSupply)
implementation(projects.data.promo)
implementation(projects.data.hotWallet)
implementation(projects.data.news)
implementation(projects.data.earn)

View file

@ -0,0 +1,27 @@
package com.tangem.tap.di.domain
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.promo.usecase.EnrollPromoCampaignUseCase
import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object PromoDomainModule {
@Provides
@Singleton
fun provideGetPromoCampaignStateUseCase(repository: PromoRepository): GetPromoCampaignStateUseCase {
return GetPromoCampaignStateUseCase(repository)
}
@Provides
@Singleton
fun provideEnrollPromoCampaignUseCase(repository: PromoRepository): EnrollPromoCampaignUseCase {
return EnrollPromoCampaignUseCase(repository)
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.datasource.api.promotion.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CreatePromotionRegistrationBody(
@Json(name = "campaignId") val campaignId: String,
@Json(name = "walletIds") val walletIds: List<String>,
@Json(name = "tokenReward") val tokenReward: TokenRewardDto,
) {
@JsonClass(generateAdapter = true)
data class TokenRewardDto(
@Json(name = "tokenAddress") val tokenAddress: String,
@Json(name = "networkId") val networkId: String,
)
}

View file

@ -0,0 +1,19 @@
package com.tangem.datasource.api.promotion.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PromotionRegistrationResponse(
@Json(name = "status") val status: String,
@Json(name = "message") val message: String?,
@Json(name = "data") val data: RegistrationData,
) {
@JsonClass(generateAdapter = true)
data class RegistrationData(
@Json(name = "campaignId") val campaignId: String,
@Json(name = "registeredAt") val registeredAt: String?,
@Json(name = "tokenReward") val tokenReward: CreatePromotionRegistrationBody.TokenRewardDto,
)
}

View file

@ -1,6 +1,8 @@
package com.tangem.datasource.api.tangemTech
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.promotion.models.CreatePromotionRegistrationBody
import com.tangem.datasource.api.promotion.models.PromotionRegistrationResponse
import com.tangem.datasource.api.marketing.models.MarketingCampaignsResponse
import com.tangem.datasource.api.promotion.models.PromotionsResponse
import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse
@ -124,7 +126,7 @@ interface TangemTechApi {
@GET("v1/stories/{story_id}")
suspend fun getStoryById(@Path("story_id") storyId: String): ApiResponse<StoryContentResponse>
// region yield-boost promo
// region promotions
@GET("/v2/promotion")
suspend fun getPromotions(
@Query("walletId") walletId: String,
@ -134,6 +136,11 @@ interface TangemTechApi {
@Suppress("FunctionSignature", "TrailingCommaOnDeclarationSite")
@GET("/v2/promotion/yield-apr-boost/status")
suspend fun getYieldBoostStatus(@Query("walletId") walletId: String): ApiResponse<YieldBoostStatusResponse>
@POST("/v2/promotion/registrations")
suspend fun createPromotionRegistration(
@Body body: CreatePromotionRegistrationBody,
): ApiResponse<PromotionRegistrationResponse>
// endregion
// region push notifications

View file

@ -0,0 +1,32 @@
package com.tangem.datasource.di
import com.tangem.datasource.api.promotion.models.PromotionsResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.promotion.DefaultPromotionsSupplier
import com.tangem.datasource.local.promotion.PromotionsSupplier
import com.tangem.domain.models.wallet.UserWalletId
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 PromotionModule {
@Provides
@Singleton
fun providePromotionsSupplier(
tangemApi: TangemTechApi,
dispatchers: CoroutineDispatcherProvider,
): PromotionsSupplier {
return DefaultPromotionsSupplier(
tangemApi = tangemApi,
store = RuntimeSharedStore<Map<UserWalletId, PromotionsResponse>>(),
dispatchers = dispatchers,
)
}
}

View file

@ -144,6 +144,8 @@ object PreferencesKeys {
val PENDING_ASSETS_DISCOVERY_KEY by lazy { stringPreferencesKey(name = "pendingAssetsDiscovery") }
val PROMO_ENROLLMENTS_KEY by lazy { stringPreferencesKey(name = "promoEnrollments") }
// region Notifications
val NOTIFICATIONS_APPLICATION_ID_KEY by lazy { stringPreferencesKey(name = "notificationsApplicationId") }

View file

@ -0,0 +1,27 @@
package com.tangem.datasource.local.promotion
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.promotion.models.PromotionsResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
internal class DefaultPromotionsSupplier(
private val tangemApi: TangemTechApi,
private val store: RuntimeSharedStore<Map<UserWalletId, PromotionsResponse>>,
private val dispatchers: CoroutineDispatcherProvider,
) : PromotionsSupplier {
override suspend fun getPromotions(userWalletId: UserWalletId, forceRefresh: Boolean): PromotionsResponse {
if (!forceRefresh) {
store.getSyncOrNull()?.get(userWalletId)?.let { return it }
}
val fresh = withContext(dispatchers.io) {
tangemApi.getPromotions(walletId = userWalletId.stringValue).getOrThrow()
}
store.update(emptyMap()) { it + (userWalletId to fresh) }
return fresh
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.datasource.local.promotion
import com.tangem.datasource.api.promotion.models.PromotionsResponse
import com.tangem.domain.models.wallet.UserWalletId
/**
* Shared cache-first fetch of GET /v2/promotion. Keeps one in-memory entry per [UserWalletId]:
* a non-forced call returns the cached response when present, otherwise it fetches. A fetch failure
* propagates to the caller (no stale-cache fallback), so the caller decides how to handle it.
*/
interface PromotionsSupplier {
@Throws(Exception::class)
suspend fun getPromotions(userWalletId: UserWalletId, forceRefresh: Boolean = false): PromotionsResponse
}

View file

@ -0,0 +1,119 @@
package com.tangem.datasource.local.promotion
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.promotion.models.PromotionsResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.wallet.UserWalletId
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 DefaultPromotionsSupplierTest {
private val tangemApi: TangemTechApi = mockk()
private fun newSupplier() = DefaultPromotionsSupplier(
tangemApi = tangemApi,
store = RuntimeSharedStore(),
dispatchers = TestingCoroutineDispatcherProvider(),
)
private val userWalletId = UserWalletId("abcdef012345")
private val response = PromotionsResponse(promotions = emptyList())
private val response2 = PromotionsResponse(
promotions = listOf(
PromotionsResponse.PromotionDto(name = "dummy", all = null),
),
)
@BeforeEach
fun setUp() {
clearMocks(tangemApi)
}
@Test
fun `GIVEN empty cache WHEN getPromotions THEN fetches and returns response`() = runTest {
// Arrange
coEvery { tangemApi.getPromotions(any(), any()) } returns ApiResponse.Success(response)
val supplier = newSupplier()
// Act
val result = supplier.getPromotions(userWalletId)
// Assert
assertThat(result).isEqualTo(response)
coVerify(exactly = 1) { tangemApi.getPromotions(userWalletId.stringValue, any()) }
}
@Test
fun `GIVEN cached value and no refresh WHEN getPromotions THEN returns cache without api`() = runTest {
// Arrange
coEvery { tangemApi.getPromotions(any(), any()) } returns ApiResponse.Success(response)
val supplier = newSupplier()
supplier.getPromotions(userWalletId)
clearMocks(tangemApi)
// Act
val result = supplier.getPromotions(userWalletId, forceRefresh = false)
// Assert
assertThat(result).isEqualTo(response)
coVerify(exactly = 0) { tangemApi.getPromotions(any(), any()) }
}
@Test
fun `GIVEN cached value WHEN getPromotions forceRefresh THEN hits api again and rebinds value`() = runTest {
// Arrange
coEvery { tangemApi.getPromotions(any(), any()) } returnsMany listOf(
ApiResponse.Success(response),
ApiResponse.Success(response2),
)
val supplier = newSupplier()
supplier.getPromotions(userWalletId)
// Act
val result = supplier.getPromotions(userWalletId, forceRefresh = true)
// Assert
assertThat(result).isEqualTo(response2)
coVerify(exactly = 2) { tangemApi.getPromotions(any(), any()) }
}
@Test
fun `GIVEN fetch fails and cache present WHEN getPromotions forceRefresh THEN rethrows`() = runTest {
// Arrange
coEvery { tangemApi.getPromotions(any(), any()) } returns ApiResponse.Success(response)
val supplier = newSupplier()
supplier.getPromotions(userWalletId)
coEvery { tangemApi.getPromotions(any(), any()) } throws IOException("boom")
// Act
val error = runCatching { supplier.getPromotions(userWalletId, forceRefresh = true) }.exceptionOrNull()
// Assert
assertThat(error).isInstanceOf(IOException::class.java)
}
@Test
fun `GIVEN fetch fails and empty cache WHEN getPromotions THEN rethrows`() = runTest {
// Arrange
coEvery { tangemApi.getPromotions(any(), any()) } throws IOException("boom")
val supplier = newSupplier()
// Act
val error = runCatching { supplier.getPromotions(userWalletId) }.exceptionOrNull()
// Assert
assertThat(error).isInstanceOf(IOException::class.java)
}
}

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
@ -92,12 +93,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

@ -7,6 +7,7 @@ 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
@ -27,11 +28,13 @@ import java.io.IOException
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(),
@ -41,7 +44,7 @@ internal class DefaultYieldPromoRepositoryTest {
@BeforeEach
fun setUp() {
clearMocks(tangemApi, promoStore, statusStore)
clearMocks(tangemApi, promotionsSupplier, promoStore, statusStore)
}
// region getYieldBoostPromo
@ -56,7 +59,7 @@ internal class DefaultYieldPromoRepositoryTest {
// Assert
assertThat(result).isEqualTo(cached)
coVerify(exactly = 0) { tangemApi.getPromotions(any(), any()) }
coVerify(exactly = 0) { promotionsSupplier.getPromotions(any(), any()) }
}
@Test
@ -64,8 +67,8 @@ internal class DefaultYieldPromoRepositoryTest {
// Arrange
val dto = matchingPromoDto()
coEvery { promoStore.getSyncOrNull(userWalletId) } returns null
coEvery { tangemApi.getPromotions(any(), any()) } returns ApiResponse.Success(
PromotionsResponse(promotions = listOf(dto)),
coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns PromotionsResponse(
promotions = listOf(dto),
)
val expected = YieldBoostPromoConverter.convert(dto)
@ -81,23 +84,23 @@ internal class DefaultYieldPromoRepositoryTest {
fun `GIVEN cached promo and force refresh WHEN getYieldBoostPromo THEN fetches anyway`() = runTest {
// Arrange
coEvery { promoStore.getSyncOrNull(userWalletId) } returns YieldBoostPromo.None
coEvery { tangemApi.getPromotions(any(), any()) } returns ApiResponse.Success(
PromotionsResponse(promotions = listOf(matchingPromoDto())),
coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns PromotionsResponse(
promotions = listOf(matchingPromoDto()),
)
// Act
repository.getYieldBoostPromo(userWalletId, forceRefresh = true)
// Assert
coVerify(exactly = 1) { tangemApi.getPromotions(any(), any()) }
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 { tangemApi.getPromotions(any(), any()) } returns ApiResponse.Success(
PromotionsResponse(promotions = listOf(PromotionsResponse.PromotionDto(name = "other", all = null))),
coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns PromotionsResponse(
promotions = listOf(PromotionsResponse.PromotionDto(name = "other", all = null)),
)
// Act
@ -112,7 +115,7 @@ internal class DefaultYieldPromoRepositoryTest {
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 { tangemApi.getPromotions(any(), any()) } throws IOException("network")
coEvery { promotionsSupplier.getPromotions(any(), any()) } throws IOException("network")
coEvery { promoStore.getSyncOrNull(userWalletId) } returns cached
// Act
@ -126,7 +129,7 @@ internal class DefaultYieldPromoRepositoryTest {
@Test
fun `GIVEN fetch fails and no cache WHEN getYieldBoostPromo THEN rethrows`() = runTest {
// Arrange
coEvery { tangemApi.getPromotions(any(), any()) } throws IOException("network")
coEvery { promotionsSupplier.getPromotions(any(), any()) } throws IOException("network")
coEvery { promoStore.getSyncOrNull(userWalletId) } returns null
// Act

View file

@ -0,0 +1,34 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.domain.promo"
}
dependencies {
// region Kotlin
api(deps.kotlin.coroutines)
api(deps.arrow.core)
// endregion
// region Core modules
api(projects.core.utils)
// endregion
// region Domain models
api(projects.domain.models)
api(projects.domain.promo.models)
// endregion
// region Tests
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.test.core)
// endregion
}

View file

@ -0,0 +1,20 @@
plugins {
alias(deps.plugins.kotlin.jvm)
id("configuration")
}
dependencies {
// region Kotlin
api(deps.kotlin.datetime)
// endregion
// region Domain models
api(projects.domain.models)
// endregion
// region Tests
testImplementation(deps.test.junit5)
testImplementation(deps.test.truth)
// endregion
}

View file

@ -0,0 +1,12 @@
package com.tangem.domain.promo.models
enum class PromoCampaignId(val deeplinkId: Int, val slug: String) {
WhaleSwapCashback(deeplinkId = 1, slug = "whale-swap-cashback"),
ReactivationCashback(deeplinkId = 2, slug = "reactivation-cashback"),
;
companion object {
fun fromDeeplinkId(id: Int): PromoCampaignId? = entries.firstOrNull { it.deeplinkId == id }
fun fromSlug(slug: String): PromoCampaignId? = entries.firstOrNull { it.slug == slug }
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.domain.promo.models
sealed interface PromoCampaignState {
val campaign: PromoCampaignId
data class Available(
override val campaign: PromoCampaignId,
val payoutTokens: List<PromoPayoutToken>,
val timeline: PromoTimeline,
) : PromoCampaignState
data class Enrolled(
override val campaign: PromoCampaignId,
val tokenReward: TokenReward,
) : PromoCampaignState
data class NotActive(
override val campaign: PromoCampaignId,
) : PromoCampaignState
}

View file

@ -0,0 +1,27 @@
package com.tangem.domain.promo.models
import kotlinx.datetime.Instant
data class PromoPayoutToken(
val tokenAddress: String,
val tokenSymbol: String,
val tokenName: String,
val networkId: String,
)
data class PromoTimeline(
val start: Instant,
val end: Instant,
)
data class TokenReward(
val tokenAddress: String,
val networkId: String,
)
sealed interface EnrollResult {
val tokenReward: TokenReward
data class Success(override val tokenReward: TokenReward) : EnrollResult
data class AlreadyEnrolled(override val tokenReward: TokenReward) : EnrollResult
}

View file

@ -0,0 +1,29 @@
package com.tangem.domain.promo.models
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
internal class PromoCampaignIdTest {
@Test
fun `GIVEN known deeplink id WHEN fromDeeplinkId THEN returns campaign`() {
assertThat(PromoCampaignId.fromDeeplinkId(1)).isEqualTo(PromoCampaignId.WhaleSwapCashback)
assertThat(PromoCampaignId.fromDeeplinkId(2)).isEqualTo(PromoCampaignId.ReactivationCashback)
}
@Test
fun `GIVEN unknown deeplink id WHEN fromDeeplinkId THEN returns null`() {
assertThat(PromoCampaignId.fromDeeplinkId(99)).isNull()
}
@Test
fun `GIVEN known slug WHEN fromSlug THEN returns campaign`() {
assertThat(PromoCampaignId.fromSlug("whale-swap-cashback")).isEqualTo(PromoCampaignId.WhaleSwapCashback)
assertThat(PromoCampaignId.fromSlug("reactivation-cashback")).isEqualTo(PromoCampaignId.ReactivationCashback)
}
@Test
fun `GIVEN unknown slug WHEN fromSlug THEN returns null`() {
assertThat(PromoCampaignId.fromSlug("nope")).isNull()
}
}

View file

@ -0,0 +1,38 @@
package com.tangem.domain.promo
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
/**
* Backend promo-campaign plumbing (enrollment state and registration).
*
* Both methods propagate the underlying error (network, parsing, etc.) by throwing rather than
* returning an error type callers (use cases) are expected to wrap the call, e.g. with `Either.catch`.
*/
interface PromoRepository {
/**
* Resolves the state of [campaign] for [userWalletId]: locally enrolled, available, or not active.
* Throws if the campaign list can't be fetched and no cached/local data is available.
*/
@Throws(Exception::class)
suspend fun getCampaignState(
campaign: PromoCampaignId,
userWalletId: UserWalletId,
forceRefresh: Boolean = false,
): PromoCampaignState
/**
* Registers [walletIds] for [campaign] with the given [tokenReward]. Throws on any non-conflict
* API error; a 409 conflict resolves to [EnrollResult.AlreadyEnrolled] instead of throwing.
*/
@Throws(Exception::class)
suspend fun enroll(
campaign: PromoCampaignId,
tokenReward: TokenReward,
walletIds: List<UserWalletId>,
): EnrollResult
}

View file

@ -0,0 +1,21 @@
package com.tangem.domain.promo.usecase
import arrow.core.Either
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.TokenReward
class EnrollPromoCampaignUseCase(
private val repository: PromoRepository,
) {
suspend operator fun invoke(
campaign: PromoCampaignId,
tokenReward: TokenReward,
walletIds: List<UserWalletId>,
): Either<Throwable, EnrollResult> = Either.catch {
repository.enroll(campaign, tokenReward, walletIds)
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.domain.promo.usecase
import arrow.core.Either
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.promo.models.PromoCampaignId
import com.tangem.domain.promo.models.PromoCampaignState
class GetPromoCampaignStateUseCase(
private val repository: PromoRepository,
) {
suspend operator fun invoke(
campaign: PromoCampaignId,
userWalletId: UserWalletId,
forceRefresh: Boolean = false,
): Either<Throwable, PromoCampaignState> = Either.catch {
repository.getCampaignState(campaign, userWalletId, forceRefresh)
}
}

View file

@ -0,0 +1,57 @@
package com.tangem.domain.promo.usecase
import com.google.common.truth.Truth.assertThat
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.TokenReward
import com.tangem.test.core.assertEitherLeft
import io.mockk.clearMocks
import io.mockk.coEvery
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 EnrollPromoCampaignUseCaseTest {
private val repository: PromoRepository = mockk()
private val useCase = EnrollPromoCampaignUseCase(repository)
private val campaign = PromoCampaignId.WhaleSwapCashback
private val walletIds = listOf(UserWalletId("abcdef012345"))
private val tokenReward = TokenReward("0xToken", "ethereum")
@BeforeEach
fun setUp() = clearMocks(repository)
@Test
fun `GIVEN repo returns Success WHEN invoke THEN Right Success`() = runTest {
// Arrange
val expected = EnrollResult.Success(tokenReward)
coEvery { repository.enroll(campaign, tokenReward, walletIds) } returns expected
// Act
val result = useCase(campaign, tokenReward, walletIds)
// Assert
assertThat(result.getOrNull()).isEqualTo(expected)
}
@Test
fun `GIVEN repo throws WHEN invoke THEN Left`() = runTest {
// Arrange
val error = IOException("x")
coEvery { repository.enroll(campaign, tokenReward, walletIds) } throws error
// Act
val result = useCase(campaign, tokenReward, walletIds)
// Assert
assertEitherLeft(result, error)
}
}

View file

@ -0,0 +1,55 @@
package com.tangem.domain.promo.usecase
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.promo.models.PromoCampaignId
import com.tangem.domain.promo.models.PromoCampaignState
import com.tangem.test.core.assertEitherLeft
import io.mockk.clearMocks
import io.mockk.coEvery
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 GetPromoCampaignStateUseCaseTest {
private val repository: PromoRepository = mockk()
private val useCase = GetPromoCampaignStateUseCase(repository)
private val campaign = PromoCampaignId.WhaleSwapCashback
private val userWalletId = UserWalletId("abcdef012345")
@BeforeEach
fun setUp() = clearMocks(repository)
@Test
fun `GIVEN repo returns state WHEN invoke THEN Right of state`() = runTest {
// Arrange
val expected = PromoCampaignState.NotActive(campaign)
coEvery { repository.getCampaignState(campaign, userWalletId, false) } returns expected
// Act
val result = useCase(campaign, userWalletId)
// Assert
assertThat(result.getOrNull()).isEqualTo(expected)
}
@Test
fun `GIVEN repo throws WHEN invoke THEN Left`() = runTest {
// Arrange
val error = IOException("x")
coEvery { repository.getCampaignState(campaign, userWalletId, false) } throws error
// Act
val result = useCase(campaign, userWalletId)
// Assert
assertEitherLeft(result, error)
}
}

View file

@ -475,6 +475,8 @@ include(":domain:wallet-manager")
include(":domain:wallet-manager:models")
include(":domain:yield-supply")
include(":domain:yield-supply:models")
include(":domain:promo")
include(":domain:promo:models")
include(":domain:news")
include(":domain:earn")
include(":domain:search")
@ -522,6 +524,7 @@ include(":data:swap")
include(":data:express")
include(":data:wallet-manager")
include(":data:yield-supply")
include(":data:promo")
include(":data:news")
include(":data:earn")
include(":data:search")