Commit 55075d206dd for woocommerce
commit 55075d206dd14cb4c0e45310c2ab96eb10356c75
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date: Thu Sep 3 13:50:03 2026 +0100
Add push notification driver status REST endpoint. (#67315)
* Add push notification driver status REST endpoint.
- Add GET /wc-push-notifications/status returning the installed drivers with their connected/enabled/available flags plus the active driver.
- Add DriverAvailabilityService to resolve the Jetpack Sync and remote proxy driver dependencies (Jetpack blog/user connection, sync state, disable filter).
- Move the enablement logic out of PushNotifications::should_be_enabled() into the new service's is_remote_proxy_available().
- Register the status controller before the enablement check so it stays reachable when push notifications are disabled, letting clients fall back to Jetpack Sync.
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushNotificationStatusRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushNotificationStatusRestController.php
new file mode 100644
index 00000000000..a64ff978d90
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushNotificationStatusRestController.php
@@ -0,0 +1,181 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Controllers;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Services\DriverAvailabilityService;
+use Automattic\WooCommerce\Internal\PushNotifications\Traits\AuthorizesPushNotificationRequests;
+use Automattic\WooCommerce\Internal\RestApiControllerBase;
+use WP_Error;
+use WP_Http;
+use WP_REST_Request;
+use WP_REST_Response;
+use WP_REST_Server;
+
+/**
+ * Controller for the REST endpoint that reports which push notification drivers
+ * are installed, connected, and available, and which one is enabled.
+ *
+ * Stays reachable even when push notifications are disabled, so clients (e.g.
+ * the mobile apps) can discover which drivers are installed and configured, and
+ * know to fall back to Jetpack Sync when the remote proxy is not set up on this
+ * store.
+ *
+ * Reports configuration state only. A driver reported as available is installed
+ * and connected; whether a given notification is delivered promptly is a separate
+ * concern, covered by delivery logging rather than by this endpoint.
+ *
+ * @since 11.2.0
+ */
+class PushNotificationStatusRestController extends RestApiControllerBase {
+ use AuthorizesPushNotificationRequests;
+
+ /**
+ * The root namespace for the JSON REST API endpoints.
+ *
+ * @var string
+ */
+ protected string $route_namespace = 'wc-push-notifications';
+
+ /**
+ * The REST base for the endpoints URL.
+ *
+ * @var string
+ */
+ protected string $rest_base = 'status';
+
+ /**
+ * The driver availability service.
+ *
+ * @var DriverAvailabilityService
+ */
+ private DriverAvailabilityService $driver_availability_service;
+
+ /**
+ * Initialize injected dependencies.
+ *
+ * @internal
+ *
+ * @param DriverAvailabilityService $driver_availability_service The driver availability service.
+ *
+ * @since 11.2.0
+ */
+ final public function init( DriverAvailabilityService $driver_availability_service ): void {
+ $this->driver_availability_service = $driver_availability_service;
+ }
+
+ /**
+ * Class identifier used by `woocommerce_rest_api_get_rest_namespaces`.
+ *
+ * Intentionally distinct from the URL `$route_namespace` — the filter keys
+ * one class per value here, so sharing the value with sibling controllers
+ * in the same module would overwrite them.
+ *
+ * @since 11.2.0
+ *
+ * @return string
+ */
+ protected function get_rest_api_namespace(): string {
+ return 'wc-push-notifications-status';
+ }
+
+ /**
+ * Register the REST API endpoints handled by this controller.
+ *
+ * @since 11.2.0
+ *
+ * @return void
+ */
+ public function register_routes(): void {
+ register_rest_route(
+ $this->route_namespace,
+ $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => fn ( WP_REST_Request $request ) => $this->run( $request, 'get_status' ),
+ 'permission_callback' => array( $this, 'authorize_as_from_wpcom_or_logged_in_user' ),
+ ),
+ // A sibling of the endpoint array, not a key inside it. WP_REST_Server
+ // promotes only non-numeric top-level keys into its route options, and
+ // reads the schema exclusively from there, so nesting this one level
+ // deeper drops it silently.
+ 'schema' => array( $this, 'get_schema' ),
+ )
+ );
+ }
+
+ /**
+ * The schema for the status response.
+ *
+ * `installed_drivers` is keyed by driver identifier, so its shape is
+ * described with `additionalProperties` rather than named properties.
+ *
+ * @since 11.2.0
+ *
+ * @return array<string, mixed>
+ */
+ public function get_schema(): array {
+ $driver_flags = array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'connected' => array(
+ 'description' => __( "Whether the driver's underlying connection is present. Null when the check could not be performed.", 'woocommerce' ),
+ 'type' => array( 'boolean', 'null' ),
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ 'enabled' => array(
+ 'description' => __( 'Whether the driver itself is switched on. Null when the check could not be performed.', 'woocommerce' ),
+ 'type' => array( 'boolean', 'null' ),
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ 'available' => array(
+ 'description' => __( 'Whether the driver is definitively both connected and enabled. An undetermined flag makes the driver unavailable.', 'woocommerce' ),
+ 'type' => 'boolean',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+
+ return array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'push_notification_status',
+ 'type' => 'object',
+ 'properties' => array(
+ 'installed_drivers' => array(
+ 'description' => __( 'The installed notification drivers, keyed by driver identifier.', 'woocommerce' ),
+ 'type' => 'object',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ 'additionalProperties' => $driver_flags,
+ ),
+ 'preferred_driver' => array(
+ 'description' => __( 'The driver the site prefers, being the first available one in precedence order, or null when none are available. Not a statement about what is delivering notifications to a given app.', 'woocommerce' ),
+ 'type' => array( 'string', 'null' ),
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Return the installed push notification drivers and the preferred one.
+ *
+ * @since 11.2.0
+ *
+ * @return WP_REST_Response|WP_Error
+ */
+ public function get_status() {
+ return new WP_REST_Response(
+ $this->driver_availability_service->get_status(),
+ WP_Http::OK
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
index 47e6abb839c..3805b570c2f 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
@@ -6,7 +6,6 @@ namespace Automattic\WooCommerce\Internal\PushNotifications\Controllers;
defined( 'ABSPATH' ) || exit;
-use Automattic\Jetpack\Connection\Rest_Authentication;
use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore;
use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
use Automattic\WooCommerce\Internal\PushNotifications\Exceptions\PushTokenNotFoundException;
@@ -310,10 +309,7 @@ class PushTokenRestController extends RestApiControllerBase {
return false;
}
- if (
- class_exists( Rest_Authentication::class )
- && Rest_Authentication::is_signed_with_blog_token()
- ) {
+ if ( $this->is_signed_with_blog_token() ) {
return true;
}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
index 233b9911b1f..b237c919186 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
@@ -6,11 +6,12 @@ namespace Automattic\WooCommerce\Internal\PushNotifications;
defined( 'ABSPATH' ) || exit;
-use Automattic\Jetpack\Connection\Manager as JetpackConnectionManager;
use Automattic\WooCommerce\Internal\PushNotifications\Controllers\NotificationPreferencesRestController;
use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushNotificationRestController;
+use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushNotificationStatusRestController;
use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushTokenRestController;
use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\DriverAvailabilityService;
use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationRetryHandler;
use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
@@ -18,9 +19,6 @@ use Automattic\WooCommerce\Internal\PushNotifications\Triggers\NewOrderNotificat
use Automattic\WooCommerce\Internal\PushNotifications\Triggers\NewReviewNotificationTrigger;
use Automattic\WooCommerce\Internal\PushNotifications\Triggers\StockNotificationRecoveryHandler;
use Automattic\WooCommerce\Internal\PushNotifications\Triggers\StockNotificationTrigger;
-use Automattic\WooCommerce\Proxies\LegacyProxy;
-use WC_Logger;
-use Exception;
/**
* WC Push Notifications
@@ -71,6 +69,11 @@ class PushNotifications {
* @since 10.6.0
*/
public function on_init(): void {
+ // Registered ahead of the enablement check, so the status endpoint stays
+ // available when push notifications are disabled and clients can discover
+ // the state and fall back if needed.
+ wc_get_container()->get( PushNotificationStatusRestController::class )->register();
+
if ( ! $this->should_be_enabled() ) {
return;
}
@@ -123,8 +126,9 @@ class PushNotifications {
/**
* Determines if local push notification functionality should be enabled.
- * Push notifications require Jetpack to be connected. Memoize the value so
- * we only check once per request.
+ * The module runs on the remote proxy driver, so it is enabled exactly when
+ * that driver can send (feature not disabled and Jetpack connected). Memoize
+ * the value so we only check once per request.
*
* @return bool
*
@@ -135,46 +139,7 @@ class PushNotifications {
return $this->enabled;
}
- $feature_disabled = wc_string_to_bool(
- /**
- * Filters whether enhanced push notifications should be disabled.
- *
- * The feature was previously controlled by a now-deprecated feature
- * flag. It is now enabled by default for all compatible users, but this
- * filter lets a store force it off (e.g. to fall back to Jetpack Sync
- * if something isn't working). The feature also requires a Jetpack
- * connection, which is checked separately below.
- *
- * @since 10.9.2
- *
- * @param bool $disabled Whether enhanced push notifications are disabled. Defaults to false.
- */
- apply_filters( 'woocommerce_enhanced_push_notifications_disabled', false )
- );
-
- if ( $feature_disabled ) {
- $this->enabled = false;
- return $this->enabled;
- }
-
- try {
- $proxy = wc_get_container()->get( LegacyProxy::class );
-
- $this->enabled = (
- class_exists( JetpackConnectionManager::class )
- && $proxy->get_instance_of( JetpackConnectionManager::class )->is_connected()
- );
- } catch ( Exception $e ) {
- $logger = wc_get_container()->get( LegacyProxy::class )->call_function( 'wc_get_logger' );
-
- if ( $logger instanceof WC_Logger ) {
- $logger->error(
- 'Error determining if PushNotifications feature should be enabled: ' . $e->getMessage()
- );
- }
-
- $this->enabled = false;
- }
+ $this->enabled = wc_get_container()->get( DriverAvailabilityService::class )->is_remote_proxy_available();
return $this->enabled;
}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/ROADMAP.md b/plugins/woocommerce/src/Internal/PushNotifications/ROADMAP.md
index 8e414e3a36b..e6c74d6dddc 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/ROADMAP.md
+++ b/plugins/woocommerce/src/Internal/PushNotifications/ROADMAP.md
@@ -149,6 +149,21 @@ This library will be in the `src/Internal` directory and is not intended to be u
- Endpoint: `POST /wp-json/wc-push-notifications/send`
- Auth: token generated by the async dispatcher, verified in the async endpoint
+- **Get driver status:**
+ - Endpoint: `GET /wp-json/wc-push-notifications/status`
+ - Auth: Jetpack blog token, or any logged in user. No role is required, because WPCOM reads this endpoint and signs those requests with the blog token, which identifies no user.
+ - Returns the installed notification drivers, each with `connected` (its underlying connection is present), `enabled` (the driver itself isn't disabled), and `available` (`connected && enabled`, i.e. configured and usable, which is not a statement about delivery) flags, plus the `preferred_driver`, the first available driver in precedence order. `preferred_driver` is the site's preference, not a statement about what is delivering notifications to a given app, which also depends on the app version and on whether its token registered successfully. The `jetpack-sync` driver is listed only when the Jetpack Sync package is installed; the `remote-push-notification-proxy` driver ships with core and is always listed. Stays reachable even when push notifications are disabled, so clients can read the driver state and fall back to Jetpack Sync when the proxy isn't available.
+
+ ```json
+ {
+ "installed_drivers": {
+ "jetpack-sync": { "connected": true, "enabled": true, "available": true },
+ "remote-push-notification-proxy": { "connected": true, "enabled": true, "available": true }
+ },
+ "preferred_driver": "remote-push-notification-proxy"
+ }
+ ```
+
## Steps
1. **Add foundations to support the push notification functionality**
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/DriverAvailabilityService.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/DriverAvailabilityService.php
new file mode 100644
index 00000000000..2c469d71b65
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/DriverAvailabilityService.php
@@ -0,0 +1,450 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Services;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\Jetpack\Connection\Manager as JetpackConnectionManager;
+use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Automattic\WooCommerce\Proxies\LegacyProxy;
+use Error;
+use Throwable;
+use WC_Logger_Interface;
+
+/**
+ * Reports which push notification drivers are installed and whether push
+ * notifications can be sent through them.
+ *
+ * A "driver" is a mechanism through which the mobile apps can receive
+ * notifications: the legacy Jetpack Sync flow, or the remote push notification
+ * proxy provided by this module. For each driver this resolves the dependencies
+ * it needs (Jetpack connection, feature flag, Jetpack Sync state) into
+ * `connected`, `enabled`, and `available` flags, and determines which driver the
+ * site prefers.
+ *
+ * @since 11.2.0
+ */
+class DriverAvailabilityService {
+ /**
+ * Driver ID for the legacy Jetpack Sync notification flow.
+ */
+ const DRIVER_JETPACK_SYNC = 'jetpack-sync';
+
+ /**
+ * Driver ID for the remote push notification proxy provided by this module.
+ */
+ const DRIVER_REMOTE_PROXY = 'remote-push-notification-proxy';
+
+ /**
+ * The Jetpack Sync settings class, checked/called defensively as the
+ * package is only present at runtime when Jetpack Sync is installed.
+ */
+ const JETPACK_SYNC_SETTINGS_CLASS = 'Automattic\\Jetpack\\Sync\\Settings';
+
+ /**
+ * The Jetpack plugin's main class.
+ *
+ * The Jetpack Sync driver needs the Jetpack plugin itself, not merely the
+ * `jetpack-sync` package. Other plugins bundle that package without Jetpack,
+ * and on those stores the package is present while nothing can actually
+ * deliver a notification through it.
+ */
+ const JETPACK_PLUGIN_CLASS = 'Jetpack';
+
+ /**
+ * Drivers in precedence order: the first available one is the preferred
+ * driver. The remote proxy is preferred over Jetpack Sync when both are
+ * available.
+ */
+ const DRIVER_PRECEDENCE = array(
+ self::DRIVER_REMOTE_PROXY,
+ self::DRIVER_JETPACK_SYNC,
+ );
+
+ /**
+ * Identifies the blog connection check, which the remote proxy driver uses.
+ */
+ private const CHECK_BLOG_CONNECTION = 'blog';
+
+ /**
+ * Identifies the user connection check, which the Jetpack Sync driver uses.
+ */
+ private const CHECK_USER_CONNECTION = 'user';
+
+ /**
+ * Identifies the Jetpack Sync enabled check.
+ */
+ private const CHECK_SYNC_ENABLED = 'sync_enabled';
+
+ /**
+ * Identifies the remote proxy enabled check, which reads a filter.
+ */
+ private const CHECK_PROXY_ENABLED = 'proxy_enabled';
+
+ /**
+ * Connection checks that threw rather than answering, keyed by check.
+ *
+ * Distinguishes "the merchant has not connected Jetpack", which is
+ * actionable by the merchant, from "the connection state could not be
+ * determined", which is not. Both otherwise present as `connected: false`.
+ *
+ * Recorded per check rather than as one flag because the drivers ask
+ * different questions: the remote proxy needs a blog connection and Jetpack
+ * Sync needs a connected owner. Either can fail while the other answers, so
+ * a failure must only affect the flag it actually relates to, which is
+ * reported as null.
+ *
+ * Reset at the start of {@see self::get_status()}. The container shares one
+ * instance of this service, so state left here would otherwise outlive the
+ * request that produced it and go stale in a long-lived process.
+ *
+ * @var array<string, bool>
+ */
+ private array $failed_checks = array();
+
+ /**
+ * Builds the driver status: the installed drivers with their connected,
+ * enabled, and available flags, and which driver the site prefers.
+ *
+ * Property names are snake_case per REST convention; the driver identifiers
+ * used as keys within `installed_drivers` are kebab-case slugs.
+ *
+ * A flag is null when its check could not be performed, as distinct from false
+ * meaning the check ran and answered no.
+ *
+ * @return array{installed_drivers: array<string, array{connected: bool|null, enabled: bool|null, available: bool}>, preferred_driver: string|null}
+ *
+ * @since 11.2.0
+ */
+ public function get_status(): array {
+ $this->failed_checks = array();
+
+ $installed_drivers = array();
+
+ if ( $this->is_jetpack_sync_installed() ) {
+ $installed_drivers[ self::DRIVER_JETPACK_SYNC ] = $this->driver_status(
+ $this->resolve( $this->has_user_connection(), self::CHECK_USER_CONNECTION ),
+ $this->resolve( $this->is_jetpack_sync_enabled(), self::CHECK_SYNC_ENABLED )
+ );
+ }
+
+ // The remote proxy driver ships with this module, so it is always present.
+ $installed_drivers[ self::DRIVER_REMOTE_PROXY ] = $this->driver_status(
+ $this->resolve( $this->has_blog_connection(), self::CHECK_BLOG_CONNECTION ),
+ $this->resolve( $this->is_remote_proxy_enabled(), self::CHECK_PROXY_ENABLED )
+ );
+
+ return array(
+ 'installed_drivers' => $installed_drivers,
+ 'preferred_driver' => $this->get_preferred_driver( $installed_drivers ),
+ );
+ }
+
+ /**
+ * Builds a single driver's status flags. A driver is available (usable now)
+ * only when it is both connected and enabled.
+ *
+ * A driver is only available when both flags are definitively true. An
+ * undetermined flag is not usable, so null makes the driver unavailable in the
+ * same way false does.
+ *
+ * @param bool|null $connected Whether the driver's underlying connection is present, or null if undetermined.
+ * @param bool|null $enabled Whether the driver itself is switched on, or null if undetermined.
+ * @return array{connected: bool|null, enabled: bool|null, available: bool}
+ */
+ private function driver_status( ?bool $connected, ?bool $enabled ): array {
+ return array(
+ 'connected' => $connected,
+ 'enabled' => $enabled,
+ 'available' => true === $connected && true === $enabled,
+ );
+ }
+
+ /**
+ * Reports a check's result as null when the check could not be performed.
+ *
+ * The check methods return false on failure so that the gating callers, such
+ * as {@see self::is_remote_proxy_available()}, stay boolean and fail closed.
+ * The status response wants the distinction, so it is recovered here.
+ *
+ * @param bool $result The value the check returned.
+ * @param string $check_key The check to look up in {@see self::$failed_checks}.
+ * @return bool|null
+ */
+ private function resolve( bool $result, string $check_key ): ?bool {
+ return empty( $this->failed_checks[ $check_key ] ) ? $result : null;
+ }
+
+ /**
+ * Determines whether push notifications can currently be sent through the
+ * remote proxy driver: the feature must be enabled and Jetpack connected.
+ * This is the proxy driver's availability, which also gates the module.
+ *
+ * @return bool
+ *
+ * @since 11.2.0
+ */
+ public function is_remote_proxy_available(): bool {
+ return $this->is_remote_proxy_enabled() && $this->has_blog_connection();
+ }
+
+ /**
+ * Determines which driver the site prefers: the first available driver in
+ * {@see self::DRIVER_PRECEDENCE} order (remote proxy before Jetpack Sync).
+ *
+ * This is the site's preference, not a statement about what is delivering
+ * notifications to a given app. That depends on the app version and on
+ * whether its token registered successfully, neither of which is known here.
+ *
+ * @param array<string, array{connected: bool|null, enabled: bool|null, available: bool}> $installed_drivers The installed drivers.
+ * @return string|null The preferred driver id, or null when none are available.
+ */
+ private function get_preferred_driver( array $installed_drivers ): ?string {
+ foreach ( self::DRIVER_PRECEDENCE as $driver ) {
+ if ( ! empty( $installed_drivers[ $driver ]['available'] ) ) {
+ return $driver;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Determines whether the remote proxy driver is enabled (i.e. the feature is
+ * not disabled via the filter), a dependency of that driver.
+ *
+ * @return bool
+ *
+ * @since 11.2.0
+ */
+ protected function is_remote_proxy_enabled(): bool {
+ try {
+ return $this->read_disabled_filter();
+ } catch ( Throwable $e ) {
+ $this->failed_checks[ self::CHECK_PROXY_ENABLED ] = true;
+
+ $this->log_throwable( 'Error reading the push notifications disabled filter', $e );
+
+ // Fail closed: an unreadable filter must not enable the feature.
+ return false;
+ }
+ }
+
+ /**
+ * Reads the disable filter.
+ *
+ * Split out so the filter call, which runs arbitrary third-party callbacks, sits
+ * behind the same guard as every other foreign call in this class. It is reached
+ * on every request through {@see self::is_remote_proxy_available()}, so an
+ * escaping throwable here is a fatal on every page load rather than one bad
+ * endpoint response.
+ *
+ * @return bool
+ */
+ private function read_disabled_filter(): bool {
+ return ! wc_string_to_bool(
+ /**
+ * Filters whether enhanced push notifications should be disabled.
+ *
+ * The feature was previously controlled by a now-deprecated feature
+ * flag. It is now enabled by default for all compatible users, but this
+ * filter lets a store force it off (e.g. to fall back to Jetpack Sync
+ * if something isn't working). The feature also requires a Jetpack
+ * connection, which is checked separately.
+ *
+ * @since 10.9.2
+ *
+ * @param bool $disabled Whether enhanced push notifications are disabled. Defaults to false.
+ */
+ apply_filters( 'woocommerce_enhanced_push_notifications_disabled', false )
+ );
+ }
+
+ /**
+ * Determines whether the Jetpack Sync driver is installed.
+ *
+ * Requires the Jetpack plugin as well as the sync package: the package alone
+ * can be supplied by an unrelated plugin, in which case there is no Jetpack
+ * Sync flow for the apps to fall back to.
+ *
+ * @return bool
+ *
+ * @since 11.2.0
+ */
+ protected function is_jetpack_sync_installed(): bool {
+ return $this->class_is_present( self::JETPACK_PLUGIN_CLASS )
+ && $this->class_is_present( self::JETPACK_SYNC_SETTINGS_CLASS );
+ }
+
+ /**
+ * Whether a class is loadable.
+ *
+ * A seam so the driver-detection rule can be tested across every combination of
+ * plugin and package presence. Neither class exists in the test environment, so
+ * without this a test can only assert the implementation against itself.
+ *
+ * @param string $class_name The fully qualified class name.
+ * @return bool
+ *
+ * @since 11.2.0
+ */
+ protected function class_is_present( string $class_name ): bool {
+ return class_exists( $class_name );
+ }
+
+ /**
+ * Determines whether Jetpack Sync is enabled. Treated as not enabled when
+ * the package isn't installed, or when the package throws.
+ *
+ * The call reaches third-party package code, so it is guarded: an `Error`
+ * from an incompatible Jetpack would otherwise escape
+ * {@see \Automattic\WooCommerce\Internal\RestApiControllerBase::run()},
+ * which catches `Exception` only, and turn an endpoint whose purpose is to
+ * stay reachable into a 500.
+ *
+ * @return bool
+ *
+ * @since 11.2.0
+ */
+ protected function is_jetpack_sync_enabled(): bool {
+ $is_sync_enabled = array( self::JETPACK_SYNC_SETTINGS_CLASS, 'is_sync_enabled' );
+
+ if ( ! is_callable( $is_sync_enabled ) ) {
+ // class_exists() already passed, so the class is present but the method
+ // is not: an incompatible Jetpack Sync rather than a merchant choice.
+ // Recorded so this reports as undetermined rather than a definitive no.
+ $this->failed_checks[ self::CHECK_SYNC_ENABLED ] = true;
+
+ return false;
+ }
+
+ try {
+ return (bool) call_user_func( $is_sync_enabled );
+ } catch ( Throwable $e ) {
+ $this->failed_checks[ self::CHECK_SYNC_ENABLED ] = true;
+
+ $this->log_throwable( 'Error determining Jetpack Sync state for push notifications', $e );
+
+ return false;
+ }
+ }
+
+ /**
+ * Determines whether the site has an active Jetpack blog connection, which
+ * the remote proxy driver requires.
+ *
+ * @return bool
+ *
+ * @since 11.2.0
+ */
+ protected function has_blog_connection(): bool {
+ return $this->query_jetpack_connection(
+ self::CHECK_BLOG_CONNECTION,
+ static fn ( JetpackConnectionManager $manager ): bool => $manager->is_connected()
+ );
+ }
+
+ /**
+ * Determines whether the site has a connected Jetpack user (owner), which
+ * the Jetpack Sync driver requires.
+ *
+ * @return bool
+ *
+ * @since 11.2.0
+ */
+ protected function has_user_connection(): bool {
+ return $this->query_jetpack_connection(
+ self::CHECK_USER_CONNECTION,
+ static fn ( JetpackConnectionManager $manager ): bool => $manager->has_connected_owner()
+ );
+ }
+
+ /**
+ * Runs the given check against the Jetpack connection manager, returning
+ * false (and logging) if the connection state can't be determined.
+ *
+ * A failure is recorded against $check_key on {@see self::$failed_checks} as
+ * well as returning false, so the driver that depends on this check can tell
+ * an unconnected store from one whose connection state could not be read.
+ *
+ * Deliberately not memoized. The container hands out one shared instance of
+ * this service, so a cached result would outlive the caller that asked for
+ * it and go stale in any long-lived process (WP-CLI, cron), where the
+ * Jetpack connection can change under it.
+ *
+ * @param string $check_key Identifies the check, so a failure is recorded against the driver that depends on it.
+ * @param callable(JetpackConnectionManager): bool $check The connection check to run.
+ * @return bool
+ */
+ private function query_jetpack_connection( string $check_key, callable $check ): bool {
+ // Resolved before the try so the catch below cannot re-run the call that
+ // threw, which would escape uncaught.
+ $proxy = wc_get_container()->get( LegacyProxy::class );
+
+ try {
+ if ( ! class_exists( JetpackConnectionManager::class ) ) {
+ return false;
+ }
+
+ return $check( $proxy->get_instance_of( JetpackConnectionManager::class ) );
+ } catch ( Throwable $e ) {
+ $this->failed_checks[ $check_key ] = true;
+
+ $this->log_throwable( 'Error determining Jetpack connection state for push notifications', $e, $proxy );
+
+ return false;
+ }
+ }
+
+ /**
+ * Logs a swallowed throwable, distinguishing an `Error` from an `Exception`.
+ *
+ * An `Exception` here usually means Jetpack answered unhappily; an `Error`
+ * means the call was incompatible, which is a WooCommerce problem rather
+ * than a merchant one. Both are swallowed, so the log is the only place the
+ * difference survives.
+ *
+ * @param string $message The message describing what was being determined.
+ * @param Throwable $e The caught throwable.
+ * @param LegacyProxy|null $proxy An already-resolved proxy, when the caller has one.
+ * @return void
+ */
+ private function log_throwable( string $message, Throwable $e, ?LegacyProxy $proxy = null ): void {
+ try {
+ $this->write_log( $message, $e, $proxy );
+ } catch ( Throwable $ignored ) {
+ // Logging is best effort. Failing to record why something went wrong must
+ // never be worse than the original failure, which callers have handled.
+ return;
+ }
+ }
+
+ /**
+ * Writes the log entry for a swallowed throwable.
+ *
+ * @param string $message The message describing what was being determined.
+ * @param Throwable $e The caught throwable.
+ * @param LegacyProxy|null $proxy An already-resolved proxy, when the caller has one.
+ * @return void
+ */
+ private function write_log( string $message, Throwable $e, ?LegacyProxy $proxy = null ): void {
+ $proxy = $proxy ?? wc_get_container()->get( LegacyProxy::class );
+ $logger = $proxy->call_function( 'wc_get_logger' );
+
+ if ( ! $logger instanceof WC_Logger_Interface ) {
+ return;
+ }
+
+ $logger->error(
+ sprintf(
+ '%s (%s): %s',
+ $message,
+ $e instanceof Error ? 'error' : 'exception',
+ $e->getMessage()
+ ),
+ array( 'source' => PushNotifications::FEATURE_NAME )
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Traits/AuthorizesPushNotificationRequests.php b/plugins/woocommerce/src/Internal/PushNotifications/Traits/AuthorizesPushNotificationRequests.php
index e83711a7b1a..cc18e69af35 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Traits/AuthorizesPushNotificationRequests.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Traits/AuthorizesPushNotificationRequests.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Internal\PushNotifications\Traits;
defined( 'ABSPATH' ) || exit;
+use Automattic\Jetpack\Connection\Rest_Authentication;
use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
use WP_Error;
use WP_REST_Request;
@@ -36,16 +37,53 @@ trait AuthorizesPushNotificationRequests {
);
}
- if ( ! wc_get_container()->get( PushNotifications::class )->should_be_enabled() ) {
- return false;
- }
-
$has_valid_role = array_reduce(
PushNotifications::ROLES_WITH_PUSH_NOTIFICATIONS_ENABLED,
fn ( $carry, $role ) => $this->check_permission( $request, $role ) === true ? true : $carry,
false
);
- return $has_valid_role ? true : false;
+ if ( ! $has_valid_role ) {
+ return false;
+ }
+
+ return wc_get_container()->get( PushNotifications::class )->should_be_enabled();
+ }
+
+ /**
+ * Checks the caller is either WPCOM or a logged in user, with no role
+ * requirement and without requiring the module to be enabled.
+ *
+ * WPCOM reads this endpoint to decide how to reach a store, and signs those
+ * requests with the Jetpack blog token, which identifies no user. Requiring a
+ * role would reject them. The response describes driver configuration only,
+ * so it carries nothing specific to the calling user or to the merchant.
+ *
+ * @return bool|WP_Error
+ *
+ * @since 11.2.0
+ */
+ public function authorize_as_from_wpcom_or_logged_in_user() {
+ if ( $this->is_signed_with_blog_token() || get_current_user_id() ) {
+ return true;
+ }
+
+ return new WP_Error(
+ 'woocommerce_rest_cannot_view',
+ __( 'Sorry, you are not allowed to do that.', 'woocommerce' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ /**
+ * Determines whether the request is signed with the Jetpack blog token, which
+ * only WPCOM holds.
+ *
+ * @return bool
+ *
+ * @since 11.2.0
+ */
+ protected function is_signed_with_blog_token(): bool {
+ return class_exists( Rest_Authentication::class ) && Rest_Authentication::is_signed_with_blog_token();
}
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushNotificationStatusRestControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushNotificationStatusRestControllerTest.php
new file mode 100644
index 00000000000..9cb65a85c4b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushNotificationStatusRestControllerTest.php
@@ -0,0 +1,272 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Controllers;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushNotificationStatusRestController;
+use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushTokenRestController;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\DriverAvailabilityService;
+use Automattic\WooCommerce\Tests\Internal\PushNotifications\Helpers\PushNotificationsTestTrait;
+use WC_Unit_Test_Case;
+use WP_Http;
+use WP_REST_Request;
+use WP_REST_Server;
+use WP_UnitTest_Factory;
+
+/**
+ * Tests for the PushNotificationStatusRestController class.
+ *
+ * @package WooCommerce\Tests\PushNotifications
+ */
+class PushNotificationStatusRestControllerTest extends WC_Unit_Test_Case {
+ use PushNotificationsTestTrait;
+
+ /**
+ * REST server used to dispatch status requests.
+ *
+ * @var WP_REST_Server
+ */
+ private $server;
+
+ /**
+ * Shop manager fixture user ID.
+ *
+ * @var int
+ */
+ private static $fixture_user_id;
+
+ /**
+ * Subscriber fixture user ID.
+ *
+ * @var int
+ */
+ private static $fixture_subscriber_id;
+
+ /**
+ * Shop manager user ID for testing.
+ *
+ * @var int
+ */
+ private $user_id;
+
+ /**
+ * Subscriber user ID for testing.
+ *
+ * @var int
+ */
+ private $subscriber_id;
+
+ /**
+ * Create immutable users shared by the test class.
+ *
+ * @param WP_UnitTest_Factory $factory WordPress unit test factory.
+ */
+ public static function wpSetUpBeforeClass( $factory ): void {
+ self::$fixture_user_id = $factory->user->create( array( 'role' => 'shop_manager' ) );
+ self::$fixture_subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) );
+ }
+
+ /**
+ * Set up test.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->reset_push_notifications_cache();
+
+ $this->user_id = self::$fixture_user_id;
+ $this->subscriber_id = self::$fixture_subscriber_id;
+ }
+
+ /**
+ * Register the controller's routes using the container so init() auto-wires
+ * the push-notifications dependencies.
+ */
+ private function register_routes(): void {
+ $controller = wc_get_container()->get( PushNotificationStatusRestController::class );
+ $this->server = $this->create_rest_server_with_routes(
+ array( array( $controller, 'register_routes' ) ),
+ true
+ );
+ }
+
+ /**
+ * Tear down test.
+ */
+ public function tearDown(): void {
+ wp_set_current_user( 0 );
+
+ $this->reset_container_replacements();
+ wc_get_container()->reset_all_resolved();
+ $this->clear_rest_server();
+ unset( $this->server );
+
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox GET should reject unauthenticated requests.
+ */
+ public function test_get_status_requires_authentication() {
+ $this->mock_jetpack_connection_manager_is_connected( true );
+ $this->register_routes();
+
+ $request = new WP_REST_Request( 'GET', '/wc-push-notifications/status' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( rest_authorization_required_code(), $response->get_status() );
+ }
+
+ /**
+ * @testdox GET should accept a logged in user who holds no push-notifications role.
+ */
+ public function test_get_status_accepts_users_without_role() {
+ wp_set_current_user( $this->subscriber_id );
+ $this->mock_jetpack_connection_manager_is_connected( true );
+ $this->register_routes();
+
+ $request = new WP_REST_Request( 'GET', '/wc-push-notifications/status' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( WP_Http::OK, $response->get_status() );
+ }
+
+ /**
+ * WPCOM signs its requests with the Jetpack blog token, which identifies no
+ * user, so the endpoint has to authorize them without one.
+ *
+ * @testdox GET should accept a blog token signed request that carries no user.
+ */
+ public function test_get_status_accepts_a_blog_token_signed_request_without_a_user() {
+ wp_set_current_user( 0 );
+
+ $controller = new class() extends PushNotificationStatusRestController {
+ /**
+ * Stands in for a request WPCOM signed with the Jetpack blog token.
+ *
+ * @return bool
+ */
+ protected function is_signed_with_blog_token(): bool {
+ return true;
+ }
+ };
+
+ $this->assertTrue( $controller->authorize_as_from_wpcom_or_logged_in_user() );
+ }
+
+ /**
+ * @testdox GET should report the remote proxy driver as connected, available, and enabled when Jetpack is connected.
+ */
+ public function test_get_status_reports_remote_proxy_enabled_when_connected() {
+ wp_set_current_user( $this->user_id );
+ $this->mock_jetpack_connection_manager_is_connected( true );
+ $this->register_routes();
+
+ $request = new WP_REST_Request( 'GET', '/wc-push-notifications/status' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( WP_Http::OK, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertArrayHasKey( 'installed_drivers', $data );
+
+ $proxy = $data['installed_drivers'][ DriverAvailabilityService::DRIVER_REMOTE_PROXY ];
+ $this->assertTrue( $proxy['connected'] );
+ $this->assertTrue( $proxy['enabled'] );
+ $this->assertTrue( $proxy['available'] );
+ $this->assertSame( DriverAvailabilityService::DRIVER_REMOTE_PROXY, $data['preferred_driver'] );
+ }
+
+ /**
+ * @testdox GET should stay reachable and report the remote proxy unavailable when disabled via the filter.
+ */
+ public function test_get_status_reports_remote_proxy_unavailable_when_feature_filtered_off() {
+ wp_set_current_user( $this->user_id );
+ $this->mock_jetpack_connection_manager_is_connected( true );
+ add_filter( 'woocommerce_enhanced_push_notifications_disabled', '__return_true' );
+ $this->register_routes();
+
+ $request = new WP_REST_Request( 'GET', '/wc-push-notifications/status' );
+ $response = $this->server->dispatch( $request );
+
+ remove_filter( 'woocommerce_enhanced_push_notifications_disabled', '__return_true' );
+
+ $this->assertSame( WP_Http::OK, $response->get_status() );
+
+ $data = $response->get_data();
+ $proxy = $data['installed_drivers'][ DriverAvailabilityService::DRIVER_REMOTE_PROXY ];
+ $this->assertTrue( $proxy['connected'] );
+ $this->assertFalse( $proxy['enabled'] );
+ $this->assertFalse( $proxy['available'] );
+ $this->assertNull( $data['preferred_driver'] );
+ }
+
+ /**
+ * @testdox GET should stay reachable and report the remote proxy not connected when Jetpack is disconnected.
+ */
+ public function test_get_status_reports_remote_proxy_disconnected_when_jetpack_not_connected() {
+ wp_set_current_user( $this->user_id );
+ $this->mock_jetpack_connection_manager_is_connected( false );
+ $this->register_routes();
+
+ $request = new WP_REST_Request( 'GET', '/wc-push-notifications/status' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( WP_Http::OK, $response->get_status() );
+
+ $data = $response->get_data();
+ $proxy = $data['installed_drivers'][ DriverAvailabilityService::DRIVER_REMOTE_PROXY ];
+ $this->assertFalse( $proxy['connected'] );
+ $this->assertTrue( $proxy['enabled'] );
+ $this->assertFalse( $proxy['available'] );
+ $this->assertNull( $data['preferred_driver'] );
+ }
+
+ /**
+ * @testdox Should not collide with sibling controllers on the WC REST namespaces filter.
+ *
+ * Sibling controllers share the URL route namespace `wc-push-notifications`, but they must use
+ * distinct class identifiers via `get_rest_api_namespace()` so that neither overwrites the
+ * other in the `woocommerce_rest_api_get_rest_namespaces` filter output.
+ */
+ public function test_does_not_overwrite_sibling_controller_in_rest_namespaces_filter() {
+ $status_controller = new PushNotificationStatusRestController();
+ $push_token_controller = new PushTokenRestController();
+
+ $status_controller->register();
+ $push_token_controller->register();
+
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Triggering an existing filter from RestApiControllerBase, not defining one.
+ $namespaces = apply_filters( 'woocommerce_rest_api_get_rest_namespaces', array( 'wc/v3' => array() ) );
+
+ $this->assertArrayHasKey( 'wc/v3', $namespaces );
+
+ $registered_classes = array_values( $namespaces['wc/v3'] );
+
+ $this->assertContains( PushNotificationStatusRestController::class, $registered_classes );
+ $this->assertContains( PushTokenRestController::class, $registered_classes );
+ }
+ /**
+ * The schema has to be a sibling of the endpoint array rather than a key within
+ * it: WP_REST_Server promotes only non-numeric top-level keys into its route
+ * options and reads the schema exclusively from there, so misplacing it drops
+ * the schema silently with every other test still passing.
+ *
+ * @testdox The route exposes its schema, so OPTIONS and the help context return it.
+ */
+ public function test_route_exposes_its_schema() {
+ $server = rest_get_server();
+ $data = $server->get_data_for_route( '/wc-push-notifications/status', $server->get_routes()['/wc-push-notifications/status'], 'help' );
+
+ $this->assertArrayHasKey( 'schema', $data, 'The schema was not promoted into the route options.' );
+ $this->assertSame( 'push_notification_status', $data['schema']['title'] );
+ $this->assertArrayHasKey( 'installed_drivers', $data['schema']['properties'] );
+ $this->assertArrayHasKey( 'preferred_driver', $data['schema']['properties'] );
+
+ $driver_flags = $data['schema']['properties']['installed_drivers']['additionalProperties']['properties'];
+ $this->assertSame( array( 'boolean', 'null' ), $driver_flags['connected']['type'], 'connected must allow null for an undetermined check.' );
+ $this->assertSame( array( 'boolean', 'null' ), $driver_flags['enabled']['type'], 'enabled must allow null for an undetermined check.' );
+ $this->assertSame( 'boolean', $driver_flags['available']['type'], 'available is strictly boolean.' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/PushNotificationsTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/PushNotificationsTest.php
index c07b5c91360..61e3d11d00a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/PushNotificationsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/PushNotificationsTest.php
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace Automattic\WooCommerce\Tests\Internal\PushNotifications;
use Automattic\Jetpack\Connection\Manager as JetpackConnectionManager;
+use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushNotificationStatusRestController;
+use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushTokenRestController;
use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
use Automattic\WooCommerce\Proxies\LegacyProxy;
@@ -104,8 +106,8 @@ class PushNotificationsTest extends WC_Unit_Test_Case {
$logger_mock->expects( $this->once() )
->method( 'error' )
->with(
- $this->stringContains( 'Error determining if PushNotifications feature should be enabled' ),
- $this->anything()
+ $this->stringContains( 'Error determining Jetpack connection state for push notifications' ),
+ array( 'source' => PushNotifications::FEATURE_NAME )
);
$this->register_legacy_proxy_function_mocks( array( 'wc_get_logger' => fn () => $logger_mock ) );
@@ -223,6 +225,35 @@ class PushNotificationsTest extends WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox Tests that on_init registers the status controller but no other controllers when disabled.
+ */
+ public function test_on_init_registers_only_status_controller_when_disabled() {
+ $this->set_up_jetpack_connection_manager_mock( array( 'is_connected' ) );
+
+ $this->jetpack_connection_manager_mock
+ ->expects( $this->once() )
+ ->method( 'is_connected' )
+ ->willReturn( false );
+
+ $push_notifications = new PushNotifications();
+ $push_notifications->on_init();
+
+ // The status controller registers on rest_api_init rather than during
+ // on_init, so a front-end request does not resolve it for nothing. WooCommerce
+ // applies the namespaces filter on rest_api_init at priority 10, and the
+ // controller registers at priority 0, so it is always in place in time.
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Triggering a WordPress core hook, not defining one.
+ do_action( 'rest_api_init' );
+
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Triggering an existing filter from RestApiControllerBase, not defining one.
+ $namespaces = apply_filters( 'woocommerce_rest_api_get_rest_namespaces', array( 'wc/v3' => array() ) );
+ $registered = array_values( $namespaces['wc/v3'] );
+
+ $this->assertContains( PushNotificationStatusRestController::class, $registered );
+ $this->assertNotContains( PushTokenRestController::class, $registered );
+ }
+
/**
* Sets up the Jetpack connection manager mocking.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/DriverAvailabilityServiceTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/DriverAvailabilityServiceTest.php
new file mode 100644
index 00000000000..8080b8f960d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/DriverAvailabilityServiceTest.php
@@ -0,0 +1,494 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Services;
+
+use Automattic\Jetpack\Connection\Manager as JetpackConnectionManager;
+use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\DriverAvailabilityService;
+use Automattic\WooCommerce\Proxies\LegacyProxy;
+use Error;
+use Exception;
+use PHPUnit\Framework\MockObject\MockObject;
+use ReflectionMethod;
+use WC_Logger;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the DriverAvailabilityService class.
+ *
+ * @package WooCommerce\Tests\PushNotifications
+ */
+class DriverAvailabilityServiceTest extends WC_Unit_Test_Case {
+
+ /**
+ * Tear down the test case.
+ */
+ public function tearDown(): void {
+ $this->reset_container_replacements();
+ wc_get_container()->reset_all_resolved();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Builds a DriverAvailabilityService with its dependency-check seams stubbed to
+ * the supplied values, so get_status() logic can be exercised in isolation.
+ *
+ * @param array<string, bool> $state The driver state to simulate. Keys: sync_installed, sync_enabled, blog_connected, user_connected, proxy_enabled.
+ * @return DriverAvailabilityService
+ */
+ private function make_service( array $state ): DriverAvailabilityService {
+ $defaults = array(
+ 'sync_installed' => false,
+ 'sync_enabled' => true,
+ 'blog_connected' => true,
+ 'user_connected' => true,
+ 'proxy_enabled' => true,
+ );
+ $state = array_merge( $defaults, $state );
+
+ /**
+ * The service under test with its dependency-check seams stubbed.
+ *
+ * @var DriverAvailabilityService&MockObject $service
+ */
+ $service = $this->getMockBuilder( DriverAvailabilityService::class )
+ ->onlyMethods(
+ array(
+ 'is_remote_proxy_enabled',
+ 'is_jetpack_sync_installed',
+ 'is_jetpack_sync_enabled',
+ 'has_blog_connection',
+ 'has_user_connection',
+ )
+ )
+ ->getMock();
+
+ $service->method( 'is_remote_proxy_enabled' )->willReturn( $state['proxy_enabled'] );
+ $service->method( 'is_jetpack_sync_installed' )->willReturn( $state['sync_installed'] );
+ $service->method( 'is_jetpack_sync_enabled' )->willReturn( $state['sync_enabled'] );
+ $service->method( 'has_blog_connection' )->willReturn( $state['blog_connected'] );
+ $service->method( 'has_user_connection' )->willReturn( $state['user_connected'] );
+
+ return $service;
+ }
+
+ /**
+ * Mocks the Jetpack connection manager so both the blog and user connection
+ * checks return the supplied value. Both are stubbed because get_status()
+ * evaluates every driver, so leaving one unstubbed would run real Jetpack code.
+ *
+ * @param bool $connected The connection state the manager should report.
+ */
+ private function mock_jetpack_connection( bool $connected ): void {
+ $manager = $this->getMockBuilder( JetpackConnectionManager::class )
+ ->disableOriginalConstructor()
+ ->onlyMethods( array( 'is_connected', 'has_connected_owner' ) )
+ ->getMock();
+ $manager->method( 'is_connected' )->willReturn( $connected );
+ $manager->method( 'has_connected_owner' )->willReturn( $connected );
+
+ wc_get_container()->get( LegacyProxy::class )->register_class_mocks(
+ array( JetpackConnectionManager::class => $manager )
+ );
+ }
+
+ /**
+ * @testdox The Jetpack Sync driver is omitted when the package is not installed.
+ */
+ public function test_jetpack_sync_driver_omitted_when_not_installed() {
+ $status = $this->make_service( array( 'sync_installed' => false ) )->get_status();
+
+ $this->assertArrayNotHasKey( DriverAvailabilityService::DRIVER_JETPACK_SYNC, $status['installed_drivers'] );
+ $this->assertArrayHasKey( DriverAvailabilityService::DRIVER_REMOTE_PROXY, $status['installed_drivers'] );
+ }
+
+ /**
+ * @testdox The Jetpack Sync driver is connected and available when installed, user-connected, and not disabled.
+ */
+ public function test_jetpack_sync_driver_connected_and_available() {
+ $status = $this->make_service(
+ array(
+ 'sync_installed' => true,
+ 'sync_enabled' => true,
+ 'user_connected' => true,
+ )
+ )->get_status();
+
+ $driver = $status['installed_drivers'][ DriverAvailabilityService::DRIVER_JETPACK_SYNC ];
+ $this->assertTrue( $driver['connected'] );
+ $this->assertTrue( $driver['enabled'] );
+ $this->assertTrue( $driver['available'] );
+ }
+
+ /**
+ * @testdox The Jetpack Sync driver is disabled and unavailable when sync is disabled, even if connected.
+ */
+ public function test_jetpack_sync_driver_unavailable_when_disabled() {
+ $status = $this->make_service(
+ array(
+ 'sync_installed' => true,
+ 'sync_enabled' => false,
+ 'user_connected' => true,
+ )
+ )->get_status();
+
+ $driver = $status['installed_drivers'][ DriverAvailabilityService::DRIVER_JETPACK_SYNC ];
+ $this->assertTrue( $driver['connected'] );
+ $this->assertFalse( $driver['enabled'] );
+ $this->assertFalse( $driver['available'] );
+ }
+
+ /**
+ * @testdox The Jetpack Sync driver is enabled but not connected or available without a user connection.
+ */
+ public function test_jetpack_sync_driver_unavailable_without_user_connection() {
+ $status = $this->make_service(
+ array(
+ 'sync_installed' => true,
+ 'sync_enabled' => true,
+ 'user_connected' => false,
+ )
+ )->get_status();
+
+ $driver = $status['installed_drivers'][ DriverAvailabilityService::DRIVER_JETPACK_SYNC ];
+ $this->assertFalse( $driver['connected'] );
+ $this->assertTrue( $driver['enabled'] );
+ $this->assertFalse( $driver['available'] );
+ }
+
+ /**
+ * @testdox The remote proxy driver reflects the blog connection and feature-enabled flags.
+ *
+ * @testWith [true, true, true, true]
+ * [true, false, true, false]
+ * [false, true, false, false]
+ *
+ * @param bool $blog_connected Whether the Jetpack blog connection is present.
+ * @param bool $proxy_enabled Whether the remote proxy is enabled.
+ * @param bool $expected_connected Expected connected flag.
+ * @param bool $expected_available Expected available flag.
+ */
+ public function test_remote_proxy_driver_reflects_connection_and_feature( bool $blog_connected, bool $proxy_enabled, bool $expected_connected, bool $expected_available ) {
+ $status = $this->make_service(
+ array(
+ 'blog_connected' => $blog_connected,
+ 'proxy_enabled' => $proxy_enabled,
+ )
+ )->get_status();
+
+ $driver = $status['installed_drivers'][ DriverAvailabilityService::DRIVER_REMOTE_PROXY ];
+ $this->assertSame( $expected_connected, $driver['connected'] );
+ $this->assertSame( $proxy_enabled, $driver['enabled'] );
+ $this->assertSame( $expected_available, $driver['available'] );
+ }
+
+ /**
+ * @testdox The remote proxy is the preferred driver when available, taking precedence over Jetpack Sync.
+ */
+ public function test_preferred_driver_prefers_remote_proxy() {
+ $status = $this->make_service(
+ array(
+ 'sync_installed' => true,
+ 'sync_enabled' => true,
+ 'user_connected' => true,
+ 'blog_connected' => true,
+ 'proxy_enabled' => true,
+ )
+ )->get_status();
+
+ $this->assertSame( DriverAvailabilityService::DRIVER_REMOTE_PROXY, $status['preferred_driver'] );
+ }
+
+ /**
+ * @testdox Jetpack Sync is the preferred driver when the remote proxy is unavailable.
+ */
+ public function test_preferred_driver_falls_back_to_jetpack_sync() {
+ $status = $this->make_service(
+ array(
+ 'sync_installed' => true,
+ 'sync_enabled' => true,
+ 'user_connected' => true,
+ 'blog_connected' => true,
+ 'proxy_enabled' => false,
+ )
+ )->get_status();
+
+ $this->assertSame( DriverAvailabilityService::DRIVER_JETPACK_SYNC, $status['preferred_driver'] );
+ }
+
+ /**
+ * @testdox The preferred driver is null when no driver is available.
+ */
+ public function test_preferred_driver_null_when_none_available() {
+ $status = $this->make_service(
+ array(
+ 'sync_installed' => true,
+ 'sync_enabled' => false,
+ 'user_connected' => true,
+ 'blog_connected' => false,
+ 'proxy_enabled' => true,
+ )
+ )->get_status();
+
+ $this->assertNull( $status['preferred_driver'] );
+ }
+
+ /**
+ * @testdox is_remote_proxy_available() reflects the real Jetpack blog connection.
+ *
+ * @testWith [true]
+ * [false]
+ *
+ * @param bool $is_connected Whether Jetpack reports a blog connection.
+ */
+ public function test_is_remote_proxy_available_reflects_real_blog_connection( bool $is_connected ) {
+ $this->mock_jetpack_connection( $is_connected );
+
+ $this->assertSame( $is_connected, ( new DriverAvailabilityService() )->is_remote_proxy_available() );
+ }
+
+ /**
+ * @testdox The Jetpack Sync driver's connected flag reflects the real Jetpack user connection.
+ *
+ * @testWith [true]
+ * [false]
+ *
+ * @param bool $has_owner Whether Jetpack reports a connected owner.
+ */
+ public function test_jetpack_sync_connected_reflects_real_user_connection( bool $has_owner ) {
+ $this->mock_jetpack_connection( $has_owner );
+
+ /**
+ * A service with only the Jetpack Sync package-detection seam stubbed, so
+ * the real user-connection check runs.
+ *
+ * @var DriverAvailabilityService&MockObject $service
+ */
+ $service = $this->getMockBuilder( DriverAvailabilityService::class )
+ ->onlyMethods( array( 'is_jetpack_sync_installed' ) )
+ ->getMock();
+ $service->method( 'is_jetpack_sync_installed' )->willReturn( true );
+
+ $status = $service->get_status();
+
+ $this->assertSame(
+ $has_owner,
+ $status['installed_drivers'][ DriverAvailabilityService::DRIVER_JETPACK_SYNC ]['connected']
+ );
+ }
+
+ /**
+ * @testdox A Jetpack connection failure is treated as disconnected and logged.
+ */
+ public function test_connection_failure_returns_disconnected_and_logs() {
+ $logger_mock = $this->createMock( WC_Logger::class );
+ $logger_mock->expects( $this->once() )
+ ->method( 'error' )
+ ->with(
+ $this->stringContains( '(exception)' ),
+ array( 'source' => PushNotifications::FEATURE_NAME )
+ );
+
+ $this->register_legacy_proxy_function_mocks( array( 'wc_get_logger' => fn () => $logger_mock ) );
+
+ $manager = $this->getMockBuilder( JetpackConnectionManager::class )
+ ->disableOriginalConstructor()
+ ->onlyMethods( array( 'is_connected' ) )
+ ->getMock();
+ $manager->method( 'is_connected' )->willThrowException( new Exception( 'Connection check failed' ) );
+
+ wc_get_container()->get( LegacyProxy::class )->register_class_mocks(
+ array( JetpackConnectionManager::class => $manager )
+ );
+
+ $this->assertFalse( ( new DriverAvailabilityService() )->is_remote_proxy_available() );
+ }
+
+ /**
+ * An incompatible Jetpack raises an Error rather than an Exception, which is
+ * why the catch covers Throwable. Without this the widened catch is untested.
+ *
+ * @testdox An Error from the connection manager is caught, logged as an error, and treated as disconnected.
+ */
+ public function test_connection_error_is_caught_and_logged_distinctly() {
+ $logger_mock = $this->createMock( WC_Logger::class );
+ $logger_mock->expects( $this->once() )
+ ->method( 'error' )
+ ->with(
+ $this->stringContains( '(error)' ),
+ array( 'source' => PushNotifications::FEATURE_NAME )
+ );
+
+ $this->register_legacy_proxy_function_mocks( array( 'wc_get_logger' => fn () => $logger_mock ) );
+
+ $manager = $this->getMockBuilder( JetpackConnectionManager::class )
+ ->disableOriginalConstructor()
+ ->onlyMethods( array( 'is_connected' ) )
+ ->getMock();
+ $manager->method( 'is_connected' )->willThrowException( new Error( 'Call to undefined method' ) );
+
+ wc_get_container()->get( LegacyProxy::class )->register_class_mocks(
+ array( JetpackConnectionManager::class => $manager )
+ );
+
+ $this->assertFalse( ( new DriverAvailabilityService() )->is_remote_proxy_available() );
+ }
+
+ /**
+ * The sync package alone is not enough: other plugins bundle it without the
+ * Jetpack plugin, and on those stores there is no Jetpack Sync flow for the
+ * apps to fall back to. Class presence is stubbed because neither class exists
+ * in the test environment, so asserting against real class_exists() calls would
+ * only compare the implementation with itself.
+ *
+ * @testdox The Jetpack Sync driver is installed only when both the Jetpack plugin and the sync package are present.
+ *
+ * @testWith [true, true, true]
+ * [true, false, false]
+ * [false, true, false]
+ * [false, false, false]
+ *
+ * @param bool $plugin_present Whether the Jetpack plugin class is loadable.
+ * @param bool $package_present Whether the Jetpack Sync settings class is loadable.
+ * @param bool $expected Whether the driver should count as installed.
+ */
+ public function test_jetpack_sync_installed_requires_both_plugin_and_package( bool $plugin_present, bool $package_present, bool $expected ) {
+ /**
+ * The service with only its class-presence seam stubbed.
+ *
+ * @var DriverAvailabilityService&MockObject $service
+ */
+ $service = $this->getMockBuilder( DriverAvailabilityService::class )
+ ->onlyMethods( array( 'class_is_present' ) )
+ ->getMock();
+
+ $service->method( 'class_is_present' )->willReturnMap(
+ array(
+ array( DriverAvailabilityService::JETPACK_PLUGIN_CLASS, $plugin_present ),
+ array( DriverAvailabilityService::JETPACK_SYNC_SETTINGS_CLASS, $package_present ),
+ )
+ );
+
+ $reflection = new ReflectionMethod( DriverAvailabilityService::class, 'is_jetpack_sync_installed' );
+ $reflection->setAccessible( true );
+
+ $this->assertSame( $expected, $reflection->invoke( $service ) );
+ }
+
+ /**
+ * Reaching the is_callable guard means the class is present but the method is
+ * not, which is an incompatible Jetpack Sync rather than a merchant choice.
+ *
+ * @testdox An unusable Jetpack Sync settings class reports enabled as null, not false.
+ */
+ public function test_unusable_sync_settings_class_reports_enabled_as_null() {
+ $this->mock_jetpack_connection( true );
+
+ /**
+ * Only the install seam is stubbed, so is_jetpack_sync_enabled() runs for real
+ * and hits the is_callable guard, the class being absent in tests.
+ *
+ * @var DriverAvailabilityService&MockObject $service
+ */
+ $service = $this->getMockBuilder( DriverAvailabilityService::class )
+ ->onlyMethods( array( 'is_jetpack_sync_installed' ) )
+ ->getMock();
+ $service->method( 'is_jetpack_sync_installed' )->willReturn( true );
+
+ $driver = $service->get_status()['installed_drivers'][ DriverAvailabilityService::DRIVER_JETPACK_SYNC ];
+
+ $this->assertNull( $driver['enabled'], 'An undeterminable sync state must be null rather than a definitive false.' );
+ $this->assertFalse( $driver['available'] );
+ }
+
+ /**
+ * @testdox A cleanly unconnected store reports connected as false, not null.
+ */
+ public function test_unconnected_store_reports_false_rather_than_null() {
+ $this->mock_jetpack_connection( false );
+
+ $driver = ( new DriverAvailabilityService() )->get_status()['installed_drivers'][ DriverAvailabilityService::DRIVER_REMOTE_PROXY ];
+
+ $this->assertFalse( $driver['connected'], 'A check that ran and answered no must be false, not null.' );
+ $this->assertFalse( $driver['available'] );
+ }
+
+ /**
+ * The two drivers depend on different connection checks, so a failure must be
+ * reported against the driver that asked, not across the whole response.
+ *
+ * @testdox A failing blog connection check nulls only the remote proxy driver, not Jetpack Sync.
+ */
+ public function test_connection_check_failure_is_scoped_to_the_driver_that_asked() {
+ $manager = $this->getMockBuilder( JetpackConnectionManager::class )
+ ->disableOriginalConstructor()
+ ->onlyMethods( array( 'is_connected', 'has_connected_owner' ) )
+ ->getMock();
+
+ // The remote proxy's check throws; Jetpack Sync's answers cleanly.
+ $manager->method( 'is_connected' )->willThrowException( new Error( 'Call to undefined method' ) );
+ $manager->method( 'has_connected_owner' )->willReturn( true );
+
+ wc_get_container()->get( LegacyProxy::class )->register_class_mocks(
+ array( JetpackConnectionManager::class => $manager )
+ );
+
+ /**
+ * Only the sync-installed seam is stubbed, so both drivers appear and each
+ * runs its real connection check.
+ *
+ * @var DriverAvailabilityService&MockObject $service
+ */
+ $service = $this->getMockBuilder( DriverAvailabilityService::class )
+ ->onlyMethods( array( 'is_jetpack_sync_installed', 'is_jetpack_sync_enabled' ) )
+ ->getMock();
+ $service->method( 'is_jetpack_sync_installed' )->willReturn( true );
+ $service->method( 'is_jetpack_sync_enabled' )->willReturn( true );
+
+ $drivers = $service->get_status()['installed_drivers'];
+
+ $this->assertNull(
+ $drivers[ DriverAvailabilityService::DRIVER_REMOTE_PROXY ]['connected'],
+ 'The driver whose check threw should report connected as null.'
+ );
+ $this->assertFalse(
+ $drivers[ DriverAvailabilityService::DRIVER_REMOTE_PROXY ]['available'],
+ 'An undetermined connection must not count as available.'
+ );
+ $this->assertTrue(
+ $drivers[ DriverAvailabilityService::DRIVER_JETPACK_SYNC ]['connected'],
+ 'A driver whose check answered cleanly must not inherit the other driver’s failure.'
+ );
+ }
+
+ /**
+ * @testdox An undetermined check is not carried over into a later call on the same shared instance.
+ */
+ public function test_failed_check_state_does_not_leak_between_calls() {
+ $manager = $this->getMockBuilder( JetpackConnectionManager::class )
+ ->disableOriginalConstructor()
+ ->onlyMethods( array( 'is_connected', 'has_connected_owner' ) )
+ ->getMock();
+ $manager->method( 'is_connected' )
+ ->willReturnOnConsecutiveCalls( $this->throwException( new Error( 'boom' ) ), true );
+ $manager->method( 'has_connected_owner' )->willReturn( false );
+
+ wc_get_container()->get( LegacyProxy::class )->register_class_mocks(
+ array( JetpackConnectionManager::class => $manager )
+ );
+
+ $service = new DriverAvailabilityService();
+
+ $first = $service->get_status()['installed_drivers'][ DriverAvailabilityService::DRIVER_REMOTE_PROXY ];
+ $this->assertNull( $first['connected'] );
+
+ $second = $service->get_status()['installed_drivers'][ DriverAvailabilityService::DRIVER_REMOTE_PROXY ];
+ $this->assertTrue(
+ $second['connected'],
+ 'The container shares this service, so a failure must not persist into a later call.'
+ );
+ }
+}