Commit c72cecb0347 for woocommerce

commit c72cecb03478d6d58396c04b64afffa753886ea7
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date:   Wed Mar 25 15:13:08 2026 +0000

    Adds supporting methods for the (forthcoming) notification processor. (#63748)

    * Add meta dedup, get_tokens_for_roles, and notification abstractions.

    - Add abstract has_meta/write_meta to Notification base class, with
      implementations on NewOrderNotification and NewReviewNotification.
    - Add from_array() static factory using NOTIFICATION_CLASSES registry.
    - Add PushTokensDataStore::get_tokens_for_roles() for role-based token
      queries with meta cache priming.
    - Remove response check from InternalNotificationDispatcher (non-blocking
      request makes response unobservable; safety net covers failures).
    - Add StubOrderNotification/StubReviewNotification test helpers.

    * Narrows catch args, updates tests.

    * Resolves linting errors.

    * Updates meta checks to use exists methods.

    * Updates function calls to use legacy wrapper.

    * Improve test stubs and dispatcher test assertions.

    - Stub notifications now track meta writes in memory so has_meta
      reflects write_meta calls, matching real notification behavior.
    - Assert timeout and Content-Type in dispatcher request shape test.

    * Updates some code comments for clarity.

    * Move NOTIFICATION_CLASSES to Notification and derive type from map.

    - Move NOTIFICATION_CLASSES from PushNotifications to Notification as a type-to-class map.
    - Derive notification type in the constructor via is_a() lookup, removing the need for subclasses to implement get_type().
    - Replace from_array() iteration with direct map lookup.

    * Remove get_type() in favour of protected $type property.

    - Make $type protected so subclasses access it directly.
    - Remove public get_type() method from Notification.
    - Replace external get_type() call in dispatcher log with $notification::class.

    * Add const TYPE and abstract get_type() to Notification.

    - Add const TYPE on each notification subclass as source of truth for the type string.
    - Add abstract get_type() on Notification, subclasses return self::TYPE.
    - Remove protected $type property and constructor map lookup.
    - Simplify constructor to just resource_id validation.

    * Actions PR feedback re. get_type usage and tests.

diff --git a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
index 85b38b48736..7489f849aed 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
@@ -302,6 +302,73 @@ class PushTokensDataStore {
 		return null;
 	}

+	/**
+	 * Returns all push tokens belonging to users with the given roles.
+	 *
+	 * @param string[] $roles The roles to query tokens for.
+	 * @return PushToken[]
+	 *
+	 * @since 10.7.0
+	 */
+	public function get_tokens_for_roles( array $roles ): array {
+		if ( empty( $roles ) ) {
+			return array();
+		}
+
+		$user_ids = get_users(
+			array(
+				'role__in' => $roles,
+				'fields'   => 'ID',
+			)
+		);
+
+		if ( empty( $user_ids ) ) {
+			return array();
+		}
+
+		$query = new WP_Query(
+			array(
+				'post_type'      => PushToken::POST_TYPE,
+				'post_status'    => 'private',
+				'author__in'     => $user_ids,
+				'posts_per_page' => -1,
+				'fields'         => 'ids',
+			)
+		);
+
+		/**
+		 * Typehint for PHPStan, specifies these are IDs and not instances of
+		 * WP_Post.
+		 *
+		 * @var int[] $post_ids
+		 */
+		$post_ids = $query->posts;
+
+		if ( empty( $post_ids ) ) {
+			return array();
+		}
+
+		update_meta_cache( 'post', $post_ids );
+
+		$tokens = array();
+
+		foreach ( $post_ids as $post_id ) {
+			try {
+				$tokens[] = $this->read( (int) $post_id );
+			} catch ( WC_Data_Exception $e ) {
+				wc_get_logger()->warning(
+					'Skipping malformed push token during role-based query.',
+					array(
+						'token_id' => $post_id,
+						'error'    => $e->getMessage(),
+					)
+				);
+			}
+		}
+
+		return $tokens;
+	}
+
 	/**
 	 * Returns an associative array of post meta as key => value pairs for the
 	 * keys defined in SUPPORTED_META; missing keys return null. Use
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcher.php b/plugins/woocommerce/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcher.php
index 4642c5434d6..5cec21c20f9 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcher.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcher.php
@@ -65,7 +65,12 @@ class InternalNotificationDispatcher {
 			wp_salt( 'auth' )
 		);

-		$response = wp_remote_post(
+		/**
+		 * The request is non-blocking so the response is not handled anywhere.
+		 * If the request fails, the ActionScheduler safety net will pick up
+		 * unsent notifications after 60 seconds.
+		 */
+		wp_remote_post(
 			rest_url( self::SEND_ENDPOINT ),
 			array(
 				'blocking' => false,
@@ -77,12 +82,5 @@ class InternalNotificationDispatcher {
 				'body'     => $body,
 			)
 		);
-
-		if ( is_wp_error( $response ) ) {
-			wc_get_logger()->error(
-				sprintf( 'Loopback dispatch failed: %s', $response->get_error_message() ),
-				array( 'source' => PushNotifications::FEATURE_NAME )
-			);
-		}
 	}
 }
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewOrderNotification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewOrderNotification.php
index 850b381f61e..dc98e28f320 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewOrderNotification.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewOrderNotification.php
@@ -15,8 +15,7 @@ defined( 'ABSPATH' ) || exit;
  */
 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.
