Commit 562c9eef88 for woocommerce

commit 562c9eef88f1a73730f0e08779997b23bdaaf370
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date:   Mon Feb 2 12:12:28 2026 +0000

    Adds create and delete functionality to push token controller (#62968)

    Adds create method functionality, and delete method to controller.

diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
index 41e8bdaafe..6f509d1ec7 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
@@ -15,6 +15,7 @@ use InvalidArgumentException;
 use Exception;
 use WP_REST_Server;
 use WP_REST_Request;
+use WP_REST_Response;
 use WP_Error;
 use WP_Http;

@@ -73,6 +74,20 @@ class PushTokenRestController extends RestApiControllerBase {
 				),
 			)
 		);
+
+		register_rest_route(
+			$this->get_rest_api_namespace(),
+			$this->rest_base . '/(?P<id>[\d]+)',
+			array(
+				array(
+					'methods'             => WP_REST_Server::DELETABLE,
+					'callback'            => fn ( WP_REST_Request $request ) => $this->run( $request, 'delete' ),
+					'args'                => $this->get_args( 'delete' ),
+					'permission_callback' => array( $this, 'authorize' ),
+					'schema'              => array( $this, 'get_schema' ),
+				),
+			)
+		);
 	}

 	/**
@@ -80,10 +95,68 @@ class PushTokenRestController extends RestApiControllerBase {
 	 *
 	 * @since 10.6.0
 	 *
-	 * @return void
+	 * @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 create(): void {
-		// Functionality to be added later.
+	public function create( WP_REST_Request $request ) {
+		try {
+			$push_token = new PushToken();
+			$push_token->set_user_id( get_current_user_id() );
+			$push_token->set_token( $request->get_param( 'token' ) );
+			$push_token->set_platform( $request->get_param( 'platform' ) );
+			$push_token->set_device_uuid( $request->get_param( 'device_uuid' ) );
+			$push_token->set_origin( $request->get_param( 'origin' ) );
+
+			$data_store = wc_get_container()->get( PushTokensDataStore::class );
+
+			$existing_token = clone $push_token;
+			$existing_token = $data_store->get_by_token_or_device_id( $existing_token );
+
+			if ( $existing_token ) {
+				$push_token->set_id( (int) $existing_token->get_id() );
+				$data_store->update( $push_token );
+			} else {
+				$data_store->create( $push_token );
+			}
+		} catch ( Exception $e ) {
+			return $this->convert_exception_to_wp_error( $e );
+		}
+
+		return new WP_REST_Response(
+			array( 'id' => $push_token->get_id() ),
+			WP_Http::CREATED
+		);
+	}
+
+	/**
+	 * Deletes a push token record.
+	 *
+	 * @since 10.6.0
+	 *
+	 * @param WP_REST_Request $request The request object.
+	 * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+	 * @throws PushTokenNotFoundException If token does not belong to authenticated user.
+	 * @return WP_REST_Response|WP_Error
+	 */
+	public function delete( WP_REST_Request $request ) {
+		try {
+			$push_token = new PushToken();
+			$push_token->set_id( (int) $request->get_param( 'id' ) );
+
+			$data_store = wc_get_container()->get( PushTokensDataStore::class );
+			$data_store->read( $push_token );
+
+			if ( $push_token->get_user_id() !== get_current_user_id() ) {
+				throw new PushTokenNotFoundException();
+			}
+
+			$data_store->delete( $push_token );
+		} catch ( Exception $e ) {
+			return $this->convert_exception_to_wp_error( $e );
+		}
+
+		return new WP_REST_Response( null, WP_Http::NO_CONTENT );
 	}

 	/**
@@ -263,25 +336,6 @@ class PushTokenRestController extends RestApiControllerBase {
 			return false;
 		}

-		if ( $request->has_param( 'id' ) ) {
-			$push_token = new PushToken();
-			$push_token->set_id( (int) $request->get_param( 'id' ) );
-
-			try {
-				wc_get_container()->get( PushTokensDataStore::class )->read( $push_token );
-			} catch ( Exception $e ) {
-				return $this->convert_exception_to_wp_error( $e );
-			}
-
-			if ( $push_token->get_user_id() !== get_current_user_id() ) {
-				return new WP_Error(
-					'rest_invalid_push_token',
-					'Push token could not be found.',
-					array( 'status' => WP_Http::NOT_FOUND )
-				);
-			}
-		}
-
 		return true;
 	}

@@ -297,8 +351,8 @@ class PushTokenRestController extends RestApiControllerBase {
 		$exception_class = get_class( $e );

 		$slugs = array(
-			PushTokenNotFoundException::class => 'rest_invalid_push_token',
-			InvalidArgumentException::class   => 'rest_invalid_argument',
+			PushTokenNotFoundException::class => 'woocommerce_rest_invalid_push_token',
+			InvalidArgumentException::class   => 'woocommerce_rest_invalid_argument',
 		);

 		$statuses = array(
@@ -306,7 +360,7 @@ class PushTokenRestController extends RestApiControllerBase {
 			InvalidArgumentException::class   => WP_Http::BAD_REQUEST,
 		);

-		$slug    = $slugs[ $exception_class ] ?? 'rest_internal_error';
+		$slug    = $slugs[ $exception_class ] ?? 'woocommerce_rest_internal_error';
 		$status  = $statuses[ $exception_class ] ?? WP_Http::INTERNAL_SERVER_ERROR;
 		$message = ! isset( $slugs[ $exception_class ] ) ? 'Internal server error' : $e->getMessage();

diff --git a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
index 40f40f51cb..0cff271282 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
@@ -81,7 +81,7 @@ class PushTokensDataStore {
 		$post = get_post( $push_token->get_id() );

 		if ( ! $post || PushToken::POST_TYPE !== $post->post_type ) {
-			throw new PushTokenNotFoundException( 'Push token could not be found.' );
+			throw new PushTokenNotFoundException();
 		}

 		$meta = $this->build_meta_array_from_database( $push_token );
@@ -127,8 +127,7 @@ class PushTokensDataStore {
 		$post = get_post( $push_token->get_id() );

 		if ( ! $post || PushToken::POST_TYPE !== $post->post_type ) {
-			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
-			throw new PushTokenNotFoundException( 'Push token could not be found.' );
+			throw new PushTokenNotFoundException();
 		}

 		$result = wp_update_post(
@@ -171,7 +170,7 @@ class PushTokensDataStore {
 		$post = get_post( $push_token->get_id() );

 		if ( ! $post || PushToken::POST_TYPE !== $post->post_type ) {
-			throw new PushTokenNotFoundException( 'Push token could not be found.' );
+			throw new PushTokenNotFoundException();
 		}

 		wp_delete_post( (int) $push_token->get_id(), true );
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Exceptions/PushTokenNotFoundException.php b/plugins/woocommerce/src/Internal/PushNotifications/Exceptions/PushTokenNotFoundException.php
index 08e0ea9f16..509fdc47f3 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Exceptions/PushTokenNotFoundException.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Exceptions/PushTokenNotFoundException.php
@@ -17,4 +17,11 @@ use WP_Http;
  *
  * @since 10.5.0
  */
