Skip to content

Commit 74a5499

Browse files
authored
LYNX-918: [AC-2.4.9] Cart query returns incorrect availability status for products with Multi-Source Inventory (MSI) and default stock is 0 (#370)
1 parent 567aec7 commit 74a5499

File tree

4 files changed

+635
-372
lines changed

4 files changed

+635
-372
lines changed

app/code/Magento/QuoteGraphQl/Model/CartItem/ProductStock.php

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
use Magento\Catalog\Api\Data\ProductInterface;
1111
use Magento\Catalog\Api\ProductRepositoryInterface;
12+
use Magento\CatalogInventory\Api\Data\StockStatusInterface;
1213
use Magento\CatalogInventory\Api\StockRegistryInterface;
1314
use Magento\CatalogInventory\Model\Configuration;
1415
use Magento\CatalogInventory\Model\StockState;
@@ -18,7 +19,7 @@
1819
use Magento\Store\Model\ScopeInterface;
1920

2021
/**
21-
* Product Stock class to check availability of product
22+
* Product Stock class to check the availability of product
2223
*/
2324
class ProductStock
2425
{
@@ -33,7 +34,7 @@ class ProductStock
3334
private const PRODUCT_TYPE_CONFIGURABLE = "configurable";
3435

3536
/**
36-
* ProductStock constructor
37+
* ProductStock Constructor
3738
*
3839
* @param ProductRepositoryInterface $productRepositoryInterface
3940
* @param StockState $stockState
@@ -176,16 +177,16 @@ private function isStockQtyAvailable(
176177
float $requiredQuantity,
177178
float $prevQty
178179
): bool {
179-
$scopeId = $cartItem->getStore()->getId();
180-
$stockStatus = $this->stockState->checkQuoteItemQty(
180+
$this->stockState->checkQuoteItemQty(
181181
$product->getId(),
182182
$itemQty,
183183
$requiredQuantity,
184184
$prevQty,
185-
$scopeId
185+
$cartItem->getStore()->getId()
186186
);
187187

188-
return ((bool) $stockStatus->getHasError()) === false;
188+
return ($this->getProductStockStatus($product)->getStockStatus() &&
189+
$this->getAvailableStock($product) >= $itemQty);
189190
}
190191

191192
/**
@@ -196,7 +197,7 @@ private function isStockQtyAvailable(
196197
*/
197198
private function getAvailableStock(ProductInterface $product): float
198199
{
199-
return $this->stockState->getStockQty($product->getId());
200+
return (float) $this->getProductStockStatus($product)->getQty();
200201
}
201202

202203
/**
@@ -293,4 +294,18 @@ public function getSaleableQty(ProductInterface $product, ?float $thresholdQty):
293294

294295
return ($stockQty >= 0 && $stockLeft <= $thresholdQty) ? $stockQty : 0.0;
295296
}
297+
298+
/**
299+
* Returns the stock status of a product
300+
*
301+
* @param ProductInterface $product
302+
* @return StockStatusInterface
303+
*/
304+
private function getProductStockStatus(ProductInterface $product): StockStatusInterface
305+
{
306+
return $this->stockRegistry->getStockStatus(
307+
$product->getId(),
308+
$product->getStore()->getWebsiteId()
309+
);
310+
}
296311
}

app/code/Magento/QuoteGraphQl/Test/Unit/Model/CartItem/ProductStockTest.php

Lines changed: 76 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@ class ProductStockTest extends TestCase
7070
*/
7171
private $stockStatusMock;
7272

73+
/**
74+
* @var ProductInterface|MockObject
75+
*/
76+
private $optionProductMock;
77+
78+
/**
79+
* @var Option|MockObject
80+
*/
81+
private $qtyOptionMock;
82+
7383
/**
7484
* Set up mocks and initialize the ProductStock class
7585
*/
@@ -87,15 +97,25 @@ protected function setUp(): void
8797
);
8898
$this->stockStatusMock = $this->getMockBuilder(StockStatusInterface::class)
8999
->disableOriginalConstructor()
90-
->addMethods(['getHasError'])
100+
->onlyMethods(['getQty', 'getStockStatus'])
91101
->getMockForAbstractClass();
92102
$this->cartItemMock = $this->getMockBuilder(Item::class)
93103
->addMethods(['getQtyToAdd', 'getPreviousQty'])
94104
->onlyMethods(['getStore', 'getProductType', 'getProduct', 'getChildren', 'getQtyOptions'])
95105
->disableOriginalConstructor()
96106
->getMock();
97-
$this->productMock = $this->createMock(ProductInterface::class);
107+
$this->productMock = $this->getMockBuilder(ProductInterface::class)
108+
->onlyMethods(['getId'])
109+
->addMethods(['getStore'])
110+
->disableOriginalConstructor()
111+
->getMockForAbstractClass();
112+
$this->optionProductMock = $this->getMockBuilder(ProductInterface::class)
113+
->onlyMethods(['getId'])
114+
->addMethods(['getStore'])
115+
->disableOriginalConstructor()
116+
->getMockForAbstractClass();
98117
$this->storeMock = $this->createMock(StoreInterface::class);
118+
$this->qtyOptionMock = $this->createMock(Option::class);
99119
}
100120

