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..845423f1eac04 --- /dev/null +++ b/app/code/Magento/Checkout/Controller/Cart/AddToCartLinkV1.php @@ -0,0 +1,307 @@ +_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 (should now reflect the correct store) + 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 + 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(); + + // Parse products parameter + 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 { + $productIdentifier = $item['identifier']; + $qty = $item['qty']; + $product = null; + + // 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 (less likely to be store specific identifier) + try { + $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 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 identifier "%1" is invalid.', + $productIdentifier + ) + ); + continue; + } + } + + // 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 + // 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, log and continue + $this->messageManager->addErrorMessage(__('Could not add product to cart: %1', $e->getMessage())); + // Consider logging $e for debugging + continue; + } + } + + // Save quote and collect totals + $quote->collectTotals(); + try { + $this->cartRepository->save($quote); + } catch (\Exception $e) { + $this->messageManager->addErrorMessage(__('Unable to save cart.')); + } + + // Update checkout session + $this->checkoutSession->setQuoteId($quote->getId()); + } + + // Apply coupon code if provided + if (!empty($couponCode)) { + try { + $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 + $this->messageManager->addErrorMessage( + __('The coupon code "%1" is not valid or cannot be applied.', $couponCode) + ); + } + } catch (\Exception $e) { + // Log the error for debugging + $this->messageManager->addErrorMessage( + __('Could not apply coupon code "%1".', $couponCode) + ); + } + } + + // 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: identifier:qty,identifier:qty + * (where identifier can be SKU or product ID) + * + * @param string $productsParam Products parameter string + * + * @return array> + */ + private function _parseProductsParam(string $productsParam): array + { + $result = []; + $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) { + $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)); + } + } + + return $result; + } +} 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..b46e537c4dc1b --- /dev/null +++ b/app/code/Magento/Checkout/Test/Unit/Controller/Cart/AddToCartLinkV1Test.php @@ -0,0 +1,335 @@ +_contextMock = $this->createMock(Context::class); + $this->_checkoutSessionMock = $this->createMock(CheckoutSession::class); + $this->_productRepositoryMock = $this->createMock(ProductRepositoryInterface::class); + $this->_resultPageFactoryMock = $this->createMock(PageFactory::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); + $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', 'getStoreId', 'setStoreId']) + ->disableOriginalConstructor() + ->getMock(); + + // Set up product mock to return IDs + $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); + + // 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 + * + * @return void + */ + public function testExecuteWithProducts(): void + { + $productsParam = '12345:2,67890:1'; + $productId1 = '12345'; + $productId2 = '67890'; + $qty1 = 2; + $qty2 = 1; + + // 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', '', ''] + ]); + + // 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()) + ->method('removeAllItems'); + + // 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') + ->withConsecutive([$productId1], [$productId2]) + ->willReturn($this->_productMock); + + // Set up quote save and collect totals + $this->_quoteMock->expects($this->once()) + ->method('collectTotals'); + $this->cartRepositoryMock->expects($this->once()) + ->method('save') + ->with($this->_quoteMock); + + // 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'; + + // 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(3)) + ->method('getParam') + ->willReturnMap([ + ['store', null, null], + ['products', '', $productsParam], + ['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()) + ->method('removeAllItems'); + + // 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 quote save and collect totals + $this->_quoteMock->expects($this->once()) + ->method('collectTotals'); + $this->cartRepositoryMock->expects($this->once()) + ->method('save') + ->with($this->_quoteMock); + + // 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); + } +} 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 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..bbb6ca626c9b9 --- /dev/null +++ b/app/code/Magento/Checkout/view/frontend/layout/checkout_cart_addtocartlinkv1.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file