-class PushTokenNotFoundException extends Exception {}
+class PushTokenNotFoundException extends Exception {
+	/**
+	 * Default exception message.
+	 *
+	 * @var string
+	 */
+	protected $message = 'Push token could not be found.';
+}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/ROADMAP.md b/plugins/woocommerce/src/Internal/PushNotifications/ROADMAP.md
new file mode 100644
index 0000000000..8e414e3a36
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/ROADMAP.md
@@ -0,0 +1,224 @@
+# WooCommerce-driven Push Notification: Roadmap
+
+This document is intended to describe the purpose and planned changes for the WooCommerce: Self-driven Push Notifications project, to assist folks in reviewing related PRs and understanding the wider context of the changes.
+
+The following terms have been used in this post to mean:
+
+- **"WPCOM"**: WordPress.com hosted infrastructure, including the public API, databases, and asynchronous jobs system
+- **"Remote WooCommerce Site"**: the blog running WordPress and WooCommerce, which could be self-hosted, a WordPress.com hosted simple site, a WP Cloud hosted site, etc
+
+## Contents
+
+- Goals
+- Key Flows
+    - Before
+    - After
+- Data models
+    - Push Token
+    - Push Notification Preferences
+- API Surface
+- Steps
+    - Add foundations to support the push notification functionality
+    - Add token registration/unregistration endpoint
+    - Add asynchronous internal send endpoint
+    - Add send functionality
+    - Add retry functionality
+    - Add new booking notification
+    - Update notification setting controls
+- Notes
+    - Failures/retries
+    - Users who don't connect Jetpack
+    - Users who don't upgrade WooCommerce
+    - WordPress.com Notification Control Center preferences
+
+## Goals
+
+We are adding the ability for WooCommerce to trigger its own Android and iOS push notifications. The reasons are discussed in more detail in this post, but briefly, the goals of this will be:
+
+- The Jetpack Sync plugin will no longer be required to enable push notifications for sites
+- Reduced complexity and easier debugging of the push notifications send process (which currently requires tracking orders/reviews through multiple systems)
+- Ability to send notifications to site users who do not have a connected WordPress.com account (e.g. shop managers)
+- Improved ease of adding new notifications (which currently requires changes in the apps, WPCOM, and Jetpack Sync)
+- WooCommerce notifications not creating noise in the WordPress.com masterbar
+
+The new functionality will be available for sites that:
+
+- Have the Jetpack connection plugin installed (which now ships with WooCommerce Core)
+- Have an active Jetpack connection
+- Have the wc_push_notifications feature enabled
+
+## Key flows
+
+### Before
+
+This section summarises the way WooCommerce push notifications currently work.
+
+**Push token registration:**
+
+1. User logs into site on app
+2. App retrieves a push token from the device (which is a string we can use to send notifications to the device)
+3. App sends token to WPCOM with information about the owner, app, and device
+4. WPCOM stores the token, deduplicating it to ensure only one token with this value or device UUID exists for the user
+
+**Push notification trigger/send:**
+
+1. Order is placed on remote WooCommerce site (or review is created)
+2. Remote WooCommerce site triggers a Jetpack Sync request
+3. Order is sync'ed from the remote WooCommerce site to WPCOM
+4. If WPCOM detects that a notification has not already been sent for the order (via meta), then a WPCOM note is created and a push notification is triggered for this note
+5. The push notifications infra generates a payload from the note, and sends it via FCM (Android, browsers) or APNS (Apple) to registered tokens belonging to any admins or shop managers of that site
+
+**Push notification retries:**
+
+1. If the request to FCM/APNS fails/gets an error response, a WPCOM job is queued to try again
+2. Retry delay is read from the response header (e.g. if rate limited) or 10 seconds
+3. Notification is retried a max of 4 times (so 5 total tries)
+
+### After
+
+This section summarises the way WooCommerce push notifications will work when this project is complete.
+
+**Push token registration:**
+
+1. User logs into site on app
+2. App retrieves a push token from the device
+3. App tries to register token with remote WooCommerce site - if it fails (e.g. because Woo version not supported, or Jetpack not connected), it registers the token with WPCOM instead
+4. Whichever location it end up stored in, deduplication still occurs
+
+**Push notification trigger/send:**
+
+1. Order is placed on remote WooCommerce site (or review is created)
+2. Remote WooCommerce site recognises from hooks that a notification should be triggered
+3. Internal async request is made in the checkout (or review) request shutdown to trigger the notifications
+4. Internal async endpoint makes a request to a new WPCOM endpoint to send the notifications
+5. WPCOM validates the data and returns errors if it can't attempt to send the notification
+
+**Push notification retries:**
+
+1. If the send request to WPCOM returns a failure, queue a retry using Action Scheduler
+2. Retry a max of 4 times, with exponential backoff
+3. If retries are not successful, store/log the failure
+
+## Data models
+
+### Push Token
+
+- **Description**: represents a token string that can be used to send a push notification to a mobile device.
+- **Storage**: custom post type (`wc_push_token`) - based on existing data in WordPress.com, we expect most users to have less than 10 push tokens each (usually 1-3)
+- **Meta**:
+    - **Token**: the token string that can be used to send a notification to a specific device
+    - **Device UUID**: a unique identifier representing the device, randomly generated by the app
+    - **Platform**: the platform the device belongs to, these match values used in the WPCOM push notifications infra: `apple`, `android`, or `browser`
+    - **Origin**: an environment-specific string representing the app that sent the token, `com.woocommerce.android`, `com.woocommerce.android:dev`, `com.automattic.woocommerce`, or `com.automattic.woocommerce:dev`
+- **Usage**:
+    - Register/create push token endpoint: lookup by user ID, save new entry/update existing entry by token ID, retrieve meta by token ID, save meta
+    - Unregister/delete push token endpoint: lookup by token ID, delete by token ID
+    - Async send endpoint: lookup all, retrieve meta by token ID
+
+### Push Notification Preferences
+
+- **Description**: Represents preferences for the notifications and devices the user has enabled/disabled notifications for
+- **Storage**: custom post type (`wc_push_notification_preferences`) - content is a JSON encoded list of enabled notifications or map of notifications to enabled status (true/false)
+- **Usage**:
+    - Update notification preferences endpoint: lookup by user ID, create entry, update entry by entry ID
+    - Async send endpoint: lookup by user ID
+
+## API surface
+
+This library will be in the `src/Internal` directory and is not intended to be used by external users/developers.
+
+### New API endpoints
+
+- **Register token:**
+    - Endpoint: `POST /wp-json/wc-push-notifications/push-tokens`
+    - Auth: user-identifying token
+
+- **Unregister token:**
+    - Endpoint: `DELETE /wp-json/wc-push-notifications/push-tokens/{id}`
+    - Auth: user-identifying token
+
+- **Get notification preferences:**
+    - Endpoint: `GET /wp-json/wc-push-notifications/preferences`
+    - Auth: user-identifying token
+
+- **Save notification preferences:**
+    - Endpoint: `POST /wp-json/wc-push-notifications/preferences`
+    - Auth: user-identifying token
+
+- **Async send triggered notifications:**
+    - Endpoint: `POST /wp-json/wc-push-notifications/send`
+    - Auth: token generated by the async dispatcher, verified in the async endpoint
+
+## Steps
+
+1. **Add foundations to support the push notification functionality**
+   - FeaturesController entry
+   - Push token entity/DTO
+   - Push token data store (CRUD + the ability to find a token which matches a token value or device ID)
+   - Any required exception classes
+   - Feature class that will load relevant files if the push notification functionality should be enabled (i.e. if Jetpack connected and feature is enabled)
+
+2. **Add token registration/unregistration endpoint:**
+   - This will be used to register and unregister push tokens generated by the device on the remote WooCommerce site
+   - It will validate token and device UUID formats, and enum values for app platform and origin
+   - If a token already exists for this user with either a matching device UUID or token value, it will update the existing version, not create a duplicate
+   - It will authorize access for authenticated users with a valid role (admin, shop manager)
+
+3. **Add asynchronous internal send endpoint:**
+   - This will be used to send a collection of notifications to WPCOM
+   - It will be used by requests that trigger notifications to actually send those notifications
+   - It will authorize the request based on a temporarily stored token with an expiry (instead of a transient or nonce, in order to support multi-server/loadbalanced sites)
+   - It will set appropriate timeouts to ensure the async process can't be stuck 'processing', and use database locks to ensure that notifications for the order/review can only be processed once.
+
+4. ** Add get notification preferences endpoint:**
+   - This will be used to get the notification preferences for the logged in user for each notification type and each of their devices
+   - Available for admins and shop managers
+   - Will allow access via WP Admin and via the app
+
+5. **Add update notification preferences endpoint:**
+   - This will be used to update the notification preferences for the logged in user for each notification type and each of their devices
+   - Available for admins and shop managers
+   - Will allow access via WP Admin and via the app
+
+6. **Add send functionality:**
+   - Will respond to `woocommerce_new_order` and `woocommerce_order_status_changed` to send a new order notification for orders with one of the following statuses: processing, on-hold, completed, pre-order, pre-ordered, partial-payment
+   - Will respond to `comment_post` to send a review notification for comments of the type review
+   - Triggered notifications will be 'remembered' during the request, and then sent asynchronously to an internal endpoint during the request shutdown process - this should avoid delaying/slowing the current request for the user. The process will generate and store a token with an expiry, that will be verified and consumed by the internal async endpoint
+   - The internal async endpoint will send the notifications to a new WPCOM endpoint (authenticating using the Jetpack site token) which will do some validation before sending the notification through the WPCOM push notifications infra
+   - If the request fails or returns an error, the notifications will be queued for retry via the Action Scheduler
+
+7. **Add retry functionality:**
+   - Retries will be attempted a max of 4 times, resulting in a total of 5 send attempts
+   - Retry delay will be based on headers (e.g. in case of rate limiting) where present, or will use exponential backoff (60s, 5m, 15m, 60m)
+   - The Action Scheduler will be used to queue retries
+   - If all retries fail, it will log (and potentially store) the failures for debugging later
+
+8. **Add new booking notification:**
+   - Will be triggered when a new booking is made
+   - Will be processed in the same way as other notifications
+
+9. **Update notification setting controls:**
+   - WooCommerce notifications for compatible sites will be hidden in the notification controls in WordPress.com
+   - WooCommerce notifications controls will be added in WooCommerce Core settings
+   - Will use the notifications preferences endpoint mentioned above
+
+## Notes
+
+### Failures/retries
+
+- Due to WPCOM sending notifications asynchronously, a successful response from the WPCOM send endpoint does not mean the notification was successfully sent to APNS/FCM, only that WPCOM committed to making the request
+- Failures to send to APNS/FCM will be handled by WPCOM's retry system, failures to send to WPCOM will be handled by WooCommerce's retry system.
+- A successful response from FCM/APNS doesn't mean the notification was successfully delivered, only that FCM/APNS committed to trying to deliver it. We can't control/detect whether the notification was actually delivered from the server side, or the reason if it wasn't.
+
+### Users who don't connect Jetpack
+
+These users won't receive push notifications - this is the same as the existing behaviour, which also requires a Jetpack connection.
+
+### Users who don't upgrade WooCommerce
+
+- Notifications for these users will be processed in the existing way - via Jetpack sync
+- Some users may have old and updated WooCommerce stores, meaning that they have tokens in both systems. We investigated avoiding duplicate notifications for these users here
+
+### WordPress.com Notification Control Center preferences
+
+- These will still be respected for all WooCommerce notifications, as it is part of our duplicate notification avoidance strategy
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 79d7bdea19..626b8b2db1 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
@@ -29,12 +29,33 @@ use WP_REST_Request;
  */
 class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 	/**
-	 * User ID for testing.
+	 * Shop manager user ID for testing.
 	 *
 	 * @var int
 	 */
 	private $user_id;

