Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- One `DiscoveryCacheManager` is shared by the engine and the `pollora:doctor` discovery cache check, which built its own
- The discovery benchmarks measure memory as PHP allocates it, from a reset peak: they read 2 MB whatever the load
- `#[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
- `#[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`
Expand Down
20 changes: 17 additions & 3 deletions src/Schedule/Infrastructure/Services/ScheduleDiscovery.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
}

/**
Expand Down
28 changes: 28 additions & 0 deletions tests/Unit/Schedule/PackageBoundaryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

use Pollora\Schedule\Every;
use Pollora\Schedule\Infrastructure\Services\ScheduleDiscovery;
use Pollora\Schedule\Interval;

/**
* #[Schedule] runs on WP-Cron alone and becomes pollora/schedule. The bridge
* to Laravel's scheduler (Scheduler, its events and jobs, the wp_events table)
* stays in the framework.
*/
arch('the WP-Cron discovery stays free of the Laravel application')
->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',
]);
103 changes: 103 additions & 0 deletions tests/Unit/Schedule/ScheduleDiscoveryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

declare(strict_types=1);

use Brain\Monkey\Functions;
use Illuminate\Support\Str;
use Pollora\Attributes\Schedule;
use Pollora\Discovery\Domain\Models\DiscoveryLocation;
use Pollora\Discovery\Infrastructure\Services\ReflectionCache;
use Pollora\Schedule\Every;
use Pollora\Schedule\Infrastructure\Services\ScheduleDiscovery;
use Pollora\Schedule\Interval;
use Spatie\StructureDiscoverer\Data\DiscoveredClass;

final class NightlyReports
{
/** @var list<string> */
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'],
]);
Loading