diff --git a/app/code/Magento/Review/Model/Review/Config.php b/app/code/Magento/Review/Model/Review/Config.php new file mode 100644 index 0000000000000..a3082503b1391 --- /dev/null +++ b/app/code/Magento/Review/Model/Review/Config.php @@ -0,0 +1,46 @@ +scopeConfig = $scopeConfig; + } + + /** + * Check whether the reviews are enabled or not + * + * @return bool + */ + public function isEnabled(): bool + { + return $this->scopeConfig->isSetFlag( + self::XML_PATH_REVIEW_ACTIVE, + ScopeInterface::SCOPE_STORES + ); + } +} diff --git a/app/code/Magento/ReviewGraphQl/Mapper/ReviewDataMapper.php b/app/code/Magento/ReviewGraphQl/Mapper/ReviewDataMapper.php new file mode 100644 index 0000000000000..6a06fbfc4102c --- /dev/null +++ b/app/code/Magento/ReviewGraphQl/Mapper/ReviewDataMapper.php @@ -0,0 +1,36 @@ + $review->getData('title'), + 'text' => $review->getData('detail'), + 'nickname' => $review->getData('nickname'), + 'created_at' => $review->getData('created_at'), + 'sku' => $review->getSku(), + 'model' => $review + ]; + } +} diff --git a/app/code/Magento/ReviewGraphQl/Model/DataProvider/AggregatedReviewsDataProvider.php b/app/code/Magento/ReviewGraphQl/Model/DataProvider/AggregatedReviewsDataProvider.php new file mode 100644 index 0000000000000..5412c670b4800 --- /dev/null +++ b/app/code/Magento/ReviewGraphQl/Model/DataProvider/AggregatedReviewsDataProvider.php @@ -0,0 +1,75 @@ +reviewDataMapper = $reviewDataMapper; + } + + /** + * Get reviews result + * + * @param ProductCollection|ReviewCollection $reviewsCollection + * + * @return array + */ + public function getData($reviewsCollection): array + { + if ($reviewsCollection->getPageSize()) { + $maxPages = ceil($reviewsCollection->getSize() / $reviewsCollection->getPageSize()); + } else { + $maxPages = 0; + } + + $currentPage = $reviewsCollection->getCurPage(); + if ($reviewsCollection->getCurPage() > $maxPages && $reviewsCollection->getSize() > 0) { + $currentPage = new GraphQlInputException( + __( + 'currentPage value %1 specified is greater than the number of pages available.', + [$maxPages] + ) + ); + } + + $items = []; + foreach ($reviewsCollection->getItems() as $item) { + $items[] = $this->reviewDataMapper->map($item); + } + + return [ + 'total_count' => $reviewsCollection->getSize(), + 'items' => $items, + 'page_info' => [ + 'page_size' => $reviewsCollection->getPageSize(), + 'current_page' => $currentPage, + 'total_pages' => $maxPages + ] + ]; + } +} diff --git a/app/code/Magento/ReviewGraphQl/Model/DataProvider/CustomerReviewsDataProvider.php b/app/code/Magento/ReviewGraphQl/Model/DataProvider/CustomerReviewsDataProvider.php new file mode 100644 index 0000000000000..42adc8009c010 --- /dev/null +++ b/app/code/Magento/ReviewGraphQl/Model/DataProvider/CustomerReviewsDataProvider.php @@ -0,0 +1,60 @@ +collectionFactory = $collectionFactory; + } + + /** + * Get customer reviews + * + * @param int $customerId + * @param int $currentPage + * @param int $pageSize + * + * @return ReviewsCollection + */ + public function getData(int $customerId, int $currentPage, int $pageSize): ReviewsCollection + { + /** @var ReviewsCollection $reviewsCollection */ + $reviewsCollection = $this->collectionFactory->create(); + $reviewsCollection + ->addCustomerFilter($customerId) + ->setPageSize($pageSize) + ->setCurPage($currentPage) + ->setDateOrder(); + $reviewsCollection->getSelect()->join( + ['cpe' => $reviewsCollection->getTable('catalog_product_entity')], + 'cpe.entity_id = main_table.entity_pk_value', + ['sku'] + ); + $reviewsCollection->addRateVotes(); + + return $reviewsCollection; + } +} diff --git a/app/code/Magento/ReviewGraphQl/Model/DataProvider/ProductReviewsDataProvider.php b/app/code/Magento/ReviewGraphQl/Model/DataProvider/ProductReviewsDataProvider.php new file mode 100644 index 0000000000000..635605f9091ed --- /dev/null +++ b/app/code/Magento/ReviewGraphQl/Model/DataProvider/ProductReviewsDataProvider.php @@ -0,0 +1,60 @@ +collectionFactory = $collectionFactory; + } + + /** + * Get product reviews + * + * @param int $productId + * @param int $currentPage + * @param int $pageSize + * + * @return Collection + */ + public function getData(int $productId, int $currentPage, int $pageSize): Collection + { + /** @var Collection $reviewsCollection */ + $reviewsCollection = $this->collectionFactory->create() + ->addStatusFilter(Review::STATUS_APPROVED) + ->addEntityFilter(Review::ENTITY_PRODUCT_CODE, $productId) + ->setPageSize($pageSize) + ->setCurPage($currentPage) + ->setDateOrder(); + $reviewsCollection->getSelect()->join( + ['cpe' => $reviewsCollection->getTable('catalog_product_entity')], + 'cpe.entity_id = main_table.entity_pk_value', + ['sku'] + ); + $reviewsCollection->addRateVotes(); + + return $reviewsCollection; + } +} diff --git a/app/code/Magento/ReviewGraphQl/Model/DataProvider/ReviewRatingsDataProvider.php b/app/code/Magento/ReviewGraphQl/Model/DataProvider/ReviewRatingsDataProvider.php new file mode 100644 index 0000000000000..82e0f73b1c774 --- /dev/null +++ b/app/code/Magento/ReviewGraphQl/Model/DataProvider/ReviewRatingsDataProvider.php @@ -0,0 +1,56 @@ +voteCollectionFactory = $voteCollectionFactory; + } + + /** + * Providing rating votes + * + * @param int $reviewId + * + * @return array + */ + public function getData(int $reviewId): array + { + /** @var VoteCollection $ratingVotes */ + $ratingVotes = $this->voteCollectionFactory->create(); + $ratingVotes->setReviewFilter($reviewId); + $ratingVotes->addRatingInfo(); + + $data = []; + + foreach ($ratingVotes->getItems() as $ratingVote) { + $data[] = [ + 'name' => $ratingVote->getData('rating_code'), + 'value' => $ratingVote->getData('value') + ]; + } + + return $data; + } +} diff --git a/app/code/Magento/ReviewGraphQl/Model/Resolver/CreateProductReview.php b/app/code/Magento/ReviewGraphQl/Model/Resolver/CreateProductReview.php new file mode 100644 index 0000000000000..9b0171c3b700a --- /dev/null +++ b/app/code/Magento/ReviewGraphQl/Model/Resolver/CreateProductReview.php @@ -0,0 +1,118 @@ +addReviewToProduct = $addReviewToProduct; + $this->reviewDataMapper = $reviewDataMapper; + $this->reviewHelper = $reviewHelper; + $this->reviewsConfig = $reviewsConfig; + } + + /** + * Resolve product review ratings + * + * @param Field $field + * @param ContextInterface $context + * @param ResolveInfo $info + * @param array|null $value + * @param array|null $args + * + * @return array[]|Value|mixed + * + * @throws GraphQlAuthorizationException + * @throws GraphQlNoSuchEntityException + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ) { + if (false === $this->reviewsConfig->isEnabled()) { + throw new GraphQlAuthorizationException(__('Creating product reviews are not currently available.')); + } + + $input = $args['input']; + $customerId = null; + + if (false !== $context->getExtensionAttributes()->getIsCustomer()) { + $customerId = (int) $context->getUserId(); + } + + if (!$customerId && !$this->reviewHelper->getIsGuestAllowToWrite()) { + throw new GraphQlAuthorizationException(__('Guest customers aren\'t allowed to add product reviews.')); + } + + $sku = $input['sku']; + $ratings = $input['ratings']; + $data = [ + 'nickname' => $input['nickname'], + 'title' => $input['summary'], + 'detail' => $input['text'], + ]; + /** @var StoreInterface $store */ + $store = $context->getExtensionAttributes()->getStore(); + $review = $this->addReviewToProduct->execute($data, $ratings, $sku, $customerId, (int) $store->getId()); + + return ['review' => $this->reviewDataMapper->map($review)]; + } +} diff --git a/app/code/Magento/ReviewGraphQl/Model/Resolver/Customer/Reviews.php b/app/code/Magento/ReviewGraphQl/Model/Resolver/Customer/Reviews.php new file mode 100644 index 0000000000000..8c0bca63f8efc --- /dev/null +++ b/app/code/Magento/ReviewGraphQl/Model/Resolver/Customer/Reviews.php @@ -0,0 +1,90 @@ +customerReviewsDataProvider = $customerReviewsDataProvider; + $this->aggregatedReviewsDataProvider = $aggregatedReviewsDataProvider; + } + + /** + * Resolves the customer reviews + * + * @param Field $field + * @param ContextInterface $context + * @param ResolveInfo $info + * @param array|null $value + * @param array|null $args + * + * @return array|Value|mixed + * + * @throws GraphQlInputException + * @throws GraphQlAuthorizationException + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ) { + if (false === $context->getExtensionAttributes()->getIsCustomer()) { + throw new GraphQlAuthorizationException(__('The current customer isn\'t authorized.')); + } + + if ($args['currentPage'] < 1) { + throw new GraphQlInputException(__('currentPage value must be greater than 0.')); + } + + if ($args['pageSize'] < 1) { + throw new GraphQlInputException(__('pageSize value must be greater than 0.')); + } + + $reviewsCollection = $this->customerReviewsDataProvider->getData( + (int) $context->getUserId(), + $args['currentPage'], + $args['pageSize'] + ); + + return $this->aggregatedReviewsDataProvider->getData($reviewsCollection); + } +} diff --git a/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/RatingSummary.php b/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/RatingSummary.php new file mode 100644 index 0000000000000..eed5034c59daa --- /dev/null +++ b/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/RatingSummary.php @@ -0,0 +1,92 @@ +summaryFactory = $summaryFactory; + $this->reviewsConfig = $reviewsConfig; + } + + /** + * Resolves the product rating summary + * + * @param Field $field + * @param ContextInterface $context + * @param ResolveInfo $info + * @param array|null $value + * @param array|null $args + * + * @return float + * + * @throws GraphQlInputException + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ): float { + if (false === $this->reviewsConfig->isEnabled()) { + return 0; + } + + if (!isset($value['model'])) { + throw new GraphQlInputException(__('Value must contain "model" property.')); + } + + /** @var StoreInterface $store */ + $store = $context->getExtensionAttributes()->getStore(); + + /** @var Product $product */ + $product = $value['model']; + + try { + $summary = $this->summaryFactory->create()->setStoreId($store->getId())->load($product->getId()); + + return floatval($summary->getData('rating_summary')); + } catch (Exception $e) { + throw new GraphQlInputException(__('Couldn\'t get the product rating summary.')); + } + } +} diff --git a/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/Review/AverageRating.php b/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/Review/AverageRating.php new file mode 100644 index 0000000000000..2e0d428b47873 --- /dev/null +++ b/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/Review/AverageRating.php @@ -0,0 +1,78 @@ +ratingFactory = $ratingFactory; + } + + /** + * Resolves review average rating + * + * @param Field $field + * @param ContextInterface $context + * @param ResolveInfo $info + * @param array|null $value + * @param array|null $args + * + * @return float|Value|mixed + * + * @throws GraphQlInputException + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ) { + if (!isset($value['model'])) { + throw new GraphQlInputException(__('Value must contain "model" property.')); + } + + /** @var Review $review */ + $review = $value['model']; + $summary = $this->ratingFactory->create()->getReviewSummary($review->getId()); + $averageRating = $summary->getSum() ?: 0; + + if ($averageRating > 0) { + $averageRating = (float) number_format( + (int) $summary->getSum() / (int) $summary->getCount(), + 2 + ); + } + + return $averageRating; + } +} diff --git a/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/Review/RatingBreakdown.php b/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/Review/RatingBreakdown.php new file mode 100644 index 0000000000000..a51bd0420dda9 --- /dev/null +++ b/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/Review/RatingBreakdown.php @@ -0,0 +1,69 @@ +reviewRatingsDataProvider = $reviewRatingsDataProvider; + } + + /** + * Resolves the rating breakdown + * + * @param Field $field + * @param ContextInterface $context + * @param ResolveInfo $info + * @param array|null $value + * @param array|null $args + * + * @return array|Value|mixed + * + * @throws GraphQlInputException + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ) { + if (!isset($value['model'])) { + throw new GraphQlInputException(__('Value must contain "model" property.')); + } + + /** @var Review $review */ + $review = $value['model']; + + return $this->reviewRatingsDataProvider->getData((int) $review->getId()); + } +} diff --git a/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/ReviewCount.php b/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/ReviewCount.php new file mode 100644 index 0000000000000..dfa62adf0266e --- /dev/null +++ b/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/ReviewCount.php @@ -0,0 +1,80 @@ +review = $review; + $this->reviewsConfig = $reviewsConfig; + } + + /** + * Resolves the product total reviews + * + * @param Field $field + * @param ContextInterface $context + * @param ResolveInfo $info + * @param array|null $value + * @param array|null $args + * + * @return int|Value|mixed + * + * @throws GraphQlInputException + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ) { + if (false === $this->reviewsConfig->isEnabled()) { + return 0; + } + + if (!isset($value['model'])) { + throw new GraphQlInputException(__('Value must contain "model" property.')); + } + + /** @var Product $product */ + $product = $value['model']; + + return (int) $this->review->getTotalReviews($product->getId(), true); + } +} diff --git a/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/Reviews.php b/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/Reviews.php new file mode 100644 index 0000000000000..72eea5e6b3bd2 --- /dev/null +++ b/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/Reviews.php @@ -0,0 +1,104 @@ +productReviewsDataProvider = $productReviewsDataProvider; + $this->aggregatedReviewsDataProvider = $aggregatedReviewsDataProvider; + $this->reviewsConfig = $reviewsConfig; + } + + /** + * Resolves the product reviews + * + * @param Field $field + * @param ContextInterface $context + * @param ResolveInfo $info + * @param array|null $value + * @param array|null $args + * + * @return array|Value|mixed + * + * @throws GraphQlInputException + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ) { + if (false === $this->reviewsConfig->isEnabled()) { + return ['items' => []]; + } + + if (!isset($value['model'])) { + throw new GraphQlInputException(__('Value must contain "model" property.')); + } + + if ($args['currentPage'] < 1) { + throw new GraphQlInputException(__('currentPage value must be greater than 0.')); + } + + if ($args['pageSize'] < 1) { + throw new GraphQlInputException(__('pageSize value must be greater than 0.')); + } + + /** @var Product $product */ + $product = $value['model']; + $reviewsCollection = $this->productReviewsDataProvider->getData( + (int) $product->getId(), + $args['currentPage'], + $args['pageSize'] + ); + + return $this->aggregatedReviewsDataProvider->getData($reviewsCollection); + } +} diff --git a/app/code/Magento/ReviewGraphQl/Model/Resolver/ProductReviewRatingValueMetadata.php b/app/code/Magento/ReviewGraphQl/Model/Resolver/ProductReviewRatingValueMetadata.php new file mode 100644 index 0000000000000..e7e6574e7e7ae --- /dev/null +++ b/app/code/Magento/ReviewGraphQl/Model/Resolver/ProductReviewRatingValueMetadata.php @@ -0,0 +1,56 @@ + $item->getData('value'), 'value_id' => base64_encode($item->getData('option_id'))]; + } + + return $data; + } +} diff --git a/app/code/Magento/ReviewGraphQl/Model/Resolver/ProductReviewRatingsMetadata.php b/app/code/Magento/ReviewGraphQl/Model/Resolver/ProductReviewRatingsMetadata.php new file mode 100644 index 0000000000000..2cf536255baf7 --- /dev/null +++ b/app/code/Magento/ReviewGraphQl/Model/Resolver/ProductReviewRatingsMetadata.php @@ -0,0 +1,92 @@ +ratingCollectionFactory = $ratingCollectionFactory; + $this->reviewsConfig = $reviewsConfig; + } + + /** + * Resolve product review ratings + * + * @param Field $field + * @param ContextInterface $context + * @param ResolveInfo $info + * @param array|null $value + * @param array|null $args + * + * @return array[]|Value|mixed + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ) { + if (false === $this->reviewsConfig->isEnabled()) { + return ['items' => []]; + } + + $items = []; + /** @var StoreInterface $store */ + $store = $context->getExtensionAttributes()->getStore(); + + /** @var RatingCollection $ratingCollection */ + $ratingCollection = $this->ratingCollectionFactory->create(); + $ratingCollection->addEntityFilter(Review::ENTITY_PRODUCT_CODE) + ->setStoreFilter($store->getId()) + ->setActiveFilter(true) + ->setPositionOrder() + ->addOptionToItems(); + + foreach ($ratingCollection->getItems() as $item) { + $items[] = [ + 'id' => base64_encode($item->getData('rating_id')), + 'name' => $item->getData('rating_code'), + 'values' => $item->getData('options') + ]; + } + + return ['items' => $items]; + } +} diff --git a/app/code/Magento/ReviewGraphQl/Model/Review/AddReviewToProduct.php b/app/code/Magento/ReviewGraphQl/Model/Review/AddReviewToProduct.php new file mode 100644 index 0000000000000..1b744e717a782 --- /dev/null +++ b/app/code/Magento/ReviewGraphQl/Model/Review/AddReviewToProduct.php @@ -0,0 +1,159 @@ +productRepository = $productRepository; + $this->reviewFactory = $reviewFactory; + $this->ratingFactory = $ratingFactory; + $this->ratingOptionCollectionFactory = $ratingOptionCollectionFactory; + } + + /** + * Add review to product + * + * @param array $data + * @param array $ratings + * @param string $sku + * @param int|null $customerId + * @param int $storeId + * + * @return Review + * + * @throws GraphQlNoSuchEntityException + */ + public function execute(array $data, array $ratings, string $sku, ?int $customerId, int $storeId): Review + { + $review = $this->reviewFactory->create()->setData($data); + $review->unsetData('review_id'); + $productId = $this->getProductIdBySku($sku); + $review->setEntityId($review->getEntityIdByCode(Review::ENTITY_PRODUCT_CODE)) + ->setEntityPkValue($productId) + ->setStatusId(Review::STATUS_PENDING) + ->setCustomerId($customerId) + ->setStoreId($storeId) + ->setStores([$storeId]) + ->save(); + $this->addReviewRatingVotes($ratings, (int) $review->getId(), $customerId, $productId); + $review->aggregate(); + $votesCollection = $this->getReviewRatingVotes((int) $review->getId(), $storeId); + $review->setData('rating_votes', $votesCollection); + $review->setData('sku', $sku); + + return $review; + } + + /** + * Get Product ID + * + * @param string $sku + * + * @return int|null + * + * @throws GraphQlNoSuchEntityException + */ + private function getProductIdBySku(string $sku): ?int + { + try { + $product = $this->productRepository->get($sku, false, null, true); + + return (int) $product->getId(); + } catch (NoSuchEntityException $e) { + throw new GraphQlNoSuchEntityException(__('Could not find a product with SKU "%sku"', ['sku' => $sku])); + } + } + + /** + * Add review rating votes + * + * @param array $ratings + * @param int $reviewId + * @param int|null $customerId + * @param int $productId + * + * @return void + * + * @phpcs:disable Magento2.Functions.DiscouragedFunction + */ + private function addReviewRatingVotes(array $ratings, int $reviewId, ?int $customerId, int $productId): void + { + foreach ($ratings as $option) { + $ratingId = $option['id']; + $optionId = $option['value_id']; + /** @var Rating $ratingModel */ + $ratingModel = $this->ratingFactory->create(); + $ratingModel->setRatingId(base64_decode($ratingId)) + ->setReviewId($reviewId) + ->setCustomerId($customerId) + ->addOptionVote(base64_decode($optionId), $productId); + } + } + + /** + * Get review rating votes + * + * @param int $reviewId + * @param int $storeId + * + * @return OptionVoteCollection + */ + private function getReviewRatingVotes(int $reviewId, int $storeId): OptionVoteCollection + { + /** @var OptionVoteCollection $votesCollection */ + $votesCollection = $this->ratingOptionCollectionFactory->create(); + $votesCollection->setReviewFilter($reviewId)->setStoreFilter($storeId)->addRatingInfo($storeId); + + return $votesCollection; + } +} diff --git a/app/code/Magento/ReviewGraphQl/README.md b/app/code/Magento/ReviewGraphQl/README.md new file mode 100644 index 0000000000000..bf9563b87c9b2 --- /dev/null +++ b/app/code/Magento/ReviewGraphQl/README.md @@ -0,0 +1,3 @@ +# ReviewGraphQl + +**ReviewGraphQl** provides endpoints for getting and creating the Product reviews by guest and logged in customers. diff --git a/app/code/Magento/ReviewGraphQl/composer.json b/app/code/Magento/ReviewGraphQl/composer.json new file mode 100644 index 0000000000000..819ddefd76213 --- /dev/null +++ b/app/code/Magento/ReviewGraphQl/composer.json @@ -0,0 +1,28 @@ +{ + "name": "magento/module-review-graph-ql", + "description": "N/A", + "type": "magento2-module", + "require": { + "php": "~7.3.0||~7.4.0", + "magento/module-catalog": "*", + "magento/module-review": "*", + "magento/module-store": "*", + "magento/framework": "*" + }, + "suggest": { + "magento/module-graph-ql": "*", + "magento/module-graph-ql-cache": "*" + }, + "license": [ + "OSL-3.0", + "AFL-3.0" + ], + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "Magento\\ReviewGraphQl\\": "" + } + } +} diff --git a/app/code/Magento/ReviewGraphQl/etc/module.xml b/app/code/Magento/ReviewGraphQl/etc/module.xml new file mode 100644 index 0000000000000..c098ee5094760 --- /dev/null +++ b/app/code/Magento/ReviewGraphQl/etc/module.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/app/code/Magento/ReviewGraphQl/etc/schema.graphqls b/app/code/Magento/ReviewGraphQl/etc/schema.graphqls new file mode 100644 index 0000000000000..14b4fc60e8b09 --- /dev/null +++ b/app/code/Magento/ReviewGraphQl/etc/schema.graphqls @@ -0,0 +1,78 @@ +# Copyright © Magento, Inc. All rights reserved. +# See COPYING.txt for license details. + +interface ProductInterface { + rating_summary: Float! @doc(description: "The average of all the ratings given to the product.") @resolver(class: "Magento\\ReviewGraphQl\\Model\\Resolver\\Product\\RatingSummary") + review_count: Int! @doc(description: "The total count of all the reviews given to the product.") @resolver(class: "Magento\\ReviewGraphQl\\Model\\Resolver\\Product\\ReviewCount") + reviews( + pageSize: Int = 20 @doc(description: "Specifies the maximum number of results to return at once."), + currentPage: Int = 1 @doc(description: "Specifies which page of results to return."), + ): ProductReviews! @doc(description: "The list of products reviews.") @resolver(class: "Magento\\ReviewGraphQl\\Model\\Resolver\\Product\\Reviews") +} + +type ProductReviews { + items: [ProductReview]! @doc(description: "An array of product reviews.") + page_info: SearchResultPageInfo! @doc(description: "Metadata for pagination rendering.") +} + +type ProductReview @doc(description: "Details of a product review") { + product: ProductInterface! @doc(description: "Contains details about the reviewed product") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product") + summary: String! @doc(description: "The summary (title) of the review") + text: String! @doc(description: "The review text.") + nickname: String! @doc(description: "The customer's nickname. Defaults to the customer name, if logged in") + created_at: String! @doc(description: "Date indicating when the review was created.") + average_rating: Float! @doc(description: "The average rating for product review.") @resolver(class: "Magento\\ReviewGraphQl\\Model\\Resolver\\Product\\Review\\AverageRating") + ratings_breakdown: [ProductReviewRating!]! @doc(description: "An array of ratings by rating category, such as quality, price, and value") @resolver(class: "Magento\\ReviewGraphQl\\Model\\Resolver\\Product\\Review\\RatingBreakdown") +} + +type ProductReviewRating { + name: String! @doc(description: "The label assigned to an aspect of a product that is being rated, such as quality or price") + value: String! @doc(description: "The rating value given by customer. By default, possible values range from 1 to 5.") +} + +type Query { + productReviewRatingsMetadata: ProductReviewRatingsMetadata! @doc(description: "Retrieves metadata required by clients to render the Reviews section.") @resolver(class: "Magento\\ReviewGraphQl\\Model\\Resolver\\ProductReviewRatingsMetadata") +} + +type ProductReviewRatingsMetadata { + items: [ProductReviewRatingMetadata!]! @doc(description: "List of product reviews sorted by position") +} + +type ProductReviewRatingMetadata { + id: String! @doc(description: "Base64 encoded rating ID.") + name: String! @doc(description: "The label assigned to an aspect of a product that is being rated, such as quality or price") + values: [ProductReviewRatingValueMetadata!]! @doc(description: "List of product review ratings sorted by position.") @resolver(class: "Magento\\ReviewGraphQl\\Model\\Resolver\\ProductReviewRatingValueMetadata") +} + +type ProductReviewRatingValueMetadata { + value_id: String! @doc(description: "Base 64 encoded rating value id.") + value: String! @doc(description: "e.g Good, Perfect, 3, 4, 5") +} + +type Customer { + reviews( + pageSize: Int = 20 @doc(description: "Specifies the maximum number of results to return at once."), + currentPage: Int = 1 @doc(description: "Specifies which page of results to return."), + ): ProductReviews! @doc(description: "Contains the customer's product reviews") @resolver(class: "Magento\\ReviewGraphQl\\Model\\Resolver\\Customer\\Reviews") +} + +type Mutation { + createProductReview(input: CreateProductReviewInput!): CreateProductReviewOutput! @doc(description: "Creates a product review for the specified SKU") @resolver(class: "Magento\\ReviewGraphQl\\Model\\Resolver\\CreateProductReview") +} + +type CreateProductReviewOutput { + review: ProductReview! @doc(description: "Contains the completed product review") +} + +input CreateProductReviewInput { + sku: String! @doc(description: "The SKU of the reviewed product") + nickname: String! @doc(description: "The customer's nickname. Defaults to the customer name, if logged in") + summary: String! @doc(description: "The summary (title) of the review") + text: String! @doc(description: "The review text.") + ratings: [ProductReviewRatingInput!]! @doc(description: "Ratings details by category. e.g price: 5, quality: 4 etc") +} + +input ProductReviewRatingInput { + id: String! @doc(description: "Base64 encoded rating ID.") + value_id: String! @doc(description: "Base 64 encoded rating value id.") +} diff --git a/app/code/Magento/ReviewGraphQl/registration.php b/app/code/Magento/ReviewGraphQl/registration.php new file mode 100644 index 0000000000000..8fb6535902edf --- /dev/null +++ b/app/code/Magento/ReviewGraphQl/registration.php @@ -0,0 +1,9 @@ +customerTokenService = $objectManager->get(CustomerTokenServiceInterface::class); + $this->customerRepository = $objectManager->get(CustomerRepositoryInterface::class); + $this->reviewCollectionFactory = $objectManager->get(ReviewCollectionFactory::class); + $this->registry = $objectManager->get(Registry::class); + } + + /** + * Test adding a product review as guest and logged in customer + * + * @param string $customerName + * @param bool $isGuest + * + * @magentoApiDataFixture Magento/Review/_files/set_position_and_add_store_to_all_ratings.php + * @magentoApiDataFixture Magento/GraphQl/Catalog/_files/simple_product.php + * @magentoApiDataFixture Magento/Customer/_files/customer.php + * + * @dataProvider customerDataProvider + */ + public function testCustomerAddProductReviews(string $customerName, bool $isGuest) + { + $productSku = 'simple_product'; + $query = $this->getQuery($productSku, $customerName); + $headers = []; + + if (!$isGuest) { + $headers = $this->getHeaderMap(); + } + + $response = $this->graphQlMutation($query, [], '', $headers); + + $expectedResult = [ + 'nickname' => $customerName, + 'summary' => 'Summary Test', + 'text' => 'Text Test', + 'average_rating' => 66.67, + 'ratings_breakdown' => [ + [ + 'name' => 'Price', + 'value' => 3 + ], [ + 'name' => 'Quality', + 'value' => 2 + ], [ + 'name' => 'Value', + 'value' => 5 + ] + ] + ]; + self::assertArrayHasKey('createProductReview', $response); + self::assertArrayHasKey('review', $response['createProductReview']); + self::assertEquals($expectedResult, $response['createProductReview']['review']); + } + + /** + * @magentoConfigFixture default_store catalog/review/allow_guest 0 + */ + public function testAddProductReviewGuestIsNotAllowed() + { + $productSku = 'simple_product'; + $customerName = 'John Doe'; + $query = $this->getQuery($productSku, $customerName); + self::expectExceptionMessage('Guest customers aren\'t allowed to add product reviews.'); + $this->graphQlMutation($query); + } + + /** + * Removing the recently added product reviews + */ + public function tearDown(): void + { + $this->registry->unregister('isSecureArea'); + $this->registry->register('isSecureArea', true); + $productId = 1; + /** @var Collection $reviewsCollection */ + $reviewsCollection = $this->reviewCollectionFactory->create(); + $reviewsCollection->addEntityFilter(Review::ENTITY_PRODUCT_CODE, $productId); + /** @var Review $review */ + foreach ($reviewsCollection as $review) { + $review->delete(); + } + $this->registry->unregister('isSecureArea'); + $this->registry->register('isSecureArea', false); + + parent::tearDown(); + } + + /** + * @return array + */ + public function customerDataProvider(): array + { + return [ + 'Guest Customer' => ['John Doe', true], + 'Logged In Customer' => ['John', false], + ]; + } + + /** + * @param string $username + * @param string $password + * + * @return array + * + * @throws AuthenticationException + */ + private function getHeaderMap(string $username = 'customer@example.com', string $password = 'password'): array + { + $customerToken = $this->customerTokenService->createCustomerAccessToken($username, $password); + + return ['Authorization' => 'Bearer ' . $customerToken]; + } + + /** + * Get mutation query + * + * @param string $sku + * @param string $customerName + * + * @return string + */ + private function getQuery(string $sku, string $customerName): string + { + return << 'MTI=', + 'value' => "2" + ],[ + 'value_id' => 'MTM=', + 'value' => "3" + ],[ + 'value_id' => 'MTQ=', + 'value' => "4" + ],[ + 'value_id' => 'MTU=', + 'value' => "5" + ] + ] + ], [ + 'id' => 'MQ==', + 'name' => 'Quality', + 'values' => [ + [ + 'value_id' => 'MQ==', + 'value' => "1" + ],[ + 'value_id' => 'Mg==', + 'value' => "2" + ],[ + 'value_id' => 'Mw==', + 'value' => "3" + ],[ + 'value_id' => 'NA==', + 'value' => "4" + ],[ + 'value_id' => 'NQ==', + 'value' => "5" + ] + ] + ], [ + 'id' => 'Mg==', + 'name' => 'Value', + 'values' => [ + [ + 'value_id' => 'Ng==', + 'value' => "1" + ],[ + 'value_id' => 'Nw==', + 'value' => "2" + ],[ + 'value_id' => 'OA==', + 'value' => "3" + ],[ + 'value_id' => 'OQ==', + 'value' => "4" + ],[ + 'value_id' => 'MTA=', + 'value' => "5" + ] + ] + ] + ]; + $response = $this->graphQlQuery($query); + self::assertArrayHasKey('productReviewRatingsMetadata', $response); + self::assertArrayHasKey('items', $response['productReviewRatingsMetadata']); + self::assertNotEmpty($response['productReviewRatingsMetadata']['items']); + self::assertEquals($expectedRatingItems, $response['productReviewRatingsMetadata']['items']); + } + + /** + * @magentoApiDataFixture Magento/Review/_files/different_reviews.php + */ + public function testProductReviewRatings() + { + $productSku = 'simple'; + /** @var ProductRepositoryInterface $productRepository */ + $productRepository = ObjectManager::getInstance()->get(ProductRepositoryInterface::class); + $product = $productRepository->get($productSku, false, null, true); + $summaryFactory = ObjectManager::getInstance()->get(SummaryFactory::class); + $storeId = ObjectManager::getInstance()->get(StoreManagerInterface::class)->getStore()->getId(); + $summary = $summaryFactory->create()->setStoreId($storeId)->load($product->getId()); + $query + = <<graphQlQuery($query); + self::assertArrayHasKey('products', $response); + self::assertArrayHasKey('items', $response['products']); + self::assertNotEmpty($response['products']['items']); + + $items = $response['products']['items']; + self::assertEquals($summary->getData('rating_summary'), $items[0]['rating_summary']); + self::assertEquals($summary->getData('reviews_count'), $items[0]['review_count']); + self::assertArrayHasKey('items', $items[0]['reviews']); + self::assertNotEmpty($items[0]['reviews']['items']); + } + + /** + * @magentoApiDataFixture Magento/Review/_files/customer_review_with_rating.php + */ + public function testCustomerReviewsAddedToProduct() + { + $query = << 'Nickname', + 'summary' => 'Review Summary', + 'text' => 'Review text', + 'average_rating' => 40, + 'ratings_breakdown' => [ + [ + 'name' => 'Quality', + 'value' => 2 + ],[ + 'name' => 'Value', + 'value' => 2 + ] + ] + ]; + $response = $this->graphQlQuery($query, [], '', $this->getHeaderMap()); + + self::assertArrayHasKey('customer', $response); + self::assertArrayHasKey('reviews', $response['customer']); + self::assertArrayHasKey('items', $response['customer']['reviews']); + self::assertNotEmpty($response['customer']['reviews']['items']); + self::assertEquals($expectedFirstItem, $response['customer']['reviews']['items'][0]); + } + + /** + * Removing the recently added product reviews + */ + public function tearDown(): void + { + $this->registry->unregister('isSecureArea'); + $this->registry->register('isSecureArea', true); + $productId = 1; + /** @var Collection $reviewsCollection */ + $reviewsCollection = $this->reviewCollectionFactory->create(); + $reviewsCollection->addEntityFilter(Review::ENTITY_PRODUCT_CODE, $productId); + /** @var Review $review */ + foreach ($reviewsCollection as $review) { + $review->delete(); + } + $this->registry->unregister('isSecureArea'); + $this->registry->register('isSecureArea', false); + + parent::tearDown(); + } + + /** + * @param string $username + * @param string $password + * + * @return array + * + * @throws AuthenticationException + */ + private function getHeaderMap(string $username = 'customer@example.com', string $password = 'password'): array + { + $customerToken = $this->customerTokenService->createCustomerAccessToken($username, $password); + + return ['Authorization' => 'Bearer ' . $customerToken]; + } +} diff --git a/dev/tests/integration/testsuite/Magento/Review/_files/customer_review_with_rating_rollback.php b/dev/tests/integration/testsuite/Magento/Review/_files/customer_review_with_rating_rollback.php new file mode 100644 index 0000000000000..0931d881a6fdc --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Review/_files/customer_review_with_rating_rollback.php @@ -0,0 +1,11 @@ +requireDataFixture('Magento/Customer/_files/customer_rollback.php'); +Resolver::getInstance()->requireDataFixture('Magento/Catalog/_files/product_simple_rollback.php'); diff --git a/dev/tests/integration/testsuite/Magento/Review/_files/different_reviews_rollback.php b/dev/tests/integration/testsuite/Magento/Review/_files/different_reviews_rollback.php new file mode 100644 index 0000000000000..328c1e229da5c --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Review/_files/different_reviews_rollback.php @@ -0,0 +1,10 @@ +requireDataFixture('Magento/Catalog/_files/product_simple_rollback.php'); diff --git a/dev/tests/integration/testsuite/Magento/Review/_files/set_position_and_add_store_to_all_ratings.php b/dev/tests/integration/testsuite/Magento/Review/_files/set_position_and_add_store_to_all_ratings.php new file mode 100644 index 0000000000000..0c097f62101f8 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Review/_files/set_position_and_add_store_to_all_ratings.php @@ -0,0 +1,31 @@ +loadArea(FrontNameResolver::AREA_CODE); + +$objectManager = Bootstrap::getObjectManager(); + +$storeId = $objectManager->get(StoreManagerInterface::class)->getStore()->getId(); + +/** @var RatingResourceModel $ratingResourceModel */ +$ratingResourceModel = $objectManager->create(RatingResourceModel::class); + +/** @var RatingCollection $ratingCollection */ +$ratingCollection = $objectManager->create(RatingCollection::class)->setOrder('rating_code', 'ASC'); +$position = 0; + +foreach ($ratingCollection as $rating) { + $rating->setStores([$storeId])->setPosition($position++); + $ratingResourceModel->save($rating); +} diff --git a/dev/tests/integration/testsuite/Magento/Review/_files/set_position_and_add_store_to_all_ratings_rollback.php b/dev/tests/integration/testsuite/Magento/Review/_files/set_position_and_add_store_to_all_ratings_rollback.php new file mode 100644 index 0000000000000..3a96a1be17a8b --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Review/_files/set_position_and_add_store_to_all_ratings_rollback.php @@ -0,0 +1,29 @@ +loadArea(FrontNameResolver::AREA_CODE); +$objectManager = Bootstrap::getObjectManager(); + +$storeId = Bootstrap::getObjectManager()->get(StoreManagerInterface::class)->getStore()->getId(); + +/** @var RatingResourceModel $ratingResourceModel */ +$ratingResourceModel = $objectManager->create(RatingResourceModel::class); + +/** @var RatingCollection $ratingCollection */ +$ratingCollection = Bootstrap::getObjectManager()->create(RatingCollection::class); + +foreach ($ratingCollection as $rating) { + $rating->setStores([])->setPosition(0); + $ratingResourceModel->save($rating); +}