Merge branch '1.12' into 1.13

* 1.12:
  Provide post-CR fixes
  Fix static analysis
  Adjust scenarios to use less technical language
  Prevent placing an order with not available shipping method
  Add behat scenario covering preventing a placing an order with a disabled shipping method
  Add behat scenario covering expires session while cart updating
  [API] Fix CommandDenormalizer ignoring custom property names
  Update OrderController.php with early return
  check before refreshing from db
This commit is contained in:
Grzegorz Sadowski 2023-07-21 07:19:19 +02:00
commit 34e77e5d0f
No known key found for this signature in database
GPG key ID: EF87FF4E6E3BD364
25 changed files with 598 additions and 22 deletions

View file

@ -11,3 +11,12 @@ Feature: Viewing a cart summary
Scenario: Viewing information about empty cart
When I see the summary of my cart
Then my cart should be empty
@ui @no-api
Scenario: Viewing information about empty cart after clearing cookies
Given the store has a product "T-Shirt banana" priced at "$12.54"
And I added this product to the cart
And I am on the summary of my cart page
But I've been gone for a long time
When I try to update my cart
Then I should see an empty cart

View file

@ -0,0 +1,28 @@
@checkout
Feature: Preventing placing an order with a disabled shipping method
In order to have my order shipped without issues
As a Customer
I want to be prevented from placing an order with a disabled shipping method
Background:
Given the store operates on a single channel in the "United States" named "US Web Store"
And the store has a product "Ubi T-Shirt" priced at "$19.99"
And the store has "Raven Post" shipping method with "$4.00" fee
And the store allows paying "Offline"
And I am a logged in customer
@ui @api
Scenario: Being prevented from placing an order with a shipping method that's disabled after completing the shipping method choice step
Given I added product "Ubi T-Shirt" to the cart
And I have proceeded through checkout process with "Raven Post" shipping method
But this shipping method has been disabled
When I try to confirm my order
Then I should not be able to confirm order because the "Raven Post" shipping method is not available
@ui @api
Scenario: Being prevented from placing an order with a shipping method that's has been disabled for the customer's country after completing the shipping method choice step
Given I added product "Ubi T-Shirt" to the cart
And I have proceeded through checkout process with "Raven Post" shipping method
But this shipping method has been disabled for "US Web Store" channel
When I try to confirm my order
Then I should not be able to confirm order because the "Raven Post" shipping method is not available

View file

@ -39,3 +39,4 @@ parameters:
- '/Class Symfony\\Component\\Security\\Core\\Authentication\\Token\\UsernamePasswordToken constructor invoked with 4 parameters\, 2\-3 required./'
- '/Method Symfony\\Component\\Serializer\\Normalizer\\NormalizerInterface\:\:supportsNormalization\(\) invoked with 3 parameters\, 1\-2 required\./'
- '/(Interface|Class) [a-zA-Z\\]+ specifies template type (\w+) of interface [a-zA-Z\\]+ as [a-zA-Z\\]+ (of [a-zA-Z\\]+ )?but it''s already specified as/' # turns off a weird generics check behavior, we are basing on Psalm for generics checks
- '/Symfony\\Component\\Serializer\\NameConverter\\NameConverterInterface::normalize\(\) invoked with 2 parameters, 1 required./'

View file

@ -252,6 +252,7 @@
<referencedFunction name="Symfony\Component\HttpKernel\Config\FileLocator::__construct" />
<referencedFunction name="Symfony\Contracts\EventDispatcher\EventDispatcherInterface::dispatch" />
<referencedFunction name="Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken::__construct" /> <!-- removed parameter in Symfony 5.4 -->
<referencedFunction name="Symfony\Component\Serializer\NameConverter\NameConverterInterface::normalize" />
</errorLevel>
<errorLevel type="suppress">
<referencedFunction name="Doctrine\ORM\Query\Expr::andX" />

View file

@ -523,6 +523,22 @@ final class CheckoutContext implements Context
$this->iChoosePaymentMethod($paymentMethod);
}
/**
* @Given I have proceeded through checkout process with :shippingMethod shipping method
*/
public function iHaveProceededThroughCheckoutProcessWithShippingMethod(ShippingMethodInterface $shippingMethod): void
{
$this->addressOrder($this->getArrayWithDefaultAddress());
$this->selectShippingMethod($shippingMethod);
/** @var PaymentMethodInterface $paymentMethod */
$paymentMethod = $this->paymentMethodRepository->findOneBy([]);
$this->iChoosePaymentMethod($paymentMethod);
$this->sharedStorage->set('shipping_method', $shippingMethod);
}
/**
* @Then /^(address "[^"]+", "[^"]+", "[^"]+", "[^"]+", "[^"]+", "[^"]+") should be filled as (billing) address$/
* @Then /^the visitor should has ("[^"]+", "[^"]+", "[^"]+", "[^"]+", "[^"]+" specified as) (billing) address$/
@ -1226,6 +1242,20 @@ final class CheckoutContext implements Context
);
}
/**
* @Then I should not be able to confirm order because the :shippingMethodName shipping method is not available
*/
public function iShouldNotBeAbleToConfirmOrderBecauseTheShippingMethodIsNotAvailable(string $shippingMethodName): void
{
Assert::same(
$this->responseChecker->getError($this->client->getLastResponse()),
sprintf(
'The "%s" shipping method is not available. Please reselect your shipping method.',
$shippingMethodName,
),
);
}
/**
* @When /^I should see (product "[^"]+") with unit price ("[^"]+")$/
*/

