From 25dc2772858ae5ced85ef379fcd5c3b1bbafbf4b Mon Sep 17 00:00:00 2001 From: Jakub 'Wolen' Tobiasz <80641364+jakubtobiasz@users.noreply.github.com> Date: Thu, 30 Dec 2021 12:27:28 +0100 Subject: [PATCH] [OPSRC-417] Add API Platform support (#28) * Enable sylius_api in the test application --- .github/workflows/build.yml | 7 +- .github/workflows/coding_standard.yml | 8 +- .gitignore | 2 + README.md | 2 +- composer.json | 10 +- phpstan.neon | 4 + phpunit.xml.dist | 25 + src/BitBagSyliusProductBundlePlugin.php | 9 + src/Command/AddProductBundleToCartCommand.php | 56 +-- src/Command/OrderIdentityAwareInterface.php | 16 + src/Command/ProductCodeAwareInterface.php | 16 + src/Controller/OrderItemController.php | 41 +- ...dProductBundleToCartDtoDataTransformer.php | 50 ++ .../AuthenticationManagerPolyfillPass.php | 28 ++ src/Dto/AddProductBundleToCartDto.php | 88 ++++ .../AddProductBundleToCartDtoInterface.php | 33 ++ src/Dto/Api/AddProductBundleToCartDto.php | 51 ++ ...dProductBundleItemToCartCommandFactory.php | 22 + ...undleItemToCartCommandFactoryInterface.php | 19 + .../AddProductBundleToCartCommandFactory.php | 34 ++ ...uctBundleToCartCommandFactoryInterface.php | 25 + .../AddProductBundleToCartDtoFactory.php | 56 +++ ...ProductBundleToCartDtoFactoryInterface.php | 25 + src/Factory/OrderItemFactory.php | 54 +++ src/Factory/OrderItemFactoryInterface.php | 21 + src/Factory/ProductBundleOrderItemFactory.php | 46 ++ ...ProductBundleOrderItemFactoryInterface.php | 20 + src/Form/Type/AddProductBundleToCartType.php | 4 +- src/Handler/AddProductBundleToCartHandler.php | 69 +-- .../CartProcessor.php | 72 +++ .../CartProcessorInterface.php | 23 + src/Resources/config/api_resources/Order.xml | 446 ++++++++++++++++++ .../config/api_resources/Product.xml | 131 +++++ .../config/api_resources/ProductBundle.xml | 99 ++++ .../api_resources/ProductBundleItem.xml | 28 ++ .../AddProductBundleToCartCommand.xml | 21 + .../AddProductBundleToCartDto.xml | 21 + .../config/serialization/OrderItem.xml | 21 + .../config/serialization/Product.xml | 21 + .../config/serialization/ProductBundle.xml | 46 ++ .../serialization/ProductBundleItem.xml | 27 ++ .../serialization/ProductBundleOrderItem.xml | 31 ++ .../config/serialization/ProductVariant.xml | 32 ++ .../ProductVariantTranslation.xml | 19 + src/Resources/config/services.xml | 3 + src/Resources/config/services/controller.xml | 3 + src/Resources/config/services/factory.xml | 30 ++ src/Resources/config/services/handler.xml | 7 +- src/Resources/config/services/processor.xml | 15 + src/Resources/config/services/transformer.xml | 12 + src/Resources/config/services/validator.xml | 32 ++ .../AddProductBundleToCartCommand.xml | 25 + .../validation/AddProductBundleToCartDto.xml | 17 + .../config/validation/ProductBundle.xml | 7 + .../config/validation/ProductBundleItem.xml | 7 + src/Resources/translations/validators.en.yml | 12 + src/Validator/HasAvailableProductBundle.php | 34 ++ .../HasAvailableProductBundleValidator.php | 154 ++++++ src/Validator/HasExistingCart.php | 28 ++ src/Validator/HasExistingCartValidator.php | 50 ++ src/Validator/HasProductBundle.php | 30 ++ src/Validator/HasProductBundleValidator.php | 58 +++ src/Validator/Sequentially.php | 52 ++ src/Validator/SequentiallyValidator.php | 37 ++ tests/Api/Admin/OrderTest.php | 57 +++ tests/Api/Admin/ProductBundleTest.php | 184 ++++++++ tests/Api/AdminJsonApiTestCase.php | 42 ++ .../ORM/general/authentication.yml | 8 + .../Api/DataFixtures/ORM/general/channels.yml | 20 + tests/Api/DataFixtures/ORM/shop/orders.yml | 85 ++++ .../DataFixtures/ORM/shop/product_bundles.yml | 120 +++++ tests/Api/JsonApiTestCase.php | 31 ++ .../admin/get_order_with_bundle_response.json | 52 ++ ...et_product_bundle_collection_response.json | 21 + .../admin/get_product_bundle_response.json | 14 + .../admin/post_product_bundle_response.json | 14 + .../admin/put_product_bundle_response.json | 14 + .../shop/get_bundled_product_response.json | 67 +++ .../get_not_bundled_product_response.json | 21 + .../shop/get_order_with_bundle_response.json | 67 +++ .../shop/get_product_bundle_response.json | 51 ++ tests/Api/Shop/OrderTest.php | 91 ++++ tests/Api/Shop/ProductTest.php | 72 +++ tests/Api/Utils/CartHelperTrait.php | 38 ++ tests/Application/config/bundles.php | 4 +- .../config/sylius/1.10/packages/_sylius.yaml | 2 + .../config/sylius/1.10/packages/security.yaml | 42 +- .../Application/config/sylius/1.8/bundles.php | 20 - .../config/sylius/1.8/packages/_sylius.yaml | 2 - .../config/sylius/1.8/packages/security.yaml | 159 ------- .../sylius/1.8/routes/sylius_admin_api.yaml | 3 - .../Application/config/sylius/1.9/bundles.php | 20 - .../config/sylius/1.9/packages/_sylius.yaml | 2 - .../config/sylius/1.9/packages/security.yaml | 159 ------- .../sylius/1.9/routes/sylius_admin_api.yaml | 3 - ...ductBundleToCartDtoDataTransformerTest.php | 69 +++ ...ductBundleItemToCartCommandFactoryTest.php | 29 ++ ...dProductBundleToCartCommandFactoryTest.php | 49 ++ .../AddProductBundleToCartDtoFactoryTest.php | 64 +++ tests/Unit/Factory/OrderItemFactoryTest.php | 37 ++ .../ProductBundleOrderItemFactoryTest.php | 55 +++ .../CartProcessorTest.php | 241 ++++++++++ .../AddProductBundleToCartHandlerTest.php | 163 +++++++ ...ddProductBundleItemToCartCommandMother.php | 22 + .../AddProductBundleToCartDtoMother.php | 36 ++ .../Api/AddProductBundleToCartDtoMother.php | 21 + tests/Unit/MotherObject/ChannelMother.php | 31 ++ tests/Unit/MotherObject/OrderItemMother.php | 22 + tests/Unit/MotherObject/OrderMother.php | 45 ++ .../MotherObject/ProductBundleItemMother.php | 22 + .../Unit/MotherObject/ProductBundleMother.php | 37 ++ tests/Unit/MotherObject/ProductMother.php | 76 +++ .../MotherObject/ProductVariantMother.php | 40 ++ tests/Unit/TypeExceptionMessage.php | 18 + ...HasAvailableProductBundleValidatorTest.php | 166 +++++++ .../HasExistingCartValidatorTest.php | 90 ++++ tests/Unit/Validator/HasProductBundleTest.php | 87 ++++ 117 files changed, 4913 insertions(+), 484 deletions(-) create mode 100644 phpunit.xml.dist create mode 100644 src/Command/OrderIdentityAwareInterface.php create mode 100644 src/Command/ProductCodeAwareInterface.php create mode 100644 src/DataTransformer/AddProductBundleToCartDtoDataTransformer.php create mode 100644 src/DependencyInjection/CompilerPass/AuthenticationManagerPolyfillPass.php create mode 100644 src/Dto/AddProductBundleToCartDto.php create mode 100644 src/Dto/AddProductBundleToCartDtoInterface.php create mode 100644 src/Dto/Api/AddProductBundleToCartDto.php create mode 100644 src/Factory/AddProductBundleItemToCartCommandFactory.php create mode 100644 src/Factory/AddProductBundleItemToCartCommandFactoryInterface.php create mode 100644 src/Factory/AddProductBundleToCartCommandFactory.php create mode 100644 src/Factory/AddProductBundleToCartCommandFactoryInterface.php create mode 100644 src/Factory/AddProductBundleToCartDtoFactory.php create mode 100644 src/Factory/AddProductBundleToCartDtoFactoryInterface.php create mode 100644 src/Factory/OrderItemFactory.php create mode 100644 src/Factory/OrderItemFactoryInterface.php create mode 100644 src/Factory/ProductBundleOrderItemFactory.php create mode 100644 src/Factory/ProductBundleOrderItemFactoryInterface.php create mode 100644 src/Handler/AddProductBundleToCartHandler/CartProcessor.php create mode 100644 src/Handler/AddProductBundleToCartHandler/CartProcessorInterface.php create mode 100644 src/Resources/config/api_resources/Order.xml create mode 100644 src/Resources/config/api_resources/Product.xml create mode 100644 src/Resources/config/api_resources/ProductBundle.xml create mode 100644 src/Resources/config/api_resources/ProductBundleItem.xml create mode 100644 src/Resources/config/serialization/AddProductBundleToCartCommand.xml create mode 100644 src/Resources/config/serialization/AddProductBundleToCartDto.xml create mode 100644 src/Resources/config/serialization/OrderItem.xml create mode 100644 src/Resources/config/serialization/Product.xml create mode 100644 src/Resources/config/serialization/ProductBundle.xml create mode 100644 src/Resources/config/serialization/ProductBundleItem.xml create mode 100644 src/Resources/config/serialization/ProductBundleOrderItem.xml create mode 100644 src/Resources/config/serialization/ProductVariant.xml create mode 100644 src/Resources/config/serialization/ProductVariantTranslation.xml create mode 100644 src/Resources/config/services/processor.xml create mode 100644 src/Resources/config/services/transformer.xml create mode 100644 src/Resources/config/services/validator.xml create mode 100644 src/Resources/config/validation/AddProductBundleToCartCommand.xml create mode 100644 src/Resources/config/validation/AddProductBundleToCartDto.xml create mode 100644 src/Validator/HasAvailableProductBundle.php create mode 100644 src/Validator/HasAvailableProductBundleValidator.php create mode 100644 src/Validator/HasExistingCart.php create mode 100644 src/Validator/HasExistingCartValidator.php create mode 100644 src/Validator/HasProductBundle.php create mode 100644 src/Validator/HasProductBundleValidator.php create mode 100644 src/Validator/Sequentially.php create mode 100644 src/Validator/SequentiallyValidator.php create mode 100644 tests/Api/Admin/OrderTest.php create mode 100644 tests/Api/Admin/ProductBundleTest.php create mode 100644 tests/Api/AdminJsonApiTestCase.php create mode 100644 tests/Api/DataFixtures/ORM/general/authentication.yml create mode 100644 tests/Api/DataFixtures/ORM/general/channels.yml create mode 100644 tests/Api/DataFixtures/ORM/shop/orders.yml create mode 100644 tests/Api/DataFixtures/ORM/shop/product_bundles.yml create mode 100644 tests/Api/JsonApiTestCase.php create mode 100644 tests/Api/Responses/admin/get_order_with_bundle_response.json create mode 100644 tests/Api/Responses/admin/get_product_bundle_collection_response.json create mode 100644 tests/Api/Responses/admin/get_product_bundle_response.json create mode 100644 tests/Api/Responses/admin/post_product_bundle_response.json create mode 100644 tests/Api/Responses/admin/put_product_bundle_response.json create mode 100644 tests/Api/Responses/shop/get_bundled_product_response.json create mode 100644 tests/Api/Responses/shop/get_not_bundled_product_response.json create mode 100644 tests/Api/Responses/shop/get_order_with_bundle_response.json create mode 100644 tests/Api/Responses/shop/get_product_bundle_response.json create mode 100644 tests/Api/Shop/OrderTest.php create mode 100644 tests/Api/Shop/ProductTest.php create mode 100644 tests/Api/Utils/CartHelperTrait.php create mode 100644 tests/Application/config/sylius/1.10/packages/_sylius.yaml delete mode 100644 tests/Application/config/sylius/1.8/bundles.php delete mode 100644 tests/Application/config/sylius/1.8/packages/_sylius.yaml delete mode 100644 tests/Application/config/sylius/1.8/packages/security.yaml delete mode 100644 tests/Application/config/sylius/1.8/routes/sylius_admin_api.yaml delete mode 100644 tests/Application/config/sylius/1.9/bundles.php delete mode 100644 tests/Application/config/sylius/1.9/packages/_sylius.yaml delete mode 100644 tests/Application/config/sylius/1.9/packages/security.yaml delete mode 100644 tests/Application/config/sylius/1.9/routes/sylius_admin_api.yaml create mode 100644 tests/Unit/DataTransformer/AddProductBundleToCartDtoDataTransformerTest.php create mode 100644 tests/Unit/Factory/AddProductBundleItemToCartCommandFactoryTest.php create mode 100644 tests/Unit/Factory/AddProductBundleToCartCommandFactoryTest.php create mode 100644 tests/Unit/Factory/AddProductBundleToCartDtoFactoryTest.php create mode 100644 tests/Unit/Factory/OrderItemFactoryTest.php create mode 100644 tests/Unit/Factory/ProductBundleOrderItemFactoryTest.php create mode 100644 tests/Unit/Handler/AddProductBundleToCartHandler/CartProcessorTest.php create mode 100644 tests/Unit/Handler/AddProductBundleToCartHandlerTest.php create mode 100644 tests/Unit/MotherObject/AddProductBundleItemToCartCommandMother.php create mode 100644 tests/Unit/MotherObject/AddProductBundleToCartDtoMother.php create mode 100644 tests/Unit/MotherObject/Api/AddProductBundleToCartDtoMother.php create mode 100644 tests/Unit/MotherObject/ChannelMother.php create mode 100644 tests/Unit/MotherObject/OrderItemMother.php create mode 100644 tests/Unit/MotherObject/OrderMother.php create mode 100644 tests/Unit/MotherObject/ProductBundleItemMother.php create mode 100644 tests/Unit/MotherObject/ProductBundleMother.php create mode 100644 tests/Unit/MotherObject/ProductMother.php create mode 100644 tests/Unit/MotherObject/ProductVariantMother.php create mode 100644 tests/Unit/TypeExceptionMessage.php create mode 100644 tests/Unit/Validator/HasAvailableProductBundleValidatorTest.php create mode 100644 tests/Unit/Validator/HasExistingCartValidatorTest.php create mode 100644 tests/Unit/Validator/HasProductBundleTest.php diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 49108a6e..3efa59d7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,7 +23,7 @@ jobs: matrix: php: [7.4, 7.3, 8.0] symfony: [^4.4, ^5.2] - sylius: [~1.8.0, ~1.9.0, ~1.10.0] + sylius: [~1.10.0] node: [10.x] mysql: [5.7] @@ -173,11 +173,14 @@ jobs: name: Run PHPSpec run: vendor/bin/phpspec run --ansi -f progress --no-interaction - - name: Run Behat run: vendor/bin/behat --colors --strict -vvv --no-interaction || vendor/bin/behat --colors --strict -vvv --no-interaction --rerun + - + name: Run PHPUnit + run: ./vendor/bin/phpunit -c ./phpunit.xml.dist + - name: Upload Behat logs uses: actions/upload-artifact@v2 diff --git a/.github/workflows/coding_standard.yml b/.github/workflows/coding_standard.yml index 7983f145..bae2ffea 100644 --- a/.github/workflows/coding_standard.yml +++ b/.github/workflows/coding_standard.yml @@ -23,7 +23,7 @@ jobs: matrix: php: [7.4, 7.3, 8.0] symfony: [^4.4, ^5.2] - sylius: [~1.8.0, ~1.9.0, ~1.10.0] + sylius: [~1.10.0] node: [10.x] mysql: [5.7] @@ -82,11 +82,11 @@ jobs: name: Restrict Sylius version if: matrix.sylius != '' run: composer require "sylius/sylius:${{ matrix.sylius }}" --no-update --no-scripts --no-interaction - + - name: Install PHP dependencies run: composer install --no-interaction - + - name: Run ECS run: vendor/bin/ecs @@ -98,4 +98,4 @@ jobs: name: Run PHPStan in /test directory run: ./vendor/bin/phpstan analyze --configuration=vendor/bitbag/coding-standard/phpstan.neon tests --level=5 - + diff --git a/.gitignore b/.gitignore index 07229a68..5f8e7464 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ /behat.yml /phpspec.yml +/phpunit.xml +/.phpunit.result.cache diff --git a/README.md b/README.md index c0051ed9..2b8eb44d 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ This **open-source plugin was developed to help the Sylius community**. If you h composer require bitbag/product-bundle-plugin ``` -2. Add plugin dependencies to your `config/bundles.php` file: +2. Add plugin dependencies to your `config/bundles.php` file after `Sylius\Bundle\ApiBundle\SyliusApiBundle`. ```php return [ diff --git a/composer.json b/composer.json index de027354..e95b56a1 100644 --- a/composer.json +++ b/composer.json @@ -6,9 +6,10 @@ "license": "MIT", "require": { "php": "^7.3 || ^8.0", - "sylius/sylius": "~1.8.0 || ~1.9.0 || ~1.10.0" + "sylius/sylius": "~1.10.0" }, "require-dev": { + "ext-json": "*", "behat/behat": "^3.6.1", "behat/mink-selenium2-driver": "^1.4", "dmore/behat-chrome-extension": "^1.3", @@ -39,7 +40,12 @@ "vimeo/psalm": "^4.12", "composer/xdebug-handler": "^2.0", "friendsofphp/php-cs-fixer": "^3.0", - "bitbag/coding-standard": "^1.0" + "bitbag/coding-standard": "^1.0", + "lchrusciel/api-test-case": "^5.1", + "polishsymfonycommunity/symfony-mocker-container": "^1.0" + }, + "conflict": { + "doctrine/orm": "^2.10.0" }, "autoload": { "psr-4": { diff --git a/phpstan.neon b/phpstan.neon index f7eee75f..af854e06 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,6 +3,10 @@ parameters: checkMissingIterableValueType: false checkGenericClassInNonGenericObjectType: false + level: 7 + paths: + - src + excludes_analyse: # Makes PHPStan crash - 'src/DependencyInjection/Configuration.php' diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 00000000..e5b1da95 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,25 @@ + + + + + + ./tests/Api + + + ./tests/Api + + + + + + + + + + + + + diff --git a/src/BitBagSyliusProductBundlePlugin.php b/src/BitBagSyliusProductBundlePlugin.php index b5853702..cb2f680d 100644 --- a/src/BitBagSyliusProductBundlePlugin.php +++ b/src/BitBagSyliusProductBundlePlugin.php @@ -10,10 +10,19 @@ namespace BitBag\SyliusProductBundlePlugin; +use BitBag\SyliusProductBundlePlugin\DependencyInjection\CompilerPass\AuthenticationManagerPolyfillPass; use Sylius\Bundle\CoreBundle\Application\SyliusPluginTrait; +use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; final class BitBagSyliusProductBundlePlugin extends Bundle { use SyliusPluginTrait; + + public function build(ContainerBuilder $container): void + { + $container->addCompilerPass(new AuthenticationManagerPolyfillPass()); + + parent::build($container); + } } diff --git a/src/Command/AddProductBundleToCartCommand.php b/src/Command/AddProductBundleToCartCommand.php index ea0e2ccd..7ef3e44c 100644 --- a/src/Command/AddProductBundleToCartCommand.php +++ b/src/Command/AddProductBundleToCartCommand.php @@ -10,57 +10,39 @@ namespace BitBag\SyliusProductBundlePlugin\Command; -use BitBag\SyliusProductBundlePlugin\Entity\OrderItemInterface; -use BitBag\SyliusProductBundlePlugin\Entity\ProductBundleItemInterface; -use BitBag\SyliusProductBundlePlugin\Entity\ProductInterface; -use Sylius\Component\Order\Model\OrderInterface; - -final class AddProductBundleToCartCommand +final class AddProductBundleToCartCommand implements OrderIdentityAwareInterface, ProductCodeAwareInterface { - /** @var OrderInterface */ - private $cart; - - /** @var OrderItemInterface */ - private $cartItem; + /** @var int */ + private $orderId; - /** @var ProductInterface */ - private $product; + /** @var string */ + private $productCode; - /** @var AddProductBundleItemToCartCommand[] */ - private $productBundleItems = []; + /** @var int */ + private $quantity; public function __construct( - OrderInterface $cart, - OrderItemInterface $cartItem, - ProductInterface $product + int $orderId, + string $productCode, + int $quantity = 1 ) { - $this->cart = $cart; - $this->cartItem = $cartItem; - $this->product = $product; - assert(null !== $product->getProductBundle()); - /** @var ProductBundleItemInterface $productBundleItem */ - foreach ($product->getProductBundle()->getProductBundleItems() as $productBundleItem) { - $this->productBundleItems[] = new AddProductBundleItemToCartCommand($productBundleItem); - } - } - - public function getProduct(): ProductInterface - { - return $this->product; + $this->orderId = $orderId; + $this->productCode = $productCode; + $this->quantity = $quantity; } - public function getProductBundleItems(): array + public function getOrderId(): int { - return $this->productBundleItems; + return $this->orderId; } - public function getCart(): OrderInterface + public function getProductCode(): string { - return $this->cart; + return $this->productCode; } - public function getCartItem(): OrderItemInterface + public function getQuantity(): int { - return $this->cartItem; + return $this->quantity; } } diff --git a/src/Command/OrderIdentityAwareInterface.php b/src/Command/OrderIdentityAwareInterface.php new file mode 100644 index 00000000..da12ee71 --- /dev/null +++ b/src/Command/OrderIdentityAwareInterface.php @@ -0,0 +1,16 @@ +messageBus = $messageBus; + $this->orderRepository = $orderRepository; + $this->addProductBundleToCartDtoFactory = $addProductBundleToCartDtoFactory; + $this->addProductBundleToCartCommandFactory = $addProductBundleToCartCommandFactory; } public function addProductBundleAction(Request $request): ?Response @@ -90,9 +108,11 @@ public function addProductBundleAction(Request $request): ?Response /** @var ProductInterface $product */ $product = $orderItem->getProduct(); assert(null !== $configuration->getFormType()); + + $addProductBundleToCartDto = $this->addProductBundleToCartDtoFactory->createNew($cart, $orderItem, $product); $form = $this->getFormFactory()->create( $configuration->getFormType(), - new AddProductBundleToCartCommand($cart, $orderItem, $product), + $addProductBundleToCartDto, $configuration->getFormOptions() ); @@ -120,9 +140,10 @@ private function handleForm( OrderItemInterface $orderItem, Request $request ): ?Response { - /** @var AddProductBundleToCartCommand $addProductBundleToCartCommand */ - $addProductBundleToCartCommand = $form->getData(); - $errors = $this->getCartItemErrors($addProductBundleToCartCommand->getCartItem()); + /** @var AddProductBundleToCartDto $addProductBundleToCartDto */ + $addProductBundleToCartDto = $form->getData(); + + $errors = $this->getCartItemErrors($addProductBundleToCartDto->getCartItem()); if (0 < count($errors)) { $form = $this->getAddToCartFormWithErrors($errors, $form); @@ -137,7 +158,15 @@ private function handleForm( return $this->redirectHandler->redirectToIndex($configuration, $orderItem); } + + $cart = $addProductBundleToCartDto->getCart(); + if (null === $cart->getId()) { + $this->orderRepository->add($cart); + } + + $addProductBundleToCartCommand = $this->addProductBundleToCartCommandFactory->createFromDto($addProductBundleToCartDto); $this->messageBus->dispatch($addProductBundleToCartCommand); + $resourceControllerEvent = $this->eventDispatcher->dispatchPostEvent(CartActions::ADD, $configuration, $orderItem); if ($resourceControllerEvent->hasResponse()) { return $resourceControllerEvent->getResponse(); diff --git a/src/DataTransformer/AddProductBundleToCartDtoDataTransformer.php b/src/DataTransformer/AddProductBundleToCartDtoDataTransformer.php new file mode 100644 index 00000000..c7d70dc3 --- /dev/null +++ b/src/DataTransformer/AddProductBundleToCartDtoDataTransformer.php @@ -0,0 +1,50 @@ +getProductCode(); + $quantity = $object->getQuantity(); + + return new AddProductBundleToCartCommand($cart->getId(), $productCode, $quantity); + } + + public function supportsTransformation( + $data, + string $to, + array $context = [] + ): bool { + return isset($context['input']['class']) && AddProductBundleToCartDto::class === $context['input']['class']; + } +} diff --git a/src/DependencyInjection/CompilerPass/AuthenticationManagerPolyfillPass.php b/src/DependencyInjection/CompilerPass/AuthenticationManagerPolyfillPass.php new file mode 100644 index 00000000..78336020 --- /dev/null +++ b/src/DependencyInjection/CompilerPass/AuthenticationManagerPolyfillPass.php @@ -0,0 +1,28 @@ +has('security.authentication_manager') + && + true === $container->has('security.authentication.manager') + ) { + $container->setAlias('security.authentication_manager', 'security.authentication.manager'); + } + } +} diff --git a/src/Dto/AddProductBundleToCartDto.php b/src/Dto/AddProductBundleToCartDto.php new file mode 100644 index 00000000..6a320a13 --- /dev/null +++ b/src/Dto/AddProductBundleToCartDto.php @@ -0,0 +1,88 @@ +cart = $cart; + $this->cartItem = $cartItem; + $this->product = $product; + $this->productBundleItems = new ArrayCollection($productBundleItems); + } + + public function getCart(): OrderInterface + { + return $this->cart; + } + + public function setCart(OrderInterface $cart): void + { + $this->cart = $cart; + } + + public function getCartItem(): OrderItemInterface + { + return $this->cartItem; + } + + public function setCartItem(OrderItemInterface $cartItem): void + { + $this->cartItem = $cartItem; + } + + public function getProduct(): ProductInterface + { + return $this->product; + } + + public function setProduct(ProductInterface $product): void + { + $this->product = $product; + } + + public function getProductBundleItems(): ArrayCollection + { + return $this->productBundleItems; + } + + public function getProductCode(): string + { + return $this->product->getCode() ?? ''; + } +} diff --git a/src/Dto/AddProductBundleToCartDtoInterface.php b/src/Dto/AddProductBundleToCartDtoInterface.php new file mode 100644 index 00000000..ffb8e23b --- /dev/null +++ b/src/Dto/AddProductBundleToCartDtoInterface.php @@ -0,0 +1,33 @@ +productCode = $productCode; + $this->quantity = $quantity; + } + + public function getOrderTokenValue(): ?string + { + return $this->orderTokenValue; + } + + public function setOrderTokenValue(?string $orderTokenValue): void + { + $this->orderTokenValue = $orderTokenValue; + } + + public function getProductCode(): string + { + return $this->productCode; + } + + public function getQuantity(): int + { + return $this->quantity; + } +} diff --git a/src/Factory/AddProductBundleItemToCartCommandFactory.php b/src/Factory/AddProductBundleItemToCartCommandFactory.php new file mode 100644 index 00000000..2b52e760 --- /dev/null +++ b/src/Factory/AddProductBundleItemToCartCommandFactory.php @@ -0,0 +1,22 @@ +getCart()->getId(); + $productCode = $dto->getProduct()->getCode() ?? ''; + $quantity = $dto->getCartItem()->getQuantity(); + + return $this->createNew($cartId, $productCode, $quantity); + } +} diff --git a/src/Factory/AddProductBundleToCartCommandFactoryInterface.php b/src/Factory/AddProductBundleToCartCommandFactoryInterface.php new file mode 100644 index 00000000..46e22d0d --- /dev/null +++ b/src/Factory/AddProductBundleToCartCommandFactoryInterface.php @@ -0,0 +1,25 @@ +addProductBundleItemToCartCommandFactory = $addProductBundleItemToCartCommandFactory; + } + + public function createNew( + OrderInterface $order, + OrderItemInterface $orderItem, + ProductInterface $product + ): AddProductBundleToCartDtoInterface { + /** @var ProductBundleInterface $productBundle */ + $productBundle = $product->getProductBundle(); + $processedProductBundleItems = $this->getProcessedProductBundleItems($productBundle); + + return new AddProductBundleToCartDto($order, $orderItem, $product, $processedProductBundleItems); + } + + /** + * @return AddProductBundleItemToCartCommand[] + */ + private function getProcessedProductBundleItems(ProductBundleInterface $productBundle): array + { + $addProductBundleItemToCartCommands = []; + + foreach ($productBundle->getProductBundleItems() as $bundleItem) { + $addProductBundleItemToCartCommands[] = $this->addProductBundleItemToCartCommandFactory->createNew($bundleItem); + } + + return $addProductBundleItemToCartCommands; + } +} diff --git a/src/Factory/AddProductBundleToCartDtoFactoryInterface.php b/src/Factory/AddProductBundleToCartDtoFactoryInterface.php new file mode 100644 index 00000000..ef9846ab --- /dev/null +++ b/src/Factory/AddProductBundleToCartDtoFactoryInterface.php @@ -0,0 +1,25 @@ +decoratedFactory = $decoratedFactory; + } + + public function createNew(): OrderItemInterface + { + /** @var OrderItemInterface $orderItem */ + $orderItem = $this->decoratedFactory->createNew(); + + return $orderItem; + } + + public function createWithVariant(ProductVariantInterface $productVariant): OrderItemInterface + { + $orderItem = $this->createNew(); + $orderItem->setVariant($productVariant); + + return $orderItem; + } + + public function createForProduct(ProductInterface $product): \Sylius\Component\Core\Model\OrderItemInterface + { + return $this->decoratedFactory->createForProduct($product); + } + + public function createForCart(OrderInterface $order): \Sylius\Component\Core\Model\OrderItemInterface + { + return $this->decoratedFactory->createForCart($order); + } +} diff --git a/src/Factory/OrderItemFactoryInterface.php b/src/Factory/OrderItemFactoryInterface.php new file mode 100644 index 00000000..308ed03e --- /dev/null +++ b/src/Factory/OrderItemFactoryInterface.php @@ -0,0 +1,21 @@ +decoratedFactory = $decoratedFactory; + } + + public function createNew(): ProductBundleOrderItemInterface + { + /** @var ProductBundleOrderItemInterface $productBundleOrderItem */ + $productBundleOrderItem = $this->decoratedFactory->createNew(); + + return $productBundleOrderItem; + } + + public function createFromProductBundleItem(ProductBundleItemInterface $bundleItem): ProductBundleOrderItemInterface + { + /** @var ProductBundleOrderItemInterface $productBundleOrderItem */ + $productBundleOrderItem = $this->decoratedFactory->createNew(); + + $productBundleOrderItem->setProductBundleItem($bundleItem); + $productBundleOrderItem->setProductVariant($bundleItem->getProductVariant()); + $productBundleOrderItem->setQuantity($bundleItem->getQuantity()); + + return $productBundleOrderItem; + } +} diff --git a/src/Factory/ProductBundleOrderItemFactoryInterface.php b/src/Factory/ProductBundleOrderItemFactoryInterface.php new file mode 100644 index 00000000..d24cb5f4 --- /dev/null +++ b/src/Factory/ProductBundleOrderItemFactoryInterface.php @@ -0,0 +1,20 @@ +setAllowedTypes('product', ProductInterface::class) ->setDefaults([ - 'data_class' => AddProductBundleToCartCommand::class, + 'data_class' => AddProductBundleToCartDto::class, ]) ; } diff --git a/src/Handler/AddProductBundleToCartHandler.php b/src/Handler/AddProductBundleToCartHandler.php index 10c5152f..5ae0af0c 100644 --- a/src/Handler/AddProductBundleToCartHandler.php +++ b/src/Handler/AddProductBundleToCartHandler.php @@ -10,53 +10,54 @@ namespace BitBag\SyliusProductBundlePlugin\Handler; -use BitBag\SyliusProductBundlePlugin\Command\AddProductBundleItemToCartCommand; use BitBag\SyliusProductBundlePlugin\Command\AddProductBundleToCartCommand; -use BitBag\SyliusProductBundlePlugin\Entity\ProductBundleOrderItemInterface; -use Doctrine\ORM\EntityManagerInterface; -use Sylius\Component\Order\Modifier\OrderModifierInterface; -use Sylius\Component\Resource\Factory\FactoryInterface; +use BitBag\SyliusProductBundlePlugin\Entity\ProductBundleInterface; +use BitBag\SyliusProductBundlePlugin\Entity\ProductInterface; +use BitBag\SyliusProductBundlePlugin\Handler\AddProductBundleToCartHandler\CartProcessorInterface; +use Sylius\Component\Core\Repository\OrderRepositoryInterface; +use Sylius\Component\Core\Repository\ProductRepositoryInterface; use Symfony\Component\Messenger\Handler\MessageHandlerInterface; +use Webmozart\Assert\Assert; final class AddProductBundleToCartHandler implements MessageHandlerInterface { - /** @var FactoryInterface */ - private $productBundleOrderItemFactory; + /** @var OrderRepositoryInterface */ + private $orderRepository; - /** @var OrderModifierInterface */ - private $orderModifier; + /** @var ProductRepositoryInterface */ + private $productRepository; - /** @var EntityManagerInterface */ - private $orderEntityManager; + /** @var CartProcessorInterface */ + private $cartProcessor; public function __construct( - FactoryInterface $productBundleOrderItemFactory, - OrderModifierInterface $orderModifier, - EntityManagerInterface $orderEntityManager + OrderRepositoryInterface $orderRepository, + ProductRepositoryInterface $productRepository, + CartProcessorInterface $cartItemProcessor ) { - $this->productBundleOrderItemFactory = $productBundleOrderItemFactory; - $this->orderModifier = $orderModifier; - $this->orderEntityManager = $orderEntityManager; + $this->orderRepository = $orderRepository; + $this->productRepository = $productRepository; + $this->cartProcessor = $cartItemProcessor; } public function __invoke(AddProductBundleToCartCommand $addProductBundleToCartCommand): void { - $cart = $addProductBundleToCartCommand->getCart(); - $cartItem = $addProductBundleToCartCommand->getCartItem(); - - /** @var AddProductBundleItemToCartCommand $productBundleItem */ - foreach ($addProductBundleToCartCommand->getProductBundleItems() as $productBundleItem) { - /** @var ProductBundleOrderItemInterface $productBundleOrderItem */ - $productBundleOrderItem = $this->productBundleOrderItemFactory->createNew(); - - $productBundleOrderItem->setProductVariant($productBundleItem->getProductVariant()); - $productBundleOrderItem->setQuantity($productBundleItem->getQuantity()); - $productBundleOrderItem->setProductBundleItem($productBundleItem->getProductBundleItem()); - $cartItem->addProductBundleOrderItem($productBundleOrderItem); - } - - $this->orderModifier->addToOrder($cart, $cartItem); - $this->orderEntityManager->persist($cart); - $this->orderEntityManager->flush(); + $cart = $this->orderRepository->findCartById($addProductBundleToCartCommand->getOrderId()); + Assert::notNull($cart); + + /** @var ProductInterface|null $product */ + $product = $this->productRepository->findOneByCode($addProductBundleToCartCommand->getProductCode()); + Assert::notNull($product); + Assert::true($product->isBundle()); + + /** @var ProductBundleInterface|null $productBundle */ + $productBundle = $product->getProductBundle(); + Assert::notNull($productBundle); + + $quantity = $addProductBundleToCartCommand->getQuantity(); + Assert::greaterThan($quantity, 0); + + $this->cartProcessor->process($cart, $productBundle, $quantity); + $this->orderRepository->add($cart); } } diff --git a/src/Handler/AddProductBundleToCartHandler/CartProcessor.php b/src/Handler/AddProductBundleToCartHandler/CartProcessor.php new file mode 100644 index 00000000..bb2ffe32 --- /dev/null +++ b/src/Handler/AddProductBundleToCartHandler/CartProcessor.php @@ -0,0 +1,72 @@ +orderItemQuantityModifier = $orderItemQuantityModifier; + $this->productBundleOrderItemFactory = $productBundleOrderItemFactory; + $this->orderModifier = $orderModifier; + $this->cartItemFactory = $cartItemFactory; + } + + public function process( + OrderInterface $cart, + ProductBundleInterface $productBundle, + int $quantity + ): void { + Assert::greaterThan($quantity, 0); + + $product = $productBundle->getProduct(); + Assert::notNull($product); + + /** @var ProductVariantInterface|false $productVariant */ + $productVariant = $product->getVariants()->first(); + Assert::notFalse($productVariant); + + $cartItem = $this->cartItemFactory->createWithVariant($productVariant); + $this->orderItemQuantityModifier->modify($cartItem, $quantity); + + foreach ($productBundle->getProductBundleItems() as $bundleItem) { + $productBundleOrderItem = $this->productBundleOrderItemFactory->createFromProductBundleItem($bundleItem); + $cartItem->addProductBundleOrderItem($productBundleOrderItem); + } + + $this->orderModifier->addToOrder($cart, $cartItem); + } +} diff --git a/src/Handler/AddProductBundleToCartHandler/CartProcessorInterface.php b/src/Handler/AddProductBundleToCartHandler/CartProcessorInterface.php new file mode 100644 index 00000000..fb01ece9 --- /dev/null +++ b/src/Handler/AddProductBundleToCartHandler/CartProcessorInterface.php @@ -0,0 +1,23 @@ + + + + + + + + + admin:order:read + + + + sylius + + + + GET + admin/orders + + + + POST + /shop/orders + input + Sylius\Bundle\ApiBundle\Command\Cart\PickupCart + + shop:order:create + + + Pickups a new cart. Provided locale code has to be one of available for a particular channel. + + + + GET + /shop/orders + + + shop:order:read + + + + + + + + GET + /admin/orders/{tokenValue} + + + + GET + /shop/orders/{tokenValue} + + shop:cart:read + + + + + DELETE + /shop/orders/{tokenValue} + + Deletes cart + + + shop:order:read + + + + + PATCH + /admin/orders/{tokenValue}/cancel + false + sylius.api.order_state_machine_transition_applicator:cancel + + admin:order:update + + + Cancels Order + + + + + PATCH + /shop/orders/{tokenValue}/items + input + Sylius\Bundle\ApiBundle\Command\Cart\AddItemToCart + + shop:cart:read + + + shop:cart:add_item + + + Adds Item to cart + + + + + PATCH + /shop/orders/{tokenValue}/product-bundle + 200 + input + BitBag\SyliusProductBundlePlugin\Dto\Api\AddProductBundleToCartDto + false + + Default + + + shop:cart:add_product_bundle + + + Adds Product Bundle to cart + + + Product bundle added to the cart + + + + + + + PATCH + /shop/orders/{tokenValue}/address + input + Sylius\Bundle\ApiBundle\Command\Checkout\AddressOrder + + shop:cart:address + + + shop:cart:read + + + Addresses cart to given location, logged in Customer does not have to provide an email + + + + + PATCH + + sylius + + /shop/orders/{tokenValue}/shipments/{shipmentId} + input + Sylius\Bundle\ApiBundle\Command\Checkout\ChooseShippingMethod + + shop:cart:select_shipping_method + + + shop:cart:read + + + Selects shipping methods for particular shipment + + + tokenValue + path + true + + string + + + + shipmentId + path + true + + string + + + + + + + + PATCH + /shop/orders/{tokenValue}/payments/{paymentId} + input + Sylius\Bundle\ApiBundle\Command\Checkout\ChoosePaymentMethod + + shop:cart:select_payment_method + + + shop:cart:read + + + Selects payment methods for particular payment + + + tokenValue + path + true + + string + + + + paymentId + path + true + + string + + + + + + + + PATCH + /shop/account/orders/{tokenValue}/payments/{paymentId} + input + Sylius\Bundle\ApiBundle\Command\Account\ChangePaymentMethod + + shop:order:account:change_payment_method + + + shop:order:account:read + + + Change the payment method as logged shop user + + + tokenValue + path + true + + string + + + + paymentId + path + true + + string + + + + + + + + GET + sylius.api.get_configuration_action + /shop/orders/{tokenValue}/payments/{paymentId}/configuration + + Retrieve payment method configuration + + + tokenValue + path + true + + string + + + + paymentId + path + true + + string + + + + + + + + PATCH + /shop/orders/{tokenValue}/complete + + sylius + sylius_checkout_complete + + input + Sylius\Bundle\ApiBundle\Command\Checkout\CompleteOrder + + shop:cart:complete + + + shop:cart:read + + + Completes checkout + + + + + DELETE + /shop/orders/{tokenValue}/items/{itemId} + input + Sylius\Bundle\ApiBundle\Controller\DeleteOrderItemAction + false + + shop:cart:remove_item + + + + + tokenValue + path + true + + string + + + + itemId + path + true + + string + + + + + + + + PATCH + /shop/orders/{tokenValue}/items/{orderItemId} + input + Sylius\Bundle\ApiBundle\Command\Cart\ChangeItemQuantityInCart + + shop:cart:change_quantity + + + Changes quantity of order item + + + tokenValue + path + true + + string + + + + orderItemId + path + true + + string + + + + + + + + PATCH + /shop/orders/{tokenValue}/apply-coupon + input + Sylius\Bundle\ApiBundle\Command\Cart\ApplyCouponToCart + + shop:cart:apply_coupon + + + Applies coupon to cart + + + + + PUT + /shop/orders/{tokenValue} + + admin:cart:update + + + + + + + GET + /shop/orders/{tokenValue}/items + + + + GET + /admin/orders/{tokenValue}/shipments + + + + GET + /admin/orders/{tokenValue}/payments + + + + GET + /shop/orders/{tokenValue}/adjustments + + + + GET + /shop/orders/{tokenValue}/payments/{payments}/methods + + + + GET + /shop/orders/{tokenValue}/shipments/{shipments}/methods + + + + GET + /shop/orders/{tokenValue}/items/{items}/adjustments + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/api_resources/Product.xml b/src/Resources/config/api_resources/Product.xml new file mode 100644 index 00000000..854c50ed --- /dev/null +++ b/src/Resources/config/api_resources/Product.xml @@ -0,0 +1,131 @@ + + + + + + + sylius + + + ASC + + + + + GET + /admin/products + + sylius.api.product_name_filter + sylius.api.product_order_filter + sylius.api.product_taxon_code_filter + sylius.api.translation_order_name_and_locale_filter + + + admin:product:read + + + + + GET + /shop/products + + sylius.api.product_name_filter + sylius.api.product_order_filter + sylius.api.product_taxon_code_filter + sylius.api.translation_order_name_and_locale_filter + sylius.api.product_taxon_filter + + + shop:product:read + + + + + POST + /admin/products + + admin:product:create + + + + + + + GET + /admin/products/{code} + + Use code to retrieve a product resource. + + + admin:product:read + + + + + GET + /shop/products/{code} + + Use code to retrieve a product resource. + + + shop:product:read + + + + + PUT + /admin/products/{code} + + admin:product:update + + + + + DELETE + /admin/products/{code} + + + + + + /shop/products/{code}/bundle + + + + + + + + + + object + + + string + string + string + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/api_resources/ProductBundle.xml b/src/Resources/config/api_resources/ProductBundle.xml new file mode 100644 index 00000000..09ca5316 --- /dev/null +++ b/src/Resources/config/api_resources/ProductBundle.xml @@ -0,0 +1,99 @@ + + + + + + + + + GET + /admin/product-bundles + + + admin:product_bundle:read + + + + + + POST + /admin/product-bundles + + admin:product_bundle:create + + + + + + GET + /admin/product-bundles/{id} + + + admin:product_bundle:read + + + + + PUT + /admin/product-bundles/{id} + + + admin:product_bundle:update + + + + + DELETE + /admin/product-bundles/{id} + + + GET + /shop/product-bundles/{id} + + + shop:product_bundle:read + + + + + PATCH + /shop/product-bundles/{id}/add-to-cart + 200 + input + BitBag\SyliusProductBundlePlugin\Command\AddProductBundleToCartCommand + false + + + shop:product_bundle:add_to_cart + + + + + Adds the product bundle to the cart retrieved by token. + + + + Product bundle added to the cart + + + + + + + + + + shop:product_bundle:read + + + + + + diff --git a/src/Resources/config/api_resources/ProductBundleItem.xml b/src/Resources/config/api_resources/ProductBundleItem.xml new file mode 100644 index 00000000..abb91cbe --- /dev/null +++ b/src/Resources/config/api_resources/ProductBundleItem.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + GET + /shop/product-bundle-items/{id} + + + shop:product_bundle_items:read + + + + + + diff --git a/src/Resources/config/serialization/AddProductBundleToCartCommand.xml b/src/Resources/config/serialization/AddProductBundleToCartCommand.xml new file mode 100644 index 00000000..38abb5de --- /dev/null +++ b/src/Resources/config/serialization/AddProductBundleToCartCommand.xml @@ -0,0 +1,21 @@ + + + + + + + + shop:product_bundle:add_to_cart + + + shop:product_bundle:add_to_cart + + + diff --git a/src/Resources/config/serialization/AddProductBundleToCartDto.xml b/src/Resources/config/serialization/AddProductBundleToCartDto.xml new file mode 100644 index 00000000..7d6fe771 --- /dev/null +++ b/src/Resources/config/serialization/AddProductBundleToCartDto.xml @@ -0,0 +1,21 @@ + + + + + + + + shop:cart:add_product_bundle + + + shop:cart:add_product_bundle + + + diff --git a/src/Resources/config/serialization/OrderItem.xml b/src/Resources/config/serialization/OrderItem.xml new file mode 100644 index 00000000..0ec5838b --- /dev/null +++ b/src/Resources/config/serialization/OrderItem.xml @@ -0,0 +1,21 @@ + + + + + + + + admin:order:read + admin:order_item:read + shop:order_item:read + shop:cart:read + + + diff --git a/src/Resources/config/serialization/Product.xml b/src/Resources/config/serialization/Product.xml new file mode 100644 index 00000000..2d762ec0 --- /dev/null +++ b/src/Resources/config/serialization/Product.xml @@ -0,0 +1,21 @@ + + + + + + + + shop:product:read + + + shop:product:read + + + diff --git a/src/Resources/config/serialization/ProductBundle.xml b/src/Resources/config/serialization/ProductBundle.xml new file mode 100644 index 00000000..e55042cf --- /dev/null +++ b/src/Resources/config/serialization/ProductBundle.xml @@ -0,0 +1,46 @@ + + + + + + + + admin:product_bundle:read + + + shop:product_bundle:read + admin:product_bundle:read + admin:product_bundle:create + admin:product_bundle:update + + + shop:product:read + shop:product_bundle:read + admin:product_bundle:read + admin:product_bundle:create + admin:product_bundle:update + + + admin:product_bundle:create + admin:product_bundle:update + + + shop:product:read + shop:product_bundle:read + admin:product_bundle:read + + + admin:product_bundle:read + + + admin:product_bundle:read + + + diff --git a/src/Resources/config/serialization/ProductBundleItem.xml b/src/Resources/config/serialization/ProductBundleItem.xml new file mode 100644 index 00000000..fb599916 --- /dev/null +++ b/src/Resources/config/serialization/ProductBundleItem.xml @@ -0,0 +1,27 @@ + + + + + + + + admin:product_bundle:create + admin:product_bundle:update + shop:product:read + shop:product_bundle:read + + + admin:product_bundle:create + admin:product_bundle:update + shop:product:read + shop:product_bundle:read + + + diff --git a/src/Resources/config/serialization/ProductBundleOrderItem.xml b/src/Resources/config/serialization/ProductBundleOrderItem.xml new file mode 100644 index 00000000..6b068a55 --- /dev/null +++ b/src/Resources/config/serialization/ProductBundleOrderItem.xml @@ -0,0 +1,31 @@ + + + + + + + + admin:order:read + admin:order_item:read + + + admin:order:read + admin:order_item:read + shop:order_item:read + shop:cart:read + + + admin:order:read + admin:order_item:read + shop:order_item:read + shop:cart:read + + + diff --git a/src/Resources/config/serialization/ProductVariant.xml b/src/Resources/config/serialization/ProductVariant.xml new file mode 100644 index 00000000..26fadc39 --- /dev/null +++ b/src/Resources/config/serialization/ProductVariant.xml @@ -0,0 +1,32 @@ + + + + + + + + shop:product:read + shop:product_bundle:read + shop:order_item:read + shop:cart:read + + + shop:product_bundle:read + + + shop:product:read + shop:product_bundle:read + + + shop:product:read + shop:product_bundle:read + + + diff --git a/src/Resources/config/serialization/ProductVariantTranslation.xml b/src/Resources/config/serialization/ProductVariantTranslation.xml new file mode 100644 index 00000000..50bf19f2 --- /dev/null +++ b/src/Resources/config/serialization/ProductVariantTranslation.xml @@ -0,0 +1,19 @@ + + + + + + + + shop:product:read + shop:product_bundle:read + + + diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index e96f46cd..0ef0affb 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -7,6 +7,9 @@ + + + diff --git a/src/Resources/config/services/controller.xml b/src/Resources/config/services/controller.xml index ca789898..c51fcb10 100644 --- a/src/Resources/config/services/controller.xml +++ b/src/Resources/config/services/controller.xml @@ -4,6 +4,9 @@ + + + diff --git a/src/Resources/config/services/factory.xml b/src/Resources/config/services/factory.xml index c0360415..26e912a5 100644 --- a/src/Resources/config/services/factory.xml +++ b/src/Resources/config/services/factory.xml @@ -6,5 +6,35 @@ + + + + + + + + + + + + diff --git a/src/Resources/config/services/handler.xml b/src/Resources/config/services/handler.xml index 7e4f6394..66cfaf68 100644 --- a/src/Resources/config/services/handler.xml +++ b/src/Resources/config/services/handler.xml @@ -3,9 +3,10 @@ - - - + + + + diff --git a/src/Resources/config/services/processor.xml b/src/Resources/config/services/processor.xml new file mode 100644 index 00000000..dec8a8cf --- /dev/null +++ b/src/Resources/config/services/processor.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/src/Resources/config/services/transformer.xml b/src/Resources/config/services/transformer.xml new file mode 100644 index 00000000..1cacdd6a --- /dev/null +++ b/src/Resources/config/services/transformer.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/src/Resources/config/services/validator.xml b/src/Resources/config/services/validator.xml new file mode 100644 index 00000000..9994250d --- /dev/null +++ b/src/Resources/config/services/validator.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/validation/AddProductBundleToCartCommand.xml b/src/Resources/config/validation/AddProductBundleToCartCommand.xml new file mode 100644 index 00000000..0b6f78bc --- /dev/null +++ b/src/Resources/config/validation/AddProductBundleToCartCommand.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/validation/AddProductBundleToCartDto.xml b/src/Resources/config/validation/AddProductBundleToCartDto.xml new file mode 100644 index 00000000..73877de2 --- /dev/null +++ b/src/Resources/config/validation/AddProductBundleToCartDto.xml @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/src/Resources/config/validation/ProductBundle.xml b/src/Resources/config/validation/ProductBundle.xml index 59843d06..6557647b 100644 --- a/src/Resources/config/validation/ProductBundle.xml +++ b/src/Resources/config/validation/ProductBundle.xml @@ -1,4 +1,11 @@ + + + + + + productRepository = $productRepository; + $this->orderRepository = $orderRepository; + $this->availabilityChecker = $availabilityChecker; + } + + /** + * @param AddProductBundleToCartCommand|mixed $value + * @param HasAvailableProductBundle|Constraint $constraint + */ + public function validate($value, Constraint $constraint): void + { + Assert::isInstanceOf($value, AddProductBundleToCartCommand::class); + Assert::isInstanceOf($constraint, HasAvailableProductBundle::class); + + $product = $this->productRepository->findOneByCode($value->getProductCode()); + Assert::notNull($product); + + if (false === $this->validateIfProductIsEnabled($product)) { + return; + } + + /** @var ProductVariantInterface $productVariant */ + $productVariant = $product->getVariants()->first(); + if (false === $this->validateIfProductVariantIsEnabled($productVariant)) { + return; + } + + /** @var OrderInterface|null $cart */ + $cart = $this->orderRepository->findCartById($value->getOrderId()); + Assert::notNull($cart); + + if (false === $this->validateIfProductAndCartChannelsMatch($product, $cart)) { + return; + } + + $targetQuantity = $value->getQuantity() + $this->getCurrentProductVariantQuantityFromCart($cart, $productVariant); + $this->validateProductVariantStock($productVariant, $targetQuantity); + } + + private function validateIfProductIsEnabled(ProductInterface $product): bool + { + if (!$product->isEnabled()) { + $this->context->addViolation(HasAvailableProductBundle::PRODUCT_DISABLED_MESSAGE, [ + '{{ code }}' => $product->getCode(), + ]); + + return false; + } + + return true; + } + + private function validateIfProductVariantIsEnabled(ProductVariantInterface $productVariant): bool + { + if (!$productVariant->isEnabled()) { + $this->context->addViolation(HasAvailableProductBundle::PRODUCT_VARIANT_DISABLED_MESSAGE, [ + '{{ code }}' => $productVariant->getCode(), + ]); + + return false; + } + + return true; + } + + private function validateIfProductAndCartChannelsMatch(ProductInterface $product, OrderInterface $cart): bool + { + /** @var ChannelInterface $channel */ + $channel = $cart->getChannel(); + + if (!$product->hasChannel($channel)) { + $this->context->addViolation(HasAvailableProductBundle::PRODUCT_DOESNT_EXIST_IN_CHANNEL_MESSAGE, [ + '{{ channel }}' => $channel->getName(), + '{{ code }}' => $product->getCode(), + ]); + + return false; + } + + return true; + } + + private function validateProductVariantStock(ProductVariantInterface $productVariant, int $targetQuantity): void + { + if ($this->availabilityChecker->isStockSufficient($productVariant, $targetQuantity)) { + return; + } + + $this->context->addViolation( + HasAvailableProductBundle::PRODUCT_VARIANT_INSUFFICIENT_STOCK_MESSAGE, + [ + '{{ code }}' => $productVariant->getCode(), + ] + ); + } + + private function getCurrentProductVariantQuantityFromCart( + OrderInterface $cart, + ProductVariantInterface $productVariant + ): int { + /** @var OrderItemInterface $item */ + foreach ($cart->getItems() as $item) { + /** @var ProductVariantInterface $itemProductVariant */ + $itemProductVariant = $item->getVariant(); + + if ($productVariant->isTracked() && $itemProductVariant->getCode() === $productVariant->getCode()) { + return $item->getQuantity(); + } + } + + return 0; + } +} diff --git a/src/Validator/HasExistingCart.php b/src/Validator/HasExistingCart.php new file mode 100644 index 00000000..0c5b48f6 --- /dev/null +++ b/src/Validator/HasExistingCart.php @@ -0,0 +1,28 @@ +orderRepository = $orderRepository; + } + + /** + * @param OrderIdentityAwareInterface|mixed $value + * @param HasExistingCart|Constraint $constraint + */ + public function validate($value, Constraint $constraint): void + { + Assert::isInstanceOf($constraint, HasExistingCart::class); + + if (!$value instanceof OrderIdentityAwareInterface) { + throw new UnexpectedValueException($value, OrderIdentityAwareInterface::class); + } + + $cart = $this->orderRepository->findCartById($value->getOrderId()); + + if (null !== $cart) { + return; + } + + $this->context->addViolation(HasExistingCart::CART_DOESNT_EXIST_MESSAGE); + } +} diff --git a/src/Validator/HasProductBundle.php b/src/Validator/HasProductBundle.php new file mode 100644 index 00000000..1791d5f9 --- /dev/null +++ b/src/Validator/HasProductBundle.php @@ -0,0 +1,30 @@ +productRepository = $productRepository; + } + + /** + * @param ProductCodeAwareInterface|mixed $value + * @param HasProductBundle|Constraint $constraint + */ + public function validate($value, Constraint $constraint): void + { + Assert::isInstanceOf($constraint, HasProductBundle::class); + + if (!$value instanceof ProductCodeAwareInterface) { + throw new UnexpectedValueException($value, ProductCodeAwareInterface::class); + } + + /** @var ProductInterface|null $product */ + $product = $this->productRepository->findOneByCode($value->getProductCode()); + + if (null === $product) { + $this->context->addViolation(HasProductBundle::PRODUCT_DOESNT_EXIST_MESSAGE); + + return; + } + + if (!$product->isBundle()) { + $this->context->addViolation(HasProductBundle::NOT_A_BUNDLE_MESSAGE); + + return; + } + } +} diff --git a/src/Validator/Sequentially.php b/src/Validator/Sequentially.php new file mode 100644 index 00000000..c92c6c42 --- /dev/null +++ b/src/Validator/Sequentially.php @@ -0,0 +1,52 @@ +|string + */ + public function getTargets() + { + return [self::CLASS_CONSTRAINT, self::PROPERTY_CONSTRAINT]; + } + + public function validatedBy(): string + { + return 'bitbag_sylius_product_bundle_validator_sequentially'; + } +} diff --git a/src/Validator/SequentiallyValidator.php b/src/Validator/SequentiallyValidator.php new file mode 100644 index 00000000..280c2a6b --- /dev/null +++ b/src/Validator/SequentiallyValidator.php @@ -0,0 +1,37 @@ +context; + + $validator = $context->getValidator()->inContext($context); + + $originalCount = $validator->getViolations()->count(); + + foreach ($constraint->constraints as $c) { + if ($originalCount !== $validator->validate($value, $c)->getViolations()->count()) { + break; + } + } + } +} diff --git a/tests/Api/Admin/OrderTest.php b/tests/Api/Admin/OrderTest.php new file mode 100644 index 00000000..4ffb59eb --- /dev/null +++ b/tests/Api/Admin/OrderTest.php @@ -0,0 +1,57 @@ +fixtures = $this->loadFixturesFromFiles([ + 'general/channels.yml', + 'general/authentication.yml', + 'shop/product_bundles.yml', + 'shop/orders.yml', + ]); + $authToken = $this->getAuthToken('api@example.com', 'sylius'); + $this->authHeaders = $this->getHeaders($authToken); + } + + /** @test */ + public function it_gets_order_data_containing_info_about_bundled_items(): void + { + /** @var OrderInterface $order */ + $order = $this->fixtures['order_with_bundle']; + + $this->client->request( + Request::METHOD_GET, + sprintf(self::ENDPOINT_ORDERS_ITEM, $order->getTokenValue()), + [], + [], + $this->authHeaders + ); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'admin/get_order_with_bundle_response', Response::HTTP_OK); + } +} diff --git a/tests/Api/Admin/ProductBundleTest.php b/tests/Api/Admin/ProductBundleTest.php new file mode 100644 index 00000000..c41f5255 --- /dev/null +++ b/tests/Api/Admin/ProductBundleTest.php @@ -0,0 +1,184 @@ +fixtures = $this->loadFixturesFromFiles(['general/channels.yml', 'general/authentication.yml', 'shop/product_bundles.yml']); + $authToken = $this->getAuthToken('api@example.com', 'sylius'); + $this->authHeaders = $this->getHeaders($authToken); + } + + /** @test */ + public function it_gets_product_bundles_collection(): void + { + $this->client->request( + Request::METHOD_GET, + self::ENDPOINT_PRODUCT_BUNDLES_COLLECTION, + [], + [], + $this->authHeaders + ); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'admin/get_product_bundle_collection_response', Response::HTTP_OK); + } + + /** @test */ + public function it_gets_product_bundle(): void + { + /** @var ProductBundleInterface $productBundleId */ + $productBundleId = $this->fixtures['productBundle1']; + + $this->client->request( + Request::METHOD_GET, + sprintf(self::ENDPOINT_PRODUCT_BUNDLES_ITEM, $productBundleId->getId()), + [], + [], + $this->authHeaders + ); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'admin/get_product_bundle_response', Response::HTTP_OK); + } + + /** @test */ + public function it_creates_product_bundle(): void + { + $johnnyWalkerBlack = $this->createProductBundleItemObject('JOHNNY_WALKER_BLACK'); + $johnnyWalkerBlue = $this->createProductBundleItemObject('JOHNNY_WALKER_BLUE'); + + $this->client->request( + Request::METHOD_POST, + self::ENDPOINT_PRODUCT_BUNDLES_COLLECTION, + [], + [], + $this->authHeaders, + json_encode([ + 'product' => self::JOHNNY_WALKER_BUNDLE_PRODUCT_IRI, + 'items' => [ + $johnnyWalkerBlack, + $johnnyWalkerBlue, + ], + 'isPacked' => true, + ], \JSON_THROW_ON_ERROR) + ); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'admin/post_product_bundle_response', Response::HTTP_CREATED); + } + + /** @test */ + public function it_updates_product_bundle(): void + { + /** @var ProductBundleInterface $productBundle */ + $productBundle = $this->fixtures['productBundle1']; + + $this->client->request( + Request::METHOD_GET, + sprintf(self::ENDPOINT_PRODUCT_BUNDLES_ITEM, $productBundle->getId()), + [], + [], + $this->authHeaders + ); + /** @var string $baseResponseContent */ + $baseResponseContent = $this->client->getResponse()->getContent(); + $baseProductBundle = json_decode($baseResponseContent, true, 512, \JSON_THROW_ON_ERROR); + $baseBundleItems = $baseProductBundle['items'] ?? []; + + $johnnyWalkerBlue = $this->createProductBundleItemObject('JOHNNY_WALKER_BLUE'); + $johnnyWalkerGold = $this->createProductBundleItemObject('JOHNNY_WALKER_GOLD'); + + $this->client->request( + Request::METHOD_PUT, + sprintf(self::ENDPOINT_PRODUCT_BUNDLES_ITEM, $productBundle->getId()), + [], + [], + $this->authHeaders, + json_encode([ + 'items' => [ + $johnnyWalkerBlue, + $johnnyWalkerGold, + ], + 'isPacked' => false, + ], \JSON_THROW_ON_ERROR) + ); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'admin/put_product_bundle_response', Response::HTTP_OK); + + /** @var string $updateResponseContent */ + $updateResponseContent = $response->getContent(); + $updatedProductBundle = json_decode($updateResponseContent, true, 512, \JSON_THROW_ON_ERROR); + $updatedBundleItems = $updatedProductBundle['items'] ?? []; + + foreach ($updatedBundleItems as $bundleItem) { + self::assertNotContains($bundleItem, $baseBundleItems); + } + } + + private function createProductBundleItemObject(string $productVariantCode, int $quantity = 1): object + { + $productBundleItem = new \stdClass(); + $productBundleItem->productVariant = '/api/v2/admin/product-variants/' . $productVariantCode; + $productBundleItem->quantity = $quantity; + + return $productBundleItem; + } + + /** @test */ + public function it_deletes_product_bundle(): void + { + /** @var ProductBundleInterface $productBundle */ + $productBundle = $this->fixtures['productBundle1']; + + $this->client->request( + Request::METHOD_DELETE, + sprintf(self::ENDPOINT_PRODUCT_BUNDLES_ITEM, $productBundle->getId()), + [], + [], + $this->authHeaders + ); + $response = $this->client->getResponse(); + + $this->assertResponseCode($response, Response::HTTP_NO_CONTENT); + + $this->client->request( + Request::METHOD_GET, + sprintf(self::ENDPOINT_PRODUCT_BUNDLES_ITEM, $productBundle->getId()), + [], + [], + $this->authHeaders + ); + $response = $this->client->getResponse(); + + $this->assertResponseCode($response, Response::HTTP_NOT_FOUND); + } +} diff --git a/tests/Api/AdminJsonApiTestCase.php b/tests/Api/AdminJsonApiTestCase.php new file mode 100644 index 00000000..3a9632af --- /dev/null +++ b/tests/Api/AdminJsonApiTestCase.php @@ -0,0 +1,42 @@ +client->request( + 'POST', + '/api/v2/admin/authentication-token', + [], + [], + ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json'], + json_encode(['email' => $email, 'password' => $password]) + ); + + return json_decode($this->client->getResponse()->getContent(), true)['token']; + } + + public function getHeaders($authToken = null): array + { + $headers = ['CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json']; + + if (null === $authToken) { + return $headers; + } + + $authorizationHeader = self::getContainer()->getParameter('sylius.api.authorization_header'); + $headers['HTTP_' . $authorizationHeader] = 'Bearer ' . $authToken; + + return $headers; + } +} diff --git a/tests/Api/DataFixtures/ORM/general/authentication.yml b/tests/Api/DataFixtures/ORM/general/authentication.yml new file mode 100644 index 00000000..93c89f34 --- /dev/null +++ b/tests/Api/DataFixtures/ORM/general/authentication.yml @@ -0,0 +1,8 @@ +Sylius\Component\Core\Model\AdminUser: + admin: + plainPassword: sylius + roles: [ROLE_API_ACCESS] + enabled: true + email: api@example.com + username: sylius + localeCode: en_US diff --git a/tests/Api/DataFixtures/ORM/general/channels.yml b/tests/Api/DataFixtures/ORM/general/channels.yml new file mode 100644 index 00000000..ebe65c16 --- /dev/null +++ b/tests/Api/DataFixtures/ORM/general/channels.yml @@ -0,0 +1,20 @@ +Sylius\Component\Core\Model\Channel: + channel_web: + code: "WEB" + name: "Web Channel" + hostname: "localhost" + description: "Lorem ipsum" + baseCurrency: "@currency1" + defaultLocale: "@locale" + locales: ["@locale"] + color: "black" + enabled: true + taxCalculationStrategy: "order_items_based" + +Sylius\Component\Currency\Model\Currency: + currency1: + code: "EUR" + +Sylius\Component\Locale\Model\Locale: + locale: + code: "en_US" diff --git a/tests/Api/DataFixtures/ORM/shop/orders.yml b/tests/Api/DataFixtures/ORM/shop/orders.yml new file mode 100644 index 00000000..4ad75072 --- /dev/null +++ b/tests/Api/DataFixtures/ORM/shop/orders.yml @@ -0,0 +1,85 @@ +Sylius\Component\Core\Model\Address: + address_joe_doe: + firstName: Joe + lastName: Doe + street: 'Warszawska' + city: 'Warszawa' + postcode: '00-001' + countryCode: 'PL' + +Sylius\Component\Core\Model\Customer: + customer_joe_doe: + email: joe@localhost + emailCanonical: joe@localhost + +Sylius\Component\Addressing\Model\Zone: + zone_world: + code: 'WORLD' + name: 'World' + type: 'country' + scope: 'all' + +Sylius\Component\Shipping\Model\ShippingMethodTranslation: + shipping_method_translation_ups: + name: 'UPS' + description: 'UPS' + locale: 'en_US' + translatable: '@shipping_method_ups' + +Sylius\Component\Core\Model\ShippingMethod: + shipping_method_ups: + zone: '@zone_world' + code: 'ups' + channels: + - '@channel_web' + position: 0 + categoryRequirement: 1 + calculator: 'flat_rate' + configuration: + WEB: + amount: 0 + translations: + - '@shipping_method_translation_ups' + +Sylius\Component\Core\Model\Shipment: + shipment_1: + state: 'ready' + method: '@shipping_method_ups' + order: '@order_with_bundle' + +Sylius\Component\Core\Model\OrderItemUnit: + order_item_unit_1: + __construct: ['@order_item_1'] + shipment: '@shipment_1' + +BitBag\SyliusProductBundlePlugin\Entity\ProductBundleOrderItem: + product_bundle_order_item_{1..2}: + orderItem: '@order_item_1' + productBundleItem: '@productBundleItem' + productVariant: '@productVariant' + quantity: 1 + +Tests\BitBag\SyliusProductBundlePlugin\Entity\OrderItem: + order_item_1: + order: '@order_with_bundle' + unitPrice: 1800 + variant: '@productVariant3' + productName: 'Whiskey Double Pack' + variantname: 'Whiskey Double Pack' + +Sylius\Component\Core\Model\Order: + order_with_bundle: + shippingAddress: '@address_joe_doe' + billingAddress: '@address_joe_doe' + channel: '@channel_web' + customer: '@customer_joe_doe' + state: 'new' + items: + - '@order_item_1' + currencyCode: 'EUR' + localeCode: 'en_US' + checkoutState: 'completed' + payment_state: 'awaiting_payment' + shipping_state: 'ready' + tokenValue: 'zszRdAaZIx' + number: "000000001" diff --git a/tests/Api/DataFixtures/ORM/shop/product_bundles.yml b/tests/Api/DataFixtures/ORM/shop/product_bundles.yml new file mode 100644 index 00000000..2e84fcf6 --- /dev/null +++ b/tests/Api/DataFixtures/ORM/shop/product_bundles.yml @@ -0,0 +1,120 @@ +Sylius\Component\Core\Model\ProductTranslation: + productTranslation (template): + locale: "en_US" + description: + translatable: "@product" + productTranslation{1} (extends productTranslation): + name: "Johnny Walker Black" + slug: "johnny-walker-black" + productTranslation{2} (extends productTranslation): + name: "Jack Daniel's Gentleman Jack" + slug: "jack-daniels-gentleman-jack" + productTranslation{3} (extends productTranslation): + name: "Whiskey Double Pack" + slug: "whiskey-double-pack" + productTranslation{4} (extends productTranslation) : + name: "Johnny Walker Blue" + slug: "johnny-walker-blue" + productTranslation{5} (extends productTranslation) : + name: "Johnny Walker Bundle" + slug: "johnny-walker-bundle" + productTranslation{6} (extends productTranslation) : + name: "Johnny Walker Gold" + slug: "johnny-walker-gold" + +Tests\BitBag\SyliusProductBundlePlugin\Entity\Product: + product (template): + fallbackLocale: "en_US" + currentLocale: "en_US" + channels: + - "@channel_web" + translations: + - "@productTranslation" + product{1} (extends product): + code: "JOHNNY_WALKER_BLACK" + product{2} (extends product): + code: "JACK_DANIELS_GENTLEMAN" + product{3} (extends product): + code: "WHISKEY_DOUBLE_PACK" + product{4} (extends product): + code: "JOHNNY_WALKER_BLUE" + product{5} (extends product): + code: "JOHNNY_WALKER_BUNDLE" + product{6} (extends product): + code: "JOHNNY_WALKER_GOLD" + +Sylius\Component\Product\Model\ProductVariantTranslation: + productVariantTranslation (template): + locale: en_US + translatable: "@productVariant" + productVariantTranslation{1} (extends productVariantTranslation): + name: "Johnny Walker Black" + productVariantTranslation{2} (extends productVariantTranslation): + name: "Jack Daniel's Gentleman Jack" + productVariantTranslation{3} (extends productVariantTranslation): + name: "Whiskey Double Pack" + productVariantTranslation{4} (extends productVariantTranslation): + name: "Johnny Walker Blue" + productVariantTranslation{5} (extends productVariantTranslation): + name: "Johnny Walker Bundle" + productVariantTranslation{6} (extends productVariantTranslation): + name: "Johnny Walker Gold" + +Sylius\Component\Core\Model\ChannelPricing: + channelPricing (template): + productVariant: "@productVariant" + channelCode: "WEB" + channelPricing{1} (extends channelPricing): + price: 1000 + originalPrice: 1000 + channelPricing{2} (extends channelPricing): + price: 1000 + originalPrice: 1000 + channelPricing{3} (extends channelPricing): + price: 1800 + originalPrice: 1800 + channelPricing{4} (extends channelPricing): + price: 2000 + originalPrice: 2000 + channelPricing{5} (extends channelPricing): + price: 2500 + originalPrice: 2500 + channelPricing{6} (extends channelPricing): + price: 1000 + originalPrice: 1000 + +Sylius\Component\Core\Model\ProductVariant: + productVariant (template): + version: 1 + product: "@product" + fallbackLocale: "en_US" + currentLocale: "en_US" + position: "" + translations: + - "@productVariantTranslation" + productVariant{1} (extends productVariant): + code: "JOHNNY_WALKER_BLACK" + productVariant{2} (extends productVariant): + code: "JACK_DANIELS_GENTLEMAN" + productVariant{3} (extends productVariant): + code: "WHISKEY_DOUBLE_PACK" + productVariant{4} (extends productVariant): + code: "JOHNNY_WALKER_BLUE" + productVariant{5} (extends productVariant): + code: "JOHNNY_WALKER_BUNDLE" + productVariant{6} (extends productVariant): + code: "JOHNNY_WALKER_GOLD" + +BitBag\SyliusProductBundlePlugin\Entity\ProductBundleItem: + productBundleItem{1..2}: + productVariant: "@productVariant" + quantity: 1 + productBundle: "@productBundle1" + +BitBag\SyliusProductBundlePlugin\Entity\ProductBundle: + productBundle1: + product: "@product3" + productBundleItems: + - "@productBundleItem1" + - "@productBundleItem2" + isPackedProduct: true diff --git a/tests/Api/JsonApiTestCase.php b/tests/Api/JsonApiTestCase.php new file mode 100644 index 00000000..542e309e --- /dev/null +++ b/tests/Api/JsonApiTestCase.php @@ -0,0 +1,31 @@ + 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json']; + + public const PATCH_HEADER = ['CONTENT_TYPE' => 'application/merge-patch+json', 'HTTP_ACCEPT' => 'application/ld+json']; + + protected static function getContainer(): ContainerInterface + { + if (is_callable('parent::getContainer')) { + /* @phpstan-ignore-next-line */ + return parent::getContainer(); + } + + return self::$container; + } +} diff --git a/tests/Api/Responses/admin/get_order_with_bundle_response.json b/tests/Api/Responses/admin/get_order_with_bundle_response.json new file mode 100644 index 00000000..d30173f2 --- /dev/null +++ b/tests/Api/Responses/admin/get_order_with_bundle_response.json @@ -0,0 +1,52 @@ +{ + "@context": "\/api\/v2\/contexts\/Order", + "@id": "\/api\/v2\/admin\/orders\/zszRdAaZIx", + "@type": "Order", + "customer": "\/api\/v2\/admin\/customers\/@integer@", + "channel": "\/api\/v2\/admin\/channels\/WEB", + "shippingAddress": "@*@", + "billingAddress": "@*@", + "payments": "@*@", + "shipments": "@*@", + "currencyCode": "EUR", + "localeCode": "en_US", + "checkoutState": "completed", + "paymentState": "awaiting_payment", + "shippingState": "ready", + "tokenValue": "zszRdAaZIx", + "id": "@integer@", + "number": "@string@", + "items": [ + { + "@id": "\/api\/v2\/admin\/order-items\/@integer@", + "@type": "OrderItem", + "variant": "\/api\/v2\/admin\/product-variants\/WHISKEY_DOUBLE_PACK", + "productName": "Whiskey Double Pack", + "id": "@integer@", + "quantity": 1, + "total": 1800, + "productBundleOrderItems": [ + { + "@type": "ProductBundleOrderItem", + "@id": "@string@", + "id": "@integer@", + "productVariant": "\/api\/v2\/admin\/product-variants\/@string@", + "quantity": 1 + }, + { + "@type": "ProductBundleOrderItem", + "@id": "@string@", + "id": "@integer@", + "productVariant": "\/api\/v2\/admin\/product-variants\/@string@", + "quantity": 1 + } + ], + "subtotal": 1800 + } + ], + "total": 1800, + "state": "new", + "taxTotal": 0, + "shippingTotal": 0, + "orderPromotionTotal": 0 +} diff --git a/tests/Api/Responses/admin/get_product_bundle_collection_response.json b/tests/Api/Responses/admin/get_product_bundle_collection_response.json new file mode 100644 index 00000000..67e2a136 --- /dev/null +++ b/tests/Api/Responses/admin/get_product_bundle_collection_response.json @@ -0,0 +1,21 @@ +{ + "@context": "\/api\/v2\/contexts\/ProductBundle", + "@id": "\/api\/v2\/admin\/product-bundles", + "@type": "hydra:Collection", + "hydra:member": [ + { + "@id": "\/api\/v2\/admin\/product-bundles\/@integer@", + "@type": "ProductBundle", + "id": "@integer@", + "product": "\/api\/v2\/admin\/products\/WHISKEY_DOUBLE_PACK", + "items": [ + "\/api\/v2\/shop\/product-bundle-items\/@integer@", + "\/api\/v2\/shop\/product-bundle-items\/@integer@" + ], + "createdAt": "@datetime@", + "updatedAt": "@datetime@", + "isPacked": true + } + ], + "hydra:totalItems": 1 +} diff --git a/tests/Api/Responses/admin/get_product_bundle_response.json b/tests/Api/Responses/admin/get_product_bundle_response.json new file mode 100644 index 00000000..3592341d --- /dev/null +++ b/tests/Api/Responses/admin/get_product_bundle_response.json @@ -0,0 +1,14 @@ +{ + "@context": "\/api\/v2\/contexts\/ProductBundle", + "@id": "\/api\/v2\/admin\/product-bundles\/@integer@", + "@type": "ProductBundle", + "id": "@integer@", + "product": "\/api\/v2\/admin\/products\/WHISKEY_DOUBLE_PACK", + "items": [ + "\/api\/v2\/shop\/product-bundle-items\/@integer@", + "\/api\/v2\/shop\/product-bundle-items\/@integer@" + ], + "createdAt": "@datetime@", + "updatedAt": "@datetime@", + "isPacked": true +} diff --git a/tests/Api/Responses/admin/post_product_bundle_response.json b/tests/Api/Responses/admin/post_product_bundle_response.json new file mode 100644 index 00000000..7fec9ba3 --- /dev/null +++ b/tests/Api/Responses/admin/post_product_bundle_response.json @@ -0,0 +1,14 @@ +{ + "@context": "\/api\/v2\/contexts\/ProductBundle", + "@id": "\/api\/v2\/admin\/product-bundles\/@integer@", + "@type": "ProductBundle", + "id": "@integer@", + "product": "\/api\/v2\/admin\/products\/JOHNNY_WALKER_BUNDLE", + "items": [ + "\/api\/v2\/shop\/product-bundle-items\/@integer@", + "\/api\/v2\/shop\/product-bundle-items\/@integer@" + ], + "createdAt": "@datetime@", + "updatedAt": "@datetime@", + "isPacked": true +} diff --git a/tests/Api/Responses/admin/put_product_bundle_response.json b/tests/Api/Responses/admin/put_product_bundle_response.json new file mode 100644 index 00000000..33e86896 --- /dev/null +++ b/tests/Api/Responses/admin/put_product_bundle_response.json @@ -0,0 +1,14 @@ +{ + "@context": "\/api\/v2\/contexts\/ProductBundle", + "@id": "\/api\/v2\/admin\/product-bundles\/@integer@", + "@type": "ProductBundle", + "id": "@integer@", + "product": "\/api\/v2\/admin\/products\/WHISKEY_DOUBLE_PACK", + "items": [ + "\/api\/v2\/shop\/product-bundle-items\/@integer@", + "\/api\/v2\/shop\/product-bundle-items\/@integer@" + ], + "createdAt": "@datetime@", + "updatedAt": "@datetime@", + "isPacked": false +} diff --git a/tests/Api/Responses/shop/get_bundled_product_response.json b/tests/Api/Responses/shop/get_bundled_product_response.json new file mode 100644 index 00000000..ebc68fb3 --- /dev/null +++ b/tests/Api/Responses/shop/get_bundled_product_response.json @@ -0,0 +1,67 @@ +{ + "@context": "\/api\/v2\/contexts\/Product", + "@id": "\/api\/v2\/shop\/products\/WHISKEY_DOUBLE_PACK", + "@type": "Product", + "productTaxons": "@*@", + "mainTaxon": "@string@||@null@", + "reviews": "@*@", + "averageRating": "@integer@", + "images": "@*@", + "id": "@integer@", + "code": "WHISKEY_DOUBLE_PACK", + "variants": "@*@", + "options": "@*@", + "createdAt": "@string@", + "updatedAt": "@string@", + "translations": "@json@", + "bundle": { + "@id": "\/api\/v2\/shop\/product-bundles\/@integer@", + "@type": "ProductBundle", + "items": [ + { + "@type": "ProductBundleItem", + "@id": "@string@", + "productVariant": { + "@id": "\/api\/v2\/shop\/product-variants\/JOHNNY_WALKER_BLACK", + "@type": "ProductVariant", + "code": "JOHNNY_WALKER_BLACK", + "optionValues": "@*@", + "translations": { + "en_US": { + "@id": "/api/v2/shop/product-variant-translation/@integer@", + "@type": "ProductVariantTranslation", + "name": "Johnny Walker Black" + } + }, + "price": 1000, + "inStock": true + }, + "quantity": 1 + }, + { + "@type": "ProductBundleItem", + "@id": "@string@", + "productVariant": { + "@id": "\/api\/v2\/shop\/product-variants\/JACK_DANIELS_GENTLEMAN", + "@type": "ProductVariant", + "code": "JACK_DANIELS_GENTLEMAN", + "optionValues": "@*@", + "translations": { + "en_US": { + "@id": "/api/v2/shop/product-variant-translation/@integer@", + "@type": "ProductVariantTranslation", + "name": "Jack Daniel's Gentleman Jack" + } + }, + "price": 1000, + "inStock": true + }, + "quantity": 1 + } + ], + "isPacked": true + }, + "isBundle": true, + "description": "@string@", + "defaultVariant": "@string@" +} diff --git a/tests/Api/Responses/shop/get_not_bundled_product_response.json b/tests/Api/Responses/shop/get_not_bundled_product_response.json new file mode 100644 index 00000000..2e4e59b2 --- /dev/null +++ b/tests/Api/Responses/shop/get_not_bundled_product_response.json @@ -0,0 +1,21 @@ +{ + "@context": "\/api\/v2\/contexts\/Product", + "@id": "\/api\/v2\/shop\/products\/JOHNNY_WALKER_BLACK", + "@type": "Product", + "productTaxons": "@*@", + "mainTaxon": "@string@||@null@", + "reviews": "@*@", + "averageRating": "@integer@", + "images": "@*@", + "id": "@integer@", + "code": "JOHNNY_WALKER_BLACK", + "variants": "@*@", + "options": "@*@", + "createdAt": "@string@", + "updatedAt": "@string@", + "translations": "@json@", + "bundle": "@null@", + "isBundle": false, + "description": "@string@", + "defaultVariant": "@string@" +} diff --git a/tests/Api/Responses/shop/get_order_with_bundle_response.json b/tests/Api/Responses/shop/get_order_with_bundle_response.json new file mode 100644 index 00000000..f3d8e637 --- /dev/null +++ b/tests/Api/Responses/shop/get_order_with_bundle_response.json @@ -0,0 +1,67 @@ +{ + "@context": "\/api\/v2\/contexts\/Order", + "@id": "\/api\/v2\/shop\/orders\/zszRdAaZIx", + "@type": "Order", + "shippingAddress": "@*@", + "billingAddress": "@*@", + "payments": "@*@", + "shipments": "@*@", + "currencyCode": "EUR", + "localeCode": "en_US", + "checkoutState": "completed", + "paymentState": "awaiting_payment", + "shippingState": "ready", + "tokenValue": "zszRdAaZIx", + "id": "@integer@", + "number": "@string@", + "items": [ + { + "@id": "\/api\/v2\/shop\/order-items\/@integer@", + "@type": "OrderItem", + "variant": { + "@id": "\/api\/v2\/shop\/product-variants\/WHISKEY_DOUBLE_PACK", + "@type": "ProductVariant", + "code": "WHISKEY_DOUBLE_PACK", + "price": 1800, + "inStock": true + }, + "productName": "Whiskey Double Pack", + "id": "@integer@", + "quantity": 1, + "unitPrice": 1800, + "total": 1800, + "productBundleOrderItems": [ + { + "@type": "ProductBundleOrderItem", + "@id": "@string@", + "productVariant": { + "@id": "\/api\/v2\/shop\/product-variants\/@string@", + "@type": "ProductVariant", + "code": "@string@", + "price": 1000, + "inStock": true + }, + "quantity": 1 + }, + { + "@type": "ProductBundleOrderItem", + "@id": "@string@", + "productVariant": { + "@id": "\/api\/v2\/shop\/product-variants\/@string@", + "@type": "ProductVariant", + "code": "@string@", + "price": 1000, + "inStock": true + }, + "quantity": 1 + } + ], + "subtotal": 1800 + } + ], + "itemsTotal": 1800, + "total": 1800, + "taxTotal": 0, + "shippingTotal": 0, + "orderPromotionTotal": 0 +} diff --git a/tests/Api/Responses/shop/get_product_bundle_response.json b/tests/Api/Responses/shop/get_product_bundle_response.json new file mode 100644 index 00000000..c349ec5d --- /dev/null +++ b/tests/Api/Responses/shop/get_product_bundle_response.json @@ -0,0 +1,51 @@ +{ + "@context": "\/api\/v2\/contexts\/ProductBundle", + "@id": "\/api\/v2\/shop\/product-bundles\/@integer@", + "@type": "ProductBundle", + "product": "\/api\/v2\/shop\/products\/WHISKEY_DOUBLE_PACK", + "items": [ + { + "@type": "ProductBundleItem", + "@id": "@string@", + "productVariant": { + "@id": "/api/v2/shop/product-variants/JOHNNY_WALKER_BLACK", + "@type": "ProductVariant", + "code": "JOHNNY_WALKER_BLACK", + "product": "\/api\/v2\/shop\/products\/JOHNNY_WALKER_BLACK", + "optionValues": "@*@", + "translations": { + "en_US": { + "@id": "/api/v2/shop/product-variant-translation/@integer@", + "@type": "ProductVariantTranslation", + "name": "Johnny Walker Black" + } + }, + "price": 1000, + "inStock": true + }, + "quantity": 1 + }, + { + "@type": "ProductBundleItem", + "@id": "@string@", + "productVariant": { + "@id": "/api/v2/shop/product-variants/JACK_DANIELS_GENTLEMAN", + "@type": "ProductVariant", + "code": "JACK_DANIELS_GENTLEMAN", + "product": "\/api\/v2\/shop\/products\/JACK_DANIELS_GENTLEMAN", + "optionValues": "@*@", + "translations": { + "en_US": { + "@id": "/api/v2/shop/product-variant-translation/@integer@", + "@type": "ProductVariantTranslation", + "name": "Jack Daniel's Gentleman Jack" + } + }, + "price": 1000, + "inStock": true + }, + "quantity": 1 + } + ], + "isPacked": true +} diff --git a/tests/Api/Shop/OrderTest.php b/tests/Api/Shop/OrderTest.php new file mode 100644 index 00000000..38a49dc7 --- /dev/null +++ b/tests/Api/Shop/OrderTest.php @@ -0,0 +1,91 @@ +fixtures = $this->loadFixturesFromFiles([ + 'general/channels.yml', + 'shop/product_bundles.yml', + 'shop/orders.yml', + ]); + } + + /** @test */ + public function it_gets_order_data_containing_info_about_bundled_items(): void + { + /** @var OrderInterface $order */ + $order = $this->fixtures['order_with_bundle']; + + $this->client->request( + Request::METHOD_GET, + sprintf(self::ENDPOINT_ORDERS_ITEM, $order->getTokenValue()), + [], + [], + self::DEFAULT_HEADER + ); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'shop/get_order_with_bundle_response', Response::HTTP_OK); + } + + /** @test */ + public function it_adds_product_bundle_to_a_cart(): void + { + $this->createCart(self::CART_TOKEN); + /** @var ProductBundleInterface $productBundle */ + $productBundle = $this->fixtures['productBundle1']; + + $this->client->request( + Request::METHOD_PATCH, + sprintf(self::ENDPOINT_ORDERS_PRODUCT_BUNDLE, self::CART_TOKEN), + [], + [], + self::PATCH_HEADER, + json_encode([ + 'productCode' => $productBundle->getProduct()->getCode(), + ], \JSON_THROW_ON_ERROR) + ); + $response = $this->client->getResponse(); + + $this->assertResponseCode($response, Response::HTTP_OK); + + $cart = $this->findCart(self::CART_TOKEN); + /** @var OrderItemInterface $cartItem */ + $cartItem = $cart->getItems()->first(); + $cartProduct = $cartItem->getProduct(); + + self::assertCount(1, $cart->getItems()); + self::assertCount(2, $cartItem->getProductBundleOrderItems()); + self::assertSame($productBundle->getProduct()->getId(), $cartProduct->getId()); + } +} diff --git a/tests/Api/Shop/ProductTest.php b/tests/Api/Shop/ProductTest.php new file mode 100644 index 00000000..6f4f0405 --- /dev/null +++ b/tests/Api/Shop/ProductTest.php @@ -0,0 +1,72 @@ +loadFixturesFromFiles(['general/channels.yml', 'shop/product_bundles.yml']); + } + + /** @test */ + public function it_gets_bundled_product(): void + { + $this->client->request( + Request::METHOD_GET, + sprintf(self::ENDPOINT_PRODUCTS_ITEM, 'WHISKEY_DOUBLE_PACK'), + [], + [], + self::DEFAULT_HEADER + ); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'shop/get_bundled_product_response', Response::HTTP_OK); + } + + /** @test */ + public function it_gets_not_bundled_product(): void + { + $this->client->request( + Request::METHOD_GET, + sprintf(self::ENDPOINT_PRODUCTS_ITEM, 'JOHNNY_WALKER_BLACK'), + [], + [], + self::DEFAULT_HEADER + ); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'shop/get_not_bundled_product_response', Response::HTTP_OK); + } + + /** @test */ + public function it_gets_product_bundle_as_a_subresource(): void + { + $this->client->request( + Request::METHOD_GET, + sprintf(self::ENDPOINT_PRODUCTS_ITEM_PRODUCT_BUNDLE, 'WHISKEY_DOUBLE_PACK'), + [], + [], + self::DEFAULT_HEADER + ); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'shop/get_product_bundle_response', Response::HTTP_OK); + } +} diff --git a/tests/Api/Utils/CartHelperTrait.php b/tests/Api/Utils/CartHelperTrait.php new file mode 100644 index 00000000..51f826f7 --- /dev/null +++ b/tests/Api/Utils/CartHelperTrait.php @@ -0,0 +1,38 @@ +get('sylius.command_bus'); + + $command = new PickupCart($tokenValue, 'en_US'); + $command->setChannelCode('WEB'); + + $commandBus->dispatch($command); + } + + public function findCart(string $tokenValue): ?OrderInterface + { + /** @var OrderRepositoryInterface $orderManager */ + $orderManager = self::getContainer()->get('sylius.repository.order'); + + return $orderManager->findCartByTokenValue($tokenValue); + } +} diff --git a/tests/Application/config/bundles.php b/tests/Application/config/bundles.php index 845f4bd0..59b57b19 100644 --- a/tests/Application/config/bundles.php +++ b/tests/Application/config/bundles.php @@ -47,7 +47,6 @@ Sylius\Bundle\ThemeBundle\SyliusThemeBundle::class => ['all' => true], Sylius\Bundle\AdminBundle\SyliusAdminBundle::class => ['all' => true], Sylius\Bundle\ShopBundle\SyliusShopBundle::class => ['all' => true], - BitBag\SyliusProductBundlePlugin\BitBagSyliusProductBundlePlugin::class => ['all' => true], Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true, 'test_cached' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true, 'test_cached' => true], FriendsOfBehat\SymfonyExtension\Bundle\FriendsOfBehatSymfonyExtensionBundle::class => ['test' => true, 'test_cached' => true], @@ -55,4 +54,7 @@ Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true], Sylius\Bundle\ApiBundle\SyliusApiBundle::class => ['all' => true], SyliusLabs\DoctrineMigrationsExtraBundle\SyliusLabsDoctrineMigrationsExtraBundle::class => ['all' => true], + BitBag\SyliusProductBundlePlugin\BitBagSyliusProductBundlePlugin::class => ['all' => true], + Nelmio\Alice\Bridge\Symfony\NelmioAliceBundle::class => ['test' => true], + Fidry\AliceDataFixtures\Bridge\Symfony\FidryAliceDataFixturesBundle::class => ['test' => true], ]; diff --git a/tests/Application/config/sylius/1.10/packages/_sylius.yaml b/tests/Application/config/sylius/1.10/packages/_sylius.yaml new file mode 100644 index 00000000..cd01aaf7 --- /dev/null +++ b/tests/Application/config/sylius/1.10/packages/_sylius.yaml @@ -0,0 +1,2 @@ +sylius_api: + enabled: true diff --git a/tests/Application/config/sylius/1.10/packages/security.yaml b/tests/Application/config/sylius/1.10/packages/security.yaml index 10628102..51a6ec9a 100644 --- a/tests/Application/config/sylius/1.10/packages/security.yaml +++ b/tests/Application/config/sylius/1.10/packages/security.yaml @@ -1,13 +1,14 @@ parameters: sylius.security.admin_regex: "^/%sylius_admin.path_name%" - sylius.security.api_regex: "^/api" - sylius.security.shop_regex: "^/(?!%sylius_admin.path_name%|new-api|api/.*|api$|media/.*)[^/]++" - sylius.security.new_api_route: "/new-api" + sylius.security.shop_regex: "^/(?!%sylius_admin.path_name%|api/.*|api$|media/.*)[^/]++" + sylius.security.new_api_route: "/api/v2" sylius.security.new_api_regex: "^%sylius.security.new_api_route%" sylius.security.new_api_admin_route: "%sylius.security.new_api_route%/admin" sylius.security.new_api_admin_regex: "^%sylius.security.new_api_admin_route%" sylius.security.new_api_shop_route: "%sylius.security.new_api_route%/shop" sylius.security.new_api_shop_regex: "^%sylius.security.new_api_shop_route%" + sylius.security.new_api_user_account_route: "%sylius.security.new_api_shop_route%/account" + sylius.security.new_api_user_account_regex: "^%sylius.security.new_api_user_account_route%" security: always_authenticate_before_granting: true @@ -20,9 +21,6 @@ security: id: sylius.shop_user_provider.email_or_name_based sylius_api_shop_user_provider: id: sylius.shop_user_provider.email_or_name_based - sylius_api_chain_provider: - chain: - providers: [sylius_api_shop_user_provider, sylius_api_admin_user_provider] encoders: Sylius\Component\User\Model\UserInterface: argon2i @@ -55,12 +53,12 @@ security: anonymous: true new_api_admin_user: - pattern: "%sylius.security.new_api_route%/admin-user-authentication-token" - provider: sylius_admin_user_provider + pattern: "%sylius.security.new_api_admin_regex%/.*" + provider: sylius_api_admin_user_provider stateless: true anonymous: true json_login: - check_path: "%sylius.security.new_api_route%/admin-user-authentication-token" + check_path: "%sylius.security.new_api_admin_route%/authentication-token" username_path: email password_path: password success_handler: lexik_jwt_authentication.handler.authentication_success @@ -70,12 +68,12 @@ security: - lexik_jwt_authentication.jwt_token_authenticator new_api_shop_user: - pattern: "%sylius.security.new_api_route%/shop-user-authentication-token" - provider: sylius_shop_user_provider + pattern: "%sylius.security.new_api_shop_regex%/.*" + provider: sylius_api_shop_user_provider stateless: true anonymous: true json_login: - check_path: "%sylius.security.new_api_route%/shop-user-authentication-token" + check_path: "%sylius.security.new_api_shop_route%/authentication-token" username_path: email password_path: password success_handler: lexik_jwt_authentication.handler.authentication_success @@ -84,15 +82,6 @@ security: authenticators: - lexik_jwt_authentication.jwt_token_authenticator - new_api: - pattern: "%sylius.security.new_api_regex%/*" - provider: sylius_api_chain_provider - stateless: true - anonymous: lazy - guard: - authenticators: - - lexik_jwt_authentication.jwt_token_authenticator - shop: switch_user: { role: ROLE_ALLOWED_TO_SWITCH } context: shop @@ -124,7 +113,11 @@ security: anonymous: true dev: - pattern: ^/(_(profiler|wdt)|css|images|js)/ + pattern: ^/(_(profiler|wdt)|css|images|js)/ + security: false + + image_resolver: + pattern: ^/media/cache/resolve security: false access_control: @@ -134,15 +127,16 @@ security: - { path: "%sylius.security.shop_regex%/_partial", role: ROLE_NO_ACCESS } - { path: "%sylius.security.admin_regex%/login", role: IS_AUTHENTICATED_ANONYMOUSLY } - - { path: "%sylius.security.api_regex%/login", role: IS_AUTHENTICATED_ANONYMOUSLY } - { path: "%sylius.security.shop_regex%/login", role: IS_AUTHENTICATED_ANONYMOUSLY } - { path: "%sylius.security.shop_regex%/register", role: IS_AUTHENTICATED_ANONYMOUSLY } - { path: "%sylius.security.shop_regex%/verify", role: IS_AUTHENTICATED_ANONYMOUSLY } - { path: "%sylius.security.admin_regex%", role: ROLE_ADMINISTRATION_ACCESS } - - { path: "%sylius.security.api_regex%/.*", role: ROLE_API_ACCESS } - { path: "%sylius.security.shop_regex%/account", role: ROLE_USER } - { path: "%sylius.security.new_api_admin_regex%/.*", role: ROLE_API_ACCESS } + - { path: "%sylius.security.new_api_admin_route%/authentication-token", role: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: "%sylius.security.new_api_user_account_regex%/.*", role: ROLE_USER } + - { path: "%sylius.security.new_api_shop_route%/authentication-token", role: IS_AUTHENTICATED_ANONYMOUSLY } - { path: "%sylius.security.new_api_shop_regex%/.*", role: IS_AUTHENTICATED_ANONYMOUSLY } diff --git a/tests/Application/config/sylius/1.8/bundles.php b/tests/Application/config/sylius/1.8/bundles.php deleted file mode 100644 index 5cc9825d..00000000 --- a/tests/Application/config/sylius/1.8/bundles.php +++ /dev/null @@ -1,20 +0,0 @@ - true]; -} -if (class_exists('WhiteOctober\PagerfantaBundle\WhiteOctoberPagerfantaBundle')) { - $bundles[WhiteOctober\PagerfantaBundle\WhiteOctoberPagerfantaBundle::class] = ['all' => true]; -} -if (class_exists('FOS\OAuthServerBundle\FOSOAuthServerBundle')) { - $bundles[FOS\OAuthServerBundle\FOSOAuthServerBundle::class] = ['all' => true]; -} -if (class_exists('Sylius\Bundle\AdminApiBundle\SyliusAdminApiBundle')) { - $bundles[Sylius\Bundle\AdminApiBundle\SyliusAdminApiBundle::class] = ['all' => true]; -} - -return $bundles; diff --git a/tests/Application/config/sylius/1.8/packages/_sylius.yaml b/tests/Application/config/sylius/1.8/packages/_sylius.yaml deleted file mode 100644 index 1674a972..00000000 --- a/tests/Application/config/sylius/1.8/packages/_sylius.yaml +++ /dev/null @@ -1,2 +0,0 @@ -imports: - - { resource: "@SyliusAdminApiBundle/Resources/config/app/config.yml" } diff --git a/tests/Application/config/sylius/1.8/packages/security.yaml b/tests/Application/config/sylius/1.8/packages/security.yaml deleted file mode 100644 index 8161bdab..00000000 --- a/tests/Application/config/sylius/1.8/packages/security.yaml +++ /dev/null @@ -1,159 +0,0 @@ -parameters: - sylius.security.admin_regex: "^/%sylius_admin.path_name%" - sylius.security.api_regex: "^/api" - sylius.security.shop_regex: "^/(?!%sylius_admin.path_name%|new-api|api/.*|api$|media/.*)[^/]++" - sylius.security.new_api_route: "/new-api" - sylius.security.new_api_regex: "^%sylius.security.new_api_route%" - sylius.security.new_api_admin_route: "%sylius.security.new_api_route%/admin" - sylius.security.new_api_admin_regex: "^%sylius.security.new_api_admin_route%" - sylius.security.new_api_shop_route: "%sylius.security.new_api_route%/shop" - sylius.security.new_api_shop_regex: "^%sylius.security.new_api_shop_route%" - -security: - always_authenticate_before_granting: true - providers: - sylius_admin_user_provider: - id: sylius.admin_user_provider.email_or_name_based - sylius_api_admin_user_provider: - id: sylius.admin_user_provider.email_or_name_based - sylius_shop_user_provider: - id: sylius.shop_user_provider.email_or_name_based - sylius_api_shop_user_provider: - id: sylius.shop_user_provider.email_or_name_based - sylius_api_chain_provider: - chain: - providers: [sylius_api_shop_user_provider, sylius_api_admin_user_provider] - - encoders: - Sylius\Component\User\Model\UserInterface: argon2i - firewalls: - admin: - switch_user: true - context: admin - pattern: "%sylius.security.admin_regex%" - provider: sylius_admin_user_provider - form_login: - provider: sylius_admin_user_provider - login_path: sylius_admin_login - check_path: sylius_admin_login_check - failure_path: sylius_admin_login - default_target_path: sylius_admin_dashboard - use_forward: false - use_referer: true - csrf_token_generator: security.csrf.token_manager - csrf_parameter: _csrf_admin_security_token - csrf_token_id: admin_authenticate - remember_me: - secret: "%env(APP_SECRET)%" - path: "/%sylius_admin.path_name%" - name: APP_ADMIN_REMEMBER_ME - lifetime: 31536000 - remember_me_parameter: _remember_me - logout: - path: sylius_admin_logout - target: sylius_admin_login - anonymous: true - - oauth_token: - pattern: "%sylius.security.api_regex%/oauth/v2/token" - security: false - - new_api_admin_user: - pattern: "%sylius.security.new_api_route%/admin-user-authentication-token" - provider: sylius_admin_user_provider - stateless: true - anonymous: true - json_login: - check_path: "%sylius.security.new_api_route%/admin-user-authentication-token" - username_path: email - password_path: password - success_handler: lexik_jwt_authentication.handler.authentication_success - failure_handler: lexik_jwt_authentication.handler.authentication_failure - guard: - authenticators: - - lexik_jwt_authentication.jwt_token_authenticator - - new_api_shop_user: - pattern: "%sylius.security.new_api_route%/shop-user-authentication-token" - provider: sylius_shop_user_provider - stateless: true - anonymous: true - json_login: - check_path: "%sylius.security.new_api_route%/shop-user-authentication-token" - username_path: email - password_path: password - success_handler: lexik_jwt_authentication.handler.authentication_success - failure_handler: lexik_jwt_authentication.handler.authentication_failure - guard: - authenticators: - - lexik_jwt_authentication.jwt_token_authenticator - - new_api: - pattern: "%sylius.security.new_api_regex%/*" - provider: sylius_api_chain_provider - stateless: true - anonymous: lazy - guard: - authenticators: - - lexik_jwt_authentication.jwt_token_authenticator - - api: - pattern: "%sylius.security.api_regex%/.*" - provider: sylius_admin_user_provider - fos_oauth: true - stateless: true - anonymous: true - - shop: - switch_user: { role: ROLE_ALLOWED_TO_SWITCH } - context: shop - pattern: "%sylius.security.shop_regex%" - provider: sylius_shop_user_provider - form_login: - success_handler: sylius.authentication.success_handler - failure_handler: sylius.authentication.failure_handler - provider: sylius_shop_user_provider - login_path: sylius_shop_login - check_path: sylius_shop_login_check - failure_path: sylius_shop_login - default_target_path: sylius_shop_homepage - use_forward: false - use_referer: true - csrf_token_generator: security.csrf.token_manager - csrf_parameter: _csrf_shop_security_token - csrf_token_id: shop_authenticate - remember_me: - secret: "%env(APP_SECRET)%" - name: APP_SHOP_REMEMBER_ME - lifetime: 31536000 - remember_me_parameter: _remember_me - logout: - path: sylius_shop_logout - target: sylius_shop_login - invalidate_session: false - success_handler: sylius.handler.shop_user_logout - anonymous: true - - dev: - pattern: ^/(_(profiler|wdt)|css|images|js)/ - security: false - - access_control: - - { path: "%sylius.security.admin_regex%/_partial", role: IS_AUTHENTICATED_ANONYMOUSLY, ips: [127.0.0.1, ::1] } - - { path: "%sylius.security.admin_regex%/_partial", role: ROLE_NO_ACCESS } - - { path: "%sylius.security.shop_regex%/_partial", role: IS_AUTHENTICATED_ANONYMOUSLY, ips: [127.0.0.1, ::1] } - - { path: "%sylius.security.shop_regex%/_partial", role: ROLE_NO_ACCESS } - - - { path: "%sylius.security.admin_regex%/login", role: IS_AUTHENTICATED_ANONYMOUSLY } - - { path: "%sylius.security.api_regex%/login", role: IS_AUTHENTICATED_ANONYMOUSLY } - - { path: "%sylius.security.shop_regex%/login", role: IS_AUTHENTICATED_ANONYMOUSLY } - - - { path: "%sylius.security.shop_regex%/register", role: IS_AUTHENTICATED_ANONYMOUSLY } - - { path: "%sylius.security.shop_regex%/verify", role: IS_AUTHENTICATED_ANONYMOUSLY } - - - { path: "%sylius.security.admin_regex%", role: ROLE_ADMINISTRATION_ACCESS } - - { path: "%sylius.security.api_regex%/.*", role: ROLE_API_ACCESS } - - { path: "%sylius.security.shop_regex%/account", role: ROLE_USER } - - - { path: "%sylius.security.new_api_admin_regex%/.*", role: ROLE_API_ACCESS } - - { path: "%sylius.security.new_api_shop_regex%/.*", role: IS_AUTHENTICATED_ANONYMOUSLY } diff --git a/tests/Application/config/sylius/1.8/routes/sylius_admin_api.yaml b/tests/Application/config/sylius/1.8/routes/sylius_admin_api.yaml deleted file mode 100644 index 80aed457..00000000 --- a/tests/Application/config/sylius/1.8/routes/sylius_admin_api.yaml +++ /dev/null @@ -1,3 +0,0 @@ -sylius_admin_api: - resource: "@SyliusAdminApiBundle/Resources/config/routing.yml" - prefix: /api diff --git a/tests/Application/config/sylius/1.9/bundles.php b/tests/Application/config/sylius/1.9/bundles.php deleted file mode 100644 index 7e956f7d..00000000 --- a/tests/Application/config/sylius/1.9/bundles.php +++ /dev/null @@ -1,20 +0,0 @@ - true]; -} -if (class_exists('SyliusLabs\Polyfill\Symfony\Security\Bundle\SyliusLabsPolyfillSymfonySecurityBundle')) { - $bundles[SyliusLabs\Polyfill\Symfony\Security\Bundle\SyliusLabsPolyfillSymfonySecurityBundle::class] = ['all' => true]; -} -if (class_exists('FOS\OAuthServerBundle\FOSOAuthServerBundle')) { - $bundles[FOS\OAuthServerBundle\FOSOAuthServerBundle::class] = ['all' => true]; -} -if (class_exists('Sylius\Bundle\AdminApiBundle\SyliusAdminApiBundle')) { - $bundles[Sylius\Bundle\AdminApiBundle\SyliusAdminApiBundle::class] = ['all' => true]; -} - -return $bundles; diff --git a/tests/Application/config/sylius/1.9/packages/_sylius.yaml b/tests/Application/config/sylius/1.9/packages/_sylius.yaml deleted file mode 100644 index 1674a972..00000000 --- a/tests/Application/config/sylius/1.9/packages/_sylius.yaml +++ /dev/null @@ -1,2 +0,0 @@ -imports: - - { resource: "@SyliusAdminApiBundle/Resources/config/app/config.yml" } diff --git a/tests/Application/config/sylius/1.9/packages/security.yaml b/tests/Application/config/sylius/1.9/packages/security.yaml deleted file mode 100644 index 8161bdab..00000000 --- a/tests/Application/config/sylius/1.9/packages/security.yaml +++ /dev/null @@ -1,159 +0,0 @@ -parameters: - sylius.security.admin_regex: "^/%sylius_admin.path_name%" - sylius.security.api_regex: "^/api" - sylius.security.shop_regex: "^/(?!%sylius_admin.path_name%|new-api|api/.*|api$|media/.*)[^/]++" - sylius.security.new_api_route: "/new-api" - sylius.security.new_api_regex: "^%sylius.security.new_api_route%" - sylius.security.new_api_admin_route: "%sylius.security.new_api_route%/admin" - sylius.security.new_api_admin_regex: "^%sylius.security.new_api_admin_route%" - sylius.security.new_api_shop_route: "%sylius.security.new_api_route%/shop" - sylius.security.new_api_shop_regex: "^%sylius.security.new_api_shop_route%" - -security: - always_authenticate_before_granting: true - providers: - sylius_admin_user_provider: - id: sylius.admin_user_provider.email_or_name_based - sylius_api_admin_user_provider: - id: sylius.admin_user_provider.email_or_name_based - sylius_shop_user_provider: - id: sylius.shop_user_provider.email_or_name_based - sylius_api_shop_user_provider: - id: sylius.shop_user_provider.email_or_name_based - sylius_api_chain_provider: - chain: - providers: [sylius_api_shop_user_provider, sylius_api_admin_user_provider] - - encoders: - Sylius\Component\User\Model\UserInterface: argon2i - firewalls: - admin: - switch_user: true - context: admin - pattern: "%sylius.security.admin_regex%" - provider: sylius_admin_user_provider - form_login: - provider: sylius_admin_user_provider - login_path: sylius_admin_login - check_path: sylius_admin_login_check - failure_path: sylius_admin_login - default_target_path: sylius_admin_dashboard - use_forward: false - use_referer: true - csrf_token_generator: security.csrf.token_manager - csrf_parameter: _csrf_admin_security_token - csrf_token_id: admin_authenticate - remember_me: - secret: "%env(APP_SECRET)%" - path: "/%sylius_admin.path_name%" - name: APP_ADMIN_REMEMBER_ME - lifetime: 31536000 - remember_me_parameter: _remember_me - logout: - path: sylius_admin_logout - target: sylius_admin_login - anonymous: true - - oauth_token: - pattern: "%sylius.security.api_regex%/oauth/v2/token" - security: false - - new_api_admin_user: - pattern: "%sylius.security.new_api_route%/admin-user-authentication-token" - provider: sylius_admin_user_provider - stateless: true - anonymous: true - json_login: - check_path: "%sylius.security.new_api_route%/admin-user-authentication-token" - username_path: email - password_path: password - success_handler: lexik_jwt_authentication.handler.authentication_success - failure_handler: lexik_jwt_authentication.handler.authentication_failure - guard: - authenticators: - - lexik_jwt_authentication.jwt_token_authenticator - - new_api_shop_user: - pattern: "%sylius.security.new_api_route%/shop-user-authentication-token" - provider: sylius_shop_user_provider - stateless: true - anonymous: true - json_login: - check_path: "%sylius.security.new_api_route%/shop-user-authentication-token" - username_path: email - password_path: password - success_handler: lexik_jwt_authentication.handler.authentication_success - failure_handler: lexik_jwt_authentication.handler.authentication_failure - guard: - authenticators: - - lexik_jwt_authentication.jwt_token_authenticator - - new_api: - pattern: "%sylius.security.new_api_regex%/*" - provider: sylius_api_chain_provider - stateless: true - anonymous: lazy - guard: - authenticators: - - lexik_jwt_authentication.jwt_token_authenticator - - api: - pattern: "%sylius.security.api_regex%/.*" - provider: sylius_admin_user_provider - fos_oauth: true - stateless: true - anonymous: true - - shop: - switch_user: { role: ROLE_ALLOWED_TO_SWITCH } - context: shop - pattern: "%sylius.security.shop_regex%" - provider: sylius_shop_user_provider - form_login: - success_handler: sylius.authentication.success_handler - failure_handler: sylius.authentication.failure_handler - provider: sylius_shop_user_provider - login_path: sylius_shop_login - check_path: sylius_shop_login_check - failure_path: sylius_shop_login - default_target_path: sylius_shop_homepage - use_forward: false - use_referer: true - csrf_token_generator: security.csrf.token_manager - csrf_parameter: _csrf_shop_security_token - csrf_token_id: shop_authenticate - remember_me: - secret: "%env(APP_SECRET)%" - name: APP_SHOP_REMEMBER_ME - lifetime: 31536000 - remember_me_parameter: _remember_me - logout: - path: sylius_shop_logout - target: sylius_shop_login - invalidate_session: false - success_handler: sylius.handler.shop_user_logout - anonymous: true - - dev: - pattern: ^/(_(profiler|wdt)|css|images|js)/ - security: false - - access_control: - - { path: "%sylius.security.admin_regex%/_partial", role: IS_AUTHENTICATED_ANONYMOUSLY, ips: [127.0.0.1, ::1] } - - { path: "%sylius.security.admin_regex%/_partial", role: ROLE_NO_ACCESS } - - { path: "%sylius.security.shop_regex%/_partial", role: IS_AUTHENTICATED_ANONYMOUSLY, ips: [127.0.0.1, ::1] } - - { path: "%sylius.security.shop_regex%/_partial", role: ROLE_NO_ACCESS } - - - { path: "%sylius.security.admin_regex%/login", role: IS_AUTHENTICATED_ANONYMOUSLY } - - { path: "%sylius.security.api_regex%/login", role: IS_AUTHENTICATED_ANONYMOUSLY } - - { path: "%sylius.security.shop_regex%/login", role: IS_AUTHENTICATED_ANONYMOUSLY } - - - { path: "%sylius.security.shop_regex%/register", role: IS_AUTHENTICATED_ANONYMOUSLY } - - { path: "%sylius.security.shop_regex%/verify", role: IS_AUTHENTICATED_ANONYMOUSLY } - - - { path: "%sylius.security.admin_regex%", role: ROLE_ADMINISTRATION_ACCESS } - - { path: "%sylius.security.api_regex%/.*", role: ROLE_API_ACCESS } - - { path: "%sylius.security.shop_regex%/account", role: ROLE_USER } - - - { path: "%sylius.security.new_api_admin_regex%/.*", role: ROLE_API_ACCESS } - - { path: "%sylius.security.new_api_shop_regex%/.*", role: IS_AUTHENTICATED_ANONYMOUSLY } diff --git a/tests/Application/config/sylius/1.9/routes/sylius_admin_api.yaml b/tests/Application/config/sylius/1.9/routes/sylius_admin_api.yaml deleted file mode 100644 index 80aed457..00000000 --- a/tests/Application/config/sylius/1.9/routes/sylius_admin_api.yaml +++ /dev/null @@ -1,3 +0,0 @@ -sylius_admin_api: - resource: "@SyliusAdminApiBundle/Resources/config/routing.yml" - prefix: /api diff --git a/tests/Unit/DataTransformer/AddProductBundleToCartDtoDataTransformerTest.php b/tests/Unit/DataTransformer/AddProductBundleToCartDtoDataTransformerTest.php new file mode 100644 index 00000000..c99e1ca5 --- /dev/null +++ b/tests/Unit/DataTransformer/AddProductBundleToCartDtoDataTransformerTest.php @@ -0,0 +1,69 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + sprintf(TypeExceptionMessage::EXPECTED_INSTANCE_OF_X_GOT_Y, AddProductBundleToCartDto::class, \stdClass::class) + ); + + $object = new \stdClass(); + $dataTransformer = new AddProductBundleToCartDtoDataTransformer(); + + $dataTransformer->transform($object, ''); + } + + public function testThrowIfObjectToPopulateDoesntExist(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(TypeExceptionMessage::EXPECTED_VALUE_OTHER_THAN_NULL); + + $object = AddProductBundleToCartDtoMother::create('PRODUCT_CODE'); + $dataTransformer = new AddProductBundleToCartDtoDataTransformer(); + + $dataTransformer->transform($object, ''); + } + + public function testReturnAddProductBundleToCart(): void + { + $object = AddProductBundleToCartDtoMother::create('PRODUCT_CODE', 2); + $context = [ + AddProductBundleToCartDtoDataTransformer::OBJECT_TO_POPULATE => OrderMother::createWithId(3), + ]; + $dataTransformer = new AddProductBundleToCartDtoDataTransformer(); + + $addProductBundleToCartCommand = $dataTransformer->transform($object, '', $context); + + self::assertInstanceOf(AddProductBundleToCartCommand::class, $addProductBundleToCartCommand); + self::assertSame('PRODUCT_CODE', $addProductBundleToCartCommand->getProductCode()); + self::assertSame(2, $addProductBundleToCartCommand->getQuantity()); + self::assertSame(3, $addProductBundleToCartCommand->getOrderId()); + } +} diff --git a/tests/Unit/Factory/AddProductBundleItemToCartCommandFactoryTest.php b/tests/Unit/Factory/AddProductBundleItemToCartCommandFactoryTest.php new file mode 100644 index 00000000..211cf9af --- /dev/null +++ b/tests/Unit/Factory/AddProductBundleItemToCartCommandFactoryTest.php @@ -0,0 +1,29 @@ +createNew($productBundleItem); + + self::assertInstanceOf(AddProductBundleItemToCartCommand::class, $command); + } +} diff --git a/tests/Unit/Factory/AddProductBundleToCartCommandFactoryTest.php b/tests/Unit/Factory/AddProductBundleToCartCommandFactoryTest.php new file mode 100644 index 00000000..c146ffae --- /dev/null +++ b/tests/Unit/Factory/AddProductBundleToCartCommandFactoryTest.php @@ -0,0 +1,49 @@ +createNew(self::ORDER_ID, self::PRODUCT_CODE, self::QUANTITY); + + self::assertInstanceOf(AddProductBundleToCartCommand::class, $command); + self::assertEquals(self::ORDER_ID, $command->getOrderId()); + self::assertEquals(self::PRODUCT_CODE, $command->getProductCode()); + self::assertEquals(self::QUANTITY, $command->getQuantity()); + } + + public function testCreateAddProductBundleToCartCommandObjectFromDto(): void + { + $dto = AddProductBundleToCartDtoMother::createWithOrderIdAndProductCode(self::ORDER_ID, self::PRODUCT_CODE); + + $factory = new AddProductBundleToCartCommandFactory(); + $command = $factory->createFromDto($dto); + + self::assertInstanceOf(AddProductBundleToCartCommand::class, $command); + self::assertEquals(self::ORDER_ID, $command->getOrderId()); + self::assertEquals(self::PRODUCT_CODE, $command->getProductCode()); + self::assertEquals(0, $command->getQuantity()); + } +} diff --git a/tests/Unit/Factory/AddProductBundleToCartDtoFactoryTest.php b/tests/Unit/Factory/AddProductBundleToCartDtoFactoryTest.php new file mode 100644 index 00000000..a7a4fd07 --- /dev/null +++ b/tests/Unit/Factory/AddProductBundleToCartDtoFactoryTest.php @@ -0,0 +1,64 @@ +addProductBundleItemToCartCommandFactory = $this->createMock( + AddProductBundleItemToCartCommandFactoryInterface::class + ); + } + + public function testCreateAddProductBundleToCartDtoObject(): void + { + $bundleItem1 = ProductBundleItemMother::create(); + $bundleItem2 = ProductBundleItemMother::create(); + $addProductBundleItemToCartCommand1 = AddProductBundleItemToCartCommandMother::create($bundleItem1); + $addProductBundleItemToCartCommand2 = AddProductBundleItemToCartCommandMother::create($bundleItem2); + + $this->addProductBundleItemToCartCommandFactory->expects(self::exactly(2)) + ->method('createNew') + ->withConsecutive([$bundleItem1], [$bundleItem2]) + ->willReturnOnConsecutiveCalls($addProductBundleItemToCartCommand1, $addProductBundleItemToCartCommand2) + ; + + $factory = new AddProductBundleToCartDtoFactory($this->addProductBundleItemToCartCommandFactory); + + $order = OrderMother::create(); + $orderItem = OrderItemMother::create(); + $productBundle = ProductBundleMother::createWithBundleItems($bundleItem1, $bundleItem2); + $product = ProductMother::createWithBundle($productBundle); + $dto = $factory->createNew($order, $orderItem, $product); + + self::assertInstanceOf(AddProductBundleToCartDtoInterface::class, $dto); + self::assertSame($order, $dto->getCart()); + self::assertSame($orderItem, $dto->getCartItem()); + self::assertSame($product, $dto->getProduct()); + self::assertCount(2, $dto->getProductBundleItems()); + } +} diff --git a/tests/Unit/Factory/OrderItemFactoryTest.php b/tests/Unit/Factory/OrderItemFactoryTest.php new file mode 100644 index 00000000..7dc5bf6a --- /dev/null +++ b/tests/Unit/Factory/OrderItemFactoryTest.php @@ -0,0 +1,37 @@ +createMock(CartItemFactoryInterface::class); + $baseFactory->expects(self::once()) + ->method('createNew') + ->willReturn($orderItem) + ; + + $factory = new OrderItemFactory($baseFactory); + $orderItemWithVariant = $factory->createWithVariant($productVariant); + + self::assertSame($productVariant, $orderItemWithVariant->getVariant()); + } +} diff --git a/tests/Unit/Factory/ProductBundleOrderItemFactoryTest.php b/tests/Unit/Factory/ProductBundleOrderItemFactoryTest.php new file mode 100644 index 00000000..4ccd57bf --- /dev/null +++ b/tests/Unit/Factory/ProductBundleOrderItemFactoryTest.php @@ -0,0 +1,55 @@ +baseProductBundleOrderItemFactory = $this->createMock(FactoryInterface::class); + $this->baseProductBundleOrderItemFactory + ->method('createNew') + ->willReturn(new ProductBundleOrderItem()) + ; + } + + public function testCreateProductBundleOrderItemFromProductBundleItem(): void + { + $factory = new ProductBundleOrderItemFactory($this->baseProductBundleOrderItemFactory); + + $productVariant = new ProductVariant(); + $productVariant->setCode(self::PRODUCT_VARIANT_CODE); + + $productBundleItem = new ProductBundleItem(); + $productBundleItem->setProductVariant($productVariant); + $productBundleItem->setQuantity(2); + + $orderItem = $factory->createFromProductBundleItem($productBundleItem); + $orderItemProductVariant = $orderItem->getProductVariant(); + + self::assertEquals($productBundleItem, $orderItem->getProductBundleItem()); + self::assertSame($productBundleItem->getQuantity(), $orderItem->getQuantity()); + self::assertSame($productVariant->getCode(), $orderItemProductVariant->getCode()); + } +} diff --git a/tests/Unit/Handler/AddProductBundleToCartHandler/CartProcessorTest.php b/tests/Unit/Handler/AddProductBundleToCartHandler/CartProcessorTest.php new file mode 100644 index 00000000..91059c37 --- /dev/null +++ b/tests/Unit/Handler/AddProductBundleToCartHandler/CartProcessorTest.php @@ -0,0 +1,241 @@ +orderItemQuantityModifier = $this->createMock(OrderItemQuantityModifierInterface::class); + $this->productBundleOrderItemFactory = $this->createMock(ProductBundleOrderItemFactoryInterface::class); + $this->orderModifier = $this->createMock(OrderModifierInterface::class); + $this->cartItemFactory = $this->createMock(OrderItemFactoryInterface::class); + } + + public function testThrowExceptionIfQuantityNotGreaterThanZero(): void + { + $this->expectException(InvalidArgumentException::class); + + $cart = $this->createCart(); + $productBundle = $this->createProductBundle(); + + $processor = $this->createProcessor(); + $processor->process($cart, $productBundle, 0); + } + + public function testThrowExceptionIfProductIsNull(): void + { + $this->expectException(InvalidArgumentException::class); + + $cart = $this->createCart(); + $productBundle = $this->createProductBundle(); + + $processor = $this->createProcessor(); + $processor->process($cart, $productBundle, 1); + } + + public function testThrowExceptionIfProductHasNoVariant(): void + { + $this->expectException(InvalidArgumentException::class); + + $cart = $this->createCart(); + $productBundle = $this->createProductBundleWithProduct(); + + $processor = $this->createProcessor(); + $processor->process($cart, $productBundle, 1); + } + + public function testCreateCartItem(): void + { + $cart = $this->createCart(); + $productVariant = new ProductVariant(); + $product = $this->createProductWithVariant($productVariant); + $productBundle = $this->createProductBundleWithProduct($product); + + $this->cartItemFactory->expects(self::once()) + ->method('createWithVariant') + ->with($productVariant) + ; + + $processor = $this->createProcessor(); + $processor->process($cart, $productBundle, 2); + } + + public function testModifyCartItemQuantity(): void + { + $cart = $this->createCart(); + $productVariant = new ProductVariant(); + $product = $this->createProductWithVariant($productVariant); + $productBundle = $this->createProductBundleWithProduct($product); + $cartItem = $this->createCartItem(); + + $this->cartItemFactory + ->method('createWithVariant') + ->willReturn($cartItem) + ; + $this->orderItemQuantityModifier->expects(self::once()) + ->method('modify') + ->with($cartItem, 2) + ; + + $processor = $this->createProcessor(); + $processor->process($cart, $productBundle, 2); + } + + public function testCreateBundleOrderItemsFromBundleItems(): void + { + $bundleItem1 = $this->createProductBundleItem(); + $bundleItem2 = $this->createProductBundleItem(); + + $productBundleOrderItem1 = $this->createProductBundleOrderItem(); + $productBundleOrderItem2 = $this->createProductBundleOrderItem(); + + $cart = $this->createCart(); + $product = $this->createProductWithVariant(); + $productBundle = $this->createProductBundleWithProduct($product); + $productBundle->addProductBundleItem($bundleItem1); + $productBundle->addProductBundleItem($bundleItem2); + + $cartItem = $this->createMock(OrderItemInterface::class); + $cartItem->expects(self::exactly(2)) + ->method('addProductBundleOrderItem') + ->withConsecutive([$productBundleOrderItem1], [$productBundleOrderItem2]) + ; + + $this->cartItemFactory + ->method('createWithVariant') + ->willReturn($cartItem) + ; + $this->productBundleOrderItemFactory->expects(self::exactly(2)) + ->method('createFromProductBundleItem') + ->withConsecutive([$bundleItem1], [$bundleItem2]) + ->willReturn($productBundleOrderItem1, $productBundleOrderItem2) + ; + + $processor = $this->createProcessor(); + $processor->process($cart, $productBundle, 1); + } + + public function testAddCartItemToOrder(): void + { + $cart = $this->createCart(); + $product = $this->createProductWithVariant(); + $productBundle = $this->createProductBundleWithProduct($product); + $cartItem = $this->createCartItem(); + + $this->cartItemFactory + ->method('createWithVariant') + ->willReturn($cartItem) + ; + $this->orderModifier->expects(self::once()) + ->method('addToOrder') + ->with($cart, $cartItem) + ; + + $processor = $this->createProcessor(); + $processor->process($cart, $productBundle, 1); + } + + private function createProcessor(): CartProcessorInterface + { + return new CartProcessor( + $this->orderItemQuantityModifier, + $this->productBundleOrderItemFactory, + $this->orderModifier, + $this->cartItemFactory + ); + } + + private function createCart(): OrderInterface + { + return new Order(); + } + + private function createCartItem(): OrderItemInterface + { + return new OrderItem(); + } + + private function createProductWithVariant(?ProductVariantInterface $productVariant = null): ProductInterface + { + if (null === $productVariant) { + $productVariant = new ProductVariant(); + } + + $product = new Product(); + $product->addVariant($productVariant); + + return $product; + } + + private function createProductBundle(): ProductBundleInterface + { + return new ProductBundle(); + } + + private function createProductBundleWithProduct(?ProductInterface $product = null): ProductBundleInterface + { + if (null === $product) { + $product = new Product(); + } + + $productBundle = $this->createProductBundle(); + $productBundle->setProduct($product); + + return $productBundle; + } + + private function createProductBundleItem(): ProductBundleItemInterface + { + return new ProductBundleItem(); + } + + private function createProductBundleOrderItem(): ProductBundleOrderItemInterface + { + return new ProductBundleOrderItem(); + } +} diff --git a/tests/Unit/Handler/AddProductBundleToCartHandlerTest.php b/tests/Unit/Handler/AddProductBundleToCartHandlerTest.php new file mode 100644 index 00000000..60bbbf5b --- /dev/null +++ b/tests/Unit/Handler/AddProductBundleToCartHandlerTest.php @@ -0,0 +1,163 @@ +orderRepository = $this->createMock(OrderRepositoryInterface::class); + $this->productRepository = $this->createMock(ProductRepositoryInterface::class); + $this->cartProcessor = $this->createMock(CartProcessorInterface::class); + } + + public function testThrowExceptionIfCartDoesntExist(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(TypeExceptionMessage::EXPECTED_VALUE_OTHER_THAN_NULL); + + $this->orderRepository->expects(self::once()) + ->method('findCartById') + ->willReturn(null) + ; + + $command = new AddProductBundleToCartCommand(0, '', 1); + $handler = $this->createHandler(); + $handler($command); + } + + /** + * @dataProvider pessimisticDataProvider + */ + public function testPessimisticCase( + string $exceptionMessage, + ?OrderInterface $cart, + ?ProductInterface $product, + int $quantity + ): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($exceptionMessage); + + $this->orderRepository->method('findCartById') + ->willReturn($cart) + ; + + $this->productRepository->method('findOneByCode') + ->willReturn($product) + ; + + $command = new AddProductBundleToCartCommand(0, '', $quantity); + $handler = $this->createHandler(); + $handler($command); + } + + public function pessimisticDataProvider(): array + { + $productBundle = ProductBundleMother::create(); + $productWithBundle = ProductMother::createWithBundle($productBundle); + + return [ + 'order is a null' => [TypeExceptionMessage::EXPECTED_VALUE_OTHER_THAN_NULL, null, null, 1], + 'product is a null' => [TypeExceptionMessage::EXPECTED_VALUE_OTHER_THAN_NULL, OrderMother::create(), null, 1], + 'product is not a bundle' => [ + 'Expected a value to be true. Got: false', + OrderMother::create(), + ProductMother::create(), + 1, + ], + 'quantity is not greater than 0' => [ + 'Expected a value greater than 0. Got: 0', + OrderMother::create(), + $productWithBundle, + 0, + ], + ]; + } + + public function testProcessCart(): void + { + $cart = OrderMother::create(); + $this->orderRepository->method('findCartById') + ->willReturn($cart) + ; + + $productBundle = ProductBundleMother::create(); + $product = ProductMother::createWithBundle($productBundle); + $this->productRepository->method('findOneByCode') + ->willReturn($product) + ; + + $this->cartProcessor->expects(self::once()) + ->method('process') + ->with($cart, $productBundle, 2) + ; + + $command = new AddProductBundleToCartCommand(1, '', 2); + $handler = $this->createHandler(); + $handler($command); + } + + public function testAddCartToRepository(): void + { + $cart = OrderMother::create(); + $this->orderRepository->method('findCartById') + ->willReturn($cart) + ; + + $productBundle = ProductBundleMother::create(); + $product = ProductMother::createWithBundle($productBundle); + $this->productRepository->method('findOneByCode') + ->willReturn($product) + ; + + $this->orderRepository->expects(self::once()) + ->method('add') + ->with($cart) + ; + + $command = new AddProductBundleToCartCommand(1, '', 1); + $handler = $this->createHandler(); + $handler($command); + } + + private function createHandler(): AddProductBundleToCartHandler + { + return new AddProductBundleToCartHandler( + $this->orderRepository, + $this->productRepository, + $this->cartProcessor + ); + } +} diff --git a/tests/Unit/MotherObject/AddProductBundleItemToCartCommandMother.php b/tests/Unit/MotherObject/AddProductBundleItemToCartCommandMother.php new file mode 100644 index 00000000..3e8ba122 --- /dev/null +++ b/tests/Unit/MotherObject/AddProductBundleItemToCartCommandMother.php @@ -0,0 +1,22 @@ +setName($name); + + return $channel; + } +} diff --git a/tests/Unit/MotherObject/OrderItemMother.php b/tests/Unit/MotherObject/OrderItemMother.php new file mode 100644 index 00000000..d6187cc4 --- /dev/null +++ b/tests/Unit/MotherObject/OrderItemMother.php @@ -0,0 +1,22 @@ +id = $id; + }; + ($setIdClosure->bindTo($order, $order))($id); + + return $order; + } + + public static function createWithChannel(ChannelInterface $channel): OrderInterface + { + $order = self::create(); + + $order->setChannel($channel); + + return $order; + } +} diff --git a/tests/Unit/MotherObject/ProductBundleItemMother.php b/tests/Unit/MotherObject/ProductBundleItemMother.php new file mode 100644 index 00000000..da8ba40f --- /dev/null +++ b/tests/Unit/MotherObject/ProductBundleItemMother.php @@ -0,0 +1,22 @@ +addProductBundleItem($bundleItem); + } + + return $productBundle; + } +} diff --git a/tests/Unit/MotherObject/ProductMother.php b/tests/Unit/MotherObject/ProductMother.php new file mode 100644 index 00000000..95789d73 --- /dev/null +++ b/tests/Unit/MotherObject/ProductMother.php @@ -0,0 +1,76 @@ +setProductBundle($productBundle); + + return $product; + } + + public static function createWithProductVariantAndCode( + ProductVariantInterface $productVariant, + string $code + ): ProductInterface { + $product = self::create(); + + $product->addVariant($productVariant); + $product->setCode($code); + + return $product; + } + + public static function createWithChannelAndProductVariantAndCode( + ChannelInterface $channel, + ProductVariantInterface $productVariant, + string $code + ): ProductInterface { + $product = self::createWithProductVariantAndCode($productVariant, $code); + + $product->addChannel($channel); + + return $product; + } + + public static function createWithCode(string $code): ProductInterface + { + $product = self::create(); + + $product->setCode($code); + + return $product; + } + + public static function createDisabledWithCode(string $code): ProductInterface + { + $product = self::createWithCode($code); + + $product->disable(); + + return $product; + } +} diff --git a/tests/Unit/MotherObject/ProductVariantMother.php b/tests/Unit/MotherObject/ProductVariantMother.php new file mode 100644 index 00000000..dabb38b9 --- /dev/null +++ b/tests/Unit/MotherObject/ProductVariantMother.php @@ -0,0 +1,40 @@ +setCode($code); + + return $productVariant; + } + + public static function createDisabledWithCode(string $code): ProductVariantInterface + { + $productVariant = self::createWithCode($code); + + $productVariant->disable(); + + return $productVariant; + } +} diff --git a/tests/Unit/TypeExceptionMessage.php b/tests/Unit/TypeExceptionMessage.php new file mode 100644 index 00000000..05ea3e09 --- /dev/null +++ b/tests/Unit/TypeExceptionMessage.php @@ -0,0 +1,18 @@ +productRepository = $this->createMock(ProductRepositoryInterface::class); + $this->orderRepository = $this->createMock(OrderRepositoryInterface::class); + $this->availabilityChecker = $this->createMock(AvailabilityCheckerInterface::class); + + parent::setUp(); + } + + /** + * @dataProvider pessimisticDataProvider + */ + public function testPessimisticCase( + ProductInterface $product, + ?OrderInterface $cart, + bool $isStockSufficient, + string $violationMessage, + array $violationParameters + ): void { + $this->productRepository->method('findOneByCode') + ->with(self::PRODUCT_CODE) + ->willReturn($product) + ; + + $this->orderRepository->method('findCartById') + ->with(self::ORDER_ID) + ->willReturn($cart) + ; + + $productVariant = $product->getVariants()->first(); + $this->availabilityChecker->method('isStockSufficient') + ->with($productVariant, 1) + ->willReturn($isStockSufficient) + ; + + $command = new AddProductBundleToCartCommand(self::ORDER_ID, self::PRODUCT_CODE); + $constraint = new HasAvailableProductBundle(); + + $this->validator->validate($command, $constraint); + + $this->buildViolation($violationMessage) + ->setParameters($violationParameters) + ->assertRaised(); + } + + public function pessimisticDataProvider(): iterable + { + yield 'product is disabled' => $this->getProductDisabledCaseData(); + yield 'product variant is disabled' => $this->getProductVariantDisabledCaseData(); + yield 'product\'s channel and cart\'s channel are different' => $this->getProductAndCartChannelsAreDifferentCaseData(); + yield 'product\'s quantity in the cart exceeds the stock' => $this->getProductQuantityExceedsStockCaseData(); + } + + private function getProductDisabledCaseData(): array + { + $product = ProductMother::createDisabledWithCode(self::PRODUCT_CODE); + $violationMessage = HasAvailableProductBundle::PRODUCT_DISABLED_MESSAGE; + $violationParameters = [ + '{{ code }}' => self::PRODUCT_CODE, + ]; + + return [$product, null, false, $violationMessage, $violationParameters]; + } + + private function getProductVariantDisabledCaseData(): array + { + $productVariant = ProductVariantMother::createDisabledWithCode(self::PRODUCT_CODE); + $product = ProductMother::createWithProductVariantAndCode($productVariant, self::PRODUCT_CODE); + $violationMessage = HasAvailableProductBundle::PRODUCT_VARIANT_DISABLED_MESSAGE; + $violationParameters = [ + '{{ code }}' => self::PRODUCT_CODE, + ]; + + return [$product, null, false, $violationMessage, $violationParameters]; + } + + private function getProductAndCartChannelsAreDifferentCaseData(): array + { + $productVariant = ProductVariantMother::createWithCode(self::PRODUCT_CODE); + $product = ProductMother::createWithProductVariantAndCode($productVariant, self::PRODUCT_CODE); + + $channel = ChannelMother::createWithName(self::CHANNEL_NAME); + $cart = OrderMother::createWithChannel($channel); + + $violationMessage = HasAvailableProductBundle::PRODUCT_DOESNT_EXIST_IN_CHANNEL_MESSAGE; + $violationParameters = [ + '{{ channel }}' => self::CHANNEL_NAME, + '{{ code }}' => self::PRODUCT_CODE, + ]; + + return [$product, $cart, false, $violationMessage, $violationParameters]; + } + + private function getProductQuantityExceedsStockCaseData(): array + { + $channel = ChannelMother::createWithName(self::CHANNEL_NAME); + $productVariant = ProductVariantMother::createWithCode(self::PRODUCT_CODE); + $product = ProductMother::createWithChannelAndProductVariantAndCode( + $channel, + $productVariant, + self::PRODUCT_CODE + ); + + $cart = OrderMother::createWithChannel($channel); + + $violationMessage = HasAvailableProductBundle::PRODUCT_VARIANT_INSUFFICIENT_STOCK_MESSAGE; + $violationParameters = [ + '{{ code }}' => self::PRODUCT_CODE, + ]; + + return [$product, $cart, false, $violationMessage, $violationParameters]; + } + + protected function createValidator(): HasAvailableProductBundleValidator + { + return new HasAvailableProductBundleValidator( + $this->productRepository, + $this->orderRepository, + $this->availabilityChecker + ); + } +} diff --git a/tests/Unit/Validator/HasExistingCartValidatorTest.php b/tests/Unit/Validator/HasExistingCartValidatorTest.php new file mode 100644 index 00000000..f8b951d8 --- /dev/null +++ b/tests/Unit/Validator/HasExistingCartValidatorTest.php @@ -0,0 +1,90 @@ +orderRepository = $this->createMock(OrderRepositoryInterface::class); + + parent::setUp(); + } + + public function testThrowExceptionIfValueIsNotImplementingOrderIdentityAwareInterface(): void + { + $this->expectException(UnexpectedValueException::class); + + $value = new \stdClass(); + $constraint = new HasExistingCart(); + + $this->validator->validate($value, $constraint); + } + + public function testQueryOrderFromRepositoryIfValueIsInt(): void + { + $this->orderRepository->expects(self::once()) + ->method('findCartById') + ->with(5) + ->willReturn(OrderMother::create()) + ; + + $value = new AddProductBundleToCartCommand(self::ORDER_ID, self::PRODUCT_CODE); + $constraint = new HasExistingCart(); + + $this->validator->validate($value, $constraint); + } + + public function testAddViolationIfValueIsNull(): void + { + $this->orderRepository->method('findCartById') + ->willReturn(null) + ; + + $value = new AddProductBundleToCartCommand(self::ORDER_ID, self::PRODUCT_CODE); + $constraint = new HasExistingCart(); + + $this->validator->validate($value, $constraint); + + $this->buildViolation(HasExistingCart::CART_DOESNT_EXIST_MESSAGE)->assertRaised(); + } + + public function testAddViolationIfOrderHasNoId(): void + { + $value = new AddProductBundleToCartCommand(self::ORDER_ID, self::PRODUCT_CODE); + $constraint = new HasExistingCart(); + + $this->validator->validate($value, $constraint); + + $this->buildViolation(HasExistingCart::CART_DOESNT_EXIST_MESSAGE)->assertRaised(); + } + + protected function createValidator(): HasExistingCartValidator + { + return new HasExistingCartValidator($this->orderRepository); + } +} diff --git a/tests/Unit/Validator/HasProductBundleTest.php b/tests/Unit/Validator/HasProductBundleTest.php new file mode 100644 index 00000000..76c23137 --- /dev/null +++ b/tests/Unit/Validator/HasProductBundleTest.php @@ -0,0 +1,87 @@ +productRepository = $this->createMock(ProductRepositoryInterface::class); + + parent::setUp(); + } + + public function testThrowExceptionIfValueIsNotImplementingProductCodeAwareInterface(): void + { + $this->expectException(UnexpectedValueException::class); + + $value = new \stdClass(); + $constraint = new HasProductBundle(); + + $this->validator->validate($value, $constraint); + } + + /** + * @dataProvider pessimisticDataProvider + */ + public function testPessimisticCase(?ProductInterface $product, ?string $violationMessage): void + { + $this->productRepository->expects(self::once()) + ->method('findOneByCode') + ->with(self::PRODUCT_CODE) + ->willReturn($product) + ; + + $value = new AddProductBundleToCartCommand(self::ORDER_ID, self::PRODUCT_CODE); + $constraint = new HasProductBundle(); + + $this->validator->validate($value, $constraint); + + if (null !== $violationMessage) { + $this->buildViolation($violationMessage)->assertRaised(); + } else { + $this->assertNoViolation(); + } + } + + public function pessimisticDataProvider(): array + { + return [ + 'product is a null' => [null, HasProductBundle::PRODUCT_DOESNT_EXIST_MESSAGE], + 'product is not a bundle' => [ProductMother::create(), HasProductBundle::NOT_A_BUNDLE_MESSAGE], + 'product is a bundle' => [ProductMother::createWithBundle(ProductBundleMother::create()), null], + ]; + } + + protected function createValidator(): HasProductBundleValidator + { + return new HasProductBundleValidator($this->productRepository); + } +}