diff --git a/InventoryCatalog/Test/Integration/CompositeParentSalabilityOnNonDefaultStockTest.php b/InventoryCatalog/Test/Integration/CompositeParentSalabilityOnNonDefaultStockTest.php new file mode 100644 index 000000000000..550c4e0272b1 --- /dev/null +++ b/InventoryCatalog/Test/Integration/CompositeParentSalabilityOnNonDefaultStockTest.php @@ -0,0 +1,117 @@ +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); + } +} diff --git a/InventoryCatalog/Test/Unit/Plugin/InventoryApi/UpdateCompositeProductStockStatusOnSourceItemsSaveTest.php b/InventoryCatalog/Test/Unit/Plugin/InventoryApi/UpdateCompositeProductStockStatusOnSourceItemsSaveTest.php new file mode 100644 index 000000000000..80902e0a9527 --- /dev/null +++ b/InventoryCatalog/Test/Unit/Plugin/InventoryApi/UpdateCompositeProductStockStatusOnSourceItemsSaveTest.php @@ -0,0 +1,111 @@ +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; + } +}