Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 18 additions & 13 deletions app/code/Magento/Quote/Model/Cart/AddProductsToCart.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,20 +92,24 @@ public function execute(string $maskedCartId, array $cartItems): AddProductsToCa
public function addItemsToCart(Quote $cart, array $cartItems): array
{
$failedCartItems = [];
// add new cart items for preload
$skus = \array_map(
function ($item) {
return $item->getSku();
},
$cartItems
);
$this->productReader->loadProducts($skus, $cart->getStoreId());

// Collect all SKUs to pre-load: child SKUs + parent SKUs when present (GH-40598)
$skus = [];
foreach ($cartItems as $cartItem) {
$skus[] = $cartItem->getSku();
if ($cartItem->getParentSku() !== null) {
$skus[] = $cartItem->getParentSku();
}
}
$this->productReader->loadProducts(array_unique($skus), $cart->getStoreId());

foreach ($cartItems as $cartItemPosition => $cartItem) {
$product = $this->productReader->getProductBySku($cartItem->getSku());
// Use the child product for stock quantity display, regardless of which product is added
$childProduct = $this->productReader->getProductBySku($cartItem->getSku());
$stockItemQuantity = 0.0;
if ($product) {
if ($childProduct) {
$stockItem = $this->stockRegistry->getStockItem(
$product->getId(),
$childProduct->getId(),
$cart->getStore()->getWebsiteId()
);
$stockItemQuantity = $stockItem->getQty() - $stockItem->getMinQty();
Expand Down Expand Up @@ -146,13 +150,14 @@ private function addItemToCart(
);
}

$productBySku = $this->productReader->getProductBySku($sku);
$resolvedSku = $cartItem->getParentSku() ?? $sku;
$productBySku = $this->productReader->getProductBySku($resolvedSku);
$product = isset($productBySku) ? clone $productBySku : null;

if (!$product || !$product->isSaleable() || !$product->isAvailable()) {
return [
$this->error->create(
__('Could not find a product with SKU "%sku"', ['sku' => $sku])->render(),
__('Could not find a product with SKU "%sku"', ['sku' => $resolvedSku])->render(),
$cartItemPosition,
$stockItemQuantity
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@

namespace Magento\QuoteConfigurableOptions\Model\Cart\BuyRequest;

use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\ConfigurableProductGraphQl\Model\Options\Collection as OptionCollection;
use Magento\Framework\EntityManager\MetadataPool;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Quote\Model\Cart\BuyRequest\BuyRequestDataProviderInterface;
use Magento\Quote\Model\Cart\Data\CartItem;

Expand All @@ -18,6 +23,13 @@ class SuperAttributeDataProvider implements BuyRequestDataProviderInterface
{
private const OPTION_TYPE = 'configurable';

public function __construct(
private readonly ProductRepositoryInterface $productRepository,
private readonly OptionCollection $optionCollection,
private readonly MetadataPool $metadataPool,
) {
}

/**
* @inheritdoc
*
Expand All @@ -26,7 +38,7 @@ class SuperAttributeDataProvider implements BuyRequestDataProviderInterface
public function execute(CartItem $cartItem): array
{
$configurableProductData = [];
foreach ($cartItem->getSelectedOptions() as $optionData) {
foreach ($cartItem->getSelectedOptions() ?? [] as $optionData) {
// phpcs:ignore Magento2.Functions.DiscouragedFunction
$optionData = \explode('/', base64_decode($optionData->getId()));

Expand All @@ -41,9 +53,55 @@ public function execute(CartItem $cartItem): array
}
}

if (empty($configurableProductData) && $cartItem->getParentSku() !== null) {
$configurableProductData = $this->resolveFromParentSku(
$cartItem->getParentSku(),
$cartItem->getSku()
);
}

return ['super_attribute' => $configurableProductData];
}

/**
* Resolves super_attribute from parent_sku + child sku (GH-40598 fallback).
*/
private function resolveFromParentSku(string $parentSku, string $childSku): array
{
try {
$parentProduct = $this->productRepository->get($parentSku);
$childProduct = $this->productRepository->get($childSku);
} catch (NoSuchEntityException) {
throw new LocalizedException(__('Could not find a product with SKU "%1" or "%2".', $parentSku, $childSku));
}

$configurableLinks = $parentProduct->getExtensionAttributes()?->getConfigurableProductLinks() ?? [];
if (!in_array($childProduct->getId(), $configurableLinks, strict: true)) {
throw new LocalizedException(
__('The product "%1" is not a variant of "%2".', $childSku, $parentSku)
);
}

$linkField = $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField();
$parentLinkId = (int) $parentProduct->getData($linkField);

$this->optionCollection->addProductId($parentLinkId);
$options = $this->optionCollection->getAttributesByProductId($parentLinkId);

$superAttributesData = [];
foreach ($options as $option) {
$code = $option['attribute_code'];
foreach ($option['values'] as $optionValue) {
if ($optionValue['value_index'] === $childProduct->getData($code)) {
$superAttributesData[$option['attribute_id']] = $optionValue['value_index'];
break;
}
}
}

return $superAttributesData;
}

/**
* Checks whether this provider is applicable for the current option
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
<?php
/**
* Copyright 2026 Adobe
* All Rights Reserved.
*/
declare(strict_types=1);

namespace Magento\QuoteConfigurableOptions\Test\Unit\Model\Cart\BuyRequest;

use Magento\Catalog\Api\Data\ProductExtensionInterface;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Model\Product;
use Magento\ConfigurableProductGraphQl\Model\Options\Collection as OptionCollection;
use Magento\Framework\EntityManager\EntityMetadataInterface;
use Magento\Framework\EntityManager\MetadataPool;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\TestFramework\Unit\Helper\MockCreationTrait;
use Magento\Quote\Model\Cart\Data\CartItem;
use Magento\Quote\Model\Cart\Data\SelectedOption;
use Magento\QuoteConfigurableOptions\Model\Cart\BuyRequest\SuperAttributeDataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

/**
* @covers \Magento\QuoteConfigurableOptions\Model\Cart\BuyRequest\SuperAttributeDataProvider
* @SuppressWarnings(PHPMD.CouplingBetweenObjects)
*/
class SuperAttributeDataProviderTest extends TestCase
{
use MockCreationTrait;

private SuperAttributeDataProvider $provider;
private ProductRepositoryInterface&MockObject $productRepository;
private OptionCollection&MockObject $optionCollection;
private MetadataPool&MockObject $metadataPool;

protected function setUp(): void
{
$this->productRepository = $this->createMock(ProductRepositoryInterface::class);
$this->optionCollection = $this->createMock(OptionCollection::class);
$this->metadataPool = $this->createMock(MetadataPool::class);

$this->provider = new SuperAttributeDataProvider(
$this->productRepository,
$this->optionCollection,
$this->metadataPool,
);
}

public function testExecuteResolvesFromSelectedOptions(): void
{
$uid = base64_encode('configurable/93/57');
$selectedOption = $this->createMock(SelectedOption::class);
$selectedOption->method('getId')->willReturn($uid);

$result = $this->provider->execute(new CartItem('simple-child', 1.0, null, [$selectedOption]));

$this->assertSame(['super_attribute' => ['93' => '57']], $result);
}

public function testExecuteIgnoresNonConfigurableSelectedOptions(): void
{
$uid = base64_encode('custom-option/10/20');
$selectedOption = $this->createMock(SelectedOption::class);
$selectedOption->method('getId')->willReturn($uid);

$result = $this->provider->execute(new CartItem('simple-child', 1.0, null, [$selectedOption]));

$this->assertSame(['super_attribute' => []], $result);
}

public function testExecuteFallsBackToParentSkuWhenNoSelectedOptions(): void
{
[$parentMock, $childMock] = $this->buildProductMocks(
parentId: 10,
childId: 42,
configurableLinks: [42],
linkFieldValue: 10,
options: [['attribute_code' => 'color', 'attribute_id' => 93, 'values' => [['value_index' => 5]]]],
childAttributeValue: 5
);

$this->productRepository->method('get')->willReturnMap([
['ParentItem', false, null, false, $parentMock],
['ParentItem-Variant', false, null, false, $childMock],
]);

$result = $this->provider->execute(new CartItem('ParentItem-Variant', 1.0, 'ParentItem'));

$this->assertSame(['super_attribute' => [93 => 5]], $result);
}

public function testSelectedOptionsTakePriorityOverParentSku(): void
{
$uid = base64_encode('configurable/93/57');
$selectedOption = $this->createMock(SelectedOption::class);
$selectedOption->method('getId')->willReturn($uid);

$this->productRepository->expects($this->never())->method('get');

$result = $this->provider->execute(new CartItem('ParentItem-Variant', 1.0, 'ParentItem', [$selectedOption]));

$this->assertSame(['super_attribute' => ['93' => '57']], $result);
}

public function testExecuteReturnsEmptyWhenNoSelectedOptionsAndNoParentSku(): void
{
$this->productRepository->expects($this->never())->method('get');

$result = $this->provider->execute(new CartItem('simple-standalone', 1.0));

$this->assertSame(['super_attribute' => []], $result);
}

public function testExecuteThrowsWhenChildIsNotVariantOfParent(): void
{
$extensionAttributes = $this->createPartialMockWithReflection(
ProductExtensionInterface::class,
['getConfigurableProductLinks']
);
$extensionAttributes->method('getConfigurableProductLinks')->willReturn([99]);

$parentMock = $this->createMock(Product::class);
$parentMock->method('getId')->willReturn(10);
$parentMock->method('getExtensionAttributes')->willReturn($extensionAttributes);
$parentMock->method('getData')->willReturn(10);

$childMock = $this->createMock(Product::class);
$childMock->method('getId')->willReturn(42);

$this->productRepository->method('get')->willReturnMap([
['ParentItem', false, null, false, $parentMock],
['other-simple', false, null, false, $childMock],
]);

$this->expectException(LocalizedException::class);
$this->expectExceptionMessage('not a variant');

$this->provider->execute(new CartItem('other-simple', 1.0, 'ParentItem'));
}

public function testExecuteThrowsWhenProductNotFound(): void
{
$this->productRepository->method('get')->willThrowException(new NoSuchEntityException());

$this->expectException(LocalizedException::class);

$this->provider->execute(new CartItem('ParentItem-Variant', 1.0, 'NonExistentParent'));
}

public function testExecuteThrowsOnMalformedSelectedOptionUid(): void
{
$uid = base64_encode('configurable/only-two-parts');
$selectedOption = $this->createMock(SelectedOption::class);
$selectedOption->method('getId')->willReturn($uid);

$this->expectException(LocalizedException::class);

$this->provider->execute(new CartItem('simple-child', 1.0, null, [$selectedOption]));
}

private function buildProductMocks(
int $parentId,
int $childId,
array $configurableLinks,
int $linkFieldValue,
array $options,
mixed $childAttributeValue,
): array {
$extensionAttributes = $this->createPartialMockWithReflection(
ProductExtensionInterface::class,
['getConfigurableProductLinks']
);
$extensionAttributes->method('getConfigurableProductLinks')->willReturn($configurableLinks);

$parentMock = $this->createMock(Product::class);
$parentMock->method('getId')->willReturn($parentId);
$parentMock->method('getExtensionAttributes')->willReturn($extensionAttributes);
$parentMock->method('getData')->willReturn($linkFieldValue);

$childMock = $this->createMock(Product::class);
$childMock->method('getId')->willReturn($childId);
$childMock->method('getData')->willReturn($childAttributeValue);

$productMetadata = $this->createMock(EntityMetadataInterface::class);
$productMetadata->method('getLinkField')->willReturn('entity_id');
$this->metadataPool->method('getMetadata')->with(ProductInterface::class)->willReturn($productMetadata);

$this->optionCollection->method('getAttributesByProductId')->with($linkFieldValue)->willReturn($options);

return [$parentMock, $childMock];
}
}
2 changes: 2 additions & 0 deletions app/code/Magento/QuoteConfigurableOptions/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
"require": {
"php": "~8.3.0||~8.4.0||~8.5.0",
"magento/framework": "*",
"magento/module-catalog": "*",
"magento/module-configurable-product-graph-ql": "*",
"magento/module-quote": "*"
},
"type": "magento2-module",
Expand Down
7 changes: 6 additions & 1 deletion app/code/Magento/QuoteConfigurableOptions/etc/module.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,10 @@
*/
-->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="Magento_QuoteConfigurableOptions"/>
<module name="Magento_QuoteConfigurableOptions">
<sequence>
<module name="Magento_ConfigurableProductGraphQl"/>
<module name="Magento_Catalog"/>
</sequence>
</module>
</config>