Skip to content

Commit df72199

Browse files
committed
LYNX-403: Fixed only_x_left_in_stock for configurable products
1 parent 490fdf9 commit df72199

File tree

4 files changed

+154
-34
lines changed

4 files changed

+154
-34
lines changed

app/code/Magento/CatalogInventoryGraphQl/Model/Resolver/OnlyXLeftInStockResolver.php

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
namespace Magento\CatalogInventoryGraphQl\Model\Resolver;
99

1010
use Magento\Catalog\Api\Data\ProductInterface;
11+
use Magento\Catalog\Api\ProductRepositoryInterface;
1112
use Magento\CatalogInventory\Api\StockRegistryInterface;
1213
use Magento\CatalogInventory\Model\Configuration;
1314
use Magento\Framework\App\Config\ScopeConfigInterface;
@@ -23,25 +24,20 @@
2324
class OnlyXLeftInStockResolver implements ResolverInterface
2425
{
2526
/**
26-
* @var ScopeConfigInterface
27+
* Configurable product type code
2728
*/
28-
private $scopeConfig;
29-
30-
/**
31-
* @var StockRegistryInterface
32-
*/
33-
private $stockRegistry;
29+
private const PRODUCT_TYPE_CONFIGURABLE = "configurable";
3430

3531
/**
3632
* @param ScopeConfigInterface $scopeConfig
3733
* @param StockRegistryInterface $stockRegistry
34+
* @param ProductRepositoryInterface $productRepositoryInterface
3835
*/
3936
public function __construct(
40-
ScopeConfigInterface $scopeConfig,
41-
StockRegistryInterface $stockRegistry
37+
private readonly ScopeConfigInterface $scopeConfig,
38+
private readonly StockRegistryInterface $stockRegistry,
39+
private readonly ProductRepositoryInterface $productRepositoryInterface
4240
) {
43-
$this->scopeConfig = $scopeConfig;
44-
$this->stockRegistry = $stockRegistry;
4541
}
4642

4743
/**
@@ -53,11 +49,12 @@ public function resolve(Field $field, $context, ResolveInfo $info, array $value
5349
throw new LocalizedException(__('"model" value should be specified'));
5450
}
5551

56-
/* @var $product ProductInterface */
5752
$product = $value['model'];
58-
$onlyXLeftQty = $this->getOnlyXLeftQty($product);
59-
60-
return $onlyXLeftQty;
53+
if ($product->getTypeId() === self::PRODUCT_TYPE_CONFIGURABLE) {
54+
$variant = $this->productRepositoryInterface->get($product->getSku());
55+
return $this->getOnlyXLeftQty($variant);
56+
}
57+
return $this->getOnlyXLeftQty($product);
6158
}
6259

