Updated on 2026-08-14

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,32 @@
package com.tangem.datasource.di
import com.tangem.datasource.api.promotion.models.PromotionsResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.promotion.DefaultPromotionsSupplier
import com.tangem.datasource.local.promotion.PromotionsSupplier
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object PromotionModule {
@Provides
@Singleton
fun providePromotionsSupplier(
tangemApi: TangemTechApi,
dispatchers: CoroutineDispatcherProvider,
): PromotionsSupplier {
return DefaultPromotionsSupplier(
tangemApi = tangemApi,
store = RuntimeSharedStore<Map<UserWalletId, PromotionsResponse>>(),
dispatchers = dispatchers,
)
}
}

View file

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

View file

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

View file

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

View file

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