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
@@ -0,0 +1,117 @@
<?php
/**
* Copyright 2026 Adobe
* All Rights Reserved.
*/
declare(strict_types=1);

namespace Magento\InventoryCatalog\Test\Integration;

use Magento\InventoryApi\Api\Data\SourceItemInterface;
use Magento\InventoryApi\Api\Data\SourceItemInterfaceFactory;
use Magento\InventoryApi\Api\SourceItemsSaveInterface;
use Magento\InventorySalesApi\Model\GetStockItemDataInterface;
use Magento\TestFramework\Fixture\DataFixture;
use Magento\TestFramework\Fixture\DbIsolation;
use Magento\TestFramework\Helper\Bootstrap;
use PHPUnit\Framework\TestCase;

/**
* Regression guard for magento/inventory#3350.
*
* A composite parent whose children are out of stock at the DEFAULT source but still in stock at another
* source must remain salable in the stock those other sources back. The parent's legacy stock item is
* default-stock data; recomputing it from default-scope children and then letting it decide another
* stock's salability is what #3350 reported.
*/
class CompositeParentSalabilityOnNonDefaultStockTest extends TestCase
{
private const NON_DEFAULT_STOCK_ID = 10;

private const PARENT_SKU = 'configurable_1';

private const CHILD_SKUS = ['simple_11', 'simple_21', 'simple_31'];

/**
* @var GetStockItemDataInterface
*/
private $getStockItemData;

protected function setUp(): void
{
$this->getStockItemData = Bootstrap::getObjectManager()->get(GetStockItemDataInterface::class);
}

/**
* @return void
*/
#[
DbIsolation(false),
DataFixture('Magento_InventoryApi::Test/_files/sources.php'),
DataFixture('Magento_InventoryApi::Test/_files/stocks.php'),
DataFixture('Magento_InventoryApi::Test/_files/stock_source_links.php'),
DataFixture('Magento_InventorySalesApi::Test/_files/websites_with_stores.php'),
DataFixture('Magento_InventorySalesApi::Test/_files/stock_website_sales_channels.php'),
DataFixture('Magento/ConfigurableProduct/_files/configurable_attribute.php'),
DataFixture('Magento_InventoryConfigurableProductIndexer::Test/_files/product_configurable_multiple.php'),
DataFixture('Magento_InventoryConfigurableProductIndexer::Test/_files/source_items_configurable_multiple.php'),
]
public function testTheParentStaysSalableWhenOnlyTheDefaultSourceRunsOut(): void
{
$before = $this->salabilityInNonDefaultStock();
self::assertSame(
1,
$before,
'PREMISE FAILED: the parent must be salable in the non-default stock to begin with, otherwise '
. 'this test cannot detect the regression it guards against.'
);

// The default source runs out. The eu sources backing stock 10 are untouched.
$this->setDefaultSourceOutOfStock();

self::assertSame(
1,
$this->salabilityInNonDefaultStock(),
'magento/inventory#3350 has been reintroduced: the parent lost its salability in a stock backed '
. 'by sources that still hold stock, because its default-stock legacy row was recomputed from '
. 'default-scope children and then vetoed the other stock.'
);
}

/**
* @return int
*/
private function salabilityInNonDefaultStock(): int
{
$data = $this->getStockItemData->execute(self::PARENT_SKU, self::NON_DEFAULT_STOCK_ID);
self::assertNotNull(
$data,
sprintf('No index row for "%s" in stock %d.', self::PARENT_SKU, self::NON_DEFAULT_STOCK_ID)
);

return (int)$data[GetStockItemDataInterface::IS_SALABLE];
}

/**
* Put every child out of stock on the default source only.
*
* @return void
*/
private function setDefaultSourceOutOfStock(): void
{
$objectManager = Bootstrap::getObjectManager();
$sourceItemFactory = $objectManager->get(SourceItemInterfaceFactory::class);

$sourceItems = [];
foreach (self::CHILD_SKUS as $sku) {
$sourceItem = $sourceItemFactory->create();
$sourceItem->setSourceCode('default');
$sourceItem->setSku($sku);
$sourceItem->setQuantity(0);
$sourceItem->setStatus(SourceItemInterface::STATUS_OUT_OF_STOCK);
$sourceItems[] = $sourceItem;
}

$objectManager->get(SourceItemsSaveInterface::class)->execute($sourceItems);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php
/**
* Copyright 2026 Adobe
* All Rights Reserved.
*/
declare(strict_types=1);

namespace Magento\InventoryCatalog\Test\Unit\Plugin\InventoryApi;

use Magento\InventoryApi\Api\Data\SourceItemInterface;
use Magento\InventoryApi\Api\SourceItemsSaveInterface;
use Magento\InventoryCatalog\Plugin\InventoryApi\UpdateCompositeProductStockStatusOnSourceItemsSave;
use Magento\InventoryCatalogApi\Model\CompositeProductStockStatusProcessorInterface;
use Magento\InventoryCatalogApi\Model\IsSingleSourceModeInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

/**
* Documents the source-mode gate on the composite parent recompute.
*
* The recompute runs only in single source mode. In multi source mode it is skipped, because the parent's
* legacy stock item is a single default-stock value and recomputing it from default-scope children would
* let it veto stocks backed by other sources - see magento/inventory#3350.
*/
class UpdateCompositeProductStockStatusOnSourceItemsSaveTest extends TestCase
{
/**
* @var IsSingleSourceModeInterface|MockObject
*/
private $isSingleSourceMode;

/**
* @var CompositeProductStockStatusProcessorInterface|MockObject
*/
private $processor;

/**
* @var UpdateCompositeProductStockStatusOnSourceItemsSave
*/
private $plugin;

protected function setUp(): void
{
$this->isSingleSourceMode = $this->createMock(IsSingleSourceModeInterface::class);
$this->processor = $this->createMock(CompositeProductStockStatusProcessorInterface::class);
$this->plugin = new UpdateCompositeProductStockStatusOnSourceItemsSave(
$this->isSingleSourceMode,
$this->processor
);
}

/**
* @return void
*/
public function testTheParentIsRecomputedInSingleSourceMode(): void
{
$this->isSingleSourceMode->method('execute')->willReturn(true);

$this->processor->expects($this->once())
->method('execute')
->with(['sku-a', 'sku-b']);

$this->plugin->afterExecute(
$this->createMock(SourceItemsSaveInterface::class),
null,
[$this->sourceItem('sku-a'), $this->sourceItem('sku-b')]
);
}

/**
* In multi source mode the recompute is deliberately skipped. See magento/inventory#3350: the parent's
* legacy row is default-stock data, and recomputing it here makes it veto other stocks.
*
* @return void
*/
public function testTheParentIsNotRecomputedInMultiSourceMode(): void
{
$this->isSingleSourceMode->method('execute')->willReturn(false);

$this->processor->expects($this->never())->method('execute');

$this->plugin->afterExecute(
$this->createMock(SourceItemsSaveInterface::class),
null,
[$this->sourceItem('sku-a'), $this->sourceItem('sku-b')]
);
}

/**
* @return void
*/
public function testAnEmptyBatchRecomputesNothing(): void
{
$this->isSingleSourceMode->method('execute')->willReturn(true);
$this->processor->expects($this->never())->method('execute');

$this->plugin->afterExecute($this->createMock(SourceItemsSaveInterface::class), null, []);
}

/**
* @param string $sku
* @return SourceItemInterface|MockObject
*/
private function sourceItem(string $sku)
{
$sourceItem = $this->createMock(SourceItemInterface::class);
$sourceItem->method('getSku')->willReturn($sku);

return $sourceItem;
}
}