View file

@ -546,6 +546,24 @@ final class ShippingContext implements Context
$this->addRuleToShippingMethod($rule, $shippingMethod);
}
/**
* @Given /^(this shipping method) has been disabled$/
* @Given /^(this shipping method) has been disabled for ("[^"]+" channel)$/
*/
public function thisShippingMethodHasBeenDisabled(ShippingMethodInterface $shippingMethod, ?ChannelInterface $channel = null): void
{
/** @var ShippingMethodInterface $shippingMethod */
$shippingMethod = $this->shippingMethodRepository->findOneBy(['code' => $shippingMethod->getCode()]);
if (null === $channel) {
$shippingMethod->disable();
} else {
$shippingMethod->removeChannel($channel);
}
$this->shippingMethodManager->flush();
}
private function getConfigurationByChannels(array $channels, int $amount = 0): array
{
$configuration = [];

View file

@ -15,6 +15,7 @@ namespace Sylius\Behat\Context\Ui\Shop;
use Behat\Behat\Context\Context;
use Behat\Mink\Exception\ElementNotFoundException;
use Sylius\Behat\Element\BrowserElementInterface;
use Sylius\Behat\Element\Shop\CartWidgetElementInterface;
use Sylius\Behat\Element\Shop\CheckoutSubtotalElementInterface;
use Sylius\Behat\NotificationType;
@ -40,6 +41,7 @@ final class CartContext implements Context
private CartWidgetElementInterface $cartWidgetElement,
private NotificationCheckerInterface $notificationChecker,
private SessionManagerInterface $sessionManager,
private BrowserElementInterface $browserElement,
) {
}
@ -53,8 +55,17 @@ final class CartContext implements Context
$this->summaryPage->open();
}
/**
* @Given I've been gone for a long time
*/
public function iveBeenGoneForLongTime(): void
{
$this->browserElement->resetSession();
}
/**
* @When I update my cart
* @When I try to update my cart
*/
public function iUpdateMyCart()
{
@ -590,6 +601,14 @@ final class CartContext implements Context
Assert::same($this->checkoutSubtotalElement->getProductQuantity($productName), $quantity);
}
/**
* @Then I should see an empty cart
*/
public function iShouldSeeAnEmptyCart(): void
{
Assert::true($this->summaryPage->isEmpty());
}
private function getPriceFromString(string $price): int
{
return (int) round((float) str_replace(['€', '£', '$'], '', $price) * 100, 2);

View file

@ -372,6 +372,20 @@ final class CheckoutCompleteContext implements Context
throw new UnexpectedPageException('It should not be possible to complete checkout complete step.');
}
/**
* @Then I should not be able to confirm order because the :shippingMethodName shipping method is not available
*/
public function iShouldNotBeAbleToConfirmOrderBecauseTheShippingMethodIsNotAvailable(string $shippingMethodName): void
{
Assert::same(
$this->completePage->getValidationErrors(),
sprintf(
'The "%s" shipping method is not available. Please reselect your shipping method.',
$shippingMethodName,
),
);
}
/**
* @When /^I should see (product "[^"]+") with unit price ("[^"]+")$/
*/

View file

@ -25,7 +25,9 @@ use Sylius\Behat\Page\Shop\Checkout\CompletePageInterface;
use Sylius\Behat\Page\Shop\Checkout\SelectPaymentPageInterface;
use Sylius\Behat\Page\Shop\Checkout\SelectShippingPageInterface;
use Sylius\Behat\Service\Resolver\CurrentPageResolverInterface;
use Sylius\Behat\Service\SharedStorageInterface;
use Sylius\Component\Addressing\Model\CountryInterface;
use Sylius\Component\Core\Model\ShippingMethodInterface;
use Webmozart\Assert\Assert;
final class CheckoutContext implements Context
@ -41,6 +43,7 @@ final class CheckoutContext implements Context
private CheckoutAddressingContext $addressingContext,
private CheckoutShippingContext $shippingContext,
private CheckoutPaymentContext $paymentContext,
private SharedStorageInterface $sharedStorage,
) {
}
@ -99,6 +102,18 @@ final class CheckoutContext implements Context
$this->paymentContext->iCompleteThePaymentStep();
}
/**
* @Given I have proceeded through checkout process with :shippingMethod shipping method
*/
public function iHaveProceededThroughCheckoutProcessWithShippingMethod(ShippingMethodInterface $shippingMethod): void
{
$this->addressingContext->iProceedSelectingBillingCountry();
$this->shippingContext->iHaveProceededSelectingShippingMethod($shippingMethod->getName());
$this->paymentContext->iCompleteThePaymentStep();
$this->sharedStorage->set('shipping_method', $shippingMethod);
}
/**
* @When /^I proceed selecting ("[^"]+" as billing country) with "([^"]+)" method$/
*/

