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.
 	 */