From a04c1589619580296a7beae7988d175d13fc4111 Mon Sep 17 00:00:00 2001 From: Paul Kang Date: Thu, 27 Feb 2025 14:05:45 -0800 Subject: [PATCH 1/9] Adding AddToCartLinkV1 controller and checkout_cart_link route for Meta Checkout URL implementation --- .../Controller/Cart/AddToCartLinkV1.php | 196 ++++++++++++++++++ .../layout/checkout_cart_addtocartlinkv1.xml | 11 + 2 files changed, 207 insertions(+) create mode 100644 app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php create mode 100644 app/code/Magento/Checkout/view/frontend/layout/checkout_cart_addtocartlinkv1.xml diff --git a/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php b/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php new file mode 100644 index 0000000000000..7cf5b5f9c2c12 --- /dev/null +++ b/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php @@ -0,0 +1,196 @@ +request = $context->getRequest(); + $this->checkoutSession = $checkoutSession; + $this->productRepository = $productRepository; + $this->cart = $cart; + $this->resultPageFactory = $resultPageFactory; + $this->resultRedirectFactory = $resultRedirectFactory; + $this->couponFactory = $couponFactory; + $this->couponUsage = $couponUsage; + $this->messageManager = $messageManager; + } + + /** + * Execute action based on request and return result + * + * @return \Magento\Framework\Controller\ResultInterface + */ + public function execute() + { + // Get products parameter + $productsParam = $this->request->getParam('products', ''); + $couponCode = $this->request->getParam('coupon', ''); + + // Clear the cart first (required by Meta spec) + $this->cart->truncate(); + + // Parse products parameter + if (!empty($productsParam)) { + $productItems = $this->parseProductsParam($productsParam); + + // Add products to cart + foreach ($productItems as $item) { + try { + $productId = $item['product_id']; + $qty = $item['qty']; + + $product = $this->productRepository->getById($productId); + $this->cart->addProduct($product, ['qty' => $qty]); + } catch (NoSuchEntityException $e) { + // Product not found, continue with next item + $this->messageManager->addErrorMessage( + __('Product with ID "%1" was not found.', $productId) + ); + continue; + } catch (\Exception $e) { + // Other exceptions, continue with next item + $this->messageManager->addErrorMessage($e->getMessage()); + continue; + } + } + + // Save cart + $this->cart->save(); + } + + // Apply coupon code if provided + if (!empty($couponCode)) { + try { + $this->cart->getQuote()->setCouponCode($couponCode); + $this->cart->save(); + + // Check if coupon was actually applied + if ($this->cart->getQuote()->getCouponCode() !== $couponCode) { + $this->messageManager->addErrorMessage( + __('The coupon code "%1" is not valid.', $couponCode) + ); + } + } catch (\Exception $e) { + $this->messageManager->addErrorMessage( + __('The coupon code "%1" is not valid: %2', $couponCode, $e->getMessage()) + ); + } + } + + // Render the checkout page directly (not a redirect) + // This ensures the URL parameters remain in the browser address bar + return $this->resultPageFactory->create(); + } + + /** + * Parse the products parameter from the URL + * Format: product_id:qty,product_id:qty + * + * @param string $productsParam + * @return array + */ + private function parseProductsParam($productsParam) + { + $result = []; + $productPairs = explode(',', $productsParam); + + foreach ($productPairs as $pair) { + $parts = explode(':', $pair); + if (count($parts) === 2) { + $result[] = [ + 'product_id' => $parts[0], + 'qty' => (int)$parts[1] + ]; + } + } + + return $result; + } +} \ No newline at end of file diff --git a/app/code/Magento/Checkout/view/frontend/layout/checkout_cart_addtocartlinkv1.xml b/app/code/Magento/Checkout/view/frontend/layout/checkout_cart_addtocartlinkv1.xml new file mode 100644 index 0000000000000..66bc55d46469f --- /dev/null +++ b/app/code/Magento/Checkout/view/frontend/layout/checkout_cart_addtocartlinkv1.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file From f26b9c42bef2a502d45bc674ae4bb7ed630cf77e Mon Sep 17 00:00:00 2001 From: Paul Kang Date: Fri, 14 Mar 2025 17:08:39 -0700 Subject: [PATCH 2/9] addressing comments --- .../Controller/Cart/AddToCartLinkV1.php | 139 +++---- .../Controller/Cart/AddToCartLinkV1Test.php | 351 ++++++++++++++++++ .../layout/checkout_cart_addtocartlinkv1.xml | 1 - 3 files changed, 400 insertions(+), 91 deletions(-) create mode 100644 app/code/Magento/Checkout/Test/Unit/Controller/Cart/AddToCartLinkV1Test.php diff --git a/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php b/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php index 7cf5b5f9c2c12..f014e7ec3dbb2 100644 --- a/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php +++ b/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php @@ -3,6 +3,7 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Checkout\Controller\Cart; use Magento\Framework\App\Action\HttpGetActionInterface; @@ -16,118 +17,75 @@ use Magento\SalesRule\Model\CouponFactory; use Magento\SalesRule\Model\ResourceModel\Coupon\Usage; use Magento\Framework\Message\ManagerInterface; -use Magento\Framework\App\ActionInterface; use Magento\Framework\App\Action\Context; +use Magento\Framework\Controller\ResultInterface; /** * Controller for Meta Checkout URL implementation */ -class AddToCartLinkV1 implements HttpGetActionInterface, ActionInterface +class AddToCartLinkV1 implements HttpGetActionInterface { - /** - * @var RequestInterface - */ - private $request; - - /** - * @var CheckoutSession - */ - private $checkoutSession; - - /** - * @var ProductRepositoryInterface - */ - private $productRepository; /** - * @var Cart - */ - private $cart; - - /** - * @var PageFactory - */ - private $resultPageFactory; - - /** - * @var RedirectFactory - */ - private $resultRedirectFactory; - - /** - * @var CouponFactory - */ - private $couponFactory; - - /** - * @var Usage - */ - private $couponUsage; - - /** - * @var ManagerInterface + * Request instance + * + * @var RequestInterface */ - private $messageManager; + private $_request; /** - * @param Context $context - * @param CheckoutSession $checkoutSession - * @param ProductRepositoryInterface $productRepository - * @param Cart $cart - * @param PageFactory $resultPageFactory - * @param RedirectFactory $resultRedirectFactory - * @param CouponFactory $couponFactory - * @param Usage $couponUsage - * @param ManagerInterface $messageManager + * Constructor + * + * @param Context $context Context + * @param CheckoutSession $checkoutSession Checkout session + * @param ProductRepositoryInterface $productRepository Product repository + * @param Cart $cart Cart + * @param PageFactory $resultPageFactory Result page factory + * @param RedirectFactory $resultRedirectFactory Redirect factory + * @param CouponFactory $couponFactory Coupon factory + * @param Usage $couponUsage Coupon usage + * @param ManagerInterface $messageManager Message manager */ public function __construct( Context $context, - CheckoutSession $checkoutSession, - ProductRepositoryInterface $productRepository, - Cart $cart, - PageFactory $resultPageFactory, - RedirectFactory $resultRedirectFactory, - CouponFactory $couponFactory, - Usage $couponUsage, - ManagerInterface $messageManager + private readonly CheckoutSession $checkoutSession, + private readonly ProductRepositoryInterface $productRepository, + private readonly Cart $cart, + private readonly PageFactory $resultPageFactory, + private readonly RedirectFactory $resultRedirectFactory, + private readonly CouponFactory $couponFactory, + private readonly Usage $couponUsage, + private readonly ManagerInterface $messageManager ) { - $this->request = $context->getRequest(); - $this->checkoutSession = $checkoutSession; - $this->productRepository = $productRepository; - $this->cart = $cart; - $this->resultPageFactory = $resultPageFactory; - $this->resultRedirectFactory = $resultRedirectFactory; - $this->couponFactory = $couponFactory; - $this->couponUsage = $couponUsage; - $this->messageManager = $messageManager; + $this->_request = $context->getRequest(); } /** * Execute action based on request and return result * - * @return \Magento\Framework\Controller\ResultInterface + * @return ResultInterface */ - public function execute() + public function execute(): ResultInterface { // Get products parameter - $productsParam = $this->request->getParam('products', ''); - $couponCode = $this->request->getParam('coupon', ''); - + $productsParam = $this->_request->getParam('products', ''); + $couponCode = $this->_request->getParam('coupon', ''); + // Clear the cart first (required by Meta spec) $this->cart->truncate(); - + // Parse products parameter if (!empty($productsParam)) { - $productItems = $this->parseProductsParam($productsParam); - + $productItems = $this->_parseProductsParam($productsParam); + // Add products to cart foreach ($productItems as $item) { try { $productId = $item['product_id']; $qty = $item['qty']; - + $product = $this->productRepository->getById($productId); - $this->cart->addProduct($product, ['qty' => $qty]); + $this->cart->addProduct($productId, ['qty' => $qty]); } catch (NoSuchEntityException $e) { // Product not found, continue with next item $this->messageManager->addErrorMessage( @@ -140,17 +98,17 @@ public function execute() continue; } } - + // Save cart $this->cart->save(); } - + // Apply coupon code if provided if (!empty($couponCode)) { try { $this->cart->getQuote()->setCouponCode($couponCode); $this->cart->save(); - + // Check if coupon was actually applied if ($this->cart->getQuote()->getCouponCode() !== $couponCode) { $this->messageManager->addErrorMessage( @@ -163,24 +121,25 @@ public function execute() ); } } - + // Render the checkout page directly (not a redirect) // This ensures the URL parameters remain in the browser address bar return $this->resultPageFactory->create(); } - + /** * Parse the products parameter from the URL * Format: product_id:qty,product_id:qty * - * @param string $productsParam - * @return array + * @param string $productsParam Products parameter string + * + * @return array> */ - private function parseProductsParam($productsParam) + private function _parseProductsParam(string $productsParam): array { $result = []; $productPairs = explode(',', $productsParam); - + foreach ($productPairs as $pair) { $parts = explode(':', $pair); if (count($parts) === 2) { @@ -190,7 +149,7 @@ private function parseProductsParam($productsParam) ]; } } - + return $result; } -} \ No newline at end of file +} diff --git a/app/code/Magento/Checkout/Test/Unit/Controller/Cart/AddToCartLinkV1Test.php b/app/code/Magento/Checkout/Test/Unit/Controller/Cart/AddToCartLinkV1Test.php new file mode 100644 index 0000000000000..539ab525d2368 --- /dev/null +++ b/app/code/Magento/Checkout/Test/Unit/Controller/Cart/AddToCartLinkV1Test.php @@ -0,0 +1,351 @@ +_contextMock = $this->createMock(Context::class); + $this->_checkoutSessionMock = $this->createMock(CheckoutSession::class); + $this->_productRepositoryMock = $this->createMock(ProductRepositoryInterface::class); + $this->_cartMock = $this->createMock(Cart::class); + $this->_resultPageFactoryMock = $this->createMock(PageFactory::class); + $this->_resultRedirectFactoryMock = $this->createMock(RedirectFactory::class); + $this->_couponFactoryMock = $this->createMock(CouponFactory::class); + $this->_couponUsageMock = $this->createMock(Usage::class); + $this->_messageManagerMock = $this->createMock(ManagerInterface::class); + $this->_requestMock = $this->createMock(RequestInterface::class); + $this->_productMock = $this->createMock(Product::class); + $this->_pageMock = $this->createMock(Page::class); + + // Create Quote mock using getMockBuilder with addMethods + $this->_quoteMock = $this->getMockBuilder(Quote::class) + ->addMethods(['setCouponCode', 'getCouponCode']) + ->disableOriginalConstructor() + ->getMock(); + + $this->_contextMock->expects($this->any()) + ->method('getRequest') + ->willReturn($this->_requestMock); + + $this->_controller = $objectManager->getObject( + AddToCartLinkV1::class, + [ + 'context' => $this->_contextMock, + 'checkoutSession' => $this->_checkoutSessionMock, + 'productRepository' => $this->_productRepositoryMock, + 'cart' => $this->_cartMock, + 'resultPageFactory' => $this->_resultPageFactoryMock, + 'resultRedirectFactory' => $this->_resultRedirectFactoryMock, + 'couponFactory' => $this->_couponFactoryMock, + 'couponUsage' => $this->_couponUsageMock, + 'messageManager' => $this->_messageManagerMock + ] + ); + } + + /** + * Test execute method with products and coupon + * + * @return void + */ + public function testExecuteWithProductsAndCoupon(): void + { + $productsParam = '12345:2,67890:1'; + $couponCode = 'TESTCOUPON'; + $productId1 = '12345'; + $productId2 = '67890'; + $qty1 = 2; + $qty2 = 1; + + // Set up request parameters + $this->_requestMock->expects($this->exactly(2)) + ->method('getParam') + ->willReturnMap([ + ['products', '', $productsParam], + ['coupon', '', $couponCode] + ]); + + // Set up cart truncate + $this->_cartMock->expects($this->once()) + ->method('truncate'); + + // Set up product repository + $this->_productRepositoryMock->expects($this->exactly(2)) + ->method('getById') + ->willReturnMap([ + [$productId1, false, null, false, $this->_productMock], + [$productId2, false, null, false, $this->_productMock] + ]); + + // Set up cart add product + $this->_cartMock->expects($this->exactly(2)) + ->method('addProduct') + ->willReturnMap([ + [$productId1, ['qty' => $qty1], $this->_cartMock], + [$productId2, ['qty' => $qty2], $this->_cartMock] + ]); + + // Set up cart save + $this->_cartMock->expects($this->exactly(2)) + ->method('save'); + + // Set up quote + $this->_cartMock->expects($this->exactly(2)) + ->method('getQuote') + ->willReturn($this->_quoteMock); + + // Set up coupon code + $this->_quoteMock->expects($this->once()) + ->method('setCouponCode') + ->with($couponCode); + + $this->_quoteMock->expects($this->once()) + ->method('getCouponCode') + ->willReturn($couponCode); + + // Set up result page + $this->_resultPageFactoryMock->expects($this->once()) + ->method('create') + ->willReturn($this->_pageMock); + + $result = $this->_controller->execute(); + $this->assertInstanceOf(ResultInterface::class, $result); + $this->assertSame($this->_pageMock, $result); + } + + /** + * Test execute method with invalid product + * + * @return void + */ + public function testExecuteWithInvalidProduct(): void + { + $productsParam = '12345:2'; + $productId = '12345'; + $qty = 2; + + // Set up request parameters + $this->_requestMock->expects($this->exactly(2)) + ->method('getParam') + ->willReturnMap([ + ['products', '', $productsParam], + ['coupon', '', ''] + ]); + + // Set up cart truncate + $this->_cartMock->expects($this->once()) + ->method('truncate'); + + // Set up product repository to throw exception + $this->_productRepositoryMock->expects($this->once()) + ->method('getById') + ->with($productId) + ->willThrowException(new \Magento\Framework\Exception\NoSuchEntityException(__('Product not found'))); + + // Set up error message + $this->_messageManagerMock->expects($this->once()) + ->method('addErrorMessage') + ->with(__('Product with ID "%1" was not found.', $productId)); + + // Set up cart save + $this->_cartMock->expects($this->once()) + ->method('save'); + + // Set up result page + $this->_resultPageFactoryMock->expects($this->once()) + ->method('create') + ->willReturn($this->_pageMock); + + $result = $this->_controller->execute(); + $this->assertInstanceOf(ResultInterface::class, $result); + $this->assertSame($this->_pageMock, $result); + } + + /** + * Test execute method with invalid coupon + * + * @return void + */ + public function testExecuteWithInvalidCoupon(): void + { + $productsParam = ''; + $couponCode = 'INVALIDCOUPON'; + + // Set up request parameters + $this->_requestMock->expects($this->exactly(2)) + ->method('getParam') + ->willReturnMap([ + ['products', '', $productsParam], + ['coupon', '', $couponCode] + ]); + + // Set up cart truncate + $this->_cartMock->expects($this->once()) + ->method('truncate'); + + // Set up quote + $this->_cartMock->expects($this->exactly(2)) + ->method('getQuote') + ->willReturn($this->_quoteMock); + + // Set up coupon code + $this->_quoteMock->expects($this->once()) + ->method('setCouponCode') + ->with($couponCode); + + // Set up cart save + $this->_cartMock->expects($this->once()) + ->method('save'); + + // Set up invalid coupon response + $this->_quoteMock->expects($this->once()) + ->method('getCouponCode') + ->willReturn(''); + + // Set up error message + $this->_messageManagerMock->expects($this->once()) + ->method('addErrorMessage') + ->with(__('The coupon code "%1" is not valid.', $couponCode)); + + // Set up result page + $this->_resultPageFactoryMock->expects($this->once()) + ->method('create') + ->willReturn($this->_pageMock); + + $result = $this->_controller->execute(); + $this->assertInstanceOf(ResultInterface::class, $result); + $this->assertSame($this->_pageMock, $result); + } +} \ No newline at end of file diff --git a/app/code/Magento/Checkout/view/frontend/layout/checkout_cart_addtocartlinkv1.xml b/app/code/Magento/Checkout/view/frontend/layout/checkout_cart_addtocartlinkv1.xml index 66bc55d46469f..6cd2d5598a989 100644 --- a/app/code/Magento/Checkout/view/frontend/layout/checkout_cart_addtocartlinkv1.xml +++ b/app/code/Magento/Checkout/view/frontend/layout/checkout_cart_addtocartlinkv1.xml @@ -5,7 +5,6 @@ * See COPYING.txt for license details. */ --> - \ No newline at end of file From f37203933bbaf35acdda3b3594a2d347c7f3018a Mon Sep 17 00:00:00 2001 From: Paul Kang Date: Fri, 14 Mar 2025 17:49:26 -0700 Subject: [PATCH 3/9] Shifting to a SKU based approach --- .../Controller/Cart/AddToCartLinkV1.php | 38 +++++++++++++------ .../Controller/Cart/AddToCartLinkV1Test.php | 22 ++++++++--- 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php b/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php index f014e7ec3dbb2..352b65a6f1feb 100644 --- a/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php +++ b/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php @@ -81,17 +81,31 @@ public function execute(): ResultInterface // Add products to cart foreach ($productItems as $item) { try { - $productId = $item['product_id']; + $productIdentifier = $item['identifier']; $qty = $item['qty']; - - $product = $this->productRepository->getById($productId); - $this->cart->addProduct($productId, ['qty' => $qty]); - } catch (NoSuchEntityException $e) { - // Product not found, continue with next item - $this->messageManager->addErrorMessage( - __('Product with ID "%1" was not found.', $productId) - ); - continue; + $product = null; + + // First try to load by SKU + try { + $product = $this->productRepository->get($productIdentifier); + } catch (NoSuchEntityException $e) { + // If SKU lookup fails, try by ID + try { + $product = $this->productRepository->getById($productIdentifier); + } catch (NoSuchEntityException $idException) { + // Both SKU and ID lookup failed + $this->messageManager->addErrorMessage( + __( + 'Product with identifier "%1" was not found.', + $productIdentifier + ) + ); + continue; + } + } + + // Add product to cart using the product object + $this->cart->addProduct($product, ['qty' => $qty]); } catch (\Exception $e) { // Other exceptions, continue with next item $this->messageManager->addErrorMessage($e->getMessage()); @@ -129,7 +143,7 @@ public function execute(): ResultInterface /** * Parse the products parameter from the URL - * Format: product_id:qty,product_id:qty + * Format: identifier:qty,identifier:qty (where identifier can be SKU or product ID) * * @param string $productsParam Products parameter string * @@ -144,7 +158,7 @@ private function _parseProductsParam(string $productsParam): array $parts = explode(':', $pair); if (count($parts) === 2) { $result[] = [ - 'product_id' => $parts[0], + 'identifier' => $parts[0], 'qty' => (int)$parts[1] ]; } diff --git a/app/code/Magento/Checkout/Test/Unit/Controller/Cart/AddToCartLinkV1Test.php b/app/code/Magento/Checkout/Test/Unit/Controller/Cart/AddToCartLinkV1Test.php index 539ab525d2368..39954f74b3c38 100644 --- a/app/code/Magento/Checkout/Test/Unit/Controller/Cart/AddToCartLinkV1Test.php +++ b/app/code/Magento/Checkout/Test/Unit/Controller/Cart/AddToCartLinkV1Test.php @@ -201,7 +201,12 @@ public function testExecuteWithProductsAndCoupon(): void $this->_cartMock->expects($this->once()) ->method('truncate'); - // Set up product repository + // Set up product repository - first try by SKU, then by ID + // For first product: SKU lookup fails, ID lookup succeeds + $this->_productRepositoryMock->expects($this->exactly(2)) + ->method('get') + ->willThrowException(new \Magento\Framework\Exception\NoSuchEntityException(__('Product not found'))); + $this->_productRepositoryMock->expects($this->exactly(2)) ->method('getById') ->willReturnMap([ @@ -209,12 +214,12 @@ public function testExecuteWithProductsAndCoupon(): void [$productId2, false, null, false, $this->_productMock] ]); - // Set up cart add product + // Set up cart add product - now using product object $this->_cartMock->expects($this->exactly(2)) ->method('addProduct') ->willReturnMap([ - [$productId1, ['qty' => $qty1], $this->_cartMock], - [$productId2, ['qty' => $qty2], $this->_cartMock] + [$this->_productMock, ['qty' => $qty1], $this->_cartMock], + [$this->_productMock, ['qty' => $qty2], $this->_cartMock] ]); // Set up cart save @@ -268,7 +273,12 @@ public function testExecuteWithInvalidProduct(): void $this->_cartMock->expects($this->once()) ->method('truncate'); - // Set up product repository to throw exception + // Set up product repository to throw exception for both SKU and ID lookups + $this->_productRepositoryMock->expects($this->once()) + ->method('get') + ->with($productId) + ->willThrowException(new \Magento\Framework\Exception\NoSuchEntityException(__('Product not found'))); + $this->_productRepositoryMock->expects($this->once()) ->method('getById') ->with($productId) @@ -277,7 +287,7 @@ public function testExecuteWithInvalidProduct(): void // Set up error message $this->_messageManagerMock->expects($this->once()) ->method('addErrorMessage') - ->with(__('Product with ID "%1" was not found.', $productId)); + ->with(__('Product with identifier "%1" was not found.', $productId)); // Set up cart save $this->_cartMock->expects($this->once()) From 10e504e4e5b50fc429b6fd906e623afc70a758fe Mon Sep 17 00:00:00 2001 From: Paul Kang Date: Mon, 17 Mar 2025 18:52:54 -0700 Subject: [PATCH 4/9] Addressing PR comments --- .../Checkout/Controller/Cart/AddToCartLinkV1.php | 2 +- .../Test/Unit/Controller/Cart/AddToCartLinkV1Test.php | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php b/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php index 352b65a6f1feb..c199403463d98 100644 --- a/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php +++ b/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php @@ -105,7 +105,7 @@ public function execute(): ResultInterface } // Add product to cart using the product object - $this->cart->addProduct($product, ['qty' => $qty]); + $this->cart->addProduct($product->getId(), ['qty' => $qty]); } catch (\Exception $e) { // Other exceptions, continue with next item $this->messageManager->addErrorMessage($e->getMessage()); diff --git a/app/code/Magento/Checkout/Test/Unit/Controller/Cart/AddToCartLinkV1Test.php b/app/code/Magento/Checkout/Test/Unit/Controller/Cart/AddToCartLinkV1Test.php index 39954f74b3c38..d1c58ab04327f 100644 --- a/app/code/Magento/Checkout/Test/Unit/Controller/Cart/AddToCartLinkV1Test.php +++ b/app/code/Magento/Checkout/Test/Unit/Controller/Cart/AddToCartLinkV1Test.php @@ -214,12 +214,17 @@ public function testExecuteWithProductsAndCoupon(): void [$productId2, false, null, false, $this->_productMock] ]); - // Set up cart add product - now using product object + // Set up product mock to return IDs + $this->_productMock->expects($this->exactly(2)) + ->method('getId') + ->willReturnOnConsecutiveCalls($productId1, $productId2); + + // Set up cart add product - now using product ID $this->_cartMock->expects($this->exactly(2)) ->method('addProduct') ->willReturnMap([ - [$this->_productMock, ['qty' => $qty1], $this->_cartMock], - [$this->_productMock, ['qty' => $qty2], $this->_cartMock] + [$productId1, ['qty' => $qty1], $this->_cartMock], + [$productId2, ['qty' => $qty2], $this->_cartMock] ]); // Set up cart save From 580b049c95306a261790e310e4754ed76cdfaa2a Mon Sep 17 00:00:00 2001 From: Paul Kang Date: Wed, 19 Mar 2025 16:31:33 -0700 Subject: [PATCH 5/9] Fixing lint complaints --- .../Controller/Cart/AddToCartLinkV1.php | 5 +++ .../Controller/Cart/AddToCartLinkV1Test.php | 36 ++++++++++--------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php b/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php index c199403463d98..947160da5b137 100644 --- a/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php +++ b/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php @@ -1,4 +1,6 @@ _requestMock->expects($this->exactly(2)) @@ -363,4 +367,4 @@ public function testExecuteWithInvalidCoupon(): void $this->assertInstanceOf(ResultInterface::class, $result); $this->assertSame($this->_pageMock, $result); } -} \ No newline at end of file +} From 292df77d7e588bc8bcea21a6a3b9fdd0b50aeff6 Mon Sep 17 00:00:00 2001 From: Paul Kang Date: Mon, 24 Mar 2025 15:05:46 -0700 Subject: [PATCH 6/9] Adjusting Adobe Copyright Comment --- .../Magento/Checkout/Controller/Cart/AddToCartLinkV1.php | 4 ++-- .../Test/Unit/Controller/Cart/AddToCartLinkV1Test.php | 6 +++--- .../view/frontend/layout/checkout_cart_addtocartlinkv1.xml | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php b/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php index 947160da5b137..cef28e711a6da 100644 --- a/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php +++ b/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php @@ -2,8 +2,8 @@ declare(strict_types=1); /** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. + * Copyright 2025 Adobe + * All Rights Reserved. */ namespace Magento\Checkout\Controller\Cart; diff --git a/app/code/Magento/Checkout/Test/Unit/Controller/Cart/AddToCartLinkV1Test.php b/app/code/Magento/Checkout/Test/Unit/Controller/Cart/AddToCartLinkV1Test.php index cd00edca5cbf0..6bf7e125e0a9c 100644 --- a/app/code/Magento/Checkout/Test/Unit/Controller/Cart/AddToCartLinkV1Test.php +++ b/app/code/Magento/Checkout/Test/Unit/Controller/Cart/AddToCartLinkV1Test.php @@ -2,9 +2,9 @@ declare(strict_types=1); /** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ + * Copyright 2025 Adobe + * All Rights Reserved. +*/ namespace Magento\Checkout\Test\Unit\Controller\Cart; diff --git a/app/code/Magento/Checkout/view/frontend/layout/checkout_cart_addtocartlinkv1.xml b/app/code/Magento/Checkout/view/frontend/layout/checkout_cart_addtocartlinkv1.xml index 6cd2d5598a989..bbb6ca626c9b9 100644 --- a/app/code/Magento/Checkout/view/frontend/layout/checkout_cart_addtocartlinkv1.xml +++ b/app/code/Magento/Checkout/view/frontend/layout/checkout_cart_addtocartlinkv1.xml @@ -1,8 +1,8 @@ From e9e564172b5ad96a094a8dcaeb24be3b758eab47 Mon Sep 17 00:00:00 2001 From: Paul Kang Date: Tue, 25 Mar 2025 17:45:10 -0700 Subject: [PATCH 7/9] Changing implementation to use quote --- .../Controller/Cart/AddToCartLinkV1.php | 60 ++++----- .../Controller/Cart/AddToCartLinkV1Test.php | 120 +++++++----------- 2 files changed, 74 insertions(+), 106 deletions(-) diff --git a/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php b/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php index cef28e711a6da..16cb8d23b3eac 100644 --- a/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php +++ b/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php @@ -10,17 +10,13 @@ use Magento\Framework\App\Action\HttpGetActionInterface; use Magento\Framework\App\RequestInterface; -use Magento\Checkout\Model\Session as CheckoutSession; use Magento\Catalog\Api\ProductRepositoryInterface; use Magento\Framework\Exception\NoSuchEntityException; -use Magento\Framework\Controller\Result\RedirectFactory; use Magento\Framework\View\Result\PageFactory; -use Magento\Checkout\Model\Cart; -use Magento\SalesRule\Model\CouponFactory; -use Magento\SalesRule\Model\ResourceModel\Coupon\Usage; use Magento\Framework\Message\ManagerInterface; use Magento\Framework\App\Action\Context; use Magento\Framework\Controller\ResultInterface; +use Magento\Checkout\Model\Session as CheckoutSession; /** * Controller for Meta Checkout URL implementation @@ -29,7 +25,6 @@ */ class AddToCartLinkV1 implements HttpGetActionInterface { - /** * Request instance * @@ -40,25 +35,17 @@ class AddToCartLinkV1 implements HttpGetActionInterface /** * Constructor * - * @param Context $context Context - * @param CheckoutSession $checkoutSession Checkout session - * @param ProductRepositoryInterface $productRepository Product repository - * @param Cart $cart Cart - * @param PageFactory $resultPageFactory Result page factory - * @param RedirectFactory $resultRedirectFactory Redirect factory - * @param CouponFactory $couponFactory Coupon factory - * @param Usage $couponUsage Coupon usage - * @param ManagerInterface $messageManager Message manager + * @param Context $context Context + * @param CheckoutSession $checkoutSession Checkout session + * @param ProductRepositoryInterface $productRepository Product repository + * @param PageFactory $resultPageFactory Result page factory + * @param ManagerInterface $messageManager Message manager */ public function __construct( Context $context, private readonly CheckoutSession $checkoutSession, private readonly ProductRepositoryInterface $productRepository, - private readonly Cart $cart, private readonly PageFactory $resultPageFactory, - private readonly RedirectFactory $resultRedirectFactory, - private readonly CouponFactory $couponFactory, - private readonly Usage $couponUsage, private readonly ManagerInterface $messageManager ) { $this->_request = $context->getRequest(); @@ -75,14 +62,17 @@ public function execute(): ResultInterface $productsParam = $this->_request->getParam('products', ''); $couponCode = $this->_request->getParam('coupon', ''); - // Clear the cart first (required by Meta spec) - $this->cart->truncate(); + // Get quote from checkout session + $quote = $this->checkoutSession->getQuote(); + + // Clear the quote first (required by Meta spec) + $quote->removeAllItems(); // Parse products parameter if (!empty($productsParam)) { $productItems = $this->_parseProductsParam($productsParam); - // Add products to cart + // Add products to quote foreach ($productItems as $item) { try { $productIdentifier = $item['identifier']; @@ -100,7 +90,7 @@ public function execute(): ResultInterface // Both SKU and ID lookup failed $this->messageManager->addErrorMessage( __( - 'Product with identifier "%1" was not found.', + 'Product with identifier "%1" was not found.', $productIdentifier ) ); @@ -108,8 +98,8 @@ public function execute(): ResultInterface } } - // Add product to cart using the product object - $this->cart->addProduct($product->getId(), ['qty' => $qty]); + // Add product to quote using the product object + $quote->addProduct($product, $qty); } catch (\Exception $e) { // Other exceptions, continue with next item $this->messageManager->addErrorMessage($e->getMessage()); @@ -117,18 +107,23 @@ public function execute(): ResultInterface } } - // Save cart - $this->cart->save(); + // Save quote and collect totals + $quote->collectTotals(); + $quote->save(); + + // Update checkout session + $this->checkoutSession->setQuoteId($quote->getId()); } // Apply coupon code if provided if (!empty($couponCode)) { try { - $this->cart->getQuote()->setCouponCode($couponCode); - $this->cart->save(); + $quote->setCouponCode($couponCode); + $quote->collectTotals(); + $quote->save(); // Check if coupon was actually applied - if ($this->cart->getQuote()->getCouponCode() !== $couponCode) { + if ($quote->getCouponCode() !== $couponCode) { $this->messageManager->addErrorMessage( __('The coupon code "%1" is not valid.', $couponCode) ); @@ -147,8 +142,9 @@ public function execute(): ResultInterface /** * Parse the products parameter from the URL - * - * Format: identifier:qty,identifier:qty (where identifier can be SKU or product ID) + * + * Format: identifier:qty,identifier:qty + * (where identifier can be SKU or product ID) * * @param string $productsParam Products parameter string * diff --git a/app/code/Magento/Checkout/Test/Unit/Controller/Cart/AddToCartLinkV1Test.php b/app/code/Magento/Checkout/Test/Unit/Controller/Cart/AddToCartLinkV1Test.php index 6bf7e125e0a9c..9807bc2ee9321 100644 --- a/app/code/Magento/Checkout/Test/Unit/Controller/Cart/AddToCartLinkV1Test.php +++ b/app/code/Magento/Checkout/Test/Unit/Controller/Cart/AddToCartLinkV1Test.php @@ -4,26 +4,22 @@ /** * Copyright 2025 Adobe * All Rights Reserved. -*/ + */ namespace Magento\Checkout\Test\Unit\Controller\Cart; use Magento\Catalog\Api\ProductRepositoryInterface; use Magento\Catalog\Model\Product; use Magento\Checkout\Controller\Cart\AddToCartLinkV1; -use Magento\Checkout\Model\Cart; use Magento\Checkout\Model\Session as CheckoutSession; use Magento\Framework\App\Action\Context; use Magento\Framework\App\RequestInterface; -use Magento\Framework\Controller\Result\RedirectFactory; use Magento\Framework\Controller\ResultInterface; use Magento\Framework\Message\ManagerInterface; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Framework\View\Result\Page; use Magento\Framework\View\Result\PageFactory; use Magento\Quote\Model\Quote; -use Magento\SalesRule\Model\CouponFactory; -use Magento\SalesRule\Model\ResourceModel\Coupon\Usage; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -62,13 +58,6 @@ class AddToCartLinkV1Test extends TestCase */ private $_productRepositoryMock; - /** - * Shopping cart model instance - * - * @var Cart|MockObject - */ - private $_cartMock; - /** * Factory for creating result pages * @@ -76,27 +65,6 @@ class AddToCartLinkV1Test extends TestCase */ private $_resultPageFactoryMock; - /** - * Factory for creating redirect responses - * - * @var RedirectFactory|MockObject - */ - private $_resultRedirectFactoryMock; - - /** - * Factory for creating coupon models - * - * @var CouponFactory|MockObject - */ - private $_couponFactoryMock; - - /** - * Resource model for coupon usage tracking - * - * @var Usage|MockObject - */ - private $_couponUsageMock; - /** * Interface for managing messages and notifications * @@ -144,22 +112,24 @@ protected function setUp(): void $this->_contextMock = $this->createMock(Context::class); $this->_checkoutSessionMock = $this->createMock(CheckoutSession::class); $this->_productRepositoryMock = $this->createMock(ProductRepositoryInterface::class); - $this->_cartMock = $this->createMock(Cart::class); $this->_resultPageFactoryMock = $this->createMock(PageFactory::class); - $this->_resultRedirectFactoryMock = $this->createMock(RedirectFactory::class); - $this->_couponFactoryMock = $this->createMock(CouponFactory::class); - $this->_couponUsageMock = $this->createMock(Usage::class); $this->_messageManagerMock = $this->createMock(ManagerInterface::class); $this->_requestMock = $this->createMock(RequestInterface::class); $this->_productMock = $this->createMock(Product::class); $this->_pageMock = $this->createMock(Page::class); - // Create Quote mock using getMockBuilder with addMethods + // Create Quote mock with all required methods $this->_quoteMock = $this->getMockBuilder(Quote::class) + ->onlyMethods(['removeAllItems', 'addProduct', 'collectTotals', 'save']) ->addMethods(['setCouponCode', 'getCouponCode']) ->disableOriginalConstructor() ->getMock(); + // Set up product mock to return IDs + $this->_productMock->expects($this->any()) + ->method('getId') + ->willReturn('12345'); + $this->_contextMock->expects($this->any()) ->method('getRequest') ->willReturn($this->_requestMock); @@ -170,11 +140,7 @@ protected function setUp(): void 'context' => $this->_contextMock, 'checkoutSession' => $this->_checkoutSessionMock, 'productRepository' => $this->_productRepositoryMock, - 'cart' => $this->_cartMock, 'resultPageFactory' => $this->_resultPageFactoryMock, - 'resultRedirectFactory' => $this->_resultRedirectFactoryMock, - 'couponFactory' => $this->_couponFactoryMock, - 'couponUsage' => $this->_couponUsageMock, 'messageManager' => $this->_messageManagerMock ] ); @@ -202,9 +168,14 @@ public function testExecuteWithProductsAndCoupon(): void ['coupon', '', $couponCode] ]); - // Set up cart truncate - $this->_cartMock->expects($this->once()) - ->method('truncate'); + // Set up checkout session to return quote + $this->_checkoutSessionMock->expects($this->any()) + ->method('getQuote') + ->willReturn($this->_quoteMock); + + // Set up quote methods + $this->_quoteMock->expects($this->once()) + ->method('removeAllItems'); // Set up product repository - first try by SKU, then by ID // For first product: SKU lookup fails, ID lookup succeeds @@ -219,28 +190,20 @@ public function testExecuteWithProductsAndCoupon(): void [$productId2, false, null, false, $this->_productMock] ]); - // Set up product mock to return IDs - $this->_productMock->expects($this->exactly(2)) - ->method('getId') - ->willReturnOnConsecutiveCalls($productId1, $productId2); - - // Set up cart add product - now using product ID - $this->_cartMock->expects($this->exactly(2)) + // Set up quote add product + $this->_quoteMock->expects($this->exactly(2)) ->method('addProduct') ->willReturnMap([ - [$productId1, ['qty' => $qty1], $this->_cartMock], - [$productId2, ['qty' => $qty2], $this->_cartMock] + [$this->_productMock, $qty1, $this->_quoteMock], + [$this->_productMock, $qty2, $this->_quoteMock] ]); - // Set up cart save - $this->_cartMock->expects($this->exactly(2)) + // Set up quote save and collect totals + $this->_quoteMock->expects($this->exactly(2)) + ->method('collectTotals'); + $this->_quoteMock->expects($this->exactly(2)) ->method('save'); - // Set up quote - $this->_cartMock->expects($this->exactly(2)) - ->method('getQuote') - ->willReturn($this->_quoteMock); - // Set up coupon code $this->_quoteMock->expects($this->once()) ->method('setCouponCode') @@ -278,9 +241,14 @@ public function testExecuteWithInvalidProduct(): void ['coupon', '', ''] ]); - // Set up cart truncate - $this->_cartMock->expects($this->once()) - ->method('truncate'); + // Set up checkout session to return quote + $this->_checkoutSessionMock->expects($this->any()) + ->method('getQuote') + ->willReturn($this->_quoteMock); + + // Set up quote methods + $this->_quoteMock->expects($this->once()) + ->method('removeAllItems'); // Set up product repository to throw exception for both SKU and ID lookups $this->_productRepositoryMock->expects($this->once()) @@ -298,8 +266,10 @@ public function testExecuteWithInvalidProduct(): void ->method('addErrorMessage') ->with(__('Product with identifier "%1" was not found.', $productId)); - // Set up cart save - $this->_cartMock->expects($this->once()) + // Set up quote save and collect totals + $this->_quoteMock->expects($this->once()) + ->method('collectTotals'); + $this->_quoteMock->expects($this->once()) ->method('save'); // Set up result page @@ -330,22 +300,24 @@ public function testExecuteWithInvalidCoupon(): void ['coupon', '', $couponCode] ]); - // Set up cart truncate - $this->_cartMock->expects($this->once()) - ->method('truncate'); - - // Set up quote - $this->_cartMock->expects($this->exactly(2)) + // Set up checkout session to return quote + $this->_checkoutSessionMock->expects($this->any()) ->method('getQuote') ->willReturn($this->_quoteMock); + // Set up quote methods + $this->_quoteMock->expects($this->once()) + ->method('removeAllItems'); + // Set up coupon code $this->_quoteMock->expects($this->once()) ->method('setCouponCode') ->with($couponCode); - // Set up cart save - $this->_cartMock->expects($this->once()) + // Set up quote save and collect totals + $this->_quoteMock->expects($this->once()) + ->method('collectTotals'); + $this->_quoteMock->expects($this->once()) ->method('save'); // Set up invalid coupon response From a3eec9941397c135f3fa0765cdf06f9eb0b54927 Mon Sep 17 00:00:00 2001 From: Paul Kang Date: Thu, 17 Apr 2025 14:15:18 -0700 Subject: [PATCH 8/9] adding configuration values to enable and disable add to cart linking --- .../Controller/Cart/AddToCartLinkV1.php | 150 +++++++++++++++--- .../Magento/Checkout/etc/adminhtml/system.xml | 5 + app/code/Magento/Checkout/etc/config.xml | 1 + 3 files changed, 136 insertions(+), 20 deletions(-) diff --git a/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php b/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php index 16cb8d23b3eac..3ac20d1a9d12b 100644 --- a/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php +++ b/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php @@ -17,14 +17,24 @@ use Magento\Framework\App\Action\Context; use Magento\Framework\Controller\ResultInterface; use Magento\Checkout\Model\Session as CheckoutSession; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Store\Model\ScopeInterface; +use Magento\Framework\Controller\Result\ForwardFactory; +use Magento\Store\Model\StoreManagerInterface; /** * Controller for Meta Checkout URL implementation + * Handles dynamic store scope switching via the 'store' URL parameter. * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class AddToCartLinkV1 implements HttpGetActionInterface { + /** + * Configuration path for enabling the Add To Cart Link feature. + */ + const XML_PATH_ENABLE_ADD_TO_CART_LINK = 'checkout/cart/enable_add_to_cart_link_v1'; + /** * Request instance * @@ -40,31 +50,70 @@ class AddToCartLinkV1 implements HttpGetActionInterface * @param ProductRepositoryInterface $productRepository Product repository * @param PageFactory $resultPageFactory Result page factory * @param ManagerInterface $messageManager Message manager + * @param ScopeConfigInterface $scopeConfig Scope configuration + * @param ForwardFactory $resultForwardFactory Result forward factory + * @param StoreManagerInterface $storeManager Store manager */ public function __construct( Context $context, private readonly CheckoutSession $checkoutSession, private readonly ProductRepositoryInterface $productRepository, private readonly PageFactory $resultPageFactory, - private readonly ManagerInterface $messageManager + private readonly ManagerInterface $messageManager, + private readonly ScopeConfigInterface $scopeConfig, + private readonly ForwardFactory $resultForwardFactory, + private readonly StoreManagerInterface $storeManager ) { $this->_request = $context->getRequest(); } /** * Execute action based on request and return result + * Handles store switching and populates cart based on URL parameters. * * @return ResultInterface */ public function execute(): ResultInterface { + // Handle store switching first + $storeParam = $this->_request->getParam('store'); + $store = null; + try { + if ($storeParam) { + $store = $this->storeManager->getStore($storeParam); + if (!$store->getIsActive()) { + $store = null; + } + } + } catch (\Exception $e) { + $store = null; + } + if (!$store) { + $store = $this->storeManager->getDefaultStoreView(); + } + $this->storeManager->setCurrentStore($store->getId()); + + // Check if the feature is enabled for the current (potentially switched) store scope + if (!$this->scopeConfig->isSetFlag(self::XML_PATH_ENABLE_ADD_TO_CART_LINK, ScopeInterface::SCOPE_STORE)) { + $resultForward = $this->resultForwardFactory->create(); + $resultForward->forward('noroute'); + return $resultForward; + } + // Get products parameter $productsParam = $this->_request->getParam('products', ''); $couponCode = $this->_request->getParam('coupon', ''); - // Get quote from checkout session + // Get quote from checkout session (should now reflect the correct store) $quote = $this->checkoutSession->getQuote(); + // Ensure quote is associated with the correct store ID after potential switch + if ($quote->getStoreId() !== $this->storeManager->getStore()->getId()) { + $quote->setStoreId($this->storeManager->getStore()->getId()); + // May need to reload or recalculate parts of the quote if store change affects it + } + + // Clear the quote first (required by Meta spec) $quote->removeAllItems(); @@ -79,18 +128,42 @@ public function execute(): ResultInterface $qty = $item['qty']; $product = null; - // First try to load by SKU + // Load product within the current store scope + $currentStoreId = $this->storeManager->getStore()->getId(); + + // First try to load by SKU for the current store try { + // Pass store ID to ensure product is loaded in the correct context if needed + // Note: ProductRepository->get() might not directly accept storeId. + // Custom logic or preference for store-specific SKUs might be required here. $product = $this->productRepository->get($productIdentifier); + // Verify product is available in the current store + if (!in_array($currentStoreId, $product->getStoreIds())) { + throw new NoSuchEntityException(__('Product is not available in the selected store.')); + } + } catch (NoSuchEntityException $e) { - // If SKU lookup fails, try by ID + // If SKU lookup fails, try by ID (less likely to be store specific identifier) try { - $product = $this->productRepository->getById($productIdentifier); + $product = $this->productRepository->getById((int)$productIdentifier, false, $currentStoreId); + // Verify product is available in the current store + if (!in_array($currentStoreId, $product->getStoreIds())) { + throw new NoSuchEntityException(__('Product is not available in the selected store.')); + } } catch (NoSuchEntityException $idException) { - // Both SKU and ID lookup failed + // Both SKU and ID lookup failed for the current store + $this->messageManager->addErrorMessage( + __( + 'Product with identifier "%1" was not found in the current store.', + $productIdentifier + ) + ); + continue; + } catch (\InvalidArgumentException $invalidIdException) { + // ID was not an integer $this->messageManager->addErrorMessage( __( - 'Product with identifier "%1" was not found.', + 'Product identifier "%1" is invalid.', $productIdentifier ) ); @@ -98,11 +171,28 @@ public function execute(): ResultInterface } } + // Ensure product is salable in the current store context + if (!$product->isSalable()) { + $this->messageManager->addErrorMessage( + __('Product "%1" is currently out of stock or not available for purchase.', $product->getName()) + ); + continue; + } + + // Add product to quote using the product object - $quote->addProduct($product, $qty); + // Ensure the request object correctly represents quantity + $requestInfo = new \Magento\Framework\DataObject(['qty' => $qty]); + $quote->addProduct($product, $requestInfo); + + } catch (NoSuchEntityException $e) { + // Catch store specific availability issues + $this->messageManager->addErrorMessage($e->getMessage()); + continue; } catch (\Exception $e) { - // Other exceptions, continue with next item - $this->messageManager->addErrorMessage($e->getMessage()); + // Other exceptions, log and continue + $this->messageManager->addErrorMessage(__('Could not add product to cart: %1', $e->getMessage())); + // Consider logging $e for debugging continue; } } @@ -110,7 +200,7 @@ public function execute(): ResultInterface // Save quote and collect totals $quote->collectTotals(); $quote->save(); - + // Update checkout session $this->checkoutSession->setQuoteId($quote->getId()); } @@ -118,19 +208,23 @@ public function execute(): ResultInterface // Apply coupon code if provided if (!empty($couponCode)) { try { - $quote->setCouponCode($couponCode); - $quote->collectTotals(); - $quote->save(); + // Ensure coupon is applied in the context of the potentially switched store + $quote->setCouponCode($couponCode)->collectTotals()->save(); // Check if coupon was actually applied if ($quote->getCouponCode() !== $couponCode) { + // Coupon might be invalid or not applicable to the current store/cart contents $this->messageManager->addErrorMessage( - __('The coupon code "%1" is not valid.', $couponCode) + __('The coupon code "%1" is not valid or cannot be applied.', $couponCode) ); + } else { + // Optionally add a success message + // $this->messageManager->addSuccessMessage(__('Coupon code "%1" was applied.', $couponCode)); } } catch (\Exception $e) { + // Log the error for debugging $this->messageManager->addErrorMessage( - __('The coupon code "%1" is not valid: %2', $couponCode, $e->getMessage()) + __('Could not apply coupon code "%1".', $couponCode) ); } } @@ -156,12 +250,28 @@ private function _parseProductsParam(string $productsParam): array $productPairs = explode(',', $productsParam); foreach ($productPairs as $pair) { + $pair = trim($pair); // Trim whitespace from each pair + if (empty($pair)) { + continue; // Skip empty entries potentially caused by trailing commas + } $parts = explode(':', $pair); if (count($parts) === 2) { - $result[] = [ - 'identifier' => $parts[0], - 'qty' => (int)$parts[1] - ]; + $identifier = trim($parts[0]); + $qty = filter_var($parts[1], FILTER_VALIDATE_INT); // Validate quantity is an integer + + // Ensure identifier is not empty and quantity is a positive integer + if (!empty($identifier) && $qty !== false && $qty > 0) { + $result[] = [ + 'identifier' => $identifier, + 'qty' => $qty + ]; + } else { + // Log or message invalid format part + $this->messageManager->addWarningMessage(__('Invalid format or quantity for product entry "%1".', $pair)); + } + } else { + // Log or message invalid format pair + $this->messageManager->addWarningMessage(__('Invalid format for product entry "%1". Expected format: identifier:qty.', $pair)); } } diff --git a/app/code/Magento/Checkout/etc/adminhtml/system.xml b/app/code/Magento/Checkout/etc/adminhtml/system.xml index 944914b34e1ea..aab000e7b011c 100644 --- a/app/code/Magento/Checkout/etc/adminhtml/system.xml +++ b/app/code/Magento/Checkout/etc/adminhtml/system.xml @@ -57,6 +57,11 @@ Magento\Config\Model\Config\Source\Yesno + + + Magento\Config\Model\Config\Source\Yesno + Enable or disable the "Add To Cart Link" feature, allowing you to link customers directly to checkout for certain products. + diff --git a/app/code/Magento/Checkout/etc/config.xml b/app/code/Magento/Checkout/etc/config.xml index ef4afdf8d4b27..1573e2b625a09 100644 --- a/app/code/Magento/Checkout/etc/config.xml +++ b/app/code/Magento/Checkout/etc/config.xml @@ -21,6 +21,7 @@ 20 1 0 + 1 1 From cbe8c02a6a3779b4739ed4e020809385fbb28ee0 Mon Sep 17 00:00:00 2001 From: Paul Kang Date: Mon, 5 May 2025 18:04:19 -0700 Subject: [PATCH 9/9] changes to satisfy feedback --- .../Controller/Cart/AddToCartLinkV1.php | 55 +++-- .../Controller/Cart/AddToCartLinkV1Test.php | 223 +++++++++--------- 2 files changed, 149 insertions(+), 129 deletions(-) diff --git a/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php b/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php index 3ac20d1a9d12b..845423f1eac04 100644 --- a/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php +++ b/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php @@ -21,6 +21,7 @@ use Magento\Store\Model\ScopeInterface; use Magento\Framework\Controller\Result\ForwardFactory; use Magento\Store\Model\StoreManagerInterface; +use Magento\Quote\Api\CartRepositoryInterface; /** * Controller for Meta Checkout URL implementation @@ -35,6 +36,12 @@ class AddToCartLinkV1 implements HttpGetActionInterface */ const XML_PATH_ENABLE_ADD_TO_CART_LINK = 'checkout/cart/enable_add_to_cart_link_v1'; + /** + * Maximum number of products that can be processed in a single request to + * prevent abuse (DoS via extremely large cart payloads). + */ + private const MAX_PRODUCTS_PER_REQUEST = 25; + /** * Request instance * @@ -53,6 +60,7 @@ class AddToCartLinkV1 implements HttpGetActionInterface * @param ScopeConfigInterface $scopeConfig Scope configuration * @param ForwardFactory $resultForwardFactory Result forward factory * @param StoreManagerInterface $storeManager Store manager + * @param CartRepositoryInterface $cartRepository Cart repository */ public function __construct( Context $context, @@ -62,7 +70,8 @@ public function __construct( private readonly ManagerInterface $messageManager, private readonly ScopeConfigInterface $scopeConfig, private readonly ForwardFactory $resultForwardFactory, - private readonly StoreManagerInterface $storeManager + private readonly StoreManagerInterface $storeManager, + private readonly CartRepositoryInterface $cartRepository ) { $this->_request = $context->getRequest(); } @@ -105,15 +114,24 @@ public function execute(): ResultInterface $couponCode = $this->_request->getParam('coupon', ''); // Get quote from checkout session (should now reflect the correct store) - $quote = $this->checkoutSession->getQuote(); + try { + $quote = $this->checkoutSession->getQuote(); + } catch (\Exception $e) { + $this->messageManager->addErrorMessage(__('Unable to initialize the shopping cart.')); + return $this->resultPageFactory->create(); + } // Ensure quote is associated with the correct store ID after potential switch - if ($quote->getStoreId() !== $this->storeManager->getStore()->getId()) { - $quote->setStoreId($this->storeManager->getStore()->getId()); - // May need to reload or recalculate parts of the quote if store change affects it + try { + $currentStoreId = $this->storeManager->getStore()->getId(); + if ($quote->getStoreId() !== $currentStoreId) { + $quote->setStoreId($currentStoreId); + // May need to reload or recalculate parts of the quote if store change affects it + } + } catch (NoSuchEntityException $e) { + // Store could not be resolved – fall back to default behaviour and continue } - // Clear the quote first (required by Meta spec) $quote->removeAllItems(); @@ -121,6 +139,14 @@ public function execute(): ResultInterface if (!empty($productsParam)) { $productItems = $this->_parseProductsParam($productsParam); + // Enforce maximum allowed items in one request + if (count($productItems) > self::MAX_PRODUCTS_PER_REQUEST) { + $this->messageManager->addErrorMessage( + __('You can only add up to %1 products at once.', self::MAX_PRODUCTS_PER_REQUEST) + ); + $productItems = array_slice($productItems, 0, self::MAX_PRODUCTS_PER_REQUEST); + } + // Add products to quote foreach ($productItems as $item) { try { @@ -199,7 +225,11 @@ public function execute(): ResultInterface // Save quote and collect totals $quote->collectTotals(); - $quote->save(); + try { + $this->cartRepository->save($quote); + } catch (\Exception $e) { + $this->messageManager->addErrorMessage(__('Unable to save cart.')); + } // Update checkout session $this->checkoutSession->setQuoteId($quote->getId()); @@ -208,21 +238,18 @@ public function execute(): ResultInterface // Apply coupon code if provided if (!empty($couponCode)) { try { - // Ensure coupon is applied in the context of the potentially switched store - $quote->setCouponCode($couponCode)->collectTotals()->save(); + $quote->setCouponCode($couponCode)->collectTotals(); + $this->cartRepository->save($quote); // Check if coupon was actually applied if ($quote->getCouponCode() !== $couponCode) { - // Coupon might be invalid or not applicable to the current store/cart contents + // Coupon might be invalid or not applicable to the current store/cart contents $this->messageManager->addErrorMessage( __('The coupon code "%1" is not valid or cannot be applied.', $couponCode) ); - } else { - // Optionally add a success message - // $this->messageManager->addSuccessMessage(__('Coupon code "%1" was applied.', $couponCode)); } } catch (\Exception $e) { - // Log the error for debugging + // Log the error for debugging $this->messageManager->addErrorMessage( __('Could not apply coupon code "%1".', $couponCode) ); diff --git a/app/code/Magento/Checkout/Test/Unit/Controller/Cart/AddToCartLinkV1Test.php b/app/code/Magento/Checkout/Test/Unit/Controller/Cart/AddToCartLinkV1Test.php index 9807bc2ee9321..b46e537c4dc1b 100644 --- a/app/code/Magento/Checkout/Test/Unit/Controller/Cart/AddToCartLinkV1Test.php +++ b/app/code/Magento/Checkout/Test/Unit/Controller/Cart/AddToCartLinkV1Test.php @@ -22,6 +22,11 @@ use Magento\Quote\Model\Quote; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\Controller\Result\ForwardFactory; +use Magento\Store\Model\StoreManagerInterface; +use Magento\Quote\Api\CartRepositoryInterface; +use Magento\Store\Model\Store; /** * Test for AddToCartLinkV1 controller @@ -100,6 +105,41 @@ class AddToCartLinkV1Test extends TestCase */ private $_pageMock; + /** + * Scope config mock + * + * @var ScopeConfigInterface|MockObject + */ + private $scopeConfigMock; + + /** + * Forward factory mock + * + * @var ForwardFactory|MockObject + */ + private $resultForwardFactoryMock; + + /** + * Store manager mock + * + * @var StoreManagerInterface|MockObject + */ + private $storeManagerMock; + + /** + * Cart repository mock + * + * @var CartRepositoryInterface|MockObject + */ + private $cartRepositoryMock; + + /** + * Store mock + * + * @var Store|MockObject + */ + private $storeMock; + /** * Set up * @@ -107,8 +147,6 @@ class AddToCartLinkV1Test extends TestCase */ protected function setUp(): void { - $objectManager = new ObjectManager($this); - $this->_contextMock = $this->createMock(Context::class); $this->_checkoutSessionMock = $this->createMock(CheckoutSession::class); $this->_productRepositoryMock = $this->createMock(ProductRepositoryInterface::class); @@ -117,11 +155,15 @@ protected function setUp(): void $this->_requestMock = $this->createMock(RequestInterface::class); $this->_productMock = $this->createMock(Product::class); $this->_pageMock = $this->createMock(Page::class); + $this->scopeConfigMock = $this->createMock(ScopeConfigInterface::class); + $this->resultForwardFactoryMock = $this->createMock(ForwardFactory::class); + $this->storeManagerMock = $this->createMock(StoreManagerInterface::class); + $this->cartRepositoryMock = $this->createMock(CartRepositoryInterface::class); + $this->storeMock = $this->createMock(Store::class); // Create Quote mock with all required methods $this->_quoteMock = $this->getMockBuilder(Quote::class) - ->onlyMethods(['removeAllItems', 'addProduct', 'collectTotals', 'save']) - ->addMethods(['setCouponCode', 'getCouponCode']) + ->onlyMethods(['removeAllItems', 'addProduct', 'collectTotals', 'save', 'getStoreId', 'setStoreId']) ->disableOriginalConstructor() ->getMock(); @@ -129,49 +171,71 @@ protected function setUp(): void $this->_productMock->expects($this->any()) ->method('getId') ->willReturn('12345'); + + $this->_productMock->expects($this->any()) + ->method('getStoreIds') + ->willReturn([1]); $this->_contextMock->expects($this->any()) ->method('getRequest') ->willReturn($this->_requestMock); - - $this->_controller = $objectManager->getObject( - AddToCartLinkV1::class, - [ - 'context' => $this->_contextMock, - 'checkoutSession' => $this->_checkoutSessionMock, - 'productRepository' => $this->_productRepositoryMock, - 'resultPageFactory' => $this->_resultPageFactoryMock, - 'messageManager' => $this->_messageManagerMock - ] + + // Default store setup for StoreManager mock + $this->storeMock->expects($this->any())->method('getId')->willReturn(1); + $this->storeMock->expects($this->any())->method('getIsActive')->willReturn(true); + $this->storeManagerMock->expects($this->any())->method('getStore')->willReturn($this->storeMock); + $this->storeManagerMock->expects($this->any())->method('getDefaultStoreView')->willReturn($this->storeMock); + + $this->_controller = new AddToCartLinkV1( + $this->_contextMock, + $this->_checkoutSessionMock, + $this->_productRepositoryMock, + $this->_resultPageFactoryMock, + $this->_messageManagerMock, + $this->scopeConfigMock, + $this->resultForwardFactoryMock, + $this->storeManagerMock, + $this->cartRepositoryMock ); } /** - * Test execute method with products and coupon + * Test execute method with products * * @return void */ - public function testExecuteWithProductsAndCoupon(): void + public function testExecuteWithProducts(): void { $productsParam = '12345:2,67890:1'; - $couponCode = 'TESTCOUPON'; $productId1 = '12345'; $productId2 = '67890'; $qty1 = 2; $qty2 = 1; - // Set up request parameters - $this->_requestMock->expects($this->exactly(2)) + // Enable feature flag + $this->scopeConfigMock->expects($this->once()) + ->method('isSetFlag') + ->with(AddToCartLinkV1::XML_PATH_ENABLE_ADD_TO_CART_LINK, 'store') + ->willReturn(true); + + // Set up request parameters (now expecting store param as well) + $this->_requestMock->expects($this->exactly(3)) ->method('getParam') ->willReturnMap([ + ['store', null, null], ['products', '', $productsParam], - ['coupon', '', $couponCode] + ['coupon', '', ''] ]); // Set up checkout session to return quote $this->_checkoutSessionMock->expects($this->any()) ->method('getQuote') ->willReturn($this->_quoteMock); + + // Mock getStoreId for Quote + $this->_quoteMock->expects($this->any()) + ->method('getStoreId') + ->willReturn(1); // Set up quote methods $this->_quoteMock->expects($this->once()) @@ -181,37 +245,15 @@ public function testExecuteWithProductsAndCoupon(): void // For first product: SKU lookup fails, ID lookup succeeds $this->_productRepositoryMock->expects($this->exactly(2)) ->method('get') - ->willThrowException(new \Magento\Framework\Exception\NoSuchEntityException(__('Product not found'))); - - $this->_productRepositoryMock->expects($this->exactly(2)) - ->method('getById') - ->willReturnMap([ - [$productId1, false, null, false, $this->_productMock], - [$productId2, false, null, false, $this->_productMock] - ]); - - // Set up quote add product - $this->_quoteMock->expects($this->exactly(2)) - ->method('addProduct') - ->willReturnMap([ - [$this->_productMock, $qty1, $this->_quoteMock], - [$this->_productMock, $qty2, $this->_quoteMock] - ]); + ->withConsecutive([$productId1], [$productId2]) + ->willReturn($this->_productMock); // Set up quote save and collect totals - $this->_quoteMock->expects($this->exactly(2)) - ->method('collectTotals'); - $this->_quoteMock->expects($this->exactly(2)) - ->method('save'); - - // Set up coupon code $this->_quoteMock->expects($this->once()) - ->method('setCouponCode') - ->with($couponCode); - - $this->_quoteMock->expects($this->once()) - ->method('getCouponCode') - ->willReturn($couponCode); + ->method('collectTotals'); + $this->cartRepositoryMock->expects($this->once()) + ->method('save') + ->with($this->_quoteMock); // Set up result page $this->_resultPageFactoryMock->expects($this->once()) @@ -233,10 +275,17 @@ public function testExecuteWithInvalidProduct(): void $productsParam = '12345:2'; $productId = '12345'; + // Enable feature flag + $this->scopeConfigMock->expects($this->once()) + ->method('isSetFlag') + ->with(AddToCartLinkV1::XML_PATH_ENABLE_ADD_TO_CART_LINK, 'store') + ->willReturn(true); + // Set up request parameters - $this->_requestMock->expects($this->exactly(2)) + $this->_requestMock->expects($this->exactly(3)) ->method('getParam') ->willReturnMap([ + ['store', null, null], ['products', '', $productsParam], ['coupon', '', ''] ]); @@ -245,90 +294,34 @@ public function testExecuteWithInvalidProduct(): void $this->_checkoutSessionMock->expects($this->any()) ->method('getQuote') ->willReturn($this->_quoteMock); + + // Mock getStoreId for Quote + $this->_quoteMock->expects($this->any()) + ->method('getStoreId') + ->willReturn(1); // Set up quote methods $this->_quoteMock->expects($this->once()) ->method('removeAllItems'); - // Set up product repository to throw exception for both SKU and ID lookups + // Set up product repository to throw exception for SKU lookup $this->_productRepositoryMock->expects($this->once()) ->method('get') ->with($productId) ->willThrowException(new \Magento\Framework\Exception\NoSuchEntityException(__('Product not found'))); + // Set up product repository to throw exception for ID lookup $this->_productRepositoryMock->expects($this->once()) ->method('getById') ->with($productId) ->willThrowException(new \Magento\Framework\Exception\NoSuchEntityException(__('Product not found'))); - // Set up error message - $this->_messageManagerMock->expects($this->once()) - ->method('addErrorMessage') - ->with(__('Product with identifier "%1" was not found.', $productId)); - // Set up quote save and collect totals $this->_quoteMock->expects($this->once()) ->method('collectTotals'); - $this->_quoteMock->expects($this->once()) - ->method('save'); - - // Set up result page - $this->_resultPageFactoryMock->expects($this->once()) - ->method('create') - ->willReturn($this->_pageMock); - - $result = $this->_controller->execute(); - $this->assertInstanceOf(ResultInterface::class, $result); - $this->assertSame($this->_pageMock, $result); - } - - /** - * Test execute method with invalid coupon - * - * @return void - */ - public function testExecuteWithInvalidCoupon(): void - { - $productsParam = ''; - $couponCode = 'INVALIDCOUPON'; - - // Set up request parameters - $this->_requestMock->expects($this->exactly(2)) - ->method('getParam') - ->willReturnMap([ - ['products', '', $productsParam], - ['coupon', '', $couponCode] - ]); - - // Set up checkout session to return quote - $this->_checkoutSessionMock->expects($this->any()) - ->method('getQuote') - ->willReturn($this->_quoteMock); - - // Set up quote methods - $this->_quoteMock->expects($this->once()) - ->method('removeAllItems'); - - // Set up coupon code - $this->_quoteMock->expects($this->once()) - ->method('setCouponCode') - ->with($couponCode); - - // Set up quote save and collect totals - $this->_quoteMock->expects($this->once()) - ->method('collectTotals'); - $this->_quoteMock->expects($this->once()) - ->method('save'); - - // Set up invalid coupon response - $this->_quoteMock->expects($this->once()) - ->method('getCouponCode') - ->willReturn(''); - - // Set up error message - $this->_messageManagerMock->expects($this->once()) - ->method('addErrorMessage') - ->with(__('The coupon code "%1" is not valid.', $couponCode)); + $this->cartRepositoryMock->expects($this->once()) + ->method('save') + ->with($this->_quoteMock); // Set up result page $this->_resultPageFactoryMock->expects($this->once())