Merge branch '1.12' into 1.13

* 1.12:
  [PostgreSQL] Handle DriverException gracefully with 22P02 SQLState
  [Orders][Shop] Improve order item quantity change validation
  [Unit][Orders] Add tests for items quantity change
  [Orders][Shop] Add checkout completion validation
  [Unit][Orders] Add tests for checkout completion
  [Unit][Orders] Add tests for preventing from order completion
  [Orders][Shop] Add shipment validation when trying to assign shipping method
  [Unit][Orders] Add test for validating shipmentId when trying to assign shipping method
  [Unit][Utils] Introduce ContentType helper class
  [Orders] Add missing order payment configuration endpoint validation
  [Unit][Orders][Shop] Add test for validation invalid payment param
  [Unit][Orders][Shop] Add test for invalid uri param validation
  [Unit][Orders][Shop] Extract helper methods
This commit is contained in:
Grzegorz Sadowski 2023-07-05 10:07:11 +02:00
commit 1d46d40031
No known key found for this signature in database
GPG key ID: EF87FF4E6E3BD364
29 changed files with 1227 additions and 148 deletions

View file

@ -13,12 +13,12 @@ declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\Controller\Payment;
use Sylius\Bundle\ApiBundle\Exception\PaymentNotFoundException;
use Sylius\Bundle\ApiBundle\Provider\CompositePaymentConfigurationProviderInterface;
use Sylius\Component\Core\Model\PaymentInterface;
use Sylius\Component\Core\Repository\PaymentRepositoryInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Webmozart\Assert\Assert;
/** @experimental */
final class GetPaymentConfiguration
@ -37,7 +37,9 @@ final class GetPaymentConfiguration
$request->attributes->get('tokenValue'),
);
Assert::notNull($payment);
if ($payment === null) {
throw new PaymentNotFoundException();
}
return new JsonResponse($this->compositePaymentConfigurationProvider->provide($payment));
}

View file

@ -0,0 +1,39 @@
<?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\Bundle\ApiBundle\EventListener;
use Doctrine\DBAL\Exception\DriverException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
final class PostgreSQLDriverExceptionListener
{
public function onKernelException(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
if ($exception instanceof DriverException && $exception->getSQLState() === '22P02') {
$event
->setResponse(
new JSONResponse(
['message' => 'Invalid URL parameter for type integer'],
$event->getRequest()->getMethod() === Request::METHOD_GET ? Response::HTTP_NOT_FOUND : Response::HTTP_UNPROCESSABLE_ENTITY,
),
)
;
}
}
}

View file

@ -0,0 +1,25 @@
<?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\Bundle\ApiBundle\Exception;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/** @experimental */
final class PaymentNotFoundException extends NotFoundHttpException
{
public function __construct()
{
parent::__construct('Payment not found.');
}
}

View file

@ -126,6 +126,10 @@
<tag name="kernel.event_listener" event="lexik_jwt_authentication.on_authentication_success" method="onAuthenticationSuccessResponse" />
</service>
<service id="sylius.listener.api_postgresql_driver_exception_listener" class="Sylius\Bundle\ApiBundle\EventListener\PostgreSQLDriverExceptionListener">
<tag name="kernel.event_listener" event="kernel.exception" method="onKernelException" />
</service>
<service id="Sylius\Bundle\ApiBundle\Converter\IriToIdentifierConverterInterface" class="Sylius\Bundle\ApiBundle\Converter\IriToIdentifierConverter">
<argument type="service" id="api_platform.router" />
<argument type="service" id="api_platform.identifier.converter" on-invalid="ignore" />

View file

@ -46,6 +46,12 @@
<tag name="validator.constraint_validator" alias="sylius_api_validator_order_shipping_method_eligibility" />
</service>
<service id="Sylius\Bundle\ApiBundle\Validator\Constraints\CheckoutCompletionValidator">
<argument type="service" id="sylius.repository.order" />
<argument type="service" id="sm.factory" />
<tag name="validator.constraint_validator" alias="sylius_api_validator_checkout_completion" />
</service>
<service id="Sylius\Bundle\ApiBundle\Validator\Constraints\ChosenShippingMethodEligibilityValidator">
<argument type="service" id="sylius.repository.shipment" />
<argument type="service" id="sylius.repository.shipping_method" />

View file

@ -16,6 +16,11 @@
xsi:schemaLocation="http://symfony.com/schema/dic/constraint-mapping http://symfony.com/schema/dic/services/constraint-mapping-1.0.xsd">
<class name="Sylius\Bundle\ApiBundle\Command\Checkout\CompleteOrder">
<constraint name="Sylius\Bundle\ApiBundle\Validator\Constraints\CheckoutCompletion">
<option name="groups">
<value>sylius_checkout_complete</value>
</option>
</constraint>
<constraint name="Sylius\Bundle\ApiBundle\Validator\Constraints\OrderNotEmpty">
<option name="groups">
<value>sylius_checkout_complete</value>

View file

@ -24,6 +24,7 @@ sylius:
not_sufficient: 'The product variant with %productVariantCode% code does not have sufficient stock.'
product_variant_with_name_not_sufficient: 'The product variant with %productVariantName% name does not have sufficient stock.'
shipment:
not_found: 'The shipment does not exist.'
shipped: 'You cannot ship a shipment that was shipped before.'
shipping_method:
not_found: 'The shipping method with %code% code does not exist.'

View file

@ -0,0 +1,100 @@
<?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\Bundle\ApiBundle\Tests\Listener;
use Doctrine\DBAL\Exception\DriverException;
use PHPUnit\Framework\TestCase;
use Sylius\Bundle\ApiBundle\EventListener\PostgreSQLDriverExceptionListener;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
class PostgreSQLDriverExceptionListenerTest extends TestCase
{
/** @test */
public function it_does_nothing_if_exception_is_not_a_driver_exception(): void
{
$event = $this->createExceptionEvent();
(new PostgreSQLDriverExceptionListener())->onKernelException($event);
$this->assertNull($event->getResponse());
}
/** @test */
public function it_does_nothing_if_exception_is_a_driver_exception_but_sql_state_is_not_22P02(): void
{
$event = $this->createDriverExceptionEvent('some_other_sql_state', Request::METHOD_PATCH);
(new PostgreSQLDriverExceptionListener())->onKernelException($event);
$this->assertNull($event->getResponse());
}
/** @test */
public function it_responses_with_not_found_status_if_exception_is_a_driver_exception_with_sql_state_22P02_and_request_method_is_get(): void
{
$event = $this->createDriverExceptionEvent('22P02', Request::METHOD_GET);
(new PostgreSQLDriverExceptionListener())->onKernelException($event);
$response = $event->getResponse();
$this->assertNotNull($response);
$this->assertInstanceOf(Response::class, $response);
$this->assertEquals(Response::HTTP_NOT_FOUND, $response->getStatusCode());
$this->assertEquals(json_encode(['message' => 'Invalid URL parameter for type integer']), $response->getContent());
}
/** @test */
public function it_responses_with_unprocessable_entity_status_if_exception_is_a_driver_exception_with_sql_state_22P02_and_request_method_is_not_get(): void
{
$event = $this->createDriverExceptionEvent('22P02', Request::METHOD_PATCH);
(new PostgreSQLDriverExceptionListener())->onKernelException($event);
$response = $event->getResponse();
$this->assertNotNull($response);
$this->assertInstanceOf(Response::class, $response);
$this->assertEquals(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
$this->assertEquals(json_encode(['message' => 'Invalid URL parameter for type integer']), $response->getContent());
}
private function createExceptionEvent(): ExceptionEvent
{
$kernel = $this->createMock(HttpKernelInterface::class);
$exception = new \Exception();
return new ExceptionEvent($kernel, new Request(), HttpKernelInterface::MAIN_REQUEST, $exception);
}
private function createDriverExceptionEvent(string $sqlState, string $requestMethod): ExceptionEvent
{
$kernel = $this->createMock(HttpKernelInterface::class);
$exception = $this->getMockBuilder(DriverException::class)
->disableOriginalConstructor()
->onlyMethods(['getSQLState'])
->getMock();
$request = new Request();
$request->setMethod($requestMethod);
$exception->method('getSQLState')->willReturn($sqlState);
return new ExceptionEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $exception);
}
}

