Commit b0831266822 for woocommerce
commit b08312668226cc027c10ee8a2c893e0c91f84b7d
Author: Adam Grzybkowski <agrzybkowski@outlook.com>
Date: Tue May 12 14:45:05 2026 +0200
Add per-user push notification preferences (#64351)
* Add NotificationPreferencesService for push notification preferences
* Add NotificationPreferencesRestController for GET/POST preferences
* Fix PushTokenRestController REST namespace collision risk
Use a unique class identifier for the woocommerce_rest_api_get_rest_namespaces
filter so sibling controllers under /wc-push-notifications/ don't overwrite
this one's entry. The URL path is unchanged.
* Add changefile(s) from automation for the following project(s): woocommerce
* Fix lint errors from branch-level WordPress Coding Standards sniff
* Refactor save_preferences to return merged map and throw on failure
* Extract shared PushNotifications test setup into a trait
* Move push notification preferences persistence into a data store
* Fix notification preferences leaking across sites in multisite
* Route preferences migration write through the data store's write()
* Derive notification preferences defaults from the notification registry
* Simplify update_preferences() — defer key filtering and bool coercion to the service
* Drop @since annotations from private controller methods
* Extract convert_exception_to_wp_error into a shared trait
* Make PushNotificationsTestTrait helpers protected for subclass override
* Assert internal exception details are not leaked in 500 responses
* Wrap each preference in an object to allow forward-compatible sub-fields
* Add changefile(s) from automation for the following project(s): woocommerce
* Deep-merge preference updates so partial updates preserve unrelated sub-fields
* Inject controller dependencies via init() instead of resolving inline
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/64351-issue-RSM-693 b/plugins/woocommerce/changelog/64351-issue-RSM-693
new file mode 100644
index 00000000000..69fd248ebf0
--- /dev/null
+++ b/plugins/woocommerce/changelog/64351-issue-RSM-693
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add per-user push notification preferences REST endpoint at `/wc-push-notifications/preferences`.
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestController.php
new file mode 100644
index 00000000000..a1a148588c3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestController.php
@@ -0,0 +1,222 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Controllers;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationPreferencesService;
+use Automattic\WooCommerce\Internal\PushNotifications\Traits\ConvertsExceptionsToWpError;
+use Automattic\WooCommerce\Internal\RestApiControllerBase;
+use Exception;
+use WP_Error;
+use WP_Http;
+use WP_REST_Request;
+use WP_REST_Response;
+use WP_REST_Server;
+
+/**
+ * Controller for the REST endpoints associated with the current user's
+ * push notification preferences.
+ *
+ * @since 10.8.0
+ */
+class NotificationPreferencesRestController extends RestApiControllerBase {
+ use ConvertsExceptionsToWpError;
+
+ /**
+ * 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 = 'preferences';
+
+ /**
+ * The notification preferences service.
+ *
+ * @var NotificationPreferencesService
+ */
+ private NotificationPreferencesService $preferences_service;
+
+ /**
+ * The push notifications module enablement gate.
+ *
+ * @var PushNotifications
+ */
+ private PushNotifications $push_notifications;
+
+ /**
+ * Initialize injected dependencies.
+ *
+ * @internal
+ *
+ * @param NotificationPreferencesService $preferences_service The preferences service.
+ * @param PushNotifications $push_notifications The push notifications module.
+ *
+ * @since 10.8.0
+ */
+ final public function init(
+ NotificationPreferencesService $preferences_service,
+ PushNotifications $push_notifications
+ ): void {
+ $this->preferences_service = $preferences_service;
+ $this->push_notifications = $push_notifications;
+ }
+
+ /**
+ * 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
+ * (e.g. `PushTokenRestController`) would overwrite them.
+ *
+ * @since 10.8.0
+ *
+ * @return string
+ */
+ protected function get_rest_api_namespace(): string {
+ return 'wc-push-notifications-preferences';
+ }
+
+ /**
+ * Register the REST API endpoints handled by this controller.
+ *
+ * @since 10.8.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_preferences' ),
+ 'permission_callback' => array( $this, 'authorize_as_authenticated' ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::EDITABLE,
+ 'callback' => fn ( WP_REST_Request $request ) => $this->run( $request, 'update_preferences' ),
+ 'permission_callback' => array( $this, 'authorize_as_authenticated' ),
+ 'args' => $this->get_args(),
+ ),
+ )
+ );
+ }
+
+ /**
+ * Return the current user's notification preferences.
+ *
+ * @since 10.8.0
+ *
+ * @param WP_REST_Request $request The request object.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ * @return WP_REST_Response|WP_Error
+ */
+ public function get_preferences( WP_REST_Request $request ) {
+ unset( $request );
+
+ $preferences = $this->preferences_service->get_preferences( get_current_user_id() );
+
+ return new WP_REST_Response( $preferences, WP_Http::OK );
+ }
+
+ /**
+ * Partially update the current user's notification preferences and return
+ * the merged result.
+ *
+ * @since 10.8.0
+ *
+ * @param WP_REST_Request $request The request object.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ * @return WP_REST_Response|WP_Error
+ */
+ public function update_preferences( WP_REST_Request $request ) {
+ try {
+ $merged = $this->preferences_service->save_preferences(
+ get_current_user_id(),
+ $request->get_params()
+ );
+ } catch ( Exception $e ) {
+ return $this->convert_exception_to_wp_error( $e );
+ }
+
+ return new WP_REST_Response( $merged, WP_Http::OK );
+ }
+
+ /**
+ * Checks user is authenticated and authorized to access this endpoint.
+ *
+ * @since 10.8.0
+ *
+ * @param WP_REST_Request $request The request object.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ * @return bool|WP_Error
+ */
+ public function authorize_as_authenticated( WP_REST_Request $request ) {
+ if ( ! get_current_user_id() ) {
+ return new WP_Error(
+ 'woocommerce_rest_cannot_view',
+ __( 'Sorry, you are not allowed to do that.', 'woocommerce' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( ! $this->push_notifications->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;
+ }
+
+ /**
+ * Get the accepted arguments for the POST request.
+ *
+ * Each preference is an object so future sub-fields can be added without
+ * a schema-version bump. Keys are derived from the service's defaults so
+ * this stays in lock-step with the list of supported notification types.
+ *
+ * @return array<string, array<string, mixed>>
+ */
+ private function get_args(): array {
+ $args = array();
+ $defaults = $this->preferences_service->get_defaults();
+
+ foreach ( array_keys( $defaults ) as $key ) {
+ $args[ $key ] = array(
+ 'description' => sprintf(
+ /* translators: %s: notification preference key (e.g. store_order). */
+ __( 'Preferences for the %s push notification type.', 'woocommerce' ),
+ $key
+ ),
+ 'type' => 'object',
+ 'properties' => array(
+ 'enabled' => array(
+ 'type' => 'boolean',
+ 'description' => __( 'Whether this notification type is enabled.', 'woocommerce' ),
+ ),
+ ),
+ 'required' => false,
+ 'validate_callback' => 'rest_validate_request_arg',
+ );
+ }
+
+ return $args;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
index a0184fa10c9..db890d8068a 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
@@ -11,12 +11,11 @@ use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataS
use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
use Automattic\WooCommerce\Internal\PushNotifications\Exceptions\PushTokenNotFoundException;
use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Automattic\WooCommerce\Internal\PushNotifications\Traits\ConvertsExceptionsToWpError;
use Automattic\WooCommerce\Internal\PushNotifications\Validators\PushTokenValidator;
use Automattic\WooCommerce\Internal\RestApiControllerBase;
-use Automattic\WooCommerce\Proxies\LegacyProxy;
use Exception;
use WC_Data_Exception;
-use WC_Logger;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
@@ -30,6 +29,8 @@ use WP_Http;
* @since 10.6.0
*/
class PushTokenRestController extends RestApiControllerBase {
+ use ConvertsExceptionsToWpError;
+
/**
* The root namespace for the JSON REST API endpoints.
*
@@ -45,14 +46,18 @@ class PushTokenRestController extends RestApiControllerBase {
protected string $rest_base = 'push-tokens';
/**
- * Get the WooCommerce REST API namespace for the class.
+ * 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 10.6.0
*
* @return string
*/
protected function get_rest_api_namespace(): string {
- return $this->route_namespace;
+ return 'wc-push-notifications-push-tokens';
}
/**
@@ -64,7 +69,7 @@ class PushTokenRestController extends RestApiControllerBase {
*/
public function register_routes(): void {
register_rest_route(
- $this->get_rest_api_namespace(),
+ $this->route_namespace,
$this->rest_base,
array(
array(
@@ -102,7 +107,7 @@ class PushTokenRestController extends RestApiControllerBase {
);
register_rest_route(
- $this->get_rest_api_namespace(),
+ $this->route_namespace,
$this->rest_base . '/(?P<id>[\d]+)',
array(
array(
@@ -352,43 +357,6 @@ class PushTokenRestController extends RestApiControllerBase {
);
}
- /**
- * Converts an exception to an instance of WP_Error.
- *
- * @since 10.6.0
- *
- * @param Exception $e The exception to convert.
- * @return WP_Error
- */
- private function convert_exception_to_wp_error( Exception $e ): WP_Error {
- /**
- * If the exception is `WC_Data_Exception`, and doesn't represent an
- * internal server error (which may contain internal details that should
- * be obscured) then format it as a `WP_Error`.
- */
- if (
- $e instanceof WC_Data_Exception
- && $e->getCode() !== WP_Http::INTERNAL_SERVER_ERROR
- ) {
- return new WP_Error(
- $e->getErrorCode(),
- $e->getMessage(),
- $e->getErrorData()
- );
- }
-
- wc_get_container()
- ->get( LegacyProxy::class )
- ->call_function( 'wc_get_logger' )
- ->error( (string) $e->getMessage(), array( 'source' => PushNotifications::FEATURE_NAME ) );
-
- return new WP_Error(
- 'woocommerce_internal_error',
- 'Internal server error',
- array( 'status' => WP_Http::INTERNAL_SERVER_ERROR )
- );
- }
-
/**
* Get the accepted arguments for the POST request.
*
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/NotificationPreferencesDataStore.php b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/NotificationPreferencesDataStore.php
new file mode 100644
index 00000000000..60afe76314e
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/NotificationPreferencesDataStore.php
@@ -0,0 +1,139 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\DataStores;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Internal\Utilities\Users;
+use WC_Data_Exception;
+use WP_Http;
+
+/**
+ * Persistence layer for per-user push notification preferences.
+ *
+ * Stores a single versioned envelope per user under the `wc_push_notification_preferences`
+ * user meta key. The key is automatically scoped to the current site by `Users::*_site_user_meta`,
+ * which prefixes the underlying meta key with the blog ID so preferences set on one site in a
+ * multisite network do not leak to other sites the same user belongs to. Owns schema migration
+ * on read and surfaces real DB write failures via `WC_Data_Exception`.
+ *
+ * @since 10.8.0
+ */
+class NotificationPreferencesDataStore {
+ /**
+ * User meta key under which the preferences envelope is stored.
+ */
+ const META_KEY = 'wc_push_notification_preferences';
+
+ /**
+ * Current preferences schema version.
+ *
+ * Bump when the envelope's `preferences` shape changes, and add a
+ * corresponding branch to `migrate()`.
+ */
+ const CURRENT_SCHEMA_VERSION = 1;
+
+ /**
+ * Read the stored envelope for a user.
+ *
+ * Migrates older schema versions on the fly and persists the upgrade so
+ * callers always receive a current-version envelope. Returns null when
+ * nothing is stored for the user.
+ *
+ * @param int $user_id The user ID.
+ *
+ * @return array|null Envelope with `schema_version` and `preferences` keys, or null if unstored.
+ *
+ * @throws WC_Data_Exception When persisting a migrated envelope fails.
+ *
+ * @since 10.8.0
+ */
+ public function read( int $user_id ): ?array {
+ $stored = Users::get_site_user_meta( $user_id, self::META_KEY );
+
+ if ( ! is_array( $stored ) || empty( $stored ) ) {
+ return null;
+ }
+
+ $stored_version = isset( $stored['schema_version'] ) ? (int) $stored['schema_version'] : 0;
+
+ if ( $stored_version < self::CURRENT_SCHEMA_VERSION ) {
+ $stored = $this->migrate( $stored, $stored_version );
+ $this->write( $user_id, $stored );
+ }
+
+ return $stored;
+ }
+
+ /**
+ * Persist an envelope for a user.
+ *
+ * No-ops when the stored value already matches the supplied envelope.
+ * Throws `WC_Data_Exception` when the underlying user meta write fails
+ * for a reason other than the value being unchanged.
+ *
+ * @param int $user_id The user ID.
+ * @param array $envelope The envelope to persist (must have `schema_version` and `preferences` keys).
+ *
+ * @return void
+ *
+ * @throws WC_Data_Exception When the user meta write fails for a non-no-op reason.
+ *
+ * @since 10.8.0
+ */
+ public function write( int $user_id, array $envelope ): void {
+ // Skip the write when the stored envelope already matches. This avoids
+ // the ambiguous `false` return from update_user_meta() that means
+ // either "value unchanged" or "DB write failed" — by short-circuiting
+ // the no-op case, a `false` from the call below unambiguously means
+ // the write itself failed and we can surface it.
+ $stored = Users::get_site_user_meta( $user_id, self::META_KEY );
+ if ( $stored === $envelope ) {
+ return;
+ }
+
+ $result = Users::update_site_user_meta( $user_id, self::META_KEY, $envelope );
+
+ if ( false === $result ) {
+ // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ throw new WC_Data_Exception(
+ 'woocommerce_push_notification_preferences_save_failed',
+ 'Failed to save push notification preferences.',
+ WP_Http::INTERNAL_SERVER_ERROR
+ );
+ // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ }
+ }
+
+ /**
+ * Upgrade an envelope to the current schema version.
+ *
+ * Pure transformation — does not persist. Missing or malformed
+ * `preferences` entries are replaced with an empty array; the service
+ * layer is responsible for filling in defaults from the empty case.
+ *
+ * @param array $data The stored envelope (expected keys: `schema_version`, `preferences`).
+ * @param int $from_version The schema version currently on disk.
+ *
+ * @return array Envelope upgraded to `self::CURRENT_SCHEMA_VERSION`.
+ *
+ * @since 10.8.0
+ */
+ public function migrate( array $data, int $from_version ): array {
+ // Parameter reserved for future schema migrations.
+ unset( $from_version );
+
+ $preferences = isset( $data['preferences'] ) && is_array( $data['preferences'] )
+ ? $data['preferences']
+ : array();
+
+ // For v1 the envelope shape is stable; we only normalize the version tag.
+
+ return array(
+ 'schema_version' => self::CURRENT_SCHEMA_VERSION,
+ 'preferences' => $preferences,
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
index 548a53dc27b..752894d78ea 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
@@ -7,6 +7,7 @@ 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\PushTokenRestController;
use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
@@ -78,6 +79,7 @@ class PushNotifications {
( new PushTokenRestController() )->register();
( new PushNotificationRestController() )->register();
+ ( new NotificationPreferencesRestController() )->register();
( new NewOrderNotificationTrigger() )->register();
( new NewReviewNotificationTrigger() )->register();
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationPreferencesService.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationPreferencesService.php
new file mode 100644
index 00000000000..fc56810d2d7
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationPreferencesService.php
@@ -0,0 +1,173 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Services;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\NotificationPreferencesDataStore;
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
+
+/**
+ * Manages per-user push notification preferences.
+ *
+ * Owns the domain logic — the default preference values and how arbitrary
+ * input is sanitized — and delegates persistence to
+ * `NotificationPreferencesDataStore`.
+ *
+ * @since 10.8.0
+ */
+class NotificationPreferencesService {
+ /**
+ * The data store used for persistence.
+ *
+ * @var NotificationPreferencesDataStore
+ */
+ private NotificationPreferencesDataStore $data_store;
+
+ /**
+ * Initialize injected dependencies.
+ *
+ * @internal
+ *
+ * @param NotificationPreferencesDataStore $data_store The data store.
+ *
+ * @since 10.8.0
+ */
+ final public function init( NotificationPreferencesDataStore $data_store ): void {
+ $this->data_store = $data_store;
+ }
+
+ /**
+ * Retrieve a user's notification preferences.
+ *
+ * Falls back to defaults for users with no stored preferences. Stored
+ * preferences are overlaid on top of the defaults so that any newer keys
+ * not yet on disk are filled in.
+ *
+ * @param int $user_id The user ID.
+ *
+ * @return array<string, array<string, mixed>> Map of preference key => sub-options.
+ *
+ * @since 10.8.0
+ */
+ public function get_preferences( int $user_id ): array {
+ $envelope = $this->data_store->read( $user_id );
+
+ if ( null === $envelope ) {
+ return $this->get_defaults();
+ }
+
+ $stored = isset( $envelope['preferences'] ) && is_array( $envelope['preferences'] )
+ ? $envelope['preferences']
+ : array();
+
+ return $this->sanitize( array_replace_recursive( $this->get_defaults(), $stored ) );
+ }
+
+ /**
+ * Persist a partial update to a user's notification preferences.
+ *
+ * Unknown top-level keys and unknown sub-fields per key are dropped.
+ * The merged result is wrapped in the current versioned envelope and
+ * handed to the data store.
+ *
+ * @param int $user_id The user ID.
+ * @param array<string, array<string, mixed>> $preferences Partial preferences to merge over existing values.
+ *
+ * @return array<string, array<string, mixed>> The merged, sanitized preferences map after the save.
+ *
+ * @throws \WC_Data_Exception Propagated from the data store on real persistence failure.
+ *
+ * @since 10.8.0
+ */
+ public function save_preferences( int $user_id, array $preferences ): array {
+ $current = $this->get_preferences( $user_id );
+ $merged = $this->sanitize( array_replace_recursive( $current, $preferences ) );
+
+ // Data store throws WC_Data_Exception on real failure; let it propagate.
+ $this->data_store->write(
+ $user_id,
+ array(
+ 'schema_version' => NotificationPreferencesDataStore::CURRENT_SCHEMA_VERSION,
+ 'preferences' => $merged,
+ )
+ );
+
+ return $merged;
+ }
+
+ /**
+ * Return the default preferences for a new user.
+ *
+ * Each preference is a small object so future fields (thresholds, sub-toggles)
+ * can be added without bumping the schema version. The keyset is derived from
+ * `Notification::NOTIFICATION_CLASSES` so adding a new notification type
+ * automatically opts it into preferences — no parallel list to keep in sync.
+ *
+ * @return array<string, array<string, mixed>> Map of preference key => default sub-options.
+ *
+ * @since 10.8.0
+ */
+ public function get_defaults(): array {
+ $defaults = array();
+ foreach ( array_keys( Notification::NOTIFICATION_CLASSES ) as $type ) {
+ $defaults[ $type ] = array( 'enabled' => true );
+ }
+ return $defaults;
+ }
+
+ /**
+ * Drop unknown top-level keys and unknown sub-fields per key, coercing
+ * known sub-fields to their expected types.
+ *
+ * @param array $preferences Arbitrary preferences map.
+ *
+ * @return array<string, array<string, mixed>> Sanitized preferences.
+ */
+ private function sanitize( array $preferences ): array {
+ $allowed = $this->get_defaults();
+ $sanitized = array();
+
+ foreach ( $allowed as $key => $default_shape ) {
+ $value = $preferences[ $key ] ?? array();
+ $value = is_array( $value ) ? $value : array();
+ $sanitized[ $key ] = $this->sanitize_value( $key, $value, $default_shape );
+ }
+
+ return $sanitized;
+ }
+
+ /**
+ * Apply per-key sanitization to a single preference's sub-options.
+ *
+ * Unknown sub-keys are dropped; missing sub-keys fall back to their default.
+ * Today only `enabled` is recognized; future preference types extend this method
+ * (or its dispatch) to validate their additional sub-fields.
+ *
+ * @param string $key Preference key (e.g. `store_order`).
+ * @param array $value Submitted sub-options for the key.
+ * @param array<string, mixed> $default_shape Default sub-options for the key.
+ *
+ * @return array<string, mixed>
+ */
+ protected function sanitize_value( string $key, array $value, array $default_shape ): array {
+ // Reserved for per-key dispatch when sub-fields are added.
+ unset( $key );
+
+ $sanitized = array();
+
+ foreach ( $default_shape as $sub_key => $sub_default ) {
+ if ( 'enabled' === $sub_key ) {
+ $sanitized[ $sub_key ] = array_key_exists( $sub_key, $value )
+ ? (bool) $value[ $sub_key ]
+ : (bool) $sub_default;
+ continue;
+ }
+ // Future sub-fields (thresholds, sub-toggles) extend this switch.
+ }
+
+ return $sanitized;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Traits/ConvertsExceptionsToWpError.php b/plugins/woocommerce/src/Internal/PushNotifications/Traits/ConvertsExceptionsToWpError.php
new file mode 100644
index 00000000000..8d378c281b2
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Traits/ConvertsExceptionsToWpError.php
@@ -0,0 +1,58 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Traits;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Automattic\WooCommerce\Proxies\LegacyProxy;
+use Exception;
+use WC_Data_Exception;
+use WP_Error;
+use WP_Http;
+
+/**
+ * Shared converter for REST controller catch blocks in the PushNotifications
+ * module.
+ *
+ * Surfaces domain-specific `WC_Data_Exception` details for client-recoverable
+ * failures (non-500 status codes), and logs + returns a generic
+ * internal-error response for 500-level or unrecognized exceptions so that
+ * internal details aren't leaked to API clients.
+ */
+trait ConvertsExceptionsToWpError {
+ /**
+ * Convert an exception thrown by a service into a WP_Error suitable for
+ * the REST response.
+ *
+ * @param Exception $e The exception to convert.
+ * @return WP_Error
+ */
+ protected function convert_exception_to_wp_error( Exception $e ): WP_Error {
+ // Non-500 WC_Data_Exception: client-recoverable, surface details.
+ if (
+ $e instanceof WC_Data_Exception
+ && $e->getCode() !== WP_Http::INTERNAL_SERVER_ERROR
+ ) {
+ return new WP_Error(
+ $e->getErrorCode(),
+ $e->getMessage(),
+ $e->getErrorData()
+ );
+ }
+
+ // Anything else: log and surface a generic 500.
+ wc_get_container()
+ ->get( LegacyProxy::class )
+ ->call_function( 'wc_get_logger' )
+ ->error( $e->getMessage(), array( 'source' => PushNotifications::FEATURE_NAME ) );
+
+ return new WP_Error(
+ 'woocommerce_internal_error',
+ __( 'Internal server error', 'woocommerce' ),
+ array( 'status' => WP_Http::INTERNAL_SERVER_ERROR )
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestControllerTest.php
new file mode 100644
index 00000000000..735a7dd1f13
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestControllerTest.php
@@ -0,0 +1,305 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Controllers;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Controllers\NotificationPreferencesRestController;
+use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushTokenRestController;
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\NotificationPreferencesDataStore;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationPreferencesService;
+use Automattic\WooCommerce\Internal\Utilities\Users;
+use Automattic\WooCommerce\Tests\Internal\PushNotifications\Helpers\PushNotificationsTestTrait;
+use WC_Data_Exception;
+use WC_REST_Unit_Test_Case;
+use WP_Http;
+use WP_REST_Request;
+
+/**
+ * Tests for the NotificationPreferencesRestController class.
+ *
+ * @package WooCommerce\Tests\PushNotifications
+ */
+class NotificationPreferencesRestControllerTest extends WC_REST_Unit_Test_Case {
+ use PushNotificationsTestTrait;
+
+ /**
+ * Shop manager user ID for testing.
+ *
+ * @var int
+ */
+ private $user_id;
+
+ /**
+ * Subscriber user ID for testing.
+ *
+ * @var int
+ */
+ private $subscriber_id;
+
+ /**
+ * Set up test.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->set_up_features_controller_mock();
+ $this->reset_push_notifications_cache();
+
+ $this->user_id = $this->factory->user->create( array( 'role' => 'shop_manager' ) );
+ $this->subscriber_id = $this->factory->user->create( array( 'role' => 'subscriber' ) );
+ }
+
+ /**
+ * Register the controller's routes using the container so init() auto-wires the service
+ * and push-notifications dependencies. Tests call this after setting up any container
+ * mocks they need (e.g. replacing the service) so the resolved controller picks them up.
+ */
+ private function register_routes(): void {
+ wc_get_container()->get( NotificationPreferencesRestController::class )->register_routes();
+ }
+
+ /**
+ * Tear down test.
+ */
+ public function tearDown(): void {
+ wp_set_current_user( 0 );
+
+ Users::delete_site_user_meta( $this->user_id, NotificationPreferencesDataStore::META_KEY );
+ wp_delete_user( $this->user_id );
+ wp_delete_user( $this->subscriber_id );
+
+ $this->reset_container_replacements();
+ wc_get_container()->reset_all_resolved();
+
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox GET should reject unauthenticated requests.
+ */
+ public function test_get_preferences_requires_authentication() {
+ $this->mock_jetpack_connection_manager_is_connected( true );
+ $this->register_routes();
+
+ $request = new WP_REST_Request( 'GET', '/wc-push-notifications/preferences' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( rest_authorization_required_code(), $response->get_status() );
+ }
+
+ /**
+ * @testdox GET should reject users without a push-notifications role.
+ */
+ public function test_get_preferences_rejects_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/preferences' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( WP_Http::FORBIDDEN, $response->get_status() );
+ }
+
+ /**
+ * @testdox GET should return the current user's preferences merged with defaults.
+ */
+ public function test_get_preferences_returns_user_preferences() {
+ wp_set_current_user( $this->user_id );
+ $this->mock_jetpack_connection_manager_is_connected( true );
+ $this->register_routes();
+
+ wc_get_container()
+ ->get( NotificationPreferencesService::class )
+ ->save_preferences(
+ $this->user_id,
+ array( 'store_order' => array( 'enabled' => false ) )
+ );
+
+ $request = new WP_REST_Request( 'GET', '/wc-push-notifications/preferences' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( WP_Http::OK, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertArrayHasKey( 'store_order', $data );
+ $this->assertFalse( $data['store_order']['enabled'] );
+ $this->assertArrayHasKey( 'store_review', $data );
+ $this->assertTrue( $data['store_review']['enabled'] );
+ }
+
+ /**
+ * @testdox POST should persist new preferences to the authenticated user.
+ */
+ public function test_post_preferences_updates_settings() {
+ wp_set_current_user( $this->user_id );
+ $this->mock_jetpack_connection_manager_is_connected( true );
+ $this->register_routes();
+
+ $request = new WP_REST_Request( 'POST', '/wc-push-notifications/preferences' );
+ $request->set_param( 'store_order', array( 'enabled' => false ) );
+ $request->set_param( 'store_review', array( 'enabled' => false ) );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( WP_Http::OK, $response->get_status() );
+
+ $stored = Users::get_site_user_meta( $this->user_id, NotificationPreferencesDataStore::META_KEY );
+ $this->assertIsArray( $stored );
+ $this->assertFalse( $stored['preferences']['store_order']['enabled'] );
+ $this->assertFalse( $stored['preferences']['store_review']['enabled'] );
+ }
+
+ /**
+ * @testdox POST should reject non-object values via the REST validation layer.
+ */
+ public function test_post_preferences_rejects_non_object_value() {
+ wp_set_current_user( $this->user_id );
+ $this->mock_jetpack_connection_manager_is_connected( true );
+ $this->register_routes();
+
+ $request = new WP_REST_Request( 'POST', '/wc-push-notifications/preferences' );
+ $request->set_param( 'store_order', 'not-an-object' );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( WP_Http::BAD_REQUEST, $response->get_status() );
+ }
+
+ /**
+ * @testdox POST should reject non-boolean `enabled` sub-fields via the REST validation layer.
+ */
+ public function test_post_preferences_rejects_non_boolean_enabled() {
+ wp_set_current_user( $this->user_id );
+ $this->mock_jetpack_connection_manager_is_connected( true );
+ $this->register_routes();
+
+ $request = new WP_REST_Request( 'POST', '/wc-push-notifications/preferences' );
+ $request->set_param( 'store_order', array( 'enabled' => 'not-a-boolean' ) );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( WP_Http::BAD_REQUEST, $response->get_status() );
+ }
+
+ /**
+ * @testdox PATCH should be accepted and update preferences like POST.
+ */
+ public function test_patch_preferences_updates_settings() {
+ wp_set_current_user( $this->user_id );
+ $this->mock_jetpack_connection_manager_is_connected( true );
+ $this->register_routes();
+
+ $request = new WP_REST_Request( 'PATCH', '/wc-push-notifications/preferences' );
+ $request->set_param( 'store_order', array( 'enabled' => false ) );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( WP_Http::OK, $response->get_status() );
+
+ $stored = Users::get_site_user_meta( $this->user_id, NotificationPreferencesDataStore::META_KEY );
+ $this->assertFalse( $stored['preferences']['store_order']['enabled'] );
+ }
+
+ /**
+ * @testdox POST should return the merged preferences after partial update.
+ */
+ public function test_post_preferences_returns_merged_result() {
+ wp_set_current_user( $this->user_id );
+ $this->mock_jetpack_connection_manager_is_connected( true );
+ $this->register_routes();
+
+ wc_get_container()
+ ->get( NotificationPreferencesService::class )
+ ->save_preferences(
+ $this->user_id,
+ array(
+ 'store_order' => array( 'enabled' => false ),
+ 'store_review' => array( 'enabled' => false ),
+ )
+ );
+
+ $request = new WP_REST_Request( 'POST', '/wc-push-notifications/preferences' );
+ $request->set_param( 'store_review', array( 'enabled' => true ) );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( WP_Http::OK, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertFalse( $data['store_order']['enabled'] );
+ $this->assertTrue( $data['store_review']['enabled'] );
+ }
+
+ /**
+ * @testdox POST should return a 500 when the service throws a persistence error.
+ */
+ public function test_post_preferences_returns_500_when_service_throws() {
+ wp_set_current_user( $this->user_id );
+ $this->mock_jetpack_connection_manager_is_connected( true );
+
+ $service_mock = $this->createMock( NotificationPreferencesService::class );
+ $service_mock->method( 'get_defaults' )->willReturn(
+ array(
+ 'store_order' => array( 'enabled' => true ),
+ 'store_review' => array( 'enabled' => true ),
+ )
+ );
+ $internal_code = 'woocommerce_push_notification_preferences_save_failed';
+ $internal_message = 'Failed to save push notification preferences.';
+
+ $service_mock->method( 'save_preferences' )->willThrowException(
+ new WC_Data_Exception(
+ $internal_code,
+ $internal_message,
+ WP_Http::INTERNAL_SERVER_ERROR
+ )
+ );
+
+ wc_get_container()->replace( NotificationPreferencesService::class, $service_mock );
+ $this->register_routes();
+
+ $request = new WP_REST_Request( 'POST', '/wc-push-notifications/preferences' );
+ $request->set_param( 'store_review', array( 'enabled' => false ) );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( WP_Http::INTERNAL_SERVER_ERROR, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertArrayHasKey( 'code', $data );
+ $this->assertSame( 'woocommerce_internal_error', $data['code'] );
+
+ // Internal exception details must not be leaked to API clients.
+ $this->assertNotSame( $internal_code, $data['code'] );
+ $serialized = wp_json_encode( $data );
+ $this->assertStringNotContainsString( $internal_code, (string) $serialized );
+ $this->assertStringNotContainsString( $internal_message, (string) $serialized );
+ }
+
+ /**
+ * @testdox Should not collide with PushTokenRestController on the WC REST namespaces filter.
+ *
+ * Both 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() {
+ $preferences_controller = new NotificationPreferencesRestController();
+ $push_token_controller = new PushTokenRestController();
+
+ $preferences_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( NotificationPreferencesRestController::class, $registered_classes );
+ $this->assertContains( PushTokenRestController::class, $registered_classes );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
index 9058ccc0b41..7ab63d883c7 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
@@ -4,18 +4,15 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Controllers;
-use Automattic\Jetpack\Connection\Manager as JetpackConnectionManager;
-use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushTokenRestController;
use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore;
use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
use Automattic\WooCommerce\Internal\PushNotifications\Exceptions\PushTokenInvalidDataException;
use Automattic\WooCommerce\Internal\PushNotifications\Exceptions\PushTokenNotFoundException;
use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
-use Automattic\WooCommerce\Proxies\LegacyProxy;
+use Automattic\WooCommerce\Tests\Internal\PushNotifications\Helpers\PushNotificationsTestTrait;
use Exception;
use RuntimeException;
-use PHPUnit\Framework\MockObject\MockObject;
use ReflectionClass;
use WC_Data_Exception;
use WC_REST_Unit_Test_Case;
@@ -29,6 +26,8 @@ use WP_REST_Request;
* @package WooCommerce\Tests\PushNotifications
*/
class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
+ use PushNotificationsTestTrait;
+
/**
* Shop manager user ID for testing.
*
@@ -57,16 +56,6 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
*/
private $subscriber_id;
- /**
- * @var JetpackConnectionManager|MockObject
- */
- private $jetpack_connection_manager_mock;
-
- /**
- * @var FeaturesController|MockObject
- */
- private $features_controller_mock;
-
/**
* Set up test.
*/
@@ -1312,67 +1301,6 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
$this->assertEquals( WP_Http::INTERNAL_SERVER_ERROR, $result->get_error_data()['status'] );
}
- /**
- * Sets up the Jetpack connection manager mocking, and ensures the
- * PushNotifications class state is reset so `should_be_enabled` calculates
- * this from scratch.
- *
- * @param bool $is_connected Whether the manager should report Jetpack is
- * connected or not.
- */
- private function mock_jetpack_connection_manager_is_connected( bool $is_connected = true ) {
- $this->jetpack_connection_manager_mock = $this
- ->getMockBuilder( JetpackConnectionManager::class )
- ->disableOriginalConstructor()
- ->onlyMethods( array( 'is_connected' ) )
- ->getMock();
-
- wc_get_container()->get( LegacyProxy::class )->register_class_mocks(
- array( JetpackConnectionManager::class => $this->jetpack_connection_manager_mock )
- );
-
- $this->jetpack_connection_manager_mock
- ->expects( $this->any() )
- ->method( 'is_connected' )
- ->willReturn( $is_connected );
-
- $this->reset_push_notifications_cache();
- }
-
- /**
- * Sets up the FeaturesController mock to enable push_notifications feature.
- */
- private function set_up_features_controller_mock() {
- $this->features_controller_mock = $this
- ->getMockBuilder( FeaturesController::class )
- ->disableOriginalConstructor()
- ->onlyMethods( array( 'feature_is_enabled' ) )
- ->getMock();
-
- $this->features_controller_mock
- ->method( 'feature_is_enabled' )
- ->willReturnCallback(
- function ( $feature_id ) {
- return PushNotifications::FEATURE_NAME === $feature_id;
- }
- );
-
- wc_get_container()->replace( FeaturesController::class, $this->features_controller_mock );
- }
-
- /**
- * Resets the cached enablement state on the container's PushNotifications
- * instance.
- */
- private function reset_push_notifications_cache() {
- $push_notifications = wc_get_container()->get( PushNotifications::class );
- $reflection = new ReflectionClass( $push_notifications );
- $property = $reflection->getProperty( 'enabled' );
-
- $property->setAccessible( true );
- $property->setValue( $push_notifications, null );
- }
-
/**
* Asserts that a push token was persisted correctly in the database.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/NotificationPreferencesDataStoreTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/NotificationPreferencesDataStoreTest.php
new file mode 100644
index 00000000000..873b17db0c8
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/NotificationPreferencesDataStoreTest.php
@@ -0,0 +1,167 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\DataStores;
+
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\NotificationPreferencesDataStore;
+use Automattic\WooCommerce\Internal\Utilities\Users;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the NotificationPreferencesDataStore class.
+ *
+ * @covers \Automattic\WooCommerce\Internal\PushNotifications\DataStores\NotificationPreferencesDataStore
+ */
+class NotificationPreferencesDataStoreTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var NotificationPreferencesDataStore
+ */
+ private $sut;
+
+ /**
+ * A test user ID.
+ *
+ * @var int
+ */
+ private int $user_id;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->sut = new NotificationPreferencesDataStore();
+ $this->user_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+ }
+
+ /**
+ * Clean up user meta and the test user between tests.
+ */
+ public function tearDown(): void {
+ Users::delete_site_user_meta( $this->user_id, NotificationPreferencesDataStore::META_KEY );
+ wp_delete_user( $this->user_id );
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox Should return null when nothing is stored for the user.
+ */
+ public function test_read_returns_null_for_unstored_user(): void {
+ $this->assertNull( $this->sut->read( $this->user_id ) );
+ }
+
+ /**
+ * @testdox Should return the envelope as stored when it is at the current schema version.
+ */
+ public function test_read_returns_stored_envelope(): void {
+ $envelope = array(
+ 'schema_version' => NotificationPreferencesDataStore::CURRENT_SCHEMA_VERSION,
+ 'preferences' => array(
+ 'store_order' => array( 'enabled' => false ),
+ 'store_review' => array( 'enabled' => true ),
+ ),
+ );
+
+ Users::update_site_user_meta( $this->user_id, NotificationPreferencesDataStore::META_KEY, $envelope );
+
+ $result = $this->sut->read( $this->user_id );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( NotificationPreferencesDataStore::CURRENT_SCHEMA_VERSION, $result['schema_version'] );
+ $this->assertArrayHasKey( 'preferences', $result );
+ $this->assertArrayHasKey( 'store_order', $result['preferences'] );
+ $this->assertFalse( $result['preferences']['store_order']['enabled'] );
+ $this->assertArrayHasKey( 'store_review', $result['preferences'] );
+ $this->assertTrue( $result['preferences']['store_review']['enabled'] );
+ }
+
+ /**
+ * @testdox Should migrate an older envelope on read and persist the upgraded version.
+ */
+ public function test_read_migrates_old_envelope_and_persists_upgrade(): void {
+ Users::update_site_user_meta(
+ $this->user_id,
+ NotificationPreferencesDataStore::META_KEY,
+ array(
+ 'schema_version' => 0,
+ 'preferences' => array(
+ 'store_order' => array( 'enabled' => false ),
+ ),
+ )
+ );
+
+ $result = $this->sut->read( $this->user_id );
+
+ // Returned envelope is at current version.
+ $this->assertSame( NotificationPreferencesDataStore::CURRENT_SCHEMA_VERSION, $result['schema_version'] );
+ $this->assertArrayHasKey( 'store_order', $result['preferences'] );
+ $this->assertFalse( $result['preferences']['store_order']['enabled'] );
+
+ // And the upgrade was persisted back to user meta.
+ $stored = Users::get_site_user_meta( $this->user_id, NotificationPreferencesDataStore::META_KEY );
+ $this->assertSame( NotificationPreferencesDataStore::CURRENT_SCHEMA_VERSION, $stored['schema_version'] );
+ }
+
+ /**
+ * @testdox Should fall back to an empty preferences array when migrating an envelope with malformed preferences.
+ */
+ public function test_migrate_falls_back_to_empty_preferences_for_malformed_input(): void {
+ $migrated = $this->sut->migrate(
+ array(
+ 'schema_version' => 0,
+ 'preferences' => 'corrupted',
+ ),
+ 0
+ );
+
+ $this->assertSame( NotificationPreferencesDataStore::CURRENT_SCHEMA_VERSION, $migrated['schema_version'] );
+ $this->assertArrayHasKey( 'preferences', $migrated );
+ $this->assertSame( array(), $migrated['preferences'] );
+ }
+
+ /**
+ * @testdox Should persist the supplied envelope to user meta.
+ */
+ public function test_write_persists_envelope_to_user_meta(): void {
+ $envelope = array(
+ 'schema_version' => NotificationPreferencesDataStore::CURRENT_SCHEMA_VERSION,
+ 'preferences' => array(
+ 'store_order' => array( 'enabled' => false ),
+ 'store_review' => array( 'enabled' => false ),
+ ),
+ );
+
+ $this->sut->write( $this->user_id, $envelope );
+
+ $stored = Users::get_site_user_meta( $this->user_id, NotificationPreferencesDataStore::META_KEY );
+ $this->assertSame( $envelope, $stored );
+ }
+
+ /**
+ * @testdox Should be a no-op when the supplied envelope already matches what is stored.
+ *
+ * Verified indirectly: a second write of an identical envelope does not throw, and the stored
+ * value remains unchanged. The pre-check inside write() short-circuits before update_user_meta(),
+ * which would otherwise return false for the unchanged value and trigger a spurious exception.
+ */
+ public function test_write_is_a_no_op_when_envelope_unchanged(): void {
+ $envelope = array(
+ 'schema_version' => NotificationPreferencesDataStore::CURRENT_SCHEMA_VERSION,
+ 'preferences' => array(
+ 'store_order' => array( 'enabled' => true ),
+ 'store_review' => array( 'enabled' => true ),
+ ),
+ );
+
+ $this->sut->write( $this->user_id, $envelope );
+ $this->sut->write( $this->user_id, $envelope );
+
+ $stored = Users::get_site_user_meta( $this->user_id, NotificationPreferencesDataStore::META_KEY );
+ $this->assertSame( $envelope, $stored );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Helpers/PushNotificationsTestTrait.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Helpers/PushNotificationsTestTrait.php
new file mode 100644
index 00000000000..de37a767efd
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Helpers/PushNotificationsTestTrait.php
@@ -0,0 +1,95 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Helpers;
+
+use Automattic\Jetpack\Connection\Manager as JetpackConnectionManager;
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Automattic\WooCommerce\Proxies\LegacyProxy;
+use PHPUnit\Framework\MockObject\MockObject;
+use ReflectionClass;
+
+/**
+ * Shared test helpers for the PushNotifications module.
+ *
+ * Mocks the Jetpack connection state, the FeaturesController, and resets the
+ * memoized enablement flag on the container's `PushNotifications` instance —
+ * the three things every push-notifications-related controller test needs in
+ * setUp.
+ *
+ * @package WooCommerce\Tests\PushNotifications
+ */
+trait PushNotificationsTestTrait {
+ /**
+ * @var JetpackConnectionManager|MockObject|null
+ */
+ protected $jetpack_connection_manager_mock;
+
+ /**
+ * @var FeaturesController|MockObject|null
+ */
+ protected $features_controller_mock;
+
+ /**
+ * Mocks the JetpackConnectionManager so its `is_connected()` returns the
+ * supplied value, and resets the PushNotifications enablement cache so
+ * `should_be_enabled()` re-evaluates against the new mock.
+ *
+ * @param bool $is_connected Whether the manager should report Jetpack is connected.
+ */
+ protected function mock_jetpack_connection_manager_is_connected( bool $is_connected = true ) {
+ $this->jetpack_connection_manager_mock = $this
+ ->getMockBuilder( JetpackConnectionManager::class )
+ ->disableOriginalConstructor()
+ ->onlyMethods( array( 'is_connected' ) )
+ ->getMock();
+
+ wc_get_container()->get( LegacyProxy::class )->register_class_mocks(
+ array( JetpackConnectionManager::class => $this->jetpack_connection_manager_mock )
+ );
+
+ $this->jetpack_connection_manager_mock
+ ->expects( $this->any() )
+ ->method( 'is_connected' )
+ ->willReturn( $is_connected );
+
+ $this->reset_push_notifications_cache();
+ }
+
+ /**
+ * Sets up the FeaturesController mock so the `push_notifications` feature
+ * reports as enabled (and only that feature).
+ */
+ protected function set_up_features_controller_mock() {
+ $this->features_controller_mock = $this
+ ->getMockBuilder( FeaturesController::class )
+ ->disableOriginalConstructor()
+ ->onlyMethods( array( 'feature_is_enabled' ) )
+ ->getMock();
+
+ $this->features_controller_mock
+ ->method( 'feature_is_enabled' )
+ ->willReturnCallback(
+ function ( $feature_id ) {
+ return PushNotifications::FEATURE_NAME === $feature_id;
+ }
+ );
+
+ wc_get_container()->replace( FeaturesController::class, $this->features_controller_mock );
+ }
+
+ /**
+ * Resets the cached enablement state on the container's PushNotifications
+ * instance so subsequent `should_be_enabled()` calls re-evaluate.
+ */
+ protected function reset_push_notifications_cache() {
+ $push_notifications = wc_get_container()->get( PushNotifications::class );
+ $reflection = new ReflectionClass( $push_notifications );
+ $property = $reflection->getProperty( 'enabled' );
+
+ $property->setAccessible( true );
+ $property->setValue( $push_notifications, null );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationPreferencesServiceTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationPreferencesServiceTest.php
new file mode 100644
index 00000000000..67b5ada0413
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationPreferencesServiceTest.php
@@ -0,0 +1,351 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Services;
+
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\NotificationPreferencesDataStore;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationPreferencesService;
+use PHPUnit\Framework\MockObject\MockObject;
+use WC_Data_Exception;
+use WC_Unit_Test_Case;
+use WP_Http;
+
+/**
+ * Tests for the NotificationPreferencesService class.
+ *
+ * @covers \Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationPreferencesService
+ */
+class NotificationPreferencesServiceTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var NotificationPreferencesService
+ */
+ private $sut;
+
+ /**
+ * Mocked data store.
+ *
+ * @var NotificationPreferencesDataStore|MockObject
+ */
+ private $data_store;
+
+ /**
+ * An arbitrary test user ID.
+ *
+ * @var int
+ */
+ private int $user_id = 42;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->data_store = $this->createMock( NotificationPreferencesDataStore::class );
+ $this->sut = new NotificationPreferencesService();
+ $this->sut->init( $this->data_store );
+ }
+
+ /**
+ * @testdox Should return defaults when the data store has no envelope for the user.
+ */
+ public function test_get_preferences_returns_defaults_when_data_store_returns_null(): void {
+ $this->data_store->method( 'read' )->willReturn( null );
+
+ $preferences = $this->sut->get_preferences( $this->user_id );
+
+ $this->assertSame( $this->sut->get_defaults(), $preferences );
+ }
+
+ /**
+ * @testdox Should overlay stored preferences on top of defaults.
+ */
+ public function test_get_preferences_returns_saved_preferences_overlaid_on_defaults(): void {
+ $this->data_store->method( 'read' )->willReturn(
+ array(
+ 'schema_version' => NotificationPreferencesDataStore::CURRENT_SCHEMA_VERSION,
+ 'preferences' => array(
+ 'store_order' => array( 'enabled' => false ),
+ ),
+ )
+ );
+
+ $preferences = $this->sut->get_preferences( $this->user_id );
+
+ $this->assertArrayHasKey( 'store_order', $preferences );
+ $this->assertArrayHasKey( 'enabled', $preferences['store_order'] );
+ $this->assertFalse( $preferences['store_order']['enabled'] );
+
+ $this->assertArrayHasKey( 'store_review', $preferences );
+ $this->assertArrayHasKey( 'enabled', $preferences['store_review'] );
+ $this->assertTrue( $preferences['store_review']['enabled'] );
+ }
+
+ /**
+ * @testdox Should fall back to defaults when the stored envelope has empty preferences.
+ */
+ public function test_get_preferences_overlays_defaults_when_stored_preferences_is_empty(): void {
+ $this->data_store->method( 'read' )->willReturn(
+ array(
+ 'schema_version' => NotificationPreferencesDataStore::CURRENT_SCHEMA_VERSION,
+ 'preferences' => array(),
+ )
+ );
+
+ $preferences = $this->sut->get_preferences( $this->user_id );
+
+ $this->assertSame( $this->sut->get_defaults(), $preferences );
+ }
+
+ /**
+ * @testdox Should write the merged envelope to the data store on save.
+ */
+ public function test_save_preferences_calls_data_store_with_correctly_built_envelope(): void {
+ $this->data_store->method( 'read' )->willReturn( null );
+
+ $this->data_store
+ ->expects( $this->once() )
+ ->method( 'write' )
+ ->with(
+ $this->user_id,
+ $this->callback(
+ function ( $envelope ) {
+ return NotificationPreferencesDataStore::CURRENT_SCHEMA_VERSION === $envelope['schema_version']
+ && false === $envelope['preferences']['store_order']['enabled']
+ && true === $envelope['preferences']['store_review']['enabled'];
+ }
+ )
+ );
+
+ $this->sut->save_preferences(
+ $this->user_id,
+ array( 'store_order' => array( 'enabled' => false ) )
+ );
+ }
+
+ /**
+ * @testdox Should return the merged preferences map after save.
+ */
+ public function test_save_preferences_returns_merged_map(): void {
+ $this->data_store->method( 'read' )->willReturn( null );
+
+ $result = $this->sut->save_preferences(
+ $this->user_id,
+ array(
+ 'store_order' => array( 'enabled' => false ),
+ 'store_review' => array( 'enabled' => false ),
+ )
+ );
+
+ $this->assertArrayHasKey( 'store_order', $result );
+ $this->assertFalse( $result['store_order']['enabled'] );
+ $this->assertArrayHasKey( 'store_review', $result );
+ $this->assertFalse( $result['store_review']['enabled'] );
+ }
+
+ /**
+ * @testdox Should merge a partial save with previously stored preferences.
+ */
+ public function test_save_preferences_merges_with_existing_preferences(): void {
+ $this->data_store->method( 'read' )->willReturn(
+ array(
+ 'schema_version' => NotificationPreferencesDataStore::CURRENT_SCHEMA_VERSION,
+ 'preferences' => array(
+ 'store_order' => array( 'enabled' => false ),
+ 'store_review' => array( 'enabled' => false ),
+ ),
+ )
+ );
+
+ $result = $this->sut->save_preferences(
+ $this->user_id,
+ array( 'store_review' => array( 'enabled' => true ) )
+ );
+
+ $this->assertFalse( $result['store_order']['enabled'] );
+ $this->assertTrue( $result['store_review']['enabled'] );
+ }
+
+ /**
+ * @testdox Should drop unknown top-level preference keys before writing.
+ */
+ public function test_save_preferences_drops_unknown_keys(): void {
+ $this->data_store->method( 'read' )->willReturn( null );
+
+ $this->data_store
+ ->expects( $this->once() )
+ ->method( 'write' )
+ ->with(
+ $this->user_id,
+ $this->callback(
+ function ( $envelope ) {
+ return ! array_key_exists( 'store_abandoned_cart', $envelope['preferences'] );
+ }
+ )
+ );
+
+ $result = $this->sut->save_preferences(
+ $this->user_id,
+ array(
+ 'store_order' => array( 'enabled' => false ),
+ 'store_abandoned_cart' => array( 'enabled' => true ),
+ )
+ );
+
+ $this->assertArrayNotHasKey( 'store_abandoned_cart', $result );
+ }
+
+ /**
+ * @testdox Should drop unknown sub-fields within a known preference before writing.
+ */
+ public function test_save_preferences_drops_unknown_sub_fields(): void {
+ $this->data_store->method( 'read' )->willReturn( null );
+
+ $result = $this->sut->save_preferences(
+ $this->user_id,
+ array(
+ 'store_order' => array(
+ 'enabled' => true,
+ 'future_unknown' => 'should be dropped',
+ ),
+ )
+ );
+
+ $this->assertArrayHasKey( 'store_order', $result );
+ $this->assertArrayHasKey( 'enabled', $result['store_order'] );
+ $this->assertArrayNotHasKey( 'future_unknown', $result['store_order'] );
+ }
+
+ /**
+ * @testdox Should propagate WC_Data_Exception thrown by the data store.
+ */
+ public function test_save_preferences_propagates_data_store_exception(): void {
+ $this->data_store->method( 'read' )->willReturn( null );
+ $this->data_store->method( 'write' )->willThrowException(
+ new WC_Data_Exception(
+ 'woocommerce_push_notification_preferences_save_failed',
+ 'Failed to save push notification preferences.',
+ WP_Http::INTERNAL_SERVER_ERROR
+ )
+ );
+
+ $this->expectException( WC_Data_Exception::class );
+
+ $this->sut->save_preferences(
+ $this->user_id,
+ array( 'store_order' => array( 'enabled' => false ) )
+ );
+ }
+
+ /**
+ * @testdox Should perform a deep merge so partial updates preserve unrelated sub-fields.
+ *
+ * Locks in the contract for forward-compatible sub-fields. When stored preferences contain
+ * multiple sub-fields per pref (e.g. RSM-1550's `min_amount` alongside `enabled`), a partial
+ * update that only sends one sub-field must not clobber the others. With a shallow merge
+ * (`array_merge`), the entire sub-object is replaced; with a deep merge
+ * (`array_replace_recursive`), only the specified sub-fields are overridden.
+ *
+ * Today's schema only has `enabled` per pref, so the bug is invisible. This test extends
+ * the schema via an anonymous subclass to exercise the multi-sub-field case the future
+ * tickets rely on.
+ */
+ public function test_save_preferences_deep_merges_partial_updates(): void {
+ $service = new class() extends NotificationPreferencesService {
+ /**
+ * Extended schema for the test: a second sub-field alongside `enabled`.
+ *
+ * @return array<string, array<string, mixed>>
+ */
+ public function get_defaults(): array {
+ return array(
+ 'store_order' => array(
+ 'enabled' => true,
+ 'min_amount' => 0,
+ ),
+ );
+ }
+
+ /**
+ * Permissive sanitize for the test: preserve every sub-key in the default shape,
+ * coercing to the type implied by its default value.
+ *
+ * @param string $key Preference key.
+ * @param array $value Submitted sub-options.
+ * @param array<string, mixed> $default_shape Default sub-options.
+ * @return array<string, mixed>
+ */
+ protected function sanitize_value( string $key, array $value, array $default_shape ): array {
+ $sanitized = array();
+ foreach ( $default_shape as $sub_key => $sub_default ) {
+ if ( ! array_key_exists( $sub_key, $value ) ) {
+ $sanitized[ $sub_key ] = $sub_default;
+ continue;
+ }
+ if ( is_bool( $sub_default ) ) {
+ $sanitized[ $sub_key ] = (bool) $value[ $sub_key ];
+ } elseif ( is_int( $sub_default ) ) {
+ $sanitized[ $sub_key ] = (int) $value[ $sub_key ];
+ } else {
+ $sanitized[ $sub_key ] = $value[ $sub_key ];
+ }
+ }
+ return $sanitized;
+ }
+ };
+ $service->init( $this->data_store );
+
+ // Stored state already has a non-default `min_amount`.
+ $this->data_store->method( 'read' )->willReturn(
+ array(
+ 'schema_version' => NotificationPreferencesDataStore::CURRENT_SCHEMA_VERSION,
+ 'preferences' => array(
+ 'store_order' => array(
+ 'enabled' => true,
+ 'min_amount' => 500,
+ ),
+ ),
+ )
+ );
+
+ // Verify that a partial update of just `enabled` preserves `min_amount`.
+ $this->data_store
+ ->expects( $this->once() )
+ ->method( 'write' )
+ ->with(
+ $this->anything(),
+ $this->callback(
+ function ( $envelope ) {
+ $prefs = $envelope['preferences']['store_order'];
+ return false === $prefs['enabled'] && 500 === $prefs['min_amount'];
+ }
+ )
+ );
+
+ $service->save_preferences(
+ $this->user_id,
+ array( 'store_order' => array( 'enabled' => false ) )
+ );
+ }
+
+ /**
+ * @testdox Should return a nested-object default for every known notification type.
+ */
+ public function test_get_defaults_includes_all_notification_types(): void {
+ $defaults = $this->sut->get_defaults();
+
+ $this->assertIsArray( $defaults );
+ $this->assertArrayHasKey( 'store_order', $defaults );
+ $this->assertArrayHasKey( 'store_review', $defaults );
+
+ foreach ( $defaults as $type => $shape ) {
+ $this->assertIsArray( $shape, "Default for {$type} should be an object/array." );
+ $this->assertArrayHasKey( 'enabled', $shape, "Default for {$type} should have an `enabled` sub-field." );
+ $this->assertIsBool( $shape['enabled'] );
+ }
+ }
+}