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