Updated on 2026-08-14
This commit is contained in:
parent
7472e7b1dc
commit
2debaafcbb
33 changed files with 1386 additions and 7 deletions
|
|
@ -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
|
||||
|
||||
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<StoryContentResponse>
|
||||
|
||||
// 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<YieldBoostStatusResponse>
|
||||
|
||||
@POST("/v2/promotion/registrations")
|
||||
suspend fun createPromotionRegistration(
|
||||
@Body body: CreatePromotionRegistrationBody,
|
||||
): ApiResponse<PromotionRegistrationResponse>
|
||||
// endregion
|
||||
|
||||
// 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 PROMO_ENROLLMENTS_KEY by lazy { stringPreferencesKey(name = "promoEnrollments") }
|
||||
|
||||
// region Notifications
|
||||
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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue