diff --git a/features/shop/cart/shopping_cart/maintaining_cart_after_authorization.feature b/features/shop/cart/shopping_cart/maintaining_cart_after_authorization.feature index 816ed3e195..cdd7f50b22 100644 --- a/features/shop/cart/shopping_cart/maintaining_cart_after_authorization.feature +++ b/features/shop/cart/shopping_cart/maintaining_cart_after_authorization.feature @@ -52,6 +52,16 @@ Feature: Maintaining cart after authorization Then there should be one item in my cart And this item should have name "Stark T-Shirt" + @api @ui @mink:chromedriver + Scenario: Having cart maintained after logging in when the guest has addressed the cart with a typo email and then corrected it + When I add "Stark T-Shirt" product to the cart + And I addressed the cart with email "robb@strak.com" + And I addressed the cart with email "robb@stark.com" + And I log in as "robb@stark.com" with "KingInTheNorth" password + And I see the summary of my cart + Then there should be one item in my cart + And this item should have name "Stark T-Shirt" + @api @ui @mink:chromedriver Scenario: Having cart maintained after registration When I add product "Stark T-Shirt" to the cart diff --git a/src/Sylius/Bundle/ApiBundle/CommandHandler/Cart/BlameCartHandler.php b/src/Sylius/Bundle/ApiBundle/CommandHandler/Cart/BlameCartHandler.php index cc42844011..c02446ef64 100644 --- a/src/Sylius/Bundle/ApiBundle/CommandHandler/Cart/BlameCartHandler.php +++ b/src/Sylius/Bundle/ApiBundle/CommandHandler/Cart/BlameCartHandler.php @@ -54,7 +54,7 @@ final readonly class BlameCartHandler throw new UnprocessableCartException(); } - if (null !== $cart->getCustomer()) { + if (!$cart->isCreatedByGuest()) { throw new ConflictHttpException('There is an assigned customer to this cart'); } diff --git a/src/Sylius/Bundle/ApiBundle/tests/CommandHandler/Cart/BlameCartHandlerTest.php b/src/Sylius/Bundle/ApiBundle/tests/CommandHandler/Cart/BlameCartHandlerTest.php index e38014ddf4..bcf25e0c1b 100644 --- a/src/Sylius/Bundle/ApiBundle/tests/CommandHandler/Cart/BlameCartHandlerTest.php +++ b/src/Sylius/Bundle/ApiBundle/tests/CommandHandler/Cart/BlameCartHandlerTest.php @@ -65,14 +65,14 @@ final class BlameCartHandlerTest extends TestCase ->method('findCartByTokenValue') ->with('TOKEN') ->willReturn($this->cart); - $this->cart->expects(self::once())->method('getCustomer')->willReturn(null); + $this->cart->expects(self::once())->method('isCreatedByGuest')->willReturn(true); $this->user->expects(self::once())->method('getCustomer')->willReturn($this->customerMock); $this->cart->expects(self::once())->method('setCustomerWithAuthorization')->with($this->customerMock); $this->orderProcessor->expects(self::once())->method('process')->with($this->cart); $this->handler->__invoke(new BlameCart('sylius@example.com', 'TOKEN')); } - public function testThrowsAnExceptionIfCartIsOccupied(): void + public function testThrowsAnExceptionIfCartIsNotCreatedByGuest(): void { $this->shopUserRepository->expects(self::once()) ->method('findOneByEmail') @@ -81,7 +81,7 @@ final class BlameCartHandlerTest extends TestCase $this->orderRepository->expects(self::once()) ->method('findCartByTokenValue') ->with('TOKEN')->willReturn($this->cart); - $this->cart->expects(self::once())->method('getCustomer')->willReturn($this->customerMock); + $this->cart->expects(self::once())->method('isCreatedByGuest')->willReturn(false); self::expectException(ConflictHttpException::class); $this->handler->__invoke(new BlameCart('sylius@example.com', 'TOKEN')); } diff --git a/src/Sylius/Bundle/CoreBundle/Doctrine/ORM/OrderRepository.php b/src/Sylius/Bundle/CoreBundle/Doctrine/ORM/OrderRepository.php index 2e80af98e3..bffb1cb5ac 100644 --- a/src/Sylius/Bundle/CoreBundle/Doctrine/ORM/OrderRepository.php +++ b/src/Sylius/Bundle/CoreBundle/Doctrine/ORM/OrderRepository.php @@ -233,12 +233,20 @@ class OrderRepository extends BaseOrderRepository implements OrderRepositoryInte ChannelInterface $channel, CustomerInterface $customer, ): ?OrderInterface { + $completedCheckoutsQueryBuilder = $this->createQueryBuilder('completedOrder') + ->select('completedOrder.id') + ->andWhere('completedOrder.channel = :channel') + ->andWhere('completedOrder.customer = :customer') + ->andWhere('completedOrder.checkoutCompletedAt > o.createdAt') + ; + return $this->createQueryBuilder('o') ->andWhere('o.state = :state') ->andWhere('o.channel = :channel') ->andWhere('o.customer = :customer') ->andWhere('o.items IS NOT EMPTY') ->andWhere('o.createdByGuest = :createdByGuest') + ->andWhere(sprintf('NOT EXISTS (%s)', $completedCheckoutsQueryBuilder->getDQL())) ->setParameter('state', OrderInterface::STATE_CART) ->setParameter('channel', $channel) ->setParameter('customer', $customer) diff --git a/src/Sylius/Bundle/ShopBundle/EventListener/ShopCartBlamerListener.php b/src/Sylius/Bundle/ShopBundle/EventListener/ShopCartBlamerListener.php index 577a44892f..fc1eacf4af 100644 --- a/src/Sylius/Bundle/ShopBundle/EventListener/ShopCartBlamerListener.php +++ b/src/Sylius/Bundle/ShopBundle/EventListener/ShopCartBlamerListener.php @@ -61,7 +61,7 @@ final class ShopCartBlamerListener private function blame(ShopUserInterface $user): void { $cart = $this->getCart(); - if (null === $cart || null !== $cart->getCustomer()) { + if (null === $cart || !$cart->isCreatedByGuest()) { return; } diff --git a/src/Sylius/Bundle/ShopBundle/tests/EventListener/ShopCartBlamerListenerTest.php b/src/Sylius/Bundle/ShopBundle/tests/EventListener/ShopCartBlamerListenerTest.php index 9039bdef1e..ad3c06f09c 100644 --- a/src/Sylius/Bundle/ShopBundle/tests/EventListener/ShopCartBlamerListenerTest.php +++ b/src/Sylius/Bundle/ShopBundle/tests/EventListener/ShopCartBlamerListenerTest.php @@ -103,7 +103,7 @@ final class ShopCartBlamerListenerTest extends TestCase $this->sectionResolver->expects($this->once())->method('getSection')->willReturn($shopSection); $this->cartContext->expects($this->once())->method('getCart')->willReturn($cart); - $cart->expects($this->once())->method('getCustomer')->willReturn(null); + $cart->expects($this->once())->method('isCreatedByGuest')->willReturn(true); $userEvent->expects($this->once())->method('getUser')->willReturn($user); $user->expects($this->once())->method('getCustomer')->willReturn($customer); $cart->expects($this->once())->method('setCustomerWithAuthorization')->with($customer); @@ -128,7 +128,7 @@ final class ShopCartBlamerListenerTest extends TestCase $this->sectionResolver->expects($this->once())->method('getSection')->willReturn($shopSection); $this->cartContext->expects($this->once())->method('getCart')->willReturn($cart); - $cart->expects($this->once())->method('getCustomer')->willReturn(null); + $cart->expects($this->once())->method('isCreatedByGuest')->willReturn(true); $token->expects($this->once())->method('getUser')->willReturn($user); $user->expects($this->once())->method('getCustomer')->willReturn($customer); $cart->expects($this->once())->method('setCustomerWithAuthorization')->with($customer); @@ -137,6 +137,28 @@ final class ShopCartBlamerListenerTest extends TestCase } public function testDoesNothingIfGivenCartHasBeenBlamedInPast(): void + { + /** @var OrderInterface&MockObject $cart */ + $cart = $this->createMock(OrderInterface::class); + /** @var Request&MockObject $request */ + $request = $this->createMock(Request::class); + /** @var TokenInterface&MockObject $token */ + $token = $this->createMock(TokenInterface::class); + /** @var ShopUserInterface&MockObject $user */ + $user = $this->createMock(ShopUserInterface::class); + /** @var ShopSection&MockObject $shopSection */ + $shopSection = $this->createMock(ShopSection::class); + + $this->sectionResolver->expects($this->once())->method('getSection')->willReturn($shopSection); + $this->cartContext->expects($this->once())->method('getCart')->willReturn($cart); + $cart->expects($this->once())->method('isCreatedByGuest')->willReturn(false); + $token->expects($this->once())->method('getUser')->willReturn($user); + $cart->expects($this->never())->method('setCustomerWithAuthorization'); + + $this->shopCartBlamerListener->onInteractiveLogin(new InteractiveLoginEvent($request, $token)); + } + + public function testBlamesCartWhenGuestCartAlreadyHasCustomerButIsStillGuestCart(): void { /** @var OrderInterface&MockObject $cart */ $cart = $this->createMock(OrderInterface::class); @@ -153,9 +175,10 @@ final class ShopCartBlamerListenerTest extends TestCase $this->sectionResolver->expects($this->once())->method('getSection')->willReturn($shopSection); $this->cartContext->expects($this->once())->method('getCart')->willReturn($cart); - $cart->expects($this->once())->method('getCustomer')->willReturn($customer); + $cart->expects($this->once())->method('isCreatedByGuest')->willReturn(true); $token->expects($this->once())->method('getUser')->willReturn($user); - $cart->expects($this->never())->method('setCustomerWithAuthorization'); + $user->expects($this->once())->method('getCustomer')->willReturn($customer); + $cart->expects($this->once())->method('setCustomerWithAuthorization')->with($customer); $this->shopCartBlamerListener->onInteractiveLogin(new InteractiveLoginEvent($request, $token)); } diff --git a/tests/Api/Shop/Checkout/CartTest.php b/tests/Api/Shop/Checkout/CartTest.php index 090803deaa..73d59157b2 100644 --- a/tests/Api/Shop/Checkout/CartTest.php +++ b/tests/Api/Shop/Checkout/CartTest.php @@ -110,6 +110,78 @@ final class CartTest extends JsonApiTestCase self::assertSame([], $response['items']); } + #[Test] + public function it_does_not_return_a_cart_abandoned_before_the_customer_last_completed_checkout(): void + { + $this->setUpDefaultPostHeaders(); + $this->setUpShopUserContext(); + + $this->loadFixturesFromFiles([ + 'authentication/shop_user.yaml', + 'channel/channel.yaml', + 'cart.yaml', + 'country.yaml', + 'shipping_method.yaml', + 'payment_method.yaml', + ]); + + $this->placeOrder( + tokenValue: 'placedOrderToken', + email: 'shop@example.com', + checkoutCompletedAt: new \DateTimeImmutable(), + ); + + $abandonedCartToken = $this->pickUpCart(tokenValue: 'abandonedCartToken', email: 'shop@example.com'); + $this->addItemToCart('MUG_BLUE', 3, $abandonedCartToken); + + $abandonedCart = $this->orderRepository->findCartByTokenValue($abandonedCartToken); + $abandonedCart->setCreatedAt(new \DateTime('-1 hour')); + $this->get('doctrine.orm.entity_manager')->flush(); + + $this->requestPost(uri: '/api/v2/shop/orders', body: []); + + $this->assertResponseCode($this->client->getResponse(), Response::HTTP_CREATED); + + $response = json_decode($this->client->getResponse()->getContent(), true, flags: \JSON_THROW_ON_ERROR); + + self::assertNotSame($abandonedCartToken, $response['tokenValue']); + self::assertSame([], $response['items']); + } + + #[Test] + public function it_returns_the_customer_cart_created_after_the_last_completed_checkout(): void + { + $this->setUpDefaultPostHeaders(); + $this->setUpShopUserContext(); + + $this->loadFixturesFromFiles([ + 'authentication/shop_user.yaml', + 'channel/channel.yaml', + 'cart.yaml', + 'country.yaml', + 'shipping_method.yaml', + 'payment_method.yaml', + ]); + + $this->placeOrder( + tokenValue: 'placedOrderToken', + email: 'shop@example.com', + checkoutCompletedAt: new \DateTimeImmutable('-1 hour'), + ); + + $newCartToken = $this->pickUpCart(tokenValue: 'newCartToken', email: 'shop@example.com'); + $this->addItemToCart('MUG_BLUE', 3, $newCartToken); + + $this->requestPost(uri: '/api/v2/shop/orders', body: []); + + $this->assertResponseCode($this->client->getResponse(), Response::HTTP_CREATED); + + $response = json_decode($this->client->getResponse()->getContent(), true, flags: \JSON_THROW_ON_ERROR); + + self::assertSame($newCartToken, $response['tokenValue']); + self::assertCount(1, $response['items']); + } + #[Test] public function it_gets_an_empty_cart_as_guest(): void {