1
0
Fork 0
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:
Konstantin Ivanov 2024-06-03 17:21:08 +02:00 committed by GitHub
parent 560404bd6d
commit 7b9190a215
Signed by: github
GPG key ID: B5690EEEBB952194
9 changed files with 668 additions and 42 deletions

View file

@ -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

View file

@ -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
)

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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
)
)
}
}
}

View file

@ -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
)
}
}
}
}

View file

@ -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 {