Commit a98a2ca04c4 for woocommerce
commit a98a2ca04c4ec1346b0bd5e4e25cf9453df7c3ce
Author: Vincent Borgers <88669109+VincentBorgers@users.noreply.github.com>
Date: Fri Oct 9 18:48:32 2026 +0200
Fix on_sale product filter when combined with include or exclude (#69432)
* Fix on_sale product filter when combined with include or exclude
* Guard the Store API related filter against an empty intersection
* Apply the on_sale filter to post__in when include or exclude is set
WP_Query ignores post__not_in when post__in is set, so on_sale=false with
include returned on-sale products and on_sale=true ignored exclude. Apply
both to post__in in that case, and use the same logic in the V4 products
endpoint.
* Add extra tests
---------
Co-authored-by: Albert Juhé Lluveras <contact@albertjuhe.com>
diff --git a/plugins/woocommerce/changelog/fix-onsale-filter-include-exclude b/plugins/woocommerce/changelog/fix-onsale-filter-include-exclude
new file mode 100644
index 00000000000..6708979a249
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-onsale-filter-include-exclude
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Products REST and Store API: return the correct products when the on_sale (or Store API related) filter is combined with include or exclude.
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
index 09d384fc02f..a7b3e0843bf 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
@@ -458,13 +458,26 @@ class WC_REST_Products_V2_Controller extends WC_REST_CRUD_Controller {
// Filter by on sale products.
if ( is_bool( $request['on_sale'] ) ) {
- $on_sale_key = $request['on_sale'] ? 'post__in' : 'post__not_in';
$on_sale_ids = wc_get_product_ids_on_sale();
// Use 0 when there's no on sale products to avoid return all products.
$on_sale_ids = empty( $on_sale_ids ) ? array( 0 ) : $on_sale_ids;
- $args[ $on_sale_key ] += $on_sale_ids;
+ if ( $request['on_sale'] || ! empty( $args['post__in'] ) ) {
+ $post_in = empty( $args['post__in'] ) ? $on_sale_ids : $args['post__in'];
+
+ // WP_Query ignores post__not_in when post__in is set, so the on_sale filter and exclude are applied to post__in.
+ if ( $request['on_sale'] ) {
+ $post_in = array_intersect( $post_in, $on_sale_ids );
+ } else {
+ $post_in = array_diff( $post_in, $on_sale_ids );
+ }
+ $post_in = array_diff( $post_in, (array) $args['post__not_in'] );
+
+ $args['post__in'] = empty( $post_in ) ? array( 0 ) : array_values( $post_in );
+ } else {
+ $args['post__not_in'] = array_merge( (array) $args['post__not_in'], $on_sale_ids );
+ }
}
// Force the post_type argument, since it's not a user input variable.
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
index 73c3011fe68..4b71d02a3db 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
@@ -1152,13 +1152,26 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
// Filter by on sale products.
if ( is_bool( $request['on_sale'] ) ) {
- $on_sale_key = $request['on_sale'] ? 'post__in' : 'post__not_in';
$on_sale_ids = wc_get_product_ids_on_sale();
// Use 0 when there's no on sale products to avoid return all products.
$on_sale_ids = empty( $on_sale_ids ) ? array( 0 ) : $on_sale_ids;
- $args[ $on_sale_key ] += $on_sale_ids;
+ if ( $request['on_sale'] || ! empty( $args['post__in'] ) ) {
+ $post_in = empty( $args['post__in'] ) ? $on_sale_ids : $args['post__in'];
+
+ // WP_Query ignores post__not_in when post__in is set, so the on_sale filter and exclude are applied to post__in.
+ if ( $request['on_sale'] ) {
+ $post_in = array_intersect( $post_in, $on_sale_ids );
+ } else {
+ $post_in = array_diff( $post_in, $on_sale_ids );
+ }
+ $post_in = array_diff( $post_in, (array) $args['post__not_in'] );
+
+ $args['post__in'] = empty( $post_in ) ? array( 0 ) : array_values( $post_in );
+ } else {
+ $args['post__not_in'] = array_merge( (array) $args['post__not_in'], $on_sale_ids );
+ }
}
// Force the post_type argument, since it's not a user input variable.
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
index 1f9cecfc87f..09b221e5710 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
@@ -453,13 +453,26 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
// Filter by on sale products.
if ( is_bool( $request['on_sale'] ) ) {
- $on_sale_key = $request['on_sale'] ? 'post__in' : 'post__not_in';
$on_sale_ids = wc_get_product_ids_on_sale();
// Use 0 when there's no on sale products to avoid return all products.
$on_sale_ids = empty( $on_sale_ids ) ? array( 0 ) : $on_sale_ids;
- $args[ $on_sale_key ] += $on_sale_ids;
+ if ( $request['on_sale'] || ! empty( $args['post__in'] ) ) {
+ $post_in = empty( $args['post__in'] ) ? $on_sale_ids : $args['post__in'];
+
+ // WP_Query ignores post__not_in when post__in is set, so the on_sale filter and exclude are applied to post__in.
+ if ( $request['on_sale'] ) {
+ $post_in = array_intersect( $post_in, $on_sale_ids );
+ } else {
+ $post_in = array_diff( $post_in, $on_sale_ids );
+ }
+ $post_in = array_diff( $post_in, (array) $args['post__not_in'] );
+
+ $args['post__in'] = empty( $post_in ) ? array( 0 ) : array_values( $post_in );
+ } else {
+ $args['post__not_in'] = array_merge( (array) $args['post__not_in'], $on_sale_ids );
+ }
}
// Force the post_type argument, since it's not a user input variable.
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
index 44d4167ec9d..e449959c135 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
@@ -581,20 +581,25 @@ class Controller extends WC_REST_Products_V2_Controller {
// Filter by on sale products.
if ( is_bool( $request['on_sale'] ) ) {
- $on_sale_key = $request['on_sale'] ? 'post__in' : 'post__not_in';
$on_sale_ids = wc_get_product_ids_on_sale();
// Use 0 when there's no on sale products to avoid return all products.
$on_sale_ids = empty( $on_sale_ids ) ? array( 0 ) : $on_sale_ids;
- if ( true === $request['on_sale'] && ! empty( $this->stock_quantity_filter ) && ! empty( $args['post__in'] ) ) {
- $args['post__in'] = array_values( array_intersect( array_map( 'absint', $args['post__in'] ), array_map( 'absint', $on_sale_ids ) ) );
+ if ( $request['on_sale'] || ! empty( $args['post__in'] ) ) {
+ $post_in = empty( $args['post__in'] ) ? $on_sale_ids : $args['post__in'];
- if ( empty( $args['post__in'] ) ) {
- $args['post__in'] = array( 0 );
+ // WP_Query ignores post__not_in when post__in is set, so the on_sale filter and exclude are applied to post__in.
+ if ( $request['on_sale'] ) {
+ $post_in = array_intersect( $post_in, $on_sale_ids );
+ } else {
+ $post_in = array_diff( $post_in, $on_sale_ids );
}
+ $post_in = array_diff( $post_in, (array) $args['post__not_in'] );
+
+ $args['post__in'] = empty( $post_in ) ? array( 0 ) : array_values( $post_in );
} else {
- $args[ $on_sale_key ] += $on_sale_ids;
+ $args['post__not_in'] = array_merge( (array) $args['post__not_in'], $on_sale_ids );
}
}
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php b/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php
index d3638f19e4b..8e7ba1ad272 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php
@@ -193,13 +193,26 @@ class ProductQuery implements QueryClausesGenerator {
// Filter by on sale products.
if ( is_bool( $request['on_sale'] ) ) {
- $on_sale_key = $request['on_sale'] ? 'post__in' : 'post__not_in';
$on_sale_ids = wc_get_product_ids_on_sale();
// Use 0 when there's no on sale products to avoid return all products.
$on_sale_ids = empty( $on_sale_ids ) ? array( 0 ) : $on_sale_ids;
- $args[ $on_sale_key ] += $on_sale_ids;
+ if ( $request['on_sale'] || ! empty( $args['post__in'] ) ) {
+ $post_in = empty( $args['post__in'] ) ? $on_sale_ids : $args['post__in'];
+
+ // WP_Query ignores post__not_in when post__in is set, so the on_sale filter and exclude are applied to post__in.
+ if ( $request['on_sale'] ) {
+ $post_in = array_intersect( $post_in, $on_sale_ids );
+ } else {
+ $post_in = array_diff( $post_in, $on_sale_ids );
+ }
+ $post_in = array_diff( $post_in, (array) $args['post__not_in'] );
+
+ $args['post__in'] = empty( $post_in ) ? array( 0 ) : array_values( $post_in );
+ } else {
+ $args['post__not_in'] = array_merge( (array) $args['post__not_in'], $on_sale_ids );
+ }
}
$catalog_visibility = $request->get_param( 'catalog_visibility' );
@@ -270,6 +283,10 @@ class ProductQuery implements QueryClausesGenerator {
$args['post__in'] = ! empty( $args['post__in'] )
? array_values( array_intersect( $args['post__in'], $related ) )
: array_values( $related );
+ // An empty intersection (no included product is related) must return no products, not all.
+ if ( empty( $args['post__in'] ) ) {
+ $args['post__in'] = array( 0 );
+ }
} else {
// No related products found, return empty result.
$args['post__in'] = array( 0 );
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php
index 7a36436500a..1a6b6f089a4 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php
@@ -506,6 +506,52 @@ class WC_REST_Products_V2_Controller_Test extends WC_REST_Unit_Test_Case {
$this->assertSame( array( 'stale-finish' ), wp_list_pluck( $data['default_attributes'], 'name' ) );
}
+ /**
+ * @testdox The on_sale parameter combines with include and exclude.
+ */
+ public function test_products_filter_by_on_sale_with_include_and_exclude(): void {
+ $on_sale_product = WC_Helper_Product::create_simple_product( true, array( 'sale_price' => 5 ) );
+ $excluded_product = WC_Helper_Product::create_simple_product( true, array( 'sale_price' => 5 ) );
+ $regular_product = WC_Helper_Product::create_simple_product();
+ $included_products = array( $on_sale_product->get_id(), $regular_product->get_id() );
+
+ delete_transient( 'wc_products_onsale' );
+
+ $cases = array(
+ 'on_sale=true with include' => array(
+ 'params' => array(
+ 'on_sale' => true,
+ 'include' => $included_products,
+ ),
+ 'expected' => array( $on_sale_product->get_id() ),
+ ),
+ 'on_sale=false with include' => array(
+ 'params' => array(
+ 'on_sale' => false,
+ 'include' => $included_products,
+ ),
+ 'expected' => array( $regular_product->get_id() ),
+ ),
+ 'on_sale=true with exclude' => array(
+ 'params' => array(
+ 'on_sale' => true,
+ 'exclude' => array( $excluded_product->get_id() ),
+ ),
+ 'expected' => array( $on_sale_product->get_id() ),
+ ),
+ );
+
+ foreach ( $cases as $name => $case ) {
+ $request = new WP_REST_Request( 'GET', '/wc/v2/products' );
+ $request->set_query_params( $case['params'] );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status(), $name );
+ $this->assertSame( $case['expected'], wp_list_pluck( $response->get_data(), 'id' ), $name );
+ }
+ }
+
/**
* @testdox The deprecated get_attribute_taxonomy_label() returns the attribute slug for a taxonomy that is not registered.
*/
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
index 76fa2562202..b382de3d594 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
@@ -465,6 +465,60 @@ class WC_REST_Product_Variations_Controller_Tests extends WC_Unit_Test_Case {
$this->assertEquals( $response_products[0]['id'], $variation->get_id() );
}
+ /**
+ * @testdox The on_sale parameter combines with include and exclude.
+ */
+ public function test_variations_filter_by_on_sale_with_include_and_exclude(): void {
+ $parent_product = WC_Helper_Product::create_variation_product();
+ $variation_ids = $parent_product->get_children();
+ $on_sale_variation = wc_get_product( $variation_ids[0] );
+ $on_sale_variation->set_sale_price( 5 );
+ $on_sale_variation->save();
+
+ $excluded_variation = wc_get_product( $variation_ids[1] );
+ $excluded_variation->set_sale_price( 5 );
+ $excluded_variation->save();
+
+ $regular_variation = wc_get_product( $variation_ids[2] );
+ $included_ids = array( $on_sale_variation->get_id(), $regular_variation->get_id() );
+
+ delete_transient( 'wc_products_onsale' );
+
+ $cases = array(
+ 'on_sale=true with include' => array(
+ 'params' => array(
+ 'on_sale' => true,
+ 'include' => $included_ids,
+ ),
+ 'expected' => array( $on_sale_variation->get_id() ),
+ ),
+ 'on_sale=false with include' => array(
+ 'params' => array(
+ 'on_sale' => false,
+ 'include' => $included_ids,
+ ),
+ 'expected' => array( $regular_variation->get_id() ),
+ ),
+ 'on_sale=true with exclude' => array(
+ 'params' => array(
+ 'on_sale' => true,
+ 'exclude' => array( $excluded_variation->get_id() ),
+ ),
+ 'expected' => array( $on_sale_variation->get_id() ),
+ ),
+ );
+
+ foreach ( $cases as $name => $case ) {
+ $request = new WP_REST_Request( 'GET', '/wc/v3/products/' . $parent_product->get_id() . '/variations' );
+ $request->set_query_params( $case['params'] );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status(), $name );
+ $this->assertSame( $case['expected'], wp_list_pluck( $response->get_data(), 'id' ), $name );
+ }
+ }
+
/**
* Test `downloadable` filter returns only downloadable product variations.
*/
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
index 2d767f5b213..c4aa6f4b00b 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
@@ -339,6 +339,52 @@ class WC_REST_Products_Controller_Tests extends WC_Unit_Test_Case {
$this->assertEquals( $response_products[1]['sku'], 'waffle-3' );
}
+ /**
+ * @testdox The on_sale parameter combines with include and exclude.
+ */
+ public function test_products_filter_by_on_sale_with_include_and_exclude(): void {
+ $on_sale_product = WC_Helper_Product::create_simple_product( true, array( 'sale_price' => 5 ) );
+ $excluded_product = WC_Helper_Product::create_simple_product( true, array( 'sale_price' => 5 ) );
+ $regular_product = WC_Helper_Product::create_simple_product();
+ $included_products = array( $on_sale_product->get_id(), $regular_product->get_id() );
+
+ delete_transient( 'wc_products_onsale' );
+
+ $cases = array(
+ 'on_sale=true with include' => array(
+ 'params' => array(
+ 'on_sale' => true,
+ 'include' => $included_products,
+ ),
+ 'expected' => array( $on_sale_product->get_id() ),
+ ),
+ 'on_sale=false with include' => array(
+ 'params' => array(
+ 'on_sale' => false,
+ 'include' => $included_products,
+ ),
+ 'expected' => array( $regular_product->get_id() ),
+ ),
+ 'on_sale=true with exclude' => array(
+ 'params' => array(
+ 'on_sale' => true,
+ 'exclude' => array( $excluded_product->get_id() ),
+ ),
+ 'expected' => array( $on_sale_product->get_id() ),
+ ),
+ );
+
+ foreach ( $cases as $name => $case ) {
+ $request = new WP_REST_Request( 'GET', '/wc/v3/products' );
+ $request->set_query_params( $case['params'] );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status(), $name );
+ $this->assertSame( $case['expected'], wp_list_pluck( $response->get_data(), 'id' ), $name );
+ }
+ }
+
/**
* Test that the `search_sku` parameter does partial matching in the product SKU, but not the name.
*
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/ProductQueryTest.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/ProductQueryTest.php
index a623b6dc5ed..7d3dac1e3e8 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/ProductQueryTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/ProductQueryTest.php
@@ -280,6 +280,104 @@ class ProductQueryTest extends \WC_Unit_Test_Case {
);
}
+ /**
+ * on_sale=true combined with include must intersect: only the included products that are on sale.
+ */
+ public function test_on_sale_true_intersects_with_include(): void {
+ set_transient( 'wc_products_onsale', array( 55, 66, 77 ) );
+
+ $request = new \WP_REST_Request( 'GET', '/wc/store/v1/products' );
+ $request->set_param( 'on_sale', true );
+ $request->set_param( 'include', array( 55, 101 ) );
+
+ $args = $this->product_query->prepare_objects_query( $request );
+
+ $this->assertEqualsCanonicalizing( array( 55 ), $args['post__in'] );
+ }
+
+ /**
+ * on_sale=true with an include list that contains no on-sale products must return nothing, not the include list.
+ */
+ public function test_on_sale_true_is_not_ignored_when_include_has_no_on_sale_products(): void {
+ set_transient( 'wc_products_onsale', array( 55, 66 ) );
+
+ $request = new \WP_REST_Request( 'GET', '/wc/store/v1/products' );
+ $request->set_param( 'on_sale', true );
+ $request->set_param( 'include', array( 101, 102 ) );
+
+ $args = $this->product_query->prepare_objects_query( $request );
+
+ $this->assertSame( array( 0 ), $args['post__in'] );
+ }
+
+ /**
+ * on_sale=false combined with exclude must merge: exclude the explicit list plus every on-sale product.
+ */
+ public function test_on_sale_false_merges_on_sale_ids_into_exclude(): void {
+ set_transient( 'wc_products_onsale', array( 55, 66 ) );
+
+ $request = new \WP_REST_Request( 'GET', '/wc/store/v1/products' );
+ $request->set_param( 'on_sale', false );
+ $request->set_param( 'exclude', array( 99 ) );
+
+ $args = $this->product_query->prepare_objects_query( $request );
+
+ $this->assertEqualsCanonicalizing( array( 99, 55, 66 ), $args['post__not_in'] );
+ }
+
+ /**
+ * on_sale=false combined with include must remove the on-sale products from post__in, because WP_Query ignores post__not_in when post__in is set.
+ */
+ public function test_on_sale_false_removes_on_sale_products_from_include(): void {
+ set_transient( 'wc_products_onsale', array( 55, 66 ) );
+
+ $request = new \WP_REST_Request( 'GET', '/wc/store/v1/products' );
+ $request->set_param( 'on_sale', false );
+ $request->set_param( 'include', array( 55, 101 ) );
+
+ $args = $this->product_query->prepare_objects_query( $request );
+
+ $this->assertSame( array( 101 ), $args['post__in'] );
+ }
+
+ /**
+ * on_sale=true combined with exclude must remove the excluded products from post__in.
+ */
+ public function test_on_sale_true_removes_excluded_products(): void {
+ set_transient( 'wc_products_onsale', array( 55, 66, 77 ) );
+
+ $request = new \WP_REST_Request( 'GET', '/wc/store/v1/products' );
+ $request->set_param( 'on_sale', true );
+ $request->set_param( 'exclude', array( 66 ) );
+
+ $args = $this->product_query->prepare_objects_query( $request );
+
+ $this->assertEqualsCanonicalizing( array( 55, 77 ), $args['post__in'] );
+ }
+
+ /**
+ * The related filter must return no products when none of the included products are related, not every product.
+ */
+ public function test_related_filter_with_empty_intersection_returns_no_products(): void {
+ $fixtures = new FixtureData();
+ $anchor = $fixtures->get_simple_product( array( 'name' => 'Related anchor' ) );
+
+ $callback = static function () {
+ return array( 55, 66 );
+ };
+ add_filter( 'woocommerce_related_products', $callback );
+
+ $request = new \WP_REST_Request( 'GET', '/wc/store/v1/products' );
+ $request->set_param( 'related', $anchor->get_id() );
+ $request->set_param( 'include', array( 101 ) );
+
+ $args = $this->product_query->prepare_objects_query( $request );
+
+ remove_filter( 'woocommerce_related_products', $callback );
+
+ $this->assertSame( array( 0 ), $args['post__in'] );
+ }
+
/**
* Get product IDs matched by a Store API slug filter.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php
index 4bf071989ed..18f209f2831 100644
--- a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php
@@ -1738,6 +1738,57 @@ class ProductsControllerTest extends WC_Unit_Test_Case {
delete_transient( 'wc_products_onsale' );
}
+ /**
+ * @testdox The on_sale parameter combines with include and exclude.
+ */
+ public function test_products_filter_by_on_sale_with_include_and_exclude(): void {
+ $on_sale_product = WC_Helper_Product::create_simple_product( true, array( 'sale_price' => 5 ) );
+ $excluded_product = WC_Helper_Product::create_simple_product( true, array( 'sale_price' => 5 ) );
+ $regular_product = WC_Helper_Product::create_simple_product();
+ $included_products = array( $on_sale_product->get_id(), $regular_product->get_id() );
+
+ delete_transient( 'wc_products_onsale' );
+
+ $cases = array(
+ 'on_sale=true with include' => array(
+ 'params' => array(
+ 'on_sale' => true,
+ 'include' => $included_products,
+ ),
+ 'expected' => array( $on_sale_product->get_id() ),
+ ),
+ 'on_sale=false with include' => array(
+ 'params' => array(
+ 'on_sale' => false,
+ 'include' => $included_products,
+ ),
+ 'expected' => array( $regular_product->get_id() ),
+ ),
+ 'on_sale=true with exclude' => array(
+ 'params' => array(
+ 'on_sale' => true,
+ 'exclude' => array( $excluded_product->get_id() ),
+ ),
+ 'expected' => array( $on_sale_product->get_id() ),
+ ),
+ );
+
+ foreach ( $cases as $name => $case ) {
+ $request = new WP_REST_Request( 'GET', '/wc/v4/products' );
+ $request->set_query_params( $case['params'] );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status(), $name );
+ $this->assertEquals( $case['expected'], wp_list_pluck( $response->get_data(), 'id' ), $name );
+ }
+
+ WC_Helper_Product::delete_product( $on_sale_product->get_id() );
+ WC_Helper_Product::delete_product( $excluded_product->get_id() );
+ WC_Helper_Product::delete_product( $regular_product->get_id() );
+ delete_transient( 'wc_products_onsale' );
+ }
+
/**
* Create one product of each parent product type for type-filter tests.
*/