+	/**
+	 * Customer user ID for testing.
+	 *
+	 * @var int
+	 */
+	private $customer_id;
+
+	/**
+	 * Another shop manager user ID for testing.
+	 *
+	 * @var int
+	 */
+	private $other_shop_manager_id;
+
+	/**
+	 * Subscriber user ID for testing.
+	 *
+	 * @var int
+	 */
+	private $subscriber_id;
+
 	/**
 	 * @var JetpackConnectionManager|MockObject
 	 */
@@ -56,7 +77,10 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {

 		( new PushTokenRestController() )->register_routes();

-		$this->user_id = $this->factory->user->create( array( 'role' => 'shop_manager' ) );
+		$this->user_id               = $this->factory->user->create( array( 'role' => 'shop_manager' ) );
+		$this->customer_id           = $this->factory->user->create( array( 'role' => 'customer' ) );
+		$this->other_shop_manager_id = $this->factory->user->create( array( 'role' => 'shop_manager' ) );
+		$this->subscriber_id         = $this->factory->user->create( array( 'role' => 'subscriber' ) );
 	}

 	/**
@@ -65,12 +89,202 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 	public function tearDown(): void {
 		wp_set_current_user( 0 );

+		wp_delete_user( $this->user_id );
+		wp_delete_user( $this->customer_id );
+		wp_delete_user( $this->other_shop_manager_id );
+		wp_delete_user( $this->subscriber_id );
+
 		$this->reset_container_replacements();
 		wc_get_container()->reset_all_resolved();

 		parent::tearDown();
 	}

+	/**
+	 * @testdox Test it can create a push token for iOS.
+	 */
+	public function test_it_can_create_push_token_for_ios() {
+		wp_set_current_user( $this->user_id );
+
+		$this->mock_jetpack_connection_manager_is_connected( true );
+
+		$token_value = str_repeat( 'a', 64 );
+		$device_uuid = 'test-device-uuid-123';
+
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
+		$request->set_param( 'token', $token_value );
+		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
+		$request->set_param( 'device_uuid', $device_uuid );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::CREATED, $response->get_status() );
+
+		$data = $response->get_data();
+
+		$this->assertArrayHasKey( 'id', $data );
+		$this->assertIsInt( $data['id'] );
+		$this->assertGreaterThan( 0, $data['id'] );
+
+		$this->assert_token_was_persisted(
+			$data['id'],
+			$this->user_id,
+			$token_value,
+			PushToken::PLATFORM_APPLE,
+			$device_uuid,
+			PushToken::ORIGIN_WOOCOMMERCE_IOS
+		);
+	}
+
+	/**
+	 * @testdox Test it can create a push token for Android.
+	 */
+	public function test_it_can_create_push_token_for_android() {
+		wp_set_current_user( $this->user_id );
+
+		$this->mock_jetpack_connection_manager_is_connected( true );
+
+		$token_value = 'test_android_token_123';
+		$device_uuid = 'test-device-uuid-456';
+
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
+		$request->set_param( 'token', $token_value );
+		$request->set_param( 'platform', PushToken::PLATFORM_ANDROID );
+		$request->set_param( 'device_uuid', $device_uuid );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_ANDROID );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::CREATED, $response->get_status() );
+
+		$data = $response->get_data();
+
+		$this->assertArrayHasKey( 'id', $data );
+		$this->assertIsInt( $data['id'] );
+		$this->assertGreaterThan( 0, $data['id'] );
+
+		$this->assert_token_was_persisted(
+			$data['id'],
+			$this->user_id,
+			$token_value,
+			PushToken::PLATFORM_ANDROID,
+			$device_uuid,
+			PushToken::ORIGIN_WOOCOMMERCE_ANDROID
+		);
+	}
+
+	/**
+	 * @testdox Test it updates an existing push token by token value.
+	 */
+	public function test_it_updates_existing_token_by_token_value() {
+		wp_set_current_user( $this->user_id );
+
+		$this->mock_jetpack_connection_manager_is_connected( true );
+
+		$token_value = str_repeat( 'a', 64 );
+
+		/**
+		 * Create initial token.
+		 */
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
+		$request->set_param( 'token', $token_value );
+		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
+		$request->set_param( 'device_uuid', 'device-1' );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::CREATED, $response->get_status() );
+
+		$first_id = $response->get_data()['id'];
+
+		/**
+		 * Create again with the same token but different device UUID.
+		 */
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
+		$request->set_param( 'token', $token_value );
+		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
+		$request->set_param( 'device_uuid', 'device-2' );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::CREATED, $response->get_status() );
+
+		$second_id = $response->get_data()['id'];
+
+		$this->assertEquals( $first_id, $second_id );
+
+		/**
+		 * Verify the token was updated in the database with the new device UUID.
+		 */
+		$this->assert_token_was_persisted(
+			$first_id,
+			$this->user_id,
+			$token_value,
+			PushToken::PLATFORM_APPLE,
+			'device-2',
+			PushToken::ORIGIN_WOOCOMMERCE_IOS
+		);
+	}
+
+	/**
+	 * @testdox Test it updates an existing push token by device UUID.
+	 */
+	public function test_it_updates_existing_token_by_device_uuid() {
+		wp_set_current_user( $this->user_id );
+
+		$this->mock_jetpack_connection_manager_is_connected( true );
+
+		$device_uuid = 'device-uuid-constant';
+		$new_token   = str_repeat( 'b', 64 );
+
+		/**
+		 * Create initial token.
+		 */
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
+		$request->set_param( 'token', str_repeat( 'a', 64 ) );
+		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
+		$request->set_param( 'device_uuid', $device_uuid );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::CREATED, $response->get_status() );
+
+		$first_id = $response->get_data()['id'];
+
+		/**
+		 * Create again with different token but same device UUID.
+		 */
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
+		$request->set_param( 'token', $new_token );
+		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
+		$request->set_param( 'device_uuid', $device_uuid );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::CREATED, $response->get_status() );
+
+		$second_id = $response->get_data()['id'];
+
+		$this->assertEquals( $first_id, $second_id );
+
+		/**
+		 * Verify the token was updated in the database with the new token value.
+		 */
+		$this->assert_token_was_persisted(
+			$first_id,
+			$this->user_id,
+			$new_token,
+			PushToken::PLATFORM_APPLE,
+			$device_uuid,
+			PushToken::ORIGIN_WOOCOMMERCE_IOS
+		);
+	}
+
 	/**
 	 * @testdox Test it cannot create a push token without authentication.
 	 */