View file

@ -21,4 +21,9 @@ final class BrowserElement extends Element implements BrowserElementInterface
{
$this->getDriver()->back();
}
public function resetSession(): void
{
$this->getSession()->setCookie('MOCKSESSID');
}
}

View file

@ -16,4 +16,6 @@ namespace Sylius\Behat\Element;
interface BrowserElementInterface
{
public function goBack(): void;
public function resetSession(): void;
}

View file

@ -372,6 +372,7 @@
<argument type="service" id="sylius.behat.context.ui.shop.checkout.addressing" />
<argument type="service" id="sylius.behat.context.ui.shop.checkout.shipping" />
<argument type="service" id="sylius.behat.context.ui.shop.checkout.payment" />
<argument type="service" id="sylius.behat.shared_storage" />
</service>
<service id="sylius.behat.context.ui.shop.checkout.thank_you" class="Sylius\Behat\Context\Ui\Shop\Checkout\CheckoutThankYouContext">
@ -484,6 +485,7 @@
<argument type="service" id="Sylius\Behat\Element\Shop\CartWidgetElementInterface" />
<argument type="service" id="sylius.behat.notification_checker" />
<argument type="service" id="Sylius\Behat\Service\SessionManagerInterface"/>
<argument type="service" id="sylius.behat.element.browser" />
</service>
<service id="sylius.behat.context.ui.shop.contact" class="Sylius\Behat\Context\Ui\Shop\ContactContext">

View file

@ -32,6 +32,7 @@ default:
- sylius.behat.context.setup.zone
- Sylius\Behat\Context\Setup\CatalogPromotionContext
- sylius.behat.context.ui.browser
- sylius.behat.context.ui.channel
- sylius.behat.context.ui.shop.cart
- sylius.behat.context.ui.shop.currency

View file

@ -34,6 +34,7 @@
<service id="Sylius\Bundle\ApiBundle\Serializer\CommandDenormalizer">
<argument type="service" id="api_platform.serializer.normalizer.item" />
<argument type="service" id="api_platform.name_converter" />
<tag name="serializer.normalizer" />
</service>

View file

