diff --git a/docs/api/index.rst b/docs/api/index.rst index 99e494682a..64974b5d4c 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -20,7 +20,9 @@ The REST API Reference payments product_attributes product_options + product_reviews product_variants + product_reviews products promotion_coupons promotions diff --git a/docs/api/map.rst.inc b/docs/api/map.rst.inc index 8bd6314997..c67c48f078 100644 --- a/docs/api/map.rst.inc +++ b/docs/api/map.rst.inc @@ -14,7 +14,9 @@ * :doc:`/api/payments` * :doc:`/api/product_attributes` * :doc:`/api/product_options` +* :doc:`/api/product_reviews` * :doc:`/api/product_variants` +* :doc:`/api/product_reviews` * :doc:`/api/products` * :doc:`/api/promotion_coupons` * :doc:`/api/promotions` diff --git a/docs/api/product_reviews.rst b/docs/api/product_reviews.rst new file mode 100644 index 0000000000..e3999e4bbf --- /dev/null +++ b/docs/api/product_reviews.rst @@ -0,0 +1,635 @@ +Product Reviews API +==================== + +These endpoints will allow you to easily manage product reviews. Base URI is `/api/v1/products/{productCode}/reviews/`. + +Product Reviews API response structure +-------------------------------------- + +When you get a collection of resources, you will receive objects with the following fields: + ++------------------+------------------------------------------------------------------------------------------------+ +| Field | Description | ++==================+================================================================================================+ +| id | Id of product review | ++------------------+------------------------------------------------------------------------------------------------+ +| title | Title of product review | ++------------------+------------------------------------------------------------------------------------------------+ +| comment | Comment of product review | ++------------------+------------------------------------------------------------------------------------------------+ +| author | Customer author for product review (This is customer that added the | +| | product review; this will contain customer resource information) | ++------------------+------------------------------------------------------------------------------------------------+ +| status | Status of product review (New, Accepted, Rejected) | ++------------------+------------------------------------------------------------------------------------------------+ +| reviewSubject | This is the review subject for the product review. For this case of the product review, this | +| | will contain a product resource | ++------------------+------------------------------------------------------------------------------------------------+ + +.. note:: + + Read more about :doc:`ProductReviews docs`. + +Creating a Product Review +-------------------------- + +To create a new product review you will need to call the ``/api/v1/products/{productCode}/reviews/`` endpoint with the ``POST`` method. + +Definition +^^^^^^^^^^ + +.. code-block:: text + + POST /api/v1/products/{productCode}/reviews/ + ++---------------+----------------+----------------------------------------------------------+ +| Parameter | Parameter type | Description | ++===============+================+==========================================================+ +| Authorization | header | Token received during authentication | ++---------------+----------------+----------------------------------------------------------+ +| productCode | url attribute | Code of product for which the reviews should be created | ++---------------+----------------+----------------------------------------------------------+ +| title | request | Product review title | ++---------------+----------------+----------------------------------------------------------+ +| comment | request | Product review comment | ++---------------+----------------+----------------------------------------------------------+ +| rating | request | Product review rating (1..5) | ++---------------+----------------+----------------------------------------------------------+ +| author | request | Product review author | ++---------------+----------------+----------------------------------------------------------+ + +Example +^^^^^^^ + +To create a new product review for the product with ``code = MUG-TH`` use the below method. + +.. code-block:: bash + + $ curl http://demo.sylius.org/api/v1/products/MUG-TH/reviews/ \ + -H "Authorization: Bearer SampleToken" \ + -H "Content-Type: application/json" \ + -X POST \ + --data ' + { + "title": "A product review", + "rating": "3", + "comment": "This is a comment review", + "author": { + "email": "test@example.com" + } + } + ' + +Exemplary Response +^^^^^^^^^^^^^^^^^^ + +.. code-block:: text + + STATUS: 201 Created + +.. code-block:: json + + { + "id": 4, + "title": "A product review", + "rating": 3, + "comment": "This is a comment review", + "author": { + "id": 2, + "email": "test@example.com", + "emailCanonical": "test@example.com", + "gender": "u", + "_links": { + "self": { + "href": "/api/v1/customers/2" + } + } + }, + "status": "new", + "reviewSubject": { + "id": 1, + "name": "MUG-TH", + "code": "MUG-TH", + "attributes": [], + "options": [], + "associations": [], + "translations": [] + } + } + +.. warning:: + + If you try to create a resource without title, rating, comment or author, you will receive a ``400 Bad Request`` error. + +Example +^^^^^^^ + +.. code-block:: bash + + $ curl http://demo.sylius.org/api/v1/products/MUG-TH/reviews/ \ + -H "Authorization: Bearer SampleToken" \ + -H "Content-Type: application/json" \ + -X POST + +Exemplary Response +^^^^^^^^^^^^^^^^^^ + +.. code-block:: text + + STATUS: 400 Bad Request + +.. code-block:: json + + { + "code": 400, + "message": "Validation Failed", + "errors": { + "children": { + "rating": { + "errors": [ + "You must check review rating." + ], + "children": [ + {}, + {}, + {}, + {}, + {} + ] + }, + "title": { + "errors": [ + "Review title should not be blank." + ] + }, + "comment": { + "errors": [ + "Review comment should not be blank." + ] + }, + "author": { + "children": { + "email": { + "errors": [ + "Please enter your email." + ] + } + } + } + } + } + } + +Getting a Single Product Review +-------------------------------- + +To retrieve the details of a product review you will need to call the ``/api/v1/products/{productCode}/reviews/{id}`` endpoint with the ``GET`` method. + +Definition +^^^^^^^^^^ + +.. code-block:: text + + GET /api/v1/products/{productCode}/reviews/{id} + ++---------------+----------------+-----------------------------------------------------------+ +| Parameter | Parameter type | Description | ++===============+================+===========================================================+ +| Authorization | header | Token received during authentication | ++---------------+----------------+-----------------------------------------------------------+ +| id | url attribute | Identifier of the product review | ++---------------+----------------+-----------------------------------------------------------+ +| productCode | url attribute | Code of product for which the reviews should be displayed | ++---------------+----------------+-----------------------------------------------------------+ + +Example +^^^^^^^ + +To see the details of the product review with ``id = 1``, which is defined for the product with ``code = MUG-TH`` use the below method. + +.. code-block:: bash + + $ curl http://demo.sylius.org/api/v1/products/MUG-TH/reviews/1 \ + -H "Authorization: Bearer SampleToken" \ + -H "Accept: application/json" + +Exemplary Response +^^^^^^^^^^^^^^^^^^ + +.. code-block:: text + + STATUS: 200 OK + +.. code-block:: json + + { + "id": 1, + "title": "A product review", + "rating": 3, + "comment": "This is a comment review", + "author": { + "id": 2, + "email": "test@example.com", + "emailCanonical": "test@example.com", + "gender": "u", + "_links": { + "self": { + "href": "/api/v1/customers/2" + } + } + }, + "status": "new", + "reviewSubject": { + "id": 1, + "name": "MUG-TH", + "code": "MUG-TH", + "attributes": [], + "options": [], + "associations": [], + "translations": [] + } + } + +Collection of Product Reviews +------------------------------ + +To retrieve a paginated list of reviews for a selected product you will need to call the ``/api/v1/products/{productCode}/reviews/`` endpoint with the ``GET`` method. + +Definition +^^^^^^^^^^ + +.. code-block:: text + + GET /api/v1/products/{productCode}/reviews/ + ++-------------------------------------+----------------+------------------------------------------------------------+ +| Parameter | Parameter type | Description | ++=====================================+================+============================================================+ +| Authorization | header | Token received during authentication | ++-------------------------------------+----------------+------------------------------------------------------------+ +| productCode | url attribute | Code of product for which the reviews should be displayed | ++-------------------------------------+----------------+------------------------------------------------------------+ +| limit | query | *(optional)* Number of items to display per page, | +| | | by default = 10 | ++-------------------------------------+----------------+------------------------------------------------------------+ +| sorting['nameOfField']['direction'] | query | *(optional)* Field and direction of sorting, | +| | | by default 'desc' and 'createdAt' | ++-------------------------------------+----------------+------------------------------------------------------------+ + +Example +^^^^^^^ + +To see the first page of all product reviews for the product with ``code = MUG-TH`` use the method below. + +.. code-block:: bash + + $ curl http://demo.sylius.org/api/v1/products/MUG-TH/reviews/ \ + -H "Authorization: Bearer SampleToken" \ + -H "Accept: application/json" + +Exemplary Response +^^^^^^^^^^^^^^^^^^ + +.. code-block:: text + + STATUS: 200 OK + +.. code-block:: json + + { + "page": 1, + "limit": 10, + "pages": 1, + "total": 3, + "_links": { + "self": { + "href": "/api/v1/products/MUG-TH/reviews/?page=1&limit=10" + }, + "first": { + "href": "/api/v1/products/MUG-TH/reviews/?page=1&limit=10" + }, + "last": { + "href": "/api/v1/products/MUG-TH/reviews/?page=1&limit=10" + } + }, + "_embedded": { + "items": [ + { + "id": 4, + "title": "A product review", + "rating": 3, + "comment": "This is a comment review", + "author": { + "id": 2, + "email": "test@example.com", + "_links": { + "self": { + "href": "/api/v1/customers/2" + } + } + }, + "status": "new", + "reviewSubject": { + "id": 1, + "name": "MUG-TH", + "code": "MUG-TH", + "options": [], + "averageRating": 0, + "images": [], + "_links": { + "self": { + "href": "/api/v1/products/MUG-TH" + } + } + }, + "createdAt": "2017-10-04T20:19:06+03:00", + "updatedAt": "2017-10-04T20:19:06+03:00" + }, + { + "id": 3, + "title": "A product review 2", + "rating": 5, + "comment": "This is a comment review 2", + "author": { + "id": 1, + "email": "onetest@example.com", + "_links": { + "self": { + "href": "/api/v1/customers/1" + } + } + }, + "status": "new", + "reviewSubject": { + "id": 1, + "name": "MUG-TH", + "code": "MUG-TH", + "options": [], + "averageRating": 0, + "images": [], + "_links": { + "self": { + "href": "/api/v1/products/MUG-TH" + } + } + }, + "createdAt": "2017-10-04T18:23:56+03:00", + "updatedAt": "2017-10-04T18:44:08+03:00" + }, + { + "id": 1, + "title": "Test review 3", + "rating": 4, + "comment": "This is a comment review 3", + "author": { + "id": 1, + "email": "onetest@example.com", + "_links": { + "self": { + "href": "/api/v1/customers/1" + } + } + }, + "status": "accepted", + "reviewSubject": { + "id": 1, + "name": "MUG-TH", + "code": "MUG-TH", + "options": [], + "averageRating": 0, + "images": [], + "_links": { + "self": { + "href": "/api/v1/products/MUG-TH" + } + } + }, + "createdAt": "2017-10-03T23:53:24+03:00", + "updatedAt": "2017-10-04T19:18:00+03:00" + } + ] + } + } + +Updating Product Review +------------------------ + +To fully update a product review you will need to call the ``/api/v1/products/{productCode}/reviews/{id}`` endpoint with the ``PUT`` method. + +Definition +^^^^^^^^^^ + +.. code-block:: text + + PUT /api/v1/products/{productCode}/reviews/{id} + ++---------------+----------------+----------------------------------------------------------+ +| Parameter | Parameter type | Description | ++===============+================+==========================================================+ +| Authorization | header | Token received during authentication | ++---------------+----------------+----------------------------------------------------------+ +| id | url attribute | Product review id | ++---------------+----------------+----------------------------------------------------------+ +| productCode | url attribute | Code of product for which the reviews should be updated | ++---------------+----------------+----------------------------------------------------------+ +| title | request | Product review title | ++---------------+----------------+----------------------------------------------------------+ +| comment | request | Product review comment | ++---------------+----------------+----------------------------------------------------------+ +| rating | request | Product review rating (1..5) | ++---------------+----------------+----------------------------------------------------------+ + +Example +^^^^^^^ + +To fully update the product review with ``id = 1`` for the product with ``code = MUG-TH`` use the below method. + +.. code-block:: bash + + $ curl http://demo.sylius.org/api/v1/products/MUG-TH/reviews/1 \ + -H "Authorization: Bearer SampleToken" \ + -H "Content-Type: application/json" \ + -X PUT \ + --data ' + { + "title": "A product review", + "rating": "4", + "comment": "This is a comment for review" + } + ' + +Exemplary Response +^^^^^^^^^^^^^^^^^^ + +.. code-block:: text + + STATUS: 204 No Content + +To partially update a product review you will need to call the ``/api/v1/products/{productCode}/reviews/{id}`` endpoint with the ``PATCH`` method. + +Definition +^^^^^^^^^^ + +.. code-block:: text + + PATCH /api/v1/products/{productCode}/reviews/{id} + ++------------------------------------+----------------+-----------------------------------------------------------+ +| Parameter | Parameter type | Description | ++====================================+================+===========================================================+ +| Authorization | header | Token received during authentication | ++------------------------------------+----------------+-----------------------------------------------------------+ +| id | url attribute | Identifier of the product review | ++------------------------------------+----------------+-----------------------------------------------------------+ +| productCode | url attribute | Code of product for which the reviews should be updated | ++------------------------------------+----------------+-----------------------------------------------------------+ +| title | request | Product review title | ++------------------------------------+----------------+-----------------------------------------------------------+ + +Example +^^^^^^^ + +To partially update the product review with ``id = 1`` for the product with ``code = MUG-TH`` use the below method. + +.. code-block:: bash + + $ curl http://demo.sylius.org/api/v1/products/MUG-TH/reviews/1 \ + -H "Authorization: Bearer SampleToken" \ + -H "Content-Type: application/json" \ + -X PATCH \ + --data ' + { + "title": "This is an another title for the review" + } + ' + +Exemplary Response +^^^^^^^^^^^^^^^^^^ + +.. code-block:: text + + STATUS: 204 No Content + +Deleting a Product Review +-------------------------- + +To delete a product review you will need to call the ``/api/v1/products/{productCode}/reviews/{id}`` endpoint with the ``DELETE`` method. + +Definition +^^^^^^^^^^ + +.. code-block:: text + + DELETE /api/v1/products/{productCode}/reviews/{id} + ++---------------+----------------+-----------------------------------------------------------+ +| Parameter | Parameter type | Description | ++===============+================+===========================================================+ +| Authorization | header | Token received during authentication | ++---------------+----------------+-----------------------------------------------------------+ +| id | url attribute | Identifier of the product review | ++---------------+----------------+-----------------------------------------------------------+ +| productCode | url attribute | Code of product for which the reviews should be deleted | ++---------------+----------------+-----------------------------------------------------------+ + +Example +^^^^^^^ + +To delete the product review with ``id = 1`` from the product with ``code = MUG-TH`` use the below method. + +.. code-block:: bash + + $ curl http://demo.sylius.org/api/v1/products/MUG-TH/reviews/1 \ + -H "Authorization: Bearer SampleToken" \ + -H "Accept: application/json" \ + -X DELETE + +Exemplary Response +^^^^^^^^^^^^^^^^^^ + +.. code-block:: text + + STATUS: 204 No Content + +Accept a Product Review +-------------------------- + +To accept a product review you will need to call the ``/api/v1/products/{productCode}/reviews/{id}/accept`` endpoint with the ``POST``, ``PUT`` or ``PATCH`` method. + +Definition +^^^^^^^^^^ + +.. code-block:: text + + POST /api/v1/products/{productCode}/reviews/{id}/accept + ++---------------+----------------+-----------------------------------------------------------+ +| Parameter | Parameter type | Description | ++===============+================+===========================================================+ +| Authorization | header | Token received during authentication | ++---------------+----------------+-----------------------------------------------------------+ +| id | url attribute | Identifier of the product review | ++---------------+----------------+-----------------------------------------------------------+ +| productCode | url attribute | Code of product for which the reviews should be accepted | ++---------------+----------------+-----------------------------------------------------------+ + +Example +^^^^^^^ + +To accept the product review with ``id = 1`` from the product with ``code = MUG-TH`` use the below method. + +.. code-block:: bash + + $ curl http://demo.sylius.org/api/v1/products/MUG-TH/reviews/1/accept \ + -H "Authorization: Bearer SampleToken" \ + -H "Accept: application/json" \ + -X POST + +Exemplary Response +^^^^^^^^^^^^^^^^^^ + +.. code-block:: text + + STATUS: 204 No Content + +Reject a Product Review +-------------------------- + +To reject a product review you will need to call the ``/api/v1/products/{productCode}/reviews/{id}/reject`` endpoint with the ``POST``, ``PUT`` or ``PATCH`` method. + +Definition +^^^^^^^^^^ + +.. code-block:: text + + POST /api/v1/products/{productCode}/reviews/{id}/reject + ++---------------+----------------+-----------------------------------------------------------+ +| Parameter | Parameter type | Description | ++===============+================+===========================================================+ +| Authorization | header | Token received during authentication | ++---------------+----------------+-----------------------------------------------------------+ +| id | url attribute | Identifier of the product review | ++---------------+----------------+-----------------------------------------------------------+ +| productCode | url attribute | Code of product for which the reviews should be rejected | ++---------------+----------------+-----------------------------------------------------------+ + +Example +^^^^^^^ + +To reject the product review with ``id = 1`` from the product with ``code = MUG-TH`` use the below method. + +.. code-block:: bash + + $ curl http://demo.sylius.org/api/v1/products/MUG-TH/reviews/1/reject \ + -H "Authorization: Bearer SampleToken" \ + -H "Accept: application/json" \ + -X POST + +Exemplary Response +^^^^^^^^^^^^^^^^^^ + +.. code-block:: text + + STATUS: 204 No Content + diff --git a/src/Sylius/Bundle/AdminApiBundle/Resources/config/app/config.yml b/src/Sylius/Bundle/AdminApiBundle/Resources/config/app/config.yml index 723b525cfc..0562901416 100644 --- a/src/Sylius/Bundle/AdminApiBundle/Resources/config/app/config.yml +++ b/src/Sylius/Bundle/AdminApiBundle/Resources/config/app/config.yml @@ -7,6 +7,7 @@ imports: - { resource: "@SyliusAdminApiBundle/Resources/config/grids/cart.yml" } - { resource: "@SyliusAdminApiBundle/Resources/config/grids/payments.yml" } - { resource: "@SyliusAdminApiBundle/Resources/config/grids/product.yml" } + - { resource: "@SyliusAdminApiBundle/Resources/config/grids/product_review.yml" } - { resource: "@SyliusAdminApiBundle/Resources/config/grids/product_variant.yml" } - { resource: "@SyliusAdminApiBundle/Resources/config/grids/promotion.yml" } - { resource: "@SyliusAdminApiBundle/Resources/config/grids/shipments.yml" } diff --git a/src/Sylius/Bundle/AdminApiBundle/Resources/config/grids/product_review.yml b/src/Sylius/Bundle/AdminApiBundle/Resources/config/grids/product_review.yml new file mode 100644 index 0000000000..ed74f66bc1 --- /dev/null +++ b/src/Sylius/Bundle/AdminApiBundle/Resources/config/grids/product_review.yml @@ -0,0 +1,44 @@ +# This file is part of the Sylius package. +# (c) Paweł Jędrzejewski + +sylius_grid: + grids: + sylius_admin_api_product_review: + driver: + name: doctrine/orm + options: + class: "%sylius.model.product_review.class%" + repository: + method: createQueryBuilderByProductCode + arguments: ["%locale%", $productCode] + sorting: + date: desc + fields: + date: + type: datetime + label: sylius.ui.date + path: createdAt + sortable: createdAt + options: + format: d-m-Y H:i:s + title: + type: string + label: sylius.ui.title + sortable: ~ + rating: + type: string + label: sylius.ui.rating + sortable: ~ + status: + type: twig + label: sylius.ui.status + reviewSubject: + type: string + label: sylius.ui.product + author: + type: string + label: sylius.ui.customer + filters: + title: + type: string + label: sylius.ui.title diff --git a/src/Sylius/Bundle/AdminApiBundle/Resources/config/routing/main.yml b/src/Sylius/Bundle/AdminApiBundle/Resources/config/routing/main.yml index 47b0cb85d8..4fcf01b16f 100644 --- a/src/Sylius/Bundle/AdminApiBundle/Resources/config/routing/main.yml +++ b/src/Sylius/Bundle/AdminApiBundle/Resources/config/routing/main.yml @@ -60,6 +60,10 @@ sylius_api_product_association_type: sylius_api_product_option: resource: "@SyliusAdminApiBundle/Resources/config/routing/product_option.yml" +sylius_api_product_review: + resource: "@SyliusAdminApiBundle/Resources/config/routing/product_review.yml" + prefix: /products/{productCode} + sylius_api_product_taxon_position: resource: "@SyliusAdminApiBundle/Resources/config/routing/product_taxon_position.yml" diff --git a/src/Sylius/Bundle/AdminApiBundle/Resources/config/routing/product_review.yml b/src/Sylius/Bundle/AdminApiBundle/Resources/config/routing/product_review.yml new file mode 100644 index 0000000000..eab9184d23 --- /dev/null +++ b/src/Sylius/Bundle/AdminApiBundle/Resources/config/routing/product_review.yml @@ -0,0 +1,90 @@ +# This file is part of the Sylius package. +# (c) Paweł Jędrzejewski + +sylius_admin_api_product_review: + resource: | + path: 'reviews' + grid: sylius_admin_api_product_review + alias: sylius.product_review + section: admin_api + only: ['index'] + serialization_version: $version + vars: + route: + parameters: + productCode: $productCode + type: sylius.resource_api + +sylius_admin_api_product_review_create: + path: /reviews/ + methods: [POST] + defaults: + _controller: sylius.controller.product_review:createAction + _sylius: + serialization_groups: [Default, Detailed] + serialization_version: $version + section: admin_api + form: Sylius\Bundle\CoreBundle\Form\Type\Product\ProductReviewType + factory: + method: createForSubject + arguments: + - expr:notFoundOnNull(service('sylius.repository.product').findOneByCode($productCode)) + +sylius_admin_api_product_review_update: + path: /reviews/{id} + methods: [PUT, PATCH] + defaults: + _controller: sylius.controller.product_review:updateAction + _sylius: + serialization_version: $version + section: admin_api + form: Sylius\Bundle\CoreBundle\Form\Type\Product\ProductReviewType + repository: + method: findOneByIdAndProductCode + arguments: [$id, $productCode] + +sylius_admin_api_product_review_delete: + path: /reviews/{id} + methods: [DELETE] + defaults: + _controller: sylius.controller.product_review:deleteAction + _sylius: + serialization_version: $version + section: admin_api + repository: + method: findOneByIdAndProductCode + arguments: [$id, $productCode] + csrf_protection: false + +sylius_admin_api_product_review_show: + path: /reviews/{code} + methods: [GET] + defaults: + _controller: sylius.controller.product_review:showAction + _sylius: + serialization_version: $version + section: admin_api + serialization_groups: [Default, Detailed] + repository: + method: findOneByIdAndProductCode + arguments: [$code, $productCode] + +sylius_admin_api_product_review_accept: + path: /reviews/{id}/accept + methods: [POST, PUT, PATCH] + defaults: + _controller: sylius.controller.product_review:applyStateMachineTransitionAction + _sylius: + state_machine: + graph: sylius_product_review + transition: accept + +sylius_admin_api_product_review_reject: + path: /reviews/{id}/reject + methods: [POST, PUT, PATCH] + defaults: + _controller: sylius.controller.product_review:applyStateMachineTransitionAction + _sylius: + state_machine: + graph: sylius_product_review + transition: reject diff --git a/src/Sylius/Bundle/CoreBundle/Doctrine/ORM/ProductReviewRepository.php b/src/Sylius/Bundle/CoreBundle/Doctrine/ORM/ProductReviewRepository.php index 3b27935b43..dd6a833315 100644 --- a/src/Sylius/Bundle/CoreBundle/Doctrine/ORM/ProductReviewRepository.php +++ b/src/Sylius/Bundle/CoreBundle/Doctrine/ORM/ProductReviewRepository.php @@ -13,6 +13,7 @@ declare(strict_types=1); namespace Sylius\Bundle\CoreBundle\Doctrine\ORM; +use Doctrine\ORM\QueryBuilder; use Sylius\Bundle\ResourceBundle\Doctrine\ORM\EntityRepository; use Sylius\Component\Core\Model\ChannelInterface; use Sylius\Component\Core\Repository\ProductReviewRepositoryInterface; @@ -60,4 +61,35 @@ class ProductReviewRepository extends EntityRepository implements ProductReviewR ->getResult() ; } + + /** + * {@inheritdoc} + */ + public function createQueryBuilderByProductCode(string $locale, string $productCode): QueryBuilder + { + return $this->createQueryBuilder('o') + ->innerJoin('o.reviewSubject', 'product') + ->innerJoin('product.translations', 'translation') + ->andWhere('translation.locale = :locale') + ->andWhere('product.code = :productCode') + ->setParameter('locale', $locale) + ->setParameter('productCode', $productCode) + ; + } + + /** + * {@inheritdoc} + */ + public function findOneByIdAndProductCode($id, string $productCode): ?ReviewInterface + { + return $this->createQueryBuilder('o') + ->innerJoin('o.reviewSubject', 'product') + ->andWhere('product.code = :productCode') + ->andWhere('o.id = :id') + ->setParameter('productCode', $productCode) + ->setParameter('id', $id) + ->getQuery() + ->getOneOrNullResult() + ; + } } diff --git a/src/Sylius/Component/Core/Repository/ProductReviewRepositoryInterface.php b/src/Sylius/Component/Core/Repository/ProductReviewRepositoryInterface.php index ee68e0c951..8da1f32f05 100644 --- a/src/Sylius/Component/Core/Repository/ProductReviewRepositoryInterface.php +++ b/src/Sylius/Component/Core/Repository/ProductReviewRepositoryInterface.php @@ -13,6 +13,7 @@ declare(strict_types=1); namespace Sylius\Component\Core\Repository; +use Doctrine\ORM\QueryBuilder; use Sylius\Component\Core\Model\ChannelInterface; use Sylius\Component\Resource\Repository\RepositoryInterface; use Sylius\Component\Review\Model\ReviewInterface; @@ -38,4 +39,20 @@ interface ProductReviewRepositoryInterface extends RepositoryInterface * @return array|ReviewInterface[] */ public function findAcceptedByProductSlugAndChannel(string $slug, string $locale, ChannelInterface $channel): array; + + /** + * @param string $locale + * @param string $productCode + * + * @return QueryBuilder + */ + public function createQueryBuilderByProductCode(string $locale, string $productCode): QueryBuilder; + + /** + * @param mixed $id + * @param string $productCode + * + * @return ReviewInterface|null + */ + public function findOneByIdAndProductCode($id, string $productCode): ?ReviewInterface; } diff --git a/tests/Controller/ProductReviewApiTest.php b/tests/Controller/ProductReviewApiTest.php new file mode 100644 index 0000000000..0892a42c73 --- /dev/null +++ b/tests/Controller/ProductReviewApiTest.php @@ -0,0 +1,359 @@ + + */ +final class ProductReviewApiTest extends JsonApiTestCase +{ + /** + * @var array + */ + private static $authorizedHeaderWithContentType = [ + 'HTTP_Authorization' => 'Bearer SampleTokenNjZkNjY2MDEwMTAzMDkxMGE0OTlhYzU3NzYyMTE0ZGQ3ODcyMDAwM2EwMDZjNDI5NDlhMDdlMQ', + 'CONTENT_TYPE' => 'application/json', + ]; + + /** + * @var array + */ + private static $authorizedHeaderWithAccept = [ + 'HTTP_Authorization' => 'Bearer SampleTokenNjZkNjY2MDEwMTAzMDkxMGE0OTlhYzU3NzYyMTE0ZGQ3ODcyMDAwM2EwMDZjNDI5NDlhMDdlMQ', + 'ACCEPT' => 'application/json', + ]; + + /** + * @test + */ + public function it_does_not_allow_to_show_product_review_list_when_access_is_denied() + { + $productReviewsData = $this->loadFixturesFromFile('resources/product_reviews.yml'); + + /** @var ProductInterface $product */ + $product = $productReviewsData['product1']; + + $this->client->request('GET', $this->getReviewListUrl($product)); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'authentication/access_denied_response', Response::HTTP_UNAUTHORIZED); + } + + /** + * @test + */ + public function it_does_not_allow_to_show_product_review_when_it_does_not_exist() + { + $productReviewsData = $this->loadFixturesFromFile('resources/product_reviews.yml'); + $this->loadFixturesFromFile('authentication/api_administrator.yml'); + + /** @var ProductInterface $product */ + $product = $productReviewsData['product1']; + + $this->client->request('GET', $this->getReviewListUrl($product) . '0', [], [], static::$authorizedHeaderWithAccept); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'error/not_found_response', Response::HTTP_NOT_FOUND); + } + + /** + * @test + */ + public function it_allows_showing_product_review() + { + $this->loadFixturesFromFile('authentication/api_administrator.yml'); + $productReviewsData = $this->loadFixturesFromFile('resources/product_reviews.yml'); + + /** @var ProductInterface $product */ + $product = $productReviewsData['product1']; + + /** @var ReviewInterface $productReview */ + $productReview = $productReviewsData['productReview1']; + + $this->client->request('GET', $this->getReviewUrl($product, $productReview), [], [], static::$authorizedHeaderWithAccept); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'product_review/show_response', Response::HTTP_OK); + } + + /** + * @test + */ + public function it_allows_indexing_product_reviews() + { + $this->loadFixturesFromFile('authentication/api_administrator.yml'); + $productReviewsData = $this->loadFixturesFromFile('resources/product_reviews.yml'); + + /** @var ProductInterface $product */ + $product = $productReviewsData['product1']; + + $this->client->request('GET', $this->getReviewListUrl($product), [], [], static::$authorizedHeaderWithAccept); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'product_review/index_response', Response::HTTP_OK); + } + + /** + * @test + */ + public function it_allows_create_product_review() + { + $this->loadFixturesFromFile('authentication/api_administrator.yml'); + $productReviewsData = $this->loadFixturesFromFile('resources/product_reviews.yml'); + + /** @var ProductInterface $product */ + $product = $productReviewsData['product1']; + + $data = +<<client->request('POST', $this->getReviewListUrl($product), [], [], static::$authorizedHeaderWithContentType, $data); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'product_review/create_response', Response::HTTP_CREATED); + } + + /** + * @test + */ + public function it_does_not_allow_to_create_product_review_without_required_fields() + { + $this->loadFixturesFromFile('authentication/api_administrator.yml'); + $productReviewsData = $this->loadFixturesFromFile('resources/product_reviews.yml'); + + /** @var ProductInterface $product */ + $product = $productReviewsData['product1']; + + $this->client->request('POST', $this->getReviewListUrl($product), [], [], static::$authorizedHeaderWithContentType, []); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'product_review/create_validation_fail_response', Response::HTTP_BAD_REQUEST); + } + + /** + * @test + */ + public function it_does_not_allow_delete_product_review_if_it_does_not_exist() + { + $this->loadFixturesFromFile('authentication/api_administrator.yml'); + $productReviewsData = $this->loadFixturesFromFile('resources/product_reviews.yml'); + + /** @var ProductInterface $product */ + $product = $productReviewsData['product1']; + + $this->client->request('DELETE', $this->getReviewListUrl($product) . '0', [], [], static::$authorizedHeaderWithAccept); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'error/not_found_response', Response::HTTP_NOT_FOUND); + } + + /** + * @test + */ + public function it_allows_delete_product_review() + { + $this->loadFixturesFromFile('authentication/api_administrator.yml'); + $productReviewsData = $this->loadFixturesFromFile('resources/product_reviews.yml'); + + /** @var ProductInterface $product */ + $product = $productReviewsData['product1']; + + /** @var ReviewInterface $productReview */ + $productReview = $productReviewsData['productReview1']; + + $this->client->request('DELETE', $this->getReviewUrl($product, $productReview), [], [], static::$authorizedHeaderWithContentType, []); + + $response = $this->client->getResponse(); + $this->assertResponseCode($response, Response::HTTP_NO_CONTENT); + + /** @var ProductInterface $product */ + $product = $productReviewsData['product1']; + + $this->client->request('GET', $this->getReviewUrl($product, $productReview), [], [], static::$authorizedHeaderWithAccept); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'error/not_found_response', Response::HTTP_NOT_FOUND); + } + + /** + * @test + */ + public function it_allows_updating_information_about_product_review() + { + $this->loadFixturesFromFile('authentication/api_administrator.yml'); + $productReviewsData = $this->loadFixturesFromFile('resources/product_reviews.yml'); + + /** @var ProductInterface $product */ + $product = $productReviewsData['product1']; + + /** @var ReviewInterface $productReview */ + $productReview = $productReviewsData['productReview1']; + + $data = +<<client->request('PUT', $this->getReviewUrl($product, $productReview), [], [], static::$authorizedHeaderWithContentType, $data); + $response = $this->client->getResponse(); + + $this->assertResponseCode($response, Response::HTTP_NO_CONTENT); + } + + /** + * @test + */ + public function it_allows_updating_partial_information_about_product_review() + { + $this->loadFixturesFromFile('authentication/api_administrator.yml'); + $productReviewsData = $this->loadFixturesFromFile('resources/product_reviews.yml'); + $this->loadFixturesFromFile('resources/locales.yml'); + + /** @var ProductInterface $product */ + $product = $productReviewsData['product1']; + + /** @var ReviewInterface $productReview */ + $productReview = $productReviewsData['productReview1']; + + $data = +<<client->request('PATCH', $this->getReviewUrl($product, $productReview), [], [], static::$authorizedHeaderWithContentType, $data); + $response = $this->client->getResponse(); + + $this->assertResponseCode($response, Response::HTTP_NO_CONTENT); + } + + /** + * @test + */ + public function it_allows_accept_product_review() + { + $this->loadFixturesFromFile('authentication/api_administrator.yml'); + $productReviewsData = $this->loadFixturesFromFile('resources/product_reviews.yml'); + + /** @var ProductInterface $product */ + $product = $productReviewsData['product1']; + + /** @var ReviewInterface $productReview */ + $productReview = $productReviewsData['productReview1']; + + $this->client->request('PATCH', $this->getReviewUrl($product, $productReview) . '/accept', [], [], static::$authorizedHeaderWithAccept); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'product_review/accept_response', Response::HTTP_OK); + } + + /** + * @test + */ + public function it_does_not_allows_accept_product_review_if_it_has_not_new_status() + { + $this->loadFixturesFromFile('authentication/api_administrator.yml'); + $productReviewsData = $this->loadFixturesFromFile('resources/product_reviews.yml'); + + /** @var ProductInterface $product */ + $product = $productReviewsData['product1']; + + /** @var ReviewInterface $productReview */ + $productReview = $productReviewsData['productReview3']; + + $this->client->request('POST', $this->getReviewUrl($product, $productReview) . '/accept', [], [], static::$authorizedHeaderWithAccept); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'product_review/change_status_fail_response', Response::HTTP_BAD_REQUEST); + } + + /** + * @test + */ + public function it_allows_reject_product_review() + { + $this->loadFixturesFromFile('authentication/api_administrator.yml'); + $productReviewsData = $this->loadFixturesFromFile('resources/product_reviews.yml'); + + /** @var ProductInterface $product */ + $product = $productReviewsData['product1']; + + /** @var ReviewInterface $productReview */ + $productReview = $productReviewsData['productReview1']; + + $this->client->request('PATCH', $this->getReviewUrl($product, $productReview) . '/reject', [], [], static::$authorizedHeaderWithAccept); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'product_review/reject_response', Response::HTTP_OK); + } + + /** + * @test + */ + public function it_does_not_allows_reject_product_review_if_it_has_not_new_status() + { + $this->loadFixturesFromFile('authentication/api_administrator.yml'); + $productReviewsData = $this->loadFixturesFromFile('resources/product_reviews.yml'); + + /** @var ProductInterface $product */ + $product = $productReviewsData['product1']; + + /** @var ReviewInterface $productReview */ + $productReview = $productReviewsData['productReview3']; + + $this->client->request('POST', $this->getReviewUrl($product, $productReview) . '/accept', [], [], static::$authorizedHeaderWithAccept); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'product_review/change_status_fail_response', Response::HTTP_BAD_REQUEST); + } + + /** + * @param ProductInterface $product + * + * @return string + */ + private function getReviewListUrl(ProductInterface $product): string + { + return sprintf('/api/v1/products/%s/reviews/', $product->getCode()); + } + + /** + * @param ProductInterface $product + * @param ReviewInterface $productReview + * + * @return string + */ + private function getReviewUrl(ProductInterface $product, ReviewInterface $productReview): string + { + return sprintf('%s%s', $this->getReviewListUrl($product), $productReview->getId()); + } +} diff --git a/tests/DataFixtures/ORM/resources/product_reviews.yml b/tests/DataFixtures/ORM/resources/product_reviews.yml new file mode 100644 index 0000000000..a70d6dd820 --- /dev/null +++ b/tests/DataFixtures/ORM/resources/product_reviews.yml @@ -0,0 +1,44 @@ +Sylius\Component\Core\Model\Customer: + customer_example1: + firstName: Example + lastName: Queen + email: example.queen@example.com + emailCanonical: example.queen@example.com + customer_example2: + firstName: Example + lastName: King + email: example.king@example.com + emailCanonical: example.king@example.com + +Sylius\Component\Core\Model\Product: + product1: + fallbackLocale: en_US + currentLocale: en + code: MUG_REVIEW_BEST + product2: + fallbackLocale: en_US + currentLocale: en + code: MUG_REVIEW_GOOD + +Sylius\Component\Core\Model\ProductReview: + productReview1: + title: mug_review_best + rating: 4 + comment: "mug_review_best_comment" + author: "@customer_example1" + status: "new" + reviewSubject: "@product1" + productReview2: + title: mug_review_good + rating: 3 + comment: "mug_review_good_comment" + author: "@customer_example1" + status: "new" + reviewSubject: "@product1" + productReview3: + title: mug_review_bad + rating: 1 + comment: "mug_review_bad_comment" + author: "@customer_example1" + status: "rejected" + reviewSubject: "@product1" diff --git a/tests/Responses/Expected/product_review/accept_response.json b/tests/Responses/Expected/product_review/accept_response.json new file mode 100644 index 0000000000..c3466545c4 --- /dev/null +++ b/tests/Responses/Expected/product_review/accept_response.json @@ -0,0 +1,92 @@ +{ + "id": @integer@, + "title": "mug_review_best", + "rating": 4, + "comment": "mug_review_best_comment", + "author": { + "id": @integer@, + "email": "example.queen@example.com", + "emailCanonical": "example.queen@example.com", + "firstName": "Example", + "lastName": "Queen", + "gender": "u", + "_links": { + "self": { + "href": @string@ + } + } + }, + "status": "accepted", + "reviewSubject": { + "id": @integer@, + "code": "MUG_REVIEW_BEST", + "attributes": [], + "options": [], + "associations": [], + "translations": { + "en": { + "locale": "en" + } + }, + "productTaxons": [], + "channels": [], + "reviews": { + "1": { + "id": @integer@, + "title": "mug_review_good", + "rating": 3, + "comment": "mug_review_good_comment", + "author": { + "id": @integer@, + "email": "example.queen@example.com", + "emailCanonical": "example.queen@example.com", + "firstName": "Example", + "lastName": "Queen", + "gender": "u", + "_links": { + "self": { + "href": @string@ + } + } + }, + "status": "new", + "createdAt": @string@, + "updatedAt": @string@ + }, + "2": { + "id": @integer@, + "title": "mug_review_bad", + "rating": 1, + "comment": "mug_review_bad_comment", + "author": { + "id": @integer@, + "email": "example.queen@example.com", + "emailCanonical": "example.queen@example.com", + "firstName": "Example", + "lastName": "Queen", + "gender": "u", + "_links": { + "self": { + "href": @string@ + } + } + }, + "status": "rejected", + "createdAt": @string@, + "updatedAt": @string@ + } + }, + "averageRating": 4, + "images": [], + "_links": { + "self": { + "href": "/api/v1/products/MUG_REVIEW_BEST" + }, + "variants": { + "href": "/api/v1/products/MUG_REVIEW_BEST/variants/" + } + } + }, + "createdAt": @string@, + "updatedAt": @string@ +} diff --git a/tests/Responses/Expected/product_review/change_status_fail_response.json b/tests/Responses/Expected/product_review/change_status_fail_response.json new file mode 100644 index 0000000000..bc4332eb23 --- /dev/null +++ b/tests/Responses/Expected/product_review/change_status_fail_response.json @@ -0,0 +1,4 @@ +{ + "code":400, + "message":"" +} diff --git a/tests/Responses/Expected/product_review/create_response.json b/tests/Responses/Expected/product_review/create_response.json new file mode 100644 index 0000000000..80421db0cd --- /dev/null +++ b/tests/Responses/Expected/product_review/create_response.json @@ -0,0 +1,112 @@ +{ + "id": @integer@, + "title": "J_REVIEW", + "rating": 3, + "comment": "J_REVIEW_COMMENT", + "author": { + "id": @integer@, + "email": "j@example.com", + "emailCanonical": "j@example.com", + "gender": "u", + "_links": { + "self": { + "href": @string@ + } + } + }, + "status": "new", + "reviewSubject": { + "id": @integer@, + "code": "MUG_REVIEW_BEST", + "attributes": [], + "options": [], + "associations": [], + "translations": { + "en": { + "locale": "en" + } + }, + "productTaxons": [], + "channels": [], + "reviews": [ + { + "id": @integer@, + "title": "mug_review_best", + "rating": 4, + "comment": "mug_review_best_comment", + "author": { + "id": @integer@, + "email": "example.queen@example.com", + "emailCanonical": "example.queen@example.com", + "firstName": "Example", + "lastName": "Queen", + "gender": "u", + "_links": { + "self": { + "href": @string@ + } + } + }, + "status": "new", + "createdAt": @string@, + "updatedAt": @string@ + }, + { + "id": @integer@, + "title": "mug_review_good", + "rating": 3, + "comment": "mug_review_good_comment", + "author": { + "id": @integer@, + "email": "example.queen@example.com", + "emailCanonical": "example.queen@example.com", + "firstName": "Example", + "lastName": "Queen", + "gender": "u", + "_links": { + "self": { + "href": @string@ + } + } + }, + "status": "new", + "createdAt": @string@, + "updatedAt": @string@ + }, + { + "id": @integer@, + "title": "mug_review_bad", + "rating": 1, + "comment": "mug_review_bad_comment", + "author": { + "id": @integer@, + "email": "example.queen@example.com", + "emailCanonical": "example.queen@example.com", + "firstName": "Example", + "lastName": "Queen", + "gender": "u", + "_links": { + "self": { + "href": @string@ + } + } + }, + "status": "rejected", + "createdAt": @string@, + "updatedAt": @string@ + } + ], + "averageRating": 0, + "images": [], + "_links": { + "self": { + "href": "\/api\/v1\/products\/MUG_REVIEW_BEST" + }, + "variants": { + "href": "\/api\/v1\/products\/MUG_REVIEW_BEST\/variants\/" + } + } + }, + "createdAt": @string@, + "updatedAt": @string@ +} diff --git a/tests/Responses/Expected/product_review/create_validation_fail_response.json b/tests/Responses/Expected/product_review/create_validation_fail_response.json new file mode 100644 index 0000000000..d35dab9c82 --- /dev/null +++ b/tests/Responses/Expected/product_review/create_validation_fail_response.json @@ -0,0 +1,39 @@ +{ + "code": 400, + "message": "Validation Failed", + "errors": { + "children": { + "rating": { + "errors": [ + "You must check review rating." + ], + "children": [ + {}, + {}, + {}, + {}, + {} + ] + }, + "title": { + "errors": [ + "Review title should not be blank." + ] + }, + "comment": { + "errors": [ + "Review comment should not be blank." + ] + }, + "author": { + "children": { + "email": { + "errors": [ + "Please enter your email." + ] + } + } + } + } + } +} diff --git a/tests/Responses/Expected/product_review/index_response.json b/tests/Responses/Expected/product_review/index_response.json new file mode 100644 index 0000000000..afa682d4ea --- /dev/null +++ b/tests/Responses/Expected/product_review/index_response.json @@ -0,0 +1,20 @@ +{ + "page": 1, + "limit": 10, + "pages": 1, + "total": 0, + "_links": { + "self": { + "href": "\/api\/v1\/products\/MUG_REVIEW_BEST\/reviews\/?page=1&limit=10" + }, + "first": { + "href": "\/api\/v1\/products\/MUG_REVIEW_BEST\/reviews\/?page=1&limit=10" + }, + "last": { + "href": "\/api\/v1\/products\/MUG_REVIEW_BEST\/reviews\/?page=1&limit=10" + } + }, + "_embedded": { + "items": [] + } +} diff --git a/tests/Responses/Expected/product_review/reject_response.json b/tests/Responses/Expected/product_review/reject_response.json new file mode 100644 index 0000000000..41460f1bbb --- /dev/null +++ b/tests/Responses/Expected/product_review/reject_response.json @@ -0,0 +1,92 @@ +{ + "id": @integer@, + "title": "mug_review_best", + "rating": 4, + "comment": "mug_review_best_comment", + "author": { + "id": @integer@, + "email": "example.queen@example.com", + "emailCanonical": "example.queen@example.com", + "firstName": "Example", + "lastName": "Queen", + "gender": "u", + "_links": { + "self": { + "href": @string@ + } + } + }, + "status": "rejected", + "reviewSubject": { + "id": @integer@, + "code": "MUG_REVIEW_BEST", + "attributes": [], + "options": [], + "associations": [], + "translations": { + "en": { + "locale": "en" + } + }, + "productTaxons": [], + "channels": [], + "reviews": { + "1": { + "id": @integer@, + "title": "mug_review_good", + "rating": 3, + "comment": "mug_review_good_comment", + "author": { + "id": @integer@, + "email": "example.queen@example.com", + "emailCanonical": "example.queen@example.com", + "firstName": "Example", + "lastName": "Queen", + "gender": "u", + "_links": { + "self": { + "href": @string@ + } + } + }, + "status": "new", + "createdAt": @string@, + "updatedAt": @string@ + }, + "2": { + "id": @integer@, + "title": "mug_review_bad", + "rating": 1, + "comment": "mug_review_bad_comment", + "author": { + "id": @integer@, + "email": "example.queen@example.com", + "emailCanonical": "example.queen@example.com", + "firstName": "Example", + "lastName": "Queen", + "gender": "u", + "_links": { + "self": { + "href": @string@ + } + } + }, + "status": "rejected", + "createdAt": @string@, + "updatedAt": @string@ + } + }, + "averageRating": 0, + "images": [], + "_links": { + "self": { + "href": "/api/v1/products/MUG_REVIEW_BEST" + }, + "variants": { + "href": "/api/v1/products/MUG_REVIEW_BEST/variants/" + } + } + }, + "createdAt": @string@, + "updatedAt": @string@ +} diff --git a/tests/Responses/Expected/product_review/show_response.json b/tests/Responses/Expected/product_review/show_response.json new file mode 100644 index 0000000000..21ebc78c21 --- /dev/null +++ b/tests/Responses/Expected/product_review/show_response.json @@ -0,0 +1,92 @@ +{ + "id": @integer@, + "title": "mug_review_best", + "rating": 4, + "comment": "mug_review_best_comment", + "author": { + "id": @integer@, + "email": "example.queen@example.com", + "emailCanonical": "example.queen@example.com", + "firstName": "Example", + "lastName": "Queen", + "gender": "u", + "_links": { + "self": { + "href": @string@ + } + } + }, + "status": "new", + "reviewSubject": { + "id": @integer@, + "code": "MUG_REVIEW_BEST", + "attributes": [], + "options": [], + "associations": [], + "translations": { + "en": { + "locale": "en" + } + }, + "productTaxons": [], + "channels": [], + "reviews": { + "1": { + "id": @integer@, + "title": "mug_review_good", + "rating": 3, + "comment": "mug_review_good_comment", + "author": { + "id": @integer@, + "email": "example.queen@example.com", + "emailCanonical": "example.queen@example.com", + "firstName": "Example", + "lastName": "Queen", + "gender": "u", + "_links": { + "self": { + "href": @string@ + } + } + }, + "status": "new", + "createdAt": @string@, + "updatedAt": @string@ + }, + "2": { + "id": @integer@, + "title": "mug_review_bad", + "rating": 1, + "comment": "mug_review_bad_comment", + "author": { + "id": @integer@, + "email": "example.queen@example.com", + "emailCanonical": "example.queen@example.com", + "firstName": "Example", + "lastName": "Queen", + "gender": "u", + "_links": { + "self": { + "href": @string@ + } + } + }, + "status": "rejected", + "createdAt": @string@, + "updatedAt": @string@ + } + }, + "averageRating": 0, + "images": [], + "_links": { + "self": { + "href": "\/api\/v1\/products\/MUG_REVIEW_BEST" + }, + "variants": { + "href": "\/api\/v1\/products\/MUG_REVIEW_BEST\/variants\/" + } + } + }, + "createdAt": @string@, + "updatedAt": @string@ +}