Commit 56509278f00 for woocommerce

commit 56509278f00eaa2bdec539a1941ba7eb4d0745be
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date:   Fri Mar 13 11:52:01 2026 +0000

    Ainfra 1799 add dispatchers (#63602)

    * Add PushToken WPCOM formatting and wire PendingNotificationStore to dispatcher.

    - Add PushToken::to_wpcom_format() returning {user_id, token, origin, device_locale}
    - PendingNotificationStore now receives InternalNotificationDispatcher via DI init()
    - dispatch_all() calls dispatcher directly instead of placeholder comment

diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcher.php b/plugins/woocommerce/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcher.php
new file mode 100644
index 00000000000..4642c5434d6
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcher.php
@@ -0,0 +1,88 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Dispatchers;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
+use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken;
+
+/**
+ * Fires a non-blocking POST to the internal REST endpoint with JSON-encoded
+ * notification data and a signed JWT.
+ *
+ * Called directly by PendingNotificationStore::dispatch_all() on shutdown.
+ *
+ * @internal
+ * @since 10.7.0
+ */
+class InternalNotificationDispatcher {
+
+	/**
+	 * REST route for the send endpoint.
+	 */
+	const SEND_ENDPOINT = 'wc-push-notifications/send';
+
+	/**
+	 * JWT expiry in seconds.
+	 */
+	const JWT_EXPIRY_SECONDS = 30;
+
+	/**
+	 * JSON-encodes notifications and fires a non-blocking POST to the internal
+	 * REST endpoint.
+	 *
+	 * @param Notification[] $notifications The notifications to dispatch.
+	 * @return void
+	 *
+	 * @since 10.7.0
+	 */
+	public function dispatch( array $notifications ): void {
+		if ( empty( $notifications ) ) {
+			return;
+		}
+
+		$encoded = array_map( fn ( Notification $notification ) => $notification->to_array(), $notifications );
+		$body    = wp_json_encode( array( 'notifications' => $encoded ) );
+
+		if ( false === $body ) {
+			wc_get_logger()->error(
+				'Failed to JSON-encode push notification payload.',
+				array( 'source' => PushNotifications::FEATURE_NAME )
+			);
+			return;
+		}
+
+		$token = JsonWebToken::create(
+			array(
+				'iss'       => get_site_url(),
+				'exp'       => time() + self::JWT_EXPIRY_SECONDS,
+				'body_hash' => hash( 'sha256', $body ),
+			),
+			wp_salt( 'auth' )
+		);
+
+		$response = wp_remote_post(
+			rest_url( self::SEND_ENDPOINT ),
+			array(
+				'blocking' => false,
+				'timeout'  => 1,
+				'headers'  => array(
+					'Content-Type'  => 'application/json',
+					'Authorization' => 'Bearer ' . $token,
+				),
+				'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/Dispatchers/WpcomNotificationDispatcher.php b/plugins/woocommerce/src/Internal/PushNotifications/Dispatchers/WpcomNotificationDispatcher.php
new file mode 100644
index 00000000000..8ba180b397b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Dispatchers/WpcomNotificationDispatcher.php
@@ -0,0 +1,171 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Dispatchers;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\Jetpack\Connection\Client as Jetpack_Connection_Client;
+use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
+use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Jetpack_Options;
+use WP_Error;
+use WP_Http;
+
+/**
+ * Sends a notification to WPCOM via the Jetpack connection.
+ *
+ * Called directly by the NotificationProcessor. Combines the notification
+ * payload with formatted push tokens and sends to the WPCOM push endpoint.
+ * Returns a result array indicating success/failure and an optional retry-after
+ * value.
+ *
+ * @internal
+ * @since 10.7.0
+ */
+class WpcomNotificationDispatcher {
+
+	/**
+	 * WPCOM API version.
+	 */
+	const WPCOM_API_VERSION = '2';
+
+	/**
+	 * WPCOM endpoint path (appended after /sites/{id}/).
+	 */
+	const SEND_ENDPOINT = 'push-notifications';
+
+	/**
+	 * HTTP request timeout in seconds.
+	 */
+	const REQUEST_TIMEOUT = 15;
+
+	/**
+	 * Dispatches a notification with push tokens to WPCOM.
+	 *
+	 * @param Notification $notification The notification to send.
+	 * @param PushToken[]  $tokens       The push tokens to send to.
+	 * @return array{success: bool, retry_after: int|null}
+	 *
+	 * @since 10.7.0
+	 */
+	public function dispatch( Notification $notification, array $tokens ): array {
+		$site_id = class_exists( Jetpack_Options::class ) ? Jetpack_Options::get_option( 'id' ) : null;
+
+		if ( empty( $site_id ) ) {
+			wc_get_logger()->error(
+				'Cannot send push notifications: Jetpack site ID unavailable.',
+				array( 'source' => PushNotifications::FEATURE_NAME )
+			);
+
+			return array(
+				'success'     => false,
+				'retry_after' => null,
+			);
+		}
+
+		$payload = $notification->to_payload();
+
+		if ( null === $payload ) {
+			wc_get_logger()->error(
+				sprintf(
+					'Cannot send push notification: resource no longer exists (type=%s, resource_id=%d).',
+					$notification->get_type(),
+					$notification->get_resource_id()
+				),
+				array( 'source' => PushNotifications::FEATURE_NAME )
+			);
+
+			return array(
+				'success'     => false,
+				'retry_after' => null,
+			);
+		}
+
+		$response = $this->make_request( $site_id, $payload, $tokens );
+
+		if ( is_wp_error( $response ) ) {
+			wc_get_logger()->error(
+				sprintf(
+					'Push notification request failed: %s',
+					$response->get_error_message()
+				),
+				array( 'source' => PushNotifications::FEATURE_NAME )
+			);
+
+			return array(
+				'success'     => false,
+				'retry_after' => null,
+			);
+		}
+
+		$status_code = (int) wp_remote_retrieve_response_code( $response );
+
+		if ( WP_Http::OK === $status_code ) {
+			return array(
+				'success'     => true,
+				'retry_after' => null,
+			);
+		}
+
+		$retry_after = wp_remote_retrieve_header( $response, 'retry-after' );
+
+		wc_get_logger()->error(
+			sprintf(
+				'Push notification request returned HTTP %d.',
+				$status_code
+			),
+			array( 'source' => PushNotifications::FEATURE_NAME )
+		);
+
+		return array(
+			'success'     => false,
+			'retry_after' => '' !== $retry_after ? (int) $retry_after : null,
+		);
+	}
+
+	/**
+	 * Makes the WPCOM API request via the Jetpack connection.
+	 *
+	 * @param int         $site_id The Jetpack site ID.
+	 * @param array       $payload The notification payload.
+	 * @param PushToken[] $tokens  The push tokens.
+	 * @return array|WP_Error
+	 *
+	 * @since 10.7.0
+	 *
+	 * @phpstan-ignore return.unusedType (Jetpack stubs lack array return type.)
+	 */
+	private function make_request( int $site_id, array $payload, array $tokens ) {
+		$body = wp_json_encode(
+			array_merge(
+				$payload,
+				array(
+					'tokens' => array_map(
+						fn ( PushToken $token ) => $token->to_wpcom_format(),
+						$tokens
+					),
+				)
+			)
+		);
+
+		if ( false === $body ) {
+			return new WP_Error( 'json_encode_failed', 'Failed to encode push notification payload.' );
+		}
+
+		// @phpstan-ignore return.type
+		return Jetpack_Connection_Client::wpcom_json_api_request_as_blog(
+			sprintf( '/sites/%d/%s', $site_id, self::SEND_ENDPOINT ),
+			self::WPCOM_API_VERSION,
+			array(
+				'headers' => array( 'Content-Type' => 'application/json' ),
+				'method'  => 'POST',
+				'timeout' => self::REQUEST_TIMEOUT,
+			),
+			$body,
+			'wpcom'
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Entities/PushToken.php b/plugins/woocommerce/src/Internal/PushNotifications/Entities/PushToken.php
index 5fe3d620357..308beb49212 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Entities/PushToken.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Entities/PushToken.php
@@ -449,6 +449,22 @@ class PushToken {
 		return $this->metadata;
 	}

+	/**
+	 * Returns this token formatted for the WPCOM push notifications endpoint.
+	 *
+	 * @return array{user_id: int|null, token: string|null, origin: string|null, device_locale: string|null}
+	 *
+	 * @since 10.7.0
+	 */
+	public function to_wpcom_format(): array {
+		return array(
+			'user_id'       => $this->user_id,
+			'token'         => $this->token,
+			'origin'        => $this->origin,
+			'device_locale' => $this->device_locale ?? self::DEFAULT_DEVICE_LOCALE,
+		);
+	}
+
 	/**
 	 * Determines whether this token can be created.
 	 *
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewOrderNotification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewOrderNotification.php
index 0f81590c2b4..850b381f61e 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewOrderNotification.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewOrderNotification.php
@@ -60,7 +60,8 @@ class NewOrderNotification extends Notification {
 		return array(
 			'type'        => self::TYPE,
 			'icon'        => self::ICON,
-			'blog_id'     => get_current_blog_id(),
+			// 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(),
 			'title'       => array(
 				/**
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewReviewNotification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewReviewNotification.php
index 026d6cfb033..511c0c19762 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewReviewNotification.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewReviewNotification.php
@@ -48,7 +48,8 @@ class NewReviewNotification extends Notification {

 		return array(
 			'type'        => self::TYPE,
-			'blog_id'     => get_current_blog_id(),
+			// 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(),
 			'title'       => array(
 				'format' => 'You have a new review! ⭐️',
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
index 85f60b517c4..6fbfcd175e9 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
@@ -6,12 +6,13 @@ namespace Automattic\WooCommerce\Internal\PushNotifications\Services;

 defined( 'ABSPATH' ) || exit;

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

 /**
  * Store that collects notifications during a request and dispatches them all on
- * on shutdown. Should be accessed from the container (`wc_get_container`) to
- * ensure store is shared by all usage.
+ * shutdown via the InternalNotificationDispatcher. Should be accessed from the
+ * container (`wc_get_container`) to ensure store is shared by all usage.
  *
  * Notifications are keyed by `{type}_{resource_id}` (with blog ID from
  * `get_current_blog_id()`) to prevent duplicates within a single request.
@@ -26,6 +27,16 @@ class PendingNotificationStore {
 	 */
 	private bool $enabled = false;

+	/**
+	 * 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;
+
 	/**
 	 * Pending notifications keyed by identifier.
 	 *
@@ -40,6 +51,19 @@ class PendingNotificationStore {
 	 */
 	private bool $shutdown_registered = false;

+	/**
+	 * Initialize dependencies.
+	 *
+	 * @internal
+	 *
+	 * @param InternalNotificationDispatcher $dispatcher The dispatcher to use on shutdown.
+	 *
+	 * @since 10.7.0
+	 */
+	final public function init( InternalNotificationDispatcher $dispatcher ): void {
+		$this->dispatcher = $dispatcher;
+	}
+
 	/**
 	 * Enables the store so it accepts notifications.
 	 *
@@ -85,10 +109,10 @@ class PendingNotificationStore {
 	}

 	/**
-	 * Dispatches all pending notifications by firing an action hook.
+	 * Dispatches all pending notifications via the loopback endpoint.
 	 *
-	 * Called on shutdown. Fires the `wc_push_notifications_dispatch` action
-	 * with the array of pending notifications, then clears the store.
+	 * Called on shutdown. Sends all pending notifications through the
+	 * InternalNotificationDispatcher, then clears the store.
 	 *
 	 * @return void
 	 *
@@ -99,8 +123,6 @@ class PendingNotificationStore {
 			return;
 		}

-		$notifications = array_values( $this->pending );
-
 		/**
 		 * Fires when pending push notifications are ready to be dispatched.
 		 *
@@ -108,7 +130,8 @@ class PendingNotificationStore {
 		 *
 		 * @since 10.7.0
 		 *
-		 * The call to dispatch the notifications will go here.
+		 * The call to dispatch the notifications will go here when the
+		 * receiving controller has been added.
 		 */

 		/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcherTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcherTest.php
new file mode 100644
index 00000000000..3ed44a3e841
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcherTest.php
@@ -0,0 +1,175 @@
+<?php
+
+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 WC_Unit_Test_Case;
+
+/**
+ * Tests for the InternalNotificationDispatcher class.
+ */
+class InternalNotificationDispatcherTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var InternalNotificationDispatcher
+	 */
+	private $sut;
+
+	/**
+	 * Captured HTTP request arguments from the last wp_remote_post call.
+	 *
+	 * @var array|null
+	 */
+	private $captured_request;
+
+	/**
+	 * Captured URL from the last wp_remote_post call.
+	 *
+	 * @var string|null
+	 */
+	private $captured_url;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->sut              = new InternalNotificationDispatcher();
+		$this->captured_request = null;
+		$this->captured_url     = null;
+
+		add_filter( 'pre_http_request', array( $this, 'intercept_http_request' ), 10, 3 );
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		remove_filter( 'pre_http_request', array( $this, 'intercept_http_request' ), 10 );
+		parent::tearDown();
+	}
+
+	/**
+	 * Intercepts wp_remote_post calls and captures request data.
+	 *
+	 * @param false|array $preempt   Whether to preempt the request.
+	 * @param array       $args      Request arguments.
+	 * @param string      $url       Request URL.
+	 * @return array Fake successful response.
+	 */
+	public function intercept_http_request( $preempt, $args, $url ) {
+		unset( $preempt );
+		$this->captured_request = $args;
+		$this->captured_url     = $url;
+
+		return array(
+			'response' => array(
+				'code'    => 200,
+				'message' => 'OK',
+			),
+			'body'     => '',
+		);
+	}
+
+	/**
+	 * @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 ) );
+
+		$this->sut->dispatch( $notifications );
+
+		$this->assertStringContainsString(
+			InternalNotificationDispatcher::SEND_ENDPOINT,
+			$this->captured_url,
+			'Request URL should contain the send endpoint'
+		);
+		$this->assertFalse(
+			$this->captured_request['blocking'],
+			'Request should be non-blocking'
+		);
+	}
+
+	/**
+	 * @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 ) );
+
+		$this->sut->dispatch( $notifications );
+
+		$auth_header = $this->captured_request['headers']['Authorization'];
+		$token       = str_replace( 'Bearer ', '', $auth_header );
+
+		$this->assertTrue(
+			JsonWebToken::validate( $token, wp_salt( 'auth' ) ),
+			'JWT should be valid when verified with the auth salt'
+		);
+
+		$parts     = JsonWebToken::get_parts( $token );
+		$body_hash = hash( 'sha256', $this->captured_request['body'] );
+
+		$this->assertSame( get_site_url(), $parts->payload->iss, 'JWT issuer should be the site URL' );
+		$this->assertGreaterThan( time(), (int) $parts->payload->exp, 'JWT should not be expired' );
+		$this->assertSame(
+			$body_hash,
+			$parts->payload->body_hash,
+			'JWT body_hash should match SHA-256 hash of the request body'
+		);
+	}
+
+	/**
+	 * @testdox Should include encoded notifications in the request body.
+	 */
+	public function test_dispatch_body_contains_encoded_notifications(): void {
+		$notifications = array(
+			$this->create_notification( 'store_order', 10 ),
+			$this->create_notification( 'store_review', 20 ),
+		);
+
+		$this->sut->dispatch( $notifications );
+
+		$body = json_decode( $this->captured_request['body'], true );
+
+		$this->assertArrayHasKey( 'notifications', $body );
+		$this->assertCount( 2, $body['notifications'] );
+		$this->assertSame( 'store_order', $body['notifications'][0]['type'] );
+		$this->assertSame( 10, $body['notifications'][0]['resource_id'] );
+		$this->assertSame( 'store_review', $body['notifications'][1]['type'] );
+		$this->assertSame( 20, $body['notifications'][1]['resource_id'] );
+	}
+
+	/**
+	 * @testdox Should skip dispatch when notifications array is empty.
+	 */
+	public function test_dispatch_skips_when_empty(): void {
+		$this->sut->dispatch( array() );
+
+		$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
new file mode 100644
index 00000000000..c68540e60b6
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Dispatchers/WpcomNotificationDispatcherTest.php
@@ -0,0 +1,326 @@
+<?php
+
+declare( strict_types = 1 );
+
+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 WC_Unit_Test_Case;
+use WP_Error;
+
+/**
+ * Tests for the WpcomNotificationDispatcher class.
+ */
+class WpcomNotificationDispatcherTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var WpcomNotificationDispatcher
+	 */
+	private WpcomNotificationDispatcher $sut;
+
+	/**
+	 * The response to return from intercepted HTTP requests.
+	 *
+	 * @var array|WP_Error
+	 */
+	private $mock_response;
+
+	/**
+	 * Captured HTTP request arguments from the last intercepted call.
+	 *
+	 * @var array|null
+	 */
+	private ?array $captured_request;
+
+	/**
+	 * Captured URL from the last intercepted call.
+	 *
+	 * @var string|null
+	 */
+	private ?string $captured_url;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->sut              = new WpcomNotificationDispatcher();
+		$this->mock_response    = $this->make_response( 200 );
+		$this->captured_request = null;
+		$this->captured_url     = null;
+
+		add_filter( 'pre_option_jetpack_options', array( $this, 'filter_jetpack_options' ) );
+		add_filter( 'pre_option_jetpack_private_options', array( $this, 'filter_jetpack_private_options' ) );
+		add_filter( 'pre_http_request', array( $this, 'intercept_http_request' ), 10, 3 );
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		remove_filter( 'pre_option_jetpack_options', array( $this, 'filter_jetpack_options' ) );
+		remove_filter( 'pre_option_jetpack_private_options', array( $this, 'filter_jetpack_private_options' ) );
+		remove_filter( 'pre_http_request', array( $this, 'intercept_http_request' ), 10 );
+		parent::tearDown();
+	}
+
+	/**
+	 * Returns fake Jetpack options with a site ID.
+	 *
+	 * @return array
+	 */
+	public function filter_jetpack_options(): array {
+		return array( 'id' => 12345 );
+	}
+
+	/**
+	 * Returns fake Jetpack private options with a blog token.
+	 *
+	 * @return array
+	 */
+	public function filter_jetpack_private_options(): array {
+		return array( 'blog_token' => 'test.blogtokenvalue' );
+	}
+
+	/**
+	 * Intercepts HTTP requests and captures request data.
+	 *
+	 * @param false|array $preempt Whether to preempt the request.
+	 * @param array       $args    Request arguments.
+	 * @param string      $url     Request URL.
+	 * @return array|WP_Error The mock response.
+	 */
+	public function intercept_http_request( $preempt, $args, $url ) {
+		unset( $preempt );
+		$this->captured_request = $args;
+		$this->captured_url     = $url;
+		return $this->mock_response;
+	}
+
+	/**
+	 * @testdox Should return success on 200 response.
+	 */
+	public function test_dispatch_returns_success_on_200(): void {
+		$result = $this->sut->dispatch( $this->create_notification(), $this->create_tokens() );
+
+		$this->assertTrue( $result['success'] );
+	}
+
+	/**
+	 * @testdox Should return failure on WP_Error response.
+	 */
+	public function test_dispatch_returns_failure_on_wp_error(): void {
+		$this->mock_response = new WP_Error( 'http_request_failed', 'Connection timed out' );
+
+		$result = $this->sut->dispatch( $this->create_notification(), $this->create_tokens() );
+
+		$this->assertFalse( $result['success'] );
+	}
+
+	/**
+	 * @testdox Should return failure with correct retry_after for non-200 responses.
+	 * @dataProvider non_200_responses_provider
+	 *
+	 * @param int      $status_code    The HTTP status code.
+	 * @param int|null $expected_retry The expected retry_after value.
+	 * @param array    $headers        The response headers.
+	 */
+	public function test_dispatch_handles_non_200_responses( int $status_code, ?int $expected_retry, array $headers ): void {
+		$this->mock_response = $this->make_response( $status_code, $headers );
+
+		$result = $this->sut->dispatch( $this->create_notification(), $this->create_tokens() );
+
+		$this->assertFalse( $result['success'] );
+		$this->assertSame( $expected_retry, $result['retry_after'] );
+	}
+
+	/**
+	 * @testdox Should return failure when Jetpack site ID is unavailable.
+	 */
+	public function test_dispatch_returns_failure_when_no_site_id(): void {
+		remove_filter( 'pre_option_jetpack_options', array( $this, 'filter_jetpack_options' ) );
+		add_filter( 'pre_option_jetpack_options', array( $this, 'filter_jetpack_options_empty' ) );
+
+		$result = $this->sut->dispatch( $this->create_notification(), $this->create_tokens() );
+
+		remove_filter( 'pre_option_jetpack_options', array( $this, 'filter_jetpack_options_empty' ) );
+
+		$this->assertFalse( $result['success'] );
+		$this->assertNull( $result['retry_after'] );
+		$this->assertNull( $this->captured_url, 'No HTTP request should be made' );
+	}
+
+	/**
+	 * @testdox Should return failure when notification payload is null.
+	 */
+	public function test_dispatch_returns_failure_when_payload_is_null(): void {
+		$notification = $this->create_notification( null );
+		$result       = $this->sut->dispatch( $notification, $this->create_tokens() );
+
+		$this->assertFalse( $result['success'] );
+		$this->assertNull( $this->captured_url, 'No HTTP request should be made' );
+	}
+
+	/**
+	 * @testdox Should fire request to the send endpoint with payload and formatted tokens in the body.
+	 */
+	public function test_dispatch_sends_request_with_payload_and_tokens(): void {
+		$notification = $this->create_notification(
+			array(
+				'type'        => 'store_order',
+				'title'       => array( 'format' => 'New Order' ),
+				'resource_id' => 1,
+			)
+		);
+
+		$tokens = array(
+			new PushToken(
+				array(
+					'user_id'       => 1,
+					'token'         => 'abc123',
+					'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+					'platform'      => PushToken::PLATFORM_APPLE,
+					'device_locale' => 'en_US',
+					'device_uuid'   => 'uuid-1',
+				)
+			),
+			new PushToken(
+				array(
+					'user_id'       => 2,
+					'token'         => 'def456',
+					'origin'        => PushToken::ORIGIN_WOOCOMMERCE_ANDROID,
+					'platform'      => PushToken::PLATFORM_ANDROID,
+					'device_locale' => 'fr_FR',
+					'device_uuid'   => 'uuid-2',
+				)
+			),
+		);
+
+		$this->sut->dispatch( $notification, $tokens );
+
+		$this->assertStringContainsString(
+			WpcomNotificationDispatcher::SEND_ENDPOINT,
+			$this->captured_url
+		);
+
+		$body = json_decode( $this->captured_request['body'], true );
+
+		$this->assertArrayNotHasKey( 'payload', $body );
+		$this->assertSame( 'store_order', $body['type'] );
+		$this->assertSame( array( 'format' => 'New Order' ), $body['title'] );
+		$this->assertSame( 1, $body['resource_id'] );
+
+		$this->assertCount( 2, $body['tokens'] );
+		$this->assertSame(
+			array(
+				'user_id'       => 1,
+				'token'         => 'abc123',
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+			),
+			$body['tokens'][0]
+		);
+		$this->assertSame(
+			array(
+				'user_id'       => 2,
+				'token'         => 'def456',
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_ANDROID,
+				'device_locale' => 'fr_FR',
+			),
+			$body['tokens'][1]
+		);
+	}
+
+	/**
+	 * Returns fake Jetpack options without a site ID.
+	 *
+	 * @return array
+	 */
+	public function filter_jetpack_options_empty(): array {
+		return array();
+	}
+
+	/**
+	 * Data provider for non-200 response scenarios.
+	 *
+	 * @return array<string, array{int, int|null, array}>
+	 */
+	public function non_200_responses_provider(): array {
+		return array(
+			'500 without retry-after' => array( 500, null, array() ),
+			'429 with retry-after'    => array( 429, 60, array( 'retry-after' => '60' ) ),
+			'503 without retry-after' => array( 503, null, array() ),
+		);
+	}
+
+	/**
+	 * Creates a mock HTTP response array.
+	 *
+	 * @param int   $status_code HTTP status code.
+	 * @param array $headers     Response headers.
+	 * @return array
+	 */
+	private function make_response( int $status_code, array $headers = array() ): array {
+		return array(
+			'response' => array(
+				'code'    => $status_code,
+				'message' => 'Mock',
+			),
+			'body'     => '',
+			'headers'  => $headers,
+		);
+	}
+
+	/**
+	 * Creates a concrete Notification instance for testing.
+	 *
+	 * @param array|null $payload The payload to return from to_payload().
+	 * @return Notification
+	 */
+	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;
+			}
+		};
+	}
+
+	/**
+	 * Creates a default set of push tokens for tests that don't need specific token data.
+	 *
+	 * @return PushToken[]
+	 */
+	private function create_tokens(): array {
+		return 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',
+				)
+			),
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewOrderNotificationTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewOrderNotificationTest.php
index dc3a49764b6..451997fc4cf 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewOrderNotificationTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewOrderNotificationTest.php
@@ -22,7 +22,7 @@ class NewOrderNotificationTest extends WC_Unit_Test_Case {
 		$payload = $notification->to_payload();

 		$this->assertArrayHasKey( 'type', $payload );
-		$this->assertArrayHasKey( 'blog_id', $payload );
+		$this->assertArrayHasKey( 'timestamp', $payload );
 		$this->assertArrayHasKey( 'resource_id', $payload );
 		$this->assertArrayHasKey( 'title', $payload );
 		$this->assertArrayHasKey( 'format', $payload['title'] );
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewReviewNotificationTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewReviewNotificationTest.php
index 9e52cd7c2fd..16d9e6f31b7 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewReviewNotificationTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewReviewNotificationTest.php
@@ -23,7 +23,7 @@ class NewReviewNotificationTest extends WC_Unit_Test_Case {
 		$payload      = $notification->to_payload();

 		$this->assertArrayHasKey( 'type', $payload );
-		$this->assertArrayHasKey( 'blog_id', $payload );
+		$this->assertArrayHasKey( 'timestamp', $payload );
 		$this->assertArrayHasKey( 'resource_id', $payload );
 		$this->assertArrayHasKey( 'title', $payload );
 		$this->assertArrayHasKey( 'format', $payload['title'] );
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 efb66d60180..0e871cf9b20 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/PendingNotificationStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/PendingNotificationStoreTest.php
@@ -4,6 +4,7 @@ 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 WC_Unit_Test_Case;
@@ -26,7 +27,10 @@ class PendingNotificationStoreTest extends WC_Unit_Test_Case {
 	public function setUp(): void {
 		parent::setUp();

+		$dispatcher  = $this->createMock( InternalNotificationDispatcher::class );
 		$this->store = new PendingNotificationStore();
+
+		$this->store->init( $dispatcher );
 		$this->store->register();
 	}

@@ -92,7 +96,9 @@ class PendingNotificationStoreTest extends WC_Unit_Test_Case {
 	 * @testdox Should not add notifications when store has not been registered.
 	 */
 	public function test_add_does_nothing_when_not_registered(): void {
-		$store = new PendingNotificationStore();
+		$dispatcher = $this->createMock( InternalNotificationDispatcher::class );
+		$store      = new PendingNotificationStore();
+		$store->init( $dispatcher );

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

diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/NewOrderNotificationTriggerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/NewOrderNotificationTriggerTest.php
index a3bcf8dcbe8..008e896abc6 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/NewOrderNotificationTriggerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/NewOrderNotificationTriggerTest.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );

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

+use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\InternalNotificationDispatcher;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
 use Automattic\WooCommerce\Internal\PushNotifications\Triggers\NewOrderNotificationTrigger;
 use WC_Order;
@@ -33,7 +34,10 @@ class NewOrderNotificationTriggerTest extends WC_Unit_Test_Case {
 	public function setUp(): void {
 		parent::setUp();

+		$dispatcher  = $this->createMock( InternalNotificationDispatcher::class );
 		$this->store = new PendingNotificationStore();
+
+		$this->store->init( $dispatcher );
 		$this->store->register();

 		wc_get_container()->replace( PendingNotificationStore::class, $this->store );
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/NewReviewNotificationTriggerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/NewReviewNotificationTriggerTest.php
index f1d1146231b..1b77eae5353 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/NewReviewNotificationTriggerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/NewReviewNotificationTriggerTest.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );

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

+use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\InternalNotificationDispatcher;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
 use Automattic\WooCommerce\Internal\PushNotifications\Triggers\NewReviewNotificationTrigger;
 use WC_Helper_Product;
@@ -40,7 +41,10 @@ class NewReviewNotificationTriggerTest extends WC_Unit_Test_Case {
 	public function setUp(): void {
 		parent::setUp();

+		$dispatcher  = $this->createMock( InternalNotificationDispatcher::class );
 		$this->store = new PendingNotificationStore();
+
+		$this->store->init( $dispatcher );
 		$this->store->register();

 		wc_get_container()->replace( PendingNotificationStore::class, $this->store );