6360
/**
@@ -73,7 +70,7 @@ private function getOnlyXLeftQty(ProductInterface $product): ?float
7370
Configuration::XML_PATH_STOCK_THRESHOLD_QTY,
7471
ScopeInterface::SCOPE_STORE
7572
);
76-
if ($thresholdQty === 0) {
73+
if ($thresholdQty === 0.0) {
7774
return null;
7875
}
7976

app/code/Magento/CatalogInventoryGraphQl/Test/Unit/Model/Resolver/OnlyXLeftInStockResolverTest.php

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -115,11 +115,11 @@ protected function setUp(): void
115115
$this->stockStatusMock = $this->getMockBuilder(StockStatusInterface::class)->getMock();
116116
$this->productModelMock->expects($this->any())->method('getId')
117117
->willReturn(1);
118-
$this->productModelMock->expects($this->once())->method('getStore')
118+
$this->productModelMock->expects($this->atMost(1))->method('getStore')
119119
->willReturn($this->storeMock);
120-
$this->stockRegistryMock->expects($this->once())->method('getStockStatus')
120+
$this->stockRegistryMock->expects($this->atMost(1))->method('getStockStatus')
121121
->willReturn($this->stockStatusMock);
122-
$this->storeMock->expects($this->once())->method('getWebsiteId')->willReturn(1);
122+
$this->storeMock->expects($this->atMost(1))->method('getWebsiteId')->willReturn(1);
123123

124124
$this->resolver = $this->objectManager->getObject(
125125
OnlyXLeftInStockResolver::class,
@@ -181,15 +181,10 @@ public function testResolveOutStock()
181181

182182
public function testResolveNoThresholdQty()
183183
{
184-
$stockCurrentQty = 3;
185-
$minQty = 2;
186184
$thresholdQty = null;
187-
$this->stockItemMock->expects($this->once())->method('getMinQty')
188-
->willReturn($minQty);
189-
$this->stockStatusMock->expects($this->once())->method('getQty')
190-
->willReturn($stockCurrentQty);
191-
$this->stockRegistryMock->expects($this->once())->method('getStockItem')
192-
->willReturn($this->stockItemMock);
185+
$this->stockItemMock->expects($this->never())->method('getMinQty');
186+
$this->stockStatusMock->expects($this->never())->method('getQty');
187+
$this->stockRegistryMock->expects($this->never())->method('getStockItem');
193188
$this->scopeConfigMock->method('getValue')->willReturn($thresholdQty);
194189

195190
$this->assertEquals(

app/code/Magento/CatalogInventoryGraphQl/etc/schema.graphqls

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# See COPYING.txt for license details.
33

44
interface ProductInterface {
5-
only_x_left_in_stock: Float @doc(description: "The value assigned to the Only X Left Threshold option in the Admin.") @resolver(class: "Magento\\CatalogInventoryGraphQl\\Model\\Resolver\\OnlyXLeftInStockResolver")
5+
only_x_left_in_stock: Float @doc(description: "Remaining stock if it is below the value assigned to the Only X Left Threshold option in the Admin.") @resolver(class: "Magento\\CatalogInventoryGraphQl\\Model\\Resolver\\OnlyXLeftInStockResolver")
66
stock_status: ProductStockStatus @doc(description: "The stock status of the product.") @resolver(class: "Magento\\CatalogInventoryGraphQl\\Model\\Resolver\\StockStatusProvider")
77
}
88

dev/tests/api-functional/testsuite/Magento/GraphQl/CatalogInventory/ProductOnlyXLeftInStockTest.php

Lines changed: 134 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,21 @@
77

88
namespace Magento\GraphQl\CatalogInventory;
99

10+
use Magento\Catalog\Test\Fixture\Product as ProductFixture;
1011
use Magento\Config\Model\ResourceModel\Config;
12+
use Magento\ConfigurableProduct\Test\Fixture\Attribute as AttributeFixture;
13+
use Magento\ConfigurableProduct\Test\Fixture\Product as ConfigurableProductFixture;
14+
use Magento\Eav\Api\Data\AttributeInterface;
15+
use Magento\Eav\Api\Data\AttributeOptionInterface;
1116
use Magento\Framework\App\Config\ReinitableConfigInterface;
1217
use Magento\Catalog\Api\ProductRepositoryInterface;
13-
use Magento\Framework\App\Config\ScopeConfigInterface;
18+
use Magento\Framework\DataObject;
19+
use Magento\Quote\Test\Fixture\GuestCart as GuestCartFixture;
20+
use Magento\Quote\Test\Fixture\QuoteIdMask as QuoteMaskFixture;
21+
use Magento\TestFramework\App\ApiMutableScopeConfig;
22+
use Magento\TestFramework\Fixture\DataFixture;
23+
use Magento\TestFramework\Fixture\DataFixtureStorage;
24+
use Magento\TestFramework\Fixture\DataFixtureStorageManager;
1425
use Magento\TestFramework\ObjectManager;
1526
use Magento\TestFramework\TestCase\GraphQlAbstract;
1627
use Magento\CatalogInventory\Model\Configuration;
@@ -20,6 +31,10 @@
2031
*/
2132
class ProductOnlyXLeftInStockTest extends GraphQlAbstract
2233
{
34+
private const PARENT_SKU_CONFIGURABLE = 'parent_configurable';
35+
36+
private const SKU = 'simple_10';
37+
2338
/**
2439
* @var ProductRepositoryInterface
2540
*/
@@ -30,7 +45,7 @@ class ProductOnlyXLeftInStockTest extends GraphQlAbstract
3045
private $resourceConfig;
3146

3247
/**
33-
* @var ScopeConfigInterface
48+
* @var ApiMutableScopeConfig
3449
*/
3550
private $scopeConfig;
3651

@@ -39,6 +54,11 @@ class ProductOnlyXLeftInStockTest extends GraphQlAbstract
3954
*/
4055
private $reinitConfig;
4156

57+
/**
58+
* @var DataFixtureStorage
59+
*/
60+
private $fixtures;
61+
4262
/**
4363
* @inheritdoc
4464
*/
@@ -49,8 +69,9 @@ protected function setUp(): void
4969
$objectManager = ObjectManager::getInstance();
5070
$this->productRepository = $objectManager->create(ProductRepositoryInterface::class);
5171
$this->resourceConfig = $objectManager->get(Config::class);
52-
$this->scopeConfig = $objectManager->get(ScopeConfigInterface::class);
72+
$this->scopeConfig = $objectManager->get(ApiMutableScopeConfig::class);
5373
$this->reinitConfig = $objectManager->get(ReinitableConfigInterface::class);
74+
$this->fixtures = DataFixtureStorageManager::getStorage();
5475
}
5576

