diff --git a/CHANGELOG.md b/CHANGELOG.md index 59f08d30..b6d9367b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `#[WpRestRoute\Method]` no longer registers its route: `WpRestRouteRegistrar`, in the WpRest module, does, with the same arguments, permissions and request injection. Default slugs, meta keys and command names come from `Pollora\Attributes\Support\Naming`, which gives the same results as `Str::kebab()` and `Str::snake()`; `Pollora\Support\Slug` uses it - The WpRest module no longer reaches for Laravel's application, the first step towards `pollora/wp-rest`: custom `HandlesAttributes` attributes receive the PSR-11 container given to `WpRestDiscovery`, and `WpRestAttributableWrapper` takes the controller instance instead of building it. An architecture test keeps the module to the attributes, discovery, PSR contracts and WordPress - The WpCli module no longer reaches for Laravel's application, the first step towards `pollora/wp-cli`: `WpCliDiscovery` builds commands through the discovery's instance pool instead of `resolve()`. Its service provider and `pollora:make:wp-cli` stay in the framework; an architecture test keeps the rest to the attributes, discovery, PSR logging and WP-CLI +- `#[Schedule]`, which runs on WP-Cron alone, no longer reaches for Laravel, the first step towards `pollora/schedule`: `ScheduleDiscovery` derives default hook names itself, with the same results as `Str::snake()` (a parity test keeps them), so hooks already scheduled keep their name. The bridge to Laravel's scheduler (`use_laravel_scheduler`) stays in the framework ### Deprecated - `DiscoveryCacheInterface` and `LaravelDiscoveryCache`, removed in 15.0: nothing reads this cache, scans are cached by the Spatie driver given to `DiscoveryCacheManager` diff --git a/src/Schedule/Infrastructure/Services/ScheduleDiscovery.php b/src/Schedule/Infrastructure/Services/ScheduleDiscovery.php index b01db376..ffb32491 100644 --- a/src/Schedule/Infrastructure/Services/ScheduleDiscovery.php +++ b/src/Schedule/Infrastructure/Services/ScheduleDiscovery.php @@ -4,7 +4,6 @@ namespace Pollora\Schedule\Infrastructure\Services; -use Illuminate\Support\Str; use InvalidArgumentException; use Pollora\Attributes\Schedule; use Pollora\Discovery\Domain\Contracts\DiscoveryInterface; @@ -162,10 +161,25 @@ public function apply(): void private function generateHookName(string $className, string $methodName): string { // Extract just the class name without namespace - $shortClassName = class_basename($className); + $shortClassName = basename(str_replace('\\', '/', $className)); // Convert to snake_case following WordPress conventions - return Str::snake($shortClassName).'_'.Str::snake($methodName); + return $this->snake($shortClassName).'_'.$this->snake($methodName); + } + + /** + * The value in snake case, as Laravel's Str::snake() writes it: hooks + * already scheduled under a derived name keep that name. + */ + private function snake(string $value): string + { + if (ctype_lower($value)) { + return $value; + } + + $value = (string) preg_replace('/\s+/u', '', ucwords($value)); + + return mb_strtolower((string) preg_replace('/(.)(?=[A-Z])/u', '$1_', $value), 'UTF-8'); } /** diff --git a/tests/Unit/Schedule/PackageBoundaryTest.php b/tests/Unit/Schedule/PackageBoundaryTest.php new file mode 100644 index 00000000..dd31d761 --- /dev/null +++ b/tests/Unit/Schedule/PackageBoundaryTest.php @@ -0,0 +1,28 @@ +expect(ScheduleDiscovery::class) + ->toOnlyUse([ + 'Pollora\Attributes', + 'Pollora\Discovery', + Every::class, + Interval::class, + 'Psr\Log', + 'Spatie\StructureDiscoverer', + // WordPress + 'add_action', + 'add_filter', + 'wp_next_scheduled', + 'wp_schedule_event', + ]); diff --git a/tests/Unit/Schedule/ScheduleDiscoveryTest.php b/tests/Unit/Schedule/ScheduleDiscoveryTest.php new file mode 100644 index 00000000..ad16b49a --- /dev/null +++ b/tests/Unit/Schedule/ScheduleDiscoveryTest.php @@ -0,0 +1,103 @@ + */ + public static array $calls = []; + + #[Schedule(Every::DAY)] + public function compile(): void + { + self::$calls[] = 'compile'; + } + + #[Schedule('hourly', hook: 'reports_ping', args: ['site' => 1])] + public function ping(array $args): void + { + self::$calls[] = 'ping:'.$args['site']; + } + + #[Schedule(Every::MONTH)] + public function archiveOldReports(): void + { + self::$calls[] = 'archiveOldReports'; + } + + #[Schedule(new Interval(minutes: 15, display: 'Every quarter hour'))] + public function refresh(): void + { + self::$calls[] = 'refresh'; + } +} + +beforeEach(function (): void { + NightlyReports::$calls = []; + $this->hooks = []; + $this->filters = []; + $this->events = []; + + Functions\when('add_action')->alias(function (string $hook, callable $callback): bool { + $this->hooks[$hook] = $callback; + + return true; + }); + Functions\when('add_filter')->alias(function (string $hook, callable $callback): bool { + $this->filters[$hook][] = $callback; + + return true; + }); + Functions\when('wp_next_scheduled')->justReturn(false); + Functions\when('wp_schedule_event')->alias(function (int $timestamp, string $recurrence, string $hook, array $args = []): bool { + $this->events[$hook] = [$recurrence, $args]; + + return true; + }); + + $discovery = new ScheduleDiscovery; + $discovery->discover(new DiscoveryLocation('', __DIR__), DiscoveredClass::fromReflection(new ReflectionClass(NightlyReports::class)), new ReflectionCache); + $discovery->apply(); +}); + +it('schedules each method on its hook, named after the class and method by default', function (): void { + expect($this->events)->toHaveKeys(['nightly_reports_compile', 'reports_ping', 'nightly_reports_archive_old_reports']) + ->and($this->events['nightly_reports_compile'])->toBe(['daily', []]) + ->and($this->events['reports_ping'])->toBe(['hourly', ['site' => 1]]) + ->and($this->events['nightly_reports_archive_old_reports'])->toBe(['every_month', []]); +}); + +it('registers the schedules WordPress does not know', function (): void { + $schedules = array_reduce($this->filters['cron_schedules'], fn (array $schedules, callable $filter): array => $filter($schedules), []); + + expect($schedules['every_month'])->toBe(['interval' => 30 * 86400, 'display' => 'Once Monthly']) + ->and($schedules)->toContain(['interval' => 900, 'display' => 'Every quarter hour']); +}); + +it('runs the method, with its arguments, when the hook fires', function (): void { + ($this->hooks['nightly_reports_compile'])(); + ($this->hooks['reports_ping'])(); + + expect(NightlyReports::$calls)->toBe(['compile', 'ping:1']); +}); + +it('derives the hook name as Str::snake() does, so scheduled hooks keep their name', function (string $class, string $method): void { + $hookName = (new ReflectionMethod(ScheduleDiscovery::class, 'generateHookName'))->invoke(new ScheduleDiscovery, $class, $method); + + expect($hookName)->toBe(Str::snake(class_basename($class)).'_'.Str::snake($method)); +})->with([ + ['App\\Cron\\NightlyReports', 'compile'], + ['App\\Cron\\HTMLSitemap', 'rebuildXMLIndex'], + ['Reports', 'sendTop10'], + ['App\\Cron\\ÉvénementsPublics', 'purge'], +]);