Commit b3f540e04c5 for woocommerce

commit b3f540e04c555707ba03fb50db45064594800297
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date:   Thu Apr 2 10:12:46 2026 +0100

    Add notification processor, send controller, and safety net. (#63859)

    * Add notification processor, send controller, and safety net.

    - Add PushNotificationRestController for JWT-authed loopback send endpoint.
    - Add NotificationProcessor to orchestrate sending via WPCOM dispatcher.
    - Wire PendingNotificationStore to dispatch on shutdown and schedule AS safety net.
    - Register new controller and processor in PushNotifications on_init.

diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushNotificationRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushNotificationRestController.php
new file mode 100644
index 00000000000..621267720e0
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushNotificationRestController.php
@@ -0,0 +1,160 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Controllers;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
+use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
+use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken;
+use Exception;
+use WP_Error;
+use WP_Http;
+use WP_REST_Request;
+use WP_REST_Response;
+use WP_REST_Server;
+
+/**
+ * REST controller for the internal loopback send endpoint.
+ *
+ * Receives JWT-signed notification data from InternalNotificationDispatcher
+ * and delegates each notification to NotificationProcessor.
+ *
+ * @since 10.7.0
+ */
+class PushNotificationRestController {
+
+	/**
+	 * The route namespace, shared with PushTokenRestController.
+	 */
+	const ROUTE_NAMESPACE = 'wc-push-notifications';
+
+	/**
+	 * Registers the REST API route on the rest_api_init hook.
+	 *
+	 * @return void
+	 *
+	 * @since 10.7.0
+	 */
+	public function register(): void {
+		add_action( 'rest_api_init', array( $this, 'register_routes' ) );
+	}
+
+	/**
+	 * Registers the send route.
+	 *
+	 * @return void
+	 *
+	 * @since 10.7.0
+	 */
+	public function register_routes(): void {
+		register_rest_route(
+			self::ROUTE_NAMESPACE,
+			'send',
+			array(
+				'methods'             => WP_REST_Server::CREATABLE,
+				'callback'            => array( $this, 'create' ),
+				'permission_callback' => array( $this, 'authorize' ),
+			)
+		);
+	}
+
+	/**
+	 * Processes the send request by delegating each notification to the
+	 * processor.
+	 *
+	 * @param WP_REST_Request $request The request object.
+	 * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+	 * @return WP_REST_Response|WP_Error
+	 *
+	 * @since 10.7.0
+	 */
+	public function create( WP_REST_Request $request ) {
+		wc_set_time_limit( 30 );
+
+		$body             = json_decode( $request->get_body(), true );
+		$notifications    = is_array( $body ) ? ( $body['notifications'] ?? array() ) : array();
+		$success_response = new WP_REST_Response( array( 'success' => true ), WP_Http::OK );
+
+		if ( empty( $notifications ) || ! is_array( $notifications ) ) {
+			wc_get_logger()->warning(
+				'Loopback endpoint received empty or missing notifications array.',
+				array( 'source' => PushNotifications::FEATURE_NAME )
+			);
+
+			return $success_response;
+		}
+
+		$processor = wc_get_container()->get( NotificationProcessor::class );
+
+		foreach ( $notifications as $data ) {
+			try {
+				$notification = Notification::from_array( $data );
+				$processor->process( $notification );
+			} catch ( Exception $e ) {
+				wc_get_logger()->error(
+					sprintf( 'Failed to process notification: %s', $e->getMessage() ),
+					array( 'source' => PushNotifications::FEATURE_NAME )
+				);
+			}
+		}
+
+		return $success_response;
+	}
+
+	/**
+	 * Validates the JWT from the Authorization header.
+	 *
+	 * @param WP_REST_Request $request The request object.
+	 * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+	 * @return true|WP_Error
+	 *
+	 * @since 10.7.0
+	 */
+	public function authorize( WP_REST_Request $request ) {
+		$header = $request->get_header( 'authorization' );
+
+		if ( empty( $header ) ) {
+			return new WP_Error(
+				'woocommerce_rest_unauthorized',
+				'Missing authorization header.',
+				array( 'status' => WP_Http::UNAUTHORIZED )
+			);
+		}
+
+		$token = (string) preg_replace( '/^\s*Bearer\s+/i', '', $header );
+
+		if ( ! JsonWebToken::validate( $token, wp_salt( 'auth' ) ) ) {
+			return new WP_Error(
+				'woocommerce_rest_unauthorized',
+				'Invalid or expired token.',
+				array( 'status' => WP_Http::UNAUTHORIZED )
+			);
+		}
+
+		$parts = JsonWebToken::get_parts( $token );
+
+		if ( ! isset( $parts->payload->iss ) || get_site_url() !== $parts->payload->iss ) {
+			return new WP_Error(
+				'woocommerce_rest_unauthorized',
+				'Invalid token issuer.',
+				array( 'status' => WP_Http::UNAUTHORIZED )
+			);
+		}
+
+		$body_hash = hash( 'sha256', $request->get_body() );
+
+		if ( ! isset( $parts->payload->body_hash ) || ! hash_equals( (string) $parts->payload->body_hash, $body_hash ) ) {
+			return new WP_Error(
+				'woocommerce_rest_unauthorized',
+				'Body hash mismatch.',
+				array( 'status' => WP_Http::UNAUTHORIZED )
+			);
+		}
+
+		return true;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
index 7489f849aed..98982e66b94 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
@@ -23,6 +23,15 @@ use WP_Query;
  * @since 10.5.0
  */
 class PushTokensDataStore {
+	/**
+	 * In-memory cache for get_tokens_for_roles() results, keyed by the
+	 * comma-joined role list. Avoids repeated DB queries within the same
+	 * PHP request.
+	 *
+	 * @var array<string, PushToken[]>
+	 */
+	private array $tokens_by_roles_cache = array();
+
 	const SUPPORTED_META = array(
 		'origin',
 		'device_uuid',
@@ -315,6 +324,12 @@ class PushTokensDataStore {
 			return array();
 		}

+		$cache_key = implode( ',', $roles );
+
+		if ( isset( $this->tokens_by_roles_cache[ $cache_key ] ) ) {
+			return $this->tokens_by_roles_cache[ $cache_key ];
+		}
+
 		$user_ids = get_users(
 			array(
 				'role__in' => $roles,
@@ -323,7 +338,8 @@ class PushTokensDataStore {
 		);

 		if ( empty( $user_ids ) ) {
-			return array();
+			$this->tokens_by_roles_cache[ $cache_key ] = array();
+			return $this->tokens_by_roles_cache[ $cache_key ];
 		}

 		$query = new WP_Query(
@@ -345,7 +361,8 @@ class PushTokensDataStore {
 		$post_ids = $query->posts;

 		if ( empty( $post_ids ) ) {
-			return array();
+			$this->tokens_by_roles_cache[ $cache_key ] = array();
+			return $this->tokens_by_roles_cache[ $cache_key ];
 		}

 		update_meta_cache( 'post', $post_ids );
@@ -366,6 +383,8 @@ class PushTokensDataStore {
 			}
 		}

+		$this->tokens_by_roles_cache[ $cache_key ] = $tokens;
+
 		return $tokens;
 	}

diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewOrderNotification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewOrderNotification.php
index dc98e28f320..fc51c190144 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewOrderNotification.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewOrderNotification.php
@@ -106,4 +106,18 @@ class NewOrderNotification extends Notification {
 			$order->save_meta_data();
 		}
 	}
+
+	/**
+	 * {@inheritDoc}
+	 *
+	 * @param string $key The meta key.
+	 */
+	public function delete_meta( string $key ): void {
+		$order = WC()->call_function( 'wc_get_order', $this->get_resource_id() );
+
+		if ( $order instanceof WC_Order ) {
+			$order->delete_meta_data( $key );
+			$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 0b94b3e65cc..965e5a321d6 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewReviewNotification.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewReviewNotification.php
@@ -86,4 +86,13 @@ class NewReviewNotification extends Notification {
 	public function write_meta( string $key ): void {
 		WC()->call_function( 'update_comment_meta', $this->get_resource_id(), $key, (string) time() );
 	}
+
+	/**
+	 * {@inheritDoc}
+	 *
+	 * @param string $key The meta key.
+	 */
+	public function delete_meta( string $key ): void {
+		WC()->call_function( 'delete_comment_meta', $this->get_resource_id(), $key );
+	}
 }
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php
index b8082737e10..b0dc38fc077 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php
@@ -94,6 +94,16 @@ abstract class Notification {
 	 */
 	abstract public function write_meta( string $key ): void;

+	/**
+	 * Deletes a meta key from this notification's resource.
+	 *
+	 * @param string $key The meta key.
+	 * @return void
+	 *
+	 * @since 10.8.0
+	 */
+	abstract public function delete_meta( string $key ): void;
+
 	/**
 	 * Returns the notification data as an array.
 	 *
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
index a8795a182de..548a53dc27b 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
@@ -7,8 +7,10 @@ namespace Automattic\WooCommerce\Internal\PushNotifications;
 defined( 'ABSPATH' ) || exit;

 use Automattic\Jetpack\Connection\Manager as JetpackConnectionManager;
+use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushNotificationRestController;
 use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushTokenRestController;
 use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
 use Automattic\WooCommerce\Internal\PushNotifications\Triggers\NewOrderNotificationTrigger;
 use Automattic\WooCommerce\Internal\PushNotifications\Triggers\NewReviewNotificationTrigger;
@@ -75,8 +77,11 @@ class PushNotifications {
 		wc_get_container()->get( PendingNotificationStore::class )->register();

 		( new PushTokenRestController() )->register();
+		( new PushNotificationRestController() )->register();
 		( new NewOrderNotificationTrigger() )->register();
 		( new NewReviewNotificationTrigger() )->register();
+
+		wc_get_container()->get( NotificationProcessor::class )->register();
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
new file mode 100644
index 00000000000..51700dacfc6
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
@@ -0,0 +1,185 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Services;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore;
+use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\WpcomNotificationDispatcher;
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
+use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Exception;
+
+/**
+ * Shared orchestration for sending a single notification to WPCOM.
+ *
+ * Used by three callers:
+ * 1. PushNotificationRestController — loopback endpoint (is_retry: false)
+ * 2. ActionScheduler safety net — fallback when shutdown didn't fire (is_retry: true)
+ * 3. NotificationRetryHandler — retry for failed sends (is_retry: true)
+ *
+ * @since 10.7.0
+ */
+class NotificationProcessor {
+	/**
+	 * ActionScheduler group for push notification jobs.
+	 */
+	const ACTION_SCHEDULER_GROUP = 'wc-push-notifications';
+
+	/**
+	 * Safety net delay in seconds.
+	 */
+	const SAFETY_NET_DELAY = 60;
+
+	/**
+	 * ActionScheduler hook for the safety net job.
+	 */
+	const SAFETY_NET_HOOK = 'wc_push_notification_safety_net';
+
+	/**
+	 * Meta key written before the WPCOM send attempt.
+	 */
+	const CLAIMED_META_KEY = '_wc_push_notification_claimed';
+
+	/**
+	 * Meta key written after successful WPCOM delivery.
+	 */
+	const SENT_META_KEY = '_wc_push_notification_sent';
+
+	/**
+	 * The WPCOM dispatcher.
+	 *
+	 * @var WpcomNotificationDispatcher
+	 */
+	private WpcomNotificationDispatcher $dispatcher;
+
+	/**
+	 * The push tokens data store.
+	 *
+	 * @var PushTokensDataStore
+	 */
+	private PushTokensDataStore $data_store;
+
+	/**
+	 * Initialize dependencies.
+	 *
+	 * @internal
+	 *
+	 * @param WpcomNotificationDispatcher $dispatcher The WPCOM dispatcher.
+	 * @param PushTokensDataStore         $data_store The push tokens data store.
+	 *
+	 * @since 10.7.0
+	 */
+	final public function init(
+		WpcomNotificationDispatcher $dispatcher,
+		PushTokensDataStore $data_store
+	): void {
+		$this->dispatcher = $dispatcher;
+		$this->data_store = $data_store;
+	}
+
+	/**
+	 * Registers the ActionScheduler hook for the safety net job.
+	 *
+	 * @return void
+	 *
+	 * @since 10.7.0
+	 */
+	public function register(): void {
+		add_action( self::SAFETY_NET_HOOK, array( $this, 'handle_safety_net' ), 10, 2 );
+	}
+
+	/**
+	 * Processes a single notification: checks meta, sends to WPCOM, marks sent.
+	 *
+	 * @param Notification $notification The notification to process.
+	 * @param bool         $is_retry     Whether this is a retry or safety net attempt.
+	 * @return bool True if successfully sent (or already sent).
+	 *
+	 * @since 10.7.0
+	 */
+	public function process( Notification $notification, bool $is_retry = false ): bool {
+		/**
+		 * This notification has already been sent - don't continue.
+		 */
+		if ( $notification->has_meta( self::SENT_META_KEY ) ) {
+			return true;
+		}
+
+		if ( ! $is_retry ) {
+			/**
+			 * This notification has already been claimed for sending, and since
+			 * this is not a retry, this is not expected and means some other
+			 * process is handling the notification (e.g. race condition) -
+			 * don't continue.
+			 */
+			if ( $notification->has_meta( self::CLAIMED_META_KEY ) ) {
+				return true;
+			}
+
+			$notification->write_meta( self::CLAIMED_META_KEY );
+		}
+
+		$tokens = $this->data_store->get_tokens_for_roles(
+			PushNotifications::ROLES_WITH_PUSH_NOTIFICATIONS_ENABLED
+		);
+
+		/**
+		 * There are no recipients to send to. We don't want to retry as this
+		 * isn't a 'recoverable error', so mark as sent and return.
+		 */
+		if ( empty( $tokens ) ) {
+			$notification->write_meta( self::SENT_META_KEY );
+			return true;
+		}
+
+		$result = $this->dispatcher->dispatch( $notification, $tokens );
+
+		if ( ! empty( $result['success'] ) ) {
+			$notification->write_meta( self::SENT_META_KEY );
+			$notification->delete_meta( self::CLAIMED_META_KEY );
+			return true;
+		}
+
+		/**
+		 * Retry scheduling and safety net deletion will be added here when
+		 * NotificationRetryHandler is added.
+		 */
+
+		return false;
+	}
+
+	/**
+	 * ActionScheduler callback for the safety net job. This will be scheduled
+	 * for 60 seconds in the future when a notification is added to the
+	 * `PendingNotificationStore`. If the initial send succeeds, or fails and is
+	 * able to schedule a retry, this action will be unscheduled. If the initial
+	 * send does not occur, or fails and cannot schedule a retry (e.g. out of
+	 * memory, retry scheduling error) then this safety net will run.
+	 *
+	 * @param string $type        The notification type.
+	 * @param int    $resource_id The resource ID.
+	 * @return void
+	 *
+	 * @since 10.7.0
+	 */
+	public function handle_safety_net( string $type, int $resource_id ): void {
+		try {
+			$notification = Notification::from_array(
+				array(
+					'type'        => $type,
+					'resource_id' => $resource_id,
+				)
+			);
+
+			$this->process( $notification, true );
+		} catch ( Exception $e ) {
+			wc_get_logger()->error(
+				sprintf( 'Safety net failed: %s', $e->getMessage() ),
+				array( 'source' => PushNotifications::FEATURE_NAME )
+			);
+		}
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
index 6fbfcd175e9..43a80cb4891 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
@@ -8,6 +8,7 @@ defined( 'ABSPATH' ) || exit;

 use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\InternalNotificationDispatcher;
 use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;

 /**
  * Store that collects notifications during a request and dispatches them all on
@@ -31,9 +32,6 @@ class PendingNotificationStore {
 	 * The dispatcher that will be used to send notifications on shutdown.
 	 *
 	 * @var InternalNotificationDispatcher
-	 *
-	 * @phpstan-ignore property.onlyWritten (this will be read when the loopback
-	 * controller is added)
 	 */
 	private InternalNotificationDispatcher $dispatcher;

@@ -102,6 +100,8 @@ class PendingNotificationStore {

 		$this->pending[ $key ] = $notification;

+		$this->schedule_safety_net( $notification );
+
 		if ( ! $this->shutdown_registered ) {
 			add_action( 'shutdown', array( $this, 'dispatch_all' ) );
 			$this->shutdown_registered = true;
@@ -109,7 +109,36 @@ class PendingNotificationStore {
 	}

 	/**
-	 * Dispatches all pending notifications via the loopback endpoint.
+	 * Schedules an ActionScheduler safety net job for the notification.
+	 *
+	 * If the shutdown hook never fires (OOM, SIGKILL, etc.), this job
+	 * guarantees the notification is still processed.
+	 *
+	 * @param Notification $notification The notification to schedule.
+	 * @return void
+	 *
+	 * @since 10.7.0
+	 */
+	private function schedule_safety_net( Notification $notification ): void {
+		$args = array(
+			'type'        => $notification->get_type(),
+			'resource_id' => $notification->get_resource_id(),
+		);
+
+		if ( as_has_scheduled_action( NotificationProcessor::SAFETY_NET_HOOK, $args, NotificationProcessor::ACTION_SCHEDULER_GROUP ) ) {
+			return;
+		}
+
+		as_schedule_single_action(
+			time() + NotificationProcessor::SAFETY_NET_DELAY,
+			NotificationProcessor::SAFETY_NET_HOOK,
+			$args,
+			NotificationProcessor::ACTION_SCHEDULER_GROUP
+		);
+	}
+
+	/**
+	 * Dispatches all pending notifications via InternalNotificationDispatcher.
 	 *
 	 * Called on shutdown. Sends all pending notifications through the
 	 * InternalNotificationDispatcher, then clears the store.
@@ -123,21 +152,8 @@ class PendingNotificationStore {
 			return;
 		}

-		/**
-		 * Fires when pending push notifications are ready to be dispatched.
-		 *
-		 * @param Notification[] $notifications The notifications to dispatch.
-		 *
-		 * @since 10.7.0
-		 *
-		 * The call to dispatch the notifications will go here when the
-		 * receiving controller has been added.
-		 */
-
-		/**
-		 * Store is single-use per request lifecycle, so disable it and clear
-		 * pending notifications.
-		 */
+		$this->dispatcher->dispatch( array_values( $this->pending ) );
+
 		$this->enabled = false;
 		$this->pending = array();
 	}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushNotificationRestControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushNotificationRestControllerTest.php
new file mode 100644
index 00000000000..a07ddea3390
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushNotificationRestControllerTest.php
@@ -0,0 +1,178 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Controllers;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushNotificationRestController;
+use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken;
+use WC_REST_Unit_Test_Case;
+use WP_REST_Request;
+use WP_REST_Server;
+
+/**
+ * Tests for the PushNotificationRestController class.
+ */
+class PushNotificationRestControllerTest extends WC_REST_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var PushNotificationRestController
+	 */
+	private $sut;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->sut = new PushNotificationRestController();
+		$this->sut->register_routes();
+	}
+
+	/**
+	 * @testdox Should register the send route.
+	 */
+	public function test_register_routes_adds_send_endpoint(): void {
+		$routes = rest_get_server()->get_routes();
+
+		$this->assertArrayHasKey(
+			'/wc-push-notifications/send',
+			$routes,
+			'Send route should be registered'
+		);
+	}
+
+	/**
+	 * @testdox Should reject requests without an authorization header.
+	 */
+	public function test_authorize_rejects_missing_header(): void {
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/send' );
+		$request->set_body( '{}' );
+
+		$result = $this->sut->authorize( $request );
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_rest_unauthorized', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should reject requests with an invalid JWT.
+	 */
+	public function test_authorize_rejects_invalid_jwt(): void {
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/send' );
+		$request->set_header( 'Authorization', 'Bearer invalid.token.here' );
+		$request->set_body( '{}' );
+
+		$result = $this->sut->authorize( $request );
+
+		$this->assertWPError( $result );
+	}
+
+	/**
+	 * @testdox Should reject requests with a mismatched body hash.
+	 */
+	public function test_authorize_rejects_body_hash_mismatch(): void {
+		$token = JsonWebToken::create(
+			array(
+				'iss'       => get_site_url(),
+				'exp'       => time() + 30,
+				'body_hash' => hash( 'sha256', 'original body' ),
+			),
+			wp_salt( 'auth' )
+		);
+
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/send' );
+		$request->set_header( 'Authorization', 'Bearer ' . $token );
+		$request->set_body( 'tampered body' );
+
+		$result = $this->sut->authorize( $request );
+
+		$this->assertWPError( $result );
+	}
+
+	/**
+	 * @testdox Should reject requests with a wrong issuer.
+	 */
+	public function test_authorize_rejects_wrong_issuer(): void {
+		$body  = '{"notifications":[]}';
+		$token = JsonWebToken::create(
+			array(
+				'iss'       => 'https://evil.example.com',
+				'exp'       => time() + 30,
+				'body_hash' => hash( 'sha256', $body ),
+			),
+			wp_salt( 'auth' )
+		);
+
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/send' );
+		$request->set_header( 'Authorization', 'Bearer ' . $token );
+		$request->set_body( $body );
+
+		$result = $this->sut->authorize( $request );
+
+		$this->assertWPError( $result );
+	}
+
+	/**
+	 * @testdox Should accept a valid JWT with correct issuer and body hash.
+	 */
+	public function test_authorize_accepts_valid_jwt(): void {
+		$body  = '{"notifications":[]}';
+		$token = JsonWebToken::create(
+			array(
+				'iss'       => get_site_url(),
+				'exp'       => time() + 30,
+				'body_hash' => hash( 'sha256', $body ),
+			),
+			wp_salt( 'auth' )
+		);
+
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/send' );
+		$request->set_header( 'Authorization', 'Bearer ' . $token );
+		$request->set_body( $body );
+
+		$result = $this->sut->authorize( $request );
+
+		$this->assertTrue( $result );
+	}
+
+	/**
+	 * @testdox Should return success when no notifications are provided.
+	 */
+	public function test_create_returns_success_for_empty_notifications(): void {
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/send' );
+		$request->set_body( '{}' );
+
+		$result = $this->sut->create( $request );
+
+		$this->assertSame( 200, $result->get_status() );
+		$this->assertTrue( $result->get_data()['success'] );
+	}
+
+	/**
+	 * @testdox Should return 200 when notifications are provided.
+	 */
+	public function test_create_returns_ok_with_notifications(): void {
+		$order   = wc_create_order( array( 'status' => 'processing' ) );
+		$body    = wp_json_encode(
+			array(
+				'notifications' => array(
+					array(
+						'type'        => 'store_order',
+						'resource_id' => $order->get_id(),
+					),
+				),
+			)
+		);
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/send' );
+		$request->set_body( $body );
+
+		$result = $this->sut->create( $request );
+
+		$this->assertSame( 200, $result->get_status() );
+		$this->assertTrue( $result->get_data()['success'] );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
new file mode 100644
index 00000000000..632dad27a7d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
@@ -0,0 +1,264 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Services;
+
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore;
+use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\WpcomNotificationDispatcher;
+use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewOrderNotification;
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewReviewNotification;
+use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the NotificationProcessor class.
+ */
+class NotificationProcessorTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var NotificationProcessor
+	 */
+	private $sut;
+
+	/**
+	 * Mock WPCOM dispatcher.
+	 *
+	 * @var WpcomNotificationDispatcher
+	 */
+	private $dispatcher;
+
+	/**
+	 * Mock data store.
+	 *
+	 * @var PushTokensDataStore
+	 */
+	private $data_store;
+
+	/**
+	 * A test order ID.
+	 *
+	 * @var int
+	 */
+	private int $order_id;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->dispatcher = $this->createMock( WpcomNotificationDispatcher::class );
+		$this->data_store = $this->createMock( PushTokensDataStore::class );
+		$this->order_id   = wc_create_order( array( 'status' => 'processing' ) )->get_id();
+
+		$this->sut = new NotificationProcessor();
+		$this->sut->init( $this->dispatcher, $this->data_store );
+
+		$this->data_store->method( 'get_tokens_for_roles' )->willReturn(
+			array(
+				new PushToken(
+					array(
+						'user_id'       => 1,
+						'token'         => 'test-token',
+						'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+						'platform'      => PushToken::PLATFORM_APPLE,
+						'device_locale' => 'en_US',
+						'device_uuid'   => 'test-uuid',
+					)
+				),
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should return true, write sent meta, and clean up claimed meta on successful dispatch.
+	 */
+	public function test_process_writes_sent_meta_on_success(): void {
+		$this->dispatcher->method( 'dispatch' )->willReturn(
+			array(
+				'success'     => true,
+				'retry_after' => null,
+			)
+		);
+
+		$notification = new NewOrderNotification( $this->order_id );
+		$result       = $this->sut->process( $notification );
+
+		$this->assertTrue( $result );
+
+		$order = wc_get_order( $this->order_id );
+
+		$this->assertNotEmpty( $order->get_meta( NotificationProcessor::SENT_META_KEY ) );
+		$this->assertFalse( $notification->has_meta( NotificationProcessor::CLAIMED_META_KEY ) );
+	}
+
+	/**
+	 * @testdox Should write claimed meta before sending.
+	 */
+	public function test_process_writes_claimed_meta_before_send(): void {
+		$this->dispatcher->method( 'dispatch' )->willReturn(
+			array(
+				'success'     => false,
+				'retry_after' => null,
+			)
+		);
+
+		$notification = new NewOrderNotification( $this->order_id );
+
+		$this->sut->process( $notification );
+
+		$order = wc_get_order( $this->order_id );
+
+		$this->assertNotEmpty( $order->get_meta( NotificationProcessor::CLAIMED_META_KEY ) );
+	}
+
+	/**
+	 * @testdox Should skip processing when sent meta already exists.
+	 */
+	public function test_process_skips_when_already_sent(): void {
+		$order = wc_get_order( $this->order_id );
+		$order->update_meta_data( NotificationProcessor::SENT_META_KEY, (string) time() );
+		$order->save_meta_data();
+
+		$this->dispatcher->expects( $this->never() )->method( 'dispatch' );
+
+		$notification = new NewOrderNotification( $this->order_id );
+		$result       = $this->sut->process( $notification );
+
+		$this->assertTrue( $result );
+		$this->assertFalse( $notification->has_meta( NotificationProcessor::CLAIMED_META_KEY ) );
+	}
+
+	/**
+	 * @testdox Should skip processing when claimed meta exists on first attempt.
+	 */
+	public function test_process_skips_on_first_attempt_when_already_claimed(): void {
+		$order = wc_get_order( $this->order_id );
+
+		$order->update_meta_data( NotificationProcessor::CLAIMED_META_KEY, (string) time() );
+		$order->save_meta_data();
+
+		$this->dispatcher->expects( $this->never() )->method( 'dispatch' );
+
+		$notification = new NewOrderNotification( $this->order_id );
+		$result       = $this->sut->process( $notification );
+
+		$this->assertTrue( $result );
+		$this->assertFalse( $notification->has_meta( NotificationProcessor::SENT_META_KEY ) );
+	}
+
+	/**
+	 * @testdox Should proceed past claimed meta when is_retry is true.
+	 */
+	public function test_process_proceeds_past_claimed_on_retry(): void {
+		$order = wc_get_order( $this->order_id );
+		$order->update_meta_data( NotificationProcessor::CLAIMED_META_KEY, (string) time() );
+		$order->save_meta_data();
+
+		$this->dispatcher->expects( $this->once() )->method( 'dispatch' )->willReturn(
+			array(
+				'success'     => true,
+				'retry_after' => null,
+			)
+		);
+
+		$notification = new NewOrderNotification( $this->order_id );
+		$result       = $this->sut->process( $notification, true );
+
+		$this->assertTrue( $result );
+		$this->assertFalse( $notification->has_meta( NotificationProcessor::CLAIMED_META_KEY ) );
+	}
+
+	/**
+	 * @testdox Should return false and not write sent meta on dispatch failure.
+	 */
+	public function test_process_returns_false_on_failure(): void {
+		$this->dispatcher->method( 'dispatch' )->willReturn(
+			array(
+				'success'     => false,
+				'retry_after' => null,
+			)
+		);
+
+		$notification = new NewOrderNotification( $this->order_id );
+		$result       = $this->sut->process( $notification );
+
+		$this->assertFalse( $result );
+		$this->assertFalse( $notification->has_meta( NotificationProcessor::SENT_META_KEY ) );
+	}
+
+	/**
+	 * @testdox Should mark as sent and return true when no tokens are available.
+	 */
+	public function test_process_marks_sent_when_no_tokens(): void {
+		$data_store = $this->createMock( PushTokensDataStore::class );
+		$data_store->method( 'get_tokens_for_roles' )->willReturn( array() );
+
+		$this->dispatcher->expects( $this->never() )->method( 'dispatch' );
+
+		$sut = new NotificationProcessor();
+		$sut->init( $this->dispatcher, $data_store );
+
+		$notification = new NewOrderNotification( $this->order_id );
+		$result       = $sut->process( $notification );
+
+		$this->assertTrue( $result );
+
+		$order = wc_get_order( $this->order_id );
+
+		$this->assertNotEmpty( $order->get_meta( NotificationProcessor::SENT_META_KEY ) );
+	}
+
+	/**
+	 * @testdox Should write comment meta for review notifications.
+	 */
+	public function test_process_writes_comment_meta_for_reviews(): void {
+		$this->dispatcher->method( 'dispatch' )->willReturn(
+			array(
+				'success'     => true,
+				'retry_after' => null,
+			)
+		);
+
+		$product    = WC_Helper_Product::create_simple_product();
+		$comment_id = wp_insert_comment(
+			array(
+				'comment_post_ID' => $product->get_id(),
+				'comment_type'    => 'review',
+				'comment_content' => 'Great!',
+				'comment_author'  => 'Tester',
+			)
+		);
+
+		$notification = new NewReviewNotification( $comment_id );
+
+		$this->sut->process( $notification );
+
+		$this->assertNotEmpty( get_comment_meta( $comment_id, NotificationProcessor::SENT_META_KEY, true ) );
+		$this->assertFalse( $notification->has_meta( NotificationProcessor::CLAIMED_META_KEY ) );
+	}
+
+	/**
+	 * @testdox Should handle safety net callback by processing with is_retry true.
+	 */
+	public function test_handle_safety_net_processes_notification(): void {
+		$this->dispatcher->expects( $this->once() )->method( 'dispatch' )->willReturn(
+			array(
+				'success'     => true,
+				'retry_after' => null,
+			)
+		);
+
+		$this->sut->handle_safety_net( 'store_order', $this->order_id );
+
+		$order = wc_get_order( $this->order_id );
+
+		$this->assertNotEmpty( $order->get_meta( NotificationProcessor::SENT_META_KEY ) );
+	}
+}