101121
/**
@@ -121,16 +141,25 @@ public function testIsProductAvailableForSimpleProductWithStock(): void
121141
$this->storeMock->expects($this->once())
122142
->method('getId')
123143
->willReturn(1);
124-
$this->productMock->expects($this->once())
144+
$this->productMock->expects($this->exactly(3))
125145
->method('getId')
126146
->willReturn(123);
147+
$this->productMock->expects($this->exactly(2))
148+
->method('getStore')
149+
->willReturn($this->storeMock);
127150
$this->stockStatusMock->expects($this->once())
128-
->method('getHasError')
129-
->willReturn(false);
151+
->method('getStockStatus')
152+
->willReturn(true);
153+
$this->stockStatusMock->expects($this->once())
154+
->method('getQty')
155+
->willReturn(10);
130156
$this->stockStateMock->expects($this->once())
131157
->method('checkQuoteItemQty')
132158
->with(123, 2.0, 3.0, 1.0, 1)
133159
->willReturn($this->stockStatusMock);
160+
$this->stockRegistryMock->expects($this->exactly(2))
161+
->method('getStockStatus')
162+
->willReturn($this->stockStatusMock);
134163
$this->cartItemMock->expects($this->never())->method('getChildren');
135164
$result = $this->productStock->isProductAvailable($this->cartItemMock);
136165
$this->assertTrue($result);
@@ -159,16 +188,22 @@ public function testIsProductAvailableForSimpleProductWithoutStock()
159188
$this->storeMock->expects($this->once())
160189
->method('getId')
161190
->willReturn(1);
162-
$this->productMock->expects($this->once())
191+
$this->productMock->expects($this->exactly(2))
163192
->method('getId')
164193
->willReturn(123);
194+
$this->productMock->expects($this->once())
195+
->method('getStore')
196+
->willReturn($this->storeMock);
165197
$this->stockStateMock->expects($this->once())
166198
->method('checkQuoteItemQty')
167199
->with(123, 2.0, 3.0, 1.0, 1)
168200
->willReturn($this->stockStatusMock);
169201
$this->stockStatusMock->expects($this->once())
170-
->method('getHasError')
171-
->willReturn(true);
202+
->method('getStockStatus')
203+
->willReturn(false);
204+
$this->stockRegistryMock->expects($this->once())
205+
->method('getStockStatus')
206+
->willReturn($this->stockStatusMock);
172207
$this->cartItemMock->expects($this->never())->method('getChildren');
173208
$result = $this->productStock->isProductAvailable($this->cartItemMock);
174209
$this->assertFalse($result);
@@ -179,33 +214,40 @@ public function testIsProductAvailableForSimpleProductWithoutStock()
179214
*/
180215
public function testIsStockAvailableBundleStockAvailable()
181216
{
182-
$qtyOptionMock = $this->createMock(Option::class);
183-
$qtyOptionMock->expects($this->once())
217+
$this->qtyOptionMock->expects($this->once())
184218
->method('getValue')
185-
->willReturn(2.0);
186-
$optionProductMock = $this->createMock(ProductInterface::class);
187-
$qtyOptionMock->expects($this->once())
219+
->willReturn(1.0);
220+
$this->qtyOptionMock->expects($this->once())
188221
->method('getProduct')
189-
->willReturn($optionProductMock);
222+
->willReturn($this->optionProductMock);
190223
$this->cartItemMock->expects($this->once())
191224
->method('getQtyOptions')
192-
->willReturn([$qtyOptionMock]);
225+
->willReturn([$this->qtyOptionMock]);
193226
$this->cartItemMock->expects($this->once())
194227
->method('getStore')
195228
->willReturn($this->storeMock);
196229
$this->storeMock->expects($this->once())
197230
->method('getId')
198231
->willReturn(1);
199-
$optionProductMock->expects($this->once())
232+
$this->optionProductMock->expects($this->exactly(3))
200233
->method('getId')
201234
->willReturn(789);
202-
$this->stockStatusMock->expects($this->once())
203-
->method('getHasError')
204-
->willReturn(false);
235+
$this->optionProductMock->expects($this->exactly(2))
236+
->method('getStore')
237+
->willReturn($this->storeMock);
205238
$this->stockStateMock->expects($this->once())
206239
->method('checkQuoteItemQty')
207-
->with(789, 2.0, 6.0, 1.0, 1)
240+
->with(789, 2.0, 3.0, 1.0, 1)
208241
->willReturn($this->stockStatusMock);
242+
$this->stockStatusMock->expects($this->once())
243+
->method('getStockStatus')
244+
->willReturn(true);
245+
$this->stockRegistryMock->expects($this->exactly(2))
246+
->method('getStockStatus')
247+
->willReturn($this->stockStatusMock);
248+
$this->stockStatusMock->expects($this->once())
249+
->method('getQty')
250+
->willReturn(10);
209251
$result = $this->productStock->isStockAvailableBundle($this->cartItemMock, 1, 2.0);
210252
$this->assertTrue($result);
211253
}
@@ -215,33 +257,37 @@ public function testIsStockAvailableBundleStockAvailable()
215257
*/
216258
public function testIsStockAvailableBundleStockNotAvailable()
217259
{
218-
$qtyOptionMock = $this->createMock(\Magento\Quote\Model\Quote\Item\Option::class);
219-
$qtyOptionMock->expects($this->once())
260+
$this->qtyOptionMock->expects($this->once())
220261
->method('getValue')
221262
->willReturn(2.0);
222-
$optionProductMock = $this->createMock(ProductInterface::class);
223-
$qtyOptionMock->expects($this->once())
263+
$this->qtyOptionMock->expects($this->once())
224264
->method('getProduct')
225-
->willReturn($optionProductMock);
265+
->willReturn($this->optionProductMock);
226266
$this->cartItemMock->expects($this->once())
227267
->method('getQtyOptions')
228-
->willReturn([$qtyOptionMock]);
268+
->willReturn([$this->qtyOptionMock]);
229269
$this->cartItemMock->expects($this->once())
230270
->method('getStore')
231271
->willReturn($this->storeMock);
232272
$this->storeMock->expects($this->once())
233273
->method('getId')
234274
->willReturn(1);
235-
$this->stockStatusMock->expects($this->once())
236-
->method('getHasError')
237-
->willReturn(true);
238-
$optionProductMock->expects($this->once())
275+
$this->optionProductMock->expects($this->exactly(2))
239276
->method('getId')
240277
->willReturn(789);
278+
$this->optionProductMock->expects($this->once())
279+
->method('getStore')
280+
->willReturn($this->storeMock);
241281
$this->stockStateMock->expects($this->once())
242282
->method('checkQuoteItemQty')
243283
->with(789, 2.0, 6.0, 1.0, 1)
244284
->willReturn($this->stockStatusMock);
285+
$this->stockStatusMock->expects($this->once())
286+
->method('getStockStatus')
287+
->willReturn(false);
288+
$this->stockRegistryMock->expects($this->once())
289+
->method('getStockStatus')
290+
->willReturn($this->stockStatusMock);
245291
$result = $this->productStock->isStockAvailableBundle($this->cartItemMock, 1, 2.0);
246292
$this->assertFalse($result);
247293
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?php
2+
/**
3+
* Copyright 2025 Adobe
4+
* All Rights Reserved.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\GraphQl\Quote;
9+
10+
use Magento\Catalog\Test\Fixture\Product as ProductFixture;
11+
use Magento\InventoryApi\Test\Fixture\Source as SourceFixture;
12+
use Magento\InventoryApi\Test\Fixture\SourceItems as SourceItemsFixture;
13+
use Magento\InventoryApi\Test\Fixture\Stock as StockFixture;
14+
use Magento\InventoryApi\Test\Fixture\StockSourceLinks as StockSourceLinksFixture;
15+
use Magento\InventorySalesApi\Test\Fixture\StockSalesChannels as StockSalesChannelsFixture;
16+
use Magento\Quote\Test\Fixture\AddProductToCart;
17+
use Magento\Quote\Test\Fixture\GuestCart as GuestCartFixture;
18+
use Magento\Quote\Test\Fixture\QuoteIdMask as QuoteMaskFixture;
19+
use Magento\TestFramework\Fixture\AppIsolation;
20+
use Magento\TestFramework\Fixture\Config;
21+
use Magento\TestFramework\Fixture\DataFixture;
22+
use Magento\TestFramework\Fixture\DataFixtureStorage;
23+
use Magento\TestFramework\Fixture\DataFixtureStorageManager;
24+
use Magento\TestFramework\Fixture\DbIsolation;
25+
use Magento\TestFramework\TestCase\GraphQlAbstract;
26+
27+
class CartItemAvailabilityTest extends GraphQlAbstract
28+
{
29+
/**
30+
* @var DataFixtureStorage
31+
*/
32+
private $fixtures;
33+
34+
/**
35+
* @inheritDoc
36+
*/
37+
protected function setUp(): void
38+
{
39+
$this->fixtures = DataFixtureStorageManager::getStorage();
40+
}
41+
42+
#[
43+
Config('cataloginventory/options/not_available_message', 1),
44+
DbIsolation(false),
45+
AppIsolation(true),
46+
DataFixture(SourceFixture::class, as: 'source2'),
47+
DataFixture(StockFixture::class, as: 'stock2'),
48+
DataFixture(
49+
StockSourceLinksFixture::class,
50+
[
51+
['stock_id' => '$stock2.stock_id$', 'source_code' => '$source2.source_code$'],
52+
]
53+
),
54+
DataFixture(
55+
StockSalesChannelsFixture::class,
56+
['stock_id' => '$stock2.stock_id$', 'sales_channels' => ['base']]
57+
),
58+
59+
DataFixture(ProductFixture::class, ['sku' => 'simple1'], 'p1'),
60+
DataFixture(
61+
SourceItemsFixture::class,
62+
[
63+
['sku' => '$p1.sku$', 'source_code' => 'default', 'quantity' => 0],
64+
['sku' => '$p1.sku$', 'source_code' => '$source2.source_code$', 'quantity' => 100],
65+
]
66+
),
67+
DataFixture(GuestCartFixture::class, as: 'cart'),
68+
DataFixture(AddProductToCart::class, ['cart_id' => '$cart.id$', 'product_id' => '$p1.id$']),
69+
DataFixture(QuoteMaskFixture::class, ['cart_id' => '$cart.id$'], 'quoteIdMask')
70+
]
71+
public function testCartItemAvailabilityWithMSI(): void
72+
{
73+
$this->assertEquals(
74+
[
75+
'cart' => [
76+
'itemsV2' => [
77+
'items' => [
78+
[
79+
'not_available_message' => null,
80+
'is_available' => true,
81+
'product' => [
82+
'quantity' => 100,
83+
],
84+
],
85+
],
86+
],
87+
],
88+
],
89+
$this->graphQlQuery($this->getCartQuery(
90+
$this->fixtures->get('quoteIdMask')->getMaskedId()
91+
))
92+
);
93+
}
94+
95+
/**
96+
* Return cart query with is_available & not_available_message fields
97+
*
98+
* @param string $cartId
99+
* @return string
100+
*/
101+
private function getCartQuery(string $cartId): string
102+
{
103+
return <<<QUERY
104+
{
105+
cart(cart_id:"{$cartId}") {
106+
itemsV2 {
107+
items {
108+
not_available_message
109+
is_available
110+
product {
111+
quantity
112+
}
113+
}
114+
}
115+
}
116+
}
117+
QUERY;
118+
}
119+
}

0 commit comments

Comments
 (0)