diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/model/Product.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/model/Product.kt index c27fc2aac064..3fb096e13351 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/model/Product.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/model/Product.kt @@ -83,7 +83,6 @@ data class Product( override val width: Float, override val height: Float, override val weight: Float, - val subscription: SubscriptionDetails?, val isSampleProduct: Boolean, val specialStockStatus: ProductStockStatus? = null, val isConfigurable: Boolean = false, @@ -153,7 +152,6 @@ data class Product( downloadExpiry == product.downloadExpiry && isDownloadable == product.isDownloadable && attributes == product.attributes && - subscription == product.subscription && specialStockStatus == product.specialStockStatus && minAllowedQuantity == product.minAllowedQuantity && maxAllowedQuantity == product.maxAllowedQuantity && @@ -169,8 +167,7 @@ data class Product( get() { return weight > 0 || length > 0 || width > 0 || height > 0 || - shippingClass.isNotEmpty() || - subscription?.oneTimeShipping == true + shippingClass.isNotEmpty() } val productType get() = ProductType.fromString(type) val variationEnabledAttributes @@ -334,7 +331,6 @@ data class Product( downloads = updatedProduct.downloads, downloadLimit = updatedProduct.downloadLimit, downloadExpiry = updatedProduct.downloadExpiry, - subscription = updatedProduct.subscription, specialStockStatus = specialStockStatus, minAllowedQuantity = updatedProduct.minAllowedQuantity, maxAllowedQuantity = updatedProduct.maxAllowedQuantity, @@ -482,20 +478,10 @@ fun Product.toDataModel(storedProductModel: WCProductModel? = null): WCProductMo it.groupOfQuantity = groupOfQuantity ?: -1 it.combineVariationQuantities = combineVariationQuantities ?: false it.password = password - // Subscription details are currently the only editable metadata fields from the app. - it.metadata = subscription?.toMetadataJson().toString() } } fun WCProductModel.toAppModel(): Product { - val productType = ProductType.fromString(type) - val subscription = if ( - productType == ProductType.SUBSCRIPTION || productType == ProductType.VARIABLE_SUBSCRIPTION - ) { - SubscriptionDetailsMapper.toAppModel(this.metadata) - } else { - null - } return Product( remoteId = this.remoteProductId, parentId = this.parentId, @@ -580,7 +566,6 @@ fun WCProductModel.toAppModel(): Product { upsellProductIds = this.getUpsellProductIdList(), variationIds = this.getVariationIdList(), isPurchasable = this.purchasable, - subscription = subscription, isSampleProduct = isSampleProduct, specialStockStatus = if (this.specialStockStatus.isNotNullOrEmpty()) { ProductStockStatus.fromString(this.specialStockStatus) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/model/ProductAggregate.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/model/ProductAggregate.kt new file mode 100644 index 000000000000..96721b7b9801 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/model/ProductAggregate.kt @@ -0,0 +1,35 @@ +package com.woocommerce.android.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Container class for product and any additional details that are stored as product metadata. + * + * For now, the additional details include subscription details only. + * + * @param product The product. + * @param subscription The subscription details. + */ +@Parcelize +data class ProductAggregate( + val product: Product, + val subscription: SubscriptionDetails? = null +) : Parcelable { + val remoteId: Long + get() = product.remoteId + + val hasShipping: Boolean + get() = product.hasShipping || subscription?.oneTimeShipping == true + + fun isSame(other: ProductAggregate): Boolean { + return product.isSameProduct(other.product) && subscription == other.subscription + } + + fun merge(other: ProductAggregate): ProductAggregate { + return copy( + product = product.mergeProduct(other.product), + subscription = other.subscription + ) + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/model/SubscriptionDetailsMapper.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/model/SubscriptionDetailsMapper.kt index fc976af7b4c6..44e7f39361dc 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/model/SubscriptionDetailsMapper.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/model/SubscriptionDetailsMapper.kt @@ -1,85 +1,81 @@ package com.woocommerce.android.model import com.google.gson.Gson -import com.google.gson.JsonArray -import com.google.gson.JsonElement -import com.google.gson.JsonObject +import com.google.gson.JsonParser import org.wordpress.android.fluxc.model.WCProductModel.SubscriptionMetadataKeys import org.wordpress.android.fluxc.model.metadata.WCMetaData +import org.wordpress.android.fluxc.model.metadata.get object SubscriptionDetailsMapper { private val gson by lazy { Gson() } - fun toAppModel(metadata: String): SubscriptionDetails? { - val jsonArray = gson.fromJson(metadata, JsonArray::class.java) ?: return null + fun toAppModel(metadata: List): SubscriptionDetails? { + if (metadata.none { it.key in SubscriptionMetadataKeys.ALL_KEYS }) { + return null + } - val subscriptionInformation = jsonArray - .mapNotNull { it as? JsonObject } - .filter { jsonObject -> jsonObject[WCMetaData.KEY].asString in SubscriptionMetadataKeys.ALL_KEYS } - .associate { jsonObject -> - jsonObject[WCMetaData.KEY].asString to jsonObject[WCMetaData.VALUE] - } + val price = metadata[SubscriptionMetadataKeys.SUBSCRIPTION_PRICE]?.valueAsString?.toBigDecimalOrNull() - return if (subscriptionInformation.isNotEmpty()) { - val price = subscriptionInformation[SubscriptionMetadataKeys.SUBSCRIPTION_PRICE]?.asString - ?.toBigDecimalOrNull() - - val periodString = subscriptionInformation[SubscriptionMetadataKeys.SUBSCRIPTION_PERIOD]?.asString ?: "" - val period = SubscriptionPeriod.fromValue(periodString) - - val periodIntervalString = subscriptionInformation[SubscriptionMetadataKeys.SUBSCRIPTION_PERIOD_INTERVAL] - ?.asString ?: "" - val periodInterval = periodIntervalString.toIntOrNull() ?: 0 - - val lengthInt = subscriptionInformation[SubscriptionMetadataKeys.SUBSCRIPTION_LENGTH]?.asString - ?.toIntOrNull() - val length = if (lengthInt != null && lengthInt > 0) lengthInt else null - - val signUpFee = subscriptionInformation[SubscriptionMetadataKeys.SUBSCRIPTION_SIGN_UP_FEE]?.asString - ?.toBigDecimalOrNull() - - val trialPeriodString = subscriptionInformation[SubscriptionMetadataKeys.SUBSCRIPTION_TRIAL_PERIOD] - ?.asString - val trialPeriod = trialPeriodString?.let { SubscriptionPeriod.fromValue(trialPeriodString) } - - val trialLengthInt = subscriptionInformation[SubscriptionMetadataKeys.SUBSCRIPTION_TRIAL_LENGTH] - ?.asString?.toIntOrNull() - val trialLength = if (trialLengthInt != null && trialLengthInt > 0) trialLengthInt else null - - val oneTimeShipping = subscriptionInformation[SubscriptionMetadataKeys.SUBSCRIPTION_ONE_TIME_SHIPPING] - ?.asString == "yes" - - val paymentsSyncDate = subscriptionInformation[SubscriptionMetadataKeys.SUBSCRIPTION_PAYMENT_SYNC_DATE] - ?.extractPaymentsSyncDate() - - SubscriptionDetails( - price = price, - period = period, - periodInterval = periodInterval, - length = length, - signUpFee = signUpFee, - trialPeriod = trialPeriod, - trialLength = trialLength, - oneTimeShipping = oneTimeShipping, - paymentsSyncDate = paymentsSyncDate - ) - } else { - null - } + val periodString = metadata[SubscriptionMetadataKeys.SUBSCRIPTION_PERIOD]?.valueAsString ?: "" + val period = SubscriptionPeriod.fromValue(periodString) + + val periodIntervalString = metadata[SubscriptionMetadataKeys.SUBSCRIPTION_PERIOD_INTERVAL] + ?.valueAsString ?: "" + val periodInterval = periodIntervalString.toIntOrNull() ?: 0 + + val lengthInt = metadata[SubscriptionMetadataKeys.SUBSCRIPTION_LENGTH]?.valueAsString + ?.toIntOrNull() + val length = if (lengthInt != null && lengthInt > 0) lengthInt else null + + val signUpFee = metadata[SubscriptionMetadataKeys.SUBSCRIPTION_SIGN_UP_FEE]?.valueAsString + ?.toBigDecimalOrNull() + + val trialPeriodString = metadata[SubscriptionMetadataKeys.SUBSCRIPTION_TRIAL_PERIOD] + ?.valueAsString + val trialPeriod = trialPeriodString?.let { SubscriptionPeriod.fromValue(trialPeriodString) } + + val trialLengthInt = metadata[SubscriptionMetadataKeys.SUBSCRIPTION_TRIAL_LENGTH] + ?.valueAsString?.toIntOrNull() + val trialLength = if (trialLengthInt != null && trialLengthInt > 0) trialLengthInt else null + + val oneTimeShipping = metadata[SubscriptionMetadataKeys.SUBSCRIPTION_ONE_TIME_SHIPPING] + ?.valueAsString == "yes" + + val paymentsSyncDate = metadata[SubscriptionMetadataKeys.SUBSCRIPTION_PAYMENT_SYNC_DATE] + ?.extractPaymentsSyncDate() + + return SubscriptionDetails( + price = price, + period = period, + periodInterval = periodInterval, + length = length, + signUpFee = signUpFee, + trialPeriod = trialPeriod, + trialLength = trialLength, + oneTimeShipping = oneTimeShipping, + paymentsSyncDate = paymentsSyncDate + ) + } + + fun toAppModel(metadata: String): SubscriptionDetails? { + val metadataList = gson.fromJson(metadata, Array::class.java)?.toList() ?: return null + + return toAppModel(metadataList) } - private fun JsonElement.extractPaymentsSyncDate(): SubscriptionPaymentSyncDate? { - return when { - isJsonObject -> asJsonObject.let { - val day = it["day"].asInt - val month = it["month"].asInt + private fun WCMetaData.extractPaymentsSyncDate(): SubscriptionPaymentSyncDate? { + return when (isJson) { + true -> value.stringValue.let { + val jsonObject = JsonParser.parseString(it).asJsonObject + val day = jsonObject["day"].asInt + val month = jsonObject["month"].asInt if (day == 0) { SubscriptionPaymentSyncDate.None } else { - SubscriptionPaymentSyncDate.MonthDay(day, month) + SubscriptionPaymentSyncDate.MonthDay(month = month, day = day) } } - else -> asString?.toIntOrNull()?.let { day -> + false -> valueAsString.toIntOrNull()?.let { day -> if (day == 0) { SubscriptionPaymentSyncDate.None } else { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductHelper.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductHelper.kt index e11a2a84264c..592ad2f4b6f7 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductHelper.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductHelper.kt @@ -1,14 +1,13 @@ package com.woocommerce.android.ui.products import com.woocommerce.android.model.Product +import com.woocommerce.android.model.ProductAggregate import com.woocommerce.android.model.SubscriptionDetails import com.woocommerce.android.model.SubscriptionPeriod import com.woocommerce.android.ui.products.ProductBackorderStatus.NotAvailable import com.woocommerce.android.ui.products.ProductStatus.DRAFT import com.woocommerce.android.ui.products.ProductStatus.PUBLISH import com.woocommerce.android.ui.products.ProductStockStatus.InStock -import com.woocommerce.android.ui.products.ProductType.SUBSCRIPTION -import com.woocommerce.android.ui.products.ProductType.VARIABLE_SUBSCRIPTION import com.woocommerce.android.ui.products.settings.ProductCatalogVisibility.VISIBLE import java.math.BigDecimal import java.util.Date @@ -93,12 +92,6 @@ object ProductHelper { variationIds = listOf(), downloads = listOf(), isPurchasable = false, - subscription = - if (productType == SUBSCRIPTION || productType == VARIABLE_SUBSCRIPTION) { - getDefaultSubscriptionDetails() - } else { - null - }, isSampleProduct = false, parentId = 0, minAllowedQuantity = null, @@ -111,6 +104,19 @@ object ProductHelper { ) } + fun getDefaultProductAggregate(productType: ProductType, isVirtual: Boolean): ProductAggregate { + return ProductAggregate( + product = getDefaultNewProduct(productType, isVirtual), + subscription = if (productType == ProductType.SUBSCRIPTION || + productType == ProductType.VARIABLE_SUBSCRIPTION + ) { + getDefaultSubscriptionDetails() + } else { + null + } + ) + } + fun getDefaultSubscriptionDetails(): SubscriptionDetails = SubscriptionDetails( price = null, diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailBottomSheetBuilder.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailBottomSheetBuilder.kt index a7b3785f3b62..ee9c9e4d1008 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailBottomSheetBuilder.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailBottomSheetBuilder.kt @@ -4,6 +4,7 @@ import androidx.annotation.StringRes import com.woocommerce.android.R.string import com.woocommerce.android.analytics.AnalyticsEvent import com.woocommerce.android.model.Product +import com.woocommerce.android.model.ProductAggregate import com.woocommerce.android.model.SubscriptionProductVariation import com.woocommerce.android.ui.customfields.CustomFieldsRepository import com.woocommerce.android.ui.products.ProductNavigationTarget @@ -49,84 +50,84 @@ class ProductDetailBottomSheetBuilder( ) @Suppress("LongMethod") - suspend fun buildBottomSheetList(product: Product): List { - return when (product.productType) { + suspend fun buildBottomSheetList(productAggregate: ProductAggregate): List { + return when (productAggregate.product.productType) { SIMPLE, SUBSCRIPTION -> { listOfNotNull( - product.getShipping(), - product.getCategories(), - product.getTags(), - product.getShortDescription(), - product.getLinkedProducts(), - product.getDownloadableFiles(), - product.getCustomFields() + productAggregate.getShipping(), + productAggregate.product.getCategories(), + productAggregate.product.getTags(), + productAggregate.product.getShortDescription(), + productAggregate.product.getLinkedProducts(), + productAggregate.product.getDownloadableFiles(), + productAggregate.product.getCustomFields() ) } EXTERNAL -> { listOfNotNull( - product.getCategories(), - product.getTags(), - product.getShortDescription(), - product.getLinkedProducts(), - product.getCustomFields() + productAggregate.product.getCategories(), + productAggregate.product.getTags(), + productAggregate.product.getShortDescription(), + productAggregate.product.getLinkedProducts(), + productAggregate.product.getCustomFields() ) } GROUPED -> { listOfNotNull( - product.getCategories(), - product.getTags(), - product.getShortDescription(), - product.getLinkedProducts(), - product.getCustomFields() + productAggregate.product.getCategories(), + productAggregate.product.getTags(), + productAggregate.product.getShortDescription(), + productAggregate.product.getLinkedProducts(), + productAggregate.product.getCustomFields() ) } VARIABLE, VARIABLE_SUBSCRIPTION -> { listOfNotNull( - product.getShipping(), - product.getCategories(), - product.getTags(), - product.getShortDescription(), - product.getLinkedProducts(), - product.getCustomFields() + productAggregate.getShipping(), + productAggregate.product.getCategories(), + productAggregate.product.getTags(), + productAggregate.product.getShortDescription(), + productAggregate.product.getLinkedProducts(), + productAggregate.product.getCustomFields() ) } else -> { listOfNotNull( - product.getCategories(), - product.getTags(), - product.getShortDescription(), - product.getCustomFields() + productAggregate.product.getCategories(), + productAggregate.product.getTags(), + productAggregate.product.getShortDescription(), + productAggregate.product.getCustomFields() ) } } } - private fun Product.getShipping(): ProductDetailBottomSheetUiItem? { - return if (!isVirtual && !hasShipping) { + private fun ProductAggregate.getShipping(): ProductDetailBottomSheetUiItem? { + return if (!product.isVirtual && !hasShipping) { ProductDetailBottomSheetUiItem( ProductDetailBottomSheetType.PRODUCT_SHIPPING, ViewProductShipping( ShippingData( - weight = weight, - length = length, - width = width, - height = height, - shippingClassSlug = shippingClass, - shippingClassId = shippingClassId, - subscriptionShippingData = if (productType == SUBSCRIPTION || - this.productType == VARIABLE_SUBSCRIPTION + weight = product.weight, + length = product.length, + width = product.width, + height = product.height, + shippingClassSlug = product.shippingClass, + shippingClassId = product.shippingClassId, + subscriptionShippingData = if (product.productType == SUBSCRIPTION || + product.productType == VARIABLE_SUBSCRIPTION ) { ShippingData.SubscriptionShippingData( oneTimeShipping = subscription?.oneTimeShipping ?: false, - canEnableOneTimeShipping = if (productType == SUBSCRIPTION) { + canEnableOneTimeShipping = if (product.productType == SUBSCRIPTION) { subscription?.supportsOneTimeShipping ?: false } else { // For variable subscription products, we need to check against the variations - variationRepository.getProductVariationList(remoteId).all { + variationRepository.getProductVariationList(product.remoteId).all { (it as? SubscriptionProductVariation)?.subscriptionDetails ?.supportsOneTimeShipping ?: false } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailCardBuilder.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailCardBuilder.kt index 171a2433cb3a..13ed2ca0cf28 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailCardBuilder.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailCardBuilder.kt @@ -19,6 +19,8 @@ import com.woocommerce.android.extensions.filterNotEmpty import com.woocommerce.android.extensions.isEligibleForAI import com.woocommerce.android.extensions.isSet import com.woocommerce.android.model.Product +import com.woocommerce.android.model.ProductAggregate +import com.woocommerce.android.model.SubscriptionDetails import com.woocommerce.android.model.SubscriptionProductVariation import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.ui.blaze.BlazeUrlsHelper.BlazeFlowSource @@ -110,38 +112,41 @@ class ProductDetailCardBuilder( private val onTooltipDismiss = { appPrefsWrapper.isAIProductDescriptionTooltipDismissed = true } - suspend fun buildPropertyCards(product: Product, originalSku: String): List { + suspend fun buildPropertyCards( + productAggregate: ProductAggregate, + originalSku: String + ): List { this.originalSku = originalSku val cards = mutableListOf() - cards.addIfNotEmpty(getPrimaryCard(product)) - - cards.addIfNotEmpty(getBlazeCard(product)) - - when (product.productType) { - SIMPLE -> cards.addIfNotEmpty(getSimpleProductCard(product)) - VARIABLE -> cards.addIfNotEmpty(getVariableProductCard(product)) - GROUPED -> cards.addIfNotEmpty(getGroupedProductCard(product)) - EXTERNAL -> cards.addIfNotEmpty(getExternalProductCard(product)) - SUBSCRIPTION -> cards.addIfNotEmpty(getSubscriptionProductCard(product)) - VARIABLE_SUBSCRIPTION -> cards.addIfNotEmpty(getVariableSubscriptionProductCard(product)) - BUNDLE -> cards.addIfNotEmpty(getBundleProductsCard(product)) - COMPOSITE -> cards.addIfNotEmpty(getCompositeProductsCard(product)) - else -> cards.addIfNotEmpty(getOtherProductCard(product)) + cards.addIfNotEmpty(getPrimaryCard(productAggregate)) + + cards.addIfNotEmpty(getBlazeCard(productAggregate)) + + when (productAggregate.product.productType) { + SIMPLE -> cards.addIfNotEmpty(getSimpleProductCard(productAggregate)) + VARIABLE -> cards.addIfNotEmpty(getVariableProductCard(productAggregate)) + GROUPED -> cards.addIfNotEmpty(getGroupedProductCard(productAggregate)) + EXTERNAL -> cards.addIfNotEmpty(getExternalProductCard(productAggregate)) + SUBSCRIPTION -> cards.addIfNotEmpty(getSubscriptionProductCard(productAggregate)) + VARIABLE_SUBSCRIPTION -> cards.addIfNotEmpty(getVariableSubscriptionProductCard(productAggregate)) + BUNDLE -> cards.addIfNotEmpty(getBundleProductsCard(productAggregate)) + COMPOSITE -> cards.addIfNotEmpty(getCompositeProductsCard(productAggregate)) + else -> cards.addIfNotEmpty(getOtherProductCard(productAggregate)) } return cards } - private fun getPrimaryCard(product: Product): ProductPropertyCard { - val showTooltip = product.description.isEmpty() && + private fun getPrimaryCard(productAggregate: ProductAggregate): ProductPropertyCard { + val showTooltip = productAggregate.product.description.isEmpty() && !appPrefsWrapper.isAIProductDescriptionTooltipDismissed && appPrefsWrapper.getAIDescriptionTooltipShownNumber() <= MAXIMUM_TIMES_TO_SHOW_TOOLTIP return ProductPropertyCard( type = PRIMARY, properties = ( - listOf(product.title()) + - product.description( + listOf(productAggregate.product.title()) + + productAggregate.product.description( showAIButton = selectedSite.get().isEligibleForAI, showTooltip = showTooltip, onWriteWithAIClicked = viewModel::onWriteWithAIClicked, @@ -151,15 +156,15 @@ class ProductDetailCardBuilder( ) } - private suspend fun getBlazeCard(product: Product): ProductPropertyCard? { - val isProductPublic = product.status == ProductStatus.PUBLISH && + private suspend fun getBlazeCard(productAggregate: ProductAggregate): ProductPropertyCard? { + val isProductPublic = productAggregate.product.status == ProductStatus.PUBLISH && viewModel.getProductVisibility() == ProductVisibility.PUBLIC @Suppress("ComplexCondition") if (!isBlazeEnabled() || !isProductPublic || viewModel.isProductUnderCreation || - isProductCurrentlyPromoted(product.remoteId.toString()) + isProductCurrentlyPromoted(productAggregate.product.remoteId.toString()) ) { return null } @@ -186,169 +191,169 @@ class ProductDetailCardBuilder( ) } - private suspend fun getSimpleProductCard(product: Product): ProductPropertyCard { + private suspend fun getSimpleProductCard(productAggregate: ProductAggregate): ProductPropertyCard { return ProductPropertyCard( type = SECONDARY, properties = listOf( - product.price(), - if (viewModel.isProductUnderCreation) null else product.productReviews(), - product.inventory(SIMPLE), - product.addons(), - product.quantityRules(), - product.shipping(), - product.categories(), - product.tags(), - product.shortDescription(), - product.linkedProducts(), - product.downloads(), - product.customFields(), - product.productType() + productAggregate.price(), + if (viewModel.isProductUnderCreation) null else productAggregate.product.productReviews(), + productAggregate.product.inventory(SIMPLE), + productAggregate.product.addons(), + productAggregate.product.quantityRules(), + productAggregate.shipping(), + productAggregate.product.categories(), + productAggregate.product.tags(), + productAggregate.product.shortDescription(), + productAggregate.product.linkedProducts(), + productAggregate.product.downloads(), + productAggregate.product.customFields(), + productAggregate.product.productType() ).filterNotEmpty() ) } - private suspend fun getGroupedProductCard(product: Product): ProductPropertyCard { + private suspend fun getGroupedProductCard(productAggregate: ProductAggregate): ProductPropertyCard { return ProductPropertyCard( type = SECONDARY, properties = listOf( - product.groupedProducts(), - if (viewModel.isProductUnderCreation) null else product.productReviews(), - product.inventory(GROUPED), - product.addons(), - product.quantityRules(), - product.categories(), - product.tags(), - product.shortDescription(), - product.linkedProducts(), - product.customFields(), - product.productType() + productAggregate.product.groupedProducts(), + if (viewModel.isProductUnderCreation) null else productAggregate.product.productReviews(), + productAggregate.product.inventory(GROUPED), + productAggregate.product.addons(), + productAggregate.product.quantityRules(), + productAggregate.product.categories(), + productAggregate.product.tags(), + productAggregate.product.shortDescription(), + productAggregate.product.linkedProducts(), + productAggregate.product.customFields(), + productAggregate.product.productType() ).filterNotEmpty() ) } - private suspend fun getExternalProductCard(product: Product): ProductPropertyCard { + private suspend fun getExternalProductCard(productAggregate: ProductAggregate): ProductPropertyCard { return ProductPropertyCard( type = SECONDARY, properties = listOf( - product.price(), - if (viewModel.isProductUnderCreation) null else product.productReviews(), - product.externalLink(), - product.inventory(EXTERNAL), - product.addons(), - product.quantityRules(), - product.categories(), - product.tags(), - product.shortDescription(), - product.linkedProducts(), - product.customFields(), - product.productType() + productAggregate.price(), + if (viewModel.isProductUnderCreation) null else productAggregate.product.productReviews(), + productAggregate.product.externalLink(), + productAggregate.product.inventory(EXTERNAL), + productAggregate.product.addons(), + productAggregate.product.quantityRules(), + productAggregate.product.categories(), + productAggregate.product.tags(), + productAggregate.product.shortDescription(), + productAggregate.product.linkedProducts(), + productAggregate.product.customFields(), + productAggregate.product.productType() ).filterNotEmpty() ) } - private suspend fun getVariableProductCard(product: Product): ProductPropertyCard { + private suspend fun getVariableProductCard(productAggregate: ProductAggregate): ProductPropertyCard { return ProductPropertyCard( type = SECONDARY, properties = listOf( - product.warning(), - product.variations(), - product.variationAttributes(), - if (viewModel.isProductUnderCreation) null else product.productReviews(), - product.inventory(VARIABLE), - product.addons(), - product.quantityRules(), - product.shipping(), - product.categories(), - product.tags(), - product.shortDescription(), - product.linkedProducts(), - product.customFields(), - product.productType() + productAggregate.product.warning(), + productAggregate.product.variations(), + productAggregate.product.variationAttributes(), + if (viewModel.isProductUnderCreation) null else productAggregate.product.productReviews(), + productAggregate.product.inventory(VARIABLE), + productAggregate.product.addons(), + productAggregate.product.quantityRules(), + productAggregate.shipping(), + productAggregate.product.categories(), + productAggregate.product.tags(), + productAggregate.product.shortDescription(), + productAggregate.product.linkedProducts(), + productAggregate.product.customFields(), + productAggregate.product.productType() ).filterNotEmpty() ) } - private suspend fun getSubscriptionProductCard(product: Product): ProductPropertyCard { + private suspend fun getSubscriptionProductCard(productAggregate: ProductAggregate): ProductPropertyCard { return ProductPropertyCard( type = SECONDARY, properties = listOf( - product.price(), - product.subscriptionExpirationDate(), - product.subscriptionTrial(), - if (viewModel.isProductUnderCreation) null else product.productReviews(), - product.inventory(SIMPLE), - product.addons(), - product.quantityRules(), - product.shipping(), - product.categories(), - product.tags(), - product.shortDescription(), - product.linkedProducts(), - product.downloads(), - product.customFields(), - product.productType() + productAggregate.price(), + productAggregate.subscription?.subscriptionExpirationDate(), + productAggregate.subscription?.subscriptionTrial(), + if (viewModel.isProductUnderCreation) null else productAggregate.product.productReviews(), + productAggregate.product.inventory(SIMPLE), + productAggregate.product.addons(), + productAggregate.product.quantityRules(), + productAggregate.shipping(), + productAggregate.product.categories(), + productAggregate.product.tags(), + productAggregate.product.shortDescription(), + productAggregate.product.linkedProducts(), + productAggregate.product.downloads(), + productAggregate.product.customFields(), + productAggregate.product.productType() ).filterNotEmpty() ) } - private suspend fun getVariableSubscriptionProductCard(product: Product): ProductPropertyCard { + private suspend fun getVariableSubscriptionProductCard(productAggregate: ProductAggregate): ProductPropertyCard { return ProductPropertyCard( type = SECONDARY, properties = listOf( - product.warning(), - product.variations(), - product.variationAttributes(), - if (viewModel.isProductUnderCreation) null else product.productReviews(), - product.inventory(VARIABLE), - product.addons(), - product.quantityRules(), - product.shipping(), - product.categories(), - product.tags(), - product.shortDescription(), - product.linkedProducts(), - product.customFields(), - product.productType() + productAggregate.product.warning(), + productAggregate.product.variations(), + productAggregate.product.variationAttributes(), + if (viewModel.isProductUnderCreation) null else productAggregate.product.productReviews(), + productAggregate.product.inventory(VARIABLE), + productAggregate.product.addons(), + productAggregate.product.quantityRules(), + productAggregate.shipping(), + productAggregate.product.categories(), + productAggregate.product.tags(), + productAggregate.product.shortDescription(), + productAggregate.product.linkedProducts(), + productAggregate.product.customFields(), + productAggregate.product.productType() ).filterNotEmpty() ) } - private suspend fun getBundleProductsCard(product: Product): ProductPropertyCard { + private suspend fun getBundleProductsCard(productAggregate: ProductAggregate): ProductPropertyCard { return ProductPropertyCard( type = SECONDARY, properties = listOf( - product.bundleProducts(), - product.price(), - if (viewModel.isProductUnderCreation) null else product.productReviews(), - product.inventory(SIMPLE), - product.addons(), - product.quantityRules(), - product.categories(), - product.tags(), - product.shortDescription(), - product.linkedProducts(), - product.customFields(), - product.productType() + productAggregate.product.bundleProducts(), + productAggregate.price(), + if (viewModel.isProductUnderCreation) null else productAggregate.product.productReviews(), + productAggregate.product.inventory(SIMPLE), + productAggregate.product.addons(), + productAggregate.product.quantityRules(), + productAggregate.product.categories(), + productAggregate.product.tags(), + productAggregate.product.shortDescription(), + productAggregate.product.linkedProducts(), + productAggregate.product.customFields(), + productAggregate.product.productType() ).filterNotEmpty() ) } - private suspend fun getCompositeProductsCard(product: Product): ProductPropertyCard { + private suspend fun getCompositeProductsCard(productAggregate: ProductAggregate): ProductPropertyCard { return ProductPropertyCard( type = SECONDARY, properties = listOf( - product.componentProducts(), - product.price(), - if (viewModel.isProductUnderCreation) null else product.productReviews(), - product.inventory(SIMPLE), - product.addons(), - product.quantityRules(), - product.categories(), - product.tags(), - product.shortDescription(), - product.linkedProducts(), - product.customFields(), - product.productType() + productAggregate.product.componentProducts(), + productAggregate.price(), + if (viewModel.isProductUnderCreation) null else productAggregate.product.productReviews(), + productAggregate.product.inventory(SIMPLE), + productAggregate.product.addons(), + productAggregate.product.quantityRules(), + productAggregate.product.categories(), + productAggregate.product.tags(), + productAggregate.product.shortDescription(), + productAggregate.product.linkedProducts(), + productAggregate.product.customFields(), + productAggregate.product.productType() ).filterNotEmpty() ) } @@ -357,19 +362,19 @@ class ProductDetailCardBuilder( * Used for product types the app doesn't support yet (ex: subscriptions), uses a subset * of properties since we can't be sure pricing, shipping, etc., are applicable */ - private suspend fun getOtherProductCard(product: Product): ProductPropertyCard { + private suspend fun getOtherProductCard(productAggregate: ProductAggregate): ProductPropertyCard { return ProductPropertyCard( type = SECONDARY, properties = listOf( - if (viewModel.isProductUnderCreation) null else product.productReviews(), - product.addons(), - product.quantityRules(), - product.categories(), - product.tags(), - product.shortDescription(), - product.linkedProducts(), - product.customFields(), - product.productType() + if (viewModel.isProductUnderCreation) null else productAggregate.product.productReviews(), + productAggregate.product.addons(), + productAggregate.product.quantityRules(), + productAggregate.product.categories(), + productAggregate.product.tags(), + productAggregate.product.shortDescription(), + productAggregate.product.linkedProducts(), + productAggregate.product.customFields(), + productAggregate.product.productType() ).filterNotEmpty() ) } @@ -463,16 +468,17 @@ class ProductDetailCardBuilder( } } - private fun Product.shipping(): ProductProperty? { - return if (!this.isVirtual && hasShipping) { - val weightWithUnits = this.getWeightWithUnits(parameters.weightUnit) - val sizeWithUnits = this.getSizeWithUnits(parameters.dimensionUnit) + @Suppress("LongMethod") + private fun ProductAggregate.shipping(): ProductProperty? { + return if (!this.product.isVirtual && hasShipping) { + val weightWithUnits = product.getWeightWithUnits(parameters.weightUnit) + val sizeWithUnits = product.getSizeWithUnits(parameters.dimensionUnit) val shippingGroup = mapOf( Pair(resources.getString(string.product_weight), weightWithUnits), Pair(resources.getString(string.product_dimensions), sizeWithUnits), Pair( resources.getString(string.product_shipping_class), - viewModel.getShippingClassByRemoteShippingClassId(this.shippingClassId) + viewModel.getShippingClassByRemoteShippingClassId(this.product.shippingClassId) ), Pair( resources.getString(string.subscription_one_time_shipping), @@ -492,22 +498,22 @@ class ProductDetailCardBuilder( viewModel.onEditProductCardClicked( ViewProductShipping( ShippingData( - weight = weight, - length = length, - width = width, - height = height, - shippingClassSlug = shippingClass, - shippingClassId = shippingClassId, - subscriptionShippingData = if (productType == SUBSCRIPTION || - this.productType == VARIABLE_SUBSCRIPTION + weight = product.weight, + length = product.length, + width = product.width, + height = product.height, + shippingClassSlug = product.shippingClass, + shippingClassId = product.shippingClassId, + subscriptionShippingData = if (product.productType == SUBSCRIPTION || + product.productType == VARIABLE_SUBSCRIPTION ) { ShippingData.SubscriptionShippingData( oneTimeShipping = subscription?.oneTimeShipping ?: false, - canEnableOneTimeShipping = if (productType == SUBSCRIPTION) { + canEnableOneTimeShipping = if (product.productType == SUBSCRIPTION) { subscription?.supportsOneTimeShipping ?: false } else { // For variable subscription products, we need to check against the variations - variationRepository.getProductVariationList(remoteId).all { + variationRepository.getProductVariationList(product.remoteId).all { (it as? SubscriptionProductVariation)?.subscriptionDetails ?.supportsOneTimeShipping ?: false } @@ -552,16 +558,16 @@ class ProductDetailCardBuilder( } } - private fun Product.price(): ProductProperty { + private fun ProductAggregate.price(): ProductProperty { val pricingData = PricingData( - taxClass = taxClass, - taxStatus = taxStatus, - isSaleScheduled = isSaleScheduled, - saleStartDate = saleStartDateGmt, - saleEndDate = saleEndDateGmt, - regularPrice = regularPrice, - salePrice = salePrice, - isSubscription = this.productType == SUBSCRIPTION, + taxClass = product.taxClass, + taxStatus = product.taxStatus, + isSaleScheduled = product.isSaleScheduled, + saleStartDate = product.saleStartDateGmt, + saleEndDate = product.saleEndDateGmt, + regularPrice = product.regularPrice, + salePrice = product.salePrice, + isSubscription = product.productType == SUBSCRIPTION, subscriptionPeriod = subscription?.period, subscriptionInterval = subscription?.periodInterval, subscriptionSignUpFee = subscription?.signUpFee, @@ -578,7 +584,7 @@ class ProductDetailCardBuilder( string.product_price, pricingGroup, drawable.ic_gridicons_money, - showTitle = this.regularPrice.isSet() + showTitle = product.regularPrice.isSet() ) { viewModel.onEditProductCardClicked( ViewProductPricing(pricingData), @@ -894,43 +900,37 @@ class ProductDetailCardBuilder( ) } - private fun Product.subscriptionExpirationDate(): ProductProperty? = - this.subscription?.let { subscription -> - PropertyGroup( - title = string.product_subscription_expiration_title, - icon = drawable.ic_calendar_expiration, - properties = mapOf( - resources.getString(string.subscription_expire) to subscription.expirationDisplayValue( - resources - ) - ), - showTitle = true, - onClick = { - viewModel.onEditProductCardClicked( - ViewProductSubscriptionExpiration(subscription), - AnalyticsEvent.PRODUCT_DETAILS_VIEW_SUBSCRIPTION_EXPIRATION_TAPPED - ) - } + private fun SubscriptionDetails.subscriptionExpirationDate(): ProductProperty = PropertyGroup( + title = string.product_subscription_expiration_title, + icon = drawable.ic_calendar_expiration, + properties = mapOf( + resources.getString(string.subscription_expire) to expirationDisplayValue( + resources + ) + ), + showTitle = true, + onClick = { + viewModel.onEditProductCardClicked( + ViewProductSubscriptionExpiration(this), + AnalyticsEvent.PRODUCT_DETAILS_VIEW_SUBSCRIPTION_EXPIRATION_TAPPED ) } - - private fun Product.subscriptionTrial(): ProductProperty? = - this.subscription?.let { subscription -> - PropertyGroup( - title = string.product_subscription_free_trial_title, - icon = drawable.ic_hourglass_empty, - properties = mapOf( - resources.getString(string.subscription_free_trial) to subscription.trialDisplayValue(resources) - ), - showTitle = true, - onClick = { - viewModel.onEditProductCardClicked( - ViewProductSubscriptionFreeTrial(subscription), - AnalyticsEvent.PRODUCT_DETAILS_VIEW_SUBSCRIPTION_FREE_TRIAL_TAPPED - ) - } + ) + + private fun SubscriptionDetails.subscriptionTrial(): ProductProperty = PropertyGroup( + title = string.product_subscription_free_trial_title, + icon = drawable.ic_hourglass_empty, + properties = mapOf( + resources.getString(string.subscription_free_trial) to trialDisplayValue(resources) + ), + showTitle = true, + onClick = { + viewModel.onEditProductCardClicked( + ViewProductSubscriptionFreeTrial(this), + AnalyticsEvent.PRODUCT_DETAILS_VIEW_SUBSCRIPTION_FREE_TRIAL_TAPPED ) } + ) private fun Product.warning(): ProductProperty? { val variations = variationRepository.getProductVariationList(this.remoteId) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailRepository.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailRepository.kt index 8605bebf736f..d0b38a1dee96 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailRepository.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailRepository.kt @@ -5,11 +5,13 @@ import com.woocommerce.android.analytics.AnalyticsEvent.PRODUCT_DETAIL_UPDATE_ER import com.woocommerce.android.analytics.AnalyticsEvent.PRODUCT_DETAIL_UPDATE_SUCCESS import com.woocommerce.android.analytics.AnalyticsTracker import com.woocommerce.android.model.Product +import com.woocommerce.android.model.ProductAggregate import com.woocommerce.android.model.ProductAttribute import com.woocommerce.android.model.ProductAttributeTerm import com.woocommerce.android.model.ProductGlobalAttribute import com.woocommerce.android.model.RequestResult import com.woocommerce.android.model.ShippingClass +import com.woocommerce.android.model.SubscriptionDetailsMapper import com.woocommerce.android.model.TaxClass import com.woocommerce.android.model.toAppModel import com.woocommerce.android.model.toDataModel @@ -90,6 +92,17 @@ class ProductDetailRepository @Inject constructor( return getProduct(remoteProductId) } + suspend fun fetchAndGetProductAggregate(remoteProductId: Long): ProductAggregate? { + val payload = WCProductStore.FetchSingleProductPayload(selectedSite.get(), remoteProductId) + val result = productStore.fetchSingleProduct(payload) + + if (result.isError) { + lastFetchProductErrorType = result.error.type + } + + return getProductAggregate(remoteProductId) + } + suspend fun fetchProductPassword(remoteProductId: Long): String? { this.remoteProductId = remoteProductId val result = continuationFetchProductPassword.callAndWaitUntilTimeout(AppConstants.REQUEST_TIMEOUT) { @@ -276,6 +289,12 @@ class ProductDetailRepository @Inject constructor( getCachedWCProductModel(remoteProductId)?.toAppModel() } + suspend fun getProductAggregate(remoteProductId: Long): ProductAggregate? { + val product = getProduct(remoteProductId) ?: return null + val subscriptionDetails = SubscriptionDetailsMapper.toAppModel(getProductMetadata(remoteProductId)) + return ProductAggregate(product, subscriptionDetails) + } + fun isSkuAvailableLocally(sku: String) = !productStore.geProductExistsBySku(selectedSite.get(), sku) fun getCachedVariationCount(remoteProductId: Long) = diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModel.kt index 6fa960ddbc30..c00153a4c9f3 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModel.kt @@ -33,12 +33,14 @@ import com.woocommerce.android.media.MediaFilesRepository import com.woocommerce.android.media.MediaFilesRepository.UploadResult import com.woocommerce.android.model.Component import com.woocommerce.android.model.Product +import com.woocommerce.android.model.ProductAggregate import com.woocommerce.android.model.ProductAttribute import com.woocommerce.android.model.ProductCategory import com.woocommerce.android.model.ProductFile import com.woocommerce.android.model.ProductGlobalAttribute import com.woocommerce.android.model.ProductTag import com.woocommerce.android.model.RequestResult +import com.woocommerce.android.model.SubscriptionDetails import com.woocommerce.android.model.SubscriptionPeriod import com.woocommerce.android.model.addTags import com.woocommerce.android.model.sortCategories @@ -187,8 +189,8 @@ class ProductDetailViewModel @Inject constructor( savedState = savedState, initialValue = ProductDetailViewState(areImagesAvailable = !selectedSite.get().isPrivate) ) { old, new -> - if (old?.productDraft != new.productDraft) { - new.productDraft?.let { + if (old?.productAggregateDraft != new.productAggregateDraft) { + new.productAggregateDraft?.let { updateCards(it) draftChanges.value = it } @@ -200,9 +202,9 @@ class ProductDetailViewModel @Inject constructor( * The goal of this is to allow composition of reactive streams using the product draft changes, * we need a separate stream because [LiveDataDelegate] supports a single observer. */ - private val draftChanges = MutableStateFlow(null) + private val draftChanges = MutableStateFlow(null) - private val storedProduct = MutableStateFlow(null) + private val storedProductAggregate = MutableStateFlow(null) /** * Saving more data than necessary into the SavedState has associated risks which were not known at the time this @@ -298,9 +300,9 @@ class ProductDetailViewModel @Inject constructor( ProductDetailBottomSheetBuilder(resources, variationRepository, customFieldsRepository) } - private val _hasChanges = storedProduct - .combine(draftChanges) { storedProduct, productDraft -> - storedProduct?.let { product -> productDraft?.isSameProduct(product) == false } ?: false + private val _hasChanges = storedProductAggregate + .combine(draftChanges) { storedProductAggregate, productAggregateDraft -> + storedProductAggregate?.let { product -> productAggregateDraft?.isSame(product) == false } ?: false }.stateIn(viewModelScope, SharingStarted.Eagerly, false) val hasChanges = _hasChanges.asLiveData() @@ -318,8 +320,8 @@ class ProductDetailViewModel @Inject constructor( val menuButtonsState = draftChanges .filterNotNull() - .combine(_hasChanges) { productDraft, hasChanges -> - Pair(productDraft, hasChanges) + .combine(_hasChanges) { draft, hasChanges -> + Pair(draft.product, hasChanges) }.map { (productDraft, hasChanges) -> val canBeSavedAsDraft = this.isAddNewProductFlow && !isProductStoredAtSite && @@ -442,29 +444,31 @@ class ProductDetailViewModel @Inject constructor( private fun startAddNewProduct() { val defaultProduct = createDefaultProductForAddFlow() viewState = viewState.copy( - productDraft = defaultProduct + productAggregateDraft = defaultProduct ) updateProductState(defaultProduct) trackProductDetailLoaded() } - private fun createDefaultProductForAddFlow(): Product { + private fun createDefaultProductForAddFlow(): ProductAggregate { val preferredSavedType = appPrefsWrapper.getSelectedProductType() val defaultProductType = ProductType.fromString(preferredSavedType) val isProductVirtual = appPrefsWrapper.isSelectedProductVirtual() - return ProductHelper.getDefaultNewProduct(defaultProductType, isProductVirtual) + return ProductHelper.getDefaultProductAggregate(defaultProductType, isProductVirtual) } private fun initializeStoredProductAfterRestoration() { launch { if (isAddNewProductFlow && !isProductStoredAtSite) { - storedProduct.value = createDefaultProductForAddFlow() + storedProductAggregate.value = createDefaultProductForAddFlow() } else { when (val mode = navArgs.mode) { is ProductDetailFragment.Mode.ShowProduct -> { - storedProduct.value = productRepository.getProductAsync( + productRepository.getProductAggregate( viewState.productDraft?.remoteId ?: mode.remoteProductId - ) + )?.let { + storedProductAggregate.value = it + } } ProductDetailFragment.Mode.Loading -> { @@ -681,7 +685,7 @@ class ProductDetailViewModel @Inject constructor( when (variationRepository.bulkCreateVariations(remoteProductId, variationCandidates)) { RequestResult.SUCCESS -> { tracker.track(AnalyticsEvent.PRODUCT_VARIATION_GENERATION_SUCCESS) - productRepository.fetchAndGetProduct(remoteProductId) + productRepository.fetchAndGetProductAggregate(remoteProductId) ?.also { updateProductState(productToUpdateFrom = it) } triggerEvent(ProductExitEvent.ExitAttributesAdded) } @@ -704,22 +708,23 @@ class ProductDetailViewModel @Inject constructor( ) variationRepository.createEmptyVariation(draft) ?.let { - productRepository.fetchAndGetProduct(draft.remoteId) + productRepository.fetchAndGetProductAggregate(draft.remoteId) ?.also { updateProductState(productToUpdateFrom = it) } } }.also { attributeListViewState = attributeListViewState.copy(progressDialogState = ProgressDialogState.Hidden) } - fun hasCategoryChanges() = storedProduct.value?.hasCategoryChanges(viewState.productDraft) ?: false + fun hasCategoryChanges() = storedProductAggregate.value + ?.product?.hasCategoryChanges(viewState.productDraft) ?: false - fun hasTagChanges() = storedProduct.value?.hasTagChanges(viewState.productDraft) ?: false + fun hasTagChanges() = storedProductAggregate.value?.product?.hasTagChanges(viewState.productDraft) ?: false fun hasSettingsChanges(): Boolean { - return if (storedProduct.value?.hasSettingsChanges(viewState.productDraft) == true) { + return if (storedProductAggregate.value?.product?.hasSettingsChanges(viewState.productDraft) == true) { true } else { - storedProduct.value?.password != viewState.draftPassword + storedProductAggregate.value?.product?.password != viewState.draftPassword } } @@ -847,7 +852,8 @@ class ProductDetailViewModel @Inject constructor( ) } - fun hasExternalLinkChanges() = storedProduct.value?.hasExternalLinkChanges(viewState.productDraft) ?: false + fun hasExternalLinkChanges() = storedProductAggregate.value + ?.product?.hasExternalLinkChanges(viewState.productDraft) ?: false /** * Called when the back= button is clicked in a product sub detail screen @@ -1135,10 +1141,10 @@ class ProductDetailViewModel @Inject constructor( } private fun trackWithProductId(event: AnalyticsEvent) { - storedProduct.value?.let { + storedProductAggregate.value?.let { tracker.track( event, - mapOf(AnalyticsTracker.KEY_PRODUCT_ID to it.remoteId) + mapOf(AnalyticsTracker.KEY_PRODUCT_ID to it.product.remoteId) ) } } @@ -1175,7 +1181,7 @@ class ProductDetailViewModel @Inject constructor( */ fun onSettingsVisibilityButtonClicked() { val visibility = getProductVisibility() - val password = viewState.draftPassword ?: storedProduct.value?.password + val password = viewState.draftPassword ?: storedProductAggregate.value?.product?.password triggerEvent( ProductNavigationTarget.ViewProductVisibility( selectedSite.connectionType == SiteConnectionType.ApplicationPasswords, @@ -1222,13 +1228,11 @@ class ProductDetailViewModel @Inject constructor( ) { updateProductDraft(type = productType.value, isVirtual = isVirtual) - viewState.productDraft?.let { productDraft -> - if (productType == ProductType.SUBSCRIPTION && productDraft.subscription == null) { + viewState.productAggregateDraft?.let { productAggregateDraft -> + if (productType == ProductType.SUBSCRIPTION && productAggregateDraft.subscription == null) { viewState = viewState.copy( - productDraft = productDraft.copy( - subscription = ProductHelper.getDefaultSubscriptionDetails().copy( - price = productDraft.regularPrice - ) + subscriptionDraft = ProductHelper.getDefaultSubscriptionDetails().copy( + price = productAggregateDraft.product.regularPrice ) ) } @@ -1331,12 +1335,12 @@ class ProductDetailViewModel @Inject constructor( saleEndDateGmt = if (productHasSale(isSaleScheduled, product)) { saleEndDate } else { - storedProduct.value?.saleEndDateGmt + storedProductAggregate.value?.product?.saleEndDateGmt }, saleStartDateGmt = if (productHasSale(isSaleScheduled, product)) { saleStartDate ?: product.saleStartDateGmt } else { - storedProduct.value?.saleStartDateGmt + storedProductAggregate.value?.product?.saleStartDateGmt }, downloads = downloads ?: product.downloads, downloadLimit = downloadLimit ?: product.downloadLimit, @@ -1353,17 +1357,16 @@ class ProductDetailViewModel @Inject constructor( } fun updateProductSubscription( - price: BigDecimal? = viewState.productDraft?.subscription?.price, + price: BigDecimal? = viewState.productAggregateDraft?.subscription?.price, period: SubscriptionPeriod? = null, periodInterval: Int? = null, - signUpFee: BigDecimal? = viewState.productDraft?.subscription?.signUpFee, + signUpFee: BigDecimal? = viewState.productAggregateDraft?.subscription?.signUpFee, length: Int? = null, trialLength: Int? = null, trialPeriod: SubscriptionPeriod? = null, oneTimeShipping: Boolean? = null ) { - viewState.productDraft?.let { product -> - val subscription = product.subscription ?: return + viewState.productAggregateDraft?.subscription?.let { subscription -> // The length ranges depend on the subscription period (days,weeks,months,years) and interval. If these // change we need to reset the length to "Never expire". This replicates web behavior val updatedLength = subscription.resetSubscriptionLengthIfThePeriodOrIntervalChanged( @@ -1381,7 +1384,7 @@ class ProductDetailViewModel @Inject constructor( trialPeriod = trialPeriod ?: subscription.trialPeriod, oneTimeShipping = oneTimeShipping ?: subscription.oneTimeShipping ) - viewState = viewState.copy(productDraft = product.copy(subscription = updatedSubscription)) + viewState = viewState.copy(subscriptionDraft = updatedSubscription) } } @@ -1402,10 +1405,13 @@ class ProductDetailViewModel @Inject constructor( } } - private fun updateCards(product: Product) { + private fun updateCards(productAggregate: ProductAggregate) { launch(dispatchers.io) { mutex.withLock { - val cards = cardBuilder.buildPropertyCards(product, storedProduct.value?.sku ?: "") + val cards = cardBuilder.buildPropertyCards( + productAggregate = productAggregate, + originalSku = storedProductAggregate.value?.product?.sku ?: "" + ) withContext(dispatchers.main) { _productDetailCards.value = cards } @@ -1415,7 +1421,7 @@ class ProductDetailViewModel @Inject constructor( } private fun fetchBottomSheetList() { - viewState.productDraft?.let { + viewState.productAggregateDraft?.let { launch(dispatchers.computation) { val detailList = productDetailBottomSheetBuilder.buildBottomSheetList(it) withContext(dispatchers.main) { @@ -1454,13 +1460,13 @@ class ProductDetailViewModel @Inject constructor( launch { // fetch product - val productInDb = productRepository.getProductAsync(remoteProductId) - if (productInDb != null) { + val productAggregateInDb = productRepository.getProductAggregate(remoteProductId) + if (productAggregateInDb != null) { val shouldFetch = remoteProductId != getRemoteProductId() - updateProductState(productInDb) + updateProductState(productAggregateInDb) val cachedVariationCount = productRepository.getCachedVariationCount(remoteProductId) - if (shouldFetch || cachedVariationCount != productInDb.numVariations) { + if (shouldFetch || cachedVariationCount != productAggregateInDb.product.numVariations) { fetchProduct(remoteProductId) fetchProductPassword(remoteProductId) } @@ -1477,7 +1483,7 @@ class ProductDetailViewModel @Inject constructor( */ private fun trackProductDetailLoaded() { if (hasTrackedProductDetailLoaded.not()) { - storedProduct.value?.let { product -> + storedProductAggregate.value?.product?.let { product -> launch { val properties = mapOf( AnalyticsTracker.KEY_HAS_LINKED_PRODUCTS to product.hasLinkedProducts(), @@ -1531,8 +1537,8 @@ class ProductDetailViewModel @Inject constructor( * then the visibility is `PRIVATE`, otherwise it's `PUBLIC`. */ fun getProductVisibility(): ProductVisibility { - val status = viewState.productDraft?.status ?: storedProduct.value?.status - val password = viewState.draftPassword ?: storedProduct.value?.password + val status = viewState.productDraft?.status ?: storedProductAggregate.value?.product?.status + val password = viewState.draftPassword ?: storedProductAggregate.value?.product?.password return when { password?.isNotEmpty() == true -> { ProductVisibility.PASSWORD_PROTECTED @@ -1555,11 +1561,11 @@ class ProductDetailViewModel @Inject constructor( val productPasswordApi = determineProductPasswordApi() val password = when (productPasswordApi) { ProductPasswordApi.WPCOM -> productRepository.fetchProductPassword(remoteProductId) - ProductPasswordApi.CORE -> storedProduct.value?.password + ProductPasswordApi.CORE -> storedProductAggregate.value?.product?.password ProductPasswordApi.UNSUPPORTED -> return } - storedProduct.update { it?.copy(password = password) } + storedProductAggregate.update { it?.copy(product = it.product.copy(password = password)) } viewState = viewState.copy( productDraft = viewState.productDraft?.copy(password = viewState.draftPassword ?: password) ) @@ -1567,7 +1573,7 @@ class ProductDetailViewModel @Inject constructor( private suspend fun fetchProduct(remoteProductId: Long) { if (checkConnection()) { - val fetchedProduct = productRepository.fetchAndGetProduct(remoteProductId) + val fetchedProduct = productRepository.fetchAndGetProductAggregate(remoteProductId) if (fetchedProduct != null) { updateProductState(fetchedProduct) } else { @@ -1853,7 +1859,8 @@ class ProductDetailViewModel @Inject constructor( triggerEvent(ProductNavigationTarget.RenameProductAttribute(attributeName)) } - fun hasAttributeChanges() = storedProduct.value?.hasAttributeChanges(viewState.productDraft) ?: false + fun hasAttributeChanges() = storedProductAggregate.value + ?.product?.hasAttributeChanges(viewState.productDraft) ?: false /** * Used by the add attribute screen to fetch the list of store-wide product attributes @@ -1955,12 +1962,12 @@ class ProductDetailViewModel @Inject constructor( val result = productRepository.updateProduct(product.copy(password = viewState.draftPassword)) if (result.first) { val successMsg = pickProductUpdateSuccessText(isPublish) - val isPasswordChanged = storedProduct.value?.password != viewState.draftPassword + val isPasswordChanged = storedProductAggregate.value?.product?.password != viewState.draftPassword if (isPasswordChanged && determineProductPasswordApi() == ProductPasswordApi.WPCOM) { // Update the product password using WordPress.com API val password = viewState.productDraft?.password if (productRepository.updateProductPassword(product.remoteId, password)) { - storedProduct.update { it?.copy(password = password) } + storedProductAggregate.update { it?.copy(product = it.product.copy(password = password)) } triggerEvent(ShowSnackbar(successMsg)) } else { triggerEvent(ShowSnackbar(R.string.product_detail_update_product_password_error)) @@ -2061,22 +2068,22 @@ class ProductDetailViewModel @Inject constructor( productRepository.getProductShippingClassByRemoteId(remoteShippingClassId)?.name ?: viewState.productDraft?.shippingClass ?: "" - private fun updateProductState(productToUpdateFrom: Product) { - val updatedDraft = viewState.productDraft?.let { currentDraft -> - if (storedProduct.value?.isSameProduct(currentDraft) == true) { + private fun updateProductState(productToUpdateFrom: ProductAggregate) { + val updatedDraft = viewState.productAggregateDraft?.let { currentDraft -> + if (storedProductAggregate.value?.isSame(currentDraft) == true) { productToUpdateFrom } else { - productToUpdateFrom.mergeProduct(currentDraft) + productToUpdateFrom.merge(currentDraft) } } ?: productToUpdateFrom - loadProductTaxAndShippingClassDependencies(updatedDraft) + loadProductTaxAndShippingClassDependencies(updatedDraft.product) viewState = viewState.copy( - productDraft = updatedDraft, + productAggregateDraft = updatedDraft, auxiliaryState = ProductDetailViewState.AuxiliaryState.None ) - storedProduct.value = productToUpdateFrom + storedProductAggregate.value = productToUpdateFrom } private fun loadProductTaxAndShippingClassDependencies(product: Product) { @@ -2097,6 +2104,7 @@ class ProductDetailViewModel @Inject constructor( imageUploadsJob?.cancel() imageUploadsJob = launch { draftChanges + .map { it?.product } .distinctUntilChanged { old, new -> old?.remoteId == new?.remoteId } .map { getRemoteProductId() } .filter { productId -> productId != DEFAULT_ADD_NEW_PRODUCT_ID || isAddNewProductFlow } @@ -2578,7 +2586,7 @@ class ProductDetailViewModel @Inject constructor( } private fun hasSubscriptionExpirationChanges(): Boolean { - return storedProduct.value?.subscription?.length != viewState.productDraft?.subscription?.length + return storedProductAggregate.value?.subscription?.length != viewState.subscriptionDraft?.length } fun onProductCategorySearchQueryChanged(query: String) { @@ -2685,18 +2693,18 @@ class ProductDetailViewModel @Inject constructor( /** * [productDraft] is used for the UI. Any updates to the fields in the UI would update this model. - * [storedProduct.value] is the [Product] model that is fetched from the API and available in the local db. + * [storedProductAggregate.value] is the [Product] model that is fetched from the API and available in the local db. * This is read only and is not updated in any way. It is used in the product detail screen, to check * if we need to display the UPDATE menu button (which is only displayed if there are changes made to * any of the product fields). * - * When the user first enters the product detail screen, the [productDraft] and [storedProduct.value] are the same. + * When the user first enters the product detail screen, the [productDraft] and [storedProductAggregate.value] are the same. * When a change is made to the product in the UI, the [productDraft] model is updated with whatever change * has been made in the UI. */ @Parcelize data class ProductDetailViewState( - val productDraft: Product? = null, + val productAggregateDraft: ProductAggregate? = null, val auxiliaryState: AuxiliaryState = AuxiliaryState.None, val uploadingImageUris: List? = null, val isProgressDialogShown: Boolean? = null, @@ -2706,9 +2714,23 @@ class ProductDetailViewModel @Inject constructor( val isVariationListEmpty: Boolean? = null, val areImagesAvailable: Boolean ) : Parcelable { + val productDraft + get() = productAggregateDraft?.product + val subscriptionDraft + get() = productAggregateDraft?.subscription val draftPassword get() = productDraft?.password + fun copy(productDraft: Product?) = copy( + productAggregateDraft = productDraft?.let { + productAggregateDraft?.copy(product = it) ?: ProductAggregate(product = it) + } + ) + + fun copy(subscriptionDraft: SubscriptionDetails) = copy( + productAggregateDraft = productAggregateDraft?.copy(subscription = subscriptionDraft) + ) + @Parcelize sealed class AuxiliaryState : Parcelable { @Parcelize diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/customfields/list/CustomFieldsViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/customfields/list/CustomFieldsViewModelTest.kt index c4f19fd831a2..d83c920593d2 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/customfields/list/CustomFieldsViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/customfields/list/CustomFieldsViewModelTest.kt @@ -491,7 +491,7 @@ class CustomFieldsViewModelTest : BaseUnitTest() { val customField = CustomField( id = 1, key = "key", - value = WCMetaDataValue.fromRawString("{\"key\": \"value\"}") + value = WCMetaDataValue("{\"key\": \"value\"}") ) setup { whenever(repository.observeDisplayableCustomFields(PARENT_ITEM_ID)).thenReturn(flowOf(listOf(customField))) @@ -514,7 +514,7 @@ class CustomFieldsViewModelTest : BaseUnitTest() { val customField = CustomField( id = 1, key = "key", - value = WCMetaDataValue.fromRawString("{\"key\": \"value\"}") + value = WCMetaDataValue("{\"key\": \"value\"}") ) setup { whenever(repository.observeDisplayableCustomFields(PARENT_ITEM_ID)).thenReturn(flowOf(listOf(customField))) diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/SubscriptionDetailsMapperTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/SubscriptionDetailsMapperTest.kt index fb50393fb389..5639c0606df6 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/SubscriptionDetailsMapperTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/SubscriptionDetailsMapperTest.kt @@ -1,16 +1,18 @@ package com.woocommerce.android.ui.products import com.woocommerce.android.model.SubscriptionDetailsMapper +import com.woocommerce.android.model.SubscriptionPaymentSyncDate import com.woocommerce.android.model.SubscriptionPeriod import com.woocommerce.android.viewmodel.BaseUnitTest import kotlinx.coroutines.ExperimentalCoroutinesApi import org.assertj.core.api.Assertions.assertThat import org.junit.Test +import org.wordpress.android.fluxc.model.WCProductModel +import org.wordpress.android.fluxc.model.metadata.WCMetaData import java.math.BigDecimal @OptIn(ExperimentalCoroutinesApi::class) class SubscriptionDetailsMapperTest : BaseUnitTest() { - @Test fun `when metadata is valid then a SubscriptionDetails is returned`() { val result = SubscriptionDetailsMapper.toAppModel(successMetadata) @@ -49,6 +51,57 @@ class SubscriptionDetailsMapperTest : BaseUnitTest() { } } + @Test + fun `when sync date contains both a day and month, then parse it successfully`() { + val metadata = """ [ { + "id": 5182, + "key": ${WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_PAYMENT_SYNC_DATE}, + "value": { + "day": 1, + "month": 9 + } + }] + """ + val result = SubscriptionDetailsMapper.toAppModel(metadata) + assertThat(result).isNotNull + assertThat(result!!.paymentsSyncDate).isEqualTo( + SubscriptionPaymentSyncDate.MonthDay(month = 9, day = 1) + ) + } + + @Test + fun `when sync data day is 0, then parse it successfully`() { + val metadata = """ [ { + "id": 5182, + "key": ${WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_PAYMENT_SYNC_DATE}, + "value": { + "day": 0, + "month": 0 + } + }] + """ + val result = SubscriptionDetailsMapper.toAppModel(metadata) + assertThat(result).isNotNull + assertThat(result!!.paymentsSyncDate).isEqualTo( + SubscriptionPaymentSyncDate.None + ) + } + + @Test + fun `when sync date contains only a day, then parse it successfully`() { + val metadata = """ [ { + "id": 5182, + "key": ${WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_PAYMENT_SYNC_DATE}, + "value": 1 + }] + """ + val result = SubscriptionDetailsMapper.toAppModel(metadata) + assertThat(result).isNotNull + assertThat(result!!.paymentsSyncDate).isEqualTo( + SubscriptionPaymentSyncDate.Day(1) + ) + } + /** * price = 60, * period = month, @@ -59,77 +112,74 @@ class SubscriptionDetailsMapperTest : BaseUnitTest() { * trialLength = 2, * oneTimeShipping = yes */ - private val successMetadata = """ [ { - "id": 5182, - "key": "_subscription_payment_sync_date", - "value": "0" - }, - { - "id": 5183, - "key": "_subscription_price", - "value": "60" - }, - { - "id": 5187, - "key": "_subscription_trial_length", - "value": "2" - }, - { - "id": 5188, - "key": "_subscription_sign_up_fee", - "value": "5" - }, - { - "id": 5189, - "key": "_subscription_period", - "value": "month" - }, - { - "id": 5190, - "key": "_subscription_period_interval", - "value": "1" - }, - { - "id": 5191, - "key": "_subscription_length", - "value": "0" - }, - { - "id": 5192, - "key": "_subscription_trial_period", - "value": "day" - }, - { - "id": 5193, - "key": "_subscription_limit", - "value": "no" - }, - { - "id": 5194, - "key": "_subscription_one_time_shipping", - "value": "yes" - } ] - """ + private val successMetadata = listOf( + WCMetaData( + id = 5182, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_PAYMENT_SYNC_DATE, + value = "0" + ), + WCMetaData( + id = 5183, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_PRICE, + value = "60" + ), + WCMetaData( + id = 5187, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_TRIAL_LENGTH, + value = "2" + ), + WCMetaData( + id = 5188, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_SIGN_UP_FEE, + value = "5" + ), + WCMetaData( + id = 5189, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_PERIOD, + value = "month" + ), + WCMetaData( + id = 5190, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_PERIOD_INTERVAL, + value = "1" + ), + WCMetaData( + id = 5191, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_LENGTH, + value = "0" + ), + WCMetaData( + id = 5192, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_TRIAL_PERIOD, + value = "day" + ), + WCMetaData( + id = 5194, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_ONE_TIME_SHIPPING, + value = "yes" + ) + ) /** * Metadata with no subscription key */ - private val noSubscriptionKeysMetadata = """ [ { - "id": 5182, - "key": "sync_date", - "value": "0" - }, - { - "id": 5183, - "key": "price", - "value": "60" - }, - { - "id": 5187, - "key": "trial_length", - "value": "2" - }] - """ + private val noSubscriptionKeysMetadata = listOf( + WCMetaData( + id = 5182, + key = "sync_date", + value = "0" + ), + WCMetaData( + id = 5183, + key = "price", + value = "60" + ), + WCMetaData( + id = 5187, + key = "trial_length", + value = "2" + ) + ) /** * price = 60, @@ -141,25 +191,26 @@ class SubscriptionDetailsMapperTest : BaseUnitTest() { * trialLength = 2, * oneTimeShipping = */ - private val successMetadataPartial = """ [ { - "id": 5182, - "key": "_subscription_payment_sync_date", - "value": "0" - }, - { - "id": 5183, - "key": "_subscription_price", - "value": "60" - }, - { - "id": 5187, - "key": "_subscription_trial_length", - "value": "2" - }, - { - "id": 5188, - "key": "_subscription_sign_up_fee", - "value": "5" - }] - """ + private val successMetadataPartial = listOf( + WCMetaData( + id = 5182, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_PAYMENT_SYNC_DATE, + value = "0" + ), + WCMetaData( + id = 5183, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_PRICE, + value = "60" + ), + WCMetaData( + id = 5187, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_TRIAL_LENGTH, + value = "2" + ), + WCMetaData( + id = 5188, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_SIGN_UP_FEE, + value = "5" + ) + ) } diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailBottomSheetBuilderTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailBottomSheetBuilderTest.kt index 4486a5316cca..ebcf167f9dc3 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailBottomSheetBuilderTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailBottomSheetBuilderTest.kt @@ -1,5 +1,6 @@ package com.woocommerce.android.ui.products.details +import com.woocommerce.android.model.ProductAggregate import com.woocommerce.android.ui.customfields.CustomFieldsRepository import com.woocommerce.android.ui.products.ProductNavigationTarget import com.woocommerce.android.ui.products.ProductTestUtils @@ -39,7 +40,7 @@ class ProductDetailBottomSheetBuilderTest : BaseUnitTest() { whenever(customFieldsRepository.hasDisplayableCustomFields(any())).thenReturn(true) val product = ProductTestUtils.generateProduct(productId = 1L) - val result = sut.buildBottomSheetList(product) + val result = sut.buildBottomSheetList(ProductAggregate(product)) assertThat(result).noneMatch { it.type == ProductDetailBottomSheetBuilder.ProductDetailBottomSheetType.CUSTOM_FIELDS @@ -51,7 +52,7 @@ class ProductDetailBottomSheetBuilderTest : BaseUnitTest() { whenever(customFieldsRepository.hasDisplayableCustomFields(any())).thenReturn(false) val product = ProductTestUtils.generateProduct(productId = 1L) - val result = sut.buildBottomSheetList(product) + val result = sut.buildBottomSheetList(ProductAggregate(product)) val customFieldsItem = result.single { it.type == ProductDetailBottomSheetBuilder.ProductDetailBottomSheetType.CUSTOM_FIELDS @@ -62,7 +63,7 @@ class ProductDetailBottomSheetBuilderTest : BaseUnitTest() { @Test fun `when product is not saved in server, then hide the custom fields item`() = testBlocking { val product = ProductTestUtils.generateProduct(productId = ProductDetailViewModel.DEFAULT_ADD_NEW_PRODUCT_ID) - val result = sut.buildBottomSheetList(product) + val result = sut.buildBottomSheetList(ProductAggregate(product)) assertThat(result).noneMatch { it.type == ProductDetailBottomSheetBuilder.ProductDetailBottomSheetType.CUSTOM_FIELDS diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailCardBuilderTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailCardBuilderTest.kt index 68a7b2e49acd..fd46cdc45494 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailCardBuilderTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailCardBuilderTest.kt @@ -2,6 +2,7 @@ package com.woocommerce.android.ui.products.details import com.woocommerce.android.R import com.woocommerce.android.model.Product +import com.woocommerce.android.model.ProductAggregate import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.ui.blaze.IsBlazeEnabled import com.woocommerce.android.ui.customfields.CustomFieldsRepository @@ -83,7 +84,7 @@ class ProductDetailCardBuilderTest : BaseUnitTest() { height = 0F ) - val cards = sut.buildPropertyCards(productStub, "") + val cards = sut.buildPropertyCards(ProductAggregate(productStub), "") Assertions.assertThat(cards).isNotEmpty cards.find { it.type == ProductPropertyCard.Type.SECONDARY } @@ -110,7 +111,7 @@ class ProductDetailCardBuilderTest : BaseUnitTest() { ) var foundAttributesCard = false - val cards = sut.buildPropertyCards(productStub, "") + val cards = sut.buildPropertyCards(ProductAggregate(productStub), "") Assertions.assertThat(cards).isNotEmpty cards.find { it.type == ProductPropertyCard.Type.SECONDARY } @@ -134,7 +135,7 @@ class ProductDetailCardBuilderTest : BaseUnitTest() { ) var foundQuantityRulesCard = false - val cards = sut.buildPropertyCards(productStub, "") + val cards = sut.buildPropertyCards(ProductAggregate(productStub), "") Assertions.assertThat(cards).isNotEmpty cards.find { it.type == ProductPropertyCard.Type.SECONDARY } @@ -156,7 +157,7 @@ class ProductDetailCardBuilderTest : BaseUnitTest() { whenever(customFieldsRepository.hasDisplayableCustomFields(any())) doReturn false productStub = ProductTestUtils.generateProduct(productId = 1L) - val cards = sut.buildPropertyCards(productStub, "") + val cards = sut.buildPropertyCards(ProductAggregate(productStub), "") val properties = cards.first { it.type == ProductPropertyCard.Type.SECONDARY }.properties val customFieldsCard = properties.find { @@ -171,7 +172,7 @@ class ProductDetailCardBuilderTest : BaseUnitTest() { whenever(customFieldsRepository.hasDisplayableCustomFields(any())) doReturn true productStub = ProductTestUtils.generateProduct(productId = 1L) - val cards = sut.buildPropertyCards(productStub, "") + val cards = sut.buildPropertyCards(ProductAggregate(productStub), "") val properties = cards.first { it.type == ProductPropertyCard.Type.SECONDARY }.properties val customFieldsCard = properties.find { @@ -184,7 +185,7 @@ class ProductDetailCardBuilderTest : BaseUnitTest() { @Test fun `when a new is not saved on the server, then hide the custom fields card`() = testBlocking { productStub = ProductTestUtils.generateProduct(productId = ProductDetailViewModel.DEFAULT_ADD_NEW_PRODUCT_ID) - val cards = sut.buildPropertyCards(productStub, "") + val cards = sut.buildPropertyCards(ProductAggregate(productStub), "") val properties = cards.first { it.type == ProductPropertyCard.Type.SECONDARY }.properties val customFieldsCard = properties.find { diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModelGenerateVariationFlowTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModelGenerateVariationFlowTest.kt index c2917f0d18a5..72e062fef571 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModelGenerateVariationFlowTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModelGenerateVariationFlowTest.kt @@ -7,6 +7,7 @@ import com.woocommerce.android.analytics.AnalyticsTracker import com.woocommerce.android.analytics.AnalyticsTrackerWrapper import com.woocommerce.android.media.MediaFilesRepository import com.woocommerce.android.media.ProductImagesServiceWrapper +import com.woocommerce.android.model.ProductAggregate import com.woocommerce.android.model.RequestResult import com.woocommerce.android.model.VariantOption import com.woocommerce.android.tools.NetworkStatus @@ -92,7 +93,7 @@ class ProductDetailViewModelGenerateVariationFlowTest : BaseUnitTest() { doReturn(true).whenever(networkStatus).isConnected() productRepository = mock { - onBlocking { fetchAndGetProduct(PRODUCT_REMOTE_ID) } doReturn product + onBlocking { fetchAndGetProductAggregate(PRODUCT_REMOTE_ID) } doReturn ProductAggregate(product) } variationRepository = mock { diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModelTest.kt index 259f98698335..2439f10ef1ae 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModelTest.kt @@ -8,6 +8,7 @@ import com.woocommerce.android.analytics.AnalyticsTrackerWrapper import com.woocommerce.android.extensions.takeIfNotEqualTo import com.woocommerce.android.media.MediaFilesRepository import com.woocommerce.android.media.ProductImagesServiceWrapper +import com.woocommerce.android.model.ProductAggregate import com.woocommerce.android.model.ProductAttribute import com.woocommerce.android.model.ProductVariation import com.woocommerce.android.tools.NetworkStatus @@ -124,7 +125,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { private val prefsWrapper: AppPrefsWrapper = mock() private val productUtils = ProductUtils() - private val product = ProductTestUtils.generateProduct(PRODUCT_REMOTE_ID) + private val productAggregate = ProductAggregate(ProductTestUtils.generateProduct(PRODUCT_REMOTE_ID)) private val productWithTagsAndCategories = ProductTestUtils.generateProductWithTagsAndCategories(PRODUCT_REMOTE_ID) private val offlineProduct = ProductTestUtils.generateProduct(OFFLINE_PRODUCT_REMOTE_ID) private val productCategories = ProductTestUtils.generateProductCategories() @@ -141,7 +142,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { } private val productWithParameters = ProductDetailViewModel.ProductDetailViewState( - productDraft = product, + productAggregateDraft = productAggregate, auxiliaryState = ProductDetailViewModel.ProductDetailViewState.AuxiliaryState.None, uploadingImageUris = emptyList(), showBottomSheetButton = true, @@ -152,8 +153,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { ProductPropertyCard( type = ProductPropertyCard.Type.PRIMARY, properties = listOf( - ProductProperty.Editable(R.string.product_detail_title_hint, product.name), - ProductProperty.ComplexProperty(R.string.product_description, product.description) + ProductProperty.Editable(R.string.product_detail_title_hint, productAggregate.product.name), + ProductProperty.ComplexProperty(R.string.product_description, productAggregate.product.description) ) ), ProductPropertyCard( @@ -181,8 +182,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { ), ProductProperty.RatingBar( R.string.product_reviews, - resources.getString(R.string.product_ratings_count, product.ratingCount), - product.averageRating, + resources.getString(R.string.product_ratings_count, productAggregate.product.ratingCount), + productAggregate.product.averageRating, R.drawable.ic_reviews ), ProductProperty.PropertyGroup( @@ -227,7 +228,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { ), ProductProperty.ComplexProperty( R.string.product_short_description, - product.shortDescription, + productAggregate.product.shortDescription, R.drawable.ic_gridicons_align_left ), ProductProperty.ComplexProperty( @@ -296,7 +297,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Displays the product detail properties correctly`() = testBlocking { doReturn(true).whenever(networkStatus).isConnected() - doReturn(productWithTagsAndCategories).whenever(productRepository).getProductAsync(any()) + doReturn(ProductAggregate(productWithTagsAndCategories)).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } @@ -312,8 +313,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Displays the product detail view correctly`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) - doReturn(product).whenever(productRepository).fetchAndGetProduct(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) + doReturn(productAggregate).whenever(productRepository).fetchAndGetProductAggregate(any()) var productData: ProductDetailViewModel.ProductDetailViewState? = null viewModel.productDetailViewStateData.observeForever { _, new -> productData = new } @@ -325,12 +326,12 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `given nothing returned from repo, when view model started, the error status emitted`() = testBlocking { - whenever(productRepository.fetchAndGetProduct(PRODUCT_REMOTE_ID)).thenReturn(null) - whenever(productRepository.getProductAsync(PRODUCT_REMOTE_ID)).thenReturn(null) + whenever(productRepository.fetchAndGetProductAggregate(PRODUCT_REMOTE_ID)).thenReturn(null) + whenever(productRepository.getProductAggregate(PRODUCT_REMOTE_ID)).thenReturn(null) viewModel.start() - verify(productRepository, times(1)).fetchAndGetProduct(PRODUCT_REMOTE_ID) + verify(productRepository, times(1)).fetchAndGetProductAggregate(PRODUCT_REMOTE_ID) Assertions.assertThat(viewModel.getProduct().productDraft).isNull() Assertions.assertThat(viewModel.getProduct().auxiliaryState).isEqualTo( @@ -343,15 +344,15 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `given nothing returned from repo with INVALID_PRODUCT_ID error, when view model started, the error status emitted with invalid id text`() = testBlocking { - whenever(productRepository.fetchAndGetProduct(PRODUCT_REMOTE_ID)).thenReturn(null) - whenever(productRepository.getProductAsync(PRODUCT_REMOTE_ID)).thenReturn(null) + whenever(productRepository.fetchAndGetProductAggregate(PRODUCT_REMOTE_ID)).thenReturn(null) + whenever(productRepository.getProductAggregate(PRODUCT_REMOTE_ID)).thenReturn(null) whenever(productRepository.lastFetchProductErrorType).thenReturn( WCProductStore.ProductErrorType.INVALID_PRODUCT_ID ) viewModel.start() - verify(productRepository, times(1)).fetchAndGetProduct(PRODUCT_REMOTE_ID) + verify(productRepository, times(1)).fetchAndGetProductAggregate(PRODUCT_REMOTE_ID) Assertions.assertThat(viewModel.getProduct().productDraft).isNull() Assertions.assertThat(viewModel.getProduct().auxiliaryState).isEqualTo( @@ -363,7 +364,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Do not fetch product from api when not connected`() = testBlocking { - doReturn(offlineProduct).whenever(productRepository).getProductAsync(any()) + doReturn(ProductAggregate(offlineProduct)).whenever(productRepository).getProductAggregate(any()) doReturn(false).whenever(networkStatus).isConnected() var snackbar: MultiLiveEvent.Event.ShowSnackbar? = null @@ -373,16 +374,16 @@ class ProductDetailViewModelTest : BaseUnitTest() { viewModel.start() - verify(productRepository, times(1)).getProductAsync(PRODUCT_REMOTE_ID) - verify(productRepository, times(0)).fetchAndGetProduct(any()) + verify(productRepository, times(1)).getProductAggregate(PRODUCT_REMOTE_ID) + verify(productRepository, times(0)).fetchAndGetProductAggregate(any()) Assertions.assertThat(snackbar).isEqualTo(MultiLiveEvent.Event.ShowSnackbar(R.string.offline_error)) } @Test fun `Shows and hides product detail skeleton correctly`() = testBlocking { - doReturn(null).whenever(productRepository).getProductAsync(any()) - doReturn(product).whenever(productRepository).fetchAndGetProduct(any()) + doReturn(null).whenever(productRepository).getProductAggregate(any()) + doReturn(productAggregate).whenever(productRepository).fetchAndGetProductAggregate(any()) val auxiliaryStates = ArrayList() viewModel.productDetailViewStateData.observeForever { old, new -> @@ -402,8 +403,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Displays the updated product detail view correctly`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) - doReturn(product).whenever(productRepository).fetchAndGetProduct(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) + doReturn(productAggregate).whenever(productRepository).fetchAndGetProductAggregate(any()) var productData: ProductDetailViewModel.ProductDetailViewState? = null viewModel.productDetailViewStateData.observeForever { _, new -> productData = new } @@ -420,8 +421,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `When update product price is null, product detail view displayed correctly`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) - doReturn(product).whenever(productRepository).fetchAndGetProduct(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) + doReturn(productAggregate).whenever(productRepository).fetchAndGetProductAggregate(any()) var productData: ProductDetailViewModel.ProductDetailViewState? = null viewModel.productDetailViewStateData.observeForever { _, new -> productData = new } @@ -443,8 +444,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `When update product price is zero, product detail view displayed correctly`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) - doReturn(product).whenever(productRepository).fetchAndGetProduct(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) + doReturn(productAggregate).whenever(productRepository).fetchAndGetProductAggregate(any()) var productData: ProductDetailViewModel.ProductDetailViewState? = null viewModel.productDetailViewStateData.observeForever { _, new -> productData = new } @@ -466,7 +467,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Displays update menu action if product is edited`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } viewModel.productDetailViewStateData.observeForever { _, _ -> } @@ -485,7 +486,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Displays progress dialog when product is edited`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) doReturn(Pair(false, null)).whenever(productRepository).updateProduct(any()) val isProgressDialogShown = ArrayList() @@ -504,7 +505,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Do not update product when not connected`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) doReturn(false).whenever(networkStatus).isConnected() var snackbar: MultiLiveEvent.Event.ShowSnackbar? = null @@ -526,7 +527,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Display error message on generic update product error`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) doReturn(Pair(false, WCProductStore.ProductError())).whenever(productRepository).updateProduct(any()) var snackbar: MultiLiveEvent.Event.ShowSnackbar? = null @@ -550,7 +551,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Display error message on min-max quantities update product error`() = testBlocking { val displayErrorMessage = "This is an error message" - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) doReturn( Pair( false, @@ -582,7 +583,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Display success message on update product success`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) doReturn(Pair(true, null)).whenever(productRepository).updateProduct(any()) var successSnackbarShown = false @@ -603,19 +604,19 @@ class ProductDetailViewModelTest : BaseUnitTest() { viewModel.onSaveButtonClicked() verify(productRepository, times(1)).updateProduct(any()) - verify(productRepository, times(2)).getProductAsync(PRODUCT_REMOTE_ID) + verify(productRepository, times(2)).getProductAggregate(PRODUCT_REMOTE_ID) Assertions.assertThat(successSnackbarShown).isTrue() Assertions.assertThat(productData?.isProgressDialogShown).isFalse Assertions.assertThat(hasChanges).isFalse() - Assertions.assertThat(productData?.productDraft).isEqualTo(product) + Assertions.assertThat(productData?.productAggregateDraft).isEqualTo(productAggregate) } @Test fun `Correctly sorts the Product Categories By their Parent Ids and by name`() { testBlocking { val sortedByNameAndParent = viewModel.sortAndStyleProductCategories( - product, + productAggregate.product, productCategories ).toList() Assertions.assertThat(sortedByNameAndParent[0].category).isEqualTo(productCategories[0]) @@ -680,7 +681,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Should update view state with not null sale end date when sale is scheduled`() = testBlocking { viewModel.productDetailViewStateData.observeForever { _, _ -> } - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) viewModel.start() viewModel.updateProductDraft(saleEndDate = SALE_END_DATE, isSaleScheduled = true) @@ -691,20 +692,22 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Should update with stored product sale end date when sale is not scheduled`() = testBlocking { viewModel.productDetailViewStateData.observeForever { _, _ -> } - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) viewModel.start() viewModel.updateProductDraft(saleEndDate = SALE_END_DATE, isSaleScheduled = false) - Assertions.assertThat(productsDraft?.saleEndDateGmt).isEqualTo(product.saleEndDateGmt) + Assertions.assertThat(productsDraft?.saleEndDateGmt).isEqualTo(productAggregate.product.saleEndDateGmt) } @Test fun `Should update sale end date when sale schedule is unknown but stored product sale is scheduled`() = testBlocking { viewModel.productDetailViewStateData.observeForever { _, _ -> } - val storedProduct = product.copy(isSaleScheduled = true) - doReturn(storedProduct).whenever(productRepository).getProductAsync(any()) + val storedProductAggregate = productAggregate.copy( + product = productAggregate.product.copy(isSaleScheduled = true) + ) + doReturn(storedProductAggregate).whenever(productRepository).getProductAggregate(any()) viewModel.start() viewModel.updateProductDraft(saleEndDate = SALE_END_DATE, isSaleScheduled = null) @@ -715,11 +718,13 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Should update with null sale end date and stored product has scheduled sale`() = testBlocking { viewModel.productDetailViewStateData.observeForever { _, _ -> } - val storedProduct = product.copy( - saleEndDateGmt = SALE_END_DATE, - isSaleScheduled = true + val storedProductAggregate = productAggregate.copy( + product = productAggregate.product.copy( + saleEndDateGmt = SALE_END_DATE, + isSaleScheduled = true + ) ) - doReturn(storedProduct).whenever(productRepository).getProductAsync(any()) + doReturn(storedProductAggregate).whenever(productRepository).getProductAggregate(any()) viewModel.start() viewModel.updateProductDraft(saleEndDate = null) @@ -730,12 +735,14 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Re-ordering attribute terms is saved correctly`() = testBlocking { viewModel.productDetailViewStateData.observeForever { _, _ -> } - val storedProduct = product.copy( - attributes = ProductTestUtils.generateProductAttributeList() + val storedProductAggregate = productAggregate.copy( + product = productAggregate.product.copy( + attributes = ProductTestUtils.generateProductAttributeList() + ) ) - doReturn(storedProduct).whenever(productRepository).getProductAsync(any()) + doReturn(storedProductAggregate).whenever(productRepository).getProductAggregate(any()) - val attribute = storedProduct.attributes[0] + val attribute = storedProductAggregate.product.attributes[0] val firstTerm = attribute.terms[0] val secondTerm = attribute.terms[1] @@ -772,10 +779,10 @@ class ProductDetailViewModelTest : BaseUnitTest() { ) ) - val storedProduct = product.copy( - attributes = attributes + val storedProductAggregate = productAggregate.copy( + product = productAggregate.product.copy(attributes = attributes) ) - doReturn(storedProduct).whenever(productRepository).getProductAsync(any()) + doReturn(storedProductAggregate).whenever(productRepository).getProductAggregate(any()) viewModel.start() viewModel.renameAttributeInDraft(1, attributeName, newName) @@ -787,7 +794,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { /** * Protection for a race condition bug in Variations. * - * We're requiring [ProductDetailRepository.fetchAndGetProduct] to be called right after + * We're requiring [ProductDetailRepository.fetchAndGetProductAggregate] to be called right after * [VariationRepository.createEmptyVariation] to fix a race condition problem in the Product Details page. The * bug can be reproduced inconsistently by following these steps: * @@ -811,7 +818,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { fun `When generating a variation, the latest Product should be fetched from the site`() = testBlocking { // Given - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) var productData: ProductDetailViewModel.ProductDetailViewState? = null viewModel.productDetailViewStateData.observeForever { _, new -> productData = new } @@ -824,16 +831,16 @@ class ProductDetailViewModelTest : BaseUnitTest() { Assertions.assertThat(productData?.productDraft?.numVariations).isZero() doReturn(mock()).whenever(variationRepository).createEmptyVariation(any()) - doReturn(product.copy(numVariations = 1_914)).whenever(productRepository) - .fetchAndGetProduct(eq(product.remoteId)) + doReturn(productAggregate.copy(product = productAggregate.product.copy(numVariations = 1_914))) + .whenever(productRepository).fetchAndGetProductAggregate(eq(productAggregate.product.remoteId)) // When viewModel.onGenerateVariationClicked() // Then - verify(variationRepository, times(1)).createEmptyVariation(eq(product)) + verify(variationRepository, times(1)).createEmptyVariation(eq(productAggregate.product)) // Prove that we fetched from the API. - verify(productRepository, times(1)).fetchAndGetProduct(eq(product.remoteId)) + verify(productRepository, times(1)).fetchAndGetProductAggregate(eq(productAggregate.remoteId)) // The VM state should have been updated with the _fetched_ product's numVariations Assertions.assertThat(productData?.productDraft?.numVariations).isEqualTo(1_914) @@ -843,8 +850,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { fun `when there image upload errors, then show a snackbar`() = testBlocking { val errorEvents = MutableSharedFlow>() doReturn(errorEvents).whenever(mediaFileUploadHandler).observeCurrentUploadErrors(PRODUCT_REMOTE_ID) - doReturn(product).whenever(productRepository).fetchAndGetProduct(any()) - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).fetchAndGetProductAggregate(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) val errorMessage = "message" doReturn(errorMessage).whenever(resources).getString(any(), anyVararg()) @@ -871,8 +878,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { fun `when image uploads gets cleared, then auto-dismiss the snackbar`() = testBlocking { val errorEvents = MutableSharedFlow>() doReturn(errorEvents).whenever(mediaFileUploadHandler).observeCurrentUploadErrors(PRODUCT_REMOTE_ID) - doReturn(product).whenever(productRepository).fetchAndGetProduct(any()) - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).fetchAndGetProductAggregate(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) viewModel.start() errorEvents.emit(emptyList()) @@ -882,7 +889,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Publish option not shown when product is published except addProduct flow`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } var menuButtonsState: ProductDetailViewModel.MenuButtonsState? = null viewModel.menuButtonsState.observeForever { menuButtonsState = it } @@ -894,7 +901,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Publish option not shown when product is published privately except addProduct flow`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } var menuButtonsState: ProductDetailViewModel.MenuButtonsState? = null viewModel.menuButtonsState.observeForever { menuButtonsState = it } @@ -906,7 +913,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Publish option shown when product is Draft`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } var menuButtonsState: ProductDetailViewModel.MenuButtonsState? = null @@ -919,7 +926,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Publish option shown when product is Pending Review`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } var menuButtonsState: ProductDetailViewModel.MenuButtonsState? = null @@ -933,7 +940,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Save option shown when product has changes except add product flow irrespective of product statuses`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } var menuButtonsState: ProductDetailViewModel.MenuButtonsState? = null @@ -941,7 +948,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { viewModel.start() // Trigger changes - viewModel.updateProductDraft(title = product.name + "2") + viewModel.updateProductDraft(title = productAggregate.product.name + "2") Assertions.assertThat(menuButtonsState?.saveOption).isTrue() } @@ -949,7 +956,11 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `when restoring saved state, then re-fetch stored product to correctly calculate hasChanges`() = testBlocking { // Make sure draft product has different data than draft product - doReturn(product.copy(name = product.name + "test")).whenever(productRepository).getProductAsync(any()) + doReturn( + productAggregate.copy( + product = productAggregate.product.copy(name = productAggregate.product.name + "test") + ) + ).whenever(productRepository).getProductAggregate(any()) savedState.set(ProductDetailViewModel.ProductDetailViewState::class.java.name, productWithParameters) viewModel.productDetailViewStateData.observeForever { _, _ -> } @@ -964,10 +975,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `given regular price set, when updating inventory, then price remains unchanged`() = testBlocking { doReturn( - product.copy( - regularPrice = BigDecimal(99) - ) - ).whenever(productRepository).getProductAsync(any()) + productAggregate.copy(product = productAggregate.product.copy(regularPrice = BigDecimal(99))) + ).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } viewModel.start() @@ -979,10 +988,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `given sale price set, when updating attributes, then price remains unchanged`() = testBlocking { doReturn( - product.copy( - salePrice = BigDecimal(99) - ) - ).whenever(productRepository).getProductAsync(any()) + productAggregate.copy(product = productAggregate.product.copy(salePrice = BigDecimal(99))) + ).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } viewModel.start() @@ -994,10 +1001,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `given regular price greater than 0, when setting price to 0, then price is set to zero`() = testBlocking { doReturn( - product.copy( - regularPrice = BigDecimal(99) - ) - ).whenever(productRepository).getProductAsync(any()) + productAggregate.copy(product = productAggregate.product.copy(regularPrice = BigDecimal(99))) + ).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } viewModel.start() @@ -1009,10 +1014,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `given sale price greater than 0, when setting price to 0, then price is set to zero`() = testBlocking { doReturn( - product.copy( - regularPrice = BigDecimal(99) - ) - ).whenever(productRepository).getProductAsync(any()) + productAggregate.copy(product = productAggregate.product.copy(regularPrice = BigDecimal(99))) + ).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } viewModel.start() @@ -1024,10 +1027,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `given regular price greater than 0, when setting price to null, then price is set to null`() = testBlocking { doReturn( - product.copy( - regularPrice = BigDecimal(99) - ) - ).whenever(productRepository).getProductAsync(any()) + productAggregate.copy(product = productAggregate.product.copy(regularPrice = BigDecimal(99))) + ).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } viewModel.start() @@ -1039,10 +1040,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `given sale price greater than 0, when setting price to null, then price is set to null`() = testBlocking { doReturn( - product.copy( - regularPrice = BigDecimal(99) - ) - ).whenever(productRepository).getProductAsync(any()) + productAggregate.copy(product = productAggregate.product.copy(regularPrice = BigDecimal(99))) + ).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } viewModel.start() @@ -1059,7 +1058,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { images = uris ).toSavedStateHandle() - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) mediaFileUploadHandler = mock { on { it.observeCurrentUploadErrors(any()) } doReturn emptyFlow() @@ -1095,66 +1094,70 @@ class ProductDetailViewModelTest : BaseUnitTest() { } @Test - fun `given tablet, when loaded remote products, then PRODUCT_DETAIL_LOADED tracked with regular horizontal class`() = testBlocking { - // GIVEN - whenever(isWindowClassLargeThanCompact()).thenReturn(true) + fun `given tablet, when loaded remote products, then PRODUCT_DETAIL_LOADED tracked with regular horizontal class`() = + testBlocking { + // GIVEN + whenever(isWindowClassLargeThanCompact()).thenReturn(true) - // WHEN - setup() + // WHEN + setup() - // THEN - verify(tracker).track( - eq(AnalyticsEvent.PRODUCT_DETAIL_LOADED), - eq(mapOf("horizontal_size_class" to "regular")) - ) - } + // THEN + verify(tracker).track( + eq(AnalyticsEvent.PRODUCT_DETAIL_LOADED), + eq(mapOf("horizontal_size_class" to "regular")) + ) + } @Test - fun `given not tablet, when loaded remote products, then PRODUCT_DETAIL_LOADED tracked with compact horizontal class`() = testBlocking { - // GIVEN - whenever(isWindowClassLargeThanCompact()).thenReturn(false) + fun `given not tablet, when loaded remote products, then PRODUCT_DETAIL_LOADED tracked with compact horizontal class`() = + testBlocking { + // GIVEN + whenever(isWindowClassLargeThanCompact()).thenReturn(false) - // WHEN - setup() + // WHEN + setup() - // THEN - verify(tracker, times(2)).track( - eq(AnalyticsEvent.PRODUCT_DETAIL_LOADED), - eq(mapOf("horizontal_size_class" to "compact")) - ) - } + // THEN + verify(tracker, times(2)).track( + eq(AnalyticsEvent.PRODUCT_DETAIL_LOADED), + eq(mapOf("horizontal_size_class" to "compact")) + ) + } @Test - fun `given product updated successfuly, when onPublishButtonClicked, then ProductUpdated event emitted`() = testBlocking { - // GIVEN - whenever(productRepository.getProductAsync(any())).thenReturn(product) - whenever(productRepository.updateProduct(any())).thenReturn(Pair(true, null)) - viewModel.start() + fun `given product updated successfuly, when onPublishButtonClicked, then ProductUpdated event emitted`() = + testBlocking { + // GIVEN + whenever(productRepository.getProductAggregate(any())).thenReturn(productAggregate) + whenever(productRepository.updateProduct(any())).thenReturn(Pair(true, null)) + viewModel.start() - // WHEN - viewModel.onPublishButtonClicked() + // WHEN + viewModel.onPublishButtonClicked() - // THEN - Assertions.assertThat(viewModel.event.value).isEqualTo(ProductDetailViewModel.ProductUpdated) - } + // THEN + Assertions.assertThat(viewModel.event.value).isEqualTo(ProductDetailViewModel.ProductUpdated) + } @Test - fun `given selected site is private, when product detail is opened, then images are not available`() = testBlocking { - // GIVEN - whenever(selectedSite.get()).thenReturn(SiteModel().apply { setIsPrivate(true) }) - savedState = ProductDetailFragmentArgs(ProductDetailFragment.Mode.ShowProduct(PRODUCT_REMOTE_ID)) - .toSavedStateHandle() + fun `given selected site is private, when product detail is opened, then images are not available`() = + testBlocking { + // GIVEN + whenever(selectedSite.get()).thenReturn(SiteModel().apply { setIsPrivate(true) }) + savedState = ProductDetailFragmentArgs(ProductDetailFragment.Mode.ShowProduct(PRODUCT_REMOTE_ID)) + .toSavedStateHandle() - setup() - viewModel.start() + setup() + viewModel.start() - // WHEN - var productData: ProductDetailViewModel.ProductDetailViewState? = null - viewModel.productDetailViewStateData.observeForever { _, new -> productData = new } + // WHEN + var productData: ProductDetailViewModel.ProductDetailViewState? = null + viewModel.productDetailViewStateData.observeForever { _, new -> productData = new } - // THEN - Assertions.assertThat(productData?.areImagesAvailable).isFalse() - } + // THEN + Assertions.assertThat(productData?.areImagesAvailable).isFalse() + } @Test fun `given selected site is public, when product detail is opened, then images are available`() = testBlocking { @@ -1170,70 +1173,75 @@ class ProductDetailViewModelTest : BaseUnitTest() { } @Test - fun `given product password API uses CORE, when product details are fetched, then use password from the model`() = testBlocking { - // GIVEN - val password = "password" - whenever(determineProductPasswordApi.invoke()).thenReturn(ProductPasswordApi.CORE) - whenever(productRepository.getProductAsync(any())).thenReturn(product.copy(password = password)) + fun `given product password API uses CORE, when product details are fetched, then use password from the model`() = + testBlocking { + // GIVEN + val password = "password" + whenever(determineProductPasswordApi.invoke()).thenReturn(ProductPasswordApi.CORE) + whenever(productRepository.getProductAggregate(any())) + .thenReturn(productAggregate.copy(product = productAggregate.product.copy(password = password))) - // WHEN - viewModel.start() - val viewState = viewModel.productDetailViewStateData.liveData.getOrAwaitValue() + // WHEN + viewModel.start() + val viewState = viewModel.productDetailViewStateData.liveData.getOrAwaitValue() - // THEN - Assertions.assertThat(viewState.draftPassword).isEqualTo(password) - verify(productRepository, never()).fetchProductPassword(any()) - } + // THEN + Assertions.assertThat(viewState.draftPassword).isEqualTo(password) + verify(productRepository, never()).fetchProductPassword(any()) + } @Test - fun `given product password API uses WPCOM, when product details are fetched, then fetch password from the API`() = testBlocking { - // GIVEN - val password = "password" - whenever(determineProductPasswordApi.invoke()).thenReturn(ProductPasswordApi.WPCOM) - whenever(productRepository.getProductAsync(any())).thenReturn(product) - whenever(productRepository.fetchProductPassword(any())).thenReturn(password) + fun `given product password API uses WPCOM, when product details are fetched, then fetch password from the API`() = + testBlocking { + // GIVEN + val password = "password" + whenever(determineProductPasswordApi.invoke()).thenReturn(ProductPasswordApi.WPCOM) + whenever(productRepository.getProductAggregate(any())).thenReturn(productAggregate) + whenever(productRepository.fetchProductPassword(any())).thenReturn(password) - // WHEN - viewModel.start() - val viewState = viewModel.productDetailViewStateData.liveData.getOrAwaitValue() + // WHEN + viewModel.start() + val viewState = viewModel.productDetailViewStateData.liveData.getOrAwaitValue() - // THEN - Assertions.assertThat(viewState.draftPassword).isEqualTo(password) - verify(productRepository).fetchProductPassword(any()) - } + // THEN + Assertions.assertThat(viewState.draftPassword).isEqualTo(password) + verify(productRepository).fetchProductPassword(any()) + } @Test - fun `given product password API uses WPCOM, when product is saved, then update password using WPCOM API`() = testBlocking { - // GIVEN - val password = "password" - whenever(determineProductPasswordApi.invoke()).thenReturn(ProductPasswordApi.WPCOM) - whenever(productRepository.getProductAsync(any())).thenReturn(product) - whenever(productRepository.fetchProductPassword(any())).thenReturn(password) - whenever(productRepository.updateProduct(any())).thenReturn(Pair(true, null)) - - // WHEN - viewModel.start() - viewModel.updateProductVisibility(ProductVisibility.PASSWORD_PROTECTED, "newPassword") - viewModel.onSaveButtonClicked() + fun `given product password API uses WPCOM, when product is saved, then update password using WPCOM API`() = + testBlocking { + // GIVEN + val password = "password" + whenever(determineProductPasswordApi.invoke()).thenReturn(ProductPasswordApi.WPCOM) + whenever(productRepository.getProductAggregate(any())).thenReturn(productAggregate) + whenever(productRepository.fetchProductPassword(any())).thenReturn(password) + whenever(productRepository.updateProduct(any())).thenReturn(Pair(true, null)) + + // WHEN + viewModel.start() + viewModel.updateProductVisibility(ProductVisibility.PASSWORD_PROTECTED, "newPassword") + viewModel.onSaveButtonClicked() - // THEN - verify(productRepository).updateProductPassword(eq(product.remoteId), eq("newPassword")) - } + // THEN + verify(productRepository).updateProductPassword(eq(productAggregate.remoteId), eq("newPassword")) + } @Test - fun `given product password API is not supported, when product details are fetched, then password is empty`() = testBlocking { - // GIVEN - whenever(determineProductPasswordApi.invoke()).thenReturn(ProductPasswordApi.UNSUPPORTED) - whenever(productRepository.getProductAsync(any())).thenReturn(product) + fun `given product password API is not supported, when product details are fetched, then password is empty`() = + testBlocking { + // GIVEN + whenever(determineProductPasswordApi.invoke()).thenReturn(ProductPasswordApi.UNSUPPORTED) + whenever(productRepository.getProductAggregate(any())).thenReturn(productAggregate) - // WHEN - viewModel.start() - val viewState = viewModel.productDetailViewStateData.liveData.getOrAwaitValue() + // WHEN + viewModel.start() + val viewState = viewModel.productDetailViewStateData.liveData.getOrAwaitValue() - // THEN - Assertions.assertThat(viewState.draftPassword).isNull() - verify(productRepository, never()).fetchProductPassword(any()) - } + // THEN + Assertions.assertThat(viewState.draftPassword).isNull() + verify(productRepository, never()).fetchProductPassword(any()) + } private val productsDraft get() = viewModel.productDetailViewStateData.liveData.value?.productDraft diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModel_AddFlowTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModel_AddFlowTest.kt index 6658f9f31873..b4180796084b 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModel_AddFlowTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModel_AddFlowTest.kt @@ -7,6 +7,7 @@ import com.woocommerce.android.analytics.AnalyticsTrackerWrapper import com.woocommerce.android.media.MediaFilesRepository import com.woocommerce.android.media.ProductImagesServiceWrapper import com.woocommerce.android.model.Product +import com.woocommerce.android.model.ProductAggregate import com.woocommerce.android.tools.NetworkStatus import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.ui.blaze.IsBlazeEnabled @@ -222,7 +223,7 @@ class ProductDetailViewModel_AddFlowTest : BaseUnitTest() { @Test fun `Display success message on add product success`() = testBlocking { // given - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(ProductAggregate(product)).whenever(productRepository).getProductAggregate(any()) doReturn(Pair(true, 1L)).whenever(productRepository).addProduct(any()) var successSnackbarShown = false @@ -244,7 +245,7 @@ class ProductDetailViewModel_AddFlowTest : BaseUnitTest() { viewModel.onPublishButtonClicked() // then - verify(productRepository, times(1)).getProductAsync(1L) + verify(productRepository, times(1)).getProductAggregate(1L) Assertions.assertThat(successSnackbarShown).isTrue() Assertions.assertThat(productData?.isProgressDialogShown).isFalse() @@ -308,7 +309,7 @@ class ProductDetailViewModel_AddFlowTest : BaseUnitTest() { fun `Display correct message on updating a freshly added product`() = testBlocking { // given - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(ProductAggregate(product)).whenever(productRepository).getProductAggregate(any()) doReturn(Pair(true, 1L)).whenever(productRepository).addProduct(any()) var successSnackbarShown = false @@ -330,7 +331,7 @@ class ProductDetailViewModel_AddFlowTest : BaseUnitTest() { viewModel.onPublishButtonClicked() // then - verify(productRepository, times(1)).getProductAsync(1L) + verify(productRepository, times(1)).getProductAggregate(1L) Assertions.assertThat(successSnackbarShown).isTrue() Assertions.assertThat(productData?.isProgressDialogShown).isFalse() @@ -399,7 +400,7 @@ class ProductDetailViewModel_AddFlowTest : BaseUnitTest() { @Test fun `when a new product is saved, then assign the new id to ongoing image uploads`() = testBlocking { doReturn(Pair(true, PRODUCT_REMOTE_ID)).whenever(productRepository).addProduct(any()) - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(product).whenever(productRepository).getProductAggregate(any()) savedState = ProductDetailFragmentArgs( mode = ProductDetailFragment.Mode.AddNewProduct ).toSavedStateHandle() @@ -454,7 +455,7 @@ class ProductDetailViewModel_AddFlowTest : BaseUnitTest() { fun `given a product is under creation, when clicking on save product, then assign uploads to the new id`() = testBlocking { doReturn(Pair(true, PRODUCT_REMOTE_ID)).whenever(productRepository).addProduct(any()) - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(product).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } viewModel.start() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6afe89f43230..6a333a83ce28 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -86,7 +86,7 @@ stripe-terminal = '3.7.1' tinder-statemachine = '0.2.0' wiremock = '2.26.3' wordpress-aztec = 'v2.1.4' -wordpress-fluxc = '2.99.0' +wordpress-fluxc = 'trunk-7475ad2070b90ab63276656225890fd312188011' wordpress-login = '1.18.0' wordpress-libaddressinput = '0.0.2' wordpress-mediapicker = '0.3.1'