diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt index ce317095106d..13f0097c0228 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt @@ -243,9 +243,6 @@ interface PrivacyProFeature { @Toggle.DefaultValue(DefaultFeatureValue.TRUE) fun refreshSubscriptionPlanFeatures(): Toggle - @Toggle.DefaultValue(DefaultFeatureValue.TRUE) - fun useClientWithCacheForFeatures(): Toggle - @Toggle.DefaultValue(DefaultFeatureValue.TRUE) fun supportsAlternateStripePaymentFlow(): Toggle @@ -272,6 +269,14 @@ interface PrivacyProFeature { @Toggle.DefaultValue(defaultValue = DefaultFeatureValue.TRUE) fun sendFreeTrialConversionWideEvent(): Toggle + + /** + * When enabled, the native app will respond to the getSubscriptionTierOptions message + * with the new tier-based payload structure supporting Plus/Pro tiers. + * The flag is exposed to FE via getFeatureConfig. + */ + @Toggle.DefaultValue(DefaultFeatureValue.TRUE) + fun tierMessagingEnabled(): Toggle } @ContributesBinding(AppScope::class) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionFeaturesFetcher.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionFeaturesFetcher.kt index 21aaa2d82d48..fb1701547ed5 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionFeaturesFetcher.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionFeaturesFetcher.kt @@ -23,9 +23,9 @@ import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.BASIC_SUBSCRIPTION import com.duckduckgo.subscriptions.impl.billing.PlayBillingManager +import com.duckduckgo.subscriptions.impl.model.Entitlement import com.duckduckgo.subscriptions.impl.repository.AuthRepository import com.duckduckgo.subscriptions.impl.services.SubscriptionsCachedService -import com.duckduckgo.subscriptions.impl.services.SubscriptionsService import com.squareup.anvil.annotations.ContributesMultibinding import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.firstOrNull @@ -41,7 +41,6 @@ import javax.inject.Inject class SubscriptionFeaturesFetcher @Inject constructor( @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val playBillingManager: PlayBillingManager, - private val subscriptionsService: SubscriptionsService, private val subscriptionsCachedService: SubscriptionsCachedService, private val authRepository: AuthRepository, private val privacyProFeature: PrivacyProFeature, @@ -82,14 +81,19 @@ class SubscriptionFeaturesFetcher @Inject constructor( } } ?.forEach { basePlanId -> - val features = if (privacyProFeature.useClientWithCacheForFeatures().isEnabled()) { - subscriptionsCachedService.features(basePlanId).features + if (privacyProFeature.tierMessagingEnabled().isEnabled()) { + val features = subscriptionsCachedService.featuresV2(basePlanId).features[basePlanId] ?: emptyList() + logcat { "Subscription features for base plan $basePlanId fetched: $features" } + if (features.isNotEmpty()) { + val entitlements = features.map { Entitlement(name = it.name, product = it.product) }.toSet() + authRepository.setFeaturesV2(basePlanId, entitlements) + } } else { - subscriptionsService.features(basePlanId).features - } - logcat { "Subscription features for base plan $basePlanId fetched: $features" } - if (features.isNotEmpty()) { - authRepository.setFeatures(basePlanId, features.toSet()) + val features = subscriptionsCachedService.features(basePlanId).features + logcat { "Subscription features for base plan $basePlanId fetched: $features" } + if (features.isNotEmpty()) { + authRepository.setFeatures(basePlanId, features.toSet()) + } } } } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt index 587c375189da..621e754c8219 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt @@ -57,6 +57,7 @@ import com.duckduckgo.subscriptions.impl.billing.PurchaseState import com.duckduckgo.subscriptions.impl.billing.RetryPolicy import com.duckduckgo.subscriptions.impl.billing.SubscriptionReplacementMode import com.duckduckgo.subscriptions.impl.billing.retry +import com.duckduckgo.subscriptions.impl.model.Entitlement import com.duckduckgo.subscriptions.impl.pixels.SubscriptionFailureErrorType import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.repository.AccessToken @@ -115,7 +116,10 @@ import kotlin.time.Duration.Companion.milliseconds interface SubscriptionsManager { /** - * Returns available purchase options retrieved from Play Store + * Returns available purchase options retrieved from Play Store. + * Works seamlessly regardless of whether tierMessagingEnabled flag is on or off. + * When flag is off, entitlements are constructed from legacy feature strings with default tier "plus". + * When flag is on, actual entitlements and tier information from the API are used. */ suspend fun getSubscriptionOffer(): List @@ -763,7 +767,7 @@ class RealSubscriptionsManager @Inject constructor( val subscription = authRepository.getSubscription() return if (subscription != null) { - getFeaturesInternal(subscription.productId) + getEntitlementsForPlan(subscription.productId).map { it.product }.toSet() } else { emptySet() } @@ -1102,20 +1106,39 @@ class RealSubscriptionsManager @Inject constructor( ) } - val features = getFeaturesInternal(offer.basePlanId) + val entitlements = getEntitlementsForPlan(offer.basePlanId) - if (features.isEmpty()) return@let emptyList() + if (entitlements.isEmpty()) return@let emptyList() SubscriptionOffer( planId = offer.basePlanId, + tier = "plus", // Temporary placeholder until we have support multiple tiers pricingPhases = pricingPhases, offerId = offer.offerId, - features = features, + entitlements = entitlements, ) } } - private suspend fun getFeaturesInternal(planId: String): Set { + /** + * Returns entitlements for a plan, working seamlessly regardless of flag state. + * When tierMessagingEnabled is ON: Uses actual entitlements from V2 API, with fallback to legacy. + * When tierMessagingEnabled is OFF: Converts legacy features to entitlements with default tier "plus". + */ + private suspend fun getEntitlementsForPlan(planId: String): Set { + if (privacyProFeature.get().tierMessagingEnabled().isEnabled()) { + val v2Entitlements = authRepository.getFeaturesV2(planId) + if (v2Entitlements.isNotEmpty()) { + return v2Entitlements + } + // Fallback to legacy features for smooth runtime flag transitions + } + return getLegacyFeatures(planId).map { feature -> + Entitlement(name = "plus", product = feature) // Temporary name placeholder until we have support multiple tiers + }.toSet() + } + + private suspend fun getLegacyFeatures(planId: String): Set { return if (privacyProFeature.get().featuresApi().isEnabled()) { authRepository.getFeatures(planId) } else { @@ -1442,9 +1465,16 @@ sealed class CurrentPurchase { data class SubscriptionOffer( val planId: String, val offerId: String?, + val tier: String, val pricingPhases: List, - val features: Set, -) + val entitlements: Set, +) { + /** + * Returns the set of feature/product names from entitlements. + * Provided for backward compatibility with code that used the legacy features set. + */ + val features: Set get() = entitlements.map { it.product }.toSet() +} data class PricingPhase( val priceAmount: BigDecimal, diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterface.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterface.kt index ff4e988edb20..df7b849f51b9 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterface.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterface.kt @@ -147,6 +147,7 @@ class SubscriptionMessagingInterface @Inject constructor( override val methods: List = listOf( "subscriptionSelected", "getSubscriptionOptions", + "getSubscriptionTierOptions", "backToSettings", "activateSubscription", "featureSelected", @@ -409,10 +410,12 @@ class SubscriptionMessagingInterface @Inject constructor( val authV2Enabled = privacyProFeature.enableSubscriptionFlowsV2().isEnabled() val duckAiSubscriberModelsEnabled = privacyProFeature.duckAiPlus().isEnabled() val supportsAlternateStripePaymentFlow = privacyProFeature.supportsAlternateStripePaymentFlow().isEnabled() + val useGetSubscriptionTierOptions = privacyProFeature.tierMessagingEnabled().isEnabled() val resultJson = JSONObject().apply { put("useSubscriptionsAuthV2", authV2Enabled) put("usePaidDuckAi", duckAiSubscriberModelsEnabled) put("useAlternateStripePaymentFlow", supportsAlternateStripePaymentFlow) + put("useGetSubscriptionTierOptions", useGetSubscriptionTierOptions) } val response = JsRequestResponse.Success( diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt index 2dc22db7424c..e3eb454d17c1 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt @@ -29,6 +29,7 @@ import com.duckduckgo.subscriptions.api.SubscriptionStatus.INACTIVE import com.duckduckgo.subscriptions.api.SubscriptionStatus.NOT_AUTO_RENEWABLE import com.duckduckgo.subscriptions.api.SubscriptionStatus.UNKNOWN import com.duckduckgo.subscriptions.api.SubscriptionStatus.WAITING +import com.duckduckgo.subscriptions.impl.PrivacyProFeature import com.duckduckgo.subscriptions.impl.model.Entitlement import com.duckduckgo.subscriptions.impl.serp_promo.SerpPromo import com.duckduckgo.subscriptions.impl.store.SubscriptionsDataStore @@ -64,6 +65,8 @@ interface AuthRepository { suspend fun canSupportEncryption(): Boolean suspend fun setFeatures(basePlanId: String, features: Set) suspend fun getFeatures(basePlanId: String): Set + suspend fun setFeaturesV2(basePlanId: String, features: Set) + suspend fun getFeaturesV2(basePlanId: String): Set suspend fun isFreeTrialActive(): Boolean suspend fun registerLocalPurchasedAt() suspend fun getLocalPurchasedAt(): Long? @@ -79,8 +82,9 @@ object AuthRepositoryModule { dispatcherProvider: DispatcherProvider, sharedPreferencesProvider: SharedPreferencesProvider, serpPromo: SerpPromo, + privacyProFeature: dagger.Lazy, ): AuthRepository { - return RealAuthRepository(SubscriptionsEncryptedDataStore(sharedPreferencesProvider), dispatcherProvider, serpPromo) + return RealAuthRepository(SubscriptionsEncryptedDataStore(sharedPreferencesProvider), dispatcherProvider, serpPromo, privacyProFeature) } } @@ -88,6 +92,7 @@ internal class RealAuthRepository constructor( private val subscriptionsDataStore: SubscriptionsDataStore, private val dispatcherProvider: DispatcherProvider, private val serpPromo: SerpPromo, + private val privacyProFeature: dagger.Lazy, ) : AuthRepository { private val moshi = Builder().build() @@ -101,6 +106,12 @@ internal class RealAuthRepository constructor( moshi.adapter>>(type) } + private val featuresV2Adapter by lazy { + val entitlementSetType = Types.newParameterizedType(Set::class.java, Entitlement::class.java) + val mapType = Types.newParameterizedType(Map::class.java, String::class.java, entitlementSetType) + moshi.adapter>>(mapType) + } + private inline fun Moshi.listToJson(list: List): String { return adapter>(Types.newParameterizedType(List::class.java, T::class.java)).toJson(list) } @@ -228,11 +239,37 @@ internal class RealAuthRepository constructor( } override suspend fun getFeatures(basePlanId: String): Set = withContext(dispatcherProvider.io()) { - subscriptionsDataStore.subscriptionFeatures + if (privacyProFeature.get().tierMessagingEnabled().isEnabled()) { + // When flag is ON, try v2 first + val v2Features = getFeaturesV2(basePlanId) + if (v2Features.isNotEmpty()) { + return@withContext v2Features.map { it.product }.toSet() + } + // Fallback to v1 for smooth runtime flag transitions (until fetcher runs on next app start) + } + // Use v1 storage + return@withContext subscriptionsDataStore.subscriptionFeatures ?.let(featuresAdapter::fromJson) ?.get(basePlanId) ?: emptySet() } + override suspend fun setFeaturesV2( + basePlanId: String, + features: Set, + ) = withContext(dispatcherProvider.io()) { + val featuresMap = subscriptionsDataStore.subscriptionEntitlements + ?.let(featuresV2Adapter::fromJson) + ?.toMutableMap() ?: mutableMapOf() + featuresMap[basePlanId] = features + subscriptionsDataStore.subscriptionEntitlements = featuresV2Adapter.toJson(featuresMap) + } + + override suspend fun getFeaturesV2(basePlanId: String): Set = withContext(dispatcherProvider.io()) { + subscriptionsDataStore.subscriptionEntitlements + ?.let(featuresV2Adapter::fromJson) + ?.get(basePlanId) ?: emptySet() + } + private suspend fun updateSerpPromoCookie() = withContext(dispatcherProvider.io()) { val accessToken = subscriptionsDataStore.run { accessTokenV2 ?: accessToken } serpPromo.injectCookie(accessToken) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsCachedService.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsCachedService.kt index 9912a5b95c00..4ce830b272fa 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsCachedService.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsCachedService.kt @@ -18,8 +18,22 @@ package com.duckduckgo.subscriptions.impl.services import retrofit2.http.GET import retrofit2.http.Path +import retrofit2.http.Query interface SubscriptionsCachedService { + @Deprecated("Use featuresV2 instead") @GET("https://subscriptions.duckduckgo.com/api/products/{sku}/features") suspend fun features(@Path("sku") sku: String): FeaturesResponse + + @GET("https://subscriptions.duckduckgo.com/api/v2/features") + suspend fun featuresV2(@Query("sku") sku: String): FeaturesV2Response } + +data class FeaturesV2Response( + val features: Map>, +) + +data class TierFeatureResponse( + val product: String, + val name: String, // e.g. "Plus", "Pro" (Tier) +) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/store/SubscriptionsDataStore.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/store/SubscriptionsDataStore.kt index 2cd9a370e3af..546b52a9fbc9 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/store/SubscriptionsDataStore.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/store/SubscriptionsDataStore.kt @@ -48,7 +48,7 @@ interface SubscriptionsDataStore { var freeTrialActive: Boolean var subscriptionFeatures: String? - + var subscriptionEntitlements: String? fun canUseEncryption(): Boolean } @@ -228,6 +228,14 @@ internal class SubscriptionsEncryptedDataStore( } } + override var subscriptionEntitlements: String? + get() = encryptedPreferences?.getString(KEY_SUBSCRIPTION_ENTITLEMENTS, null) + set(value) { + encryptedPreferences?.edit(commit = true) { + putString(KEY_SUBSCRIPTION_ENTITLEMENTS, value) + } + } + override fun canUseEncryption(): Boolean { encryptedPreferences?.edit(commit = true) { putBoolean("test", true) } return encryptedPreferences?.getBoolean("test", false) == true @@ -252,6 +260,7 @@ internal class SubscriptionsEncryptedDataStore( const val KEY_STATUS = "KEY_STATUS" const val KEY_PRODUCT_ID = "KEY_PRODUCT_ID" const val KEY_SUBSCRIPTION_FEATURES = "KEY_SUBSCRIPTION_FEATURES" + const val KEY_SUBSCRIPTION_ENTITLEMENTS = "KEY_SUBSCRIPTION_ENTITLEMENTS" const val KEY_FREE_TRIAL_ACTIVE = "KEY_FREE_TRIAL_ACTIVE" } } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt index 82a0b9295f78..10d2387b6581 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt @@ -94,6 +94,7 @@ class SubscriptionWebViewViewModel @Inject constructor( private val moshi = Moshi.Builder().add(JSONObjectAdapter()).build() private val jsonAdapter: JsonAdapter = moshi.adapter(SubscriptionOptionsJson::class.java) + private val tierJsonAdapter: JsonAdapter = moshi.adapter(SubscriptionTierOptionsJson::class.java) private val command = Channel(1, DROP_OLDEST) internal fun commands(): Flow = command.receiveAsFlow() @@ -148,6 +149,7 @@ class SubscriptionWebViewViewModel @Inject constructor( "backToSettings" -> backToSettings() "backToSettingsActivateSuccess" -> backToSettingsActiveSuccess() "getSubscriptionOptions" -> id?.let { getSubscriptionOptions(featureName, method, it) } + "getSubscriptionTierOptions" -> id?.let { getSubscriptionTierOptions(featureName, method, it) } "subscriptionSelected" -> subscriptionSelected(data) "activateSubscription" -> activateSubscription() "featureSelected" -> data?.let { featureSelected(data) } @@ -321,6 +323,107 @@ class SubscriptionWebViewViewModel @Inject constructor( } } + private fun getSubscriptionTierOptions( + featureName: String, + method: String, + id: String, + ) { + suspend fun sendTierOptionJson(optionsJson: SubscriptionTierOptionsJson) { + val response = JsCallbackData( + featureName = featureName, + method = method, + id = id, + params = JSONObject(tierJsonAdapter.toJson(optionsJson)), + ) + command.send(SendResponseToJs(response)) + } + + viewModelScope.launch(dispatcherProvider.io()) { + val defaultOptions = SubscriptionTierOptionsJson( + products = emptyList(), + ) + + val subscriptionTierOptions = if (privacyProFeature.allowPurchase().isEnabled()) { + val subscriptionOffers = subscriptionsManager.getSubscriptionOffer().associateBy { it.offerId ?: it.planId } + when { + subscriptionOffers.keys.containsAll(listOf(MONTHLY_FREE_TRIAL_OFFER_US, YEARLY_FREE_TRIAL_OFFER_US)) && + subscriptionsManager.isFreeTrialEligible() -> { + val tier = subscriptionOffers.getValue(MONTHLY_FREE_TRIAL_OFFER_US).tier + .takeUnless { it.isNullOrBlank() } ?: subscriptionOffers.getValue(YEARLY_FREE_TRIAL_OFFER_US).tier + createSubscriptionTierOptions( + tier, + monthlyOffer = subscriptionOffers.getValue(MONTHLY_FREE_TRIAL_OFFER_US), + yearlyOffer = subscriptionOffers.getValue(YEARLY_FREE_TRIAL_OFFER_US), + ) + } + + subscriptionOffers.keys.containsAll(listOf(MONTHLY_FREE_TRIAL_OFFER_ROW, YEARLY_FREE_TRIAL_OFFER_ROW)) && + subscriptionsManager.isFreeTrialEligible() -> { + val tier = subscriptionOffers.getValue(MONTHLY_FREE_TRIAL_OFFER_ROW).tier + .takeUnless { it.isNullOrBlank() } ?: subscriptionOffers.getValue(YEARLY_FREE_TRIAL_OFFER_ROW).tier + createSubscriptionTierOptions( + tier, + monthlyOffer = subscriptionOffers.getValue(MONTHLY_FREE_TRIAL_OFFER_ROW), + yearlyOffer = subscriptionOffers.getValue(YEARLY_FREE_TRIAL_OFFER_ROW), + ) + } + + subscriptionOffers.keys.containsAll(listOf(MONTHLY_PLAN_US, YEARLY_PLAN_US)) -> { + val tier = subscriptionOffers.getValue(MONTHLY_PLAN_US).tier + .takeUnless { it.isNullOrBlank() } ?: subscriptionOffers.getValue(YEARLY_PLAN_US).tier + createSubscriptionTierOptions( + tier, + monthlyOffer = subscriptionOffers.getValue(MONTHLY_PLAN_US), + yearlyOffer = subscriptionOffers.getValue(YEARLY_PLAN_US), + ) + } + + subscriptionOffers.keys.containsAll(listOf(MONTHLY_PLAN_ROW, YEARLY_PLAN_ROW)) -> { + val tier = subscriptionOffers.getValue(MONTHLY_PLAN_ROW).tier + .takeUnless { it.isNullOrBlank() } ?: subscriptionOffers.getValue(YEARLY_PLAN_ROW).tier + createSubscriptionTierOptions( + tier, + monthlyOffer = subscriptionOffers.getValue(MONTHLY_PLAN_ROW), + yearlyOffer = subscriptionOffers.getValue(YEARLY_PLAN_ROW), + ) + } + + else -> defaultOptions + } + } else { + defaultOptions + } + + sendTierOptionJson(subscriptionTierOptions) + } + } + + private suspend fun createSubscriptionTierOptions( + productTier: String, + monthlyOffer: SubscriptionOffer, + yearlyOffer: SubscriptionOffer, + ): SubscriptionTierOptionsJson { + val tierFeatures = monthlyOffer.entitlements.map { entitlement -> + TierFeatureJson( + product = entitlement.product, + name = entitlement.name, + ) + } + + val product = ProductJson( + tier = productTier, + features = tierFeatures, + options = listOf( + createOptionsJson(yearlyOffer, YEARLY.lowercase()), + createOptionsJson(monthlyOffer, MONTHLY.lowercase()), + ), + ) + + return SubscriptionTierOptionsJson( + products = listOf(product), + ) + } + private suspend fun createSubscriptionOptions( monthlyOffer: SubscriptionOffer, yearlyOffer: SubscriptionOffer, @@ -412,6 +515,23 @@ class SubscriptionWebViewViewModel @Inject constructor( data class FeatureJson(val name: String) + // New tier-based data classes for getSubscriptionTierOptions + data class SubscriptionTierOptionsJson( + val platform: String = PLATFORM, + val products: List, + ) + + data class ProductJson( + val tier: String, + val features: List, + val options: List, + ) + + data class TierFeatureJson( + val product: String, + val name: String, + ) + enum class OfferType(val type: String) { FREE_TRIAL("freeTrial"), UNKNOWN("unknown"), diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt index 7164a4584514..eb33336a3074 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt @@ -120,7 +120,11 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { private val subscriptionsService: SubscriptionsService = mock() private val authDataStore: FakeSubscriptionsDataStore = FakeSubscriptionsDataStore() private val serpPromo = FakeSerpPromo() - private val authRepository = RealAuthRepository(authDataStore, coroutineRule.testDispatcherProvider, serpPromo) + + @SuppressLint("DenyListedApi") + private val privacyProFeature: PrivacyProFeature = FakeFeatureToggleFactory.create(PrivacyProFeature::class.java) + .apply { authApiV2().setRawStoredState(State(authApiV2Enabled)) } + private val authRepository = RealAuthRepository(authDataStore, coroutineRule.testDispatcherProvider, serpPromo, { privacyProFeature }) private val emailManager: EmailManager = mock() private val playBillingManager: PlayBillingManager = mock() private val context: Context = mock() @@ -131,9 +135,6 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { private val freeTrialConversionWideEvent: FreeTrialConversionWideEvent = mock() private val subscriptionRestoreWideEvent: SubscriptionRestoreWideEvent = mock() - @SuppressLint("DenyListedApi") - private val privacyProFeature: PrivacyProFeature = FakeFeatureToggleFactory.create(PrivacyProFeature::class.java) - .apply { authApiV2().setRawStoredState(State(authApiV2Enabled)) } private val authClient: AuthClient = mock() private val pkceGenerator: PkceGenerator = PkceGeneratorImpl() private val authJwtValidator: AuthJwtValidator = mock() @@ -1379,6 +1380,83 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { assertEquals(emptyList(), subscriptionsManager.getSubscriptionOffer()) } + @Test + fun whenGetSubscriptionOfferWithTierMessagingEnabledThenReturnEntitlementsFromV2() = runTest { + givenTierMessagingEnabled(true) + authRepository.setFeaturesV2( + MONTHLY_PLAN_US, + setOf(Entitlement(name = "plus", product = NETP)), + ) + authRepository.setFeaturesV2( + YEARLY_PLAN_US, + setOf(Entitlement(name = "plus", product = NETP)), + ) + givenPlansAvailable(MONTHLY_PLAN_US, YEARLY_PLAN_US) + + val subscriptionOffers = subscriptionsManager.getSubscriptionOffer() + + with(subscriptionOffers) { + assertTrue(isNotEmpty()) + assertTrue(any { it.planId == MONTHLY_PLAN_US }) + assertTrue(any { it.planId == YEARLY_PLAN_US }) + assertEquals("plus", first().tier) + assertEquals(setOf(Entitlement(name = "plus", product = NETP)), first().entitlements) + assertEquals(setOf(NETP), first().features) + } + } + + @Test + fun whenGetSubscriptionOfferWithTierMessagingEnabledAndV2EmptyThenFallbackToV1() = runTest { + givenTierMessagingEnabled(true) + // V2 is empty, but V1 has data + authRepository.setFeatures(MONTHLY_PLAN_US, setOf(NETP)) + authRepository.setFeatures(YEARLY_PLAN_US, setOf(NETP)) + givenPlansAvailable(MONTHLY_PLAN_US, YEARLY_PLAN_US) + + val subscriptionOffers = subscriptionsManager.getSubscriptionOffer() + + with(subscriptionOffers) { + assertTrue(isNotEmpty()) + assertTrue(any { it.planId == MONTHLY_PLAN_US }) + // When falling back to V1, tier defaults to "plus" + assertEquals("plus", first().tier) + // Entitlements are created from V1 features with name="plus" + assertEquals(setOf(Entitlement(name = "plus", product = NETP)), first().entitlements) + assertEquals(setOf(NETP), first().features) + } + } + + @Test + fun whenGetSubscriptionOfferWithTierMessagingDisabledThenUseV1Features() = runTest { + givenTierMessagingEnabled(false) + authRepository.setFeatures(MONTHLY_PLAN_US, setOf(NETP)) + authRepository.setFeatures(YEARLY_PLAN_US, setOf(NETP)) + givenPlansAvailable(MONTHLY_PLAN_US, YEARLY_PLAN_US) + + val subscriptionOffers = subscriptionsManager.getSubscriptionOffer() + + with(subscriptionOffers) { + assertTrue(isNotEmpty()) + assertTrue(any { it.planId == MONTHLY_PLAN_US }) + // When flag OFF, tier defaults to "plus" + assertEquals("plus", first().tier) + // Entitlements created from V1 features + assertEquals(setOf(Entitlement(name = "plus", product = NETP)), first().entitlements) + assertEquals(setOf(NETP), first().features) + } + } + + @Test + fun whenGetSubscriptionOfferWithTierMessagingEnabledAndBothStoragesEmptyThenReturnEmptyList() = runTest { + givenTierMessagingEnabled(true) + // Both V1 and V2 are empty + givenPlansAvailable(MONTHLY_PLAN_US, YEARLY_PLAN_US) + + val subscriptionOffers = subscriptionsManager.getSubscriptionOffer() + + assertTrue(subscriptionOffers.isEmpty()) + } + @Test fun whenCanSupportEncryptionThenReturnTrue() = runTest { assertTrue(subscriptionsManager.canSupportEncryption()) @@ -1387,7 +1465,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { @Test fun whenCanSupportEncryptionIfCannotThenReturnFalse() = runTest { val authDataStore: SubscriptionsDataStore = FakeSubscriptionsDataStore(supportEncryption = false) - val authRepository = RealAuthRepository(authDataStore, coroutineRule.testDispatcherProvider, serpPromo) + val authRepository = RealAuthRepository(authDataStore, coroutineRule.testDispatcherProvider, serpPromo, { privacyProFeature }) whenever(playBillingManager.purchaseState).thenReturn(flowOf()) subscriptionsManager = RealSubscriptionsManager( authService, @@ -2049,6 +2127,11 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { privacyProFeature.isLaunchedROW().setRawStoredState(State(remoteEnableState = value)) } + @SuppressLint("DenyListedApi") + private fun givenTierMessagingEnabled(value: Boolean) { + privacyProFeature.tierMessagingEnabled().setRawStoredState(State(remoteEnableState = value)) + } + @SuppressLint("DenyListedApi") private fun givenSwitchPlanFeatureFlagEnabled(value: Boolean) { privacyProFeature.supportsSwitchSubscription().setRawStoredState(State(remoteEnableState = value)) diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsTest.kt index 565ff612a8bb..925cb75a7e77 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsTest.kt @@ -36,6 +36,7 @@ import com.duckduckgo.subscriptions.api.SubscriptionStatus.UNKNOWN import com.duckduckgo.subscriptions.api.SubscriptionStatus.WAITING import com.duckduckgo.subscriptions.impl.internal.DefaultSubscriptionsBaseUrl import com.duckduckgo.subscriptions.impl.internal.RealSubscriptionsUrlProvider +import com.duckduckgo.subscriptions.impl.model.Entitlement import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.ui.SubscriptionsWebViewActivityWithParams import kotlinx.coroutines.flow.flowOf @@ -76,8 +77,9 @@ class RealSubscriptionsTest { SubscriptionOffer( planId = "test", offerId = null, + tier = "plus", pricingPhases = emptyList(), - features = setOf(SubscriptionsConstants.NETP), + entitlements = setOf(Entitlement("plus", SubscriptionsConstants.NETP)), ), ) diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/SubscriptionFeaturesFetcherTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/SubscriptionFeaturesFetcherTest.kt index a8854a376be6..fbb420cfc901 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/SubscriptionFeaturesFetcherTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/SubscriptionFeaturesFetcherTest.kt @@ -18,10 +18,12 @@ import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_US import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.NETP import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_US import com.duckduckgo.subscriptions.impl.billing.PlayBillingManager +import com.duckduckgo.subscriptions.impl.model.Entitlement import com.duckduckgo.subscriptions.impl.repository.AuthRepository import com.duckduckgo.subscriptions.impl.services.FeaturesResponse +import com.duckduckgo.subscriptions.impl.services.FeaturesV2Response import com.duckduckgo.subscriptions.impl.services.SubscriptionsCachedService -import com.duckduckgo.subscriptions.impl.services.SubscriptionsService +import com.duckduckgo.subscriptions.impl.services.TierFeatureResponse import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Before @@ -43,7 +45,6 @@ class SubscriptionFeaturesFetcherTest { private val processLifecycleOwner = TestLifecycleOwner(initialState = INITIALIZED) private val playBillingManager: PlayBillingManager = mock() - private val subscriptionsService: SubscriptionsService = mock() private val subscriptionsCachedService: SubscriptionsCachedService = mock() private val authRepository: AuthRepository = mock() private val privacyProFeature: PrivacyProFeature = FakeFeatureToggleFactory.create(PrivacyProFeature::class.java) @@ -51,7 +52,6 @@ class SubscriptionFeaturesFetcherTest { private val subscriptionFeaturesFetcher = SubscriptionFeaturesFetcher( appCoroutineScope = coroutineRule.testScope, playBillingManager = playBillingManager, - subscriptionsService = subscriptionsService, subscriptionsCachedService = subscriptionsCachedService, authRepository = authRepository, privacyProFeature = privacyProFeature, @@ -71,13 +71,13 @@ class SubscriptionFeaturesFetcherTest { verifyNoInteractions(playBillingManager) verifyNoInteractions(authRepository) - verifyNoInteractions(subscriptionsService) + verifyNoInteractions(subscriptionsCachedService) } @Test - fun `when products loaded And Use Client with Cache Enabled then fetches and stores features from Cached Service`() = runTest { + fun `when products loaded and tierMessagingEnabled OFF then fetches V1 features and stores`() = runTest { givenIsFeaturesApiEnabled(true) - givenUseClientWithCacheForFeaturesEnabled(true) + givenTierMessagingEnabled(false) val productDetails = mockProductDetails() whenever(playBillingManager.productsFlow).thenReturn(flowOf(productDetails)) whenever(authRepository.getFeatures(any())).thenReturn(emptySet()) @@ -86,7 +86,6 @@ class SubscriptionFeaturesFetcherTest { processLifecycleOwner.currentState = CREATED verify(playBillingManager).productsFlow - verifyNoInteractions(subscriptionsService) verify(subscriptionsCachedService).features(MONTHLY_PLAN_US) verify(subscriptionsCachedService).features(YEARLY_PLAN_US) verify(authRepository).setFeatures(MONTHLY_PLAN_US, setOf(NETP, ITR, DUCK_AI)) @@ -94,21 +93,40 @@ class SubscriptionFeaturesFetcherTest { } @Test - fun `when products loaded then fetches and stores features`() = runTest { + fun `when products loaded and tierMessagingEnabled ON then fetches V2 features and stores entitlements`() = runTest { givenIsFeaturesApiEnabled(true) - givenUseClientWithCacheForFeaturesEnabled(false) + givenTierMessagingEnabled(true) val productDetails = mockProductDetails() whenever(playBillingManager.productsFlow).thenReturn(flowOf(productDetails)) whenever(authRepository.getFeatures(any())).thenReturn(emptySet()) - whenever(subscriptionsService.features(any())).thenReturn(FeaturesResponse(listOf(NETP, ITR, DUCK_AI))) + whenever(subscriptionsCachedService.featuresV2(any())).thenReturn( + FeaturesV2Response( + mapOf( + MONTHLY_PLAN_US to listOf( + TierFeatureResponse(product = NETP, name = "plus"), + TierFeatureResponse(product = ITR, name = "plus"), + ), + YEARLY_PLAN_US to listOf( + TierFeatureResponse(product = NETP, name = "plus"), + TierFeatureResponse(product = ITR, name = "plus"), + ), + ), + ), + ) processLifecycleOwner.currentState = CREATED verify(playBillingManager).productsFlow - verify(subscriptionsService).features(MONTHLY_PLAN_US) - verify(subscriptionsService).features(YEARLY_PLAN_US) - verify(authRepository).setFeatures(MONTHLY_PLAN_US, setOf(NETP, ITR, DUCK_AI)) - verify(authRepository).setFeatures(YEARLY_PLAN_US, setOf(NETP, ITR, DUCK_AI)) + verify(subscriptionsCachedService).featuresV2(MONTHLY_PLAN_US) + verify(subscriptionsCachedService).featuresV2(YEARLY_PLAN_US) + verify(authRepository).setFeaturesV2( + MONTHLY_PLAN_US, + setOf(Entitlement(name = "plus", product = NETP), Entitlement(name = "plus", product = ITR)), + ) + verify(authRepository).setFeaturesV2( + YEARLY_PLAN_US, + setOf(Entitlement(name = "plus", product = NETP), Entitlement(name = "plus", product = ITR)), + ) } @Test @@ -120,17 +138,17 @@ class SubscriptionFeaturesFetcherTest { verify(playBillingManager).productsFlow verifyNoInteractions(authRepository) - verifyNoInteractions(subscriptionsService) + verifyNoInteractions(subscriptionsCachedService) } @Test fun `when features already stored and refresh features FF Disabled then does not fetch again`() = runTest { givenRefreshSubscriptionPlanFeaturesEnabled(false) givenIsFeaturesApiEnabled(true) + givenTierMessagingEnabled(false) val productDetails = mockProductDetails() whenever(playBillingManager.productsFlow).thenReturn(flowOf(productDetails)) whenever(authRepository.getFeatures(any())).thenReturn(setOf(NETP, ITR)) - whenever(subscriptionsService.features(any())).thenReturn(FeaturesResponse(listOf(NETP, ITR))) processLifecycleOwner.currentState = CREATED @@ -138,28 +156,46 @@ class SubscriptionFeaturesFetcherTest { verify(authRepository).getFeatures(MONTHLY_PLAN_US) verify(authRepository).getFeatures(YEARLY_PLAN_US) verify(authRepository, never()).setFeatures(any(), any()) - verifyNoInteractions(subscriptionsService) + verifyNoInteractions(subscriptionsCachedService) } @Test fun `when features already stored and refresh features FF enabled then does fetch again`() = runTest { givenRefreshSubscriptionPlanFeaturesEnabled(true) - givenUseClientWithCacheForFeaturesEnabled(false) givenIsFeaturesApiEnabled(true) + givenTierMessagingEnabled(false) val productDetails = mockProductDetails() whenever(playBillingManager.productsFlow).thenReturn(flowOf(productDetails)) whenever(authRepository.getFeatures(any())).thenReturn(setOf(NETP, ITR)) - whenever(subscriptionsService.features(any())).thenReturn(FeaturesResponse(listOf(NETP, ITR, DUCK_AI))) + whenever(subscriptionsCachedService.features(any())).thenReturn(FeaturesResponse(listOf(NETP, ITR, DUCK_AI))) processLifecycleOwner.currentState = CREATED verify(playBillingManager).productsFlow - verify(subscriptionsService).features(MONTHLY_PLAN_US) - verify(subscriptionsService).features(YEARLY_PLAN_US) + verify(subscriptionsCachedService).features(MONTHLY_PLAN_US) + verify(subscriptionsCachedService).features(YEARLY_PLAN_US) verify(authRepository).setFeatures(MONTHLY_PLAN_US, setOf(NETP, ITR, DUCK_AI)) verify(authRepository).setFeatures(YEARLY_PLAN_US, setOf(NETP, ITR, DUCK_AI)) } + @Test + fun `when tierMessagingEnabled ON and V2 features empty then does not store anything`() = runTest { + givenIsFeaturesApiEnabled(true) + givenTierMessagingEnabled(true) + val productDetails = mockProductDetails() + whenever(playBillingManager.productsFlow).thenReturn(flowOf(productDetails)) + whenever(authRepository.getFeatures(any())).thenReturn(emptySet()) + whenever(subscriptionsCachedService.featuresV2(any())).thenReturn( + FeaturesV2Response(emptyMap()), + ) + + processLifecycleOwner.currentState = CREATED + + verify(subscriptionsCachedService).featuresV2(MONTHLY_PLAN_US) + verify(subscriptionsCachedService).featuresV2(YEARLY_PLAN_US) + verify(authRepository, never()).setFeaturesV2(any(), any()) + } + @SuppressLint("DenyListedApi") private fun givenIsFeaturesApiEnabled(value: Boolean) { privacyProFeature.featuresApi().setRawStoredState(State(value)) @@ -171,8 +207,8 @@ class SubscriptionFeaturesFetcherTest { } @SuppressLint("DenyListedApi") - private fun givenUseClientWithCacheForFeaturesEnabled(value: Boolean) { - privacyProFeature.useClientWithCacheForFeatures().setRawStoredState(State(value)) + private fun givenTierMessagingEnabled(value: Boolean) { + privacyProFeature.tierMessagingEnabled().setRawStoredState(State(value)) } private fun mockProductDetails(): List { diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt index 9c4e471eb352..16c562e4d6b1 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt @@ -474,6 +474,46 @@ class SubscriptionMessagingInterfaceTest { assertNull(callback.id) } + @Test + fun `when process and get subscription tier options message if feature name does not match do nothing`() = runTest { + givenInterfaceIsRegistered() + + val message = """ + {"context":"subscriptionPages","featureName":"test","method":"getSubscriptionTierOptions","id":"id","params":{}} + """.trimIndent() + + messagingInterface.process(message, "duckduckgo-android-messaging-secret") + + assertEquals(0, callback.counter) + } + + @Test + fun `when process and get subscription tier options message then callback called`() = runTest { + givenInterfaceIsRegistered() + + val message = """ + {"context":"subscriptionPages","featureName":"useSubscription","method":"getSubscriptionTierOptions","id":"id","params":{}} + """.trimIndent() + + messagingInterface.process(message, "duckduckgo-android-messaging-secret") + + assertEquals(1, callback.counter) + } + + @Test + fun `when process and get subscription tier options message and no id then callback still called`() = runTest { + givenInterfaceIsRegistered() + + val message = """ + {"context":"subscriptionPages","featureName":"useSubscription","method":"getSubscriptionTierOptions","params":{}} + """.trimIndent() + + messagingInterface.process(message, "duckduckgo-android-messaging-secret") + + assertEquals(1, callback.counter) + assertNull(callback.id) + } + @Test fun `when process and subscription selected message if feature name does not match do nothing`() = runTest { givenInterfaceIsRegistered() @@ -823,13 +863,16 @@ class SubscriptionMessagingInterfaceTest { givenAuthV2(enabled = true) givenDuckAiPlus(enabled = true) givenStripeSupported(enabled = true) + givenUseGetSubscriptionTierOptions(enabled = false) val expected = JsRequestResponse.Success( context = "subscriptionPages", featureName = "useSubscription", method = "getFeatureConfig", id = "myId", - result = JSONObject("""{"useSubscriptionsAuthV2":true,"usePaidDuckAi":true,"useAlternateStripePaymentFlow":true}"""), + result = JSONObject( + """{"useSubscriptionsAuthV2":true,"usePaidDuckAi":true,"useAlternateStripePaymentFlow":true,"useGetSubscriptionTierOptions":false}""", + ), ) val message = """ @@ -853,13 +896,18 @@ class SubscriptionMessagingInterfaceTest { givenAuthV2(enabled = false) givenDuckAiPlus(enabled = true) givenStripeSupported(enabled = true) + givenUseGetSubscriptionTierOptions(enabled = false) val expected = JsRequestResponse.Success( context = "subscriptionPages", featureName = "useSubscription", method = "getFeatureConfig", id = "myId", - result = JSONObject("""{"useSubscriptionsAuthV2":false,"usePaidDuckAi":true,"useAlternateStripePaymentFlow":true}"""), + result = JSONObject( + """ + {"useSubscriptionsAuthV2":false,"usePaidDuckAi":true,"useAlternateStripePaymentFlow":true,"useGetSubscriptionTierOptions":false} + """.trimIndent(), + ), ) val message = """ @@ -883,13 +931,53 @@ class SubscriptionMessagingInterfaceTest { givenAuthV2(enabled = true) givenDuckAiPlus(enabled = false) givenStripeSupported(enabled = true) + givenUseGetSubscriptionTierOptions(enabled = false) + + val expected = JsRequestResponse.Success( + context = "subscriptionPages", + featureName = "useSubscription", + method = "getFeatureConfig", + id = "myId", + result = JSONObject( + """ + {"useSubscriptionsAuthV2":true,"usePaidDuckAi":false,"useAlternateStripePaymentFlow":true,"useGetSubscriptionTierOptions":false} + """.trimIndent(), + ), + ) + + val message = """ + {"context":"subscriptionPages","featureName":"useSubscription","method":"getFeatureConfig","id":"myId","params":{}} + """.trimIndent() + + messagingInterface.process(message, "duckduckgo-android-messaging-secret") + + val captor = argumentCaptor() + verify(jsMessageHelper).sendJsResponse(captor.capture(), eq(CALLBACK_NAME), eq(SECRET), eq(webView)) + val jsMessage = captor.firstValue + + assertTrue(jsMessage is JsRequestResponse.Success) + checkEquals(expected, jsMessage) + } + + @Test + fun `when process and get feature config and tier options enabled then return response with tier options true`() = runTest { + givenInterfaceIsRegistered() + givenSubscriptionMessaging(enabled = true) + givenAuthV2(enabled = true) + givenDuckAiPlus(enabled = true) + givenStripeSupported(enabled = true) + givenUseGetSubscriptionTierOptions(enabled = true) val expected = JsRequestResponse.Success( context = "subscriptionPages", featureName = "useSubscription", method = "getFeatureConfig", id = "myId", - result = JSONObject("""{"useSubscriptionsAuthV2":true,"usePaidDuckAi":false,"useAlternateStripePaymentFlow":true}"""), + result = JSONObject( + """ + {"useSubscriptionsAuthV2":true,"usePaidDuckAi":true,"useAlternateStripePaymentFlow":true,"useGetSubscriptionTierOptions":true} + """.trimIndent(), + ), ) val message = """ @@ -997,6 +1085,12 @@ class SubscriptionMessagingInterfaceTest { whenever(privacyProFeature.supportsAlternateStripePaymentFlow()).thenReturn(stripeSupportedToggle) } + private fun givenUseGetSubscriptionTierOptions(enabled: Boolean) { + val toggle = mock() + whenever(toggle.isEnabled()).thenReturn(enabled) + whenever(privacyProFeature.tierMessagingEnabled()).thenReturn(toggle) + } + private fun checkEquals(expected: JsRequestResponse, actual: JsRequestResponse) { if (expected is JsRequestResponse.Success && actual is JsRequestResponse.Success) { assertEquals(expected.id, actual.id) diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeSubscriptionsDataStore.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeSubscriptionsDataStore.kt index cdb42f147cd3..ac9e6ac0719a 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeSubscriptionsDataStore.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeSubscriptionsDataStore.kt @@ -52,4 +52,5 @@ class FakeSubscriptionsDataStore( override var freeTrialActive: Boolean = false override fun canUseEncryption(): Boolean = supportEncryption override var subscriptionFeatures: String? = null + override var subscriptionEntitlements: String? = null } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealAuthRepositoryTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealAuthRepositoryTest.kt index 75dca8b45c06..44bf78003c57 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealAuthRepositoryTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealAuthRepositoryTest.kt @@ -1,6 +1,9 @@ package com.duckduckgo.subscriptions.impl.repository +import android.annotation.SuppressLint import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.subscriptions.api.SubscriptionStatus.AUTO_RENEWABLE import com.duckduckgo.subscriptions.api.SubscriptionStatus.EXPIRED import com.duckduckgo.subscriptions.api.SubscriptionStatus.GRACE_PERIOD @@ -8,6 +11,8 @@ import com.duckduckgo.subscriptions.api.SubscriptionStatus.INACTIVE import com.duckduckgo.subscriptions.api.SubscriptionStatus.NOT_AUTO_RENEWABLE import com.duckduckgo.subscriptions.api.SubscriptionStatus.UNKNOWN import com.duckduckgo.subscriptions.api.SubscriptionStatus.WAITING +import com.duckduckgo.subscriptions.impl.PrivacyProFeature +import com.duckduckgo.subscriptions.impl.model.Entitlement import com.duckduckgo.subscriptions.impl.serp_promo.FakeSerpPromo import kotlinx.coroutines.test.runTest import org.junit.Assert.* @@ -15,13 +20,20 @@ import org.junit.Rule import org.junit.Test import java.time.Instant +@SuppressLint("DenyListedApi") class RealAuthRepositoryTest { @get:Rule val coroutineRule = CoroutineTestRule() private val authStore = FakeSubscriptionsDataStore() private val serpPromo = FakeSerpPromo() - private val authRepository: AuthRepository = RealAuthRepository(authStore, coroutineRule.testDispatcherProvider, serpPromo) + private val privacyProFeature = FakeFeatureToggleFactory.create(PrivacyProFeature::class.java) + private val authRepository: AuthRepository = RealAuthRepository( + authStore, + coroutineRule.testDispatcherProvider, + serpPromo, + dagger.Lazy { privacyProFeature }, + ) @Test fun whenClearAccountThenClearData() = runTest { @@ -137,6 +149,7 @@ class RealAuthRepositoryTest { FakeSubscriptionsDataStore(supportEncryption = false), coroutineRule.testDispatcherProvider, serpPromo, + dagger.Lazy { privacyProFeature }, ) assertFalse(repository.canSupportEncryption()) } @@ -215,4 +228,114 @@ class RealAuthRepositoryTest { assertNull(authStore.localPurchasedAt) } + + @Test + fun whenSetFeaturesV2AndStoredValueIsNullThenSaveJson() = runTest { + authStore.subscriptionEntitlements = null + + authRepository.setFeaturesV2( + basePlanId = "plan1", + features = setOf( + Entitlement(name = "plus", product = "Network Protection"), + Entitlement(name = "plus", product = "Data Broker Protection"), + ), + ) + + assertNotNull(authStore.subscriptionEntitlements) + assertTrue(authStore.subscriptionEntitlements!!.contains("plan1")) + assertTrue(authStore.subscriptionEntitlements!!.contains("Network Protection")) + assertTrue(authStore.subscriptionEntitlements!!.contains("Data Broker Protection")) + } + + @Test + fun whenSetFeaturesV2AndStoredValueIsNotNullThenUpdateJson() = runTest { + authStore.subscriptionEntitlements = """{"plan1":[{"name":"plus","product":"Network Protection"}]}""" + + authRepository.setFeaturesV2( + basePlanId = "plan2", + features = setOf(Entitlement(name = "plus", product = "Data Broker Protection")), + ) + + assertNotNull(authStore.subscriptionEntitlements) + assertTrue(authStore.subscriptionEntitlements!!.contains("plan1")) + assertTrue(authStore.subscriptionEntitlements!!.contains("plan2")) + } + + @Test + fun whenGetFeaturesV2ThenReturnsCorrectValue() = runTest { + authStore.subscriptionEntitlements = """{"plan1":[ + |{"name":"plus","product":"Network Protection"} + |,{"name":"plus","product":"Data Broker Protection"} + |]} + """.trimMargin() + + val result = authRepository.getFeaturesV2(basePlanId = "plan1") + + assertEquals(2, result.size) + assertTrue(result.contains(Entitlement(name = "plus", product = "Network Protection"))) + assertTrue(result.contains(Entitlement(name = "plus", product = "Data Broker Protection"))) + } + + @Test + fun whenGetFeaturesV2AndBasePlanNotFoundThenReturnEmptySet() = runTest { + authStore.subscriptionEntitlements = """{"plan1":[ + |{"name":"plus","product":"Network Protection"} + |]} + """.trimMargin() + + val result = authRepository.getFeaturesV2(basePlanId = "plan2") + + assertEquals(emptySet(), result) + } + + @Test + fun whenGetFeaturesAndTierFlagOnThenReturnV2ProductNames() = runTest { + privacyProFeature.tierMessagingEnabled().setRawStoredState(Toggle.State(enable = true)) + authStore.subscriptionEntitlements = """{"plan1":[ + |{"name":"plus","product":"Network Protection"}, + |{"name":"plus","product":"Data Broker Protection"} + |]} + """.trimMargin() + authStore.subscriptionFeatures = """{"plan1":["Old Feature"]}""" + + val result = authRepository.getFeatures(basePlanId = "plan1") + + assertEquals(setOf("Network Protection", "Data Broker Protection"), result) + } + + @Test + fun whenGetFeaturesAndTierFlagOnAndV2EmptyThenFallbackToV1() = runTest { + privacyProFeature.tierMessagingEnabled().setRawStoredState(Toggle.State(enable = true)) + authStore.subscriptionEntitlements = null + authStore.subscriptionFeatures = """{"plan1":["feature1","feature2"]}""" + + val result = authRepository.getFeatures(basePlanId = "plan1") + + // When flag is ON and v2 is empty, fallback to v1 for smooth runtime flag transitions + assertEquals(setOf("feature1", "feature2"), result) + } + + @Test + fun whenGetFeaturesAndTierFlagOnAndV2EmptyForPlanThenFallbackToV1() = runTest { + privacyProFeature.tierMessagingEnabled().setRawStoredState(Toggle.State(enable = true)) + authStore.subscriptionEntitlements = """{"plan2":[{"name":"plus","product":"Network Protection"}]}""" + authStore.subscriptionFeatures = """{"plan1":["feature1","feature2"]}""" + + val result = authRepository.getFeatures(basePlanId = "plan1") + + // When flag is ON and v2 is empty for this plan, fallback to v1 for smooth runtime flag transitions + assertEquals(setOf("feature1", "feature2"), result) + } + + @Test + fun whenGetFeaturesAndTierFlagOffThenReturnV1Features() = runTest { + privacyProFeature.tierMessagingEnabled().setRawStoredState(Toggle.State(enable = false)) + authStore.subscriptionEntitlements = """{"plan1":[{"name":"plus","product":"Network Protection"}]}""" + authStore.subscriptionFeatures = """{"plan1":["feature1","feature2"]}""" + + val result = authRepository.getFeatures(basePlanId = "plan1") + + // When flag is OFF, should use v1 storage only + assertEquals(setOf("feature1", "feature2"), result) + } } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingViewModelTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingViewModelTest.kt index 2131dacf45ce..2eb90235a3e4 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingViewModelTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingViewModelTest.kt @@ -10,6 +10,7 @@ import com.duckduckgo.subscriptions.api.SubscriptionStatus import com.duckduckgo.subscriptions.impl.PrivacyProFeature import com.duckduckgo.subscriptions.impl.SubscriptionOffer import com.duckduckgo.subscriptions.impl.SubscriptionsManager +import com.duckduckgo.subscriptions.impl.model.Entitlement import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.settings.views.ProSettingViewModel.Command.OpenBuyScreen import com.duckduckgo.subscriptions.impl.settings.views.ProSettingViewModel.Command.OpenRestoreScreen @@ -118,7 +119,9 @@ class ProSettingViewModelTest { fun whenDuckAiPlusEnabledIfSubscriptionPlanHasDuckAiThenDuckAiPlusAvailable() = runTest { privacyProFeature.duckAiPlus().setRawStoredState(State(true)) whenever(subscriptionsManager.subscriptionStatus).thenReturn(flowOf(SubscriptionStatus.AUTO_RENEWABLE)) - whenever(subscriptionsManager.getSubscriptionOffer()).thenReturn(listOf(subscriptionOffer.copy(features = setOf(Product.DuckAiPlus.value)))) + whenever( + subscriptionsManager.getSubscriptionOffer(), + ).thenReturn(listOf(subscriptionOffer.copy(entitlements = setOf(Entitlement("plus", Product.DuckAiPlus.value))))) whenever(subscriptionsManager.isFreeTrialEligible()).thenReturn(true) whenever(subscriptionsManager.blackFridayOfferAvailable()).thenReturn(false) @@ -133,7 +136,9 @@ class ProSettingViewModelTest { fun whenDuckAiPlusEnabledIfSubscriptionPlanDoesNotHaveDuckAiThenDuckAiPlusAvailable() = runTest { privacyProFeature.duckAiPlus().setRawStoredState(State(true)) whenever(subscriptionsManager.subscriptionStatus).thenReturn(flowOf(SubscriptionStatus.AUTO_RENEWABLE)) - whenever(subscriptionsManager.getSubscriptionOffer()).thenReturn(listOf(subscriptionOffer.copy(features = setOf(Product.NetP.value)))) + whenever( + subscriptionsManager.getSubscriptionOffer(), + ).thenReturn(listOf(subscriptionOffer.copy(entitlements = setOf(Entitlement("plus", Product.NetP.value))))) whenever(subscriptionsManager.isFreeTrialEligible()).thenReturn(true) whenever(subscriptionsManager.blackFridayOfferAvailable()).thenReturn(false) @@ -148,7 +153,9 @@ class ProSettingViewModelTest { fun whenDuckAiPlusDisabledIfSubscriptionPlanHasDuckAiThenDuckAiPlusAvailableFalse() = runTest { privacyProFeature.duckAiPlus().setRawStoredState(State(false)) whenever(subscriptionsManager.subscriptionStatus).thenReturn(flowOf(SubscriptionStatus.AUTO_RENEWABLE)) - whenever(subscriptionsManager.getSubscriptionOffer()).thenReturn(listOf(subscriptionOffer.copy(features = setOf(Product.DuckAiPlus.value)))) + whenever( + subscriptionsManager.getSubscriptionOffer(), + ).thenReturn(listOf(subscriptionOffer.copy(entitlements = setOf(Entitlement("plus", Product.DuckAiPlus.value))))) whenever(subscriptionsManager.isFreeTrialEligible()).thenReturn(true) whenever(subscriptionsManager.blackFridayOfferAvailable()).thenReturn(false) @@ -190,7 +197,8 @@ class ProSettingViewModelTest { private val subscriptionOffer = SubscriptionOffer( planId = "test", offerId = null, + tier = "plus", pricingPhases = emptyList(), - features = emptySet(), + entitlements = emptySet(), ) } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt index bbd6d9454435..f65cc72ea260 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt @@ -28,6 +28,7 @@ import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_US import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_FREE_TRIAL_OFFER_US import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_US import com.duckduckgo.subscriptions.impl.SubscriptionsManager +import com.duckduckgo.subscriptions.impl.model.Entitlement import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command.Reload @@ -35,6 +36,7 @@ import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Compani import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.PurchaseStateView import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.PurchaseStateView.Success import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.SubscriptionOptionsJson +import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.SubscriptionTierOptionsJson import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi import kotlinx.coroutines.flow.MutableSharedFlow @@ -64,6 +66,7 @@ class SubscriptionWebViewViewModelTest { private val moshi = Moshi.Builder().add(JSONObjectAdapter()).build() private val jsonAdapter: JsonAdapter = moshi.adapter(SubscriptionOptionsJson::class.java) + private val tierJsonAdapter: JsonAdapter = moshi.adapter(SubscriptionTierOptionsJson::class.java) private val subscriptionsManager: SubscriptionsManager = mock() private val networkProtectionAccessState: NetworkProtectionAccessState = mock() private val subscriptionsChecker: SubscriptionsChecker = mock() @@ -215,6 +218,7 @@ class SubscriptionWebViewViewModelTest { SubscriptionOffer( planId = MONTHLY_PLAN_US, offerId = null, + tier = "plus", pricingPhases = listOf( PricingPhase( priceAmount = 1.toBigDecimal(), @@ -223,11 +227,12 @@ class SubscriptionWebViewViewModelTest { billingPeriod = "P1M", ), ), - features = setOf(SubscriptionsConstants.NETP), + entitlements = setOf(Entitlement("plus", SubscriptionsConstants.NETP)), ), SubscriptionOffer( planId = YEARLY_PLAN_US, offerId = null, + tier = "plus", pricingPhases = listOf( PricingPhase( priceAmount = 10.toBigDecimal(), @@ -236,7 +241,7 @@ class SubscriptionWebViewViewModelTest { billingPeriod = "P1Y", ), ), - features = setOf(SubscriptionsConstants.NETP), + entitlements = setOf(Entitlement("plus", SubscriptionsConstants.NETP)), ), ) whenever(subscriptionsManager.getSubscriptionOffer()).thenReturn(testSubscriptionOfferList) @@ -285,6 +290,7 @@ class SubscriptionWebViewViewModelTest { SubscriptionOffer( planId = MONTHLY_PLAN_US, offerId = null, + tier = "plus", pricingPhases = listOf( PricingPhase( priceAmount = 1.toBigDecimal(), @@ -293,11 +299,12 @@ class SubscriptionWebViewViewModelTest { billingPeriod = "P1M", ), ), - features = setOf(SubscriptionsConstants.NETP), + entitlements = setOf(Entitlement("plus", SubscriptionsConstants.NETP)), ), SubscriptionOffer( planId = YEARLY_PLAN_US, offerId = null, + tier = "plus", pricingPhases = listOf( PricingPhase( priceAmount = 10.toBigDecimal(), @@ -306,7 +313,7 @@ class SubscriptionWebViewViewModelTest { billingPeriod = "P1Y", ), ), - features = setOf(SubscriptionsConstants.NETP), + entitlements = setOf(Entitlement("plus", SubscriptionsConstants.NETP)), ), ) privacyProFeature.allowPurchase().setRawStoredState(Toggle.State(enable = false)) @@ -335,6 +342,7 @@ class SubscriptionWebViewViewModelTest { SubscriptionOffer( planId = MONTHLY_PLAN_US, offerId = null, + tier = "plus", pricingPhases = listOf( PricingPhase( priceAmount = 1.toBigDecimal(), @@ -343,11 +351,12 @@ class SubscriptionWebViewViewModelTest { billingPeriod = "P1M", ), ), - features = setOf(SubscriptionsConstants.NETP), + entitlements = setOf(Entitlement("plus", SubscriptionsConstants.NETP)), ), SubscriptionOffer( planId = YEARLY_PLAN_US, offerId = null, + tier = "plus", pricingPhases = listOf( PricingPhase( priceAmount = 1.toBigDecimal(), @@ -356,11 +365,12 @@ class SubscriptionWebViewViewModelTest { billingPeriod = "P1Y", ), ), - features = setOf(SubscriptionsConstants.NETP), + entitlements = setOf(Entitlement("plus", SubscriptionsConstants.NETP)), ), SubscriptionOffer( planId = MONTHLY_PLAN_US, offerId = MONTHLY_FREE_TRIAL_OFFER_US, + tier = "plus", pricingPhases = listOf( PricingPhase( priceAmount = 1.toBigDecimal(), @@ -375,11 +385,12 @@ class SubscriptionWebViewViewModelTest { billingPeriod = "P1W", ), ), - features = setOf(SubscriptionsConstants.NETP), + entitlements = setOf(Entitlement("plus", SubscriptionsConstants.NETP)), ), SubscriptionOffer( planId = YEARLY_PLAN_US, offerId = YEARLY_FREE_TRIAL_OFFER_US, + tier = "plus", pricingPhases = listOf( PricingPhase( priceAmount = 1.toBigDecimal(), @@ -394,7 +405,7 @@ class SubscriptionWebViewViewModelTest { billingPeriod = "P1W", ), ), - features = setOf(SubscriptionsConstants.NETP), + entitlements = setOf(Entitlement("plus", SubscriptionsConstants.NETP)), ), ) whenever(subscriptionsManager.getSubscriptionOffer()).thenReturn(testSubscriptionOfferList) @@ -422,6 +433,7 @@ class SubscriptionWebViewViewModelTest { SubscriptionOffer( planId = MONTHLY_PLAN_US, offerId = null, + tier = "plus", pricingPhases = listOf( PricingPhase( priceAmount = 1.toBigDecimal(), @@ -430,11 +442,12 @@ class SubscriptionWebViewViewModelTest { billingPeriod = "P1M", ), ), - features = setOf(SubscriptionsConstants.NETP), + entitlements = setOf(Entitlement("plus", SubscriptionsConstants.NETP)), ), SubscriptionOffer( planId = YEARLY_PLAN_US, offerId = null, + tier = "plus", pricingPhases = listOf( PricingPhase( priceAmount = 10.toBigDecimal(), @@ -443,11 +456,12 @@ class SubscriptionWebViewViewModelTest { billingPeriod = "P1Y", ), ), - features = setOf(SubscriptionsConstants.NETP), + entitlements = setOf(Entitlement("plus", SubscriptionsConstants.NETP)), ), SubscriptionOffer( planId = MONTHLY_PLAN_US, offerId = MONTHLY_FREE_TRIAL_OFFER_US, + tier = "plus", pricingPhases = listOf( PricingPhase( priceAmount = 1.toBigDecimal(), @@ -462,11 +476,12 @@ class SubscriptionWebViewViewModelTest { billingPeriod = "P1W", ), ), - features = setOf(SubscriptionsConstants.NETP), + entitlements = setOf(Entitlement("plus", SubscriptionsConstants.NETP)), ), SubscriptionOffer( planId = YEARLY_PLAN_US, offerId = YEARLY_FREE_TRIAL_OFFER_US, + tier = "plus", pricingPhases = listOf( PricingPhase( priceAmount = 10.toBigDecimal(), @@ -481,7 +496,7 @@ class SubscriptionWebViewViewModelTest { billingPeriod = "P1W", ), ), - features = setOf(SubscriptionsConstants.NETP), + entitlements = setOf(Entitlement("plus", SubscriptionsConstants.NETP)), ), ) whenever(subscriptionsManager.getSubscriptionOffer()).thenReturn(testSubscriptionOfferList) @@ -913,6 +928,188 @@ class SubscriptionWebViewViewModelTest { } } + @Test + fun whenGetSubscriptionTierOptionsAndOfferExistsThenSendCommandWithTierData() = runTest { + val testSubscriptionOfferList = listOf( + SubscriptionOffer( + planId = MONTHLY_PLAN_US, + offerId = null, + tier = "plus", + pricingPhases = listOf( + PricingPhase( + priceAmount = 1.toBigDecimal(), + priceCurrency = Currency.getInstance("USD"), + formattedPrice = "$1", + billingPeriod = "P1M", + ), + ), + entitlements = setOf(Entitlement("plus", SubscriptionsConstants.NETP)), + ), + SubscriptionOffer( + planId = YEARLY_PLAN_US, + offerId = null, + tier = "plus", + pricingPhases = listOf( + PricingPhase( + priceAmount = 10.toBigDecimal(), + priceCurrency = Currency.getInstance("USD"), + formattedPrice = "$10", + billingPeriod = "P1Y", + ), + ), + entitlements = setOf(Entitlement("plus", SubscriptionsConstants.NETP)), + ), + ) + whenever(subscriptionsManager.getSubscriptionOffer()).thenReturn(testSubscriptionOfferList) + privacyProFeature.allowPurchase().setRawStoredState(Toggle.State(enable = true)) + + viewModel.commands().test { + viewModel.processJsCallbackMessage("test", "getSubscriptionTierOptions", "id", JSONObject("{}")) + val result = awaitItem() + assertTrue(result is Command.SendResponseToJs) + val response = (result as Command.SendResponseToJs).data + + val params = tierJsonAdapter.fromJson(response.params.toString()) + assertEquals("id", response.id) + assertEquals("test", response.featureName) + assertEquals("getSubscriptionTierOptions", response.method) + assertNotNull(params?.products) + assertEquals(1, params?.products?.size) + assertEquals("plus", params?.products?.first()?.tier) + assertEquals(YEARLY_PLAN_US, params?.products?.first()?.options?.first()?.id) + assertEquals(MONTHLY_PLAN_US, params?.products?.first()?.options?.last()?.id) + } + } + + @Test + fun whenGetSubscriptionTierOptionsAndNoOfferThenSendCommandWithEmptyProducts() = runTest { + privacyProFeature.allowPurchase().setRawStoredState(Toggle.State(enable = true)) + whenever(subscriptionsManager.getSubscriptionOffer()).thenReturn(emptyList()) + + viewModel.commands().test { + viewModel.processJsCallbackMessage("test", "getSubscriptionTierOptions", "id", JSONObject("{}")) + + val result = awaitItem() + assertTrue(result is Command.SendResponseToJs) + + val response = (result as Command.SendResponseToJs).data + assertEquals("id", response.id) + assertEquals("test", response.featureName) + assertEquals("getSubscriptionTierOptions", response.method) + + val params = tierJsonAdapter.fromJson(response.params.toString())!! + assertEquals(0, params.products.size) + } + } + + @Test + fun whenGetSubscriptionTierOptionsAndToggleOffThenSendCommandWithEmptyProducts() = runTest { + val testSubscriptionOfferList = listOf( + SubscriptionOffer( + planId = MONTHLY_PLAN_US, + offerId = null, + tier = "plus", + pricingPhases = listOf( + PricingPhase( + priceAmount = 1.toBigDecimal(), + priceCurrency = Currency.getInstance("USD"), + formattedPrice = "$1", + billingPeriod = "P1M", + ), + ), + entitlements = setOf(Entitlement("plus", SubscriptionsConstants.NETP)), + ), + SubscriptionOffer( + planId = YEARLY_PLAN_US, + offerId = null, + tier = "plus", + pricingPhases = listOf( + PricingPhase( + priceAmount = 10.toBigDecimal(), + priceCurrency = Currency.getInstance("USD"), + formattedPrice = "$10", + billingPeriod = "P1Y", + ), + ), + entitlements = setOf(Entitlement("plus", SubscriptionsConstants.NETP)), + ), + ) + privacyProFeature.allowPurchase().setRawStoredState(Toggle.State(enable = false)) + whenever(subscriptionsManager.getSubscriptionOffer()).thenReturn(testSubscriptionOfferList) + + viewModel.commands().test { + viewModel.processJsCallbackMessage("test", "getSubscriptionTierOptions", "id", JSONObject("{}")) + + val result = awaitItem() + assertTrue(result is Command.SendResponseToJs) + + val response = (result as Command.SendResponseToJs).data + assertEquals("id", response.id) + assertEquals("test", response.featureName) + assertEquals("getSubscriptionTierOptions", response.method) + + val params = tierJsonAdapter.fromJson(response.params.toString())!! + assertEquals(0, params.products.size) + } + } + + @Test + fun whenGetSubscriptionTierOptionsThenFeaturesAreMappedCorrectly() = runTest { + val testSubscriptionOfferList = listOf( + SubscriptionOffer( + planId = MONTHLY_PLAN_US, + offerId = null, + tier = "plus", + pricingPhases = listOf( + PricingPhase( + priceAmount = 1.toBigDecimal(), + priceCurrency = Currency.getInstance("USD"), + formattedPrice = "$1", + billingPeriod = "P1M", + ), + ), + entitlements = setOf( + Entitlement("plus", SubscriptionsConstants.NETP), + Entitlement("plus", SubscriptionsConstants.ITR), + ), + ), + SubscriptionOffer( + planId = YEARLY_PLAN_US, + offerId = null, + tier = "plus", + pricingPhases = listOf( + PricingPhase( + priceAmount = 10.toBigDecimal(), + priceCurrency = Currency.getInstance("USD"), + formattedPrice = "$10", + billingPeriod = "P1Y", + ), + ), + entitlements = setOf( + Entitlement("plus", SubscriptionsConstants.NETP), + Entitlement("plus", SubscriptionsConstants.ITR), + ), + ), + ) + whenever(subscriptionsManager.getSubscriptionOffer()).thenReturn(testSubscriptionOfferList) + privacyProFeature.allowPurchase().setRawStoredState(Toggle.State(enable = true)) + + viewModel.commands().test { + viewModel.processJsCallbackMessage("test", "getSubscriptionTierOptions", "id", JSONObject("{}")) + val result = awaitItem() + assertTrue(result is Command.SendResponseToJs) + val response = (result as Command.SendResponseToJs).data + + val params = tierJsonAdapter.fromJson(response.params.toString()) + val features = params?.products?.first()?.features + assertNotNull(features) + assertEquals(2, features?.size) + assertTrue(features?.all { it.name == "plus" } == true) + assertTrue(features?.any { it.product == SubscriptionsConstants.NETP } == true) + assertTrue(features?.any { it.product == SubscriptionsConstants.ITR } == true) + } + } + private fun givenSubscriptionStatus(subscriptionStatus: SubscriptionStatus) = runBlocking { whenever(subscriptionsManager.subscriptionStatus()).thenReturn(subscriptionStatus) whenever(subscriptionsManager.subscriptionStatus).thenReturn(flowOf(subscriptionStatus))