diff --git a/localization/src/main/res/values/strings.xml b/localization/src/main/res/values/strings.xml index 771e6ec033..8881fa4655 100644 --- a/localization/src/main/res/values/strings.xml +++ b/localization/src/main/res/values/strings.xml @@ -1629,6 +1629,9 @@ Please provide specific details of your needs here. Terms of Use and Privacy Policy + You’ve already acquired a Membership plan using another Anytype account. + Found a subscription with a different id + Found more than one subscription year diff --git a/payments/build.gradle b/payments/build.gradle index bffdcc683f..09897f2894 100644 --- a/payments/build.gradle +++ b/payments/build.gradle @@ -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 diff --git a/payments/src/main/java/com/anytypeio/anytype/payments/mapping/MembershipExt.kt b/payments/src/main/java/com/anytypeio/anytype/payments/mapping/MembershipExt.kt index 697a5340db..44cf5ff87c 100644 --- a/payments/src/main/java/com/anytypeio/anytype/payments/mapping/MembershipExt.kt +++ b/payments/src/main/java/com/anytypeio/anytype/payments/mapping/MembershipExt.kt @@ -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 +): TierButton { + when (purchases.size) { + 0 -> { + return TierButton.Hidden + } + 1 -> { + val purchase = purchases[0] + val purchaseModel = Json.decodeFromString(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 -} \ No newline at end of file +} + +@Serializable +data class PurchaseModel( + val obfuscatedAccountId: String, + val productId: String +) \ No newline at end of file diff --git a/payments/src/main/java/com/anytypeio/anytype/payments/models/MembershipModels.kt b/payments/src/main/java/com/anytypeio/anytype/payments/models/MembershipModels.kt index 0f6ac3bacb..ff7734757a 100644 --- a/payments/src/main/java/com/anytypeio/anytype/payments/models/MembershipModels.kt +++ b/payments/src/main/java/com/anytypeio/anytype/payments/models/MembershipModels.kt @@ -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() diff --git a/payments/src/main/java/com/anytypeio/anytype/payments/screens/TierScreen.kt b/payments/src/main/java/com/anytypeio/anytype/payments/screens/TierScreen.kt index 7d78be6385..788c2d4b15 100644 --- a/payments/src/main/java/com/anytypeio/anytype/payments/screens/TierScreen.kt +++ b/payments/src/main/java/com/anytypeio/anytype/payments/screens/TierScreen.kt @@ -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 { 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 diff --git a/payments/src/main/java/com/anytypeio/anytype/payments/viewmodel/MembershipViewModel.kt b/payments/src/main/java/com/anytypeio/anytype/payments/viewmodel/MembershipViewModel.kt index b074d48f13..abad6c5fad 100644 --- a/payments/src/main/java/com/anytypeio/anytype/payments/viewmodel/MembershipViewModel.kt +++ b/payments/src/main/java/com/anytypeio/anytype/payments/viewmodel/MembershipViewModel.kt @@ -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 diff --git a/payments/src/test/java/com/anytypeio/anytype/payments/MembershipTestsSetup.kt b/payments/src/test/java/com/anytypeio/anytype/payments/MembershipTestsSetup.kt index 6b2587db35..043accd454 100644 --- a/payments/src/test/java/com/anytypeio/anytype/payments/MembershipTestsSetup.kt +++ b/payments/src/test/java/com/anytypeio/anytype/payments/MembershipTestsSetup.kt @@ -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) = 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 + ) + ) + } + } } \ No newline at end of file diff --git a/payments/src/test/java/com/anytypeio/anytype/payments/TierActiveWithDifferentSubIdTest.kt b/payments/src/test/java/com/anytypeio/anytype/payments/TierActiveWithDifferentSubIdTest.kt new file mode 100644 index 0000000000..c075fd8635 --- /dev/null +++ b/payments/src/test/java/com/anytypeio/anytype/payments/TierActiveWithDifferentSubIdTest.kt @@ -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> { + 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): List { + 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(firstMainItem) + val firstTierItem = tierStateFlow.awaitItem() + assertIs(firstTierItem) + + val secondMainItem = mainStateFlow.awaitItem() + assertIs(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(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(firstMainItem) + val firstTierItem = tierStateFlow.awaitItem() + assertIs(firstTierItem) + + val secondMainItem = mainStateFlow.awaitItem() + assertIs(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(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(firstMainItem) + val firstTierItem = tierStateFlow.awaitItem() + assertIs(firstTierItem) + + val secondMainItem = mainStateFlow.awaitItem() + assertIs(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(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(firstMainItem) + val firstTierItem = tierStateFlow.awaitItem() + assertIs(firstTierItem) + + val secondMainItem = mainStateFlow.awaitItem() + assertIs(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(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 + ) + } + } + } +} \ No newline at end of file diff --git a/payments/src/test/java/com/anytypeio/anytype/payments/TierBuilderFallbackOnExplorerTest.kt b/payments/src/test/java/com/anytypeio/anytype/payments/TierBuilderFallbackOnExplorerTest.kt index 876fc4b211..06324c30f6 100644 --- a/payments/src/test/java/com/anytypeio/anytype/payments/TierBuilderFallbackOnExplorerTest.kt +++ b/payments/src/test/java/com/anytypeio/anytype/payments/TierBuilderFallbackOnExplorerTest.kt @@ -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 {