Commit 15288ca0555 for woocommerce

commit 15288ca0555ecc34c983d4e8b7799fc29a9e0880
Author: Adam Grzybkowski <agrzybkowski@outlook.com>
Date:   Wed May 20 21:57:41 2026 +0200

    Add max_rating threshold to store_review push notification (#64565)

    * Add max_rating sub-field to store_review notification preference

    * Add max_rating to notification preferences REST schema

    * Filter store_review notifications by max_rating threshold

    * Add changelog entry for store_review max_rating threshold

diff --git a/plugins/woocommerce/changelog/issue-RSM-1552 b/plugins/woocommerce/changelog/issue-RSM-1552
new file mode 100644
index 00000000000..4b20c1ba5a9
--- /dev/null
+++ b/plugins/woocommerce/changelog/issue-RSM-1552
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a merchant-configurable maximum-rating threshold to the store_review push notification, so only reviews at or below the threshold trigger a notification. Default behaviour (notify on every approved review) is unchanged.
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestController.php
index 71939936756..170351beb79 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestController.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestController.php
@@ -215,6 +215,15 @@ class NotificationPreferencesRestController extends RestApiControllerBase {
 				);
 			}

+			if ( array_key_exists( 'max_rating', $shape ) ) {
+				$properties['max_rating'] = array(
+					'type'        => array( 'integer', 'null' ),
+					'minimum'     => 1,
+					'maximum'     => 5,
+					'description' => __( 'Maximum star rating that triggers a review notification (1–5), or null to disable the threshold.', 'woocommerce' ),
+				);
+			}
+
 			$args[ $key ] = array(
 				'description'       => sprintf(
 					/* translators: %s: notification preference key (e.g. store_order). */
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewReviewNotification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewReviewNotification.php
index 6c78fa56cc8..2914b7ccdbe 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewReviewNotification.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewReviewNotification.php
@@ -26,6 +26,41 @@ class NewReviewNotification extends Notification {
 		return self::TYPE;
 	}

+	/**
+	 * {@inheritDoc}
+	 *
+	 * Extends the base enabled-toggle check with a maximum-rating threshold.
+	 * When `max_rating` is set in the user's preferences, reviews rated above
+	 * the threshold do not trigger a notification.
+	 *
+	 * @param mixed $pref_value The user's stored preference value, or null.
+	 * @return bool
+	 *
+	 * @since 10.9.0
+	 */
+	public function should_send_to_user( $pref_value ): bool {
+		if ( ! parent::should_send_to_user( $pref_value ) ) {
+			return false;
+		}
+
+		if ( ! is_array( $pref_value ) || ! isset( $pref_value['max_rating'] ) ) {
+			return true;
+		}
+
+		$comment = WC()->call_function( 'get_comment', $this->get_resource_id() );
+		if ( ! $comment instanceof WP_Comment ) {
+			return false;
+		}
+
+		$rating = WC()->call_function( 'get_comment_meta', $this->get_resource_id(), 'rating', true );
+
+		if ( '' === $rating ) {
+			return true;
+		}
+
+		return (int) $rating <= (int) $pref_value['max_rating'];
+	}
+
 	/**
 	 * Returns the WPCOM-ready payload for this notification.
 	 *
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationPreferencesService.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationPreferencesService.php
index d117d518e9e..d758214696e 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationPreferencesService.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationPreferencesService.php
@@ -116,7 +116,8 @@ class NotificationPreferencesService {
 			$defaults[ $type ] = array( 'enabled' => true );
 		}

-		$defaults['store_order']['min_amount'] = null;
+		$defaults['store_order']['min_amount']  = null;
+		$defaults['store_review']['max_rating'] = null;

 		return $defaults;
 	}
@@ -177,7 +178,17 @@ class NotificationPreferencesService {
 				$sanitized[ $sub_key ] = $amount > 0 ? $amount : null;
 				continue;
 			}
-		}
+
+			if ( 'max_rating' === $sub_key ) {
+				if ( ! array_key_exists( $sub_key, $value ) || null === $value[ $sub_key ] ) {
+					$sanitized[ $sub_key ] = null;
+					continue;
+				}
+				$rating                = (int) $value[ $sub_key ];
+				$sanitized[ $sub_key ] = ( $rating >= 1 && $rating <= 5 ) ? $rating : null;
+				continue;
+			}
+		}//end foreach

 		return $sanitized;
 	}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestControllerTest.php
index 3c30c83fd5f..2da84125d81 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestControllerTest.php
@@ -353,6 +353,81 @@ class NotificationPreferencesRestControllerTest extends WC_REST_Unit_Test_Case {
 		$this->assertNull( $data['store_order']['min_amount'] );
 	}

+	/**
+	 * @testdox POST should reject non-integer max_rating via the REST validation layer.
+	 */
+	public function test_post_preferences_rejects_non_integer_max_rating() {
+		wp_set_current_user( $this->user_id );
+		$this->mock_jetpack_connection_manager_is_connected( true );
+		$this->register_routes();
+
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/preferences' );
+		$request->set_param( 'store_review', array( 'max_rating' => 'not-a-number' ) );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( WP_Http::BAD_REQUEST, $response->get_status() );
+	}
+
+	/**
+	 * @testdox POST should reject out-of-range max_rating via the REST validation layer.
+	 *
+	 * @testWith [0]
+	 *           [6]
+	 *
+	 * @param int $value The invalid value.
+	 */
+	public function test_post_preferences_rejects_out_of_range_max_rating( int $value ) {
+		wp_set_current_user( $this->user_id );
+		$this->mock_jetpack_connection_manager_is_connected( true );
+		$this->register_routes();
+
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/preferences' );
+		$request->set_param( 'store_review', array( 'max_rating' => $value ) );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( WP_Http::BAD_REQUEST, $response->get_status() );
+	}
+
+	/**
+	 * @testdox POST should accept a valid max_rating and persist it.
+	 */
+	public function test_post_preferences_accepts_valid_max_rating() {
+		wp_set_current_user( $this->user_id );
+		$this->mock_jetpack_connection_manager_is_connected( true );
+		$this->register_routes();
+
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/preferences' );
+		$request->set_param( 'store_review', array( 'max_rating' => 3 ) );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( WP_Http::OK, $response->get_status() );
+
+		$data = $response->get_data();
+		$this->assertSame( 3, $data['store_review']['max_rating'] );
+	}
+
+	/**
+	 * @testdox POST should accept null max_rating and persist it.
+	 */
+	public function test_post_preferences_accepts_null_max_rating() {
+		wp_set_current_user( $this->user_id );
+		$this->mock_jetpack_connection_manager_is_connected( true );
+		$this->register_routes();
+
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/preferences' );
+		$request->set_param( 'store_review', array( 'max_rating' => null ) );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( WP_Http::OK, $response->get_status() );
+
+		$data = $response->get_data();
+		$this->assertNull( $data['store_review']['max_rating'] );
+	}
+
 	/**
 	 * @testdox GET should include a null min_amount in store_order by default.
 	 */
@@ -371,6 +446,24 @@ class NotificationPreferencesRestControllerTest extends WC_REST_Unit_Test_Case {
 		$this->assertNull( $data['store_order']['min_amount'] );
 	}

+	/**
+	 * @testdox GET should include a null max_rating in store_review by default.
+	 */
+	public function test_get_preferences_includes_max_rating_in_store_review() {
+		wp_set_current_user( $this->user_id );
+		$this->mock_jetpack_connection_manager_is_connected( true );
+		$this->register_routes();
+
+		$request  = new WP_REST_Request( 'GET', '/wc-push-notifications/preferences' );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( WP_Http::OK, $response->get_status() );
+
+		$data = $response->get_data();
+		$this->assertArrayHasKey( 'max_rating', $data['store_review'] );
+		$this->assertNull( $data['store_review']['max_rating'] );
+	}
+
 	/**
 	 * @testdox Should not collide with PushTokenRestController on the WC REST namespaces filter.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewReviewNotificationTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewReviewNotificationTest.php
index f920a60ce39..411e7f217af 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewReviewNotificationTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewReviewNotificationTest.php
@@ -155,4 +155,158 @@ class NewReviewNotificationTest extends WC_Unit_Test_Case {

 		$this->assertNull( $notification->to_payload() );
 	}
+
+	/**
+	 * @testdox should_send_to_user should return true when rating is below max_rating.
+	 */
+	public function test_should_send_to_user_when_rating_below_max(): void {
+		$comment_id = $this->create_review_with_rating( 3 );
+
+		$notification = new NewReviewNotification( $comment_id );
+
+		$this->assertTrue(
+			$notification->should_send_to_user(
+				array(
+					'enabled'    => true,
+					'max_rating' => 4,
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox should_send_to_user should return true when rating equals max_rating.
+	 */
+	public function test_should_send_to_user_when_rating_equals_max(): void {
+		$comment_id = $this->create_review_with_rating( 3 );
+
+		$notification = new NewReviewNotification( $comment_id );
+
+		$this->assertTrue(
+			$notification->should_send_to_user(
+				array(
+					'enabled'    => true,
+					'max_rating' => 3,
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox should_send_to_user should return false when rating is above max_rating.
+	 */
+	public function test_should_not_send_to_user_when_rating_above_max(): void {
+		$comment_id = $this->create_review_with_rating( 5 );
+
+		$notification = new NewReviewNotification( $comment_id );
+
+		$this->assertFalse(
+			$notification->should_send_to_user(
+				array(
+					'enabled'    => true,
+					'max_rating' => 3,
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox should_send_to_user should return true when max_rating key is missing (backwards compat).
+	 */
+	public function test_should_send_to_user_when_max_rating_missing(): void {
+		$comment_id = $this->create_review_with_rating( 5 );
+
+		$notification = new NewReviewNotification( $comment_id );
+
+		$this->assertTrue(
+			$notification->should_send_to_user( array( 'enabled' => true ) )
+		);
+	}
+
+	/**
+	 * @testdox should_send_to_user should return true when max_rating is null (threshold disabled).
+	 */
+	public function test_should_send_to_user_when_max_rating_null(): void {
+		$comment_id = $this->create_review_with_rating( 5 );
+
+		$notification = new NewReviewNotification( $comment_id );
+
+		$this->assertTrue(
+			$notification->should_send_to_user(
+				array(
+					'enabled'    => true,
+					'max_rating' => null,
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox should_send_to_user should return false when notification is disabled, regardless of rating.
+	 */
+	public function test_should_not_send_to_user_when_disabled(): void {
+		$comment_id = $this->create_review_with_rating( 1 );
+
+		$notification = new NewReviewNotification( $comment_id );
+
+		$this->assertFalse(
+			$notification->should_send_to_user(
+				array(
+					'enabled'    => false,
+					'max_rating' => null,
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox should_send_to_user should return false when the comment no longer exists.
+	 */
+	public function test_should_not_send_to_user_when_comment_deleted(): void {
+		$notification = new NewReviewNotification( 999999 );
+
+		$this->assertFalse(
+			$notification->should_send_to_user(
+				array(
+					'enabled'    => true,
+					'max_rating' => 3,
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox should_send_to_user should return true when the review has no rating meta.
+	 */
+	public function test_should_send_to_user_when_no_rating_meta(): void {
+		$product    = WC_Helper_Product::create_simple_product();
+		$comment_id = WC_Helper_Product::create_product_review( $product->get_id() );
+
+		// Ensure no rating meta is present, in case the helper changes to set one by default.
+		delete_comment_meta( $comment_id, 'rating' );
+
+		$notification = new NewReviewNotification( $comment_id );
+
+		$this->assertTrue(
+			$notification->should_send_to_user(
+				array(
+					'enabled'    => true,
+					'max_rating' => 3,
+				)
+			)
+		);
+	}
+
+	/**
+	 * Creates a product review with a specific rating stored in comment meta.
+	 *
+	 * @param int $rating The star rating (1–5).
+	 * @return int The new comment ID.
+	 */
+	private function create_review_with_rating( int $rating ): int {
+		$product    = WC_Helper_Product::create_simple_product();
+		$comment_id = WC_Helper_Product::create_product_review( $product->get_id() );
+		update_comment_meta( $comment_id, 'rating', $rating );
+		return (int) $comment_id;
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationPreferencesServiceTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationPreferencesServiceTest.php
index f41bc528b53..743d0d77c96 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationPreferencesServiceTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationPreferencesServiceTest.php
@@ -351,4 +351,98 @@ class NotificationPreferencesServiceTest extends WC_Unit_Test_Case {

 		$this->assertSame( 50.0, $result['store_order']['min_amount'] );
 	}
+
+	/**
+	 * @testdox Should default max_rating to null in store_review defaults.
+	 */
+	public function test_get_defaults_includes_max_rating_for_store_review(): void {
+		$defaults = $this->sut->get_defaults();
+
+		$this->assertArrayHasKey( 'max_rating', $defaults['store_review'] );
+		$this->assertNull( $defaults['store_review']['max_rating'] );
+	}
+
+	/**
+	 * @testdox Should preserve explicit null max_rating.
+	 */
+	public function test_sanitize_preserves_null_max_rating(): void {
+		$this->data_store->method( 'read' )->willReturn( null );
+
+		$result = $this->sut->save_preferences(
+			$this->user_id,
+			array( 'store_review' => array( 'max_rating' => null ) )
+		);
+
+		$this->assertNull( $result['store_review']['max_rating'] );
+	}
+
+	/**
+	 * @testdox Should fall back to null when max_rating is out of range.
+	 *
+	 * @testWith [0]
+	 *           [-3]
+	 *           [6]
+	 *           [10]
+	 *
+	 * @param int $value The invalid value.
+	 */
+	public function test_sanitize_falls_back_to_null_for_out_of_range_max_rating( int $value ): void {
+		$this->data_store->method( 'read' )->willReturn( null );
+
+		$result = $this->sut->save_preferences(
+			$this->user_id,
+			array( 'store_review' => array( 'max_rating' => $value ) )
+		);
+
+		$this->assertNull( $result['store_review']['max_rating'] );
+	}
+
+	/**
+	 * @testdox Should coerce string max_rating to int.
+	 */
+	public function test_sanitize_coerces_max_rating_to_int(): void {
+		$this->data_store->method( 'read' )->willReturn( null );
+
+		$result = $this->sut->save_preferences(
+			$this->user_id,
+			array( 'store_review' => array( 'max_rating' => '3' ) )
+		);
+
+		$this->assertSame( 3, $result['store_review']['max_rating'] );
+	}
+
+	/**
+	 * @testdox Should perform a deep merge so a partial update of enabled preserves existing max_rating.
+	 */
+	public function test_save_preferences_deep_merges_max_rating(): void {
+		$this->data_store->method( 'read' )->willReturn(
+			array(
+				'schema_version' => NotificationPreferencesDataStore::CURRENT_SCHEMA_VERSION,
+				'preferences'    => array(
+					'store_review' => array(
+						'enabled'    => true,
+						'max_rating' => 3,
+					),
+				),
+			)
+		);
+
+		$this->data_store
+			->expects( $this->once() )
+			->method( 'write' )
+			->with(
+				$this->anything(),
+				$this->callback(
+					function ( $envelope ) {
+						$prefs = $envelope['preferences']['store_review'];
+						return false === $prefs['enabled'] && 3 === $prefs['max_rating'];
+					}
+				)
+			);
+
+		$this->sut->save_preferences(
+			$this->user_id,
+			array( 'store_review' => array( 'enabled' => false ) )
+		);
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
index 269e94211f9..7ba071671ed 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
@@ -569,4 +569,38 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
 			wc_get_order( $order->get_id() )->get_meta( NotificationProcessor::SENT_META_KEY )
 		);
 	}
+
+	/**
+	 * @testdox Should skip dispatch when the review rating is above the user's max_rating threshold.
+	 */
+	public function test_process_skips_dispatch_when_review_above_max_rating(): void {
+		$product    = WC_Helper_Product::create_simple_product();
+		$comment_id = WC_Helper_Product::create_product_review( $product->get_id() );
+		update_comment_meta( $comment_id, 'rating', 5 );
+
+		$preferences_service = $this->createMock( NotificationPreferencesService::class );
+		$preferences_service->method( 'get_preferences' )->willReturn(
+			array(
+				'store_order'  => array(
+					'enabled'    => true,
+					'min_amount' => null,
+				),
+				'store_review' => array(
+					'enabled'    => true,
+					'max_rating' => 3,
+				),
+			)
+		);
+
+		$this->dispatcher->expects( $this->never() )->method( 'dispatch' );
+
+		$sut = new NotificationProcessor();
+		$sut->init( $this->dispatcher, $this->data_store, $preferences_service );
+
+		$notification = new NewReviewNotification( $comment_id );
+		$result       = $sut->process( $notification );
+
+		$this->assertTrue( $result );
+		$this->assertNotEmpty( get_comment_meta( $comment_id, NotificationProcessor::SENT_META_KEY, true ) );
+	}
 }