diff --git a/src/class-analytics-dispatcher.php b/src/class-analytics-dispatcher.php index cf95f70..92aa0c2 100644 --- a/src/class-analytics-dispatcher.php +++ b/src/class-analytics-dispatcher.php @@ -129,7 +129,10 @@ public function register(): void { self::log_debug( 'Analytics schema install error: ' . $e->getMessage() ); } - $this->ensure_scheduled(); + // Deferred to init: Action Scheduler's data store is only usable + // from init priority 1 onward, and register() runs at plugins_loaded + // — as_*() calls made that early silently schedule nothing. + add_action( 'init', array( $this, 'ensure_scheduled' ) ); } } @@ -159,9 +162,13 @@ public static function clear_scheduled(): void { * Ensure the hourly flush is scheduled exactly once, preferring Action * Scheduler and adapting when it appears or disappears. * + * Runs on init (hooked by {@see register()}) because Action Scheduler's + * data store initializes on init priority 1; called earlier, its API + * functions return without scheduling. + * * @return void */ - protected function ensure_scheduled(): void { + public function ensure_scheduled(): void { try { if ( $this->action_scheduler_available() ) { // Migrate a stale WP-Cron recurrence so both backends never fire. diff --git a/tests/AnalyticsDispatcherTest.php b/tests/AnalyticsDispatcherTest.php index 3723773..2484bba 100644 --- a/tests/AnalyticsDispatcherTest.php +++ b/tests/AnalyticsDispatcherTest.php @@ -73,6 +73,19 @@ private function make_dispatcher( ?Analytics_Queue_Table $table = null ): Analyt return new Analytics_Dispatcher( new Settings(), new WP_Http_Client(), $table ?? $this->make_fake_table() ); } + /** + * Fire every callback register() deferred to init, as WordPress would. + */ + private function fire_init_callbacks(): void { + global $wp_test_actions; + + foreach ( $wp_test_actions as $action ) { + if ( 'init' === $action['hook'] ) { + call_user_func( $action['callback'] ); + } + } + } + /** * Build a dispatcher that reports Action Scheduler as unavailable. */ @@ -330,12 +343,38 @@ public function test_flush_tolerates_partial_rejection_response(): void { $this->assertSame( array(), $table->rows ); } + public function test_register_defers_schedule_check_to_init(): void { + global $wp_test_doing_cron, $wp_test_actions, $wp_test_as_recurring_calls, $wp_test_recurring_events; + + $wp_test_doing_cron = true; + + $this->make_dispatcher()->register(); + + // register() runs at plugins_loaded, before Action Scheduler's data + // store initializes (init priority 1) — as_*() calls made that early + // silently no-op, so no scheduling API may be touched yet. + $this->assertSame( array(), $wp_test_as_recurring_calls, 'No AS call at registration time.' ); + $this->assertSame( array(), $wp_test_recurring_events, 'No WP-Cron schedule at registration time.' ); + + $init_hooks = array_values( + array_filter( $wp_test_actions, static fn ( array $a ): bool => 'init' === $a['hook'] ) + ); + $this->assertCount( 1, $init_hooks, 'The schedule check must be deferred to init.' ); + + // Firing the deferred callback performs the actual scheduling. + call_user_func( $init_hooks[0]['callback'] ); + + $this->assertCount( 1, $wp_test_as_recurring_calls ); + $this->assertSame( self::FLUSH_HOOK, $wp_test_as_recurring_calls[0]['hook'] ); + } + public function test_register_schedules_recurring_via_action_scheduler_in_cron_context(): void { global $wp_test_doing_cron, $wp_test_as_recurring_calls, $wp_test_recurring_events; $wp_test_doing_cron = true; $this->make_dispatcher()->register(); + $this->fire_init_callbacks(); $this->assertCount( 1, $wp_test_as_recurring_calls ); $call = $wp_test_as_recurring_calls[0]; @@ -352,6 +391,7 @@ public function test_register_skips_scheduling_when_as_action_exists(): void { $wp_test_as_has_scheduled = true; $this->make_dispatcher()->register(); + $this->fire_init_callbacks(); $this->assertSame( array(), $wp_test_as_recurring_calls ); } @@ -362,6 +402,7 @@ public function test_register_falls_back_to_wp_cron_recurring(): void { $wp_test_doing_cron = true; $this->make_dispatcher_without_action_scheduler( $this->make_fake_table() )->register(); + $this->fire_init_callbacks(); $this->assertCount( 1, $wp_test_recurring_events ); $this->assertSame( self::FLUSH_HOOK, $wp_test_recurring_events[0]['hook'] ); @@ -375,6 +416,7 @@ public function test_register_skips_wp_cron_when_already_scheduled(): void { $wp_test_next_scheduled = time() + 100; $this->make_dispatcher_without_action_scheduler( $this->make_fake_table() )->register(); + $this->fire_init_callbacks(); $this->assertSame( array(), $wp_test_recurring_events ); } @@ -386,6 +428,7 @@ public function test_register_migrates_wp_cron_schedule_to_action_scheduler(): v $wp_test_next_scheduled = time() + 100; $this->make_dispatcher()->register(); + $this->fire_init_callbacks(); // The stale WP-Cron recurrence is cleared so both backends never fire. $this->assertCount( 1, $wp_test_cleared_hooks ); @@ -416,6 +459,7 @@ public function install(): void { }; $this->make_dispatcher( $table )->register(); + $this->fire_init_callbacks(); $this->assertCount( 1, $wp_test_as_recurring_calls, 'Scheduling still proceeds after install failure.' ); } @@ -430,5 +474,9 @@ public function test_register_does_no_schedule_work_on_front_end(): void { $this->assertSame( 0, $table->install_calls ); $this->assertSame( array(), $wp_test_as_recurring_calls ); $this->assertSame( array(), $wp_test_recurring_events ); + + global $wp_test_actions; + $init_hooks = array_filter( $wp_test_actions, static fn ( array $a ): bool => 'init' === $a['hook'] ); + $this->assertSame( array(), $init_hooks, 'No deferred schedule check on the front end.' ); } }