Commit 4252b33a800 for woocommerce

commit 4252b33a8009117f3463103cad10de71f54e87b2
Author: Brandon Kraft <public@brandonkraft.com>
Date:   Thu Jul 9 01:39:28 2026 -0500

    Rename new coupon result filters to woocommerce_coupon_is_valid_for_* (#66433)

    PR #64363 (merged to trunk, unreleased, @since 11.1.0) added six coupon
    validation result filters under the woocommerce_coupon_validate_* prefix
    with true=valid polarity. That prefix is already owned by three shipped
    filters (expiry_date, minimum_amount, maximum_amount) that use the
    opposite true=reject polarity, so the shared prefix was a public-API
    footgun.

    Rename the six new filters to woocommerce_coupon_is_valid_for_* (polarity
    unchanged: true=valid). The is_* prefix mirrors the existing
    woocommerce_coupon_is_valid filter and makes the true=valid semantics
    self-documenting, cleanly separated from the validate_* (true=reject)
    family. Docblocks now call out the polarity difference explicitly.

    Because the filters have never shipped in a stable release, this is a
    pre-freeze rename with no backward-compatibility impact and no
    deprecation window required.

    Tests updated to the new names and extended to cover the force-reject
    (return false) direction for all six filters, not just two.

    Bug introduced in PR #64363.

diff --git a/plugins/woocommerce/changelog/fix-coupon-validate-filter-polarity b/plugins/woocommerce/changelog/fix-coupon-validate-filter-polarity
new file mode 100644
index 00000000000..1d4d94db09d
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-coupon-validate-filter-polarity
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Rename the new coupon result filters to woocommerce_coupon_is_valid_for_* so their true=valid polarity is clear and no longer collides with the woocommerce_coupon_validate_* family.
diff --git a/plugins/woocommerce/includes/class-wc-discounts.php b/plugins/woocommerce/includes/class-wc-discounts.php
index c5be196095b..9da858cfb61 100644
--- a/plugins/woocommerce/includes/class-wc-discounts.php
+++ b/plugins/woocommerce/includes/class-wc-discounts.php
@@ -810,16 +810,19 @@ class WC_Discounts {
 			}

 			/**
-			 * Filter the result of coupon product_ids validation.
+			 * Filter whether the coupon is valid for the cart given its product_ids restriction.
 			 *
-			 * Return true to treat the coupon as valid for the cart, or false to reject it.
+			 * Return true to treat the coupon as valid, or false to reject it. Mind the polarity: these
+			 * woocommerce_coupon_is_valid_for_* filters follow the woocommerce_coupon_is_valid convention where
+			 * true means valid. This is the opposite of the woocommerce_coupon_validate_* filters
+			 * (e.g. woocommerce_coupon_validate_expiry_date), where returning true rejects the coupon.
 			 *
 			 * @since 11.1.0
-			 * @param bool         $valid     Whether the coupon is valid.
+			 * @param bool         $valid     Whether the coupon is valid for the selected products.
 			 * @param WC_Coupon    $coupon    Coupon data.
 			 * @param WC_Discounts $discounts The discounts instance.
 			 */
-			$valid = apply_filters( 'woocommerce_coupon_validate_product_ids', $valid, $coupon, $this );
+			$valid = apply_filters( 'woocommerce_coupon_is_valid_for_product_ids', $valid, $coupon, $this );

 			if ( ! $valid ) {
 				throw new Exception(
@@ -867,16 +870,19 @@ class WC_Discounts {
 			}

 			/**
-			 * Filter the result of coupon product_categories validation.
+			 * Filter whether the coupon is valid for the cart given its product_categories restriction.
 			 *
-			 * Return true to treat the coupon as valid for the cart, or false to reject it.
+			 * Return true to treat the coupon as valid, or false to reject it. Mind the polarity: these
+			 * woocommerce_coupon_is_valid_for_* filters follow the woocommerce_coupon_is_valid convention where
+			 * true means valid. This is the opposite of the woocommerce_coupon_validate_* filters
+			 * (e.g. woocommerce_coupon_validate_expiry_date), where returning true rejects the coupon.
 			 *
 			 * @since 11.1.0
-			 * @param bool         $valid     Whether the coupon is valid.
+			 * @param bool         $valid     Whether the coupon is valid for the selected product categories.
 			 * @param WC_Coupon    $coupon    Coupon data.
 			 * @param WC_Discounts $discounts The discounts instance.
 			 */
-			$valid = apply_filters( 'woocommerce_coupon_validate_product_categories', $valid, $coupon, $this );
+			$valid = apply_filters( 'woocommerce_coupon_is_valid_for_product_categories', $valid, $coupon, $this );

 			if ( ! $valid ) {
 				throw new Exception(
@@ -913,16 +919,19 @@ class WC_Discounts {
 			}

 			/**
-			 * Filter the result of coupon sale_items validation.
+			 * Filter whether the coupon is valid for the cart given its sale_items restriction.
 			 *
-			 * Return true to treat the coupon as valid for the cart, or false to reject it.
+			 * Return true to treat the coupon as valid, or false to reject it. Mind the polarity: these
+			 * woocommerce_coupon_is_valid_for_* filters follow the woocommerce_coupon_is_valid convention where
+			 * true means valid. This is the opposite of the woocommerce_coupon_validate_* filters
+			 * (e.g. woocommerce_coupon_validate_expiry_date), where returning true rejects the coupon.
 			 *
 			 * @since 11.1.0
-			 * @param bool         $valid     Whether the coupon is valid.
+			 * @param bool         $valid     Whether the coupon is valid given the sale items in the cart.
 			 * @param WC_Coupon    $coupon    Coupon data.
 			 * @param WC_Discounts $discounts The discounts instance.
 			 */
-			$valid = apply_filters( 'woocommerce_coupon_validate_sale_items', $valid, $coupon, $this );
+			$valid = apply_filters( 'woocommerce_coupon_is_valid_for_sale_items', $valid, $coupon, $this );

 			if ( ! $valid ) {
 				throw new Exception(
@@ -960,16 +969,19 @@ class WC_Discounts {
 			}

 			/**
-			 * Filter the result of coupon excluded_items validation.
+			 * Filter whether the coupon is valid for the cart given its excluded items.
 			 *
-			 * Return true to treat the coupon as valid for the cart, or false to reject it.
+			 * Return true to treat the coupon as valid, or false to reject it. Mind the polarity: these
+			 * woocommerce_coupon_is_valid_for_* filters follow the woocommerce_coupon_is_valid convention where
+			 * true means valid. This is the opposite of the woocommerce_coupon_validate_* filters
+			 * (e.g. woocommerce_coupon_validate_expiry_date), where returning true rejects the coupon.
 			 *
 			 * @since 11.1.0
-			 * @param bool         $valid     Whether the coupon is valid.
+			 * @param bool         $valid     Whether the coupon is valid given the cart's exclusion rules.
 			 * @param WC_Coupon    $coupon    Coupon data.
 			 * @param WC_Discounts $discounts The discounts instance.
 			 */
-			$valid = apply_filters( 'woocommerce_coupon_validate_excluded_items', $valid, $coupon, $this );
+			$valid = apply_filters( 'woocommerce_coupon_is_valid_for_excluded_items', $valid, $coupon, $this );

 			if ( ! $valid ) {
 				throw new Exception(
@@ -1026,18 +1038,21 @@ class WC_Discounts {
 			$valid = empty( $products );

 			/**
-			 * Filter the result of coupon excluded_product_ids validation.
+			 * Filter whether the coupon is valid for the cart given its excluded_product_ids restriction.
 			 *
-			 * Return true to treat the coupon as valid for the cart, or false to reject it. When rejecting,
-			 * use the woocommerce_coupon_error filter (error code WC_Coupon::E_WC_COUPON_EXCLUDED_PRODUCTS)
-			 * to customize the message shown to the shopper.
+			 * Return true to treat the coupon as valid, or false to reject it. Mind the polarity: these
+			 * woocommerce_coupon_is_valid_for_* filters follow the woocommerce_coupon_is_valid convention where
+			 * true means valid. This is the opposite of the woocommerce_coupon_validate_* filters
+			 * (e.g. woocommerce_coupon_validate_expiry_date), where returning true rejects the coupon. When
+			 * rejecting, use the woocommerce_coupon_error filter (error code
+			 * WC_Coupon::E_WC_COUPON_EXCLUDED_PRODUCTS) to customize the message shown to the shopper.
 			 *
 			 * @since 11.1.0
-			 * @param bool         $valid     Whether the coupon is valid.
+			 * @param bool         $valid     Whether the coupon is valid given the excluded products.
 			 * @param WC_Coupon    $coupon    Coupon data.
 			 * @param WC_Discounts $discounts The discounts instance.
 			 */
-			$valid = apply_filters( 'woocommerce_coupon_validate_excluded_product_ids', $valid, $coupon, $this );
+			$valid = apply_filters( 'woocommerce_coupon_is_valid_for_excluded_product_ids', $valid, $coupon, $this );

 			if ( ! $valid ) {
 				// When a filter forces rejection but no cart item matched, fall back to the generic message.
@@ -1097,18 +1112,21 @@ class WC_Discounts {
 			$valid = empty( $categories );

 			/**
-			 * Filter the result of coupon excluded_product_categories validation.
+			 * Filter whether the coupon is valid for the cart given its excluded_product_categories restriction.
 			 *
-			 * Return true to treat the coupon as valid for the cart, or false to reject it. When rejecting,
-			 * use the woocommerce_coupon_error filter (error code WC_Coupon::E_WC_COUPON_EXCLUDED_CATEGORIES)
-			 * to customize the message shown to the shopper.
+			 * Return true to treat the coupon as valid, or false to reject it. Mind the polarity: these
+			 * woocommerce_coupon_is_valid_for_* filters follow the woocommerce_coupon_is_valid convention where
+			 * true means valid. This is the opposite of the woocommerce_coupon_validate_* filters
+			 * (e.g. woocommerce_coupon_validate_expiry_date), where returning true rejects the coupon. When
+			 * rejecting, use the woocommerce_coupon_error filter (error code
+			 * WC_Coupon::E_WC_COUPON_EXCLUDED_CATEGORIES) to customize the message shown to the shopper.
 			 *
 			 * @since 11.1.0
-			 * @param bool         $valid     Whether the coupon is valid.
+			 * @param bool         $valid     Whether the coupon is valid given the excluded categories.
 			 * @param WC_Coupon    $coupon    Coupon data.
 			 * @param WC_Discounts $discounts The discounts instance.
 			 */
-			$valid = apply_filters( 'woocommerce_coupon_validate_excluded_product_categories', $valid, $coupon, $this );
+			$valid = apply_filters( 'woocommerce_coupon_is_valid_for_excluded_product_categories', $valid, $coupon, $this );

 			if ( ! $valid ) {
 				// When a filter forces rejection but no cart item matched, fall back to the generic message.
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-discounts-tests.php b/plugins/woocommerce/tests/php/includes/class-wc-discounts-tests.php
index 03f4bd611f5..c00168278c5 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-discounts-tests.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-discounts-tests.php
@@ -241,7 +241,7 @@ class WC_Discounts_Tests extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox The woocommerce_coupon_validate_product_ids filter can bypass or force product ID validation.
+	 * @testdox The woocommerce_coupon_is_valid_for_product_ids filter can bypass or force product ID validation.
 	 */
 	public function test_product_ids_validation_filter() {
 		$in_cart     = WC_Helper_Product::create_simple_product();
@@ -263,9 +263,9 @@ class WC_Discounts_Tests extends WC_Unit_Test_Case {
 		$this->assertWPError( $result, 'A coupon restricted to a product not in the cart should be invalid.' );
 		$this->assertStringContainsString( 'is not applicable to selected products', $result->get_error_message(), 'The failure should come from the product_ids check.' );

-		add_filter( 'woocommerce_coupon_validate_product_ids', '__return_true' );
+		add_filter( 'woocommerce_coupon_is_valid_for_product_ids', '__return_true' );
 		$this->assertTrue( $discounts->is_coupon_valid( $coupon ), 'Returning true from the filter should bypass the product ID restriction.' );
-		remove_filter( 'woocommerce_coupon_validate_product_ids', '__return_true' );
+		remove_filter( 'woocommerce_coupon_is_valid_for_product_ids', '__return_true' );

 		$this->assertWPError( $discounts->is_coupon_valid( $coupon ), 'Normal validation should resume once the filter is removed.' );

@@ -280,15 +280,15 @@ class WC_Discounts_Tests extends WC_Unit_Test_Case {
 		$valid_coupon->save();

 		$this->assertTrue( $discounts->is_coupon_valid( $valid_coupon ), 'A coupon restricted to a product in the cart should be valid.' );
-		add_filter( 'woocommerce_coupon_validate_product_ids', '__return_false' );
+		add_filter( 'woocommerce_coupon_is_valid_for_product_ids', '__return_false' );
 		$result = $discounts->is_coupon_valid( $valid_coupon );
 		$this->assertWPError( $result, 'Returning false from the filter should force rejection.' );
 		$this->assertStringContainsString( 'is not applicable to selected products', $result->get_error_message(), 'Forced rejection should surface the product_ids error.' );
-		remove_filter( 'woocommerce_coupon_validate_product_ids', '__return_false' );
+		remove_filter( 'woocommerce_coupon_is_valid_for_product_ids', '__return_false' );
 	}

 	/**
-	 * @testdox The woocommerce_coupon_validate_product_categories filter can bypass category validation.
+	 * @testdox The woocommerce_coupon_is_valid_for_product_categories filter can bypass or force category validation.
 	 */
 	public function test_product_categories_validation_filter() {
 		$product = WC_Helper_Product::create_simple_product();
@@ -310,13 +310,38 @@ class WC_Discounts_Tests extends WC_Unit_Test_Case {
 		$this->assertWPError( $result, 'A coupon restricted to a category none of the cart items belong to should be invalid.' );
 		$this->assertStringContainsString( 'is not applicable to selected products', $result->get_error_message(), 'The failure should come from the product_categories check.' );

-		add_filter( 'woocommerce_coupon_validate_product_categories', '__return_true' );
+		add_filter( 'woocommerce_coupon_is_valid_for_product_categories', '__return_true' );
 		$this->assertTrue( $discounts->is_coupon_valid( $coupon ), 'Returning true from the filter should bypass the category restriction.' );
-		remove_filter( 'woocommerce_coupon_validate_product_categories', '__return_true' );
+		remove_filter( 'woocommerce_coupon_is_valid_for_product_categories', '__return_true' );
+
+		// Force-reject a coupon that is otherwise valid for the cart's categories.
+		$stocked_term = wp_insert_term( 'coupon-cat-stocked-' . uniqid(), 'product_cat' );
+		$categorized  = WC_Helper_Product::create_simple_product();
+		$categorized->set_category_ids( array( $stocked_term['term_id'] ) );
+		$categorized->save();
+		WC()->cart->add_to_cart( $categorized->get_id(), 1 );
+		$discounts = new WC_Discounts( WC()->cart );
+
+		$valid_coupon = new WC_Coupon();
+		$valid_coupon->set_props(
+			array(
+				'discount_type'      => 'fixed_cart',
+				'amount'             => 10,
+				'product_categories' => array( $stocked_term['term_id'] ),
+			)
+		);
+		$valid_coupon->save();
+
+		$this->assertTrue( $discounts->is_coupon_valid( $valid_coupon ), 'A coupon whose category is in the cart should be valid.' );
+		add_filter( 'woocommerce_coupon_is_valid_for_product_categories', '__return_false' );
+		$result = $discounts->is_coupon_valid( $valid_coupon );
+		$this->assertWPError( $result, 'Returning false from the filter should force rejection.' );
+		$this->assertStringContainsString( 'is not applicable to selected products', $result->get_error_message(), 'Forced rejection should surface the product_categories error.' );
+		remove_filter( 'woocommerce_coupon_is_valid_for_product_categories', '__return_false' );
 	}

 	/**
-	 * @testdox The woocommerce_coupon_validate_excluded_items filter can bypass excluded item validation.
+	 * @testdox The woocommerce_coupon_is_valid_for_excluded_items filter can bypass or force excluded item validation.
 	 */
 	public function test_excluded_items_validation_filter() {
 		$product = WC_Helper_Product::create_simple_product();
@@ -338,13 +363,30 @@ class WC_Discounts_Tests extends WC_Unit_Test_Case {
 		$this->assertWPError( $result, 'A product coupon whose only cart item is excluded should be invalid.' );
 		$this->assertStringContainsString( 'is not applicable to selected products', $result->get_error_message(), 'The failure should come from the excluded_items check.' );

-		add_filter( 'woocommerce_coupon_validate_excluded_items', '__return_true' );
+		add_filter( 'woocommerce_coupon_is_valid_for_excluded_items', '__return_true' );
 		$this->assertTrue( $discounts->is_coupon_valid( $coupon ), 'Returning true from the filter should bypass the excluded item restriction.' );
-		remove_filter( 'woocommerce_coupon_validate_excluded_items', '__return_true' );
+		remove_filter( 'woocommerce_coupon_is_valid_for_excluded_items', '__return_true' );
+
+		// Force-reject an unrestricted product coupon that is otherwise valid for the cart items.
+		$valid_coupon = new WC_Coupon();
+		$valid_coupon->set_props(
+			array(
+				'discount_type' => 'percent',
+				'amount'        => 10,
+			)
+		);
+		$valid_coupon->save();
+
+		$this->assertTrue( $discounts->is_coupon_valid( $valid_coupon ), 'An unrestricted product coupon should be valid for the cart.' );
+		add_filter( 'woocommerce_coupon_is_valid_for_excluded_items', '__return_false' );
+		$result = $discounts->is_coupon_valid( $valid_coupon );
+		$this->assertWPError( $result, 'Returning false from the filter should force rejection.' );
+		$this->assertStringContainsString( 'is not applicable to selected products', $result->get_error_message(), 'Forced rejection should surface the excluded_items error.' );
+		remove_filter( 'woocommerce_coupon_is_valid_for_excluded_items', '__return_false' );
 	}

 	/**
-	 * @testdox The woocommerce_coupon_validate_sale_items filter can bypass sale item validation.
+	 * @testdox The woocommerce_coupon_is_valid_for_sale_items filter can bypass or force sale item validation.
 	 */
 	public function test_sale_items_validation_filter() {
 		$product = WC_Helper_Product::create_simple_product();
@@ -369,13 +411,38 @@ class WC_Discounts_Tests extends WC_Unit_Test_Case {
 		$this->assertWPError( $result, 'A coupon excluding sale items should be invalid when the cart holds a sale item.' );
 		$this->assertStringContainsString( 'is not valid for sale items', $result->get_error_message(), 'The failure should come from the sale_items check.' );

-		add_filter( 'woocommerce_coupon_validate_sale_items', '__return_true' );
+		add_filter( 'woocommerce_coupon_is_valid_for_sale_items', '__return_true' );
 		$this->assertTrue( $discounts->is_coupon_valid( $coupon ), 'Returning true from the filter should bypass the sale item restriction.' );
-		remove_filter( 'woocommerce_coupon_validate_sale_items', '__return_true' );
+		remove_filter( 'woocommerce_coupon_is_valid_for_sale_items', '__return_true' );
+
+		// Force-reject a sale-exclusion coupon that is otherwise valid (cart holds no sale items).
+		WC()->cart->empty_cart();
+		$regular = WC_Helper_Product::create_simple_product();
+		$regular->set_regular_price( 10 );
+		$regular->save();
+		WC()->cart->add_to_cart( $regular->get_id(), 1 );
+		$discounts = new WC_Discounts( WC()->cart );
+
+		$valid_coupon = new WC_Coupon();
+		$valid_coupon->set_props(
+			array(
+				'discount_type'      => 'fixed_cart',
+				'amount'             => 10,
+				'exclude_sale_items' => true,
+			)
+		);
+		$valid_coupon->save();
+
+		$this->assertTrue( $discounts->is_coupon_valid( $valid_coupon ), 'A sale-exclusion coupon should be valid when the cart has no sale items.' );
+		add_filter( 'woocommerce_coupon_is_valid_for_sale_items', '__return_false' );
+		$result = $discounts->is_coupon_valid( $valid_coupon );
+		$this->assertWPError( $result, 'Returning false from the filter should force rejection.' );
+		$this->assertStringContainsString( 'is not valid for sale items', $result->get_error_message(), 'Forced rejection should surface the sale_items error.' );
+		remove_filter( 'woocommerce_coupon_is_valid_for_sale_items', '__return_false' );
 	}

 	/**
-	 * @testdox The woocommerce_coupon_validate_excluded_product_ids filter can bypass excluded product ID validation.
+	 * @testdox The woocommerce_coupon_is_valid_for_excluded_product_ids filter can bypass excluded product ID validation.
 	 */
 	public function test_excluded_product_ids_validation_filter() {
 		$product = WC_Helper_Product::create_simple_product();
@@ -397,9 +464,9 @@ class WC_Discounts_Tests extends WC_Unit_Test_Case {
 		$this->assertWPError( $result, 'A coupon excluding the only cart product should be invalid.' );
 		$this->assertStringContainsString( 'is not applicable to the products', $result->get_error_message(), 'The failure should come from the excluded_product_ids check.' );

-		add_filter( 'woocommerce_coupon_validate_excluded_product_ids', '__return_true' );
+		add_filter( 'woocommerce_coupon_is_valid_for_excluded_product_ids', '__return_true' );
 		$this->assertTrue( $discounts->is_coupon_valid( $coupon ), 'Returning true from the filter should bypass the excluded product restriction.' );
-		remove_filter( 'woocommerce_coupon_validate_excluded_product_ids', '__return_true' );
+		remove_filter( 'woocommerce_coupon_is_valid_for_excluded_product_ids', '__return_true' );

 		// Force-reject when no cart item matched: the generic message is used, not a dangling empty list.
 		$unmatched_coupon = new WC_Coupon();
@@ -413,16 +480,16 @@ class WC_Discounts_Tests extends WC_Unit_Test_Case {
 		$unmatched_coupon->save();

 		$this->assertTrue( $discounts->is_coupon_valid( $unmatched_coupon ), 'A coupon excluding a product not in the cart is valid by default.' );
-		add_filter( 'woocommerce_coupon_validate_excluded_product_ids', '__return_false' );
+		add_filter( 'woocommerce_coupon_is_valid_for_excluded_product_ids', '__return_false' );
 		$result = $discounts->is_coupon_valid( $unmatched_coupon );
 		$this->assertWPError( $result, 'Returning false should reject even when no excluded product is in the cart.' );
 		$this->assertStringContainsString( 'is not applicable to selected products', $result->get_error_message(), 'With no matched products the generic message is used.' );
 		$this->assertStringNotContainsString( 'products: .', $result->get_error_message(), 'The rejection message should not contain an empty product list.' );
-		remove_filter( 'woocommerce_coupon_validate_excluded_product_ids', '__return_false' );
+		remove_filter( 'woocommerce_coupon_is_valid_for_excluded_product_ids', '__return_false' );
 	}

 	/**
-	 * @testdox The woocommerce_coupon_validate_excluded_product_categories filter can bypass excluded category validation.
+	 * @testdox The woocommerce_coupon_is_valid_for_excluded_product_categories filter can bypass or force excluded category validation.
 	 */
 	public function test_excluded_product_categories_validation_filter() {
 		$term    = wp_insert_term( 'excl-cat-' . uniqid(), 'product_cat' );
@@ -447,8 +514,27 @@ class WC_Discounts_Tests extends WC_Unit_Test_Case {
 		$this->assertWPError( $result, 'A coupon excluding the cart product\'s category should be invalid.' );
 		$this->assertStringContainsString( 'is not applicable to the categories', $result->get_error_message(), 'The failure should come from the excluded_product_categories check.' );

-		add_filter( 'woocommerce_coupon_validate_excluded_product_categories', '__return_true' );
+		add_filter( 'woocommerce_coupon_is_valid_for_excluded_product_categories', '__return_true' );
 		$this->assertTrue( $discounts->is_coupon_valid( $coupon ), 'Returning true from the filter should bypass the excluded category restriction.' );
-		remove_filter( 'woocommerce_coupon_validate_excluded_product_categories', '__return_true' );
+		remove_filter( 'woocommerce_coupon_is_valid_for_excluded_product_categories', '__return_true' );
+
+		// Force-reject when no cart item is in the excluded category: the generic message is used.
+		$unused_term      = wp_insert_term( 'excl-cat-unused-' . uniqid(), 'product_cat' );
+		$unmatched_coupon = new WC_Coupon();
+		$unmatched_coupon->set_props(
+			array(
+				'discount_type'               => 'fixed_cart',
+				'amount'                      => 10,
+				'excluded_product_categories' => array( $unused_term['term_id'] ),
+			)
+		);
+		$unmatched_coupon->save();
+
+		$this->assertTrue( $discounts->is_coupon_valid( $unmatched_coupon ), 'A coupon excluding a category not in the cart is valid by default.' );
+		add_filter( 'woocommerce_coupon_is_valid_for_excluded_product_categories', '__return_false' );
+		$result = $discounts->is_coupon_valid( $unmatched_coupon );
+		$this->assertWPError( $result, 'Returning false should reject even when no excluded category is in the cart.' );
+		$this->assertStringContainsString( 'is not applicable to selected products', $result->get_error_message(), 'With no matched categories the generic message is used.' );
+		remove_filter( 'woocommerce_coupon_is_valid_for_excluded_product_categories', '__return_false' );
 	}
 }