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