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