+	 * The notification type identifier for new orders.
 	 */
 	const TYPE = 'store_order';

@@ -31,14 +30,10 @@ class NewOrderNotification extends Notification {
 	const EMOJI_LIST = array( '🎉', '🎊', '🥳', '👏', '🙌' );

 	/**
-	 * Creates a new order notification.
-	 *
-	 * @param int $order_id The order ID.
-	 *
-	 * @since 10.7.0
+	 * {@inheritDoc}
 	 */
-	public function __construct( int $order_id ) {
-		parent::__construct( self::TYPE, $order_id );
+	public function get_type(): string {
+		return self::TYPE;
 	}

 	/**
@@ -58,7 +53,7 @@ class NewOrderNotification extends Notification {
 		}

 		return array(
-			'type'        => self::TYPE,
+			'type'        => $this->get_type(),
 			'icon'        => self::ICON,
 			// This represents the time the notification was triggered, so we can monitor age of notification at delivery.
 			'timestamp'   => gmdate( 'c' ),
@@ -87,4 +82,28 @@ class NewOrderNotification extends Notification {
 			),
 		);
 	}
+
+	/**
+	 * {@inheritDoc}
+	 *
+	 * @param string $key The meta key.
+	 */
+	public function has_meta( string $key ): bool {
+		$order = WC()->call_function( 'wc_get_order', $this->get_resource_id() );
+		return $order instanceof WC_Order && $order->meta_exists( $key );
+	}
+
+	/**
+	 * {@inheritDoc}
+	 *
+	 * @param string $key The meta key.
+	 */
+	public function write_meta( string $key ): void {
+		$order = WC()->call_function( 'wc_get_order', $this->get_resource_id() );
+
+		if ( $order instanceof WC_Order ) {
+			$order->update_meta_data( $key, (string) time() );
+			$order->save_meta_data();
+		}
+	}
 }
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewReviewNotification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewReviewNotification.php
index 511c0c19762..0b94b3e65cc 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewReviewNotification.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewReviewNotification.php
@@ -15,19 +15,15 @@ defined( 'ABSPATH' ) || exit;
  */
 class NewReviewNotification extends Notification {
 	/**
-	 * The notification type identifier.
+	 * The notification type identifier for new reviews.
 	 */
 	const TYPE = 'store_review';

 	/**
-	 * Creates a new review notification.
-	 *
-	 * @param int $comment_id The comment ID.
-	 *
-	 * @since 10.7.0
+	 * {@inheritDoc}
 	 */
-	public function __construct( int $comment_id ) {
-		parent::__construct( self::TYPE, $comment_id );
+	public function get_type(): string {
+		return self::TYPE;
 	}

 	/**
@@ -47,7 +43,7 @@ class NewReviewNotification extends Notification {
 		}

 		return array(
-			'type'        => self::TYPE,
+			'type'        => $this->get_type(),
 			// This represents the time the notification was triggered, so we can monitor age of notification at delivery.
 			'timestamp'   => gmdate( 'c' ),
 			'resource_id' => $this->get_resource_id(),
@@ -72,4 +68,22 @@ class NewReviewNotification extends Notification {
 			),
 		);
 	}
+
+	/**
+	 * {@inheritDoc}
+	 *
+	 * @param string $key The meta key.
+	 */
+	public function has_meta( string $key ): bool {
+		return WC()->call_function( 'metadata_exists', 'comment', $this->get_resource_id(), $key );
+	}
+
+	/**
+	 * {@inheritDoc}
+	 *
+	 * @param string $key The meta key.
+	 */
+	public function write_meta( string $key ): void {
+		WC()->call_function( 'update_comment_meta', $this->get_resource_id(), $key, (string) time() );
+	}
 }
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php
index 655b431d1dc..b8082737e10 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php
@@ -18,11 +18,14 @@ defined( 'ABSPATH' ) || exit;
  */
 abstract class Notification {
 	/**
-	 * The notification type.
+	 * Map of notification type identifiers to their corresponding subclass.
 	 *
-	 * @var string
+	 * @var array<string, class-string<Notification>>
 	 */
-	private string $type;
+	const NOTIFICATION_CLASSES = array(
+		'store_order'  => NewOrderNotification::class,
+		'store_review' => NewReviewNotification::class,
+	);

 	/**
 	 * The ID of the resource this notification is about (e.g. order ID, comment
@@ -35,26 +38,31 @@ abstract class Notification {
 	/**
 	 * Creates a new Notification instance.
 	 *
-	 * @param string $type        The notification type.
-	 * @param int    $resource_id The resource ID.
+	 * @param int $resource_id The resource ID.
 	 *
-	 * @throws InvalidArgumentException If any argument is invalid.
+	 * @throws InvalidArgumentException If the resource ID is invalid.
 	 *
 	 * @since 10.7.0
 	 */
-	public function __construct( string $type, int $resource_id ) {
-		if ( '' === trim( $type ) ) {
-			throw new InvalidArgumentException( 'Notification type must not be empty.' );
-		}
-
+	public function __construct( int $resource_id ) {
 		if ( $resource_id <= 0 ) {
 			throw new InvalidArgumentException( 'Notification resource_id must be positive.' );
 		}

-		$this->type        = trim( $type );
 		$this->resource_id = $resource_id;
 	}

+	/**
+	 * Returns the notification type identifier, this should match the subtype
+	 * or type (if there isn't a subtype) values attributed to notes in
+	 * WordPress.com.
+	 *
+	 * @return string
+	 *
+	 * @since 10.7.0
+	 */
+	abstract public function get_type(): string;
+
 	/**
 	 * Returns the WPCOM-ready payload for this notification.
 	 *
@@ -66,6 +74,26 @@ abstract class Notification {
 	 */
 	abstract public function to_payload(): ?array;

+	/**
+	 * Checks whether a meta key exists for this notification's resource.
+	 *
+	 * @param string $key The meta key.
+	 * @return bool
+	 *
+	 * @since 10.7.0
+	 */
+	abstract public function has_meta( string $key ): bool;
+
+	/**
+	 * Writes a meta key with a timestamp to this notification's resource.
+	 *
+	 * @param string $key The meta key.
+	 * @return void
+	 *
+	 * @since 10.7.0
+	 */
+	abstract public function write_meta( string $key ): void;
+
 	/**
 	 * Returns the notification data as an array.
 	 *
@@ -75,32 +103,45 @@ abstract class Notification {
 	 */
 	public function to_array(): array {
 		return array(
-			'type'        => $this->type,
+			'type'        => $this->get_type(),
 			'resource_id' => $this->resource_id,
 		);
 	}

 	/**
-	 * Returns a unique identifier for this notification, used for
-	 * deduplication.
+	 * Reconstructs a Notification subclass from a serialized array.
 	 *
-	 * @return string
+	 * @param array{type: string, resource_id: int} $data The notification data.
+	 * @return self
+	 *
+	 * @throws InvalidArgumentException If the type is unknown.
 	 *
 	 * @since 10.7.0
 	 */
-	public function get_identifier(): string {
-		return sprintf( '%s_%s_%s', get_current_blog_id(), $this->type, $this->resource_id );
+	public static function from_array( array $data ): self {
+		$type        = $data['type'] ?? '';
+		$resource_id = (int) ( $data['resource_id'] ?? 0 );
+
+		$class = self::NOTIFICATION_CLASSES[ $type ] ?? null;
+
+		if ( ! $class ) {
+			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+			throw new InvalidArgumentException( sprintf( 'Unknown notification type: %s', $type ) );
+		}
+
+		return new $class( $resource_id );
 	}

 	/**
-	 * Gets the notification type.
+	 * Returns a unique identifier for this notification, used for
+	 * deduplication.
 	 *
 	 * @return string
 	 *
 	 * @since 10.7.0
 	 */
-	public function get_type(): string {
-		return $this->type;
+	public function get_identifier(): string {
+		return sprintf( '%s_%s_%s', get_current_blog_id(), $this->get_type(), $this->resource_id );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
index 18eabd82d4c..9d226cd894e 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
@@ -703,6 +703,156 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
 		$this->assertEquals( array( 'app_version' => '2.0' ), $updated_token->get_metadata() );
 	}

+	/**
+	 * @testdox Should return tokens for users with matching roles.
+	 */
+	public function test_get_tokens_for_roles_returns_tokens_for_matching_users(): void {
+		$admin_id   = $this->factory->user->create( array( 'role' => 'administrator' ) );
+		$data_store = new PushTokensDataStore();
+
+		$data_store->create(
+			array(
+				'user_id'       => $admin_id,
+				'token'         => 'admin_token_' . wp_rand(),
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'device_uuid'   => 'admin-device-' . wp_rand(),
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+				'metadata'      => array( 'app_version' => '1.0' ),
+			)
+		);
+
+		$tokens = $data_store->get_tokens_for_roles( array( 'administrator' ) );
+
+		$this->assertCount( 1, $tokens );
+		$this->assertInstanceOf( PushToken::class, $tokens[0] );
+		$this->assertSame( $admin_id, $tokens[0]->get_user_id() );
+	}
+
+	/**
+	 * @testdox Should return empty array when no users have the specified role.
+	 */
+	public function test_get_tokens_for_roles_returns_empty_when_no_users_have_role(): void {
+		$data_store = new PushTokensDataStore();
+
+		$tokens = $data_store->get_tokens_for_roles( array( 'shop_manager' ) );
+
+		$this->assertSame( array(), $tokens );
+	}
+
+	/**
+	 * @testdox Should return empty array when users have the role but no tokens.
+	 */
+	public function test_get_tokens_for_roles_returns_empty_when_users_have_no_tokens(): void {
+		$this->factory->user->create( array( 'role' => 'administrator' ) );
+		$data_store = new PushTokensDataStore();
+
+		$tokens = $data_store->get_tokens_for_roles( array( 'administrator' ) );
+
+		$this->assertSame( array(), $tokens );
+	}
+
+	/**
+	 * @testdox Should skip malformed tokens and return only valid ones.
+	 */
+	public function test_get_tokens_for_roles_skips_malformed_tokens(): void {
+		$admin_id   = $this->factory->user->create( array( 'role' => 'administrator' ) );
+		$data_store = new PushTokensDataStore();
+
+		wp_insert_post(
+			array(
+				'post_author' => $admin_id,
+				'post_type'   => PushToken::POST_TYPE,
+				'post_status' => 'private',
+				'meta_input'  => array(
+					'platform' => PushToken::PLATFORM_APPLE,
+					'token'    => 'partial_token',
+				),
+			)
+		);
+
+		$data_store->create(
+			array(
+				'user_id'       => $admin_id,
+				'token'         => 'valid_token',
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'device_uuid'   => 'valid-device',
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+				'metadata'      => array( 'app_version' => '1.0' ),
+			)
+		);
+
+		$tokens = $data_store->get_tokens_for_roles( array( 'administrator' ) );
+
+		$this->assertCount( 1, $tokens );
+		$this->assertSame( 'valid-device', $tokens[0]->get_device_uuid() );
+	}
+
+	/**
+	 * @testdox Should return tokens from multiple users with different matching roles.
+	 */
+	public function test_get_tokens_for_roles_returns_tokens_from_multiple_roles(): void {
+		$admin_id   = $this->factory->user->create( array( 'role' => 'administrator' ) );
+		$manager_id = $this->factory->user->create( array( 'role' => 'shop_manager' ) );
+		$data_store = new PushTokensDataStore();
+
+		$data_store->create(
+			array(
+				'user_id'       => $admin_id,
+				'token'         => 'admin_token',
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'device_uuid'   => 'admin-device',
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+				'metadata'      => array( 'app_version' => '1.0' ),
+			)
+		);
+
+		$data_store->create(
+			array(
+				'user_id'       => $manager_id,
+				'token'         => 'manager_token',
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'device_uuid'   => 'manager-device',
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+				'metadata'      => array( 'app_version' => '1.0' ),
+			)
+		);
+
+		$tokens     = $data_store->get_tokens_for_roles( array( 'administrator', 'shop_manager' ) );
+		$device_ids = array_map( fn ( PushToken $t ) => $t->get_device_uuid(), $tokens );
+
+		$this->assertCount( 2, $tokens );
+		$this->assertContains( 'admin-device', $device_ids );
+		$this->assertContains( 'manager-device', $device_ids );
+	}
+
+	/**
+	 * @testdox Should not return tokens for users without the specified role.
+	 */
+	public function test_get_tokens_for_roles_excludes_users_without_role(): void {
+		$subscriber_id = $this->factory->user->create( array( 'role' => 'subscriber' ) );
+		$data_store    = new PushTokensDataStore();
+
+		$data_store->create(
+			array(
+				'user_id'       => $subscriber_id,
+				'token'         => 'subscriber_token_' . wp_rand(),
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'device_uuid'   => 'subscriber-device-' . wp_rand(),
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+				'metadata'      => array( 'app_version' => '1.0' ),
+			)
+		);
+
+		$tokens = $data_store->get_tokens_for_roles( array( 'administrator' ) );
+
+		$this->assertSame( array(), $tokens );
+	}
+
 	/**
 	 * Creates a test push token and saves it to the database.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcherTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcherTest.php
index 3ed44a3e841..71d989a37e6 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcherTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcherTest.php
@@ -5,8 +5,9 @@ declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Dispatchers;

 use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\InternalNotificationDispatcher;
-use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
 use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken;
+use Automattic\WooCommerce\Tests\Internal\PushNotifications\Stubs\StubOrderNotification;
+use Automattic\WooCommerce\Tests\Internal\PushNotifications\Stubs\StubReviewNotification;
 use WC_Unit_Test_Case;

 /**
@@ -82,7 +83,7 @@ class InternalNotificationDispatcherTest extends WC_Unit_Test_Case {
 	 * @testdox Should fire a non-blocking POST to the send endpoint URL.
 	 */
 	public function test_dispatch_fires_non_blocking_post_to_send_endpoint(): void {
-		$notifications = array( $this->create_notification( 'store_order', 1 ) );
+		$notifications = array( new StubOrderNotification( 1 ) );

 		$this->sut->dispatch( $notifications );

@@ -95,13 +96,23 @@ class InternalNotificationDispatcherTest extends WC_Unit_Test_Case {
 			$this->captured_request['blocking'],
 			'Request should be non-blocking'
 		);
+		$this->assertSame(
+			1,
+			$this->captured_request['timeout'],
+			'Request timeout should be 1 second'
+		);
+		$this->assertSame(
+			'application/json',
+			$this->captured_request['headers']['Content-Type'],
+			'Request Content-Type should be application/json'
+		);
 	}

 	/**
 	 * @testdox Should include a valid JWT with correct claims and body hash.
 	 */
 	public function test_dispatch_includes_valid_jwt_with_correct_claims(): void {
-		$notifications = array( $this->create_notification( 'store_order', 1 ) );
+		$notifications = array( new StubOrderNotification( 1 ) );

 		$this->sut->dispatch( $notifications );

@@ -130,8 +141,8 @@ class InternalNotificationDispatcherTest extends WC_Unit_Test_Case {
 	 */
 	public function test_dispatch_body_contains_encoded_notifications(): void {
 		$notifications = array(
-			$this->create_notification( 'store_order', 10 ),
-			$this->create_notification( 'store_review', 20 ),
+			new StubOrderNotification( 10 ),
+			new StubReviewNotification( 20 ),
 		);

 		$this->sut->dispatch( $notifications );
@@ -154,22 +165,4 @@ class InternalNotificationDispatcherTest extends WC_Unit_Test_Case {

 		$this->assertNull( $this->captured_url, 'No HTTP request should be made for empty notifications' );
 	}
-
-	/**
-	 * Creates a concrete Notification instance for testing.
-	 *
-	 * @param string $type        The notification type.
-	 * @param int    $resource_id The resource ID.
-	 * @return Notification
-	 */
-	private function create_notification( string $type, int $resource_id ): Notification {
-		return new class( $type, $resource_id ) extends Notification {
-			/**
-			 * {@inheritDoc}
-			 */
-			public function to_payload(): ?array {
-				return array( 'test' => true );
-			}
-		};
-	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Dispatchers/WpcomNotificationDispatcherTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Dispatchers/WpcomNotificationDispatcherTest.php
index c68540e60b6..c77519fb94c 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Dispatchers/WpcomNotificationDispatcherTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Dispatchers/WpcomNotificationDispatcherTest.php
@@ -6,7 +6,7 @@ namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Dispatchers;

 use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\WpcomNotificationDispatcher;
 use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
-use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewOrderNotification;
 use WC_Unit_Test_Case;
 use WP_Error;

@@ -277,31 +277,19 @@ class WpcomNotificationDispatcherTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * Creates a concrete Notification instance for testing.
+	 * Creates a mock Notification instance for testing.
 	 *
 	 * @param array|null $payload The payload to return from to_payload().
-	 * @return Notification
+	 * @return NewOrderNotification
 	 */
-	private function create_notification( ?array $payload = array( 'test' => true ) ): Notification {
-		return new class( $payload ) extends Notification {
-			/** @var array|null */
-			private ?array $test_payload;
-
-			/**
-			 * @param array|null $payload The payload to return.
-			 */
-			public function __construct( ?array $payload ) {
-				parent::__construct( 'store_order', 1 );
-				$this->test_payload = $payload;
-			}
-
-			/**
-			 * {@inheritDoc}
-			 */
-			public function to_payload(): ?array {
-				return $this->test_payload;
-			}
-		};
+	private function create_notification( ?array $payload = array( 'test' => true ) ): NewOrderNotification {
+		$mock = $this->getMockBuilder( NewOrderNotification::class )
+			->setConstructorArgs( array( 1 ) )
+			->onlyMethods( array( 'to_payload', 'has_meta', 'write_meta' ) )
+			->getMock();
+		$mock->method( 'to_payload' )->willReturn( $payload );
+
+		return $mock;
 	}

 	/**
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 2ceb08d0871..d5601a83e97 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NotificationTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NotificationTest.php
@@ -4,8 +4,11 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Notifications;

+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewOrderNotification;
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewReviewNotification;
 use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
-
+use Automattic\WooCommerce\Tests\Internal\PushNotifications\Stubs\StubOrderNotification;
+use Automattic\WooCommerce\Tests\Internal\PushNotifications\Stubs\StubReviewNotification;
 use InvalidArgumentException;
 use WC_Unit_Test_Case;

@@ -13,58 +16,20 @@ use WC_Unit_Test_Case;
  * Tests for the Notification class.
  */
 class NotificationTest extends WC_Unit_Test_Case {
-	/**
-	 * @testdox Should store and return the notification type.
-	 */
-	public function test_get_type(): void {
-		$notification = $this->create_notification( 'store_order', 42 );
-
-		$this->assertSame( 'store_order', $notification->get_type() );
-	}
-
-	/**
-	 * @testdox Should store and return the resource ID.
-	 */
-	public function test_get_resource_id(): void {
-		$notification = $this->create_notification( 'store_order', 42 );
-
-		$this->assertSame( 42, $notification->get_resource_id() );
-	}
-
 	/**
 	 * @testdox Should return an identifier combining blog ID, type, and resource ID.
 	 */
 	public function test_get_identifier(): void {
-		$notification = $this->create_notification( 'store_order', 42 );
+		$notification = new StubOrderNotification( 42 );

 		$this->assertSame( get_current_blog_id() . '_store_order_42', $notification->get_identifier() );
 	}

-	/**
-	 * @testdox Should return different identifiers for different resource IDs.
-	 */
-	public function test_get_identifier_differs_by_resource_id(): void {
-		$order  = $this->create_notification( 'store_order', 42 );
-		$review = $this->create_notification( 'store_order', 43 );
-
-		$this->assertNotSame( $order->get_identifier(), $review->get_identifier() );
-	}
-
-	/**
-	 * @testdox Should return different identifiers for different types with the same resource ID.
-	 */
-	public function test_get_identifier_differs_by_type(): void {
-		$order  = $this->create_notification( 'store_order', 42 );
-		$review = $this->create_notification( 'store_review', 42 );
-
-		$this->assertNotSame( $order->get_identifier(), $review->get_identifier() );
-	}
-
 	/**
 	 * @testdox Should return notification data as an array.
 	 */
 	public function test_to_array(): void {
-		$notification = $this->create_notification( 'store_review', 99 );
+		$notification = new StubReviewNotification( 99 );

 		$result = $notification->to_array();

@@ -75,49 +40,62 @@ class NotificationTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Should throw when type is empty.
+	 * @testdox Should throw when resource_id is $resource_id.
+	 * @testWith [0]
+	 *           [-1]
+	 *
+	 * @param int $resource_id The invalid resource ID.
 	 */
-	public function test_throws_for_empty_type(): void {
+	public function test_throws_for_non_positive_resource_id( int $resource_id ): void {
 		$this->expectException( InvalidArgumentException::class );

-		$this->create_notification( '', 1 );
+		new StubOrderNotification( $resource_id );
 	}

 	/**
-	 * @testdox Should throw when resource_id is zero.
+	 * @testdox from_array should create correct notification for $type type.
+	 * @testWith ["store_order", "Automattic\\WooCommerce\\Internal\\PushNotifications\\Notifications\\NewOrderNotification"]
+	 *           ["store_review", "Automattic\\WooCommerce\\Internal\\PushNotifications\\Notifications\\NewReviewNotification"]
+	 *
+	 * @param string $type           The notification type.
+	 * @param string $expected_class The expected class name.
 	 */
-	public function test_throws_for_zero_resource_id(): void {
-		$this->expectException( InvalidArgumentException::class );
-
-		$this->create_notification( 'store_order', 0 );
+	public function test_from_array_creates_notification( string $type, string $expected_class ): void {
+		$notification = Notification::from_array(
+			array(
+				'type'        => $type,
+				'resource_id' => 42,
+			)
+		);
+
+		$this->assertInstanceOf( $expected_class, $notification );
+		$this->assertSame( 42, $notification->get_resource_id() );
 	}

 	/**
-	 * @testdox Should throw when resource_id is negative.
+	 * @testdox from_array should throw for an unknown notification type.
 	 */
-	public function test_throws_for_negative_resource_id(): void {
+	public function test_from_array_throws_for_unknown_type(): void {
 		$this->expectException( InvalidArgumentException::class );

-		$this->create_notification( 'store_order', -1 );
+		Notification::from_array(
+			array(
+				'type'        => 'unknown_type',
+				'resource_id' => 1,
+			)
+		);
 	}

 	/**
-	 * Creates a concrete Notification instance for testing.
-	 *
-	 * @param string $type        The notification type.
-	 * @param int    $resource_id The resource ID.
-	 * @return Notification
+	 * @testdox Should throw when type is missing from array data.
 	 */
-	private function create_notification( string $type, int $resource_id ): Notification {
-		return new class( $type, $resource_id ) extends Notification {
-			/**
-			 * Returns a test payload.
-			 *
-			 * @return array|null
-			 */
-			public function to_payload(): ?array {
-				return array( 'test' => true );
-			}
-		};
+	public function test_from_array_throws_for_missing_type(): void {
+		$this->expectException( InvalidArgumentException::class );
+
+		Notification::from_array(
+			array(
+				'resource_id' => 1,
+			)
+		);
 	}
 }
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 0e871cf9b20..9e3893071f5 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/PendingNotificationStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/PendingNotificationStoreTest.php
@@ -5,8 +5,9 @@ declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Services;

 use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\InternalNotificationDispatcher;
-use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
+use Automattic\WooCommerce\Tests\Internal\PushNotifications\Stubs\StubOrderNotification;
+use Automattic\WooCommerce\Tests\Internal\PushNotifications\Stubs\StubReviewNotification;
 use WC_Unit_Test_Case;

 /**
@@ -46,9 +47,7 @@ class PendingNotificationStoreTest extends WC_Unit_Test_Case {
 	 * @testdox Should add a notification to the store.
 	 */
 	public function test_add_stores_notification(): void {
-		$notification = $this->create_notification( 'store_order', 42 );
-
-		$this->store->add( $notification );
+		$this->store->add( new StubOrderNotification( 42 ) );

 		$this->assertSame( 1, $this->store->count() );
 	}
@@ -57,11 +56,8 @@ class PendingNotificationStoreTest extends WC_Unit_Test_Case {
 	 * @testdox Should deduplicate notifications with the same type and resource ID.
 	 */
 	public function test_add_deduplicates_same_type_and_resource(): void {
-		$first  = $this->create_notification( 'store_order', 42 );
-		$second = $this->create_notification( 'store_order', 42 );
-
-		$this->store->add( $first );
-		$this->store->add( $second );
+		$this->store->add( new StubOrderNotification( 42 ) );
+		$this->store->add( new StubOrderNotification( 42 ) );

 		$this->assertSame( 1, $this->store->count() );
 	}
@@ -70,11 +66,8 @@ class PendingNotificationStoreTest extends WC_Unit_Test_Case {
 	 * @testdox Should store notifications with different types separately.
 	 */
 	public function test_add_allows_different_types_for_same_resource(): void {
-		$order  = $this->create_notification( 'store_order', 42 );
-		$review = $this->create_notification( 'store_review', 42 );
-
-		$this->store->add( $order );
-		$this->store->add( $review );
+		$this->store->add( new StubOrderNotification( 42 ) );
+		$this->store->add( new StubReviewNotification( 42 ) );

 		$this->assertSame( 2, $this->store->count() );
 	}
@@ -83,11 +76,8 @@ class PendingNotificationStoreTest extends WC_Unit_Test_Case {
 	 * @testdox Should store notifications with different resource IDs separately.
 	 */
 	public function test_add_allows_same_type_for_different_resources(): void {
-		$order_1 = $this->create_notification( 'store_order', 42 );
-		$order_2 = $this->create_notification( 'store_order', 43 );
-
-		$this->store->add( $order_1 );
-		$this->store->add( $order_2 );
+		$this->store->add( new StubOrderNotification( 42 ) );
+		$this->store->add( new StubOrderNotification( 43 ) );

 		$this->assertSame( 2, $this->store->count() );
 	}
@@ -100,7 +90,7 @@ class PendingNotificationStoreTest extends WC_Unit_Test_Case {
 		$store      = new PendingNotificationStore();
 		$store->init( $dispatcher );

-		$store->add( $this->create_notification( 'store_order', 42 ) );
+		$store->add( new StubOrderNotification( 42 ) );

 		$this->assertSame( 0, $store->count() );
 	}
@@ -109,9 +99,9 @@ class PendingNotificationStoreTest extends WC_Unit_Test_Case {
 	 * @testdox Should register shutdown hook only once regardless of how many notifications are added.
 	 */
 	public function test_add_registers_shutdown_hook_once(): void {
-		$this->store->add( $this->create_notification( 'store_order', 1 ) );
-		$this->store->add( $this->create_notification( 'store_order', 2 ) );
-		$this->store->add( $this->create_notification( 'store_order', 3 ) );
+		$this->store->add( new StubOrderNotification( 1 ) );
+		$this->store->add( new StubOrderNotification( 2 ) );
+		$this->store->add( new StubOrderNotification( 3 ) );

 		$hook_count = 0;

@@ -134,7 +124,7 @@ class PendingNotificationStoreTest extends WC_Unit_Test_Case {
 	 * @testdox Should clear pending notifications after dispatch.
 	 */
 	public function test_dispatch_all_clears_store(): void {
-		$this->store->add( $this->create_notification( 'store_order', 1 ) );
+		$this->store->add( new StubOrderNotification( 1 ) );

 		$this->store->dispatch_all();

@@ -145,8 +135,8 @@ class PendingNotificationStoreTest extends WC_Unit_Test_Case {
 	 * @testdox Should return all pending notifications via get_all.
 	 */
 	public function test_get_all_returns_pending_notifications(): void {
-		$this->store->add( $this->create_notification( 'store_order', 1 ) );
-		$this->store->add( $this->create_notification( 'store_review', 2 ) );
+		$this->store->add( new StubOrderNotification( 1 ) );
+		$this->store->add( new StubReviewNotification( 2 ) );

 		$all = $this->store->get_all();

@@ -154,24 +144,4 @@ class PendingNotificationStoreTest extends WC_Unit_Test_Case {
 		$this->assertSame( 1, $all[0]->get_resource_id() );
 		$this->assertSame( 2, $all[1]->get_resource_id() );
 	}
-
-	/**
-	 * Creates a concrete Notification instance for testing.
-	 *
-	 * @param string $type        The notification type.
-	 * @param int    $resource_id The resource ID.
-	 * @return Notification
-	 */
-	private function create_notification( string $type, int $resource_id ): Notification {
-		return new class( $type, $resource_id ) extends Notification {
-			/**
-			 * Returns a test payload.
-			 *
-			 * @return array|null
-			 */
-			public function to_payload(): ?array {
-				return array( 'test' => true );
-			}
-		};
-	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Stubs/StubOrderNotification.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Stubs/StubOrderNotification.php
new file mode 100644
index 00000000000..8f3a9a94b92
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Stubs/StubOrderNotification.php
@@ -0,0 +1,40 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Stubs;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewOrderNotification;
+
+/**
+ * Stub notification with type 'store_order' for testing.
+ */
+class StubOrderNotification extends NewOrderNotification {
+	/** @var array<string, bool> */
+	private array $meta = array();
+
+	/**
+	 * {@inheritDoc}
+	 */
+	public function to_payload(): ?array {
+		return array( 'test' => true );
+	}
+
+	/**
+	 * {@inheritDoc}
+	 *
+	 * @param string $key The meta key.
+	 */
+	public function has_meta( string $key ): bool {
+		return isset( $this->meta[ $key ] );
+	}
+
+	/**
+	 * {@inheritDoc}
+	 *
+	 * @param string $key The meta key.
+	 */
+	public function write_meta( string $key ): void {
+		$this->meta[ $key ] = true;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Stubs/StubReviewNotification.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Stubs/StubReviewNotification.php
new file mode 100644
index 00000000000..26c2460980d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Stubs/StubReviewNotification.php
@@ -0,0 +1,40 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Stubs;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewReviewNotification;
+
+/**
+ * Stub notification with type 'store_review' for testing.
+ */
+class StubReviewNotification extends NewReviewNotification {
+	/** @var array<string, bool> */
+	private array $meta = array();
+
+	/**
+	 * {@inheritDoc}
+	 */
+	public function to_payload(): ?array {
+		return array( 'test' => true );
+	}
+
+	/**
+	 * {@inheritDoc}
+	 *
+	 * @param string $key The meta key.
+	 */
+	public function has_meta( string $key ): bool {
+		return isset( $this->meta[ $key ] );
+	}
+
+	/**
+	 * {@inheritDoc}
+	 *
+	 * @param string $key The meta key.
+	 */
+	public function write_meta( string $key ): void {
+		$this->meta[ $key ] = true;
+	}
+}