From de35ba1be7352b9a1403a31cc84f11a5ee587edb Mon Sep 17 00:00:00 2001 From: Audrius Date: Sat, 27 Jun 2026 03:55:28 +0200 Subject: [PATCH 1/2] Apply out-of-stock-last ordering on search result pages On search pages the products are ordered by relevance, built as a FIELD() list of the product ids returned by the core search. computeOrderByField() returned that relevance ordering early, before computeShowLast() ran, so the "show out of stock products last" behaviour - applied to category, manufacturer and other listings - was skipped for searches. Out-of-stock products could therefore show up before in-stock ones in search results. Route the search relevance ordering through computeShowLast() as well, so out of stock products are pushed to the end while the relevance order is preserved inside each group. --- src/Adapter/MySQL.php | 7 +++- tests/php/FacetedSearch/Adapter/MySQLTest.php | 35 +++++++++++++++++++ tests/php/FacetedSearch/MockProxy.php | 6 ++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/Adapter/MySQL.php b/src/Adapter/MySQL.php index 8af448ce0..b06d93da2 100644 --- a/src/Adapter/MySQL.php +++ b/src/Adapter/MySQL.php @@ -404,8 +404,13 @@ protected function computeOrderByField(array $filterToTableMapping) * to order products by their position in the search results we got from the core, with inverted order */ if ($orderField == 'p.position' && !empty($this->getInitialPopulation()->getFilters()['id_product']['='][0])) { - return 'FIELD(p.id_product,' . implode(',', $this->getInitialPopulation()->getFilters()['id_product']['='][0]) . ') ' . + $orderField = 'FIELD(p.id_product,' . implode(',', $this->getInitialPopulation()->getFilters()['id_product']['='][0]) . ') ' . ($this->getOrderDirection() === 'asc' ? 'DESC' : 'ASC'); + + // Push out-of-stock products to the end on search pages too. Without this, the relevance + // ordering was returned as is and bypassed the "out of stock last" behaviour that the + // category, manufacturer and other listings get below through computeShowLast(). + return $this->computeShowLast($orderField, $filterToTableMapping); } // Alter order by field and add some products to the end of the list, if required diff --git a/tests/php/FacetedSearch/Adapter/MySQLTest.php b/tests/php/FacetedSearch/Adapter/MySQLTest.php index 716c160f6..04c69e0a2 100644 --- a/tests/php/FacetedSearch/Adapter/MySQLTest.php +++ b/tests/php/FacetedSearch/Adapter/MySQLTest.php @@ -78,6 +78,41 @@ public function testGetEmptyQuery() ); } + /** + * On search pages the products are ordered by their relevance (their position in the pool + * returned by the core search). The "out of stock last" behaviour must still be applied on + * top of that relevance ordering, exactly like on category/manufacturer listings. + * + * @see https://github.com/PrestaShop/ps_facetedsearch/issues/1236 + */ + public function testGetQueryAppliesOutOfStockLastOnSearchRelevanceOrdering() + { + $configurationMock = Mockery::mock(Configuration::class); + $configurationMock->shouldReceive('get') + ->with('PS_LAYERED_FILTER_SHOW_OUT_OF_STOCK_LAST') + ->andReturn(1); + Configuration::setStaticExpectations($configurationMock); + + $productMock = Mockery::mock(Product::class); + $productMock->shouldReceive('isAvailableWhenOutOfStock') + ->andReturn(0); + Product::setStaticExpectations($productMock); + + // A search query: the pool of product ids becomes the initial population and the order + // field is the relevance "position". + $this->adapter->addFilter('id_product', [1, 2, 3], '='); + $this->adapter->useFiltersAsInitialPopulation(); + $this->adapter->setOrderField('position'); + $this->adapter->setOrderDirection('asc'); + + $query = $this->adapter->getQuery(); + + // Out-of-stock products are pushed to the end... + $this->assertStringContainsString('IFNULL(p.quantity, 0) <= 0', $query); + // ...while the relevance ordering is preserved within each group. + $this->assertStringContainsString('FIELD(p.id_product,1,2,3)', $query); + } + /** * @dataProvider oneSelectFieldDataProvider */ diff --git a/tests/php/FacetedSearch/MockProxy.php b/tests/php/FacetedSearch/MockProxy.php index 3dac1477d..4594517a3 100644 --- a/tests/php/FacetedSearch/MockProxy.php +++ b/tests/php/FacetedSearch/MockProxy.php @@ -115,6 +115,12 @@ class Combination extends MockProxy protected static $mock; } +class Product extends MockProxy +{ + // Redeclare to use this instead MockProxy::mock + protected static $mock; +} + class Shop extends MockProxy { // Redeclare to use this instead MockProxy::mock From 1d00b91132511ed8febc98da2030b7240a729c00 Mon Sep 17 00:00:00 2001 From: Audrius Date: Tue, 7 Jul 2026 21:15:52 +0300 Subject: [PATCH 2/2] Align the search out-of-stock-last test with the module test conventions The new test used assertStringContainsString(), which does not exist in the PHPUnit 5.7 the module runs on, and mocked Product with Mockery::mock() plus a MockProxy Product stub. That stub declared a global Product class at bootstrap, so the existing computeShowLast tests could no longer create their Product mock with Mockery::namedMock() (class already exists). Drop the MockProxy Product stub, mock Product through namedMock() like the neighbouring tests, and assert with assertContains() so the suite is green again on PHPUnit 5.7. --- tests/php/FacetedSearch/Adapter/MySQLTest.php | 12 ++++++------ tests/php/FacetedSearch/MockProxy.php | 6 ------ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/tests/php/FacetedSearch/Adapter/MySQLTest.php b/tests/php/FacetedSearch/Adapter/MySQLTest.php index 04c69e0a2..64f929b2c 100644 --- a/tests/php/FacetedSearch/Adapter/MySQLTest.php +++ b/tests/php/FacetedSearch/Adapter/MySQLTest.php @@ -90,13 +90,13 @@ public function testGetQueryAppliesOutOfStockLastOnSearchRelevanceOrdering() $configurationMock = Mockery::mock(Configuration::class); $configurationMock->shouldReceive('get') ->with('PS_LAYERED_FILTER_SHOW_OUT_OF_STOCK_LAST') - ->andReturn(1); + ->andReturn(true); Configuration::setStaticExpectations($configurationMock); - $productMock = Mockery::mock(Product::class); + $productMock = Mockery::namedMock(Product::class); $productMock->shouldReceive('isAvailableWhenOutOfStock') - ->andReturn(0); - Product::setStaticExpectations($productMock); + ->with(2) + ->andReturn(false); // A search query: the pool of product ids becomes the initial population and the order // field is the relevance "position". @@ -108,9 +108,9 @@ public function testGetQueryAppliesOutOfStockLastOnSearchRelevanceOrdering() $query = $this->adapter->getQuery(); // Out-of-stock products are pushed to the end... - $this->assertStringContainsString('IFNULL(p.quantity, 0) <= 0', $query); + $this->assertContains('IFNULL(p.quantity, 0) <= 0', $query); // ...while the relevance ordering is preserved within each group. - $this->assertStringContainsString('FIELD(p.id_product,1,2,3)', $query); + $this->assertContains('FIELD(p.id_product,1,2,3) DESC', $query); } /** diff --git a/tests/php/FacetedSearch/MockProxy.php b/tests/php/FacetedSearch/MockProxy.php index 4594517a3..3dac1477d 100644 --- a/tests/php/FacetedSearch/MockProxy.php +++ b/tests/php/FacetedSearch/MockProxy.php @@ -115,12 +115,6 @@ class Combination extends MockProxy protected static $mock; } -class Product extends MockProxy -{ - // Redeclare to use this instead MockProxy::mock - protected static $mock; -} - class Shop extends MockProxy { // Redeclare to use this instead MockProxy::mock