Commit 447e7d8f964 for woocommerce

commit 447e7d8f96406210b61767bb78e730c76dfd36b4
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date:   Thu Sep 24 14:33:29 2026 +0100

    Return the account username and email with each push token (#68714)

    The diagnostics devices page showed only the store's numeric user ID, which
    cannot be matched to anything in WPCOM. Each token on the index now carries
    the account's user_login and user_email, null when the user no longer exists.
    The payload the dispatcher sends to WPCOM is unchanged.

diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
index 842799eb594..3f8fcfc4c39 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
@@ -138,8 +138,8 @@ class PushTokenRestController extends RestApiControllerBase {

 	/**
 	 * Returns all push tokens for roles that can receive push notifications,
-	 * along with when each token was registered and when the app last
-	 * confirmed it.
+	 * along with when each token was registered, when the app last confirmed
+	 * it, and the username and email of the account it belongs to.
 	 *
 	 * @since 10.8.0
 	 *
@@ -169,12 +169,7 @@ class PushTokenRestController extends RestApiControllerBase {
 		}

 		$response = new WP_REST_Response(
-			array(
-				'tokens' => array_map(
-					fn ( $token ) => $token->to_rest_format(),
-					$result['tokens']
-				),
-			),
+			array( 'tokens' => $this->prepare_tokens_for_response( $result['tokens'] ) ),
 			WP_Http::OK
 		);

@@ -184,6 +179,59 @@ class PushTokenRestController extends RestApiControllerBase {
 		return $response;
 	}

+	/**
+	 * Formats tokens for the index response.
+	 *
+	 * The account fields are added here rather than in
+	 * {@see PushToken::to_rest_format()}, so the users for a whole page are
+	 * loaded in one query rather than one per token.
+	 *
+	 * Requesting three named columns rather than user objects skips loading
+	 * usermeta, and keeps WP_User_Query's result cache on.
+	 *
+	 * @param PushToken[] $tokens The tokens to format.
+	 * @return array[]
+	 */
+	private function prepare_tokens_for_response( array $tokens ): array {
+		$user_ids = array_values(
+			array_unique(
+				array_filter(
+					array_map( fn ( PushToken $token ) => $token->get_user_id(), $tokens )
+				)
+			)
+		);
+
+		if ( ! $user_ids ) {
+			return array();
+		}
+
+		$users = array_column(
+			get_users(
+				array(
+					'include' => $user_ids,
+					'fields'  => array( 'ID', 'user_login', 'user_email' ),
+				)
+			),
+			null,
+			'ID'
+		);
+
+		return array_map(
+			function ( PushToken $token ) use ( $users ) {
+				$user = $users[ $token->get_user_id() ] ?? null;
+
+				return array_merge(
+					$token->to_rest_format(),
+					array(
+						'user_login' => $user ? $user->user_login : null,
+						'user_email' => $user ? $user->user_email : null,
+					)
+				);
+			},
+			$tokens
+		);
+	}
+
 	/**
 	 * Creates a push token record.
 	 *
@@ -308,6 +356,18 @@ class PushTokenRestController extends RestApiControllerBase {
 						'context'     => array( 'view' ),
 						'readonly'    => true,
 					),
+					'user_login'            => array(
+						'description' => __( 'The username of the account the token belongs to. Null when the user no longer exists.', 'woocommerce' ),
+						'type'        => array( 'string', 'null' ),
+						'context'     => array( 'view' ),
+						'readonly'    => true,
+					),
+					'user_email'            => array(
+						'description' => __( 'The email address of the account the token belongs to. Null when the user no longer exists.', 'woocommerce' ),
+						'type'        => array( 'string', 'null' ),
+						'context'     => array( 'view' ),
+						'readonly'    => true,
+					),
 					'token'                 => array(
 						'description' => __( 'The push token issued by Apple or Google.', 'woocommerce' ),
 						'type'        => 'string',
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 286f9a2d676..26d38d82593 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
@@ -1226,6 +1226,8 @@ class PushTokenRestControllerTest extends WC_Unit_Test_Case {
 			array(
 				'id',
 				'user_id',
+				'user_login',
+				'user_email',
 				'token',
 				'platform',
 				'origin',
@@ -1502,6 +1504,82 @@ class PushTokenRestControllerTest extends WC_Unit_Test_Case {
 		$this->assertNotEmpty( $response->get_headers()['X-WP-TotalPages'] );
 	}

+	/**
+	 * @testdox Should return the username and email of the account each token belongs to.
+	 */
+	public function test_index_returns_account_fields_for_each_token(): void {
+		$this->mock_jetpack_connection_manager_is_connected();
+		wc_get_container()->get( PushNotifications::class )->on_init();
+
+		wc_get_container()->get( PushTokensDataStore::class )->create(
+			array(
+				'user_id'       => $this->user_id,
+				'token'         => 'account-fields-token',
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'device_uuid'   => 'account-fields-uuid',
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+			)
+		);
+
+		$controller = new PushTokenRestController();
+		$request    = new WP_REST_Request( 'GET', '/wc-push-notifications/push-tokens' );
+		$request->set_param( 'page', 1 );
+		$request->set_param( 'per_page', 100 );
+		$response = $controller->index( $request );
+
+		$user       = get_userdata( $this->user_id );
+		$token_data = $response->get_data()['tokens'][0];
+
+		$this->assertSame( $this->user_id, $token_data['user_id'] );
+		$this->assertSame( $user->user_login, $token_data['user_login'] );
+		$this->assertSame( $user->user_email, $token_data['user_email'] );
+	}
+
+	/**
+	 * @testdox Should return null account fields when the token's user no longer exists.
+	 */
+	public function test_index_returns_null_account_fields_when_user_no_longer_exists(): void {
+		$missing_user_id = 999999;
+
+		$token = new PushToken(
+			array(
+				'id'            => 1,
+				'user_id'       => $missing_user_id,
+				'token'         => 'orphaned-token',
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'device_uuid'   => 'orphaned-uuid',
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+			)
+		);
+
+		$data_store = $this->createMock( PushTokensDataStore::class );
+		$data_store
+			->method( 'get_tokens_for_roles' )
+			->willReturn(
+				array(
+					'tokens'      => array( $token ),
+					'total'       => 1,
+					'total_pages' => 1,
+				)
+			);
+
+		wc_get_container()->replace( PushTokensDataStore::class, $data_store );
+
+		$controller = new PushTokenRestController();
+		$request    = new WP_REST_Request( 'GET', '/wc-push-notifications/push-tokens' );
+		$request->set_param( 'page', 1 );
+		$request->set_param( 'per_page', 100 );
+		$response = $controller->index( $request );
+
+		$token_data = $response->get_data()['tokens'][0];
+
+		$this->assertSame( $missing_user_id, $token_data['user_id'] );
+		$this->assertNull( $token_data['user_login'] );
+		$this->assertNull( $token_data['user_email'] );
+	}
+
 	/**
 	 * @testdox Should publish a schema on the index route describing every returned field.
 	 *
@@ -1525,6 +1603,8 @@ class PushTokenRestControllerTest extends WC_Unit_Test_Case {
 			array(
 				'id',
 				'user_id',
+				'user_login',
+				'user_email',
 				'token',
 				'platform',
 				'origin',