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
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@

use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Model\Product;
use Magento\CatalogInventory\Api\StockRegistryInterface;
use Magento\CatalogInventory\Model\Configuration;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\DataObject;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use Magento\Framework\GraphQl\Config\Element\Field;
Expand Down Expand Up @@ -49,31 +51,55 @@ public function resolve(Field $field, $context, ResolveInfo $info, ?array $value
throw new LocalizedException(__('"model" value should be specified'));
}

$product = $value['model'];
if ($product->getTypeId() === self::PRODUCT_TYPE_CONFIGURABLE) {
$variant = $this->productRepositoryInterface->get($product->getSku());
return $this->getOnlyXLeftQty($variant);
$thresholdQty = (float)$this->scopeConfig->getValue(
Configuration::XML_PATH_STOCK_THRESHOLD_QTY,
ScopeInterface::SCOPE_STORE
);
if ($thresholdQty === 0.0) {
return null;
}

return $this->getOnlyXLeftQty($this->getConfiguredProduct($value['model']), $thresholdQty);
}

/**
* Get the product the stock data belongs to
*
* @param ProductInterface $product
* @return ProductInterface
* @throws LocalizedException
*/
private function getConfiguredProduct(ProductInterface $product): ProductInterface
{
if ($product->getTypeId() !== self::PRODUCT_TYPE_CONFIGURABLE || !$product instanceof Product) {
return $product;
}

// A configurable cart item carries the selected child in the "simple_product" custom option, which is also
// what Configurable::getSku() reports; outside a cart item there is no such option and no variant to resolve.
$option = $product->getCustomOption('simple_product');
$variant = $option instanceof DataObject ? $option->getProduct() : null;
if ($variant instanceof ProductInterface) {
return $variant;
}
return $this->getOnlyXLeftQty($product);

if ($product->getSku() !== $product->getData('sku')) {
return $this->productRepositoryInterface->get($product->getSku());
}

return $product;
}