View file

@ -14,12 +14,13 @@ declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\Validator\Constraints;
use Sylius\Bundle\ApiBundle\Command\Cart\ChangeItemQuantityInCart;
use Sylius\Bundle\ApiBundle\Exception\OrderItemNotFoundException;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\OrderItemInterface;
use Sylius\Component\Core\Model\ProductInterface;
use Sylius\Component\Core\Repository\OrderItemRepositoryInterface;
use Sylius\Component\Core\Repository\OrderRepositoryInterface;
use Sylius\Component\Inventory\Checker\AvailabilityCheckerInterface;
use Sylius\Component\Resource\Repository\RepositoryInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Webmozart\Assert\Assert;
@ -27,7 +28,7 @@ use Webmozart\Assert\Assert;
final class ChangedItemQuantityInCartValidator extends ConstraintValidator
{
public function __construct(
private RepositoryInterface $orderItemRepository,
private OrderItemRepositoryInterface $orderItemRepository,
private OrderRepositoryInterface $orderRepository,
private AvailabilityCheckerInterface $availabilityChecker,
) {
@ -45,8 +46,14 @@ final class ChangedItemQuantityInCartValidator extends ConstraintValidator
}
/** @var OrderItemInterface|null $orderItem */
$orderItem = $this->orderItemRepository->findOneBy(['id' => $value->orderItemId]);
Assert::notNull($orderItem);
$orderItem = $this->orderItemRepository->findOneByIdAndCartTokenValue(
$value->orderItemId,
$value->orderTokenValue,
);
if ($orderItem === null) {
throw new OrderItemNotFoundException();
}
$productVariant = $orderItem->getVariant();

View file

@ -0,0 +1,32 @@
<?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\Bundle\ApiBundle\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
/** @experimental */
class CheckoutCompletion extends Constraint
{
public string $message = 'sylius.order.invalid_state_transition';
public function validatedBy(): string
{
return 'sylius_api_validator_checkout_completion';
}
public function getTargets(): string
{
return self::CLASS_CONSTRAINT;
}
}

View file

@ -0,0 +1,58 @@
<?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\Bundle\ApiBundle\Validator\Constraints;
use SM\Factory\FactoryInterface;
use Sylius\Bundle\ApiBundle\Command\OrderTokenValueAwareInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\OrderCheckoutTransitions;
use Sylius\Component\Core\Repository\OrderRepositoryInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Webmozart\Assert\Assert;
/** @experimental */
class CheckoutCompletionValidator extends ConstraintValidator
{
public function __construct(
private OrderRepositoryInterface $orderRepository,
private FactoryInterface $stateMachineFactory,
) {
}
/** @param OrderTokenValueAwareInterface $value */
public function validate($value, Constraint $constraint): void
{
Assert::isInstanceOf($value, OrderTokenValueAwareInterface::class);
/** @var CheckoutCompletion $constraint */
Assert::isInstanceOf($constraint, CheckoutCompletion::class);
$order = $this->orderRepository->findOneBy(['tokenValue' => $value->getOrderTokenValue()]);
/** @var OrderInterface $order */
Assert::isInstanceOf($order, OrderInterface::class);
$stateMachine = $this->stateMachineFactory->get($order, OrderCheckoutTransitions::GRAPH);
if ($stateMachine->can(OrderCheckoutTransitions::TRANSITION_COMPLETE)) {
return;
}
$this->context->addViolation($constraint->message, [
'%currentState%' => $stateMachine->getState(),
'%possibleTransitions%' => implode(', ', $stateMachine->getPossibleTransitions()),
]);
}
}

View file

@ -22,6 +22,8 @@ final class ChosenShippingMethodEligibility extends Constraint
public string $notFoundMessage = 'sylius.shipping_method.not_found';
public string $shipmentNotFoundMessage = 'sylius.shipment.not_found';
/** @var string */
public $shippingAddressNotFoundMessage = 'sylius.shipping_method.shipping_address_not_found';

View file

@ -51,7 +51,12 @@ final class ChosenShippingMethodEligibilityValidator extends ConstraintValidator
/** @var ShipmentInterface|null $shipment */
$shipment = $this->shipmentRepository->find($value->shipmentId);
Assert::notNull($shipment);
if (null === $shipment) {
$this->context->addViolation($constraint->shipmentNotFoundMessage);
return;
}
$order = $shipment->getOrder();

View file

@ -17,6 +17,7 @@ use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Sylius\Bundle\ApiBundle\Command\Cart\ChangeItemQuantityInCart;
use Sylius\Bundle\ApiBundle\Command\Checkout\CompleteOrder;
use Sylius\Bundle\ApiBundle\Exception\OrderItemNotFoundException;
use Sylius\Bundle\ApiBundle\Validator\Constraints\AddingEligibleProductVariantToCart;
use Sylius\Bundle\ApiBundle\Validator\Constraints\ChangedItemQuantityInCart;
use Sylius\Component\Core\Model\ChannelInterface;
@ -24,9 +25,9 @@ use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\OrderItemInterface;
use Sylius\Component\Core\Model\ProductInterface;
use Sylius\Component\Core\Model\ProductVariantInterface;
use Sylius\Component\Core\Repository\OrderItemRepositoryInterface;
use Sylius\Component\Core\Repository\OrderRepositoryInterface;
use Sylius\Component\Inventory\Checker\AvailabilityCheckerInterface;
use Sylius\Component\Resource\Repository\RepositoryInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidatorInterface;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
@ -34,7 +35,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
final class ChangedItemQuantityInCartValidatorSpec extends ObjectBehavior
{
function let(
RepositoryInterface $orderItemRepository,
OrderItemRepositoryInterface $orderItemRepository,
OrderRepositoryInterface $orderRepository,
AvailabilityCheckerInterface $availabilityChecker,
): void {
@ -66,14 +67,31 @@ final class ChangedItemQuantityInCartValidatorSpec extends ObjectBehavior
;
}
function it_throws_an_exception_if_order_item_does_not_exist(
OrderItemRepositoryInterface $orderItemRepository,
ExecutionContextInterface $executionContext,
): void {
$this->initialize($executionContext);
$orderItemRepository->findOneByIdAndCartTokenValue('11', 'token')->willReturn(null);
$this
->shouldThrow(OrderItemNotFoundException::class)
->during('validate', [
ChangeItemQuantityInCart::createFromData('token', '11', 2),
new ChangedItemQuantityInCart(),
])
;
}
function it_adds_violation_if_product_variant_does_not_exist(
RepositoryInterface $orderItemRepository,
OrderItemRepositoryInterface $orderItemRepository,
ExecutionContextInterface $executionContext,
OrderItemInterface $orderItem,
): void {
$this->initialize($executionContext);
$orderItemRepository->findOneBy(['id' => '11'])->willReturn($orderItem);
$orderItemRepository->findOneByIdAndCartTokenValue('11', 'token')->willReturn($orderItem);
$orderItem->getVariant()->willReturn(null);
$orderItem->getVariantName()->willReturn('MacPro');
@ -89,7 +107,7 @@ final class ChangedItemQuantityInCartValidatorSpec extends ObjectBehavior
}
function it_adds_violation_if_product_is_disabled(
RepositoryInterface $orderItemRepository,
OrderItemRepositoryInterface $orderItemRepository,
ExecutionContextInterface $executionContext,
OrderItemInterface $orderItem,
ProductVariantInterface $productVariant,
@ -97,7 +115,7 @@ final class ChangedItemQuantityInCartValidatorSpec extends ObjectBehavior
): void {
$this->initialize($executionContext);
$orderItemRepository->findOneBy(['id' => '11'])->willReturn($orderItem);
$orderItemRepository->findOneByIdAndCartTokenValue('11', 'token')->willReturn($orderItem);
$orderItem->getVariant()->willReturn($productVariant);
$orderItem->getVariantName()->willReturn('Variant Name');
@ -119,7 +137,7 @@ final class ChangedItemQuantityInCartValidatorSpec extends ObjectBehavior
}
function it_adds_violation_if_product_variant_is_disabled(
RepositoryInterface $orderItemRepository,
OrderItemRepositoryInterface $orderItemRepository,
ExecutionContextInterface $executionContext,
OrderItemInterface $orderItem,
ProductVariantInterface $productVariant,
@ -127,7 +145,7 @@ final class ChangedItemQuantityInCartValidatorSpec extends ObjectBehavior
): void {
$this->initialize($executionContext);
$orderItemRepository->findOneBy(['id' => '11'])->willReturn($orderItem);
$orderItemRepository->findOneByIdAndCartTokenValue('11', 'token')->willReturn($orderItem);
$orderItem->getVariant()->willReturn($productVariant);
$orderItem->getVariantName()->willReturn('Variant Name');
@ -151,7 +169,7 @@ final class ChangedItemQuantityInCartValidatorSpec extends ObjectBehavior
}
function it_adds_violation_if_product_variant_stock_is_not_sufficient(
RepositoryInterface $orderItemRepository,
OrderItemRepositoryInterface $orderItemRepository,
ExecutionContextInterface $executionContext,
OrderItemInterface $orderItem,
ProductVariantInterface $productVariant,
@ -160,7 +178,7 @@ final class ChangedItemQuantityInCartValidatorSpec extends ObjectBehavior
): void {
$this->initialize($executionContext);
$orderItemRepository->findOneBy(['id' => '11'])->willReturn($orderItem);
$orderItemRepository->findOneByIdAndCartTokenValue('11', 'token')->willReturn($orderItem);
$orderItem->getVariant()->willReturn($productVariant);
$orderItem->getVariantName()->willReturn('Variant Name');
@ -186,7 +204,7 @@ final class ChangedItemQuantityInCartValidatorSpec extends ObjectBehavior
}
function it_adds_violation_if_product_is_not_available_in_channel(
RepositoryInterface $orderItemRepository,
OrderItemRepositoryInterface $orderItemRepository,
OrderRepositoryInterface $orderRepository,
ExecutionContextInterface $executionContext,
OrderItemInterface $orderItem,
@ -198,7 +216,7 @@ final class ChangedItemQuantityInCartValidatorSpec extends ObjectBehavior
): void {
$this->initialize($executionContext);
$orderItemRepository->findOneBy(['id' => '11'])->willReturn($orderItem);
$orderItemRepository->findOneByIdAndCartTokenValue('11', 'token')->willReturn($orderItem);
$orderItem->getVariant()->willReturn($productVariant);
$orderItem->getVariantName()->willReturn('Variant Name');
@ -214,7 +232,7 @@ final class ChangedItemQuantityInCartValidatorSpec extends ObjectBehavior
$product->getName()->willReturn('PRODUCT NAME');
$orderRepository->findCartByTokenValue('TOKEN')->willReturn($cart);
$orderRepository->findCartByTokenValue('token')->willReturn($cart);
$cart->getChannel()->willReturn($channel);
$product->hasChannel($channel)->willReturn(false);
@ -225,13 +243,13 @@ final class ChangedItemQuantityInCartValidatorSpec extends ObjectBehavior
;
$this->validate(
ChangeItemQuantityInCart::createFromData('TOKEN', '11', 2),
ChangeItemQuantityInCart::createFromData('token', '11', 2),
new ChangedItemQuantityInCart(),
);
}
function it_does_nothing_if_product_and_variant_are_enabled_and_available_in_channel(
RepositoryInterface $orderItemRepository,
OrderItemRepositoryInterface $orderItemRepository,
OrderRepositoryInterface $orderRepository,
ExecutionContextInterface $executionContext,
OrderItemInterface $orderItem,
@ -243,7 +261,7 @@ final class ChangedItemQuantityInCartValidatorSpec extends ObjectBehavior
): void {
$this->initialize($executionContext);
$orderItemRepository->findOneBy(['id' => '11'])->willReturn($orderItem);
$orderItemRepository->findOneByIdAndCartTokenValue('11', 'token')->willReturn($orderItem);
$orderItem->getVariant()->willReturn($productVariant);
$orderItem->getVariantName()->willReturn('Variant Name');
@ -259,7 +277,7 @@ final class ChangedItemQuantityInCartValidatorSpec extends ObjectBehavior
$product->getName()->willReturn('PRODUCT NAME');
$orderRepository->findCartByTokenValue('TOKEN')->willReturn($cart);
$orderRepository->findCartByTokenValue('token')->willReturn($cart);
$cart->getChannel()->willReturn($channel);
$product->hasChannel($channel)->willReturn(true);
@ -278,7 +296,7 @@ final class ChangedItemQuantityInCartValidatorSpec extends ObjectBehavior
;
$this->validate(
ChangeItemQuantityInCart::createFromData('TOKEN', '11', 2),
ChangeItemQuantityInCart::createFromData('token', '11', 2),
new ChangedItemQuantityInCart(),
);
}

View file

@ -0,0 +1,133 @@
<?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\ApiBundle\Validator\Constraints;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use SM\Factory\FactoryInterface;
use SM\StateMachine\StateMachineInterface;
use Sylius\Bundle\ApiBundle\Command\OrderTokenValueAwareInterface;
use Sylius\Bundle\ApiBundle\Validator\Constraints\CheckoutCompletion;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\OrderCheckoutTransitions;
use Sylius\Component\Core\Repository\OrderRepositoryInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidatorInterface;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
final class CheckoutCompletionValidatorSpec extends ObjectBehavior
{
function let(
OrderRepositoryInterface $orderRepository,
FactoryInterface $stateMachineFactory,
): void {
$this->beConstructedWith($orderRepository, $stateMachineFactory);
}
function it_is_a_constraint_validator(): void
{
$this->shouldImplement(ConstraintValidatorInterface::class);
}
function it_throws_an_exception_if_value_is_not_an_instance_of_order_token_value_aware_interface(
Constraint $constraint,
): void {
$this
->shouldThrow(\InvalidArgumentException::class)
->during('validate', ['', $constraint])
;
}
function it_throws_an_exception_if_constraint_is_not_an_instance_of_checkout_completion(
Constraint $constraint,
OrderTokenValueAwareInterface $orderTokenValueAware,
): void {
$this
->shouldThrow(\InvalidArgumentException::class)
->during('validate', [$orderTokenValueAware, $constraint])
;
}
function it_throws_an_exception_if_order_with_given_token_value_does_not_exist(
OrderRepositoryInterface $orderRepository,
Constraint $constraint,
OrderTokenValueAwareInterface $orderTokenValueAware,
): void {
$orderTokenValueAware->getOrderTokenValue()->willReturn('xxx');
$orderRepository->findOneBy(['tokenValue' => 'xxx'])->willReturn(null);
$this
->shouldThrow(\InvalidArgumentException::class)
->during('validate', [$orderTokenValueAware, $constraint])
;
}
function it_does_nothing_if_order_can_be_completed(
OrderRepositoryInterface $orderRepository,
FactoryInterface $stateMachineFactory,
ExecutionContextInterface $executionContext,
OrderInterface $order,
OrderTokenValueAwareInterface $orderTokenValueAware,
StateMachineInterface $stateMachine,
): void {
$this->initialize($executionContext);
$orderTokenValueAware->getOrderTokenValue()->willReturn('xxx');
$orderRepository->findOneBy(['tokenValue' => 'xxx'])->willReturn($order);
$stateMachineFactory->get($order, 'sylius_order_checkout')->willReturn($stateMachine);
$stateMachine->can(OrderCheckoutTransitions::TRANSITION_COMPLETE)->willReturn(true);
$stateMachine->getState()->shouldNotBeCalled();
$stateMachine->getPossibleTransitions()->shouldNotBeCalled();
$executionContext
->addViolation(Argument::cetera())
->shouldNotBeCalled()
;
$this->validate($orderTokenValueAware, new CheckoutCompletion());
}
function it_adds_violation_if_order_cannot_be_completed(
OrderRepositoryInterface $orderRepository,
FactoryInterface $stateMachineFactory,
ExecutionContextInterface $executionContext,
OrderInterface $order,
OrderTokenValueAwareInterface $orderTokenValueAware,
StateMachineInterface $stateMachine,
): void {
$this->initialize($executionContext);
$orderTokenValueAware->getOrderTokenValue()->willReturn('xxx');
$orderRepository->findOneBy(['tokenValue' => 'xxx'])->willReturn($order);
$stateMachineFactory->get($order, 'sylius_order_checkout')->willReturn($stateMachine);
$stateMachine->can(OrderCheckoutTransitions::TRANSITION_COMPLETE)->willReturn(false);
$stateMachine->getState()->willReturn('some_state_that_does_not_allow_to_complete_order');
$stateMachine->getPossibleTransitions()->willReturn(['some_possible_transition', 'another_possible_transition']);
$executionContext
->addViolation(
'sylius.order.invalid_state_transition',
[
'%currentState%' => 'some_state_that_does_not_allow_to_complete_order',
'%possibleTransitions%' => 'some_possible_transition, another_possible_transition',
],
)
->shouldBeCalled()
;
$this->validate($orderTokenValueAware, new CheckoutCompletion());
}
}

View file

@ -186,7 +186,7 @@ final class ChosenShippingMethodEligibilityValidatorSpec extends ObjectBehavior
$this->validate($command, new ChosenShippingMethodEligibility());
}
function it_throws_an_exception_if_given_shipmnent_is_null(
function it_adds_violation_if_order_shipment_is_not_found(
ShipmentRepositoryInterface $shipmentRepository,
ShippingMethodRepositoryInterface $shippingMethodRepository,
ExecutionContextInterface $executionContext,
@ -199,14 +199,20 @@ final class ChosenShippingMethodEligibilityValidatorSpec extends ObjectBehavior
$shippingMethodRepository->findOneBy(['code' => 'SHIPPING_METHOD_CODE'])->willReturn($shippingMethod);
$shipmentRepository->find('123')->willReturn(null);
$executionContext
->addViolation('sylius.shipping_method.not_available', Argument::any())
->shouldNotBeCalled()
;
$this
->shouldThrow(\InvalidArgumentException::class)
->during('validate', [$command, new ChosenShippingMethodEligibility()])
$executionContext
->addViolation('sylius.shipping_method.not_found', Argument::any())
->shouldNotBeCalled()
;
$executionContext
->addViolation('sylius.shipment.not_found')
->shouldBeCalled()
;
$this->validate($command, new ChosenShippingMethodEligibility());
}
}

View file

@ -73,6 +73,7 @@ sylius:
order:
currency_code:
not_valid: The currency code you entered is invalid.
invalid_state_transition: 'Cannot complete as order is in a wrong state. Current: %currentState%. Possible transitions: %possibleTransitions%.'
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.'

View file

@ -28,21 +28,55 @@ Sylius\Component\Core\Model\ProductVariant:
product: '@product_mug'
currentLocale: 'en_US'
translations:
en_US: '@product_variant_translation_mug'
en_US: '@product_variant_translation_mug_blue'
optionValues: ['@product_option_value_color_blue']
channelPricings:
WEB: '@channel_pricing_product_variant_mug_blue_web'
product_variant_mug_nft:
channelPricings:
WEB: '@channel_pricing_product_variant_mug_nft_web'
code: 'MUG_NFT'
currentLocale: 'en_US'
optionValues: ['@product_option_value_color_blue']
product: '@product_mug'
shippingRequired: false
translations:
en_US: '@product_variant_translation_mug_nft'
product_variant_free_mug_nft:
channelPricings:
WEB: '@channel_pricing_product_variant_free_mug_nft_web'
code: 'MUG_NFT_FREE'
currentLocale: 'en_US'
optionValues: ['@product_option_value_color_blue']
product: '@product_mug'
shippingRequired: false
translations:
en_US: '@product_variant_translation_free_mug_nft'
Sylius\Component\Product\Model\ProductVariantTranslation:
product_variant_translation_mug:
product_variant_translation_mug_blue:
locale: 'en_US'
name: 'Blue Mug'
translatable: '@product_variant_mug_blue'
product_variant_translation_mug_nft:
locale: 'en_US'
name: 'NFT Mug'
translatable: '@product_variant_mug_nft'
product_variant_translation_free_mug_nft:
locale: 'en_US'
name: 'NFT Mug'
translatable: '@product_variant_free_mug_nft'
Sylius\Component\Core\Model\ChannelPricing:
channel_pricing_product_variant_mug_blue_web:
channelCode: 'WEB'
price: 2000
channel_pricing_product_variant_mug_nft_web:
channelCode: 'WEB'
price: 3000
channel_pricing_product_variant_free_mug_nft_web:
channelCode: 'WEB'
price: 0
Sylius\Component\Product\Model\ProductOption:
product_option_color:

View file

@ -0,0 +1,13 @@
{
"@context": "\/api\/v2\/contexts\/ConstraintViolationList",
"@type": "ConstraintViolationList",
"hydra:title": "An error occurred",
"hydra:description": "The shipment does not exist.",
"violations": [
{
"propertyPath": "",
"message": "The shipment does not exist.",
"code": null
}
]
}

View file

@ -0,0 +1,13 @@
{
"@context": "\/api\/v2\/contexts\/ConstraintViolationList",
"@type": "ConstraintViolationList",
"hydra:title": "An error occurred",
"hydra:description": "Cannot complete as order is in a wrong state. Current: cart. Possible transitions: address.",
"violations": [
{
"propertyPath": "",
"message": "Cannot complete as order is in a wrong state. Current: cart. Possible transitions: address.",
"code": null
}
]
}

View file

@ -0,0 +1,59 @@
{
"@context": "\/api\/v2\/contexts\/Order",
"@id": "\/api\/v2\/shop\/orders\/nAWw2jewpA",
"@type": "Order",
"channel": "\/api\/v2\/shop\/channels\/WEB",
"shippingAddress": {
"@id": "\/api\/v2\/shop\/addresses\/@integer@",
"@type": "Address",
"firstName": "John",
"lastName": "Doe",
"countryCode": "US",
"street": "Avenue",
"city": "New York",
"postcode": "90000"
},
"billingAddress": {
"@id": "\/api\/v2\/shop\/addresses\/@integer@",
"@type": "Address",
"firstName": "John",
"lastName": "Doe",
"countryCode": "US",
"street": "Avenue",
"city": "New York",
"postcode": "90000"
},
"payments": [],
"shipments": [],
"currencyCode": "USD",
"localeCode": "en_US",
"checkoutState": "completed",
"paymentState": "paid",
"shippingState": "shipped",
"tokenValue": "nAWw2jewpA",
"id": @integer@,
"number": "000000001",
"items": [
{
"@id": "\/api\/v2\/shop\/order-items\/@integer@",
"@type": "OrderItem",
"variant": "\/api\/v2\/shop\/product-variants\/MUG_NFT_FREE",
"productName": "Mug",
"id": @integer@,
"quantity": 1,
"unitPrice": 0,
"originalUnitPrice": 0,
"total": 0,
"discountedUnitPrice": 0,
"subtotal": 0
}
],
"itemsTotal": 0,
"total": 0,
"state": "fulfilled",
"taxTotal": 0,
"taxExcludedTotal": 0,
"taxIncludedTotal": 0,
"shippingTotal": 0,
"orderPromotionTotal": 0
}

View file

@ -0,0 +1,66 @@
{
"@context": "\/api\/v2\/contexts\/Order",
"@id": "\/api\/v2\/shop\/orders\/nAWw2jewpA",
"@type": "Order",
"channel": "\/api\/v2\/shop\/channels\/WEB",
"shippingAddress": {
"@id": "\/api\/v2\/shop\/addresses\/@integer@",
"@type": "Address",
"firstName": "John",
"lastName": "Doe",
"countryCode": "US",
"street": "Avenue",
"city": "New York",
"postcode": "90000"
},
"billingAddress": {
"@id": "\/api\/v2\/shop\/addresses\/@integer@",
"@type": "Address",
"firstName": "John",
"lastName": "Doe",
"countryCode": "US",
"street": "Avenue",
"city": "New York",
"postcode": "90000"
},
"payments": [
{
"@id": "\/api\/v2\/shop\/payments\/@integer@",
"@type": "Payment",
"id": @integer@,
"method": "\/api\/v2\/shop\/payment-methods\/BANK_TRANSFER"
}
],
"shipments": [],
"currencyCode": "USD",
"localeCode": "en_US",
"checkoutState": "completed",
"paymentState": "awaiting_payment",
"shippingState": "shipped",
"tokenValue": "nAWw2jewpA",
"id": @integer@,
"number": "000000001",
"items": [
{
"@id": "\/api\/v2\/shop\/order-items\/@integer@",
"@type": "OrderItem",
"variant": "\/api\/v2\/shop\/product-variants\/MUG_NFT",
"productName": "Mug",
"id": @integer@,
"quantity": 1,
"unitPrice": 3000,
"originalUnitPrice": 3000,
"total": 3000,
"discountedUnitPrice": 3000,
"subtotal": 3000
}
],
"itemsTotal": 3000,
"total": 3000,
"state": "new",
"taxTotal": 0,
"taxExcludedTotal": 0,
"taxIncludedTotal": 0,
"shippingTotal": 0,
"orderPromotionTotal": 0
}

View file

@ -0,0 +1,86 @@
{
"@context": "\/api\/v2\/contexts\/Order",
"@id": "\/api\/v2\/shop\/orders\/nAWw2jewpA",
"@type": "Order",
"channel": "\/api\/v2\/shop\/channels\/WEB",
"shippingAddress": {
"@id": "\/api\/v2\/shop\/addresses\/@integer@",
"@type": "Address",
"firstName": "John",
"lastName": "Doe",
"countryCode": "US",
"street": "Avenue",
"city": "New York",
"postcode": "90000"
},
"billingAddress": {
"@id": "\/api\/v2\/shop\/addresses\/@integer@",
"@type": "Address",
"firstName": "John",
"lastName": "Doe",
"countryCode": "US",
"street": "Avenue",
"city": "New York",
"postcode": "90000"
},
"payments": [
{
"@id": "\/api\/v2\/shop\/payments\/@integer@",
"@type": "Payment",
"id": @integer@,
"method": "\/api\/v2\/shop\/payment-methods\/BANK_TRANSFER"
}
],
"shipments": [
{
"@id": "\/api\/v2\/shop\/shipments\/@integer@",
"@type": "Shipment",
"id": @integer@,
"method": "\/api\/v2\/shop\/shipping-methods\/DHL"
}
],
"currencyCode": "USD",
"localeCode": "en_US",
"checkoutState": "completed",
"paymentState": "awaiting_payment",
"shippingState": "ready",
"tokenValue": "nAWw2jewpA",
"id": @integer@,
"number": "000000001",
"items": [
{
"@id": "\/api\/v2\/shop\/order-items\/@integer@",
"@type": "OrderItem",
"variant": "\/api\/v2\/shop\/product-variants\/MUG_BLUE",
"productName": "Mug",
"id": @integer@,
"quantity": 3,
"unitPrice": 2000,
"originalUnitPrice": 2000,
"total": 6000,
"discountedUnitPrice": 2000,
"subtotal": 6000
},
{
"@id": "\/api\/v2\/shop\/order-items\/@integer@",
"@type": "OrderItem",
"variant": "\/api\/v2\/shop\/product-variants\/MUG_NFT",
"productName": "Mug",
"id": @integer@,
"quantity": 1,
"unitPrice": 3000,
"originalUnitPrice": 3000,
"total": 3000,
"discountedUnitPrice": 3000,
"subtotal": 3000
}
],
"itemsTotal": 9000,
"total": 10000,
"state": "new",
"taxTotal": 0,
"taxExcludedTotal": 0,
"taxIncludedTotal": 0,
"shippingTotal": 1000,
"orderPromotionTotal": 0
}

View file

@ -0,0 +1,13 @@
{
"@context": "\/api\/v2\/contexts\/ConstraintViolationList",
"@type": "ConstraintViolationList",
"hydra:title": "An error occurred",
"hydra:description": "Cannot complete as order is in a wrong state. Current: shipping_selected. Possible transitions: address, select_shipping, skip_payment, select_payment.",
"violations": [
{
"propertyPath": "",
"message": "Cannot complete as order is in a wrong state. Current: shipping_selected. Possible transitions: address, select_shipping, skip_payment, select_payment.",
"code": null
}
]
}

View file

@ -0,0 +1,13 @@
{
"@context": "\/api\/v2\/contexts\/ConstraintViolationList",
"@type": "ConstraintViolationList",
"hydra:title": "An error occurred",
"hydra:description": "Cannot complete as order is in a wrong state. Current: shipping_skipped. Possible transitions: address, skip_payment, select_payment.",
"violations": [
{
"propertyPath": "",
"message": "Cannot complete as order is in a wrong state. Current: shipping_skipped. Possible transitions: address, skip_payment, select_payment.",
"code": null
}
]
}

View file

@ -0,0 +1,13 @@
{
"@context": "\/api\/v2\/contexts\/ConstraintViolationList",
"@type": "ConstraintViolationList",
"hydra:title": "An error occurred",
"hydra:description": "Cannot complete as order is in a wrong state. Current: addressed. Possible transitions: address, skip_shipping, select_shipping.",
"violations": [
{
"propertyPath": "",
"message": "Cannot complete as order is in a wrong state. Current: addressed. Possible transitions: address, skip_shipping, select_shipping.",
"code": null
}
]
}

View file

@ -0,0 +1,283 @@
<?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\Tests\Api\Shop;
use Sylius\Bundle\ApiBundle\Command\Cart\AddItemToCart;
use Sylius\Bundle\ApiBundle\Command\Cart\PickupCart;
use Sylius\Bundle\ApiBundle\Command\Checkout\ChoosePaymentMethod;
use Sylius\Bundle\ApiBundle\Command\Checkout\ChooseShippingMethod;
use Sylius\Bundle\ApiBundle\Command\Checkout\UpdateCart;
use Sylius\Component\Core\Model\Address;
use Sylius\Tests\Api\JsonApiTestCase;
use Sylius\Tests\Api\Utils\ContentType;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
final class CheckoutCompletionTest extends JsonApiTestCase
{
private MessageBusInterface $commandBus;
/** @test */
public function it_prevents_from_order_completion_if_order_is_in_the_cart_state(): void
{
$this->loadFixturesFromFiles(['channel.yaml', 'cart.yaml']);
$tokenValue = $this->pickUpCart();
$this->addItemToCart('MUG_BLUE', 3, $tokenValue);
$this->client->request(
method: 'PATCH',
uri: sprintf('/api/v2/shop/orders/%s/complete', $tokenValue),
server: ContentType::APPLICATION_JSON_MERGE_PATCH,
content: json_encode([]),
);
$this->assertResponseCode($this->client->getResponse(), Response::HTTP_UNPROCESSABLE_ENTITY);
$this->assertResponse(
$this->client->getResponse(),
'shop/order_completion/order_not_addressed_response',
Response::HTTP_UNPROCESSABLE_ENTITY,
);
}
/** @test */
public function it_prevents_from_order_completion_if_order_has_been_addressed_but_does_not_have_a_shipping_method_assigned(): void
{
$this->loadFixturesFromFiles(['channel.yaml', 'cart.yaml', 'shipping_method.yaml']);
$tokenValue = $this->pickUpCart();
$this->addItemToCart('MUG_BLUE', 3, $tokenValue);
$this->updateCartWithAddress($tokenValue);
$this->client->request(
method: 'PATCH',
uri: sprintf('/api/v2/shop/orders/%s/complete', $tokenValue),
server: ContentType::APPLICATION_JSON_MERGE_PATCH,
content: json_encode([]),
);
$this->assertResponseCode($this->client->getResponse(), Response::HTTP_UNPROCESSABLE_ENTITY);
$this->assertResponse(
$this->client->getResponse(),
'shop/order_completion/shipping_method_not_chosen_response',
Response::HTTP_UNPROCESSABLE_ENTITY,
);
}
/** @test */
public function it_prevents_from_order_completion_if_order_has_shipping_method_assigned_but_the_payment_has_not_been_chosen(): void
{
$this->loadFixturesFromFiles(['channel.yaml', 'cart.yaml', 'shipping_method.yaml', 'payment_method.yaml']);
$tokenValue = $this->pickUpCart();
$this->addItemToCart('MUG_BLUE', 3, $tokenValue);
$this->updateCartWithAddress($tokenValue);
$this->chooseShippingMethod($tokenValue, $this->getFirstShipmentId($tokenValue));
$this->client->request(
method: 'PATCH',
uri: sprintf('/api/v2/shop/orders/%s/complete', $tokenValue),
server: ContentType::APPLICATION_JSON_MERGE_PATCH,
content: json_encode([]),
);
$this->assertResponseCode($this->client->getResponse(), Response::HTTP_UNPROCESSABLE_ENTITY);
$this->assertResponse(
$this->client->getResponse(),
'shop/order_completion/payment_method_not_chosen_with_shipping_selected_state_response',
Response::HTTP_UNPROCESSABLE_ENTITY,
);
}
/** @test */
public function it_prevents_from_checkout_completion_if_there_is_no_shippable_item_in_the_cart_and_order_is_in_addressed_state(): void
{
$this->loadFixturesFromFiles(['channel.yaml', 'cart.yaml', 'shipping_method.yaml', 'payment_method.yaml']);
$tokenValue = $this->pickUpCart();
$this->addItemToCart('MUG_NFT', 3, $tokenValue);
$this->updateCartWithAddress($tokenValue);
$this->client->request(
method: 'PATCH',
uri: sprintf('/api/v2/shop/orders/%s/complete', $tokenValue),
server: ContentType::APPLICATION_JSON_MERGE_PATCH,
content: json_encode([]),
);
$this->assertResponseCode($this->client->getResponse(), Response::HTTP_UNPROCESSABLE_ENTITY);
$this->assertResponse(
$this->client->getResponse(),
'shop/order_completion/payment_method_not_chosen_with_shipping_skipped_state_response',
Response::HTTP_UNPROCESSABLE_ENTITY,
);
}
/** @test */
public function it_allows_to_complete_checkout_with_shippable_and_non_shippable_items_if_all_checkout_steps_have_been_completed(): void
{
$this->loadFixturesFromFiles(['channel.yaml', 'cart.yaml', 'shipping_method.yaml', 'payment_method.yaml']);
$tokenValue = $this->pickUpCart();
$this->addItemToCart('MUG_BLUE', 3, $tokenValue);
$this->addItemToCart('MUG_NFT', 1, $tokenValue);
$this->updateCartWithAddress($tokenValue);
$this->chooseShippingMethod($tokenValue, $this->getFirstShipmentId($tokenValue));
$this->choosePaymentMethod($tokenValue, $this->getFirstPaymentId($tokenValue));
$this->client->request(
method: 'PATCH',
uri: sprintf('/api/v2/shop/orders/%s/complete', $tokenValue),
server: ContentType::APPLICATION_JSON_MERGE_PATCH,
content: json_encode([]),
);
$this->assertResponseCode($this->client->getResponse(), Response::HTTP_OK);
$this->assertResponse(
$this->client->getResponse(),
'shop/order_completion/order_with_shippable_and_non_shippable_items_response',
Response::HTTP_OK,
);
}
/** @test */
public function it_allows_to_complete_checkout_with_non_shippable_items_without_shipping_method_assigned(): void
{
$this->loadFixturesFromFiles(['channel.yaml', 'cart.yaml', 'payment_method.yaml']);
$tokenValue = $this->pickUpCart();
$this->addItemToCart('MUG_NFT', 1, $tokenValue);
$this->updateCartWithAddress($tokenValue);
$this->choosePaymentMethod($tokenValue, $this->getFirstPaymentId($tokenValue));
$this->client->request(
method: 'PATCH',
uri: sprintf('/api/v2/shop/orders/%s/complete', $tokenValue),
server: ContentType::APPLICATION_JSON_MERGE_PATCH,
content: json_encode([]),
);
$this->assertResponseCode($this->client->getResponse(), Response::HTTP_OK);
$this->assertResponse(
$this->client->getResponse(),
'shop/order_completion/order_with_non_shippable_items_response',
Response::HTTP_OK,
);
}
/** @test */
public function it_allows_to_complete_checkout_with_free_non_shippable_items_without_shipping_method_and_payment_method_assigned(): void
{
$this->loadFixturesFromFiles(['channel.yaml', 'cart.yaml']);
$tokenValue = $this->pickUpCart();
$this->addItemToCart('MUG_NFT_FREE', 1, $tokenValue);
$this->updateCartWithAddress($tokenValue);
$this->client->request(
method: 'PATCH',
uri: sprintf('/api/v2/shop/orders/%s/complete', $tokenValue),
server: ContentType::APPLICATION_JSON_MERGE_PATCH,
content: json_encode([]),
);
$this->assertResponseCode($this->client->getResponse(), Response::HTTP_OK);
$this->assertResponse(
$this->client->getResponse(),
'shop/order_completion/order_with_free_non_shippable_items_response',
Response::HTTP_OK,
);
}
protected function setUp(): void
{
parent::setUp();
$this->commandBus = self::getContainer()->get('sylius.command_bus');
}
private function pickUpCart(): string
{
$tokenValue = 'nAWw2jewpA';
$pickupCartCommand = new PickupCart($tokenValue);
$pickupCartCommand->setChannelCode('WEB');
$this->commandBus->dispatch($pickupCartCommand);
return $tokenValue;
}
private function addItemToCart(string $productVariantCode, int $quantity, string $tokenValue): void
{
$addItemToCartCommand = new AddItemToCart($productVariantCode, $quantity);
$addItemToCartCommand->setOrderTokenValue($tokenValue);
$this->commandBus->dispatch($addItemToCartCommand);
}
private function updateCartWithAddress(string $tokenValue): void
{
$address = new Address();
$address->setFirstName('John');
$address->setLastName('Doe');
$address->setCity('New York');
$address->setStreet('Avenue');
$address->setCountryCode('US');
$address->setPostcode('90000');
$updateCartCommand = new UpdateCart(email: 'sylius@example.com', billingAddress: $address);
$updateCartCommand->setOrderTokenValue($tokenValue);
$this->commandBus->dispatch($updateCartCommand);
}
private function chooseShippingMethod(string $tokenValue, string $shipmentId): void
{
$chooseShippingMethodCommand = new ChooseShippingMethod('DHL');
$chooseShippingMethodCommand->setSubresourceId($shipmentId);
$chooseShippingMethodCommand->setOrderTokenValue($tokenValue);
$this->commandBus->dispatch($chooseShippingMethodCommand);
}
private function choosePaymentMethod(string $tokenValue, string $paymentId): void
{
$choosePaymentMethodCommand = new ChoosePaymentMethod('BANK_TRANSFER');
$choosePaymentMethodCommand->setSubresourceId($paymentId);
$choosePaymentMethodCommand->setOrderTokenValue($tokenValue);
$this->commandBus->dispatch($choosePaymentMethodCommand);
}
private function getFirstShipmentId(string $tokenValue): string
{
$this->client->request(
method: 'GET',
uri: sprintf('/api/v2/shop/orders/%s', $tokenValue),
);
return (string) json_decode($this->client->getResponse()->getContent())->shipments[0]->id;
}
private function getFirstPaymentId(string $tokenValue): string
{
$this->client->request(
method: 'GET',
uri: sprintf('/api/v2/shop/orders/%s', $tokenValue),
);
return (string) json_decode($this->client->getResponse()->getContent())->payments[0]->id;
}
}

View file

@ -21,6 +21,7 @@ use Sylius\Component\Core\Model\Address;
use Sylius\Component\Core\Model\AdjustmentInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Tests\Api\JsonApiTestCase;
use Sylius\Tests\Api\Utils\ContentType;
use Sylius\Tests\Api\Utils\OrderPlacerTrait;
use Sylius\Tests\Api\Utils\ShopUserLoginTrait;
use Symfony\Component\HttpFoundation\Response;
@ -31,6 +32,8 @@ final class OrdersTest extends JsonApiTestCase
use ShopUserLoginTrait;
use OrderPlacerTrait;
private MessageBusInterface $commandBus;
/** @test */
public function it_gets_an_order(): void
{
@ -42,30 +45,9 @@ final class OrdersTest extends JsonApiTestCase
'payment_method.yaml',
]);
$tokenValue = 'nAWw2jewpA';
/** @var MessageBusInterface $commandBus */
$commandBus = self::getContainer()->get('sylius.command_bus');
$pickupCartCommand = new PickupCart($tokenValue);
$pickupCartCommand->setChannelCode('WEB');
$commandBus->dispatch($pickupCartCommand);
$addItemToCartCommand = new AddItemToCart('MUG_BLUE', 3);
$addItemToCartCommand->setOrderTokenValue($tokenValue);
$commandBus->dispatch($addItemToCartCommand);
$address = new Address();
$address->setFirstName('John');
$address->setLastName('Doe');
$address->setCity('New York');
$address->setStreet('Avenue');
$address->setCountryCode('US');
$address->setPostcode('90000');
$updateCartCommand = new UpdateCart('sylius@example.com', $address);
$updateCartCommand->setOrderTokenValue($tokenValue);
$commandBus->dispatch($updateCartCommand);
$tokenValue = $this->pickUpCart();
$this->addItemToCart('MUG_BLUE', 3, $tokenValue);
$this->updateCartWithAddress($tokenValue);
$this->client->request(method: 'GET', uri: '/api/v2/shop/orders/nAWw2jewpA', server: self::CONTENT_TYPE_HEADER);
$response = $this->client->getResponse();
@ -78,30 +60,10 @@ final class OrdersTest extends JsonApiTestCase
{
$this->loadFixturesFromFiles(['authentication/customer.yaml', 'channel.yaml', 'cart.yaml', 'country.yaml', 'shipping_method.yaml', 'payment_method.yaml']);
$tokenValue = 'nAWw2jewpA';
$tokenValue = $this->pickUpCart();
/** @var MessageBusInterface $commandBus */
$commandBus = self::getContainer()->get('sylius.command_bus');
$pickupCartCommand = new PickupCart($tokenValue);
$pickupCartCommand->setChannelCode('WEB');
$commandBus->dispatch($pickupCartCommand);
$addItemToCartCommand = new AddItemToCart('MUG_BLUE', 3);
$addItemToCartCommand->setOrderTokenValue($tokenValue);
$commandBus->dispatch($addItemToCartCommand);
$address = new Address();
$address->setFirstName('John');
$address->setLastName('Doe');
$address->setCity('New York');
$address->setStreet('Avenue');
$address->setCountryCode('US');
$address->setPostcode('90000');
$updateCartCommand = new UpdateCart('oliver@doe.com', $address);
$updateCartCommand->setOrderTokenValue($tokenValue);
$commandBus->dispatch($updateCartCommand);
$this->addItemToCart('MUG_BLUE', 3, $tokenValue);
$this->updateCartWithAddress($tokenValue);
$this->client->request(
method: 'GET',
@ -124,18 +86,9 @@ final class OrdersTest extends JsonApiTestCase
'payment_method.yaml',
]);
$tokenValue = 'nAWw2jewpA';
$tokenValue = $this->pickUpCart();
/** @var MessageBusInterface $commandBus */
$commandBus = self::getContainer()->get('sylius.command_bus');
$pickupCartCommand = new PickupCart($tokenValue);
$pickupCartCommand->setChannelCode('WEB');
$commandBus->dispatch($pickupCartCommand);
$addItemToCartCommand = new AddItemToCart('MUG_BLUE', 3);
$addItemToCartCommand->setOrderTokenValue($tokenValue);
$commandBus->dispatch($addItemToCartCommand);
$this->addItemToCart('MUG_BLUE', 3, $tokenValue);
$this->client->request(
method: 'GET',
@ -158,18 +111,9 @@ final class OrdersTest extends JsonApiTestCase
'payment_method.yaml',
]);
$tokenValue = 'nAWw2jewpA';
$tokenValue = $this->pickUpCart();
/** @var MessageBusInterface $commandBus */
$commandBus = self::getContainer()->get('sylius.command_bus');
$pickupCartCommand = new PickupCart($tokenValue);
$pickupCartCommand->setChannelCode('WEB');
$commandBus->dispatch($pickupCartCommand);
$addItemToCartCommand = new AddItemToCart('MUG_BLUE', 3);
$addItemToCartCommand->setOrderTokenValue($tokenValue);
$commandBus->dispatch($addItemToCartCommand);
$this->addItemToCart('MUG_BLUE', 3, $tokenValue);
$this->client->request(
method: 'GET',
@ -192,18 +136,9 @@ final class OrdersTest extends JsonApiTestCase
'payment_method.yaml',
]);
$tokenValue = 'nAWw2jewpA';
$tokenValue = $this->pickUpCart();
/** @var MessageBusInterface $commandBus */
$commandBus = self::getContainer()->get('sylius.command_bus');
$pickupCartCommand = new PickupCart($tokenValue);
$pickupCartCommand->setChannelCode('WEB');
$commandBus->dispatch($pickupCartCommand);
$addItemToCartCommand = new AddItemToCart('MUG_BLUE', 3);
$addItemToCartCommand->setOrderTokenValue($tokenValue);
$commandBus->dispatch($addItemToCartCommand);
$this->addItemToCart('MUG_BLUE', 3, $tokenValue);
/** @var OrderInterface $order */
$order = $this->get('sylius.repository.order')->findCartByTokenValue($tokenValue);
@ -241,14 +176,7 @@ final class OrdersTest extends JsonApiTestCase
'payment_method.yaml',
]);
$tokenValue = 'nAWw2jewpA';
/** @var MessageBusInterface $commandBus */
$commandBus = self::getContainer()->get('sylius.command_bus');
$pickupCartCommand = new PickupCart($tokenValue);
$pickupCartCommand->setChannelCode('WEB');
$commandBus->dispatch($pickupCartCommand);
$this->pickUpCart();
$this->client->request(
method: 'POST',
@ -391,17 +319,9 @@ final class OrdersTest extends JsonApiTestCase
'payment_method.yaml',
]);
$tokenValue = 'nAWw2jewpA';
$tokenValue = $this->pickUpCart();
/** @var MessageBusInterface $commandBus */
$commandBus = self::getContainer()->get('sylius.command_bus');
$pickupCartCommand = new PickupCart($tokenValue);
$pickupCartCommand->setChannelCode('WEB');
$commandBus->dispatch($pickupCartCommand);
$addItemToCartCommand = new AddItemToCart('MUG_BLUE', 3);
$addItemToCartCommand->setOrderTokenValue($tokenValue);
$commandBus->dispatch($addItemToCartCommand);
$this->addItemToCart('MUG_BLUE', 3, $tokenValue);
/** @var CountryInterface $country */
$country = $fixtures['country_US'];
@ -437,18 +357,9 @@ final class OrdersTest extends JsonApiTestCase
{
$this->loadFixturesFromFiles(['channel.yaml', 'cart.yaml']);
$tokenValue = 'nAWw2jewpA';
$tokenValue = $this->pickUpCart();
/** @var MessageBusInterface $commandBus */
$commandBus = self::getContainer()->get('sylius.command_bus');
$pickupCartCommand = new PickupCart($tokenValue);
$pickupCartCommand->setChannelCode('WEB');
$commandBus->dispatch($pickupCartCommand);
$addItemToCartCommand = new AddItemToCart('MUG_BLUE', 3);
$addItemToCartCommand->setOrderTokenValue($tokenValue);
$commandBus->dispatch($addItemToCartCommand);
$this->addItemToCart('MUG_BLUE', 3, $tokenValue);
$this->client->request('GET', sprintf('/api/v2/shop/orders/%s', $tokenValue));
$itemId = json_decode($this->client->getResponse()->getContent(), true)['items'][0]['id'];
@ -459,18 +370,27 @@ final class OrdersTest extends JsonApiTestCase
}
/** @test */
public function it_returns_unprocessable_entity_status_if_tries_to_remove_an_item_that_not_exist_in_the_order(): void
public function it_does_not_remove_item_from_the_cart_if_invalid_uri_item_parameter_passed(): void
{
$this->loadFixturesFromFiles(['channel.yaml', 'cart.yaml']);
$tokenValue = 'nAWw2jewpA';
$tokenValue = $this->pickUpCart();
/** @var MessageBusInterface $commandBus */
$commandBus = self::getContainer()->get('sylius.command_bus');
$this->addItemToCart('MUG_BLUE', 3, $tokenValue);
$pickupCartCommand = new PickupCart($tokenValue);
$pickupCartCommand->setChannelCode('WEB');
$commandBus->dispatch($pickupCartCommand);
$this->client->request('GET', sprintf('/api/v2/shop/orders/%s', $tokenValue));
$this->client->request('DELETE', sprintf('/api/v2/shop/orders/%s/items/STRING-INSTEAD-OF-ID', $tokenValue));
$this->assertResponseCode($this->client->getResponse(), Response::HTTP_UNPROCESSABLE_ENTITY);
}
/** @test */
public function it_returns_unprocessable_entity_status_if_tries_to_remove_an_item_that_not_exist_in_the_order(): void
{
$this->loadFixturesFromFile('channel.yaml');
$tokenValue = $this->pickUpCart();
$nonExistingOrderItemId = 123;
$this->client->request('DELETE', sprintf('/api/v2/shop/orders/%s/items/%s', $tokenValue, $nonExistingOrderItemId));
@ -556,4 +476,99 @@ final class OrdersTest extends JsonApiTestCase
$this->assertResponseCode($response, Response::HTTP_UNPROCESSABLE_ENTITY);
}
public function it_returns_unprocessable_entity_status_if_trying_to_assign_shipping_method_to_non_existing_shipment(): void
{
$this->loadFixturesFromFiles(['channel.yaml', 'shipping_method.yaml']);
$tokenValue = $this->pickUpCart();
$this->client->request(
method: 'PATCH',
uri: sprintf('/api/v2/shop/orders/%s/shipments/%s', $tokenValue, '1237'),
server: ContentType::APPLICATION_JSON_MERGE_PATCH,
content: json_encode(['shippingMethod' => 'api/v2/shop/shipping-methods/UPS'])
);
$this->assertResponse(
$this->client->getResponse(),
'shop/assign_shipping_method_to_non_existing_shipment_response',
Response::HTTP_UNPROCESSABLE_ENTITY
);
}
/** @test */
public function it_returns_unprocessable_entity_status_if_trying_to_change_item_quantity_if_invalid_item_id_passed(): void
{
$this->loadFixturesFromFiles(['channel.yaml', 'cart.yaml']);
$tokenValue = $this->pickUpCart();
$this->client->request(
method: 'PATCH',
uri: sprintf('/api/v2/shop/orders/%s/items/%s', $tokenValue, 'invalid-item-id'),
server: ContentType::APPLICATION_JSON_MERGE_PATCH,
content: json_encode(['quantity' => 5])
);
$this->assertResponseCode($this->client->getResponse(), Response::HTTP_UNPROCESSABLE_ENTITY);
}
/** @test */
public function it_does_not_return_payment_configuration_if_invalid_payment_id_passed(): void
{
$this->loadFixturesFromFiles(['channel.yaml', 'cart.yaml']);
$tokenValue = $this->pickUpCart();
$this->client->request(
'GET',
sprintf('/api/v2/shop/orders/%s/payments/%s/configuration', $tokenValue, 'invalid-payment-id'),
);
$this->assertResponseCode($this->client->getResponse(), Response::HTTP_NOT_FOUND);
}
protected function setUp(): void
{
parent::setUp();
$this->commandBus = self::getContainer()->get('sylius.command_bus');
}
private function pickUpCart(): string
{
$tokenValue = 'nAWw2jewpA';
$pickupCartCommand = new PickupCart($tokenValue);
$pickupCartCommand->setChannelCode('WEB');
$this->commandBus->dispatch($pickupCartCommand);
return $tokenValue;
}
private function addItemToCart(string $productVariantCode, int $quantity, string $tokenValue): void
{
$addItemToCartCommand = new AddItemToCart($productVariantCode, $quantity);
$addItemToCartCommand->setOrderTokenValue($tokenValue);
$this->commandBus->dispatch($addItemToCartCommand);
}
private function updateCartWithAddress(string $tokenValue): void
{
$address = new Address();
$address->setFirstName('John');
$address->setLastName('Doe');
$address->setCity('New York');
$address->setStreet('Avenue');
$address->setCountryCode('US');
$address->setPostcode('90000');
$updateCartCommand = new UpdateCart(email: 'sylius@example.com', billingAddress: $address);
$updateCartCommand->setOrderTokenValue($tokenValue);
$this->commandBus->dispatch($updateCartCommand);
}
}

View file

@ -0,0 +1,27 @@
<?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\Tests\Api\Utils;
class ContentType
{
public const APPLICATION_JSON = ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json'];
public const APPLICATION_JSON_LD = ['CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json'];
public const APPLICATION_JSON_MERGE_PATCH = ['CONTENT_TYPE' => 'application/merge-patch+json', 'HTTP_ACCEPT' => 'application/ld+json'];
private function __construct()
{
}
}