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 ) );
+ }
}