diff --git a/CHANGELOG.md b/CHANGELOG.md index 76515c7a..aa8c0ad5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,50 @@ All notable changes to the Pollora framework will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased](https://github.com/Pollora/framework/compare/v13.35.6...develop) +## [Unreleased](https://github.com/Pollora/framework/compare/v13.36.0...develop) + +## [v13.36.0](https://github.com/Pollora/framework/compare/v13.35.6...v13.36.0) - 2026-10-14 + +### Added +- Discovery lives in its own package, [`pollora/discovery`](https://github.com/Pollora/discovery), which the framework requires: the engine, its contracts, `DiscoveryManager`, the cache and the `#[SkipDiscovery]` attribute, with their tests and benchmarks. Class names do not change. The framework keeps what needs Laravel: the service provider, `discovery:run` / `discovery:clear`, `DiscoveryRegistrar`, `ServiceProviderDiscovery`, the Laravel adapters, and `HasConfiguringSupport`, which builds post types and taxonomies +- Discovery can run without Laravel's application, the first step towards `pollora/discovery`: `new DiscoveryEngine()` works with no argument. Two contracts let an environment plug in — `InstanceResolverInterface` builds the classes discovery needs (Laravel's container in a Pollora application, `ReflectionInstanceResolver` otherwise) and `DiscoveryRegistrarInterface` adds the environment's discoveries before each run (`DiscoveryRegistrar` keeps scanning the container's `*Discovery` bindings). `Psr16DiscoverCacheDriver` caches scans in any PSR-16 cache +- Post type and taxonomy attributes say what they register: each implements `ConfiguresArguments`, a pure function that returns the registration arguments with its own contribution (`#[HasArchive('library')]` sets `has_archive`, `#[Labels]` merges into the generated labels), and the method attributes (`#[AdminCol]`, `#[RegisterMetaBoxCb]`, `#[MetaBoxCb]`, `#[MetaBoxSanitizeCb]`, `#[UpdateCountCallback]`) implement `ConfiguresArgumentsWithMethod`, the discovery handing them the method as a callback. The `Pollora\Attributes` namespace no longer reaches Laravel or a framework service, which an architecture test keeps: the first step towards `pollora/attributes`, a package to declare attributes without the framework +- The attributes live in their own package, [`pollora/attributes`](https://github.com/Pollora/attributes), which the framework requires: every attribute and its contracts, with the value types they take (`Every`, `Interval`, `Access`, `Control`, `BindingFieldType`). Class names do not change. The framework keeps the two deprecated bases, `PostTypeAttribute` and `TaxonomyAttribute`, and the code that reads the attributes +- 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 +- The `laravel` scheduling driver (`POLLORA_SCHEDULE_DRIVER=laravel`): every event is a job delayed until it is due, in the application's queue (`database`, Redis or Horizon, SQS), and a worker runs it. There is no table of Pollora's own and no `schedule:run`: the `cron` option is the record of what is scheduled, and cancelling an event takes its entry out of it. A job runs only if the entry still exists with its identifier and time, so a cancelled, moved or duplicated job stops; a recurring event queues its next occurrence once it ran or failed for good, never in the past. `config/schedule.php` gains `laravel.connection`, `queue`, `max_delay` (a job due later than the connection allows, 900 seconds on SQS, wakes and waits again) and `late_after`. On a multisite network the queue is shared, with one worker, and each job runs in the site that scheduled it +- Lost jobs (a flushed Redis, a purged queue) are found again: at most every five minutes, as a request ends and under a lock, and with `php artisan pollora:schedule:recover` (`--network`, `--dry-run`), an event more than `late_after` seconds (600) past its time is queued again with the same identifier, so it never runs twice. `pollora:doctor` and Site Health report late events (no worker), a queue connection that is `sync` or `null`, and a scheduled event that failed for good in the last week + +### Changed +- Requires Laravel 13.36: `illuminate/*` `^13.35` → `^13.36`. The framework version tracks Laravel's, so this release is v13.36.0 +- `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 +- 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 +- 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 + +- Asynchronous actions queue through the WP-Cron and Action Scheduler drivers of `pollora/schedule`, with `pollora/hook` 2.0: `->async()`, `#[Async]`, `Async::fake()`, `via()` and their options do not change, and work already queued runs once the upgrade is in. `Async\Drivers\WpCronDriver` and `ActionSchedulerDriver` are gone with no alias; the framework never used them, and the `queue` driver is unchanged + +### Deprecated +- `DiscoveryCacheInterface` and `LaravelDiscoveryCache`, removed in 15.0: nothing reads this cache, scans are cached by the Spatie driver given to `DiscoveryCacheManager` +- `Pollora\Attributes\PostType\PostTypeAttribute` and `Pollora\Attributes\Taxonomy\TaxonomyAttribute`, removed in 15.0: no built-in attribute extends them any more; implement `ConfiguresArguments` + +### Removed +- **The Laravel scheduling bridge, with no replacement under the same name**: `wordpress.use_laravel_scheduler`, `Pollora\Schedule\Scheduler`, `SchedulerInterface`, `Events\*` (`AbstractEvent`, `SingleEvent`, `RecurringEvent`), `Jobs\JobDispatcher`, `RegisterSchedulerFiltersUseCase`, the `pre_schedule_event`, `pre_option_cron` and related filters it installed, and the `wp_events` table. Setting the option no longer does anything. To keep WP-Cron events on Laravel's queue, set `POLLORA_SCHEDULE_DRIVER=laravel` and run a worker (`php artisan queue:work`) in place of `schedule:run`; events that sat in `wp_events` are not moved (let `#[Schedule]` and your own code schedule them again, then drop the table: `DROP TABLE wp_events`, with the prefix of your database connection). `pollora:doctor` no longer reads the option +- `handle()` on `#[Action]`, `#[Filter]`, `#[WpRestRoute]` and `#[WpRestRoute\Method]`, and `Hook` no longer implements `HandlesAttributes`: `HookDiscovery` and `WpRestDiscovery` register hooks and routes themselves. Only the bug below reached `Action::handle()`, and `WpRestRoute::handle()` wrote to a read-only wrapper + +### Fixed +- A queued asynchronous action runs even when the request that runs it registers no hook: the framework listens to `pollora/async/run` at boot instead of waiting for an `Action` to be built. Before, a handler registered only in the request that queued it was never run, and Action Scheduler marked it failed ("no callbacks are registered") +- `wp_get_environment_type()` follows `APP_ENV` instead of reading `production` on every site (#443): `local` gives `local`, `dev`, `development` and `testing` give `development`, `staging` gives `staging`, anything else `production`. A `WP_ENVIRONMENT_TYPE` environment variable, a `wordpress.constants.WP_ENVIRONMENT_TYPE` value or a constant defined earlier still wins. WordPress 7.1 turns pings (incoming and outgoing pingbacks and trackbacks) off outside `production`, so they are off on a site with `APP_ENV=local` or `staging`: `add_filter('wp_should_disable_pings_for_environment', '__return_false')` brings them back +- `pollora:doctor` no longer counts as overdue WP-Cron events the ones Action Scheduler keeps: the `cron` option indexes them too, with a `driver` key +- A public `#[Action]` or `#[Filter]` method in a `#[PostType]` or `#[Taxonomy]` class is registered once: the post type and taxonomy discoveries also handed it to `Action::handle()`, which registered it a second time on the discovery's configuration object, so the page firing that hook failed with `call_user_func(): Argument #1 ($callback) must be a valid callback, class Pollora\PostType\Infrastructure\Services\PostTypeConfiguration does not have a method …` +- A REST controller declared with `#[WpRestRoute]` gets its constructor dependencies: it is resolved through the discovery's instance pool (Laravel's container in a Pollora application) instead of `new` with no argument, so a controller with a required dependency answered every request with `Trying to invoke non static method … without an object` ## [v13.35.6](https://github.com/Pollora/framework/compare/v13.35.5...v13.35.6) - 2026-10-12 diff --git a/README.md b/README.md index d6883d3f..db6588c8 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ composer create-project pollora/pollora my-project Requirements: - PHP 8.4+ for a new project (the skeleton's lock file ships Symfony 8; this package alone accepts PHP 8.3) -- Laravel 13.35 (Pollora's version numbers follow the Laravel release it is built on) +- Laravel 13.36 (Pollora's version numbers follow the Laravel release it is built on) - WordPress 7.1+ ## Quick start diff --git a/composer.json b/composer.json index c7662e94..1ab45cb6 100644 --- a/composer.json +++ b/composer.json @@ -24,16 +24,21 @@ "require": { "php": "^8.3", "pollora/helper-overrider": "^1.2", - "illuminate/auth": "^13.35", - "illuminate/support": "^13.35", - "illuminate/database": "^13.35", - "illuminate/routing": "^13.35", - "illuminate/console": "^13.35", + "illuminate/auth": "^13.36", + "illuminate/support": "^13.36", + "illuminate/database": "^13.36", + "illuminate/routing": "^13.36", + "illuminate/console": "^13.36", "pollora/abilities": "^1.0", "pollora/ajax": "^1.1", + "pollora/attributes": "^0.1 || ^1.0", + "pollora/discovery": "^0.1 || ^1.0", "pollora/entity": "^1.2", - "pollora/hook": "^1.5", + "pollora/hook": "^2.0.1", "pollora/option": "^1.1", + "pollora/schedule": "^1.0", + "pollora/wp-cli": "^0.1 || ^1.0", + "pollora/wp-rest": "^0.1 || ^1.0", "watson/rememberable": "^7.1", "cweagans/composer-patches": "^2.0", "pollora/colt": "^10.0.1", @@ -44,8 +49,8 @@ "coduo/php-humanizer": "5.0.3", "nwidart/laravel-modules": "^13.0", "symfony/process": "^7.4 || ^8.0", - "illuminate/config": "^13.35", - "laravel/prompts": "^0.3.24" + "illuminate/config": "^13.36", + "laravel/prompts": "^0.3.25" }, "autoload": { "psr-4": { @@ -108,8 +113,8 @@ "brain/monkey": "^2.7", "mockery/mockery": "^1.6.15", "wp-cli/wp-cli": "^2.12", - "larastan/larastan": "^3.12", - "orchestra/testbench": "^11.1.0" + "larastan/larastan": "^3.13", + "orchestra/testbench": "^11.3.0" }, "autoload-dev": { "psr-4": { diff --git a/config/schedule.php b/config/schedule.php new file mode 100644 index 00000000..b59c9175 --- /dev/null +++ b/config/schedule.php @@ -0,0 +1,76 @@ + env('POLLORA_SCHEDULE_DRIVER', 'wp-cron'), + + /* + |-------------------------------------------------------------------------- + | The "laravel" driver + |-------------------------------------------------------------------------- + | + | connection: the queue connection of the jobs; null is the application's + | default. On a multisite network every site uses the same one, with a + | single worker. + | + | queue: the queue they wait in; null is the connection's default. + | + | max_delay: the longest delay, in seconds, the connection accepts. A job + | due later waits again when it wakes. null: 900 for SQS, none otherwise. + | + | late_after: an event this many seconds past its time is queued again + | (same identifier, so it never runs twice) when its job was lost. + | Checked every five minutes, or with php artisan pollora:schedule:recover. + | + */ + + 'laravel' => [ + 'connection' => env('POLLORA_SCHEDULE_QUEUE_CONNECTION'), + 'queue' => env('POLLORA_SCHEDULE_QUEUE'), + 'max_delay' => env('POLLORA_SCHEDULE_MAX_DELAY'), + 'late_after' => (int) env('POLLORA_SCHEDULE_LATE_AFTER', 600), + ], + + /* + |-------------------------------------------------------------------------- + | 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/phpstan-baseline.neon b/phpstan-baseline.neon index f75c76db..8334026c 100755 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -18,24 +18,6 @@ parameters: count: 1 path: src/Asset/Infrastructure/Services/ViteManager.php - - - message: '#^Access to an undefined property Pollora\\Attributes\\Attributable\:\:\$classPermission\.$#' - identifier: property.notFound - count: 1 - path: src/Attributes/WpRestRoute/Method.php - - - - message: '#^Access to an undefined property Pollora\\Attributes\\Attributable\:\:\$namespace\.$#' - identifier: property.notFound - count: 1 - path: src/Attributes/WpRestRoute/Method.php - - - - message: '#^Access to an undefined property Pollora\\Attributes\\Attributable\:\:\$route\.$#' - identifier: property.notFound - count: 2 - path: src/Attributes/WpRestRoute/Method.php - - message: '#^Constant AUTH_COOKIE not found\.$#' identifier: constant.notFound @@ -228,8 +210,3 @@ parameters: count: 1 path: src/View/Application/UseCases/MakeTemplateIncludableUseCase.php - - - message: '#^Constant WP_CLI_VERSION not found\.$#' - identifier: constant.notFound - count: 1 - path: src/WpCli/Infrastructure/Adapters/WpCliAdapter.php diff --git a/src/Attributes/Ability.php b/src/Attributes/Ability.php deleted file mode 100644 index 5d8dcc35..00000000 --- a/src/Attributes/Ability.php +++ /dev/null @@ -1,60 +0,0 @@ -get(ActionService::class); - if (! $actionService) { - return; - } - - // #[Action] targets methods only - if (! $context instanceof ReflectionMethod) { - return; - } - - // Asynchronously when the method or its class carries #[Async] - $logger = $this->optional($serviceLocator, LoggerInterface::class); - $failures = $this->optional($serviceLocator, AsyncDeclarationFailures::class); - - (new AsyncAttributeRegistrar($actionService, $logger, $failures))->register( - $attribute->hook, - $instance, - $context, - $attribute->priority, - $context->getNumberOfParameters() - ); - } - - /** - * A service the service locator may not provide: the logger, the record of ignored #[Async]. - * - * @template T of object - * - * @param class-string $class - * @return T|null - */ - private function optional(object $serviceLocator, string $class): ?object - { - try { - $service = $serviceLocator->get($class); - } catch (\Throwable) { - return null; - } - - return $service instanceof $class ? $service : null; - } -} diff --git a/src/Attributes/Ajax.php b/src/Attributes/Ajax.php deleted file mode 100644 index 5dd17909..00000000 --- a/src/Attributes/Ajax.php +++ /dev/null @@ -1,40 +0,0 @@ -async(): #[Action] keeps the hook and - * the priority, #[Async] carries the deferred-execution options. - * - * #[Action('save_post_event')] - * #[Async(delay: 60, unique: true, tries: 3)] - * public function syncToCrm(int $postId): void {} - * - * On a class, it applies to every #[Action] method; an #[Async] on a method - * replaces the class options for that method. With several #[Action] on a - * method, it applies to all of them, except the hooks listed in $except. - */ -#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] -final readonly class Async -{ - /** - * @param int|\DateInterval|null $delay Minimum delay before execution, in seconds - * @param string|null $via Driver: queue, action-scheduler, wp-cron, sync - * @param string|null $onQueue Queue name, the group with Action Scheduler - * @param bool|int $unique Merge identical triggers until the first one runs; an integer sets how long the lock lasts, in seconds - * @param int|null $tries Attempts when the handler throws - * @param int|list|null $backoff Seconds before each retry - * @param bool|null $asUser Run as the user who fired the hook - * @param string|null $capture Public method of the class recording values at trigger time - * @param string|null $when Public method of the class deciding, at trigger time, whether to queue - * @param bool $keepMissing Run with null in place of a referenced object deleted in the meantime - * @param string|list $except Hooks of the method that stay synchronous - */ - public function __construct( - public int|\DateInterval|null $delay = null, - public ?string $via = null, - public ?string $onQueue = null, - public bool|int $unique = false, - public ?int $tries = null, - public int|array|null $backoff = null, - public ?bool $asUser = null, - public ?string $capture = null, - public ?string $when = null, - public bool $keepMissing = false, - public string|array $except = [], - ) {} -} diff --git a/src/Attributes/Attributable.php b/src/Attributes/Attributable.php deleted file mode 100755 index 942df62d..00000000 --- a/src/Attributes/Attributable.php +++ /dev/null @@ -1,19 +0,0 @@ -|Permission|null $classPermission - * @property string $namespace - * @property string $route - */ -interface Attributable {} diff --git a/src/Attributes/BlockBinding.php b/src/Attributes/BlockBinding.php deleted file mode 100644 index e9da9805..00000000 --- a/src/Attributes/BlockBinding.php +++ /dev/null @@ -1,50 +0,0 @@ - */ - public array $usesContext; - - /** @var list */ - public array $postTypes; - - /** - * @param string $name Source name, `namespace/name` in lowercase, as WordPress requires - * @param string|null $label Label shown in the editor. Defaults to the class name, headlined - * @param list $usesContext Block context the source reads - * @param string|list $postTypes Post types the source answers for; empty for all - */ - public function __construct( - public string $name, - public ?string $label = null, - array $usesContext = ['postId', 'postType'], - string|array $postTypes = [], - ) { - $this->usesContext = array_values($usesContext); - $this->postTypes = array_values((array) $postTypes); - } -} diff --git a/src/Attributes/BlockBinding/BindingField.php b/src/Attributes/BlockBinding/BindingField.php deleted file mode 100644 index 4d8f436f..00000000 --- a/src/Attributes/BlockBinding/BindingField.php +++ /dev/null @@ -1,33 +0,0 @@ -get(FilterService::class); - if (! $filterService) { - return; - } - - $filterService->add( - $attribute->hook, - [$instance, $context->getName()], - $attribute->priority, - $context->getNumberOfParameters() - ); - } -} diff --git a/src/Attributes/Hook.php b/src/Attributes/Hook.php deleted file mode 100755 index 511667ec..00000000 --- a/src/Attributes/Hook.php +++ /dev/null @@ -1,27 +0,0 @@ - $rules Laravel validation rules, checked on writes from PHP and REST - * @param bool $single On an `array` property, false stores one row per item instead of one serialized array - * @param string|null $items On an `array` property, the item type: `'string'`, `'int'`, `'float'`, `'bool'` or a class. Defaults to the `@var list<…>` docblock - * @param Control|null $control The input a UI driver should build. Defaults to one derived from the type - * @param string|null $group The group of fields a UI driver puts the meta in - * @param array $hints Options passed as they are to UI drivers, by driver: `['acf' => ['wrapper' => ['width' => 50]]]` - * @param bool $media On an `int` property, the value is an attachment ID: a block binding gives its URL, alt text or caption - * @param bool $public The value may be shown to anyone: required for a user meta to be read by the `pollora/author-meta` block binding - */ - public function __construct( - public ?string $key = null, - public bool $showInRest = false, - public ?string $label = null, - public ?string $description = null, - public string|array|null $sanitize = null, - public ?string $capability = null, - public bool $revisions = false, - public array $rules = [], - public bool $single = true, - public ?string $items = null, - public ?Control $control = null, - public ?string $group = null, - public array $hints = [], - public bool $media = false, - public bool $public = false, - ) {} - - /** - * The key the meta is stored under for the given property. - */ - public function resolveKey(string $property): string - { - return $this->key ?? Str::snake($property); - } -} diff --git a/src/Attributes/ModifyRole.php b/src/Attributes/ModifyRole.php deleted file mode 100644 index accf608b..00000000 --- a/src/Attributes/ModifyRole.php +++ /dev/null @@ -1,34 +0,0 @@ - */ - public array $postTypes; - - /** - * @param string|list $postTypes The post type slugs the meta belong to - */ - public function __construct(string|array $postTypes) - { - $this->postTypes = array_values((array) $postTypes); - } -} diff --git a/src/Attributes/PostType.php b/src/Attributes/PostType.php deleted file mode 100644 index 80ba791f..00000000 --- a/src/Attributes/PostType.php +++ /dev/null @@ -1,56 +0,0 @@ -slug ?? substr(Str::kebab(class_basename($className)), 0, 20); - } -} diff --git a/src/Attributes/PostType/AdminCol.php b/src/Attributes/PostType/AdminCol.php deleted file mode 100755 index 645c882e..00000000 --- a/src/Attributes/PostType/AdminCol.php +++ /dev/null @@ -1,140 +0,0 @@ -getArg('admin_cols', []); - - // Build column configuration based on extended-cpts options - $columnConfig = [ - 'title' => $this->title, - ]; - - // Add custom function callback if this is a method-level attribute - if ($context instanceof ReflectionMethod) { - $columnConfig['function'] = [new $context->class, $context->getName()]; - } - - // Add optional configuration based on extended-cpts features - if ($this->titleIcon !== null) { - $columnConfig['title_icon'] = $this->titleIcon; - } - - if ($this->dateFormat !== null) { - $columnConfig['date_format'] = $this->dateFormat; - } - - if ($this->link !== null) { - $columnConfig['link'] = $this->link; - } - - if ($this->cap !== null) { - $columnConfig['cap'] = $this->cap; - } - - if ($this->postCap !== null) { - $columnConfig['post_cap'] = $this->postCap; - } - - if ($this->default !== null) { - $columnConfig['default'] = $this->default; - } - - if ($this->metaKey !== null) { - $columnConfig['meta_key'] = $this->metaKey; - } - - if ($this->taxonomy !== null) { - $columnConfig['taxonomy'] = $this->taxonomy; - } - - if ($this->featuredImage !== null) { - $columnConfig['featured_image'] = $this->featuredImage; - } - - if ($this->postField !== null) { - $columnConfig['post_field'] = $this->postField; - } - - if ($this->width !== null) { - $columnConfig['width'] = $this->width; - } - - // Handle sortable configuration - if ($this->sortable !== false) { - $columnConfig['sortable'] = $this->sortable; - } - - // Add the column to the admin_cols array - $adminCols[$this->key] = $columnConfig; - $instance->setArg('admin_cols', $adminCols); - } -} diff --git a/src/Attributes/PostType/AdminCols.php b/src/Attributes/PostType/AdminCols.php deleted file mode 100755 index ac92f1ba..00000000 --- a/src/Attributes/PostType/AdminCols.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('admin_cols', $this->value); - } -} diff --git a/src/Attributes/PostType/AdminFilters.php b/src/Attributes/PostType/AdminFilters.php deleted file mode 100755 index 6c6de4d1..00000000 --- a/src/Attributes/PostType/AdminFilters.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('admin_filters', $this->value); - } -} diff --git a/src/Attributes/PostType/Archive.php b/src/Attributes/PostType/Archive.php deleted file mode 100755 index d65294de..00000000 --- a/src/Attributes/PostType/Archive.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('archive', $this->value); - } -} diff --git a/src/Attributes/PostType/BlockEditor.php b/src/Attributes/PostType/BlockEditor.php deleted file mode 100755 index 145fdf1b..00000000 --- a/src/Attributes/PostType/BlockEditor.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('show_in_rest', $this->value); - } -} diff --git a/src/Attributes/PostType/CanExport.php b/src/Attributes/PostType/CanExport.php deleted file mode 100755 index 73199a2c..00000000 --- a/src/Attributes/PostType/CanExport.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('can_export', $this->value); - } -} diff --git a/src/Attributes/PostType/Capabilities.php b/src/Attributes/PostType/Capabilities.php deleted file mode 100755 index 7fc3e65d..00000000 --- a/src/Attributes/PostType/Capabilities.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('capabilities', $this->value); - } -} diff --git a/src/Attributes/PostType/CapabilityType.php b/src/Attributes/PostType/CapabilityType.php deleted file mode 100755 index 886630d6..00000000 --- a/src/Attributes/PostType/CapabilityType.php +++ /dev/null @@ -1,44 +0,0 @@ -setArg('capability_type', $this->value); - } -} diff --git a/src/Attributes/PostType/Chronological.php b/src/Attributes/PostType/Chronological.php deleted file mode 100755 index 10065f3b..00000000 --- a/src/Attributes/PostType/Chronological.php +++ /dev/null @@ -1,44 +0,0 @@ -value) { - // Set default ordering to date, descending - $postType->setArg('orderby', 'date'); - $postType->setArg('order', 'DESC'); - } - } -} diff --git a/src/Attributes/PostType/DashboardActivity.php b/src/Attributes/PostType/DashboardActivity.php deleted file mode 100755 index 97ae5c52..00000000 --- a/src/Attributes/PostType/DashboardActivity.php +++ /dev/null @@ -1,40 +0,0 @@ -setArg('dashboard_activity', $this->value); - } -} diff --git a/src/Attributes/PostType/DashboardGlance.php b/src/Attributes/PostType/DashboardGlance.php deleted file mode 100755 index 99a0e08f..00000000 --- a/src/Attributes/PostType/DashboardGlance.php +++ /dev/null @@ -1,38 +0,0 @@ -setArg('dashboard_glance', $this->value); - } -} diff --git a/src/Attributes/PostType/DeleteWithUser.php b/src/Attributes/PostType/DeleteWithUser.php deleted file mode 100755 index b762aeed..00000000 --- a/src/Attributes/PostType/DeleteWithUser.php +++ /dev/null @@ -1,40 +0,0 @@ -setArg('delete_with_user', $this->value); - } -} diff --git a/src/Attributes/PostType/Description.php b/src/Attributes/PostType/Description.php deleted file mode 100755 index 7ba770eb..00000000 --- a/src/Attributes/PostType/Description.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('description', $this->value); - } -} diff --git a/src/Attributes/PostType/ExcludeFromSearch.php b/src/Attributes/PostType/ExcludeFromSearch.php deleted file mode 100755 index 69e75e60..00000000 --- a/src/Attributes/PostType/ExcludeFromSearch.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('exclude_from_search', $this->value); - } -} diff --git a/src/Attributes/PostType/FeaturedImage.php b/src/Attributes/PostType/FeaturedImage.php deleted file mode 100755 index 8e8c8daa..00000000 --- a/src/Attributes/PostType/FeaturedImage.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('featured_image', $this->value); - } -} diff --git a/src/Attributes/PostType/HasArchive.php b/src/Attributes/PostType/HasArchive.php deleted file mode 100755 index cc2c7786..00000000 --- a/src/Attributes/PostType/HasArchive.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('has_archive', $this->value); - } -} diff --git a/src/Attributes/PostType/Hierarchical.php b/src/Attributes/PostType/Hierarchical.php deleted file mode 100755 index f7e2610e..00000000 --- a/src/Attributes/PostType/Hierarchical.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('hierarchical', $this->value); - } -} diff --git a/src/Attributes/PostType/Label.php b/src/Attributes/PostType/Label.php deleted file mode 100755 index 33720b71..00000000 --- a/src/Attributes/PostType/Label.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('label', $this->value); - } -} diff --git a/src/Attributes/PostType/Labels.php b/src/Attributes/PostType/Labels.php deleted file mode 100755 index 026fd71b..00000000 --- a/src/Attributes/PostType/Labels.php +++ /dev/null @@ -1,100 +0,0 @@ - */ - private readonly array $labels; - - public function __construct( - ?string $name = null, - ?string $singularName = null, - ?string $menuName = null, - ?string $allItems = null, - ?string $addNew = null, - ?string $addNewItem = null, - ?string $editItem = null, - ?string $newItem = null, - ?string $viewItem = null, - ?string $viewItems = null, - ?string $searchItems = null, - ?string $notFound = null, - ?string $notFoundInTrash = null, - ?string $parentItemColon = null, - ?string $archives = null, - ?string $attributes = null, - ?string $insertIntoItem = null, - ?string $uploadedToThisItem = null, - ?string $featuredImage = null, - ?string $setFeaturedImage = null, - ?string $removeFeaturedImage = null, - ?string $useFeaturedImage = null, - ?string $filterItemsList = null, - ?string $itemsListNavigation = null, - ?string $itemsList = null, - ?string $itemPublished = null, - ?string $itemUpdated = null, - ?string $itemScheduled = null, - ?string $itemReverted = null, - ) { - $this->labels = array_filter([ - 'name' => $name, - 'singular_name' => $singularName, - 'menu_name' => $menuName, - 'all_items' => $allItems, - 'add_new' => $addNew, - 'add_new_item' => $addNewItem, - 'edit_item' => $editItem, - 'new_item' => $newItem, - 'view_item' => $viewItem, - 'view_items' => $viewItems, - 'search_items' => $searchItems, - 'not_found' => $notFound, - 'not_found_in_trash' => $notFoundInTrash, - 'parent_item_colon' => $parentItemColon, - 'archives' => $archives, - 'attributes' => $attributes, - 'insert_into_item' => $insertIntoItem, - 'uploaded_to_this_item' => $uploadedToThisItem, - 'featured_image' => $featuredImage, - 'set_featured_image' => $setFeaturedImage, - 'remove_featured_image' => $removeFeaturedImage, - 'use_featured_image' => $useFeaturedImage, - 'filter_items_list' => $filterItemsList, - 'items_list_navigation' => $itemsListNavigation, - 'items_list' => $itemsList, - 'item_published' => $itemPublished, - 'item_updated' => $itemUpdated, - 'item_scheduled' => $itemScheduled, - 'item_reverted_to_draft' => $itemReverted, - ], fn (?string $v): bool => $v !== null); - } - - protected function configure(PostTypeAttributeInterface $postType): void - { - $existing = $postType->getArg('labels', []); - $postType->setArg('labels', array_merge($existing, $this->labels)); - } -} diff --git a/src/Attributes/PostType/MapMetaCap.php b/src/Attributes/PostType/MapMetaCap.php deleted file mode 100755 index 1dea4737..00000000 --- a/src/Attributes/PostType/MapMetaCap.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('map_meta_cap', $this->value); - } -} diff --git a/src/Attributes/PostType/MenuIcon.php b/src/Attributes/PostType/MenuIcon.php deleted file mode 100755 index 7450e6ea..00000000 --- a/src/Attributes/PostType/MenuIcon.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('menu_icon', $this->value); - } -} diff --git a/src/Attributes/PostType/MenuPosition.php b/src/Attributes/PostType/MenuPosition.php deleted file mode 100755 index 41cb7b1c..00000000 --- a/src/Attributes/PostType/MenuPosition.php +++ /dev/null @@ -1,50 +0,0 @@ -setArg('menu_position', $this->value); - } -} diff --git a/src/Attributes/PostType/PostTypeAttribute.php b/src/Attributes/PostType/PostTypeAttribute.php index 0ecb963f..ef612c31 100755 --- a/src/Attributes/PostType/PostTypeAttribute.php +++ b/src/Attributes/PostType/PostTypeAttribute.php @@ -5,6 +5,7 @@ namespace Pollora\Attributes\PostType; use Pollora\Attributes\Attributable; +use Pollora\Attributes\Contracts\ConfiguresArguments; use Pollora\Attributes\Contracts\HandlesAttributes; use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; use ReflectionClass; @@ -16,7 +17,9 @@ * This attribute serves as the base for all post type configuration attributes. * It provides common functionality for handling post type registration arguments. * - * @Attribute + * @deprecated since 13.36, removed in 15.0. Implement + * {@see ConfiguresArguments}, as the + * built-in attributes now do. */ abstract class PostTypeAttribute implements HandlesAttributes { diff --git a/src/Attributes/PostType/Priority.php b/src/Attributes/PostType/Priority.php deleted file mode 100755 index a1176bb5..00000000 --- a/src/Attributes/PostType/Priority.php +++ /dev/null @@ -1,34 +0,0 @@ -setArg('priority', $this->priority); - } -} diff --git a/src/Attributes/PostType/PublicPostType.php b/src/Attributes/PostType/PublicPostType.php deleted file mode 100755 index fdcc04a6..00000000 --- a/src/Attributes/PostType/PublicPostType.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('public', $this->value); - } -} diff --git a/src/Attributes/PostType/PubliclyQueryable.php b/src/Attributes/PostType/PubliclyQueryable.php deleted file mode 100755 index 1957ecbe..00000000 --- a/src/Attributes/PostType/PubliclyQueryable.php +++ /dev/null @@ -1,38 +0,0 @@ -setArg('publicly_queryable', $this->value); - } -} diff --git a/src/Attributes/PostType/QueryVar.php b/src/Attributes/PostType/QueryVar.php deleted file mode 100755 index d70da40e..00000000 --- a/src/Attributes/PostType/QueryVar.php +++ /dev/null @@ -1,41 +0,0 @@ -setArg('query_var', $this->value); - } -} diff --git a/src/Attributes/PostType/QuickEdit.php b/src/Attributes/PostType/QuickEdit.php deleted file mode 100755 index bbc2cc47..00000000 --- a/src/Attributes/PostType/QuickEdit.php +++ /dev/null @@ -1,40 +0,0 @@ -setArg('quick_edit', $this->value); - } -} diff --git a/src/Attributes/PostType/RegisterMetaBoxCb.php b/src/Attributes/PostType/RegisterMetaBoxCb.php deleted file mode 100755 index 40e66c41..00000000 --- a/src/Attributes/PostType/RegisterMetaBoxCb.php +++ /dev/null @@ -1,42 +0,0 @@ -setArg('register_meta_box_cb', [new $context->class, $context->getName()]); - } -} diff --git a/src/Attributes/PostType/RestBase.php b/src/Attributes/PostType/RestBase.php deleted file mode 100755 index 0dff0967..00000000 --- a/src/Attributes/PostType/RestBase.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('rest_base', $this->value); - } -} diff --git a/src/Attributes/PostType/RestControllerClass.php b/src/Attributes/PostType/RestControllerClass.php deleted file mode 100755 index ac8ba8a6..00000000 --- a/src/Attributes/PostType/RestControllerClass.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('rest_controller_class', $this->value); - } -} diff --git a/src/Attributes/PostType/RestNamespace.php b/src/Attributes/PostType/RestNamespace.php deleted file mode 100755 index 77244233..00000000 --- a/src/Attributes/PostType/RestNamespace.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('rest_namespace', $this->value); - } -} diff --git a/src/Attributes/PostType/Rewrite.php b/src/Attributes/PostType/Rewrite.php deleted file mode 100755 index 122e1c0d..00000000 --- a/src/Attributes/PostType/Rewrite.php +++ /dev/null @@ -1,41 +0,0 @@ -setArg('rewrite', $this->value); - } -} diff --git a/src/Attributes/PostType/ShowInAdminBar.php b/src/Attributes/PostType/ShowInAdminBar.php deleted file mode 100755 index 99cf0690..00000000 --- a/src/Attributes/PostType/ShowInAdminBar.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('show_in_admin_bar', $this->value); - } -} diff --git a/src/Attributes/PostType/ShowInFeed.php b/src/Attributes/PostType/ShowInFeed.php deleted file mode 100755 index b7cc0ce0..00000000 --- a/src/Attributes/PostType/ShowInFeed.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('show_in_feed', $this->value); - } -} diff --git a/src/Attributes/PostType/ShowInMenu.php b/src/Attributes/PostType/ShowInMenu.php deleted file mode 100755 index fd2371fa..00000000 --- a/src/Attributes/PostType/ShowInMenu.php +++ /dev/null @@ -1,41 +0,0 @@ -setArg('show_in_menu', $this->value); - } -} diff --git a/src/Attributes/PostType/ShowInNavMenus.php b/src/Attributes/PostType/ShowInNavMenus.php deleted file mode 100755 index c42b0f97..00000000 --- a/src/Attributes/PostType/ShowInNavMenus.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('show_in_nav_menus', $this->value); - } -} diff --git a/src/Attributes/PostType/ShowInRest.php b/src/Attributes/PostType/ShowInRest.php deleted file mode 100755 index 81527a44..00000000 --- a/src/Attributes/PostType/ShowInRest.php +++ /dev/null @@ -1,40 +0,0 @@ -setArg('show_in_rest', $this->value); - } -} diff --git a/src/Attributes/PostType/ShowUI.php b/src/Attributes/PostType/ShowUI.php deleted file mode 100755 index eda88522..00000000 --- a/src/Attributes/PostType/ShowUI.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('show_ui', $this->value); - } -} diff --git a/src/Attributes/PostType/SiteFilters.php b/src/Attributes/PostType/SiteFilters.php deleted file mode 100755 index cc7ff9ab..00000000 --- a/src/Attributes/PostType/SiteFilters.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('site_filters', $this->value); - } -} diff --git a/src/Attributes/PostType/SiteSortables.php b/src/Attributes/PostType/SiteSortables.php deleted file mode 100755 index 0462fa75..00000000 --- a/src/Attributes/PostType/SiteSortables.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('site_sortables', $this->value); - } -} diff --git a/src/Attributes/PostType/Supports.php b/src/Attributes/PostType/Supports.php deleted file mode 100755 index 5ed03d98..00000000 --- a/src/Attributes/PostType/Supports.php +++ /dev/null @@ -1,38 +0,0 @@ - $features Array of features the post type supports - */ - public function __construct( - public readonly array $features = ['title', 'editor'] - ) {} - - /** - * Configure the post type with the supports parameter. - * - * @param PostTypeAttributeInterface $postType The post type to configure - */ - protected function configure(PostTypeAttributeInterface $postType): void - { - $postType->setArg('supports', $this->features); - } -} diff --git a/src/Attributes/PostType/Taxonomies.php b/src/Attributes/PostType/Taxonomies.php deleted file mode 100755 index ec1fe97a..00000000 --- a/src/Attributes/PostType/Taxonomies.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('taxonomies', $this->value); - } -} diff --git a/src/Attributes/PostType/Template.php b/src/Attributes/PostType/Template.php deleted file mode 100755 index a4582bb8..00000000 --- a/src/Attributes/PostType/Template.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('template', $this->value); - } -} diff --git a/src/Attributes/PostType/TemplateLock.php b/src/Attributes/PostType/TemplateLock.php deleted file mode 100755 index a06cb614..00000000 --- a/src/Attributes/PostType/TemplateLock.php +++ /dev/null @@ -1,41 +0,0 @@ -setArg('template_lock', $this->value); - } -} diff --git a/src/Attributes/PostType/TitlePlaceholder.php b/src/Attributes/PostType/TitlePlaceholder.php deleted file mode 100755 index 26418f29..00000000 --- a/src/Attributes/PostType/TitlePlaceholder.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('title_placeholder', $this->value); - } -} diff --git a/src/Attributes/Role.php b/src/Attributes/Role.php deleted file mode 100644 index f4909c2e..00000000 --- a/src/Attributes/Role.php +++ /dev/null @@ -1,42 +0,0 @@ - - */ - public array $capabilities; - - public function __construct(string|BackedEnum ...$capabilities) - { - $this->capabilities = array_values($capabilities); - } -} diff --git a/src/Attributes/Role/GrantsPostType.php b/src/Attributes/Role/GrantsPostType.php deleted file mode 100644 index b16c96da..00000000 --- a/src/Attributes/Role/GrantsPostType.php +++ /dev/null @@ -1,27 +0,0 @@ - - */ - public array $capabilities; - - public function __construct(string|BackedEnum ...$capabilities) - { - $this->capabilities = array_values($capabilities); - } -} diff --git a/src/Attributes/Schedule.php b/src/Attributes/Schedule.php deleted file mode 100755 index fc72a85d..00000000 --- a/src/Attributes/Schedule.php +++ /dev/null @@ -1,46 +0,0 @@ -|Every|Interval $recurrence The schedule recurrence definition - * - string: WordPress built-in schedule ('hourly', 'daily', 'weekly', 'twicedaily') - * - array: Custom schedule with 'interval' (seconds) and 'display' (name) keys - * - Every: Enum for predefined intervals (Every::HOUR, Every::DAY, etc.) - * - Interval: Custom interval instance with precise timing control - * @param string|null $hook Optional custom hook name. If null, generated from class and method names - * @param array $args Optional arguments to pass to the scheduled method - */ - public function __construct( - public readonly string|array|Every|Interval $recurrence, - public readonly ?string $hook = null, - public readonly array $args = [] - ) { - // No validation here - will be handled by ScheduleDiscovery - } -} diff --git a/src/Attributes/SkipDiscovery.php b/src/Attributes/SkipDiscovery.php deleted file mode 100644 index fcb5704f..00000000 --- a/src/Attributes/SkipDiscovery.php +++ /dev/null @@ -1,31 +0,0 @@ - $except Discovery classes that should still process this class - */ -#[Attribute(Attribute::TARGET_CLASS)] -final readonly class SkipDiscovery -{ - /** - * @param array $except Discovery classes that should still process this class - */ - public function __construct(public array $except = []) {} -} diff --git a/src/Attributes/Taxonomy.php b/src/Attributes/Taxonomy.php deleted file mode 100644 index c301d052..00000000 --- a/src/Attributes/Taxonomy.php +++ /dev/null @@ -1,66 +0,0 @@ -|string|null $objectType The post types this taxonomy applies to. If null, defaults to ['post']. - */ - public function __construct( - public ?string $slug = null, - public ?string $singular = null, - public ?string $plural = null, - public array|string|null $objectType = null, - public ?string $textDomain = null - ) {} - - /** - * The slug WordPress registers the taxonomy under. - * - * The explicit slug when the attribute has one; otherwise the class name in - * kebab-case, cut to 20 characters. Discovery registers with it and - * `pollora:status` reports it, so the two cannot disagree. - * - * @param string $className The class carrying the attribute - */ - public function resolveSlug(string $className): string - { - return $this->slug ?? substr(Str::kebab(class_basename($className)), 0, 20); - } -} diff --git a/src/Attributes/Taxonomy/AllowHierarchy.php b/src/Attributes/Taxonomy/AllowHierarchy.php deleted file mode 100755 index d9eff1ea..00000000 --- a/src/Attributes/Taxonomy/AllowHierarchy.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('allow_hierarchy', $this->value); - } -} diff --git a/src/Attributes/Taxonomy/Args.php b/src/Attributes/Taxonomy/Args.php deleted file mode 100755 index fc4889b2..00000000 --- a/src/Attributes/Taxonomy/Args.php +++ /dev/null @@ -1,36 +0,0 @@ -setArg('args', $this->value); - } -} diff --git a/src/Attributes/Taxonomy/Capabilities.php b/src/Attributes/Taxonomy/Capabilities.php deleted file mode 100644 index 36d87d9a..00000000 --- a/src/Attributes/Taxonomy/Capabilities.php +++ /dev/null @@ -1,45 +0,0 @@ -setArg('capabilities', $this->value); - } -} diff --git a/src/Attributes/Taxonomy/CheckedOntop.php b/src/Attributes/Taxonomy/CheckedOntop.php deleted file mode 100755 index bb8b092c..00000000 --- a/src/Attributes/Taxonomy/CheckedOntop.php +++ /dev/null @@ -1,37 +0,0 @@ -setArg('checked_ontop', $this->value); - } -} diff --git a/src/Attributes/Taxonomy/DefaultTerm.php b/src/Attributes/Taxonomy/DefaultTerm.php deleted file mode 100755 index a6fa0e3a..00000000 --- a/src/Attributes/Taxonomy/DefaultTerm.php +++ /dev/null @@ -1,36 +0,0 @@ -setArg('default_term', $this->value); - } -} diff --git a/src/Attributes/Taxonomy/Description.php b/src/Attributes/Taxonomy/Description.php deleted file mode 100755 index 6a9d91ca..00000000 --- a/src/Attributes/Taxonomy/Description.php +++ /dev/null @@ -1,36 +0,0 @@ -setArg('description', $this->value); - } -} diff --git a/src/Attributes/Taxonomy/Exclusive.php b/src/Attributes/Taxonomy/Exclusive.php deleted file mode 100755 index 5a14a75e..00000000 --- a/src/Attributes/Taxonomy/Exclusive.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('exclusive', $this->value); - } -} diff --git a/src/Attributes/Taxonomy/Hierarchical.php b/src/Attributes/Taxonomy/Hierarchical.php deleted file mode 100755 index 96b633e4..00000000 --- a/src/Attributes/Taxonomy/Hierarchical.php +++ /dev/null @@ -1,36 +0,0 @@ -setArg('hierarchical', $this->value); - } -} diff --git a/src/Attributes/Taxonomy/Label.php b/src/Attributes/Taxonomy/Label.php deleted file mode 100755 index 18d11a03..00000000 --- a/src/Attributes/Taxonomy/Label.php +++ /dev/null @@ -1,36 +0,0 @@ -setArg('label', $this->value); - } -} diff --git a/src/Attributes/Taxonomy/Labels.php b/src/Attributes/Taxonomy/Labels.php deleted file mode 100755 index 0f9173dc..00000000 --- a/src/Attributes/Taxonomy/Labels.php +++ /dev/null @@ -1,87 +0,0 @@ - */ - private readonly array $labels; - - public function __construct( - ?string $name = null, - ?string $singularName = null, - ?string $menuName = null, - ?string $allItems = null, - ?string $editItem = null, - ?string $viewItem = null, - ?string $updateItem = null, - ?string $addNewItem = null, - ?string $newItemName = null, - ?string $searchItems = null, - ?string $popularItems = null, - ?string $separateItemsWithCommas = null, - ?string $addOrRemoveItems = null, - ?string $chooseFromMostUsed = null, - ?string $notFound = null, - ?string $parentItem = null, - ?string $parentItemColon = null, - ?string $noTerms = null, - ?string $filterByItem = null, - ?string $itemsListNavigation = null, - ?string $itemsList = null, - ?string $backToItems = null, - ) { - $this->labels = array_filter([ - 'name' => $name, - 'singular_name' => $singularName, - 'menu_name' => $menuName, - 'all_items' => $allItems, - 'edit_item' => $editItem, - 'view_item' => $viewItem, - 'update_item' => $updateItem, - 'add_new_item' => $addNewItem, - 'new_item_name' => $newItemName, - 'search_items' => $searchItems, - 'popular_items' => $popularItems, - 'separate_items_with_commas' => $separateItemsWithCommas, - 'add_or_remove_items' => $addOrRemoveItems, - 'choose_from_most_used' => $chooseFromMostUsed, - 'not_found' => $notFound, - 'parent_item' => $parentItem, - 'parent_item_colon' => $parentItemColon, - 'no_terms' => $noTerms, - 'filter_by_item' => $filterByItem, - 'items_list_navigation' => $itemsListNavigation, - 'items_list' => $itemsList, - 'back_to_items' => $backToItems, - ], fn (?string $v): bool => $v !== null); - } - - protected function configure(TaxonomyAttributeInterface $taxonomy): void - { - $existing = $taxonomy->getArg('labels', []); - $taxonomy->setArg('labels', array_merge($existing, $this->labels)); - } -} diff --git a/src/Attributes/Taxonomy/MetaBoxCb.php b/src/Attributes/Taxonomy/MetaBoxCb.php deleted file mode 100755 index 5eb310bd..00000000 --- a/src/Attributes/Taxonomy/MetaBoxCb.php +++ /dev/null @@ -1,40 +0,0 @@ -setArg('meta_box_cb', [new $context->class, $context->getName()]); - } -} diff --git a/src/Attributes/Taxonomy/MetaBoxSanitizeCb.php b/src/Attributes/Taxonomy/MetaBoxSanitizeCb.php deleted file mode 100755 index e24dcdcf..00000000 --- a/src/Attributes/Taxonomy/MetaBoxSanitizeCb.php +++ /dev/null @@ -1,40 +0,0 @@ -setArg('meta_box_sanitize_cb', [new $context->class, $context->getName()]); - } -} diff --git a/src/Attributes/Taxonomy/ObjectType.php b/src/Attributes/Taxonomy/ObjectType.php deleted file mode 100755 index c39a90e9..00000000 --- a/src/Attributes/Taxonomy/ObjectType.php +++ /dev/null @@ -1,36 +0,0 @@ - $value The post type(s) this taxonomy is associated with - */ - public function __construct( - public readonly string|array $value - ) {} - - /** - * Configure the taxonomy with the object_type parameter. - * - * @param TaxonomyAttributeInterface $taxonomy The taxonomy to configure - */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void - { - $taxonomy->setArg('object_type', $this->value); - } -} diff --git a/src/Attributes/Taxonomy/Priority.php b/src/Attributes/Taxonomy/Priority.php deleted file mode 100755 index fc9e3d44..00000000 --- a/src/Attributes/Taxonomy/Priority.php +++ /dev/null @@ -1,34 +0,0 @@ -setArg('priority', $this->priority); - } -} diff --git a/src/Attributes/Taxonomy/PublicTaxonomy.php b/src/Attributes/Taxonomy/PublicTaxonomy.php deleted file mode 100755 index b8205a15..00000000 --- a/src/Attributes/Taxonomy/PublicTaxonomy.php +++ /dev/null @@ -1,39 +0,0 @@ -setArg('public', $this->value); - } -} diff --git a/src/Attributes/Taxonomy/QueryVar.php b/src/Attributes/Taxonomy/QueryVar.php deleted file mode 100755 index 61e3a168..00000000 --- a/src/Attributes/Taxonomy/QueryVar.php +++ /dev/null @@ -1,36 +0,0 @@ -setArg('query_var', $this->value); - } -} diff --git a/src/Attributes/Taxonomy/RestBase.php b/src/Attributes/Taxonomy/RestBase.php deleted file mode 100755 index 9eb7c2ab..00000000 --- a/src/Attributes/Taxonomy/RestBase.php +++ /dev/null @@ -1,36 +0,0 @@ -setArg('rest_base', $this->value); - } -} diff --git a/src/Attributes/Taxonomy/RestControllerClass.php b/src/Attributes/Taxonomy/RestControllerClass.php deleted file mode 100755 index e54a48c7..00000000 --- a/src/Attributes/Taxonomy/RestControllerClass.php +++ /dev/null @@ -1,36 +0,0 @@ -setArg('rest_controller_class', $this->value); - } -} diff --git a/src/Attributes/Taxonomy/RestNamespace.php b/src/Attributes/Taxonomy/RestNamespace.php deleted file mode 100755 index c2a28acd..00000000 --- a/src/Attributes/Taxonomy/RestNamespace.php +++ /dev/null @@ -1,36 +0,0 @@ -setArg('rest_namespace', $this->value); - } -} diff --git a/src/Attributes/Taxonomy/Rewrite.php b/src/Attributes/Taxonomy/Rewrite.php deleted file mode 100755 index ebd639d1..00000000 --- a/src/Attributes/Taxonomy/Rewrite.php +++ /dev/null @@ -1,36 +0,0 @@ -setArg('rewrite', $this->value); - } -} diff --git a/src/Attributes/Taxonomy/ShowAdminColumn.php b/src/Attributes/Taxonomy/ShowAdminColumn.php deleted file mode 100755 index eef42295..00000000 --- a/src/Attributes/Taxonomy/ShowAdminColumn.php +++ /dev/null @@ -1,36 +0,0 @@ -setArg('show_admin_column', $this->value); - } -} diff --git a/src/Attributes/Taxonomy/ShowInMenu.php b/src/Attributes/Taxonomy/ShowInMenu.php deleted file mode 100755 index 249546f6..00000000 --- a/src/Attributes/Taxonomy/ShowInMenu.php +++ /dev/null @@ -1,41 +0,0 @@ -setArg('show_in_menu', $this->value); - } -} diff --git a/src/Attributes/Taxonomy/ShowInNavMenus.php b/src/Attributes/Taxonomy/ShowInNavMenus.php deleted file mode 100755 index 9bf014cc..00000000 --- a/src/Attributes/Taxonomy/ShowInNavMenus.php +++ /dev/null @@ -1,36 +0,0 @@ -setArg('show_in_nav_menus', $this->value); - } -} diff --git a/src/Attributes/Taxonomy/ShowInQuickEdit.php b/src/Attributes/Taxonomy/ShowInQuickEdit.php deleted file mode 100755 index 8105d502..00000000 --- a/src/Attributes/Taxonomy/ShowInQuickEdit.php +++ /dev/null @@ -1,36 +0,0 @@ -setArg('show_in_quick_edit', $this->value); - } -} diff --git a/src/Attributes/Taxonomy/ShowInRest.php b/src/Attributes/Taxonomy/ShowInRest.php deleted file mode 100755 index ed6fe08d..00000000 --- a/src/Attributes/Taxonomy/ShowInRest.php +++ /dev/null @@ -1,36 +0,0 @@ -setArg('show_in_rest', $this->value); - } -} diff --git a/src/Attributes/Taxonomy/ShowTagcloud.php b/src/Attributes/Taxonomy/ShowTagcloud.php deleted file mode 100755 index 603d7d5a..00000000 --- a/src/Attributes/Taxonomy/ShowTagcloud.php +++ /dev/null @@ -1,36 +0,0 @@ -setArg('show_tagcloud', $this->value); - } -} diff --git a/src/Attributes/Taxonomy/ShowUI.php b/src/Attributes/Taxonomy/ShowUI.php deleted file mode 100755 index 6c0e346d..00000000 --- a/src/Attributes/Taxonomy/ShowUI.php +++ /dev/null @@ -1,36 +0,0 @@ -setArg('show_ui', $this->value); - } -} diff --git a/src/Attributes/Taxonomy/Sort.php b/src/Attributes/Taxonomy/Sort.php deleted file mode 100755 index f9902ada..00000000 --- a/src/Attributes/Taxonomy/Sort.php +++ /dev/null @@ -1,36 +0,0 @@ -setArg('sort', $this->value); - } -} diff --git a/src/Attributes/Taxonomy/TaxonomyAttribute.php b/src/Attributes/Taxonomy/TaxonomyAttribute.php index 292210a3..9ca188dc 100755 --- a/src/Attributes/Taxonomy/TaxonomyAttribute.php +++ b/src/Attributes/Taxonomy/TaxonomyAttribute.php @@ -5,6 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Pollora\Attributes\Attributable; +use Pollora\Attributes\Contracts\ConfiguresArguments; use Pollora\Attributes\Contracts\HandlesAttributes; use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; use ReflectionClass; @@ -16,7 +17,9 @@ * This attribute serves as the base for all taxonomy configuration attributes. * It provides common functionality for handling taxonomy registration arguments. * - * @Attribute + * @deprecated since 13.36, removed in 15.0. Implement + * {@see ConfiguresArguments}, as the + * built-in attributes now do. */ abstract class TaxonomyAttribute implements HandlesAttributes { diff --git a/src/Attributes/Taxonomy/UpdateCountCallback.php b/src/Attributes/Taxonomy/UpdateCountCallback.php deleted file mode 100755 index 218a2465..00000000 --- a/src/Attributes/Taxonomy/UpdateCountCallback.php +++ /dev/null @@ -1,40 +0,0 @@ -setArg('update_count_callback', [new $context->class, $context->getName()]); - } -} diff --git a/src/Attributes/TermMeta.php b/src/Attributes/TermMeta.php deleted file mode 100644 index 8bdc3d09..00000000 --- a/src/Attributes/TermMeta.php +++ /dev/null @@ -1,37 +0,0 @@ - */ - public array $taxonomies; - - /** - * @param string|list $taxonomies The taxonomy slugs the meta belong to - */ - public function __construct(string|array $taxonomies) - { - $this->taxonomies = array_values((array) $taxonomies); - } -} diff --git a/src/Attributes/UserMeta.php b/src/Attributes/UserMeta.php deleted file mode 100644 index 25a70858..00000000 --- a/src/Attributes/UserMeta.php +++ /dev/null @@ -1,25 +0,0 @@ -newsletterOptIn; - * - * @experimental The API may still change before it is declared stable. - */ -#[Attribute(Attribute::TARGET_CLASS)] -final readonly class UserMeta {} diff --git a/src/Attributes/WpCli.php b/src/Attributes/WpCli.php deleted file mode 100644 index 7248b637..00000000 --- a/src/Attributes/WpCli.php +++ /dev/null @@ -1,44 +0,0 @@ -commandName ?? Slug::fromClassName($className); - } -} diff --git a/src/Attributes/WpCli/AfterInvoke.php b/src/Attributes/WpCli/AfterInvoke.php deleted file mode 100644 index aaa226fa..00000000 --- a/src/Attributes/WpCli/AfterInvoke.php +++ /dev/null @@ -1,24 +0,0 @@ -commandName ?? Slug::fromMethodName($methodName); - } -} diff --git a/src/Attributes/WpCli/IsDeferred.php b/src/Attributes/WpCli/IsDeferred.php deleted file mode 100644 index bcaeaea6..00000000 --- a/src/Attributes/WpCli/IsDeferred.php +++ /dev/null @@ -1,25 +0,0 @@ -|Permission|null $permissionCallback The permission for the route: a Permission class, or an instance such as `new Can('edit_posts')`. - */ - public function __construct( - public readonly string $namespace, - public readonly string $route, - public readonly string|Permission|null $permissionCallback = null - ) {} - - /** - * Handle the attribute processing. - * - * @param object $serviceLocator Service locator used to resolve dependencies - * @param object $instance The instance to which the attribute applies - * @param ReflectionClass|ReflectionMethod $context The reflection context. - * @param object $attribute The attribute instance containing provided arguments. - */ - public function handle($serviceLocator, object $instance, ReflectionClass|ReflectionMethod $context, object $attribute): void - { - $instance->namespace = $attribute->namespace; - $instance->route = $attribute->route; - $instance->classPermission = $attribute->permissionCallback; - } -} diff --git a/src/Attributes/WpRestRoute/Method.php b/src/Attributes/WpRestRoute/Method.php deleted file mode 100755 index a43417bb..00000000 --- a/src/Attributes/WpRestRoute/Method.php +++ /dev/null @@ -1,183 +0,0 @@ -|Permission|null $permissionCallback The permission for this method, replacing the route's: a Permission class, or an instance such as `new Can('edit_posts')`. - * - * @throws InvalidArgumentException If an invalid HTTP method is provided. - */ - public function __construct( - public array|string $methods, - public string|Permission|null $permissionCallback = null - ) { - $this->methods = is_array($methods) ? $methods : [$methods]; - $this->validateMethods(); - } - - /** - * Validates the provided HTTP methods. - * - * @throws InvalidArgumentException If an invalid HTTP method is found. - */ - private function validateMethods(): void - { - foreach ($this->methods as $method) { - if (! in_array(strtoupper((string) $method), self::ALLOWED_METHODS, true)) { - throw new InvalidArgumentException( - sprintf( - 'Invalid HTTP method "%s". Allowed methods are: %s', - $method, - implode(', ', self::ALLOWED_METHODS) - ) - ); - } - } - } - - /** - * Retrieves the list of HTTP methods. - * - * @return array The list of allowed HTTP methods. - */ - public function getMethods(): array - { - return $this->methods; - } - - /** - * Handles the registration of the REST route for the method. - * - * @param object $serviceLocator Service locator used to resolve dependencies - * @param Attributable $instance The class instance - * @param ReflectionMethod|ReflectionClass $context The reflection context - * @param object $attribute The attribute instance - */ - public function handle($serviceLocator, Attributable $instance, ReflectionMethod|ReflectionClass $context, object $attribute): void - { - $methodPermission = $attribute->permissionCallback; - $permissionCallback = $methodPermission ?? $instance->classPermission; - - register_rest_route( - $instance->namespace, - $instance->route, - [ - 'methods' => $attribute->getMethods(), - 'callback' => WpGlobals::wrap( - fn (WP_REST_Request $request): mixed => $this->handleRequest($instance, $context, $request) - ), - 'args' => $this->extractArgsFromRoute($instance->route), - 'permission_callback' => $this->resolvePermissionCallback($permissionCallback), - ] - ); - } - - /** - * Executes the class method associated with the REST request. - * - * @param Attributable $instance The class instance - * @param ReflectionMethod $method The method to invoke - * @param WP_REST_Request $request The REST request instance - * @return mixed The result of the method invocation - */ - private function handleRequest(Attributable $instance, ReflectionMethod $method, WP_REST_Request $request): mixed - { - $args = []; - - foreach ($method->getParameters() as $param) { - // A WP_REST_Request type hint gets the request itself. Looking its - // name up among the route parameters found nothing — no route - // declares one called "request" — so the handler was invoked with - // null and a controller declaring the type fataled before running. - if ($this->expectsTheRequest($param)) { - $args[] = $request; - - continue; - } - - $args[] = $request->get_param($param->getName()); - } - - // Get the real instance if available, otherwise use the provided instance - $realInstance = method_exists($instance, 'getRealInstance') ? $instance->getRealInstance() : $instance; - - return $method->invoke($realInstance, ...$args); - } - - /** - * Whether a parameter asks for the request object rather than a route value. - */ - private function expectsTheRequest(\ReflectionParameter $param): bool - { - $type = $param->getType(); - - if (! $type instanceof \ReflectionNamedType || $type->isBuiltin()) { - return false; - } - - $name = $type->getName(); - - // The name comparison stands on its own: ::class resolves whether or - // not WordPress is loaded, so this holds outside a WordPress runtime. - return $name === WP_REST_Request::class - || is_a($name, WP_REST_Request::class, true); - } - - /** - * Extracts dynamic arguments from a WordPress route. - * - * @param string $route The route pattern - * @return array The extracted arguments - */ - private function extractArgsFromRoute(string $route): array - { - preg_match_all('/\(\?P<(\w+)>/', $route, $matches); - - return array_fill_keys($matches[1], [ - 'validate_callback' => fn ($param): bool => is_string($param) || is_numeric($param), - ]); - } - - /** - * Resolves and executes the permission callback. - * - * @param class-string|Permission|null $permissionCallback The permission class or instance to use - * @return callable The permission function - */ - private function resolvePermissionCallback(string|Permission|null $permissionCallback): callable - { - if ($permissionCallback === null) { - return '__return_true'; - } - - if (is_string($permissionCallback) && (! class_exists($permissionCallback) || ! is_subclass_of($permissionCallback, Permission::class))) { - return fn (): WP_Error => new WP_Error('rest_forbidden', __('Invalid permission handler.'), ['status' => 403]); - } - - return WpGlobals::wrap(function (WP_REST_Request $request) use ($permissionCallback): bool|WP_Error { - $permissionInstance = is_string($permissionCallback) ? new $permissionCallback : $permissionCallback; - - return $permissionInstance->allow($request); - }); - } -} diff --git a/src/Attributes/WpRestRoute/Permission.php b/src/Attributes/WpRestRoute/Permission.php deleted file mode 100755 index f535a20a..00000000 --- a/src/Attributes/WpRestRoute/Permission.php +++ /dev/null @@ -1,20 +0,0 @@ -engine->addLocation($location); - - return $this; - } - - /** - * Add multiple discovery locations - * - * @param array $paths Array of filesystem paths - * @return static Returns self for method chaining - */ - public function addLocations(array $paths): static - { - foreach ($paths as $path) { - $this->addLocation($path); - } - - return $this; - } - - /** - * Register a discovery class - * - * @param string $identifier Unique identifier for the discovery - * @param string|DiscoveryInterface $discovery Discovery class name or instance - * @return static Returns self for method chaining - */ - public function addDiscovery(string $identifier, string|DiscoveryInterface $discovery): static - { - $this->engine->addDiscovery($identifier, $discovery); - - return $this; - } - - /** - * Register multiple discovery classes - * - * @param array $discoveries Map of identifier to discovery - * @return static Returns self for method chaining - */ - public function addDiscoveries(array $discoveries): static - { - $this->engine->addDiscoveries($discoveries); - - return $this; - } - - /** - * Execute discovery and apply all discoveries - * - * This is the main method that runs the entire discovery process. - * - * @return static Returns self for method chaining - */ - public function run(): static - { - $this->engine->run(); - - return $this; - } - - /** - * Execute only the discovery phase - * - * Useful for testing or when you want to inspect discovered items - * before applying them. - * - * @return static Returns self for method chaining - */ - public function discover(): static - { - $this->engine->discover(); - - return $this; - } - - /** - * Execute only the application phase - * - * Applies previously discovered items. Should be called after discover(). - * - * @return static Returns self for method chaining - */ - public function apply(): static - { - $this->engine->apply(); - - return $this; - } - - /** - * Get a specific discovery by identifier - * - * @param string $identifier The discovery identifier - * @return DiscoveryInterface The discovery instance - */ - public function getDiscovery(string $identifier): DiscoveryInterface - { - return $this->engine->getDiscovery($identifier); - } - - /** - * Get all registered discoveries - * - * @return Collection Collection of discoveries keyed by identifier - */ - public function getDiscoveries(): Collection - { - return $this->engine->getDiscoveries(); - } - - /** - * Get all discovery locations - * - * @return Collection Collection of discovery locations - */ - public function getLocations(): Collection - { - return $this->engine->getLocations(); - } - - /** - * Clear persistent discovery cache - * - * Only clears persistent cache (Spatie structure discoverer cache). - * In-memory caches are automatically cleared at process end. - * - * @return static Returns self for method chaining - */ - public function clearCache(): static - { - $this->engine->clearCache(); - - return $this; - } - - /** - * Check if a discovery is registered - * - * @param string $identifier The discovery identifier to check - * @return bool True if the discovery is registered, false otherwise - */ - public function hasDiscovery(string $identifier): bool - { - try { - $this->getDiscovery($identifier); - - return true; - } catch (\Throwable) { - return false; - } - } - - /** - * Get discovered items for a specific discovery - * - * @param string $identifier The discovery identifier - * @return array The discovered items - */ - public function getDiscoveredItems(string $identifier): array - { - $discovery = $this->getDiscovery($identifier); - - return $discovery->getItems()->all(); - } - - /** - * Run a specific discovery type on a specific location - * - * @param string $identifier The discovery identifier - * @param DiscoveryLocationInterface $location The location to discover in - * @return static Returns self for method chaining - */ - public function runSpecificDiscovery(string $identifier, DiscoveryLocationInterface $location): static - { - if (! $this->hasDiscovery($identifier)) { - return $this; - } - - // Create a temporary engine with just this discovery and location - $tempEngine = clone $this->engine; - $tempEngine->clearLocations(); - $tempEngine->addLocation($location); - - // Run only this specific discovery - $discovery = $this->getDiscovery($identifier); - $tempEngine->runDiscovery($identifier, $discovery); - - return $this; - } - - /** - * Discover all structure types in a location - * - * @param DiscoveryLocationInterface $location The location to discover in - * @return array Results grouped by discovery type - */ - public function discoverAllInLocation(DiscoveryLocationInterface $location): array - { - $results = []; - - // Create a temporary engine for this location - $tempEngine = clone $this->engine; - $tempEngine->clearLocations(); - $tempEngine->addLocation($location); - - // Run discovery and collect results by type - $tempEngine->discover(); - - foreach ($this->getDiscoveries() as $identifier => $discovery) { - $items = $discovery->getItems()->all(); - if (! empty($items)) { - $results[$identifier] = $items; - } - } - - return $results; - } - - /** - * Get the underlying discovery engine - * - * @return DiscoveryEngineInterface The discovery engine instance - */ - public function getEngine(): DiscoveryEngineInterface - { - return $this->engine; - } - - /** - * Get performance statistics from the optimized engine - * - * @return array Performance statistics or empty array if not available - */ - public function getPerformanceStats(): array - { - if (method_exists($this->engine, 'getPerformanceStats')) { - return $this->engine->getPerformanceStats(); - } - - return []; - } - - /** - * Get debugging information about the discovery state - * - * @return array Debug information - */ - public function getDebugInfo(): array - { - $debugInfo = [ - 'discoveries_count' => $this->getDiscoveries()->count(), - 'locations_count' => $this->getLocations()->count(), - 'discoveries' => [], - 'locations' => [], - ]; - - // Add discovery details - foreach ($this->getDiscoveries() as $identifier => $discovery) { - $items = $this->getDiscoveredItems($identifier); - $debugInfo['discoveries'][$identifier] = [ - 'class' => $discovery::class, - 'items_count' => count($items), - 'items' => array_slice($items, 0, 3), // Show first 3 items for debugging - ]; - } - - // Add location details - foreach ($this->getLocations() as $location) { - $debugInfo['locations'][] = [ - 'path' => $location->getPath(), - 'exists' => is_dir($location->getPath()), - ]; - } - - return $debugInfo; - } -} diff --git a/src/Discovery/Domain/Contracts/DiscoversPathInterface.php b/src/Discovery/Domain/Contracts/DiscoversPathInterface.php deleted file mode 100644 index b1bccd32..00000000 --- a/src/Discovery/Domain/Contracts/DiscoversPathInterface.php +++ /dev/null @@ -1,30 +0,0 @@ - $locations The discovery locations - * @return string The generated cache key - */ - public function generateKey(string $discoveryIdentifier, array $locations): string; -} diff --git a/src/Discovery/Domain/Contracts/DiscoveryEngineInterface.php b/src/Discovery/Domain/Contracts/DiscoveryEngineInterface.php deleted file mode 100644 index 4b875649..00000000 --- a/src/Discovery/Domain/Contracts/DiscoveryEngineInterface.php +++ /dev/null @@ -1,139 +0,0 @@ - $locations The locations to add - * @return static Returns self for method chaining - */ - public function addLocations(array $locations): static; - - /** - * Register a discovery class - * - * @param string $identifier Unique identifier for the discovery - * @param string|DiscoveryInterface $discovery Discovery class name or instance - * @return static Returns self for method chaining - */ - public function addDiscovery(string $identifier, string|DiscoveryInterface $discovery): static; - - /** - * Register multiple discovery classes - * - * @param array $discoveries Map of identifier to discovery - * @return static Returns self for method chaining - */ - public function addDiscoveries(array $discoveries): static; - - /** - * Execute discovery process for all registered discoveries - * - * Runs the discovery phase for all registered discovery classes - * across all configured locations. - * - * @return static Returns self for method chaining - * - * @throws DiscoveryException When discovery fails - */ - public function discover(): static; - - /** - * Apply all discoveries - * - * Applies all discovered items by calling the apply() method - * on all registered discovery classes. - * - * @return static Returns self for method chaining - * - * @throws DiscoveryException When application fails - */ - public function apply(): static; - - /** - * Execute discovery and apply in one operation - * - * Convenience method that runs both discover() and apply(). - * - * @return static Returns self for method chaining - * - * @throws DiscoveryException When discovery or application fails - */ - public function run(): static; - - /** - * Get a specific discovery by identifier - * - * @param string $identifier The discovery identifier - * @return DiscoveryInterface The discovery instance - * - * @throws DiscoveryNotFoundException When discovery not found - */ - public function getDiscovery(string $identifier): DiscoveryInterface; - - /** - * Get all registered discoveries - * - * @return Collection Collection of discoveries keyed by identifier - */ - public function getDiscoveries(): Collection; - - /** - * Get all discovery locations - * - * @return Collection Collection of discovery locations - */ - public function getLocations(): Collection; - - /** - * Clear persistent discovery cache. - * - * @return static Returns self for method chaining - */ - public function clearCache(): static; - - /** - * Clear all registered discovery locations. - * - * @return static Returns self for method chaining - */ - public function clearLocations(): static; - - /** - * Run a specific discovery by identifier. - * - * @param string $identifier The discovery identifier - * @param DiscoveryInterface $discovery The discovery instance to run - * @return static Returns self for method chaining - */ - public function runDiscovery(string $identifier, DiscoveryInterface $discovery): static; -} diff --git a/src/Discovery/Domain/Contracts/DiscoveryInterface.php b/src/Discovery/Domain/Contracts/DiscoveryInterface.php deleted file mode 100644 index 041d3b18..00000000 --- a/src/Discovery/Domain/Contracts/DiscoveryInterface.php +++ /dev/null @@ -1,73 +0,0 @@ - - */ -interface DiscoveryItemsInterface extends Countable, IteratorAggregate -{ - /** - * Add multiple values for a specific location - * - * @param DiscoveryLocationInterface $location The discovery location - * @param array $values The values to add - * @return static Returns self for method chaining - */ - public function addForLocation(DiscoveryLocationInterface $location, array $values): static; - - /** - * Get all values for a specific location - * - * @param DiscoveryLocationInterface $location The discovery location - * @return array The values for the location - */ - public function getForLocation(DiscoveryLocationInterface $location): array; - - /** - * Add a single value for a specific location - * - * @param DiscoveryLocationInterface $location The discovery location - * @param mixed $value The value to add - * @return static Returns self for method chaining - */ - public function add(DiscoveryLocationInterface $location, mixed $value): static; - - /** - * Check if a location has any discovered items - * - * @param DiscoveryLocationInterface $location The discovery location - * @return bool True if the location has items, false otherwise - */ - public function hasLocation(DiscoveryLocationInterface $location): bool; - - /** - * Check if any items have been loaded - * - * @return bool True if items are loaded, false if empty - */ - public function isLoaded(): bool; - - /** - * Filter to only include vendor locations - * - * Creates a new instance containing only items from vendor directories. - * - * @return static A new instance with only vendor items - */ - public function onlyVendor(): static; - - /** - * Get all items flattened into a single array - * - * @return array All discovered items - */ - public function all(): array; - - /** - * Get the items not applied yet - * - * Creates a new instance holding, for each location, the items added - * since the last call to markApplied(). - * - * @return static A new instance with only the pending items - */ - public function pending(): static; - - /** - * Mark every current item as applied - * - * Items added afterwards are the next pending() ones. - */ - public function markApplied(): void; - - /** - * Serialize the discovery items for caching. - * - * @return array - */ - public function __serialize(): array; -} diff --git a/src/Discovery/Domain/Contracts/DiscoveryLocationInterface.php b/src/Discovery/Domain/Contracts/DiscoveryLocationInterface.php deleted file mode 100644 index 7fcb8745..00000000 --- a/src/Discovery/Domain/Contracts/DiscoveryLocationInterface.php +++ /dev/null @@ -1,36 +0,0 @@ - Array of methods with the specified attributes - */ - public function getMethodsWithAttributes(string $className, ?string $attributeClass = null): array; - - /** - * Get cached class-level attributes for a class. - * - * @param string $className The fully qualified class name - * @param string|null $attributeClass Optional filter by attribute class - * @return array<\ReflectionAttribute> Array of class attributes - */ - public function getClassAttributes(string $className, ?string $attributeClass = null): array; - - /** - * Get cached public methods for a class. - * - * Eliminates redundant getMethods(IS_PUBLIC) calls when multiple - * discoveries inspect the same class. - * - * @param string $className The fully qualified class name - * @return array Array of public methods - */ - public function getPublicMethods(string $className): array; - - /** - * Get cached class instance from the DI container. - * - * @param string $className The fully qualified class name - * @return object The cached class instance - * - * @throws \Throwable If the class cannot be instantiated - */ - public function getClassInstance(string $className): object; - - /** - * Check if a class has been processed by reflection cache. - * - * @param string $className The fully qualified class name - * @return bool True if the class is cached - */ - public function hasClass(string $className): bool; - - /** - * Preload multiple classes into the cache. - * - * @param array $classNames Array of class names to preload - */ - public function preloadClasses(array $classNames): void; -} diff --git a/src/Discovery/Domain/Exceptions/DiscoveryException.php b/src/Discovery/Domain/Exceptions/DiscoveryException.php deleted file mode 100644 index 55a1f519..00000000 --- a/src/Discovery/Domain/Exceptions/DiscoveryException.php +++ /dev/null @@ -1,44 +0,0 @@ -path; - } - - /** - * Check if the directory exists - * - * @return bool True if the directory exists and is readable - */ - public function exists(): bool - { - return is_dir($this->path) && is_readable($this->path); - } - - /** - * {@inheritDoc} - */ - public function getKey(): string - { - return md5($this->path); - } - - /** - * {@inheritDoc} - */ - public function isVendor(): bool - { - return str_contains($this->path, '/vendor/') || str_contains($this->path, '\\vendor\\'); - } - - /** - * Get a string representation of the location - * - * @return string The string representation of the location - */ - public function __toString(): string - { - return $this->path; - } -} diff --git a/src/Discovery/Domain/Models/DiscoveryContext.php b/src/Discovery/Domain/Models/DiscoveryContext.php deleted file mode 100644 index 4bab9a89..00000000 --- a/src/Discovery/Domain/Models/DiscoveryContext.php +++ /dev/null @@ -1,252 +0,0 @@ -> - */ - private array $processedClasses = []; - - /** - * Shared metadata between discoveries - * - * @var array> - */ - private array $sharedData = []; - - /** - * Current processing stats - * - * @var array - */ - private array $processingStats = [ - 'classes_processed' => 0, - 'discoveries_executed' => 0, - 'cache_hits' => 0, - 'cache_misses' => 0, - 'errors' => 0, - ]; - - /** - * Create a new discovery context - * - * @param ReflectionCacheInterface $reflectionCache The reflection cache service - */ - public function __construct( - private readonly ReflectionCacheInterface $reflectionCache - ) {} - - /** - * Mark a class as processed by a specific discovery type. - * - * @param string $className The fully qualified class name - * @param string $discoveryType The discovery type identifier - */ - public function markProcessed(string $className, string $discoveryType): void - { - $this->processedClasses[$className][$discoveryType] = true; - } - - /** - * Check if a class has been processed by a specific discovery type. - * - * @param string $className The fully qualified class name - * @param string $discoveryType The discovery type identifier - * @return bool True if the class has been processed - */ - public function isProcessed(string $className, string $discoveryType): bool - { - return $this->processedClasses[$className][$discoveryType] ?? false; - } - - /** - * Check if a class has been processed by any discovery type. - * - * @param string $className The fully qualified class name - * @return bool True if the class has been processed by any discovery - */ - public function isClassProcessed(string $className): bool - { - return isset($this->processedClasses[$className]) && - (isset($this->processedClasses[$className]) && $this->processedClasses[$className] !== []); - } - - /** - * Get the list of discovery types that have processed a class. - * - * @param string $className The fully qualified class name - * @return array Array of discovery type identifiers - */ - public function getProcessedDiscoveries(string $className): array - { - return array_keys($this->processedClasses[$className] ?? []); - } - - /** - * Store shared data for a class that can be accessed by other discoveries. - * - * @param string $className The fully qualified class name - * @param string $key The data key - * @param mixed $value The data value - */ - public function setSharedData(string $className, string $key, mixed $value): void - { - $this->sharedData[$className][$key] = $value; - } - - /** - * Get shared data for a class. - * - * @param string $className The fully qualified class name - * @param string|null $key Optional specific key to retrieve - * @return mixed The shared data value or array of all data - */ - public function getSharedData(string $className, ?string $key = null): mixed - { - if ($key === null) { - return $this->sharedData[$className] ?? []; - } - - return $this->sharedData[$className][$key] ?? null; - } - - /** - * Check if shared data exists for a class and key. - * - * @param string $className The fully qualified class name - * @param string $key The data key - * @return bool True if the data exists - */ - public function hasSharedData(string $className, string $key): bool - { - return isset($this->sharedData[$className][$key]); - } - - /** - * Get the reflection cache instance. - */ - public function getReflectionCache(): ReflectionCacheInterface - { - return $this->reflectionCache; - } - - /** - * Preload a batch of classes into the reflection cache. - * - * @param array $structures Array of discovered structures - */ - public function preloadStructures(array $structures): void - { - $classNames = []; - - foreach ($structures as $structure) { - if ($structure instanceof DiscoveredClass && - ! $structure->isAbstract) { - $classNames[] = $structure->namespace.'\\'.$structure->name; - } - } - - $this->reflectionCache->preloadClasses($classNames); - } - - /** - * Increment a processing statistic. - * - * @param string $stat The statistic name - * @param int $increment The increment value - */ - public function incrementStat(string $stat, int $increment = 1): void - { - $this->processingStats[$stat] = ($this->processingStats[$stat] ?? 0) + $increment; - } - - /** - * Get processing statistics. - * - * @return array Array of processing statistics - */ - public function getStats(): array - { - return $this->processingStats; - } - - /** - * Record a cache hit. - */ - public function recordCacheHit(): void - { - $this->incrementStat('cache_hits'); - } - - /** - * Record a cache miss. - */ - public function recordCacheMiss(): void - { - $this->incrementStat('cache_misses'); - } - - /** - * Record a processing error. - */ - public function recordError(): void - { - $this->incrementStat('errors'); - } - - /** - * Get cache efficiency as a percentage. - * - * @return float Cache hit ratio as percentage - */ - public function getCacheEfficiency(): float - { - $hits = $this->processingStats['cache_hits']; - $misses = $this->processingStats['cache_misses']; - $total = $hits + $misses; - - return $total > 0 ? round(($hits / $total) * 100, 2) : 0.0; - } - - /** - * Get a summary of the discovery context state. - * - * @return array Summary information - */ - public function getSummary(): array - { - $totalClasses = count($this->processedClasses); - $totalDiscoveries = array_sum(array_map(count(...), $this->processedClasses)); - - return [ - 'total_classes' => $totalClasses, - 'total_discovery_executions' => $totalDiscoveries, - 'cache_efficiency' => $this->getCacheEfficiency(), - 'shared_data_entries' => array_sum(array_map(count(...), $this->sharedData)), - 'stats' => $this->processingStats, - ]; - } -} diff --git a/src/Discovery/Domain/Models/DiscoveryItems.php b/src/Discovery/Domain/Models/DiscoveryItems.php deleted file mode 100644 index de3a2ff7..00000000 --- a/src/Discovery/Domain/Models/DiscoveryItems.php +++ /dev/null @@ -1,192 +0,0 @@ -> $items Initial items organized by location key - */ - public function __construct( - /** - * The discovered items organized by location key - */ - private array $items = [] - ) {} - - /** - * How many items of each location have been applied, by location key - * - * @var array - */ - private array $applied = []; - - /** - * {@inheritDoc} - */ - public function addForLocation(DiscoveryLocationInterface $location, array $values): static - { - $locationKey = $location->getKey(); - $existingValues = $this->items[$locationKey] ?? []; - - $this->items[$locationKey] = [...$existingValues, ...$values]; - - return $this; - } - - /** - * {@inheritDoc} - */ - public function getForLocation(DiscoveryLocationInterface $location): array - { - return $this->items[$location->getKey()] ?? []; - } - - /** - * {@inheritDoc} - */ - public function add(DiscoveryLocationInterface $location, mixed $value): static - { - $locationKey = $location->getKey(); - $this->items[$locationKey] ??= []; - $this->items[$locationKey][] = $value; - - return $this; - } - - /** - * {@inheritDoc} - */ - public function hasLocation(DiscoveryLocationInterface $location): bool - { - return array_key_exists($location->getKey(), $this->items); - } - - /** - * {@inheritDoc} - */ - public function isLoaded(): bool - { - return $this->items !== []; - } - - /** - * {@inheritDoc} - */ - public function onlyVendor(): static - { - $vendorItems = []; - - foreach ($this->items as $locationKey => $items) { - // We need to check if the location is vendor, but we only have the key - // This is a limitation - we'd need to store location objects or add vendor flag - // For now, we'll use a simple heuristic based on common vendor paths - if ($this->isVendorLocation()) { - $vendorItems[$locationKey] = $items; - } - } - - return new self($vendorItems); - } - - /** - * {@inheritDoc} - */ - public function all(): array - { - return $this->items === [] ? [] : array_merge(...array_values($this->items)); - } - - /** - * {@inheritDoc} - */ - public function pending(): static - { - $pending = []; - - foreach ($this->items as $locationKey => $items) { - $remaining = array_slice($items, $this->applied[$locationKey] ?? 0); - - if ($remaining !== []) { - $pending[$locationKey] = $remaining; - } - } - - return new self($pending); - } - - /** - * {@inheritDoc} - */ - public function markApplied(): void - { - $this->applied = array_map(count(...), $this->items); - } - - /** - * {@inheritDoc} - */ - public function getIterator(): Traversable - { - return new ArrayIterator($this->all()); - } - - /** - * {@inheritDoc} - */ - public function count(): int - { - return count($this->all()); - } - - /** - * Check if a location key represents a vendor location - * - * This is a heuristic approach since we only have the CRC32 key. - * In a real implementation, we might need to store more location metadata. - * - * @return bool True if likely a vendor location - */ - private function isVendorLocation(): bool - { - // This is a simplified heuristic - in practice, you might want to - // store additional metadata about locations or use a different approach - return false; - } - - /** - * Serialize the items for caching - * - * @return array> - */ - public function __serialize(): array - { - return $this->items; - } - - /** - * Unserialize the items from cache - * - * @param array> $data The serialized data - */ - public function __unserialize(array $data): void - { - $this->items = $data; - } -} diff --git a/src/Discovery/Domain/Models/DiscoveryLocation.php b/src/Discovery/Domain/Models/DiscoveryLocation.php deleted file mode 100644 index 4abb7107..00000000 --- a/src/Discovery/Domain/Models/DiscoveryLocation.php +++ /dev/null @@ -1,104 +0,0 @@ -path = $this->resolvePath($path); - } - - /** - * {@inheritDoc} - */ - public function getNamespace(): string - { - return $this->namespace; - } - - /** - * {@inheritDoc} - */ - public function getPath(): string - { - return $this->path; - } - - /** - * {@inheritDoc} - */ - public function getKey(): string - { - return (string) crc32($this->path); - } - - /** - * {@inheritDoc} - */ - public function isVendor(): bool - { - return str_contains($this->path, '/vendor/') || str_contains($this->path, '\\vendor\\'); - } - - /** - * {@inheritDoc} - */ - public function toClassName(string $filePath): string - { - // Ensure the file path is within this location - if (! str_starts_with($filePath, $this->path)) { - return ''; - } - - // Convert file path to class name - $relativePath = substr($filePath, strlen($this->path)); - $relativePath = ltrim($relativePath, '/\\'); - - // Remove .php extension - $relativePath = preg_replace('/\.php$/', '', $relativePath); - - // Convert path separators to namespace separators - $className = str_replace(['/', '\\'], '\\', $relativePath); - - // Combine with base namespace - return rtrim($this->namespace, '\\').'\\'.$className; - } - - /** - * Resolve the absolute path - * - * @param string $path The path to resolve - * @return string The resolved absolute path - */ - private function resolvePath(string $path): string - { - $resolved = realpath(rtrim($path, '\\/')); - - return $resolved !== false ? $resolved : rtrim($path, '\\/'); - } -} diff --git a/src/Discovery/Domain/Services/HasInstancePool.php b/src/Discovery/Domain/Services/HasInstancePool.php deleted file mode 100644 index 5c5e9b8d..00000000 --- a/src/Discovery/Domain/Services/HasInstancePool.php +++ /dev/null @@ -1,58 +0,0 @@ -instancePool = $instancePool; - } - - /** - * Get an instance from the pool if available, otherwise use fallback. - * - * @param string $className The class name to instantiate - * @param callable|null $fallback Optional fallback creation function - * @return object The class instance - */ - protected function getInstanceFromPool(string $className, ?callable $fallback = null): object - { - if ($this->instancePool !== null) { - return $this->instancePool->getInstance($className); - } - - if ($fallback !== null) { - return $fallback(); - } - - return resolve($className); - } - - /** - * Check if instance pool is available. - */ - protected function hasInstancePool(): bool - { - return $this->instancePool !== null; - } -} diff --git a/src/Discovery/Domain/Services/IsDiscovery.php b/src/Discovery/Domain/Services/IsDiscovery.php deleted file mode 100644 index 81c8359d..00000000 --- a/src/Discovery/Domain/Services/IsDiscovery.php +++ /dev/null @@ -1,54 +0,0 @@ -discoveryItems ??= new DiscoveryItems; - - return $this->discoveryItems; - } - - /** - * {@inheritDoc} - */ - public function setItems(DiscoveryItemsInterface $items): void - { - $this->discoveryItems = $items; - } - - /** - * {@inheritDoc} - */ - public function getIdentifier(): string - { - return static::class; - } -} diff --git a/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCache.php b/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCache.php index 4d12e9a7..a3767ced 100644 --- a/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCache.php +++ b/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCache.php @@ -15,6 +15,8 @@ * * Implementation of discovery cache using Laravel's cache system. * Provides persistent caching of discovery results to improve performance. + * + * @deprecated Removed in 15.0, with DiscoveryCacheInterface. */ final readonly class LaravelDiscoveryCache implements DiscoveryCacheInterface { diff --git a/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCacheManagerFactory.php b/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCacheManagerFactory.php new file mode 100644 index 00000000..3ce56e9a --- /dev/null +++ b/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCacheManagerFactory.php @@ -0,0 +1,73 @@ +debugDetector->isDebugMode(); + + return new DiscoveryCacheManager( + cacheDriver: $isDebugMode ? new NullDiscoverCacheDriver : $this->resolveCacheDriver(), + logger: $this->resolveLogger(), + warnOnSlowScans: $isDebugMode, + ); + } + + private function resolveCacheDriver(): DiscoverCacheDriver + { + /** @var array{driver?: class-string, store?: string|null} $cacheConfig */ + $cacheConfig = $this->container->bound('config') + ? (array) $this->container->make('config')->get('structure-discoverer.cache', []) + : []; + + $driverClass = $cacheConfig['driver'] ?? LaravelDiscoverCacheDriver::class; + $store = $cacheConfig['store'] ?? null; + + if ($driverClass === LaravelDiscoverCacheDriver::class) { + return new LaravelDiscoverCacheDriver(prefix: 'pollora', store: $store); + } + + try { + return $this->container->make($driverClass); + } catch (\Throwable) { + return new LaravelDiscoverCacheDriver(prefix: 'pollora', store: $store); + } + } + + private function resolveLogger(): ?LoggerInterface + { + try { + return $this->container->bound(LoggerInterface::class) + ? $this->container->make(LoggerInterface::class) + : null; + } catch (\Throwable) { + // A missing logger must not keep discovery from starting. + return null; + } + } +} diff --git a/src/Discovery/Infrastructure/Adapters/LaravelInstanceResolver.php b/src/Discovery/Infrastructure/Adapters/LaravelInstanceResolver.php new file mode 100644 index 00000000..bc5e00f6 --- /dev/null +++ b/src/Discovery/Infrastructure/Adapters/LaravelInstanceResolver.php @@ -0,0 +1,27 @@ +container->make($className); + } +} diff --git a/src/Discovery/Infrastructure/Providers/DiscoveryServiceProvider.php b/src/Discovery/Infrastructure/Providers/DiscoveryServiceProvider.php index c509d705..f11ab8f5 100644 --- a/src/Discovery/Infrastructure/Providers/DiscoveryServiceProvider.php +++ b/src/Discovery/Infrastructure/Providers/DiscoveryServiceProvider.php @@ -4,13 +4,19 @@ namespace Pollora\Discovery\Infrastructure\Providers; +use Illuminate\Container\Container; use Illuminate\Support\ServiceProvider; use Pollora\Application\Domain\Contracts\DebugDetectorInterface; use Pollora\Discovery\Application\Services\DiscoveryManager; use Pollora\Discovery\Domain\Contracts\DiscoveryEngineInterface; +use Pollora\Discovery\Domain\Contracts\InstanceResolverInterface; use Pollora\Discovery\Domain\Contracts\ReflectionCacheInterface; use Pollora\Discovery\Domain\Models\DirectoryLocation; +use Pollora\Discovery\Infrastructure\Adapters\LaravelDiscoveryCacheManagerFactory; +use Pollora\Discovery\Infrastructure\Adapters\LaravelInstanceResolver; +use Pollora\Discovery\Infrastructure\Services\DiscoveryCacheManager; use Pollora\Discovery\Infrastructure\Services\DiscoveryEngine; +use Pollora\Discovery\Infrastructure\Services\DiscoveryRegistrar; use Pollora\Discovery\Infrastructure\Services\InstancePool; use Pollora\Discovery\Infrastructure\Services\ReflectionCache; use Pollora\Discovery\Infrastructure\Services\ServiceProviderDiscovery; @@ -64,7 +70,9 @@ public function provides(): array DiscoveryEngineInterface::class, DiscoveryManager::class, ReflectionCacheInterface::class, + InstanceResolverInterface::class, InstancePool::class, + DiscoveryCacheManager::class, DiscoveryCommand::class, DiscoveryClearCommand::class, ]; @@ -75,11 +83,24 @@ public function provides(): array */ private function registerOptimizedServices(): void { + // Discovery builds classes through Laravel's container + $this->app->singleton(InstanceResolverInterface::class, fn (Container $app): LaravelInstanceResolver => new LaravelInstanceResolver($app)); + // Register reflection cache as singleton for maximum efficiency - $this->app->singleton(ReflectionCacheInterface::class, ReflectionCache::class); + $this->app->singleton(ReflectionCacheInterface::class, fn (Container $app): ReflectionCache => new ReflectionCache( + $app->make(InstanceResolverInterface::class) + )); // Register instance pool as singleton to maintain state across discoveries - $this->app->singleton(InstancePool::class); + $this->app->singleton(InstancePool::class, fn (Container $app): InstancePool => new InstancePool( + $app->make(InstanceResolverInterface::class) + )); + + // One cache manager, shared by the engine and the doctor's cache check + $this->app->singleton(DiscoveryCacheManager::class, fn (Container $app): DiscoveryCacheManager => (new LaravelDiscoveryCacheManagerFactory( + $app, + $app->make(DebugDetectorInterface::class) + ))->make()); // Register ServiceProviderDiscovery so the DiscoveryRegistrar can find it $this->app->singleton(ServiceProviderDiscovery::class); @@ -90,11 +111,12 @@ private function registerOptimizedServices(): void */ private function registerDiscoveryEngine(): void { - $this->app->singleton(DiscoveryEngineInterface::class, fn ($app): DiscoveryEngine => new DiscoveryEngine( - container: $app, - debugDetector: $app->make(DebugDetectorInterface::class), + $this->app->singleton(DiscoveryEngineInterface::class, fn (Container $app): DiscoveryEngine => new DiscoveryEngine( + resolver: $app->make(InstanceResolverInterface::class), reflectionCache: $app->make(ReflectionCacheInterface::class), instancePool: $app->make(InstancePool::class), + cacheManager: $app->make(DiscoveryCacheManager::class), + registrar: new DiscoveryRegistrar($app), skipClasses: (array) config('discovery.skip_classes', []), skipPaths: (array) config('discovery.skip_paths', []), )); @@ -105,7 +127,7 @@ private function registerDiscoveryEngine(): void */ private function registerDiscoveryManager(): void { - $this->app->singleton(DiscoveryManager::class, fn ($app): DiscoveryManager => new DiscoveryManager( + $this->app->singleton(DiscoveryManager::class, fn (Container $app): DiscoveryManager => new DiscoveryManager( engine: $app->make(DiscoveryEngineInterface::class) )); } diff --git a/src/Discovery/Infrastructure/Services/DiscoveryCacheManager.php b/src/Discovery/Infrastructure/Services/DiscoveryCacheManager.php deleted file mode 100644 index d1e98790..00000000 --- a/src/Discovery/Infrastructure/Services/DiscoveryCacheManager.php +++ /dev/null @@ -1,284 +0,0 @@ - - */ - private static array $structuresCache = []; - - /** - * Above this, one location's scan is reported rather than absorbed. - */ - private const int SLOW_SCAN_MS = 250; - - /** - * Resolved cache driver for Spatie's structure discovery. - */ - private readonly ?DiscoverCacheDriver $cacheDriver; - - /** - * Where each location's structures came from in this process, in order. - * - * @var list - */ - private array $scans = []; - - public function __construct( - private readonly Container $container, - private readonly DebugDetectorInterface $debugDetector - ) { - $this->cacheDriver = $this->resolveCacheDriver(); - } - - /** - * Get structures for a specific location, using static and Spatie cache. - * - * @param DiscoveryLocationInterface $location The discovery location - * @param DiscoveryContext $context Context for recording cache stats - * @return array The discovered structures - */ - public function getStructuresForLocation(DiscoveryLocationInterface $location, DiscoveryContext $context): array - { - $cacheId = $this->generateCacheId($location); - - if (isset(self::$structuresCache[$cacheId])) { - $context->recordCacheHit(); - $this->recordScan($location, 'memory', 0.0, count(self::$structuresCache[$cacheId])); - - return self::$structuresCache[$cacheId]; - } - - $context->recordCacheMiss(); - - // Asked before reading: once the discoverer has run, a cold cache has - // been filled and looks exactly like a warm one. - $fromCache = $this->shouldUseCache() && $this->cacheDriver->has($cacheId); - - $discover = $this->createSpatieDiscoverer($location, $cacheId); - - $startedAt = microtime(true); - $structures = $discover->get(); - $elapsed = (microtime(true) - $startedAt) * 1000; - - self::$structuresCache[$cacheId] = $structures; - $this->recordScan($location, $fromCache ? 'cache' : 'disk', $elapsed, count($structures)); - - $this->warnIfSlow($location, $elapsed, count($structures)); - - return $structures; - } - - /** - * Where each location's structures came from, and what it cost. - * - * `memory` is this process's own copy, `cache` the persistent cache, `disk` - * a real scan — the one that costs, and the only one in debug mode, where - * the persistent cache is off. The context's hit and miss counters only - * know about the first of the three. - * - * @return list - */ - public function scans(): array - { - return $this->scans; - } - - /** - * @param 'memory'|'cache'|'disk' $source - */ - private function recordScan(DiscoveryLocationInterface $location, string $source, float $milliseconds, int $structures): void - { - $this->scans[] = [ - 'path' => $location->getPath(), - 'source' => $source, - 'milliseconds' => round($milliseconds, 2), - 'structures' => $structures, - ]; - } - - /** - * Say so when one location costs more than it can possibly be worth. - * - * Discovery walks a directory in full, and Symfony's Finder enumerates - * before it filters by extension — so a `node_modules/` anywhere under a - * location is paid for on every request that misses the cache, which in - * debug mode is every request. Measured once: 69,741 files walked to reach - * three PHP files, 1,691 ms against 1 ms, and nothing anywhere said so. - * - * The threshold is deliberately far above a healthy scan — the same site's - * other four locations came in between 2 and 25 ms — so this stays quiet - * until something is actually wrong. Debug only: the cache carries the - * cost in production, and a log line per request is its own problem. - */ - private function warnIfSlow(DiscoveryLocationInterface $location, float $elapsed, int $structures): void - { - if ($elapsed < self::SLOW_SCAN_MS || ! $this->debugDetector->isDebugMode()) { - return; - } - - try { - $this->container->make(LoggerInterface::class)->warning(sprintf( - 'Pollora discovery scanned %s in %dms for %d structure(s). ' - .'A directory this slow usually holds node_modules, vendor or a build output; ' - .'discovery walks every file in it on each request while the cache is off.', - $location->getPath(), - (int) round($elapsed), - $structures - )); - } catch (\Throwable) { - // A missing logger must not turn a warning into a failure. - } - } - - /** - * Clear persistent Spatie cache for the given locations. - * - * @param iterable $locations - */ - public function clearCache(iterable $locations): void - { - if (! $this->cacheDriver instanceof DiscoverCacheDriver) { - return; - } - - foreach ($locations as $location) { - $cacheId = $this->generateCacheId($location); - $this->cacheDriver->forget($cacheId); - } - } - - /** - * Classes on disk that the cached entry for a location does not list. - * - * They stay invisible to discovery — no hook, no post type, no route — until - * the cache is cleared, and nothing says so. Null when the location has no - * cached entry, or the cache is off, since then discovery reads the disk. - * - * @return list|null - */ - public function classesMissingFromCache(DiscoveryLocationInterface $location): ?array - { - if (! $this->shouldUseCache()) { - return null; - } - - $cacheId = $this->generateCacheId($location); - - if (! $this->cacheDriver->has($cacheId)) { - return null; - } - - $names = static fn (array $structures): array => array_map( - static fn (mixed $structure): string => is_object($structure) && method_exists($structure, 'getFcqn') ? $structure->getFcqn() : (string) $structure, - $structures, - ); - - return array_values(array_diff( - $names(Discover::in($location->getPath())->full()->get()), - $names($this->cacheDriver->get($cacheId)), - )); - } - - /** - * Whether discovery reads through a persistent cache (it does not in debug mode). - */ - public function isCacheEnabled(): bool - { - return $this->shouldUseCache(); - } - - /** - * Get the cache driver instance. - */ - public function getCacheDriver(): ?DiscoverCacheDriver - { - return $this->cacheDriver; - } - - /** - * Get the number of statically cached entries. - */ - public function getStaticCacheSize(): int - { - return count(self::$structuresCache); - } - - /** - * Resolve the cache driver from Laravel configuration. - */ - private function resolveCacheDriver(): ?DiscoverCacheDriver - { - if ($this->debugDetector->isDebugMode()) { - return new NullDiscoverCacheDriver; - } - - $cacheConfig = config('structure-discoverer.cache', []); - $driverClass = $cacheConfig['driver'] ?? LaravelDiscoverCacheDriver::class; - $store = $cacheConfig['store'] ?? null; - - if ($driverClass === LaravelDiscoverCacheDriver::class) { - return new LaravelDiscoverCacheDriver(prefix: 'pollora', store: $store); - } - - try { - return $this->container->make($driverClass); - } catch (\Throwable) { - return new LaravelDiscoverCacheDriver(prefix: 'pollora', store: $store); - } - } - - /** - * Create a configured Spatie discoverer instance. - */ - private function createSpatieDiscoverer(DiscoveryLocationInterface $location, string $cacheId): Discover - { - $discover = Discover::in($location->getPath())->full(); - - if ($this->shouldUseCache()) { - return $discover->withCache($cacheId, $this->cacheDriver); - } - - return $discover; - } - - /** - * Determine if caching should be used. - */ - private function shouldUseCache(): bool - { - return $this->cacheDriver instanceof DiscoverCacheDriver - && ! ($this->cacheDriver instanceof NullDiscoverCacheDriver); - } - - /** - * Generate cache ID for a discovery location. - */ - private function generateCacheId(DiscoveryLocationInterface $location): string - { - return 'discovery_'.md5($location->getPath()); - } -} diff --git a/src/Discovery/Infrastructure/Services/DiscoveryEngine.php b/src/Discovery/Infrastructure/Services/DiscoveryEngine.php deleted file mode 100644 index 6671206e..00000000 --- a/src/Discovery/Infrastructure/Services/DiscoveryEngine.php +++ /dev/null @@ -1,645 +0,0 @@ - - */ - private Collection $locations; - - /** - * Collection of registered discoveries - * - * @var Collection - */ - private Collection $discoveries; - - /** - * Discovery context for coordinating across discoveries - */ - private readonly DiscoveryContext $context; - - /** - * Instance pool for managing class instances - */ - private readonly InstancePool $instancePool; - - /** - * Cache manager for Spatie's structure discovery - */ - private readonly DiscoveryCacheManager $cacheManager; - - /** - * Registrar for auto-discovering Discovery classes - */ - private readonly DiscoveryRegistrar $registrar; - - /** - * Create a new discovery engine - * - * @param Container $container The service container for dependency injection - * @param DebugDetectorInterface $debugDetector Debug mode detector - * @param ReflectionCacheInterface|null $reflectionCache Optional reflection cache - * @param InstancePool|null $instancePool Optional instance pool - * @param LoggerInterface|null $logger Optional PSR-3 logger - * @param DiscoveryCacheManager|null $cacheManager Optional cache manager - * @param array $skipClasses Classes to exclude from discovery - * @param array $skipPaths Path patterns to exclude from discovery - */ - public function __construct( - private readonly Container $container, - DebugDetectorInterface $debugDetector, - ?ReflectionCacheInterface $reflectionCache = null, - ?InstancePool $instancePool = null, - private readonly ?LoggerInterface $logger = null, - ?DiscoveryCacheManager $cacheManager = null, - private readonly array $skipClasses = [], - private readonly array $skipPaths = [], - ) { - $this->locations = new Collection; - $this->discoveries = new Collection; - - // Initialize optimized services - $reflectionCache ??= new ReflectionCache($container); - $this->instancePool = $instancePool ?? new InstancePool($container); - $this->context = new DiscoveryContext($reflectionCache); - $this->cacheManager = $cacheManager ?? new DiscoveryCacheManager($container, $debugDetector); - $this->registrar = new DiscoveryRegistrar($container, $this->logger); - } - - /** - * {@inheritDoc} - */ - public function addLocation(DiscoveryLocationInterface $location): static - { - // Check for duplicate locations (same path) - $locationPath = realpath($location->getPath()) ?: $location->getPath(); - - $isDuplicate = $this->locations->contains(function (DiscoveryLocationInterface $existingLocation) use ($locationPath): bool { - $existingPath = realpath($existingLocation->getPath()) ?: $existingLocation->getPath(); - - return $existingPath === $locationPath; - }); - - if (! $isDuplicate) { - $this->locations->push($location); - } - - return $this; - } - - /** - * {@inheritDoc} - */ - public function addLocations(array $locations): static - { - foreach ($locations as $location) { - $this->addLocation($location); - } - - return $this; - } - - /** - * {@inheritDoc} - */ - public function addDiscovery(string $identifier, string|DiscoveryInterface $discovery): static - { - if ($this->discoveries->has($identifier)) { - throw InvalidDiscoveryException::duplicateIdentifier($identifier); - } - - $discoveryInstance = $this->resolveDiscovery($discovery); - $this->discoveries->put($identifier, $discoveryInstance); - - return $this; - } - - /** - * {@inheritDoc} - */ - public function addDiscoveries(array $discoveries): static - { - foreach ($discoveries as $identifier => $discovery) { - $this->addDiscovery($identifier, $discovery); - } - - return $this; - } - - /** - * {@inheritDoc} - */ - public function discover(): static - { - // Auto-register Discovery classes from container bindings - $this->registrar->registerFromContainer($this); - - $allStructures = $this->discoverAllStructures(); - - $this->processStructuresUnified($allStructures); - - return $this; - } - - /** - * {@inheritDoc} - */ - public function apply(): static - { - foreach ($this->discoveries as $discovery) { - try { - // Inject instance pool into discoveries that can use it - $this->injectInstancePoolIfSupported($discovery); - - $this->applyPending($discovery); - } catch (\Throwable $e) { - throw DiscoveryException::applicationFailed($discovery::class, $e); - } - } - - return $this; - } - - /** - * {@inheritDoc} - */ - public function run(): static - { - return $this->discover()->apply(); - } - - /** - * {@inheritDoc} - */ - public function getDiscovery(string $identifier): DiscoveryInterface - { - if (! $this->discoveries->has($identifier)) { - throw DiscoveryNotFoundException::withIdentifier($identifier); - } - - return $this->discoveries->get($identifier); - } - - /** - * {@inheritDoc} - */ - public function getDiscoveries(): Collection - { - return $this->discoveries; - } - - /** - * {@inheritDoc} - */ - public function getLocations(): Collection - { - return $this->locations; - } - - /** - * Discover items for a single discovery class - * - * @param DiscoveryInterface $discovery The discovery instance - * - * @throws DiscoveryException When discovery fails - */ - private function discoverSingle(DiscoveryInterface $discovery): void - { - try { - // Initialize fresh discovery items - $discovery->setItems(new DiscoveryItems); - - // Discover PHP structures using Spatie's native cache - $this->discoverStructures($discovery); - } catch (\Throwable $throwable) { - $this->logDiscoveryError($discovery::class, $throwable); - throw DiscoveryException::discoveryFailed($discovery::class, $throwable); - } - } - - /** - * Discover PHP structures using Spatie's discoverer - * - * @param DiscoveryInterface $discovery The discovery instance - */ - private function discoverStructures(DiscoveryInterface $discovery): void - { - $reflectionCache = $this->context->getReflectionCache(); - - foreach ($this->locations as $location) { - $structures = $this->cacheManager->getStructuresForLocation($location, $this->context); - - foreach ($structures as $structure) { - $discovery->discover($location, $structure, $reflectionCache); - } - } - } - - /** - * Clear all locations - */ - public function clearLocations(): static - { - $this->locations = new Collection; - - return $this; - } - - /** - * Clear persistent discovery cache - * - * Only clears persistent cache (Spatie's structure discoverer cache). - * In-memory caches (reflection, instance pool, static cache) are automatically - * cleared at the end of the PHP process and don't need manual clearing. - */ - public function clearCache(): static - { - $this->cacheManager->clearCache($this->locations); - - return $this; - } - - /** - * Run a specific discovery - * - * @param string $identifier The discovery identifier - * @param DiscoveryInterface $discovery The discovery instance - */ - public function runDiscovery(string $identifier, DiscoveryInterface $discovery): static - { - try { - $this->discoverSingle($discovery); - $this->applyPending($discovery); - } catch (\Throwable $throwable) { - $this->logDiscoveryError($discovery::class, $throwable); - throw DiscoveryException::discoveryFailed($discovery::class, $throwable); - } - - return $this; - } - - /** - * Apply only the items the discovery has not applied yet. - * - * Discoveries are shared by every engine (the main one, its clones for - * modules) and their items accumulate across locations, so the engine is - * asked to apply several times per request. Handing the discovery only its - * new items makes each item apply once, whether or not the discovery's - * apply() is idempotent. Its full item list is restored afterwards, for - * whatever reads it later (status, doctor, dashboard). - * - * @param DiscoveryInterface $discovery The discovery to apply - */ - private function applyPending(DiscoveryInterface $discovery): void - { - $items = $discovery->getItems(); - $pending = $items->pending(); - - if (! $pending->isLoaded()) { - return; - } - - $discovery->setItems($pending); - - try { - $discovery->apply(); - } finally { - $items->markApplied(); - $discovery->setItems($items); - } - } - - /** - * Clone the engine - */ - public function __clone(): void - { - $this->locations = clone $this->locations; - $this->discoveries = clone $this->discoveries; - } - - /** - * Resolve a discovery instance from class name or instance - * - * @param string|DiscoveryInterface $discovery The discovery to resolve - * @return DiscoveryInterface The resolved discovery instance - * - * @throws InvalidDiscoveryException When the discovery is invalid - */ - private function resolveDiscovery(string|DiscoveryInterface $discovery): DiscoveryInterface - { - if ($discovery instanceof DiscoveryInterface) { - return $discovery; - } - - if (! class_exists($discovery)) { - throw InvalidDiscoveryException::invalidClass($discovery, 'Class does not exist'); - } - - if (! is_subclass_of($discovery, DiscoveryInterface::class)) { - throw InvalidDiscoveryException::missingInterface($discovery, DiscoveryInterface::class); - } - - try { - return $this->container->make($discovery); - } catch (\Throwable $throwable) { - throw InvalidDiscoveryException::invalidClass($discovery, 'Cannot instantiate: '.$throwable->getMessage()); - } - } - - /** - * Discover all structures from all locations using Spatie's discoverer. - * - * @return array All discovered structures with their locations - */ - private function discoverAllStructures(): array - { - $allStructures = []; - - foreach ($this->locations as $location) { - $locationStructures = $this->cacheManager->getStructuresForLocation($location, $this->context); - - foreach ($locationStructures as $structure) { - $allStructures[] = [ - 'structure' => $structure, - 'location' => $location, - ]; - } - } - - return $allStructures; - } - - /** - * Process structures using unified approach to minimize redundant operations. - * - * @param array $structures All discovered structures with their locations - */ - private function processStructuresUnified(array $structures): void - { - // Group structures by class name for batch processing - $structuresByClass = []; - - foreach ($structures as $entry) { - $structure = $entry['structure']; - - // Concrete classes, and enums carrying attributes (#[CapabilitySet]…). - $isDiscoverable = ($structure instanceof DiscoveredClass && ! $structure->isAbstract) - || ($structure instanceof DiscoveredEnum && $structure->attributes !== []); - - if (! $isDiscoverable) { - continue; - } - - $className = $structure->namespace.'\\'.$structure->name; - - // Config-level exclusions (no reflection needed) - if ($this->isSkippedByConfig($className, $structure->file)) { - continue; - } - - // Attribute-level exclusion - $skipAttr = $this->getSkipDiscoveryAttribute($structure); - if ($skipAttr instanceof SkipDiscovery && $skipAttr->except === []) { - continue; - } - - $structuresByClass[$className] = [ - 'structure' => $structure, - 'location' => $entry['location'], - 'skip_except' => $skipAttr?->except, - ]; - } - - // Initialize discoveries with fresh items only if they don't have items yet - foreach ($this->discoveries as $discovery) { - if (! $discovery->getItems()->isLoaded()) { - $discovery->setItems(new DiscoveryItems); - } - } - - // Process each class once for all applicable discoveries - foreach ($structuresByClass as $className => $data) { - $this->processClassForAllDiscoveries( - $data['structure'], - $data['location'], - $className, - $data['skip_except'] - ); - } - - $this->context->incrementStat('classes_processed', count($structuresByClass)); - } - - /** - * Process a single class for all applicable discoveries. - * - * @param DiscoveredClass|DiscoveredEnum $structure The discovered structure - * @param DiscoveryLocationInterface $location The discovery location - * @param string $className The fully qualified class name - */ - /** - * @param array|null $skipExcept If set, only these discovery classes may process the structure - */ - private function processClassForAllDiscoveries( - DiscoveredClass|DiscoveredEnum $structure, - DiscoveryLocationInterface $location, - string $className, - ?array $skipExcept = null - ): void { - try { - $reflectionCache = $this->context->getReflectionCache(); - - foreach ($this->discoveries as $discoveryId => $discovery) { - try { - if ($this->context->isProcessed($className, $discoveryId)) { - continue; - } - - // #[SkipDiscovery(except: [...])] — only allow listed discoveries - if ($skipExcept !== null && ! in_array($discovery::class, $skipExcept, true)) { - continue; - } - - $discovery->discover($location, $structure, $reflectionCache); - - $this->context->markProcessed($className, $discoveryId); - $this->context->incrementStat('discoveries_executed'); - - } catch (\Throwable $e) { - $this->context->recordError(); - $this->logDiscoveryError(sprintf('Discovery %s for class %s', $discoveryId, $className), $e); - } - } - - } catch (\Throwable $throwable) { - $this->context->recordError(); - $this->logDiscoveryError('Failed to process class '.$className, $throwable); - } - } - - /** - * Check if a class or file is excluded by config-level skip rules. - */ - private function isSkippedByConfig(string $className, string $filePath): bool - { - if (in_array($className, $this->skipClasses, true)) { - return true; - } - - foreach ($this->skipPaths as $pattern) { - if (str_contains($filePath, $pattern)) { - return true; - } - } - - return false; - } - - /** - * Get the #[SkipDiscovery] attribute instance if present on the structure. - * - * Uses Spatie's token-parsed data to detect the attribute without reflection. - * Only loads reflection to read parameters when the attribute is found. - */ - private function getSkipDiscoveryAttribute(DiscoveredClass|DiscoveredEnum $structure): ?SkipDiscovery - { - foreach ($structure->attributes as $attribute) { - if ($attribute->class === SkipDiscovery::class) { - return $this->instantiateSkipDiscovery($structure); - } - } - - return null; - } - - /** - * Instantiate the #[SkipDiscovery] attribute to read its parameters. - */ - private function instantiateSkipDiscovery(DiscoveredClass|DiscoveredEnum $structure): SkipDiscovery - { - try { - $className = $structure->namespace.'\\'.$structure->name; - $reflection = new \ReflectionClass($className); - $attrs = $reflection->getAttributes(SkipDiscovery::class); - - if ($attrs !== []) { - return $attrs[0]->newInstance(); - } - } catch (\Throwable) { - // If reflection fails, treat as full skip - } - - return new SkipDiscovery; - } - - /** - * Get the discovery context. - */ - public function getContext(): DiscoveryContext - { - return $this->context; - } - - /** - * Get the instance pool. - */ - public function getInstancePool(): InstancePool - { - return $this->instancePool; - } - - /** - * Get performance statistics. - * - * @return array - */ - public function getPerformanceStats(): array - { - return [ - 'context' => $this->context->getSummary(), - 'instance_pool' => $this->instancePool->getStats(), - 'static_cache_size' => $this->cacheManager->getStaticCacheSize(), - ]; - } - - /** - * Inject instance pool into discoveries that support it - * - * @param DiscoveryInterface $discovery The discovery to potentially inject into - */ - private function injectInstancePoolIfSupported(DiscoveryInterface $discovery): void - { - // Check if the discovery has a method to accept the instance pool - if (method_exists($discovery, 'setInstancePool')) { - $discovery->setInstancePool($this->instancePool); - } - } - - /** - * Get the cache driver instance - */ - public function getCacheDriver(): ?DiscoverCacheDriver - { - return $this->cacheManager->getCacheDriver(); - } - - /** - * Get the cache manager instance - */ - public function getCacheManager(): DiscoveryCacheManager - { - return $this->cacheManager; - } - - /** - * Log discovery errors with consistent format - * - * @param string $context The error context - * @param \Throwable $exception The exception - */ - private function logDiscoveryError(string $context, \Throwable $exception): void - { - $this->logger?->error(sprintf('%s: %s', $context, $exception->getMessage()), [ - 'exception' => $exception, - ]); - } -} diff --git a/src/Discovery/Infrastructure/Services/DiscoveryRegistrar.php b/src/Discovery/Infrastructure/Services/DiscoveryRegistrar.php index 07c0f7db..40763f2b 100644 --- a/src/Discovery/Infrastructure/Services/DiscoveryRegistrar.php +++ b/src/Discovery/Infrastructure/Services/DiscoveryRegistrar.php @@ -7,6 +7,7 @@ use Illuminate\Container\Container; use Pollora\Discovery\Domain\Contracts\DiscoveryEngineInterface; use Pollora\Discovery\Domain\Contracts\DiscoveryInterface; +use Pollora\Discovery\Domain\Contracts\DiscoveryRegistrarInterface; use Psr\Log\LoggerInterface; /** @@ -19,7 +20,7 @@ * Each ServiceProvider still registers its Discovery as a singleton in * register(). The registrar picks them up and adds them to the engine. */ -final readonly class DiscoveryRegistrar +final readonly class DiscoveryRegistrar implements DiscoveryRegistrarInterface { public function __construct( private Container $container, @@ -30,9 +31,9 @@ public function __construct( * Scan container bindings and register any DiscoveryInterface implementations. * * @param DiscoveryEngineInterface $engine The engine to register discoveries with - * @return array Identifiers of auto-registered discoveries + * @return array Identifiers of auto-registered discoveries */ - public function registerFromContainer(DiscoveryEngineInterface $engine): array + public function registerInto(DiscoveryEngineInterface $engine): array { $registered = []; diff --git a/src/Discovery/Infrastructure/Services/InstancePool.php b/src/Discovery/Infrastructure/Services/InstancePool.php deleted file mode 100644 index de66a871..00000000 --- a/src/Discovery/Infrastructure/Services/InstancePool.php +++ /dev/null @@ -1,311 +0,0 @@ - - */ - private array $instances = []; - - /** - * Currently instantiating classes (for circular dependency detection) - * - * @var array - */ - private array $instantiating = []; - - /** - * Instance creation statistics - * - * @var array - */ - private array $stats = [ - 'hits' => 0, - 'misses' => 0, - 'circular_dependencies' => 0, - 'errors' => 0, - ]; - - /** - * DI Container for instance resolution - */ - private readonly Container $container; - - public function __construct(?Container $container = null) - { - $this->container = $container ?? app(); - } - - /** - * Get or create an instance of the specified class. - * - * @param string $className The fully qualified class name - * @return object The class instance - * - * @throws CircularDependencyException If a circular dependency is detected - * @throws \RuntimeException If the class cannot be instantiated - */ - public function getInstance(string $className): object - { - // Cache hit - return existing instance - if (isset($this->instances[$className])) { - $this->stats['hits']++; - - return $this->instances[$className]; - } - - // Cache miss - need to create instance - $this->stats['misses']++; - - // Detect circular dependencies - if (isset($this->instantiating[$className])) { - $this->stats['circular_dependencies']++; - throw new CircularDependencyException( - 'Circular dependency detected while instantiating: '.$className - ); - } - - try { - // Mark as currently instantiating - $this->instantiating[$className] = true; - - // Create instance through DI container - $instance = $this->container->make($className); - - // Store in pool - $this->instances[$className] = $instance; - - // Remove from instantiating list - unset($this->instantiating[$className]); - - return $instance; - - } catch (\Throwable $throwable) { - // Clean up instantiating state on error - unset($this->instantiating[$className]); - - $this->stats['errors']++; - - throw new \RuntimeException( - sprintf('Failed to instantiate class %s: %s', $className, $throwable->getMessage()), - $throwable->getCode(), - $throwable - ); - } - } - - /** - * Check if an instance exists in the pool. - * - * @param string $className The fully qualified class name - * @return bool True if the instance exists in the pool - */ - public function hasInstance(string $className): bool - { - return isset($this->instances[$className]); - } - - /** - * Preload multiple instances into the pool. - * - * @param array $classNames Array of class names to preload - * @return array Map of successfully preloaded instances - */ - public function preloadInstances(array $classNames): array - { - $preloadedInstances = []; - - foreach ($classNames as $className) { - try { - $preloadedInstances[$className] = $this->getInstance($className); - } catch (\Throwable) { - // Skip classes that cannot be instantiated - continue; - } - } - - return $preloadedInstances; - } - - /** - * Remove an instance from the pool. - * - * @param string $className The class name to remove - * @return bool True if the instance was removed, false if it didn't exist - */ - public function removeInstance(string $className): bool - { - if (isset($this->instances[$className])) { - unset($this->instances[$className]); - - return true; - } - - return false; - } - - /** - * Get the number of instances in the pool. - * - * @return int The number of pooled instances - */ - public function getPoolSize(): int - { - return count($this->instances); - } - - /** - * Get pool statistics. - * - * @return array Pool usage statistics - */ - public function getStats(): array - { - $total = $this->stats['hits'] + $this->stats['misses']; - $hitRatio = $total > 0 ? round(($this->stats['hits'] / $total) * 100, 2) : 0.0; - - return [ - 'pool_size' => $this->getPoolSize(), - 'cache_hits' => $this->stats['hits'], - 'cache_misses' => $this->stats['misses'], - 'hit_ratio_percent' => $hitRatio, - 'circular_dependencies' => $this->stats['circular_dependencies'], - 'instantiation_errors' => $this->stats['errors'], - 'total_requests' => $total, - ]; - } - - /** - * Get list of all pooled class names. - * - * @return array Array of pooled class names - */ - public function getPooledClasses(): array - { - return array_keys($this->instances); - } - - /** - * Perform memory optimization by removing unused instances. - * - * @param int $maxPoolSize Maximum number of instances to keep - */ - public function optimize(int $maxPoolSize = 100): void - { - if ($this->getPoolSize() <= $maxPoolSize) { - return; - } - - // Simple LRU-style cleanup - remove excess instances - $excessCount = $this->getPoolSize() - $maxPoolSize; - $classNames = array_keys($this->instances); - - for ($i = 0; $i < $excessCount; $i++) { - if (isset($classNames[$i])) { - unset($this->instances[$classNames[$i]]); - } - } - } - - /** - * Reset statistics counters. - */ - public function resetStats(): void - { - $this->stats = [ - 'hits' => 0, - 'misses' => 0, - 'circular_dependencies' => 0, - 'errors' => 0, - ]; - } - - /** - * Check if a class is currently being instantiated (circular dependency check). - * - * @param string $className The class name to check - * @return bool True if the class is currently being instantiated - */ - public function isInstantiating(string $className): bool - { - return isset($this->instantiating[$className]); - } - - /** - * Get debug information about the instance pool state. - * - * @return array Debug information - */ - public function getDebugInfo(): array - { - return [ - 'instances' => $this->getPooledClasses(), - 'instantiating' => array_keys($this->instantiating), - 'stats' => $this->getStats(), - 'memory_usage' => $this->estimateMemoryUsage(), - ]; - } - - /** - * Estimate memory usage of the instance pool. - * - * @return string Human-readable memory usage estimate - */ - private function estimateMemoryUsage(): string - { - $memoryUsage = 0; - - foreach ($this->instances as $instance) { - // Rough estimate - this is not precise but gives an idea - $memoryUsage += strlen(serialize($instance)); - } - - return $this->formatBytes($memoryUsage); - } - - /** - * Format bytes into human-readable format. - * - * @param int $bytes Number of bytes - * @return string Formatted string - */ - private function formatBytes(int $bytes): string - { - $units = ['B', 'KB', 'MB', 'GB']; - $unitIndex = 0; - - while ($bytes >= 1024 && $unitIndex < count($units) - 1) { - $bytes /= 1024; - $unitIndex++; - } - - return round($bytes, 2).' '.$units[$unitIndex]; - } -} - -/** - * Exception thrown when circular dependency is detected during instantiation - */ -class CircularDependencyException extends \RuntimeException {} diff --git a/src/Discovery/Infrastructure/Services/ReflectionCache.php b/src/Discovery/Infrastructure/Services/ReflectionCache.php deleted file mode 100644 index 6e8b3a25..00000000 --- a/src/Discovery/Infrastructure/Services/ReflectionCache.php +++ /dev/null @@ -1,255 +0,0 @@ - - */ - private array $classReflections = []; - - /** - * Cache for class-level attributes - * - * @var array>> - */ - private array $classAttributes = []; - - /** - * Cache for method-level attributes grouped by class and attribute type - * - * @var array>> - */ - private array $methodsWithAttributes = []; - - /** - * Cache for public methods per class - * - * @var array> - */ - private array $publicMethods = []; - - /** - * Cache for class instances from DI container - * - * @var array - */ - private array $classInstances = []; - - /** - * Cache for processed classes to avoid reprocessing - * - * @var array - */ - private array $processedClasses = []; - - /** - * DI Container for instance resolution - */ - private readonly Container $container; - - public function __construct(?Container $container = null) - { - $this->container = $container ?? app(); - } - - /** - * {@inheritDoc} - */ - public function getClassReflection(string $className): ReflectionClass - { - if (! isset($this->classReflections[$className])) { - try { - // Check if class exists and can be autoloaded first - if (! class_exists($className, true)) { - throw new ReflectionException(sprintf('Class %s does not exist or cannot be autoloaded', $className)); - } - - $this->classReflections[$className] = new ReflectionClass($className); - $this->processedClasses[$className] = true; - } catch (\Throwable $e) { - // Log the error but mark as processed to avoid retrying - $this->processedClasses[$className] = true; - - throw new ReflectionException( - sprintf('Failed to create reflection for class %s: %s', $className, $e->getMessage()), - $e->getCode(), - $e - ); - } - } - - return $this->classReflections[$className]; - } - - /** - * {@inheritDoc} - */ - public function getPublicMethods(string $className): array - { - $this->publicMethods[$className] ??= $this->getClassReflection($className) - ->getMethods(ReflectionMethod::IS_PUBLIC); - - return $this->publicMethods[$className]; - } - - /** - * {@inheritDoc} - */ - public function getMethodsWithAttributes(string $className, ?string $attributeClass = null): array - { - $cacheKey = $attributeClass ?? '*'; - - if (! isset($this->methodsWithAttributes[$className][$cacheKey])) { - $this->buildMethodAttributeCache($className, $attributeClass); - } - - return $this->methodsWithAttributes[$className][$cacheKey]; - } - - /** - * {@inheritDoc} - */ - public function getClassAttributes(string $className, ?string $attributeClass = null): array - { - $cacheKey = $attributeClass ?? '*'; - - if (! isset($this->classAttributes[$className][$cacheKey])) { - $this->buildClassAttributeCache($className, $attributeClass); - } - - return $this->classAttributes[$className][$cacheKey]; - } - - /** - * {@inheritDoc} - */ - public function getClassInstance(string $className): object - { - if (! isset($this->classInstances[$className])) { - try { - // Validate the class can be instantiated before attempting - $reflection = $this->getClassReflection($className); - - if ($reflection->isAbstract() || $reflection->isInterface() || $reflection->isTrait()) { - throw new \InvalidArgumentException( - 'Cannot instantiate abstract class, interface, or trait: '.$className - ); - } - - if (! $reflection->isInstantiable()) { - throw new \InvalidArgumentException(sprintf('Class %s is not instantiable', $className)); - } - - $this->classInstances[$className] = $this->container->make($className); - } catch (\Throwable $e) { - throw new \RuntimeException( - sprintf('Failed to instantiate class %s: %s', $className, $e->getMessage()), - $e->getCode(), - $e - ); - } - } - - return $this->classInstances[$className]; - } - - /** - * {@inheritDoc} - */ - public function hasClass(string $className): bool - { - return isset($this->processedClasses[$className]); - } - - /** - * {@inheritDoc} - */ - public function preloadClasses(array $classNames): void - { - foreach ($classNames as $className) { - if (! $this->hasClass($className)) { - try { - $this->getClassReflection($className); - } catch (\Throwable) { - // Skip classes that cannot be loaded - continue; - } - } - } - } - - /** - * Build method attribute cache for a specific class and attribute type. - * - * @param string $className The class name to process - * @param string|null $attributeClass Optional attribute class filter - */ - private function buildMethodAttributeCache(string $className, ?string $attributeClass = null): void - { - $cacheKey = $attributeClass ?? '*'; - $methodsWithAttributes = []; - - foreach ($this->getPublicMethods($className) as $method) { - if ($attributeClass === null) { - // Get all methods with any attributes - $hasMatchingAttribute = ! empty($method->getAttributes()); - } else { - // Get methods with specific attribute class - $hasMatchingAttribute = ! empty($method->getAttributes($attributeClass)); - } - - if ($hasMatchingAttribute) { - $methodsWithAttributes[] = $method; - } - } - - $this->methodsWithAttributes[$className][$cacheKey] = $methodsWithAttributes; - } - - /** - * Build class attribute cache for a specific class and attribute type. - * - * @param string $className The class name to process - * @param string|null $attributeClass Optional attribute class filter - */ - private function buildClassAttributeCache(string $className, ?string $attributeClass = null): void - { - $cacheKey = $attributeClass ?? '*'; - $reflection = $this->getClassReflection($className); - - if ($attributeClass === null) { - // Get all class attributes - $attributes = $reflection->getAttributes(); - } else { - // Get specific class attributes - $attributes = $reflection->getAttributes($attributeClass); - } - - $this->classAttributes[$className][$cacheKey] = $attributes; - } -} diff --git a/src/Hook/Infrastructure/Checks/AsyncActionsCheck.php b/src/Hook/Infrastructure/Checks/AsyncActionsCheck.php index 1ce6440f..11857797 100644 --- a/src/Hook/Infrastructure/Checks/AsyncActionsCheck.php +++ b/src/Hook/Infrastructure/Checks/AsyncActionsCheck.php @@ -179,10 +179,6 @@ private function connectionProblems(): array */ private function cronFix(): string { - if ($this->config->get('wordpress.use_laravel_scheduler')) { - return 'WP-Cron events are Laravel jobs here (wordpress.use_laravel_scheduler): run a queue worker, php artisan queue:work'; - } - return $this->inspector->cronRunsOnPageLoad() ? 'The site gets too few visits to run WP-Cron: add a system cron that requests wp-cron.php every minute' : 'DISABLE_WP_CRON is set (Pollora sets it): add a system cron, e.g. * * * * * curl -s https://example.com/cms/wp-cron.php, or wp cron event run --due-now'; diff --git a/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php b/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php index 705cbe79..42efea50 100644 --- a/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php +++ b/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php @@ -49,6 +49,9 @@ public function boot(): void $this->app->tag([AsyncActionsCheck::class], DoctorServiceProvider::CHECKS_TAG); $this->configureAsync($this->app->make(Repository::class)); + + // The run of a queued action must not wait for an Action to be built in the request: the request that runs it may register no hook at all + Async::listen(); } private function configureAsync(Repository $config): void diff --git a/src/Hook/Infrastructure/Services/AsyncInspector.php b/src/Hook/Infrastructure/Services/AsyncInspector.php index da55592e..d05ecf3b 100644 --- a/src/Hook/Infrastructure/Services/AsyncInspector.php +++ b/src/Hook/Infrastructure/Services/AsyncInspector.php @@ -81,8 +81,9 @@ public function overdueCronEvents(int $seconds): array continue; } + // The events another driver keeps (Action Scheduler) are in the index too: this counts what only WP-Cron runs foreach ((array) $hooks as $events) { - $count += count((array) $events); + $count += count(array_filter((array) $events, static fn (mixed $event): bool => ! is_array($event) || ($event['driver'] ?? 'wp-cron') === 'wp-cron')); } $oldest = $oldest === null ? $timestamp : min($oldest, $timestamp); diff --git a/src/Meta/Domain/Enums/Control.php b/src/Meta/Domain/Enums/Control.php deleted file mode 100644 index ba3e0600..00000000 --- a/src/Meta/Domain/Enums/Control.php +++ /dev/null @@ -1,25 +0,0 @@ -attributeArgs[$key] ?? $default; } + /** + * Replace every attribute argument. + * + * @param array $args The arguments to keep + */ + public function replaceArgs(array $args): void + { + $this->attributeArgs = $args; + } + /** * Merge additional arguments into the configuration. * diff --git a/src/PostType/Infrastructure/Services/PostTypeDiscovery.php b/src/PostType/Infrastructure/Services/PostTypeDiscovery.php index 0095dd91..de149d40 100644 --- a/src/PostType/Infrastructure/Services/PostTypeDiscovery.php +++ b/src/PostType/Infrastructure/Services/PostTypeDiscovery.php @@ -5,6 +5,9 @@ namespace Pollora\PostType\Infrastructure\Services; use Illuminate\Support\Str; +use Pollora\Attributes\Contracts\ConfiguresArguments; +use Pollora\Attributes\Contracts\ConfiguresArgumentsWithMethod; +use Pollora\Attributes\Contracts\HandlesAttributes; use Pollora\Attributes\PostType; use Pollora\Discovery\Domain\Contracts\ConfigurableDiscoveryInterface; use Pollora\Discovery\Domain\Contracts\DiscoveryInterface; @@ -302,8 +305,13 @@ private function processClassAttribute(ReflectionClass $reflectionClass, \Reflec { $attributeInstance = $attribute->newInstance(); - // Check if the attribute has a handle method and call it - if (method_exists($attributeInstance, 'handle')) { + if ($attributeInstance instanceof ConfiguresArguments) { + $config->replaceArgs($attributeInstance->configureArguments($config->withArgs())); + + return; + } + + if ($attributeInstance instanceof HandlesAttributes) { $attributeInstance->handle(app(), $config, $reflectionClass, $attributeInstance); } } @@ -325,8 +333,13 @@ private function processMethodAttribute( ): void { $attributeInstance = $attribute->newInstance(); - // Check if the attribute has a handle method and call it - if (method_exists($attributeInstance, 'handle')) { + if ($attributeInstance instanceof ConfiguresArgumentsWithMethod) { + $config->replaceArgs($attributeInstance->configureArguments($config->withArgs(), [new $method->class, $method->getName()])); + + return; + } + + if ($attributeInstance instanceof HandlesAttributes) { $attributeInstance->handle(app(), $config, $method, $attributeInstance); } } diff --git a/src/Providers/PolloraServiceProvider.php b/src/Providers/PolloraServiceProvider.php index b83b4b7a..efb6d705 100755 --- a/src/Providers/PolloraServiceProvider.php +++ b/src/Providers/PolloraServiceProvider.php @@ -4,7 +4,6 @@ namespace Pollora\Providers; -use Illuminate\Contracts\Bus\Dispatcher; use Illuminate\Support\ServiceProvider; use Log1x\SageDirectives\SageDirectivesServiceProvider; use Pollora\Ability\Infrastructure\Providers\AbilityServiceProvider; @@ -36,7 +35,6 @@ use Pollora\PostType\Infrastructure\Providers\PostTypeServiceProvider; use Pollora\Role\Infrastructure\Providers\RoleServiceProvider; use Pollora\Route\Infrastructure\Providers\RouteServiceProvider; -use Pollora\Schedule\Jobs\JobDispatcher; use Pollora\Schedule\SchedulerServiceProvider; use Pollora\Taxonomy\Infrastructure\Providers\TaxonomyServiceProvider; use Pollora\Theme\Infrastructure\Providers\ThemeServiceProvider; @@ -125,7 +123,6 @@ public function register(): void $this->app->register(RouteServiceProvider::class); $this->app->register(SchedulerServiceProvider::class); - $this->app->singleton(JobDispatcher::class, fn ($app): JobDispatcher => new JobDispatcher($app->make(Dispatcher::class))); // Authentication service provider $this->app->register(AuthServiceProvider::class); diff --git a/src/Role/Domain/Enums/Access.php b/src/Role/Domain/Enums/Access.php deleted file mode 100644 index 71e4eb83..00000000 --- a/src/Role/Domain/Enums/Access.php +++ /dev/null @@ -1,40 +0,0 @@ - - */ - public function genericCapabilities(): array - { - $contributor = ['edit_posts', 'delete_posts']; - $author = ['publish_posts', 'edit_published_posts', 'delete_published_posts']; - $editor = ['edit_others_posts', 'delete_others_posts', 'read_private_posts', 'edit_private_posts', 'delete_private_posts']; - - return match ($this) { - self::Contributor => $contributor, - self::Author => [...$contributor, ...$author], - self::Editor => [...$contributor, ...$author, ...$editor], - }; - } -} diff --git a/src/Schedule/Application/Services/ScheduleStatus.php b/src/Schedule/Application/Services/ScheduleStatus.php new file mode 100644 index 00000000..e6a4bb7b --- /dev/null +++ b/src/Schedule/Application/Services/ScheduleStatus.php @@ -0,0 +1,125 @@ +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; + } + + /** + * Seconds after which an event of the "laravel" driver counts as late. + */ + public function lateAfter(): int + { + return max(60, (int) $this->config->get('schedule.laravel.late_after', 600)); + } + + /** + * Events of the "laravel" driver that are late: a job lost, or no worker. + */ + public function late(): int + { + $limit = time() - $this->lateAfter(); + + return count(array_filter(Schedule::engine()->index(), static fn (Event $event): bool => $event->driver === LaravelQueueDriver::NAME && $event->timestamp < $limit)); + } + + /** + * 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/Application/UseCases/RegisterSchedulerFiltersUseCase.php b/src/Schedule/Application/UseCases/RegisterSchedulerFiltersUseCase.php deleted file mode 100644 index 91784f2a..00000000 --- a/src/Schedule/Application/UseCases/RegisterSchedulerFiltersUseCase.php +++ /dev/null @@ -1,66 +0,0 @@ -registerEventQueryFilters(); - $this->registerEventManagementFilters(); - } - - /** - * Register filters for querying scheduled events. - */ - private function registerEventQueryFilters(): void - { - $filters = [ - 'pre_get_scheduled_event' => 'preGetScheduledEvent', - 'pre_get_ready_cron_jobs' => 'preGetReadyCronJobs', - ]; - - foreach ($filters as $hook => $method) { - $this->filter->add($hook, [$this->scheduler, $method], 10, 5); - } - } - - /** - * Register filters for managing scheduled events. - */ - private function registerEventManagementFilters(): void - { - $filters = [ - 'pre_update_option_cron' => 'preUpdateOptionCron', - 'pre_option_cron' => 'preOptionCron', - 'pre_schedule_event' => 'preScheduleEvent', - 'pre_reschedule_event' => 'preRescheduleEvent', - 'pre_unschedule_event' => 'preUnscheduleEvent', - 'pre_clear_scheduled_hook' => 'preClearScheduledHook', - 'pre_unschedule_hook' => 'preUnscheduleHook', - ]; - - foreach ($filters as $hook => $method) { - $this->filter->add($hook, [$this->scheduler, $method], 10, 5); - } - } -} diff --git a/src/Schedule/Contracts/EventInterface.php b/src/Schedule/Contracts/EventInterface.php deleted file mode 100755 index a3ffb23a..00000000 --- a/src/Schedule/Contracts/EventInterface.php +++ /dev/null @@ -1,55 +0,0 @@ -hook = $event->hook; - $this->timestamp = $event->timestamp; - $this->args = $event->args; - } - } - - /** - * Get the event hook name. - */ - public function getHook(): string - { - return $this->hook; - } - - /** - * Get the event timestamp. - */ - public function getTimestamp(): int - { - return $this->timestamp; - } - - /** - * Get the event arguments. - */ - public function getArgs(): array - { - return $this->args; - } - - /** - * Create and persist a new job instance. - * - * @param object $event WordPress event object - */ - public static function createJob(object $event): self - { - $job = new static($event); - $dispatcher = resolve(JobDispatcher::class); - $jobId = $dispatcher->dispatch($job); - $job->saveToDatabase($jobId); - - return $job; - } - - /** - * Save the event to the database. - * - * @param int $jobId The queue job ID - */ - protected function saveToDatabase($jobId) - { - DB::table('wp_events')->insert([ - 'hook' => $this->hook, - 'args' => json_encode($this->args), - 'schedule' => $this->schedule, - 'interval' => $this->interval, - 'is_recurring' => ! is_null($this->schedule), - 'timestamp' => $this->timestamp, - 'job_id' => $jobId, - 'created_at' => now(), - 'updated_at' => now(), - ]); - } - - /** - * Delete the event from the database. - */ - protected function deleteEvent() - { - DB::table('wp_events')->where('job_id', $this->job->getJobId())->delete(); - } - - /** - * Handle the event execution. - */ - abstract public function handle(): void; -} diff --git a/src/Schedule/Events/RecurringEvent.php b/src/Schedule/Events/RecurringEvent.php deleted file mode 100755 index cc49d9ec..00000000 --- a/src/Schedule/Events/RecurringEvent.php +++ /dev/null @@ -1,140 +0,0 @@ -schedule = $event->schedule; - $this->interval = $event->interval; - } - } - - /** - * Create and persist a new recurring job instance. - * - * @param object $event WordPress event object\n */ - public static function createJob(object $event): self - { - $job = new static($event); - $job->saveToDatabase(); - - return $job; - } - - /** - * Save the recurring job to the database. - * - * @param int|null $jobId Optional job ID - */ - protected function saveToDatabase($jobId = null): void - { - DB::table('wp_events')->insert([ - 'hook' => $this->hook, - 'args' => json_encode($this->args), - 'schedule' => $this->schedule, - 'interval' => $this->interval, - 'created_at' => now(), - 'updated_at' => now(), - ]); - } - - /** - * Schedule all recurring events in the Laravel scheduler. - * - * @param Schedule $schedule Laravel schedule instance - */ - public static function scheduleAllEvents(Schedule $schedule): void - { - $events = DB::table('wp_events')->whereNotNull('schedule')->get(); - - foreach ($events as $event) { - $schedule->call(function () use ($event): void { - do_action_ref_array($event->hook, json_decode((string) $event->args, true)); - })->cron(self::getCronExpression($event->schedule, $event->interval)); - } - } - - /** - * Convert WordPress schedule to cron expression. - * - * @param string $schedule WordPress schedule name - * @param int|null $interval Custom interval in seconds - * @return string Cron expression - */ - public static function getCronExpression(string $schedule, ?int $interval): string - { - $schedules = wp_get_schedules(); - - if (isset($schedules[$schedule])) { - $interval = $schedules[$schedule]['interval']; - } - - return match ($schedule) { - 'minutly' => '* * * * *', - 'hourly' => '0 * * * *', - 'twicedaily' => '0 */12 * * *', - 'daily' => '0 0 * * *', - 'weekly' => '0 0 * * 0', - default => self::calculateCustomInterval($interval), - }; - } - - /** - * Handle the recurring event execution. - */ - public function handle(): void - { - do_action_ref_array($this->hook, $this->args); - } - - /** - * Calculate cron expression for custom interval. - * - * @param int|null $interval Interval in seconds - * @return string Cron expression - */ - private static function calculateCustomInterval(?int $interval): string - { - if ($interval === null || $interval === 0) { - return '0 0 * * *'; - } - - $minutes = $interval / 60; - - if ($minutes < 60) { - return sprintf('*/%s * * * *', $minutes); - } - - if ($minutes == 60) { - return '0 * * * *'; - } - - if ($minutes % 60 === 0) { - $hours = $minutes / 60; - - return sprintf('0 */%s * * *', $hours); - } - - return sprintf('*/%s * * * *', $minutes); - } -} diff --git a/src/Schedule/Events/SingleEvent.php b/src/Schedule/Events/SingleEvent.php deleted file mode 100755 index 5343c16f..00000000 --- a/src/Schedule/Events/SingleEvent.php +++ /dev/null @@ -1,26 +0,0 @@ -hook, $this->args); - $this->deleteEvent(); - } -} diff --git a/src/Schedule/Every.php b/src/Schedule/Every.php deleted file mode 100644 index 73f89cef..00000000 --- a/src/Schedule/Every.php +++ /dev/null @@ -1,44 +0,0 @@ - 'hourly', - self::TWICE_DAILY => 'twicedaily', - self::DAY => 'daily', - self::WEEK => 'weekly', - self::MONTH => 'monthly', - self::YEAR => 'yearly', - }; - } - - public function isCustom(): bool - { - return in_array($this, [self::MONTH, self::YEAR], true); - } - - public function toInterval(): Interval - { - return match ($this) { - self::HOUR => new Interval(hours: 1, display: 'Once Hourly'), - self::TWICE_DAILY => new Interval(hours: 12, display: 'Twice Daily'), - self::DAY => new Interval(days: 1, display: 'Once Daily'), - self::WEEK => new Interval(weeks: 1, display: 'Once Weekly'), - self::MONTH => new Interval(days: 30, display: 'Once Monthly'), - self::YEAR => new Interval(days: 365, display: 'Once Yearly'), - }; - } -} diff --git a/src/Schedule/Infrastructure/Checks/ScheduleDriverCheck.php b/src/Schedule/Infrastructure/Checks/ScheduleDriverCheck.php new file mode 100644 index 00000000..20948454 --- /dev/null +++ b/src/Schedule/Infrastructure/Checks/ScheduleDriverCheck.php @@ -0,0 +1,83 @@ + $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, action-scheduler or laravel'; + } elseif ($this->status->unavailable()) { + $problems[] = sprintf('The "%s" driver is not available, so events stay on WP-Cron', $this->status->active()); + $fix = $this->status->active() === LaravelQueueDriver::NAME + ? 'Point the queue at a connection a worker runs (database, redis, sqs…), never sync or null: QUEUE_CONNECTION or POLLORA_SCHEDULE_QUEUE_CONNECTION' + : '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()); + } + + $late = $this->status->late(); + + if ($late > 0) { + $problems[] = sprintf('%d scheduled event(s) are more than %d minutes late: no queue worker is running them', $late, intdiv($this->status->lateAfter(), 60)); + $fix ??= 'Run a queue worker (php artisan queue:work), or send lost jobs again with php artisan pollora:schedule:recover'; + } + + foreach (array_slice(ScheduleFailures::recent(), -3) as $failure) { + $problems[] = sprintf('"%s" failed for good on %s: %s', $failure['hook'], gmdate('Y-m-d H:i', $failure['time']).' UTC', $failure['message']); + $fix ??= 'See php artisan queue:failed; the event is out of the schedule if it was a single one'; + } + + 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/Drivers/LaravelQueueDriver.php b/src/Schedule/Infrastructure/Drivers/LaravelQueueDriver.php new file mode 100644 index 00000000..db80fc6b --- /dev/null +++ b/src/Schedule/Infrastructure/Drivers/LaravelQueueDriver.php @@ -0,0 +1,180 @@ + 900]; + + /** + * The bus is resolved when a job is queued: the driver is built while the application is still registering its providers. + */ + public function __construct( + private Container $container, + private Repository $config, + ) {} + + public function available(): bool + { + $driver = $this->config->get(sprintf('queue.connections.%s.driver', $this->connection())); + + return is_string($driver) && ! in_array($driver, ['sync', 'null'], true); + } + + public function placeIdentified(Event $event): bool + { + $this->send(RunScheduledEvent::for($this->withInterval($event))); + + return true; + } + + /** + * A recurring event runs again only when its job knows the interval: the fluent API and #[Schedule] give the name of the recurrence, WordPress's own functions give both. + */ + private function withInterval(Event $event): Event + { + if ($event->recurrence === null || $event->interval !== null) { + return $event; + } + + $interval = (int) ((function_exists('wp_get_schedules') ? wp_get_schedules() : [])[$event->recurrence]['interval'] ?? 0); + + return $interval > 0 + ? new Event($event->hook, $event->timestamp, $event->recurrence, $event->args, $interval, $event->driver, $event->id) + : $event; + } + + public function once(string $hook, int $timestamp, array $args = []): bool + { + return $this->place(new Event($hook, $timestamp, null, $args)); + } + + public function recurring(string $hook, int $timestamp, string $recurrence, array $args = []): bool + { + return $this->place($this->withInterval(new Event($hook, $timestamp, $recurrence, $args))); + } + + /** + * What the engine removes from the index is cancelled: a job that finds no entry stops. + */ + public function cancel(string $hook, array $args = []): int + { + return count(array_filter($this->events(), static fn (Event $event): bool => $event->hook === $hook && $event->args === $args)); + } + + public function clear(string $hook): int + { + return count(array_filter($this->events(), static fn (Event $event): bool => $event->hook === $hook)); + } + + public function next(string $hook, array $args = []): ?int + { + foreach ($this->events() as $event) { + if ($event->hook === $hook && $event->args === $args) { + return $event->timestamp; + } + } + + return null; + } + + public function events(): array + { + return CronIndex::events(self::NAME); + } + + /** + * Queue the job of an event, delayed until it is due, or by the longest delay the connection accepts: the job then waits again. + */ + public function send(RunScheduledEvent $job): void + { + $delay = max(0, $job->timestamp - time()); + $longest = $this->longestDelay(); + + if ($longest !== null && $delay > $longest) { + $delay = $longest; + } + + $job->onConnection($this->connection())->onQueue($this->queue()); + + if ($delay > 0) { + $job->delay($delay); + } + + $this->container->make(Dispatcher::class)->dispatch($job); + } + + /** + * Seconds after which an event that has not run is late, and the recovery sends it again. + */ + public function lateAfter(): int + { + return max(60, (int) $this->config->get('schedule.laravel.late_after', 600)); + } + + /** + * Where the identifier of a placement is not known to the engine: record the event and queue it. + */ + private function place(Event $event): bool + { + $event = $event->withId(bin2hex(random_bytes(8))); + + CronIndex::record($event, self::NAME); + $this->placeIdentified($event); + + return true; + } + + private function connection(): string + { + $connection = $this->config->get('schedule.laravel.connection'); + + return is_string($connection) && $connection !== '' ? $connection : (string) $this->config->get('queue.default', 'sync'); + } + + private function queue(): ?string + { + $queue = $this->config->get('schedule.laravel.queue'); + + return is_string($queue) && $queue !== '' ? $queue : null; + } + + private function longestDelay(): ?int + { + $configured = $this->config->get('schedule.laravel.max_delay'); + + if (is_numeric($configured) && (int) $configured > 0) { + return (int) $configured; + } + + $driver = $this->config->get(sprintf('queue.connections.%s.driver', $this->connection())); + + return is_string($driver) ? (self::MAX_DELAYS[$driver] ?? null) : null; + } +} diff --git a/src/Schedule/Infrastructure/Jobs/RunScheduledEvent.php b/src/Schedule/Infrastructure/Jobs/RunScheduledEvent.php new file mode 100644 index 00000000..7eaa1664 --- /dev/null +++ b/src/Schedule/Infrastructure/Jobs/RunScheduledEvent.php @@ -0,0 +1,193 @@ + $args + */ + public function __construct( + public readonly string $hook, + public readonly array $args, + public readonly int $timestamp, + public readonly string $id, + public readonly ?string $recurrence = null, + public readonly ?int $interval = null, + public readonly int $site = 1, + ) {} + + public static function for(Event $event): self + { + return new self( + $event->hook, + $event->args, + $event->timestamp, + (string) $event->id, + $event->recurrence, + $event->interval, + function_exists('get_current_blog_id') ? get_current_blog_id() : 1, + ); + } + + public function handle(LaravelQueueDriver $driver): void + { + $this->inSite(function () use ($driver): void { + if (! $this->entryHolds()) { + return; + } + + // The delay was cut short for a connection with a ceiling: wait again + if ($this->timestamp - time() > 1) { + $driver->send($this->again()); + + return; + } + + // One run at a time: a copy sent again while this one is still working finds the lock taken + $lock = Cache::lock('pollora_schedule_run_'.$this->id, 3600); + + if (! $lock->get()) { + return; + } + + try { + do_action_ref_array($this->hook, $this->args); + $this->conclude($driver); + } finally { + $lock->release(); + } + }); + } + + /** + * Retries are over: a recurring event goes on with its next occurrence, a single one leaves the index, and the doctor is told. + */ + public function failed(Throwable $exception): void + { + $this->inSite(function () use ($exception): void { + if (! $this->entryHolds()) { + return; + } + + ScheduleFailures::record($this->hook, $exception->getMessage()); + $this->conclude(resolve(LaravelQueueDriver::class)); + }); + } + + /** + * Name shown by Horizon and in the failed jobs table. + */ + public function displayName(): string + { + return 'pollora/schedule '.$this->hook; + } + + /** + * Tags for Horizon. + * + * @return list + */ + public function tags(): array + { + return ['pollora/schedule', $this->hook]; + } + + /** + * Whether the index still has this event, at this time, under this identifier. + */ + private function entryHolds(): bool + { + // A worker lives across jobs: what WordPress cached in memory, the cron option included, is from before + if (function_exists('wp_cache_flush_runtime')) { + wp_cache_flush_runtime(); + } + + foreach (CronIndex::events(LaravelQueueDriver::NAME) as $event) { + if ($event->hook === $this->hook && $event->args === $this->args) { + return $event->id === $this->id && $event->timestamp === $this->timestamp; + } + } + + return false; + } + + /** + * What follows a run: the next occurrence of a recurring event (never in the past), or the end of a single one. + */ + private function conclude(LaravelQueueDriver $driver): void + { + if ($this->recurrence === null || $this->interval === null || $this->interval <= 0) { + CronIndex::forget($this->hook, $this->args, LaravelQueueDriver::NAME); + + return; + } + + $next = $this->timestamp + $this->interval; + + if ($next <= time()) { + $next += (int) ceil((time() - $next + 1) / $this->interval) * $this->interval; + } + + $event = new Event($this->hook, $next, $this->recurrence, $this->args, $this->interval, LaravelQueueDriver::NAME, bin2hex(random_bytes(8))); + + CronIndex::record($event, LaravelQueueDriver::NAME); + $driver->send(self::for($event)); + } + + /** + * The same promise as a new job: the one a worker holds carries its queue message. + */ + private function again(): self + { + return new self($this->hook, $this->args, $this->timestamp, $this->id, $this->recurrence, $this->interval, $this->site); + } + + /** + * Run in the site that scheduled the event: on a multisite network the queue and its worker are shared. + */ + private function inSite(callable $run): void + { + $switch = function_exists('is_multisite') && is_multisite() && $this->site !== get_current_blog_id(); + + if ($switch) { + switch_to_blog($this->site); + } + + try { + $run(); + } finally { + if ($switch) { + restore_current_blog(); + } + } + } +} diff --git a/src/Schedule/Infrastructure/Services/LateEventRecovery.php b/src/Schedule/Infrastructure/Services/LateEventRecovery.php new file mode 100644 index 00000000..7e06b311 --- /dev/null +++ b/src/Schedule/Infrastructure/Services/LateEventRecovery.php @@ -0,0 +1,92 @@ +get()) { + return 0; + } + + try { + $sent = 0; + + foreach ($this->late() as $event) { + if ($event->id === null) { + $event = $event->withId(bin2hex(random_bytes(8))); + CronIndex::record($event, LaravelQueueDriver::NAME); + } + + // Sent again, then left alone for as long as it takes to count as late again + if (! $force && ! Cache::add('pollora_schedule_resent_'.$event->id, 1, $this->driver->lateAfter())) { + continue; + } + + $this->driver->send(RunScheduledEvent::for($event)); + $sent++; + } + + return $sent; + } finally { + $lock->release(); + } + } + + /** + * The events of the index more than late_after seconds past their time; one without an identifier gets one when it is sent again. + * + * @return list + */ + public function late(): array + { + $late = []; + + foreach (CronIndex::events(LaravelQueueDriver::NAME) as $event) { + if ($event->timestamp >= time() - $this->driver->lateAfter()) { + break; + } + + $late[] = $event; + } + + return $late; + } +} 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/Infrastructure/Services/ScheduleDiscovery.php b/src/Schedule/Infrastructure/Services/ScheduleDiscovery.php deleted file mode 100644 index b01db376..00000000 --- a/src/Schedule/Infrastructure/Services/ScheduleDiscovery.php +++ /dev/null @@ -1,351 +0,0 @@ - - */ - private const array DEFAULT_SCHEDULES = [ - 'hourly', - 'twicedaily', - 'daily', - 'weekly', - ]; - - /** - * {@inheritDoc} - * - * Discovers methods with Schedule attributes and collects them for registration. - * Only processes public methods that have the Schedule attribute. - */ - public function discover(DiscoveryLocationInterface $location, DiscoveredStructure $structure, ?ReflectionCacheInterface $reflectionCache = null): void - { - // Only process classes - if (! $structure instanceof DiscoveredClass) { - return; - } - - // Skip abstract classes - if ($structure->isAbstract) { - return; - } - - try { - $className = $structure->namespace.'\\'.$structure->name; - $methods = $reflectionCache->getPublicMethods($className); - - foreach ($methods as $method) { - $scheduleAttributes = $method->getAttributes(Schedule::class); - - if (empty($scheduleAttributes)) { - continue; - } - - foreach ($scheduleAttributes as $scheduleAttribute) { - // Collect the method for registration - $this->getItems()->add($location, [ - 'class' => $className, - 'method' => $method->getName(), - 'attribute' => $scheduleAttribute, - 'reflection_method' => $method, - ]); - } - } - } catch (\Throwable) { - // Skip classes that can't be reflected - // This might happen for classes with missing dependencies - return; - } - } - - /** - * {@inheritDoc} - * - * Applies discovered Schedule methods by registering them as WordPress cron events. - * Each discovered method is processed to extract schedule information, validate - * recurrence definitions, register custom schedules if needed, and finally - * register the scheduled task with WordPress cron system. - */ - public function apply(): void - { - foreach ($this->getItems() as $discoveredItem) { - [ - 'class' => $className, - 'method' => $methodName, - 'attribute' => $scheduleAttribute, - 'reflection_method' => $reflectionMethod - ] = $discoveredItem; - - try { - // Get the Schedule attribute instance - /** @var Schedule $schedule */ - $schedule = $scheduleAttribute->newInstance(); - - // Determine hook name (custom or generated) - $hookName = $schedule->hook ?? $this->generateHookName($className, $methodName); - - // Process the recurrence definition and get the schedule identifier - $scheduleIdentifier = $this->processRecurrence($schedule->recurrence, $hookName); - - // Register the action handler that will execute the scheduled method - add_action($hookName, function () use ($className, $methodName, $schedule): void { - // Use instance pool if available, otherwise fallback to container - $instance = $this->getInstanceFromPool($className); - - // Call method with arguments if provided - if ($schedule->args !== []) { - $instance->{$methodName}($schedule->args); - } else { - $instance->{$methodName}(); - } - }); - - // Schedule the cron event if not already scheduled - if (! wp_next_scheduled($hookName, $schedule->args)) { - $this->scheduleWordPressCron($hookName, $scheduleIdentifier, $schedule->args); - } - } catch (\Throwable $e) { - // Log the error but continue with other scheduled tasks - $this->logger?->error(sprintf('Failed to register Schedule from method %s::%s', $className, $methodName), ['exception' => $e]); - } - } - } - - /** - * Generate a hook name for a class method. - * - * Creates a human-readable hook name based on the class and method names - * using snake_case formatting, following WordPress naming conventions. - * - * @param string $className The fully qualified class name - * @param string $methodName The method name - * @return string The generated hook name in snake_case format - */ - private function generateHookName(string $className, string $methodName): string - { - // Extract just the class name without namespace - $shortClassName = class_basename($className); - - // Convert to snake_case following WordPress conventions - return Str::snake($shortClassName).'_'.Str::snake($methodName); - } - - /** - * Process and validate a recurrence definition. - * - * Converts various recurrence types (string, array, Every, Interval) into - * a standardized schedule identifier that can be used by WordPress cron. - * Also handles registration of custom schedules when needed. - * - * @param string|array|Every|Interval $recurrence The recurrence definition to process - * @param string $hookName The hook name for custom schedule registration - * @return string The processed schedule identifier - * - * @throws InvalidArgumentException If the recurrence type is unsupported or invalid - */ - private function processRecurrence(string|array|Every|Interval $recurrence, string $hookName): string - { - return match (true) { - // Handle string-based WordPress schedules - is_string($recurrence) => $this->validateAndReturnStringRecurrence($recurrence), - - // Handle array-based custom schedules - is_array($recurrence) => $this->processArrayRecurrence($recurrence, $hookName), - - // Handle Every enum values - $recurrence instanceof Every => $this->processEveryRecurrence($recurrence), - - // Handle Interval instances (only remaining type) - default => $this->processIntervalRecurrence($recurrence, $hookName), - }; - } - - /** - * Validate and return a string-based WordPress schedule. - * - * Ensures the provided string matches one of WordPress's built-in schedule intervals. - * - * @param string $recurrence The WordPress schedule name to validate - * @return string The validated schedule name - * - * @throws InvalidArgumentException If the schedule name is not recognized - */ - private function validateAndReturnStringRecurrence(string $recurrence): string - { - if (! in_array($recurrence, self::DEFAULT_SCHEDULES, true)) { - throw new InvalidArgumentException( - sprintf( - 'Invalid WordPress schedule "%s". Allowed schedules: %s', - $recurrence, - implode(', ', self::DEFAULT_SCHEDULES) - ) - ); - } - - return $recurrence; - } - - /** - * Process an array-based custom schedule. - * - * Validates that the array contains required 'interval' and 'display' keys, - * registers the custom schedule with WordPress, and returns the schedule identifier. - * - * @param array $recurrence The custom schedule definition array - * @param string $hookName The hook name to use as schedule identifier - * @return string The custom schedule identifier - * - * @throws InvalidArgumentException If the array structure is invalid - */ - private function processArrayRecurrence(array $recurrence, string $hookName): string - { - if (! isset($recurrence['interval']) || ! is_numeric($recurrence['interval'])) { - throw new InvalidArgumentException( - 'Custom schedule array must contain a numeric "interval" key (seconds)' - ); - } - - if (! isset($recurrence['display']) || ! is_string($recurrence['display'])) { - throw new InvalidArgumentException( - 'Custom schedule array must contain a string "display" key (human-readable name)' - ); - } - - // Register the custom schedule with WordPress - $scheduleKey = 'custom_'.md5($hookName); - $this->registerCustomSchedule($scheduleKey, $recurrence); - - return $scheduleKey; - } - - /** - * Process Every enum recurrence values. - * - * Converts Every enum values to their corresponding WordPress schedule names - * or registers custom schedules for non-standard intervals like MONTH/YEAR. - * - * @param Every $recurrence The Every enum value to process - * @return string The processed schedule identifier - */ - private function processEveryRecurrence(Every $recurrence): string - { - // For standard WordPress schedules, return the direct mapping - if (! $recurrence->isCustom()) { - return $recurrence->toScheduleKey(); - } - - // For custom schedules (MONTH, YEAR), register and return identifier - $scheduleKey = 'every_'.strtolower($recurrence->name); - $interval = $recurrence->toInterval(); - - $this->registerCustomSchedule($scheduleKey, [ - 'interval' => $interval->totalSeconds(), - 'display' => $interval->display, - ]); - - return $scheduleKey; - } - - /** - * Process Interval instance recurrence values. - * - * Registers a custom schedule for the Interval and returns its identifier. - * - * @param Interval $recurrence The Interval instance to process - * @param string $hookName The hook name for custom schedule registration - * @return string The custom schedule identifier - */ - private function processIntervalRecurrence(Interval $recurrence, string $hookName): string - { - // Generate unique identifier based on interval - $scheduleKey = 'interval_'.md5($hookName.'_'.$recurrence->totalSeconds()); - - $this->registerCustomSchedule($scheduleKey, [ - 'interval' => $recurrence->totalSeconds(), - 'display' => $recurrence->display, - ]); - - return $scheduleKey; - } - - /** - * Register a custom schedule with WordPress. - * - * Adds a new schedule interval to WordPress's cron system using the cron_schedules filter. - * This allows custom intervals beyond WordPress's built-in options. - * - * @param string $scheduleKey The unique key for the custom schedule - * @param array $schedule The schedule definition with 'interval' and 'display' keys - */ - private function registerCustomSchedule(string $scheduleKey, array $schedule): void - { - add_filter('cron_schedules', function (array $schedules) use ($scheduleKey, $schedule): array { - $schedules[$scheduleKey] = [ - 'interval' => (int) $schedule['interval'], - 'display' => (string) $schedule['display'], - ]; - - return $schedules; - }); - } - - /** - * Schedule a WordPress cron event - * - * @param string $hookName The hook name to schedule - * @param string $interval The interval (e.g., 'hourly', 'daily') - * @param array $args Optional arguments to pass to the hook - */ - private function scheduleWordPressCron(string $hookName, string $interval, array $args = []): void - { - if (wp_schedule_event(time(), $interval, $hookName, $args) === false) { - $this->logger?->error('Failed to schedule WordPress cron event for hook: '.$hookName); - } - } - - /** - * {@inheritDoc} - */ - public function getIdentifier(): string - { - return 'schedules'; - } -} diff --git a/src/Schedule/Infrastructure/Services/ScheduleFailures.php b/src/Schedule/Infrastructure/Services/ScheduleFailures.php new file mode 100644 index 00000000..e5ca27c4 --- /dev/null +++ b/src/Schedule/Infrastructure/Services/ScheduleFailures.php @@ -0,0 +1,46 @@ + $hook, 'time' => time(), 'message' => mb_substr($message, 0, 300)]; + + update_option(self::OPTION, array_slice($failures, -self::KEEP), false); + } + + /** + * Failures of the last week, oldest first. + * + * @return list + */ + public static function recent(): array + { + $stored = get_option(self::OPTION, []); + $recent = []; + + foreach (is_array($stored) ? $stored : [] as $failure) { + if (is_array($failure) && is_string($failure['hook'] ?? null) && is_int($failure['time'] ?? null) && $failure['time'] > time() - self::WEEK) { + $recent[] = ['hook' => $failure['hook'], 'time' => $failure['time'], 'message' => (string) ($failure['message'] ?? '')]; + } + } + + return $recent; + } +} diff --git a/src/Schedule/Interval.php b/src/Schedule/Interval.php deleted file mode 100644 index 448c2c91..00000000 --- a/src/Schedule/Interval.php +++ /dev/null @@ -1,34 +0,0 @@ - $this->totalSeconds(), - 'display' => $this->display, - ]; - } - - public function totalSeconds(): int - { - return $this->seconds - + $this->minutes * 60 - + $this->hours * 3600 - + $this->days * 86400 - + $this->weeks * 604800; - } -} diff --git a/src/Schedule/Jobs/JobDispatcher.php b/src/Schedule/Jobs/JobDispatcher.php deleted file mode 100755 index f3fc7fd1..00000000 --- a/src/Schedule/Jobs/JobDispatcher.php +++ /dev/null @@ -1,35 +0,0 @@ -dispatcher->dispatch($job); - } -} diff --git a/src/Schedule/Scheduler.php b/src/Schedule/Scheduler.php deleted file mode 100755 index c4c59006..00000000 --- a/src/Schedule/Scheduler.php +++ /dev/null @@ -1,370 +0,0 @@ -processCronDifferences($old_value, $value); - - return $old_value; - } - - /** - * Retrieve the cron option. - * - * @param mixed $value Current option value - * @return array The cron jobs array - */ - public function preOptionCron($value): array - { - if ($value !== false) { - return $value; - } - - return $this->generateCronArray(); - } - - /** - * Schedule a new event. - * - * @param mixed $pre Pre-filtered value - * @param object $event Event to schedule - * @param bool $wp_error Whether to return WP_Error on failure - * @return AbstractEvent|WP_Error|null The scheduled job or error - */ - public function preScheduleEvent($pre, object $event, bool $wp_error): AbstractEvent|WP_Error|null - { - if ($pre !== null) { - return $pre; - } - - try { - $job = $event->schedule ? new RecurringEvent($event) : new SingleEvent($event); - - return $job->createJob($event); - } catch (\Throwable $throwable) { - return $wp_error ? new WP_Error('schedule_error', $throwable->getMessage()) : null; - } - } - - /** - * Reschedule an event. - * - * @param mixed $pre Pre-filtered value - * @param object $event Event to reschedule - * @param bool $wp_error Whether to return WP_Error on failure - * @return AbstractEvent|WP_Error|null The rescheduled job or error - */ - public function preRescheduleEvent($pre, object $event, bool $wp_error): AbstractEvent|WP_Error|null - { - if ($pre !== null) { - return $pre; - } - - try { - $job = new RecurringEvent($event); - - return $job->createJob($event); - } catch (\Throwable $throwable) { - return $wp_error ? new WP_Error('reschedule_error', $throwable->getMessage()) : null; - } - } - - /** - * Unschedule an event. - * - * @param mixed $pre Pre-filtered value - * @param int $timestamp Event timestamp - * @param string $hook Event hook - * @param array $args Event arguments - * @param bool $wp_error Whether to return WP_Error on failure - * @return bool|WP_Error Whether the event was unscheduled - */ - public function preUnscheduleEvent($pre, int $timestamp, string $hook, array $args, bool $wp_error): bool|WP_Error - { - if ($pre !== null) { - return $pre; - } - - try { - $deleted = DB::table('wp_events') - ->where('hook', $hook) - ->where('timestamp', $timestamp) - ->where('args', json_encode($args)) - ->delete(); - - if ($deleted) { - $this->deleteAssociatedJob($hook, $timestamp, $args); - } - - return $deleted > 0; - } catch (\Throwable $throwable) { - return $wp_error ? new WP_Error('unschedule_error', $throwable->getMessage()) : false; - } - } - - /** - * Clear all scheduled hooks. - * - * @param mixed $pre Pre-filtered value - * @param string $hook Hook to clear - * @param array|null $args Arguments to match - * @param bool $wp_error Whether to return WP_Error on failure - * @return int|WP_Error The number of events cleared - */ - public function preClearScheduledHook($pre, string $hook, ?array $args, bool $wp_error): int|WP_Error - { - if ($pre !== null) { - return $pre; - } - - try { - $query = DB::table('wp_events')->where('hook', $hook); - - if ($args !== null) { - $query->where('args', json_encode($args)); - } - - $events = $query->get(); - $count = $events->count(); - - foreach ($events as $event) { - $this->deleteAssociatedJob($event->hook, $event->timestamp, json_decode((string) $event->args, true)); - } - - $query->delete(); - - return $count; - } catch (\Throwable $throwable) { - return $wp_error ? new WP_Error('clear_hook_error', $throwable->getMessage()) : 0; - } - } - - protected function deleteAssociatedJob(string $hook, int $timestamp, array $args) - { - $jobId = DB::table('wp_events') - ->where('hook', $hook) - ->where('timestamp', $timestamp) - ->where('args', json_encode($args)) - ->value('job_id'); - - if ($jobId) { - DB::table('jobs')->where('id', $jobId)->delete(); - } - } - - /** - * Unschedule all events attached to a specific hook. - * - * @param mixed $pre Pre-filtered value - * @param string $hook Hook to unschedule - * @param bool $wp_error Whether to return WP_Error on failure - * @return int|WP_Error The number of events unscheduled - */ - public function preUnscheduleHook($pre, string $hook, bool $wp_error): int|WP_Error - { - return $this->preClearScheduledHook($pre, $hook, null, $wp_error); - } - - /** - * Retrieve a scheduled event. - * - * @param mixed $pre Pre-filtered value - * @param string $hook Event hook - * @param array $args Event arguments - * @param int|null $timestamp Unix timestamp (UTC) of the event. Null to retrieve next scheduled event. - * @return object|false The event object or false if not found - */ - public function preGetScheduledEvent($pre, string $hook, array $args, ?int $timestamp): object|false - { - if ($pre !== null) { - return $pre; - } - - $query = DB::table('wp_events') - ->where('hook', $hook); - - if ($args !== []) { - $query->where('args', json_encode($args)); - } - - if ($timestamp !== null) { - $query->where('timestamp', $timestamp); - } - - $event = $query->first(); - - if (! $event) { - return false; - } - - return $this->convertDbEventToWpEvent($event); - } - - protected function convertDbEventToWpEvent(object $event): object - { - return (object) [ - 'hook' => $event->hook, - 'timestamp' => $event->schedule ? $this->getNextRunTime($event->schedule, $event->interval) : $event->timestamp, - 'schedule' => $event->schedule, - 'args' => json_decode($event->args, true), - 'interval' => $event->interval, - ]; - } - - /** - * Generate a unique job ID. - * - * @param int|null $timestamp Event timestamp - * @param string $hook Event hook - * @param array $args Event arguments - * @return string The generated job ID - */ - protected function generateJobId(?int $timestamp, string $hook, array $args): string - { - return md5(serialize([$timestamp, $hook, $args])); - } - - /** - * Convert a job to a WordPress event object. - * - * @param object $job The job to convert - * @return object The converted event object - */ - protected function convertJobToEvent(object $job): object - { - $event = new \stdClass; - $event->hook = $job->wp_hook; - $event->timestamp = $job->available_at; - $event->args = json_decode($job->wp_args, true); - $event->schedule = $job->wp_schedule ?? false; - $event->interval = $job->wp_interval ?? null; - - return $event; - } - - /** - * Process the differences between old and new cron arrays. - * - * @param array $oldCrons The old cron array - * @param array $newCrons The new cron array - */ - protected function processCronDifferences(array $oldCrons, array $newCrons): void - { - // Implementation to handle differences (add, update, delete jobs) - } - - /** - * Generate a cron array from the current queue jobs. - * - * @return array The generated cron array - */ - protected function generateCronArray(): array - { - $jobs = $this->getAllJobs(); - - return $this->convertJobsToWordPressCronArray($jobs); - } - - /** - * Get all jobs from the queue. - */ - protected function getAllJobs(): array - { - return DB::table('wp_events') - ->orderBy('timestamp', 'asc') - ->get() - ->map(fn (object $job): object => $this->convertDbEventToWpEvent($job)) - ->all(); - } - - /** - * Retrieve cron jobs ready to be run. - * - * @param mixed $pre Pre-filtered value - * @return array The array of ready cron jobs - */ - public function preGetReadyCronJobs($pre): array - { - if ($pre !== null) { - return $pre; - } - - return []; - } - - /** - * Convert an array of jobs to a WordPress cron array format. - * - * @param array $jobs The jobs to convert - * @return array The converted cron array - */ - protected function convertJobsToWordPressCronArray(array $jobs): array - { - $crons = []; - - foreach ($jobs as $job) { - if (! $job->hook) { - continue; - } - - $timestamp = $job->timestamp; - $hook = $job->hook; - $key = md5(serialize($job->args)); - - $crons[$timestamp][$hook][$key] = [ - 'schedule' => $job->schedule, - 'args' => $job->args, - ]; - - if ($job->interval) { - $crons[$timestamp][$hook][$key]['interval'] = $job->interval; - } - } - - ksort($crons, SORT_NUMERIC); - $crons['version'] = 2; - - return $crons; - } - - /** - * Get the next run time for a scheduled event. - * - * @param string $schedule WordPress schedule name - * @param int|null $interval Custom interval in seconds - * @return int Next run timestamp - */ - protected function getNextRunTime(string $schedule, ?int $interval): int - { - $cron = RecurringEvent::getCronExpression($schedule, $interval); - $cron = new CronExpression($cron); - - return $cron->getNextRunDate()->getTimestamp(); - } -} diff --git a/src/Schedule/SchedulerDiscoveryServiceProvider.php b/src/Schedule/SchedulerDiscoveryServiceProvider.php index 19e1c7e0..529aae99 100755 --- a/src/Schedule/SchedulerDiscoveryServiceProvider.php +++ b/src/Schedule/SchedulerDiscoveryServiceProvider.php @@ -22,5 +22,7 @@ class SchedulerDiscoveryServiceProvider extends ServiceProvider public function register(): void { $this->app->singleton(ScheduleDiscovery::class, fn (): ScheduleDiscovery => new ScheduleDiscovery); + + Schedule::useContainer($this->app); } } diff --git a/src/Schedule/SchedulerServiceProvider.php b/src/Schedule/SchedulerServiceProvider.php index ca4f178a..87998f59 100755 --- a/src/Schedule/SchedulerServiceProvider.php +++ b/src/Schedule/SchedulerServiceProvider.php @@ -4,22 +4,24 @@ 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\Hook\Domain\Contract\Filter; -use Pollora\Schedule\Application\UseCases\RegisterSchedulerFiltersUseCase; -use Pollora\Schedule\Contracts\SchedulerInterface; -use Pollora\Schedule\Events\RecurringEvent; +use Pollora\Doctor\Infrastructure\Providers\DoctorServiceProvider; +use Pollora\Schedule\Application\Services\ScheduleStatus; +use Pollora\Schedule\Infrastructure\Checks\ScheduleDriverCheck; +use Pollora\Schedule\Infrastructure\Drivers\LaravelQueueDriver; +use Pollora\Schedule\Infrastructure\Services\LateEventRecovery; +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; +use Pollora\Schedule\UI\Console\ScheduleRecoverCommand; /** - * Service provider for WordPress cron scheduler functionality. - * - * Registers and bootstraps the scheduler services, including filters - * and recurring event scheduling. + * Service provider for scheduled events: the driver of the site, the commands + * and the checks around it. */ class SchedulerServiceProvider extends ServiceProvider { @@ -29,7 +31,18 @@ class SchedulerServiceProvider extends ServiceProvider 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))); + + // Before anything creates the engine: POLLORA_SCHEDULE_DRIVER may name it + ScheduleApi::extend(LaravelQueueDriver::NAME, fn (): LaravelQueueDriver => $this->app->make(LaravelQueueDriver::class)); + ScheduleApi::useContainer($this->app); + $this->chooseDriver($this->app->make(Repository::class)); + + if ($this->app->runningInConsole()) { + $this->commands([ScheduleListCommand::class, ScheduleMigrateCommand::class, ScheduleRecoverCommand::class]); + } } /** @@ -37,58 +50,61 @@ public function register(): void */ public function boot(): void { - if (config('wordpress.use_laravel_scheduler', false)) { - $this->app->make(RegisterSchedulerFiltersUseCase::class)->execute(); - } + $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(); $this->app->booted(function (): void { - $this->scheduleRecurringEvents(); + // The driver was chosen when the application started; WordPress can take its hooks by now + ScheduleApi::boot(); + $this->recoverLateEvents(); }); } /** - * Register domain contracts with their infrastructure implementations. + * Choose the driver of the site, WP-Cron when config/schedule.php names one that does not exist (pollora:doctor says so). */ - private function registerDomainContracts(): void + private function chooseDriver(Repository $config): void { - $this->app->singleton(SchedulerInterface::class, Scheduler::class); - $this->app->singleton(ScheduleDiscovery::class, fn (): ScheduleDiscovery => new ScheduleDiscovery); + $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 application use cases. + * Register domain contracts with their infrastructure implementations. */ - private function registerUseCases(): void + private function registerDomainContracts(): void { - $this->app->bind(RegisterSchedulerFiltersUseCase::class, fn (Application $app): RegisterSchedulerFiltersUseCase => new RegisterSchedulerFiltersUseCase( - $app->make(Filter::class), - $app->make(SchedulerInterface::class) - )); - + $this->app->singleton(ScheduleDiscovery::class, fn (): ScheduleDiscovery => new ScheduleDiscovery); + $this->app->singleton(LaravelQueueDriver::class); + $this->app->singleton(LateEventRecovery::class); } /** - * Schedule all recurring events. + * Look for events of the "laravel" driver whose job was lost, when the request ends, at most every five minutes. */ - protected function scheduleRecurringEvents(): void + private function recoverLateEvents(): void { - if ($this->isOrchestraTest() || defined('WP_CLI')) { + if (! function_exists('add_action') || ScheduleApi::engine()->driverName() !== LaravelQueueDriver::NAME) { return; } - try { - $schedule = $this->app->make(Schedule::class); - RecurringEvent::scheduleAllEvents($schedule); - } catch (QueryException) { - // WordPress tables may not exist yet during initial installation - } - } - - /** - * Check if we're running in an Orchestra test environment. - */ - private function isOrchestraTest(): bool - { - return defined('LARAVEL_START') && class_exists(TestCase::class); + add_action('shutdown', function (): void { + if ($this->app->make(LaravelQueueDriver::class)->available()) { + $this->app->make(LateEventRecovery::class)->recover(); + } + }); } } 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/src/Schedule/UI/Console/ScheduleRecoverCommand.php b/src/Schedule/UI/Console/ScheduleRecoverCommand.php new file mode 100644 index 00000000..ef195176 --- /dev/null +++ b/src/Schedule/UI/Console/ScheduleRecoverCommand.php @@ -0,0 +1,58 @@ +option('network') && function_exists('is_multisite') && is_multisite() + ? array_map(static fn ($site): int => (int) $site->blog_id, get_sites(['fields' => 'objects', 'number' => 0])) + : [null]; + + $total = 0; + + foreach ($sites as $site) { + if ($site !== null) { + switch_to_blog($site); + } + + try { + $total += $this->option('dry-run') ? $this->list($recovery, $site) : $recovery->recover(force: true); + } finally { + if ($site !== null) { + restore_current_blog(); + } + } + } + + $this->info(sprintf($this->option('dry-run') ? '%d late event(s).' : '%d event(s) sent again.', $total)); + + return self::SUCCESS; + } + + private function list(LateEventRecovery $recovery, ?int $site): int + { + $late = $recovery->late(); + + foreach ($late as $event) { + $this->line(sprintf('%s%s %s', $site === null ? '' : sprintf('[site %d] ', $site), gmdate('Y-m-d H:i:s', $event->timestamp), $event->hook)); + } + + return count($late); + } +} diff --git a/src/Support/Slug.php b/src/Support/Slug.php index 7d0ae61a..94a1c9a9 100644 --- a/src/Support/Slug.php +++ b/src/Support/Slug.php @@ -4,6 +4,8 @@ namespace Pollora\Support; +use Pollora\Attributes\Support\Naming; + /** * Utility class for slug generation. * @@ -25,15 +27,7 @@ class Slug */ public static function fromClassName(string $className): string { - // Extract the base class name (remove namespace) - $baseName = basename(str_replace('\\', '/', $className)); - - // Remove "Command" suffix if present - if (self::endsWith($baseName, 'Command')) { - $baseName = substr($baseName, 0, -7); - } - - return self::toKebabCase($baseName); + return Naming::commandFromClassName($className); } /** @@ -50,7 +44,7 @@ public static function fromClassName(string $className): string */ public static function fromMethodName(string $methodName): string { - return self::toKebabCase($methodName); + return Naming::slug($methodName); } /** @@ -61,35 +55,6 @@ public static function fromMethodName(string $methodName): string */ public static function toKebabCase(string $string): string { - // Insert a hyphen before any uppercase letter that follows a lowercase letter or number - $string = preg_replace('/([a-z0-9])([A-Z])/', '$1-$2', $string); - - // Convert to lowercase - $string = strtolower((string) $string); - - // Replace multiple consecutive hyphens with a single hyphen - $string = preg_replace('/-+/', '-', $string); - - // Trim hyphens from the beginning and end - $string = trim((string) $string, '-'); - - return $string; - } - - /** - * Check if a string ends with a specific suffix. - * - * @param string $haystack The string to check - * @param string $needle The suffix to look for - * @return bool True if the string ends with the suffix - */ - private static function endsWith(string $haystack, string $needle): bool - { - $length = strlen($needle); - if ($length === 0) { - return true; - } - - return substr($haystack, -$length) === $needle; + return Naming::slug($string); } } diff --git a/src/Support/WpGlobals.php b/src/Support/WpGlobals.php deleted file mode 100755 index 7f4c22d1..00000000 --- a/src/Support/WpGlobals.php +++ /dev/null @@ -1,90 +0,0 @@ - - */ - private static array $essentialGlobals = [ - 'current_user', - 'wpdb', - 'wp_roles', - 'wp', - 'wp_query', - 'post', - 'pagenow', - 'hook_suffix', - 'wp_filter', - 'wp_actions', - 'wp_current_filter', - ]; - - /** - * Wraps a callback function with WordPress globals. - * - * @param callable $callback The function to wrap - * @param array|null $globals Optional specific list of globals to preserve - * @return callable The wrapped function with access to WordPress globals - */ - public static function wrap(callable $callback, ?array $globals = null): callable - { - // If $globals is null, use the default list - $globalKeys = $globals ?? self::$essentialGlobals; - - // Capture the global variables - $capturedGlobals = array_intersect_key($GLOBALS, array_flip($globalKeys)); - - return static function (...$args) use ($callback, $capturedGlobals) { - // Reinject the globals into the context - foreach ($capturedGlobals as $key => $value) { - $GLOBALS[$key] = $value; - } - - // Execute the callback - return $callback(...$args); - }; - } - - /** - * Adds a global to the list of essential globals. - * - * @param string $globalName Name of the global variable to add - */ - public static function addEssentialGlobal(string $globalName): void - { - if (! in_array($globalName, self::$essentialGlobals)) { - self::$essentialGlobals[] = $globalName; - } - } - - /** - * Sets multiple essential globals at once. - * - * @param array $globalNames An array of global variable names - */ - public static function setEssentialGlobals(array $globalNames): void - { - self::$essentialGlobals = $globalNames; - } - - /** - * Gets the current list of essential globals. - * - * @return array The list of essential global variable names - */ - public static function getEssentialGlobals(): array - { - return self::$essentialGlobals; - } -} diff --git a/src/Taxonomy/Infrastructure/Services/TaxonomyConfiguration.php b/src/Taxonomy/Infrastructure/Services/TaxonomyConfiguration.php index c6acae9b..0a289bc8 100644 --- a/src/Taxonomy/Infrastructure/Services/TaxonomyConfiguration.php +++ b/src/Taxonomy/Infrastructure/Services/TaxonomyConfiguration.php @@ -96,6 +96,16 @@ public function getArg(string $key, mixed $default = null): mixed return $this->attributeArgs[$key] ?? $default; } + /** + * Replace every attribute argument. + * + * @param array $args The arguments to keep + */ + public function replaceArgs(array $args): void + { + $this->attributeArgs = $args; + } + /** * Merge additional arguments into the configuration. * diff --git a/src/Taxonomy/Infrastructure/Services/TaxonomyDiscovery.php b/src/Taxonomy/Infrastructure/Services/TaxonomyDiscovery.php index 2cb6ca82..c5f4880e 100644 --- a/src/Taxonomy/Infrastructure/Services/TaxonomyDiscovery.php +++ b/src/Taxonomy/Infrastructure/Services/TaxonomyDiscovery.php @@ -5,6 +5,9 @@ namespace Pollora\Taxonomy\Infrastructure\Services; use Illuminate\Support\Str; +use Pollora\Attributes\Contracts\ConfiguresArguments; +use Pollora\Attributes\Contracts\ConfiguresArgumentsWithMethod; +use Pollora\Attributes\Contracts\HandlesAttributes; use Pollora\Attributes\Taxonomy; use Pollora\Discovery\Domain\Contracts\ConfigurableDiscoveryInterface; use Pollora\Discovery\Domain\Contracts\DiscoveryInterface; @@ -307,8 +310,13 @@ private function processClassAttribute(ReflectionClass $reflectionClass, \Reflec { $attributeInstance = $attribute->newInstance(); - // Check if the attribute has a handle method and call it - if (method_exists($attributeInstance, 'handle')) { + if ($attributeInstance instanceof ConfiguresArguments) { + $config->replaceArgs($attributeInstance->configureArguments($config->withArgs())); + + return; + } + + if ($attributeInstance instanceof HandlesAttributes) { $attributeInstance->handle(app(), $config, $reflectionClass, $attributeInstance); } } @@ -330,8 +338,13 @@ private function processMethodAttribute( ): void { $attributeInstance = $attribute->newInstance(); - // Check if the attribute has a handle method and call it - if (method_exists($attributeInstance, 'handle')) { + if ($attributeInstance instanceof ConfiguresArgumentsWithMethod) { + $config->replaceArgs($attributeInstance->configureArguments($config->withArgs(), [new $method->class, $method->getName()])); + + return; + } + + if ($attributeInstance instanceof HandlesAttributes) { $attributeInstance->handle(app(), $config, $method, $attributeInstance); } } diff --git a/src/WordPress/Bootstrap.php b/src/WordPress/Bootstrap.php index 80f908e2..f3c4554b 100644 --- a/src/WordPress/Bootstrap.php +++ b/src/WordPress/Bootstrap.php @@ -396,6 +396,15 @@ private function defineWordPressConstants(): void Constant::queue('JETPACK_DEV_DEBUG', $debugMode); + if (! function_exists('getenv') || getenv('WP_ENVIRONMENT_TYPE') === false) { + Constant::queue('WP_ENVIRONMENT_TYPE', match (config('app.env')) { + 'local' => 'local', + 'dev', 'development', 'testing' => 'development', + 'staging' => 'staging', + default => 'production', + }); + } + foreach ((array) config('wordpress.constants', []) as $key => $value) { $key = strtoupper((string) $key); Constant::queue($key, $value); diff --git a/src/WpCli/Application/Services/WpCliService.php b/src/WpCli/Application/Services/WpCliService.php deleted file mode 100644 index 3f52a2f0..00000000 --- a/src/WpCli/Application/Services/WpCliService.php +++ /dev/null @@ -1,118 +0,0 @@ - - */ - private array $registeredCommands = []; - - public function __construct( - private readonly WpCliAdapter $wpCliAdapter, - private readonly ?LoggerInterface $logger = null - ) {} - - /** - * Register a WP CLI command. - * - * @param string $name The command name - * @param string|array $className The command class name or callable array - * @param string $description The command description - * @param int $priority The registration priority - * @param array $args Additional WP_CLI::add_command() arguments - */ - public function register(string $name, string|array $className, string $description = '', int $priority = 0, array $args = []): void - { - $this->registeredCommands[$name] = [ - 'class' => $className, - 'description' => $description, - 'priority' => $priority, - 'args' => $args, - ]; - - // Register immediately if WP CLI is available - $this->registerWithWpCli($name, $className, $args); - } - - /** - * Register a command with WP CLI through the adapter. - * - * @param string $name The command name - * @param string|array $className The command class name or callable array - * @param array $args Additional WP_CLI::add_command() arguments - */ - private function registerWithWpCli(string $name, string|array $className, array $args = []): void - { - if (! $this->wpCliAdapter->isAvailable()) { - return; - } - - try { - // Delegate validation and registration to the adapter - $this->wpCliAdapter->addCommand($name, $className, $args); - } catch (\Throwable $throwable) { - $this->logger?->error(sprintf('Failed to register WP CLI command %s: ', $name).$throwable->getMessage()); - } - } - - /** - * Initialize all registered commands. - * This method can be called to ensure all commands are properly initialized. - */ - public function initializeCommands(): void - { - if (! $this->wpCliAdapter->isAvailable()) { - return; - } - - foreach ($this->registeredCommands as $name => $commandData) { - $this->registerWithWpCli($name, $commandData['class'], $commandData['args']); - } - } - - /** - * Get all registered commands. - * - * @return array - */ - public function getRegisteredCommands(): array - { - return $this->registeredCommands; - } - - /** - * Check if a command is registered. - * - * @param string $name The command name - */ - public function hasCommand(string $name): bool - { - return isset($this->registeredCommands[$name]); - } - - /** - * Get command information. - * - * @param string $name The command name - * @return array{class: string|array, description: string, priority: int, args: array}|null - */ - public function getCommand(string $name): ?array - { - return $this->registeredCommands[$name] ?? null; - } -} diff --git a/src/WpCli/Domain/Contracts/WpCliCommandInterface.php b/src/WpCli/Domain/Contracts/WpCliCommandInterface.php deleted file mode 100644 index eb1c2c21..00000000 --- a/src/WpCli/Domain/Contracts/WpCliCommandInterface.php +++ /dev/null @@ -1,43 +0,0 @@ - $arguments The command arguments - * @param array $options The command options/flags - */ - public function __invoke(array $arguments, array $options): void; - - /** - * Get the command priority for registration order. - * - * @return int The priority (higher numbers = higher priority) - */ - public function getPriority(): int; - - /** - * Initialize the command. - * This method is called during the discovery and registration process. - */ - public function initialize(): void; - - /** - * Get the default command name from the AsWordpressCommand attribute. - * - * @return string|null The command name or null if not found - */ - public static function getDefaultName(): ?string; -} diff --git a/src/WpCli/Domain/Models/WordpressCommand.php b/src/WpCli/Domain/Models/WordpressCommand.php deleted file mode 100644 index e9e84c35..00000000 --- a/src/WpCli/Domain/Models/WordpressCommand.php +++ /dev/null @@ -1,122 +0,0 @@ - $arguments The command arguments - * @param array $options The command options/flags - */ - abstract public function __invoke(array $arguments, array $options): void; - - /** - * Get the command priority for registration order. - * Override this method to change the registration priority. - * - * @return int The priority (higher numbers = higher priority) - */ - public function getPriority(): int - { - return 0; - } - - /** - * Get command registration data. - * Returns data needed for command registration without directly interacting with WP-CLI. - * This respects the hexagonal architecture by keeping the domain layer pure. - * - * @return array{name: string|null, description: string|null, class: string} - */ - public function getRegistrationData(): array - { - return [ - 'name' => static::getDefaultName(), - 'description' => static::getDefaultDescription(), - 'class' => static::class, - ]; - } - - /** - * Get the default command name from the WpCli attribute. - * - * @return string|null The command name or null if not found - */ - public static function getDefaultName(): ?string - { - $class = static::class; - - try { - $reflectionClass = new \ReflectionClass($class); - $attributes = $reflectionClass->getAttributes(WpCli::class); - - if ($attributes !== []) { - /** @var WpCli $attribute */ - $attribute = $attributes[0]->newInstance(); - - return $attribute->getCommandName($class); - } - } catch (\ReflectionException) { - // Reflection failure is handled by returning null - } - - return null; - } - - /** - * Get the default command description from the WpCli attribute. - * - * @return string|null The command description or null if not found - */ - public static function getDefaultDescription(): ?string - { - $class = static::class; - - try { - $reflectionClass = new \ReflectionClass($class); - $attributes = $reflectionClass->getAttributes(WpCli::class); - - if ($attributes !== []) { - /** @var WpCli $attribute */ - $attribute = $attributes[0]->newInstance(); - - return null; - } - } catch (\ReflectionException) { - // Reflection failure is handled by returning null - } - - return null; - } - - /** - * Get the command description from the WpCli attribute. - * This method is called by WP CLI to get the command description. - * - * @return string The command description - */ - public static function get_shortdesc(): string - { - return static::getDefaultDescription() ?? ''; - } -} diff --git a/src/WpCli/Infrastructure/Adapters/WpCliAdapter.php b/src/WpCli/Infrastructure/Adapters/WpCliAdapter.php deleted file mode 100644 index aae45f5c..00000000 --- a/src/WpCli/Infrastructure/Adapters/WpCliAdapter.php +++ /dev/null @@ -1,144 +0,0 @@ - $args Additional arguments for WP-CLI - * - * @throws \RuntimeException If WP-CLI is not available - * @throws \InvalidArgumentException If the handler is invalid - */ - public function addCommand(string $name, string|array|object $handler, array $args = []): void - { - if (! $this->isAvailable()) { - throw new \RuntimeException('WP-CLI is not available'); - } - - $this->validateHandler($handler); - - WP_CLI::add_command($name, $handler, $args); - } - - /** - * Validate that the handler is properly formatted. - * - * @throws \InvalidArgumentException If the handler is invalid - */ - private function validateHandler(string|array|object $handler): void - { - if (is_string($handler)) { - if (! class_exists($handler)) { - throw new \InvalidArgumentException(sprintf("Command handler class '%s' does not exist", $handler)); - } - } elseif (is_array($handler)) { - if (count($handler) !== 2) { - throw new \InvalidArgumentException('Command handler array must contain exactly 2 elements [object, method]'); - } - - [$object, $method] = $handler; - - if (! is_object($object)) { - throw new \InvalidArgumentException('First element of handler array must be an object'); - } - - if (! is_string($method)) { - throw new \InvalidArgumentException('Second element of handler array must be a method name string'); - } - - if (! method_exists($object, $method)) { - $class = $object::class; - throw new \InvalidArgumentException(sprintf("Method '%s' does not exist on class '%s'", $method, $class)); - } - } elseif (is_object($handler)) { - if (! method_exists($handler, '__invoke')) { - $class = $handler::class; - throw new \InvalidArgumentException(sprintf("Object of class '%s' must be invokable (have __invoke method)", $class)); - } - } - } - - /** - * Check if a command is already registered with WP-CLI. - * - * @param string $name The command name - * @return bool True if the command exists - */ - public function hasCommand(string $name): bool - { - if (! $this->isAvailable()) { - return false; - } - - try { - $runner = WP_CLI::get_runner(); - - return $runner->find_command_to_run(explode(' ', $name)) !== null; - } catch (\Throwable) { - return false; - } - } - - /** - * Get WP-CLI version information. - * - * @return string|null The WP-CLI version or null if not available - */ - public function getVersion(): ?string - { - if (! $this->isAvailable()) { - return null; - } - - return WP_CLI_VERSION ?? null; - } - - /** - * Log a message to WP-CLI output. - * - * @param string $message The message to log - * @param string $level The log level (debug, log, warning, error, success) - */ - public function log(string $message, string $level = 'log'): void - { - if (! $this->isAvailable()) { - return; - } - - try { - match ($level) { - 'debug' => WP_CLI::debug($message), - 'warning' => WP_CLI::warning($message), - 'error' => WP_CLI::error($message), - 'success' => WP_CLI::success($message), - default => WP_CLI::log($message), - }; - } catch (\Throwable) { - // Silently fail for logging to avoid breaking command execution - } - } -} diff --git a/src/WpCli/Infrastructure/Adapters/WpCliMethodWrapper.php b/src/WpCli/Infrastructure/Adapters/WpCliMethodWrapper.php deleted file mode 100644 index f0c8430b..00000000 --- a/src/WpCli/Infrastructure/Adapters/WpCliMethodWrapper.php +++ /dev/null @@ -1,81 +0,0 @@ -docComment = $this->originalMethod->getDocComment() ?: ''; - } - - public function __invoke(array $args, array $assocArgs): mixed - { - // Delegate to the original method - return $this->originalMethod->invoke($this->instance, $args, $assocArgs); - } - - /** - * Magic method to expose the original method name when WP-CLI reflects on this object - * This allows WP-CLI to find a method with the same name as the original. - */ - public function __call(string $name, array $arguments): mixed - { - if ($name === $this->originalMethod->getName()) { - return $this->originalMethod->invoke($this->instance, ...$arguments); - } - - throw new \BadMethodCallException(sprintf('Method %s does not exist', $name)); - } - - /** - * Creates a dynamic method with the original method's name and documentation. - * This method will have the same name and docblock as the original method, - * allowing WP-CLI to properly extract documentation. - */ - public function createProxyMethod(): ReflectionMethod - { - return $this->originalMethod; - } - - /** - * Get the original method for reflection purposes - */ - public function getOriginalMethod(): ReflectionMethod - { - return $this->originalMethod; - } - - /** - * Get the original instance - */ - public function getOriginalInstance(): object - { - return $this->instance; - } - - /** - * Get the cached docblock - */ - public function getDocComment(): string|false - { - return $this->docComment ?: false; - } -} diff --git a/src/WpCli/Infrastructure/Services/WpCliDiscovery.php b/src/WpCli/Infrastructure/Services/WpCliDiscovery.php deleted file mode 100644 index 36c9baeb..00000000 --- a/src/WpCli/Infrastructure/Services/WpCliDiscovery.php +++ /dev/null @@ -1,321 +0,0 @@ - - */ - private array $commandInstances = []; - - public function __construct( - private readonly WpCliService $wpCliService, - private readonly ?LoggerInterface $logger = null - ) {} - - /** - * {@inheritDoc} - */ - public function discover(DiscoveryLocationInterface $location, DiscoveredStructure $structure, ?ReflectionCacheInterface $reflectionCache = null): void - { - if (! $structure instanceof DiscoveredClass || $structure->isAbstract) { - return; - } - - foreach ($structure->attributes as $attribute) { - if ($attribute->class === WpCli::class) { - $this->getItems()->add($location, [ - 'class' => $structure->namespace.'\\'.$structure->name, - 'reflection_cache' => $reflectionCache, - ]); - - return; - } - } - } - - /** - * {@inheritDoc} - */ - public function apply(): void - { - foreach ($this->getItems() as $discoveredItem) { - try { - $reflectionCache = $discoveredItem['reflection_cache'] ?? null; - $this->processWpCliCommand($discoveredItem['class'], $reflectionCache); - } catch (\Throwable $e) { - $this->logger?->error(sprintf('Failed to register WP CLI command from class %s: ', $discoveredItem['class']).$e->getMessage()); - } - } - } - - /** - * Process a WP CLI command class for registration. - */ - private function processWpCliCommand(string $className, ?ReflectionCacheInterface $reflectionCache = null): void - { - $reflectionClass = $reflectionCache->getClassReflection($className); - - if (! $reflectionClass->isInstantiable()) { - return; - } - - $attributes = $reflectionClass->getAttributes(WpCli::class); - if ($attributes === []) { - return; - } - - /** @var WpCli $attribute */ - $attribute = $attributes[0]->newInstance(); - $commandName = $attribute->getCommandName($className); - - if (empty($commandName)) { - $this->logger?->error(sprintf('WP CLI command %s has no command name defined', $className)); - - return; - } - - // On garde quand même l'instance dispo pour les subcommands - $instance = $this->getCommandInstance($className); - - if ($this->hasSubcommands($reflectionClass)) { - $this->processSubcommands($reflectionClass, $className, $commandName, $instance); - } else { - // IMPORTANT : on repasse au class-string, pas à l'instance - $this->registerCommand( - $commandName, - $className, - $this->collectWpCliArguments($reflectionClass) - ); - } - } - - /** - * Retourne une instance unique de la classe de commande. - * - * @param class-string $className - */ - private function getCommandInstance(string $className): object - { - // Use instance pool if available, otherwise fallback to local cache - return $this->getInstanceFromPool($className, function () use ($className): object { - // On laisse le container gérer la construction - $this->commandInstances[$className] ??= resolve($className); - - return $this->commandInstances[$className]; - }); - } - - /** - * Check if the class has subcommands (no __invoke and has #[Command] methods). - */ - private function hasSubcommands(ReflectionClass $reflectionClass): bool - { - if ($reflectionClass->hasMethod('__invoke')) { - return false; - } - - foreach ($reflectionClass->getMethods() as $method) { - if ($method->getAttributes(Command::class) !== []) { - return true; - } - } - - return false; - } - - /** - * Process and register subcommands for a class. - */ - private function processSubcommands( - ReflectionClass $reflectionClass, - string $className, - string $baseCommandName, - object $instance - ): void { - // Base command => class-string, comme avant - $this->registerCommand( - $baseCommandName, - $className, - $this->collectWpCliArguments($reflectionClass) - ); - - // Subcommands => [instance, méthode] - foreach ($reflectionClass->getMethods() as $method) { - $commandAttributes = $method->getAttributes(Command::class); - if ($commandAttributes === []) { - continue; - } - - /** @var Command $commandAttribute */ - $commandAttribute = $commandAttributes[0]->newInstance(); - $subcommandName = $commandAttribute->getSubcommandName($method->getName()); - $fullCommandName = sprintf('%s %s', $baseCommandName, $subcommandName); - - $handler = $this->createCallable($instance, $method); - - $this->registerCommand( - $fullCommandName, - $handler, - $this->collectWpCliArguments($method) - ); - } - } - - /** - * Register a command through the WP CLI service only. - * This ensures single responsibility and avoids duplication. - * - * @param array $args - */ - private function registerCommand(string $commandName, string|array|\Closure $handler, array $args = []): void - { - // Delegate to the application service which handles WP-CLI registration - $this->wpCliService->register($commandName, $handler, '', 0, $args); - } - - /** - * Create a callable for a method (handles private/protected methods). - */ - private function createCallable(object $instance, ReflectionMethod $method): array|\Closure - { - if ($method->isPublic()) { - // Cas simple : WP-CLI peut appeler directement [instance, 'methodName'] - return [$instance, $method->getName()]; - } - - // Créer un wrapper qui se comporte comme la méthode originale - $wrapper = new WpCliMethodWrapper($instance, $method); - - // Retourner un callable qui préserve l'accès à la documentation - return $wrapper->__invoke(...); - } - - /** - * Collect WP CLI arguments from reflection attributes. - */ - private function collectWpCliArguments(ReflectionClass|ReflectionMethod $reflection): array - { - $args = []; - - $attributeMap = [ - BeforeInvoke::class => ['callback', 'before_invoke'], - AfterInvoke::class => ['callback', 'after_invoke'], - Synopsis::class => ['synopsis', 'synopsis'], - When::class => ['hook', 'when'], - IsDeferred::class => ['deferred', 'is_deferred'], - ]; - - foreach ($attributeMap as $attributeClass => [$property, $wpCliKey]) { - $attributes = $reflection->getAttributes($attributeClass); - if ($attributes !== []) { - $args[$wpCliKey] = $attributes[0]->newInstance()->$property; - } - } - - // Si c'est une méthode, extraire la documentation du docblock - if ($reflection instanceof ReflectionMethod) { - $docComment = $reflection->getDocComment(); - if ($docComment) { - // Extraire la description courte et longue du docblock - $description = $this->extractMethodDescription($docComment); - $args['shortdesc'] = $description['short']; - - if (! empty($description['long'])) { - $args['longdesc'] = $description['long']; - } - } - } - - return $args; - } - - /** - * Extract description from method docblock for WP-CLI help. - */ - private function extractMethodDescription(string $docComment): array - { - // Remove /** and */ and leading asterisks - $cleaned = preg_replace('/^\/\*\*|\*\/$/', '', $docComment); - $lines = explode("\n", (string) $cleaned); - - $description = ['short' => '', 'long' => '']; - $inLongDesc = false; - $longDescLines = []; - - foreach ($lines as $line) { - $line = trim(ltrim($line, ' *')); - - // Skip empty lines at the beginning - if (($line === '' || $line === '0') && empty($description['short']) && $longDescLines === []) { - continue; - } - - // Stop at @tags - if (str_starts_with($line, '@') || str_starts_with($line, '##')) { - break; - } - - // First non-empty line is the short description - if (empty($description['short']) && ($line !== '' && $line !== '0')) { - $description['short'] = $line; - - continue; - } - - // After short description, collect long description - if ($description['short'] !== '' && $description['short'] !== '0') { - $inLongDesc = true; - if ($line !== '' && $line !== '0' || $longDescLines !== []) { - $longDescLines[] = $line; - } - } - } - - if ($longDescLines !== []) { - $description['long'] = trim(implode("\n", $longDescLines)); - } - - return $description; - } - - /** - * {@inheritDoc} - */ - public function getIdentifier(): string - { - return 'wp_cli_commands'; - } -} diff --git a/src/WpRest/Infrastructure/Services/WpRestAttributableWrapper.php b/src/WpRest/Infrastructure/Services/WpRestAttributableWrapper.php deleted file mode 100644 index cca261ca..00000000 --- a/src/WpRest/Infrastructure/Services/WpRestAttributableWrapper.php +++ /dev/null @@ -1,62 +0,0 @@ -realInstance = $this->createRealInstance(); - } - - /** - * Get the real instance of the wrapped class. - */ - public function getRealInstance(): mixed - { - return $this->realInstance; - } - - /** - * Create an instance of the real class. - */ - private function createRealInstance(): mixed - { - try { - $reflectionClass = $this->reflectionCache->getClassReflection($this->className); - - if ($reflectionClass->isInstantiable()) { - return $reflectionClass->newInstance(); - } - } catch (\Throwable $throwable) { - try { - resolve(LoggerInterface::class)->error( - sprintf('Failed to create instance of %s: ', $this->className).$throwable->getMessage() - ); - } catch (\Throwable) { - } - } - - return null; - } -} diff --git a/src/WpRest/Infrastructure/Services/WpRestDiscovery.php b/src/WpRest/Infrastructure/Services/WpRestDiscovery.php deleted file mode 100644 index 62e89c78..00000000 --- a/src/WpRest/Infrastructure/Services/WpRestDiscovery.php +++ /dev/null @@ -1,230 +0,0 @@ -isAbstract) { - return; - } - - // Check if class has WpRestRoute attribute - $wpRestRouteAttribute = $this->findWpRestRouteAttribute($structure->attributes); - if ($wpRestRouteAttribute === null) { - return; - } - - // Collect the class for registration - $this->getItems()->add($location, [ - 'class' => $structure->namespace.'\\'.$structure->name, - 'attribute' => $wpRestRouteAttribute, - 'structure' => $structure, - 'reflection_cache' => $reflectionCache, - ]); - } - - /** - * {@inheritDoc} - * - * Applies discovered WpRestRoute classes by registering them as REST API endpoints. - * Each discovered class is processed for method-level attributes. - */ - public function apply(): void - { - foreach ($this->getItems() as $discoveredItem) { - [ - 'class' => $className, - 'attribute' => $restRouteAttribute, - 'structure' => $structure - ] = $discoveredItem; - - try { - // Process the complete WP REST route configuration - $reflectionCache = $discoveredItem['reflection_cache'] ?? null; - $this->processWpRestRoute($className, $reflectionCache); - } catch (\Throwable $e) { - // Log the error but continue with other REST routes - $this->logger?->error(sprintf('Failed to register WP REST route from class %s: ', $className).$e->getMessage()); - } - } - } - - /** - * Find WpRestRoute attribute in the given attributes array. - * - * @param array $attributes The attributes to search through - * @return object|null The WpRestRoute attribute or null if not found - */ - private function findWpRestRouteAttribute(array $attributes): ?object - { - foreach ($attributes as $attribute) { - if ($attribute->class === WpRestRoute::class) { - return $attribute; - } - } - - return null; - } - - /** - * Process a complete WP REST route configuration from its class and attributes. - * - * @param string $className The fully qualified class name - * @param ReflectionCacheInterface|null $reflectionCache Optional reflection cache - */ - private function processWpRestRoute(string $className, ?ReflectionCacheInterface $reflectionCache = null): void - { - try { - $reflectionClass = $reflectionCache->getClassReflection($className); - $wpRestRouteAttributes = $reflectionCache->getClassAttributes($className, WpRestRoute::class); - - if ($wpRestRouteAttributes === []) { - return; - } - - /** @var WpRestRoute $wpRestRoute */ - $wpRestRoute = $wpRestRouteAttributes[0]->newInstance(); - - // Create wrapper once for the class (use cache to avoid recreating) - $wrapperKey = md5($className.$wpRestRoute->namespace.$wpRestRoute->route); - - $this->wrapperCache[$wrapperKey] ??= new WpRestAttributableWrapper( - $className, - $wpRestRoute->namespace, - $wpRestRoute->route, - $wpRestRoute->permissionCallback, - $reflectionCache - ); - - $attributableWrapper = $this->wrapperCache[$wrapperKey]; - - // Process all method-level attributes - $this->processMethodLevelAttributes($className, $attributableWrapper, $reflectionCache); - - } catch (\ReflectionException $reflectionException) { - $this->logger?->error(sprintf('Failed to process WP REST route for class %s: ', $className).$reflectionException->getMessage()); - } - } - - /** - * Process method-level attributes to register REST endpoints. - * - * @param string $className The fully qualified class name - * @param WpRestAttributableWrapper $attributableWrapper The wrapper instance - * @param ReflectionCacheInterface|null $reflectionCache Optional reflection cache - */ - private function processMethodLevelAttributes(string $className, WpRestAttributableWrapper $attributableWrapper, ?ReflectionCacheInterface $reflectionCache = null): void - { - try { - $methods = $reflectionCache->getPublicMethods($className); - - foreach ($methods as $method) { - $this->processMethodAttributes($method, $attributableWrapper); - } - } catch (\ReflectionException $reflectionException) { - $this->logger?->error(sprintf('Failed to process method-level attributes for %s: ', $className).$reflectionException->getMessage()); - } - } - - /** - * Process all attributes for a single method. - * - * @param ReflectionMethod $method The method to process - * @param WpRestAttributableWrapper $attributableWrapper The wrapper instance - */ - private function processMethodAttributes(ReflectionMethod $method, WpRestAttributableWrapper $attributableWrapper): void - { - foreach ($method->getAttributes() as $attribute) { - // Process attributes in the WpRestRoute namespace - if (! str_contains($attribute->getName(), 'Pollora\\Attributes\\WpRestRoute\\')) { - continue; - } - - $this->processMethodAttribute($method, $attribute, $attributableWrapper); - } - } - - /** - * Process a single method-level attribute for REST route registration. - * - * @param ReflectionMethod $method The method with the attribute - * @param \ReflectionAttribute $attribute The attribute to process - * @param WpRestAttributableWrapper $attributableWrapper The wrapper instance - */ - private function processMethodAttribute( - ReflectionMethod $method, - \ReflectionAttribute $attribute, - WpRestAttributableWrapper $attributableWrapper - ): void { - try { - $attributeInstance = $attribute->newInstance(); - - // Check if the attribute has a handle method and call it - if (! method_exists($attributeInstance, 'handle')) { - return; - } - - // Let the attribute handle the registration - add_action('rest_api_init', function () use ($attributeInstance, $attributableWrapper, $method): void { - $attributeInstance->handle(app(), $attributableWrapper, $method, $attributeInstance); - }); - } catch (\Throwable $throwable) { - $className = $method->getDeclaringClass()->getName(); - $this->logger?->error(sprintf('Failed to process method attribute for %s::%s: ', $className, $method->getName()).$throwable->getMessage()); - } - } - - /** - * {@inheritDoc} - */ - public function getIdentifier(): string - { - return 'wp_rest_routes'; - } -} diff --git a/src/WpRest/Permissions/Can.php b/src/WpRest/Permissions/Can.php deleted file mode 100644 index ef9418dd..00000000 --- a/src/WpRest/Permissions/Can.php +++ /dev/null @@ -1,43 +0,0 @@ -capability instanceof BackedEnum ? (string) $this->capability->value : $this->capability; - $arguments = $this->parameter === null ? [] : [$request->get_param($this->parameter)]; - - return current_user_can($capability, ...$arguments) ?: new WP_Error( - 'rest_forbidden', - __('You do not have permission to access this endpoint.'), - ['status' => rest_authorization_required_code()] - ); - } -} diff --git a/src/WpRest/Permissions/IsAdmin.php b/src/WpRest/Permissions/IsAdmin.php deleted file mode 100755 index faa42e05..00000000 --- a/src/WpRest/Permissions/IsAdmin.php +++ /dev/null @@ -1,29 +0,0 @@ - 403] - ); - } -} diff --git a/src/WpRest/Permissions/IsLoggedIn.php b/src/WpRest/Permissions/IsLoggedIn.php deleted file mode 100755 index 43c34274..00000000 --- a/src/WpRest/Permissions/IsLoggedIn.php +++ /dev/null @@ -1,29 +0,0 @@ - 403] - ); - } -} diff --git a/tests/Benchmark/BenchmarkDiscovery.php b/tests/Benchmark/BenchmarkDiscovery.php deleted file mode 100644 index ab42faac..00000000 --- a/tests/Benchmark/BenchmarkDiscovery.php +++ /dev/null @@ -1,120 +0,0 @@ -items = new DiscoveryItems; - } - - public function discover( - DiscoveryLocationInterface $location, - DiscoveredStructure $structure, - ?ReflectionCacheInterface $reflectionCache = null - ): void { - if (! $structure instanceof DiscoveredClass) { - return; - } - - if ($structure->isAbstract) { - return; - } - - $className = $structure->namespace.'\\'.$structure->name; - - if (! $reflectionCache instanceof ReflectionCacheInterface) { - return; - } - - try { - $reflection = $reflectionCache->getClassReflection($className); - - // Process ALL class-level attributes (like PostTypeDiscovery does) - $classAttributes = $reflection->getAttributes(); - foreach ($classAttributes as $attribute) { - try { - $instance = $attribute->newInstance(); - $this->items->add($location, [ - 'type' => 'class_attribute', - 'class' => $className, - 'attribute' => $attribute->getName(), - 'instance' => $instance, - ]); - } catch (\Throwable) { - // Skip attributes that can't be instantiated (missing classes) - } - } - - // Process ALL public methods and their attributes (like HookDiscovery does) - $methods = $reflection->getMethods(ReflectionMethod::IS_PUBLIC); - foreach ($methods as $method) { - $methodAttributes = $method->getAttributes(); - foreach ($methodAttributes as $attribute) { - try { - $instance = $attribute->newInstance(); - $this->items->add($location, [ - 'type' => 'method_attribute', - 'class' => $className, - 'method' => $method->getName(), - 'attribute' => $attribute->getName(), - 'instance' => $instance, - 'param_count' => $method->getNumberOfParameters(), - ]); - } catch (\Throwable) { - // Skip attributes that can't be instantiated - } - } - } - } catch (\Throwable) { - // Skip unloadable classes, like real discoveries - } - } - - public function getItems(): DiscoveryItemsInterface - { - return $this->items; - } - - public function setItems(DiscoveryItemsInterface $items): void - { - $this->items = $items; - } - - public function apply(): void - { - // No-op for benchmarking — we measure discovery, not application - } - - public function getIdentifier(): string - { - return $this->identifier; - } -} diff --git a/tests/Benchmark/DiscoveryBenchmark.php b/tests/Benchmark/DiscoveryBenchmark.php deleted file mode 100644 index 603c701f..00000000 --- a/tests/Benchmark/DiscoveryBenchmark.php +++ /dev/null @@ -1,479 +0,0 @@ -> */ - private array $results = []; - - /** @var array> */ - private array $resultsMulti = []; - - public function __construct() - { - $this->container = new Container; - Container::setInstance($this->container); - - $this->debugDetector = new class implements DebugDetectorInterface - { - public function isDebugMode(): bool - { - return true; // Force no persistent cache - } - }; - - $this->fixturesBasePath = __DIR__.'/Generated'; - } - - /** - * Run the full benchmark suite. - * - * @param int[] $classCounts Class counts to benchmark - * @param int $iterations Number of iterations per benchmark for averaging - */ - public function run(array $classCounts = [50, 100, 250, 500, 1000, 2000], int $iterations = 3): void - { - $this->printHeader(); - - echo "\n ── SINGLE LOCATION (flat directory) ──\n"; - foreach ($classCounts as $count) { - $this->benchmarkClassCount($count, $iterations); - } - - $this->printSummary(); - - // Multi-location benchmark - echo "\n ── MULTI-LOCATION (modules with DDD subdirs) ──\n"; - $this->resultsMulti = []; - $multiScenarios = [ - ['classes' => 100, 'modules' => 5], - ['classes' => 250, 'modules' => 8], - ['classes' => 500, 'modules' => 10], - ['classes' => 1000, 'modules' => 15], - ]; - - foreach ($multiScenarios as $scenario) { - $this->benchmarkMultiLocation($scenario['classes'], $scenario['modules'], $iterations); - } - - $this->printMultiLocationSummary(); - } - - private function benchmarkClassCount(int $count, int $iterations): void - { - echo "\n".str_repeat('─', 70)."\n"; - echo " Benchmarking with {$count} classes ({$iterations} iterations)\n"; - echo str_repeat('─', 70)."\n"; - - // Generate fixtures - $generator = new FixtureGenerator( - $this->fixturesBasePath, - 'Tests\\Benchmark\\Generated' - ); - - $fixtureInfo = $generator->generate($count); - $this->printDistribution($fixtureInfo['distribution']); - - // Register autoloader for generated classes - $autoloader = $this->registerAutoloader($fixtureInfo['path'], $fixtureInfo['namespace']); - - $timings = [ - 'spatie_scan' => [], - 'full_discovery' => [], - 'memory_peak' => [], - 'classes_processed' => [], - 'total_items' => [], - ]; - - for ($i = 0; $i < $iterations; $i++) { - echo ' Iteration '.($i + 1).'...'; - - // Clear any static caches between iterations - $this->clearStaticCaches(); - - // Benchmark: Spatie scan phase only - $scanResult = $this->benchmarkSpatieScan($fixtureInfo); - $timings['spatie_scan'][] = $scanResult['time_ms']; - - // Clear static caches again - $this->clearStaticCaches(); - - // Benchmark: Full discovery (scan + process for all discovery types) - $fullResult = $this->benchmarkFullDiscovery($fixtureInfo); - $timings['full_discovery'][] = $fullResult['time_ms']; - $timings['memory_peak'][] = $fullResult['memory_peak_mb']; - $timings['classes_processed'][] = $fullResult['classes_processed']; - $timings['total_items'][] = $fullResult['total_items']; - - echo " done\n"; - } - - // Unregister autoloader - spl_autoload_unregister($autoloader); - - // Cleanup generated files - $generator->cleanup(); - - // Store averaged results - $this->results[$count] = [ - 'count' => $count, - 'spatie_scan_avg_ms' => $this->average($timings['spatie_scan']), - 'spatie_scan_min_ms' => min($timings['spatie_scan']), - 'spatie_scan_max_ms' => max($timings['spatie_scan']), - 'full_discovery_avg_ms' => $this->average($timings['full_discovery']), - 'full_discovery_min_ms' => min($timings['full_discovery']), - 'full_discovery_max_ms' => max($timings['full_discovery']), - 'memory_peak_mb' => $this->average($timings['memory_peak']), - 'classes_processed' => (int) $this->average($timings['classes_processed']), - 'total_items' => (int) $this->average($timings['total_items']), - 'per_class_us' => ($this->average($timings['full_discovery']) * 1000) / $count, - ]; - - $r = $this->results[$count]; - echo "\n"; - echo sprintf(" Spatie scan: avg %7.2f ms (min %.2f / max %.2f)\n", $r['spatie_scan_avg_ms'], $r['spatie_scan_min_ms'], $r['spatie_scan_max_ms']); - echo sprintf(" Full discovery: avg %7.2f ms (min %.2f / max %.2f)\n", $r['full_discovery_avg_ms'], $r['full_discovery_min_ms'], $r['full_discovery_max_ms']); - echo sprintf(" Per class: %7.1f us\n", $r['per_class_us']); - echo sprintf(" Memory peak: %7.2f MB\n", $r['memory_peak_mb']); - echo sprintf(" Classes found: %d\n", $r['classes_processed']); - echo sprintf(" Attributes found: %d (avg %.1f/class)\n", $r['total_items'], $r['total_items'] / max(1, $r['classes_processed'])); - } - - /** - * Benchmark: Spatie structure scan only (no discovery processing). - * - * @return array{time_ms: float, structures_count: int} - */ - private function benchmarkSpatieScan(array $fixtureInfo): array - { - $cacheManager = new DiscoveryCacheManager($this->container, $this->debugDetector); - $reflectionCache = new ReflectionCache($this->container); - $context = new DiscoveryContext($reflectionCache); - - $location = new DiscoveryLocation( - $fixtureInfo['namespace'], - $fixtureInfo['path'] - ); - - $start = hrtime(true); - $structures = $cacheManager->getStructuresForLocation($location, $context); - $elapsed = (hrtime(true) - $start) / 1_000_000; - - return [ - 'time_ms' => $elapsed, - 'structures_count' => count($structures), - ]; - } - - /** - * Benchmark: Full discovery with multiple discovery types. - * - * Uses a lightweight HookDiscovery mock to test the real DiscoveryEngine - * processing pipeline without depending on WordPress runtime. - * - * @return array{time_ms: float, memory_peak_mb: float, classes_processed: int, stats: array} - */ - private function benchmarkFullDiscovery(array $fixtureInfo): array - { - $engine = new DiscoveryEngine( - $this->container, - $this->debugDetector - ); - - $location = new DiscoveryLocation( - $fixtureInfo['namespace'], - $fixtureInfo['path'] - ); - - // Add multiple discovery types to simulate realistic load - $engine->addLocation($location); - $engine->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); - $engine->addDiscovery('providers', new BenchmarkDiscovery('providers')); - $engine->addDiscovery('post_types', new BenchmarkDiscovery('post_types')); - $engine->addDiscovery('rest_routes', new BenchmarkDiscovery('rest_routes')); - $engine->addDiscovery('schedules', new BenchmarkDiscovery('schedules')); - - $memBefore = memory_get_usage(true); - $start = hrtime(true); - - $engine->discover(); - - $elapsed = (hrtime(true) - $start) / 1_000_000; - $memAfter = memory_get_peak_usage(true); - - $stats = $engine->getPerformanceStats(); - - // Count total discovered items across all discoveries - $totalItems = 0; - foreach ($engine->getDiscoveries() as $discovery) { - $totalItems += count($discovery->getItems()); - } - - return [ - 'time_ms' => $elapsed, - 'memory_peak_mb' => ($memAfter - $memBefore) / (1024 * 1024), - 'classes_processed' => $stats['context']['total_classes'], - 'total_items' => $totalItems, - 'stats' => $stats, - ]; - } - - private function benchmarkMultiLocation(int $totalClasses, int $moduleCount, int $iterations): void - { - $key = sprintf('%dc_%dm', $totalClasses, $moduleCount); - echo "\n".str_repeat('─', 70)."\n"; - echo " {$totalClasses} classes across {$moduleCount} modules ({$iterations} iterations)\n"; - echo str_repeat('─', 70)."\n"; - - $generator = new FixtureGenerator( - $this->fixturesBasePath, - 'Tests\\Benchmark\\Generated' - ); - - $multiInfo = $generator->generateMultiLocation($totalClasses, $moduleCount); - echo sprintf(" Generated: %d classes, %d modules, %d subdirs\n", - $multiInfo['total_classes'], $multiInfo['modules'], $multiInfo['dirs_created']); - - // Register autoloaders for all locations - $autoloaders = []; - foreach ($multiInfo['locations'] as $loc) { - $autoloaders[] = $this->registerAutoloader($loc['path'], $loc['namespace']); - } - - $timings = ['scan' => [], 'discovery' => [], 'memory' => [], 'items' => []]; - - for ($i = 0; $i < $iterations; $i++) { - echo ' Iteration '.($i + 1).'...'; - $this->clearStaticCaches(); - - // Full discovery with multiple locations - $engine = new DiscoveryEngine($this->container, $this->debugDetector); - - foreach ($multiInfo['locations'] as $loc) { - $engine->addLocation(new DiscoveryLocation($loc['namespace'], $loc['path'])); - } - - $engine->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); - $engine->addDiscovery('providers', new BenchmarkDiscovery('providers')); - $engine->addDiscovery('post_types', new BenchmarkDiscovery('post_types')); - $engine->addDiscovery('rest_routes', new BenchmarkDiscovery('rest_routes')); - $engine->addDiscovery('schedules', new BenchmarkDiscovery('schedules')); - - $memBefore = memory_get_usage(true); - $start = hrtime(true); - $engine->discover(); - $elapsed = (hrtime(true) - $start) / 1_000_000; - - $totalItems = 0; - foreach ($engine->getDiscoveries() as $d) { - $totalItems += count($d->getItems()); - } - - $timings['scan'][] = $elapsed; // Combined scan + processing - $timings['memory'][] = (memory_get_peak_usage(true) - $memBefore) / (1024 * 1024); - $timings['items'][] = $totalItems; - - echo " done\n"; - } - - foreach ($autoloaders as $al) { - spl_autoload_unregister($al); - } - - $generator->cleanup(); - - $avgMs = $this->average($timings['scan']); - $this->resultsMulti[$key] = [ - 'classes' => $multiInfo['total_classes'], - 'modules' => $multiInfo['modules'], - 'dirs' => $multiInfo['dirs_created'], - 'avg_ms' => $avgMs, - 'min_ms' => min($timings['scan']), - 'max_ms' => max($timings['scan']), - 'per_class_us' => ($avgMs * 1000) / max(1, $multiInfo['total_classes']), - 'items' => (int) $this->average($timings['items']), - 'memory_mb' => $this->average($timings['memory']), - ]; - - $r = $this->resultsMulti[$key]; - echo sprintf("\n Discovery: avg %7.2f ms (min %.2f / max %.2f)\n", $r['avg_ms'], $r['min_ms'], $r['max_ms']); - echo sprintf(" Per class: %7.1f us\n", $r['per_class_us']); - echo sprintf(" Attrs found: %d\n", $r['items']); - echo sprintf(" Memory peak: %7.2f MB\n", $r['memory_mb']); - } - - private function printMultiLocationSummary(): void - { - if ($this->resultsMulti === []) { - return; - } - - echo "\n\n"; - echo "╔════════════════════════════════════════════════════════════════════════════╗\n"; - echo "║ MULTI-LOCATION SUMMARY ║\n"; - echo "╠════════════════════════════════════════════════════════════════════════════╣\n"; - echo sprintf("║ %-7s │ %-7s │ %-5s │ %-11s │ %-9s │ %-6s │ %-6s ║\n", - 'Classes', 'Modules', 'Dirs', 'Discov (ms)', 'Per cls', 'Attrs', 'Mem MB'); - echo "╠════════════════════════════════════════════════════════════════════════════╣\n"; - - foreach ($this->resultsMulti as $r) { - echo sprintf( - "║ %-7d │ %-7d │ %-5d │ %11.2f │ %6.1f us │ %6d │ %5.2f ║\n", - $r['classes'], $r['modules'], $r['dirs'], - $r['avg_ms'], $r['per_class_us'], $r['items'], $r['memory_mb'] - ); - } - - echo "╚════════════════════════════════════════════════════════════════════════════╝\n"; - - // Compare single vs multi for same class count - echo "\n Single-location vs Multi-location comparison:\n"; - foreach ($this->resultsMulti as $r) { - $classCount = $r['classes']; - // Find closest single-location result - $closest = null; - $closestDiff = PHP_INT_MAX; - foreach ($this->results as $sr) { - $diff = abs($sr['count'] - $classCount); - if ($diff < $closestDiff) { - $closestDiff = $diff; - $closest = $sr; - } - } - - if ($closest && $closestDiff < $classCount * 0.3) { - $overhead = (($r['avg_ms'] / max(0.01, $closest['full_discovery_avg_ms'])) - 1) * 100; - echo sprintf(" %d classes: single=%.1fms, multi(%d modules)=%.1fms => %+.0f%% overhead\n", - $classCount, $closest['full_discovery_avg_ms'], $r['modules'], $r['avg_ms'], $overhead); - } - } - - echo "\n"; - } - - private function registerAutoloader(string $path, string $namespace): \Closure - { - $autoloader = function (string $class) use ($path, $namespace): void { - if (! str_starts_with($class, $namespace)) { - return; - } - - $relative = str_replace($namespace.'\\', '', $class); - $file = $path.'/'.str_replace('\\', '/', $relative).'.php'; - - if (file_exists($file)) { - require_once $file; - } - }; - - spl_autoload_register($autoloader); - - return $autoloader; - } - - private function clearStaticCaches(): void - { - // Use reflection to clear DiscoveryCacheManager's static cache - $ref = new \ReflectionClass(DiscoveryCacheManager::class); - $prop = $ref->getProperty('structuresCache'); - $prop->setValue(null, []); - } - - private function average(array $values): float - { - return array_sum($values) / count($values); - } - - private function printHeader(): void - { - echo "\n"; - echo "╔══════════════════════════════════════════════════════════════════════╗\n"; - echo "║ Pollora Discovery System — Performance Benchmark ║\n"; - echo "╠══════════════════════════════════════════════════════════════════════╣\n"; - echo "║ Tests discovery WITHOUT persistent cache (debug mode). ║\n"; - echo "║ Measures: Spatie scan, full discovery pipeline, per-class cost. ║\n"; - echo "╚══════════════════════════════════════════════════════════════════════╝\n"; - echo "\n"; - echo ' PHP '.phpversion().' | Memory limit: '.ini_get('memory_limit')."\n"; - } - - private function printDistribution(array $distribution): void - { - $parts = []; - foreach ($distribution as $type => $count) { - $parts[] = sprintf('%s: %s', $type, $count); - } - - echo ' Distribution: '.implode(', ', $parts)."\n"; - } - - private function printSummary(): void - { - echo "\n\n"; - echo "╔══════════════════════════════════════════════════════════════════════╗\n"; - echo "║ SUMMARY TABLE ║\n"; - echo "╠══════════════════════════════════════════════════════════════════════╣\n"; - echo sprintf("║ %-7s │ %-10s │ %-12s │ %-9s │ %-6s │ %-6s ║\n", 'Classes', 'Scan (ms)', 'Discov (ms)', 'Per cls', 'Attrs', 'Mem MB'); - echo "╠══════════════════════════════════════════════════════════════════════╣\n"; - - foreach ($this->results as $r) { - echo sprintf( - "║ %-7d │ %10.2f │ %12.2f │ %6.1f us │ %6d │ %5.2f ║\n", - $r['count'], - $r['spatie_scan_avg_ms'], - $r['full_discovery_avg_ms'], - $r['per_class_us'], - $r['total_items'], - $r['memory_peak_mb'] - ); - } - - echo "╚══════════════════════════════════════════════════════════════════════╝\n"; - - // Scaling analysis - if (count($this->results) >= 2) { - echo "\n Scaling analysis:\n"; - $keys = array_keys($this->results); - $first = $this->results[$keys[0]]; - $last = $this->results[$keys[count($keys) - 1]]; - $classRatio = $last['count'] / $first['count']; - $timeRatio = $last['full_discovery_avg_ms'] / max(0.01, $first['full_discovery_avg_ms']); - - echo sprintf(" %dx classes => %.1fx time (ideal linear = %.1fx)\n", (int) $classRatio, $timeRatio, $classRatio); - - if ($timeRatio > $classRatio * 1.5) { - echo " => Super-linear scaling detected — optimization opportunities likely\n"; - } elseif ($timeRatio <= $classRatio * 1.1) { - echo " => Linear scaling — discovery scales well\n"; - } else { - echo " => Near-linear scaling — acceptable\n"; - } - } - - echo "\n"; - } -} diff --git a/tests/Benchmark/DiscoveryPerformanceTest.php b/tests/Benchmark/DiscoveryPerformanceTest.php deleted file mode 100644 index 9eb86ff2..00000000 --- a/tests/Benchmark/DiscoveryPerformanceTest.php +++ /dev/null @@ -1,331 +0,0 @@ -getProperty('structuresCache'); - $prop->setValue(null, []); -} - -function generateAndRegister(int $count): array -{ - $basePath = __DIR__.'/Generated'; - $namespace = 'Tests\\Benchmark\\Generated'; - - $generator = new FixtureGenerator($basePath, $namespace); - $info = $generator->generate($count); - - $autoloader = function (string $class) use ($basePath, $namespace): void { - if (! str_starts_with($class, $namespace)) { - return; - } - - $relative = str_replace($namespace.'\\', '', $class); - $file = $basePath.'/'.str_replace('\\', '/', $relative).'.php'; - if (file_exists($file)) { - require_once $file; - } - }; - spl_autoload_register($autoloader); - - return ['info' => $info, 'generator' => $generator, 'autoloader' => $autoloader]; -} - -describe('Discovery Performance', function (): void { - - afterEach(function (): void { - clearDiscoveryCacheManagerStatic(); - restorePreviousContainer(); - Facade::clearResolvedInstances(); - Facade::setFacadeApplication(Container::getInstance()); - }); - - it('scans 100 classes via Spatie in under 500ms', function (): void { - $setup = generateAndRegister(100); - - try { - $container = createBenchContainer(); - $cacheManager = new DiscoveryCacheManager($container, createBenchDebugDetector()); - $reflectionCache = new ReflectionCache($container); - $context = new DiscoveryContext($reflectionCache); - - $location = new DiscoveryLocation( - $setup['info']['namespace'], - $setup['info']['path'] - ); - - $start = hrtime(true); - $structures = $cacheManager->getStructuresForLocation($location, $context); - $elapsedMs = (hrtime(true) - $start) / 1_000_000; - - expect(count($structures))->toBeGreaterThanOrEqual(100); - expect($elapsedMs)->toBeLessThan(500); - } finally { - spl_autoload_unregister($setup['autoloader']); - $setup['generator']->cleanup(); - } - }); - - it('runs full discovery on 100 classes with 5 discoveries in under 2000ms', function (): void { - $setup = generateAndRegister(100); - - try { - $container = createBenchContainer(); - $engine = new DiscoveryEngine($container, createBenchDebugDetector()); - - $location = new DiscoveryLocation( - $setup['info']['namespace'], - $setup['info']['path'] - ); - - $engine->addLocation($location); - $engine->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); - $engine->addDiscovery('providers', new BenchmarkDiscovery('providers')); - $engine->addDiscovery('post_types', new BenchmarkDiscovery('post_types')); - $engine->addDiscovery('rest_routes', new BenchmarkDiscovery('rest_routes')); - $engine->addDiscovery('schedules', new BenchmarkDiscovery('schedules')); - - $start = hrtime(true); - $engine->discover(); - $elapsedMs = (hrtime(true) - $start) / 1_000_000; - - $stats = $engine->getPerformanceStats(); - - expect($stats['context']['total_classes'])->toBeGreaterThanOrEqual(100); - expect($elapsedMs)->toBeLessThan(2000); - } finally { - spl_autoload_unregister($setup['autoloader']); - $setup['generator']->cleanup(); - } - }); - - it('runs full discovery on 500 classes with 5 discoveries in under 10000ms', function (): void { - $setup = generateAndRegister(500); - - try { - $container = createBenchContainer(); - $engine = new DiscoveryEngine($container, createBenchDebugDetector()); - - $location = new DiscoveryLocation( - $setup['info']['namespace'], - $setup['info']['path'] - ); - - $engine->addLocation($location); - $engine->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); - $engine->addDiscovery('providers', new BenchmarkDiscovery('providers')); - $engine->addDiscovery('post_types', new BenchmarkDiscovery('post_types')); - $engine->addDiscovery('rest_routes', new BenchmarkDiscovery('rest_routes')); - $engine->addDiscovery('schedules', new BenchmarkDiscovery('schedules')); - - $start = hrtime(true); - $engine->discover(); - $elapsedMs = (hrtime(true) - $start) / 1_000_000; - - $stats = $engine->getPerformanceStats(); - - expect($stats['context']['total_classes'])->toBeGreaterThanOrEqual(500); - expect($elapsedMs)->toBeLessThan(10000); - } finally { - spl_autoload_unregister($setup['autoloader']); - $setup['generator']->cleanup(); - } - }); - - it('scales near-linearly from 100 to 500 classes', function (): void { - // Benchmark 100 classes - $setup100 = generateAndRegister(100); - $container = createBenchContainer(); - - try { - $engine100 = new DiscoveryEngine($container, createBenchDebugDetector()); - $loc100 = new DiscoveryLocation($setup100['info']['namespace'], $setup100['info']['path']); - $engine100->addLocation($loc100); - $engine100->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); - $engine100->addDiscovery('providers', new BenchmarkDiscovery('providers')); - $engine100->addDiscovery('post_types', new BenchmarkDiscovery('post_types')); - - $start = hrtime(true); - $engine100->discover(); - $time100 = (hrtime(true) - $start) / 1_000_000; - } finally { - spl_autoload_unregister($setup100['autoloader']); - $setup100['generator']->cleanup(); - } - - clearDiscoveryCacheManagerStatic(); - - // Benchmark 500 classes - $setup500 = generateAndRegister(500); - - try { - $engine500 = new DiscoveryEngine($container, createBenchDebugDetector()); - $loc500 = new DiscoveryLocation($setup500['info']['namespace'], $setup500['info']['path']); - $engine500->addLocation($loc500); - $engine500->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); - $engine500->addDiscovery('providers', new BenchmarkDiscovery('providers')); - $engine500->addDiscovery('post_types', new BenchmarkDiscovery('post_types')); - - $start = hrtime(true); - $engine500->discover(); - $time500 = (hrtime(true) - $start) / 1_000_000; - } finally { - spl_autoload_unregister($setup500['autoloader']); - $setup500['generator']->cleanup(); - } - - // 5x classes should be less than 8x time (allowing for overhead) - $ratio = $time500 / max(0.01, $time100); - expect($ratio)->toBeLessThan(8.0, - sprintf('Scaling ratio: %sx for 5x classes (100: %sms, 500: %sms)', $ratio, $time100, $time500) - ); - }); - - it('processes each class in under 2000 microseconds on average', function (): void { - $setup = generateAndRegister(250); - - try { - $container = createBenchContainer(); - $engine = new DiscoveryEngine($container, createBenchDebugDetector()); - - $location = new DiscoveryLocation( - $setup['info']['namespace'], - $setup['info']['path'] - ); - - $engine->addLocation($location); - $engine->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); - $engine->addDiscovery('providers', new BenchmarkDiscovery('providers')); - $engine->addDiscovery('post_types', new BenchmarkDiscovery('post_types')); - $engine->addDiscovery('rest_routes', new BenchmarkDiscovery('rest_routes')); - $engine->addDiscovery('schedules', new BenchmarkDiscovery('schedules')); - - $start = hrtime(true); - $engine->discover(); - $elapsedUs = (hrtime(true) - $start) / 1_000; - - $perClassUs = $elapsedUs / 250; - - expect($perClassUs)->toBeLessThan(3000, - sprintf('Per-class cost: %s us (total: ', $perClassUs).($elapsedUs / 1000).' ms for 250 classes)' - ); - } finally { - spl_autoload_unregister($setup['autoloader']); - $setup['generator']->cleanup(); - } - }); - - it('handles multi-location with DDD subdirectories without excessive overhead', function (): void { - $basePath = __DIR__.'/Generated'; - $namespace = 'Tests\\Benchmark\\Generated'; - $generator = new FixtureGenerator($basePath, $namespace); - - $multiInfo = $generator->generateMultiLocation(200, 5); - - $autoloaders = []; - foreach ($multiInfo['locations'] as $loc) { - $al = function (string $class) use ($loc): void { - if (! str_starts_with($class, $loc['namespace'])) { - return; - } - - $relative = str_replace($loc['namespace'].'\\', '', $class); - $file = $loc['path'].'/'.str_replace('\\', '/', $relative).'.php'; - if (file_exists($file)) { - require_once $file; - } - }; - spl_autoload_register($al); - $autoloaders[] = $al; - } - - try { - $container = createBenchContainer(); - $engine = new DiscoveryEngine($container, createBenchDebugDetector()); - - foreach ($multiInfo['locations'] as $loc) { - $engine->addLocation(new DiscoveryLocation($loc['namespace'], $loc['path'])); - } - - $engine->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); - $engine->addDiscovery('providers', new BenchmarkDiscovery('providers')); - $engine->addDiscovery('post_types', new BenchmarkDiscovery('post_types')); - - $start = hrtime(true); - $engine->discover(); - $elapsedMs = (hrtime(true) - $start) / 1_000_000; - - $stats = $engine->getPerformanceStats(); - - expect($stats['context']['total_classes'])->toBeGreaterThanOrEqual(200); - // 5 modules with subdirs should not be dramatically slower than flat - expect($elapsedMs)->toBeLessThan(5000); - } finally { - foreach ($autoloaders as $al) { - spl_autoload_unregister($al); - } - - $generator->cleanup(); - } - }); -}); diff --git a/tests/Benchmark/Fixtures/FixtureGenerator.php b/tests/Benchmark/Fixtures/FixtureGenerator.php deleted file mode 100644 index c35e6758..00000000 --- a/tests/Benchmark/Fixtures/FixtureGenerator.php +++ /dev/null @@ -1,673 +0,0 @@ -} - */ - public function generate(int $count): array - { - $this->ensureDirectory($this->basePath); - - $distribution = $this->calculateDistribution($count); - $index = 0; - - foreach ($distribution as $type => $typeCount) { - for ($i = 0; $i < $typeCount; $i++) { - $this->generateClass($type, $index); - $index++; - } - } - - return [ - 'path' => $this->basePath, - 'namespace' => $this->namespace, - 'count' => $index, - 'distribution' => $distribution, - ]; - } - - /** - * Generate a multi-location structure simulating a real Pollora project. - * - * Creates N modules, each with a DDD-like directory structure: - * ModuleName/ - * app/ - * PostTypes/ - * Hooks/ - * Http/Controllers/ - * Providers/ - * Jobs/ - * Models/ - * - * Each module is a separate discovery location (like nwidart modules). - * - * @param int $totalClasses Total number of classes across all modules - * @param int $moduleCount Number of modules to create - * @return array{locations: array, total_classes: int, modules: int, dirs_created: int} - */ - public function generateMultiLocation(int $totalClasses, int $moduleCount): array - { - $this->ensureDirectory($this->basePath); - - $classesPerModule = (int) ceil($totalClasses / $moduleCount); - $locations = []; - $totalGenerated = 0; - $dirsCreated = 0; - $remaining = $totalClasses; - - for ($mod = 0; $mod < $moduleCount; $mod++) { - $moduleName = 'Module'.chr(65 + ($mod % 26)).($mod >= 26 ? $mod : ''); - $modulePath = $this->basePath.'/'.$moduleName.'/app'; - $moduleNamespace = $this->namespace.'\\'.$moduleName.'\\App'; - $classCount = min($classesPerModule, $remaining); - - if ($classCount <= 0) { - break; - } - - // DDD subdirectories - $subdirs = [ - 'PostTypes' => 'post_type', - 'Hooks' => 'hook', - 'Http/Controllers' => 'rest', - 'Providers' => 'provider', - 'Jobs' => 'schedule', - 'Models' => 'plain', - 'Services' => 'mixed', - ]; - - $perSubdir = (int) ceil($classCount / count($subdirs)); - $moduleGenerated = 0; - - foreach ($subdirs as $subdir => $type) { - $subdirPath = $modulePath.'/'.$subdir; - $subdirNamespace = $moduleNamespace.'\\'.str_replace('/', '\\', $subdir); - - $subdirCount = min($perSubdir, $classCount - $moduleGenerated); - if ($subdirCount <= 0) { - break; - } - - $this->ensureDirectory($subdirPath); - $dirsCreated++; - - for ($i = 0; $i < $subdirCount; $i++) { - $globalIndex = $totalGenerated + $moduleGenerated + $i; - $className = $this->classNameForType($type, $globalIndex); - $content = $this->generateClassContent($type, $className, $globalIndex, $subdirNamespace); - file_put_contents($subdirPath.'/'.$className.'.php', $content); - } - - $moduleGenerated += $subdirCount; - } - - $locations[] = [ - 'path' => $modulePath, - 'namespace' => $moduleNamespace, - ]; - - $totalGenerated += $moduleGenerated; - $remaining -= $moduleGenerated; - } - - return [ - 'locations' => $locations, - 'total_classes' => $totalGenerated, - 'modules' => count($locations), - 'dirs_created' => $dirsCreated, - ]; - } - - /** - * Clean up generated fixtures. - */ - public function cleanup(): void - { - if (is_dir($this->basePath)) { - $this->removeDirectory($this->basePath); - } - } - - private function calculateDistribution(int $count): array - { - $hooks = (int) round($count * 0.30); - $providers = (int) round($count * 0.15); - $postTypes = (int) round($count * 0.15); - $rest = (int) round($count * 0.10); - $schedule = (int) round($count * 0.10); - $mixed = (int) round($count * 0.10); - $plain = $count - $hooks - $providers - $postTypes - $rest - $schedule - $mixed; - - return [ - 'hook' => $hooks, - 'provider' => $providers, - 'post_type' => $postTypes, - 'rest' => $rest, - 'schedule' => $schedule, - 'mixed' => $mixed, - 'plain' => max(0, $plain), - ]; - } - - private function generateClass(string $type, int $index): void - { - $className = $this->classNameForType($type, $index); - $content = $this->generateClassContent($type, $className, $index, $this->namespace); - - file_put_contents($this->basePath.'/'.$className.'.php', $content); - } - - private function generateClassContent(string $type, string $className, int $index, string $namespace): string - { - return match ($type) { - 'hook' => $this->generateHookClass($className, $index, $namespace), - 'provider' => $this->generateProviderClass($className, $namespace), - 'post_type' => $this->generatePostTypeClass($className, $index, $namespace), - 'rest' => $this->generateRestClass($className, $index, $namespace), - 'schedule' => $this->generateScheduleClass($className, $index, $namespace), - 'mixed' => $this->generateMixedClass($className, $index, $namespace), - 'plain' => $this->generatePlainClass($className, $namespace), - }; - } - - private function classNameForType(string $type, int $index): string - { - return match ($type) { - 'hook' => 'BenchHook'.$index, - 'provider' => 'BenchProvider'.$index, - 'post_type' => 'BenchPostType'.$index, - 'rest' => 'BenchRestController'.$index, - 'schedule' => 'BenchSchedule'.$index, - 'mixed' => 'BenchMixed'.$index, - 'plain' => 'BenchPlain'.$index, - }; - } - - /** - * Hook class: 2-6 methods, each with 1-3 repeatable Action/Filter attributes. - */ - private function generateHookClass(string $className, int $index, ?string $namespace = null): string - { - $ns = $namespace ?? $this->namespace; - $methodCount = ($index % 5) + 2; // 2 to 6 methods - $methods = ''; - - for ($m = 0; $m < $methodCount; $m++) { - $attrCount = ($m % 3) + 1; // 1 to 3 attributes per method - $attrs = ''; - - for ($a = 0; $a < $attrCount; $a++) { - $hookName = sprintf('bench_hook_%d_%d_%d', $index, $m, $a); - $attr = ($m + $a) % 3 === 0 ? 'Filter' : 'Action'; - $priority = (($m * 3) + $a) * 5 + 10; - $attrs .= " #[\\Pollora\\Attributes\\{$attr}(hook: '{$hookName}', priority: {$priority})]\n"; - } - - $methods .= <<namespace; - - return <<namespace; - $slug = 'bench_cpt_'.$index; - - // Pool of class-level PostType sub-attributes - $subAttributes = [ - "#[\\Pollora\\Attributes\\PostType\\Supports(features: ['title', 'editor', 'thumbnail', 'excerpt', 'comments'])]", - "#[\\Pollora\\Attributes\\PostType\\MenuIcon(value: 'dashicons-admin-post')]", - '#[\\Pollora\\Attributes\\PostType\\HasArchive(value: true)]', - '#[\\Pollora\\Attributes\\PostType\\ShowInRest(value: true)]', - '#[\\Pollora\\Attributes\\PostType\\ShowUI(value: true)]', - '#[\\Pollora\\Attributes\\PostType\\Hierarchical(value: false)]', - '#[\\Pollora\\Attributes\\PostType\\PublicPostType(value: true)]', - '#[\\Pollora\\Attributes\\PostType\\MenuPosition(value: 25)]', - '#[\\Pollora\\Attributes\\PostType\\ExcludeFromSearch(value: false)]', - '#[\\Pollora\\Attributes\\PostType\\PubliclyQueryable(value: true)]', - '#[\\Pollora\\Attributes\\PostType\\CanExport(value: true)]', - '#[\\Pollora\\Attributes\\PostType\\DeleteWithUser(value: false)]', - ]; - - // Pick 3-8 sub-attributes based on index - $subAttrCount = ($index % 6) + 3; - $selectedAttrs = array_slice($subAttributes, 0, $subAttrCount); - $attrBlock = implode("\n", $selectedAttrs); - - return <<namespace; - $methodCount = ($index % 4) + 2; // 2 to 5 methods - $methods = ''; - - $httpMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']; - - for ($m = 0; $m < $methodCount; $m++) { - $httpMethod = $httpMethods[$m % count($httpMethods)]; - $methodName = match ($httpMethod) { - 'GET' => $m === 0 ? 'index' : 'show', - 'POST' => 'store', - 'PUT' => 'update', - 'DELETE' => 'destroy', - 'PATCH' => 'patch', - }; - // Avoid duplicate method names - $methodName .= $m > 4 ? $m : ''; - - $methods .= << \$id, 'method' => '{$httpMethod}']; - } - - PHP; - } - - return <<\d+)')] - class {$className} - { - {$methods} - } - - PHP; - } - - /** - * Schedule class: 1-4 methods each with a Schedule attribute. - */ - private function generateScheduleClass(string $className, int $index, ?string $namespace = null): string - { - $ns = $namespace ?? $this->namespace; - $methodCount = ($index % 4) + 1; // 1 to 4 methods - $recurrences = ['hourly', 'daily', 'twicedaily', 'weekly']; - $methods = ''; - - for ($m = 0; $m < $methodCount; $m++) { - $recurrence = $recurrences[$m % count($recurrences)]; - $hookName = sprintf('bench_cron_%d_%d', $index, $m); - $methods .= <<namespace; - $slug = 'bench_mixed_'.$index; - $variant = $index % 3; - - return match ($variant) { - 0 => $this->generatePostTypeWithHooks($className, $index, $slug, $ns), - 1 => $this->generateRestWithHooksAndSchedule($className, $index, $ns), - 2 => $this->generateHeavyMixed($className, $index, $slug, $ns), - }; - } - - private function generatePostTypeWithHooks(string $className, int $index, string $slug, ?string $ns = null): string - { - $ns ??= $this->namespace; - $hookCount = ($index % 3) + 2; // 2-4 hook methods - $methods = ''; - - for ($m = 0; $m < $hookCount; $m++) { - $hookName = sprintf('bench_mixed_hook_%d_%d', $index, $m); - $methods .= <<namespace; - - return << \$id]; - } - - #[\\Pollora\\Attributes\\Action(hook: 'rest_api_init', priority: 10)] - public function onRestInit(): void - { - // Register additional routes - } - - #[\\Pollora\\Attributes\\Action(hook: 'init', priority: 5)] - #[\\Pollora\\Attributes\\Action(hook: 'admin_init', priority: 10)] - public function onInit(): void - { - // Multiple hooks on same method - } - - #[\\Pollora\\Attributes\\Schedule(recurrence: 'daily', hook: 'bench_mixed_cron_{$index}')] - public function cleanup(): void - { - // Scheduled cleanup - } - } - - PHP; - } - - private function generateHeavyMixed(string $className, int $index, string $slug, ?string $ns = null): string - { - $ns ??= $this->namespace; - - return <<namespace; - - return <<isDir()) { - rmdir($item->getPathname()); - } else { - unlink($item->getPathname()); - } - } - - rmdir($path); - } -} diff --git a/tests/Benchmark/run.php b/tests/Benchmark/run.php deleted file mode 100644 index 727cf88d..00000000 --- a/tests/Benchmark/run.php +++ /dev/null @@ -1,54 +0,0 @@ -run($classCounts, $iterations); diff --git a/tests/Feature/Discovery/DiscoveryServiceProviderTest.php b/tests/Feature/Discovery/DiscoveryServiceProviderTest.php index 50f667c2..13112877 100644 --- a/tests/Feature/Discovery/DiscoveryServiceProviderTest.php +++ b/tests/Feature/Discovery/DiscoveryServiceProviderTest.php @@ -5,13 +5,21 @@ use Pollora\Application\Domain\Contracts\DebugDetectorInterface; use Pollora\Discovery\Application\Services\DiscoveryManager; use Pollora\Discovery\Domain\Contracts\DiscoveryEngineInterface; +use Pollora\Discovery\Domain\Contracts\InstanceResolverInterface; use Pollora\Discovery\Domain\Contracts\ReflectionCacheInterface; +use Pollora\Discovery\Infrastructure\Adapters\LaravelInstanceResolver; use Pollora\Discovery\Infrastructure\Providers\DiscoveryServiceProvider; +use Pollora\Discovery\Infrastructure\Services\DiscoveryCacheManager; use Pollora\Discovery\Infrastructure\Services\DiscoveryEngine; use Pollora\Discovery\Infrastructure\Services\InstancePool; use Pollora\Discovery\Infrastructure\Services\ReflectionCache; use Pollora\Discovery\Infrastructure\Services\ServiceProviderDiscovery; +final class InstancePoolTestService +{ + public function __construct(public string $source = 'by reflection') {} +} + beforeEach(function (): void { // DiscoveryServiceProvider depends on DebugDetectorInterface $this->app->singleton(DebugDetectorInterface::class, fn () => Mockery::mock(DebugDetectorInterface::class, [ @@ -56,6 +64,32 @@ expect($this->app->make(DiscoveryManager::class))->toBe($manager); }); + it('builds discovery classes through the application container', function (): void { + $this->app->bind(InstancePoolTestService::class, fn (): InstancePoolTestService => new InstancePoolTestService('from the container')); + + expect($this->app->make(InstanceResolverInterface::class))->toBeInstanceOf(LaravelInstanceResolver::class) + ->and($this->app->make(InstancePool::class)->getInstance(InstancePoolTestService::class)->source)->toBe('from the container'); + }); + + it('shares one cache manager between the engine and its other readers', function (): void { + $engine = $this->app->make(DiscoveryEngineInterface::class); + + expect($engine->getCacheManager())->toBe($this->app->make(DiscoveryCacheManager::class)); + }); + + it('turns the persistent cache off in debug mode', function (): void { + $this->app->forgetInstance(DiscoveryCacheManager::class); + $this->app->singleton(DebugDetectorInterface::class, fn () => Mockery::mock(DebugDetectorInterface::class, [ + 'isDebugMode' => true, + ])); + + expect($this->app->make(DiscoveryCacheManager::class)->isCacheEnabled())->toBeFalse(); + }); + + it('keeps the persistent cache on outside debug mode', function (): void { + expect($this->app->make(DiscoveryCacheManager::class)->isCacheEnabled())->toBeTrue(); + }); + it('registers ServiceProviderDiscovery as singleton for auto-registration', function (): void { expect($this->app->bound(ServiceProviderDiscovery::class))->toBeTrue(); }); diff --git a/tests/Feature/Doctor/InstallChecksTest.php b/tests/Feature/Doctor/InstallChecksTest.php index 29bfbd27..dd5832b8 100644 --- a/tests/Feature/Doctor/InstallChecksTest.php +++ b/tests/Feature/Doctor/InstallChecksTest.php @@ -8,6 +8,7 @@ use Pollora\Discovery\Application\Services\DiscoveryManager; use Pollora\Discovery\Domain\Contracts\DiscoveryLocationInterface; use Pollora\Discovery\Domain\Models\DiscoveryLocation; +use Pollora\Discovery\Infrastructure\Adapters\LaravelDiscoveryCacheManagerFactory; use Pollora\Discovery\Infrastructure\Services\DiscoveryCacheManager; use Pollora\Doctor\Domain\Enums\RunContext; use Pollora\Doctor\Infrastructure\Checks\DiscoveryCacheCheck; @@ -213,7 +214,7 @@ function cacheManager(bool $debug): DiscoveryCacheManager $detector = Mockery::mock(DebugDetectorInterface::class); $detector->shouldReceive('isDebugMode')->andReturn($debug); - return new DiscoveryCacheManager(app(), $detector); + return (new LaravelDiscoveryCacheManagerFactory(app(), $detector))->make(); } beforeEach(function (): void { diff --git a/tests/Feature/Hook/Async/AsyncActionsCheckTest.php b/tests/Feature/Hook/Async/AsyncActionsCheckTest.php index ed96ee04..78d6e27a 100644 --- a/tests/Feature/Hook/Async/AsyncActionsCheckTest.php +++ b/tests/Feature/Hook/Async/AsyncActionsCheckTest.php @@ -170,8 +170,7 @@ public function dispatch(AsyncPayload $payload, int $delay = 0): void {} 'null' => [['jobs' => ['driver' => 'null']], 'discards jobs'], ]); -it('warns when WP-Cron events are overdue, with the fix for what runs WP-Cron', function (bool $onPageLoad, bool $laravelScheduler, string $fix): void { - config(['wordpress.use_laravel_scheduler' => $laravelScheduler]); +it('warns when WP-Cron events are overdue, with the fix for what runs WP-Cron', function (bool $onPageLoad, string $fix): void { ($this->register)('save_post'); $this->inspector->cronOnPageLoad = $onPageLoad; $this->inspector->overdue = ['count' => 3, 'oldest' => 1_800_000_000]; @@ -182,9 +181,8 @@ public function dispatch(AsyncPayload $payload, int $delay = 0): void {} ->and($result->details[0])->toBe('3 WP-Cron event(s) overdue, the oldest since 2027-01-15 08:00 UTC: WP-Cron and Action Scheduler run nothing until wp-cron.php is requested') ->and($result->fix)->toContain($fix); })->with([ - 'disabled' => [false, false, 'DISABLE_WP_CRON is set'], - 'on page load' => [true, false, 'too few visits'], - 'Laravel scheduler' => [false, true, 'run a queue worker'], + 'disabled' => [false, 'DISABLE_WP_CRON is set'], + 'on page load' => [true, 'too few visits'], ]); it('leaves overdue WP-Cron events alone when no driver in use relies on WP-Cron', function (): void { diff --git a/tests/Feature/Hook/Async/AsyncAttributeTest.php b/tests/Feature/Hook/Async/AsyncAttributeTest.php index 8506b5bb..96b402da 100644 --- a/tests/Feature/Hook/Async/AsyncAttributeTest.php +++ b/tests/Feature/Hook/Async/AsyncAttributeTest.php @@ -11,7 +11,6 @@ use Pollora\Hook\Async\Async; use Pollora\Hook\Async\AsyncPayload; use Pollora\Hook\Async\QueuedHandler; -use Pollora\Hook\Domain\Contract\Action as ActionContract; use Pollora\Hook\Infrastructure\Services\AsyncAttributeRegistrar; use Pollora\Hook\Infrastructure\Services\HookDiscovery; use Psr\Log\AbstractLogger; @@ -149,42 +148,3 @@ function registeredCallback(Action $actions, string $hook): mixed ->and($this->logger->errors)->toContain(sprintf('#[Async] on %s::orphan() is ignored: the method has no #[Action] to make asynchronous.', AsyncAttributeHooks::class)); }); }); - -describe('Action::handle()', function (): void { - it('registers through the registrar, asynchronously when #[Async] says so', function (): void { - $locator = new readonly class($this->actions, $this->logger) - { - public function __construct(private ActionContract $actions, private AbstractLogger $logger) {} - - public function get(string $id): mixed - { - return $id === ActionContract::class ? $this->actions : $this->logger; - } - }; - $method = new ReflectionMethod(AsyncAttributeHooks::class, 'notify'); - $attribute = new ActionAttribute('transition_post_status'); - - $attribute->handle($locator, new AsyncAttributeHooks, $method, $attribute); - - expect(registeredCallback($this->actions, 'transition_post_status'))->toBeInstanceOf(QueuedHandler::class); - }); - - it('works without a logger in the service locator, and ignores a class context', function (): void { - $locator = new readonly class($this->actions) - { - public function __construct(private ActionContract $actions) {} - - public function get(string $id): mixed - { - return $id === ActionContract::class ? $this->actions : throw new RuntimeException('Not bound'); - } - }; - $attribute = new ActionAttribute('save_post'); - - $attribute->handle($locator, new AsyncAttributeInvalidHooks, new ReflectionMethod(AsyncAttributeInvalidHooks::class, 'typo'), $attribute); - $attribute->handle($locator, new AsyncAttributeHooks, new ReflectionClass(AsyncAttributeHooks::class), new ActionAttribute('init')); - - expect(registeredCallback($this->actions, 'save_post'))->toBeArray() - ->and($this->actions->callbacks('init'))->toBeNull(); - }); -}); diff --git a/tests/Feature/Hook/Async/AsyncInspectorTest.php b/tests/Feature/Hook/Async/AsyncInspectorTest.php index 1ed06cca..4195a02e 100644 --- a/tests/Feature/Hook/Async/AsyncInspectorTest.php +++ b/tests/Feature/Hook/Async/AsyncInspectorTest.php @@ -93,6 +93,15 @@ public function get_results(string $query, string $output): array ->and($queued)->toBeInstanceOf(QueuedHandler::class); }); +it("does not count as WP-Cron's the events another driver keeps, which the cron option indexes too", function (): void { + $now = time(); + Functions\when('_get_cron_array')->justReturn([ + $now - 7200 => ['wp_version_check' => ['k1' => ['schedule' => 'daily', 'args' => []]], 'reports' => ['k2' => ['schedule' => 'daily', 'args' => [], 'driver' => 'action-scheduler']]], + ]); + + expect($this->inspector->overdueCronEvents(3600))->toBe(['count' => 1, 'oldest' => $now - 7200]); +}); + it('counts the WP-Cron events overdue for longer than the grace period', function (): void { $now = time(); Functions\when('_get_cron_array')->justReturn([ diff --git a/tests/Feature/Hook/HookDiscoveryTest.php b/tests/Feature/Hook/HookDiscoveryTest.php new file mode 100644 index 00000000..487f9a15 --- /dev/null +++ b/tests/Feature/Hook/HookDiscoveryTest.php @@ -0,0 +1,94 @@ + */ + public array $calls = []; + + #[ActionAttribute('init')] + public function boot(): void + { + $this->calls[] = 'boot'; + } + + #[ActionAttribute('save_post', priority: 99)] + #[ActionAttribute('edit_post', priority: 20)] + public function syncPost(int $postId, object $post): void + { + $this->calls[] = sprintf('syncPost(%d, %s)', $postId, $post::class); + } + + #[FilterAttribute('the_title')] + public function title(string $title): string + { + return 'modified_'.$title; + } + + #[FilterAttribute('the_content', priority: 5)] + public function content(string $content): string + { + return $content; + } + + public function notAHook(): void + { + $this->calls[] = 'notAHook'; + } +} + +beforeEach(function (): void { + $this->actions = new Action; + $this->filters = new Filter; + $discovery = new HookDiscovery($this->actions, $this->filters); + $discovery->discover(new DiscoveryLocation('', __DIR__), DiscoveredClass::fromReflection(new ReflectionClass(DiscoveredHooks::class)), new ReflectionCache); + $discovery->apply(); +}); + +/** + * The registration made on a hook: its method, priority and accepted arguments. + * + * @return array{method: string, priority: int, args: int} + */ +function hookRegistration(Action|Filter $hooks, string $hook): array +{ + $registration = $hooks->callbacks($hook)[0]; + + return ['method' => $registration['callback'][1], 'priority' => $registration['priority'], 'args' => $registration['args']]; +} + +it('registers each #[Action] with its priority, 10 by default', function (): void { + expect(hookRegistration($this->actions, 'init'))->toBe(['method' => 'boot', 'priority' => 10, 'args' => 0]) + ->and(hookRegistration($this->actions, 'save_post'))->toBe(['method' => 'syncPost', 'priority' => 99, 'args' => 2]) + ->and(hookRegistration($this->actions, 'edit_post'))->toBe(['method' => 'syncPost', 'priority' => 20, 'args' => 2]); +}); + +it('registers each #[Filter] with its priority, 10 by default', function (): void { + expect(hookRegistration($this->filters, 'the_title'))->toBe(['method' => 'title', 'priority' => 10, 'args' => 1]) + ->and(hookRegistration($this->filters, 'the_content'))->toBe(['method' => 'content', 'priority' => 5, 'args' => 1]); +}); + +it('calls the method of an instance of the declaring class', function (): void { + $callback = $this->filters->callbacks('the_title')[0]['callback']; + + expect($callback[0])->toBeInstanceOf(DiscoveredHooks::class) + ->and($callback('Hello'))->toBe('modified_Hello'); +}); + +it('registers nothing for a method without hook attribute', function (): void { + $registrations = array_merge(...array_values($this->actions->all()), ...array_values($this->filters->all())); + + expect(array_map(fn (array $registration): string => $registration['callback'][1], $registrations)) + ->toHaveCount(5) + ->not->toContain('notAHook'); +}); diff --git a/tests/Feature/Schedule/LaravelDriverTest.php b/tests/Feature/Schedule/LaravelDriverTest.php new file mode 100644 index 00000000..38d56645 --- /dev/null +++ b/tests/Feature/Schedule/LaravelDriverTest.php @@ -0,0 +1,328 @@ +cron = []; + $this->fired = []; + $this->wpOptions = []; + + 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(['hourly' => ['interval' => 3600], 'daily' => ['interval' => 86400]]); + Functions\when('add_filter')->justReturn(true); + Functions\when('add_action')->justReturn(true); + Functions\when('remove_filter')->justReturn(true); + Functions\when('do_action_ref_array')->alias(function (string $hook, array $args): void { + $this->fired[] = [$hook, $args]; + }); + Functions\when('wp_cache_flush_runtime')->justReturn(true); + Functions\when('get_current_blog_id')->justReturn(1); + Functions\when('is_multisite')->justReturn(false); + Functions\when('get_option')->alias(fn (string $name, mixed $default = false): mixed => $this->wpOptions[$name] ?? $default); + Functions\when('update_option')->alias(function (string $name, mixed $value): bool { + $this->wpOptions[$name] = $value; + + return true; + }); + Functions\when('did_action')->justReturn(1); + + config([ + 'queue.default' => 'database', + 'queue.connections.database' => ['driver' => 'database'], + 'queue.connections.sqs' => ['driver' => 'sqs'], + 'queue.connections.sync' => ['driver' => 'sync'], + 'schedule.laravel' => ['connection' => null, 'queue' => null, 'max_delay' => null, 'late_after' => 600], + ]); + + Bus::fake(); + Functions\when('wp_clear_scheduled_hook')->justReturn(0); + Functions\when('wp_unschedule_hook')->justReturn(0); + + foreach (['engine', 'fake', 'driverBeforeFake', 'wanted'] as $property) { + (new ReflectionClass(Schedule::class))->setStaticPropertyValue($property, null); + } + + app()->forgetInstance(LaravelQueueDriver::class); + app()->forgetInstance(LateEventRecovery::class); + config(['schedule.driver' => 'laravel', 'schedule.protected_hooks' => []]); + app()->register(SchedulerServiceProvider::class, force: true); + + $this->driver = resolve(LaravelQueueDriver::class); + $this->engine = Schedule::engine(); +}); + +/** + * @return list + */ +function dispatchedJobs(): array +{ + return Bus::dispatched(RunScheduledEvent::class)->all(); +} + +describe('the driver', function (): void { + it('is available on a queue a worker runs, not on sync or null', function (): void { + expect($this->driver->available())->toBeTrue(); + + config(['schedule.laravel.connection' => 'sync']); + expect($this->driver->available())->toBeFalse(); + + config(['schedule.laravel.connection' => 'missing']); + expect($this->driver->available())->toBeFalse(); + }); + + it('is built before the application has a bus: the driver is chosen while the providers register', function (): void { + $driver = new LaravelQueueDriver(new Container, resolve('config')); + + expect($driver->available())->toBeTrue(); + }); + + it('queues a delayed job and writes the entry, with its identifier, in the index', function (): void { + $time = time() + 7200; + $this->engine->place(new Event('digest', $time, null, ['u' => 1])); + + $job = dispatchedJobs()[0]; + + expect(dispatchedJobs())->toHaveCount(1) + ->and($job->hook)->toBe('digest') + ->and($job->timestamp)->toBe($time) + ->and($job->delay)->toBeBetween(7199, 7200) + ->and($job->connection)->toBe('database') + ->and(CronIndex::ownerOf('digest', ['u' => 1]))->toBe('laravel') + ->and(CronIndex::idOf('digest', ['u' => 1]))->toBe($job->id) + ->and($this->engine->next('digest', ['u' => 1]))->toBe($time); + }); + + it('queues a job for an event already due without a delay', function (): void { + $this->engine->place(new Event('digest', time() - 5)); + + expect(dispatchedJobs()[0]->delay)->toBeNull(); + }); + + it('waits by the longest delay a connection accepts, and by the one configured', function (): void { + config(['schedule.laravel.connection' => 'sqs']); + $this->engine->place(new Event('digest', time() + 7200)); + + config(['schedule.laravel.connection' => 'database', 'schedule.laravel.max_delay' => 60]); + $this->engine->place(new Event('other', time() + 7200)); + + expect(dispatchedJobs()[0]->delay)->toBe(900) + ->and(dispatchedJobs()[1]->delay)->toBe(60); + }); + + it('cancels by taking the entry out of the index, without reading the queue', function (): void { + $this->engine->place(new Event('digest', time() + 3600, null, ['u' => 1])); + + expect($this->engine->cancel('digest', ['u' => 1]))->toBe(1) + ->and(CronIndex::ownerOf('digest', ['u' => 1]))->toBeNull() + ->and($this->engine->next('digest', ['u' => 1]))->toBeNull(); + }); + + it('gives a recurring event the interval of its recurrence when the caller gave none: the fluent API and #[Schedule] only know the name', function (): void { + $this->engine->place(new Event('reports', time() - 5, 'daily', [])); + + expect(dispatchedJobs()[0]->interval)->toBe(86400); + + $job = dispatchedJobs()[0]; + $job->handle($this->driver); + + $next = CronIndex::events('laravel'); + + expect($next)->toHaveCount(1) + ->and($next[0]->hook)->toBe('reports') + ->and($next[0]->timestamp)->toBe($job->timestamp + 86400); + }); + + it('stores a single event of the WordPress functions the same way', function (): void { + $this->driver->once('digest', time() + 600, []); + $this->driver->recurring('reports', time() + 600, 'daily', []); + + expect(CronIndex::events('laravel'))->toHaveCount(2) + ->and(CronIndex::events('laravel')[0]->id)->not->toBeNull() + ->and(dispatchedJobs()[1]->interval)->toBe(86400); + }); +}); + +describe('the job', function (): void { + it('fires the hook and takes a single event out of the index', function (): void { + $this->engine->place(new Event('digest', time() - 1, null, ['u' => 1])); + + dispatchedJobs()[0]->handle($this->driver); + + expect($this->fired)->toBe([['digest', ['u' => 1]]]) + ->and(CronIndex::events('laravel'))->toBe([]); + }); + + it('queues the next occurrence of a recurring event, never in the past', function (): void { + $this->engine->place(new Event('reports', time() - 10000, 'hourly', [], 3600)); + $job = dispatchedJobs()[0]; + + $job->handle($this->driver); + + $next = CronIndex::events('laravel')[0]; + + expect($this->fired)->toHaveCount(1) + ->and($next->timestamp)->toBeGreaterThan(time()) + ->and($next->timestamp)->toBeLessThanOrEqual(time() + 3600) + ->and(($next->timestamp - $job->timestamp) % 3600)->toBe(0) + ->and($next->id)->not->toBe($job->id) + ->and(dispatchedJobs())->toHaveCount(2) + ->and(dispatchedJobs()[1]->id)->toBe($next->id); + }); + + it('stops when its event was cancelled, moved or replaced', function (): void { + $this->engine->place(new Event('digest', time() - 1)); + $this->engine->place(new Event('moved', time() - 1)); + $this->engine->place(new Event('replaced', time() - 1)); + [$cancelled, $moved, $replaced] = dispatchedJobs(); + + $this->engine->cancel('digest'); + CronIndex::record((new Event('moved', time() + 5000))->withId($moved->id), 'laravel'); + CronIndex::record((new Event('replaced', time() - 1))->withId('another'), 'laravel'); + + $cancelled->handle($this->driver); + $moved->handle($this->driver); + $replaced->handle($this->driver); + + expect($this->fired)->toBe([]); + }); + + it('waits again when it wakes before its time, and runs only once when sent twice', function (): void { + $this->engine->place(new Event('digest', time() + 3600)); + $early = dispatchedJobs()[0]; + + $early->handle($this->driver); + + expect($this->fired)->toBe([]) + ->and(dispatchedJobs())->toHaveCount(2) + ->and(dispatchedJobs()[1]->id)->toBe($early->id) + ->and(dispatchedJobs()[1]->timestamp)->toBe($early->timestamp); + + $this->cron = []; + $this->engine->place(new Event('again', time() - 1)); + $first = dispatchedJobs()[2]; + $copy = clone $first; + + $first->handle($this->driver); + $copy->handle($this->driver); + + expect($this->fired)->toBe([['again', []]]); + }); + + it('records a final failure, and goes on with a recurring event', function (): void { + $this->engine->place(new Event('reports', time() - 10, 'hourly', [], 3600)); + $this->engine->place(new Event('digest', time() - 10)); + [$recurring, $single] = dispatchedJobs(); + + $recurring->failed(new RuntimeException('boom')); + $single->failed(new RuntimeException('bang')); + + expect(array_column(ScheduleFailures::recent(), 'hook'))->toBe(['reports', 'digest']) + ->and(ScheduleFailures::recent()[1]['message'])->toBe('bang') + ->and(array_column(CronIndex::events('laravel'), 'hook'))->toBe(['reports']) + ->and(dispatchedJobs())->toHaveCount(3); + }); + + it('runs in the site that scheduled the event on a network', function (): void { + $switched = []; + Functions\when('is_multisite')->justReturn(true); + Functions\when('switch_to_blog')->alias(function (int $site) use (&$switched): bool { + $switched[] = $site; + + return true; + }); + Functions\when('restore_current_blog')->alias(function () use (&$switched): bool { + $switched[] = 'restore'; + + return true; + }); + + $this->engine->place(new Event('digest', time() - 1)); + $job = dispatchedJobs()[0]; + $job = new RunScheduledEvent($job->hook, $job->args, $job->timestamp, $job->id, null, null, 3); + + $job->handle($this->driver); + + expect($switched)->toBe([3, 'restore']); + }); +}); + +describe('the recovery', function (): void { + it('sends again the events more than late_after seconds past their time, with the same identifier, once', function (): void { + $this->engine->place(new Event('lost', time() - 1200)); + $this->engine->place(new Event('fresh', time() - 30)); + $this->engine->place(new Event('future', time() + 3000)); + + $queued = count(dispatchedJobs()); + + $recovery = resolve(LateEventRecovery::class); + $sent = $recovery->recover(); + + expect($sent)->toBe(1) + ->and(dispatchedJobs())->toHaveCount($queued + 1) + ->and(dispatchedJobs()[$queued]->hook)->toBe('lost') + ->and(dispatchedJobs()[$queued]->id)->toBe(CronIndex::idOf('lost')) + ->and($recovery->recover())->toBe(0) + ->and($recovery->recover(force: true))->toBe(1); + }); + + it('gives an event of the index that has no identifier one', function (): void { + $this->cron = [time() - 5000 => ['lost' => [md5(serialize([])) => ['schedule' => false, 'args' => [], 'driver' => 'laravel']]]]; + + expect(resolve(LateEventRecovery::class)->recover(force: true))->toBe(1) + ->and(CronIndex::idOf('lost'))->not->toBeNull(); + }); + + it('runs from the command, and lists with --dry-run', function (): void { + $this->engine->place(new Event('lost', time() - 1200)); + + expect(Artisan::call('pollora:schedule:recover', ['--dry-run' => true]))->toBe(0) + ->and(Artisan::output())->toContain('lost', '1 late event(s).') + ->and(dispatchedJobs())->toHaveCount(1) + ->and(Artisan::call('pollora:schedule:recover'))->toBe(0) + ->and(Artisan::output())->toContain('1 event(s) sent again.') + ->and(dispatchedJobs())->toHaveCount(2); + }); +}); + +describe('the doctor check', function (): void { + it('warns of late events, and of a failure that left a single event out of the schedule', function (): void { + $this->engine->place(new Event('lost', time() - 1200)); + ScheduleFailures::record('digest', 'bang'); + + $result = resolve(ScheduleDriverCheck::class)->run(RunContext::Console); + + expect($result->status)->toBe(CheckStatus::Warning) + ->and(implode(' ', $result->details))->toContain('1 scheduled event(s) are more than 10 minutes late', '"digest" failed for good') + ->and($result->fix)->toContain('queue:work'); + }); + + it('says what to change when the queue is sync', function (): void { + config(['schedule.laravel.connection' => 'sync']); + + $result = resolve(ScheduleDriverCheck::class)->run(RunContext::Console); + + expect($result->status)->toBe(CheckStatus::Warning) + ->and($result->fix)->toContain('never sync or null'); + }); +}); diff --git a/tests/Feature/Schedule/ScheduleCommandsTest.php b/tests/Feature/Schedule/ScheduleCommandsTest.php new file mode 100644 index 00000000..85cc56b6 --- /dev/null +++ b/tests/Feature/Schedule/ScheduleCommandsTest.php @@ -0,0 +1,338 @@ + */ + 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('get_current_blog_id')->justReturn(1); + Functions\when('is_multisite')->justReturn(false); + 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']); + }); +}); diff --git a/tests/Feature/Schedule/SchedulerServiceProviderTest.php b/tests/Feature/Schedule/SchedulerServiceProviderTest.php index 023f58b3..90084e35 100644 --- a/tests/Feature/Schedule/SchedulerServiceProviderTest.php +++ b/tests/Feature/Schedule/SchedulerServiceProviderTest.php @@ -4,10 +4,8 @@ use Pollora\Discovery\Domain\Contracts\DiscoveryEngineInterface; use Pollora\Hook\Domain\Contract\Filter; -use Pollora\Schedule\Application\UseCases\RegisterSchedulerFiltersUseCase; -use Pollora\Schedule\Contracts\SchedulerInterface; use Pollora\Schedule\Infrastructure\Services\ScheduleDiscovery; -use Pollora\Schedule\Scheduler; +use Pollora\Schedule\Schedule; use Pollora\Schedule\SchedulerServiceProvider; beforeEach(function (): void { @@ -22,13 +20,6 @@ }); describe('SchedulerServiceProvider', function (): void { - it('binds SchedulerInterface as singleton', function (): void { - $scheduler = $this->app->make(SchedulerInterface::class); - - expect($scheduler)->toBeInstanceOf(Scheduler::class); - expect($this->app->make(SchedulerInterface::class))->toBe($scheduler); - }); - it('binds ScheduleDiscovery as singleton', function (): void { $discovery = $this->app->make(ScheduleDiscovery::class); @@ -36,9 +27,9 @@ expect($this->app->make(ScheduleDiscovery::class))->toBe($discovery); }); - it('binds RegisterSchedulerFiltersUseCase', function (): void { - $useCase = $this->app->make(RegisterSchedulerFiltersUseCase::class); + it('gives the schedule engine the application, so the classes passed to call() get their dependencies', function (): void { + $container = (new ReflectionProperty(Schedule::engine(), 'container'))->getValue(Schedule::engine()); - expect($useCase)->toBeInstanceOf(RegisterSchedulerFiltersUseCase::class); + expect($container)->toBe($this->app); }); }); diff --git a/tests/Pest.php b/tests/Pest.php index 37093e09..e5722449 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -17,7 +17,6 @@ uses(TestCase::class)->in('Feature/Modules', 'Feature/Option'); uses(TestCase::class)->in('Unit'); -uses()->in('Benchmark'); uses(Orchestra\Testbench\TestCase::class) ->beforeEach(function (): void { \Brain\Monkey\setUp(); diff --git a/tests/Unit/Attributes/ActionTest.php b/tests/Unit/Attributes/ActionTest.php deleted file mode 100644 index 4cd33586..00000000 --- a/tests/Unit/Attributes/ActionTest.php +++ /dev/null @@ -1,187 +0,0 @@ -mockAction = Mockery::mock(ActionService::class); - - // Create a fake service locator - $this->mockServiceLocator = new class($this->mockAction) - { - public function __construct(private $actionService) {} - - public function get($serviceClass) - { - if ($serviceClass === ActionService::class) { - return $this->actionService; - } - - return null; - } - }; -}); - -class SingleActionClass -{ - #[Action('test_action', priority: 10)] - public function actionMethod($param = null): string - { - // Test method - return $param ? 'processed_'.$param : 'processed'; - } -} - -class MultipleActionClass -{ - #[Action('test_action', priority: 10)] - public function actionMethod($param = null): string - { - // Test method - return $param ? 'processed_'.$param : 'processed'; - } - - #[Action('another_action', priority: 20)] - public function anotherActionMethod(): string - { - // Another test method - return 'another_processed'; - } -} - -class DefaultPriorityActionClass -{ - #[Action('test_action')] - public function actionMethod($param = null): string - { - // Test method with default priority - return $param ? 'processed_'.$param : 'processed'; - } -} - -class CustomPriorityActionClass -{ - #[Action('test_action', priority: 42)] - public function actionMethod($param = null): string - { - // Test method with custom priority - return $param ? 'processed_'.$param : 'processed'; - } -} - -it('registers an action hook correctly', function (): void { - // Set up expectations - $this->mockAction->shouldReceive('add') - ->once() - ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'test_action' - && is_array($callback) - && $callback[0] instanceof SingleActionClass - && $callback[1] === 'actionMethod' - && $priority === 10 - && $acceptedArgs === 1); - - // Test the action attribute directly using handle method - $testClass = new SingleActionClass; - $actionAttribute = new Action('test_action', 10); - $methodReflection = new ReflectionMethod($testClass, 'actionMethod'); - - $actionAttribute->handle($this->mockServiceLocator, $testClass, $methodReflection, $actionAttribute); -}); - -it('registers multiple action hooks with different priorities', function (): void { - $testClass = new MultipleActionClass; - - // Test first action - $this->mockAction->shouldReceive('add') - ->once() - ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'test_action' - && is_array($callback) - && $callback[0] instanceof MultipleActionClass - && $callback[1] === 'actionMethod' - && $priority === 10); - - $actionAttribute1 = new Action('test_action', 10); - $methodReflection1 = new ReflectionMethod($testClass, 'actionMethod'); - $actionAttribute1->handle($this->mockServiceLocator, $testClass, $methodReflection1, $actionAttribute1); - - // Test second action - $this->mockAction->shouldReceive('add') - ->once() - ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'another_action' - && is_array($callback) - && $callback[0] instanceof MultipleActionClass - && $callback[1] === 'anotherActionMethod' - && $priority === 20); - - $actionAttribute2 = new Action('another_action', 20); - $methodReflection2 = new ReflectionMethod($testClass, 'anotherActionMethod'); - $actionAttribute2->handle($this->mockServiceLocator, $testClass, $methodReflection2, $actionAttribute2); -}); - -it('registers an action hook with default priority (10)', function (): void { - // Set up expectations - $this->mockAction->shouldReceive('add') - ->once() - ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'test_action' - && is_array($callback) - && $callback[0] instanceof DefaultPriorityActionClass - && $callback[1] === 'actionMethod' - && $priority === 10 // Default priority should be 10 - && $acceptedArgs === 1); - - // Test with default priority - $testClass = new DefaultPriorityActionClass; - $actionAttribute = new Action('test_action'); // No priority specified, should default to 10 - $methodReflection = new ReflectionMethod($testClass, 'actionMethod'); - - $actionAttribute->handle($this->mockServiceLocator, $testClass, $methodReflection, $actionAttribute); -}); - -it('registers an action hook with custom priority', function (): void { - // Set up expectations - $this->mockAction->shouldReceive('add') - ->once() - ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'test_action' - && is_array($callback) - && $callback[0] instanceof CustomPriorityActionClass - && $callback[1] === 'actionMethod' - && $priority === 42 // Custom priority - && $acceptedArgs === 1); - - // Test with custom priority - $testClass = new CustomPriorityActionClass; - $actionAttribute = new Action('test_action', 42); - $methodReflection = new ReflectionMethod($testClass, 'actionMethod'); - - $actionAttribute->handle($this->mockServiceLocator, $testClass, $methodReflection, $actionAttribute); -}); - -it('handles null service locator resolution gracefully', function (): void { - // Create a service locator that returns null for the service - $mockServiceLocator = new class - { - public function get($serviceClass): null - { - return null; - } - }; - - $testClass = new SingleActionClass; - $actionAttribute = new Action('test_action', 10); - $methodReflection = new ReflectionMethod($testClass, 'actionMethod'); - - // This should not throw an exception - $actionAttribute->handle($mockServiceLocator, $testClass, $methodReflection, $actionAttribute); - - // No assertions needed - we're just checking that it doesn't throw - expect(true)->toBeTrue(); -}); - -afterEach(function (): void { - Mockery::close(); - Facade::clearResolvedInstances(); -}); diff --git a/tests/Unit/Attributes/FilterTest.php b/tests/Unit/Attributes/FilterTest.php deleted file mode 100644 index abf6cd87..00000000 --- a/tests/Unit/Attributes/FilterTest.php +++ /dev/null @@ -1,209 +0,0 @@ -mockFilter = Mockery::mock(FilterService::class); - - // Create a fake service locator - $this->mockServiceLocator = new class($this->mockFilter) - { - public function __construct(private $filterService) {} - - public function get($serviceClass) - { - if ($serviceClass === FilterService::class) { - return $this->filterService; - } - - return null; - } - }; -}); - -class SingleFilterClass -{ - #[Filter('test_filter', priority: 10)] - public function filterMethod(string $value): string - { - return 'modified_'.$value; - } -} - -class MultipleFilterClass -{ - #[Filter('test_filter', priority: 10)] - public function filterMethod(string $value): string - { - return 'modified_'.$value; - } - - #[Filter('another_filter', priority: 20)] - public function anotherFilterMethod(array $data): array - { - $data['modified'] = true; - - return $data; - } -} - -class DefaultPriorityFilterClass -{ - #[Filter('test_filter')] - public function filterMethod(string $value): string - { - // Test method with default priority - return 'modified_'.$value; - } -} - -class CustomPriorityFilterClass -{ - #[Filter('test_filter', priority: 99)] - public function filterMethod(string $value): string - { - // Test method with custom priority - return 'modified_'.$value; - } -} - -it('registers a filter hook correctly', function (): void { - // Set up expectations - $this->mockFilter->shouldReceive('add') - ->once() - ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'test_filter' - && is_array($callback) - && $callback[0] instanceof SingleFilterClass - && $callback[1] === 'filterMethod' - && $priority === 10 - && $acceptedArgs === 1); - - // Test the filter attribute directly using handle method - $testClass = new SingleFilterClass; - $filterAttribute = new Filter('test_filter', 10); - $methodReflection = new ReflectionMethod($testClass, 'filterMethod'); - - $filterAttribute->handle($this->mockServiceLocator, $testClass, $methodReflection, $filterAttribute); -}); - -it('registers multiple filter hooks with different priorities', function (): void { - $testClass = new MultipleFilterClass; - - // Test first filter - $this->mockFilter->shouldReceive('add') - ->once() - ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'test_filter' - && is_array($callback) - && $callback[0] instanceof MultipleFilterClass - && $callback[1] === 'filterMethod' - && $priority === 10); - - $filterAttribute1 = new Filter('test_filter', 10); - $methodReflection1 = new ReflectionMethod($testClass, 'filterMethod'); - $filterAttribute1->handle($this->mockServiceLocator, $testClass, $methodReflection1, $filterAttribute1); - - // Test second filter - $this->mockFilter->shouldReceive('add') - ->once() - ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'another_filter' - && is_array($callback) - && $callback[0] instanceof MultipleFilterClass - && $callback[1] === 'anotherFilterMethod' - && $priority === 20); - - $filterAttribute2 = new Filter('another_filter', 20); - $methodReflection2 = new ReflectionMethod($testClass, 'anotherFilterMethod'); - $filterAttribute2->handle($this->mockServiceLocator, $testClass, $methodReflection2, $filterAttribute2); -}); - -it('registers a filter hook with default priority (10)', function (): void { - // Set up expectations - $this->mockFilter->shouldReceive('add') - ->once() - ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'test_filter' - && is_array($callback) - && $callback[0] instanceof DefaultPriorityFilterClass - && $callback[1] === 'filterMethod' - && $priority === 10 // Default priority should be 10 - && $acceptedArgs === 1); - - // Test with default priority - $testClass = new DefaultPriorityFilterClass; - $filterAttribute = new Filter('test_filter'); // No priority specified, should default to 10 - $methodReflection = new ReflectionMethod($testClass, 'filterMethod'); - - $filterAttribute->handle($this->mockServiceLocator, $testClass, $methodReflection, $filterAttribute); -}); - -it('registers a filter hook with custom priority', function (): void { - // Set up expectations - $this->mockFilter->shouldReceive('add') - ->once() - ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'test_filter' - && is_array($callback) - && $callback[0] instanceof CustomPriorityFilterClass - && $callback[1] === 'filterMethod' - && $priority === 99 // Custom priority - && $acceptedArgs === 1); - - // Test with custom priority - $testClass = new CustomPriorityFilterClass; - $filterAttribute = new Filter('test_filter', 99); - $methodReflection = new ReflectionMethod($testClass, 'filterMethod'); - - $filterAttribute->handle($this->mockServiceLocator, $testClass, $methodReflection, $filterAttribute); -}); - -it('executes filter and returns modified value', function (): void { - // Set up expectations for the add method - $this->mockFilter->shouldReceive('add') - ->once() - ->withAnyArgs(); - - // Set up expectations for the apply method - $this->mockFilter->shouldReceive('apply') - ->once() - ->with('test_filter', 'original') - ->andReturn('modified_original'); - - // Test filter attribute directly - $testClass = new SingleFilterClass; - $filterAttribute = new Filter('test_filter', 10); - $methodReflection = new ReflectionMethod($testClass, 'filterMethod'); - $filterAttribute->handle($this->mockServiceLocator, $testClass, $methodReflection, $filterAttribute); - - // Test the apply method - $result = $this->mockFilter->apply('test_filter', 'original'); - expect($result)->toBe('modified_original'); -}); - -it('handles null service locator resolution gracefully', function (): void { - // Create a service locator that returns null for the service - $mockServiceLocator = new class - { - public function get($serviceClass): null - { - return null; - } - }; - - $testClass = new SingleFilterClass; - $filterAttribute = new Filter('test_filter', 10); - $methodReflection = new ReflectionMethod($testClass, 'filterMethod'); - - // This should not throw an exception - $filterAttribute->handle($mockServiceLocator, $testClass, $methodReflection, $filterAttribute); - - // No assertions needed - we're just checking that it doesn't throw - expect(true)->toBeTrue(); -}); - -afterEach(function (): void { - Mockery::close(); - Facade::clearResolvedInstances(); -}); diff --git a/tests/Unit/Attributes/HookTest.php b/tests/Unit/Attributes/HookTest.php deleted file mode 100644 index 72b02c9c..00000000 --- a/tests/Unit/Attributes/HookTest.php +++ /dev/null @@ -1,66 +0,0 @@ -hook)->toBe('test_hook'); - expect($hook->priority)->toBe(10); -}); - -it('initializes with custom priority', function (): void { - $hook = new ConcreteHook('test_hook', 20); - expect($hook->hook)->toBe('test_hook'); - expect($hook->priority)->toBe(20); -}); - -it('stores hook name correctly', function (): void { - $hookName = 'custom_hook_name'; - $hook = new ConcreteHook($hookName); - expect($hook->hook)->toBe($hookName); -}); - -it('allows priority to be a negative number', function (): void { - $priority = -1; - $hook = new ConcreteHook('test_hook', $priority); - expect($hook->priority)->toBe($priority); -}); - -it('allows priority to be zero', function (): void { - $priority = 0; - $hook = new ConcreteHook('test_hook', $priority); - expect($hook->priority)->toBe($priority); -}); - -it('allows priority to be a large number', function (): void { - $priority = 9999; - $hook = new ConcreteHook('test_hook', $priority); - expect($hook->priority)->toBe($priority); -}); - -afterEach(function (): void { - Mockery::close(); - Facade::clearResolvedInstances(); -}); diff --git a/tests/Unit/Attributes/LabelsAttributeTest.php b/tests/Unit/Attributes/LabelsAttributeTest.php index 84bde720..2f6b4af1 100644 --- a/tests/Unit/Attributes/LabelsAttributeTest.php +++ b/tests/Unit/Attributes/LabelsAttributeTest.php @@ -56,9 +56,7 @@ class GenreWithLabels {} $reflection = new ReflectionClass(ProjectWithLabels::class); $attr = $reflection->getAttributes(PostTypeLabels::class)[0]->newInstance(); - $attr->handle(null, $config, $reflection, $attr); - - $labels = $config->attributeArgs['labels']; + $labels = $attr->configureArguments($config->attributeArgs)['labels']; // Overridden by #[Labels] expect($labels['all_items'])->toBe('All Projects'); @@ -88,10 +86,7 @@ class GenreWithLabels {} ); $config = new PostTypeConfiguration('item', 'Item', 'Items'); - $reflection = new ReflectionClass(ArticleWithoutLabels::class); - $labels->handle(null, $config, $reflection, $labels); - - $result = $config->attributeArgs['labels']; + $result = $labels->configureArguments($config->attributeArgs)['labels']; expect($result)->toBe([ 'name' => 'My Items', @@ -126,9 +121,7 @@ class GenreWithLabels {} $reflection = new ReflectionClass(GenreWithLabels::class); $attr = $reflection->getAttributes(TaxonomyLabels::class)[0]->newInstance(); - $attr->handle(null, $config, $reflection, $attr); - - $labels = $config->attributeArgs['labels']; + $labels = $attr->configureArguments($config->attributeArgs)['labels']; // Overridden expect($labels['all_items'])->toBe('All Genres'); diff --git a/tests/Unit/Attributes/PostTypeAttributeTest.php b/tests/Unit/Attributes/PostTypeAttributeTest.php deleted file mode 100644 index 2c0cd6b9..00000000 --- a/tests/Unit/Attributes/PostTypeAttributeTest.php +++ /dev/null @@ -1,344 +0,0 @@ - 'Test Product']; - } -} - -it('creates PostType attribute with all parameters', function (): void { - $postType = new PostType('custom-slug', 'Custom Type', 'Custom Types'); - - expect($postType->slug)->toBe('custom-slug'); - expect($postType->singular)->toBe('Custom Type'); - expect($postType->plural)->toBe('Custom Types'); -}); - -it('creates PostType attribute with only slug', function (): void { - $postType = new PostType('events'); - - expect($postType->slug)->toBe('events'); - expect($postType->singular)->toBeNull(); - expect($postType->plural)->toBeNull(); -}); - -it('creates PostType attribute with no parameters', function (): void { - $postType = new PostType; - - expect($postType->slug)->toBeNull(); - expect($postType->singular)->toBeNull(); - expect($postType->plural)->toBeNull(); -}); - -it('stores PostType properties as readonly', function (): void { - $postType = new PostType('test'); - - expect($postType->slug)->toBe('test'); - - // Verify properties are readonly - $reflection = new ReflectionClass($postType); - $slugProperty = $reflection->getProperty('slug'); - $singularProperty = $reflection->getProperty('singular'); - $pluralProperty = $reflection->getProperty('plural'); - - expect($slugProperty->isReadOnly())->toBeTrue(); - expect($singularProperty->isReadOnly())->toBeTrue(); - expect($pluralProperty->isReadOnly())->toBeTrue(); -}); - -it('creates HasArchive attribute with default value', function (): void { - $hasArchive = new HasArchive; - - expect($hasArchive->value)->toBeTrue(); -}); - -it('creates HasArchive attribute with custom slug', function (): void { - $hasArchive = new HasArchive('custom-archive'); - - expect($hasArchive->value)->toBe('custom-archive'); -}); - -it('creates HasArchive attribute disabled', function (): void { - $hasArchive = new HasArchive(false); - - expect($hasArchive->value)->toBeFalse(); -}); - -it('creates Supports attribute with default features', function (): void { - $supports = new Supports; - - expect($supports->features)->toBe(['title', 'editor']); -}); - -it('creates Supports attribute with custom features', function (): void { - $features = ['title', 'editor', 'thumbnail', 'excerpt']; - $supports = new Supports($features); - - expect($supports->features)->toBe($features); -}); - -it('creates MenuIcon attribute with dashicon', function (): void { - $menuIcon = new MenuIcon('dashicons-admin-post'); - - expect($menuIcon->value)->toBe('dashicons-admin-post'); -}); - -it('creates MenuIcon attribute with custom URL', function (): void { - $menuIcon = new MenuIcon('https://example.com/icon.png'); - - expect($menuIcon->value)->toBe('https://example.com/icon.png'); -}); - -it('creates PubliclyQueryable attribute with default value', function (): void { - $publiclyQueryable = new PubliclyQueryable; - - expect($publiclyQueryable->value)->toBeTrue(); -}); - -it('creates PubliclyQueryable attribute disabled', function (): void { - $publiclyQueryable = new PubliclyQueryable(false); - - expect($publiclyQueryable->value)->toBeFalse(); -}); - -it('creates AdminCol attribute with all parameters', function (): void { - $adminCol = new AdminCol( - 'price', - 'Product Price', - sortable: true, - titleIcon: 'dashicons-money', - dateFormat: 'd/m/Y', - link: 'edit', - cap: 'edit_posts', - default: 'ASC', - width: 120 - ); - - expect($adminCol->key)->toBe('price'); - expect($adminCol->title)->toBe('Product Price'); - expect($adminCol->width)->toBe(120); - expect($adminCol->sortable)->toBeTrue(); - expect($adminCol->titleIcon)->toBe('dashicons-money'); - expect($adminCol->dateFormat)->toBe('d/m/Y'); - expect($adminCol->link)->toBe('edit'); - expect($adminCol->cap)->toBe('edit_posts'); - expect($adminCol->default)->toBe('ASC'); -}); - -it('creates AdminCol attribute with minimal parameters', function (): void { - $adminCol = new AdminCol('title', 'Title'); - - expect($adminCol->key)->toBe('title'); - expect($adminCol->title)->toBe('Title'); - expect($adminCol->width)->toBeNull(); - expect($adminCol->sortable)->toBeFalse(); - expect($adminCol->titleIcon)->toBeNull(); - expect($adminCol->dateFormat)->toBeNull(); - expect($adminCol->link)->toBeNull(); - expect($adminCol->cap)->toBeNull(); - expect($adminCol->default)->toBeNull(); -}); - -it('creates AdminCol attribute for meta fields', function (): void { - $adminCol = new AdminCol( - 'custom_price', - 'Price', - sortable: 'meta_value_num', - metaKey: 'product_price' - ); - - expect($adminCol->key)->toBe('custom_price'); - expect($adminCol->title)->toBe('Price'); - expect($adminCol->sortable)->toBe('meta_value_num'); - expect($adminCol->metaKey)->toBe('product_price'); -}); - -it('creates AdminCol attribute for taxonomy fields', function (): void { - $adminCol = new AdminCol( - 'categories', - 'Categories', - taxonomy: 'product_category' - ); - - expect($adminCol->key)->toBe('categories'); - expect($adminCol->title)->toBe('Categories'); - expect($adminCol->taxonomy)->toBe('product_category'); -}); - -it('creates AdminCol attribute for featured image', function (): void { - $adminCol = new AdminCol( - 'image', - 'Featured Image', - featuredImage: 'thumbnail', - width: 80 - ); - - expect($adminCol->key)->toBe('image'); - expect($adminCol->title)->toBe('Featured Image'); - expect($adminCol->featuredImage)->toBe('thumbnail'); - expect($adminCol->width)->toBe(80); -}); - -it('creates RegisterMetaBoxCb attribute', function (): void { - $registerMetaBoxCb = new RegisterMetaBoxCb; - - expect($registerMetaBoxCb)->toBeInstanceOf(RegisterMetaBoxCb::class); -}); - -it('has correct PHP attribute configurations', function (): void { - // Test PostType attribute configuration - $postTypeReflection = new ReflectionClass(PostType::class); - $postTypeAttributes = $postTypeReflection->getAttributes(Attribute::class); - - expect($postTypeAttributes)->toHaveCount(1); - $postTypeAttribute = $postTypeAttributes[0]->newInstance(); - expect($postTypeAttribute->flags)->toBe(Attribute::TARGET_CLASS); - - // Test AdminCol attribute configuration - $adminColReflection = new ReflectionClass(AdminCol::class); - $adminColAttributes = $adminColReflection->getAttributes(Attribute::class); - - expect($adminColAttributes)->toHaveCount(1); - $adminColAttribute = $adminColAttributes[0]->newInstance(); - expect($adminColAttribute->flags)->toBe(Attribute::TARGET_METHOD); - - // Test RegisterMetaBoxCb attribute configuration - $registerMetaBoxCbReflection = new ReflectionClass(RegisterMetaBoxCb::class); - $registerMetaBoxCbAttributes = $registerMetaBoxCbReflection->getAttributes(Attribute::class); - - expect($registerMetaBoxCbAttributes)->toHaveCount(1); - $registerMetaBoxCbAttribute = $registerMetaBoxCbAttributes[0]->newInstance(); - expect($registerMetaBoxCbAttribute->flags)->toBe(Attribute::TARGET_METHOD); -}); - -it('test class has correct attributes applied', function (): void { - $reflection = new ReflectionClass(TestProduct::class); - - // Check class-level attributes - $postTypeAttrs = $reflection->getAttributes(PostType::class); - expect($postTypeAttrs)->toHaveCount(1); - - $hasArchiveAttrs = $reflection->getAttributes(HasArchive::class); - expect($hasArchiveAttrs)->toHaveCount(1); - - $supportsAttrs = $reflection->getAttributes(Supports::class); - expect($supportsAttrs)->toHaveCount(1); - - // Check method-level attributes - $priceMethod = $reflection->getMethod('getPriceColumn'); - $priceAdminColAttrs = $priceMethod->getAttributes(AdminCol::class); - expect($priceAdminColAttrs)->toHaveCount(1); - - $stockMethod = $reflection->getMethod('getStockColumn'); - $stockAdminColAttrs = $stockMethod->getAttributes(AdminCol::class); - expect($stockAdminColAttrs)->toHaveCount(1); - - $metaBoxMethod = $reflection->getMethod('registerProductMetaBoxes'); - $metaBoxAttrs = $metaBoxMethod->getAttributes(RegisterMetaBoxCb::class); - expect($metaBoxAttrs)->toHaveCount(1); -}); - -it('attribute values can be extracted correctly', function (): void { - $reflection = new ReflectionClass(TestProduct::class); - - // Extract PostType attribute values - $postTypeAttr = $reflection->getAttributes(PostType::class)[0]; - $postType = $postTypeAttr->newInstance(); - expect($postType->slug)->toBe('test-product'); - expect($postType->singular)->toBe('Product'); - expect($postType->plural)->toBe('Products'); - - // Extract HasArchive attribute value - $hasArchiveAttr = $reflection->getAttributes(HasArchive::class)[0]; - $hasArchive = $hasArchiveAttr->newInstance(); - expect($hasArchive->value)->toBeTrue(); - - // Extract Supports attribute values - $supportsAttr = $reflection->getAttributes(Supports::class)[0]; - $supports = $supportsAttr->newInstance(); - expect($supports->features)->toBe(['title', 'editor', 'thumbnail']); - - // Extract AdminCol attribute values - $priceMethod = $reflection->getMethod('getPriceColumn'); - $adminColAttr = $priceMethod->getAttributes(AdminCol::class)[0]; - $adminCol = $adminColAttr->newInstance(); - expect($adminCol->key)->toBe('price'); - expect($adminCol->title)->toBe('Price'); -}); - -it('accepts all types without validation', function (): void { - // No validation should happen in attribute constructors - // All validation is now handled by PostTypeDiscovery - - // PostType with any values - expect(fn (): PostType => new PostType('', '', ''))->not->toThrow(Exception::class); - expect(fn (): PostType => new PostType('invalid slug', 'invalid name', 'invalid plural'))->not->toThrow(Exception::class); - - // HasArchive with any values - expect(fn (): HasArchive => new HasArchive(''))->not->toThrow(Exception::class); - expect(fn (): HasArchive => new HasArchive('invalid-archive'))->not->toThrow(Exception::class); - - // Supports with any array - expect(fn (): Supports => new Supports([]))->not->toThrow(Exception::class); - expect(fn (): Supports => new Supports(['invalid-feature']))->not->toThrow(Exception::class); - - // MenuIcon with any string - expect(fn (): MenuIcon => new MenuIcon(''))->not->toThrow(Exception::class); - expect(fn (): MenuIcon => new MenuIcon('invalid-icon'))->not->toThrow(Exception::class); - - // AdminCol with any values - expect(fn (): AdminCol => new AdminCol('', ''))->not->toThrow(Exception::class); -}); - -test('resolveSlug returns the explicit slug', function (): void { - expect((new PostType('synthese-presse'))->resolveSlug('App\\Cms\\PostTypes\\SyntheseDePresse')) - ->toBe('synthese-presse'); -}); - -test('resolveSlug derives the slug from the class name, cut to 20 characters', function (): void { - expect((new PostType)->resolveSlug('App\\Cms\\PostTypes\\SyntheseDePresse'))->toBe('synthese-de-presse') - ->and((new PostType)->resolveSlug('App\\VeryLongPostTypeClassName'))->toBe('very-long-post-type-'); -}); diff --git a/tests/Unit/Attributes/PostTypeSubAttributesTest.php b/tests/Unit/Attributes/PostTypeSubAttributesTest.php deleted file mode 100644 index ae2e589c..00000000 --- a/tests/Unit/Attributes/PostTypeSubAttributesTest.php +++ /dev/null @@ -1,271 +0,0 @@ -getAttributes(PostType::class); - expect($postTypeAttrs)->toHaveCount(1); - - // Should detect HasArchive sub-attribute - $hasArchiveAttrs = $reflection->getAttributes(HasArchive::class); - expect($hasArchiveAttrs)->toHaveCount(1); - - // Should detect Supports sub-attribute - $supportsAttrs = $reflection->getAttributes(Supports::class); - expect($supportsAttrs)->toHaveCount(1); - - // Should detect MenuIcon sub-attribute - $menuIconAttrs = $reflection->getAttributes(MenuIcon::class); - expect($menuIconAttrs)->toHaveCount(1); -}); - -it('detects all method-level AdminCol attributes', function (): void { - $reflection = new ReflectionClass(ProductWithSubAttributes::class); - - // Check price method - $priceMethod = $reflection->getMethod('getPriceColumn'); - $priceAttrs = $priceMethod->getAttributes(AdminCol::class); - expect($priceAttrs)->toHaveCount(1); - - // Check stock method - $stockMethod = $reflection->getMethod('getStockColumn'); - $stockAttrs = $stockMethod->getAttributes(AdminCol::class); - expect($stockAttrs)->toHaveCount(1); - - // Check category method - $categoryMethod = $reflection->getMethod('getCategoryColumn'); - $categoryAttrs = $categoryMethod->getAttributes(AdminCol::class); - expect($categoryAttrs)->toHaveCount(1); - - // Check that method without attribute has no AdminCol attributes - $nameMethod = $reflection->getMethod('getProductName'); - $nameAttrs = $nameMethod->getAttributes(AdminCol::class); - expect($nameAttrs)->toHaveCount(0); -}); - -it('extracts correct values from class-level attributes', function (): void { - $reflection = new ReflectionClass(ProductWithSubAttributes::class); - - // Extract PostType attribute - $postTypeAttr = $reflection->getAttributes(PostType::class)[0]; - $postType = $postTypeAttr->newInstance(); - expect($postType->slug)->toBe('product'); - expect($postType->singular)->toBeNull(); - expect($postType->plural)->toBeNull(); - - // Extract HasArchive attribute - $hasArchiveAttr = $reflection->getAttributes(HasArchive::class)[0]; - $hasArchive = $hasArchiveAttr->newInstance(); - expect($hasArchive->value)->toBe('products-archive'); - - // Extract Supports attribute - $supportsAttr = $reflection->getAttributes(Supports::class)[0]; - $supports = $supportsAttr->newInstance(); - expect($supports->features)->toBe(['title', 'editor', 'thumbnail', 'excerpt']); - - // Extract MenuIcon attribute - $menuIconAttr = $reflection->getAttributes(MenuIcon::class)[0]; - $menuIcon = $menuIconAttr->newInstance(); - expect($menuIcon->value)->toBe('dashicons-cart'); -}); - -it('extracts correct values from method-level AdminCol attributes', function (): void { - $reflection = new ReflectionClass(ProductWithSubAttributes::class); - - // Extract price AdminCol - $priceMethod = $reflection->getMethod('getPriceColumn'); - $priceAttr = $priceMethod->getAttributes(AdminCol::class)[0]; - $priceAdminCol = $priceAttr->newInstance(); - expect($priceAdminCol->key)->toBe('price'); - expect($priceAdminCol->title)->toBe('Price'); - expect($priceAdminCol->width)->toBeNull(); // Default value - expect($priceAdminCol->sortable)->toBeFalse(); // Default value - - // Extract stock AdminCol - $stockMethod = $reflection->getMethod('getStockColumn'); - $stockAttr = $stockMethod->getAttributes(AdminCol::class)[0]; - $stockAdminCol = $stockAttr->newInstance(); - expect($stockAdminCol->key)->toBe('stock'); - expect($stockAdminCol->title)->toBe('Stock Status'); - expect($stockAdminCol->width)->toBeNull(); // Default value - expect($stockAdminCol->sortable)->toBeFalse(); // Default value - - // Extract category AdminCol - $categoryMethod = $reflection->getMethod('getCategoryColumn'); - $categoryAttr = $categoryMethod->getAttributes(AdminCol::class)[0]; - $categoryAdminCol = $categoryAttr->newInstance(); - expect($categoryAdminCol->key)->toBe('category'); - expect($categoryAdminCol->title)->toBe('Category'); - expect($categoryAdminCol->width)->toBeNull(); // Default value - expect($categoryAdminCol->sortable)->toBeFalse(); // Default value -}); - -it('supports multiple AdminCol attributes on different methods', function (): void { - $reflection = new ReflectionClass(ProductWithSubAttributes::class); - $adminColMethods = []; - - foreach ($reflection->getMethods() as $method) { - $adminColAttrs = $method->getAttributes(AdminCol::class); - if (! empty($adminColAttrs)) { - $adminColMethods[$method->getName()] = $adminColAttrs[0]->newInstance(); - } - } - - expect($adminColMethods)->toHaveCount(3); - expect($adminColMethods)->toHaveKeys(['getPriceColumn', 'getStockColumn', 'getCategoryColumn']); - - // Verify each column has unique key - $keys = array_map(fn (AdminCol $adminCol): string => $adminCol->key, $adminColMethods); - expect($keys)->toBe(['getPriceColumn' => 'price', 'getStockColumn' => 'stock', 'getCategoryColumn' => 'category']); - expect(array_unique($keys))->toHaveCount(3); // All keys are unique -}); - -it('demonstrates attribute composition pattern', function (): void { - // This test demonstrates how the new system works: - // 1. PostType attribute defines the main post type - // 2. Sub-attributes (HasArchive, Supports, MenuIcon) configure post type settings - // 3. Method-level attributes (AdminCol) define callbacks and additional functionality - - $reflection = new ReflectionClass(ProductWithSubAttributes::class); - - // Main post type definition - $postTypeAttr = $reflection->getAttributes(PostType::class)[0]->newInstance(); - expect($postTypeAttr->slug)->toBe('product'); - - // Archive configuration - $hasArchiveAttr = $reflection->getAttributes(HasArchive::class)[0]->newInstance(); - expect($hasArchiveAttr->value)->toBe('products-archive'); - - // Features configuration - $supportsAttr = $reflection->getAttributes(Supports::class)[0]->newInstance(); - expect($supportsAttr->features)->toContain('title', 'editor', 'thumbnail', 'excerpt'); - - // Admin UI configuration - $menuIconAttr = $reflection->getAttributes(MenuIcon::class)[0]->newInstance(); - expect($menuIconAttr->value)->toBe('dashicons-cart'); - - // Admin columns configuration (method-level) - $columnMethods = []; - foreach ($reflection->getMethods() as $method) { - $adminColAttrs = $method->getAttributes(AdminCol::class); - if (! empty($adminColAttrs)) { - $columnMethods[] = [ - 'method' => $method->getName(), - 'column' => $adminColAttrs[0]->newInstance(), - ]; - } - } - - expect($columnMethods)->toHaveCount(3); - - // Verify the composition creates a complete post type configuration - $allClassAttributes = $reflection->getAttributes(); - $allMethodAttributes = []; - foreach ($reflection->getMethods() as $method) { - $allMethodAttributes = array_merge($allMethodAttributes, $method->getAttributes()); - } - - // Should have class-level attributes for configuration - expect($allClassAttributes)->toHaveCount(4); // PostType + 3 sub-attributes - - // Should have method-level attributes for callbacks - expect($allMethodAttributes)->toHaveCount(3); // 3 AdminCol attributes -}); - -it('attributes have correct target configurations', function (): void { - // Verify class-level attributes target classes - $classLevelAttributes = [ - PostType::class, - HasArchive::class, - Supports::class, - MenuIcon::class, - ]; - - foreach ($classLevelAttributes as $attributeClass) { - $reflection = new ReflectionClass($attributeClass); - $attributes = $reflection->getAttributes(Attribute::class); - expect($attributes)->toHaveCount(1); - - $attribute = $attributes[0]->newInstance(); - expect($attribute->flags)->toBe(Attribute::TARGET_CLASS); - } - - // Verify method-level attributes target methods - $methodLevelAttributes = [ - AdminCol::class, - ]; - - foreach ($methodLevelAttributes as $attributeClass) { - $reflection = new ReflectionClass($attributeClass); - $attributes = $reflection->getAttributes(Attribute::class); - expect($attributes)->toHaveCount(1); - - $attribute = $attributes[0]->newInstance(); - expect($attribute->flags)->toBe(Attribute::TARGET_METHOD); - } -}); - -it('demonstrates no validation in attributes', function (): void { - // All attributes should accept any values without validation - // Validation will be handled by PostTypeDiscovery - - expect(fn (): PostType => new PostType('', '', ''))->not->toThrow(Exception::class); - expect(fn (): HasArchive => new HasArchive(''))->not->toThrow(Exception::class); - expect(fn (): Supports => new Supports([]))->not->toThrow(Exception::class); - expect(fn (): MenuIcon => new MenuIcon(''))->not->toThrow(Exception::class); - expect(fn (): AdminCol => new AdminCol('', ''))->not->toThrow(Exception::class); - - // Even completely invalid values should not throw - expect(fn (): PostType => new PostType('invalid slug with spaces', 'inv@lid', 'pl{ur}al'))->not->toThrow(Exception::class); - expect(fn (): HasArchive => new HasArchive(123))->not->toThrow(Exception::class); // Wrong type - expect(fn (): Supports => new Supports(['invalid-feature', '', null]))->not->toThrow(Exception::class); - expect(fn (): MenuIcon => new MenuIcon(null))->not->toThrow(Exception::class); // Wrong type -}); diff --git a/tests/Unit/Attributes/ScheduleTest.php b/tests/Unit/Attributes/ScheduleTest.php deleted file mode 100644 index 97843335..00000000 --- a/tests/Unit/Attributes/ScheduleTest.php +++ /dev/null @@ -1,158 +0,0 @@ -toBeInstanceOf(Schedule::class); - expect($schedule->recurrence)->toBe('daily'); - expect($schedule->hook)->toBeNull(); - expect($schedule->args)->toBe([]); -}); - -it('creates Schedule attribute with custom hook name', function (): void { - $schedule = new Schedule('hourly', 'custom_hook_name'); - - expect($schedule->recurrence)->toBe('hourly'); - expect($schedule->hook)->toBe('custom_hook_name'); - expect($schedule->args)->toBe([]); -}); - -it('creates Schedule attribute with arguments', function (): void { - $args = ['type' => 'full', 'force' => true]; - $schedule = new Schedule('daily', null, $args); - - expect($schedule->recurrence)->toBe('daily'); - expect($schedule->hook)->toBeNull(); - expect($schedule->args)->toBe($args); -}); - -it('creates Schedule attribute with all parameters', function (): void { - $args = ['batch_size' => 100]; - $schedule = new Schedule('weekly', 'weekly_cleanup', $args); - - expect($schedule->recurrence)->toBe('weekly'); - expect($schedule->hook)->toBe('weekly_cleanup'); - expect($schedule->args)->toBe($args); -}); - -it('creates Schedule attribute with array recurrence', function (): void { - $recurrence = ['interval' => 3600, 'display' => 'Every Hour']; - $schedule = new Schedule($recurrence); - - expect($schedule->recurrence)->toBe($recurrence); - expect($schedule->hook)->toBeNull(); - expect($schedule->args)->toBe([]); -}); - -it('creates Schedule attribute with Every enum', function (): void { - $schedule = new Schedule(Every::DAY); - - expect($schedule->recurrence)->toBe(Every::DAY); - expect($schedule->hook)->toBeNull(); - expect($schedule->args)->toBe([]); -}); - -it('creates Schedule attribute with Interval instance', function (): void { - $interval = new Interval(minutes: 30, hours: 2); - $schedule = new Schedule($interval); - - expect($schedule->recurrence)->toBe($interval); - expect($schedule->hook)->toBeNull(); - expect($schedule->args)->toBe([]); -}); - -it('creates Schedule attribute with complex Every enum and parameters', function (): void { - $args = ['source' => 'api', 'limit' => 50]; - $schedule = new Schedule(Every::MONTH, 'monthly_sync', $args); - - expect($schedule->recurrence)->toBe(Every::MONTH); - expect($schedule->hook)->toBe('monthly_sync'); - expect($schedule->args)->toBe($args); -}); - -it('creates Schedule attribute with complex Interval and parameters', function (): void { - $interval = new Interval(minutes: 30, hours: 12, days: 1); - $args = ['cleanup_type' => 'deep']; - $schedule = new Schedule($interval, 'complex_cleanup', $args); - - expect($schedule->recurrence)->toBe($interval); - expect($schedule->hook)->toBe('complex_cleanup'); - expect($schedule->args)->toBe($args); -}); - -it('stores recurrence as readonly property', function (): void { - $schedule = new Schedule('daily'); - - expect($schedule->recurrence)->toBe('daily'); - - // Verify property is readonly by checking it cannot be modified - $reflection = new ReflectionClass($schedule); - $property = $reflection->getProperty('recurrence'); - expect($property->isReadOnly())->toBeTrue(); -}); - -it('stores hook as readonly property', function (): void { - $schedule = new Schedule('daily', 'test_hook'); - - expect($schedule->hook)->toBe('test_hook'); - - // Verify property is readonly - $reflection = new ReflectionClass($schedule); - $property = $reflection->getProperty('hook'); - expect($property->isReadOnly())->toBeTrue(); -}); - -it('stores args as readonly property', function (): void { - $args = ['key' => 'value']; - $schedule = new Schedule('daily', null, $args); - - expect($schedule->args)->toBe($args); - - // Verify property is readonly - $reflection = new ReflectionClass($schedule); - $property = $reflection->getProperty('args'); - expect($property->isReadOnly())->toBeTrue(); -}); - -it('has correct PHP attribute configuration', function (): void { - $reflection = new ReflectionClass(Schedule::class); - $attributes = $reflection->getAttributes(Attribute::class); - - expect($attributes)->toHaveCount(1); - - $attribute = $attributes[0]->newInstance(); - expect($attribute->flags)->toBe(Attribute::TARGET_METHOD); -}); - -it('accepts all supported recurrence types without validation', function (): void { - // No validation should happen in the attribute constructor - // All validation is now handled by ScheduleDiscovery - - // String recurrence - expect(fn (): Schedule => new Schedule('daily'))->not->toThrow(Exception::class); - expect(fn (): Schedule => new Schedule('invalid_schedule'))->not->toThrow(Exception::class); // No validation - - // Array recurrence - expect(fn (): Schedule => new Schedule(['interval' => 3600, 'display' => 'Valid']))->not->toThrow(Exception::class); - expect(fn (): Schedule => new Schedule(['invalid' => 'array']))->not->toThrow(Exception::class); // No validation - - // Every enum - expect(fn (): Schedule => new Schedule(Every::HOUR))->not->toThrow(Exception::class); - expect(fn (): Schedule => new Schedule(Every::MONTH))->not->toThrow(Exception::class); - - // Interval instance - $interval = new Interval(minutes: 30); - expect(fn (): Schedule => new Schedule($interval))->not->toThrow(Exception::class); -}); diff --git a/tests/Unit/Attributes/TaxonomyAttributeTest.php b/tests/Unit/Attributes/TaxonomyAttributeTest.php index 66edb2fb..52a346d5 100644 --- a/tests/Unit/Attributes/TaxonomyAttributeTest.php +++ b/tests/Unit/Attributes/TaxonomyAttributeTest.php @@ -5,6 +5,7 @@ use Illuminate\Container\Container; use Illuminate\Support\Facades\Facade; use Mockery as m; +use Pollora\Attributes\Contracts\ConfiguresArguments; use Pollora\Attributes\Taxonomy; use Pollora\Attributes\Taxonomy\AllowHierarchy; use Pollora\Attributes\Taxonomy\Args; @@ -161,8 +162,8 @@ function testBooleanAttribute(string $attributeName, string $argName): void $reflectionClass = new ReflectionClass($taxonomy); foreach ($reflectionClass->getAttributes() as $attribute) { $attributeInstance = $attribute->newInstance(); - if (method_exists($attributeInstance, 'handle')) { - $attributeInstance->handle(app(), $taxonomy, $reflectionClass, $attributeInstance); + if ($attributeInstance instanceof ConfiguresArguments) { + $taxonomy->attributeArgs = $attributeInstance->configureArguments($taxonomy->attributeArgs); } } @@ -180,8 +181,8 @@ function testValueAttribute(string $attributeName, string $argName, mixed $expec $reflectionClass = new ReflectionClass($taxonomy); foreach ($reflectionClass->getAttributes() as $attribute) { $attributeInstance = $attribute->newInstance(); - if (method_exists($attributeInstance, 'handle')) { - $attributeInstance->handle(app(), $taxonomy, $reflectionClass, $attributeInstance); + if ($attributeInstance instanceof ConfiguresArguments) { + $taxonomy->attributeArgs = $attributeInstance->configureArguments($taxonomy->attributeArgs); } } @@ -206,7 +207,7 @@ function testMethodAttribute(string $attributeName, string $argName, string $met $attributes = $method->getAttributes($attributeClass); foreach ($attributes as $attribute) { $attributeInstance = $attribute->newInstance(); - $attributeInstance->handle(null, $taxonomy, $method, $attributeInstance); + $taxonomy->attributeArgs = $attributeInstance->configureArguments($taxonomy->attributeArgs, [$taxonomy, $method->getName()]); } } @@ -259,8 +260,8 @@ function testMethodAttribute(string $attributeName, string $argName, string $met $reflectionClass = new ReflectionClass($taxonomy); foreach ($reflectionClass->getAttributes() as $attribute) { $attributeInstance = $attribute->newInstance(); - if (method_exists($attributeInstance, 'handle')) { - $attributeInstance->handle(app(), $taxonomy, $reflectionClass, $attributeInstance); + if ($attributeInstance instanceof ConfiguresArguments) { + $taxonomy->attributeArgs = $attributeInstance->configureArguments($taxonomy->attributeArgs); } } diff --git a/tests/Unit/Attributes/TaxonomySubAttributesTest.php b/tests/Unit/Attributes/TaxonomySubAttributesTest.php deleted file mode 100644 index 3838bb40..00000000 --- a/tests/Unit/Attributes/TaxonomySubAttributesTest.php +++ /dev/null @@ -1,313 +0,0 @@ -Custom taxonomy meta box

'; - } - - #[UpdateCountCallback] - public function updateTermCount($terms, $taxonomy): void - { - // Custom count update logic - foreach ($terms as $term) { - // Update count logic here - } - } - - #[MetaBoxSanitizeCb] - public function sanitizeTerms($terms): array - { - // Sanitize the terms - return array_map(sanitize_text_field(...), (array) $terms); - } - - public function getTaxonomyName(): string - { - return 'Product Category'; - } -} - -it('detects all class-level Taxonomy attributes', function (): void { - $reflection = new ReflectionClass(ProductCategoryWithSubAttributes::class); - - // Should detect Taxonomy main attribute - $taxonomyAttrs = $reflection->getAttributes(Taxonomy::class); - expect($taxonomyAttrs)->toHaveCount(1); - - // Should detect Hierarchical sub-attribute - $hierarchicalAttrs = $reflection->getAttributes(Hierarchical::class); - expect($hierarchicalAttrs)->toHaveCount(1); - - // Should detect PublicTaxonomy sub-attribute - $publicAttrs = $reflection->getAttributes(PublicTaxonomy::class); - expect($publicAttrs)->toHaveCount(1); - - // Should detect ShowUI sub-attribute - $showUIAttrs = $reflection->getAttributes(ShowUI::class); - expect($showUIAttrs)->toHaveCount(1); - - // Should detect ShowInRest sub-attribute - $showInRestAttrs = $reflection->getAttributes(ShowInRest::class); - expect($showInRestAttrs)->toHaveCount(1); - - // Should detect ObjectType sub-attribute - $objectTypeAttrs = $reflection->getAttributes(ObjectType::class); - expect($objectTypeAttrs)->toHaveCount(1); -}); - -it('detects all method-level callback attributes', function (): void { - $reflection = new ReflectionClass(ProductCategoryWithSubAttributes::class); - - // Check custom meta box method - $metaBoxMethod = $reflection->getMethod('customMetaBox'); - $metaBoxAttrs = $metaBoxMethod->getAttributes(MetaBoxCb::class); - expect($metaBoxAttrs)->toHaveCount(1); - - // Check update count method - $updateCountMethod = $reflection->getMethod('updateTermCount'); - $updateCountAttrs = $updateCountMethod->getAttributes(UpdateCountCallback::class); - expect($updateCountAttrs)->toHaveCount(1); - - // Check sanitize method - $sanitizeMethod = $reflection->getMethod('sanitizeTerms'); - $sanitizeAttrs = $sanitizeMethod->getAttributes(MetaBoxSanitizeCb::class); - expect($sanitizeAttrs)->toHaveCount(1); - - // Check that method without attribute has no callback attributes - $nameMethod = $reflection->getMethod('getTaxonomyName'); - $nameCallbackAttrs = array_merge( - $nameMethod->getAttributes(MetaBoxCb::class), - $nameMethod->getAttributes(UpdateCountCallback::class), - $nameMethod->getAttributes(MetaBoxSanitizeCb::class) - ); - expect($nameCallbackAttrs)->toHaveCount(0); -}); - -it('extracts correct values from class-level attributes', function (): void { - $reflection = new ReflectionClass(ProductCategoryWithSubAttributes::class); - - // Extract Taxonomy attribute - $taxonomyAttr = $reflection->getAttributes(Taxonomy::class)[0]; - $taxonomy = $taxonomyAttr->newInstance(); - expect($taxonomy->slug)->toBe('product-category'); - expect($taxonomy->singular)->toBeNull(); - expect($taxonomy->plural)->toBeNull(); - expect($taxonomy->objectType)->toBeNull(); - - // Test that attributes can be instantiated successfully - $hierarchicalAttr = $reflection->getAttributes(Hierarchical::class)[0]; - $hierarchical = $hierarchicalAttr->newInstance(); - expect($hierarchical)->toBeInstanceOf(Hierarchical::class); - - $publicAttr = $reflection->getAttributes(PublicTaxonomy::class)[0]; - $public = $publicAttr->newInstance(); - expect($public)->toBeInstanceOf(PublicTaxonomy::class); - - $showUIAttr = $reflection->getAttributes(ShowUI::class)[0]; - $showUI = $showUIAttr->newInstance(); - expect($showUI)->toBeInstanceOf(ShowUI::class); - - $showInRestAttr = $reflection->getAttributes(ShowInRest::class)[0]; - $showInRest = $showInRestAttr->newInstance(); - expect($showInRest)->toBeInstanceOf(ShowInRest::class); - - $objectTypeAttr = $reflection->getAttributes(ObjectType::class)[0]; - $objectType = $objectTypeAttr->newInstance(); - expect($objectType)->toBeInstanceOf(ObjectType::class); -}); - -it('extracts correct values from method-level callback attributes', function (): void { - $reflection = new ReflectionClass(ProductCategoryWithSubAttributes::class); - - // Extract MetaBoxCb - $metaBoxMethod = $reflection->getMethod('customMetaBox'); - $metaBoxAttr = $metaBoxMethod->getAttributes(MetaBoxCb::class)[0]; - $metaBoxCb = $metaBoxAttr->newInstance(); - expect($metaBoxCb)->toBeInstanceOf(MetaBoxCb::class); - - // Extract UpdateCountCallback - $updateCountMethod = $reflection->getMethod('updateTermCount'); - $updateCountAttr = $updateCountMethod->getAttributes(UpdateCountCallback::class)[0]; - $updateCountCb = $updateCountAttr->newInstance(); - expect($updateCountCb)->toBeInstanceOf(UpdateCountCallback::class); - - // Extract MetaBoxSanitizeCb - $sanitizeMethod = $reflection->getMethod('sanitizeTerms'); - $sanitizeAttr = $sanitizeMethod->getAttributes(MetaBoxSanitizeCb::class)[0]; - $sanitizeCb = $sanitizeAttr->newInstance(); - expect($sanitizeCb)->toBeInstanceOf(MetaBoxSanitizeCb::class); -}); - -it('supports multiple callback attributes on different methods', function (): void { - $reflection = new ReflectionClass(ProductCategoryWithSubAttributes::class); - $callbackMethods = []; - - foreach ($reflection->getMethods() as $method) { - $callbackAttrs = array_merge( - $method->getAttributes(MetaBoxCb::class), - $method->getAttributes(UpdateCountCallback::class), - $method->getAttributes(MetaBoxSanitizeCb::class) - ); - - if ($callbackAttrs !== []) { - $callbackMethods[$method->getName()] = $callbackAttrs[0]->newInstance(); - } - } - - expect($callbackMethods)->toHaveCount(3); - expect($callbackMethods)->toHaveKeys(['customMetaBox', 'updateTermCount', 'sanitizeTerms']); - - // Verify each callback has correct type - expect($callbackMethods['customMetaBox'])->toBeInstanceOf(MetaBoxCb::class); - expect($callbackMethods['updateTermCount'])->toBeInstanceOf(UpdateCountCallback::class); - expect($callbackMethods['sanitizeTerms'])->toBeInstanceOf(MetaBoxSanitizeCb::class); -}); - -it('demonstrates attribute composition pattern for taxonomies', function (): void { - // This test demonstrates how the taxonomy attribute system works: - // 1. Taxonomy attribute defines the main taxonomy - // 2. Sub-attributes (Hierarchical, PublicTaxonomy, ShowUI, etc.) configure taxonomy settings - // 3. Method-level attributes (MetaBoxCb, UpdateCountCallback, etc.) define callbacks - - $reflection = new ReflectionClass(ProductCategoryWithSubAttributes::class); - - // Main taxonomy definition - $taxonomyAttr = $reflection->getAttributes(Taxonomy::class)[0]->newInstance(); - expect($taxonomyAttr->slug)->toBe('product-category'); - - // Test attribute instantiation - $hierarchicalAttr = $reflection->getAttributes(Hierarchical::class)[0]->newInstance(); - expect($hierarchicalAttr)->toBeInstanceOf(Hierarchical::class); - - $publicAttr = $reflection->getAttributes(PublicTaxonomy::class)[0]->newInstance(); - expect($publicAttr)->toBeInstanceOf(PublicTaxonomy::class); - - $showUIAttr = $reflection->getAttributes(ShowUI::class)[0]->newInstance(); - expect($showUIAttr)->toBeInstanceOf(ShowUI::class); - - $showInRestAttr = $reflection->getAttributes(ShowInRest::class)[0]->newInstance(); - expect($showInRestAttr)->toBeInstanceOf(ShowInRest::class); - - $objectTypeAttr = $reflection->getAttributes(ObjectType::class)[0]->newInstance(); - expect($objectTypeAttr)->toBeInstanceOf(ObjectType::class); - - // Callback methods configuration (method-level) - $callbackMethods = []; - foreach ($reflection->getMethods() as $method) { - $callbackAttrs = array_merge( - $method->getAttributes(MetaBoxCb::class), - $method->getAttributes(UpdateCountCallback::class), - $method->getAttributes(MetaBoxSanitizeCb::class) - ); - - if ($callbackAttrs !== []) { - $callbackMethods[] = [ - 'method' => $method->getName(), - 'attribute' => $callbackAttrs[0]->newInstance(), - ]; - } - } - - expect($callbackMethods)->toHaveCount(3); - - // Verify the composition creates a complete taxonomy configuration - $allClassAttributes = $reflection->getAttributes(); - $allMethodAttributes = []; - foreach ($reflection->getMethods() as $method) { - $allMethodAttributes = array_merge($allMethodAttributes, $method->getAttributes()); - } - - // Should have class-level attributes for configuration - expect($allClassAttributes)->toHaveCount(6); // Taxonomy + 5 sub-attributes - - // Should have method-level attributes for callbacks - expect($allMethodAttributes)->toHaveCount(3); // 3 callback attributes -}); - -it('attributes have correct target configurations', function (): void { - // Verify class-level attributes target classes - $classLevelAttributes = [ - Taxonomy::class, - Hierarchical::class, - PublicTaxonomy::class, - ShowUI::class, - ShowInRest::class, - ObjectType::class, - ]; - - foreach ($classLevelAttributes as $attributeClass) { - $reflection = new ReflectionClass($attributeClass); - $attributes = $reflection->getAttributes(Attribute::class); - expect($attributes)->toHaveCount(1); - - $attribute = $attributes[0]->newInstance(); - expect($attribute->flags)->toBe(Attribute::TARGET_CLASS); - } - - // Verify method-level attributes target methods - $methodLevelAttributes = [ - MetaBoxCb::class, - UpdateCountCallback::class, - MetaBoxSanitizeCb::class, - ]; - - foreach ($methodLevelAttributes as $attributeClass) { - $reflection = new ReflectionClass($attributeClass); - $attributes = $reflection->getAttributes(Attribute::class); - expect($attributes)->toHaveCount(1); - - $attribute = $attributes[0]->newInstance(); - expect($attribute->flags)->toBe(Attribute::TARGET_METHOD); - } -}); - -it('demonstrates no validation in attributes', function (): void { - // All attributes should accept any values without validation - // Validation will be handled by TaxonomyDiscovery - - expect(fn (): Taxonomy => new Taxonomy('', '', '', []))->not->toThrow(Throwable::class); - expect(fn (): Hierarchical => new Hierarchical(false))->not->toThrow(Throwable::class); - expect(fn (): PublicTaxonomy => new PublicTaxonomy(false))->not->toThrow(Throwable::class); - expect(fn (): ShowUI => new ShowUI(false))->not->toThrow(Throwable::class); - expect(fn (): ShowInRest => new ShowInRest(false))->not->toThrow(Throwable::class); - expect(fn (): ObjectType => new ObjectType([]))->not->toThrow(Throwable::class); - expect(fn (): MetaBoxCb => new MetaBoxCb)->not->toThrow(Throwable::class); - expect(fn (): UpdateCountCallback => new UpdateCountCallback)->not->toThrow(Throwable::class); - expect(fn (): MetaBoxSanitizeCb => new MetaBoxSanitizeCb)->not->toThrow(Throwable::class); - - // Even completely invalid values should not throw - expect(fn (): Taxonomy => new Taxonomy('invalid slug with spaces', 'inv@lid', 'pl{ur}al', 'invalid'))->not->toThrow(Throwable::class); - expect(fn (): Hierarchical => new Hierarchical('not-boolean'))->not->toThrow(Throwable::class); // Wrong type - expect(fn (): ObjectType => new ObjectType('not-array'))->not->toThrow(Throwable::class); // Wrong type -}); diff --git a/tests/Unit/Discovery/Domain/Models/DiscoveryItemsTest.php b/tests/Unit/Discovery/Domain/Models/DiscoveryItemsTest.php deleted file mode 100644 index eebaf84c..00000000 --- a/tests/Unit/Discovery/Domain/Models/DiscoveryItemsTest.php +++ /dev/null @@ -1,154 +0,0 @@ -isLoaded())->toBeFalse(); - expect($items)->toHaveCount(0); - expect($items->all())->toBe([]); - }); - - it('can create with initial data', function (): void { - $items = new DiscoveryItems([ - 'location1' => ['item1', 'item2'], - 'location2' => ['item3'], - ]); - - expect($items->isLoaded())->toBeTrue(); - expect($items)->toHaveCount(3); - expect($items->all())->toEqual(['item1', 'item2', 'item3']); - }); - - it('can add single item for location', function (): void { - $items = new DiscoveryItems; - $location = new DiscoveryLocation('App\\Models', '/app/models'); - - $items->add($location, 'test-item'); - - expect($items->hasLocation($location))->toBeTrue(); - expect($items->getForLocation($location))->toEqual(['test-item']); - expect($items)->toHaveCount(1); - }); - - it('can add multiple items for location', function (): void { - $items = new DiscoveryItems; - $location = new DiscoveryLocation('App\\Models', '/app/models'); - - $items->addForLocation($location, ['item1', 'item2', 'item3']); - - expect($items->getForLocation($location))->toEqual(['item1', 'item2', 'item3']); - expect($items)->toHaveCount(3); - }); - - it('can add items to existing location', function (): void { - $items = new DiscoveryItems; - $location = new DiscoveryLocation('App\\Models', '/app/models'); - - $items->add($location, 'item1'); - $items->addForLocation($location, ['item2', 'item3']); - - expect($items->getForLocation($location))->toEqual(['item1', 'item2', 'item3']); - expect($items)->toHaveCount(3); - }); - - it('handles multiple locations', function (): void { - $items = new DiscoveryItems; - $location1 = new DiscoveryLocation('App\\Models', '/app/models'); - $location2 = new DiscoveryLocation('App\\Services', '/app/services'); - - $items->addForLocation($location1, ['model1', 'model2']); - $items->addForLocation($location2, ['service1']); - - expect($items)->toHaveCount(3); - expect($items->getForLocation($location1))->toEqual(['model1', 'model2']); - expect($items->getForLocation($location2))->toEqual(['service1']); - expect($items->all())->toEqual(['model1', 'model2', 'service1']); - }); - - it('returns empty array for unknown location', function (): void { - $items = new DiscoveryItems; - $location = new DiscoveryLocation('App\\Models', '/app/models'); - - expect($items->getForLocation($location))->toBe([]); - expect($items->hasLocation($location))->toBeFalse(); - }); - - it('can iterate over all items', function (): void { - $items = new DiscoveryItems; - $location1 = new DiscoveryLocation('App\\Models', '/app/models'); - $location2 = new DiscoveryLocation('App\\Services', '/app/services'); - - $items->addForLocation($location1, ['model1', 'model2']); - $items->addForLocation($location2, ['service1']); - - $iteratedItems = []; - foreach ($items as $item) { - $iteratedItems[] = $item; - } - - expect($iteratedItems)->toEqual(['model1', 'model2', 'service1']); - }); - - it('supports serialization and unserialization', function (): void { - $items = new DiscoveryItems; - $location = new DiscoveryLocation('App\\Models', '/app/models'); - - $items->addForLocation($location, ['item1', 'item2']); - - $serialized = $items->__serialize(); - expect($serialized)->toBeArray(); - - $newItems = new DiscoveryItems; - $newItems->__unserialize($serialized); - - expect($newItems->all())->toEqual($items->all()); - expect($newItems->count())->toBe($items->count()); - }); - - it('onlyVendor returns new instance', function (): void { - $items = new DiscoveryItems([ - 'location1' => ['item1'], - 'location2' => ['item2'], - ]); - - $vendorItems = $items->onlyVendor(); - - expect($vendorItems)->not->toBe($items); - expect($vendorItems)->toBeInstanceOf(DiscoveryItems::class); - }); -}); - -describe('DiscoveryItems pending items', function (): void { - it('has every item pending until marked applied', function (): void { - $items = new DiscoveryItems; - $items->addForLocation(new DiscoveryLocation('App\\Models', '/app/models'), ['item1', 'item2']); - - expect($items->pending()->all())->toBe(['item1', 'item2']); - - $items->markApplied(); - - expect($items->pending()->isLoaded())->toBeFalse() - ->and($items->all())->toBe(['item1', 'item2']); - }); - - it('keeps as pending only the items added since, in every location', function (): void { - $models = new DiscoveryLocation('App\\Models', '/app/models'); - $theme = new DiscoveryLocation('Theme\\Demo', '/themes/demo'); - $items = new DiscoveryItems; - $items->add($models, 'model1'); - $items->markApplied(); - - $items->add($models, 'model2'); - $items->add($theme, 'theme1'); - - expect($items->pending()->all())->toBe(['model2', 'theme1']) - ->and($items->pending())->not->toBe($items) - ->and($items->all())->toBe(['model1', 'model2', 'theme1']); - }); -}); diff --git a/tests/Unit/Discovery/Domain/Models/DiscoveryLocationTest.php b/tests/Unit/Discovery/Domain/Models/DiscoveryLocationTest.php deleted file mode 100644 index b821be06..00000000 --- a/tests/Unit/Discovery/Domain/Models/DiscoveryLocationTest.php +++ /dev/null @@ -1,67 +0,0 @@ -getNamespace())->toBe('App\\Models'); - expect($location->getPath())->toBe('/path/to/models'); - }); - - it('generates unique key based on path', function (): void { - $location1 = new DiscoveryLocation('App\\Models', '/path/to/models'); - $location2 = new DiscoveryLocation('App\\Services', '/path/to/models'); - $location3 = new DiscoveryLocation('App\\Models', '/path/to/services'); - - expect($location1->getKey())->toBe($location2->getKey()); - expect($location1->getKey())->not->toBe($location3->getKey()); - }); - - it('detects vendor locations', function (): void { - $vendorLocation = new DiscoveryLocation('Vendor\\Package', '/path/to/vendor/package'); - $appLocation = new DiscoveryLocation('App\\Models', '/path/to/app/models'); - - expect($vendorLocation->isVendor())->toBeTrue(); - expect($appLocation->isVendor())->toBeFalse(); - }); - - it('detects vendor locations with windows path', function (): void { - $vendorLocation = new DiscoveryLocation('Vendor\\Package', 'C:\\path\\to\\vendor\\package'); - - expect($vendorLocation->isVendor())->toBeTrue(); - }); - - it('converts file path to class name', function (): void { - $location = new DiscoveryLocation('App\\Models', '/app/Models'); - - expect($location->toClassName('/app/Models/User.php'))->toBe('App\\Models\\User'); - }); - - it('converts nested file path to class name', function (): void { - $location = new DiscoveryLocation('App\\Services', '/app/Services'); - - expect($location->toClassName('/app/Services/Auth/UserService.php'))->toBe('App\\Services\\Auth\\UserService'); - }); - - it('returns empty string for file outside location', function (): void { - $location = new DiscoveryLocation('App\\Models', '/app/Models'); - - expect($location->toClassName('/app/Services/UserService.php'))->toBe(''); - }); - - it('handles windows paths in class name conversion', function (): void { - $location = new DiscoveryLocation('App\\Models', 'C:\\app\\Models'); - - expect($location->toClassName('C:\\app\\Models\\Auth\\User.php'))->toBe('App\\Models\\Auth\\User'); - }); - - it('trims namespace slashes', function (): void { - $location = new DiscoveryLocation('App\\Models\\', '/app/Models'); - - expect($location->toClassName('/app/Models/User.php'))->toBe('App\\Models\\User'); - }); -}); diff --git a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryCacheManagerSlowScanTest.php b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryCacheManagerSlowScanTest.php index 1266b4ed..271605d8 100644 --- a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryCacheManagerSlowScanTest.php +++ b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryCacheManagerSlowScanTest.php @@ -7,6 +7,7 @@ use Pollora\Application\Domain\Contracts\DebugDetectorInterface; use Pollora\Discovery\Domain\Contracts\DiscoveryLocationInterface; use Pollora\Discovery\Domain\Models\DiscoveryContext; +use Pollora\Discovery\Infrastructure\Adapters\LaravelDiscoveryCacheManagerFactory; use Pollora\Discovery\Infrastructure\Services\DiscoveryCacheManager; use Pollora\Discovery\Infrastructure\Services\ReflectionCache; use Psr\Log\LoggerInterface; @@ -38,15 +39,14 @@ function managerFor(bool $debug, ?LoggerInterface $logger = null): DiscoveryCach $container->instance(LoggerInterface::class, $logger); } - // config() reaches for the application container; without it the - // non-debug path cannot resolve a cache driver. + // Without a config repository the non-debug path cannot resolve a cache driver. $container->instance('config', new Repository(['structure-discoverer' => ['cache' => []]])); Container::setInstance($container); $detector = Mockery::mock(DebugDetectorInterface::class); $detector->shouldReceive('isDebugMode')->andReturn($debug); - return new DiscoveryCacheManager($container, $detector); + return (new LaravelDiscoveryCacheManagerFactory($container, $detector))->make(); } function scanOf(DiscoveryCacheManager $manager, string $path): void @@ -157,14 +157,14 @@ function scanOf(DiscoveryCacheManager $manager, string $path): void $detector = Mockery::mock(DebugDetectorInterface::class); $detector->shouldReceive('isDebugMode')->andReturn(false); - $first = new DiscoveryCacheManager($container, $detector); + $first = (new LaravelDiscoveryCacheManagerFactory($container, $detector))->make(); scanOf($first, $this->dir); // A new process starts with an empty copy of its own, and only the // persistent cache is left to answer. (new ReflectionProperty(DiscoveryCacheManager::class, 'structuresCache'))->setValue(null, []); - $second = new DiscoveryCacheManager($container, $detector); + $second = (new LaravelDiscoveryCacheManagerFactory($container, $detector))->make(); scanOf($second, $this->dir); expect($first->scans()[0]['source'])->toBe('disk') diff --git a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryEngineTest.php b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryEngineTest.php deleted file mode 100644 index 9987ab03..00000000 --- a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryEngineTest.php +++ /dev/null @@ -1,342 +0,0 @@ - true]); - - return new DiscoveryEngine( - $container, - $debugDetector, - cacheManager: $cacheManager ?? Mockery::mock(DiscoveryCacheManager::class) - ); -} - -function createLocationMock(string $path): DiscoveryLocationInterface -{ - $location = Mockery::mock(DiscoveryLocationInterface::class); - $location->shouldReceive('getPath')->andReturn($path); - $location->shouldReceive('getKey')->andReturn('key_'.md5($path)); - $location->shouldReceive('isVendor')->andReturn(false); - - return $location; -} - -describe('DiscoveryEngine', function (): void { - describe('addLocation', function (): void { - it('adds a location', function (): void { - $engine = createEngine(); - $location = createLocationMock('/tmp/test-path-'.uniqid()); - - $result = $engine->addLocation($location); - - expect($result)->toBe($engine); - expect($engine->getLocations())->toHaveCount(1); - }); - - it('deduplicates locations with same path', function (): void { - $engine = createEngine(); - $path = '/tmp/dedup-path-'.uniqid(); - $location1 = createLocationMock($path); - $location2 = createLocationMock($path); - - $engine->addLocation($location1); - $engine->addLocation($location2); - - expect($engine->getLocations())->toHaveCount(1); - }); - - it('adds locations with different paths', function (): void { - $engine = createEngine(); - - $engine->addLocation(createLocationMock('/tmp/path-a-'.uniqid())); - $engine->addLocation(createLocationMock('/tmp/path-b-'.uniqid())); - - expect($engine->getLocations())->toHaveCount(2); - }); - }); - - describe('addLocations', function (): void { - it('adds multiple locations at once', function (): void { - $engine = createEngine(); - - $engine->addLocations([ - createLocationMock('/tmp/loc-1-'.uniqid()), - createLocationMock('/tmp/loc-2-'.uniqid()), - ]); - - expect($engine->getLocations())->toHaveCount(2); - }); - }); - - describe('addDiscovery', function (): void { - it('adds a discovery instance', function (): void { - $engine = createEngine(); - $discovery = Mockery::mock(DiscoveryInterface::class); - - $engine->addDiscovery('test', $discovery); - - expect($engine->getDiscoveries())->toHaveCount(1); - expect($engine->getDiscoveries()->has('test'))->toBeTrue(); - }); - - it('throws on duplicate identifier', function (): void { - $engine = createEngine(); - $discovery = Mockery::mock(DiscoveryInterface::class); - - $engine->addDiscovery('dup', $discovery); - - expect(fn (): DiscoveryEngine => $engine->addDiscovery('dup', $discovery)) - ->toThrow(InvalidDiscoveryException::class); - }); - }); - - describe('addDiscoveries', function (): void { - it('adds multiple discoveries at once', function (): void { - $engine = createEngine(); - - $engine->addDiscoveries([ - 'first' => Mockery::mock(DiscoveryInterface::class), - 'second' => Mockery::mock(DiscoveryInterface::class), - ]); - - expect($engine->getDiscoveries())->toHaveCount(2); - }); - }); - - describe('getDiscovery', function (): void { - it('returns discovery by identifier', function (): void { - $engine = createEngine(); - $discovery = Mockery::mock(DiscoveryInterface::class); - - $engine->addDiscovery('my-discovery', $discovery); - - expect($engine->getDiscovery('my-discovery'))->toBe($discovery); - }); - - it('throws when discovery not found', function (): void { - $engine = createEngine(); - - expect(fn (): DiscoveryInterface => $engine->getDiscovery('nonexistent')) - ->toThrow(DiscoveryNotFoundException::class); - }); - }); - - describe('clearLocations', function (): void { - it('removes all locations', function (): void { - $engine = createEngine(); - $engine->addLocation(createLocationMock('/tmp/clear-'.uniqid())); - - expect($engine->getLocations())->toHaveCount(1); - - $engine->clearLocations(); - - expect($engine->getLocations())->toHaveCount(0); - }); - - it('returns the engine for chaining', function (): void { - $engine = createEngine(); - - expect($engine->clearLocations())->toBe($engine); - }); - }); - - describe('clearCache', function (): void { - it('delegates to cache manager', function (): void { - $cacheManager = Mockery::mock(DiscoveryCacheManager::class); - $cacheManager->shouldReceive('clearCache')->once(); - - $engine = createEngine($cacheManager); - $engine->clearCache(); - }); - }); - - describe('getPerformanceStats', function (): void { - it('returns stats array with expected keys', function (): void { - $cacheManager = Mockery::mock(DiscoveryCacheManager::class); - $cacheManager->shouldReceive('getStaticCacheSize')->andReturn(0); - - $engine = createEngine($cacheManager); - $stats = $engine->getPerformanceStats(); - - expect($stats)->toHaveKeys(['context', 'instance_pool', 'static_cache_size']); - expect($stats['static_cache_size'])->toBe(0); - }); - }); - - describe('__clone', function (): void { - it('creates independent copies of collections', function (): void { - $engine = createEngine(); - $engine->addLocation(createLocationMock('/tmp/clone-'.uniqid())); - - $clone = clone $engine; - $clone->addLocation(createLocationMock('/tmp/clone-extra-'.uniqid())); - - expect($engine->getLocations())->toHaveCount(1); - expect($clone->getLocations())->toHaveCount(2); - }); - }); -}); - -describe('DiscoveryEngine apply', function (): void { - /** - * A discovery that records the items each apply() receives. - */ - function recordingDiscovery(bool $failing = false): DiscoveryInterface - { - return new class($failing) implements DiscoveryInterface - { - use IsDiscovery; - - /** @var list> */ - public array $applied = []; - - public function __construct(private readonly bool $failing) {} - - public function discover(DiscoveryLocationInterface $location, DiscoveredStructure $structure, ?ReflectionCacheInterface $reflectionCache = null): void {} - - public function apply(): void - { - $this->applied[] = $this->getItems()->all(); - - if ($this->failing) { - throw new RuntimeException('apply failed'); - } - } - }; - } - - it('applies each item once, however many times it is asked to apply', function (): void { - $engine = createEngine(); - $discovery = recordingDiscovery(); - $engine->addDiscovery('recording', $discovery); - $app = new DiscoveryLocation('App\\', '/app'); - $theme = new DiscoveryLocation('Theme\\', '/theme'); - - $discovery->getItems()->add($app, 'app-item'); - $engine->apply(); - $engine->apply(); - $discovery->getItems()->add($theme, 'theme-item'); - $engine->apply(); - - expect($discovery->applied)->toBe([['app-item'], ['theme-item']]); - }); - - it('keeps every item readable after applying', function (): void { - $engine = createEngine(); - $discovery = recordingDiscovery(); - $engine->addDiscovery('recording', $discovery); - $location = new DiscoveryLocation('App\\', '/app'); - - $discovery->getItems()->add($location, 'first'); - $engine->apply(); - $discovery->getItems()->add($location, 'second'); - $engine->apply(); - - expect($discovery->getItems()->all())->toBe(['first', 'second']); - }); - - it('applies once for the engine and its clones, which share the discovery', function (): void { - $engine = createEngine(); - $discovery = recordingDiscovery(); - $engine->addDiscovery('recording', $discovery); - $discovery->getItems()->add(new DiscoveryLocation('App\\', '/app'), 'item'); - - $engine->apply(); - (clone $engine)->apply(); - - expect($discovery->applied)->toBe([['item']]); - }); - - it('does not call apply() when nothing new was discovered', function (): void { - $engine = createEngine(); - $discovery = recordingDiscovery(); - $engine->addDiscovery('recording', $discovery); - - $engine->apply(); - - expect($discovery->applied)->toBe([]); - }); - - it('does not retry items whose apply failed, and restores the full list', function (): void { - $engine = createEngine(); - $discovery = recordingDiscovery(failing: true); - $engine->addDiscovery('recording', $discovery); - $discovery->getItems()->add(new DiscoveryLocation('App\\', '/app'), 'item'); - - expect(fn (): DiscoveryEngine => $engine->apply())->toThrow(DiscoveryException::class); - - $engine->apply(); - - expect($discovery->applied)->toBe([['item']]) - ->and($discovery->getItems()->all())->toBe(['item']); - }); -}); - -#[CapabilitySet] -enum EngineTestAttributedEnum: string -{ - case Value = 'value'; -} - -enum EngineTestPlainEnum: string -{ - case Value = 'value'; -} - -final class EngineTestClass {} - -abstract class EngineTestAbstractClass {} - -describe('DiscoveryEngine structures', function (): void { - it('hands discoveries concrete classes and enums carrying attributes', function (): void { - $structures = array_map( - fn (string $name): DiscoveredStructure => enum_exists($name) - ? DiscoveredEnum::fromReflection(new ReflectionEnum($name)) - : DiscoveredClass::fromReflection(new ReflectionClass($name)), - [EngineTestAttributedEnum::class, EngineTestPlainEnum::class, EngineTestClass::class, EngineTestAbstractClass::class] - ); - $cacheManager = Mockery::mock(DiscoveryCacheManager::class); - $cacheManager->shouldReceive('getStructuresForLocation')->andReturn($structures); - $engine = createEngine($cacheManager); - $seen = []; - $discovery = new class($seen) implements DiscoveryInterface - { - use IsDiscovery; - - public function __construct(private array &$seen) {} - - public function discover(DiscoveryLocationInterface $location, DiscoveredStructure $structure, ?ReflectionCacheInterface $reflectionCache = null): void - { - $this->seen[] = $structure->name; - } - - public function apply(): void {} - }; - $engine->addDiscovery('seen', $discovery); - $engine->addLocation(new DiscoveryLocation('Tests\\', __DIR__)); - - $engine->discover(); - - expect($seen)->toBe(['EngineTestAttributedEnum', 'EngineTestClass']); - }); -}); diff --git a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryRegistrarTest.php b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryRegistrarTest.php index d30a7ec5..b3c9e069 100644 --- a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryRegistrarTest.php +++ b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryRegistrarTest.php @@ -71,7 +71,7 @@ function createMockRegistrarEngine(): DiscoveryEngineInterface ->once() ->with('test_discovery', $discovery); - $registered = $this->registrar->registerFromContainer($engine); + $registered = $this->registrar->registerInto($engine); expect($registered)->toContain('test_discovery'); }); @@ -82,7 +82,7 @@ function createMockRegistrarEngine(): DiscoveryEngineInterface $engine = createMockRegistrarEngine(); $engine->shouldNotReceive('addDiscovery'); - $registered = $this->registrar->registerFromContainer($engine); + $registered = $this->registrar->registerInto($engine); expect($registered)->toBe([]); }); @@ -96,7 +96,7 @@ function createMockRegistrarEngine(): DiscoveryEngineInterface ->andReturn(new Collection(['already_registered' => $discovery])); $engine->shouldNotReceive('addDiscovery'); - $registered = $this->registrar->registerFromContainer($engine); + $registered = $this->registrar->registerInto($engine); expect($registered)->toBe([]); }); @@ -107,7 +107,7 @@ function createMockRegistrarEngine(): DiscoveryEngineInterface $engine = createMockRegistrarEngine(); $engine->shouldNotReceive('addDiscovery'); - $registered = $this->registrar->registerFromContainer($engine); + $registered = $this->registrar->registerInto($engine); expect($registered)->toBe([]); }); @@ -118,7 +118,7 @@ function createMockRegistrarEngine(): DiscoveryEngineInterface $engine = createMockRegistrarEngine(); $engine->shouldNotReceive('addDiscovery'); - $registered = $this->registrar->registerFromContainer($engine); + $registered = $this->registrar->registerInto($engine); expect($registered)->toBe([]); }); diff --git a/tests/Unit/PostType/PostTypeDiscoveryTest.php b/tests/Unit/PostType/PostTypeDiscoveryTest.php new file mode 100644 index 00000000..be45aed2 --- /dev/null +++ b/tests/Unit/PostType/PostTypeDiscoveryTest.php @@ -0,0 +1,114 @@ + */ + public array $calls = []; + + #[AdminCol('isbn', 'ISBN', sortable: true)] + public function isbnColumn(): string + { + return '978'; + } + + #[RegisterMetaBoxCb] + public function metaBoxes(): void + { + $this->calls[] = 'metaBoxes'; + } + + #[Action('init')] + public function onInit(): void + { + $this->calls[] = 'onInit'; + } +} + +/** + * Run the discovery over the given classes and return what reached the post type service. + * + * @return array{slug: string, singular: string, plural: string, args: array, priority: int} + */ +function registeredPostType(string $class): array +{ + $registered = []; + $service = Mockery::mock(PostTypeServiceInterface::class); + $service->shouldReceive('register')->once()->andReturnUsing( + function (string $slug, string $singular, string $plural, array $args, int $priority) use (&$registered): object { + $registered = ['slug' => $slug, 'singular' => $singular, 'plural' => $plural, 'args' => $args, 'priority' => $priority]; + + return new stdClass; + } + ); + + $discovery = new PostTypeDiscovery($service); + $discovery->discover(new DiscoveryLocation('Tests\\', __DIR__), DiscoveredClass::fromReflection(new ReflectionClass($class)), new ReflectionCache); + $discovery->apply(); + + return $registered; +} + +it('registers the arguments its attributes declare', function (): void { + $registered = registeredPostType(DiscoveredBook::class); + + expect($registered['slug'])->toBe('fixture_book') + ->and($registered['priority'])->toBe(7) + ->and($registered['args'])->toMatchArray([ + 'has_archive' => 'library', + 'supports' => ['title', 'excerpt'], + 'orderby' => 'date', + 'order' => 'DESC', + 'text_domain' => 'pollora', + ]) + ->and($registered['args'])->not->toHaveKey('priority'); +}); + +it('merges declared labels into the generated ones', function (): void { + $labels = registeredPostType(DiscoveredBook::class)['args']['labels']; + + expect($labels['menu_name'])->toBe('Library') + ->and($labels['singular_name'])->toBe('Book'); +}); + +it('turns method attributes into callbacks on the declaring class', function (): void { + $args = registeredPostType(DiscoveredBook::class)['args']; + + expect($args['admin_cols']['isbn'])->toMatchArray(['title' => 'ISBN', 'sortable' => true]) + ->and($args['admin_cols']['isbn']['function'][0])->toBeInstanceOf(DiscoveredBook::class) + ->and($args['admin_cols']['isbn']['function'][1])->toBe('isbnColumn') + ->and($args['register_meta_box_cb'][0])->toBeInstanceOf(DiscoveredBook::class) + ->and($args['register_meta_box_cb'][1])->toBe('metaBoxes'); +}); + +it('leaves the actions of a post type class to the hook discovery', function (): void { + $actions = Mockery::mock(ActionService::class); + $actions->shouldNotReceive('add'); + + app()->instance(ActionService::class, $actions); + + registeredPostType(DiscoveredBook::class); +}); diff --git a/tests/Unit/Schedule/Application/UseCases/RegisterSchedulerFiltersUseCaseTest.php b/tests/Unit/Schedule/Application/UseCases/RegisterSchedulerFiltersUseCaseTest.php deleted file mode 100644 index 27641a5a..00000000 --- a/tests/Unit/Schedule/Application/UseCases/RegisterSchedulerFiltersUseCaseTest.php +++ /dev/null @@ -1,72 +0,0 @@ -filter = m::mock(Filter::class); - $this->scheduler = m::mock(SchedulerInterface::class); - $this->useCase = new RegisterSchedulerFiltersUseCase($this->filter, $this->scheduler); -}); - -describe('RegisterSchedulerFiltersUseCase', function (): void { - - it('registers all nine WordPress cron filters', function (): void { - $this->filter->shouldReceive('add')->times(9); - - $this->useCase->execute(); - }); - - it('registers event query filters with correct hooks', function (): void { - $scheduler = $this->scheduler; - - $this->filter->shouldReceive('add') - ->with('pre_get_scheduled_event', [$scheduler, 'preGetScheduledEvent'], 10, 5) - ->once(); - $this->filter->shouldReceive('add') - ->with('pre_get_ready_cron_jobs', [$scheduler, 'preGetReadyCronJobs'], 10, 5) - ->once(); - - // Allow the management filters - $this->filter->shouldReceive('add')->times(7); - - $this->useCase->execute(); - }); - - it('registers event management filters with correct hooks', function (): void { - $scheduler = $this->scheduler; - - $expectedManagementFilters = [ - 'pre_update_option_cron' => 'preUpdateOptionCron', - 'pre_option_cron' => 'preOptionCron', - 'pre_schedule_event' => 'preScheduleEvent', - 'pre_reschedule_event' => 'preRescheduleEvent', - 'pre_unschedule_event' => 'preUnscheduleEvent', - 'pre_clear_scheduled_hook' => 'preClearScheduledHook', - 'pre_unschedule_hook' => 'preUnscheduleHook', - ]; - - foreach ($expectedManagementFilters as $hook => $method) { - $this->filter->shouldReceive('add') - ->with($hook, [$scheduler, $method], 10, 5) - ->once(); - } - - // Allow the query filters - $this->filter->shouldReceive('add')->times(2); - - $this->useCase->execute(); - }); - - it('registers all filters with priority 10 and 5 accepted args', function (): void { - $this->filter->shouldReceive('add') - ->with(m::type('string'), m::type('array'), 10, 5) - ->times(9); - - $this->useCase->execute(); - }); -}); diff --git a/tests/Unit/Schedule/EveryTest.php b/tests/Unit/Schedule/EveryTest.php deleted file mode 100644 index 68386522..00000000 --- a/tests/Unit/Schedule/EveryTest.php +++ /dev/null @@ -1,64 +0,0 @@ -toScheduleKey())->toBe('hourly'); - expect(Every::TWICE_DAILY->toScheduleKey())->toBe('twicedaily'); - expect(Every::DAY->toScheduleKey())->toBe('daily'); - expect(Every::WEEK->toScheduleKey())->toBe('weekly'); - expect(Every::MONTH->toScheduleKey())->toBe('monthly'); - expect(Every::YEAR->toScheduleKey())->toBe('yearly'); - }); - - it('identifies custom schedules correctly', function (): void { - expect(Every::HOUR->isCustom())->toBeFalse(); - expect(Every::TWICE_DAILY->isCustom())->toBeFalse(); - expect(Every::DAY->isCustom())->toBeFalse(); - expect(Every::WEEK->isCustom())->toBeFalse(); - expect(Every::MONTH->isCustom())->toBeTrue(); - expect(Every::YEAR->isCustom())->toBeTrue(); - }); - - it('converts to correct Interval instances', function (): void { - $hourInterval = Every::HOUR->toInterval(); - expect($hourInterval)->toBeInstanceOf(Interval::class); - expect($hourInterval->totalSeconds())->toBe(3600); - - $twiceDailyInterval = Every::TWICE_DAILY->toInterval(); - expect($twiceDailyInterval->totalSeconds())->toBe(43200); - - $dayInterval = Every::DAY->toInterval(); - expect($dayInterval->totalSeconds())->toBe(86400); - - $weekInterval = Every::WEEK->toInterval(); - expect($weekInterval->totalSeconds())->toBe(604800); - - $monthInterval = Every::MONTH->toInterval(); - expect($monthInterval->totalSeconds())->toBe(2592000); - - $yearInterval = Every::YEAR->toInterval(); - expect($yearInterval->totalSeconds())->toBe(31536000); - }); - - it('provides meaningful display text in intervals', function (): void { - expect(Every::HOUR->toInterval()->display)->toBe('Once Hourly'); - expect(Every::DAY->toInterval()->display)->toBe('Once Daily'); - expect(Every::WEEK->toInterval()->display)->toBe('Once Weekly'); - expect(Every::MONTH->toInterval()->display)->toBe('Once Monthly'); - expect(Every::YEAR->toInterval()->display)->toBe('Once Yearly'); - }); - - it('generates valid schedule arrays for custom intervals', function (): void { - $monthArray = Every::MONTH->toInterval()->toScheduleArray(); - - expect($monthArray)->toHaveKeys(['interval', 'display']); - expect($monthArray['interval'])->toBeInt(); - expect($monthArray['interval'])->toBeGreaterThan(0); - expect($monthArray['display'])->toBeString(); - }); -}); diff --git a/tests/Unit/Schedule/IntervalTest.php b/tests/Unit/Schedule/IntervalTest.php deleted file mode 100644 index 5630b804..00000000 --- a/tests/Unit/Schedule/IntervalTest.php +++ /dev/null @@ -1,67 +0,0 @@ -totalSeconds())->toBe(30 + 300 + 7200 + 86400 + 604800); - }); - - it('returns zero for empty interval', function (): void { - $interval = new Interval; - - expect($interval->totalSeconds())->toBe(0); - }); - - it('calculates seconds from hours only', function (): void { - $interval = new Interval(hours: 1); - - expect($interval->totalSeconds())->toBe(3600); - }); - - it('calculates seconds from days only', function (): void { - $interval = new Interval(days: 30); - - expect($interval->totalSeconds())->toBe(2592000); - }); - - it('calculates seconds from weeks only', function (): void { - $interval = new Interval(weeks: 1); - - expect($interval->totalSeconds())->toBe(604800); - }); - - it('returns schedule array with interval and display', function (): void { - $interval = new Interval(hours: 12, display: 'Twice Daily'); - - $array = $interval->toScheduleArray(); - - expect($array)->toBe([ - 'interval' => 43200, - 'display' => 'Twice Daily', - ]); - }); - - it('uses default display text', function (): void { - $interval = new Interval(minutes: 30); - - expect($interval->display)->toBe('Custom schedule'); - }); - - it('is readonly', function (): void { - $reflection = new ReflectionClass(Interval::class); - - expect($reflection->isReadOnly())->toBeTrue(); - }); -}); diff --git a/tests/Unit/Support/SlugTest.php b/tests/Unit/Support/SlugTest.php new file mode 100644 index 00000000..3b662ca5 --- /dev/null +++ b/tests/Unit/Support/SlugTest.php @@ -0,0 +1,20 @@ +toBe('user-management') + ->and(Slug::fromClassName('Cache'))->toBe('cache'); +}); + +it('derives a subcommand from a method name', function (): void { + expect(Slug::fromMethodName('deleteById'))->toBe('delete-by-id') + ->and(Slug::toKebabCase('listItems'))->toBe('list-items'); +}); diff --git a/tests/Unit/Taxonomy/TaxonomyDiscoveryTest.php b/tests/Unit/Taxonomy/TaxonomyDiscoveryTest.php new file mode 100644 index 00000000..c025d307 --- /dev/null +++ b/tests/Unit/Taxonomy/TaxonomyDiscoveryTest.php @@ -0,0 +1,86 @@ + */ + public array $calls = []; + + #[MetaBoxCb] + public function metaBox(): void + { + $this->calls[] = 'metaBox'; + } + + #[Action('init')] + public function onInit(): void + { + $this->calls[] = 'onInit'; + } +} + +/** + * Run the discovery over the class and return the arguments it registered. + * + * @return array + */ +function registeredTaxonomyArgs(string $class): array +{ + $registered = []; + $service = Mockery::mock(TaxonomyServiceInterface::class); + $service->shouldReceive('register')->once()->andReturnUsing( + function (string $slug, string|array $objectType, string $singular, string $plural, array $args) use (&$registered): object { + $registered = $args; + + return new stdClass; + } + ); + + $discovery = new TaxonomyDiscovery($service); + $discovery->discover(new DiscoveryLocation('Tests\\', __DIR__), DiscoveredClass::fromReflection(new ReflectionClass($class)), new ReflectionCache); + $discovery->apply(); + + return $registered; +} + +it('registers the arguments its attributes declare, labels merged', function (): void { + $args = registeredTaxonomyArgs(DiscoveredGenre::class); + + expect($args)->toMatchArray(['hierarchical' => true, 'show_in_rest' => true]) + ->and($args['labels']['menu_name'])->toBe('Shelves') + ->and($args['labels']['singular_name'])->toBe('Genre'); +}); + +it('turns a method attribute into a callback on the declaring class', function (): void { + $callback = registeredTaxonomyArgs(DiscoveredGenre::class)['meta_box_cb']; + + expect($callback[0])->toBeInstanceOf(DiscoveredGenre::class) + ->and($callback[1])->toBe('metaBox'); +}); + +it('leaves the actions of a taxonomy class to the hook discovery', function (): void { + $actions = Mockery::mock(ActionService::class); + $actions->shouldNotReceive('add'); + + app()->instance(ActionService::class, $actions); + + registeredTaxonomyArgs(DiscoveredGenre::class); +}); diff --git a/tests/Unit/WordPress/BootstrapEnvironmentTest.php b/tests/Unit/WordPress/BootstrapEnvironmentTest.php new file mode 100644 index 00000000..599e810a --- /dev/null +++ b/tests/Unit/WordPress/BootstrapEnvironmentTest.php @@ -0,0 +1,174 @@ +registerWordPressConstants(); + + self::assertTrue(WP_DEBUG); + self::assertSame('local', constant('WP_ENVIRONMENT_TYPE')); + } + + #[DataProvider('environmentMappings')] + public function test_application_environment_is_mapped(?string $environment, string $expected): void + { + putenv('WP_ENVIRONMENT_TYPE'); + $this->registerWordPressConstants($environment); + + self::assertSame($expected, constant('WP_ENVIRONMENT_TYPE')); + } + + #[DataProvider('debugValues')] + public function test_environment_does_not_depend_on_debug_mode(bool $debug): void + { + putenv('WP_ENVIRONMENT_TYPE'); + $this->registerWordPressConstants(debug: $debug); + + self::assertSame($debug, WP_DEBUG); + self::assertSame('local', constant('WP_ENVIRONMENT_TYPE')); + } + + #[DataProvider('nativeEnvironments')] + public function test_native_environment_prevents_fallback_constant(string $environment): void + { + putenv('WP_ENVIRONMENT_TYPE='.$environment); + $this->registerWordPressConstants(); + + self::assertFalse(defined('WP_ENVIRONMENT_TYPE')); + self::assertSame($environment, getenv('WP_ENVIRONMENT_TYPE')); + } + + #[DataProvider('explicitValues')] + public function test_configured_environment_is_not_normalized(mixed $value): void + { + putenv('WP_ENVIRONMENT_TYPE'); + $this->registerWordPressConstants(constants: ['wp_environment_type' => $value]); + + self::assertSame($value, constant('WP_ENVIRONMENT_TYPE')); + } + + public function test_configured_constant_still_applies_with_native_environment(): void + { + putenv('WP_ENVIRONMENT_TYPE=production'); + $this->registerWordPressConstants(constants: ['WP_ENVIRONMENT_TYPE' => 'staging']); + + self::assertSame('staging', constant('WP_ENVIRONMENT_TYPE')); + self::assertSame('production', getenv('WP_ENVIRONMENT_TYPE')); + } + + #[DataProvider('explicitValues')] + public function test_predefined_constant_is_not_overwritten(mixed $value): void + { + putenv('WP_ENVIRONMENT_TYPE=production'); + define('WP_ENVIRONMENT_TYPE', $value); + $this->registerWordPressConstants(constants: ['WP_ENVIRONMENT_TYPE' => 'development']); + + self::assertSame($value, constant('WP_ENVIRONMENT_TYPE')); + } + + /** + * @return array + */ + public static function environmentMappings(): array + { + return [ + 'local' => ['local', 'local'], + 'dev' => ['dev', 'development'], + 'development' => ['development', 'development'], + 'testing' => ['testing', 'development'], + 'staging' => ['staging', 'staging'], + 'production' => ['production', 'production'], + 'prod' => ['prod', 'production'], + 'unknown' => ['preview', 'production'], + 'empty' => ['', 'production'], + 'missing' => [null, 'production'], + ]; + } + + /** + * @return array + */ + public static function debugValues(): array + { + return ['enabled' => [true], 'disabled' => [false]]; + } + + /** + * @return array + */ + public static function nativeEnvironments(): array + { + return [ + 'local' => ['local'], + 'development' => ['development'], + 'staging' => ['staging'], + 'production' => ['production'], + 'empty' => [''], + 'invalid' => ['preview'], + ]; + } + + /** + * @return array + */ + public static function explicitValues(): array + { + return [ + 'valid' => ['staging'], + 'empty' => [''], + 'invalid' => ['preview'], + 'false' => [false], + ]; + } + + /** + * @param array $constants + */ + private function registerWordPressConstants(?string $environment = 'local', array $constants = [], bool $debug = true): void + { + $app = new Application(dirname(__DIR__, 3)); + $app->instance('config', new Repository([ + 'app' => ['env' => $environment, 'debug' => $debug, 'url' => 'http://environment.test'], + 'wordpress' => ['constants' => $constants], + ])); + $app->instance('request', Request::create('/')); + $app->instance('constant.manager', new ConstantManager); + Facade::setFacadeApplication($app); + + $bootstrap = new Bootstrap( + new ConsoleDetectionService(new LaravelConsoleDetector($app)), + new LaravelDebugDetector($app), + \Mockery::mock(Action::class), + ); + + $bootstrap->register(); + } +} diff --git a/tests/Unit/WpCli/Application/Services/WpCliServiceTest.php b/tests/Unit/WpCli/Application/Services/WpCliServiceTest.php deleted file mode 100644 index a6210720..00000000 --- a/tests/Unit/WpCli/Application/Services/WpCliServiceTest.php +++ /dev/null @@ -1,98 +0,0 @@ -adapter = new WpCliAdapter; - $this->service = new WpCliService($this->adapter); - }); - - describe('register', function (): void { - it('stores command in registered commands', function (): void { - $this->service->register('pollora test', 'TestCommand', 'A test command', 10); - - expect($this->service->hasCommand('pollora test'))->toBeTrue(); - }); - - it('stores command metadata correctly', function (): void { - $this->service->register('pollora test', 'TestCommand', 'A test command', 10, ['when' => 'before_wp_load']); - - $command = $this->service->getCommand('pollora test'); - - expect($command['class'])->toBe('TestCommand'); - expect($command['description'])->toBe('A test command'); - expect($command['priority'])->toBe(10); - expect($command['args'])->toBe(['when' => 'before_wp_load']); - }); - - it('accepts array handler', function (): void { - $handler = ['SomeClass', 'someMethod']; - - $this->service->register('pollora array-cmd', $handler, 'Array handler'); - - $command = $this->service->getCommand('pollora array-cmd'); - expect($command['class'])->toBe($handler); - }); - - it('stores default values when not provided', function (): void { - $this->service->register('pollora minimal', 'MinimalCommand'); - - $command = $this->service->getCommand('pollora minimal'); - - expect($command['description'])->toBe(''); - expect($command['priority'])->toBe(0); - expect($command['args'])->toBe([]); - }); - }); - - describe('hasCommand', function (): void { - it('returns false for unregistered command', function (): void { - expect($this->service->hasCommand('nonexistent'))->toBeFalse(); - }); - - it('returns true for registered command', function (): void { - $this->service->register('pollora exists', 'ExistsCommand'); - - expect($this->service->hasCommand('pollora exists'))->toBeTrue(); - }); - }); - - describe('getCommand', function (): void { - it('returns null for unregistered command', function (): void { - expect($this->service->getCommand('nonexistent'))->toBeNull(); - }); - }); - - describe('getRegisteredCommands', function (): void { - it('returns empty array initially', function (): void { - expect($this->service->getRegisteredCommands())->toBe([]); - }); - - it('returns all registered commands', function (): void { - $this->service->register('cmd1', 'Class1'); - $this->service->register('cmd2', 'Class2'); - - $commands = $this->service->getRegisteredCommands(); - - expect($commands)->toHaveCount(2); - expect($commands)->toHaveKeys(['cmd1', 'cmd2']); - }); - }); - - describe('initializeCommands', function (): void { - it('does not throw when WP CLI is unavailable', function (): void { - $this->service->register('cmd1', 'Class1'); - - // Should not throw — WP CLI is not available, so it silently skips - $this->service->initializeCommands(); - - expect($this->service->hasCommand('cmd1'))->toBeTrue(); - }); - }); -}); diff --git a/tests/Unit/WpCli/Infrastructure/Adapters/WpCliAdapterTest.php b/tests/Unit/WpCli/Infrastructure/Adapters/WpCliAdapterTest.php deleted file mode 100644 index 9b0b1522..00000000 --- a/tests/Unit/WpCli/Infrastructure/Adapters/WpCliAdapterTest.php +++ /dev/null @@ -1,121 +0,0 @@ -adapter = new WpCliAdapter; - }); - - describe('isAvailable', function (): void { - it('returns false when WP_CLI is not defined', function (): void { - // WP_CLI is not defined in test environment - expect($this->adapter->isAvailable())->toBeFalse(); - }); - }); - - describe('addCommand', function (): void { - it('throws RuntimeException when WP_CLI is not available', function (): void { - expect(fn () => $this->adapter->addCommand('test', 'SomeClass')) - ->toThrow(RuntimeException::class, 'WP-CLI is not available'); - }); - }); - - describe('hasCommand', function (): void { - it('returns false when WP_CLI is not available', function (): void { - expect($this->adapter->hasCommand('test'))->toBeFalse(); - }); - }); - - describe('getVersion', function (): void { - it('returns null when WP_CLI is not available', function (): void { - expect($this->adapter->getVersion())->toBeNull(); - }); - }); - - describe('log', function (): void { - it('does nothing when WP_CLI is not available', function (): void { - // Should not throw - $this->adapter->log('test message', 'debug'); - $this->adapter->log('test message', 'warning'); - $this->adapter->log('test message', 'error'); - $this->adapter->log('test message', 'success'); - $this->adapter->log('test message'); - - expect(true)->toBeTrue(); // Assert no exception - }); - }); -}); - -describe('WpCliAdapter validation', function (): void { - /* - * Since validateHandler is private, we test it indirectly through addCommand. - * When WP_CLI is unavailable, RuntimeException is thrown before validation. - * We use Reflection to test validation logic directly. - */ - beforeEach(function (): void { - $this->adapter = new WpCliAdapter; - $this->validate = new ReflectionMethod(WpCliAdapter::class, 'validateHandler'); - }); - - it('accepts a valid class string', function (): void { - // stdClass exists - $this->validate->invoke($this->adapter, stdClass::class); - - expect(true)->toBeTrue(); - }); - - it('rejects a non-existent class string', function (): void { - expect(fn () => $this->validate->invoke($this->adapter, 'NonExistentClass')) - ->toThrow(InvalidArgumentException::class, 'does not exist'); - }); - - it('accepts a valid [object, method] array', function (): void { - $handler = [new class - { - public function handle(): void {} - }, 'handle']; - - $this->validate->invoke($this->adapter, $handler); - - expect(true)->toBeTrue(); - }); - - it('rejects array with wrong count', function (): void { - expect(fn () => $this->validate->invoke($this->adapter, ['only-one'])) - ->toThrow(InvalidArgumentException::class, 'exactly 2 elements'); - }); - - it('rejects array where first element is not an object', function (): void { - expect(fn () => $this->validate->invoke($this->adapter, ['string', 'method'])) - ->toThrow(InvalidArgumentException::class, 'must be an object'); - }); - - it('rejects array where second element is not a string', function (): void { - expect(fn () => $this->validate->invoke($this->adapter, [new stdClass, 42])) - ->toThrow(InvalidArgumentException::class, 'must be a method name string'); - }); - - it('rejects array where method does not exist', function (): void { - expect(fn () => $this->validate->invoke($this->adapter, [new stdClass, 'nonExistent'])) - ->toThrow(InvalidArgumentException::class, 'does not exist on class'); - }); - - it('accepts an invokable object', function (): void { - $handler = new class - { - public function __invoke(): void {} - }; - - $this->validate->invoke($this->adapter, $handler); - - expect(true)->toBeTrue(); - }); - - it('rejects a non-invokable object', function (): void { - expect(fn () => $this->validate->invoke($this->adapter, new stdClass)) - ->toThrow(InvalidArgumentException::class, 'must be invokable'); - }); -}); diff --git a/tests/Unit/WpCli/Infrastructure/Adapters/WpCliMethodWrapperTest.php b/tests/Unit/WpCli/Infrastructure/Adapters/WpCliMethodWrapperTest.php deleted file mode 100644 index d235251e..00000000 --- a/tests/Unit/WpCli/Infrastructure/Adapters/WpCliMethodWrapperTest.php +++ /dev/null @@ -1,102 +0,0 @@ -instance = new class - { - /** - * Test command documentation. - * - * ## OPTIONS - * - * : The name to greet. - */ - public function greet(array $args, array $assocArgs): string - { - return 'Hello '.$args[0]; - } - - private function secretMethod(array $args): string - { - return 'secret: '.$args[0]; - } - }; - }); - - it('invokes the original method via __invoke', function (): void { - $method = new ReflectionMethod($this->instance, 'greet'); - $wrapper = new WpCliMethodWrapper($this->instance, $method); - - $result = $wrapper(['World'], []); - - expect($result)->toBe('Hello World'); - }); - - it('delegates __call to original method by name', function (): void { - $method = new ReflectionMethod($this->instance, 'greet'); - $wrapper = new WpCliMethodWrapper($this->instance, $method); - - $result = $wrapper->greet(['World'], []); - - expect($result)->toBe('Hello World'); - }); - - it('throws BadMethodCallException for unknown method via __call', function (): void { - $method = new ReflectionMethod($this->instance, 'greet'); - $wrapper = new WpCliMethodWrapper($this->instance, $method); - - expect(fn () => $wrapper->unknownMethod()) - ->toThrow(BadMethodCallException::class, 'does not exist'); - }); - - it('caches and returns the docblock', function (): void { - $method = new ReflectionMethod($this->instance, 'greet'); - $wrapper = new WpCliMethodWrapper($this->instance, $method); - - $doc = $wrapper->getDocComment(); - - expect($doc)->toBeString(); - expect($doc)->toContain('Test command documentation'); - expect($doc)->toContain('## OPTIONS'); - }); - - it('returns false when method has no docblock', function (): void { - $obj = new class - { - public function noDoc(array $args, array $assocArgs): void {} - }; - - $method = new ReflectionMethod($obj, 'noDoc'); - $wrapper = new WpCliMethodWrapper($obj, $method); - - expect($wrapper->getDocComment())->toBeFalse(); - }); - - it('returns the original method reflection', function (): void { - $method = new ReflectionMethod($this->instance, 'greet'); - $wrapper = new WpCliMethodWrapper($this->instance, $method); - - expect($wrapper->getOriginalMethod())->toBe($method); - }); - - it('returns the original instance', function (): void { - $method = new ReflectionMethod($this->instance, 'greet'); - $wrapper = new WpCliMethodWrapper($this->instance, $method); - - expect($wrapper->getOriginalInstance())->toBe($this->instance); - }); - - it('can invoke private methods via reflection', function (): void { - $method = new ReflectionMethod($this->instance, 'secretMethod'); - - $wrapper = new WpCliMethodWrapper($this->instance, $method); - - $result = $wrapper(['data'], []); - - expect($result)->toBe('secret: data'); - }); -}); diff --git a/tests/Unit/WpRest/CanPermissionTest.php b/tests/Unit/WpRest/CanPermissionTest.php deleted file mode 100644 index e2c2b1e9..00000000 --- a/tests/Unit/WpRest/CanPermissionTest.php +++ /dev/null @@ -1,45 +0,0 @@ -justReturn(401); -}); - -it('allows a user who has the capability', function (): void { - Functions\expect('current_user_can')->once()->with('export_attendees')->andReturn(true); - - expect((new Can(EventCap::ExportAttendees))->allow(Mockery::mock(WP_REST_Request::class)))->toBeTrue(); -}); - -it('passes the value of a request parameter, for a meta capability', function (): void { - $request = Mockery::mock(WP_REST_Request::class); - $request->shouldReceive('get_param')->with('id')->andReturn('42'); - Functions\expect('current_user_can')->once()->with('edit_post', '42')->andReturn(true); - - expect((new Can('edit_post', parameter: 'id'))->allow($request))->toBeTrue(); -}); - -it("refuses with WordPress's authorization status", function (): void { - Functions\when('current_user_can')->justReturn(false); - - expect((new Can('manage_options'))->allow(Mockery::mock(WP_REST_Request::class)))->toBeInstanceOf(WP_Error::class); -}); - -it('is accepted as an instance by #[Method] and #[WpRestRoute]', function (): void { - Functions\when('current_user_can')->justReturn(true); - $resolve = new ReflectionMethod(Method::class, 'resolvePermissionCallback'); - - $callback = $resolve->invoke(new Method('GET', permissionCallback: new Can('edit_posts')), new Can('edit_posts')); - - expect($callback(Mockery::mock(WP_REST_Request::class)))->toBeTrue(); -}); diff --git a/tests/Unit/WpRest/RestRequestInjectionTest.php b/tests/Unit/WpRest/RestRequestInjectionTest.php deleted file mode 100644 index d69922c1..00000000 --- a/tests/Unit/WpRest/RestRequestInjectionTest.php +++ /dev/null @@ -1,52 +0,0 @@ -getParameters()[0]; - - $method = (new ReflectionClass(Method::class))->getMethod('expectsTheRequest'); - - return $method->invoke(new Method('GET'), $param); -} - -describe('Method::expectsTheRequest()', function (): void { - it('gives the request to a parameter that asks for it by type', function (): void { - expect(expectsRequest('withRequest'))->toBeTrue(); - }); - - it('leaves a route parameter alone', function (): void { - expect(expectsRequest('withRouteParam'))->toBeFalse(); - }); - - it('leaves an untyped parameter alone', function (): void { - expect(expectsRequest('withUntyped'))->toBeFalse(); - }); - - it('leaves a builtin type alone', function (): void { - // int, string and friends name route values, never the request. - expect(expectsRequest('withBuiltin'))->toBeFalse(); - }); -}); diff --git a/tests/Unit/WpRest/WpRestDiscoveryTest.php b/tests/Unit/WpRest/WpRestDiscoveryTest.php deleted file mode 100644 index ed292d5b..00000000 --- a/tests/Unit/WpRest/WpRestDiscoveryTest.php +++ /dev/null @@ -1,230 +0,0 @@ - $namespace, 'route' => $route, 'args' => $args]; - - return true; - } -} - -if (! function_exists('add_action')) { - function add_action($hook, $callback, $priority = 10, $accepted_args = 1): bool - { - global $wp_actions; - $wp_actions[$hook][] = $callback; - - return true; - } -} - -// Test class that doesn't implement Attributable (real-world scenario) -#[WpRestRoute('api/v1', '/documents/(?P\\d+)')] -class TestDocumentAPI -{ - #[Method('GET')] - public function get(int $documentId): array - { - return ['id' => $documentId, 'title' => 'Test Document']; - } - - #[Method(['POST', 'DELETE'])] - public function modify(int $documentId): array - { - return ['id' => $documentId, 'modified' => true]; - } -} - -// Test class without WpRestRoute attribute -class RegularController -{ - public function someMethod(): string - { - return 'regular'; - } -} - -// Test class that is abstract -#[WpRestRoute('api/v1', '/abstract')] -abstract class AbstractController -{ - #[Method('GET')] - abstract public function get(): array; -} - -// Mock classes for testing - simplified version without Spatie dependencies -class MockDiscoveredAttribute -{ - public function __construct( - public readonly string $name, - public readonly string $class, - public readonly array $arguments = [] - ) {} - - public function newInstance() - { - return new $this->class(...$this->arguments); - } -} - -class MockDiscoveredClass -{ - public function __construct( - public readonly string $name, - public readonly string $namespace, - public readonly string $file, - public readonly array $attributes = [], - public readonly bool $isAbstract = false - ) {} -} - -describe('WpRestDiscovery', function (): void { - - beforeEach(function (): void { - global $registered_routes, $wp_actions; - $registered_routes = []; - $wp_actions = []; - }); - - test('discover method processes only DiscoveredClass instances', function (): void { - $discovery = new WpRestDiscovery; - - // Test that discovery starts empty - expect($discovery->getItems()->all())->toHaveCount(0); - - // Test that we can add items manually (simulating proper discovery) - $location = new DiscoveryLocation('Test\\', '/test/path'); - $discovery->getItems()->add($location, ['test' => 'item']); - - expect($discovery->getItems()->all())->toHaveCount(1); - }); - - test('basic discovery functionality works', function (): void { - $discovery = new WpRestDiscovery; - - // Test that we can manually add items (simulating discovery) - $location = new DiscoveryLocation('Test\\', '/test/path'); - $mockAttribute = new MockDiscoveredAttribute( - name: WpRestRoute::class, - class: WpRestRoute::class, - arguments: ['api/v1', '/test'] - ); - - $discovery->getItems()->add($location, [ - 'class' => 'TestClass', - 'attribute' => $mockAttribute, - 'structure' => new MockDiscoveredClass('TestClass', '', '/test.php'), - ]); - - expect($discovery->getItems()->all())->toHaveCount(1); - }); - - test('registers REST routes when applying discovered items', function (): void { - global $registered_routes, $wp_actions; - - $discovery = new WpRestDiscovery; - $location = new DiscoveryLocation('', '/test/path'); - $reflectionCache = new ReflectionCache; - - // Add a simple test item to verify the apply logic works - $discovery->getItems()->add($location, [ - 'class' => 'TestDocumentAPI', - 'attribute' => new MockDiscoveredAttribute( - name: WpRestRoute::class, - class: WpRestRoute::class, - arguments: ['api/v1', '/documents/(?P\\d+)'] - ), - 'structure' => new MockDiscoveredClass( - name: 'TestDocumentAPI', - namespace: '', - file: '/test/TestDocumentAPI.php' - ), - 'reflection_cache' => $reflectionCache, - ]); - - // The apply method should not throw even if reflection fails - expect(fn () => $discovery->apply())->not->toThrow(Exception::class); - - // Verify that items were processed (we don't check wp_actions as it depends on complex logic) - expect($discovery->getItems()->all())->toHaveCount(1); - }); - - test('handles reflection errors gracefully', function (): void { - $discovery = new WpRestDiscovery; - $location = new DiscoveryLocation('', '/test/path'); - $reflectionCache = new ReflectionCache; - - // Add an item with a non-existent class - $discovery->getItems()->add($location, [ - 'class' => 'NonExistentClass', - 'attribute' => new MockDiscoveredAttribute( - name: WpRestRoute::class, - class: WpRestRoute::class, - arguments: ['api/v1', '/test'] - ), - 'structure' => new MockDiscoveredClass( - name: 'NonExistentClass', - namespace: '', - file: '/test/NonExistentClass.php' - ), - 'reflection_cache' => $reflectionCache, - ]); - - // Should not throw, just log errors - expect(fn () => $discovery->apply())->not->toThrow(Exception::class); - }); - - test('returns correct identifier', function (): void { - $discovery = new WpRestDiscovery; - expect($discovery->getIdentifier())->toBe('wp_rest_routes'); - }); - - test('wrapper system works with non-Attributable classes', function (): void { - // Test the core wrapper functionality separately - $className = 'TestDocumentAPI'; - $namespace = 'api/v1'; - $route = '/documents/(?P\\d+)'; - - // Create wrapper like WpRestDiscovery does - $wrapper = new class($className, $namespace, $route) implements Attributable - { - private mixed $realInstance = null; - - public function __construct( - private readonly string $className, - public readonly string $namespace, - public readonly string $route, - public readonly ?string $classPermission = null - ) {} - - public function getRealInstance(): mixed - { - if ($this->realInstance === null) { - $reflectionClass = new ReflectionClass($this->className); - if ($reflectionClass->isInstantiable()) { - $this->realInstance = $reflectionClass->newInstance(); - } - } - - return $this->realInstance; - } - }; - - expect($wrapper)->toBeInstanceOf(Attributable::class); - expect($wrapper->namespace)->toBe($namespace); - expect($wrapper->route)->toBe($route); - expect($wrapper->getRealInstance())->toBeInstanceOf(TestDocumentAPI::class); - }); -}); diff --git a/tests/e2e/fixtures/plugins/e2e-features/e2e-features.php b/tests/e2e/fixtures/plugins/e2e-features/e2e-features.php index abcee5d3..73bd5e59 100644 --- a/tests/e2e/fixtures/plugins/e2e-features/e2e-features.php +++ b/tests/e2e/fixtures/plugins/e2e-features/e2e-features.php @@ -15,4 +15,7 @@ exit; } +// The site follows APP_ENV (local), and WordPress 7.1 refuses pings outside production: the trackback test needs them. +add_filter('wp_should_disable_pings_for_environment', '__return_false'); + pollora_register(ModuleType::Plugin, 'e2e-features', __DIR__);