Commit 9bce399db3 for woocommerce
commit 9bce399db391878d9362ac40eb389620a993560a
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date: Wed Feb 25 16:22:50 2026 +0000
Adds concrete notification classes (#63380)
* Add Notification base class and PendingNotificationStore.
- Add abstract Notification class with to_payload() for subclasses.
- Add PendingNotificationStore with dedup and shutdown dispatch.
- Store uses register() flag pattern, enabled via PushNotifications::on_init().
- Dispatch fires wc_push_notifications_dispatch action hook.
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
index b308fec69d..b97a2e451c 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
@@ -109,7 +109,7 @@ class PushTokenRestController extends RestApiControllerBase {
'device_uuid' => $request->get_param( 'device_uuid' ),
'origin' => $request->get_param( 'origin' ),
'device_locale' => $request->get_param( 'device_locale' ),
- 'metadata' => $request->get_param( 'metadata' ),
+ 'metadata' => $request->get_param( 'metadata' ) ?? array(),
);
$data_store = wc_get_container()->get( PushTokensDataStore::class );
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewOrderNotification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewOrderNotification.php
new file mode 100644
index 0000000000..0f81590c2b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewOrderNotification.php
@@ -0,0 +1,89 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Notifications;
+
+use WC_Order;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Notification for new WooCommerce orders.
+ *
+ * @since 10.7.0
+ */
+class NewOrderNotification extends Notification {
+ /**
+ * The notification type identifier, this should match the subtype or type
+ * (if there isn't a subtype) values attributed to notes in WordPress.com.
+ */
+ const TYPE = 'store_order';
+
+ /**
+ * The icon to use in the notification.
+ */
+ const ICON = 'https://s.wp.com/wp-content/mu-plugins/notes/images/update-payment-2x.png';
+
+ /**
+ * An array of emojis to select from when forming the payload.
+ */
+ const EMOJI_LIST = array( '🎉', '🎊', '🥳', '👏', '🙌' );
+
+ /**
+ * Creates a new order notification.
+ *
+ * @param int $order_id The order ID.
+ *
+ * @since 10.7.0
+ */
+ public function __construct( int $order_id ) {
+ parent::__construct( self::TYPE, $order_id );
+ }
+
+ /**
+ * Returns the WPCOM-ready payload for this notification.
+ *
+ * Returns null if the order no longer exists.
+ *
+ * @return array|null
+ *
+ * @since 10.7.0
+ */
+ public function to_payload(): ?array {
+ $order = WC()->call_function( 'wc_get_order', $this->get_resource_id() );
+
+ if ( ! $order || ! $order instanceof WC_Order ) {
+ return null;
+ }
+
+ return array(
+ 'type' => self::TYPE,
+ 'icon' => self::ICON,
+ 'blog_id' => get_current_blog_id(),
+ 'resource_id' => $this->get_resource_id(),
+ 'title' => array(
+ /**
+ * This will be translated in WordPress.com, format:
+ * 1: emoji
+ */
+ 'format' => 'You have a new order! %1$s',
+ 'args' => array( self::EMOJI_LIST[ wp_rand( 0, count( self::EMOJI_LIST ) - 1 ) ] ),
+ ),
+ 'message' => array(
+ /**
+ * This will be translated in WordPress.com, format:
+ * 1: order total, 2: site title
+ */
+ 'format' => 'New order for %1$s on %2$s',
+ 'args' => array(
+ wp_strip_all_tags( $order->get_formatted_order_total() ),
+ wp_strip_all_tags( get_bloginfo( 'name' ) ),
+ ),
+ ),
+ 'meta' => array(
+ 'order_id' => $this->get_resource_id(),
+ ),
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewReviewNotification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewReviewNotification.php
new file mode 100644
index 0000000000..026d6cfb03
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewReviewNotification.php
@@ -0,0 +1,74 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Notifications;
+
+use WP_Comment;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Notification for new product reviews.
+ *
+ * @since 10.7.0
+ */
+class NewReviewNotification extends Notification {
+ /**
+ * The notification type identifier.
+ */
+ const TYPE = 'store_review';
+
+ /**
+ * Creates a new review notification.
+ *
+ * @param int $comment_id The comment ID.
+ *
+ * @since 10.7.0
+ */
+ public function __construct( int $comment_id ) {
+ parent::__construct( self::TYPE, $comment_id );
+ }
+
+ /**
+ * Returns the WPCOM-ready payload for this notification.
+ *
+ * Returns null if the comment no longer exists.
+ *
+ * @return array|null
+ *
+ * @since 10.7.0
+ */
+ public function to_payload(): ?array {
+ $comment = WC()->call_function( 'get_comment', $this->get_resource_id() );
+
+ if ( ! $comment || ! $comment instanceof WP_Comment ) {
+ return null;
+ }
+
+ return array(
+ 'type' => self::TYPE,
+ 'blog_id' => get_current_blog_id(),
+ 'resource_id' => $this->get_resource_id(),
+ 'title' => array(
+ 'format' => 'You have a new review! ⭐️',
+ ),
+ 'message' => array(
+ /**
+ * This will be translated in WordPress.com, format:
+ * 1: reviewer name, 2: product name, 3: comment content
+ */
+ 'format' => '%1$s left a review on %2$s: %3$s',
+ 'args' => array(
+ wp_strip_all_tags( $comment->comment_author ),
+ wp_strip_all_tags( get_the_title( (int) $comment->comment_post_ID ) ),
+ wp_strip_all_tags( $comment->comment_content ),
+ ),
+ ),
+ 'icon' => get_avatar_url( $comment->comment_author_email ),
+ 'meta' => array(
+ 'comment_id' => $this->get_resource_id(),
+ ),
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewOrderNotificationTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewOrderNotificationTest.php
new file mode 100644
index 0000000000..dc3a49764b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewOrderNotificationTest.php
@@ -0,0 +1,93 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Notifications;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewOrderNotification;
+use WC_Helper_Order;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the NewOrderNotification class.
+ */
+class NewOrderNotificationTest extends WC_Unit_Test_Case {
+ /**
+ * @testdox Should return a payload with all required keys for an existing order.
+ */
+ public function test_to_payload_contains_required_keys(): void {
+ $order = WC_Helper_Order::create_order();
+ $notification = new NewOrderNotification( $order->get_id() );
+
+ $payload = $notification->to_payload();
+
+ $this->assertArrayHasKey( 'type', $payload );
+ $this->assertArrayHasKey( 'blog_id', $payload );
+ $this->assertArrayHasKey( 'resource_id', $payload );
+ $this->assertArrayHasKey( 'title', $payload );
+ $this->assertArrayHasKey( 'format', $payload['title'] );
+ $this->assertArrayHasKey( 'args', $payload['title'] );
+ $this->assertArrayHasKey( 'message', $payload );
+ $this->assertArrayHasKey( 'format', $payload['message'] );
+ $this->assertArrayHasKey( 'args', $payload['message'] );
+ $this->assertArrayHasKey( 'icon', $payload );
+ $this->assertArrayHasKey( 'meta', $payload );
+ $this->assertArrayHasKey( 'order_id', $payload['meta'] );
+ }
+
+ /**
+ * @testdox Should return store_order as the notification type.
+ */
+ public function test_type_is_store_order(): void {
+ $notification = new NewOrderNotification( 1 );
+
+ $this->assertSame( 'store_order', $notification->get_type() );
+ }
+
+ /**
+ * @testdox Should return the order ID as the resource ID.
+ */
+ public function test_resource_id_matches_order_id(): void {
+ $notification = new NewOrderNotification( 42 );
+
+ $this->assertSame( 42, $notification->get_resource_id() );
+ }
+
+ /**
+ * @testdox Should include an emoji in the title args.
+ */
+ public function test_to_payload_title_args_contain_emoji(): void {
+ $order = WC_Helper_Order::create_order();
+ $notification = new NewOrderNotification( $order->get_id() );
+
+ $payload = $notification->to_payload();
+
+ $this->assertContains( $payload['title']['args'][0], NewOrderNotification::EMOJI_LIST );
+ }
+
+ /**
+ * @testdox Should include an order total and blog name in the message args.
+ */
+ public function test_to_payload_message_args_contain_order_total_and_blog_name(): void {
+ $order = WC_Helper_Order::create_order();
+ $notification = new NewOrderNotification( $order->get_id() );
+
+ $payload = $notification->to_payload();
+
+ $this->assertSame( get_bloginfo( 'name' ), $payload['message']['args'][1] );
+
+ $this->assertSame(
+ wp_strip_all_tags( $order->get_formatted_order_total() ),
+ $payload['message']['args'][0]
+ );
+ }
+
+ /**
+ * @testdox Should return null when the order no longer exists.
+ */
+ public function test_to_payload_returns_null_for_deleted_order(): void {
+ $notification = new NewOrderNotification( 999999 );
+
+ $this->assertNull( $notification->to_payload() );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewReviewNotificationTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewReviewNotificationTest.php
new file mode 100644
index 0000000000..9e52cd7c2f
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewReviewNotificationTest.php
@@ -0,0 +1,127 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Notifications;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewReviewNotification;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the NewReviewNotification class.
+ */
+class NewReviewNotificationTest extends WC_Unit_Test_Case {
+ /**
+ * @testdox Should return a payload with all required keys for an existing review.
+ */
+ public function test_to_payload_contains_required_keys(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $comment_id = WC_Helper_Product::create_product_review( $product->get_id() );
+
+ $notification = new NewReviewNotification( $comment_id );
+ $payload = $notification->to_payload();
+
+ $this->assertArrayHasKey( 'type', $payload );
+ $this->assertArrayHasKey( 'blog_id', $payload );
+ $this->assertArrayHasKey( 'resource_id', $payload );
+ $this->assertArrayHasKey( 'title', $payload );
+ $this->assertArrayHasKey( 'format', $payload['title'] );
+ $this->assertArrayHasKey( 'message', $payload );
+ $this->assertArrayHasKey( 'format', $payload['message'] );
+ $this->assertArrayHasKey( 'args', $payload['message'] );
+ $this->assertArrayHasKey( 'icon', $payload );
+ $this->assertArrayHasKey( 'meta', $payload );
+ $this->assertArrayHasKey( 'comment_id', $payload['meta'] );
+ }
+
+ /**
+ * @testdox Should return store_review as the notification type.
+ */
+ public function test_type_is_store_review(): void {
+ $notification = new NewReviewNotification( 1 );
+
+ $this->assertSame( 'store_review', $notification->get_type() );
+ }
+
+ /**
+ * @testdox Should return the comment ID as the resource ID.
+ */
+ public function test_resource_id_matches_comment_id(): void {
+ $notification = new NewReviewNotification( 42 );
+
+ $this->assertSame( 42, $notification->get_resource_id() );
+ }
+
+ /**
+ * @testdox Should include the reviewer name, product name, and review
+ * content in the message args.
+ */
+ public function test_to_payload_message_args_contains_expected_values(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $comment_id = WC_Helper_Product::create_product_review( $product->get_id() );
+ $comment = get_comment( $comment_id );
+
+ $notification = new NewReviewNotification( $comment_id );
+ $payload = $notification->to_payload();
+
+ $this->assertSame( $comment->comment_author, $payload['message']['args'][0] );
+ $this->assertSame( $product->get_name(), $payload['message']['args'][1] );
+ $this->assertSame( $comment->comment_content, $payload['message']['args'][2] );
+ }
+
+ /**
+ * @testdox Should strip HTML tags, and script tags including content, from
+ * reviewer name in message args.
+ */
+ public function test_to_payload_strips_html_and_script_content_from_comment_author(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $comment_id = wp_insert_comment(
+ array(
+ 'comment_post_ID' => $product->get_id(),
+ 'comment_author' => '<b>Evil</b> <script>alert("xss")</script>Author',
+ 'comment_author_email' => 'test@test.local',
+ 'comment_content' => 'A clean review.',
+ 'comment_approved' => 1,
+ 'comment_type' => 'review',
+ )
+ );
+
+ $notification = new NewReviewNotification( $comment_id );
+ $payload = $notification->to_payload();
+
+ $this->assertSame( 'Evil Author', $payload['message']['args'][0] );
+ }
+
+ /**
+ * @testdox Should strip HTML tags, and script tags including content, from
+ * review content in message args.
+ */
+ public function test_to_payload_strips_html_and_script_content_from_comment_content(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $comment_id = wp_insert_comment(
+ array(
+ 'comment_post_ID' => $product->get_id(),
+ 'comment_author' => 'Reviewer',
+ 'comment_author_email' => 'test@test.local',
+ 'comment_content' => '<p>Great product!</p> <script>alert("xss")</script>',
+ 'comment_approved' => 1,
+ 'comment_type' => 'review',
+ )
+ );
+
+ $notification = new NewReviewNotification( $comment_id );
+ $payload = $notification->to_payload();
+
+ $this->assertSame( 'Great product!', $payload['message']['args'][2] );
+ }
+
+ /**
+ * @testdox Should return null when the comment no longer exists.
+ */
+ public function test_to_payload_returns_null_for_deleted_comment(): void {
+ $notification = new NewReviewNotification( 999999 );
+
+ $this->assertNull( $notification->to_payload() );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NotificationTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NotificationTest.php
index 8b045b197e..2ceb08d087 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NotificationTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NotificationTest.php
@@ -5,6 +5,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Notifications;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
+
use InvalidArgumentException;
use WC_Unit_Test_Case;
@@ -112,9 +113,9 @@ class NotificationTest extends WC_Unit_Test_Case {
/**
* Returns a test payload.
*
- * @return array
+ * @return array|null
*/
- public function to_payload(): array {
+ public function to_payload(): ?array {
return array( 'test' => true );
}
};
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/PendingNotificationStoreTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/PendingNotificationStoreTest.php
index 636252926a..efb66d6018 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/PendingNotificationStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/PendingNotificationStoreTest.php
@@ -161,9 +161,9 @@ class PendingNotificationStoreTest extends WC_Unit_Test_Case {
/**
* Returns a test payload.
*
- * @return array
+ * @return array|null
*/
- public function to_payload(): array {
+ public function to_payload(): ?array {
return array( 'test' => true );
}
};