Commit 819256d4d25 for woocommerce

commit 819256d4d25f94affde1bfc5c596ef5093ba807e
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date:   Mon Sep 28 18:13:26 2026 +0100

    Order push tokens by last registration when sending notifications (#69118)

    * Order push tokens by last registration when sending notifications

    Tokens read for sending were ordered by when each device first registered;
    they are now ordered by when each device last registered, most recent first.

    * Order unpaginated push tokens by GMT last registration time

    Local modified times can run backwards across a daylight saving change, which put an older registration first.

diff --git a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
index 441af070a3b..b5a85da1503 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
@@ -436,8 +436,10 @@ class PushTokensDataStore {
 	 * Returns push tokens belonging to users with the given roles.
 	 *
 	 * When called without pagination parameters, returns all tokens as a
-	 * flat array (cached per-request). When $page and $per_page are
-	 * provided, returns a paginated result with total counts.
+	 * flat array (cached per-request), most recently registered first. When
+	 * $page and $per_page are provided, returns a paginated result with total
+	 * counts, ordered by ID so a re-registration cannot move a token between
+	 * pages.
 	 *
 	 * The eligible-user lookup is restricted to users that actually own
 	 * push tokens, so the role check runs against a handful of IDs instead
@@ -580,6 +582,17 @@ class PushTokensDataStore {
 			}
 		}

+		if ( ! $paginate ) {
+			/**
+			 * Sorted on the GMT date because WP_Query can only order by the local
+			 * `post_modified`, which runs backwards across a daylight saving change.
+			 */
+			usort(
+				$tokens,
+				fn ( PushToken $a, PushToken $b ) => array( $b->get_last_confirmed_at_gmt(), $b->get_id() ) <=> array( $a->get_last_confirmed_at_gmt(), $a->get_id() )
+			);
+		}
+
 		$result = $paginate
 			? array(
 				'tokens'      => $tokens,
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 3ad3ea7e697..459fe71b927 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
@@ -804,6 +804,48 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
 		$this->assertSame( $admin_id, $tokens[0]->get_user_id() );
 	}

+	/**
+	 * @testdox Should return tokens most recently registered first, whatever order they were created in.
+	 */
+	public function test_get_tokens_for_roles_orders_by_last_registration(): void {
+		$admin_id   = $this->factory->user->create( array( 'role' => 'administrator' ) );
+		$data_store = new PushTokensDataStore();
+
+		$created_first = $this->create_push_token_for_user( $data_store, $admin_id );
+		$created_last  = $this->create_push_token_for_user( $data_store, $admin_id );
+
+		$this->set_token_dates( $created_first->get_id(), '2026-01-01 00:00:00', '2026-03-01 00:00:00' );
+		$this->set_token_dates( $created_last->get_id(), '2026-02-01 00:00:00', '2026-02-01 00:00:00' );
+
+		$tokens = ( new PushTokensDataStore() )->get_tokens_for_roles( array( 'administrator' ) );
+
+		$this->assertSame(
+			array( $created_first->get_id(), $created_last->get_id() ),
+			array_map( fn ( PushToken $token ) => $token->get_id(), $tokens )
+		);
+	}
+
+	/**
+	 * @testdox Should order tokens by GMT registration time when local time runs backwards at a daylight saving change.
+	 */
+	public function test_get_tokens_for_roles_orders_by_gmt_across_daylight_saving_change(): void {
+		$admin_id   = $this->factory->user->create( array( 'role' => 'administrator' ) );
+		$data_store = new PushTokensDataStore();
+
+		$registered_earlier = $this->create_push_token_for_user( $data_store, $admin_id );
+		$registered_later   = $this->create_push_token_for_user( $data_store, $admin_id );
+
+		$this->set_token_dates( $registered_earlier->get_id(), '2026-10-01 00:00:00', '2026-10-25 00:30:00', '2026-10-25 01:30:00' );
+		$this->set_token_dates( $registered_later->get_id(), '2026-10-01 00:00:00', '2026-10-25 01:10:00', '2026-10-25 01:10:00' );
+
+		$tokens = ( new PushTokensDataStore() )->get_tokens_for_roles( array( 'administrator' ) );
+
+		$this->assertSame(
+			array( $registered_later->get_id(), $registered_earlier->get_id() ),
+			array_map( fn ( PushToken $token ) => $token->get_id(), $tokens )
+		);
+	}
+
 	/**
 	 * @testdox Should return empty array when no users have the specified role.
 	 */
@@ -1134,6 +1176,31 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
 		}
 	}

+	/**
+	 * Sets the date a token was first registered and the date it was last registered.
+	 *
+	 * @param int         $post_id        The token's post ID.
+	 * @param string      $created        The first registration date, as GMT.
+	 * @param string      $modified       The last registration date, as GMT.
+	 * @param string|null $modified_local The last registration date in site time, defaulting to $modified.
+	 */
+	private function set_token_dates( int $post_id, string $created, string $modified, ?string $modified_local = null ): void {
+		global $wpdb;
+
+		$wpdb->update(
+			$wpdb->posts,
+			array(
+				'post_date'         => $created,
+				'post_date_gmt'     => $created,
+				'post_modified'     => $modified_local ?? $modified,
+				'post_modified_gmt' => $modified,
+			),
+			array( 'ID' => $post_id )
+		);
+
+		clean_post_cache( $post_id );
+	}
+
 	/**
 	 * Creates a push token owned by the given user.
 	 *