@@ -94,8 +308,7 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 	 * @testdox Test it cannot create a push token without required role.
 	 */
 	public function test_it_cannot_create_push_token_without_required_role() {
-		$customer_id = $this->factory->user->create( array( 'role' => 'customer' ) );
-		wp_set_current_user( $customer_id );
+		wp_set_current_user( $this->customer_id );

 		$this->mock_jetpack_connection_manager_is_connected( true );

@@ -456,25 +669,190 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Test it cannot create a push token when push notifications are
-	 * disabled.
+	 * @testdox Test it can delete a push token.
 	 */
-	public function test_it_cannot_create_push_token_when_push_notifications_disabled() {
+	public function test_it_can_delete_push_token() {
 		wp_set_current_user( $this->user_id );

-		$this->mock_jetpack_connection_manager_is_connected( false );
+		$this->mock_jetpack_connection_manager_is_connected( true );

-		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
-		$request->set_param( 'token', str_repeat( 'a', 64 ) );
-		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
-		$request->set_param( 'device_uuid', 'test-device-uuid-123' );
-		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		/**
+		 * Create a token first.
+		 */
+		$push_token = new PushToken();
+		$push_token->set_user_id( $this->user_id );
+		$push_token->set_token( str_repeat( 'a', 64 ) );
+		$push_token->set_platform( PushToken::PLATFORM_APPLE );
+		$push_token->set_device_uuid( 'device-to-delete' );
+		$push_token->set_origin( PushToken::ORIGIN_WOOCOMMERCE_IOS );
+
+		$data_store = wc_get_container()->get( PushTokensDataStore::class );
+		$data_store->create( $push_token );
+		$token_id = $push_token->get_id();
+
+		/**
+		 * Delete the token.
+		 */
+		$request  = new WP_REST_Request( 'DELETE', '/wc-push-notifications/push-tokens/' . $token_id );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::NO_CONTENT, $response->get_status() );
+		$this->assertNull( $response->get_data() );
+
+		/**
+		 * Verify the token was deleted from the database.
+		 */
+		$this->assertNull( get_post( $token_id ) );
+	}

+	/**
+	 * @testdox Test it can't delete a push token without being authenticated.
+	 */
+	public function test_it_cannot_delete_push_token_without_authentication() {
+		$request  = new WP_REST_Request( 'DELETE', '/wc-push-notifications/push-tokens/123' );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::UNAUTHORIZED, $response->get_status() );
+	}
+
+	/**
+	 * @testdox Test it can't delete a push token without required role.
+	 */
+	public function test_it_cannot_delete_push_token_without_required_role() {
+		wp_set_current_user( $this->customer_id );
+
+		$this->mock_jetpack_connection_manager_is_connected( true );
+
+		$request  = new WP_REST_Request( 'DELETE', '/wc-push-notifications/push-tokens/123' );
 		$response = $this->server->dispatch( $request );

 		$this->assertEquals( WP_Http::FORBIDDEN, $response->get_status() );
 	}

