Commit ff645e3c50 for woocommerce

commit ff645e3c5000e2c28e14d73cb5d8c30ac926b609
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date:   Mon Jan 5 15:52:42 2026 +0000

    Adds remaining CRUD methods to data store (#62024)

    * Adds PushTokenDataStore for push token CRUD operations.

diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index b4b852827a..a01212f6db 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -74509,83 +74509,17 @@ parameters:
 			path: src/Internal/ProductImage/MatchImageBySKU.php

 		-
-			message: '#^Method Automattic\\WooCommerce\\Internal\\PushNotifications\\DataStores\\PushTokensDataStore\:\:create\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Internal\\PushNotifications\\DataStores\\PushTokensDataStore\:\:read\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Internal\\PushNotifications\\DataStores\\PushTokensDataStore\:\:update\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
-
-		-
-			message: '#^Parameter \#1 \$post_id of function delete_post_meta expects int, int\|null given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
-
-		-
-			message: '#^Parameter \#1 \$post_id of function get_post_meta expects int, int\|null given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
-
-		-
-			message: '#^Parameter \#1 \$postarr of function wp_insert_post expects array\{ID\?\: int, post_author\?\: int, post_date\?\: string, post_date_gmt\?\: string, post_content\?\: string, post_content_filtered\?\: string, post_title\?\: string, post_excerpt\?\: string, \.\.\.\}, array\{post_author\: int\|null, post_type\: ''push_token'', post_status\: ''private'', meta_input\: array\{platform\: string\|null, token\: string\|null, device_uuid\?\: string, origin\: string\|null\}\} given\.$#'
+			message: '#^Parameter \#1 \$id of method Automattic\\WooCommerce\\Internal\\PushNotifications\\Entities\\PushToken\:\:set_id\(\) expects int, int\|WP_Post given\.$#'
 			identifier: argument.type
-			count: 1
+			count: 2
 			path: src/Internal/PushNotifications/DataStores/PushTokensDataStore.php

 		-
-			message: '#^Parameter \#1 \$postarr of function wp_update_post expects array\{ID\?\: int, post_author\?\: int, post_date\?\: string, post_date_gmt\?\: string, post_content\?\: string, post_content_filtered\?\: string, post_title\?\: string, post_excerpt\?\: string, \.\.\.\}, array\{ID\: int\|null, post_author\: int\|null, post_type\: ''push_token'', post_status\: ''private'', meta_input\: array\{platform\: string\|null, token\: string\|null, device_uuid\?\: string, origin\: string\|null\}\} given\.$#'
+			message: '#^Parameter \#2 \$object_ids of function update_meta_cache expects array\<int\>\|string, array\<int\|WP_Post\> given\.$#'
 			identifier: argument.type
 			count: 1
 			path: src/Internal/PushNotifications/DataStores/PushTokensDataStore.php

-		-
-			message: '#^Method Automattic\\WooCommerce\\Internal\\PushNotifications\\Entities\\PushToken\:\:set_device_uuid\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: src/Internal/PushNotifications/Entities/PushToken.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Internal\\PushNotifications\\Entities\\PushToken\:\:set_id\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: src/Internal/PushNotifications/Entities/PushToken.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Internal\\PushNotifications\\Entities\\PushToken\:\:set_origin\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: src/Internal/PushNotifications/Entities/PushToken.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Internal\\PushNotifications\\Entities\\PushToken\:\:set_platform\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: src/Internal/PushNotifications/Entities/PushToken.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Internal\\PushNotifications\\Entities\\PushToken\:\:set_token\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: src/Internal/PushNotifications/Entities/PushToken.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Internal\\PushNotifications\\Entities\\PushToken\:\:set_user_id\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: src/Internal/PushNotifications/Entities/PushToken.php
-
 		-
 			message: '#^@param string \$css does not accept actual type of parameter\: string\|false\.$#'
 			identifier: parameter.phpDocType
diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
index 4634939e8f..98083025da 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -556,7 +556,7 @@ class FeaturesController {
 				'enabled_by_default'           => false,
 				'is_experimental'              => true,
 				'disable_ui'                   => true,
-				'skip_compatibility_checks'    => true,
+				'skip_compatibility_checks'    => false,
 				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
 			),
 			'rest_api_caching'                   => array(
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
index 940097f3eb..40f40f51cb 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
@@ -10,9 +10,10 @@ namespace Automattic\WooCommerce\Internal\PushNotifications\DataStores;
 defined( 'ABSPATH' ) || exit;

 use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
+use Automattic\WooCommerce\Internal\PushNotifications\Exceptions\PushTokenNotFoundException;
 use Exception;
 use InvalidArgumentException;
-use WP_Http;
+use WP_Query;

 /**
  * Data store class for push tokens.
@@ -20,6 +21,13 @@ use WP_Http;
  * @since 10.5.0
  */
 class PushTokensDataStore {
+	const SUPPORTED_META = array(
+		'origin',
+		'device_uuid',
+		'token',
+		'platform',
+	);
+
 	/**
 	 * Creates a post representing the push token.
 	 *
@@ -27,38 +35,28 @@ class PushTokensDataStore {
 	 * @param PushToken $push_token An instance of PushToken.
 	 * @throws InvalidArgumentException If the token can't be created.
 	 * @throws Exception If the token creation fails.
+	 * @return void
 	 */
-	public function create( &$push_token ) {
+	public function create( PushToken &$push_token ): void {
 		if ( ! $push_token->can_be_created() ) {
 			throw new InvalidArgumentException(
-				'Can\'t create push token because the push token data provided is invalid.',
-				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
-				WP_Http::BAD_REQUEST
+				'Can\'t create push token because the push token data provided is invalid.'
 			);
 		}

 		$id = wp_insert_post(
 			array(
-				'post_author' => $push_token->get_user_id(),
+				'post_author' => (int) $push_token->get_user_id(),
 				'post_type'   => PushToken::POST_TYPE,
 				'post_status' => 'private',
-				'meta_input'  => array_filter(
-					array(
-						'platform'    => $push_token->get_platform(),
-						'token'       => $push_token->get_token(),
-						'device_uuid' => $push_token->get_device_uuid(),
-						'origin'      => $push_token->get_origin(),
-					),
-					static fn ( $value, $key ) => 'device_uuid' !== $key || null !== $value,
-					ARRAY_FILTER_USE_BOTH
-				),
+				'meta_input'  => $this->build_meta_array_from_token( $push_token ),
 			),
 			true
 		);

 		if ( is_wp_error( $id ) ) {
 			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
-			throw new Exception( $id->get_error_message(), WP_Http::INTERNAL_SERVER_ERROR );
+			throw new Exception( $id->get_error_message() );
 		}

 		$push_token->set_id( $id );
@@ -70,26 +68,23 @@ class PushTokensDataStore {
 	 * @since 10.5.0
 	 * @param PushToken $push_token An instance of PushToken.
 	 * @throws InvalidArgumentException If the token can't be read.
-	 * @throws Exception If the token can't be found.
-	 * @throws Exception If the ID doesn't belong to a push token.
+	 * @throws PushTokenNotFoundException If the token can't be found.
+	 * @return void
 	 */
-	public function read( &$push_token ) {
+	public function read( PushToken &$push_token ): void {
 		if ( ! $push_token->can_be_read() ) {
 			throw new InvalidArgumentException(
-				'Can\'t read push token because the push token data provided is invalid.',
-				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
-				WP_Http::BAD_REQUEST
+				'Can\'t read push token because the push token data provided is invalid.'
 			);
 		}

 		$post = get_post( $push_token->get_id() );

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

-		$meta = $this->read_meta( $push_token );
+		$meta = $this->build_meta_array_from_database( $push_token );

 		if (
 			empty( $meta['token'] )
@@ -101,9 +96,7 @@ class PushTokensDataStore {
 			)
 		) {
 			throw new InvalidArgumentException(
-				'Can\'t read push token because the push token record is malformed.',
-				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
-				WP_Http::BAD_REQUEST
+				'Can\'t read push token because the push token record is malformed.'
 			);
 		}

@@ -120,14 +113,14 @@ class PushTokensDataStore {
 	 * @since 10.5.0
 	 * @param PushToken $push_token An instance of PushToken.
 	 * @throws InvalidArgumentException If the token can't be updated.
+	 * @throws PushTokenNotFoundException If the token can't be found.
 	 * @throws Exception If the token update fails.
+	 * @return void
 	 */
-	public function update( &$push_token ) {
+	public function update( PushToken &$push_token ): void {
 		if ( ! $push_token->can_be_updated() ) {
 			throw new InvalidArgumentException(
-				'Can\'t update push token because the push token data provided is invalid.',
-				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
-				WP_Http::BAD_REQUEST
+				'Can\'t update push token because the push token data provided is invalid.'
 			);
 		}

@@ -135,59 +128,198 @@ class PushTokensDataStore {

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

 		$result = wp_update_post(
 			array(
-				'ID'          => $push_token->get_id(),
-				'post_author' => $push_token->get_user_id(),
+				'ID'          => (int) $push_token->get_id(),
+				'post_author' => (int) $push_token->get_user_id(),
 				'post_type'   => PushToken::POST_TYPE,
 				'post_status' => 'private',
-				'meta_input'  => array_filter(
-					array(
-						'platform'    => $push_token->get_platform(),
-						'token'       => $push_token->get_token(),
-						'device_uuid' => $push_token->get_device_uuid(),
-						'origin'      => $push_token->get_origin(),
-					),
-					static fn ( $value, $key ) => 'device_uuid' !== $key || null !== $value,
-					ARRAY_FILTER_USE_BOTH
-				),
+				'meta_input'  => $this->build_meta_array_from_token( $push_token ),
 			),
 			true
 		);

 		if ( is_wp_error( $result ) ) {
 			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
-			throw new Exception( $result->get_error_message(), WP_Http::INTERNAL_SERVER_ERROR );
+			throw new Exception( $result->get_error_message() );
 		}

 		if ( null === $push_token->get_device_uuid() ) {
-			delete_post_meta( $push_token->get_id(), 'device_uuid' );
+			delete_post_meta( (int) $push_token->get_id(), 'device_uuid' );
 		}
 	}

 	/**
-	 * Returns an array of post meta objects as key => value pairs.
+	 * Deletes a push token.
+	 *
+	 * @since 10.5.0
+	 * @param PushToken $push_token An instance of PushToken.
+	 * @throws InvalidArgumentException If the token can't be deleted.
+	 * @throws PushTokenNotFoundException If the token can't be found.
+	 * @return void
+	 */
+	public function delete( PushToken &$push_token ): void {
+		if ( ! $push_token->can_be_deleted() ) {
+			throw new InvalidArgumentException(
+				'Can\'t delete push token because the push token data provided is invalid.'
+			);
+		}
+
+		$post = get_post( $push_token->get_id() );
+
+		if ( ! $post || PushToken::POST_TYPE !== $post->post_type ) {
+			throw new PushTokenNotFoundException( 'Push token could not be found.' );
+		}
+
+		wp_delete_post( (int) $push_token->get_id(), true );
+	}
+
+	/**
+	 * Find tokens for this user and platform that match either the token
+	 * or device UUID. We check the token value to avoid creating a duplicate.
+	 * We check the device UUID value because only one token should be issued
+	 * per device, therefore if we already have one then we can update it to
+	 * avoid creating a duplicate.
+	 *
+	 * @since 10.5.0
+	 * @param PushToken $push_token An instance of PushToken.
+	 * @return null|PushToken
+	 * @throws InvalidArgumentException If push token is missing data.
+	 */
+	public function get_by_token_or_device_id( PushToken &$push_token ): ?PushToken {
+		if (
+			! $push_token->get_user_id()
+			|| ! $push_token->get_platform()
+			|| ! $push_token->get_origin()
+			|| (
+				/**
+				 * Platforms iOS and Android require token OR device UUID.
+				 */
+				$push_token->get_platform() !== PushToken::PLATFORM_BROWSER
+				&& ! $push_token->get_token()
+				&& ! $push_token->get_device_uuid()
+			)
+			|| (
+				/**
+				 * Browsers don't have device UUIDs, so require token.
+				 */
+				$push_token->get_platform() === PushToken::PLATFORM_BROWSER
+				&& ! $push_token->get_token()
+			)
+		) {
+			throw new InvalidArgumentException(
+				'Can\'t retrieve push token because the push token data provided is invalid.'
+			);
+		}
+
+		$query = new WP_Query(
+			array(
+				'post_type'      => PushToken::POST_TYPE,
+				'post_status'    => 'private',
+				'author'         => $push_token->get_user_id(),
+				'posts_per_page' => -1,
+				'orderby'        => 'ID',
+				'order'          => 'DESC',
+				'fields'         => 'ids',
+			)
+		);
+
+		$post_ids = $query->posts;
+
+		if ( empty( $post_ids ) ) {
+			return null;
+		}
+
+		update_meta_cache( 'post', $post_ids );
+
+		foreach ( $post_ids as $post_id ) {
+			$candidate = new PushToken();
+			$candidate->set_id( $post_id );
+
+			try {
+				$meta = $this->build_meta_array_from_database( $candidate );
+			} catch ( Exception $e ) {
+				wc_get_logger()->warning(
+					'Failed to load meta for push token.',
+					array(
+						'token_id' => $post_id,
+						'error'    => $e->getMessage(),
+					)
+				);
+
+				continue;
+			}
+
+			if (
+				$meta['platform'] === $push_token->get_platform()
+				&& $meta['origin'] === $push_token->get_origin()
+				&& (
+					( $push_token->get_token() && $push_token->get_token() === $meta['token'] )
+					|| ( $push_token->get_device_uuid() && $push_token->get_device_uuid() === $meta['device_uuid'] )
+				)
+			) {
+				$push_token->set_id( $post_id );
+				$push_token->set_token( $meta['token'] );
+				$push_token->set_device_uuid( $meta['device_uuid'] );
+				return $push_token;
+			}
+		}
+
+		return null;
+	}
+
+	/**
+	 * Returns an associative array of post meta as key => value pairs for the
+	 * keys defined in SUPPORTED_META; missing keys return null.
 	 *
 	 * @since 10.5.0
 	 * @param PushToken $push_token An instance of PushToken.
 	 * @return array
 	 * @throws InvalidArgumentException If the token can't be read.
 	 */
-	public function read_meta( &$push_token ) {
+	private function build_meta_array_from_database( PushToken &$push_token ) {
 		if ( ! $push_token->can_be_read() ) {
 			throw new InvalidArgumentException(
-				'Can\'t read meta for push token because the push token data provided is invalid.',
-				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
-				WP_Http::BAD_REQUEST
+				'Can\'t read meta for push token because the push token data provided is invalid.'
 			);
 		}

-		return array_map(
-			static fn ( $meta ) => $meta[0] ?? $meta,
-			get_post_meta( $push_token->get_id() )
+		$meta        = (array) get_post_meta( (int) $push_token->get_id() );
+		$meta_by_key = (array) array_combine( static::SUPPORTED_META, static::SUPPORTED_META );
+
+		foreach ( static::SUPPORTED_META as $key ) {
+			if ( ! isset( $meta[ $key ] ) ) {
+				$meta_by_key[ $key ] = null;
+			} elseif ( is_array( $meta[ $key ] ) ) {
+				$meta_by_key[ $key ] = $meta[ $key ][0];
+			} else {
+				$meta_by_key[ $key ] = $meta[ $key ];
+			}
+		}
+
+		return $meta_by_key;
+	}
+
+	/**
+	 * Returns an associative array of post meta as key => value pairs, built
+	 * using push token properties.
+	 *
+	 * @since 10.5.0
+	 * @param PushToken $push_token An instance of PushToken.
+	 * @return array
+	 * @throws InvalidArgumentException If the token can't be read.
+	 */
+	private function build_meta_array_from_token( PushToken &$push_token ) {
+		return array_filter(
+			array(
+				'platform'    => $push_token->get_platform(),
+				'token'       => $push_token->get_token(),
+				'device_uuid' => $push_token->get_device_uuid(),
+				'origin'      => $push_token->get_origin(),
+			)
 		);
 	}
 }
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Entities/PushToken.php b/plugins/woocommerce/src/Internal/PushNotifications/Entities/PushToken.php
index 5abb951e54..a1676b4e8c 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Entities/PushToken.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Entities/PushToken.php
@@ -17,12 +17,12 @@ class PushToken {
 	/**
 	 * WordPress post type for storing push tokens.
 	 */
-	const POST_TYPE = 'push_token';
+	const POST_TYPE = 'wc_push_token';

 	/**
-	 * Platform identifier for iOS devices.
+	 * Platform identifier for Apple devices.
 	 */
-	const PLATFORM_IOS = 'ios';
+	const PLATFORM_APPLE = 'apple';

 	/**
 	 * Platform identifier for Android devices.
@@ -63,7 +63,7 @@ class PushToken {
 	 * List of valid platforms.
 	 */
 	const PLATFORMS = array(
-		self::PLATFORM_IOS,
+		self::PLATFORM_APPLE,
 		self::PLATFORM_ANDROID,
 		self::PLATFORM_BROWSER,
 	);
@@ -188,10 +188,11 @@ class PushToken {
 	 *
 	 * @param int $id The id of the token post.
 	 * @throws InvalidArgumentException If ID is <= 0.
+	 * @return void
 	 *
 	 * @since 10.4.0
 	 */
-	public function set_id( int $id ) {
+	public function set_id( int $id ): void {
 		if ( $id <= 0 ) {
 			throw new InvalidArgumentException( 'ID must be a positive integer.' );
 		}
@@ -204,10 +205,11 @@ class PushToken {
 	 *
 	 * @param int $user_id The id of the user who owns the token.
 	 * @throws InvalidArgumentException If ID is <= 0.
+	 * @return void
 	 *
 	 * @since 10.4.0
 	 */
-	public function set_user_id( int $user_id ) {
+	public function set_user_id( int $user_id ): void {
 		if ( $user_id <= 0 ) {
 			throw new InvalidArgumentException( 'User ID must be a positive integer.' );
 		}
@@ -220,10 +222,11 @@ class PushToken {
 	 *
 	 * @param string $token The token representing a device we can send a push notification to.
 	 * @throws InvalidArgumentException If token is empty or exceeds maximum length.
+	 * @return void
 	 *
 	 * @since 10.4.0
 	 */
-	public function set_token( string $token ) {
+	public function set_token( string $token ): void {
 		$token = trim( $token );

 		if ( '' === $token ) {
@@ -244,10 +247,11 @@ class PushToken {
 	 * Sets the device UUID, normalize empty (non-null) values to null.
 	 *
 	 * @param string|null $device_uuid The UUID of the device that generated the token.
+	 * @return void
 	 *
 	 * @since 10.4.0
 	 */
-	public function set_device_uuid( ?string $device_uuid ) {
+	public function set_device_uuid( ?string $device_uuid ): void {
 		if ( null !== $device_uuid ) {
 			$device_uuid = trim( $device_uuid );
 		}
@@ -260,10 +264,11 @@ class PushToken {
 	 *
 	 * @param string $platform The platform the token was generated by.
 	 * @throws InvalidArgumentException If the platform is invalid.
+	 * @return void
 	 *
 	 * @since 10.4.0
 	 */
-	public function set_platform( string $platform ) {
+	public function set_platform( string $platform ): void {
 		if ( ! in_array( $platform, self::PLATFORMS, true ) ) {
 			throw new InvalidArgumentException( 'Platform for PushToken is invalid.' );
 		}
@@ -276,10 +281,11 @@ class PushToken {
 	 *
 	 * @param string $origin The origin of the token, e.g. the app it came from.
 	 * @throws InvalidArgumentException If the origin is invalid.
+	 * @return void
 	 *
 	 * @since 10.4.0
 	 */
-	public function set_origin( string $origin ) {
+	public function set_origin( string $origin ): void {
 		if ( ! in_array( $origin, self::ORIGINS, true ) ) {
 			throw new InvalidArgumentException( 'Origin for PushToken is invalid.' );
 		}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Exceptions/PushTokenNotFoundException.php b/plugins/woocommerce/src/Internal/PushNotifications/Exceptions/PushTokenNotFoundException.php
new file mode 100644
index 0000000000..08e0ea9f16
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Exceptions/PushTokenNotFoundException.php
@@ -0,0 +1,20 @@
+<?php
+/**
+ * PushTokenNotFoundException class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Exceptions;
+
+defined( 'ABSPATH' ) || exit;
+
+use Exception;
+use WP_Http;
+
+/**
+ * Exception thrown when a push token cannot be found.
+ *
+ * @since 10.5.0
+ */
+class PushTokenNotFoundException extends Exception {}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
index f254e29934..d89b87fa80 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
@@ -7,8 +7,10 @@ namespace Automattic\WooCommerce\Internal\PushNotifications;
 defined( 'ABSPATH' ) || exit;

 use Automattic\Jetpack\Connection\Manager as JetpackConnectionManager;
+use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
 use Automattic\WooCommerce\Proxies\LegacyProxy;
 use Automattic\WooCommerce\Utilities\FeaturesUtil;
+use WC_Logger;
 use Exception;

 /**
@@ -53,9 +55,41 @@ class PushNotifications {
 			return;
 		}

+		add_action( 'init', array( $this, 'register_post_types' ) );
+
 		// Library endpoints and scheduled tasks will be registered here.
 	}

+	/**
+	 * Registers the push token custom post type.
+	 *
+	 * @since 10.5.0
+	 * @return void
+	 */
+	public function register_post_types(): void {
+		register_post_type(
+			PushToken::POST_TYPE,
+			array(
+				'labels'             => array(
+					'name'          => __( 'Push Tokens', 'woocommerce' ),
+					'singular_name' => __( 'Push Token', 'woocommerce' ),
+				),
+				'public'             => false,
+				'publicly_queryable' => false,
+				'show_ui'            => false,
+				'show_in_menu'       => false,
+				'query_var'          => false,
+				'rewrite'            => false,
+				'capability_type'    => 'post',
+				'has_archive'        => false,
+				'hierarchical'       => false,
+				'supports'           => array( 'author' ),
+				'can_export'         => false,
+				'delete_with_user'   => true,
+			)
+		);
+	}
+
 	/**
 	 * Determines if local push notification functionality should be enabled.
 	 * Push notifications require both the feature flag to be enabled and
@@ -85,7 +119,12 @@ class PushNotifications {
 			);
 		} catch ( Exception $e ) {
 			$logger = wc_get_container()->get( LegacyProxy::class )->call_function( 'wc_get_logger' );
-			$logger->error( 'Error determining if PushNotifications feature should be enabled: ' . $e->getMessage() );
+
+			if ( $logger instanceof WC_Logger ) {
+				$logger->error(
+					'Error determining if PushNotifications feature should be enabled: ' . $e->getMessage()
+				);
+			}

 			$this->enabled = false;
 		}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
index 31a019dc6d..3b17c9568f 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
@@ -50,7 +50,7 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
 		$push_token = new PushToken();
 		$push_token->set_user_id( 1 );
 		$push_token->set_token( 'test_token_12345' );
-		$push_token->set_platform( PushToken::PLATFORM_IOS );
+		$push_token->set_platform( PushToken::PLATFORM_APPLE );
 		$push_token->set_device_uuid( 'device-uuid-123' );
 		$push_token->set_origin( PushToken::ORIGIN_WOOCOMMERCE_IOS );

@@ -108,21 +108,29 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
 		$data_store = new PushTokensDataStore();
 		$push_token = $this->create_test_push_token();

-		// Verify device_uuid exists initially.
 		$this->assertNotNull( $push_token->get_device_uuid() );
 		$device_uuid = get_post_meta( $push_token->get_id(), 'device_uuid', true );
 		$this->assertNotEmpty( $device_uuid );

-		// Convert to browser token (device_uuid becomes null).
 		$push_token->set_platform( PushToken::PLATFORM_BROWSER );
 		$push_token->set_device_uuid( null );
 		$data_store->update( $push_token );

-		// Verify device_uuid meta is removed from database.
 		$device_uuid = get_post_meta( $push_token->get_id(), 'device_uuid', true );
 		$this->assertEmpty( $device_uuid );
 	}

+	/**
+	 * @testdox Tests the delete method of the push tokens data store.
+	 */
+	public function test_it_can_delete_push_token() {
+		$data_store = new PushTokensDataStore();
+		$push_token = $this->create_test_push_token();
+		$data_store->delete( $push_token );
+
+		$this->assertNull( get_post( $push_token->get_id() ) );
+	}
+
 	/**
 	 * @testdox Tests the create method throws exception when push token data is
 	 * incomplete.
@@ -136,7 +144,6 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {

 		$this->expectException( InvalidArgumentException::class );
 		$this->expectExceptionMessage( 'Can\'t create push token because the push token data provided is invalid.' );
-		$this->expectExceptionCode( 400 );

 		$data_store->create( $push_token );
 	}
@@ -152,7 +159,6 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {

 		$this->expectException( InvalidArgumentException::class );
 		$this->expectExceptionMessage( 'Can\'t read push token because the push token data provided is invalid.' );
-		$this->expectExceptionCode( 400 );

 		$data_store->read( $push_token );
 	}
@@ -169,7 +175,6 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {

 		$this->expectException( Exception::class );
 		$this->expectExceptionMessage( 'Push token could not be found.' );
-		$this->expectExceptionCode( 404 );

 		$data_store->read( $push_token );
 	}
@@ -181,7 +186,6 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
 	public function test_it_throws_exception_when_reading_push_token_with_wrong_post_type() {
 		$data_store = new PushTokensDataStore();

-		// Create a regular post instead of a push_token.
 		$post_id = wp_insert_post(
 			array(
 				'post_title'  => 'Test Post',
@@ -195,7 +199,35 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {

 		$this->expectException( Exception::class );
 		$this->expectExceptionMessage( 'Push token could not be found.' );
-		$this->expectExceptionCode( 404 );
+
+		$data_store->read( $push_token );
+	}
+
+	/**
+	 * @testdox Tests the read method throws exception when push token metadata
+	 * is malformed/missing.
+	 */
+	public function test_it_throws_exception_when_reading_push_token_with_malformed_metadata() {
+		$data_store = new PushTokensDataStore();
+
+		$post_id = wp_insert_post(
+			array(
+				'post_author' => 1,
+				'post_type'   => PushToken::POST_TYPE,
+				'post_status' => 'private',
+				'meta_input'  => array(
+					'platform' => PushToken::PLATFORM_APPLE,
+					'token'    => 'test_token',
+					// Missing device_uuid and origin.
+				),
+			)
+		);
+
+		$push_token = new PushToken();
+		$push_token->set_id( $post_id );
+
+		$this->expectException( InvalidArgumentException::class );
+		$this->expectExceptionMessage( 'Can\'t read push token because the push token record is malformed.' );

 		$data_store->read( $push_token );
 	}
@@ -213,7 +245,6 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {

 		$this->expectException( InvalidArgumentException::class );
 		$this->expectExceptionMessage( 'Can\'t update push token because the push token data provided is invalid.' );
-		$this->expectExceptionCode( 400 );

 		$data_store->update( $push_token );
 	}
@@ -229,13 +260,12 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
 		$push_token->set_id( 999999 );
 		$push_token->set_user_id( 1 );
 		$push_token->set_token( 'test_token' );
-		$push_token->set_platform( PushToken::PLATFORM_IOS );
+		$push_token->set_platform( PushToken::PLATFORM_APPLE );
 		$push_token->set_device_uuid( 'device-uuid' );
 		$push_token->set_origin( PushToken::ORIGIN_WOOCOMMERCE_IOS );

 		$this->expectException( Exception::class );
 		$this->expectExceptionMessage( 'Push token could not be found.' );
-		$this->expectExceptionCode( 404 );

 		$data_store->update( $push_token );
 	}
@@ -247,7 +277,6 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
 	public function test_it_throws_exception_when_updating_push_token_with_wrong_post_type() {
 		$data_store = new PushTokensDataStore();

-		// Create a regular post instead of a push_token.
 		$post_id = wp_insert_post(
 			array(
 				'post_title'  => 'Test Post',
@@ -260,37 +289,328 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
 		$push_token->set_id( $post_id );
 		$push_token->set_user_id( 1 );
 		$push_token->set_token( 'test_token' );
-		$push_token->set_platform( PushToken::PLATFORM_IOS );
+		$push_token->set_platform( PushToken::PLATFORM_APPLE );
 		$push_token->set_device_uuid( 'device-uuid' );
 		$push_token->set_origin( PushToken::ORIGIN_WOOCOMMERCE_IOS );

 		$this->expectException( Exception::class );
 		$this->expectExceptionMessage( 'Push token could not be found.' );
-		$this->expectExceptionCode( 404 );

 		$data_store->update( $push_token );
 	}

 	/**
-	 * @testdox Tests the read_meta method of the push tokens data store.
+	 * @testdox Tests the delete method throws exception when push token has no
+	 * ID.
 	 */
-	public function test_it_can_read_meta() {
+	public function test_it_throws_exception_when_deleting_push_token_without_id() {
 		$data_store = new PushTokensDataStore();
-		$push_token = $this->create_test_push_token();
+		$push_token = new PushToken();

-		$meta = $data_store->read_meta( $push_token );
+		$this->expectException( InvalidArgumentException::class );
+		$this->expectExceptionMessage( 'Can\'t delete push token because the push token data provided is invalid.' );

-		$this->assertIsArray( $meta );
+		$data_store->delete( $push_token );
+	}

-		$this->assertEquals(
+	/**
+	 * @testdox Tests the delete method throws exception when the post exists but
+	 * is not the correct post type.
+	 */
+	public function test_it_throws_exception_when_deleting_push_token_with_wrong_post_type() {
+		$data_store = new PushTokensDataStore();
+
+		$post_id = wp_insert_post(
 			array(
-				'platform'    => $push_token->get_platform(),
-				'token'       => $push_token->get_token(),
-				'device_uuid' => $push_token->get_device_uuid(),
-				'origin'      => $push_token->get_origin(),
-			),
-			$meta
+				'post_title'  => 'Test Post',
+				'post_type'   => 'post',
+				'post_status' => 'private',
+			)
 		);
+
+		$push_token = new PushToken();
+		$push_token->set_id( $post_id );
+
+		$this->expectException( Exception::class );
+		$this->expectExceptionMessage( 'Push token could not be found.' );
+
+		$data_store->delete( $push_token );
+	}
+
+	/**
+	 * @testdox Tests the get_by_token_or_device_id method finds push token by
+	 * token when user ID, platform, and origin match.
+	 */
+	public function test_it_can_get_by_token_if_platform_and_user_id_matches() {
+		$data_store = new PushTokensDataStore();
+
+		$original_push_token = $this->create_test_push_token();
+
+		$new_push_token = new PushToken();
+		$new_push_token->set_user_id( $original_push_token->get_user_id() );
+		$new_push_token->set_token( $original_push_token->get_token() );
+		$new_push_token->set_platform( $original_push_token->get_platform() );
+		$new_push_token->set_origin( $original_push_token->get_origin() );
+		$new_push_token->set_device_uuid( 'different-device' );
+
+		$found_token = $data_store->get_by_token_or_device_id( $new_push_token );
+
+		$this->assertNotNull( $found_token );
+		$this->assertEquals( $original_push_token->get_id(), $found_token->get_id() );
+		$this->assertEquals( $original_push_token->get_token(), $found_token->get_token() );
+	}
+
+	/**
+	 * @testdox Tests the get_by_token_or_device_id method finds push token by
+	 * device UUID when user ID, platform, and origin match.
+	 */
+	public function test_it_can_get_by_device_uuid_if_platform_and_user_id_matches() {
+		$data_store = new PushTokensDataStore();
+
+		$original_push_token = $this->create_test_push_token();
+
+		$new_push_token = new PushToken();
+		$new_push_token->set_user_id( $original_push_token->get_user_id() );
+		$new_push_token->set_platform( $original_push_token->get_platform() );
+		$new_push_token->set_origin( $original_push_token->get_origin() );
+		$new_push_token->set_device_uuid( $original_push_token->get_device_uuid() );
+		$new_push_token->set_token( 'different_token' );
+
+		$found_token = $data_store->get_by_token_or_device_id( $new_push_token );
+
+		$this->assertNotNull( $found_token );
+		$this->assertEquals( $original_push_token->get_id(), $found_token->get_id() );
+		$this->assertEquals( $original_push_token->get_device_uuid(), $found_token->get_device_uuid() );
+	}
+
+	/**
+	 * @testdox Tests the get_by_token_or_device_id method returns null when
+	 * user ID, platform, and origin match but token and device UUID don't.
+	 */
+	public function test_it_cannot_get_by_token_or_device_id_if_token_and_device_do_not_match() {
+		$data_store = new PushTokensDataStore();
+
+		$original_push_token = $this->create_test_push_token();
+
+		$new_push_token = new PushToken();
+		$new_push_token->set_user_id( $original_push_token->get_user_id() );
+		$new_push_token->set_platform( $original_push_token->get_platform() );
+		$new_push_token->set_origin( $original_push_token->get_origin() );
+		$new_push_token->set_device_uuid( 'different-device' );
+		$new_push_token->set_token( 'different_token' );
+
+		$found_token = $data_store->get_by_token_or_device_id( $new_push_token );
+
+		$this->assertNull( $found_token );
+	}
+
+	/**
+	 * @testdox Tests the get_by_token_or_device_id method returns null when
+	 * user ID does not match.
+	 */
+	public function test_it_cannot_get_by_token_or_device_id_if_user_id_does_not_match() {
+		$data_store = new PushTokensDataStore();
+
+		$original_push_token = $this->create_test_push_token();
+
+		$new_push_token = new PushToken();
+		$new_push_token->set_user_id( 999 );
+		$new_push_token->set_platform( $original_push_token->get_platform() );
+		$new_push_token->set_origin( $original_push_token->get_origin() );
+		$new_push_token->set_device_uuid( $original_push_token->get_device_uuid() );
+		$new_push_token->set_token( $original_push_token->get_token() );
+
+		$found_token = $data_store->get_by_token_or_device_id( $new_push_token );
+
+		$this->assertNull( $found_token );
+	}
+
+	/**
+	 * @testdox Tests the get_by_token_or_device_id method returns null when
+	 * platform does not match.
+	 */
+	public function test_it_cannot_get_by_token_or_device_id_if_platform_does_not_match() {
+		$data_store = new PushTokensDataStore();
+
+		$original_push_token = $this->create_test_push_token();
+
+		$new_push_token = new PushToken();
+		$new_push_token->set_user_id( $original_push_token->get_user_id() );
+		$new_push_token->set_platform( PushToken::PLATFORM_ANDROID );
+		$new_push_token->set_origin( $original_push_token->get_origin() );
+		$new_push_token->set_device_uuid( $original_push_token->get_device_uuid() );
+		$new_push_token->set_token( $original_push_token->get_token() );
+
+		$found_token = $data_store->get_by_token_or_device_id( $new_push_token );
+
+		$this->assertNull( $found_token );
+	}
+
+	/**
+	 * @testdox Tests the get_by_token_or_device_id method returns null when
+	 * origin does not match.
+	 */
+	public function test_it_cannot_get_by_token_or_device_id_if_origin_does_not_match() {
+		$data_store = new PushTokensDataStore();
+
+		$original_push_token = $this->create_test_push_token();
+
+		$new_push_token = new PushToken();
+		$new_push_token->set_user_id( $original_push_token->get_user_id() );
+		$new_push_token->set_platform( $original_push_token->get_platform() );
+		$new_push_token->set_origin( PushToken::ORIGIN_WOOCOMMERCE_IOS_DEV );
+		$new_push_token->set_device_uuid( $original_push_token->get_device_uuid() );
+		$new_push_token->set_token( $original_push_token->get_token() );
+
+		$found_token = $data_store->get_by_token_or_device_id( $new_push_token );
+
+		$this->assertNull( $found_token );
+	}
+
+	/**
+	 * @testdox Tests that browser tokens with null device_uuid don't
+	 * incorrectly match each other by empty device_uuid.
+	 */
+	public function test_it_does_not_match_browser_tokens_by_empty_device_uuid() {
+		$data_store = new PushTokensDataStore();
+
+		/**
+		 * Create first browser token for user.
+		 */
+		$browser_token_1 = new PushToken();
+		$browser_token_1->set_user_id( 1 );
+		$browser_token_1->set_token( 'browser_token_1_' . wp_rand() );
+		$browser_token_1->set_platform( PushToken::PLATFORM_BROWSER );
+		$browser_token_1->set_device_uuid( null );
+		$browser_token_1->set_origin( PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$data_store->create( $browser_token_1 );
+
+		/**
+		 * Create second browser token for same user (different browser/tab).
+		 */
+		$browser_token_2 = new PushToken();
+		$browser_token_2->set_user_id( 1 );
+		$browser_token_2->set_token( 'browser_token_2_' . wp_rand() );
+		$browser_token_2->set_platform( PushToken::PLATFORM_BROWSER );
+		$browser_token_2->set_device_uuid( null );
+		$browser_token_2->set_origin( PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$data_store->create( $browser_token_2 );
+
+		/**
+		 * Try to find browser_token_1 by its token - should only match itself,
+		 * not browser_token_2.
+		 */
+		$search_token = new PushToken();
+		$search_token->set_user_id( 1 );
+		$search_token->set_token( $browser_token_1->get_token() );
+		$search_token->set_platform( PushToken::PLATFORM_BROWSER );
+		$search_token->set_device_uuid( null );
+		$search_token->set_origin( PushToken::ORIGIN_WOOCOMMERCE_IOS );
+
+		$found_token = $data_store->get_by_token_or_device_id( $search_token );
+
+		$this->assertNotNull( $found_token, 'Should find browser_token_1 by its token value' );
+		$this->assertEquals( $browser_token_1->get_id(), $found_token->get_id(), 'Should match browser_token_1 ID' );
+		$this->assertEquals( $browser_token_1->get_token(), $found_token->get_token(), 'Should match browser_token_1 token' );
+		$this->assertNotEquals( $browser_token_2->get_id(), $found_token->get_id(), 'Should not match browser_token_2 ID' );
+
+		/**
+		 * Now search with a DIFFERENT token - should return null, not match by
+		 * empty device_uuid.
+		 */
+		$different_token = new PushToken();
+		$different_token->set_user_id( 1 );
+		$different_token->set_platform( PushToken::PLATFORM_BROWSER );
+		$different_token->set_origin( PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$different_token->set_token(
+			wp_json_encode(
+				array(
+					'endpoint' => 'https://example.com/push/subscription3',
+					'keys'     => array(
+						'auth'   => 'a3',
+						'p256dh' => 'p3',
+					),
+				)
+			)
+		);
+
+		$found = $data_store->get_by_token_or_device_id( $different_token );
+		$this->assertNull( $found, 'Should not match existing tokens by empty device_uuid' );
+	}
+
+	/**
+	 * @testdox Tests the get_by_token_or_device_id method throws exception when
+	 * user ID is missing.
+	 */
+	public function test_it_throws_exception_when_getting_by_token_or_device_id_without_user_id() {
+		$data_store = new PushTokensDataStore();
+
+		$push_token = new PushToken();
+		$push_token->set_platform( PushToken::PLATFORM_APPLE );
+		$push_token->set_origin( PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$push_token->set_token( 'test_token' );
+		$push_token->set_device_uuid( 'test_device' );
+
+		$this->expectException( InvalidArgumentException::class );
+		$this->expectExceptionMessage( 'Can\'t retrieve push token because the push token data provided is invalid.' );
+
+		$data_store->get_by_token_or_device_id( $push_token );
+	}
+
+	/**
+	 * @testdox Tests the get_by_token_or_device_id method throws exception when
+	 * platform is missing.
+	 */
+	public function test_it_throws_exception_when_getting_by_token_or_device_id_without_platform() {
+		$data_store = new PushTokensDataStore();
+
+		$push_token = new PushToken();
+		$push_token->set_user_id( 1 );
+		$push_token->set_origin( PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$push_token->set_token( 'test_token' );
+		$push_token->set_device_uuid( 'test_device' );
+
+		$this->expectException( InvalidArgumentException::class );
+		$this->expectExceptionMessage( 'Can\'t retrieve push token because the push token data provided is invalid.' );
+
+		$data_store->get_by_token_or_device_id( $push_token );
+	}
+
+	/**
+	 * @testdox Tests the get_by_token_or_device_id method throws exception when
+	 * origin is missing.
+	 */
+	public function test_it_throws_exception_when_getting_by_token_or_device_id_without_origin() {
+		$data_store = new PushTokensDataStore();
+
+		$push_token = new PushToken();
+		$push_token->set_user_id( 1 );
+		$push_token->set_platform( PushToken::PLATFORM_APPLE );
+		$push_token->set_token( 'test_token' );
+		$push_token->set_device_uuid( 'test_device' );
+
+		$this->expectException( InvalidArgumentException::class );
+		$this->expectExceptionMessage( 'Can\'t retrieve push token because the push token data provided is invalid.' );
+
+		$data_store->get_by_token_or_device_id( $push_token );
+	}
+
+	/**
+	 * @testdox Tests the get_by_token_or_device_id method throws exception when
+	 * both token and device_uuid are missing.
+	 */
+	public function test_it_throws_exception_when_getting_by_token_or_device_id_without_token_and_device_uuid() {
+		$data_store = new PushTokensDataStore();
+
+		$push_token = new PushToken();
+		$push_token->set_user_id( 1 );
+		$push_token->set_platform( PushToken::PLATFORM_APPLE );
+		$push_token->set_origin( PushToken::ORIGIN_WOOCOMMERCE_IOS );
+
+		$this->expectException( InvalidArgumentException::class );
+		$this->expectExceptionMessage( 'Can\'t retrieve push token because the push token data provided is invalid.' );
+
+		$data_store->get_by_token_or_device_id( $push_token );
 	}

 	/**
@@ -300,7 +620,6 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
 	public function test_it_can_create_and_read_browser_token_without_device_uuid() {
 		$data_store = new PushTokensDataStore();

-		// Create a browser token without device_uuid.
 		$push_token = new PushToken();
 		$push_token->set_user_id( 1 );
 		$push_token->set_token( '{"endpoint":"https://example.com/push","keys":{"auth":"test","p256dh":"test"}}' );
@@ -311,7 +630,6 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {

 		$this->assertNotNull( $push_token->get_id() );

-		// Now try to read it back.
 		$read_token = new PushToken();
 		$read_token->set_id( $push_token->get_id() );

@@ -336,7 +654,7 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
 		$push_token = new PushToken();
 		$push_token->set_user_id( 1 );
 		$push_token->set_token( 'test_token_' . wp_rand() );
-		$push_token->set_platform( PushToken::PLATFORM_IOS );
+		$push_token->set_platform( PushToken::PLATFORM_APPLE );
 		$push_token->set_device_uuid( 'test-device-uuid-' . wp_rand() );
 		$push_token->set_origin( PushToken::ORIGIN_WOOCOMMERCE_IOS );

diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Entities/PushTokenTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Entities/PushTokenTest.php
index ddd74653d1..88cd7d11da 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Entities/PushTokenTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Entities/PushTokenTest.php
@@ -59,9 +59,9 @@ class PushTokenTest extends WC_Unit_Test_Case {
 	 */
 	public function test_it_can_get_and_set_platform() {
 		$push_token = new PushToken();
-		$push_token->set_platform( PushToken::PLATFORM_IOS );
+		$push_token->set_platform( PushToken::PLATFORM_APPLE );

-		$this->assertEquals( PushToken::PLATFORM_IOS, $push_token->get_platform() );
+		$this->assertEquals( PushToken::PLATFORM_APPLE, $push_token->get_platform() );
 	}

 	/**
@@ -73,7 +73,7 @@ class PushTokenTest extends WC_Unit_Test_Case {
 			1,
 			'test_token',
 			'test-device-uuid',
-			PushToken::PLATFORM_IOS,
+			PushToken::PLATFORM_APPLE,
 			PushToken::ORIGIN_WOOCOMMERCE_IOS
 		);

@@ -89,7 +89,7 @@ class PushTokenTest extends WC_Unit_Test_Case {
 			1,
 			'test_token',
 			'test-device-uuid',
-			PushToken::PLATFORM_IOS,
+			PushToken::PLATFORM_APPLE,
 			PushToken::ORIGIN_WOOCOMMERCE_IOS
 		);

@@ -105,7 +105,7 @@ class PushTokenTest extends WC_Unit_Test_Case {
 			null,
 			'test_token',
 			'test-device-uuid',
-			PushToken::PLATFORM_IOS,
+			PushToken::PLATFORM_APPLE,
 			PushToken::ORIGIN_WOOCOMMERCE_IOS
 		);

@@ -137,7 +137,7 @@ class PushTokenTest extends WC_Unit_Test_Case {
 			1,
 			null,
 			'test-device-uuid',
-			PushToken::PLATFORM_IOS,
+			PushToken::PLATFORM_APPLE,
 			PushToken::ORIGIN_WOOCOMMERCE_IOS
 		);

@@ -153,7 +153,7 @@ class PushTokenTest extends WC_Unit_Test_Case {
 			1,
 			'test_token',
 			null,
-			PushToken::PLATFORM_IOS,
+			PushToken::PLATFORM_APPLE,
 			PushToken::ORIGIN_WOOCOMMERCE_IOS
 		);

@@ -170,7 +170,7 @@ class PushTokenTest extends WC_Unit_Test_Case {
 			1,
 			'test_token',
 			'test-device-uuid',
-			PushToken::PLATFORM_IOS,
+			PushToken::PLATFORM_APPLE,
 			PushToken::ORIGIN_WOOCOMMERCE_IOS
 		);

@@ -186,7 +186,7 @@ class PushTokenTest extends WC_Unit_Test_Case {
 			1,
 			'test_token',
 			'test-device-uuid',
-			PushToken::PLATFORM_IOS,
+			PushToken::PLATFORM_APPLE,
 			PushToken::ORIGIN_WOOCOMMERCE_IOS
 		);

@@ -202,7 +202,7 @@ class PushTokenTest extends WC_Unit_Test_Case {
 			null,
 			'test_token',
 			'test-device-uuid',
-			PushToken::PLATFORM_IOS,
+			PushToken::PLATFORM_APPLE,
 			PushToken::ORIGIN_WOOCOMMERCE_IOS
 		);

@@ -234,7 +234,7 @@ class PushTokenTest extends WC_Unit_Test_Case {
 			1,
 			'test_token',
 			null,
-			PushToken::PLATFORM_IOS,
+			PushToken::PLATFORM_APPLE,
 			PushToken::ORIGIN_WOOCOMMERCE_IOS
 		);

@@ -250,7 +250,7 @@ class PushTokenTest extends WC_Unit_Test_Case {
 			1,
 			null,
 			'test-device-uuid',
-			PushToken::PLATFORM_IOS,
+			PushToken::PLATFORM_APPLE,
 			PushToken::ORIGIN_WOOCOMMERCE_IOS
 		);

@@ -359,7 +359,7 @@ class PushTokenTest extends WC_Unit_Test_Case {
 			1,
 			'test_token',
 			'test-device-uuid',
-			PushToken::PLATFORM_IOS,
+			PushToken::PLATFORM_APPLE,
 			null
 		);

@@ -375,7 +375,7 @@ class PushTokenTest extends WC_Unit_Test_Case {
 			1,
 			'test_token',
 			'test-device-uuid',
-			PushToken::PLATFORM_IOS,
+			PushToken::PLATFORM_APPLE,
 			null
 		);

@@ -425,30 +425,6 @@ class PushTokenTest extends WC_Unit_Test_Case {
 		$this->assertEquals( PushToken::ORIGIN_WOOCOMMERCE_IOS_DEV, $push_token->get_origin() );
 	}

-	/**
-	 * @testdox Tests set_id throws exception with zero.
-	 */
-	public function test_it_throws_exception_when_setting_id_to_zero() {
-		$push_token = new PushToken();
-
-		$this->expectException( InvalidArgumentException::class );
-		$this->expectExceptionMessage( 'ID must be a positive integer.' );
-
-		$push_token->set_id( 0 );
-	}
-
-	/**
-	 * @testdox Tests set_id throws exception with negative number.
-	 */
-	public function test_it_throws_exception_when_setting_negative_id() {
-		$push_token = new PushToken();
-
-		$this->expectException( InvalidArgumentException::class );
-		$this->expectExceptionMessage( 'ID must be a positive integer.' );
-
-		$push_token->set_id( -1 );
-	}
-
 	/**
 	 * @testdox Tests set_user_id throws exception with zero.
 	 */
@@ -561,7 +537,7 @@ class PushTokenTest extends WC_Unit_Test_Case {
 			456,
 			'test_token_value',
 			'device-uuid-123',
-			PushToken::PLATFORM_IOS,
+			PushToken::PLATFORM_APPLE,
 			PushToken::ORIGIN_WOOCOMMERCE_IOS
 		);

@@ -569,7 +545,7 @@ class PushTokenTest extends WC_Unit_Test_Case {
 		$this->assertSame( 456, $push_token->get_user_id() );
 		$this->assertSame( 'test_token_value', $push_token->get_token() );
 		$this->assertSame( 'device-uuid-123', $push_token->get_device_uuid() );
-		$this->assertSame( PushToken::PLATFORM_IOS, $push_token->get_platform() );
+		$this->assertSame( PushToken::PLATFORM_APPLE, $push_token->get_platform() );
 		$this->assertSame( PushToken::ORIGIN_WOOCOMMERCE_IOS, $push_token->get_origin() );
 	}

diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/PushNotificationsTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/PushNotificationsTest.php
index fac883ec70..d9f1d7b489 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/PushNotificationsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/PushNotificationsTest.php
@@ -6,10 +6,12 @@ namespace Automattic\WooCommerce\Tests\Internal\PushNotifications;

 use Automattic\Jetpack\Connection\Manager as JetpackConnectionManager;
 use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
 use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
 use Automattic\WooCommerce\Proxies\LegacyProxy;
 use Exception;
 use PHPUnit\Framework\MockObject\MockObject;
+use WC_Logger;
 use WC_Unit_Test_Case;

 /**
@@ -106,20 +108,15 @@ class PushNotificationsTest extends WC_Unit_Test_Case {
 	 * enablement check.
 	 */
 	public function test_it_logs_error_when_jetpack_connection_check_throws_exception() {
-		// phpcs:disable Squiz.Commenting
-		$fake_logger = new class() {
-			public $errors = array();
-
-			public function error( $message, $data = array() ) {
-				$this->errors[] = array(
-					'message' => $message,
-					'data'    => $data,
-				);
-			}
-		};
-		// phpcs:enable Squiz.Commenting
-
-		$this->register_legacy_proxy_function_mocks( array( 'wc_get_logger' => fn () => $fake_logger ) );
+		$logger_mock = $this->createMock( WC_Logger::class );
+		$logger_mock->expects( $this->once() )
+			->method( 'error' )
+			->with(
+				$this->stringContains( 'Error determining if PushNotifications feature should be enabled' ),
+				$this->anything()
+			);
+
+		$this->register_legacy_proxy_function_mocks( array( 'wc_get_logger' => fn () => $logger_mock ) );
 		$this->set_up_features_controller_mock( true );
 		$this->set_up_jetpack_connection_manager_mock( array( 'is_connected' ) );

@@ -132,13 +129,6 @@ class PushNotificationsTest extends WC_Unit_Test_Case {
 		$result             = $push_notifications->should_be_enabled();

 		$this->assertFalse( $result, 'Should be disabled when exception is thrown' );
-		$this->assertCount( 1, $fake_logger->errors, 'Should have logged exactly one error' );
-		$this->assertStringContainsString( 'Connection check failed', $fake_logger->errors[0]['message'] );
-
-		$this->assertStringContainsString(
-			'Error determining if PushNotifications feature should be enabled',
-			$fake_logger->errors[0]['message']
-		);
 	}

 	/**
@@ -175,6 +165,59 @@ class PushNotificationsTest extends WC_Unit_Test_Case {
 		$this->assertTrue( $push_notifications_2->should_be_enabled(), 'Should return cached true value' );
 	}

+	/**
+	 * @testdox Tests that register() hooks register_post_types to init when enabled.
+	 */
+	public function test_it_hooks_register_post_types_when_enabled() {
+		$this->set_up_jetpack_connection_manager_mock( array( 'is_connected' ) );
+
+		$this->jetpack_connection_manager_mock
+			->expects( $this->once() )
+			->method( 'is_connected' )
+			->willReturn( true );
+
+		$push_notifications = new PushNotifications();
+		$push_notifications->register();
+
+		$callback_priority = has_action( 'init', array( $push_notifications, 'register_post_types' ) );
+
+		$this->assertTrue( (bool) $callback_priority, 'register_post_types should be hooked to init' );
+		$this->assertEquals( 10, $callback_priority, 'register_post_types should have priority 10' );
+	}
+
+	/**
+	 * @testdox Tests that register_post_types() registers push_token post type with correct properties.
+	 */
+	public function test_it_registers_push_token_post_type_with_correct_properties() {
+		$push_notifications = new PushNotifications();
+		$push_notifications->register_post_types();
+
+		$this->assertTrue( post_type_exists( PushToken::POST_TYPE ), 'Push token post type should be registered' );
+
+		$post_type_object = get_post_type_object( PushToken::POST_TYPE );
+
+		$this->assertNotNull( $post_type_object );
+		$this->assertFalse( $post_type_object->public );
+		$this->assertFalse( $post_type_object->publicly_queryable );
+		$this->assertTrue( $post_type_object->delete_with_user );
+	}
+
+	/**
+	 * @testdox Tests that push_token post type is not registered when disabled.
+	 */
+	public function test_it_does_not_register_push_token_post_type_when_disabled() {
+		$this->set_up_features_controller_mock( false );
+		$this->set_up_jetpack_connection_manager_mock( array( 'is_connected' ) );
+
+		$push_notifications = new PushNotifications();
+		$push_notifications->register();
+
+		$this->assertFalse(
+			has_action( 'init', array( $push_notifications, 'register_post_types' ) ),
+			'register_post_types should not be hooked to init when disabled'
+		);
+	}
+
 	/**
 	 * Sets up the FeaturesController mock.
 	 *