5677
/**
@@ -91,7 +112,7 @@ public function testQueryProductOnlyXLeftInStockEnabled()
91112
products(filter: {sku: {eq: "{$productSku}"}})
92113
{
93114
items {
94-
only_x_left_in_stock
115+
only_x_left_in_stock
95116
}
96117
}
97118
}
@@ -118,13 +139,13 @@ public function testQueryProductOnlyXLeftInStockOutstock()
118139
// need to resave product to reindex it with new configuration.
119140
$product = $this->productRepository->get($productSku);
120141
$this->productRepository->save($product);
121-
142+
122143
$query = <<<QUERY
123144
{
124145
products(filter: {sku: {eq: "{$productSku}"}})
125146
{
126147
items {
127-
only_x_left_in_stock
148+
only_x_left_in_stock
128149
}
129150
}
130151
}
@@ -138,4 +159,111 @@ public function testQueryProductOnlyXLeftInStockOutstock()
138159
$this->assertArrayHasKey('only_x_left_in_stock', $response['products']['items'][0]);
139160
$this->assertEquals(0, $response['products']['items'][0]['only_x_left_in_stock']);
140161
}
162+
163+
#[
164+
DataFixture(ProductFixture::class, ['sku' => self::SKU], as: 'product'),
165+
DataFixture(AttributeFixture::class, as: 'attribute'),
166+
DataFixture(
167+
ConfigurableProductFixture::class,
168+
[
169+
'sku' => self::PARENT_SKU_CONFIGURABLE,
170+
'_options' => ['$attribute$'],
171+
'_links' => ['$product$'],
172+
],
173+
'configurable_product'
174+
),
175+
DataFixture(GuestCartFixture::class, as: 'cart'),
176+
DataFixture(QuoteMaskFixture::class, ['cart_id' => '$cart.id$'], 'quoteIdMask'),
177+
]
178+
/**
179+
* @dataProvider stockThresholdQtyProvider
180+
*/
181+
public function testOnlyXLeftInStockConfigurableProduct(string $stockThresholdQty, ?int $expected): void
182+
{
183+
$this->scopeConfig->setValue('cataloginventory/options/stock_threshold_qty', $stockThresholdQty);
184+
$maskedQuoteId = $this->fixtures->get('quoteIdMask')->getMaskedId();
185+
/** @var AttributeInterface $attribute */
186+
$attribute = $this->fixtures->get('attribute');
187+
/** @var AttributeOptionInterface $option */
188+
$option = $attribute->getOptions()[1];
189+
$selectedOption = base64_encode("configurable/{$attribute->getAttributeId()}/{$option->getValue()}");
190+
$query = $this->mutationAddConfigurableProduct(
191+
$maskedQuoteId,
192+
self::PARENT_SKU_CONFIGURABLE,
193+
$selectedOption,
194+
100
195+
);
196+
197+
$this->graphQlMutation($query);
198+
199+
$query = <<<QUERY
200+
{
201+
cart(cart_id: "$maskedQuoteId") {
202+
total_quantity
203+
itemsV2 {
204+
items {
205+
uid
206+
product {
207+
name
208+
sku
209+
stock_status
210+
only_x_left_in_stock
211+
}
212+
}
213+
}
214+
}
215+
}
216+
QUERY;
217+
218+
$response = $this->graphQlQuery($query);
219+
$responseDataObject = new DataObject($response);
220+
self::assertEquals(
221+
$expected,
222+
$responseDataObject->getData('cart/itemsV2/items/0/product/only_x_left_in_stock'),
223+
);
224+
}
225+
226+
public function stockThresholdQtyProvider(): array
227+
{
228+
return [
229+
['0', null],
230+
['200', 100]
231+
];
232+
}
233+
234+
private function mutationAddConfigurableProduct(
235+
string $cartId,
236+
string $sku,
237+
string $selectedOption,
238+
int $qty = 1
239+
): string {
240+
return <<<QUERY
241+
mutation {
242+
addProductsToCart(
243+
cartId: "{$cartId}",
244+
cartItems: [
245+
{
246+
sku: "{$sku}"
247+
quantity: $qty
248+
selected_options: [
249+
"$selectedOption"
250+
]
251+
}]
252+
) {
253+
cart {
254+
items {
255+
is_available
256+
product {
257+
sku
258+
}
259+
}
260+
}
261+
user_errors {
262+
code
263+
message
264+
}
265+
}
266+
}
267+
QUERY;
268+
}
141269
}

0 commit comments

Comments
 (0)