+	/**
+	 * @testdox Test it can't delete a push token that doesn't belong to the
+	 * authenticated user.
+	 */
+	public function test_it_cannot_delete_push_token_belonging_to_another_user() {
+		/**
+		 * Create a token for another shop manager.
+		 */
+		$push_token = new PushToken();
+		$push_token->set_user_id( $this->other_shop_manager_id );
+		$push_token->set_token( str_repeat( 'a', 64 ) );
+		$push_token->set_platform( PushToken::PLATFORM_APPLE );
+		$push_token->set_device_uuid( 'device-other-user' );
+		$push_token->set_origin( PushToken::ORIGIN_WOOCOMMERCE_IOS );
+
+		$data_store = wc_get_container()->get( PushTokensDataStore::class );
+		$data_store->create( $push_token );
+		$token_id = $push_token->get_id();
+
+		/**
+		 * Try to delete as a different user.
+		 */
+		wp_set_current_user( $this->user_id );
+
+		$this->mock_jetpack_connection_manager_is_connected( true );
+
+		$request  = new WP_REST_Request( 'DELETE', '/wc-push-notifications/push-tokens/' . $token_id );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::NOT_FOUND, $response->get_status() );
+
+		$data = $response->get_data();
+
+		$this->assertEquals( 'woocommerce_rest_invalid_push_token', $data['code'] );
+		$this->assertEquals( 'Push token could not be found.', $data['message'] );
+	}
+
+	/**
+	 * @testdox Test it gets 404 response trying to delete a push token that
+	 * doesn't exist.
+	 */
+	public function test_it_cannot_delete_push_token_that_does_not_exist() {
+		wp_set_current_user( $this->user_id );
+
+		$this->mock_jetpack_connection_manager_is_connected( true );
+
+		$request  = new WP_REST_Request( 'DELETE', '/wc-push-notifications/push-tokens/999999' );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::NOT_FOUND, $response->get_status() );
+
+		$data = $response->get_data();
+
+		$this->assertEquals( 'woocommerce_rest_invalid_push_token', $data['code'] );
+		$this->assertEquals( 'Push token could not be found.', $data['message'] );
+	}
+
+	/**
+	 * @testdox Test authorize returns false when push notifications are
+	 * disabled.
+	 */
+	public function test_authorize_returns_false_when_push_notifications_disabled() {
+		wp_set_current_user( $this->user_id );
+
+		$this->mock_jetpack_connection_manager_is_connected( false );
+
+		$controller = new PushTokenRestController();
+		$request    = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
+
+		$result = $controller->authorize( $request );
+
+		$this->assertFalse( $result );
+	}
+
+	/**
+	 * @testdox Test authorize returns true when push notifications are enabled
+	 * and user has valid role.
+	 */
+	public function test_authorize_returns_true_when_enabled_and_valid_role() {
+		wp_set_current_user( $this->user_id );
+
+		$this->mock_jetpack_connection_manager_is_connected( true );
+
+		$controller = new PushTokenRestController();
+		$request    = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
+
+		$result = $controller->authorize( $request );
+
+		$this->assertTrue( $result );
+	}
+
+	/**
+	 * @testdox Test authorize returns WP_Error when user is not logged in.
+	 */
+	public function test_authorize_returns_error_when_not_logged_in() {
+		wp_set_current_user( 0 );
+
+		$this->mock_jetpack_connection_manager_is_connected( true );
+
+		$controller = new PushTokenRestController();
+		$request    = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
+
+		$result = $controller->authorize( $request );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertEquals( 'woocommerce_rest_cannot_view', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Test authorize returns false when user has invalid role.
+	 */
+	public function test_authorize_returns_false_when_invalid_role() {
+		wp_set_current_user( $this->subscriber_id );
+
+		$this->mock_jetpack_connection_manager_is_connected( true );
+
+		$controller = new PushTokenRestController();
+		$request    = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
+
+		$result = $controller->authorize( $request );
+
+		$this->assertFalse( $result );
+	}
+
 	/**
 	 * @testdox Test it cannot create a push token with device_uuid exceeding
 	 * 255 characters.
@@ -579,7 +957,7 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$result = $method->invoke( $controller, $exception );

 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertEquals( 'rest_internal_error', $result->get_error_code() );
+		$this->assertEquals( 'woocommerce_rest_internal_error', $result->get_error_code() );
 		$this->assertEquals( 'Internal server error', $result->get_error_message() );
 		$this->assertEquals( WP_Http::INTERNAL_SERVER_ERROR, $result->get_error_data()['status'] );
 	}
@@ -590,7 +968,7 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 	 */
 	public function test_it_exposes_message_for_push_token_not_found_exception() {
 		$controller = new PushTokenRestController();
-		$exception  = new PushTokenNotFoundException( 'Push token could not be found.' );
+		$exception  = new PushTokenNotFoundException();

 		$reflection = new ReflectionClass( $controller );
 		$method     = $reflection->getMethod( 'convert_exception_to_wp_error' );
@@ -599,7 +977,7 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$result = $method->invoke( $controller, $exception );

 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertEquals( 'rest_invalid_push_token', $result->get_error_code() );
+		$this->assertEquals( 'woocommerce_rest_invalid_push_token', $result->get_error_code() );
 		$this->assertEquals( 'Push token could not be found.', $result->get_error_message() );
 		$this->assertEquals( WP_Http::NOT_FOUND, $result->get_error_data()['status'] );
 	}
@@ -619,7 +997,7 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$result = $method->invoke( $controller, $exception );

 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertEquals( 'rest_invalid_argument', $result->get_error_code() );
+		$this->assertEquals( 'woocommerce_rest_invalid_argument', $result->get_error_code() );
 		$this->assertEquals( 'Invalid argument provided.', $result->get_error_message() );
 		$this->assertEquals( WP_Http::BAD_REQUEST, $result->get_error_data()['status'] );
 	}
@@ -640,7 +1018,7 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$result = $method->invoke( $controller, $exception );

 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertEquals( 'rest_internal_error', $result->get_error_code() );
+		$this->assertEquals( 'woocommerce_rest_internal_error', $result->get_error_code() );
 		$this->assertEquals( 'Internal server error', $result->get_error_message() );
 		$this->assertEquals( WP_Http::INTERNAL_SERVER_ERROR, $result->get_error_data()['status'] );
 	}
