Commit a68f7284444 for woocommerce
commit a68f72844447e1b0671ab699fe2929b44b5ec756
Author: Adam Grzybkowski <agrzybkowski@outlook.com>
Date: Tue Nov 3 10:02:59 2026 +0100
Add a daily cleanup for stale push notification tokens (#70349)
* Add a daily cleanup for stale push notification tokens
* Reset the has_tokens cache after deleting a user's push tokens
* Add changelog entry for stale push token cleanup
diff --git a/plugins/woocommerce/changelog/woomob-4215-stale-push-token-cleanup b/plugins/woocommerce/changelog/woomob-4215-stale-push-token-cleanup
new file mode 100644
index 00000000000..afa9ee74cf5
--- /dev/null
+++ b/plugins/woocommerce/changelog/woomob-4215-stale-push-token-cleanup
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Stop sending push notifications to devices that haven't checked in for 90 days (180 for older app versions).
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
index 1499c8948b8..58d06cbd073 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
@@ -297,10 +297,128 @@ class PushTokensDataStore {
// Anything read earlier in this request now includes deleted tokens.
$this->tokens_by_roles_cache = array();
$this->token_count = null;
+ $this->has_tokens = null;
return $deleted;
}
+ /**
+ * Returns the IDs of the push tokens last registered before a cutoff.
+ *
+ * Re-registering a device updates its post, so `post_modified_gmt` is the
+ * last time an app confirmed the token. Oldest first, all in one query.
+ *
+ * @since 11.4.0
+ *
+ * @param string $cutoff_gmt A GMT `Y-m-d H:i:s` datetime. Tokens registered at exactly this time are not returned.
+ * @return int[]
+ */
+ public function get_stale_token_ids( string $cutoff_gmt ): array {
+ global $wpdb;
+
+ return array_map(
+ 'intval',
+ $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'private' AND post_modified_gmt < %s ORDER BY post_modified_gmt ASC, ID ASC",
+ PushToken::POST_TYPE,
+ $cutoff_gmt
+ )
+ )
+ );
+ }
+
+ /**
+ * Deletes push tokens by ID, one at a time so a failure cannot stop the rest.
+ *
+ * IDs that are not push tokens, or no longer exist, are skipped. With a
+ * cutoff, each token is checked again just before it is deleted, so a device
+ * that re-registered since the caller selected it is kept.
+ *
+ * @since 11.4.0
+ *
+ * @param int[] $ids The push token IDs.
+ * @param string|null $cutoff_gmt Optional GMT `Y-m-d H:i:s` datetime a token must have been registered before.
+ * @return array{deleted: int[], failed: int[]} The IDs deleted, and the IDs whose deletion failed.
+ */
+ public function delete_tokens( array $ids, ?string $cutoff_gmt = null ): array {
+ $result = array(
+ 'deleted' => array(),
+ 'failed' => array(),
+ );
+
+ $ids = array_values( array_unique( array_filter( array_map( 'intval', $ids ), fn( int $id ) => $id > 0 ) ) );
+
+ if ( empty( $ids ) ) {
+ return $result;
+ }
+
+ // Prime caches to reduce future queries.
+ _prime_post_caches( $ids, false, false );
+
+ foreach ( $ids as $id ) {
+ if ( ! $this->is_deletable_token( $id, $cutoff_gmt ) ) {
+ continue;
+ }
+
+ try {
+ $deleted = wp_delete_post( $id, true );
+ } catch ( Throwable $e ) {
+ $deleted = false;
+ }
+
+ if ( $deleted ) {
+ $result['deleted'][] = $id;
+ unset( $this->pending_last_sent_at[ $id ] );
+ } else {
+ $result['failed'][] = $id;
+ }
+ }
+
+ // Anything read earlier in this request now includes deleted tokens.
+ $this->tokens_by_roles_cache = array();
+ $this->token_count = null;
+ $this->has_tokens = null;
+
+ return $result;
+ }
+
+ /**
+ * Whether an ID is a push token that may be deleted: it still exists and,
+ * with a cutoff, was last registered before it.
+ *
+ * Read from the database rather than the post cache, so a re-registration
+ * by another request since the caller selected the ID is seen.
+ *
+ * @param int $id The post ID.
+ * @param string|null $cutoff_gmt Optional GMT `Y-m-d H:i:s` datetime the token must have been registered before.
+ * @return bool
+ */
+ private function is_deletable_token( int $id, ?string $cutoff_gmt ): bool {
+ global $wpdb;
+
+ if ( null === $cutoff_gmt ) {
+ $found = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT ID FROM {$wpdb->posts} WHERE ID = %d AND post_type = %s",
+ $id,
+ PushToken::POST_TYPE
+ )
+ );
+ } else {
+ $found = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT ID FROM {$wpdb->posts} WHERE ID = %d AND post_type = %s AND post_modified_gmt < %s",
+ $id,
+ PushToken::POST_TYPE,
+ $cutoff_gmt
+ )
+ );
+ }
+
+ return null !== $found;
+ }
+
/**
* 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.
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Enums/CleanupOutcome.php b/plugins/woocommerce/src/Internal/PushNotifications/Enums/CleanupOutcome.php
new file mode 100644
index 00000000000..58211528d4f
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Enums/CleanupOutcome.php
@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Enums;
+
+/**
+ * Outcomes of a stale push token cleanup run.
+ */
+final class CleanupOutcome {
+
+ /**
+ * Every stale token the run selected was handled.
+ *
+ * @var string
+ */
+ public const COMPLETED = 'completed';
+
+ /**
+ * The run hit its deletion limit, leaving stale tokens for the next run.
+ *
+ * @var string
+ */
+ public const CAPPED = 'capped';
+
+ /**
+ * Push notifications are not enabled on the store, so nothing was deleted.
+ *
+ * @var string
+ */
+ public const SKIPPED_DISABLED = 'skipped_disabled';
+
+ /**
+ * The cleanup was switched off with a filter, so nothing was deleted.
+ *
+ * @var string
+ */
+ public const SKIPPED_FILTER = 'skipped_filter';
+
+ /**
+ * Push notifications came back on less than a retention window ago, so the
+ * apps have not yet had the chance to check in and nothing was deleted.
+ *
+ * @var string
+ */
+ public const SKIPPED_RECENTLY_RESUMED = 'skipped_recently_resumed';
+
+ /**
+ * The run stopped on an unexpected error.
+ *
+ * @var string
+ */
+ public const FAILED = 'failed';
+}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
index 9afd0d615fa..ff4f5217760 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
@@ -15,6 +15,7 @@ use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
use Automattic\WooCommerce\Internal\PushNotifications\Services\DriverAvailabilityService;
use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationRetryHandler;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\StaleTokenCleanupService;
use Automattic\WooCommerce\Internal\PushNotifications\Services\UserDataCleanupService;
use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
use Automattic\WooCommerce\Internal\PushNotifications\Triggers\NewOrderNotificationTrigger;
@@ -90,6 +91,10 @@ class PushNotifications {
// to start sending against.
wc_get_container()->get( UserDataCleanupService::class )->register();
+ // Also registered ahead of the enablement check: it unschedules the
+ // cleanup while the feature is off, and tracks how long it was off.
+ wc_get_container()->get( StaleTokenCleanupService::class )->register();
+
// Registered ahead of the enablement check so the token list can still be
// read on a store that has been switched off. The controller registers the
// write routes only while the module is enabled, like everything below.
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/ROADMAP.md b/plugins/woocommerce/src/Internal/PushNotifications/ROADMAP.md
index 908283b007a..07bc1399f59 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/ROADMAP.md
+++ b/plugins/woocommerce/src/Internal/PushNotifications/ROADMAP.md
@@ -30,6 +30,7 @@ The following terms have been used in this post to mean:
- Users who don't connect Jetpack
- Users who don't upgrade WooCommerce
- WordPress.com Notification Control Center preferences
+ - Stale token cleanup
## Goals
@@ -237,3 +238,15 @@ These users won't receive push notifications - this is the same as the existing
### 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
+
+### Stale token cleanup
+
+- From 25.8, the iOS and Android apps check in with every visible store at most once a day while the app is used (on iOS, only when it is opened). Each check-in re-registers the token, which moves its `post_modified_gmt` forward and records `metadata.app_version`.
+- A daily Action Scheduler job (`wc_push_notification_stale_token_cleanup`) deletes:
+ - tokens last registered by a WooCommerce app at 25.8 or later that have not checked in for 90 days
+ - any other token, such as one from an older app version, that has not re-registered for 180 days
+- Older app versions are kept for longer because they do not check in daily, so a quiet token is not a sign the install is gone.
+- Creation time and send attempts are not used as a sign of activity: Woo attempts a send to every token and cannot tell which deliveries succeeded.
+- While push notifications are off (feature disabled, Jetpack disconnected or WooCommerce deactivated) the job is unscheduled, because the apps cannot re-register. Once they are back on, silence from before that moment does not count: the 90-day rule waits 90 days and the 180-day rule waits 180 days.
+- Each run deletes at most 100 tokens, oldest first, and attempts at most 500. It writes one `info` summary line to the `push_notifications` log, even when step logging is off; a log threshold above `info` drops it.
+- Return false from the `woocommerce_push_notification_stale_token_cleanup_enabled` filter to keep every token.
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationStepLogger.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationStepLogger.php
index dad1dc76841..ea5ec8606e3 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationStepLogger.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationStepLogger.php
@@ -168,6 +168,33 @@ class NotificationStepLogger {
}
}
+ /**
+ * Records a step of a maintenance job that belongs to no notification,
+ * such as the stale token cleanup.
+ *
+ * Written under the module's own source, and not switched off with step
+ * logging, since it is the only record of what a job deleted.
+ *
+ * @since 11.4.0
+ *
+ * @param string $step Machine-readable step name, e.g. `stale_token_cleanup`.
+ * @param string $outcome Machine-readable outcome, e.g. `completed`.
+ * @param array $context Extra fields to store with the line.
+ * @return void
+ */
+ public function log_maintenance_step( string $step, string $outcome, array $context = array() ): void {
+ try {
+ $context['step'] = $step;
+ $context['outcome'] = $outcome;
+ $context['source'] = PushNotifications::FEATURE_NAME;
+ $context['remote-logging'] = false;
+
+ wc_get_logger()->info( self::format_message( $step, $outcome ), self::bound( $context ) );
+ } catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
+ // Deliberately ignored: a logging failure must not affect the job.
+ }
+ }
+
/**
* Records a failure that happened before a notification could be
* identified, such as a loopback request that failed authorization.
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/StaleTokenCleanupService.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/StaleTokenCleanupService.php
new file mode 100644
index 00000000000..a10cdb52c3f
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/StaleTokenCleanupService.php
@@ -0,0 +1,477 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Services;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore;
+use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
+use Automattic\WooCommerce\Internal\PushNotifications\Enums\CleanupOutcome;
+use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Throwable;
+
+/**
+ * Deletes push tokens that no app has re-registered for a long time.
+ *
+ * Apps from 25.8 check in daily while used, so their tokens go after
+ * {@see self::RETENTION_DAYS} days of silence; any other token after
+ * {@see self::FALLBACK_RETENTION_DAYS}. See ROADMAP.md for the reasoning.
+ *
+ * @since 11.4.0
+ *
+ * @internal
+ */
+class StaleTokenCleanupService {
+ /**
+ * Action Scheduler hook for the daily cleanup.
+ */
+ const CLEANUP_HOOK = 'wc_push_notification_stale_token_cleanup';
+
+ /**
+ * Days a token from an app that checks in daily is kept without a registration.
+ */
+ const RETENTION_DAYS = 90;
+
+ /**
+ * Days any other token is kept without a registration.
+ */
+ const FALLBACK_RETENTION_DAYS = 180;
+
+ /**
+ * The first app version that checks in daily.
+ */
+ const MIN_CHECK_IN_APP_VERSION = '25.8';
+
+ /**
+ * Origins of the apps that check in daily.
+ */
+ const CHECK_IN_ORIGINS = array(
+ PushToken::ORIGIN_WOOCOMMERCE_ANDROID,
+ PushToken::ORIGIN_WOOCOMMERCE_ANDROID_DEV,
+ PushToken::ORIGIN_WOOCOMMERCE_IOS,
+ PushToken::ORIGIN_WOOCOMMERCE_IOS_DEV,
+ );
+
+ /**
+ * How many stale tokens to load and classify at once.
+ */
+ const CHUNK_SIZE = 100;
+
+ /**
+ * The most tokens one run deletes, so a bug cannot empty a store at once.
+ */
+ const MAX_DELETIONS_PER_RUN = 100;
+
+ /**
+ * The most deletions one run attempts, counting failures, so tokens that
+ * never delete cannot make a run endless.
+ */
+ const MAX_ATTEMPTS_PER_RUN = 500;
+
+ /**
+ * Option set while push notifications are off, as a Unix timestamp.
+ */
+ const PAUSED_AT_OPTION = 'woocommerce_push_notification_cleanup_paused_at';
+
+ /**
+ * Option holding when push notifications came back on, as a Unix timestamp.
+ *
+ * Apps cannot re-register while push notifications are off, so silence from
+ * before this moment does not count towards a token's age.
+ */
+ const RESUMED_AT_OPTION = 'woocommerce_push_notification_cleanup_resumed_at';
+
+ /**
+ * Step name for the run's log line.
+ */
+ const LOG_STEP = 'stale_token_cleanup';
+
+ /**
+ * The push tokens data store.
+ *
+ * @var PushTokensDataStore
+ */
+ private PushTokensDataStore $data_store;
+
+ /**
+ * The step logger.
+ *
+ * @var NotificationStepLogger
+ */
+ private NotificationStepLogger $step_logger;
+
+ /**
+ * The push notifications feature class.
+ *
+ * @var PushNotifications
+ */
+ private PushNotifications $push_notifications;
+
+ /**
+ * Initialize dependencies.
+ *
+ * @since 11.4.0
+ *
+ * @internal
+ *
+ * @param PushTokensDataStore $data_store The push tokens data store.
+ * @param NotificationStepLogger $step_logger The step logger.
+ * @param PushNotifications $push_notifications The push notifications feature class.
+ */
+ final public function init(
+ PushTokensDataStore $data_store,
+ NotificationStepLogger $step_logger,
+ PushNotifications $push_notifications
+ ): void {
+ $this->data_store = $data_store;
+ $this->step_logger = $step_logger;
+ $this->push_notifications = $push_notifications;
+ }
+
+ /**
+ * Registers the cleanup, scheduling and deactivation callbacks.
+ *
+ * Called whether or not push notifications are enabled, so the schedule
+ * follows the store's state and an action left pending never lacks a callback.
+ *
+ * @since 11.4.0
+ *
+ * @return void
+ */
+ public function register(): void {
+ add_action( self::CLEANUP_HOOK, array( $this, 'handle_stale_token_cleanup' ) );
+ add_action( 'action_scheduler_ensure_recurring_actions', array( $this, 'handle_ensure_recurring_actions' ) );
+
+ if ( defined( 'WC_PLUGIN_BASENAME' ) ) {
+ add_action( 'deactivate_' . WC_PLUGIN_BASENAME, array( $this, 'handle_plugin_deactivation' ) );
+ }
+ }
+
+ /**
+ * Handles the daily action_scheduler_ensure_recurring_actions hook.
+ *
+ * Keeps the cleanup scheduled while push notifications are on, and removes
+ * it while they are off.
+ *
+ * @since 11.4.0
+ *
+ * @internal
+ *
+ * @return void
+ */
+ public function handle_ensure_recurring_actions(): void {
+ if ( ! $this->push_notifications->should_be_enabled() ) {
+ $this->pause();
+ return;
+ }
+
+ $this->note_resumed( time() );
+
+ // The first run waits a day, so a store's first cleanup never follows
+ // straight on from the update or switch that scheduled it.
+ as_schedule_recurring_action(
+ time() + DAY_IN_SECONDS,
+ DAY_IN_SECONDS,
+ self::CLEANUP_HOOK,
+ array(),
+ NotificationProcessor::ACTION_SCHEDULER_GROUP,
+ true
+ );
+ }
+
+ /**
+ * Handles WooCommerce being deactivated.
+ *
+ * @since 11.4.0
+ *
+ * @internal
+ *
+ * @return void
+ */
+ public function handle_plugin_deactivation(): void {
+ $this->pause();
+ }
+
+ /**
+ * Handles the daily cleanup action.
+ *
+ * @since 11.4.0
+ *
+ * @internal
+ *
+ * @return void
+ */
+ public function handle_stale_token_cleanup(): void {
+ try {
+ $this->run();
+ } catch ( Throwable $e ) {
+ $this->step_logger->log_unattributed_failure(
+ self::LOG_STEP,
+ CleanupOutcome::FAILED,
+ 'error',
+ sprintf( 'Stale push token cleanup failed: %s', $e->getMessage() )
+ );
+ }
+ }
+
+ /**
+ * Deletes up to {@see self::MAX_DELETIONS_PER_RUN} stale tokens, oldest
+ * first, and logs what it did.
+ *
+ * @since 11.4.0
+ *
+ * @param int|null $now Unix timestamp to measure the retention from. Defaults to now.
+ * @return array The summary that was logged, including the outcome.
+ */
+ public function run( ?int $now = null ): array {
+ $now = $now ?? time();
+
+ if ( ! $this->push_notifications->should_be_enabled() ) {
+ $this->mark_paused( $now );
+ return $this->log_summary( CleanupOutcome::SKIPPED_DISABLED, array() );
+ }
+
+ if ( ! $this->is_enabled_by_filter() ) {
+ return $this->log_summary( CleanupOutcome::SKIPPED_FILTER, array() );
+ }
+
+ $resumed_at = $this->note_resumed( $now );
+ $check_in_cutoff = $this->get_cutoff( $now, self::RETENTION_DAYS, $resumed_at );
+ $cutoffs = array(
+ 'check_in' => $check_in_cutoff,
+ 'fallback' => $this->get_cutoff( $now, self::FALLBACK_RETENTION_DAYS, $resumed_at ),
+ );
+
+ if ( null === $check_in_cutoff ) {
+ return $this->log_summary( CleanupOutcome::SKIPPED_RECENTLY_RESUMED, array( 'resumed_at' => gmdate( 'Y-m-d H:i:s', (int) $resumed_at ) ) );
+ }
+
+ $summary = array(
+ 'cutoff' => $cutoffs['check_in'],
+ 'fallback_cutoff' => $cutoffs['fallback'],
+ 'resumed_at' => null === $resumed_at ? null : gmdate( 'Y-m-d H:i:s', $resumed_at ),
+ 'tokens_before' => $this->data_store->count_tokens(),
+ 'stale' => 0,
+ 'kept_older_apps' => 0,
+ 'deleted_daily_check_in' => 0,
+ 'deleted_fallback' => 0,
+ 'failed' => 0,
+ 'deleted_token_ids' => array(),
+ 'failed_token_ids' => array(),
+ );
+ $outcome = CleanupOutcome::COMPLETED;
+
+ try {
+ $stale_ids = $this->data_store->get_stale_token_ids( $check_in_cutoff );
+ $summary['stale'] = count( $stale_ids );
+ $attempts_remaining = self::MAX_ATTEMPTS_PER_RUN;
+
+ foreach ( array_chunk( $stale_ids, self::CHUNK_SIZE ) as $chunk ) {
+ $deletions_remaining = self::MAX_DELETIONS_PER_RUN - count( $summary['deleted_token_ids'] );
+ $limit = min( $deletions_remaining, $attempts_remaining );
+
+ list( $deletable, $kept ) = $this->classify( $chunk, $cutoffs['fallback'] );
+
+ $summary['kept_older_apps'] += $kept;
+
+ if ( count( $deletable ) > $limit ) {
+ $deletable = array_slice( $deletable, 0, $limit, true );
+ $outcome = CleanupOutcome::CAPPED;
+ }
+
+ $attempts_remaining -= count( $deletable );
+
+ foreach ( array( 'check_in', 'fallback' ) as $rule ) {
+ $ids = array_keys( array_filter( $deletable, fn( string $matched ) => $rule === $matched ) );
+ $result = $this->data_store->delete_tokens( $ids, $cutoffs[ $rule ] );
+
+ $summary[ 'check_in' === $rule ? 'deleted_daily_check_in' : 'deleted_fallback' ] += count( $result['deleted'] );
+ $summary['failed'] += count( $result['failed'] );
+ $summary['deleted_token_ids'] = array_merge( $summary['deleted_token_ids'], $result['deleted'] );
+ $summary['failed_token_ids'] = array_merge( $summary['failed_token_ids'], $result['failed'] );
+ }
+
+ if ( CleanupOutcome::CAPPED === $outcome ) {
+ break;
+ }
+ }
+ } catch ( Throwable $e ) {
+ $outcome = CleanupOutcome::FAILED;
+ $summary['error'] = $e->getMessage();
+ }
+
+ return $this->log_summary( $outcome, $summary );
+ }
+
+ /**
+ * Returns the GMT datetime a token must have registered before to be
+ * deleted under a rule, or null while the rule is waiting.
+ *
+ * Silence from before push notifications came back on does not count, so
+ * a rule applies only once its full window has passed since then.
+ *
+ * @param int $now Unix timestamp.
+ * @param int $days The rule's retention in days.
+ * @param int|null $resumed_at When push notifications came back on, if that still matters.
+ * @return string|null
+ */
+ private function get_cutoff( int $now, int $days, ?int $resumed_at ): ?string {
+ $cutoff = $now - $days * DAY_IN_SECONDS;
+
+ if ( null !== $resumed_at && $resumed_at >= $cutoff ) {
+ return null;
+ }
+
+ return gmdate( 'Y-m-d H:i:s', $cutoff );
+ }
+
+ /**
+ * Picks the stale tokens to delete, in the order given, and counts the rest.
+ *
+ * @param int[] $ids Tokens last registered before the daily check-in cutoff, oldest first.
+ * @param string|null $fallback_cutoff GMT `Y-m-d H:i:s` datetime before which any token is deleted, or null when that rule is paused.
+ * @return array{0: array<int, string>, 1: int} Token ID => the rule it is deleted under (`check_in` or `fallback`), and how many were kept.
+ */
+ private function classify( array $ids, ?string $fallback_cutoff ): array {
+ $deletable = array();
+ $kept = 0;
+
+ if ( ! empty( $ids ) ) {
+ // Prime caches to reduce future queries.
+ _prime_post_caches( $ids, false, true );
+ }
+
+ foreach ( $ids as $id ) {
+ $post = get_post( $id );
+
+ if ( ! $post ) {
+ continue;
+ }
+
+ if ( $this->checks_in_daily( $id ) ) {
+ $deletable[ $id ] = 'check_in';
+ } elseif ( null !== $fallback_cutoff && $post->post_modified_gmt < $fallback_cutoff ) {
+ $deletable[ $id ] = 'fallback';
+ } else {
+ ++$kept;
+ }
+ }
+
+ return array( $deletable, $kept );
+ }
+
+ /**
+ * Whether a token was last registered by an app that checks in daily.
+ *
+ * Compares only `major.minor`, since `version_compare()` ranks Android's
+ * `25.8-rc-1` below `25.8`. A missing or unrecognised version counts as old.
+ *
+ * @param int $id The token's post ID.
+ * @return bool
+ */
+ private function checks_in_daily( int $id ): bool {
+ if ( ! in_array( get_post_meta( $id, 'origin', true ), self::CHECK_IN_ORIGINS, true ) ) {
+ return false;
+ }
+
+ $metadata = get_post_meta( $id, 'metadata', true );
+
+ if ( ! is_array( $metadata ) || ! is_scalar( $metadata['app_version'] ?? null ) ) {
+ return false;
+ }
+
+ if ( ! preg_match( '/^(\d+)\.(\d+)/', (string) $metadata['app_version'], $matches ) ) {
+ return false;
+ }
+
+ return version_compare( $matches[1] . '.' . $matches[2], self::MIN_CHECK_IN_APP_VERSION, '>=' );
+ }
+
+ /**
+ * Removes the cleanup and records that push notifications are off.
+ *
+ * @return void
+ */
+ private function pause(): void {
+ as_unschedule_all_actions( self::CLEANUP_HOOK, array(), NotificationProcessor::ACTION_SCHEDULER_GROUP );
+ $this->mark_paused( time() );
+ }
+
+ /**
+ * Records that push notifications are off, keeping the first time it was seen.
+ *
+ * @param int $now Unix timestamp.
+ * @return void
+ */
+ private function mark_paused( int $now ): void {
+ add_option( self::PAUSED_AT_OPTION, $now, '', false );
+ }
+
+ /**
+ * Turns a recorded pause into a resume time, and returns the resume time
+ * while it still affects a rule.
+ *
+ * @param int $now Unix timestamp.
+ * @return int|null When push notifications came back on, or null if that no longer matters.
+ */
+ private function note_resumed( int $now ): ?int {
+ if ( false !== get_option( self::PAUSED_AT_OPTION ) ) {
+ update_option( self::RESUMED_AT_OPTION, $now, false );
+ delete_option( self::PAUSED_AT_OPTION );
+ }
+
+ $resumed_at = (int) get_option( self::RESUMED_AT_OPTION, 0 );
+
+ if ( $resumed_at <= 0 ) {
+ return null;
+ }
+
+ if ( $resumed_at < $now - self::FALLBACK_RETENTION_DAYS * DAY_IN_SECONDS ) {
+ delete_option( self::RESUMED_AT_OPTION );
+ return null;
+ }
+
+ return $resumed_at;
+ }
+
+ /**
+ * Whether the cleanup filter allows this run. A callback that throws or
+ * returns something unrecognised turns the cleanup off.
+ *
+ * @return bool
+ */
+ private function is_enabled_by_filter(): bool {
+ try {
+ /**
+ * Filters whether stale push tokens are deleted.
+ *
+ * Runs once per daily cleanup. Return false to keep every token, for
+ * example while investigating missing notifications.
+ *
+ * @since 11.4.0
+ *
+ * @param bool $enabled Whether stale push tokens are deleted. Default true.
+ */
+ $enabled = apply_filters( 'woocommerce_push_notification_stale_token_cleanup_enabled', true );
+
+ return ( is_bool( $enabled ) || is_string( $enabled ) || is_int( $enabled ) ) && wc_string_to_bool( $enabled );
+ } catch ( Throwable $e ) {
+ return false;
+ }
+ }
+
+ /**
+ * Writes the run's summary line and returns it.
+ *
+ * @param string $outcome One of the CleanupOutcome constants.
+ * @param array $summary The run's counts.
+ * @return array The summary, with the outcome added.
+ */
+ private function log_summary( string $outcome, array $summary ): array {
+ $this->step_logger->log_maintenance_step( self::LOG_STEP, $outcome, $summary );
+
+ return array_merge( $summary, array( 'outcome' => $outcome ) );
+ }
+}
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 312de3e4d12..fe14ba24924 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
@@ -9,6 +9,8 @@ use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
use Automattic\WooCommerce\Internal\PushNotifications\Exceptions\PushTokenInvalidDataException;
use Automattic\WooCommerce\Internal\PushNotifications\Exceptions\PushTokenNotFoundException;
use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
+use Automattic\WooCommerce\Tests\Internal\PushNotifications\Helpers\PushNotificationsTestTrait;
+use RuntimeException;
use WC_Unit_Test_Case;
/**
@@ -19,6 +21,7 @@ use WC_Unit_Test_Case;
class PushTokensDataStoreTest extends WC_Unit_Test_Case {
use LoggerSpyTrait;
+ use PushNotificationsTestTrait;
/**
* Tear down the test case.
@@ -1243,31 +1246,6 @@ 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.
*
@@ -1348,6 +1326,205 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
$this->assertSame( '', get_post_meta( $token_id, 'token', true ) );
}
+ /**
+ * @testdox Should stop reporting tokens in the same request once a user's last token is deleted.
+ */
+ public function test_delete_for_user_resets_the_memoized_has_tokens(): void {
+ $data_store = new PushTokensDataStore();
+ $this->create_push_token_for_user( $data_store, 106 );
+
+ $this->assertTrue( $data_store->has_tokens(), 'Memoizes has_tokens() before the delete.' );
+
+ $data_store->delete_for_user( 106 );
+
+ $this->assertFalse( $data_store->has_tokens(), 'has_tokens() should be read again after the delete.' );
+ }
+
+ /**
+ * @testdox Should return only the tokens last registered strictly before the cutoff.
+ */
+ public function test_get_stale_token_ids_excludes_tokens_registered_at_or_after_the_cutoff(): void {
+ $data_store = new PushTokensDataStore();
+ $before = $this->create_push_token_for_user( $data_store, 101 );
+ $at = $this->create_push_token_for_user( $data_store, 101 );
+ $after = $this->create_push_token_for_user( $data_store, 101 );
+
+ $this->set_token_dates( $before->get_id(), '2026-01-01 00:00:00', '2026-06-30 23:59:59' );
+ $this->set_token_dates( $at->get_id(), '2026-01-01 00:00:00', '2026-07-01 00:00:00' );
+ $this->set_token_dates( $after->get_id(), '2026-01-01 00:00:00', '2026-07-01 00:00:01' );
+
+ $this->assertSame( array( $before->get_id() ), $data_store->get_stale_token_ids( '2026-07-01 00:00:00' ) );
+ }
+
+ /**
+ * @testdox Should return stale tokens oldest first by last registration, not by ID or creation date.
+ */
+ public function test_get_stale_token_ids_orders_by_last_registration(): void {
+ $data_store = new PushTokensDataStore();
+ $newer = $this->create_push_token_for_user( $data_store, 101 );
+ $older = $this->create_push_token_for_user( $data_store, 101 );
+
+ $this->set_token_dates( $newer->get_id(), '2026-01-01 00:00:00', '2026-03-01 00:00:00' );
+ $this->set_token_dates( $older->get_id(), '2026-02-01 00:00:00', '2026-02-01 00:00:00' );
+
+ $this->assertSame( array( $older->get_id(), $newer->get_id() ), $data_store->get_stale_token_ids( '2026-07-01 00:00:00' ) );
+ }
+
+ /**
+ * @testdox Should select stale tokens by their GMT registration time, not the site's local time.
+ */
+ public function test_get_stale_token_ids_compares_gmt_times(): void {
+ $data_store = new PushTokensDataStore();
+ $token = $this->create_push_token_for_user( $data_store, 101 );
+
+ $this->set_token_dates( $token->get_id(), '2026-01-01 00:00:00', '2026-07-01 00:00:01', '2026-06-30 20:00:01' );
+
+ $this->assertSame( array(), $data_store->get_stale_token_ids( '2026-07-01 00:00:00' ), 'A token registered just after the cutoff in GMT should not be stale.' );
+ }
+
+ /**
+ * @testdox Should not return old posts of other types as stale tokens.
+ */
+ public function test_get_stale_token_ids_ignores_other_post_types(): void {
+ $data_store = new PushTokensDataStore();
+ self::factory()->post->create(
+ array(
+ 'post_status' => 'private',
+ 'post_date' => '2020-01-01 00:00:00',
+ 'post_modified_gmt' => '2020-01-01 00:00:00',
+ )
+ );
+
+ $this->assertSame( array(), $data_store->get_stale_token_ids( '2026-07-01 00:00:00' ) );
+ }
+
+ /**
+ * @testdox Should keep a token that re-registers while earlier tokens in the same call are being deleted.
+ */
+ public function test_delete_tokens_checks_each_token_just_before_deleting_it(): void {
+ global $wpdb;
+
+ $data_store = new PushTokensDataStore();
+ $first = $this->create_push_token_for_user( $data_store, 101 );
+ $second = $this->create_push_token_for_user( $data_store, 101 );
+ $this->set_token_dates( $first->get_id(), '2026-01-01 00:00:00', '2026-01-01 00:00:00' );
+ $this->set_token_dates( $second->get_id(), '2026-01-01 00:00:00', '2026-01-01 00:00:00' );
+
+ add_action(
+ 'before_delete_post',
+ function ( $post_id ) use ( $wpdb, $first, $second ) {
+ if ( $post_id === $first->get_id() ) {
+ $wpdb->update( $wpdb->posts, array( 'post_modified_gmt' => '2026-08-01 00:00:00' ), array( 'ID' => $second->get_id() ) );
+ }
+ }
+ );
+
+ $result = $data_store->delete_tokens( array( $first->get_id(), $second->get_id() ), '2026-07-01 00:00:00' );
+
+ $this->assertSame( array( $first->get_id() ), $result['deleted'], 'Only the token still stale at its turn should go.' );
+ $this->assertNotNull( get_post( $second->get_id() ), 'The token that re-registered mid-call should be kept.' );
+ }
+
+ /**
+ * @testdox Should drop a deleted token's pending last-send stamp so the flush writes no orphan meta.
+ */
+ public function test_delete_tokens_drops_pending_last_sent_at(): void {
+ global $wpdb;
+
+ $data_store = new PushTokensDataStore();
+ $token = $this->create_push_token_for_user( $data_store, 101 );
+ $data_store->record_last_sent_at( array( $token ) );
+
+ $data_store->delete_tokens( array( $token->get_id() ) );
+ $data_store->flush_last_sent_at();
+
+ $this->assertSame(
+ '0',
+ $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE post_id = %d", $token->get_id() ) ),
+ 'No meta should be written for a deleted token.'
+ );
+ }
+
+ /**
+ * @testdox Should delete the tokens and refresh the counts read earlier in the request.
+ */
+ public function test_delete_tokens_deletes_and_resets_cached_counts(): void {
+ $data_store = new PushTokensDataStore();
+ $first = $this->create_push_token_for_user( $data_store, 101 );
+ $second = $this->create_push_token_for_user( $data_store, 101 );
+
+ $this->assertSame( 2, $data_store->count_tokens(), 'Memoizes the count before the delete.' );
+ $this->assertTrue( $data_store->has_tokens(), 'Memoizes has_tokens() before the delete.' );
+
+ $result = $data_store->delete_tokens( array( $first->get_id(), $second->get_id() ) );
+
+ $this->assertSame( array( $first->get_id(), $second->get_id() ), $result['deleted'], 'Both tokens should be reported deleted.' );
+ $this->assertSame( array(), $result['failed'], 'Nothing should fail.' );
+ $this->assertSame( 0, $data_store->count_tokens(), 'The memoized count should be dropped after a delete.' );
+ $this->assertFalse( $data_store->has_tokens(), 'The memoized has_tokens() should be dropped after a delete.' );
+ }
+
+ /**
+ * @testdox Should leave posts that are not push tokens untouched.
+ */
+ public function test_delete_tokens_skips_posts_of_other_types(): void {
+ $data_store = new PushTokensDataStore();
+ $post_id = self::factory()->post->create();
+
+ $result = $data_store->delete_tokens( array( $post_id ) );
+
+ $this->assertSame( array(), $result['deleted'] );
+ $this->assertSame( array(), $result['failed'] );
+ $this->assertNotNull( get_post( $post_id ) );
+ }
+
+ /**
+ * @testdox Should keep a token re-registered at or after the cutoff since it was selected.
+ */
+ public function test_delete_tokens_keeps_a_token_re_registered_since_the_cutoff(): void {
+ $data_store = new PushTokensDataStore();
+ $stale = $this->create_push_token_for_user( $data_store, 101 );
+ $refreshed = $this->create_push_token_for_user( $data_store, 101 );
+
+ $this->set_token_dates( $stale->get_id(), '2026-01-01 00:00:00', '2026-01-01 00:00:00' );
+ $this->set_token_dates( $refreshed->get_id(), '2026-01-01 00:00:00', '2026-07-01 00:00:00' );
+
+ $result = $data_store->delete_tokens( array( $stale->get_id(), $refreshed->get_id() ), '2026-07-01 00:00:00' );
+
+ $this->assertSame( array( $stale->get_id() ), $result['deleted'] );
+ $this->assertNotNull( get_post( $refreshed->get_id() ) );
+ }
+
+ /**
+ * @testdox Should carry on deleting when one token cannot be deleted or its deletion throws.
+ */
+ public function test_delete_tokens_reports_failures_without_stopping(): void {
+ $data_store = new PushTokensDataStore();
+ $refused = $this->create_push_token_for_user( $data_store, 101 );
+ $throwing = $this->create_push_token_for_user( $data_store, 101 );
+ $deletable = $this->create_push_token_for_user( $data_store, 101 );
+
+ add_filter(
+ 'pre_delete_post',
+ fn( $check, $post ) => $post->ID === $refused->get_id() ? false : $check,
+ 10,
+ 2
+ );
+ add_action(
+ 'before_delete_post',
+ function ( $post_id ) use ( $throwing ) {
+ if ( $post_id === $throwing->get_id() ) {
+ throw new RuntimeException( 'A third-party callback failed.' );
+ }
+ }
+ );
+
+ $result = $data_store->delete_tokens( array( $refused->get_id(), $throwing->get_id(), $deletable->get_id() ) );
+
+ $this->assertSame( array( $deletable->get_id() ), $result['deleted'] );
+ $this->assertSame( array( $refused->get_id(), $throwing->get_id() ), $result['failed'] );
+ }
+
/**
* Counts the push token records owned by a user.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Helpers/PushNotificationsTestTrait.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Helpers/PushNotificationsTestTrait.php
index a0215079ee4..7ae938e1aa0 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Helpers/PushNotificationsTestTrait.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Helpers/PushNotificationsTestTrait.php
@@ -18,7 +18,8 @@ use ReflectionClass;
* Mocks the Jetpack connection state and resets the memoized enablement flag on
* the container's `PushNotifications` instance — the two things every
* push-notifications-related controller test needs in setUp. Also builds the
- * push tokens data store mock that PendingNotificationStore tests need.
+ * push tokens data store mock that PendingNotificationStore tests need, and
+ * backdates token registrations for the tests that depend on token age.
*
* @package WooCommerce\Tests\PushNotifications
*/
@@ -102,4 +103,29 @@ trait PushNotificationsTestTrait {
}
}
}
+
+ /**
+ * 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.
+ */
+ protected 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 );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/PushNotificationsTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/PushNotificationsTest.php
index 70b6d0b5c42..fe0b3a9f615 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/PushNotificationsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/PushNotificationsTest.php
@@ -13,6 +13,7 @@ use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushTokenRestC
use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore;
use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\StaleTokenCleanupService;
use Automattic\WooCommerce\Internal\PushNotifications\Triggers\NewOrderNotificationTrigger;
use Automattic\WooCommerce\Tests\Internal\PushNotifications\Helpers\PushNotificationsTestTrait;
use Automattic\WooCommerce\Proxies\LegacyProxy;
@@ -393,6 +394,29 @@ class PushNotificationsTest extends WC_Unit_Test_Case {
$this->assertNotContains( NotificationPreferencesRestController::class, $registered );
}
+ /**
+ * @testdox Tests that on_init registers the stale token cleanup callbacks whether push notifications are enabled ($enabled) or not.
+ * @testWith [true]
+ * [false]
+ *
+ * @param bool $enabled Whether push notifications are enabled.
+ */
+ public function test_on_init_registers_stale_token_cleanup( bool $enabled ) {
+ if ( $enabled ) {
+ $this->set_up_jetpack_connection_manager_mock( array( 'is_connected' ) );
+ $this->jetpack_connection_manager_mock->method( 'is_connected' )->willReturn( true );
+ } else {
+ add_filter( 'woocommerce_enhanced_push_notifications_disabled', '__return_true' );
+ }
+
+ wc_get_container()->get( PushNotifications::class )->on_init();
+
+ $cleanup = wc_get_container()->get( StaleTokenCleanupService::class );
+
+ $this->assertNotFalse( has_action( StaleTokenCleanupService::CLEANUP_HOOK, array( $cleanup, 'handle_stale_token_cleanup' ) ), 'The cleanup callback should always be registered.' );
+ $this->assertNotFalse( has_action( 'action_scheduler_ensure_recurring_actions', array( $cleanup, 'handle_ensure_recurring_actions' ) ), 'The scheduling callback should always be registered, to unschedule while off.' );
+ }
+
/**
* @testdox Tests that the tokens endpoint returns the store's registered tokens while
* the module is disabled via the filter.
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/StaleTokenCleanupServiceTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/StaleTokenCleanupServiceTest.php
new file mode 100644
index 00000000000..a916bc89c24
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/StaleTokenCleanupServiceTest.php
@@ -0,0 +1,567 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Services;
+
+use ActionScheduler_Action;
+use ActionScheduler_Store;
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore;
+use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
+use Automattic\WooCommerce\Internal\PushNotifications\Enums\CleanupOutcome;
+use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationStepLogger;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\StaleTokenCleanupService;
+use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
+use Automattic\WooCommerce\Tests\Internal\PushNotifications\Helpers\PushNotificationsTestTrait;
+use PHPUnit\Framework\MockObject\MockObject;
+use RuntimeException;
+use WC_Unit_Test_Case;
+use WP_Post;
+
+/**
+ * Tests for the StaleTokenCleanupService class.
+ *
+ * @covers \Automattic\WooCommerce\Internal\PushNotifications\Services\StaleTokenCleanupService
+ */
+class StaleTokenCleanupServiceTest extends WC_Unit_Test_Case {
+
+ use LoggerSpyTrait;
+ use PushNotificationsTestTrait;
+
+ /**
+ * The moment every test measures token age from.
+ */
+ const NOW = '2026-10-23 12:00:00';
+
+ /**
+ * The System Under Test.
+ *
+ * @var StaleTokenCleanupService
+ */
+ private $sut;
+
+ /**
+ * The push tokens data store.
+ *
+ * @var PushTokensDataStore
+ */
+ private PushTokensDataStore $data_store;
+
+ /**
+ * Whether the mocked push notifications feature reports itself enabled.
+ *
+ * @var bool
+ */
+ private bool $push_enabled = true;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->data_store = new PushTokensDataStore();
+
+ /**
+ * The feature class, enabled unless a test switches it off.
+ *
+ * @var PushNotifications|MockObject $push_notifications
+ */
+ $push_notifications = $this->createMock( PushNotifications::class );
+ $push_notifications->method( 'should_be_enabled' )->willReturnCallback( fn() => $this->push_enabled );
+
+ $this->sut = new StaleTokenCleanupService();
+ $this->sut->init( $this->data_store, new NotificationStepLogger(), $push_notifications );
+ }
+
+ /**
+ * @testdox Should delete a token from an app that checks in daily once it is past 90 days, and keep a recent one.
+ */
+ public function test_deletes_check_in_tokens_past_the_retention_window(): void {
+ $stale = $this->create_token( '25.8', $this->days( 91 ) );
+ $recent = $this->create_token( '25.8', $this->days( 89 ) );
+
+ $summary = $this->sut->run( $this->now() );
+
+ $this->assertNull( get_post( $stale ), 'A 25.8 token silent for 91 days should be deleted.' );
+ $this->assertNotNull( get_post( $recent ), 'A 25.8 token seen 89 days ago should be kept.' );
+ $this->assertSame( CleanupOutcome::COMPLETED, $summary['outcome'], 'The run should complete.' );
+ $this->assertSame( 1, $summary['deleted_daily_check_in'], 'One token should be deleted under the 90-day rule.' );
+ }
+
+ /**
+ * @testdox Should treat app version $app_version from $origin as one that checks in daily.
+ * @testWith ["25.8", "com.automattic.woocommerce"]
+ * ["25.8.0.0", "com.automattic.woocommerce"]
+ * ["25.8.0.1", "com.automattic.woocommerce:dev"]
+ * ["25.8-rc-1", "com.woocommerce.android"]
+ * ["25.8.1", "com.woocommerce.android"]
+ * ["25.10", "com.woocommerce.android:dev"]
+ * ["26.0", "com.automattic.woocommerce"]
+ *
+ * @param string $app_version The app version the token was registered with.
+ * @param string $origin The app the token was registered from.
+ */
+ public function test_deletes_tokens_from_versions_that_check_in_daily( string $app_version, string $origin ): void {
+ $token = $this->create_token( $app_version, $this->days( 91 ), $origin );
+
+ $this->sut->run( $this->now() );
+
+ $this->assertNull( get_post( $token ), "A {$app_version} token from {$origin} silent for 91 days should be deleted." );
+ }
+
+ /**
+ * @testdox Should keep a 91-day-old token with app version $app_version until the 180-day fallback.
+ * @testWith ["25.7"]
+ * ["25.7.0.1"]
+ * ["pr123-abc1234"]
+ * [""]
+ * [null]
+ *
+ * @param string|null $app_version The app version the token was registered with, or null for none.
+ */
+ public function test_keeps_tokens_from_older_or_unknown_versions_inside_the_fallback_window( ?string $app_version ): void {
+ $token = $this->create_token( $app_version, $this->days( 91 ) );
+
+ $summary = $this->sut->run( $this->now() );
+
+ $this->assertNotNull( get_post( $token ), 'An older or unknown app version should get the longer window.' );
+ $this->assertSame( 1, $summary['kept_older_apps'], 'The kept token should be counted.' );
+ }
+
+ /**
+ * @testdox Should delete a $app_version token only when it is older than the cutoff by at least a second.
+ * @testWith ["25.8", 7776000, true]
+ * ["25.8", 7776001, false]
+ * ["25.7", 15552000, true]
+ * ["25.7", 15552001, false]
+ *
+ * @param string $app_version The app version the token was registered with.
+ * @param int $age Seconds since the token last registered.
+ * @param bool $kept Whether the token should survive.
+ */
+ public function test_deletes_only_tokens_strictly_older_than_the_cutoff( string $app_version, int $age, bool $kept ): void {
+ $token = $this->create_token( $app_version, $age );
+
+ $this->sut->run( $this->now() );
+
+ $this->assertSame( $kept, get_post( $token ) instanceof WP_Post, "A {$app_version} token {$age}s old should " . ( $kept ? 'be kept.' : 'be deleted.' ) );
+ }
+
+ /**
+ * @testdox Should apply only the 180-day rule to a token whose origin is not a WooCommerce app.
+ */
+ public function test_applies_the_fallback_to_tokens_from_other_origins(): void {
+ $inside = $this->create_token( '25.8', $this->days( 100 ) );
+ $outside = $this->create_token( '25.8', $this->days( 181 ) );
+ update_post_meta( $inside, 'origin', PushToken::ORIGIN_BROWSER );
+ update_post_meta( $outside, 'origin', PushToken::ORIGIN_BROWSER );
+
+ $this->sut->run( $this->now() );
+
+ $this->assertNotNull( get_post( $inside ), 'A browser token seen 100 days ago should be kept.' );
+ $this->assertNull( get_post( $outside ), 'A browser token silent for 181 days should be deleted.' );
+ }
+
+ /**
+ * @testdox Should spend the deletion limit on the oldest tokens, whichever rule they fall under.
+ */
+ public function test_deletes_the_oldest_tokens_first_when_capped(): void {
+ $fallback = array();
+ for ( $i = 0; $i < 60; $i++ ) {
+ $fallback[] = $this->create_token( '25.7', $this->days( 300 + $i ) );
+ }
+
+ $check_in = array();
+ for ( $i = 0; $i < 60; $i++ ) {
+ $check_in[] = $this->create_token( '25.8', $this->days( 100 + $i ) );
+ }
+
+ $first = $this->sut->run( $this->now() );
+
+ $this->assertSame( CleanupOutcome::CAPPED, $first['outcome'], 'The first run should stop at the limit.' );
+ $this->assertSame( 60, $first['deleted_fallback'], 'Every fallback token is older than every daily check-in token.' );
+ $this->assertSame( 40, $first['deleted_daily_check_in'], 'The limit should be filled with the oldest daily check-in tokens.' );
+ $this->assertSame( array_slice( $check_in, 0, 20 ), $this->existing( $check_in ), 'The 20 newest daily check-in tokens should remain.' );
+
+ $second = $this->sut->run( $this->now() );
+
+ $this->assertSame( CleanupOutcome::COMPLETED, $second['outcome'], 'The second run should finish.' );
+ $this->assertSame( 20, $second['deleted_daily_check_in'], 'The second run should delete the rest.' );
+ }
+
+ /**
+ * @testdox Should report a completed run when it deletes exactly the limit.
+ */
+ public function test_reports_completed_when_deleting_exactly_the_limit(): void {
+ for ( $i = 0; $i < StaleTokenCleanupService::MAX_DELETIONS_PER_RUN; $i++ ) {
+ $this->create_token( '25.8', $this->days( 91 ) );
+ }
+ $this->create_token( '25.7', $this->days( 100 ) );
+
+ $summary = $this->sut->run( $this->now() );
+
+ $this->assertSame( CleanupOutcome::COMPLETED, $summary['outcome'], 'Nothing deletable is left, so the run is not capped.' );
+ $this->assertSame( StaleTokenCleanupService::MAX_DELETIONS_PER_RUN, $summary['deleted_daily_check_in'], 'Every eligible token should go.' );
+ }
+
+ /**
+ * @testdox Should finish across runs whatever old-app tokens come first, and do nothing on a repeat run.
+ */
+ public function test_respects_the_deletion_limit_across_runs(): void {
+ for ( $i = 0; $i < 3; $i++ ) {
+ $this->create_token( '25.7', $this->days( 150 ) );
+ }
+
+ $deletable = array();
+ for ( $i = 0; $i < StaleTokenCleanupService::MAX_DELETIONS_PER_RUN + 1; $i++ ) {
+ $deletable[] = $this->create_token( '25.8', $this->days( 91 ) );
+ }
+
+ $first = $this->sut->run( $this->now() );
+ $second = $this->sut->run( $this->now() );
+ $third = $this->sut->run( $this->now() );
+
+ $this->assertSame( CleanupOutcome::CAPPED, $first['outcome'], 'The first run should stop at the limit.' );
+ $this->assertSame( StaleTokenCleanupService::MAX_DELETIONS_PER_RUN, $first['deleted_daily_check_in'], 'The first run should delete up to the limit.' );
+ $this->assertSame( 1, $second['deleted_daily_check_in'], 'The second run should delete the one left.' );
+ $this->assertSame( 0, $third['deleted_daily_check_in'], 'A repeat run should find nothing left to delete.' );
+ $this->assertSame( array(), $this->existing( $deletable ), 'Every eligible token should be gone.' );
+ $this->assertSame( 3, $this->data_store->count_tokens(), 'Only the old-app tokens inside the fallback window should remain.' );
+ }
+
+ /**
+ * @testdox Should report a token that cannot be deleted and carry on with the rest.
+ */
+ public function test_reports_failures_and_carries_on(): void {
+ $refused = $this->create_token( '25.8', $this->days( 95 ) );
+ $deletable = $this->create_token( '25.8', $this->days( 91 ) );
+ $this->refuse_deletion_of( array( $refused ) );
+
+ $summary = $this->sut->run( $this->now() );
+
+ $this->assertSame( CleanupOutcome::COMPLETED, $summary['outcome'], 'A failed token should not stop the run.' );
+ $this->assertSame( 1, $summary['failed'], 'The failure should be counted.' );
+ $this->assertSame( array( $refused ), $summary['failed_token_ids'], 'The failed token should be named.' );
+ $this->assertNull( get_post( $deletable ), 'The other token should still be deleted.' );
+ }
+
+ /**
+ * @testdox Should not let tokens that never delete use up the deletion limit.
+ */
+ public function test_failures_do_not_count_towards_the_deletion_limit(): void {
+ $refused = array();
+ for ( $i = 0; $i < StaleTokenCleanupService::MAX_DELETIONS_PER_RUN + 1; $i++ ) {
+ $refused[] = $this->create_token( '25.8', $this->days( 120 ) );
+ }
+ $deletable = $this->create_token( '25.8', $this->days( 91 ) );
+ $this->refuse_deletion_of( $refused );
+
+ $summary = $this->sut->run( $this->now() );
+
+ $this->assertNull( get_post( $deletable ), 'A newer deletable token should be reached past the failing ones.' );
+ $this->assertSame( StaleTokenCleanupService::MAX_DELETIONS_PER_RUN + 1, $summary['failed'], 'Every failure should be counted.' );
+ }
+
+ /**
+ * @testdox Should keep a $app_version token that re-registered after it was selected for deletion.
+ * @testWith ["25.8", 91]
+ * ["25.7", 181]
+ *
+ * @param string $app_version The app version the token was registered with.
+ * @param int $age_days Days since the token last registered when the run starts.
+ */
+ public function test_keeps_a_token_that_re_registers_during_the_run( string $app_version, int $age_days ): void {
+ global $wpdb;
+
+ $token = $this->create_token( $app_version, $this->days( $age_days ) );
+
+ // Simulates the app checking in between the selection and the deletion.
+ // 100 days is still past the 90-day cutoff, so only the 180-day cutoff
+ // spares an old-app token.
+ $refreshed_at = gmdate( 'Y-m-d H:i:s', $this->now() - ( '25.8' === $app_version ? 0 : $this->days( 100 ) ) );
+ add_filter(
+ 'get_post_metadata',
+ function ( $value, $object_id, $meta_key ) use ( $wpdb, $token, $refreshed_at ) {
+ if ( $object_id === $token && 'origin' === $meta_key ) {
+ $wpdb->update( $wpdb->posts, array( 'post_modified_gmt' => $refreshed_at ), array( 'ID' => $token ) );
+ }
+ return $value;
+ },
+ 10,
+ 3
+ );
+
+ $this->sut->run( $this->now() );
+
+ $this->assertNotNull( get_post( $token ), 'A token that checked in mid-run should be kept.' );
+ }
+
+ /**
+ * @testdox Should delete nothing while push notifications are off, and record that they are off.
+ */
+ public function test_skips_and_records_a_pause_when_push_notifications_are_disabled(): void {
+ $this->push_enabled = false;
+ $token = $this->create_token( '25.8', $this->days( 91 ) );
+
+ $summary = $this->sut->run( $this->now() );
+
+ $this->assertSame( CleanupOutcome::SKIPPED_DISABLED, $summary['outcome'], 'The run should be skipped.' );
+ $this->assertNotNull( get_post( $token ), 'Nothing should be deleted while push notifications are off.' );
+ $this->assertSame( $this->now(), (int) get_option( StaleTokenCleanupService::PAUSED_AT_OPTION ), 'The pause should be recorded.' );
+ }
+
+ /**
+ * @testdox Should not count the time push notifications were off, so a store back from 100 days off keeps its tokens until they miss 90 more days.
+ */
+ public function test_waits_a_full_window_after_push_notifications_come_back_on(): void {
+ $off_at = $this->now();
+ $check_in = $this->create_token( '25.8', 0 );
+ $older_app = $this->create_token( '25.7', 0 );
+
+ $this->push_enabled = false;
+ $this->sut->run( $off_at + $this->days( 1 ) );
+
+ $this->push_enabled = true;
+ $back_at = $off_at + $this->days( 100 );
+ $resumed = $this->sut->run( $back_at );
+
+ $this->assertSame( CleanupOutcome::SKIPPED_RECENTLY_RESUMED, $resumed['outcome'], 'The first run after coming back should delete nothing.' );
+ $this->assertNotNull( get_post( $check_in ), 'A token silent only because push was off should be kept.' );
+ $this->assertSame( $back_at, (int) get_option( StaleTokenCleanupService::RESUMED_AT_OPTION ), 'The resume should be recorded.' );
+ $this->assertFalse( get_option( StaleTokenCleanupService::PAUSED_AT_OPTION ), 'The pause should be cleared.' );
+
+ $this->sut->run( $back_at + $this->days( 89 ) );
+ $this->assertNotNull( get_post( $check_in ), 'The 90-day rule should still wait 89 days after coming back.' );
+
+ $after_90 = $this->sut->run( $back_at + $this->days( 91 ) );
+ $this->assertNull( get_post( $check_in ), 'The 90-day rule should apply once 90 days have passed since coming back.' );
+ $this->assertNotNull( get_post( $older_app ), 'The 180-day rule should still be waiting.' );
+ $this->assertNull( $after_90['fallback_cutoff'], 'The summary should show the 180-day rule as paused.' );
+
+ $this->sut->run( $back_at + $this->days( 181 ) );
+ $this->assertNull( get_post( $older_app ), 'The 180-day rule should apply once 180 days have passed since coming back.' );
+ $this->assertFalse( get_option( StaleTokenCleanupService::RESUMED_AT_OPTION ), 'The resume time should be dropped once it no longer matters.' );
+ }
+
+ /**
+ * @testdox Should delete nothing when the filter returns $returned.
+ * @testWith ["false"]
+ * ["'false'"]
+ * ["array"]
+ * ["throw"]
+ *
+ * @param string $returned What the filter callback does.
+ */
+ public function test_skips_when_the_filter_switches_cleanup_off( string $returned ): void {
+ add_filter(
+ 'woocommerce_push_notification_stale_token_cleanup_enabled',
+ function () use ( $returned ) {
+ switch ( $returned ) {
+ case 'throw':
+ throw new RuntimeException( 'A broken callback.' );
+ case 'array':
+ return array( true );
+ case "'false'":
+ return 'false';
+ default:
+ return false;
+ }
+ }
+ );
+ $token = $this->create_token( '25.8', $this->days( 91 ) );
+
+ $summary = $this->sut->run( $this->now() );
+
+ $this->assertSame( CleanupOutcome::SKIPPED_FILTER, $summary['outcome'], 'The run should be skipped.' );
+ $this->assertNotNull( get_post( $token ), 'Nothing should be deleted.' );
+ $this->assertLogged( 'info', 'Stale token cleanup: skipped filter', array( 'source' => PushNotifications::FEATURE_NAME ) );
+ }
+
+ /**
+ * @testdox Should log the run's counts even when step logging is switched off.
+ */
+ public function test_logs_a_summary_when_step_logging_is_off(): void {
+ add_filter( 'woocommerce_push_notification_step_logging_enabled', '__return_false' );
+ $token = $this->create_token( '25.8', $this->days( 91 ) );
+ $this->create_token( '25.7', $this->days( 91 ) );
+
+ $this->sut->run( $this->now() );
+
+ $this->assertLogged(
+ 'info',
+ 'Stale token cleanup: completed',
+ array(
+ 'source' => PushNotifications::FEATURE_NAME,
+ 'cutoff' => gmdate( 'Y-m-d H:i:s', $this->now() - $this->days( 90 ) ),
+ 'tokens_before' => 2,
+ 'stale' => 2,
+ 'kept_older_apps' => 1,
+ 'deleted_daily_check_in' => 1,
+ 'deleted_token_ids' => array( $token ),
+ )
+ );
+ }
+
+ /**
+ * @testdox Should log a failed outcome, with the error, when selecting the stale tokens throws.
+ */
+ public function test_logs_a_failed_outcome_when_selection_throws(): void {
+ $data_store = $this->createMock( PushTokensDataStore::class );
+ $data_store->method( 'get_stale_token_ids' )->willThrowException( new RuntimeException( 'Database gone.' ) );
+ $push_notifications = $this->createMock( PushNotifications::class );
+ $push_notifications->method( 'should_be_enabled' )->willReturn( true );
+ $this->sut->init( $data_store, new NotificationStepLogger(), $push_notifications );
+
+ $this->sut->handle_stale_token_cleanup();
+
+ $this->assertLogged(
+ 'info',
+ 'Stale token cleanup: failed',
+ array( 'error' => 'Database gone.' )
+ );
+ }
+
+ /**
+ * @testdox Should keep one daily cleanup scheduled a day out while push notifications are on.
+ */
+ public function test_ensure_recurring_actions_schedules_one_daily_cleanup(): void {
+ $this->sut->handle_ensure_recurring_actions();
+ $this->sut->handle_ensure_recurring_actions();
+
+ $actions = as_get_scheduled_actions(
+ array(
+ 'hook' => StaleTokenCleanupService::CLEANUP_HOOK,
+ 'group' => NotificationProcessor::ACTION_SCHEDULER_GROUP,
+ 'status' => ActionScheduler_Store::STATUS_PENDING,
+ )
+ );
+
+ $this->assertCount( 1, $actions, 'Scheduling twice should leave one pending action.' );
+
+ /**
+ * The pending cleanup.
+ *
+ * @var ActionScheduler_Action $action
+ */
+ $action = reset( $actions );
+ $schedule = $action->get_schedule();
+ $next = $schedule->get_date()->getTimestamp();
+
+ $this->assertTrue( $schedule->is_recurring(), 'The cleanup should recur.' );
+ $this->assertSame( DAY_IN_SECONDS, (int) $schedule->get_recurrence(), 'The cleanup should run daily.' );
+ $this->assertGreaterThan( time() + DAY_IN_SECONDS - MINUTE_IN_SECONDS, $next, 'The first run should wait a day.' );
+ $this->assertLessThanOrEqual( time() + DAY_IN_SECONDS, $next, 'The first run should not wait longer than a day.' );
+ }
+
+ /**
+ * @testdox Should remove the cleanup and record a pause while push notifications are off.
+ */
+ public function test_ensure_recurring_actions_unschedules_while_disabled(): void {
+ $this->sut->handle_ensure_recurring_actions();
+ $this->push_enabled = false;
+
+ $this->sut->handle_ensure_recurring_actions();
+
+ $this->assertFalse( as_next_scheduled_action( StaleTokenCleanupService::CLEANUP_HOOK ), 'The cleanup should be unscheduled.' );
+ $this->assertNotFalse( get_option( StaleTokenCleanupService::PAUSED_AT_OPTION ), 'The pause should be recorded.' );
+ }
+
+ /**
+ * @testdox Should remove the cleanup and record a pause when WooCommerce is deactivated.
+ */
+ public function test_plugin_deactivation_unschedules_and_records_a_pause(): void {
+ $this->sut->handle_ensure_recurring_actions();
+
+ $this->sut->handle_plugin_deactivation();
+
+ $this->assertFalse( as_next_scheduled_action( StaleTokenCleanupService::CLEANUP_HOOK ), 'The cleanup should be unscheduled.' );
+ $this->assertNotFalse( get_option( StaleTokenCleanupService::PAUSED_AT_OPTION ), 'The pause should be recorded.' );
+ }
+
+ /**
+ * @testdox Should register the cleanup, scheduling and deactivation callbacks.
+ */
+ public function test_register_adds_the_callbacks(): void {
+ $this->sut->register();
+
+ $this->assertNotFalse( has_action( StaleTokenCleanupService::CLEANUP_HOOK, array( $this->sut, 'handle_stale_token_cleanup' ) ), 'The cleanup callback should be registered.' );
+ $this->assertNotFalse( has_action( 'action_scheduler_ensure_recurring_actions', array( $this->sut, 'handle_ensure_recurring_actions' ) ), 'The scheduling callback should be registered.' );
+ $this->assertNotFalse( has_action( 'deactivate_' . WC_PLUGIN_BASENAME, array( $this->sut, 'handle_plugin_deactivation' ) ), 'The deactivation callback should be registered.' );
+ }
+
+ /**
+ * Creates a token last registered a number of seconds before {@see self::NOW}.
+ *
+ * @param string|null $app_version The app version to register with, or null for none.
+ * @param int $age How many seconds ago the token last registered.
+ * @param string $origin The app the token is registered from.
+ * @return int The token's post ID.
+ */
+ private function create_token( ?string $app_version, int $age, string $origin = PushToken::ORIGIN_WOOCOMMERCE_IOS ): int {
+ $is_android = in_array( $origin, array( PushToken::ORIGIN_WOOCOMMERCE_ANDROID, PushToken::ORIGIN_WOOCOMMERCE_ANDROID_DEV ), true );
+
+ $token = $this->data_store->create(
+ array(
+ 'user_id' => 1,
+ 'token' => 'test_token_' . wp_rand(),
+ 'platform' => $is_android ? PushToken::PLATFORM_ANDROID : PushToken::PLATFORM_APPLE,
+ 'device_uuid' => 'test-device-uuid-' . wp_rand(),
+ 'origin' => $origin,
+ 'device_locale' => 'en_US',
+ 'metadata' => null === $app_version ? array() : array( 'app_version' => $app_version ),
+ )
+ );
+
+ $modified = gmdate( 'Y-m-d H:i:s', $this->now() - $age );
+ $this->set_token_dates( (int) $token->get_id(), $modified, $modified );
+
+ return (int) $token->get_id();
+ }
+
+ /**
+ * Makes WordPress refuse to delete the given posts.
+ *
+ * @param int[] $ids The post IDs.
+ * @return void
+ */
+ private function refuse_deletion_of( array $ids ): void {
+ add_filter(
+ 'pre_delete_post',
+ fn( $check, $post ) => in_array( $post->ID, $ids, true ) ? false : $check,
+ 10,
+ 2
+ );
+ }
+
+ /**
+ * Returns the given post IDs that still exist, in the order given.
+ *
+ * @param int[] $ids The post IDs.
+ * @return int[]
+ */
+ private function existing( array $ids ): array {
+ return array_values( array_filter( $ids, fn( int $id ) => null !== get_post( $id ) ) );
+ }
+
+ /**
+ * Converts days to seconds.
+ *
+ * @param int $days The number of days.
+ * @return int
+ */
+ private function days( int $days ): int {
+ return $days * DAY_IN_SECONDS;
+ }
+
+ /**
+ * Returns {@see self::NOW} as a Unix timestamp.
+ *
+ * @return int
+ */
+ private function now(): int {
+ return (int) strtotime( self::NOW . ' UTC' );
+ }
+}