diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 778d121..0d3d3ae 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -78,7 +78,7 @@ jobs: dependencies: - "lowest" - "highest" - + symfony: - "~5.4.0" - "~6.4.0" @@ -97,7 +97,7 @@ jobs: - name: "Remove require-dev section in composer.json" run: "composer config --unset require-dev" - + - name: "Install composer dependencies" uses: "ramsey/composer-install@v3" env: @@ -110,7 +110,7 @@ jobs: - name: "Run composer-unused/composer-unused" run: "composer-unused" - + static-code-analysis: name: "Static Code Analysis (PHP${{ matrix.php-version }} | Deps: ${{ matrix.dependencies }} | SF${{ matrix.symfony }})" @@ -144,7 +144,7 @@ jobs: - name: "Remove sylius/sylius from composer.json" run: "composer remove --dev --no-update --no-plugins --no-scripts sylius/sylius" - + - name: "Install composer dependencies" uses: "ramsey/composer-install@v3" env: @@ -165,7 +165,7 @@ jobs: php-version: - "8.1" - "8.2" - + dependencies: - "lowest" - "highest" @@ -194,7 +194,7 @@ jobs: dependency-versions: "${{ matrix.dependencies }}" - name: "Run phpunit" - run: "composer phpunit" + run: "composer unit-tests" integration-tests: name: "Integration tests (PHP${{ matrix.php-version }} | Deps: ${{ matrix.dependencies }} | SF${{ matrix.symfony }})" @@ -208,9 +208,8 @@ jobs: - "8.2" dependencies: - - "lowest" - "highest" - + symfony: - "~5.4.0" - "~6.4.0" @@ -248,7 +247,16 @@ jobs: - name: "Validate Doctrine mapping" run: "(cd tests/Application && bin/console doctrine:schema:validate -vvv)" # The verbose flag will show 'missing' SQL statements, if any - + + - name: "Load fixtures for functional tests" + run: "(cd tests/Application && bin/console sylius:fixtures:load --no-interaction)" + + - name: "Run yarn" + run: "(cd tests/Application && yarn install && yarn build)" + + - name: "Run phpunit" + run: "composer functional-tests" + mutation-tests: name: "Mutation tests" @@ -263,6 +271,9 @@ jobs: - "highest" steps: + - name: "Start MySQL" + run: "sudo /etc/init.d/mysql start" + - name: "Checkout" uses: "actions/checkout@v4" @@ -278,11 +289,23 @@ jobs: with: dependency-versions: "${{ matrix.dependencies }}" + - name: "Create database" + run: "(cd tests/Application && bin/console doctrine:database:create)" + + - name: "Create database schema" + run: "(cd tests/Application && bin/console doctrine:schema:create)" + + - name: "Load fixtures for functional tests" + run: "(cd tests/Application && bin/console sylius:fixtures:load --no-interaction)" + + - name: "Run yarn" + run: "(cd tests/Application && yarn install && yarn build)" + - name: "Run infection" run: "vendor/bin/infection" env: STRYKER_DASHBOARD_API_KEY: "${{ secrets.STRYKER_DASHBOARD_API_KEY }}" - + code-coverage: name: "Code Coverage (PHP${{ matrix.php-version }} | Deps: ${{ matrix.dependencies }})" @@ -297,6 +320,9 @@ jobs: - "highest" steps: + - name: "Start MySQL" + run: "sudo /etc/init.d/mysql start" + - name: "Checkout" uses: "actions/checkout@v4" @@ -315,6 +341,18 @@ jobs: with: dependency-versions: "${{ matrix.dependencies }}" + - name: "Create database" + run: "(cd tests/Application && bin/console doctrine:database:create)" + + - name: "Create database schema" + run: "(cd tests/Application && bin/console doctrine:schema:create)" + + - name: "Load fixtures for functional tests" + run: "(cd tests/Application && bin/console sylius:fixtures:load --no-interaction)" + + - name: "Run yarn" + run: "(cd tests/Application && yarn install && yarn build)" + - name: "Collect code coverage with pcov and phpunit/phpunit" run: "vendor/bin/phpunit --coverage-clover=.build/logs/clover.xml" diff --git a/composer.json b/composer.json index e3595cf..4ea1a92 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ ], "require": { "php": ">=8.1", + "doctrine/orm": "^2.0 || ^3.0", "doctrine/persistence": "^2.0 || ^3.0", "sylius/core": "^1.0", "sylius/core-bundle": "^1.0", @@ -18,11 +19,13 @@ "sylius/resource-bundle": "^1.6", "symfony/config": "^5.4 || ^6.4 || ^7.0", "symfony/dependency-injection": "^5.4 || ^6.4 || ^7.0", + "symfony/doctrine-bridge": "^5.4 || ^6.3 || ^7.0", "symfony/event-dispatcher": "^5.4 || ^6.4 || ^7.0", "symfony/form": "^5.4 || ^6.4 || ^7.0", "symfony/http-foundation": "^5.4 || ^6.4 || ^7.0", "symfony/http-kernel": "^5.4 || ^6.4 || ^7.0", "symfony/options-resolver": "^5.4 || ^6.4 || ^7.0", + "symfony/routing": "^5.4 || ^6.4 || ^7.0", "webmozart/assert": "^1.11" }, "require-dev": { @@ -34,10 +37,12 @@ "jms/serializer-bundle": "^4.2", "lexik/jwt-authentication-bundle": "^2.17", "matthiasnoback/symfony-dependency-injection-test": "^4.3 || ^5.1", + "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^9.6.17", "psalm/plugin-phpunit": "^0.18.4", "setono/code-quality-pack": "^2.7", "sylius/sylius": "~1.12.13", + "symfony/browser-kit": "^5.4 || ^6.4 || ^7.0", "symfony/debug-bundle": "^5.4 || ^6.4 || ^7.0", "symfony/dotenv": "^5.4 || ^6.4 || ^7.0", "symfony/intl": "^5.4 || ^6.4 || ^7.0", @@ -48,6 +53,10 @@ "symfony/workflow": "^6.4", "willdurand/negotiation": "^3.1" }, + "conflict": { + "doctrine/annotations": "<1.13.2", + "doctrine/data-fixtures": "<1.5.1" + }, "prefer-stable": true, "autoload": { "psr-4": { @@ -80,6 +89,7 @@ "analyse": "psalm", "check-style": "ecs check", "fix-style": "ecs check --fix", - "phpunit": "phpunit" + "functional-tests": "phpunit tests/Functional/", + "unit-tests": "phpunit tests/Unit/" } } diff --git a/infection.json.dist b/infection.json.dist index e458f4c..7becd6a 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -2,7 +2,7 @@ "source": { "directories": [ "src" - ] + ], }, "logs": { "text": "php://stderr", @@ -11,6 +11,6 @@ "badge": "master" } }, - "minMsi": 100.00, - "minCoveredMsi": 100.00 + "minMsi": 70.00, + "minCoveredMsi": 70.00, } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 891e286..219b357 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -8,8 +8,11 @@ - - tests + + tests/Unit + + + tests/Functional diff --git a/psalm.xml b/psalm.xml index 0357e31..ab9fc1c 100644 --- a/psalm.xml +++ b/psalm.xml @@ -15,6 +15,7 @@ + diff --git a/src/Checker/PostUpdateChangesChecker.php b/src/Checker/PostUpdateChangesChecker.php new file mode 100644 index 0000000..edc0430 --- /dev/null +++ b/src/Checker/PostUpdateChangesChecker.php @@ -0,0 +1,18 @@ +getTotal() > $previousOrder->getTotal()) { + throw new NewOrderWrongTotalException(); + } + } +} diff --git a/src/Checker/PostUpdateChangesCheckerInterface.php b/src/Checker/PostUpdateChangesCheckerInterface.php new file mode 100644 index 0000000..8fc2b48 --- /dev/null +++ b/src/Checker/PostUpdateChangesCheckerInterface.php @@ -0,0 +1,12 @@ +oldOrderProvider->prepareToUpdate($id); + + $oldOrder = clone $order; + $updatedOrder = $this->updatedOrderProvider->provideFromOldOrderAndRequest($order, $request); + + try { + $this->updatedOrderProcessor->process($updatedOrder); + $this->postUpdateChangesChecker->check($oldOrder, $updatedOrder); + $this->entityManager->flush(); + } catch (NewOrderWrongTotalException) { + return $this->addFlashAndRedirect( + 'error', + 'setono_sylius_order_edit.order_update.total_error', + 'sylius_admin_order_update', + $id, + ); + } catch (\Throwable) { + return $this->addFlashAndRedirect( + 'error', + 'setono_sylius_order_edit.order_update.general_error', + 'sylius_admin_order_update', + $id, + ); + } + + return $this->addFlashAndRedirect( + 'success', + 'setono_sylius_order_edit.order_update.success', + 'sylius_admin_order_show', + $id, + ); + } + + private function addFlashAndRedirect( + string $type, + string $message, + string $route, + int $orderId, + ): RedirectResponse { + $session = $this->requestStack->getSession(); + + if ($session instanceof Session) { + $session->getFlashBag()->add($type, $message); + } + + return new RedirectResponse($this->router->generate($route, ['id' => $orderId])); + } +} diff --git a/src/Controller/ResourceUpdateHandler.php b/src/Controller/ResourceUpdateHandler.php deleted file mode 100644 index 161c37c..0000000 --- a/src/Controller/ResourceUpdateHandler.php +++ /dev/null @@ -1,139 +0,0 @@ - The quantities of the items in the order */ - private array $quantities = []; - - public function __construct( - private readonly ResourceUpdateHandlerInterface $decorated, - private readonly StateMachineInterface $stateMachine, - private readonly OrderProcessorInterface $orderProcessor, - private readonly ProductVariantRepositoryInterface $productVariantRepository, - ) { - } - - public static function getSubscribedEvents(): array - { - return [ - FormEvents::PRE_SET_DATA => 'populateQuantities', - ]; - } - - public function populateQuantities(PreSetDataEvent $event): void - { - /** @var OrderInterface|mixed $order */ - $order = $event->getData(); - Assert::isInstanceOf($order, OrderInterface::class); - - /** @var OrderItemInterface $item */ - foreach ($order->getItems() as $item) { - $variant = $item->getVariant(); - Assert::notNull($variant); - - if (!$variant->isTracked()) { - continue; - } - - $this->quantities[(string) $variant->getCode()] = $item->getQuantity(); - } - } - - /** - * @param ResourceInterface|OrderInterface $resource - * - * @throws UpdateHandlingException - */ - public function handle( - ResourceInterface $resource, - RequestConfiguration $requestConfiguration, - ObjectManager $manager, - ): void { - // This handler will only handle orders updated through the admin interface - $route = $requestConfiguration->getRequest()->attributes->get('_route'); - if ('sylius_admin_order_update' !== $route) { - $this->decorated->handle($resource, $requestConfiguration, $manager); - - return; - } - - try { - $this->giveBack(); - - // The resource should be an order now - Assert::isInstanceOf($resource, OrderInterface::class); - - $requestConfiguration->getParameters()->set('state_machine', [ - 'graph' => 'sylius_order', - 'transition' => 'edit', - ]); - - $this->stateMachine->apply($requestConfiguration, $resource); - - $this->orderProcessor->process($resource); - - $requestConfiguration->getParameters()->set('state_machine', [ - 'graph' => 'sylius_order', - 'transition' => 'create', - ]); - - $this->stateMachine->apply($requestConfiguration, $resource); - - $requestConfiguration->getParameters()->remove('state_machine'); - } catch (\Throwable $e) { - // todo save the exception message in the flashbag under a namespace to be able to output it in the form - throw new UpdateHandlingException($e->getMessage()); - } - - $this->decorated->handle($resource, $requestConfiguration, $manager); - } - - /** - * @throws \InvalidArgumentException - */ - private function giveBack(): void - { - foreach ($this->quantities as $code => $quantity) { - $variant = $this->productVariantRepository->findOneBy(['code' => $code, 'tracked' => true]); - if (!$variant instanceof ProductVariantInterface) { - continue; - } - - $onHold = (int) $variant->getOnHold(); - - Assert::greaterThanEq( - $onHold - $quantity, - 0, - sprintf( - 'Trying to decrease on hold value from %d to %d for product "%s" (%s) which is not possible', - $onHold, - $onHold - $quantity, - (string) $variant->getProduct()?->getName(), - $variant->getName() ?? 'No variant name', - ), - ); - - $variant->setOnHold($onHold - $quantity); - } - } -} diff --git a/src/DependencyInjection/SetonoSyliusOrderEditExtension.php b/src/DependencyInjection/SetonoSyliusOrderEditExtension.php index 1761bae..be21300 100644 --- a/src/DependencyInjection/SetonoSyliusOrderEditExtension.php +++ b/src/DependencyInjection/SetonoSyliusOrderEditExtension.php @@ -25,34 +25,6 @@ public function load(array $configs, ContainerBuilder $container): void public function prepend(ContainerBuilder $container): void { - $container->prependExtensionConfig('winzou_state_machine', [ - 'sylius_order' => [ - 'transitions' => [ - 'edit' => [ - 'from' => ['new'], - 'to' => 'cart', - ], - ], - 'callbacks' => [ - 'after' => [ - 'setono_sylius_order_edit.edit_shipping' => [ - 'on' => ['edit'], - 'do' => ['@sm.callback.cascade_transition', 'apply'], - 'args' => ['object', 'event', "'edit'", "'sylius_order_shipping'"], - ], - ], - ], - ], - 'sylius_order_shipping' => [ - 'transitions' => [ - 'edit' => [ - 'from' => ['ready'], - 'to' => 'cart', - ], - ], - ], - ]); - $container->prependExtensionConfig('sylius_ui', [ 'events' => [ 'sylius.admin.order.update.content' => [ diff --git a/src/EventSubscriber/AddEditOrderActionSubscriber.php b/src/EventSubscriber/AddEditOrderActionSubscriber.php index e085c2a..98bae2d 100644 --- a/src/EventSubscriber/AddEditOrderActionSubscriber.php +++ b/src/EventSubscriber/AddEditOrderActionSubscriber.php @@ -9,9 +9,6 @@ use Sylius\Component\Grid\Event\GridDefinitionConverterEvent; use Symfony\Component\EventDispatcher\EventSubscriberInterface; -/** - * This event subscriber will add an edit order button to the order grid - */ final class AddEditOrderActionSubscriber implements EventSubscriberInterface { public static function getSubscribedEvents(): array @@ -19,9 +16,7 @@ public static function getSubscribedEvents(): array // The grid name is found in this file: vendor/sylius/sylius/src/Sylius/Bundle/AdminBundle/Resources/config/grids/order.yml $eventName = sprintf(ArrayToDefinitionConverter::EVENT_NAME, 'admin_order'); - return [ - $eventName => 'add', - ]; + return [$eventName => 'add']; } public function add(GridDefinitionConverterEvent $event): void diff --git a/src/Exception/NewOrderWrongTotalException.php b/src/Exception/NewOrderWrongTotalException.php new file mode 100644 index 0000000..3a94b63 --- /dev/null +++ b/src/Exception/NewOrderWrongTotalException.php @@ -0,0 +1,13 @@ +add('items', OrderItemCollectionType::class); - $builder->addEventSubscriber($this->resourceUpdateHandler); } public static function getExtendedTypes(): \Generator diff --git a/src/Form/Type/OrderItemType.php b/src/Form/Type/OrderItemType.php index 36140bd..cb1957b 100644 --- a/src/Form/Type/OrderItemType.php +++ b/src/Form/Type/OrderItemType.php @@ -6,9 +6,10 @@ use Sylius\Bundle\ResourceBundle\Form\Type\AbstractResourceType; use Sylius\Component\Core\Model\OrderItemInterface; +use Sylius\Component\Core\Model\ProductVariant; use Sylius\Component\Order\Modifier\OrderItemQuantityModifierInterface; +use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\Extension\Core\Type\IntegerType; -use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; final class OrderItemType extends AbstractResourceType @@ -26,11 +27,18 @@ public function __construct( public function buildForm(FormBuilderInterface $builder, array $options): void { - $builder->add('quantity', IntegerType::class, [ - 'label' => false, - 'setter' => function (OrderItemInterface &$orderItem, int $quantity): void { - $this->orderItemQuantityModifier->modify($orderItem, $quantity); - }, - ])->add('variant', TextType::class); + $builder + ->add('quantity', IntegerType::class, [ + 'label' => false, + 'setter' => function (OrderItemInterface &$orderItem, int $quantity): void { + $this->orderItemQuantityModifier->modify($orderItem, $quantity); + }, + ]) + // TODO: change to autocomplete type for product variant + ->add('variant', EntityType::class, [ + 'class' => ProductVariant::class, + 'choice_label' => 'code', + ]) + ; } } diff --git a/src/OrderProcessing/OrderPaymentProcessor.php b/src/OrderProcessing/OrderPaymentProcessor.php index db75566..5a7aec4 100644 --- a/src/OrderProcessing/OrderPaymentProcessor.php +++ b/src/OrderProcessing/OrderPaymentProcessor.php @@ -21,9 +21,9 @@ public function process(OrderInterface $order): void /** @var mixed $route */ $route = $this->requestStack->getCurrentRequest()?->attributes->get('_route'); - // This disables the \Sylius\Component\Core\OrderProcessing\OrderPaymentProcessor if the route is 'sylius_admin_order_update' + // This disables the \Sylius\Component\Core\OrderProcessing\OrderPaymentProcessor if the route is 'setono_sylius_order_edit_admin_update' // which means we are editing the order in the admin panel - if ('sylius_admin_order_update' === $route) { + if ('setono_sylius_order_edit_admin_update' === $route) { return; } diff --git a/src/Preparer/OrderPreparer.php b/src/Preparer/OrderPreparer.php new file mode 100644 index 0000000..680acfc --- /dev/null +++ b/src/Preparer/OrderPreparer.php @@ -0,0 +1,30 @@ +orderRepository->find($orderId); + Assert::isInstanceOf($order, OrderInterface::class); + + $this->orderInventoryOperator->cancel($order); + + return $order; + } +} diff --git a/src/Preparer/OrderPreparerInterface.php b/src/Preparer/OrderPreparerInterface.php new file mode 100644 index 0000000..46390fe --- /dev/null +++ b/src/Preparer/OrderPreparerInterface.php @@ -0,0 +1,12 @@ +setState(OrderInterface::STATE_CART); + $this->orderProcessor->process($updatedOrder); + $this->afterCheckoutOrderPaymentProcessor->process($updatedOrder); + $updatedOrder->setState(OrderInterface::STATE_NEW); + $this->orderInventoryOperator->hold($updatedOrder); + + return $updatedOrder; + } +} diff --git a/src/Processor/UpdatedOrderProcessorInterface.php b/src/Processor/UpdatedOrderProcessorInterface.php new file mode 100644 index 0000000..9b4396f --- /dev/null +++ b/src/Processor/UpdatedOrderProcessorInterface.php @@ -0,0 +1,12 @@ +formFactory->create( + OrderType::class, + $oldOrder, + // TODO: figure out how to not disable CSRF here - it blocks us on the testing level, so let's leave + // it how it is now, but definitely it should not be disabled in the production-ready code + ['validation_groups' => 'sylius', 'csrf_protection' => false], + ); + $form->handleRequest($request); + + if (!$form->isSubmitted() || !$form->isValid()) { + throw new OrderUpdateException(); + } + + $data = $form->getData(); + Assert::isInstanceOf($data, OrderInterface::class); + + return $data; + } +} diff --git a/src/Provider/UpdatedOrderProviderInterface.php b/src/Provider/UpdatedOrderProviderInterface.php new file mode 100644 index 0000000..b0f6113 --- /dev/null +++ b/src/Provider/UpdatedOrderProviderInterface.php @@ -0,0 +1,13 @@ + - - - - - + + + + + + + + diff --git a/src/Resources/config/services/form.xml b/src/Resources/config/services/form.xml index a83d17c..37a420c 100644 --- a/src/Resources/config/services/form.xml +++ b/src/Resources/config/services/form.xml @@ -15,8 +15,6 @@ - - diff --git a/src/Resources/config/services/order_processing.xml b/src/Resources/config/services/order_processing.xml index aaee601..b31b99b 100644 --- a/src/Resources/config/services/order_processing.xml +++ b/src/Resources/config/services/order_processing.xml @@ -8,5 +8,34 @@ + + + + + + + + + + + + + + + + + diff --git a/src/Resources/translations/flashes.en.yaml b/src/Resources/translations/flashes.en.yaml new file mode 100644 index 0000000..aa7c27d --- /dev/null +++ b/src/Resources/translations/flashes.en.yaml @@ -0,0 +1,5 @@ +setono_sylius_order_edit: + order_update: + general_error: 'An error occurred while updating the order' + total_error: 'Order should not have bigger total than before editing' + success: 'Order has been successfully updated' diff --git a/src/Resources/views/admin/order/update/_order_items.html.twig b/src/Resources/views/admin/order/update/_order_items.html.twig index 52face2..74f9ba2 100644 --- a/src/Resources/views/admin/order/update/_order_items.html.twig +++ b/src/Resources/views/admin/order/update/_order_items.html.twig @@ -16,6 +16,7 @@ {{ 'sylius.ui.quantity'|trans }} {{ 'sylius.ui.product'|trans }} + {{ 'sylius.ui.variant'|trans }} {{ 'sylius.ui.unit_price'|trans }} {{ 'sylius.ui.total'|trans }}   @@ -28,7 +29,8 @@ {% set orderItem = itemForm.vars.data %} {{ form_row(itemForm.quantity) }} - {{ orderItem.productName }} {# todo add variant #} + {{ orderItem.productName }} + {{ orderItem.variantName }} {{ money.format(orderItem.unitPrice, order.currencyCode) }} {{ money.format(orderItem.total, order.currencyCode) }} diff --git a/tests/Application/config/routes/setono_sylius_order_edit.yaml b/tests/Application/config/routes/setono_sylius_order_edit.yaml new file mode 100644 index 0000000..3dab0fb --- /dev/null +++ b/tests/Application/config/routes/setono_sylius_order_edit.yaml @@ -0,0 +1,2 @@ +setono_sylius_edit_order: + resource: "@SetonoSyliusOrderEditPlugin/Resources/config/routes.yaml" diff --git a/tests/Application/templates/bundles/SyliusAdminBundle/Order/Update/_content.html.twig b/tests/Application/templates/bundles/SyliusAdminBundle/Order/Update/_content.html.twig index bd9383b..63e27dc 100644 --- a/tests/Application/templates/bundles/SyliusAdminBundle/Order/Update/_content.html.twig +++ b/tests/Application/templates/bundles/SyliusAdminBundle/Order/Update/_content.html.twig @@ -1,8 +1,8 @@ {% form_theme form '@SyliusAdmin/Form/theme.html.twig' %} -{{ form_start(form, {'action': path('sylius_admin_order_update', {'id': order.id}), 'attr': {'class': 'ui loadable form', 'novalidate': 'novalidate'}}) }} +{{ form_start(form, {'action': path('setono_sylius_order_edit_admin_update', {'id': order.id}), 'attr': {'class': 'ui loadable form', 'novalidate': 'novalidate'}}) }}
- + {{ include('@SetonoSyliusOrderEditPlugin/admin/order/update/_order_items.html.twig') }} @@ -24,7 +24,6 @@ {{ sylius_template_event('sylius.admin.order.update.form', {'resource': resource}) }} - {{ form_row(form._token) }} {% include '@SyliusUi/Form/Buttons/_update.html.twig' with {'paths': {'cancel': sylius_generate_redirect_path(path('sylius_admin_order_index'))}} %}
{{ form_end(form, {'render_rest': false}) }} diff --git a/tests/DependencyInjection/SetonoSyliusOrderEditExtensionTest.php b/tests/DependencyInjection/SetonoSyliusOrderEditExtensionTest.php deleted file mode 100644 index ce8378d..0000000 --- a/tests/DependencyInjection/SetonoSyliusOrderEditExtensionTest.php +++ /dev/null @@ -1,29 +0,0 @@ -load(); - } -} diff --git a/tests/Functional/OrderUpdateTest.php b/tests/Functional/OrderUpdateTest.php new file mode 100644 index 0000000..ee99184 --- /dev/null +++ b/tests/Functional/OrderUpdateTest.php @@ -0,0 +1,162 @@ + 'test', 'debug' => true]); + } + + public function testItUpdatesOrder(): void + { + $this->makeVariantTrackedWithStock(); + $order = $this->placeOrderProgramatically(quantity: 5); + + /** @var ProductVariantInterface $variant */ + $variant = $this->getVariantRepository()->findOneBy(['code' => '000F_office_grey_jeans-variant-0']); + $initialHold = $variant->getOnHold(); + + $this->loginAsAdmin(); + $this->updateOrder($order->getId()); + + self::assertResponseStatusCodeSame(302); + + $order = $this->getOrderRepository()->findOneBy(['tokenValue' => 'TOKEN']); + self::assertSame(3, $order->getItems()->first()->getQuantity()); + + $variant = $this->getVariantRepository()->findOneBy(['code' => '000F_office_grey_jeans-variant-0']); + self::assertSame($initialHold - 2, $variant->getOnHold()); + } + + private function placeOrderProgramatically( + string $variantCode = '000F_office_grey_jeans-variant-0', + int $quantity = 1, + ): Order { + /** @var MessageBusInterface $commandBus */ + $commandBus = self::getContainer()->get('sylius.command_bus'); + + $pickupCart = new PickupCart('TOKEN'); + $pickupCart->setLocaleCode('en_US'); + $pickupCart->setChannelCode('FASHION_WEB'); + $pickupCart->setEmail('shop@example.com'); + $commandBus->dispatch($pickupCart); + + $orderRepository = self::getContainer()->get('sylius.repository.order'); + /** @var OrderInterface $order */ + $order = $orderRepository->findOneBy(['tokenValue' => 'TOKEN']); + + $addToCartCommand = new AddItemToCart($variantCode, $quantity); + $addToCartCommand->setOrderTokenValue('TOKEN'); + $commandBus->dispatch($addToCartCommand); + + $address = new Address(); + $address->setFirstName('John'); + $address->setLastName('Doe'); + $address->setCountryCode('US'); + $address->setCity('New York'); + $address->setStreet('Wall Street'); + $address->setPostcode('00-001'); + $addressCart = new UpdateCart(billingAddress: $address, shippingAddress: $address); + $addressCart->setOrderTokenValue('TOKEN'); + $commandBus->dispatch($addressCart); + + $chooseShippingMethod = new ChooseShippingMethod('dhl_express'); + $chooseShippingMethod->setOrderTokenValue('TOKEN'); + $chooseShippingMethod->setSubresourceId((string) $order->getShipments()->first()->getId()); + $commandBus->dispatch($chooseShippingMethod); + + $choosePaymentMethod = new ChoosePaymentMethod('bank_transfer'); + $choosePaymentMethod->setOrderTokenValue('TOKEN'); + $choosePaymentMethod->setSubresourceId((string) $order->getPayments()->first()->getId()); + $commandBus->dispatch($choosePaymentMethod); + + $completeOrder = new CompleteOrder(); + $completeOrder->setOrderTokenValue('TOKEN'); + $commandBus->dispatch($completeOrder); + + return $this->getOrderRepository()->findOneBy(['tokenValue' => 'TOKEN']); + } + + private function makeVariantTrackedWithStock(string $code = '000F_office_grey_jeans-variant-0', int $stock = 10): void + { + $variantRepository = self::getContainer()->get('sylius.repository.product_variant'); + /** @var ProductVariantInterface $variant */ + $variant = $variantRepository->findOneBy(['code' => $code]); + $variant->setTracked(true); + $variant->setOnHand($stock); + $variant->getOnHold(0); + + self::getContainer()->get('sylius.manager.product_variant')->flush(); + } + + protected function tearDown(): void + { + $order = $this->getOrderRepository()->findOneBy(['tokenValue' => 'TOKEN']); + $orderManager = self::getContainer()->get('sylius.manager.order'); + $orderManager->remove($order); + $orderManager->flush(); + } + + private function loginAsAdmin(): void + { + $crawler = static::$client->request('GET', '/admin/login'); + $form = $crawler->selectButton('Login')->form([ + '_username' => 'sylius@example.com', + '_password' => 'sylius', + ]); + + static::$client->submit($form); + } + + private function updateOrder(int $orderId): void + { + static::$client->request( + 'PATCH', + sprintf('/admin/orders/%d/update-and-process', $orderId), + [], + [], + ['CONTENT_TYPE' => 'application/json'], + json_encode([ + 'sylius_order' => [ + 'items' => [ + ['quantity' => 3], + ], + ], + ]), + ); + } + + private function getOrderRepository(): OrderRepositoryInterface + { + return self::getContainer()->get('sylius.repository.order'); + } + + private function getVariantRepository(): ProductVariantRepositoryInterface + { + return self::getContainer()->get('sylius.repository.product_variant'); + } +} diff --git a/tests/Unit/Checker/PostUpdateChangesCheckerTest.php b/tests/Unit/Checker/PostUpdateChangesCheckerTest.php new file mode 100644 index 0000000..3d0538d --- /dev/null +++ b/tests/Unit/Checker/PostUpdateChangesCheckerTest.php @@ -0,0 +1,61 @@ +expectException(NewOrderWrongTotalException::class); + + $validator = new PostUpdateChangesChecker(); + + $newOrder = $this->prophesize(OrderInterface::class); + $newOrder->getTotal()->willReturn(1000); + + $previousOrder = $this->prophesize(OrderInterface::class); + $previousOrder->getTotal()->willReturn(500); + + $validator->check($previousOrder->reveal(), $newOrder->reveal()); + } + + public function testItDoesNothingIfNewOrderTotalIsSmallerThanThePreviousOne(): void + { + $validator = new PostUpdateChangesChecker(); + + $newOrder = $this->prophesize(OrderInterface::class); + $newOrder->getTotal()->willReturn(500); + + $previousOrder = $this->prophesize(OrderInterface::class); + $previousOrder->getTotal()->willReturn(1000); + + $this->expectNotToPerformAssertions(); + + $validator->check($previousOrder->reveal(), $newOrder->reveal()); + } + + public function testItDoesNothingIfNewOrderTotalIsEqualToThePreviousOne(): void + { + $validator = new PostUpdateChangesChecker(); + + $newOrder = $this->prophesize(OrderInterface::class); + $newOrder->getTotal()->willReturn(500); + + $previousOrder = $this->prophesize(OrderInterface::class); + $previousOrder->getTotal()->willReturn(500); + + $this->expectNotToPerformAssertions(); + + $validator->check($previousOrder->reveal(), $newOrder->reveal()); + } +} diff --git a/tests/Unit/OrderProcessing/OrderPaymentProcessorTest.php b/tests/Unit/OrderProcessing/OrderPaymentProcessorTest.php new file mode 100644 index 0000000..fd74b88 --- /dev/null +++ b/tests/Unit/OrderProcessing/OrderPaymentProcessorTest.php @@ -0,0 +1,50 @@ +prophesize(OrderProcessorInterface::class); + $requestStack = $this->prophesize(RequestStack::class); + $request = new Request([], [], ['_route' => 'setono_sylius_order_edit_admin_update']); + $requestStack->getCurrentRequest()->willReturn($request); + + $processor = new OrderPaymentProcessor($orderProcessor->reveal(), $requestStack->reveal()); + + $order = $this->prophesize(OrderInterface::class); + + $orderProcessor->process($order)->shouldNotBeCalled(); + + $processor->process($order->reveal()); + } + + public function testItDoesNothingIfItsDifferentRoute(): void + { + $orderProcessor = $this->prophesize(OrderProcessorInterface::class); + $requestStack = $this->prophesize(RequestStack::class); + $request = new Request([], [], ['_route' => 'some_other_route']); + $requestStack->getCurrentRequest()->willReturn($request); + + $processor = new OrderPaymentProcessor($orderProcessor->reveal(), $requestStack->reveal()); + + $order = $this->prophesize(OrderInterface::class); + + $orderProcessor->process($order)->shouldBeCalled(); + + $processor->process($order->reveal()); + } +} diff --git a/tests/Unit/Processor/UpdatedOrderProcessorTest.php b/tests/Unit/Processor/UpdatedOrderProcessorTest.php new file mode 100644 index 0000000..62449ea --- /dev/null +++ b/tests/Unit/Processor/UpdatedOrderProcessorTest.php @@ -0,0 +1,40 @@ +prophesize(OrderProcessorInterface::class); + $orderInventoryOperator = $this->prophesize(OrderInventoryOperatorInterface::class); + $afterCheckoutOrderPaymentProcessor = $this->prophesize(OrderProcessorInterface::class); + + $processor = new UpdatedOrderProcessor( + $orderProcessor->reveal(), + $orderInventoryOperator->reveal(), + $afterCheckoutOrderPaymentProcessor->reveal(), + ); + + $newOrder = $this->prophesize(OrderInterface::class); + + $newOrder->setState('cart')->shouldBeCalled(); + $orderProcessor->process($newOrder)->shouldBeCalled(); + $afterCheckoutOrderPaymentProcessor->process($newOrder)->shouldBeCalled(); + $newOrder->setState('new')->shouldBeCalled(); + $orderInventoryOperator->hold($newOrder)->shouldBeCalled(); + + self::assertSame($newOrder->reveal(), $processor->process($newOrder->reveal())); + } +} diff --git a/tests/Unit/Provider/OrderPreparerTest.php b/tests/Unit/Provider/OrderPreparerTest.php new file mode 100644 index 0000000..70607ca --- /dev/null +++ b/tests/Unit/Provider/OrderPreparerTest.php @@ -0,0 +1,51 @@ +prophesize(OrderRepositoryInterface::class); + $inventoryOperator = $this->prophesize(OrderInventoryOperatorInterface::class); + + $provider = new OrderPreparer( + $orderRepository->reveal(), + $inventoryOperator->reveal(), + ); + + $order = new Order(); + $orderRepository->find(1)->willReturn($order); + $inventoryOperator->cancel($order)->shouldBeCalled(); + + self::assertSame($order, $provider->prepareToUpdate(1)); + } + + public function testItThrowsExceptionIfOrderWithGivenIdDoesNotExist(): void + { + $orderRepository = $this->prophesize(OrderRepositoryInterface::class); + $inventoryOperator = $this->prophesize(OrderInventoryOperatorInterface::class); + + $provider = new OrderPreparer( + $orderRepository->reveal(), + $inventoryOperator->reveal(), + ); + + $orderRepository->find(1)->willReturn(null); + + $this->expectException(\InvalidArgumentException::class); + + $provider->prepareToUpdate(1); + } +} diff --git a/tests/Unit/Provider/UpdatedOrderProviderTest.php b/tests/Unit/Provider/UpdatedOrderProviderTest.php new file mode 100644 index 0000000..2b259e8 --- /dev/null +++ b/tests/Unit/Provider/UpdatedOrderProviderTest.php @@ -0,0 +1,102 @@ +prophesize(FormFactoryInterface::class); + $form = $this->prophesize(FormInterface::class); + + $provider = new UpdatedOrderProvider($formFactory->reveal()); + $order = new Order(); + $newOrder = new Order(); + $request = new Request(); + + $formFactory->create(OrderType::class, $order, ['validation_groups' => 'sylius', 'csrf_protection' => false])->willReturn($form); + $form->handleRequest($request)->shouldBeCalled()->willReturn($form); + + $form->isSubmitted()->willReturn(true); + $form->isValid()->willReturn(true); + + $form->getData()->willReturn($newOrder); + + self::assertSame($newOrder, $provider->provideFromOldOrderAndRequest($order, $request)); + } + + public function testItThrowsExceptionIfFormIsNotSubmitted(): void + { + $formFactory = $this->prophesize(FormFactoryInterface::class); + $form = $this->prophesize(FormInterface::class); + + $provider = new UpdatedOrderProvider($formFactory->reveal()); + $order = new Order(); + $request = new Request(); + + $formFactory->create(OrderType::class, $order, ['validation_groups' => 'sylius', 'csrf_protection' => false])->willReturn($form); + $form->handleRequest($request)->shouldBeCalled()->willReturn($form); + + $form->isSubmitted()->willReturn(false); + + $this->expectException(OrderUpdateException::class); + + $provider->provideFromOldOrderAndRequest($order, $request); + } + + public function testItThrowsExceptionIfFormIsNotValid(): void + { + $formFactory = $this->prophesize(FormFactoryInterface::class); + $form = $this->prophesize(FormInterface::class); + + $provider = new UpdatedOrderProvider($formFactory->reveal()); + $order = new Order(); + $request = new Request(); + + $formFactory->create(OrderType::class, $order, ['validation_groups' => 'sylius', 'csrf_protection' => false])->willReturn($form); + $form->handleRequest($request)->shouldBeCalled()->willReturn($form); + + $form->isSubmitted()->willReturn(true); + $form->isValid()->willReturn(false); + + $this->expectException(OrderUpdateException::class); + + $provider->provideFromOldOrderAndRequest($order, $request); + } + + public function testItThrowsExceptionIfFormDataIsNotOrder(): void + { + $formFactory = $this->prophesize(FormFactoryInterface::class); + $form = $this->prophesize(FormInterface::class); + + $provider = new UpdatedOrderProvider($formFactory->reveal()); + $order = new Order(); + $request = new Request(); + + $formFactory->create(OrderType::class, $order, ['validation_groups' => 'sylius', 'csrf_protection' => false])->willReturn($form); + $form->handleRequest($request)->shouldBeCalled()->willReturn($form); + + $form->isSubmitted()->willReturn(true); + $form->isValid()->willReturn(true); + + $form->getData()->willReturn(new \stdClass()); + + $this->expectException(\InvalidArgumentException::class); + + $provider->provideFromOldOrderAndRequest($order, $request); + } +}