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.
*