/**
* Get product qty left when "Catalog > Inventory > Stock Options > Only X left Threshold" is greater than 0
*
* @param ProductInterface $product
* @param float $thresholdQty
*
* @return null|float
*/
private function getOnlyXLeftQty(ProductInterface $product): ?float
private function getOnlyXLeftQty(ProductInterface $product, float $thresholdQty): ?float
{
$thresholdQty = (float)$this->scopeConfig->getValue(
Configuration::XML_PATH_STOCK_THRESHOLD_QTY,
ScopeInterface::SCOPE_STORE
);
if ($thresholdQty === 0.0) {
return null;
}

$stockItem = $this->stockRegistry->getStockItem($product->getId());

$stockCurrentQty = $this->stockRegistry->getStockStatus(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php
/**
* Copyright 2026 Adobe
* All Rights Reserved.
*/
declare(strict_types=1);

namespace Magento\CatalogInventoryGraphQl\Model\Resolver;

use Magento\Catalog\Api\Data\ProductInterface;
use Magento\CatalogInventory\Api\StockConfigurationInterface;
use Magento\CatalogInventory\Model\StockRegistryPreloader;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Query\Resolver\BatchRequestItemInterface;
use Magento\Framework\GraphQl\Query\Resolver\BatchResolverInterface;
use Magento\Framework\GraphQl\Query\Resolver\BatchResponse;
use Magento\Framework\GraphQl\Query\Resolver\ContextInterface;
use Magento\Quote\Model\Quote\Item;

/**
* Resolve the stock status of all products of a single response with one stock status query.
*/
class StockStatus implements BatchResolverInterface
{
/**
* In Stock return code
*/
private const IN_STOCK = "IN_STOCK";

/**
* Out of Stock return code
*/
private const OUT_OF_STOCK = "OUT_OF_STOCK";

/**
* @param StockRegistryPreloader $stockRegistryPreloader
* @param StockConfigurationInterface $stockConfiguration
* @param StockStatusProvider $stockStatusProvider
*/
public function __construct(
private readonly StockRegistryPreloader $stockRegistryPreloader,
private readonly StockConfigurationInterface $stockConfiguration,
private readonly StockStatusProvider $stockStatusProvider
) {
}

/**
* @inheritdoc
*/
public function resolve(ContextInterface $context, Field $field, array $requests): BatchResponse
{
$productIds = [];
foreach ($requests as $request) {
if (!$this->isCartItemRequest($request)) {
$productIds[] = (int)$this->getProduct($request)->getId();
}
}

$stockStatuses = [];
if ($productIds) {
$preloaded = $this->stockRegistryPreloader->preloadStockStatuses(
array_values(array_unique($productIds)),
(int)$this->stockConfiguration->getDefaultScopeId()
);
foreach ($preloaded as $stockStatus) {
$stockStatuses[(int)$stockStatus->getProductId()] = (int)$stockStatus->getStockStatus();
}
}

$response = new BatchResponse();
foreach ($requests as $request) {
if ($this->isCartItemRequest($request)) {
$response->addResponse(
$request,
$this->stockStatusProvider->resolve(
$field,
$context,
$request->getInfo(),
$request->getValue(),
$request->getArgs()
)
);
continue;
}

$productId = (int)$this->getProduct($request)->getId();
$response->addResponse(
$request,
empty($stockStatuses[$productId]) ? self::OUT_OF_STOCK : self::IN_STOCK
);
}

return $response;
}

/**
* Get the product a request has been made for
*
* @param BatchRequestItemInterface $request
* @return ProductInterface
* @throws LocalizedException
*/
private function getProduct(BatchRequestItemInterface $request): ProductInterface
{
$value = $request->getValue() ?? [];
if (!array_key_exists('model', $value) || !$value['model'] instanceof ProductInterface) {
throw new LocalizedException(__('"model" value should be specified'));
}

return $value['model'];
}

/**
* Cart items keep their own stock status semantics and are delegated to the single-item resolver
*
* @param BatchRequestItemInterface $request
* @return bool
* @throws LocalizedException
*/
private function isCartItemRequest(BatchRequestItemInterface $request): bool
{
$this->getProduct($request);

return ($request->getValue()['cart_item'] ?? null) instanceof Item;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\CatalogInventoryGraphQl\Model\Resolver\OnlyXLeftInStockResolver;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\GraphQl\Model\Query\ContextInterface;
use Magento\Framework\TestFramework\Unit\Helper\ObjectManager;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use Magento\CatalogInventory\Api\StockRegistryInterface;
use Magento\Catalog\Model\Product;
use Magento\Quote\Model\Quote\Item\Option;
use Magento\Store\Api\Data\StoreInterface;
use Magento\CatalogInventory\Api\Data\StockItemInterface;
use Magento\CatalogInventory\Api\Data\StockStatusInterface;
Expand All @@ -26,13 +27,6 @@
*/
class OnlyXLeftInStockResolverTest extends TestCase
{
/**
* Object Manager Instance
*
* @var ObjectManager
*/
private $objectManager;

/**
* Testable Object
*
Expand Down Expand Up @@ -65,6 +59,11 @@ class OnlyXLeftInStockResolverTest extends TestCase
*/
private $stockRegistryMock;

/**
* @var ProductRepositoryInterface|MockObject
*/
private $productRepositoryMock;

/**
* @var Product|MockObject
*/
Expand All @@ -91,14 +90,13 @@ class OnlyXLeftInStockResolverTest extends TestCase

protected function setUp(): void
{
$this->objectManager = new ObjectManager($this);

$this->contextMock = $this->createMock(ContextInterface::class);
$this->fieldMock = $this->createMock(Field::class);
$this->resolveInfoMock = $this->createMock(ResolveInfo::class);
$this->productModelMock = $this->createMock(Product::class);
$this->scopeConfigMock = $this->createMock(ScopeConfigInterface::class);
$this->stockRegistryMock = $this->createMock(StockRegistryInterface::class);
$this->productRepositoryMock = $this->createMock(ProductRepositoryInterface::class);
$this->storeMock = $this->createMock(StoreInterface::class);
$this->stockItemMock = $this->createMock(StockItemInterface::class);
$this->stockStatusMock = $this->createMock(StockStatusInterface::class);
Expand All @@ -110,12 +108,10 @@ protected function setUp(): void
->willReturn($this->stockStatusMock);
$this->storeMock->expects($this->atMost(1))->method('getWebsiteId')->willReturn(1);

$this->resolver = $this->objectManager->getObject(
OnlyXLeftInStockResolver::class,
[
'scopeConfig' => $this->scopeConfigMock,
'stockRegistry' => $this->stockRegistryMock
]
$this->resolver = new OnlyXLeftInStockResolver(
$this->scopeConfigMock,
$this->stockRegistryMock,
$this->productRepositoryMock
);
}

Expand Down Expand Up @@ -174,6 +170,8 @@ public function testResolveNoThresholdQty()
$this->stockItemMock->expects($this->never())->method('getMinQty');
$this->stockStatusMock->expects($this->never())->method('getQty');
$this->stockRegistryMock->expects($this->never())->method('getStockItem');
$this->productRepositoryMock->expects($this->never())->method('get');
$this->productModelMock->expects($this->never())->method('getTypeId');
$this->scopeConfigMock->method('getValue')->willReturn($thresholdQty);

$this->assertEquals(
Expand All @@ -186,4 +184,57 @@ public function testResolveNoThresholdQty()
)
);
}

public function testResolveConfigurableWithoutSelectedVariant()
{
$this->scopeConfigMock->method('getValue')->willReturn(200);
$this->productModelMock->method('getTypeId')->willReturn('configurable');
$this->productModelMock->method('getCustomOption')->with('simple_product')->willReturn(null);
$this->productModelMock->method('getSku')->willReturn('parent_configurable');
$this->productModelMock->method('getData')->with('sku')->willReturn('parent_configurable');
$this->productRepositoryMock->expects($this->never())->method('get');
$this->stockRegistryMock->expects($this->once())->method('getStockItem')->with(1)
->willReturn($this->stockItemMock);
$this->stockItemMock->method('getMinQty')->willReturn(0);
$this->stockStatusMock->method('getQty')->willReturn(100);

$this->assertEquals(
100,
$this->resolver->resolve(
$this->fieldMock,
$this->contextMock,
$this->resolveInfoMock,
['model' => $this->productModelMock]
)
);
}

public function testResolveConfigurableUsesSimpleProductOption()
{
$variantMock = $this->createMock(Product::class);
$variantMock->method('getId')->willReturn(42);
$variantMock->method('getStore')->willReturn($this->storeMock);

$optionMock = $this->createMock(Option::class);
$optionMock->method('getProduct')->willReturn($variantMock);

$this->scopeConfigMock->method('getValue')->willReturn(200);
$this->productModelMock->method('getTypeId')->willReturn('configurable');
$this->productModelMock->method('getCustomOption')->with('simple_product')->willReturn($optionMock);
$this->productRepositoryMock->expects($this->never())->method('get');
$this->stockRegistryMock->expects($this->once())->method('getStockItem')->with(42)
->willReturn($this->stockItemMock);
$this->stockItemMock->method('getMinQty')->willReturn(0);
$this->stockStatusMock->method('getQty')->willReturn(7);

$this->assertEquals(
7,
$this->resolver->resolve(
$this->fieldMock,
$this->contextMock,
$this->resolveInfoMock,
['model' => $this->productModelMock]
)
);
}
}
Loading