Commit 94256708f85 for woocommerce

commit 94256708f851ea4280a5d552daf5a39822eec5e6
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date:   Tue Sep 15 15:58:29 2026 +0200

    Guard bare as_has_scheduled_action() calls behind a version-tolerant helper (#68523)

    Guard bare as_has_scheduled_action() calls behind a helper

    - Add ActionSchedulerUtil::has_scheduled_action(), which falls back to as_next_scheduled_action() when an older Action Scheduler wins the load race
    - Route the seven unguarded call sites and the four inline-ternary sites through it
    - Bail out of the batch processing watchdog via can_check_scheduled_actions() instead of dropping processors when Action Scheduler is unavailable
    - Raise a doing-it-wrong notice when neither function is loaded

diff --git a/plugins/woocommerce/changelog/fix-52745-guard-as-has-scheduled-action b/plugins/woocommerce/changelog/fix-52745-guard-as-has-scheduled-action
new file mode 100644
index 00000000000..9fa416fb8c3
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-52745-guard-as-has-scheduled-action
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Guard every `as_has_scheduled_action()` call behind a helper that falls back to `as_next_scheduled_action()`, preventing a fatal error when another plugin loads a pre-3.3.0 copy of Action Scheduler.
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-reports.php b/plugins/woocommerce/includes/admin/class-wc-admin-reports.php
index b926a3bdd96..b41721799c5 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-reports.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-reports.php
@@ -10,6 +10,8 @@
  * @version     2.0.0
  */

+use Automattic\WooCommerce\Internal\Utilities\ActionSchedulerUtil;
+
 if ( ! defined( 'ABSPATH' ) ) {
 	exit;
 }
@@ -58,7 +60,7 @@ class WC_Admin_Reports {
 			static $skip_consequent;

 			// Schedule the deletion, cap the execution to single pending event at any given time.
-			$schedule = ! $skip_consequent && ! as_has_scheduled_action( 'woocommerce_delete_legacy_report_transients', null, 'woocommerce' );
+			$schedule = ! $skip_consequent && ! ActionSchedulerUtil::has_scheduled_action( 'woocommerce_delete_legacy_report_transients', null, 'woocommerce' );
 			if ( $schedule ) {
 				as_schedule_single_action( time() + MINUTE_IN_SECONDS, 'woocommerce_delete_legacy_report_transients', array( $order_id, false ), 'woocommerce' );
 			}
diff --git a/plugins/woocommerce/src/Blocks/BlockPatterns.php b/plugins/woocommerce/src/Blocks/BlockPatterns.php
index a68b561a5f5..93aae0eb089 100644
--- a/plugins/woocommerce/src/Blocks/BlockPatterns.php
+++ b/plugins/woocommerce/src/Blocks/BlockPatterns.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Blocks;
 use Automattic\WooCommerce\Blocks\Domain\Package;
 use Automattic\WooCommerce\Blocks\Patterns\PatternRegistry;
 use Automattic\WooCommerce\Blocks\Patterns\PTKPatternsStore;
+use Automattic\WooCommerce\Internal\Utilities\ActionSchedulerUtil;

 /**
  * Registers patterns under the `./patterns/` directory and from the PTK API and updates their content.
@@ -206,10 +207,6 @@ class BlockPatterns {
 			return;
 		}

-		// The most efficient way to check for an existing action is to use `as_has_scheduled_action`, but in unusual
-		// cases where another plugin has loaded a very old version of Action Scheduler, it may not be available to us.
-		$has_scheduled_action = function_exists( 'as_has_scheduled_action' ) ? 'as_has_scheduled_action' : 'as_next_scheduled_action';
-
 		$patterns = $this->ptk_patterns_store->get_patterns();
 		if ( empty( $patterns ) || ! is_array( $patterns ) ) {
 			// Only log once per day by using a transient.
@@ -217,7 +214,7 @@ class BlockPatterns {
 			// By only logging when patterns are empty and no fetch is scheduled,
 			// we ensure that warnings are only generated in genuinely problematic situations,
 			// such as when the pattern fetching mechanism has failed entirely.
-			if ( ! get_transient( $transient_key ) && ! call_user_func( $has_scheduled_action, 'fetch_patterns' ) ) {
+			if ( ! get_transient( $transient_key ) && ! ActionSchedulerUtil::has_scheduled_action( 'fetch_patterns' ) ) {
 				wc_get_logger()->warning(
 					__( 'Empty patterns received from the PTK Pattern Store', 'woocommerce' ),
 				);
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/DraftOrders.php b/plugins/woocommerce/src/Blocks/Domain/Services/DraftOrders.php
index 11431f1b822..49d609e122c 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/DraftOrders.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/DraftOrders.php
@@ -2,6 +2,7 @@
 namespace Automattic\WooCommerce\Blocks\Domain\Services;

 use Automattic\WooCommerce\Blocks\Domain\Package;
+use Automattic\WooCommerce\Internal\Utilities\ActionSchedulerUtil;
 use Exception;
 use WC_Order;

@@ -77,8 +78,7 @@ class DraftOrders {
 	 * Maybe create cron events.
 	 */
 	protected function maybe_create_cronjobs() {
-		$has_scheduled_action = function_exists( 'as_has_scheduled_action' ) ? 'as_has_scheduled_action' : 'as_next_scheduled_action';
-		if ( false === call_user_func( $has_scheduled_action, self::DRAFT_CLEANUP_EVENT_HOOK ) ) {
+		if ( ! ActionSchedulerUtil::has_scheduled_action( self::DRAFT_CLEANUP_EVENT_HOOK ) ) {
 			$midnight_tonight = strtotime( 'midnight tonight' );
 			if ( false !== $midnight_tonight ) {
 				as_schedule_recurring_action( $midnight_tonight, DAY_IN_SECONDS, self::DRAFT_CLEANUP_EVENT_HOOK );
diff --git a/plugins/woocommerce/src/Blocks/Patterns/PTKPatternsStore.php b/plugins/woocommerce/src/Blocks/Patterns/PTKPatternsStore.php
index c76283cee9c..9cec87a9f66 100644
--- a/plugins/woocommerce/src/Blocks/Patterns/PTKPatternsStore.php
+++ b/plugins/woocommerce/src/Blocks/Patterns/PTKPatternsStore.php
@@ -2,6 +2,7 @@

 namespace Automattic\WooCommerce\Blocks\Patterns;

+use Automattic\WooCommerce\Internal\Utilities\ActionSchedulerUtil;
 use WP_Upgrader;

 /**
@@ -109,7 +110,7 @@ class PTKPatternsStore {
 	 * @return void
 	 */
 	private function schedule_action_if_not_pending( $action ) {
-		if ( as_has_scheduled_action( $action, array(), 'woocommerce' ) ) {
+		if ( ActionSchedulerUtil::has_scheduled_action( $action, array(), 'woocommerce' ) ) {
 			return;
 		}

diff --git a/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php b/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
index 634ebaf6603..858c625fe31 100644
--- a/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
+++ b/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
@@ -16,6 +16,7 @@ use Automattic\WooCommerce\Admin\API\Reports\Products\DataStore as ProductsDataS
 use Automattic\WooCommerce\Admin\API\Reports\Taxes\DataStore as TaxesDataStore;
 use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
+use Automattic\WooCommerce\Internal\Utilities\ActionSchedulerUtil;
 use Automattic\WooCommerce\Utilities\OrderUtil;

 /**
@@ -529,10 +530,7 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
 		if ( null === $action_hook ) {
 			return;
 		}
-		// The most efficient way to check for an existing action is to use `as_has_scheduled_action`, but in unusual
-		// cases where another plugin has loaded a very old version of Action Scheduler, it may not be available to us.
-		$has_scheduled_action = function_exists( 'as_has_scheduled_action' ) ? 'as_has_scheduled_action' : 'as_next_scheduled_action';
-		if ( call_user_func( $has_scheduled_action, $action_hook ) ) {
+		if ( ActionSchedulerUtil::has_scheduled_action( $action_hook ) ) {
 			return;
 		}

diff --git a/plugins/woocommerce/src/Internal/BatchProcessing/BatchProcessingController.php b/plugins/woocommerce/src/Internal/BatchProcessing/BatchProcessingController.php
index c09e608f52f..0f60d557fcc 100644
--- a/plugins/woocommerce/src/Internal/BatchProcessing/BatchProcessingController.php
+++ b/plugins/woocommerce/src/Internal/BatchProcessing/BatchProcessingController.php
@@ -21,6 +21,8 @@

 namespace Automattic\WooCommerce\Internal\BatchProcessing;

+use Automattic\WooCommerce\Internal\Utilities\ActionSchedulerUtil;
+
 /**
  * Class BatchProcessingController
  *
@@ -380,7 +382,7 @@ class BatchProcessingController {
 			$time += apply_filters( 'woocommerce_batch_processor_watchdog_delay_seconds', HOUR_IN_SECONDS );
 		}

-		if ( ! as_has_scheduled_action( self::WATCHDOG_ACTION_NAME ) ) {
+		if ( ! ActionSchedulerUtil::has_scheduled_action( self::WATCHDOG_ACTION_NAME ) ) {
 			as_schedule_single_action(
 				$time,
 				self::WATCHDOG_ACTION_NAME,
@@ -559,14 +561,14 @@ class BatchProcessingController {

 	/**
 	 * Check if a batch processing action is already scheduled for a given processor.
-	 * Differs from `as_has_scheduled_action` in that this excludes actions in progress.
+	 * Pending and in-progress actions both count as scheduled.
 	 *
 	 * @param string $processor_class_name Fully qualified class name of the batch processor.
 	 *
 	 * @return bool True if a batch processing action is already scheduled for the processor.
 	 */
 	public function is_scheduled( string $processor_class_name ): bool {
-		return as_has_scheduled_action( self::PROCESS_SINGLE_BATCH_ACTION_NAME, array( $processor_class_name ) );
+		return ActionSchedulerUtil::has_scheduled_action( self::PROCESS_SINGLE_BATCH_ACTION_NAME, array( $processor_class_name ) );
 	}

 	/**
@@ -846,11 +848,14 @@ class BatchProcessingController {
 			return;
 		}

-		// The most efficient way to check for an existing action is to use `as_has_scheduled_action`, but in unusual
-		// cases where another plugin has loaded a very old version of Action Scheduler, it may not be available to us.
-		$has_scheduled_action = function_exists( 'as_has_scheduled_action') ? 'as_has_scheduled_action' : 'as_next_scheduled_action';
+		// Everything below reads "not scheduled" as grounds for recording a failure against a processor
+		// and eventually dropping it from the queue. Action Scheduler being unloaded also reads as
+		// "not scheduled", so bail rather than dismantle the queue over a missing dependency.
+		if ( ! ActionSchedulerUtil::can_check_scheduled_actions() ) {
+			return;
+		}

-		if ( call_user_func( $has_scheduled_action, self::WATCHDOG_ACTION_NAME ) ) {
+		if ( ActionSchedulerUtil::has_scheduled_action( self::WATCHDOG_ACTION_NAME ) ) {
 			return;
 		}

diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php
index 7a26f712ce2..af8ac86378e 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails;

 use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger_Interface;
 use Automattic\WooCommerce\Internal\EmailEditor\Logger;
+use Automattic\WooCommerce\Internal\Utilities\ActionSchedulerUtil;

 /**
  * Auto-applies the current core block template to `woo_email` posts that have
@@ -227,14 +228,14 @@ class WCEmailTemplateAutoApplier {
 	 * Enqueue the batched auto-apply runner as an Action Scheduler async action.
 	 *
 	 * Hooked to {@see 'woocommerce_email_template_divergence_sweep_complete'}. The
-	 * `as_has_scheduled_action()` short-circuit guards against double-enqueueing
+	 * already-scheduled short-circuit guards against double-enqueueing
 	 * when the detector sweep runs twice in one request — once on
 	 * `woocommerce_updated`, once on `BACKFILL_COMPLETE_ACTION`.
 	 *
 	 * @since 10.8.0
 	 */
 	public static function schedule(): void {
-		if ( as_has_scheduled_action( self::AUTO_APPLY_AS_HOOK, array(), self::AUTO_APPLY_AS_GROUP ) ) {
+		if ( ActionSchedulerUtil::has_scheduled_action( self::AUTO_APPLY_AS_HOOK, array(), self::AUTO_APPLY_AS_GROUP ) ) {
 			return;
 		}

diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
index f73ba5d372c..06879b909ba 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
@@ -9,6 +9,7 @@ defined( 'ABSPATH' ) || exit;
 use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\InternalNotificationDispatcher;
 use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
+use Automattic\WooCommerce\Internal\Utilities\ActionSchedulerUtil;

 /**
  * Store that collects notifications during a request and dispatches them all on
@@ -125,7 +126,7 @@ class PendingNotificationStore {
 		// them from the same place; see Notification::get_safety_net_args().
 		$args = $notification->get_safety_net_args();

-		if ( as_has_scheduled_action( NotificationProcessor::SAFETY_NET_HOOK, $args, NotificationProcessor::ACTION_SCHEDULER_GROUP ) ) {
+		if ( ActionSchedulerUtil::has_scheduled_action( NotificationProcessor::SAFETY_NET_HOOK, $args, NotificationProcessor::ACTION_SCHEDULER_GROUP ) ) {
 			return;
 		}

diff --git a/plugins/woocommerce/src/Internal/TransientFiles/TransientFilesEngine.php b/plugins/woocommerce/src/Internal/TransientFiles/TransientFilesEngine.php
index 5e48df2af7d..2ade0298284 100644
--- a/plugins/woocommerce/src/Internal/TransientFiles/TransientFilesEngine.php
+++ b/plugins/woocommerce/src/Internal/TransientFiles/TransientFilesEngine.php
@@ -2,6 +2,7 @@

 namespace Automattic\WooCommerce\Internal\TransientFiles;

+use Automattic\WooCommerce\Internal\Utilities\ActionSchedulerUtil;
 use \DateTime;
 use \Exception;
 use \InvalidArgumentException;
@@ -370,7 +371,7 @@ class TransientFilesEngine implements RegisterHooksInterface {
 	 * @return bool True if the expired files cleanup action is currently scheduled, false otherwise.
 	 */
 	public function expired_files_cleanup_is_scheduled(): bool {
-		return as_has_scheduled_action( self::CLEANUP_ACTION_NAME, array(), self::CLEANUP_ACTION_GROUP );
+		return ActionSchedulerUtil::has_scheduled_action( self::CLEANUP_ACTION_NAME, array(), self::CLEANUP_ACTION_GROUP );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/Utilities/ActionSchedulerUtil.php b/plugins/woocommerce/src/Internal/Utilities/ActionSchedulerUtil.php
new file mode 100644
index 00000000000..3077ab1d8de
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Utilities/ActionSchedulerUtil.php
@@ -0,0 +1,63 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Utilities;
+
+/**
+ * A class of utilities for dealing with Action Scheduler across the versions of it that may be loaded.
+ */
+class ActionSchedulerUtil {
+
+	/**
+	 * Is a matching action currently scheduled (pending or in-progress)?
+	 *
+	 * Prefers `as_has_scheduled_action`, which only exists since Action Scheduler 3.3.0: another plugin
+	 * can load an older copy early enough to win the version race against the one bundled with
+	 * WooCommerce, and a bare call is then a fatal. Falls back to the much older `as_next_scheduled_action`,
+	 * which on such a copy may report only pending actions, not in-progress ones - an accepted trade.
+	 *
+	 * Reports false when Action Scheduler is not loaded at all, indistinguishable from "nothing is
+	 * scheduled", and raises a doing-it-wrong notice so the condition is visible under debugging.
+	 * A caller that treats a negative answer as licence to discard state should check
+	 * {@see self::can_check_scheduled_actions()} first.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param string     $hook  The hook of the action.
+	 * @param array|null $args  Args that have been passed to the action. Null matches any args.
+	 * @param string     $group The group the action is assigned to.
+	 *
+	 * @return bool True if a matching action is scheduled, false otherwise.
+	 */
+	public static function has_scheduled_action( string $hook, ?array $args = null, string $group = '' ): bool {
+		foreach ( array( 'as_has_scheduled_action', 'as_next_scheduled_action' ) as $function ) {
+			// PHPStan sees the Action Scheduler copy bundled with WooCommerce and concludes both functions
+			// always exist. The runtime case this guard exists for is precisely the one it cannot see.
+			// @phpstan-ignore-next-line function.alreadyNarrowedType -- see comment above.
+			if ( function_exists( $function ) ) {
+				// `as_next_scheduled_action` returns the timestamp of the next pending action, hence the cast.
+				return (bool) $function( $hook, $args, $group );
+			}
+		}
+
+		wc_doing_it_wrong(
+			__METHOD__,
+			'Action Scheduler is not loaded, so scheduled actions cannot be checked. Call this after Action Scheduler has initialized.',
+			'11.2.0'
+		);
+
+		return false;
+	}
+
+	/**
+	 * Can {@see self::has_scheduled_action()} actually query Action Scheduler, or would it report false
+	 * only because neither function it relies on is loaded?
+	 *
+	 * @since 11.2.0
+	 *
+	 * @return bool True if a scheduled-action check can be answered, false otherwise.
+	 */
+	public static function can_check_scheduled_actions(): bool {
+		return function_exists( 'as_has_scheduled_action' ) || function_exists( 'as_next_scheduled_action' );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Utilities/ActionSchedulerUtilTest.php b/plugins/woocommerce/tests/php/src/Internal/Utilities/ActionSchedulerUtilTest.php
new file mode 100644
index 00000000000..329527ebd64
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Utilities/ActionSchedulerUtilTest.php
@@ -0,0 +1,164 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Utilities;
+
+use Automattic\WooCommerce\Internal\Utilities\ActionSchedulerUtil;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the Internal\Utilities\ActionSchedulerUtil class.
+ */
+class ActionSchedulerUtilTest extends WC_Unit_Test_Case {
+
+	private const HOOK  = 'woocommerce_action_scheduler_util_test';
+	private const GROUP = 'woocommerce_action_scheduler_util_test_group';
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		as_unschedule_all_actions( self::HOOK );
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox `has_scheduled_action` should return false when no matching action exists.
+	 */
+	public function test_returns_false_when_nothing_is_scheduled(): void {
+		$this->assertFalse(
+			ActionSchedulerUtil::has_scheduled_action( self::HOOK ),
+			'An unscheduled hook should not be reported as scheduled'
+		);
+	}
+
+	/**
+	 * @testdox `has_scheduled_action` should return true for a pending action.
+	 */
+	public function test_returns_true_for_a_pending_action(): void {
+		as_schedule_single_action( time() + HOUR_IN_SECONDS, self::HOOK, array(), self::GROUP );
+
+		$this->assertTrue(
+			ActionSchedulerUtil::has_scheduled_action( self::HOOK, array(), self::GROUP ),
+			'A pending action should be reported as scheduled'
+		);
+	}
+
+	/**
+	 * @testdox `has_scheduled_action` should return true for an in-progress action.
+	 */
+	public function test_returns_true_for_an_in_progress_action(): void {
+		$action_id = as_schedule_single_action( time() + HOUR_IN_SECONDS, self::HOOK, array(), self::GROUP );
+		\ActionScheduler::store()->log_execution( $action_id );
+
+		$this->assertTrue(
+			ActionSchedulerUtil::has_scheduled_action( self::HOOK, array(), self::GROUP ),
+			'An in-progress action should be reported as scheduled'
+		);
+	}
+
+	/**
+	 * @testdox `has_scheduled_action` should return false when only the group differs.
+	 */
+	public function test_returns_false_when_the_group_does_not_match(): void {
+		as_schedule_single_action( time() + HOUR_IN_SECONDS, self::HOOK, array(), self::GROUP );
+
+		$this->assertFalse(
+			ActionSchedulerUtil::has_scheduled_action( self::HOOK, array(), 'some_other_group' ),
+			'An action in a different group should not match'
+		);
+	}
+
+	/**
+	 * @testdox `has_scheduled_action` should return false when only the args differ.
+	 */
+	public function test_returns_false_when_the_args_do_not_match(): void {
+		as_schedule_single_action( time() + HOUR_IN_SECONDS, self::HOOK, array( 'a' ), self::GROUP );
+
+		$this->assertFalse(
+			ActionSchedulerUtil::has_scheduled_action( self::HOOK, array( 'b' ), self::GROUP ),
+			'An action scheduled with different args should not match'
+		);
+	}
+
+	/**
+	 * @testdox `has_scheduled_action` should treat null args as a wildcard.
+	 */
+	public function test_null_args_match_any_args(): void {
+		as_schedule_single_action( time() + HOUR_IN_SECONDS, self::HOOK, array( 'a' ), self::GROUP );
+
+		$this->assertTrue(
+			ActionSchedulerUtil::has_scheduled_action( self::HOOK, null, self::GROUP ),
+			'Null args should match an action scheduled with any args'
+		);
+	}
+
+	/**
+	 * @testdox `has_scheduled_action` should return false once the action has been unscheduled.
+	 */
+	public function test_returns_false_after_the_action_is_unscheduled(): void {
+		as_schedule_single_action( time() + HOUR_IN_SECONDS, self::HOOK, array(), self::GROUP );
+		as_unschedule_all_actions( self::HOOK, array(), self::GROUP );
+
+		$this->assertFalse(
+			ActionSchedulerUtil::has_scheduled_action( self::HOOK, array(), self::GROUP ),
+			'An unscheduled action should no longer be reported as scheduled'
+		);
+	}
+
+	/**
+	 * @testdox `can_check_scheduled_actions` should be true when Action Scheduler is loaded.
+	 */
+	public function test_can_check_scheduled_actions_when_action_scheduler_is_loaded(): void {
+		$this->assertTrue(
+			ActionSchedulerUtil::can_check_scheduled_actions(),
+			'Action Scheduler is loaded in the test suite, so scheduled-action checks should be possible'
+		);
+	}
+
+	/**
+	 * The helper falls back to `as_next_scheduled_action` when `as_has_scheduled_action` is missing.
+	 * Neither that branch nor the false branch of `can_check_scheduled_actions` can be exercised here:
+	 * Action Scheduler is always fully loaded in the test suite, and the function-mocking seam
+	 * (CodeHacker) only rewrites files under `includes/`, not `src/`. So pin the property the fallback
+	 * relies on instead: with the bundled Action Scheduler the two functions answer identically, so
+	 * routing a call site through the fallback is a no-op for every store not running an ancient copy.
+	 *
+	 * @testdox The `as_next_scheduled_action` fallback agrees with `as_has_scheduled_action`.
+	 * @dataProvider provider_fallback_equivalence
+	 *
+	 * @param array|null $scheduled_args Args to schedule the action with, or null to schedule nothing.
+	 * @param array|null $queried_args   Args to query with.
+	 * @param bool       $expected       Expected result.
+	 */
+	public function test_fallback_is_equivalent_to_the_preferred_function( ?array $scheduled_args, ?array $queried_args, bool $expected ): void {
+		if ( null !== $scheduled_args ) {
+			as_schedule_single_action( time() + HOUR_IN_SECONDS, self::HOOK, $scheduled_args, self::GROUP );
+		}
+
+		$this->assertSame(
+			$expected,
+			(bool) as_has_scheduled_action( self::HOOK, $queried_args, self::GROUP ),
+			'as_has_scheduled_action should report the expected result'
+		);
+		$this->assertSame(
+			$expected,
+			(bool) as_next_scheduled_action( self::HOOK, $queried_args, self::GROUP ),
+			'The as_next_scheduled_action fallback should report the same result'
+		);
+	}
+
+	/**
+	 * Data provider for test_fallback_is_equivalent_to_the_preferred_function.
+	 *
+	 * @return array
+	 */
+	public function provider_fallback_equivalence(): array {
+		return array(
+			'nothing scheduled'     => array( null, array(), false ),
+			'exact args match'      => array( array( 'a' ), array( 'a' ), true ),
+			'args mismatch'         => array( array( 'a' ), array( 'b' ), false ),
+			'null args as wildcard' => array( array( 'a' ), null, true ),
+		);
+	}
+}