@@ -705,4 +1083,46 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$property->setAccessible( true );
 		$property->setValue( $push_notifications, null );
 	}
+
+	/**
+	 * Asserts that a push token was persisted correctly in the database.
+	 *
+	 * @param int    $post_id     The post ID to check.
+	 * @param int    $user_id     The expected user ID (post_author).
+	 * @param string $token       The expected token value.
+	 * @param string $platform    The expected platform.
+	 * @param string $device_uuid The expected device UUID.
+	 * @param string $origin      The expected origin.
+	 */
+	private function assert_token_was_persisted(
+		int $post_id,
+		int $user_id,
+		string $token,
+		string $platform,
+		string $device_uuid,
+		string $origin
+	) {
+		global $wpdb;
+
+		$meta_rows = $wpdb->get_results(
+			$wpdb->prepare(
+				"SELECT meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id = %d",
+				$post_id
+			),
+			ARRAY_A
+		);
+
+		$meta = array();
+		foreach ( $meta_rows as $row ) {
+			$meta[ $row['meta_key'] ] = $row['meta_value'];
+		}
+
+		$post = get_post( $post_id );
+
+		$this->assertEquals( $user_id, (int) $post->post_author );
+		$this->assertEquals( $token, $meta['token'] );
+		$this->assertEquals( $platform, $meta['platform'] );
+		$this->assertEquals( $device_uuid, $meta['device_uuid'] );
+		$this->assertEquals( $origin, $meta['origin'] );
+	}
 }