mirror of
https://github.com/anyproto/anytype-kotlin.git
synced 2025-06-08 05:47:05 +09:00
DROID-2551 Membership | Active Builder on different accounts (#1254)
This commit is contained in:
parent
560404bd6d
commit
7b9190a215
9 changed files with 668 additions and 42 deletions
|
@ -1,6 +1,7 @@
|
|||
plugins {
|
||||
id "com.android.library"
|
||||
id "kotlin-android"
|
||||
id "kotlinx-serialization"
|
||||
}
|
||||
|
||||
android {
|
||||
|
@ -47,6 +48,8 @@ dependencies {
|
|||
|
||||
implementation libs.timber
|
||||
|
||||
implementation libs.kotlinxSerializationJson
|
||||
|
||||
testImplementation libs.junit
|
||||
testImplementation libs.kotlinTest
|
||||
testImplementation libs.mockitoKotlin
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
package com.anytypeio.anytype.payments.mapping
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import com.android.billingclient.api.ProductDetails
|
||||
import com.android.billingclient.api.Purchase
|
||||
import com.anytypeio.anytype.core_models.membership.Membership
|
||||
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
|
||||
import com.anytypeio.anytype.core_models.membership.MembershipPeriodType
|
||||
|
@ -26,10 +29,10 @@ import com.anytypeio.anytype.payments.viewmodel.MembershipMainState
|
|||
import com.anytypeio.anytype.presentation.membership.models.MembershipStatus
|
||||
import com.anytypeio.anytype.presentation.membership.models.TierId
|
||||
|
||||
|
||||
fun MembershipStatus.toMainView(
|
||||
billingClientState: BillingClientState,
|
||||
billingPurchaseState: BillingPurchaseState
|
||||
billingPurchaseState: BillingPurchaseState,
|
||||
accountId: String
|
||||
): MembershipMainState {
|
||||
val (showBanner, subtitle) = if (activeTier.value in ACTIVE_TIERS_WITH_BANNERS) {
|
||||
true to R.string.payments_subheader
|
||||
|
@ -45,7 +48,7 @@ fun MembershipStatus.toMainView(
|
|||
billingClientState = billingClientState,
|
||||
billingPurchaseState = billingPurchaseState
|
||||
)
|
||||
},
|
||||
}.sortedByDescending { it.isActive },
|
||||
membershipLevelDetails = MEMBERSHIP_LEVEL_DETAILS,
|
||||
privacyPolicy = PRIVACY_POLICY,
|
||||
termsOfService = TERMS_OF_SERVICE,
|
||||
|
@ -55,7 +58,8 @@ fun MembershipStatus.toMainView(
|
|||
it.toView(
|
||||
membershipStatus = this,
|
||||
billingClientState = billingClientState,
|
||||
billingPurchaseState = billingPurchaseState
|
||||
billingPurchaseState = billingPurchaseState,
|
||||
accountId = accountId,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
@ -79,7 +83,8 @@ private fun MembershipTierData.isActiveTierPurchasedOnAndroid(activePaymentMetho
|
|||
fun MembershipTierData.toView(
|
||||
membershipStatus: MembershipStatus,
|
||||
billingClientState: BillingClientState,
|
||||
billingPurchaseState: BillingPurchaseState
|
||||
billingPurchaseState: BillingPurchaseState,
|
||||
accountId: String
|
||||
): Tier {
|
||||
val tierId = TierId(id)
|
||||
val isActive = membershipStatus.isTierActive(id)
|
||||
|
@ -107,7 +112,8 @@ fun MembershipTierData.toView(
|
|||
buttonState = toButtonView(
|
||||
isActive = isActive,
|
||||
billingPurchaseState = billingPurchaseState,
|
||||
membershipStatus = membershipStatus
|
||||
membershipStatus = membershipStatus,
|
||||
accountId = accountId
|
||||
),
|
||||
email = emailState,
|
||||
color = colorStr,
|
||||
|
@ -148,7 +154,8 @@ fun MembershipTierData.toPreviewView(
|
|||
private fun MembershipTierData.toButtonView(
|
||||
isActive: Boolean,
|
||||
billingPurchaseState: BillingPurchaseState,
|
||||
membershipStatus: MembershipStatus
|
||||
membershipStatus: MembershipStatus,
|
||||
accountId: String
|
||||
): TierButton {
|
||||
val androidProductId = this.androidProductId
|
||||
val androidInfoUrl = this.androidManageUrl
|
||||
|
@ -206,14 +213,15 @@ private fun MembershipTierData.toButtonView(
|
|||
} else {
|
||||
when (billingPurchaseState) {
|
||||
is BillingPurchaseState.HasPurchases -> {
|
||||
//Tier has purchase, but it's not active yet, still waiting for a event from the mw
|
||||
TierButton.Hidden
|
||||
getButtonStateAccordingToPurchaseState(
|
||||
androidProductId = androidProductId,
|
||||
accountId = accountId,
|
||||
purchases = billingPurchaseState.purchases
|
||||
)
|
||||
}
|
||||
|
||||
BillingPurchaseState.Loading -> {
|
||||
TierButton.Hidden
|
||||
}
|
||||
|
||||
BillingPurchaseState.NoPurchases -> {
|
||||
if (membershipStatus.anyName.isBlank()) {
|
||||
TierButton.Pay.Disabled
|
||||
|
@ -226,6 +234,32 @@ private fun MembershipTierData.toButtonView(
|
|||
}
|
||||
}
|
||||
|
||||
private fun getButtonStateAccordingToPurchaseState(
|
||||
androidProductId: String?,
|
||||
accountId: String,
|
||||
purchases: List<Purchase>
|
||||
): TierButton {
|
||||
when (purchases.size) {
|
||||
0 -> {
|
||||
return TierButton.Hidden
|
||||
}
|
||||
1 -> {
|
||||
val purchase = purchases[0]
|
||||
val purchaseModel = Json.decodeFromString<PurchaseModel>(purchase.originalJson)
|
||||
if (purchaseModel.obfuscatedAccountId != accountId) {
|
||||
return TierButton.HiddenWithText.DifferentPurchaseAccountId
|
||||
}
|
||||
if (purchaseModel.productId != androidProductId) {
|
||||
return TierButton.HiddenWithText.DifferentPurchaseProductId
|
||||
}
|
||||
return TierButton.Hidden
|
||||
}
|
||||
else -> {
|
||||
return TierButton.HiddenWithText.MoreThenOnePurchase
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MembershipTierData.getAnyName(
|
||||
isActive: Boolean,
|
||||
billingClientState: BillingClientState,
|
||||
|
@ -289,9 +323,9 @@ private fun MembershipTierData.getConditionInfo(
|
|||
createConditionInfoForNonBillingTier()
|
||||
} else {
|
||||
createConditionInfoForBillingTier(
|
||||
billingClientState,
|
||||
membershipStatus,
|
||||
billingPurchaseState
|
||||
billingClientState = billingClientState,
|
||||
membershipStatus = membershipStatus,
|
||||
billingPurchaseState = billingPurchaseState
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -341,10 +375,7 @@ private fun MembershipTierData.createConditionInfoForBillingTier(
|
|||
) {
|
||||
return TierConditionInfo.Visible.Pending
|
||||
}
|
||||
if (
|
||||
billingPurchaseState is BillingPurchaseState.Loading
|
||||
|| billingPurchaseState is BillingPurchaseState.HasPurchases
|
||||
) {
|
||||
if (billingPurchaseState is BillingPurchaseState.Loading) {
|
||||
return TierConditionInfo.Visible.Pending
|
||||
}
|
||||
return when (billingClientState) {
|
||||
|
@ -406,4 +437,10 @@ private fun MembershipTierData.getTierEmail(isActive: Boolean, membershipEmail:
|
|||
}
|
||||
}
|
||||
return TierEmail.Hidden
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PurchaseModel(
|
||||
val obfuscatedAccountId: String,
|
||||
val productId: String
|
||||
)
|
|
@ -58,6 +58,11 @@ sealed class TierPeriod {
|
|||
|
||||
sealed class TierButton {
|
||||
data object Hidden : TierButton()
|
||||
sealed class HiddenWithText : TierButton() {
|
||||
data object DifferentPurchaseAccountId : HiddenWithText()
|
||||
data object DifferentPurchaseProductId : HiddenWithText()
|
||||
data object MoreThenOnePurchase : HiddenWithText()
|
||||
}
|
||||
sealed class Submit : TierButton() {
|
||||
data object Enabled : Submit()
|
||||
data object Disabled : Submit()
|
||||
|
|
|
@ -41,6 +41,7 @@ import com.anytypeio.anytype.core_ui.views.ButtonPrimary
|
|||
import com.anytypeio.anytype.core_ui.views.ButtonSecondary
|
||||
import com.anytypeio.anytype.core_ui.views.ButtonSize
|
||||
import com.anytypeio.anytype.core_ui.views.HeadlineTitle
|
||||
import com.anytypeio.anytype.core_ui.views.Relations2
|
||||
import com.anytypeio.anytype.payments.R
|
||||
import com.anytypeio.anytype.payments.constants.MembershipConstants.EXPLORER_ID
|
||||
import com.anytypeio.anytype.payments.constants.MembershipConstants.PRIVACY_POLICY
|
||||
|
@ -247,27 +248,42 @@ private fun MainButton(
|
|||
buttonState: TierButton,
|
||||
actionTier: (TierAction) -> Unit
|
||||
) {
|
||||
if (buttonState !is TierButton.Hidden) {
|
||||
val (stringRes, enabled) = getButtonText(buttonState)
|
||||
ButtonPrimary(
|
||||
enabled = enabled,
|
||||
text = stringResource(id = stringRes),
|
||||
onClick = {
|
||||
when (buttonState) {
|
||||
is TierButton.Pay.Enabled -> actionTier(TierAction.PayClicked(tier.id))
|
||||
is TierButton.Info.Enabled -> actionTier(TierAction.OpenUrl(tier.urlInfo))
|
||||
TierButton.Submit.Enabled -> actionTier(TierAction.SubmitClicked)
|
||||
else -> {
|
||||
Timber.d("MainButton: skipped action: $buttonState")
|
||||
}
|
||||
}
|
||||
when (buttonState) {
|
||||
TierButton.Hidden -> {}
|
||||
TierButton.HiddenWithText.DifferentPurchaseAccountId -> {
|
||||
val text = stringResource(id = R.string.membership_support_already_acquired)
|
||||
SupportText(text = text)
|
||||
}
|
||||
TierButton.HiddenWithText.DifferentPurchaseProductId -> {
|
||||
val text = stringResource(id = R.string.membership_support_different_subscription)
|
||||
SupportText(text = text)
|
||||
}
|
||||
TierButton.HiddenWithText.MoreThenOnePurchase -> {
|
||||
val text = stringResource(id = R.string.membership_support_more_then_one_subscription)
|
||||
SupportText(text = text)
|
||||
}
|
||||
else -> {
|
||||
val (stringRes, enabled) = getButtonText(buttonState)
|
||||
ButtonPrimary(
|
||||
enabled = enabled,
|
||||
text = stringResource(id = stringRes),
|
||||
onClick = {
|
||||
when (buttonState) {
|
||||
is TierButton.Pay.Enabled -> actionTier(TierAction.PayClicked(tier.id))
|
||||
is TierButton.Info.Enabled -> actionTier(TierAction.OpenUrl(tier.urlInfo))
|
||||
TierButton.Submit.Enabled -> actionTier(TierAction.SubmitClicked)
|
||||
else -> {
|
||||
Timber.d("MainButton: skipped action: $buttonState")
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
size = ButtonSize.Large,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp)
|
||||
)
|
||||
},
|
||||
size = ButtonSize.Large,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -366,9 +382,25 @@ private fun getButtonText(buttonState: TierButton): Pair<Int, Boolean> {
|
|||
TierButton.Pay.Disabled -> Pair(R.string.payments_button_pay, false)
|
||||
TierButton.Pay.Enabled -> Pair(R.string.payments_button_pay, true)
|
||||
TierButton.ChangeEmail -> Pair(R.string.payments_button_change_email, true)
|
||||
TierButton.HiddenWithText.DifferentPurchaseAccountId -> Pair(0, false)
|
||||
TierButton.HiddenWithText.DifferentPurchaseProductId -> Pair(0, false)
|
||||
TierButton.HiddenWithText.MoreThenOnePurchase -> Pair(0, false)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SupportText(text: String) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 20.dp, end = 20.dp, top = 2.dp, bottom = 10.dp),
|
||||
text = text,
|
||||
textAlign = TextAlign.Center,
|
||||
style = Relations2,
|
||||
color = colorResource(id = R.color.text_secondary)
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Preview
|
||||
@Composable
|
||||
|
|
|
@ -90,6 +90,8 @@ class MembershipViewModel(
|
|||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val account = getAccount.async(Unit)
|
||||
val accountId = account.getOrNull()?.id.orEmpty()
|
||||
combine(
|
||||
membershipProvider.status()
|
||||
.onEach { setupBillingClient(it) },
|
||||
|
@ -104,7 +106,8 @@ class MembershipViewModel(
|
|||
}.collect { (membershipStatus, billingClientState, purchases) ->
|
||||
val newState = membershipStatus.toMainView(
|
||||
billingClientState = billingClientState,
|
||||
billingPurchaseState = purchases
|
||||
billingPurchaseState = purchases,
|
||||
accountId = accountId
|
||||
)
|
||||
proceedWithUpdatingVisibleTier(newState)
|
||||
viewState.value = newState
|
||||
|
|
|
@ -2,11 +2,13 @@ package com.anytypeio.anytype.payments
|
|||
|
||||
import com.android.billingclient.api.ProductDetails
|
||||
import com.anytypeio.anytype.analytics.base.Analytics
|
||||
import com.anytypeio.anytype.core_models.Account
|
||||
import com.anytypeio.anytype.core_models.membership.Membership
|
||||
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
|
||||
import com.anytypeio.anytype.core_models.membership.MembershipTierData
|
||||
import com.anytypeio.anytype.domain.auth.interactor.GetAccount
|
||||
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
|
||||
import com.anytypeio.anytype.domain.base.Resultat
|
||||
import com.anytypeio.anytype.domain.block.repo.BlockRepository
|
||||
import com.anytypeio.anytype.domain.payments.GetMembershipEmailStatus
|
||||
import com.anytypeio.anytype.domain.payments.GetMembershipPaymentUrl
|
||||
|
@ -26,6 +28,7 @@ import com.anytypeio.anytype.payments.viewmodel.MembershipViewModel
|
|||
import com.anytypeio.anytype.presentation.membership.models.MembershipStatus
|
||||
import com.anytypeio.anytype.presentation.membership.models.TierId
|
||||
import com.anytypeio.anytype.presentation.membership.provider.MembershipProvider
|
||||
import com.anytypeio.anytype.test_utils.MockDataFactory
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
|
@ -41,6 +44,7 @@ import org.mockito.kotlin.stub
|
|||
import org.mockito.Mock
|
||||
import org.mockito.Mockito
|
||||
import org.mockito.MockitoAnnotations
|
||||
import org.mockito.kotlin.doReturn
|
||||
|
||||
open class MembershipTestsSetup {
|
||||
|
||||
|
@ -83,6 +87,7 @@ open class MembershipTestsSetup {
|
|||
@Mock
|
||||
lateinit var getMembershipPaymentUrl: GetMembershipPaymentUrl
|
||||
protected val androidProductId = "id_android_builder"
|
||||
protected val accountId = "accountId-${RandomString.make()}"
|
||||
|
||||
fun membershipStatus(tiers: List<MembershipTierData>) = MembershipStatus(
|
||||
activeTier = TierId(MembershipConstants.EXPLORER_ID),
|
||||
|
@ -97,6 +102,7 @@ open class MembershipTestsSetup {
|
|||
@Before
|
||||
open fun setUp() {
|
||||
MockitoAnnotations.openMocks(this)
|
||||
stubAccount()
|
||||
}
|
||||
|
||||
protected fun validateTierView(
|
||||
|
@ -164,6 +170,19 @@ open class MembershipTestsSetup {
|
|||
isMembershipNameValid = isMembershipNameValid,
|
||||
setMembershipEmail = setMembershipEmail,
|
||||
verifyMembershipEmailCode = verifyMembershipEmailCode,
|
||||
getMembershipEmailStatus = getMembershipEmailStatus,
|
||||
getMembershipEmailStatus = getMembershipEmailStatus
|
||||
)
|
||||
|
||||
protected fun stubAccount() {
|
||||
getAccount.stub {
|
||||
onBlocking { async(Unit) } doReturn Resultat.success(
|
||||
Account(
|
||||
id = accountId,
|
||||
name = MockDataFactory.randomString(),
|
||||
avatar = null,
|
||||
color = null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,510 @@
|
|||
package com.anytypeio.anytype.payments
|
||||
|
||||
import app.cash.turbine.turbineScope
|
||||
import com.android.billingclient.api.ProductDetails
|
||||
import com.android.billingclient.api.Purchase
|
||||
import com.anytypeio.anytype.core_models.membership.Membership
|
||||
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
|
||||
import com.anytypeio.anytype.core_models.membership.MembershipPeriodType
|
||||
import com.anytypeio.anytype.core_models.membership.MembershipTierData
|
||||
import com.anytypeio.anytype.payments.constants.MembershipConstants
|
||||
import com.anytypeio.anytype.payments.models.BillingPriceInfo
|
||||
import com.anytypeio.anytype.payments.models.PeriodDescription
|
||||
import com.anytypeio.anytype.payments.models.PeriodUnit
|
||||
import com.anytypeio.anytype.payments.models.TierAnyName
|
||||
import com.anytypeio.anytype.payments.models.TierButton
|
||||
import com.anytypeio.anytype.payments.models.TierConditionInfo
|
||||
import com.anytypeio.anytype.payments.models.TierEmail
|
||||
import com.anytypeio.anytype.payments.models.TierPeriod
|
||||
import com.anytypeio.anytype.payments.playbilling.BillingClientState
|
||||
import com.anytypeio.anytype.payments.playbilling.BillingPurchaseState
|
||||
import com.anytypeio.anytype.payments.viewmodel.MembershipMainState
|
||||
import com.anytypeio.anytype.payments.viewmodel.MembershipTierState
|
||||
import com.anytypeio.anytype.presentation.membership.models.MembershipStatus
|
||||
import com.anytypeio.anytype.presentation.membership.models.TierId
|
||||
import kotlin.test.assertIs
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import net.bytebuddy.utility.RandomString
|
||||
import org.junit.Test
|
||||
import org.mockito.Mockito
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.stub
|
||||
|
||||
class TierActiveWithDifferentSubIdTest : MembershipTestsSetup() {
|
||||
|
||||
// Randomly generated account ID for testing
|
||||
protected val accountIdDifferent = "accountIdDifferent-${RandomString.make()}"
|
||||
|
||||
// Common test setup function to generate features and tiers
|
||||
private fun commonTestSetup(): Pair<List<String>, List<MembershipTierData>> {
|
||||
val features = listOf("feature-${RandomString.make()}", "feature-${RandomString.make()}")
|
||||
val tiers = setupTierData(features)
|
||||
return Pair(features, tiers)
|
||||
}
|
||||
|
||||
// Setup tier data with predefined features
|
||||
private fun setupTierData(features: List<String>): List<MembershipTierData> {
|
||||
return listOf(
|
||||
StubMembershipTierData(
|
||||
id = MembershipConstants.EXPLORER_ID,
|
||||
androidProductId = null,
|
||||
features = features,
|
||||
periodType = MembershipPeriodType.PERIOD_TYPE_UNLIMITED,
|
||||
priceStripeUsdCents = 0
|
||||
),
|
||||
StubMembershipTierData(
|
||||
id = MembershipConstants.BUILDER_ID,
|
||||
androidProductId = androidProductId,
|
||||
features = features,
|
||||
periodValue = 1,
|
||||
periodType = MembershipPeriodType.PERIOD_TYPE_YEARS,
|
||||
priceStripeUsdCents = 9900
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Case: Display of Builder Tier when Subscription with a Different ID is Purchased
|
||||
*
|
||||
* Objective: Verify that the Builder Tier is NOT available for purchase when a subscription with a different ID has been purchased.
|
||||
*
|
||||
* Preconditions:
|
||||
* • The user has an active subscription with an AccountId that is different from the Session AccountId.
|
||||
*/
|
||||
@Test
|
||||
fun `when subscription with different AccountId is active`() = runTest {
|
||||
turbineScope {
|
||||
val (features, tiers) = commonTestSetup()
|
||||
|
||||
// Setup for two subscriptions with different IDs from Google Play
|
||||
|
||||
// First production subscription, this ID is used in the Tier model
|
||||
val product1 = Mockito.mock(ProductDetails::class.java)
|
||||
val subscriptionOfferDetails1 =
|
||||
listOf(Mockito.mock(ProductDetails.SubscriptionOfferDetails::class.java))
|
||||
val pricingPhases1 = Mockito.mock(ProductDetails.PricingPhases::class.java)
|
||||
val pricingPhaseList1 = listOf(Mockito.mock(ProductDetails.PricingPhase::class.java))
|
||||
|
||||
Mockito.`when`(product1.productId).thenReturn(androidProductId)
|
||||
Mockito.`when`(product1.subscriptionOfferDetails).thenReturn(subscriptionOfferDetails1)
|
||||
Mockito.`when`(subscriptionOfferDetails1[0].pricingPhases).thenReturn(pricingPhases1)
|
||||
Mockito.`when`(pricingPhases1.pricingPhaseList).thenReturn(pricingPhaseList1)
|
||||
val formattedPrice1 = "$299"
|
||||
Mockito.`when`(pricingPhaseList1[0].formattedPrice).thenReturn(formattedPrice1)
|
||||
Mockito.`when`(pricingPhaseList1[0].billingPeriod).thenReturn("P1Y")
|
||||
stubBilling(billingClientState = BillingClientState.Connected(listOf(product1)))
|
||||
|
||||
// Mocking purchase
|
||||
val purchase = Mockito.mock(Purchase::class.java)
|
||||
Mockito.`when`(purchase.products).thenReturn(listOf(androidProductId))
|
||||
Mockito.`when`(purchase.isAcknowledged).thenReturn(true)
|
||||
val purchaseJson =
|
||||
"{\"obfuscatedAccountId\":\"$accountIdDifferent\", \"productId\":\"$androidProductId\"}"
|
||||
Mockito.`when`(purchase.originalJson).thenReturn(purchaseJson)
|
||||
stubPurchaseState(BillingPurchaseState.HasPurchases(listOf(purchase), false))
|
||||
|
||||
// Mocking the flow of membership status
|
||||
val flow = flow {
|
||||
emit(
|
||||
MembershipStatus(
|
||||
activeTier = TierId(MembershipConstants.EXPLORER_ID),
|
||||
status = Membership.Status.STATUS_ACTIVE,
|
||||
dateEnds = 0L,
|
||||
paymentMethod = MembershipPaymentMethod.METHOD_NONE,
|
||||
anyName = "",
|
||||
tiers = tiers,
|
||||
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
|
||||
)
|
||||
)
|
||||
}
|
||||
membershipProvider.stub {
|
||||
onBlocking { status() } doReturn flow
|
||||
}
|
||||
|
||||
val validPeriod = TierPeriod.Year(1)
|
||||
|
||||
val viewModel = buildViewModel()
|
||||
|
||||
// Testing initial state and tier visibility
|
||||
val mainStateFlow = viewModel.viewState.testIn(backgroundScope)
|
||||
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
|
||||
|
||||
val firstMainItem = mainStateFlow.awaitItem()
|
||||
assertIs<MembershipMainState.Loading>(firstMainItem)
|
||||
val firstTierItem = tierStateFlow.awaitItem()
|
||||
assertIs<MembershipTierState.Hidden>(firstTierItem)
|
||||
|
||||
val secondMainItem = mainStateFlow.awaitItem()
|
||||
assertIs<MembershipMainState.Default>(secondMainItem)
|
||||
|
||||
// Simulate user clicking on the Builder Tier
|
||||
delay(200)
|
||||
viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID))
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
// Verify that the Builder Tier is shown as available for purchase with the correct price and billing period
|
||||
val expectedConditionInfo = TierConditionInfo.Visible.PriceBilling(
|
||||
BillingPriceInfo(
|
||||
formattedPrice = formattedPrice1,
|
||||
period = PeriodDescription(
|
||||
amount = 1,
|
||||
unit = PeriodUnit.YEARS
|
||||
)
|
||||
)
|
||||
)
|
||||
val secondTierItem = tierStateFlow.awaitItem()
|
||||
secondTierItem.let {
|
||||
assertIs<MembershipTierState.Visible>(secondTierItem)
|
||||
validateTierView(
|
||||
expectedId = MembershipConstants.BUILDER_ID,
|
||||
expectedActive = false,
|
||||
expectedFeatures = features,
|
||||
expectedConditionInfo = expectedConditionInfo,
|
||||
expectedAnyName = TierAnyName.Hidden,
|
||||
expectedButtonState = TierButton.HiddenWithText.DifferentPurchaseAccountId,
|
||||
tier = secondTierItem.tier,
|
||||
expectedEmailState = TierEmail.Hidden
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when subscription with different ProductId is active`() =
|
||||
runTest {
|
||||
turbineScope {
|
||||
val (features, tiers) = commonTestSetup()
|
||||
|
||||
val product1 = Mockito.mock(ProductDetails::class.java)
|
||||
val subscriptionOfferDetails1 =
|
||||
listOf(Mockito.mock(ProductDetails.SubscriptionOfferDetails::class.java))
|
||||
val pricingPhases1 = Mockito.mock(ProductDetails.PricingPhases::class.java)
|
||||
val pricingPhaseList1 =
|
||||
listOf(Mockito.mock(ProductDetails.PricingPhase::class.java))
|
||||
|
||||
Mockito.`when`(product1.productId).thenReturn(androidProductId)
|
||||
Mockito.`when`(product1?.subscriptionOfferDetails)
|
||||
.thenReturn(subscriptionOfferDetails1)
|
||||
Mockito.`when`(subscriptionOfferDetails1[0].pricingPhases)
|
||||
.thenReturn(pricingPhases1)
|
||||
Mockito.`when`(pricingPhases1?.pricingPhaseList).thenReturn(pricingPhaseList1)
|
||||
val formattedPrice1 = "$999"
|
||||
Mockito.`when`(pricingPhaseList1[0]?.formattedPrice).thenReturn(formattedPrice1)
|
||||
Mockito.`when`(pricingPhaseList1[0]?.billingPeriod).thenReturn("P1Y")
|
||||
|
||||
//вторая купленная на другой аккаунт подписка, этот id НЕ приходит в модели Тира
|
||||
val product2 = Mockito.mock(ProductDetails::class.java)
|
||||
val subscriptionOfferDetails2 =
|
||||
listOf(Mockito.mock(ProductDetails.SubscriptionOfferDetails::class.java))
|
||||
val pricingPhases2 = Mockito.mock(ProductDetails.PricingPhases::class.java)
|
||||
val pricingPhaseList2 =
|
||||
listOf(Mockito.mock(ProductDetails.PricingPhase::class.java))
|
||||
|
||||
// Mock active subscription with an invalid ID
|
||||
val invalidProductId = "invalidProductId-${RandomString.make()}"
|
||||
Mockito.`when`(product2.productId).thenReturn(invalidProductId)
|
||||
Mockito.`when`(product2?.subscriptionOfferDetails)
|
||||
.thenReturn(subscriptionOfferDetails2)
|
||||
Mockito.`when`(subscriptionOfferDetails2[0].pricingPhases)
|
||||
.thenReturn(pricingPhases2)
|
||||
Mockito.`when`(pricingPhases2?.pricingPhaseList).thenReturn(pricingPhaseList2)
|
||||
val formattedPrice2 = "$111"
|
||||
Mockito.`when`(pricingPhaseList2[0]?.formattedPrice).thenReturn(formattedPrice2)
|
||||
Mockito.`when`(pricingPhaseList2[0]?.billingPeriod).thenReturn("P3Y")
|
||||
stubBilling(
|
||||
billingClientState = BillingClientState.Connected(
|
||||
listOf(
|
||||
product2,
|
||||
product1
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val purchase = Mockito.mock(Purchase::class.java)
|
||||
Mockito.`when`(purchase.products).thenReturn(listOf(invalidProductId))
|
||||
Mockito.`when`(purchase.isAcknowledged).thenReturn(true)
|
||||
val purchaseJson =
|
||||
"{\"obfuscatedAccountId\":\"$accountId\", \"productId\":\"$invalidProductId\"}"
|
||||
Mockito.`when`(purchase.originalJson).thenReturn(purchaseJson)
|
||||
stubPurchaseState(BillingPurchaseState.HasPurchases(listOf(purchase), false))
|
||||
|
||||
val flow = flow {
|
||||
emit(
|
||||
MembershipStatus(
|
||||
activeTier = TierId(MembershipConstants.EXPLORER_ID),
|
||||
status = Membership.Status.STATUS_ACTIVE,
|
||||
dateEnds = 0L,
|
||||
paymentMethod = MembershipPaymentMethod.METHOD_NONE,
|
||||
anyName = "",
|
||||
tiers = tiers,
|
||||
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
|
||||
)
|
||||
)
|
||||
}
|
||||
membershipProvider.stub {
|
||||
onBlocking { status() } doReturn flow
|
||||
}
|
||||
|
||||
val viewModel = buildViewModel()
|
||||
|
||||
val mainStateFlow = viewModel.viewState.testIn(backgroundScope)
|
||||
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
|
||||
|
||||
val firstMainItem = mainStateFlow.awaitItem()
|
||||
assertIs<MembershipMainState.Loading>(firstMainItem)
|
||||
val firstTierItem = tierStateFlow.awaitItem()
|
||||
assertIs<MembershipTierState.Hidden>(firstTierItem)
|
||||
|
||||
val secondMainItem = mainStateFlow.awaitItem()
|
||||
assertIs<MembershipMainState.Default>(secondMainItem)
|
||||
|
||||
delay(200)
|
||||
viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID))
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
val expectedConditionInfo = TierConditionInfo.Visible.PriceBilling(
|
||||
BillingPriceInfo(
|
||||
formattedPrice = formattedPrice1,
|
||||
period = PeriodDescription(
|
||||
amount = 1,
|
||||
unit = PeriodUnit.YEARS
|
||||
)
|
||||
)
|
||||
)
|
||||
val secondTierItem = tierStateFlow.awaitItem()
|
||||
secondTierItem.let {
|
||||
assertIs<MembershipTierState.Visible>(secondTierItem)
|
||||
validateTierView(
|
||||
expectedId = MembershipConstants.BUILDER_ID,
|
||||
expectedActive = false,
|
||||
expectedFeatures = features,
|
||||
expectedConditionInfo = expectedConditionInfo,
|
||||
expectedAnyName = TierAnyName.Hidden,
|
||||
expectedButtonState = TierButton.HiddenWithText.DifferentPurchaseProductId,
|
||||
tier = secondTierItem.tier,
|
||||
expectedEmailState = TierEmail.Hidden
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the Builder Tier is available for purchase when there is no active subscription.
|
||||
*/
|
||||
|
||||
@Test
|
||||
fun `when no active subscription`() = runTest {
|
||||
turbineScope {
|
||||
val (features, tiers) = commonTestSetup()
|
||||
|
||||
val product1 = Mockito.mock(ProductDetails::class.java)
|
||||
val subscriptionOfferDetails1 =
|
||||
listOf(Mockito.mock(ProductDetails.SubscriptionOfferDetails::class.java))
|
||||
val pricingPhases1 = Mockito.mock(ProductDetails.PricingPhases::class.java)
|
||||
val pricingPhaseList1 = listOf(Mockito.mock(ProductDetails.PricingPhase::class.java))
|
||||
|
||||
Mockito.`when`(product1.productId).thenReturn(androidProductId)
|
||||
Mockito.`when`(product1.subscriptionOfferDetails).thenReturn(subscriptionOfferDetails1)
|
||||
Mockito.`when`(subscriptionOfferDetails1[0].pricingPhases).thenReturn(pricingPhases1)
|
||||
Mockito.`when`(pricingPhases1.pricingPhaseList).thenReturn(pricingPhaseList1)
|
||||
val formattedPrice1 = "$299"
|
||||
Mockito.`when`(pricingPhaseList1[0].formattedPrice).thenReturn(formattedPrice1)
|
||||
Mockito.`when`(pricingPhaseList1[0].billingPeriod).thenReturn("P1Y")
|
||||
stubBilling(billingClientState = BillingClientState.Connected(listOf(product1)))
|
||||
|
||||
// Mock no active subscription
|
||||
stubPurchaseState(BillingPurchaseState.NoPurchases)
|
||||
|
||||
val flow = flow {
|
||||
emit(
|
||||
MembershipStatus(
|
||||
activeTier = TierId(MembershipConstants.EXPLORER_ID),
|
||||
status = Membership.Status.STATUS_ACTIVE,
|
||||
dateEnds = 0L,
|
||||
paymentMethod = MembershipPaymentMethod.METHOD_NONE,
|
||||
anyName = "",
|
||||
tiers = tiers,
|
||||
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
|
||||
)
|
||||
)
|
||||
}
|
||||
membershipProvider.stub {
|
||||
onBlocking { status() } doReturn flow
|
||||
}
|
||||
|
||||
val viewModel = buildViewModel()
|
||||
|
||||
val mainStateFlow = viewModel.viewState.testIn(backgroundScope)
|
||||
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
|
||||
|
||||
val firstMainItem = mainStateFlow.awaitItem()
|
||||
assertIs<MembershipMainState.Loading>(firstMainItem)
|
||||
val firstTierItem = tierStateFlow.awaitItem()
|
||||
assertIs<MembershipTierState.Hidden>(firstTierItem)
|
||||
|
||||
val secondMainItem = mainStateFlow.awaitItem()
|
||||
assertIs<MembershipMainState.Default>(secondMainItem)
|
||||
|
||||
delay(200)
|
||||
viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID))
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
val expectedConditionInfo = TierConditionInfo.Visible.PriceBilling(
|
||||
BillingPriceInfo(
|
||||
formattedPrice = formattedPrice1,
|
||||
period = PeriodDescription(
|
||||
amount = 1,
|
||||
unit = PeriodUnit.YEARS
|
||||
)
|
||||
)
|
||||
)
|
||||
val secondTierItem = tierStateFlow.awaitItem()
|
||||
secondTierItem.let {
|
||||
assertIs<MembershipTierState.Visible>(secondTierItem)
|
||||
validateTierView(
|
||||
expectedId = MembershipConstants.BUILDER_ID,
|
||||
expectedActive = false,
|
||||
expectedFeatures = features,
|
||||
expectedConditionInfo = expectedConditionInfo,
|
||||
expectedAnyName = TierAnyName.Visible.Enter,
|
||||
expectedButtonState = TierButton.Pay.Disabled,
|
||||
tier = secondTierItem.tier,
|
||||
expectedEmailState = TierEmail.Hidden
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when there are more then one purchase`() = runTest {
|
||||
turbineScope {
|
||||
val (features, tiers) = commonTestSetup()
|
||||
|
||||
val product1 = Mockito.mock(ProductDetails::class.java)
|
||||
val subscriptionOfferDetails1 =
|
||||
listOf(Mockito.mock(ProductDetails.SubscriptionOfferDetails::class.java))
|
||||
val pricingPhases1 = Mockito.mock(ProductDetails.PricingPhases::class.java)
|
||||
val pricingPhaseList1 =
|
||||
listOf(Mockito.mock(ProductDetails.PricingPhase::class.java))
|
||||
|
||||
Mockito.`when`(product1.productId).thenReturn(androidProductId)
|
||||
Mockito.`when`(product1?.subscriptionOfferDetails)
|
||||
.thenReturn(subscriptionOfferDetails1)
|
||||
Mockito.`when`(subscriptionOfferDetails1[0].pricingPhases)
|
||||
.thenReturn(pricingPhases1)
|
||||
Mockito.`when`(pricingPhases1?.pricingPhaseList).thenReturn(pricingPhaseList1)
|
||||
val formattedPrice1 = "$999"
|
||||
Mockito.`when`(pricingPhaseList1[0]?.formattedPrice).thenReturn(formattedPrice1)
|
||||
Mockito.`when`(pricingPhaseList1[0]?.billingPeriod).thenReturn("P1Y")
|
||||
|
||||
//вторая купленная на другой аккаунт подписка, этот id НЕ приходит в модели Тира
|
||||
val product2 = Mockito.mock(ProductDetails::class.java)
|
||||
val subscriptionOfferDetails2 =
|
||||
listOf(Mockito.mock(ProductDetails.SubscriptionOfferDetails::class.java))
|
||||
val pricingPhases2 = Mockito.mock(ProductDetails.PricingPhases::class.java)
|
||||
val pricingPhaseList2 =
|
||||
listOf(Mockito.mock(ProductDetails.PricingPhase::class.java))
|
||||
|
||||
// Mock active subscription with an invalid ID
|
||||
val invalidProductId = "invalidProductId-${RandomString.make()}"
|
||||
Mockito.`when`(product2.productId).thenReturn(invalidProductId)
|
||||
Mockito.`when`(product2?.subscriptionOfferDetails)
|
||||
.thenReturn(subscriptionOfferDetails2)
|
||||
Mockito.`when`(subscriptionOfferDetails2[0].pricingPhases)
|
||||
.thenReturn(pricingPhases2)
|
||||
Mockito.`when`(pricingPhases2?.pricingPhaseList).thenReturn(pricingPhaseList2)
|
||||
val formattedPrice2 = "$111"
|
||||
Mockito.`when`(pricingPhaseList2[0]?.formattedPrice).thenReturn(formattedPrice2)
|
||||
Mockito.`when`(pricingPhaseList2[0]?.billingPeriod).thenReturn("P3Y")
|
||||
stubBilling(
|
||||
billingClientState = BillingClientState.Connected(
|
||||
listOf(
|
||||
product2,
|
||||
product1
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val purchase1 = Mockito.mock(Purchase::class.java)
|
||||
Mockito.`when`(purchase1.products).thenReturn(listOf(invalidProductId))
|
||||
Mockito.`when`(purchase1.isAcknowledged).thenReturn(true)
|
||||
val purchaseJson1 =
|
||||
"{\"obfuscatedAccountId\":\"$accountId\", \"productId\":\"$invalidProductId\"}"
|
||||
Mockito.`when`(purchase1.originalJson).thenReturn(purchaseJson1)
|
||||
|
||||
val purchase2 = Mockito.mock(Purchase::class.java)
|
||||
Mockito.`when`(purchase2.products).thenReturn(listOf(invalidProductId))
|
||||
Mockito.`when`(purchase2.isAcknowledged).thenReturn(true)
|
||||
val purchaseJson2 =
|
||||
"{\"obfuscatedAccountId\":\"$accountId\", \"productId\":\"$androidProductId\"}"
|
||||
Mockito.`when`(purchase2.originalJson).thenReturn(purchaseJson2)
|
||||
|
||||
stubPurchaseState(BillingPurchaseState.HasPurchases(listOf(purchase1, purchase2), false))
|
||||
|
||||
val flow = flow {
|
||||
emit(
|
||||
MembershipStatus(
|
||||
activeTier = TierId(MembershipConstants.EXPLORER_ID),
|
||||
status = Membership.Status.STATUS_ACTIVE,
|
||||
dateEnds = 0L,
|
||||
paymentMethod = MembershipPaymentMethod.METHOD_NONE,
|
||||
anyName = "",
|
||||
tiers = tiers,
|
||||
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
|
||||
)
|
||||
)
|
||||
}
|
||||
membershipProvider.stub {
|
||||
onBlocking { status() } doReturn flow
|
||||
}
|
||||
|
||||
val viewModel = buildViewModel()
|
||||
|
||||
val mainStateFlow = viewModel.viewState.testIn(backgroundScope)
|
||||
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
|
||||
|
||||
val firstMainItem = mainStateFlow.awaitItem()
|
||||
assertIs<MembershipMainState.Loading>(firstMainItem)
|
||||
val firstTierItem = tierStateFlow.awaitItem()
|
||||
assertIs<MembershipTierState.Hidden>(firstTierItem)
|
||||
|
||||
val secondMainItem = mainStateFlow.awaitItem()
|
||||
assertIs<MembershipMainState.Default>(secondMainItem)
|
||||
|
||||
delay(200)
|
||||
viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID))
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
val expectedConditionInfo = TierConditionInfo.Visible.PriceBilling(
|
||||
BillingPriceInfo(
|
||||
formattedPrice = formattedPrice1,
|
||||
period = PeriodDescription(
|
||||
amount = 1,
|
||||
unit = PeriodUnit.YEARS
|
||||
)
|
||||
)
|
||||
)
|
||||
val secondTierItem = tierStateFlow.awaitItem()
|
||||
secondTierItem.let {
|
||||
assertIs<MembershipTierState.Visible>(secondTierItem)
|
||||
validateTierView(
|
||||
expectedId = MembershipConstants.BUILDER_ID,
|
||||
expectedActive = false,
|
||||
expectedFeatures = features,
|
||||
expectedConditionInfo = expectedConditionInfo,
|
||||
expectedAnyName = TierAnyName.Hidden,
|
||||
expectedButtonState = TierButton.HiddenWithText.MoreThenOnePurchase,
|
||||
tier = secondTierItem.tier,
|
||||
expectedEmailState = TierEmail.Hidden
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,6 +9,9 @@ import com.anytypeio.anytype.core_models.membership.MembershipPeriodType
|
|||
import com.anytypeio.anytype.core_models.membership.MembershipTierData
|
||||
import com.anytypeio.anytype.payments.constants.MembershipConstants.BUILDER_ID
|
||||
import com.anytypeio.anytype.payments.constants.MembershipConstants.EXPLORER_ID
|
||||
import com.anytypeio.anytype.payments.models.BillingPriceInfo
|
||||
import com.anytypeio.anytype.payments.models.PeriodDescription
|
||||
import com.anytypeio.anytype.payments.models.PeriodUnit
|
||||
import com.anytypeio.anytype.payments.models.TierAnyName
|
||||
import com.anytypeio.anytype.payments.models.TierButton
|
||||
import com.anytypeio.anytype.payments.models.TierConditionInfo
|
||||
|
@ -84,6 +87,9 @@ class TierBuilderFallbackOnExplorerTest : MembershipTestsSetup() {
|
|||
val purchase = Mockito.mock(Purchase::class.java)
|
||||
Mockito.`when`(purchase.products).thenReturn(listOf(androidProductId))
|
||||
Mockito.`when`(purchase.isAcknowledged).thenReturn(true)
|
||||
val purchaseJson =
|
||||
"{\"obfuscatedAccountId\":\"$accountId\", \"productId\":\"$androidProductId\"}"
|
||||
Mockito.`when`(purchase.originalJson).thenReturn(purchaseJson)
|
||||
stubPurchaseState(BillingPurchaseState.HasPurchases(listOf(purchase), false))
|
||||
val flow = flow {
|
||||
emit(
|
||||
|
@ -167,7 +173,15 @@ class TierBuilderFallbackOnExplorerTest : MembershipTestsSetup() {
|
|||
)
|
||||
}
|
||||
|
||||
val expectedConditionInfo = TierConditionInfo.Visible.Pending
|
||||
val expectedConditionInfo = TierConditionInfo.Visible.PriceBilling(
|
||||
BillingPriceInfo(
|
||||
formattedPrice = formattedPrice,
|
||||
period = PeriodDescription(
|
||||
amount = 1,
|
||||
unit = PeriodUnit.YEARS
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val thirdTierItem = tierStateFlow.awaitItem()
|
||||
thirdTierItem.let {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue