From 2debaafcbb5268f2a426cc2ed67b3e812c4e149a Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 8 Jul 2026 20:13:28 +0500 Subject: [PATCH] Updated on 2026-08-14 --- app/build.gradle.kts | 2 + .../tangem/tap/di/domain/PromoDomainModule.kt | 27 ++ .../models/CreatePromotionRegistrationBody.kt | 18 ++ .../models/PromotionRegistrationResponse.kt | 19 ++ .../api/tangemTech/TangemTechApi.kt | 10 +- .../tangem/datasource/di/PromotionModule.kt | 32 +++ .../local/preferences/PreferencesKeys.kt | 2 + .../promotion/DefaultPromotionsSupplier.kt | 27 ++ .../local/promotion/PromotionsSupplier.kt | 15 ++ .../DefaultPromotionsSupplierTest.kt | 119 +++++++++ data/promo/build.gradle.kts | 46 ++++ .../data/promo/DefaultPromoRepository.kt | 97 +++++++ .../promo/converter/PromoCampaignConverter.kt | 29 ++ .../tangem/data/promo/di/PromoDataModule.kt | 46 ++++ .../store/DefaultPromoEnrollmentStore.kt | 27 ++ .../data/promo/store/PromoEnrollmentStore.kt | 11 + .../data/promo/DefaultPromoRepositoryTest.kt | 208 +++++++++++++++ .../converter/PromoCampaignConverterTest.kt | 54 ++++ .../yield/supply/di/YieldSupplyDataModule.kt | 3 + .../promo/DefaultYieldPromoRepository.kt | 16 +- .../promo/DefaultYieldPromoRepositoryTest.kt | 248 ++++++++++++++++++ domain/promo/build.gradle.kts | 34 +++ domain/promo/models/build.gradle.kts | 20 ++ .../domain/promo/models/PromoCampaignId.kt | 12 + .../domain/promo/models/PromoCampaignState.kt | 21 ++ .../tangem/domain/promo/models/PromoModels.kt | 27 ++ .../promo/models/PromoCampaignIdTest.kt | 29 ++ .../tangem/domain/promo/PromoRepository.kt | 38 +++ .../usecase/EnrollPromoCampaignUseCase.kt | 21 ++ .../usecase/GetPromoCampaignStateUseCase.kt | 20 ++ .../usecase/EnrollPromoCampaignUseCaseTest.kt | 57 ++++ .../GetPromoCampaignStateUseCaseTest.kt | 55 ++++ settings.gradle.kts | 3 + 33 files changed, 1386 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/di/domain/PromoDomainModule.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/CreatePromotionRegistrationBody.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionRegistrationResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/di/PromotionModule.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/promotion/DefaultPromotionsSupplier.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/promotion/PromotionsSupplier.kt create mode 100644 core/datasource/src/test/java/com/tangem/datasource/local/promotion/DefaultPromotionsSupplierTest.kt create mode 100644 data/promo/build.gradle.kts create mode 100644 data/promo/src/main/kotlin/com/tangem/data/promo/DefaultPromoRepository.kt create mode 100644 data/promo/src/main/kotlin/com/tangem/data/promo/converter/PromoCampaignConverter.kt create mode 100644 data/promo/src/main/kotlin/com/tangem/data/promo/di/PromoDataModule.kt create mode 100644 data/promo/src/main/kotlin/com/tangem/data/promo/store/DefaultPromoEnrollmentStore.kt create mode 100644 data/promo/src/main/kotlin/com/tangem/data/promo/store/PromoEnrollmentStore.kt create mode 100644 data/promo/src/test/kotlin/com/tangem/data/promo/DefaultPromoRepositoryTest.kt create mode 100644 data/promo/src/test/kotlin/com/tangem/data/promo/converter/PromoCampaignConverterTest.kt create mode 100644 data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepositoryTest.kt create mode 100644 domain/promo/build.gradle.kts create mode 100644 domain/promo/models/build.gradle.kts create mode 100644 domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignId.kt create mode 100644 domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignState.kt create mode 100644 domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoModels.kt create mode 100644 domain/promo/models/src/test/kotlin/com/tangem/domain/promo/models/PromoCampaignIdTest.kt create mode 100644 domain/promo/src/main/kotlin/com/tangem/domain/promo/PromoRepository.kt create mode 100644 domain/promo/src/main/kotlin/com/tangem/domain/promo/usecase/EnrollPromoCampaignUseCase.kt create mode 100644 domain/promo/src/main/kotlin/com/tangem/domain/promo/usecase/GetPromoCampaignStateUseCase.kt create mode 100644 domain/promo/src/test/kotlin/com/tangem/domain/promo/usecase/EnrollPromoCampaignUseCaseTest.kt create mode 100644 domain/promo/src/test/kotlin/com/tangem/domain/promo/usecase/GetPromoCampaignStateUseCaseTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 530687a6d1..5a4feed03c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -164,6 +164,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) @@ -225,6 +226,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) diff --git a/app/src/main/java/com/tangem/tap/di/domain/PromoDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/PromoDomainModule.kt new file mode 100644 index 0000000000..6c4db55e1e --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/PromoDomainModule.kt @@ -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) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/CreatePromotionRegistrationBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/CreatePromotionRegistrationBody.kt new file mode 100644 index 0000000000..dd02a67af8 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/CreatePromotionRegistrationBody.kt @@ -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, + @Json(name = "tokenReward") val tokenReward: TokenRewardDto, +) { + + @JsonClass(generateAdapter = true) + data class TokenRewardDto( + @Json(name = "tokenAddress") val tokenAddress: String, + @Json(name = "networkId") val networkId: String, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionRegistrationResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionRegistrationResponse.kt new file mode 100644 index 0000000000..7f6da1f3ec --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionRegistrationResponse.kt @@ -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, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index 7530892018..9fe1bd2211 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -1,6 +1,9 @@ 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 import com.tangem.datasource.api.stories.models.StoryContentResponse @@ -120,7 +123,7 @@ interface TangemTechApi { @GET("v1/stories/{story_id}") suspend fun getStoryById(@Path("story_id") storyId: String): ApiResponse - // region yield-boost promo + // region promotions @GET("/v2/promotion") suspend fun getPromotions( @Query("walletId") walletId: String, @@ -130,6 +133,11 @@ interface TangemTechApi { @Suppress("FunctionSignature", "TrailingCommaOnDeclarationSite") @GET("/v2/promotion/yield-apr-boost/status") suspend fun getYieldBoostStatus(@Query("walletId") walletId: String): ApiResponse + + @POST("/v2/promotion/registrations") + suspend fun createPromotionRegistration( + @Body body: CreatePromotionRegistrationBody, + ): ApiResponse // endregion // region push notifications diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/PromotionModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/PromotionModule.kt new file mode 100644 index 0000000000..298c6c602d --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/di/PromotionModule.kt @@ -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>(), + dispatchers = dispatchers, + ) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index eba1fa1049..f986a34931 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -129,6 +129,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") } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/promotion/DefaultPromotionsSupplier.kt b/core/datasource/src/main/java/com/tangem/datasource/local/promotion/DefaultPromotionsSupplier.kt new file mode 100644 index 0000000000..63ed044753 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/promotion/DefaultPromotionsSupplier.kt @@ -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>, + 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 + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/promotion/PromotionsSupplier.kt b/core/datasource/src/main/java/com/tangem/datasource/local/promotion/PromotionsSupplier.kt new file mode 100644 index 0000000000..1c3395f5bd --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/promotion/PromotionsSupplier.kt @@ -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 +} \ No newline at end of file diff --git a/core/datasource/src/test/java/com/tangem/datasource/local/promotion/DefaultPromotionsSupplierTest.kt b/core/datasource/src/test/java/com/tangem/datasource/local/promotion/DefaultPromotionsSupplierTest.kt new file mode 100644 index 0000000000..2742d52c09 --- /dev/null +++ b/core/datasource/src/test/java/com/tangem/datasource/local/promotion/DefaultPromotionsSupplierTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/data/promo/build.gradle.kts b/data/promo/build.gradle.kts new file mode 100644 index 0000000000..690ad6c659 --- /dev/null +++ b/data/promo/build.gradle.kts @@ -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 +} \ No newline at end of file diff --git a/data/promo/src/main/kotlin/com/tangem/data/promo/DefaultPromoRepository.kt b/data/promo/src/main/kotlin/com/tangem/data/promo/DefaultPromoRepository.kt new file mode 100644 index 0000000000..f1f3d5d51a --- /dev/null +++ b/data/promo/src/main/kotlin/com/tangem/data/promo/DefaultPromoRepository.kt @@ -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, + ): 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" + } +} \ No newline at end of file diff --git a/data/promo/src/main/kotlin/com/tangem/data/promo/converter/PromoCampaignConverter.kt b/data/promo/src/main/kotlin/com/tangem/data/promo/converter/PromoCampaignConverter.kt new file mode 100644 index 0000000000..4db59a14f2 --- /dev/null +++ b/data/promo/src/main/kotlin/com/tangem/data/promo/converter/PromoCampaignConverter.kt @@ -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), + ), + ) + } +} \ No newline at end of file diff --git a/data/promo/src/main/kotlin/com/tangem/data/promo/di/PromoDataModule.kt b/data/promo/src/main/kotlin/com/tangem/data/promo/di/PromoDataModule.kt new file mode 100644 index 0000000000..86123f8e4a --- /dev/null +++ b/data/promo/src/main/kotlin/com/tangem/data/promo/di/PromoDataModule.kt @@ -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, + ) + } +} \ No newline at end of file diff --git a/data/promo/src/main/kotlin/com/tangem/data/promo/store/DefaultPromoEnrollmentStore.kt b/data/promo/src/main/kotlin/com/tangem/data/promo/store/DefaultPromoEnrollmentStore.kt new file mode 100644 index 0000000000..9266118a8b --- /dev/null +++ b/data/promo/src/main/kotlin/com/tangem/data/promo/store/DefaultPromoEnrollmentStore.kt @@ -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(PreferencesKeys.PROMO_ENROLLMENTS_KEY)[campaign.slug] + } + + override suspend fun store(campaign: PromoCampaignId, tokenReward: TokenReward) { + appPreferencesStore.editData { mutablePreferences -> + val current = mutablePreferences.getObjectMap(PreferencesKeys.PROMO_ENROLLMENTS_KEY) + mutablePreferences.setObjectMap( + key = PreferencesKeys.PROMO_ENROLLMENTS_KEY, + value = current + (campaign.slug to tokenReward), + ) + } + } +} \ No newline at end of file diff --git a/data/promo/src/main/kotlin/com/tangem/data/promo/store/PromoEnrollmentStore.kt b/data/promo/src/main/kotlin/com/tangem/data/promo/store/PromoEnrollmentStore.kt new file mode 100644 index 0000000000..2f770ef638 --- /dev/null +++ b/data/promo/src/main/kotlin/com/tangem/data/promo/store/PromoEnrollmentStore.kt @@ -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) +} \ No newline at end of file diff --git a/data/promo/src/test/kotlin/com/tangem/data/promo/DefaultPromoRepositoryTest.kt b/data/promo/src/test/kotlin/com/tangem/data/promo/DefaultPromoRepositoryTest.kt new file mode 100644 index 0000000000..061e43c88c --- /dev/null +++ b/data/promo/src/test/kotlin/com/tangem/data/promo/DefaultPromoRepositoryTest.kt @@ -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 + + // 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 + + // 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 + + // Act + val error = runCatching { repository.enroll(campaign, tokenReward, listOf(userWalletId)) }.exceptionOrNull() + + // Assert + assertThat(error).isNotNull() + coVerify(exactly = 0) { enrollmentStore.store(any(), any()) } + } +} \ No newline at end of file diff --git a/data/promo/src/test/kotlin/com/tangem/data/promo/converter/PromoCampaignConverterTest.kt b/data/promo/src/test/kotlin/com/tangem/data/promo/converter/PromoCampaignConverterTest.kt new file mode 100644 index 0000000000..522ff2a3cf --- /dev/null +++ b/data/promo/src/test/kotlin/com/tangem/data/promo/converter/PromoCampaignConverterTest.kt @@ -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() + } +} \ No newline at end of file diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt index 38ff5bba43..97b4b09ff1 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt @@ -9,6 +9,7 @@ import com.tangem.data.yield.supply.promo.DefaultYieldPromoRepository import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.YieldSupplyApi import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.promotion.PromotionsSupplier import com.tangem.datasource.local.yieldsupply.YieldMarketsStore import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore @@ -83,12 +84,14 @@ internal object YieldSupplyDataModule { @Singleton fun provideYieldPromoRepository( tangemApi: TangemTechApi, + promotionsSupplier: PromotionsSupplier, promoStore: YieldBoostPromoStore, statusStore: YieldBoostStatusStore, dispatchers: CoroutineDispatcherProvider, ): YieldPromoRepository { return DefaultYieldPromoRepository( tangemApi = tangemApi, + promotionsSupplier = promotionsSupplier, promoStore = promoStore, statusStore = statusStore, dispatchers = dispatchers, diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepository.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepository.kt index f9463dd69e..2e5548e4e8 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepository.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepository.kt @@ -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() diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepositoryTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepositoryTest.kt new file mode 100644 index 0000000000..4b1562c0c8 --- /dev/null +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepositoryTest.kt @@ -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, + ) +} \ No newline at end of file diff --git a/domain/promo/build.gradle.kts b/domain/promo/build.gradle.kts new file mode 100644 index 0000000000..ae01bc51ba --- /dev/null +++ b/domain/promo/build.gradle.kts @@ -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 +} \ No newline at end of file diff --git a/domain/promo/models/build.gradle.kts b/domain/promo/models/build.gradle.kts new file mode 100644 index 0000000000..fe5476f27b --- /dev/null +++ b/domain/promo/models/build.gradle.kts @@ -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 +} \ No newline at end of file diff --git a/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignId.kt b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignId.kt new file mode 100644 index 0000000000..81269512e0 --- /dev/null +++ b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignId.kt @@ -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 } + } +} \ No newline at end of file diff --git a/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignState.kt b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignState.kt new file mode 100644 index 0000000000..6e580f4fcd --- /dev/null +++ b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignState.kt @@ -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, + val timeline: PromoTimeline, + ) : PromoCampaignState + + data class Enrolled( + override val campaign: PromoCampaignId, + val tokenReward: TokenReward, + ) : PromoCampaignState + + data class NotActive( + override val campaign: PromoCampaignId, + ) : PromoCampaignState +} \ No newline at end of file diff --git a/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoModels.kt b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoModels.kt new file mode 100644 index 0000000000..8d72a7c755 --- /dev/null +++ b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoModels.kt @@ -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 +} \ No newline at end of file diff --git a/domain/promo/models/src/test/kotlin/com/tangem/domain/promo/models/PromoCampaignIdTest.kt b/domain/promo/models/src/test/kotlin/com/tangem/domain/promo/models/PromoCampaignIdTest.kt new file mode 100644 index 0000000000..acd7d9425d --- /dev/null +++ b/domain/promo/models/src/test/kotlin/com/tangem/domain/promo/models/PromoCampaignIdTest.kt @@ -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() + } +} \ No newline at end of file diff --git a/domain/promo/src/main/kotlin/com/tangem/domain/promo/PromoRepository.kt b/domain/promo/src/main/kotlin/com/tangem/domain/promo/PromoRepository.kt new file mode 100644 index 0000000000..21de8bea8e --- /dev/null +++ b/domain/promo/src/main/kotlin/com/tangem/domain/promo/PromoRepository.kt @@ -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, + ): EnrollResult +} \ No newline at end of file diff --git a/domain/promo/src/main/kotlin/com/tangem/domain/promo/usecase/EnrollPromoCampaignUseCase.kt b/domain/promo/src/main/kotlin/com/tangem/domain/promo/usecase/EnrollPromoCampaignUseCase.kt new file mode 100644 index 0000000000..93cdcfbb18 --- /dev/null +++ b/domain/promo/src/main/kotlin/com/tangem/domain/promo/usecase/EnrollPromoCampaignUseCase.kt @@ -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, + ): Either = Either.catch { + repository.enroll(campaign, tokenReward, walletIds) + } +} \ No newline at end of file diff --git a/domain/promo/src/main/kotlin/com/tangem/domain/promo/usecase/GetPromoCampaignStateUseCase.kt b/domain/promo/src/main/kotlin/com/tangem/domain/promo/usecase/GetPromoCampaignStateUseCase.kt new file mode 100644 index 0000000000..9a920219f5 --- /dev/null +++ b/domain/promo/src/main/kotlin/com/tangem/domain/promo/usecase/GetPromoCampaignStateUseCase.kt @@ -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 = Either.catch { + repository.getCampaignState(campaign, userWalletId, forceRefresh) + } +} \ No newline at end of file diff --git a/domain/promo/src/test/kotlin/com/tangem/domain/promo/usecase/EnrollPromoCampaignUseCaseTest.kt b/domain/promo/src/test/kotlin/com/tangem/domain/promo/usecase/EnrollPromoCampaignUseCaseTest.kt new file mode 100644 index 0000000000..a308dc7335 --- /dev/null +++ b/domain/promo/src/test/kotlin/com/tangem/domain/promo/usecase/EnrollPromoCampaignUseCaseTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/domain/promo/src/test/kotlin/com/tangem/domain/promo/usecase/GetPromoCampaignStateUseCaseTest.kt b/domain/promo/src/test/kotlin/com/tangem/domain/promo/usecase/GetPromoCampaignStateUseCaseTest.kt new file mode 100644 index 0000000000..6140437e2a --- /dev/null +++ b/domain/promo/src/test/kotlin/com/tangem/domain/promo/usecase/GetPromoCampaignStateUseCaseTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index b61afb33b1..aac87637cc 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -432,6 +432,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") @@ -476,6 +478,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")