@ -14,18 +14,19 @@ declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\Serializer;
use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
use Symfony\Component\Serializer\Normalizer\ContextAwareDenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
/**
* @experimental
*/
/** @experimental */
final class CommandDenormalizer implements ContextAwareDenormalizerInterface
{
private const OBJECT_TO_POPULATE = 'object_to_populate';
public function __construct(private DenormalizerInterface $itemNormalizer)
{
public function __construct(
private DenormalizerInterface $itemNormalizer,
private NameConverterInterface $nameConverter,
) {
}
public function supportsDenormalization($data, $type, $format = null, array $context = []): bool
@ -39,23 +40,28 @@ final class CommandDenormalizer implements ContextAwareDenormalizerInterface
return $this->itemNormalizer->denormalize($data, $type, $format, $context);
}
$constructor = (new \ReflectionClass($context['input']['class']))->getConstructor();
$class = $context['input']['class'];
$constructor = (new \ReflectionClass($class))->getConstructor();
if (null !== $constructor) {
$this->assertConstructorArgumentsPresence($constructor, $data);
$this->assertConstructorArgumentsPresence($constructor, $class, $data);
}
return $this->itemNormalizer->denormalize($data, $type, $format, $context);
}
private function assertConstructorArgumentsPresence(\ReflectionMethod $constructor, $data): void
{
private function assertConstructorArgumentsPresence(
\ReflectionMethod $constructor,
string $class,
mixed $data
): void {
$parameters = $constructor->getParameters();
$missingFields = [];
foreach ($parameters as $parameter) {
if (!isset($data[$parameter->getName()]) && !($parameter->allowsNull() || $parameter->isDefaultValueAvailable())) {
$missingFields[] = $parameter->getName();
$name = $this->nameConverter->normalize($parameter->getName(), $class);
if (!isset($data[$name]) && !($parameter->allowsNull() || $parameter->isDefaultValueAvailable())) {
$missingFields[] = $name;
}
}

View file

@ -18,7 +18,28 @@ use Symfony\Component\Validator\Constraint;
/** @experimental */
final class OrderShippingMethodEligibility extends Constraint
{
public string $message = 'sylius.order.shipping_method_eligibility';
/**
* @param array<array-key, mixed> $options
*/
public function __construct(
array $options = [],
public string $message = 'sylius.order.shipping_method_eligibility',
public string $methodNotAvailableMessage = 'sylius.order.shipping_method_not_available',
mixed $groups = null,
mixed $payload = null,
) {
parent::__construct($options, $groups, $payload);
}
public function getMessage(): string
{
return $this->message;
}
public function getMethodNotAvailableMessage(): string
{
return $this->methodNotAvailableMessage;
}
public function validatedBy(): string
{

View file

@ -32,7 +32,7 @@ final class OrderShippingMethodEligibilityValidator extends ConstraintValidator
) {
}
public function validate($value, Constraint $constraint)
public function validate(mixed $value, Constraint $constraint)
{
Assert::isInstanceOf($value, OrderTokenValueAwareInterface::class);
@ -49,9 +49,18 @@ final class OrderShippingMethodEligibilityValidator extends ConstraintValidator
/** @var ShippingMethodInterface $shippingMethod */
$shippingMethod = $shipment->getMethod();
if (!$shippingMethod->isEnabled() || !$shippingMethod->getChannels()->contains($order->getChannel())) {
$this->context->addViolation(
$constraint->getMethodNotAvailableMessage(),
['%shippingMethodName%' => $shippingMethod->getName()],
);
continue;
}
if (!$this->eligibilityChecker->isEligible($shipment, $shippingMethod)) {
$this->context->addViolation(
$constraint->message,
$constraint->getMessage(),
['%shippingMethodName%' => $shippingMethod->getName()],
);
}

View file

@ -19,21 +19,29 @@ use Sylius\Bundle\ApiBundle\Command\Account\RegisterShopUser;
use Sylius\Bundle\ApiBundle\Command\Account\VerifyCustomerAccount;
use Sylius\Component\Core\Model\Customer;
use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
use Symfony\Component\Serializer\Normalizer\ContextAwareDenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
final class CommandDenormalizerSpec extends ObjectBehavior
{
function let(DenormalizerInterface $baseNormalizer): void
function let(DenormalizerInterface $baseNormalizer, NameConverterInterface $nameConverter): void
{
$this->beConstructedWith($baseNormalizer);
$this->beConstructedWith($baseNormalizer, $nameConverter);
}
function it_throws_exception_if_not_all_required_parameters_are_present_in_the_context(
DenormalizerInterface $baseNormalizer,
NameConverterInterface $nameConverter,
): void {
$baseNormalizer->denormalize(Argument::any())->shouldNotBeCalled();
$nameConverter->normalize('firstName', RegisterShopUser::class)->willReturn('firstName');
$nameConverter->normalize('lastName', RegisterShopUser::class)->willReturn('lastName');
$nameConverter->normalize('email', RegisterShopUser::class)->willReturn('email');
$nameConverter->normalize('password', RegisterShopUser::class)->willReturn('password');
$nameConverter->normalize('subscribedToNewsletter', RegisterShopUser::class)->willReturn('subscribedToNewsletter');
$this
->shouldThrow(new MissingConstructorArgumentsException(
'Request does not have the following required fields specified: firstName, lastName.',
@ -52,7 +60,14 @@ final class CommandDenormalizerSpec extends ObjectBehavior
function it_denormalizes_data_if_all_required_parameters_are_specified(
DenormalizerInterface $baseNormalizer,
NameConverterInterface $nameConverter,
): void {
$nameConverter->normalize('firstName', RegisterShopUser::class)->willReturn('firstName');
$nameConverter->normalize('lastName', RegisterShopUser::class)->willReturn('lastName');
$nameConverter->normalize('email', RegisterShopUser::class)->willReturn('email');
$nameConverter->normalize('password', RegisterShopUser::class)->willReturn('password');
$nameConverter->normalize('subscribedToNewsletter', RegisterShopUser::class)->willReturn('subscribedToNewsletter');
$baseNormalizer
->denormalize(
['firstName' => 'John', 'lastName' => 'Doe', 'email' => 'test@example.com', 'password' => 'pa$$word'],
@ -71,6 +86,34 @@ final class CommandDenormalizerSpec extends ObjectBehavior
)->shouldReturn(['key' => 'value']);
}
function it_denormalizes_data_if_all_required_parameters_are_specified_based_on_their_normalized_names(
DenormalizerInterface $baseNormalizer,
NameConverterInterface $nameConverter,
): void {
$baseNormalizer
->denormalize(
['first_name' => 'John', 'last_name' => 'Doe', 'email_address' => 'test@example.com', 'pass' => 'pa$$word'],
Customer::class,
null,
['input' => ['class' => RegisterShopUser::class]],
)
->willReturn(['key' => 'value'])
;
$nameConverter->normalize('firstName', RegisterShopUser::class)->willReturn('first_name');
$nameConverter->normalize('lastName', RegisterShopUser::class)->willReturn('last_name');
$nameConverter->normalize('email', RegisterShopUser::class)->willReturn('email_address');
$nameConverter->normalize('password', RegisterShopUser::class)->willReturn('pass');
$nameConverter->normalize('subscribedToNewsletter', RegisterShopUser::class)->willReturn('hasNewsletter');
$this->denormalize(
['first_name' => 'John', 'last_name' => 'Doe', 'email_address' => 'test@example.com', 'pass' => 'pa$$word'],
Customer::class,
null,
['input' => ['class' => RegisterShopUser::class]],
)->shouldReturn(['key' => 'value']);
}
function it_does_not_check_parameters_if_there_is_an_object_to_populate(
DenormalizerInterface $baseNormalizer,
): void {

View file

@ -14,10 +14,12 @@ declare(strict_types=1);
namespace spec\Sylius\Bundle\ApiBundle\Validator\Constraints;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use PhpSpec\ObjectBehavior;
use Sylius\Bundle\ApiBundle\Command\Checkout\CompleteOrder;
use Sylius\Bundle\ApiBundle\Command\OrderTokenValueAwareInterface;
use Sylius\Bundle\ApiBundle\Validator\Constraints\OrderShippingMethodEligibility;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\ShipmentInterface;
use Sylius\Component\Core\Model\ShippingMethodInterface;
@ -87,12 +89,102 @@ final class OrderShippingMethodEligibilityValidatorSpec extends ObjectBehavior
;
}
function it_adds_a_violation_for_every_not_available_shipping_method_attached_to_the_order(
OrderRepositoryInterface $orderRepository,
ExecutionContextInterface $context,
OrderTokenValueAwareInterface $value,
ChannelInterface $channel,
OrderInterface $order,
ShipmentInterface $shipmentOne,
ShipmentInterface $shipmentTwo,
ShippingMethodInterface $shippingMethodOne,
ShippingMethodInterface $shippingMethodTwo,
Collection $channelsCollectionOne,
Collection $channelsCollectionTwo,
): void {
$this->initialize($context);
$value->getOrderTokenValue()->willReturn('ORDERTOKENVALUE');
$orderRepository->findOneBy(['tokenValue' => 'ORDERTOKENVALUE'])->willReturn($order);
$order->getShipments()->willReturn(new ArrayCollection([$shipmentOne->getWrappedObject(), $shipmentTwo->getWrappedObject()]));
$order->getChannel()->willReturn($channel);
$shipmentOne->getMethod()->willReturn($shippingMethodOne);
$shipmentTwo->getMethod()->willReturn($shippingMethodTwo);
$shippingMethodOne->isEnabled()->willReturn(false);
$shippingMethodTwo->isEnabled()->willReturn(true);
$shippingMethodOne->getChannels()->willReturn($channelsCollectionOne);
$shippingMethodTwo->getChannels()->willReturn($channelsCollectionTwo);
$shippingMethodOne->getName()->willReturn('Shipping method one');
$shippingMethodTwo->getName()->willReturn('Shipping method two');
$channelsCollectionOne->contains($channel)->willReturn(true);
$channelsCollectionTwo->contains($channel)->willReturn(false);
$context->addViolation('sylius.order.shipping_method_not_available', ['%shippingMethodName%' => 'Shipping method one'])->shouldBeCalled();
$context->addViolation('sylius.order.shipping_method_not_available', ['%shippingMethodName%' => 'Shipping method two'])->shouldBeCalled();
$this->validate($value, new OrderShippingMethodEligibility());
}
function it_does_not_add_violation_if_all_shipping_methods_are_available(
OrderRepositoryInterface $orderRepository,
ShippingMethodEligibilityCheckerInterface $eligibilityChecker,
ExecutionContextInterface $context,
OrderTokenValueAwareInterface $value,
ChannelInterface $channel,
OrderInterface $order,
ShipmentInterface $shipmentOne,
ShipmentInterface $shipmentTwo,
ShippingMethodInterface $shippingMethodOne,
ShippingMethodInterface $shippingMethodTwo,
Collection $channelsCollectionOne,
Collection $channelsCollectionTwo,
): void {
$this->initialize($context);
$value->getOrderTokenValue()->willReturn('ORDERTOKENVALUE');
$orderRepository->findOneBy(['tokenValue' => 'ORDERTOKENVALUE'])->willReturn($order);
$order->getShipments()->willReturn(new ArrayCollection([$shipmentOne->getWrappedObject(), $shipmentTwo->getWrappedObject()]));
$order->getChannel()->willReturn($channel);
$shipmentOne->getMethod()->willReturn($shippingMethodOne);
$shipmentTwo->getMethod()->willReturn($shippingMethodTwo);
$shippingMethodOne->isEnabled()->willReturn(true);
$shippingMethodTwo->isEnabled()->willReturn(true);
$shippingMethodOne->getChannels()->willReturn($channelsCollectionOne);
$shippingMethodTwo->getChannels()->willReturn($channelsCollectionTwo);
$shippingMethodOne->getName()->willReturn('Shipping method one');
$shippingMethodTwo->getName()->willReturn('Shipping method two');
$channelsCollectionOne->contains($channel)->willReturn(true);
$channelsCollectionTwo->contains($channel)->willReturn(true);
$context->addViolation('sylius.order.shipping_method_not_available', ['%shippingMethodName%' => 'Shipping method one'])->shouldNotBeCalled();
$context->addViolation('sylius.order.shipping_method_not_available', ['%shippingMethodName%' => 'Shipping method two'])->shouldNotBeCalled();
$eligibilityChecker->isEligible($shipmentOne, $shippingMethodOne)->willReturn(true);
$eligibilityChecker->isEligible($shipmentTwo, $shippingMethodTwo)->willReturn(true);
$this->validate($value, new OrderShippingMethodEligibility());
}
function it_adds_violation_if_shipment_does_not_match_with_shipping_method(
OrderRepositoryInterface $orderRepository,
ShippingMethodEligibilityCheckerInterface $eligibilityChecker,
OrderInterface $order,
ShipmentInterface $shipment,
ShippingMethodInterface $shippingMethod,
Collection $channelsCollection,
ChannelInterface $channel,
ExecutionContextInterface $executionContext,
): void {
$this->initialize($executionContext);
@ -105,12 +197,17 @@ final class OrderShippingMethodEligibilityValidatorSpec extends ObjectBehavior
$orderRepository->findOneBy(['tokenValue' => 'token'])->willReturn($order);
$order->getShipments()->willReturn(new ArrayCollection([$shipment->getWrappedObject()]));
$order->getChannel()->willReturn($channel);
$shipment->getMethod()->willReturn($shippingMethod);
$eligibilityChecker->isEligible($shipment, $shippingMethod)->willReturn(false);
$shippingMethod->getName()->willReturn('InPost');
$shippingMethod->isEnabled()->willReturn(true);
$shippingMethod->getChannels()->willReturn($channelsCollection);
$channelsCollection->contains($channel)->willReturn(true);
$executionContext
->addViolation(
@ -129,6 +226,8 @@ final class OrderShippingMethodEligibilityValidatorSpec extends ObjectBehavior
OrderInterface $order,
ShipmentInterface $shipment,
ShippingMethodInterface $shippingMethod,
Collection $channelsCollection,
ChannelInterface $channel,
ExecutionContextInterface $executionContext,
): void {
$this->initialize($executionContext);
@ -141,12 +240,17 @@ final class OrderShippingMethodEligibilityValidatorSpec extends ObjectBehavior
$orderRepository->findOneBy(['tokenValue' => 'token'])->willReturn($order);
$order->getShipments()->willReturn(new ArrayCollection([$shipment->getWrappedObject()]));
$order->getChannel()->willReturn($channel);
$shipment->getMethod()->willReturn($shippingMethod);
$eligibilityChecker->isEligible($shipment, $shippingMethod)->willReturn(true);
$shippingMethod->getName()->willReturn('InPost');
$shippingMethod->isEnabled()->willReturn(true);
$shippingMethod->getChannels()->willReturn($channelsCollection);
$channelsCollection->contains($channel)->willReturn(true);
$executionContext
->addViolation(

View file

@ -64,7 +64,6 @@
<service id="sylius.validator.product_integrity" class="Sylius\Bundle\CoreBundle\Validator\Constraints\OrderProductEligibilityValidator">
<tag name="validator.constraint_validator" alias="sylius_order_product_eligibility_validator" />
</service>
<service id="sylius.validator.channel_default_locale_enabled" class="Sylius\Bundle\CoreBundle\Validator\Constraints\ChannelDefaultLocaleEnabledValidator">
<tag name="validator.constraint_validator" alias="sylius_channel_default_locale_enabled" />
</service>

View file

@ -77,6 +77,7 @@ sylius:
payment_method_eligibility: 'This payment method %paymentMethodName% has been disabled. Please reselect your payment method.'
product_eligibility: 'This product %productName% has been disabled.'
shipping_method_eligibility: 'Product does not fit requirements for %shippingMethodName% shipping method. Please reselect your shipping method.'
shipping_method_not_available: 'The "%shippingMethodName%" shipping method is not available. Please reselect your shipping method.'
locale:
enabled:
cannot_disable_base: The base locale cannot be disabled.

View file

@ -17,7 +17,28 @@ use Symfony\Component\Validator\Constraint;
final class OrderShippingMethodEligibility extends Constraint
{
public string $message = 'sylius.order.shipping_method_eligibility';
/**
* @param array<array-key, mixed> $options
*/
public function __construct(
array $options = [],
public string $message = 'sylius.order.shipping_method_eligibility',
public string $methodNotAvailableMessage = 'sylius.order.shipping_method_not_available',
mixed $groups = null,
mixed $payload = null,
) {
parent::__construct($options, $groups, $payload);
}
public function getMessage(): string
{
return $this->message;
}
public function getMethodNotAvailableMessage(): string
{
return $this->methodNotAvailableMessage;
}
public function validatedBy(): string
{

View file

@ -14,6 +14,7 @@ declare(strict_types=1);
namespace Sylius\Bundle\CoreBundle\Validator\Constraints;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\ShippingMethodInterface;
use Sylius\Component\Shipping\Checker\Eligibility\ShippingMethodEligibilityCheckerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
@ -28,7 +29,7 @@ final class OrderShippingMethodEligibilityValidator extends ConstraintValidator
/**
* @throws \InvalidArgumentException
*/
public function validate($value, Constraint $constraint): void
public function validate(mixed $value, Constraint $constraint): void
{
/** @var OrderInterface $value */
Assert::isInstanceOf($value, OrderInterface::class);
@ -42,9 +43,21 @@ final class OrderShippingMethodEligibilityValidator extends ConstraintValidator
}
foreach ($shipments as $shipment) {
if (!$this->methodEligibilityChecker->isEligible($shipment, $shipment->getMethod())) {
/** @var ShippingMethodInterface $shippingMethod */
$shippingMethod = $shipment->getMethod();
if (!$shippingMethod->isEnabled() || !$shippingMethod->getChannels()->contains($value->getChannel())) {
$this->context->addViolation(
$constraint->message,
$constraint->getMethodNotAvailableMessage(),
['%shippingMethodName%' => $shippingMethod->getName()],
);
continue;
}
if (!$this->methodEligibilityChecker->isEligible($shipment, $shippingMethod)) {
$this->context->addViolation(
$constraint->getMessage(),
['%shippingMethodName%' => $shipment->getMethod()->getName()],
);
}

View file

@ -0,0 +1,209 @@
<?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 spec\Sylius\Bundle\CoreBundle\Validator\Constraints;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use PhpSpec\ObjectBehavior;
use Sylius\Bundle\CoreBundle\Validator\Constraints\OrderShippingMethodEligibility;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\ShipmentInterface;
use Sylius\Component\Core\Model\ShippingMethodInterface;
use Sylius\Component\Shipping\Checker\Eligibility\ShippingMethodEligibilityCheckerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidatorInterface;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
final class OrderShippingMethodEligibilityValidatorSpec extends ObjectBehavior
{
function let(ShippingMethodEligibilityCheckerInterface $eligibilityChecker): void
{
$this->beConstructedWith($eligibilityChecker);
}
function it_is_a_constraint_validator(): void
{
$this->shouldImplement(ConstraintValidatorInterface::class);
}
function it_throws_an_exception_if_constraint_does_not_extend_order_token_value_aware_interface(): void
{
$this
->shouldThrow(\InvalidArgumentException::class)
->during('validate', ['', new class() extends Constraint {
}])
;
}
function it_throws_an_exception_if_constraint_is_not_type_of_order_shipping_method_eligibility(Constraint $constraint): void
{
$this
->shouldThrow(\InvalidArgumentException::class)
->during('validate', ['', $constraint])
;
}
function it_adds_a_violation_for_every_not_available_shipping_method_attached_to_the_order(
ExecutionContextInterface $context,
ChannelInterface $channel,
OrderInterface $value,
ShipmentInterface $shipmentOne,
ShipmentInterface $shipmentTwo,
ShippingMethodInterface $shippingMethodOne,
ShippingMethodInterface $shippingMethodTwo,
Collection $channelsCollectionOne,
Collection $channelsCollectionTwo,
): void {
$this->initialize($context);
$value->getShipments()->willReturn(new ArrayCollection([$shipmentOne->getWrappedObject(), $shipmentTwo->getWrappedObject()]));
$value->getChannel()->willReturn($channel);
$shipmentOne->getMethod()->willReturn($shippingMethodOne);
$shipmentTwo->getMethod()->willReturn($shippingMethodTwo);
$shippingMethodOne->isEnabled()->willReturn(false);
$shippingMethodTwo->isEnabled()->willReturn(true);
$shippingMethodOne->getChannels()->willReturn($channelsCollectionOne);
$shippingMethodTwo->getChannels()->willReturn($channelsCollectionTwo);
$shippingMethodOne->getName()->willReturn('Shipping method one');
$shippingMethodTwo->getName()->willReturn('Shipping method two');
$channelsCollectionOne->contains($channel)->willReturn(true);
$channelsCollectionTwo->contains($channel)->willReturn(false);
$context->addViolation('sylius.order.shipping_method_not_available', ['%shippingMethodName%' => 'Shipping method one'])->shouldBeCalled();
$context->addViolation('sylius.order.shipping_method_not_available', ['%shippingMethodName%' => 'Shipping method two'])->shouldBeCalled();
$this->validate($value, new OrderShippingMethodEligibility());
}
function it_does_not_add_violation_if_all_shipping_methods_are_available(
ShippingMethodEligibilityCheckerInterface $eligibilityChecker,
ExecutionContextInterface $context,
ChannelInterface $channel,
OrderInterface $value,
ShipmentInterface $shipmentOne,
ShipmentInterface $shipmentTwo,
ShippingMethodInterface $shippingMethodOne,
ShippingMethodInterface $shippingMethodTwo,
Collection $channelsCollectionOne,
Collection $channelsCollectionTwo,
): void {
$this->initialize($context);
$value->getShipments()->willReturn(new ArrayCollection([$shipmentOne->getWrappedObject(), $shipmentTwo->getWrappedObject()]));
$value->getChannel()->willReturn($channel);
$shipmentOne->getMethod()->willReturn($shippingMethodOne);
$shipmentTwo->getMethod()->willReturn($shippingMethodTwo);
$shippingMethodOne->isEnabled()->willReturn(true);
$shippingMethodTwo->isEnabled()->willReturn(true);
$shippingMethodOne->getChannels()->willReturn($channelsCollectionOne);
$shippingMethodTwo->getChannels()->willReturn($channelsCollectionTwo);
$shippingMethodOne->getName()->willReturn('Shipping method one');
$shippingMethodTwo->getName()->willReturn('Shipping method two');
$channelsCollectionOne->contains($channel)->willReturn(true);
$channelsCollectionTwo->contains($channel)->willReturn(true);
$context->addViolation('sylius.order.shipping_method_not_available', ['%shippingMethodName%' => 'Shipping method one'])->shouldNotBeCalled();
$context->addViolation('sylius.order.shipping_method_not_available', ['%shippingMethodName%' => 'Shipping method two'])->shouldNotBeCalled();
$eligibilityChecker->isEligible($shipmentOne, $shippingMethodOne)->willReturn(true);
$eligibilityChecker->isEligible($shipmentTwo, $shippingMethodTwo)->willReturn(true);
$this->validate($value, new OrderShippingMethodEligibility());
}
function it_adds_violation_if_shipment_does_not_match_with_shipping_method(
ShippingMethodEligibilityCheckerInterface $eligibilityChecker,
OrderInterface $value,
ShipmentInterface $shipment,
ShippingMethodInterface $shippingMethod,
Collection $channelsCollection,
ChannelInterface $channel,
ExecutionContextInterface $executionContext,
): void {
$this->initialize($executionContext);
$constraint = new OrderShippingMethodEligibility();
$value->getShipments()->willReturn(new ArrayCollection([$shipment->getWrappedObject()]));
$value->getChannel()->willReturn($channel);
$shipment->getMethod()->willReturn($shippingMethod);
$eligibilityChecker->isEligible($shipment, $shippingMethod)->willReturn(false);
$shippingMethod->getName()->willReturn('InPost');
$shippingMethod->isEnabled()->willReturn(true);
$shippingMethod->getChannels()->willReturn($channelsCollection);
$channelsCollection->contains($channel)->willReturn(true);
$executionContext
->addViolation(
'sylius.order.shipping_method_eligibility',
['%shippingMethodName%' => 'InPost'],
)
->shouldBeCalled()
;
$this->validate($value, $constraint);
}
function it_does_not_add_a_violation_if_shipment_matches_with_shipping_method(
ShippingMethodEligibilityCheckerInterface $eligibilityChecker,
OrderInterface $value,
ShipmentInterface $shipment,
ShippingMethodInterface $shippingMethod,
Collection $channelsCollection,
ChannelInterface $channel,
ExecutionContextInterface $executionContext,
): void {
$this->initialize($executionContext);
$constraint = new OrderShippingMethodEligibility();
$value->getShipments()->willReturn(new ArrayCollection([$shipment->getWrappedObject()]));
$value->getChannel()->willReturn($channel);
$shipment->getMethod()->willReturn($shippingMethod);
$eligibilityChecker->isEligible($shipment, $shippingMethod)->willReturn(true);
$shippingMethod->getName()->willReturn('InPost');
$shippingMethod->isEnabled()->willReturn(true);
$shippingMethod->getChannels()->willReturn($channelsCollection);
$channelsCollection->contains($channel)->willReturn(true);
$executionContext
->addViolation(
'sylius.order.shipping_method_eligibility',
['%shippingMethodName%' => 'InPost'],
)
->shouldNotBeCalled()
;
$this->validate($value, $constraint);
}
}

View file

@ -152,8 +152,12 @@ class OrderController extends ResourceController
private function resetChangesOnCart(OrderInterface $cart): void
{
if (!$this->manager->contains($cart)) {
return;
}
$this->manager->refresh($cart);
foreach ($cart->getItems() as $item) {
foreach($cart->getItems() as $item) {
$this->manager->refresh($item);
}
}