Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-08 20:13:28 +05:00
parent 7472e7b1dc
commit 2debaafcbb
33 changed files with 1386 additions and 7 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,57 @@
package com.tangem.domain.promo.usecase
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.promo.models.EnrollResult
import com.tangem.domain.promo.models.PromoCampaignId
import com.tangem.domain.promo.models.TokenReward
import com.tangem.test.core.assertEitherLeft
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.io.IOException
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class EnrollPromoCampaignUseCaseTest {
private val repository: PromoRepository = mockk()
private val useCase = EnrollPromoCampaignUseCase(repository)
private val campaign = PromoCampaignId.WhaleSwapCashback
private val walletIds = listOf(UserWalletId("abcdef012345"))
private val tokenReward = TokenReward("0xToken", "ethereum")
@BeforeEach
fun setUp() = clearMocks(repository)
@Test
fun `GIVEN repo returns Success WHEN invoke THEN Right Success`() = runTest {
// Arrange
val expected = EnrollResult.Success(tokenReward)
coEvery { repository.enroll(campaign, tokenReward, walletIds) } returns expected
// Act
val result = useCase(campaign, tokenReward, walletIds)
// Assert
assertThat(result.getOrNull()).isEqualTo(expected)
}
@Test
fun `GIVEN repo throws WHEN invoke THEN Left`() = runTest {
// Arrange
val error = IOException("x")
coEvery { repository.enroll(campaign, tokenReward, walletIds) } throws error
// Act
val result = useCase(campaign, tokenReward, walletIds)
// Assert
assertEitherLeft(result, error)
}
}

View file

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