diff --git a/CHANGELOG.md b/CHANGELOG.md index ba160255..1616d8b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - REST routes live in their own package, [`pollora/wp-rest`](https://github.com/Pollora/wp-rest), which the framework requires: `WpRestDiscovery`, the route registrar, the permissions (`IsLoggedIn`, `IsAdmin`, `Can`) and `Pollora\Support\WpGlobals`. Class names do not change. The framework keeps `WpRestAttributeServiceProvider` - WP-CLI commands live in their own package, [`pollora/wp-cli`](https://github.com/Pollora/wp-cli), which the framework requires: `WpCliDiscovery`, `WpCliService`, the WP-CLI adapters and `WordpressCommand`. Class names do not change. The framework keeps `WpCliServiceProvider` and `pollora:make:wp-cli` - Scheduling lives in its own package, [`pollora/schedule`](https://github.com/Pollora/schedule), which the framework requires: one engine behind `#[Schedule]` and a fluent API (`Schedule::event()`, `Schedule::once()`, `cancel()`, `next()`, `events()`), WP-Cron as the default driver, and `Schedule::fake()` for tests. `ScheduleDiscovery` keeps its class name and moves there, with the same hook names and schedule keys. The framework gives the engine its container, so the classes passed to `call()` get their dependencies, and keeps the bridge to Laravel's scheduler (`use_laravel_scheduler`) +- `config/schedule.php` (publishable with the `pollora-schedule` tag) chooses the scheduling driver, `wp-cron` (the default) or `action-scheduler`, by `POLLORA_SCHEDULE_DRIVER`, and lists the hooks that stay on WP-Cron whatever the driver. The `cron` option now indexes every driver's events, so `wp_next_scheduled()`, Site Health and WP Crontrol see the ones Action Scheduler keeps, and WP-Cron never runs them. Changing the driver moves nothing by itself +- `pollora:schedule:list` shows the scheduled events with their type, frequency and driver (`--type`, `--driver`, `--site`, `--network`, `--json`), read from the `cron` option with no query to a driver; an event on WP-Cron on purpose reads `(protected)` or `(fallback)`, one a former driver keeps reads `(to migrate)`. `pollora:schedule:migrate` moves the pending events from one driver to another (`--from`, `--to`, `--dry-run`), in both directions and safe to run again +- `pollora:doctor`, Site Health and an administration notice report a driver that does not exist or is not available (events stay on WP-Cron), and the events a former driver still keeps, with the command that moves them ### Changed - `DiscoveryEngine`, `DiscoveryCacheManager`, `InstancePool` and `ReflectionCache` take their dependencies instead of reaching for `app()` and `config()`; reading `structure-discoverer.cache` moved to `LaravelDiscoveryCacheManagerFactory`. `DiscoveryRegistrar::registerFromContainer()` is now `registerInto()`. These are infrastructure classes, outside the public contracts (`Pollora\Discovery\Domain\*`), which do not change diff --git a/composer.json b/composer.json index 04e7c4a0..7058d64c 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ "pollora/entity": "^1.2", "pollora/hook": "^1.5", "pollora/option": "^1.1", - "pollora/schedule": "^0.1 || ^1.0", + "pollora/schedule": "^0.1.1 || ^1.0", "pollora/wp-cli": "^0.1 || ^1.0", "pollora/wp-rest": "^0.1 || ^1.0", "watson/rememberable": "^7.1", diff --git a/config/schedule.php b/config/schedule.php new file mode 100644 index 00000000..322b6e65 --- /dev/null +++ b/config/schedule.php @@ -0,0 +1,44 @@ + env('POLLORA_SCHEDULE_DRIVER', 'wp-cron'), + + /* + |-------------------------------------------------------------------------- + | Hooks that stay on WP-Cron + |-------------------------------------------------------------------------- + | + | Action Scheduler's own queue runner always does: handed to Action + | Scheduler, it would never run. Add hooks that must run by WP-Cron + | whatever the driver. The pollora/schedule/protected_hooks filter extends + | the list too. + | + */ + + 'protected_hooks' => [], + +]; diff --git a/src/Schedule/Application/Services/ScheduleStatus.php b/src/Schedule/Application/Services/ScheduleStatus.php new file mode 100644 index 00000000..620fefcb --- /dev/null +++ b/src/Schedule/Application/Services/ScheduleStatus.php @@ -0,0 +1,106 @@ +config->get('schedule.driver', CronIndex::WP_CRON); + + return is_string($driver) && $driver !== '' ? $driver : CronIndex::WP_CRON; + } + + /** + * The driver new events go to now. + */ + public function active(): string + { + return Schedule::engine()->driverName(); + } + + /** + * Whether the configured driver is one the package does not know: the site runs on WP-Cron instead. + */ + public function unknown(): bool + { + return ! Schedule::knows($this->configured()); + } + + /** + * Whether the driver is not there (Action Scheduler without a plugin that bundles it): events stay on WP-Cron. + */ + public function unavailable(): bool + { + return $this->active() !== CronIndex::WP_CRON && ! Schedule::engine()->driver()->available(); + } + + /** + * Events still kept by a driver other than the active one, by driver. + * + * An event on WP-Cron because its hook is protected, or because the driver + * is not there, is where it is meant to be. + * + * @return array + */ + public function toMigrate(): array + { + $counts = []; + + foreach (Schedule::engine()->index() as $event) { + if ($this->placement($event) === 'to migrate') { + $counts[(string) $event->driver] = ($counts[(string) $event->driver] ?? 0) + 1; + } + } + + return $counts; + } + + /** + * The driver of an event, with what sets it apart from the site's: "wp-cron (protected)", "wp-cron (to migrate)". + */ + public function label(Event $event): string + { + $placement = $this->placement($event); + + return $event->driver.($placement === 'active' ? '' : sprintf(' (%s)', $placement)); + } + + /** + * Whether an event is where the site means it to be: "active" (the driver of the site), "protected" or "fallback" (on WP-Cron on purpose), or "to migrate". + */ + private function placement(Event $event): string + { + if ($event->driver === $this->active()) { + return 'active'; + } + + if ($event->driver === CronIndex::WP_CRON && in_array($event->hook, Schedule::engine()->protectedHooks(), true)) { + return 'protected'; + } + + if ($event->driver === CronIndex::WP_CRON && $this->unavailable()) { + return 'fallback'; + } + + return 'to migrate'; + } +} diff --git a/src/Schedule/Infrastructure/Checks/ScheduleDriverCheck.php b/src/Schedule/Infrastructure/Checks/ScheduleDriverCheck.php new file mode 100644 index 00000000..125bcf99 --- /dev/null +++ b/src/Schedule/Infrastructure/Checks/ScheduleDriverCheck.php @@ -0,0 +1,67 @@ + $problems */ + $problems = []; + $fix = null; + + if ($this->status->unknown()) { + $problems[] = sprintf('config/schedule.php asks for the driver "%s", which does not exist: every event goes to WP-Cron', $this->status->configured()); + $fix = 'Set POLLORA_SCHEDULE_DRIVER (config/schedule.php driver) to wp-cron or action-scheduler'; + } elseif ($this->status->unavailable()) { + $problems[] = sprintf('The "%s" driver is not available, so events stay on WP-Cron', $this->status->active()); + $fix = 'Activate a plugin that bundles Action Scheduler (WooCommerce does), or set the driver back to wp-cron'; + } + + foreach ($this->status->toMigrate() as $driver => $count) { + $problems[] = sprintf('%d event(s) are still kept by "%s", not by the driver of the site ("%s")', $count, $driver, $this->status->active()); + $fix ??= sprintf('php artisan pollora:schedule:migrate --from=%s --to=%s (--dry-run shows what would move)', $driver, $this->status->active()); + } + + if ($problems === []) { + return CheckResult::ok(sprintf('Scheduled events are kept by "%s".', $this->status->active())); + } + + return CheckResult::warning(sprintf('%d problem(s) with scheduled events.', count($problems)), $problems, (string) $fix); + } +} diff --git a/src/Schedule/Infrastructure/Services/MigrationNotice.php b/src/Schedule/Infrastructure/Services/MigrationNotice.php new file mode 100644 index 00000000..efdc2c50 --- /dev/null +++ b/src/Schedule/Infrastructure/Services/MigrationNotice.php @@ -0,0 +1,55 @@ +render(...)); + add_action('network_admin_notices', $this->render(...)); + } + } + + public function render(): void + { + if (! current_user_can('manage_options')) { + return; + } + + $pending = $this->status->toMigrate(); + + if ($pending === []) { + return; + } + + $driver = array_key_first($pending); + + printf( + '

%s %s %s

', + esc_html__('Scheduled events are still kept by another driver.', 'pollora'), + esc_html(sprintf( + /* translators: 1: number of events, 2: driver name, 3: driver of the site */ + __('%1$d event(s) wait in "%2$s", while the site uses "%3$s". Move them with', 'pollora'), + array_sum($pending), + $driver, + $this->status->active(), + )), + esc_html(sprintf('php artisan pollora:schedule:migrate --from=%s --to=%s', $driver, $this->status->active())), + ); + } +} diff --git a/src/Schedule/SchedulerServiceProvider.php b/src/Schedule/SchedulerServiceProvider.php index 2c340926..4750e57c 100755 --- a/src/Schedule/SchedulerServiceProvider.php +++ b/src/Schedule/SchedulerServiceProvider.php @@ -5,16 +5,23 @@ namespace Pollora\Schedule; use Illuminate\Console\Scheduling\Schedule; +use Illuminate\Contracts\Config\Repository; use Illuminate\Contracts\Foundation\Application; use Illuminate\Database\QueryException; use Illuminate\Support\ServiceProvider; use Orchestra\Testbench\TestCase; +use Pollora\Doctor\Infrastructure\Providers\DoctorServiceProvider; use Pollora\Hook\Domain\Contract\Filter; +use Pollora\Schedule\Application\Services\ScheduleStatus; use Pollora\Schedule\Application\UseCases\RegisterSchedulerFiltersUseCase; use Pollora\Schedule\Contracts\SchedulerInterface; use Pollora\Schedule\Events\RecurringEvent; +use Pollora\Schedule\Infrastructure\Checks\ScheduleDriverCheck; +use Pollora\Schedule\Infrastructure\Services\MigrationNotice; use Pollora\Schedule\Infrastructure\Services\ScheduleDiscovery; use Pollora\Schedule\Schedule as ScheduleApi; +use Pollora\Schedule\UI\Console\ScheduleListCommand; +use Pollora\Schedule\UI\Console\ScheduleMigrateCommand; /** * Service provider for WordPress cron scheduler functionality. @@ -32,7 +39,15 @@ public function register(): void $this->registerDomainContracts(); $this->registerUseCases(); + $this->mergeConfigFrom(__DIR__.'/../../config/schedule.php', 'schedule'); + $this->app->singleton(ScheduleStatus::class, fn (Application $app): ScheduleStatus => new ScheduleStatus($app->make(Repository::class))); + ScheduleApi::useContainer($this->app); + $this->chooseDriver($this->app->make(Repository::class)); + + if ($this->app->runningInConsole()) { + $this->commands([ScheduleListCommand::class, ScheduleMigrateCommand::class]); + } } /** @@ -40,15 +55,42 @@ public function register(): void */ public function boot(): void { + $this->publishes([ + __DIR__.'/../../config/schedule.php' => config_path('schedule.php'), + ], 'pollora-schedule'); + + // A check of pollora:doctor and Site Health; tagged on boot, so it comes after the framework's own + $this->app->tag([ScheduleDriverCheck::class], DoctorServiceProvider::CHECKS_TAG); + $this->app->make(MigrationNotice::class)->register(); + if (config('wordpress.use_laravel_scheduler', false)) { $this->app->make(RegisterSchedulerFiltersUseCase::class)->execute(); } $this->app->booted(function (): void { + // The driver was chosen when the application started; WordPress can take its hooks by now + ScheduleApi::boot(); $this->scheduleRecurringEvents(); }); } + /** + * Choose the driver of the site, WP-Cron when config/schedule.php names one that does not exist (pollora:doctor says so). + */ + private function chooseDriver(Repository $config): void + { + $driver = $config->get('schedule.driver', 'wp-cron'); + + try { + ScheduleApi::driver(is_string($driver) && $driver !== '' ? $driver : 'wp-cron'); + } catch (\InvalidArgumentException) { + ScheduleApi::driver('wp-cron'); + } + + $protected = $config->get('schedule.protected_hooks', []); + ScheduleApi::engine()->protect(...array_map(strval(...), is_array($protected) ? $protected : [])); + } + /** * Register domain contracts with their infrastructure implementations. */ diff --git a/src/Schedule/UI/Console/ScheduleListCommand.php b/src/Schedule/UI/Console/ScheduleListCommand.php new file mode 100644 index 00000000..f4b536af --- /dev/null +++ b/src/Schedule/UI/Console/ScheduleListCommand.php @@ -0,0 +1,173 @@ +option('type'); + + if ($type !== null && ! in_array($type, ['once', 'recurring'], true)) { + $this->error('--type is "once" or "recurring".'); + + return self::FAILURE; + } + + if (! function_exists('_get_cron_array')) { + $this->error('WordPress is not loaded.'); + + return self::FAILURE; + } + + $rows = $this->rows(is_string($type) ? $type : null, is_string($this->option('driver')) ? $this->option('driver') : null); + + if ($this->option('json')) { + $this->line((string) json_encode($rows, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + + return self::SUCCESS; + } + + if ($rows === []) { + $this->info('No scheduled event matches.'); + + return self::SUCCESS; + } + + $network = (bool) $this->option('network'); + + $this->table( + [...($network ? ['Site'] : []), 'Hook', 'Type', 'Frequency', 'Driver', 'Next run', 'Arguments'], + array_map(static fn (array $row): array => [ + ...($network ? [$row['site']] : []), + $row['hook'], + $row['type'], + $row['frequency'], + $row['driver_label'], + $row['next_run'], + $row['args'] === [] ? '—' : (string) json_encode($row['args'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + ], $rows), + ); + + return self::SUCCESS; + } + + /** + * @return list> + */ + private function rows(?string $type, ?string $driver): array + { + $sites = $this->sites(); + $rows = []; + + foreach ($sites as $site) { + if ($site !== null) { + switch_to_blog($site); + } + + try { + foreach (Schedule::engine()->index() as $event) { + if (($type !== null && ($event->isRecurring() ? 'recurring' : 'once') !== $type) || ($driver !== null && $event->driver !== $driver)) { + continue; + } + + $rows[] = $this->row($event, $site); + } + } finally { + if ($site !== null) { + restore_current_blog(); + } + } + } + + usort($rows, static fn (array $a, array $b): int => $a['timestamp'] <=> $b['timestamp']); + + return $rows; + } + + /** + * The sites to read: the current one (null), one by ID, or the whole network. + * + * @return list + */ + private function sites(): array + { + if ($this->option('network') && function_exists('is_multisite') && is_multisite()) { + return array_map(intval(...), (array) get_sites(['fields' => 'ids', 'number' => 0])); + } + + $site = $this->option('site'); + + return is_numeric($site) && function_exists('is_multisite') && is_multisite() ? [(int) $site] : [null]; + } + + /** + * @return array + */ + private function row(Event $event, ?int $site): array + { + return [ + 'site' => $site ?? (function_exists('get_current_blog_id') ? get_current_blog_id() : 1), + 'hook' => $event->hook, + 'type' => $event->isRecurring() ? 'recurring' : 'once', + 'recurrence' => $event->recurrence, + 'frequency' => $this->frequency($event), + 'interval' => $event->interval, + 'driver' => $event->driver, + 'driver_label' => $this->status->label($event), + 'timestamp' => $event->timestamp, + 'next_run' => function_exists('wp_date') ? (string) wp_date('Y-m-d H:i', $event->timestamp) : gmdate('Y-m-d H:i', $event->timestamp), + 'args' => $event->args, + ]; + } + + private function frequency(Event $event): string + { + if ($event->recurrence === null) { + return '—'; + } + + if (in_array($event->recurrence, self::BUILT_IN, true) || $event->interval === null) { + return $event->recurrence; + } + + foreach ([86400 => ' day', 3600 => ' hour', 60 => ' min'] as $seconds => $unit) { + if ($event->interval % $seconds === 0) { + $count = intdiv($event->interval, $seconds); + + return $count.$unit.($count > 1 && $seconds !== 60 ? 's' : ''); + } + } + + return $event->interval.' s'; + } +} diff --git a/src/Schedule/UI/Console/ScheduleMigrateCommand.php b/src/Schedule/UI/Console/ScheduleMigrateCommand.php new file mode 100644 index 00000000..ae86b382 --- /dev/null +++ b/src/Schedule/UI/Console/ScheduleMigrateCommand.php @@ -0,0 +1,99 @@ +error('WordPress is not loaded.'); + + return self::FAILURE; + } + + $to = is_string($this->option('to')) && $this->option('to') !== '' ? $this->option('to') : $this->status->active(); + $from = is_string($this->option('from')) && $this->option('from') !== '' ? $this->option('from') : $this->guessSource($to); + + if ($from === null) { + $this->error('No other driver keeps events: say which one with --from.'); + + return self::FAILURE; + } + + try { + $report = (new Migrator(Schedule::engine()))->migrate($from, $to, (bool) $this->option('dry-run')); + } catch (\Throwable $throwable) { + $this->error($throwable->getMessage()); + + return self::FAILURE; + } + + $verb = $report->dryRun ? 'Would move' : 'Moved'; + + foreach ($report->moved as $event) { + $this->line(sprintf('%s %s (%s)', $verb, $event->hook, $event->recurrence ?? 'once')); + } + + foreach ($report->skipped as $skipped) { + $this->line(sprintf('Kept %s: %s', $skipped['event']->hook, $skipped['reason'])); + } + + foreach ($report->failed as $failed) { + $this->warn(sprintf('Could not move %s: %s', $failed['event']->hook, $failed['reason'])); + } + + $summary = sprintf('%s %d event(s) from %s to %s%s.', $verb, count($report->moved), $from, $to, $report->dryRun ? ' (dry run)' : ''); + + if (! $report->succeeded()) { + $this->error($summary.' Some events stayed where they were.'); + + return self::FAILURE; + } + + $this->info($summary); + + return self::SUCCESS; + } + + /** + * WP-Cron holds what a driver has not taken; when the target is WP-Cron, the driver that still holds events. + */ + private function guessSource(string $to): ?string + { + if ($to !== CronIndex::WP_CRON) { + return CronIndex::WP_CRON; + } + + foreach (Schedule::engine()->index() as $event) { + if ($event->driver !== CronIndex::WP_CRON) { + return (string) $event->driver; + } + } + + return null; + } +} diff --git a/tests/Feature/Schedule/ScheduleCommandsTest.php b/tests/Feature/Schedule/ScheduleCommandsTest.php new file mode 100644 index 00000000..d07cb73e --- /dev/null +++ b/tests/Feature/Schedule/ScheduleCommandsTest.php @@ -0,0 +1,336 @@ + */ + public array $kept = []; + + public function available(): bool + { + return self::$available; + } + + public function once(string $hook, int $timestamp, array $args = []): bool + { + $this->kept[] = new Event($hook, $timestamp, null, $args); + + return true; + } + + public function recurring(string $hook, int $timestamp, string $recurrence, array $args = []): bool + { + $this->kept[] = new Event($hook, $timestamp, $recurrence, $args, 3600); + + return true; + } + + public function cancel(string $hook, array $args = []): int + { + $before = count($this->kept); + $this->kept = array_values(array_filter($this->kept, fn (Event $event): bool => $event->hook !== $hook || $event->args !== $args)); + + return $before - count($this->kept); + } + + public function clear(string $hook): int + { + $before = count($this->kept); + $this->kept = array_values(array_filter($this->kept, fn (Event $event): bool => $event->hook !== $hook)); + + return $before - count($this->kept); + } + + public function next(string $hook, array $args = []): ?int + { + foreach ($this->kept as $event) { + if ($event->hook === $hook && $event->args === $args) { + return $event->timestamp; + } + } + + return null; + } + + public function events(): array + { + return $this->kept; + } +} + +beforeEach(function (): void { + $schedule = new ReflectionClass(Schedule::class); + + foreach (['engine', 'fake', 'driverBeforeFake', 'wanted'] as $property) { + $schedule->setStaticPropertyValue($property, null); + } + + MemoryScheduleDriver::$available = true; + $this->memory = new MemoryScheduleDriver; + $this->time = time() + 3600; + $this->cron = [ + $this->time => [ + 'reports' => [md5(serialize([])) => ['schedule' => 'daily', 'args' => [], 'interval' => 86400]], + 'wp_version_check' => [md5(serialize([])) => ['schedule' => 'twicedaily', 'args' => [], 'interval' => 43200]], + ], + $this->time + 60 => [ + 'digest' => [md5(serialize(['u' => 1])) => ['schedule' => false, 'args' => ['u' => 1], 'driver' => 'memory']], + 'action_scheduler_run_queue' => [md5(serialize([])) => ['schedule' => 'every_minute', 'args' => [], 'interval' => 60]], + ], + ]; + + Functions\when('_get_cron_array')->alias(fn (): array => $this->cron); + Functions\when('_set_cron_array')->alias(function (array $cron): bool { + $this->cron = $cron; + + return true; + }); + Functions\when('wp_get_schedules')->justReturn(['daily' => ['interval' => 86400], 'twicedaily' => ['interval' => 43200]]); + Functions\when('wp_schedule_event')->justReturn(true); + Functions\when('wp_schedule_single_event')->justReturn(true); + Functions\when('did_action')->justReturn(1); + Functions\when('wp_date')->alias(fn (string $format, int $timestamp): string => gmdate($format, $timestamp)); + + Schedule::extend('memory', fn (): ScheduleDriver => $this->memory); + config(['schedule.driver' => 'memory', 'schedule.protected_hooks' => []]); + app()->register(SchedulerServiceProvider::class, force: true); +}); + +afterEach(function (): void { + MemoryScheduleDriver::$available = true; +}); + +/** + * @return list> + */ +function scheduleListJson(array $options = []): array +{ + expect(Artisan::call('pollora:schedule:list', [...$options, '--json' => true]))->toBe(0); + + return json_decode(Artisan::output(), true); +} + +describe('driver choice', function (): void { + it('chooses the driver config/schedule.php names, and merges the defaults', function (): void { + expect(Schedule::engine()->driverName())->toBe('memory') + ->and(require __DIR__.'/../../../config/schedule.php')->toMatchArray(['protected_hooks' => []]) + ->and(config('schedule.driver'))->toBe('memory'); + }); + + it('falls back to WP-Cron when it names a driver that does not exist, and says so', function (): void { + config(['schedule.driver' => 'nope']); + $schedule = new ReflectionClass(Schedule::class); + $schedule->setStaticPropertyValue('engine', null); + + app()->register(SchedulerServiceProvider::class, force: true); + + $status = resolve(ScheduleStatus::class); + + expect(Schedule::engine()->driverName())->toBe('wp-cron') + ->and($status->unknown())->toBeTrue() + ->and($status->configured())->toBe('nope'); + }); + + it('does not call a driver unknown because something else switched the engine after the provider', function (): void { + Schedule::driver('wp-cron'); + + $status = resolve(ScheduleStatus::class); + + expect($status->configured())->toBe('memory') + ->and($status->active())->toBe('wp-cron') + ->and($status->unknown())->toBeFalse(); + }); + + it('protects the hooks the configuration names', function (): void { + config(['schedule.protected_hooks' => ['my_heartbeat']]); + app()->register(SchedulerServiceProvider::class, force: true); + + expect(Schedule::engine()->protectedHooks())->toContain('my_heartbeat', 'action_scheduler_run_queue'); + }); +}); + +describe('pollora:schedule:list', function (): void { + it('lists every event with its type and its driver, soonest first, from the index alone', function (): void { + $rows = scheduleListJson(); + + expect(array_column($rows, 'hook'))->toBe(['reports', 'wp_version_check', 'digest', 'action_scheduler_run_queue']) + ->and($rows[2])->toMatchArray(['type' => 'once', 'driver' => 'memory', 'driver_label' => 'memory', 'args' => ['u' => 1]]) + ->and($rows[0])->toMatchArray(['type' => 'recurring', 'recurrence' => 'daily', 'frequency' => 'daily', 'driver' => 'wp-cron']); + }); + + it('marks what is on WP-Cron on purpose, and what is left to migrate', function (): void { + $labels = array_column(scheduleListJson(), 'driver_label', 'hook'); + + expect($labels['action_scheduler_run_queue'])->toBe('wp-cron (protected)') + ->and($labels['reports'])->toBe('wp-cron (to migrate)') + ->and($labels['digest'])->toBe('memory'); + + MemoryScheduleDriver::$available = false; + + expect(array_column(scheduleListJson(), 'driver_label', 'hook')['reports'])->toBe('wp-cron (fallback)'); + }); + + it('filters by type and by driver, and refuses an unknown type', function (): void { + expect(array_column(scheduleListJson(['--type' => 'once']), 'hook'))->toBe(['digest']) + ->and(array_column(scheduleListJson(['--driver' => 'wp-cron', '--type' => 'recurring']), 'hook'))->toBe(['reports', 'wp_version_check', 'action_scheduler_run_queue']) + ->and(Artisan::call('pollora:schedule:list', ['--type' => 'sometimes']))->toBe(1); + }); + + it('prints a table, and a line when nothing matches', function (): void { + expect(Artisan::call('pollora:schedule:list'))->toBe(0) + ->and(Artisan::output())->toContain('wp-cron (to migrate)', 'digest'); + + Artisan::call('pollora:schedule:list', ['--driver' => 'nobody']); + + expect(Artisan::output())->toContain('No scheduled event matches.'); + }); + + it('writes a frequency a person reads for a custom interval', function (): void { + $this->cron[$this->time]['fast'] = [md5(serialize([])) => ['schedule' => 'custom_x', 'args' => [], 'interval' => 900]]; + + expect(array_column(scheduleListJson(), 'frequency', 'hook')['fast'])->toBe('15 min'); + }); +}); + +describe('pollora:schedule:migrate', function (): void { + it('shows what would move, and moves nothing, with --dry-run', function (): void { + expect(Artisan::call('pollora:schedule:migrate', ['--dry-run' => true]))->toBe(0) + ->and(Artisan::output())->toContain('Would move reports', 'Kept action_scheduler_run_queue') + ->and($this->memory->kept)->toBe([]); + }); + + it('moves the events of WP-Cron to the driver of the site', function (): void { + expect(Artisan::call('pollora:schedule:migrate'))->toBe(0) + ->and(array_column($this->memory->kept, 'hook'))->toContain('reports', 'wp_version_check') + ->and(array_column(scheduleListJson(), 'driver_label', 'hook')['reports'])->toBe('memory'); + }); + + it('moves them back with --from and --to, and guesses the source when the target is WP-Cron', function (): void { + Artisan::call('pollora:schedule:migrate'); + + expect(Artisan::call('pollora:schedule:migrate', ['--to' => 'wp-cron']))->toBe(0) + ->and(array_column(scheduleListJson(), 'driver', 'hook')['reports'])->toBe('wp-cron'); + }); + + it('fails when the target is not there, or there is no other driver to take from', function (): void { + MemoryScheduleDriver::$available = false; + + expect(Artisan::call('pollora:schedule:migrate'))->toBe(1) + ->and(Artisan::output())->toContain('not available'); + + MemoryScheduleDriver::$available = true; + $this->cron = []; + + expect(Artisan::call('pollora:schedule:migrate', ['--to' => 'wp-cron']))->toBe(1) + ->and(Artisan::output())->toContain('say which one with --from'); + }); +}); + +describe('the doctor check', function (): void { + it('is ok when events are kept by the driver of the site', function (): void { + $this->cron = [$this->time => ['digest' => [md5(serialize([])) => ['schedule' => false, 'args' => [], 'driver' => 'memory']]]]; + + $result = resolve(ScheduleDriverCheck::class)->run(RunContext::Console); + + expect($result->status)->toBe(CheckStatus::Ok); + }); + + it('warns of events a former driver keeps, with the command that moves them', function (): void { + $result = resolve(ScheduleDriverCheck::class)->run(RunContext::Console); + + expect($result->status)->toBe(CheckStatus::Warning) + ->and(implode(' ', $result->details))->toContain('"wp-cron"', 'not by the driver of the site ("memory")') + ->and($result->fix)->toContain('pollora:schedule:migrate --from=wp-cron --to=memory'); + }); + + it('warns of a driver that is not there, and does not ask to migrate what falls back to WP-Cron', function (): void { + MemoryScheduleDriver::$available = false; + $this->cron = [$this->time => ['reports' => [md5(serialize([])) => ['schedule' => 'daily', 'args' => [], 'interval' => 86400]]]]; + + $result = resolve(ScheduleDriverCheck::class)->run(RunContext::Console); + + expect($result->status)->toBe(CheckStatus::Warning) + ->and(implode(' ', $result->details))->toContain('is not available') + ->and(implode(' ', $result->details))->not->toContain('still kept'); + }); + + it('warns of a driver the configuration names and nobody provides', function (): void { + config(['schedule.driver' => 'nope']); + (new ReflectionClass(Schedule::class))->setStaticPropertyValue('engine', null); + app()->register(SchedulerServiceProvider::class, force: true); + + $result = resolve(ScheduleDriverCheck::class)->run(RunContext::Console); + + expect($result->status)->toBe(CheckStatus::Warning) + ->and(implode(' ', $result->details))->toContain('"nope", which does not exist'); + }); + + it('is skipped before WordPress has initialised', function (): void { + Functions\when('did_action')->justReturn(0); + + expect(resolve(ScheduleDriverCheck::class)->run(RunContext::Console)->status)->toBe(CheckStatus::Skipped); + }); +}); + +describe('the administration notice', function (): void { + beforeEach(function (): void { + Functions\when('esc_html__')->returnArg(); + Functions\when('esc_html')->returnArg(); + }); + + it('tells those who can run the command which events wait, and how to move them', function (): void { + Functions\when('current_user_can')->justReturn(true); + + ob_start(); + resolve(MigrationNotice::class)->render(); + $html = (string) ob_get_clean(); + + expect($html)->toContain('notice-warning', 'pollora:schedule:migrate --from=wp-cron --to=memory'); + }); + + it('says nothing to those who cannot, or when nothing waits', function (): void { + Functions\when('current_user_can')->justReturn(false); + + ob_start(); + resolve(MigrationNotice::class)->render(); + expect((string) ob_get_clean())->toBe(''); + + Functions\when('current_user_can')->justReturn(true); + $this->cron = []; + + ob_start(); + resolve(MigrationNotice::class)->render(); + expect((string) ob_get_clean())->toBe(''); + }); + + it('is shown on the administration screens, and the network ones', function (): void { + $added = []; + Functions\when('add_action')->alias(function (string $hook) use (&$added): bool { + $added[] = $hook; + + return true; + }); + + resolve(MigrationNotice::class)->register(); + + expect($added)->toBe(['admin_notices', 'network_admin_notices']); + }); +});