Updated on 2026-08-14
This commit is contained in:
parent
7472e7b1dc
commit
2debaafcbb
33 changed files with 1386 additions and 7 deletions
|
|
@ -164,6 +164,7 @@ dependencies {
|
||||||
implementation(projects.domain.walletManager)
|
implementation(projects.domain.walletManager)
|
||||||
implementation(projects.domain.walletManager.models)
|
implementation(projects.domain.walletManager.models)
|
||||||
implementation(projects.domain.yieldSupply)
|
implementation(projects.domain.yieldSupply)
|
||||||
|
implementation(projects.domain.promo)
|
||||||
implementation(projects.domain.blockaid)
|
implementation(projects.domain.blockaid)
|
||||||
implementation(projects.domain.hotWallet)
|
implementation(projects.domain.hotWallet)
|
||||||
implementation(projects.domain.news)
|
implementation(projects.domain.news)
|
||||||
|
|
@ -225,6 +226,7 @@ dependencies {
|
||||||
implementation(projects.data.swap)
|
implementation(projects.data.swap)
|
||||||
implementation(projects.data.walletManager)
|
implementation(projects.data.walletManager)
|
||||||
implementation(projects.data.yieldSupply)
|
implementation(projects.data.yieldSupply)
|
||||||
|
implementation(projects.data.promo)
|
||||||
implementation(projects.data.hotWallet)
|
implementation(projects.data.hotWallet)
|
||||||
implementation(projects.data.news)
|
implementation(projects.data.news)
|
||||||
implementation(projects.data.earn)
|
implementation(projects.data.earn)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package com.tangem.datasource.api.tangemTech
|
package com.tangem.datasource.api.tangemTech
|
||||||
|
|
||||||
import com.tangem.datasource.api.common.response.ApiResponse
|
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.PromotionsResponse
|
||||||
import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse
|
import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse
|
||||||
import com.tangem.datasource.api.stories.models.StoryContentResponse
|
import com.tangem.datasource.api.stories.models.StoryContentResponse
|
||||||
|
|
@ -120,7 +123,7 @@ interface TangemTechApi {
|
||||||
@GET("v1/stories/{story_id}")
|
@GET("v1/stories/{story_id}")
|
||||||
suspend fun getStoryById(@Path("story_id") storyId: String): ApiResponse<StoryContentResponse>
|
suspend fun getStoryById(@Path("story_id") storyId: String): ApiResponse<StoryContentResponse>
|
||||||
|
|
||||||
// region yield-boost promo
|
// region promotions
|
||||||
@GET("/v2/promotion")
|
@GET("/v2/promotion")
|
||||||
suspend fun getPromotions(
|
suspend fun getPromotions(
|
||||||
@Query("walletId") walletId: String,
|
@Query("walletId") walletId: String,
|
||||||
|
|
@ -130,6 +133,11 @@ interface TangemTechApi {
|
||||||
@Suppress("FunctionSignature", "TrailingCommaOnDeclarationSite")
|
@Suppress("FunctionSignature", "TrailingCommaOnDeclarationSite")
|
||||||
@GET("/v2/promotion/yield-apr-boost/status")
|
@GET("/v2/promotion/yield-apr-boost/status")
|
||||||
suspend fun getYieldBoostStatus(@Query("walletId") walletId: String): ApiResponse<YieldBoostStatusResponse>
|
suspend fun getYieldBoostStatus(@Query("walletId") walletId: String): ApiResponse<YieldBoostStatusResponse>
|
||||||
|
|
||||||
|
@POST("/v2/promotion/registrations")
|
||||||
|
suspend fun createPromotionRegistration(
|
||||||
|
@Body body: CreatePromotionRegistrationBody,
|
||||||
|
): ApiResponse<PromotionRegistrationResponse>
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region push notifications
|
// region push notifications
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -129,6 +129,8 @@ object PreferencesKeys {
|
||||||
|
|
||||||
val PENDING_ASSETS_DISCOVERY_KEY by lazy { stringPreferencesKey(name = "pendingAssetsDiscovery") }
|
val PENDING_ASSETS_DISCOVERY_KEY by lazy { stringPreferencesKey(name = "pendingAssetsDiscovery") }
|
||||||
|
|
||||||
|
val PROMO_ENROLLMENTS_KEY by lazy { stringPreferencesKey(name = "promoEnrollments") }
|
||||||
|
|
||||||
// region Notifications
|
// region Notifications
|
||||||
val NOTIFICATIONS_APPLICATION_ID_KEY by lazy { stringPreferencesKey(name = "notificationsApplicationId") }
|
val NOTIFICATIONS_APPLICATION_ID_KEY by lazy { stringPreferencesKey(name = "notificationsApplicationId") }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
46
data/promo/build.gradle.kts
Normal file
46
data/promo/build.gradle.kts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.TangemTechApi
|
||||||
import com.tangem.datasource.api.tangemTech.YieldSupplyApi
|
import com.tangem.datasource.api.tangemTech.YieldSupplyApi
|
||||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
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.YieldMarketsStore
|
||||||
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore
|
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore
|
||||||
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore
|
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore
|
||||||
|
|
@ -83,12 +84,14 @@ internal object YieldSupplyDataModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideYieldPromoRepository(
|
fun provideYieldPromoRepository(
|
||||||
tangemApi: TangemTechApi,
|
tangemApi: TangemTechApi,
|
||||||
|
promotionsSupplier: PromotionsSupplier,
|
||||||
promoStore: YieldBoostPromoStore,
|
promoStore: YieldBoostPromoStore,
|
||||||
statusStore: YieldBoostStatusStore,
|
statusStore: YieldBoostStatusStore,
|
||||||
dispatchers: CoroutineDispatcherProvider,
|
dispatchers: CoroutineDispatcherProvider,
|
||||||
): YieldPromoRepository {
|
): YieldPromoRepository {
|
||||||
return DefaultYieldPromoRepository(
|
return DefaultYieldPromoRepository(
|
||||||
tangemApi = tangemApi,
|
tangemApi = tangemApi,
|
||||||
|
promotionsSupplier = promotionsSupplier,
|
||||||
promoStore = promoStore,
|
promoStore = promoStore,
|
||||||
statusStore = statusStore,
|
statusStore = statusStore,
|
||||||
dispatchers = dispatchers,
|
dispatchers = dispatchers,
|
||||||
|
|
|
||||||
|
|
@ -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.data.yield.supply.promo.converter.YieldBoostStatusConverter
|
||||||
import com.tangem.datasource.api.common.response.getOrThrow
|
import com.tangem.datasource.api.common.response.getOrThrow
|
||||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
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.YieldBoostPromoStore
|
||||||
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore
|
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore
|
||||||
import com.tangem.domain.models.wallet.UserWalletId
|
import com.tangem.domain.models.wallet.UserWalletId
|
||||||
|
|
@ -15,6 +16,7 @@ import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
internal class DefaultYieldPromoRepository(
|
internal class DefaultYieldPromoRepository(
|
||||||
private val tangemApi: TangemTechApi,
|
private val tangemApi: TangemTechApi,
|
||||||
|
private val promotionsSupplier: PromotionsSupplier,
|
||||||
private val promoStore: YieldBoostPromoStore,
|
private val promoStore: YieldBoostPromoStore,
|
||||||
private val statusStore: YieldBoostStatusStore,
|
private val statusStore: YieldBoostStatusStore,
|
||||||
private val dispatchers: CoroutineDispatcherProvider,
|
private val dispatchers: CoroutineDispatcherProvider,
|
||||||
|
|
@ -25,7 +27,7 @@ internal class DefaultYieldPromoRepository(
|
||||||
promoStore.getSyncOrNull(userWalletId)?.let { return it }
|
promoStore.getSyncOrNull(userWalletId)?.let { return it }
|
||||||
}
|
}
|
||||||
return try {
|
return try {
|
||||||
val fresh = fetchPromo(userWalletId)
|
val fresh = fetchPromo(userWalletId, forceRefresh)
|
||||||
promoStore.store(userWalletId, fresh)
|
promoStore.store(userWalletId, fresh)
|
||||||
fresh
|
fresh
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
@ -46,11 +48,13 @@ internal class DefaultYieldPromoRepository(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fetchPromo(userWalletId: UserWalletId): YieldBoostPromo = withContext(dispatchers.io) {
|
private suspend fun fetchPromo(userWalletId: UserWalletId, forceRefresh: Boolean): YieldBoostPromo =
|
||||||
val response = tangemApi.getPromotions(walletId = userWalletId.stringValue).getOrThrow()
|
withContext(dispatchers.io) {
|
||||||
val dto = response.promotions.firstOrNull { it.name == PROMO_NAME } ?: return@withContext YieldBoostPromo.None
|
val response = promotionsSupplier.getPromotions(userWalletId, forceRefresh)
|
||||||
YieldBoostPromoConverter.convert(dto)
|
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) {
|
private suspend fun fetchStatus(userWalletId: UserWalletId): YieldBoostStatus = withContext(dispatchers.io) {
|
||||||
val response = tangemApi.getYieldBoostStatus(walletId = userWalletId.stringValue).getOrThrow()
|
val response = tangemApi.getYieldBoostStatus(walletId = userWalletId.stringValue).getOrThrow()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
34
domain/promo/build.gradle.kts
Normal file
34
domain/promo/build.gradle.kts
Normal 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
|
||||||
|
}
|
||||||
20
domain/promo/models/build.gradle.kts
Normal file
20
domain/promo/models/build.gradle.kts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -432,6 +432,8 @@ include(":domain:wallet-manager")
|
||||||
include(":domain:wallet-manager:models")
|
include(":domain:wallet-manager:models")
|
||||||
include(":domain:yield-supply")
|
include(":domain:yield-supply")
|
||||||
include(":domain:yield-supply:models")
|
include(":domain:yield-supply:models")
|
||||||
|
include(":domain:promo")
|
||||||
|
include(":domain:promo:models")
|
||||||
include(":domain:news")
|
include(":domain:news")
|
||||||
include(":domain:earn")
|
include(":domain:earn")
|
||||||
include(":domain:search")
|
include(":domain:search")
|
||||||
|
|
@ -476,6 +478,7 @@ include(":data:swap")
|
||||||
include(":data:express")
|
include(":data:express")
|
||||||
include(":data:wallet-manager")
|
include(":data:wallet-manager")
|
||||||
include(":data:yield-supply")
|
include(":data:yield-supply")
|
||||||
|
include(":data:promo")
|
||||||
include(":data:news")
|
include(":data:news")
|
||||||
include(":data:earn")
|
include(":data:earn")
|
||||||
include(":data:search")
|
include(":data:search")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue