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