mirror of
https://github.com/Sylius/Sylius.git
synced 2026-07-15 01:20:59 +00:00
Prevent stale cart LiveComponents from mutating completed orders
This commit is contained in:
parent
c6ea41803e
commit
4c120f3f05
6 changed files with 309 additions and 1 deletions
|
|
@ -0,0 +1,39 @@
|
||||||
|
@stale_live_component
|
||||||
|
Feature: Stale cart LiveComponent cannot mutate a completed order
|
||||||
|
In order to protect completed orders from data corruption
|
||||||
|
As a Developer
|
||||||
|
I want the cart LiveComponent to be safe against stale browser state
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given the store operates on a single channel in "United States"
|
||||||
|
And the store has a product "Sylius T-Shirt" priced at "$19.99"
|
||||||
|
|
||||||
|
@ui @javascript
|
||||||
|
Scenario: Clearing cart from a stale page does not delete a completed order
|
||||||
|
Given I added product "Sylius T-Shirt" to the cart
|
||||||
|
And I check the details of my cart
|
||||||
|
And I note the current order id for later assertions
|
||||||
|
When the order is completed in the background
|
||||||
|
And I clear my cart from the stale page without reloading
|
||||||
|
Then the completed order should still exist in the database
|
||||||
|
And the order checkout state should be "completed"
|
||||||
|
|
||||||
|
@ui @javascript
|
||||||
|
Scenario: Removing an item from a stale page does not mutate a completed order
|
||||||
|
Given I added product "Sylius T-Shirt" to the cart
|
||||||
|
And I check the details of my cart
|
||||||
|
And I note the current order id for later assertions
|
||||||
|
When the order is completed in the background
|
||||||
|
And I remove first item from cart from the stale page without reloading
|
||||||
|
Then the completed order should still exist in the database
|
||||||
|
And the order should still have 1 item
|
||||||
|
|
||||||
|
@ui @javascript
|
||||||
|
Scenario: Increasing item quantity on a stale page does not mutate a completed order
|
||||||
|
Given I added product "Sylius T-Shirt" to the cart
|
||||||
|
And I check the details of my cart
|
||||||
|
And I note the current order id for later assertions
|
||||||
|
When the order is completed in the background
|
||||||
|
And I increase the quantity of the first cart item to 2 on the stale page without reloading
|
||||||
|
Then the completed order should still exist in the database
|
||||||
|
And the order item quantity should still be 1
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Sylius package.
|
||||||
|
*
|
||||||
|
* (c) Sylius Sp. z o.o.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Sylius\Behat\Context\Ui\Shop;
|
||||||
|
|
||||||
|
use Behat\Behat\Context\Context;
|
||||||
|
use Behat\Mink\Mink;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Sylius\Behat\Service\SharedStorageInterface;
|
||||||
|
use Sylius\Component\Core\Model\OrderInterface;
|
||||||
|
use Sylius\Component\Core\OrderCheckoutStates;
|
||||||
|
use Sylius\Component\Core\Repository\OrderRepositoryInterface;
|
||||||
|
use Sylius\Component\Order\Model\OrderInterface as BaseOrderInterface;
|
||||||
|
use Webmozart\Assert\Assert;
|
||||||
|
|
||||||
|
final readonly class StaleCartLiveComponentContext implements Context
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Mink $mink,
|
||||||
|
private SharedStorageInterface $sharedStorage,
|
||||||
|
private OrderRepositoryInterface $orderRepository,
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Given I note the current order id for later assertions
|
||||||
|
*/
|
||||||
|
public function iNoteTheCurrentOrderId(): void
|
||||||
|
{
|
||||||
|
$page = $this->mink->getSession()->getPage();
|
||||||
|
$component = $page->find('css', '[data-live-name-value="sylius_shop:cart:form"]');
|
||||||
|
Assert::notNull($component, 'Cart LiveComponent root element not found on page.');
|
||||||
|
|
||||||
|
$rawProps = $component->getAttribute('data-live-props-value');
|
||||||
|
Assert::notNull($rawProps, 'data-live-props-value attribute missing on cart form LiveComponent.');
|
||||||
|
|
||||||
|
$props = json_decode($rawProps, true);
|
||||||
|
$orderId = $props['resource'] ?? null;
|
||||||
|
Assert::notNull($orderId, 'Order ID not found in dehydrated LiveComponent props.');
|
||||||
|
|
||||||
|
$this->sharedStorage->set('stale_order_id', (int) $orderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @When the order is completed in the background
|
||||||
|
*/
|
||||||
|
public function theOrderIsCompletedInTheBackground(): void
|
||||||
|
{
|
||||||
|
$orderId = $this->sharedStorage->get('stale_order_id');
|
||||||
|
|
||||||
|
/** @var OrderInterface|null $order */
|
||||||
|
$order = $this->orderRepository->find($orderId);
|
||||||
|
Assert::notNull($order, sprintf('Order #%d not found.', $orderId));
|
||||||
|
|
||||||
|
$order->setCheckoutState(OrderCheckoutStates::STATE_COMPLETED);
|
||||||
|
$order->setState(BaseOrderInterface::STATE_NEW);
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @When I clear my cart from the stale page without reloading
|
||||||
|
*/
|
||||||
|
public function iClearMyCartFromTheStalePage(): void
|
||||||
|
{
|
||||||
|
$page = $this->mink->getSession()->getPage();
|
||||||
|
|
||||||
|
$button = $page->find('css', '[data-test-clear-cart]')
|
||||||
|
?? $page->find('css', '[data-live-action-param="clearCart"]');
|
||||||
|
|
||||||
|
Assert::notNull($button, 'Clear cart button not found on the stale page.');
|
||||||
|
|
||||||
|
$button->click();
|
||||||
|
|
||||||
|
$this->waitForLiveComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @When I remove first item from cart from the stale page without reloading
|
||||||
|
*/
|
||||||
|
public function iRemoveFirstItemFromCartFromTheStalePageWithoutReloading(): void
|
||||||
|
{
|
||||||
|
$page = $this->mink->getSession()->getPage();
|
||||||
|
|
||||||
|
$button = $page->find('css', '[data-test-remove-cart-item]')
|
||||||
|
?? $page->find('css', '[data-live-action-param="removeItem"]');
|
||||||
|
|
||||||
|
Assert::notNull($button, 'Remove item button not found on the stale page.');
|
||||||
|
|
||||||
|
$button->click();
|
||||||
|
|
||||||
|
$this->waitForLiveComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Then the completed order should still exist in the database
|
||||||
|
*/
|
||||||
|
public function theCompletedOrderShouldStillExistInTheDatabase(): void
|
||||||
|
{
|
||||||
|
$orderId = $this->sharedStorage->get('stale_order_id');
|
||||||
|
|
||||||
|
// Clear the identity map to bypass any cached entity and force a real SELECT.
|
||||||
|
$this->entityManager->clear();
|
||||||
|
|
||||||
|
$order = $this->orderRepository->find($orderId);
|
||||||
|
|
||||||
|
Assert::notNull(
|
||||||
|
$order,
|
||||||
|
sprintf('Order #%d was deleted. The stale LiveComponent action must not remove a completed order.', $orderId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Then the order checkout state should be :expectedState
|
||||||
|
*/
|
||||||
|
public function theOrderCheckoutStateShouldBe(string $expectedState): void
|
||||||
|
{
|
||||||
|
$orderId = $this->sharedStorage->get('stale_order_id');
|
||||||
|
$this->entityManager->clear();
|
||||||
|
|
||||||
|
/** @var OrderInterface|null $order */
|
||||||
|
$order = $this->orderRepository->find($orderId);
|
||||||
|
Assert::notNull($order);
|
||||||
|
|
||||||
|
Assert::same(
|
||||||
|
$order->getCheckoutState(),
|
||||||
|
$expectedState,
|
||||||
|
sprintf('Checkout state: expected "%s" but got "%s".', $expectedState, $order->getCheckoutState()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @When I increase the quantity of the first cart item to :quantity on the stale page without reloading
|
||||||
|
*/
|
||||||
|
public function iIncreaseTheQuantityOfTheFirstCartItemOnTheStalePage(int $quantity): void
|
||||||
|
{
|
||||||
|
$page = $this->mink->getSession()->getPage();
|
||||||
|
|
||||||
|
$input = $page->find('css', '[data-test-cart-item-quantity]');
|
||||||
|
Assert::notNull($input, 'Cart item quantity input not found on the stale page.');
|
||||||
|
|
||||||
|
$input->setValue((string) $quantity);
|
||||||
|
|
||||||
|
$this->waitForLiveComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Then the order item quantity should still be :quantity
|
||||||
|
*/
|
||||||
|
public function theOrderItemQuantityShouldStillBe(int $quantity): void
|
||||||
|
{
|
||||||
|
$orderId = $this->sharedStorage->get('stale_order_id');
|
||||||
|
$this->entityManager->clear();
|
||||||
|
|
||||||
|
/** @var OrderInterface|null $order */
|
||||||
|
$order = $this->orderRepository->find($orderId);
|
||||||
|
Assert::notNull($order);
|
||||||
|
|
||||||
|
$firstItem = $order->getItems()->first();
|
||||||
|
Assert::notFalse($firstItem, 'The order has no items.');
|
||||||
|
|
||||||
|
Assert::same(
|
||||||
|
$firstItem->getQuantity(),
|
||||||
|
$quantity,
|
||||||
|
sprintf('Expected item quantity %d but got %d.', $quantity, $firstItem->getQuantity()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Then the order should still have :count item(s)
|
||||||
|
*/
|
||||||
|
public function theOrderShouldStillHaveItems(int $count): void
|
||||||
|
{
|
||||||
|
$orderId = $this->sharedStorage->get('stale_order_id');
|
||||||
|
$this->entityManager->clear();
|
||||||
|
|
||||||
|
/** @var OrderInterface|null $order */
|
||||||
|
$order = $this->orderRepository->find($orderId);
|
||||||
|
Assert::notNull($order);
|
||||||
|
|
||||||
|
Assert::count(
|
||||||
|
$order->getItems(),
|
||||||
|
$count,
|
||||||
|
sprintf('Expected %d item(s) but found %d.', $count, $order->getItems()->count()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function waitForLiveComponent(int $timeoutMs = 5000): void
|
||||||
|
{
|
||||||
|
$this->mink->getSession()->wait(2000, "document.querySelector('[data-live-loading]') !== null");
|
||||||
|
$this->mink->getSession()->wait($timeoutMs, "document.querySelector('[data-live-loading]') === null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -505,6 +505,13 @@
|
||||||
<argument type="service" id="sylius.behat.element.browser" />
|
<argument type="service" id="sylius.behat.element.browser" />
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<service id="sylius.behat.context.ui.shop.stale_cart_live_component" class="Sylius\Behat\Context\Ui\Shop\StaleCartLiveComponentContext">
|
||||||
|
<argument type="service" id="behat.mink" />
|
||||||
|
<argument type="service" id="sylius.behat.shared_storage" />
|
||||||
|
<argument type="service" id="sylius.repository.order" />
|
||||||
|
<argument type="service" id="doctrine.orm.entity_manager" />
|
||||||
|
</service>
|
||||||
|
|
||||||
<service id="sylius.behat.context.ui.shop.contact" class="Sylius\Behat\Context\Ui\Shop\ContactContext">
|
<service id="sylius.behat.context.ui.shop.contact" class="Sylius\Behat\Context\Ui\Shop\ContactContext">
|
||||||
<argument type="service" id="sylius.behat.page.shop.contact" />
|
<argument type="service" id="sylius.behat.page.shop.contact" />
|
||||||
<argument type="service" id="sylius.behat.notification_checker.shop" />
|
<argument type="service" id="sylius.behat.notification_checker.shop" />
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ imports:
|
||||||
- ui/admin/security.yml
|
- ui/admin/security.yml
|
||||||
- ui/cart/accessing_cart.yaml
|
- ui/cart/accessing_cart.yaml
|
||||||
- ui/cart/shopping_cart.yml
|
- ui/cart/shopping_cart.yml
|
||||||
|
- ui/cart/stale_cart_live_component.yml
|
||||||
- ui/channel/channels.yml
|
- ui/channel/channels.yml
|
||||||
- ui/channel/managing_channels.yml
|
- ui/channel/managing_channels.yml
|
||||||
- ui/channel/products_accessibility_in_multiple_channels.yml
|
- ui/channel/products_accessibility_in_multiple_channels.yml
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
# This file is part of the Sylius package.
|
||||||
|
# (c) Sylius Sp. z o.o.
|
||||||
|
|
||||||
|
default:
|
||||||
|
suites:
|
||||||
|
ui_stale_cart_live_component:
|
||||||
|
contexts:
|
||||||
|
- sylius.behat.context.hook.bad_gateway
|
||||||
|
- sylius.behat.context.hook.doctrine_orm
|
||||||
|
- sylius.behat.context.hook.guest_cart
|
||||||
|
- sylius.behat.context.hook.session
|
||||||
|
|
||||||
|
- sylius.behat.context.transform.channel
|
||||||
|
- sylius.behat.context.transform.lexical
|
||||||
|
- sylius.behat.context.transform.product
|
||||||
|
- sylius.behat.context.transform.shared_storage
|
||||||
|
|
||||||
|
- sylius.behat.context.setup.cart
|
||||||
|
- sylius.behat.context.setup.channel
|
||||||
|
- sylius.behat.context.setup.currency
|
||||||
|
- sylius.behat.context.setup.product
|
||||||
|
- sylius.behat.context.setup.zone
|
||||||
|
|
||||||
|
- sylius.behat.context.ui.shop.cart
|
||||||
|
- sylius.behat.context.ui.shop.product
|
||||||
|
- sylius.behat.context.ui.shop.stale_cart_live_component
|
||||||
|
|
||||||
|
filters:
|
||||||
|
tags: "@stale_live_component&&@ui"
|
||||||
|
|
@ -17,8 +17,10 @@ use Doctrine\Persistence\ObjectManager;
|
||||||
use Sylius\Bundle\UiBundle\Twig\Component\ResourceFormComponentTrait;
|
use Sylius\Bundle\UiBundle\Twig\Component\ResourceFormComponentTrait;
|
||||||
use Sylius\Bundle\UiBundle\Twig\Component\TemplatePropTrait;
|
use Sylius\Bundle\UiBundle\Twig\Component\TemplatePropTrait;
|
||||||
use Sylius\Component\Core\Model\OrderInterface;
|
use Sylius\Component\Core\Model\OrderInterface;
|
||||||
|
use Sylius\Component\Core\OrderCheckoutStates;
|
||||||
use Sylius\Component\Core\Repository\OrderRepositoryInterface;
|
use Sylius\Component\Core\Repository\OrderRepositoryInterface;
|
||||||
use Sylius\Component\Order\SyliusCartEvents;
|
use Sylius\Component\Order\SyliusCartEvents;
|
||||||
|
use Sylius\Resource\Model\ResourceInterface;
|
||||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||||
use Symfony\Component\EventDispatcher\GenericEvent;
|
use Symfony\Component\EventDispatcher\GenericEvent;
|
||||||
use Symfony\Component\Form\FormFactoryInterface;
|
use Symfony\Component\Form\FormFactoryInterface;
|
||||||
|
|
@ -56,10 +58,28 @@ class FormComponent
|
||||||
$this->initialize($orderRepository, $formFactory, $resourceClass, $formClass);
|
$this->initialize($orderRepository, $formFactory, $resourceClass, $formClass);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function hydrateResource(mixed $value): ?ResourceInterface
|
||||||
|
{
|
||||||
|
if (empty($value)) {
|
||||||
|
return $this->createResource();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var OrderInterface|null $order */
|
||||||
|
$order = $this->repository->find($value);
|
||||||
|
|
||||||
|
if (!$order instanceof OrderInterface
|
||||||
|
|| $order->getCheckoutState() === OrderCheckoutStates::STATE_COMPLETED
|
||||||
|
) {
|
||||||
|
return $this->createResource();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $order;
|
||||||
|
}
|
||||||
|
|
||||||
#[PreReRender(priority: -100)]
|
#[PreReRender(priority: -100)]
|
||||||
public function saveCart(): void
|
public function saveCart(): void
|
||||||
{
|
{
|
||||||
if ($this->shouldSaveCart) {
|
if ($this->shouldSaveCart && $this->resource?->getId() !== null) {
|
||||||
$form = $this->getForm();
|
$form = $this->getForm();
|
||||||
if ($form->isValid()) {
|
if ($form->isValid()) {
|
||||||
$this->eventDispatcher->dispatch(new GenericEvent($form->getData()), SyliusCartEvents::CART_CHANGE);
|
$this->eventDispatcher->dispatch(new GenericEvent($form->getData()), SyliusCartEvents::CART_CHANGE);
|
||||||
|
|
@ -72,6 +92,10 @@ class FormComponent
|
||||||
#[LiveAction]
|
#[LiveAction]
|
||||||
public function removeItem(#[LiveArg] int $index): void
|
public function removeItem(#[LiveArg] int $index): void
|
||||||
{
|
{
|
||||||
|
if ($this->resource?->getId() === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$data = $this->formValues['items'];
|
$data = $this->formValues['items'];
|
||||||
unset($data[$index]);
|
unset($data[$index]);
|
||||||
$this->formValues['items'] = array_values($data);
|
$this->formValues['items'] = array_values($data);
|
||||||
|
|
@ -91,6 +115,10 @@ class FormComponent
|
||||||
#[LiveAction]
|
#[LiveAction]
|
||||||
public function clearCart(): void
|
public function clearCart(): void
|
||||||
{
|
{
|
||||||
|
if ($this->resource?->getId() === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->formValues['items'] = [];
|
$this->formValues['items'] = [];
|
||||||
$this->eventDispatcher->dispatch(new GenericEvent($this->resource), SyliusCartEvents::CART_CLEAR);
|
$this->eventDispatcher->dispatch(new GenericEvent($this->resource), SyliusCartEvents::CART_CLEAR);
|
||||||
$this->manager->remove($this->resource);
|
$this->manager->remove($this->resource);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue