diff --git a/CHANGELOG.md b/CHANGELOG.md index aa8c0ad5..e06b3d2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,23 @@ 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.36.0...develop) +## [Unreleased](https://github.com/Pollora/framework/compare/v13.36.1...develop) + +## [v13.36.1](https://github.com/Pollora/framework/compare/v13.36.0...v13.36.1) - 2026-10-16 + +### Added +- Block Bindings live in their own package, [`pollora/block-bindings`](https://github.com/Pollora/block-bindings), which the framework requires: the resolver, the source builder and registry, `BindingContext`, the four sources Pollora ships (`pollora/post-meta`, `pollora/term-meta`, `pollora/author-meta`, `pollora/option`), `BlockBindingDiscovery`, the WordPress adapters, the editor data and script (`block-bindings.js` ships with the package) and the preview route, with their tests. Class names do not change. The framework keeps what needs Laravel: `BlockBindingServiceProvider`, `config/block-bindings.php`, the Laravel implementations of the package's contracts (`LaravelMethodInvoker`, `LaravelBindingEvents`, `LaravelPostFinder`), `pollora:binding:list`, `pollora:make:binding` and the `pollora:doctor` check +- Roles and capabilities live in their own package, [`pollora/roles`](https://github.com/Pollora/roles), which the framework requires: `RoleDefinitionBuilder`, `RoleRegistry`, `RoleCompiler`, `PostTypeCapabilityMap`, the capability readers, the WordPress adapters (stored roles, `WP_Roles` injection, label translation, role usage) and `RoleDiscovery`, with their tests. Class names do not change. The framework keeps the Laravel side: `RoleServiceProvider`, the `role` middleware, the `@role` directive, the `HasRoles` bridge (`$user->can()`, `@can`), the `pollora:roles:*` commands and the `pollora:doctor` check +- Typed meta lives in its own package, [`pollora/meta`](https://github.com/Pollora/meta), which the framework requires: `MetaAccessor` (`of()`, `schemas()`, `schemaFor()`, `extend()`), the fluent `MetaRecord`, the schema builder and repository, `MetaAuditor`, `MetaUiDrivers`, the value caster, the WordPress adapters, `MetaDiscovery` and `MetaUiDriverConformance`, with their tests. Class names do not change. The framework keeps what needs Laravel: `MetaServiceProvider`, the `Meta` facade and `HasTypedMeta`, the Laravel validator (`LaravelMetaValidator`), the `pollora:meta:*` commands and the `pollora:doctor` check +- The asset classes live in their own package, [`pollora/assets`](https://github.com/Pollora/assets), which the framework requires: `AssetManager`, `AssetContainer`, `AssetEnqueuer` (`Asset::add()->container()->useVite()`), `AssetFile`, the record and the wp-admin notice of a missing build. Class names do not change. The package also resolves Vite builds without Laravel, for a classic theme, a plugin or a Bedrock site. The framework keeps what needs Laravel: the service provider, the `Asset` facade, `ViteManager` (Laravel's Vite, where `ASSET_URL`, a CDN and the application URL keep applying), `RootAssetManager` and the Laravel implementations of the package's three contracts (`ViteManagerFactory`, `RuntimeContext`, `PublicPath`) + +### Changed +- Accepts `pollora/query` 2.0 alongside 1.x (`^1.0 || ^2.0`, up from `^1.0`): the package now covers every WordPress query class, with terminal methods (`first()`, `ids()`, `count()`, `exists()`) and the documentation of each builder. Class names and the three existing facades do not change. `composer update` takes 2.0, and a project that needs 1.x requires `pollora/query` `^1.0` itself. A few fixes in 2.0 change the result of a query that used to run: `where()` followed by `orWhere()` groups the earlier clauses (`(a AND b) OR c`), `DateQuery::inclusive()` now takes effect, dates use a 24-hour clock, and an invalid `fields()`, `userPermission()` or `orderBy()` direction throws a `QueryException` instead of being ignored. The package's [upgrading guide](https://github.com/Pollora/Query/blob/2.0.0/docs/upgrading.md) lists them +- The asset classes that left for the package no longer reach for Laravel: `AssetEnqueuer` receives the hook services, the Vite factory, the runtime and a PSR-3 logger instead of resolving them from the container, `AssetFile` is resolved through a resolver the provider sets (`AssetFile::resolveUsing()`), and `AssetManager`, `MissingBuilds` and `MissingBuildNotice` take their public directory, logger and project root as arguments. The rendered HTML of a Pollora site is unchanged, in build and while Vite runs hot +- An `AssetEnqueuer` destroyed after its application, as in a test, no longer throws while it reports a failure of its deferred enqueue +- The typed meta classes that will leave for `pollora/meta` no longer reach for Laravel: `MetaUiDrivers` and `MetaAccessor::extend()` take a PSR-11 `ContainerInterface` (the Laravel container is one, a driver closure receives the same object), and the key of a data object's property is derived by `Pollora\Attributes\Support\Naming` instead of `Str::snake()`. What needs Laravel moved to `Pollora\Meta\Infrastructure\Laravel`: `MetaServiceProvider`, `LaravelMetaValidator`, `TypedMetaCheck`, `MetaListCommand` and `MetaAuditCommand`. The rendered pages, the REST responses and the output of the `pollora:meta:*`, `pollora:binding:list`, `pollora:roles:*` and `pollora:doctor` commands of a Pollora site are unchanged +- The roles classes that will leave for `pollora/roles` no longer reach for Laravel: the label of a `#[Role]` without one comes from `Naming::headline()` (the same words as `Str::headline()`) and `Naming::classBasename()`. What needs Laravel moved to `Pollora\Role\Infrastructure\Laravel`: `RoleServiceProvider`, the `role` middleware `EnsureUserHasRole`, the `@role` directive `RoleDirective`, the `RolesCheck` of `pollora:doctor` and the `pollora:roles:*` and `pollora:make:role` commands with their stubs. Roles, capabilities and command outputs of a Pollora site are unchanged +- The Block Bindings classes that will leave for `pollora/block-bindings` no longer reach for Laravel: the resolver takes a PSR-11 container, and three contracts carry what Laravel did for it, `MethodInvokerInterface` (the method of a `#[BlockBinding]` source gets the services its typed parameters name; `LaravelMethodInvoker` calls through Laravel's container, `ReflectionMethodInvoker` by reflection) `BindingEventsInterface` (`BindingResolved` reaches Laravel's dispatcher through `LaravelBindingEvents`) and `PostFinderInterface` (`BindingContext::post()` loads the framework's `Pollora\Models\Post` through `LaravelPostFinder`, Colt's `Post` without it, and is typed as Colt's `Post`, which the framework's extends). The options a project lists in `block-bindings.options` reach `OptionSource` and the editor data as an array, read from the config by the provider. Labels and names come from `Naming`, as `Str` writes them. What needs Laravel moved to `Pollora\BlockBinding\Infrastructure\Laravel`: the provider, the doctor check, `pollora:binding:list` and `pollora:make:binding` with their stub. Pages, REST responses and command outputs of a Pollora site are unchanged ## [v13.36.0](https://github.com/Pollora/framework/compare/v13.35.6...v13.36.0) - 2026-10-14 diff --git a/composer.json b/composer.json index 1ab45cb6..ef87c275 100644 --- a/composer.json +++ b/composer.json @@ -31,11 +31,15 @@ "illuminate/console": "^13.36", "pollora/abilities": "^1.0", "pollora/ajax": "^1.1", - "pollora/attributes": "^0.1 || ^1.0", + "pollora/assets": "^0.1", + "pollora/attributes": "^0.1.1 || ^1.0", + "pollora/block-bindings": "^0.1", "pollora/discovery": "^0.1 || ^1.0", "pollora/entity": "^1.2", + "pollora/meta": "^0.1", "pollora/hook": "^2.0.1", "pollora/option": "^1.1", + "pollora/roles": "^0.1", "pollora/schedule": "^1.0", "pollora/wp-cli": "^0.1 || ^1.0", "pollora/wp-rest": "^0.1 || ^1.0", @@ -44,7 +48,7 @@ "pollora/colt": "^10.0.1", "log1x/sage-directives": "^2.0", "roots/bedrock-autoloader": "^1.1", - "pollora/query": "^1.0", + "pollora/query": "^1.0 || ^2.0", "spatie/php-structure-discoverer": "^2.4", "coduo/php-humanizer": "5.0.3", "nwidart/laravel-modules": "^13.0", diff --git a/resources/js/block-bindings.js b/resources/js/block-bindings.js deleted file mode 100644 index 8cd6de2a..00000000 --- a/resources/js/block-bindings.js +++ /dev/null @@ -1,196 +0,0 @@ -/** - * Pollora — the editor side of Block Bindings. - * - * The server registers every Pollora source (#[BlockBinding] classes and the - * pollora/* meta sources) and the editor learns their names and labels. This - * script adds what the editor needs to use them: - * - * - getFieldsList: the fields of the source, so the block's "Attributes" - * panel offers them; a meta source offers the meta of the post type (or - * taxonomy) being edited; - * - getValues: the value each bound block shows, computed on the server by - * the same code as the page and asked in one request for all the blocks - * waiting for a value (POST pollora/v1/block-bindings/resolve). - * - * The values are read-only: the block shows them, the editor does not write - * them back. While a value loads, and when the source gives none, the block - * shows the field's label. - * - * Plain JavaScript on the wp.* globals, shipped without a build as the inline - * script of the "pollora-block-bindings" handle, after window.polloraBlockBindings. - */ -(function (wp, root) { - 'use strict'; - - var config = root.polloraBlockBindings; - - if (!config || !wp.blocks || !wp.blocks.registerBlockBindingsSource) { - return; - } - - var STORE = 'pollora/block-bindings'; - - /** Wait for the other blocks of the same render before asking the server. */ - var BATCH_DELAY = 20; - - var pending = {}; - var timer = null; - - /** The key of one bound attribute, for the store and the request. The block decides its escaping. */ - function keyOf(sourceName, args, context, blockName, attribute) { - return JSON.stringify([sourceName, args || {}, context.postId || null, context.postType || null, context.termId || null, context.taxonomy || null, blockName, attribute]); - } - - function flush(dispatch) { - var batch = pending; - var byContext = {}; - - pending = {}; - timer = null; - - Object.keys(batch).forEach(function (key) { - var item = batch[key]; - var contextKey = JSON.stringify(item.context); - - byContext[contextKey] = byContext[contextKey] || { context: item.context, bindings: [] }; - byContext[contextKey].bindings.push({ key: key, source: item.source, args: item.args, block: item.block, attribute: item.attribute }); - }); - - Object.keys(byContext).forEach(function (contextKey) { - var request = byContext[contextKey]; - var keys = request.bindings.map(function (binding) { return binding.key; }); - - wp.apiFetch({ path: '/' + config.route, method: 'POST', data: request }) - .then(function (response) { - var values = {}; - - keys.forEach(function (key) { - values[key] = response && response.values && key in response.values ? response.values[key] : null; - }); - dispatch.receiveValues(values); - }) - .catch(function () { - var values = {}; - - keys.forEach(function (key) { values[key] = null; }); - dispatch.receiveValues(values); - }); - }); - } - - var store = wp.data.createReduxStore(STORE, { - reducer: function (state, action) { - state = state || {}; - - if (action.type === 'RECEIVE_VALUES') { - return Object.assign({}, state, action.values); - } - - return state; - }, - actions: { - receiveValues: function (values) { - return { type: 'RECEIVE_VALUES', values: values }; - }, - }, - selectors: { - /** The value of a bound attribute; undefined while it loads. */ - getValue: function (state, key) { - return state[key]; - }, - }, - resolvers: { - getValue: function (key) { - return function (thunk) { - var item = JSON.parse(key); - - pending[key] = { - source: item[0], - args: item[1], - context: pick({ postId: item[2], postType: item[3], termId: item[4], taxonomy: item[5] }), - block: item[6], - attribute: item[7], - }; - - if (!timer) { - timer = setTimeout(function () { flush(thunk.dispatch); }, BATCH_DELAY); - } - }; - }, - }, - }); - - wp.data.register(store); - - /** The members that have a value. */ - function pick(object) { - var result = {}; - - Object.keys(object).forEach(function (name) { - if (object[name] !== null && object[name] !== undefined) { - result[name] = object[name]; - } - }); - - return result; - } - - function sameArgs(a, b) { - return JSON.stringify(a || {}) === JSON.stringify(b || {}); - } - - /** The fields a source offers in this context. */ - function fieldsFor(source, context) { - if (source.kind === 'meta') { - var subtype = source.subtype ? context[source.subtype] : null; - - return (source.fields[''] || []).concat(subtype ? source.fields[subtype] || [] : []); - } - - if (source.postTypes.length && (!context.postType || source.postTypes.indexOf(context.postType) === -1)) { - return []; - } - - return source.fields; - } - - function labelOf(source, args, context) { - var field = fieldsFor(source, context).filter(function (item) { return sameArgs(item.args, args); })[0]; - - return field ? field.label : null; - } - - Object.keys(config.sources).forEach(function (name) { - var source = config.sources[name]; - - wp.blocks.registerBlockBindingsSource({ - name: name, - - getFieldsList: function (options) { - return fieldsFor(source, options.context || {}); - }, - - getValues: function (options) { - var context = options.context || {}; - var values = {}; - var blockEditorStore = options.select(wp.blockEditor.store); - var attributes = options.clientId ? blockEditorStore.getBlockAttributes(options.clientId) || {} : {}; - var blockName = options.clientId ? blockEditorStore.getBlockName(options.clientId) : null; - - Object.keys(options.bindings).forEach(function (attribute) { - var args = options.bindings[attribute].args; - var value = options.select(STORE).getValue(keyOf(name, args, context, blockName, attribute)); - - if (value === undefined || value === null || value === '') { - // Loading, or no value: what the block holds, else the field's label - value = attributes[attribute] || labelOf(source, args, context) || ''; - } - - values[attribute] = typeof value === 'number' || typeof value === 'string' ? value : String(value); - }); - - return values; - }, - }); - }); -})(window.wp, window); diff --git a/src/Asset/Application/Services/AssetManager.php b/src/Asset/Application/Services/AssetManager.php deleted file mode 100755 index ffad0c2a..00000000 --- a/src/Asset/Application/Services/AssetManager.php +++ /dev/null @@ -1,137 +0,0 @@ - - */ - protected array $containers = []; - - /** - * The default asset container name. - */ - protected ?string $defaultContainer = null; - - /** - * Initializes the asset manager with the retrieval service. - * - * @param AssetRetrievalService $retrievalService Service for retrieving assets - */ - public function __construct(private readonly AssetRetrievalService $retrievalService) {} - - /** - * Factory method: returns an AssetEnqueuer (builder) for fluent asset management and enqueueing. - * - * @param string $handle Asset handle - * @param string $file Asset file path - * @return AssetEnqueuer Asset enqueuer instance - */ - public function add(string $handle, string $file): AssetEnqueuer - { - return resolve(AssetEnqueuer::class) - ->handle($handle) - ->path($file); - } - - /** - * Retrieves an asset by name. - * - * @param string $name Asset name - * @return Asset|null Asset instance or null if not found - */ - public function get(string $name): ?Asset - { - return $this->retrievalService->get($name); - } - - /** - * Retrieves all registered assets. - * - * @return array List of asset instances - */ - public function all(): array - { - return $this->retrievalService->all(); - } - - /** - * Adds a new asset container. - * - * @param string $name Name of the container - * @param array $config Configuration for the container - */ - public function addContainer(string $name, array $config): void - { - $this->containers[$name] = new AssetContainer($name, $config); - } - - /** - * Retrieves an asset container by name. - * - * @param string $name Name of the container - * @return AssetContainer|null The asset container instance or null if not found - */ - public function getContainer(string $name): ?AssetContainer - { - return $this->containers[$name] ?? null; - } - - /** - * Every asset container, keyed by name: the theme's, each plugin's and - * module's, for tools that list where a page's assets come from. - * - * @return array - */ - public function containers(): array - { - return $this->containers; - } - - /** - * Sets the default asset container. - * - * @param string $name Name of the container to set as default - */ - public function setDefaultContainer(string $name): void - { - $this->defaultContainer = $name; - } - - /** - * Gets the default asset container instance. - * - * @return AssetContainer|null The default asset container instance or null if not set - */ - public function getDefaultContainer(): ?AssetContainer - { - return in_array($this->defaultContainer, [null, '', '0'], true) ? null : $this->getContainer($this->defaultContainer); - } - - /** - * Infrastructure factory for asset file URL resolution (Vite, containers, etc.). - * - * @param string $file Asset file path - * @return AssetFile Asset file instance for URL resolution - */ - public function url(string $file): AssetFile - { - return new AssetFile($file); - } -} diff --git a/src/Asset/Application/Services/AssetRegistrationService.php b/src/Asset/Application/Services/AssetRegistrationService.php deleted file mode 100755 index 71a8d8f8..00000000 --- a/src/Asset/Application/Services/AssetRegistrationService.php +++ /dev/null @@ -1,37 +0,0 @@ -manager->registerAsset($asset); - } -} diff --git a/src/Asset/Application/Services/AssetRetrievalService.php b/src/Asset/Application/Services/AssetRetrievalService.php deleted file mode 100755 index 1e72aecf..00000000 --- a/src/Asset/Application/Services/AssetRetrievalService.php +++ /dev/null @@ -1,45 +0,0 @@ -manager->getAsset($name); - } - - /** - * Retrieves all registered assets. - * - * @return array List of all asset instances - */ - public function all(): array - { - return $this->manager->getAllAssets(); - } -} diff --git a/src/Asset/Application/Services/MissingBuilds.php b/src/Asset/Application/Services/MissingBuilds.php deleted file mode 100644 index 69705794..00000000 --- a/src/Asset/Application/Services/MissingBuilds.php +++ /dev/null @@ -1,48 +0,0 @@ - - */ - private array $missing = []; - - public function record(string $container, string $manifest): void - { - if (isset($this->missing[$container])) { - return; - } - - $this->missing[$container] = $manifest; - - Log::warning(sprintf('Pollora: the "%s" assets are not built, no Vite manifest at %s. Its scripts and styles are left out until you run npm run build.', $container, $manifest), [ - 'container' => $container, - 'manifest' => $manifest, - ]); - } - - /** - * @return array Manifest path by container name - */ - public function all(): array - { - return $this->missing; - } -} diff --git a/src/Asset/Domain/Contracts/AssetFileInterface.php b/src/Asset/Domain/Contracts/AssetFileInterface.php deleted file mode 100755 index 6755eb23..00000000 --- a/src/Asset/Domain/Contracts/AssetFileInterface.php +++ /dev/null @@ -1,34 +0,0 @@ -name; - } - - /** - * Gets the file path or URL to the asset. - */ - public function getPath(): string - { - return $this->path; - } - - /** - * Gets the asset's additional attributes. - */ - public function getAttributes(): array - { - return $this->attributes; - } -} diff --git a/src/Asset/Domain/Models/AssetFile.php b/src/Asset/Domain/Models/AssetFile.php deleted file mode 100755 index 57c2ca5f..00000000 --- a/src/Asset/Domain/Models/AssetFile.php +++ /dev/null @@ -1,76 +0,0 @@ -filename; - } - - /** - * Gets the asset container identifier. - * - * @return string The container identifier - */ - public function getAssetContainer(): string - { - return $this->assetContainer; - } - - /** - * Sets the asset container to use. - * - * @param string $container The container identifier - */ - public function from(string $container): static - { - $this->assetContainer = $container; - - return $this; - } - - /** - * Converts the asset file to its string representation (filename). - * - * @return string The file name or path - */ - public function __toString(): string - { - return $this->filename; - } -} diff --git a/src/Asset/Domain/Models/ViteManager.php b/src/Asset/Domain/Models/ViteManager.php deleted file mode 100755 index 312852c9..00000000 --- a/src/Asset/Domain/Models/ViteManager.php +++ /dev/null @@ -1,64 +0,0 @@ -repository->save($asset); - } - - /** - * Retrieves a single asset by name. - * - * @param string $name Asset name/handle - * @return Asset|null The asset instance, or null if not found - */ - public function getAsset(string $name): ?Asset - { - return $this->repository->findByName($name); - } - - /** - * Retrieves all registered assets. - * - * @return array List of all asset instances - */ - public function getAllAssets(): array - { - return $this->repository->all(); - } -} diff --git a/src/Asset/Infrastructure/Laravel/LaravelAssetFileResolver.php b/src/Asset/Infrastructure/Laravel/LaravelAssetFileResolver.php new file mode 100644 index 00000000..c021b219 --- /dev/null +++ b/src/Asset/Infrastructure/Laravel/LaravelAssetFileResolver.php @@ -0,0 +1,41 @@ +app->make(AssetManager::class)->getContainer($container); + + if (! $assetContainer instanceof AssetContainer) { + return ''; + } + + return $this->app->make(ViteManagerFactory::class)->make($assetContainer)->asset($file); + } catch (\Throwable $throwable) { + $this->app->make(LoggerInterface::class)->error('Error in AssetFile::__toString', [ + 'error' => $throwable->getMessage(), + 'path' => $file, + ]); + + return ''; + } + } +} diff --git a/src/Asset/Infrastructure/Laravel/LaravelPublicPath.php b/src/Asset/Infrastructure/Laravel/LaravelPublicPath.php new file mode 100644 index 00000000..e0c6cfb1 --- /dev/null +++ b/src/Asset/Infrastructure/Laravel/LaravelPublicPath.php @@ -0,0 +1,18 @@ +console->isConsole() || $this->console->isWpCli(); + } +} diff --git a/src/Asset/Infrastructure/Laravel/LaravelViteManagerFactory.php b/src/Asset/Infrastructure/Laravel/LaravelViteManagerFactory.php new file mode 100644 index 00000000..54d0fbdb --- /dev/null +++ b/src/Asset/Infrastructure/Laravel/LaravelViteManagerFactory.php @@ -0,0 +1,21 @@ +app->bind(AssetRepositoryInterface::class, InMemoryAssetRepository::class); + + // What the assets need from the environment: here, Laravel + $this->app->singleton(PublicPath::class, LaravelPublicPath::class); + $this->app->singleton(ViteManagerFactory::class, LaravelViteManagerFactory::class); + $this->app->singleton(RuntimeContext::class, fn (Application $app): RuntimeContext => new LaravelRuntimeContext($app->make(ConsoleDetectionService::class))); + $this->app->singleton(AssetManager::class, fn (Application $app): AssetManager => new AssetManager( - $app->make(AssetRetrievalService::class) + $app->make(AssetRetrievalService::class), + $app->make(PublicPath::class), + static fn (): AssetEnqueuer => $app->make(AssetEnqueuer::class), + )); + // Hooks come from pollora/hook's provider, absent from some test applications + $this->app->bind(AssetEnqueuer::class, fn (Application $app): AssetEnqueuer => new AssetEnqueuer( + $app->make(AssetManager::class), + $app->bound(Action::class) ? $app->make(Action::class) : null, + $app->bound(Filter::class) ? $app->make(Filter::class) : null, + $app->make(ViteManagerFactory::class), + $app->make(RuntimeContext::class), + $app->make(LoggerInterface::class), )); - $this->app->bind(AssetEnqueuer::class, fn (Application $app): AssetEnqueuer => new AssetEnqueuer($app)); + AssetFile::resolveUsing(Closure::fromCallable(new LaravelAssetFileResolver($this->app))); + $this->app->bind(MissingBuildNotice::class, fn (Application $app): MissingBuildNotice => new MissingBuildNotice($app->make(MissingBuilds::class), base_path(), 'php artisan pollora:doctor')); $this->app->singleton(MissingBuilds::class); $this->app->singleton(RootAssetManager::class, fn (Application $app): RootAssetManager => new RootAssetManager( $app->make(AssetManager::class) diff --git a/src/Asset/Infrastructure/Repositories/AssetContainer.php b/src/Asset/Infrastructure/Repositories/AssetContainer.php deleted file mode 100755 index 8158b02a..00000000 --- a/src/Asset/Infrastructure/Repositories/AssetContainer.php +++ /dev/null @@ -1,102 +0,0 @@ -hotFile = $config['hot_file'] ?? public_path($this->name.'.hot'); - $this->buildDirectory = $config['build_directory'] ?? 'build/'.$this->name; - $this->manifestPath = $config['manifest_path'] ?? 'manifest.json'; - $this->basePath = $config['base_path'] ?? ''; - } - - /** - * Gets the base path for assets in this container. - */ - public function getBasePath(): string - { - return $this->basePath; - } - - /** - * Gets the unique name of this asset container. - */ - public function getName(): string - { - return $this->name; - } - - /** - * Gets the path to the hot reload file used by Vite. - */ - public function getHotFile(): string - { - return $this->hotFile; - } - - /** - * Gets the build directory for assets. - */ - public function getBuildDirectory(): string - { - return $this->buildDirectory; - } - - /** - * Gets the path to the Vite manifest file. - */ - public function getManifestPath(): string - { - return $this->manifestPath; - } - - /** - * Gets the asset directory array. - */ - public function getAssetDir(): array - { - return $this->assetDir; - } -} diff --git a/src/Asset/Infrastructure/Repositories/InMemoryAssetRepository.php b/src/Asset/Infrastructure/Repositories/InMemoryAssetRepository.php deleted file mode 100755 index f29c9dce..00000000 --- a/src/Asset/Infrastructure/Repositories/InMemoryAssetRepository.php +++ /dev/null @@ -1,56 +0,0 @@ - - */ - private array $assets = []; - - /** - * Finds an asset by its name/handle. - * - * @param string $name The asset name/handle - * @return Asset|null The asset instance, or null if not found - */ - public function findByName(string $name): ?Asset - { - return $this->assets[$name] ?? null; - } - - /** - * Saves an asset instance to the repository. - * - * @param Asset $asset The asset instance to save - */ - public function save(Asset $asset): void - { - $this->assets[$asset->getName()] = $asset; - } - - /** - * Retrieves all assets from the repository. - * - * @return array List of all asset instances - */ - public function all(): array - { - return array_values($this->assets); - } -} diff --git a/src/Asset/Infrastructure/Services/AssetEnqueuer.php b/src/Asset/Infrastructure/Services/AssetEnqueuer.php deleted file mode 100755 index f6ad6ab9..00000000 --- a/src/Asset/Infrastructure/Services/AssetEnqueuer.php +++ /dev/null @@ -1,613 +0,0 @@ - - */ - protected string|array $path; - - /** - * The type of asset ('css' or 'js'). - */ - protected string $type; - - /** - * Array of asset handles that this asset depends on. - * - * @var array - */ - protected array $dependencies = []; - - /** - * The asset container instance. - */ - protected ?AssetContainer $container = null; - - /** - * The Vite manager instance. - */ - protected ?ViteManager $viteManager = null; - - /** - * Whether to use Vite.js for asset handling. - */ - protected bool $useVite = false; - - /** - * The version string for cache busting. - */ - protected ?string $version = null; - - /** - * The media query for stylesheet (e.g., 'all', 'print', 'screen'). - */ - protected string $media = 'all'; - - /** - * Whether to load the script in the footer. - */ - protected bool $loadInFooter = false; - - /** - * The loading strategy for scripts (e.g., 'defer', 'async'). - */ - protected ?string $loadStrategy = null; - - /** - * The content to be added inline with the asset. - */ - protected ?string $inlineContent = null; - - /** - * The position for inline content ('before' or 'after'). - */ - protected ?string $inlinePosition = null; - - /** - * Array of WordPress hooks where the asset should be enqueued. - * - * @var array - */ - protected array $hooks = []; - - /** - * The asset handle (unique identifier). - */ - protected string $handle; - - /** - * Stored localization data to apply after enqueuing. - * - * @var array - */ - protected array $localizationData = []; - - /** - * AssetManager instance for container resolution. - */ - protected AssetManager $assetManager; - - /** - * Console detection service instance. - */ - protected ConsoleDetectionService $consoleDetectionService; - - /** - * Constructor with dependency injection. - */ - public function __construct(Application $app) - { - $this->assetManager = $app->make(AssetManager::class); - $this->consoleDetectionService = $app->make(ConsoleDetectionService::class); - } - - /** - * Sets the asset handle (unique identifier). - * - * @param string $handle Unique identifier for the asset\n */ - public function handle(string $handle): self - { - $this->handle = $handle; - - return $this; - } - - /** - * Sets the asset path and determines the type. - * - * @param string $path Path to the asset file\n */ - public function path(string $path): self - { - $this->path = $path; - $this->type = $this->determineFileType($path); - - return $this; - } - - /** - * Sets the asset container by name. - * - * @param string $containerName Name of the container - * - * @throws \RuntimeException If the container is not found - */ - public function container(string $containerName): self - { - if ($this->consoleDetectionService->isConsole() || $this->consoleDetectionService->isWpCli()) { - return $this; - } - - $container = $this->assetManager->getContainer($containerName); - - if (! $container instanceof AssetContainer) { - throw new \RuntimeException(sprintf("Asset container '%s' not found. Make sure you have added it via AssetManager::addContainer().", $containerName)); - } - - $this->container = $container; - - return $this; - } - - /** - * Sets asset dependencies. - * - * @param array $dependencies Array of dependency handles\n */ - public function dependencies(array $dependencies): self - { - $this->dependencies = $dependencies; - - return $this; - } - - /** - * Enables Vite.js integration for this asset.\n * - * - * @throws \RuntimeException If no asset container is defined - */ - public function useVite(): self - { - if ($this->consoleDetectionService->isConsole() || $this->consoleDetectionService->isWpCli()) { - return $this; - } - - if (! $this->container instanceof AssetContainer) { - throw new \RuntimeException("No asset container defined before useVite(). Use ->container('theme') before ->useVite()."); - } - - $this->useVite = true; - $this->viteManager = new ViteManager($this->container); - - return $this; - } - - /** - * Sets the asset version for cache busting. - * - * @param string $version Version string\n */ - public function version(string $version): self - { - $this->version = $version; - - return $this; - } - - /** - * Sets the media type for stylesheets. - * - * @param string $media Media query string\n */ - public function media(string $media): self - { - $this->media = $media; - - return $this; - } - - /** - * Sets script to load in the footer.\n */ - public function loadInFooter(): self - { - $this->loadInFooter = true; - - return $this; - } - - /** - * Sets the loading strategy for scripts. - * - * @param string $strategy Loading strategy (e.g., 'defer', 'async')\n */ - public function loadStrategy(string $strategy): self - { - $this->loadStrategy = $strategy; - - return $this; - } - - /** - * Sets the asset type manually. - * - * @param string $type The asset type ('css' or 'js')\n */ - public function setType(string $type): self - { - $this->type = $type; - - return $this; - } - - /** - * Enqueues the asset in WordPress frontend (wp_enqueue_scripts).\n */ - public function toFrontend(): self - { - return $this->addHook('wp_enqueue_scripts'); - } - - /** - * Enqueues the asset in WordPress admin area (admin_enqueue_scripts).\n */ - public function toBackend(): self - { - return $this->addHook('admin_enqueue_scripts'); - } - - /** - * Enqueues the asset on the login screen (login_enqueue_scripts).\n */ - public function toLoginScreen(): self - { - return $this->addHook('login_enqueue_scripts'); - } - - /** - * Enqueues the asset in the customizer preview (customize_preview_init).\n */ - public function toCustomizer(): self - { - return $this->addHook('customize_preview_init'); - } - - /** - * Enqueues the asset in the block editor (enqueue_block_editor_assets).\n */ - public function toEditor(): self - { - return $this->addHook('enqueue_block_editor_assets'); - } - - /** - * Localizes a JavaScript file with data. - * - * @param string $objectName JavaScript object name - * @param array $data Data to localize\n */ - public function localize(string $objectName, array $data): self - { - if ($this->type === 'js') { - $this->localizationData[$objectName] = $data; - } - - return $this; - } - - /** - * Adds inline content to the asset. - * - * @param string $content Inline CSS/JS content - * @param string $position Position ('before' or 'after')\n */ - public function inline(string $content, string $position = 'after'): self - { - $this->inlineContent = $content; - $this->inlinePosition = $position; - - return $this; - } - - /** - * Handles the actual enqueuing of the asset. - * Called automatically when the object is destroyed. - */ - public function __destruct() - { - try { - $this->hooks = $this->hooks !== [] ? $this->hooks : ['wp_enqueue_scripts']; - - if ($this->useVite) { - $this->configureViteAssets(); - } - - foreach ($this->hooks as $hook) { - if ($this->needToLoadViteClient()) { - $this->loadViteClient($hook); - } - - resolve(HookAction::class)->add($hook, fn () => $this->enqueueStyleOrScript($hook), 99); - } - } catch (\Throwable $throwable) { - Log::error('Error in AssetEnqueuer::__destruct', ['error' => $throwable->getMessage(), 'hooks' => $this->hooks, 'path' => $this->path ?? null]); - } - } - - /** - * Enqueues all styles and scripts for the current asset. - */ - public function enqueueStyleOrScript(?string $hook = null): void - { - $paths = $this->getAssetPaths(); - foreach ($paths as $type => $pathList) { - foreach ($pathList as $path) { - $this->enqueueAsset((string) $type, $this->forceFullUrl($path), $hook); - } - } - } - - /** - * Adds a WordPress hook for asset enqueuing. - * - * @param string $hook WordPress hook name\n */ - protected function addHook(string $hook): self - { - $this->hooks[] = $hook; - - return $this; - } - - /** - * Loads the Vite client script when in development mode. - * - * @param string $hook WordPress hook to attach the client script - */ - protected function loadViteClient(string $hook): void - { - if ($this->printsScriptModules($hook)) { - // The client is a module too: printed ahead of the import map, it would void it. - resolve(HookAction::class)->add($hook, function (): void { - $url = $this->viteManager instanceof ViteManager ? $this->viteManager->clientUrl() : ''; - - if ($url !== '') { - // No version: the modules Vite serves import /@vite/client by its bare URL, - // and a ?ver= copy would be a second client, with its own HMR connection. - wp_enqueue_script_module('vite-client/'.md5($url), $url, [], null); - } - }, 1); - - return; - } - - resolve(HookAction::class)->add($hook, function (): void { - if ($this->viteManager instanceof ViteManager && $this->viteManager->isRunningHot()) { - echo $this->viteManager->getViteClientHtml(); - } - }, 1); - } - - /** - * Configures asset paths for Vite integration. - * - * @throws AssetException When Vite manager is not initialized - */ - public function configureViteAssets(): void - { - if (! $this->viteManager instanceof ViteManager) { - throw new AssetException('Vite manager not initialized. Call useVite() first.'); - } - - $asset = $this->viteManager->isRunningHot() - ? $this->viteManager->asset($this->path) - : $this->viteManager->getAssetUrls([$this->path]); - $this->path = $asset; - } - - /** - * Determines if the Vite client needs to be loaded. - * - * @return bool True if Vite client should be loaded - */ - protected function needToLoadViteClient(): bool - { - return $this->useVite && $this->viteManager instanceof ViteManager && $this->viteManager->isRunningHot(); - } - - /** - * Gets the processed asset paths based on Vite configuration. - * - * @return array Array of asset paths grouped by type - */ - protected function getAssetPaths(): array - { - if ($this->useVite && ! $this->viteManager->isRunningHot()) { - - return $this->path; - } - - return [$this->type => [$this->path]]; - } - - /** - * Enqueues an individual asset based on its type. - * - * @param string $type Asset type (css or js) - * @param string $path Asset path - * - * @throws \InvalidArgumentException When asset type is not supported - */ - protected function enqueueAsset(string $type, string $path, ?string $hook = null): void - { - $handle = $this->useVite && ! $this->viteManager->isRunningHot() ? $this->handle.'/'.sanitize_title(basename($path)) : $this->handle; - match ($type) { - 'css' => $this->enqueueStyle($path, $handle), - 'js' => $this->useVite && $this->printsScriptModules($hook) - ? $this->enqueueScriptModule($path, $handle) - : $this->enqueueScript($path, $handle), - default => throw new \InvalidArgumentException('Unsupported asset type: '.$type) - }; - } - - /** - * Enqueues a JavaScript file with WordPress. - * - * @param string $path Path to the JavaScript file - */ - protected function enqueueScript(string $path, string $handle): void - { - wp_enqueue_script($handle, $path, $this->dependencies, $this->version, $this->loadInFooter); - - foreach ($this->localizationData as $objectName => $data) { - wp_localize_script($handle, $objectName, $data); - } - - if ($this->useVite) { - $this->addViteScriptAttributes($handle); - } - - if (! in_array($this->loadStrategy, [null, '', '0'], true)) { - wp_script_add_data($handle, 'defer', true); - } - - if (! in_array($this->inlineContent, [null, '', '0'], true)) { - wp_add_inline_script($handle, $this->inlineContent, $this->inlinePosition); - } - } - - /** - * Whether a Vite entry enqueued on this hook is printed by WordPress as a script module. - */ - protected function printsScriptModules(?string $hook): bool - { - return in_array($hook, self::SCRIPT_MODULE_HOOKS, true) && function_exists('wp_enqueue_script_module'); - } - - /** - * Enqueues a Vite entry as a WordPress script module. - * - * A module can only depend on modules, and takes no localized data or inline - * script. What the asset declares of those goes on a classic companion script, - * `{handle}-data`, printed in the head: it runs before the module, which the - * browser defers. - */ - protected function enqueueScriptModule(string $path, string $handle): void - { - // null, not false: false appends WordPress's version, and a module is identified by its - // exact URL — a chunk importing the entry back would load a second copy of it. - wp_enqueue_script_module($handle, $path, [], $this->version, ['in_footer' => $this->loadInFooter]); - - resolve(HookFilter::class)->add('wp_script_attributes', fn (array $attributes): array => ($attributes['id'] ?? null) === $handle.'-js-module' - ? [...$attributes, 'crossorigin' => true] - : $attributes); - - $hasInlineContent = ! in_array($this->inlineContent, [null, '', '0'], true); - - if ($this->dependencies === [] && $this->localizationData === [] && ! $hasInlineContent) { - return; - } - - $companion = $handle.'-data'; - // No source: a registered handle that prints only its dependencies and inline data. - wp_register_script($companion, false, $this->dependencies, $this->version, false); - wp_enqueue_script($companion); - - foreach ($this->localizationData as $objectName => $data) { - wp_localize_script($companion, $objectName, $data); - } - - if ($hasInlineContent) { - wp_add_inline_script($companion, $this->inlineContent, $this->inlinePosition); - } - } - - /** - * Enqueues a CSS file with WordPress. - * - * @param string $path Path to the CSS file - * @param string $handle Unique identifier for the stylesheet - */ - protected function enqueueStyle(string $path, string $handle): void - { - wp_enqueue_style($handle, $path, $this->dependencies, $this->version, $this->media); - if (! in_array($this->inlineContent, [null, '', '0'], true)) { - wp_add_inline_style($handle, $this->inlineContent); - } - } - - /** - * Adds module and crossorigin attributes to Vite script tags. - */ - protected function addViteScriptAttributes(string $handle): void - { - resolve(HookFilter::class)->add('script_loader_tag', fn ($tag, $scriptHandle, $src) => $scriptHandle === $handle - ? '' - : $tag, 10, 3); - } - - /** - * Ensures a full URL is used for the asset path. - * - * @param string $path Asset path - * @return string Full URL to the asset - */ - protected function forceFullUrl(string $path): string - { - if (str_contains($path, '://')) { - return $path; - } - - $basePath = $this->container instanceof AssetContainer ? $this->container->getBasePath() : ''; - $fullPath = $basePath.'/'.ltrim($path, '/'); - - return home_url($fullPath); - } - - /** - * Determines the file type from the path extension. - * - * @param string $path File path - * @return string File type (css or js) - * - * @throws \InvalidArgumentException When file type is not supported - */ - protected function determineFileType(string $path): string - { - $type = pathinfo($path, PATHINFO_EXTENSION); - - // JSX/TSX/TS are JavaScript variants (transpiled by Vite) - if (in_array($type, ['jsx', 'tsx', 'ts'], true)) { - return 'js'; - } - - if (! in_array($type, ['css', 'js'], true)) { - throw new \InvalidArgumentException('Unsupported file type: '.$type); - } - - return $type; - } -} diff --git a/src/Asset/Infrastructure/Services/AssetFile.php b/src/Asset/Infrastructure/Services/AssetFile.php deleted file mode 100755 index 59408ad0..00000000 --- a/src/Asset/Infrastructure/Services/AssetFile.php +++ /dev/null @@ -1,48 +0,0 @@ -getContainer($this->assetContainer); - - if ($assetContainer === null) { - return ''; - } - - return (new ViteManager($assetContainer))->asset($this->filename); - } catch (\Throwable $throwable) { - Log::error('Error in AssetFile::__toString', [ - 'error' => $throwable->getMessage(), - 'path' => $this->filename, - ]); - - return ''; - } - } -} diff --git a/src/Asset/UI/Http/MissingBuildNotice.php b/src/Asset/UI/Http/MissingBuildNotice.php deleted file mode 100644 index 838b8237..00000000 --- a/src/Asset/UI/Http/MissingBuildNotice.php +++ /dev/null @@ -1,65 +0,0 @@ -missingBuilds->all(); - - if ($missing === [] || ! current_user_can('manage_options')) { - return; - } - - $ddev = getenv('IS_DDEV_PROJECT') === 'true'; - $build = $ddev ? 'ddev npm install && ddev npm run build' : 'npm install && npm run build'; - $doctor = ($ddev ? 'ddev exec ' : '').'php artisan pollora:doctor'; - - // A theme's blocks share its build: one line per manifest - $containersByManifest = []; - foreach ($missing as $container => $manifest) { - $containersByManifest[$manifest][] = $container; - } - - $items = ''; - foreach ($containersByManifest as $manifest => $containers) { - $items .= sprintf( - '
  • %s — %s %s
  • ', - esc_html(implode(', ', $containers)), - esc_html__('no manifest at', 'pollora'), - esc_html($this->relative($manifest)) - ); - } - - printf( - '

    %s

    %s

      %s

    %s
    %s

    %s
    %s

    ', - esc_html__('Pollora: assets not built', 'pollora'), - esc_html__('These Vite builds are missing, so their scripts and styles are left out: the site renders unstyled and blocks have no editor scripts.', 'pollora'), - $items, - esc_html__('Build them from the folder of the theme, plugin or module:', 'pollora'), - esc_html($build), - esc_html__('Not sure which folder? The builds check names it:', 'pollora'), - esc_html($doctor) - ); - } - - private function relative(string $path): string - { - $base = rtrim(base_path(), '/').'/'; - - return str_starts_with($path, $base) ? substr($path, strlen($base)) : $path; - } -} diff --git a/src/BlockBinding/Application/Services/BindingReferenceInspector.php b/src/BlockBinding/Application/Services/BindingReferenceInspector.php deleted file mode 100644 index 4ed28747..00000000 --- a/src/BlockBinding/Application/Services/BindingReferenceInspector.php +++ /dev/null @@ -1,130 +0,0 @@ - $options The options `pollora/option` may read - */ - public function __construct( - private BindingSourceRegistry $sources, - private MetaSchemaRepository $schemas, - private array $options = [], - ) {} - - /** - * Whether the source is one of Pollora's, which the inspector can check. - */ - public function knows(string $sourceName): bool - { - return $this->sources->find($sourceName) instanceof BindingSource; - } - - /** - * Why the binding shows nothing, or null when it can show its value. - * - * @param array $args - */ - public function problem(string $sourceName, array $args): ?string - { - $source = $this->sources->find($sourceName); - - if (! $source instanceof BindingSource) { - return null; - } - - return match ($sourceName) { - 'pollora/post-meta' => $this->metaProblem(MetaObjectType::Post, $args, static fn (MetaDefinition $definition): bool => $definition->showInRest && ! $definition->isProtected(), 'it is not exposed in REST (showInRest: true) or its key is protected'), - 'pollora/term-meta' => $this->metaProblem(MetaObjectType::Term, $args, static fn (MetaDefinition $definition): bool => $definition->showInRest && ! $definition->isProtected(), 'it is not exposed in REST (showInRest: true) or its key is protected'), - 'pollora/author-meta' => $this->metaProblem(MetaObjectType::User, $args, static fn (MetaDefinition $definition): bool => $definition->public, 'it is not marked #[Meta(public: true)]'), - 'pollora/option' => $this->optionProblem($args), - default => $this->fieldProblem($source, $args), - }; - } - - /** - * @param array $args - */ - private function fieldProblem(BindingSource $source, array $args): ?string - { - if ($source->isInvokable()) { - return null; - } - - $field = $args['field'] ?? null; - - if (! is_string($field) || $field === '') { - return sprintf('no "field" argument; %s has %s', $source->name, $this->fieldList($source)); - } - - return $source->field($field) instanceof BindingFieldDefinition - ? null - : sprintf('the field "%s" does not exist; %s has %s', $field, $source->name, $this->fieldList($source)); - } - - /** - * @param array $args - * @param \Closure(MetaDefinition): bool $readable - */ - private function metaProblem(MetaObjectType $objectType, array $args, \Closure $readable, string $unreadable): ?string - { - $key = $args['key'] ?? null; - - if (! is_string($key) || $key === '') { - return 'no "key" argument'; - } - - $found = false; - - foreach ($this->schemas->all() as $schema) { - if ($schema->objectType !== $objectType || ! ($definition = $schema->find($key)) instanceof MetaDefinition) { - continue; - } - - if ($readable($definition)) { - return null; - } - - $found = true; - } - - return $found - ? sprintf('the meta "%s" is never shown: %s', $key, $unreadable) - : sprintf('no #[Meta] declares the %s meta "%s"', $objectType->value, $key); - } - - /** - * @param array $args - */ - private function optionProblem(array $args): ?string - { - $name = $args['name'] ?? null; - - if (! is_string($name) || $name === '') { - return 'no "name" argument'; - } - - return in_array($name, $this->options, true) ? null : sprintf('the option "%s" is not listed in block-bindings.options', $name); - } - - private function fieldList(BindingSource $source): string - { - return 'the fields '.implode(', ', array_map(static fn (string $name): string => '"'.$name.'"', array_keys($source->fields))); - } -} diff --git a/src/BlockBinding/Application/Services/BindingResolver.php b/src/BlockBinding/Application/Services/BindingResolver.php deleted file mode 100644 index 021ef28a..00000000 --- a/src/BlockBinding/Application/Services/BindingResolver.php +++ /dev/null @@ -1,280 +0,0 @@ - Values already resolved in this request - */ - private array $resolved = []; - - /** - * @var array Source instances, made on first use - */ - private array $instances = []; - - public function __construct( - private readonly Container $container, - private readonly ContentVisibilityInterface $visibility, - private readonly ValuePresenterInterface $presenter, - private readonly ?MetaAccessor $metaAccessor = null, - private readonly bool $debug = false, - private readonly ?LoggerInterface $logger = null, - ) {} - - /** - * The value of a bound attribute, or null to keep what the block holds. - * - * @param array $args Arguments of the binding - */ - public function resolve(BindingSource $source, array $args, ?\WP_Block $block, string $attribute): mixed - { - $attributeSource = $block?->block_type?->attributes[$attribute]['source'] ?? null; - - return $this->resolveIn($source, $args, $block instanceof \WP_Block ? $block->context : [], $block, $attribute, is_string($attributeSource) ? $attributeSource : null); - } - - /** - * The value the editor previews for a bound attribute: the same checks, the - * same field and the same escaping, from the block context the editor sends. - * - * @param array $args Arguments of the binding - * @param array $blockContext `postId`, `postType`, `termId`, `taxonomy` - * @param string|null $attributeSource The `source` of the attribute in the block type (`rich-text`, `attribute`…) - */ - public function preview(BindingSource $source, array $args, array $blockContext, string $attribute, ?string $attributeSource = null): mixed - { - $value = $this->resolveIn($source, $args, $blockContext, null, $attribute, $attributeSource); - - return is_bool($value) ? $this->presenter->boolean($value) : $value; - } - - /** - * @param array $args - * @param array $blockContext - */ - private function resolveIn(BindingSource $source, array $args, array $blockContext, ?\WP_Block $block, string $attribute, ?string $attributeSource = null): mixed - { - $context = $this->context($args, $blockContext, $block, $attribute); - - if (! $this->isVisible($context) || ! $source->answersFor($context->postType)) { - return null; - } - - $field = $source->isInvokable() - ? new BindingFieldDefinition('', '', BindingFieldType::Text, '__invoke') - : $source->field(is_string($args['field'] ?? null) ? $args['field'] : ''); - - if (! $field instanceof BindingFieldDefinition) { - return null; - } - - $key = $this->cacheKey($source, $context, $attributeSource); - - if (array_key_exists($key, $this->resolved)) { - $this->announce($source, $field, $context, 0.0, true, $this->resolved[$key] !== null); - - return $this->resolved[$key]; - } - - $start = hrtime(true); - $value = $this->call($source, $field, $context); - $milliseconds = (hrtime(true) - $start) / 1_000_000; - - $this->resolved[$key] = $this->present($value, $field->type, $attributeSource); - $this->announce($source, $field, $context, $milliseconds, false, $this->resolved[$key] !== null); - - return $this->resolved[$key]; - } - - /** - * Tell debugging tools what was resolved, when one is listening. - */ - private function announce(BindingSource $source, BindingFieldDefinition $field, BindingContext $context, float $milliseconds, bool $cached, bool $hasValue): void - { - if (! $this->container->bound('events')) { - return; - } - - $events = $this->container->make('events'); - - if (! $events->hasListeners(BindingResolved::class)) { - return; - } - - $events->dispatch(new BindingResolved( - source: $source->name, - field: $field->name, - attribute: $context->attribute, - postId: $context->postId, - milliseconds: round($milliseconds, 3), - cached: $cached, - hasValue: $hasValue, - )); - } - - /** - * @param array $args - * @param array $blockContext - */ - private function context(array $args, array $blockContext, ?\WP_Block $block, string $attribute): BindingContext - { - return new BindingContext( - args: $args, - attribute: $attribute, - postId: $this->positiveInt($blockContext['postId'] ?? null), - postType: is_string($blockContext['postType'] ?? null) ? $blockContext['postType'] : null, - termId: $this->positiveInt($blockContext['termId'] ?? null), - taxonomy: is_string($blockContext['taxonomy'] ?? null) ? $blockContext['taxonomy'] : null, - block: $block, - metaAccessor: $this->metaAccessor, - ); - } - - private function isVisible(BindingContext $context): bool - { - if ($context->postId !== null && ! $this->visibility->canShowPost($context->postId)) { - return false; - } - - return $context->termId === null || $context->taxonomy === null || $this->visibility->canShowTerm($context->termId, $context->taxonomy); - } - - private function call(BindingSource $source, BindingFieldDefinition $field, BindingContext $context): mixed - { - try { - $instance = $this->instances[$source->class] ??= $this->container->make($source->class); - - if (! $this->debug) { - return $this->container->call([$instance, $field->method], [BindingContext::class => $context, 'context' => $context]); - } - - $start = hrtime(true); - $value = $this->container->call([$instance, $field->method], [BindingContext::class => $context, 'context' => $context]); - $this->reportSlow($source, $field, $context, (hrtime(true) - $start) / 1_000_000); - - return $value; - } catch (Throwable $throwable) { - if ($this->debug) { - throw $throwable; - } - - $this->logger?->error(sprintf( - 'The block binding "%s" failed on %s: %s', - $source->name, - $context->postId === null ? 'no post' : 'post '.$context->postId, - $throwable->getMessage() - ), ['exception' => $throwable]); - - return null; - } - } - - private function reportSlow(BindingSource $source, BindingFieldDefinition $field, BindingContext $context, float $milliseconds): void - { - if ($milliseconds <= self::SLOW_FIELD_MS) { - return; - } - - $this->logger?->warning(sprintf( - 'The block binding "%s"%s took %d ms on %s', - $source->name, - $field->name === '' ? '' : sprintf(' (field "%s")', $field->name), - (int) round($milliseconds), - $context->postId === null ? 'no post' : 'post '.$context->postId, - ), [ - 'source' => $source->name, - 'field' => $field->name, - 'post' => $context->postId, - 'milliseconds' => round($milliseconds, 1), - ]); - } - - private function present(mixed $value, BindingFieldType $type, ?string $attributeSource): mixed - { - if ($value === null) { - return null; - } - - $inHtml = in_array($attributeSource, self::HTML_SOURCES, true); - - if ($value instanceof Htmlable) { - return $this->presenter->richText($value->toHtml()); - } - - if (is_bool($value)) { - return $inHtml ? $this->presenter->html($this->presenter->boolean($value)) : $value; - } - - if ((is_int($value) || is_float($value)) && ! $inHtml && ! $type->isUrl()) { - return $value; - } - - $string = $value instanceof Stringable || is_scalar($value) ? (string) $value : ''; - - return match (true) { - $type->isUrl() => $this->presenter->url($string), - $inHtml => $this->presenter->html($string), - default => $string, - }; - } - - private function cacheKey(BindingSource $source, BindingContext $context, ?string $attributeSource): string - { - return implode('|', [ - $source->name, - $context->attribute, - (string) $attributeSource, - (string) $context->postId, - (string) $context->termId, - (string) $context->block?->name, - serialize($context->args), - ]); - } - - private function positiveInt(mixed $value): ?int - { - return is_numeric($value) && (int) $value > 0 ? (int) $value : null; - } -} diff --git a/src/BlockBinding/Application/Services/BindingSourceBuilder.php b/src/BlockBinding/Application/Services/BindingSourceBuilder.php deleted file mode 100644 index d4968d66..00000000 --- a/src/BlockBinding/Application/Services/BindingSourceBuilder.php +++ /dev/null @@ -1,168 +0,0 @@ -getAttributes(BlockBinding::class)[0] ?? null)?->newInstance(); - - if (! $attribute instanceof BlockBinding) { - throw InvalidBindingSourceException::forClass($class, 'the class has no #[BlockBinding] attribute.'); - } - - if (preg_match(self::NAME_PATTERN, $attribute->name) !== 1) { - throw InvalidBindingSourceException::forClass($class, sprintf( - 'the name "%s" must be "namespace/name", in lowercase letters, digits and dashes.', - $attribute->name - )); - } - - if (! $reflection->isInstantiable()) { - throw InvalidBindingSourceException::forClass($class, 'the class cannot be instantiated.'); - } - - $fields = $this->buildFields($reflection); - - if ($fields === [] && ! $reflection->hasMethod('__invoke')) { - throw InvalidBindingSourceException::forClass($class, 'mark a public method #[BindingField], or define __invoke() to answer every call.'); - } - - if ($fields === []) { - $this->assertReturnType($class, $reflection->getMethod('__invoke')); - } - - return new BindingSource( - name: $attribute->name, - label: $attribute->label ?? Str::headline($reflection->getShortName()), - class: $class, - usesContext: $attribute->usesContext, - postTypes: $attribute->postTypes, - fields: $fields, - ); - } - - /** - * @param ReflectionClass $reflection - * @return array - */ - private function buildFields(ReflectionClass $reflection): array - { - $class = $reflection->getName(); - $fields = []; - - foreach ($reflection->getMethods() as $method) { - $attribute = ($method->getAttributes(BindingField::class)[0] ?? null)?->newInstance(); - - if (! $attribute instanceof BindingField) { - continue; - } - - if (! $method->isPublic() || $method->isStatic()) { - throw InvalidBindingSourceException::forField($class, $method->getName(), 'a field must be a public, non-static method.'); - } - - $this->assertReturnType($class, $method); - - $name = $attribute->name ?? Str::snake($method->getName()); - - if (isset($fields[$name])) { - throw InvalidBindingSourceException::forField($class, $method->getName(), sprintf( - 'the field "%s" is already answered by %s().', - $name, - $fields[$name]->method - )); - } - - $type = $attribute->type instanceof BindingFieldType ? $attribute->type : BindingFieldType::tryFrom($attribute->type); - - if (! $type instanceof BindingFieldType) { - throw InvalidBindingSourceException::forField($class, $method->getName(), sprintf( - 'the type "%s" is unknown; use text, url or image.', - (string) $attribute->type - )); - } - - $fields[$name] = new BindingFieldDefinition( - name: $name, - label: $attribute->label ?? Str::headline($method->getName()), - type: $type, - method: $method->getName(), - ); - } - - return $fields; - } - - private function assertReturnType(string $class, ReflectionMethod $method): void - { - $type = $method->getReturnType(); - - if (! $type instanceof ReflectionType) { - throw InvalidBindingSourceException::forField($class, $method->getName(), 'declare its return type: string, int, float, bool, Stringable or null.'); - } - - $types = match (true) { - $type instanceof ReflectionUnionType => $type->getTypes(), - $type instanceof ReflectionIntersectionType => [$type], - default => [$type], - }; - - foreach ($types as $member) { - if (! $member instanceof ReflectionNamedType || ! $this->isAllowedReturn($member)) { - throw InvalidBindingSourceException::forField($class, $method->getName(), sprintf( - 'it returns %s; a field returns string, int, float, bool, Stringable or null.', - (string) $type - )); - } - } - } - - private function isAllowedReturn(ReflectionNamedType $type): bool - { - $name = $type->getName(); - - return in_array($name, self::SCALAR_RETURNS, true) || is_a($name, Stringable::class, true); - } -} diff --git a/src/BlockBinding/Application/Services/BindingSourceRegistry.php b/src/BlockBinding/Application/Services/BindingSourceRegistry.php deleted file mode 100644 index 71c25592..00000000 --- a/src/BlockBinding/Application/Services/BindingSourceRegistry.php +++ /dev/null @@ -1,46 +0,0 @@ - - */ - private array $sources = []; - - /** - * @throws InvalidBindingSourceException When another class already declares the name - */ - public function add(BindingSource $source): void - { - $existing = $this->sources[$source->name] ?? null; - - if ($existing instanceof BindingSource && $existing->class !== $source->class) { - throw InvalidBindingSourceException::duplicateName($source->name, $existing->class, $source->class); - } - - $this->sources[$source->name] = $source; - } - - public function find(string $name): ?BindingSource - { - return $this->sources[$name] ?? null; - } - - /** - * @return list - */ - public function all(): array - { - return array_values($this->sources); - } -} diff --git a/src/BlockBinding/Domain/Contracts/ContentVisibilityInterface.php b/src/BlockBinding/Domain/Contracts/ContentVisibilityInterface.php deleted file mode 100644 index d79373b4..00000000 --- a/src/BlockBinding/Domain/Contracts/ContentVisibilityInterface.php +++ /dev/null @@ -1,23 +0,0 @@ - $args Arguments of the binding, `field` included - * @param string $attribute The bound attribute: `content`, `url`, `alt`… - * @param int|null $postId Post of the block context - * @param string|null $postType Post type of the block context - * @param int|null $termId Term of the block context - * @param string|null $taxonomy Taxonomy of the block context - * @param \WP_Block|null $block The block, for advanced cases - */ - public function __construct( - public readonly array $args = [], - public readonly string $attribute = '', - public readonly ?int $postId = null, - public readonly ?string $postType = null, - public readonly ?int $termId = null, - public readonly ?string $taxonomy = null, - public readonly ?\WP_Block $block = null, - private readonly ?MetaAccessor $metaAccessor = null, - ) {} - - /** - * An argument of the binding: `"args": {"field": "date", "format": "j F Y"}`. - */ - public function arg(string $name, mixed $default = null): mixed - { - return $this->args[$name] ?? $default; - } - - /** - * The post of the block context, as a Pollora model (the class bound to its - * post type, when there is one). - */ - public function post(): ?Post - { - $this->post ??= $this->postId === null ? false : (Post::query()->find($this->postId) ?? false); - - return $this->post ?: null; - } - - /** - * The typed meta a class declares, on the object of the block context that - * carries them: the post, the term, the post's author. - * - * @param class-string $class A class declaring `#[Meta]` properties - */ - public function meta(string $class): ?MetaRecord - { - $accessor = $this->metaAccessor ?? throw new LogicException('Typed meta are not available.'); - $objectId = $this->objectIdFor($accessor->schema($class)->objectType); - - return $objectId === null ? null : $accessor->of($class, $objectId); - } - - private function objectIdFor(MetaObjectType $objectType): ?int - { - return match ($objectType) { - MetaObjectType::Post => $this->postId, - MetaObjectType::Term => $this->termId, - MetaObjectType::User => $this->authorId(), - MetaObjectType::Comment => isset($this->block?->context['commentId']) ? (int) $this->block->context['commentId'] : null, - }; - } - - private function authorId(): ?int - { - $author = (int) $this->post()?->post_author; - - return $author > 0 ? $author : null; - } -} diff --git a/src/BlockBinding/Domain/Models/BindingFieldDefinition.php b/src/BlockBinding/Domain/Models/BindingFieldDefinition.php deleted file mode 100644 index a8e0a65f..00000000 --- a/src/BlockBinding/Domain/Models/BindingFieldDefinition.php +++ /dev/null @@ -1,26 +0,0 @@ - $usesContext Block context the source reads - * @param list $postTypes Post types the source answers for; empty for all - * @param array $fields Fields by name; empty for an invokable source - */ - public function __construct( - public string $name, - public string $label, - public string $class, - public array $usesContext, - public array $postTypes, - public array $fields, - ) {} - - /** - * Whether `__invoke()` answers every call, the source having no field. - */ - public function isInvokable(): bool - { - return $this->fields === []; - } - - public function field(string $name): ?BindingFieldDefinition - { - return $this->fields[$name] ?? null; - } - - /** - * Whether the source answers for a post of this type. - */ - public function answersFor(?string $postType): bool - { - return $this->postTypes === [] || ($postType !== null && in_array($postType, $this->postTypes, true)); - } -} diff --git a/src/BlockBinding/Infrastructure/Adapters/WordPressBindingEditorScript.php b/src/BlockBinding/Infrastructure/Adapters/WordPressBindingEditorScript.php deleted file mode 100644 index 33e84afe..00000000 --- a/src/BlockBinding/Infrastructure/Adapters/WordPressBindingEditorScript.php +++ /dev/null @@ -1,45 +0,0 @@ -scriptPath) ? file_get_contents($this->scriptPath) : false; - - if ($script === false) { - return; - } - - \wp_register_script(self::HANDLE, false, self::DEPENDENCIES, null, true); - \wp_add_inline_script(self::HANDLE, 'window.polloraBlockBindings = '.\wp_json_encode($this->data->toArray(), JSON_HEX_TAG | JSON_UNESCAPED_SLASHES).';', 'before'); - \wp_add_inline_script(self::HANDLE, $script); - \wp_enqueue_script(self::HANDLE); - } -} diff --git a/src/BlockBinding/Infrastructure/Adapters/WordPressBindingRegistry.php b/src/BlockBinding/Infrastructure/Adapters/WordPressBindingRegistry.php deleted file mode 100644 index dc0491c3..00000000 --- a/src/BlockBinding/Infrastructure/Adapters/WordPressBindingRegistry.php +++ /dev/null @@ -1,46 +0,0 @@ - 0) { - $this->registerNow($source); - - return; - } - - $this->action->add('init', fn () => $this->registerNow($source)); - } - - private function registerNow(BindingSource $source): void - { - if (\get_block_bindings_source($source->name) !== null) { - return; - } - - \register_block_bindings_source($source->name, [ - 'label' => $source->label, - 'uses_context' => $source->usesContext, - 'get_value_callback' => fn (array $args, \WP_Block $block, string $attribute): mixed => $this->resolver->resolve($source, $args, $block, $attribute), - ]); - } -} diff --git a/src/BlockBinding/Infrastructure/Adapters/WordPressContentVisibility.php b/src/BlockBinding/Infrastructure/Adapters/WordPressContentVisibility.php deleted file mode 100644 index d46a81de..00000000 --- a/src/BlockBinding/Infrastructure/Adapters/WordPressContentVisibility.php +++ /dev/null @@ -1,37 +0,0 @@ -publicly_queryable) || \current_user_can('read'); - } -} diff --git a/src/BlockBinding/Infrastructure/Adapters/WordPressValuePresenter.php b/src/BlockBinding/Infrastructure/Adapters/WordPressValuePresenter.php deleted file mode 100644 index 79d13d39..00000000 --- a/src/BlockBinding/Infrastructure/Adapters/WordPressValuePresenter.php +++ /dev/null @@ -1,33 +0,0 @@ -app->singleton(ValuePresenterInterface::class, WordPressValuePresenter::class); $this->app->singleton(BindingFormatter::class); $this->app->singleton(TypedMetaReader::class); - $this->app->singleton(BindingEditorData::class); + $this->app->singleton(BindingEditorData::class, fn (Application $app): BindingEditorData => new BindingEditorData( + $app->make(BindingSourceRegistry::class), + $app->make(MetaSchemaRepository::class), + $this->options(), + )); + $this->app->singleton(OptionSource::class, fn (): OptionSource => new OptionSource($this->options())); $this->app->singleton(WordPressBindingEditorScript::class); $this->app->singleton(ResolveBindingsController::class); $this->app->singleton(BindingReferenceInspector::class, fn (Application $app): BindingReferenceInspector => new BindingReferenceInspector( $app->make(BindingSourceRegistry::class), $app->make(MetaSchemaRepository::class), - array_values(array_filter((array) $app->make('config')->get('block-bindings.options', []), is_string(...))), + $this->options(), )); $this->app->singleton(BindingResolver::class, fn (Application $app): BindingResolver => new BindingResolver( @@ -80,6 +84,9 @@ public function register(): void $app->make(MetaAccessor::class), (bool) $app->make('config')->get('app.debug', false), $app->make(LoggerInterface::class), + $app->make(LaravelMethodInvoker::class), + $app->make(LaravelBindingEvents::class), + $app->make(LaravelPostFinder::class), )); $this->app->singleton(WordPressBindingRegistry::class, fn (Application $app): WordPressBindingRegistry => new WordPressBindingRegistry( @@ -118,4 +125,14 @@ public function boot(): void $action->add('enqueue_block_editor_assets', fn () => $this->app->make(WordPressBindingEditorScript::class)->enqueue()); $action->add('rest_api_init', fn () => $this->app->make(ResolveBindingsController::class)->register()); } + + /** + * The options a project lists as bindable. + * + * @return list + */ + private function options(): array + { + return array_values(array_filter((array) $this->app->make('config')->get('block-bindings.options', []), is_string(...))); + } } diff --git a/src/BlockBinding/Infrastructure/Checks/BlockBindingsCheck.php b/src/BlockBinding/Infrastructure/Laravel/BlockBindingsCheck.php similarity index 99% rename from src/BlockBinding/Infrastructure/Checks/BlockBindingsCheck.php rename to src/BlockBinding/Infrastructure/Laravel/BlockBindingsCheck.php index fabb888c..14809b46 100644 --- a/src/BlockBinding/Infrastructure/Checks/BlockBindingsCheck.php +++ b/src/BlockBinding/Infrastructure/Laravel/BlockBindingsCheck.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pollora\BlockBinding\Infrastructure\Checks; +namespace Pollora\BlockBinding\Infrastructure\Laravel; use Pollora\BlockBinding\Application\Services\BindingReferenceInspector; use Pollora\Doctor\Domain\Contracts\CheckInterface; diff --git a/src/BlockBinding/UI/Console/BindingListCommand.php b/src/BlockBinding/Infrastructure/Laravel/Console/BindingListCommand.php similarity index 98% rename from src/BlockBinding/UI/Console/BindingListCommand.php rename to src/BlockBinding/Infrastructure/Laravel/Console/BindingListCommand.php index e2fa84f0..76641406 100644 --- a/src/BlockBinding/UI/Console/BindingListCommand.php +++ b/src/BlockBinding/Infrastructure/Laravel/Console/BindingListCommand.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pollora\BlockBinding\UI\Console; +namespace Pollora\BlockBinding\Infrastructure\Laravel\Console; use Illuminate\Console\Attributes\Description; use Illuminate\Console\Attributes\Signature; diff --git a/src/BlockBinding/UI/Console/MakeBindingCommand.php b/src/BlockBinding/Infrastructure/Laravel/Console/MakeBindingCommand.php similarity index 96% rename from src/BlockBinding/UI/Console/MakeBindingCommand.php rename to src/BlockBinding/Infrastructure/Laravel/Console/MakeBindingCommand.php index 50030145..3b9c5308 100644 --- a/src/BlockBinding/UI/Console/MakeBindingCommand.php +++ b/src/BlockBinding/Infrastructure/Laravel/Console/MakeBindingCommand.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pollora\BlockBinding\UI\Console; +namespace Pollora\BlockBinding\Infrastructure\Laravel\Console; use Illuminate\Console\Attributes\Description; use Illuminate\Support\Str; diff --git a/src/BlockBinding/UI/Console/stubs/binding.stub b/src/BlockBinding/Infrastructure/Laravel/Console/stubs/binding.stub similarity index 100% rename from src/BlockBinding/UI/Console/stubs/binding.stub rename to src/BlockBinding/Infrastructure/Laravel/Console/stubs/binding.stub diff --git a/src/BlockBinding/Infrastructure/Laravel/LaravelBindingEvents.php b/src/BlockBinding/Infrastructure/Laravel/LaravelBindingEvents.php new file mode 100644 index 00000000..0d6dc47a --- /dev/null +++ b/src/BlockBinding/Infrastructure/Laravel/LaravelBindingEvents.php @@ -0,0 +1,28 @@ +container->bound('events') && $this->container->make('events')->hasListeners(BindingResolved::class); + } + + public function dispatch(BindingResolved $event): void + { + $this->container->make('events')->dispatch($event); + } +} diff --git a/src/BlockBinding/Infrastructure/Laravel/LaravelMethodInvoker.php b/src/BlockBinding/Infrastructure/Laravel/LaravelMethodInvoker.php new file mode 100644 index 00000000..c609f446 --- /dev/null +++ b/src/BlockBinding/Infrastructure/Laravel/LaravelMethodInvoker.php @@ -0,0 +1,22 @@ +container->call([$instance, $method], $parameters); + } +} diff --git a/src/BlockBinding/Infrastructure/Laravel/LaravelPostFinder.php b/src/BlockBinding/Infrastructure/Laravel/LaravelPostFinder.php new file mode 100644 index 00000000..34991c65 --- /dev/null +++ b/src/BlockBinding/Infrastructure/Laravel/LaravelPostFinder.php @@ -0,0 +1,22 @@ +find($postId); + + return $post; + } +} diff --git a/src/BlockBinding/Infrastructure/Services/BindingEditorData.php b/src/BlockBinding/Infrastructure/Services/BindingEditorData.php deleted file mode 100644 index 485cb1d5..00000000 --- a/src/BlockBinding/Infrastructure/Services/BindingEditorData.php +++ /dev/null @@ -1,121 +0,0 @@ ->} - */ - public function toArray(): array - { - $sources = []; - - foreach ($this->sources->all() as $source) { - $sources[$source->name] = match ($source->name) { - 'pollora/post-meta' => ['kind' => 'meta', 'subtype' => 'postType', 'fields' => $this->metaFields(MetaObjectType::Post, TypedMetaReader::exposedInRest(...))], - 'pollora/term-meta' => ['kind' => 'meta', 'subtype' => 'taxonomy', 'fields' => $this->metaFields(MetaObjectType::Term, TypedMetaReader::exposedInRest(...))], - 'pollora/author-meta' => ['kind' => 'meta', 'subtype' => null, 'fields' => $this->metaFields(MetaObjectType::User, static fn (MetaDefinition $definition): bool => $definition->public)], - 'pollora/option' => ['kind' => 'fields', 'postTypes' => [], 'fields' => $this->optionFields()], - default => ['kind' => 'fields', 'postTypes' => $source->postTypes, 'fields' => $this->sourceFields($source)], - }; - } - - return ['route' => self::REST_NAMESPACE.self::REST_ROUTE, 'sources' => $sources]; - } - - /** - * @return list, type: string}> - */ - private function sourceFields(BindingSource $source): array - { - $fields = []; - - foreach ($source->fields as $field) { - $fields[] = ['label' => $field->label, 'args' => ['field' => $field->name], 'type' => 'string']; - } - - return $fields; - } - - /** - * The meta a source may show, by subtype ('' for every object of the type). - * - * @param \Closure(MetaDefinition): bool $readable - * @return array, type: string}>> - */ - private function metaFields(MetaObjectType $objectType, \Closure $readable): array - { - $fields = []; - - foreach ($this->schemas->all() as $schema) { - if ($schema->objectType !== $objectType) { - continue; - } - - foreach ($schema->definitions as $definition) { - if (! $readable($definition) || $definition->valueType === MetaValueType::DataObject) { - continue; - } - - $label = $definition->label ?? Str::headline($definition->property); - - foreach ($schema->subtypes === [] ? [''] : $schema->subtypes as $subtype) { - $fields[$subtype][] = ['label' => $label, 'args' => ['key' => $definition->key], 'type' => 'string']; - - if ($definition->media) { - $fields[$subtype][] = ['label' => $label, 'args' => ['key' => $definition->key], 'type' => 'number']; - } - } - } - } - - return $fields; - } - - /** - * @return list, type: string}> - */ - private function optionFields(): array - { - $fields = []; - - foreach ((array) $this->config->get('block-bindings.options', []) as $name) { - if (is_string($name)) { - $fields[] = ['label' => Str::headline($name), 'args' => ['name' => $name], 'type' => 'string']; - } - } - - return $fields; - } -} diff --git a/src/BlockBinding/Infrastructure/Services/BindingFormatter.php b/src/BlockBinding/Infrastructure/Services/BindingFormatter.php deleted file mode 100644 index 35f54238..00000000 --- a/src/BlockBinding/Infrastructure/Services/BindingFormatter.php +++ /dev/null @@ -1,149 +0,0 @@ - $args Arguments of the binding - */ - public function format(MetaDefinition $definition, mixed $value, array $args, string $attribute): string|int|float|bool|null - { - if (in_array($value, [null, '', []], true)) { - return $this->fallback($args); - } - - if (($args['format'] ?? null) === 'raw') { - return match (true) { - is_scalar($value) => $value, - $value instanceof BackedEnum => $value->value, - $value instanceof DateTimeInterface => $value->format(DateTimeInterface::ATOM), - default => null, - }; - } - - if ($definition->media && is_int($value)) { - return $this->media($value, $args, $attribute) ?? $this->fallback($args); - } - - return match ($definition->valueType) { - MetaValueType::ArrayOf => $this->list($definition, (array) $value, $args, $attribute), - MetaValueType::DataObject => null, - default => $this->scalar($definition, $value, $args), - }; - } - - /** - * @param array $args - */ - private function scalar(MetaDefinition $definition, mixed $value, array $args): ?string - { - return match (true) { - is_bool($value) => $this->presenter->boolean($value, $this->stringArg($args, 'true'), $this->stringArg($args, 'false')), - is_int($value), is_float($value) => \number_format_i18n($value, $this->decimals($definition, $value, $args)), - $value instanceof DateTimeInterface => \wp_date($this->stringArg($args, 'format') ?? (string) \get_option('date_format'), $value->getTimestamp()) ?: null, - $value instanceof BackedEnum => method_exists($value, 'label') ? (string) $value->label() : (string) $value->value, - is_string($value) => $value, - default => null, - }; - } - - /** - * The items of an array, each formatted, as a sentence: "rock, jazz and blues". - * - * @param array $values - * @param array $args - */ - private function list(MetaDefinition $definition, array $values, array $args, string $attribute): ?string - { - $items = []; - - foreach ($values as $value) { - $item = $value === null ? null : $this->format($definition->item(), $value, [...$args, 'fallback' => null], $attribute); - - if ($item !== null && $item !== '') { - $items[] = (string) $item; - } - } - - return $items === [] ? $this->fallback($args) : \wp_sprintf('%l', $items); - } - - /** - * An attachment, as the bound attribute needs it. - * - * @param array $args - */ - private function media(int $attachmentId, array $args, string $attribute): string|int|null - { - if (\get_post_type($attachmentId) !== 'attachment') { - return null; - } - - $value = match ($attribute) { - 'id' => $attachmentId, - 'alt' => (string) \get_post_meta($attachmentId, '_wp_attachment_image_alt', true), - 'title' => \get_the_title($attachmentId), - 'caption' => (string) \wp_get_attachment_caption($attachmentId), - default => $this->presenter->url((string) (\wp_get_attachment_image_url($attachmentId, $this->stringArg($args, 'size') ?? 'full') ?: \wp_get_attachment_url($attachmentId))), - }; - - return $value === '' ? null : $value; - } - - /** - * @param array $args - */ - private function decimals(MetaDefinition $definition, int|float $value, array $args): int - { - if (is_numeric($args['decimals'] ?? null)) { - return max(0, (int) $args['decimals']); - } - - if ($definition->valueType === MetaValueType::Integer || is_int($value)) { - return 0; - } - - $fraction = strrchr((string) $value, '.'); - - return $fraction === false ? 0 : strlen($fraction) - 1; - } - - /** - * @param array $args - */ - private function fallback(array $args): ?string - { - return $this->stringArg($args, 'fallback'); - } - - /** - * @param array $args - */ - private function stringArg(array $args, string $name): ?string - { - return is_scalar($args[$name] ?? null) ? (string) $args[$name] : null; - } -} diff --git a/src/BlockBinding/Infrastructure/Services/BlockBindingDiscovery.php b/src/BlockBinding/Infrastructure/Services/BlockBindingDiscovery.php deleted file mode 100644 index 54a29cb4..00000000 --- a/src/BlockBinding/Infrastructure/Services/BlockBindingDiscovery.php +++ /dev/null @@ -1,72 +0,0 @@ -isAbstract) { - return; - } - - foreach ($structure->attributes as $attribute) { - if ($attribute->class === BlockBinding::class) { - $this->getItems()->add($location, ['class' => $structure->namespace.'\\'.$structure->name]); - - return; - } - } - } - - public function apply(): void - { - foreach ($this->getItems() as $item) { - /** @var class-string $class */ - $class = $item['class']; - - try { - $source = $this->builder->build($class); - $this->sources->add($source); - $this->registry->register($source); - } catch (\Throwable $throwable) { - $this->logger?->error(sprintf('Failed to register the block binding source %s: %s', $class, $throwable->getMessage()), ['exception' => $throwable]); - } - } - } - - public function getIdentifier(): string - { - return 'block_bindings'; - } -} diff --git a/src/BlockBinding/Infrastructure/Sources/AuthorMetaSource.php b/src/BlockBinding/Infrastructure/Sources/AuthorMetaSource.php deleted file mode 100644 index 51b9e089..00000000 --- a/src/BlockBinding/Infrastructure/Sources/AuthorMetaSource.php +++ /dev/null @@ -1,46 +0,0 @@ -arg('key'); - - if (! is_string($key) || $key === '' || $context->postId === null) { - return null; - } - - $authorId = (int) \get_post_field('post_author', $context->postId); - - if ($authorId <= 0) { - return null; - } - - $meta = $this->reader->read(MetaObjectType::User, null, $authorId, $key, static fn (MetaDefinition $definition): bool => $definition->public); - - return $meta === null ? null : $this->formatter->format($meta[0], $meta[1], $context->args, $context->attribute); - } -} diff --git a/src/BlockBinding/Infrastructure/Sources/OptionSource.php b/src/BlockBinding/Infrastructure/Sources/OptionSource.php deleted file mode 100644 index ac93e81a..00000000 --- a/src/BlockBinding/Infrastructure/Sources/OptionSource.php +++ /dev/null @@ -1,38 +0,0 @@ -arg('name'); - $fallback = $context->arg('fallback'); - $fallback = is_scalar($fallback) ? (string) $fallback : null; - - if (! is_string($name) || ! in_array($name, (array) $this->config->get('block-bindings.options', []), true)) { - return null; - } - - $value = \get_option($name, null); - - return is_scalar($value) && (string) $value !== '' ? (string) $value : $fallback; - } -} diff --git a/src/BlockBinding/Infrastructure/Sources/PostMetaSource.php b/src/BlockBinding/Infrastructure/Sources/PostMetaSource.php deleted file mode 100644 index 5684c6ed..00000000 --- a/src/BlockBinding/Infrastructure/Sources/PostMetaSource.php +++ /dev/null @@ -1,40 +0,0 @@ -arg('key'); - - if (! is_string($key) || $key === '' || $context->postId === null) { - return null; - } - - $postType = $context->postType ?? (\get_post_type($context->postId) ?: null); - $meta = $this->reader->read(MetaObjectType::Post, $postType, $context->postId, $key, TypedMetaReader::exposedInRest(...)); - - return $meta === null ? null : $this->formatter->format($meta[0], $meta[1], $context->args, $context->attribute); - } -} diff --git a/src/BlockBinding/Infrastructure/Sources/TermMetaSource.php b/src/BlockBinding/Infrastructure/Sources/TermMetaSource.php deleted file mode 100644 index 313fce6d..00000000 --- a/src/BlockBinding/Infrastructure/Sources/TermMetaSource.php +++ /dev/null @@ -1,61 +0,0 @@ -arg('key'); - [$termId, $taxonomy] = $this->term($context); - - if (! is_string($key) || $key === '' || $termId === null || $taxonomy === null) { - return null; - } - - $meta = $this->reader->read(MetaObjectType::Term, $taxonomy, $termId, $key, TypedMetaReader::exposedInRest(...)); - - return $meta === null ? null : $this->formatter->format($meta[0], $meta[1], $context->args, $context->attribute); - } - - /** - * @return array{0: int|null, 1: string|null} - */ - private function term(BindingContext $context): array - { - if ($context->termId !== null && $context->taxonomy !== null) { - return [$context->termId, $context->taxonomy]; - } - - $queried = \get_queried_object(); - - if (! $queried instanceof \WP_Term || ! $this->visibility->canShowTerm($queried->term_id, $queried->taxonomy)) { - return [null, null]; - } - - return [$queried->term_id, $queried->taxonomy]; - } -} diff --git a/src/BlockBinding/Infrastructure/Sources/TypedMetaReader.php b/src/BlockBinding/Infrastructure/Sources/TypedMetaReader.php deleted file mode 100644 index 5dcb9a5d..00000000 --- a/src/BlockBinding/Infrastructure/Sources/TypedMetaReader.php +++ /dev/null @@ -1,55 +0,0 @@ -schemas->forObject($objectType, $subtype) as $schema) { - $definition = $schema->find($key); - - if (! $definition instanceof MetaDefinition) { - continue; - } - - if (! $readable($definition)) { - return null; - } - - return [$definition, $this->accessor->record($schema, $objectId)->get($definition->property)]; - } - - return null; - } - - /** - * The rule of `core/post-meta`: a meta exposed in REST, under a key that is not protected. - */ - public static function exposedInRest(MetaDefinition $definition): bool - { - return $definition->showInRest && ! $definition->isProtected(); - } -} diff --git a/src/BlockBinding/UI/Http/ResolveBindingsController.php b/src/BlockBinding/UI/Http/ResolveBindingsController.php deleted file mode 100644 index 589eff96..00000000 --- a/src/BlockBinding/UI/Http/ResolveBindingsController.php +++ /dev/null @@ -1,128 +0,0 @@ - 'POST', - 'callback' => $this->handle(...), - 'permission_callback' => $this->canPreview(...), - 'args' => [ - 'context' => ['type' => 'object', 'default' => []], - 'bindings' => ['type' => 'array', 'required' => true, 'maxItems' => self::MAX_BINDINGS], - ], - ]); - } - - public function canPreview(\WP_REST_Request $request): bool - { - $postId = $this->context($request)['postId'] ?? null; - - return is_int($postId) ? \current_user_can('edit_post', $postId) : \current_user_can('edit_posts'); - } - - /** - * @return array{values: array} - */ - public function handle(\WP_REST_Request $request): array - { - $context = $this->context($request); - $values = []; - - // The page formats dates and numbers in the site's language; REST runs in the user's - $switched = \switch_to_locale(\get_locale()); - - foreach ((array) $request->get_param('bindings') as $binding) { - if (! is_array($binding) || ! is_scalar($binding['key'] ?? null)) { - continue; - } - - $source = is_string($binding['source'] ?? null) ? $this->sources->find($binding['source']) : null; - $args = is_array($binding['args'] ?? null) ? $binding['args'] : []; - $attribute = is_string($binding['attribute'] ?? null) ? $binding['attribute'] : 'content'; - - $values[(string) $binding['key']] = $source instanceof BindingSource - ? $this->resolver->preview($source, $args, $context, $attribute, $this->attributeSource($binding['block'] ?? null, $attribute)) - : null; - } - - if ($switched) { - \restore_previous_locale(); - } - - return ['values' => $values]; - } - - /** - * The `source` of an attribute in a registered block type, which decides its escaping. - */ - private function attributeSource(mixed $blockName, string $attribute): ?string - { - if (! is_string($blockName)) { - return null; - } - - $source = \WP_Block_Type_Registry::get_instance()->get_registered($blockName)?->attributes[$attribute]['source'] ?? null; - - return is_string($source) ? $source : null; - } - - /** - * The block context the editor sent, reduced to what the sources read. - * - * @return array{postId?: int, postType?: string, termId?: int, taxonomy?: string} - */ - private function context(\WP_REST_Request $request): array - { - $sent = (array) $request->get_param('context'); - $context = []; - - foreach (['postId', 'termId'] as $name) { - if (is_numeric($sent[$name] ?? null) && (int) $sent[$name] > 0) { - $context[$name] = (int) $sent[$name]; - } - } - - foreach (['postType', 'taxonomy'] as $name) { - if (is_string($sent[$name] ?? null) && $sent[$name] !== '') { - $context[$name] = \sanitize_key($sent[$name]); - } - } - - return $context; - } -} diff --git a/src/Meta/Application/Services/MetaAccessor.php b/src/Meta/Application/Services/MetaAccessor.php deleted file mode 100644 index 73ea4ce8..00000000 --- a/src/Meta/Application/Services/MetaAccessor.php +++ /dev/null @@ -1,110 +0,0 @@ -record($this->schema($class), $objectId); - } - - /** - * The schema a class declares, discovered or built on the spot. - * - * @param class-string $class - */ - public function schema(string $class): MetaSchema - { - return $this->schemas->forClass($class) ?? $this->builder->build($class); - } - - /** - * Every typed meta schema of the project. - * - * @return list - */ - public function schemas(): array - { - return $this->schemas->all(); - } - - /** - * The schemas whose meta an object carries: `Meta::schemaFor('post', 'event')`, - * `Meta::schemaFor('user')`. - * - * @return list - */ - public function schemaFor(MetaObjectType|string $objectType, ?string $subtype = null): array - { - return $this->schemas->forObject($objectType instanceof MetaObjectType ? $objectType : MetaObjectType::from($objectType), $subtype); - } - - /** - * Registers a UI driver, from a package's service provider: - * `Meta::extend('acf', AcfDriver::class)`. - * - * @param class-string|Closure(Container): MetaUiDriver $driver - */ - public function extend(string $name, string|Closure $driver): void - { - ($this->drivers ?? throw new LogicException('Meta UI drivers are not available.'))->extend($name, $driver); - } - - /** - * The typed meta of a schema on one object. - */ - public function record(MetaSchema $schema, int $objectId): MetaRecord - { - return new MetaRecord($schema, $objectId, $this->store, $this->caster, $this->handleUnreadable(...), $this->validator); - } - - private function handleUnreadable(InvalidMetaValueException $exception, MetaDefinition $definition): mixed - { - if ($this->debug) { - throw $exception; - } - - $this->logger?->warning($exception->getMessage()); - - return $definition->default; - } -} diff --git a/src/Meta/Application/Services/MetaAuditor.php b/src/Meta/Application/Services/MetaAuditor.php deleted file mode 100644 index d1c0f1b7..00000000 --- a/src/Meta/Application/Services/MetaAuditor.php +++ /dev/null @@ -1,108 +0,0 @@ -, example: string}> - */ - public function unreadable(int $limit): array - { - $findings = []; - - foreach ($this->schemas->all() as $schema) { - foreach ($schema->definitions as $definition) { - $values = $this->inventory->values($schema->objectType, $schema->subtypes, $definition->key, $limit); - $objects = []; - $example = ''; - - foreach ($values as $objectId => $rows) { - try { - $this->caster->toPhp($definition, $definition->single ? $rows[0] : $rows); - } catch (InvalidMetaValueException $invalidMetaValueException) { - $objects[] = $objectId; - $example = $example === '' ? $invalidMetaValueException->getMessage() : $example; - } - } - - if ($objects === []) { - continue; - } - - $findings[] = [ - 'class' => $schema->declaringClass, - 'property' => $definition->property, - 'key' => $definition->key, - 'owner' => $schema->ownerName(), - 'checked' => count($values), - 'unreadable' => count($objects), - 'objects' => array_slice($objects, 0, self::SAMPLE), - 'example' => $example, - ]; - } - } - - return $findings; - } - - /** - * Keys stored on the post types and taxonomies the project declares that - * no `#[Meta]` declares. Protected keys (starting with "_") are left out. - * - * @param int $limit Keys read per post type or taxonomy, at most - * @return list - */ - public function undeclared(int $limit): array - { - $findings = []; - $seen = []; - - foreach ($this->schemas->all() as $schema) { - if (! $schema->declaresSubtypes) { - continue; - } - - foreach ($schema->subtypes as $subtype) { - if (isset($seen[$schema->objectType->value][$subtype])) { - continue; - } - - $seen[$schema->objectType->value][$subtype] = true; - - foreach ($this->inventory->keys($schema->objectType, [$subtype], $limit) as $key => $objects) { - if (! $this->schemas->declares($schema->objectType, $key)) { - $findings[] = ['owner' => sprintf('%s "%s"', $schema->objectType->value, $subtype), 'key' => $key, 'objects' => $objects]; - } - } - } - } - - return $findings; - } -} diff --git a/src/Meta/Application/Services/MetaSchemaBuilder.php b/src/Meta/Application/Services/MetaSchemaBuilder.php deleted file mode 100644 index 8811f32e..00000000 --- a/src/Meta/Application/Services/MetaSchemaBuilder.php +++ /dev/null @@ -1,323 +0,0 @@ -resolveOwner($reflection); - - $definitions = []; - $properties = []; - - foreach ($reflection->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { - $attributes = $property->getAttributes(Meta::class); - - if ($attributes === [] || $property->isStatic()) { - continue; - } - - $definition = $this->buildDefinition($class, $property, $attributes[0]->newInstance(), $objectType); - - if (isset($properties[$definition->key])) { - throw InvalidMetaDefinitionException::forProperty($class, $property->getName(), sprintf( - 'the key "%s" is already used by $%s.', - $definition->key, - $properties[$definition->key] - )); - } - - $properties[$definition->key] = $definition->property; - $definitions[$definition->property] = $definition; - } - - return new MetaSchema($class, $objectType, $subtypes, $definitions, $declaresSubtypes); - } - - /** - * @param ReflectionClass $reflection - * @return array{0: MetaObjectType, 1: list, 2: bool} - */ - private function resolveOwner(ReflectionClass $reflection): array - { - $class = $reflection->getName(); - $owner = static fn (string $attribute): ?object => ($reflection->getAttributes($attribute)[0] ?? null)?->newInstance(); - - return match (true) { - ($postType = $owner(PostType::class)) instanceof PostType => [MetaObjectType::Post, [$postType->resolveSlug($class)], true], - ($taxonomy = $owner(Taxonomy::class)) instanceof Taxonomy => [MetaObjectType::Term, [$taxonomy->resolveSlug($class)], true], - ($postMeta = $owner(PostMeta::class)) instanceof PostMeta => [MetaObjectType::Post, $postMeta->postTypes, false], - ($termMeta = $owner(TermMeta::class)) instanceof TermMeta => [MetaObjectType::Term, $termMeta->taxonomies, false], - $owner(UserMeta::class) instanceof UserMeta => [MetaObjectType::User, [], false], - $owner(CommentMeta::class) instanceof CommentMeta => [MetaObjectType::Comment, [], false], - default => throw InvalidMetaDefinitionException::notADeclaration($class), - }; - } - - private function buildDefinition(string $class, ReflectionProperty $property, Meta $meta, MetaObjectType $objectType): MetaDefinition - { - $name = $property->getName(); - $type = $property->getType(); - - if (! $type instanceof ReflectionNamedType) { - throw InvalidMetaDefinitionException::forProperty($class, $name, 'the property needs a single type (no union, no untyped property).'); - } - - [$valueType, $valueClass] = $this->resolveValueType($class, $name, $type->getName()); - - // A data object without a default reads as an instance with its own defaults. - if (! $property->hasDefaultValue() && ! $type->allowsNull() && $valueType !== MetaValueType::DataObject) { - throw InvalidMetaDefinitionException::forProperty($class, $name, 'give the property a default value or make it nullable: it is what an absent meta reads as.'); - } - - if (! $meta->single && $valueType !== MetaValueType::ArrayOf) { - throw InvalidMetaDefinitionException::forProperty($class, $name, 'single: false stores one row per item, so it needs an array property.'); - } - - $items = $valueType === MetaValueType::ArrayOf ? $this->buildItems($class, $property, $meta) : null; - $properties = $valueType === MetaValueType::DataObject ? $this->buildProperties($class, $name, (string) $valueClass) : []; - - $key = $meta->resolveKey($name); - - if ($meta->showInRest && str_starts_with($key, '_') && $meta->capability === null) { - throw InvalidMetaDefinitionException::forProperty($class, $name, sprintf( - 'the protected key "%s" can only be exposed in REST with an explicit capability.', - $key - )); - } - - if ($meta->media && $valueType !== MetaValueType::Integer) { - throw InvalidMetaDefinitionException::forProperty($class, $name, 'media: true holds an attachment ID, so it needs an int property.'); - } - - if ($meta->revisions && $objectType !== MetaObjectType::Post) { - throw InvalidMetaDefinitionException::forProperty($class, $name, 'revisions only exist for post types.'); - } - - return new MetaDefinition( - property: $name, - key: $key, - valueType: $valueType, - valueClass: $valueClass, - nullable: $type->allowsNull(), - default: $property->hasDefaultValue() ? $property->getDefaultValue() : null, - showInRest: $meta->showInRest, - label: $meta->label, - description: $meta->description, - sanitize: $meta->sanitize, - capability: $meta->capability, - revisions: $meta->revisions, - rules: array_values($meta->rules), - single: $meta->single, - items: $items, - properties: $properties, - control: $meta->control ?? ($meta->media ? Control::Media : $this->defaultControl($valueType, $meta)), - group: $meta->group, - hints: $meta->hints, - media: $meta->media, - public: $meta->public, - ); - } - - /** - * @return array{0: MetaValueType, 1: class-string|null} - */ - private function resolveValueType(string $class, string $property, string $typeName): array - { - $scalar = match ($typeName) { - 'string' => MetaValueType::String, - 'int' => MetaValueType::Integer, - 'float' => MetaValueType::Number, - 'bool' => MetaValueType::Boolean, - default => null, - }; - - if ($scalar instanceof MetaValueType) { - return [$scalar, null]; - } - - if (is_a($typeName, DateTimeInterface::class, true)) { - return [MetaValueType::DateTime, $typeName]; - } - - if (is_a($typeName, BackedEnum::class, true)) { - return [MetaValueType::Enum, $typeName]; - } - - if ($typeName === 'array') { - return [MetaValueType::ArrayOf, null]; - } - - if (class_exists($typeName) && ! is_a($typeName, UnitEnum::class, true) && (new ReflectionClass($typeName))->isInstantiable()) { - return [MetaValueType::DataObject, $typeName]; - } - - $reason = is_a($typeName, UnitEnum::class, true) - ? sprintf('the enum %s needs backing values (enum %s: string).', $typeName, class_basename($typeName)) - : sprintf('the type %s is not supported; use string, int, float, bool, a date, a backed enum, an array or a class with public typed properties.', $typeName); - - throw InvalidMetaDefinitionException::forProperty($class, $property, $reason); - } - - /** - * The input a type calls for; none for an array or an object, which a UI - * driver builds its own way. - */ - private function defaultControl(MetaValueType $valueType, Meta $meta): ?Control - { - return match ($valueType) { - MetaValueType::String => $meta->sanitize === 'wp_kses_post' ? Control::RichText : Control::Text, - MetaValueType::Integer, MetaValueType::Number => Control::Number, - MetaValueType::Boolean => Control::Toggle, - MetaValueType::DateTime => Control::DateTime, - MetaValueType::Enum => Control::Select, - MetaValueType::ArrayOf, MetaValueType::DataObject => null, - }; - } - - /** - * The item of an array property, from `items:` or the `@var list<…>` docblock. - */ - private function buildItems(string $class, ReflectionProperty $property, Meta $meta): MetaDefinition - { - $name = $property->getName(); - $itemType = $meta->items ?? $this->itemTypeFromDocblock((string) $property->getDocComment()); - - if ($itemType === null) { - throw InvalidMetaDefinitionException::forProperty($class, $name, "say what the array holds, with items: 'string' (or int, float, bool, a class) or a @var list docblock."); - } - - $itemType = ltrim($itemType, '\\'); - - if ($itemType === 'array') { - throw InvalidMetaDefinitionException::forProperty($class, $name, 'an item cannot be an array: use a class with public typed properties.'); - } - - $scalar = ['string' => 'string', 'int' => 'int', 'integer' => 'int', 'float' => 'float', 'bool' => 'bool', 'boolean' => 'bool'][$itemType] ?? null; - - if ($scalar === null && ! class_exists($itemType) && ! enum_exists($itemType)) { - throw InvalidMetaDefinitionException::forProperty($class, $name, sprintf('the item type %s is unknown: use string, int, float, bool or a fully qualified class.', $itemType)); - } - - [$valueType, $valueClass] = $this->resolveValueType($class, $name, $scalar ?? $itemType); - - if ($valueType === MetaValueType::ArrayOf || ($valueType === MetaValueType::DataObject && ! $meta->single)) { - throw InvalidMetaDefinitionException::forProperty($class, $name, 'an item cannot be an array, and objects need single: true.'); - } - - return new MetaDefinition( - property: $name, - key: $meta->resolveKey($name), - valueType: $valueType, - valueClass: $valueClass, - nullable: false, - default: null, - properties: $valueType === MetaValueType::DataObject ? $this->buildProperties($class, $name, (string) $valueClass) : [], - ); - } - - private function itemTypeFromDocblock(string $docblock): ?string - { - if (preg_match('/@var\s+(?:list<\s*([\w\\\\]+)\s*>|array<\s*(?:int\s*,\s*)?([\w\\\\]+)\s*>|([\w\\\\]+)\[\])/', $docblock, $matches) !== 1) { - return null; - } - - return $matches[1] ?: ($matches[2] ?? '') ?: ($matches[3] ?? null); - } - - /** - * The properties of a data object: public typed properties of a scalar, date - * or enum type, each with a default or nullable. - * - * @param class-string|string $objectClass - * @return array - */ - private function buildProperties(string $class, string $name, string $objectClass): array - { - $reflection = new ReflectionClass($objectClass); - $defaults = []; - - foreach ($reflection->getConstructor()?->getParameters() ?? [] as $parameter) { - if ($parameter->isPromoted() && $parameter->isDefaultValueAvailable()) { - $defaults[$parameter->getName()] = $parameter->getDefaultValue(); - } - } - - $properties = []; - - foreach ($reflection->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { - if ($property->isStatic()) { - continue; - } - - $propertyName = $property->getName(); - $type = $property->getType(); - $where = sprintf('%s::$%s', $objectClass, $propertyName); - - if (! $type instanceof ReflectionNamedType) { - throw InvalidMetaDefinitionException::forProperty($class, $name, sprintf('%s needs a single type.', $where)); - } - - [$valueType, $valueClass] = $this->resolveValueType($class, $name, $type->getName()); - - if ($valueType === MetaValueType::ArrayOf || $valueType === MetaValueType::DataObject) { - throw InvalidMetaDefinitionException::forProperty($class, $name, sprintf('%s: an object property can be a string, int, float, bool, date or backed enum, not an array or an object.', $where)); - } - - $hasDefault = $property->hasDefaultValue() || array_key_exists($propertyName, $defaults); - - if (! $hasDefault && ! $type->allowsNull()) { - throw InvalidMetaDefinitionException::forProperty($class, $name, sprintf('%s needs a default value or a nullable type.', $where)); - } - - $properties[$propertyName] = new MetaDefinition( - property: $propertyName, - key: Str::snake($propertyName), - valueType: $valueType, - valueClass: $valueClass, - nullable: $type->allowsNull(), - default: $property->hasDefaultValue() ? $property->getDefaultValue() : ($defaults[$propertyName] ?? null), - ); - } - - return $properties; - } -} diff --git a/src/Meta/Application/Services/MetaSchemaRepository.php b/src/Meta/Application/Services/MetaSchemaRepository.php deleted file mode 100644 index 070fd4e5..00000000 --- a/src/Meta/Application/Services/MetaSchemaRepository.php +++ /dev/null @@ -1,116 +0,0 @@ - - */ - private array $schemas = []; - - /** - * @var array> Property names and keys, by object type - */ - private array $names = []; - - /** - * @var array Declarations discovery refused, with the reason - */ - private array $failures = []; - - /** - * @throws InvalidMetaDefinitionException When another class already declares one of its keys on the same object - */ - public function add(MetaSchema $schema): void - { - foreach ($this->schemas as $existing) { - if ($existing->declaringClass === $schema->declaringClass || ! $existing->overlaps($schema)) { - continue; - } - - foreach ($schema->definitions as $definition) { - if ($existing->find($definition->key) instanceof MetaDefinition) { - throw InvalidMetaDefinitionException::duplicateKey($definition->key, $schema->ownerName(), $existing->declaringClass, $schema->declaringClass); - } - } - } - - $this->schemas[$schema->declaringClass] = $schema; - - foreach ($schema->definitions as $definition) { - $this->names[$schema->objectType->value][$definition->property] = true; - $this->names[$schema->objectType->value][$definition->key] = true; - } - } - - /** - * @param class-string $class - */ - public function forClass(string $class): ?MetaSchema - { - return $this->schemas[$class] ?? null; - } - - /** - * The schemas whose meta an object can carry: those of its type covering every - * object, and those listing its subtype. - * - * @return list - */ - public function forObject(MetaObjectType $objectType, ?string $subtype): array - { - return array_values(array_filter( - $this->schemas, - static fn (MetaSchema $schema): bool => $schema->objectType === $objectType - && ($schema->subtypes === [] || ($subtype !== null && in_array($subtype, $schema->subtypes, true))) - )); - } - - /** - * Whether a schema of this object type declares a meta under that property name or key. - */ - public function declares(MetaObjectType $objectType, string $propertyOrKey): bool - { - return isset($this->names[$objectType->value][$propertyOrKey]); - } - - /** - * Records a declaration discovery could not register, so that it can be - * reported (pollora:doctor) instead of only logged. - * - * @param class-string $class - */ - public function fail(string $class, string $reason): void - { - $this->failures[$class] = $reason; - } - - /** - * The declarations discovery refused, with the reason, by class. - * - * @return array - */ - public function failures(): array - { - return $this->failures; - } - - /** - * @return list - */ - public function all(): array - { - return array_values($this->schemas); - } -} diff --git a/src/Meta/Application/Services/MetaUiDrivers.php b/src/Meta/Application/Services/MetaUiDrivers.php deleted file mode 100644 index 8942ef50..00000000 --- a/src/Meta/Application/Services/MetaUiDrivers.php +++ /dev/null @@ -1,66 +0,0 @@ -|Closure(Container): MetaUiDriver> */ - private array $drivers = []; - - public function __construct(private readonly Container $container) {} - - /** - * @param class-string|Closure(Container): MetaUiDriver $driver - */ - public function extend(string $name, string|Closure $driver): void - { - $this->drivers[$name] = $driver; - } - - public function has(string $name): bool - { - return isset($this->drivers[$name]); - } - - /** - * @throws InvalidArgumentException When no package registered that driver - */ - public function driver(string $name): MetaUiDriver - { - $driver = $this->drivers[$name] ?? throw new InvalidArgumentException(sprintf('No meta UI driver is named "%s"; its package registers it with Meta::extend().', $name)); - $instance = $driver instanceof Closure ? $driver($this->container) : $this->container->make($driver); - - return $instance instanceof MetaUiDriver - ? $instance - : throw new InvalidArgumentException(sprintf('The meta UI driver "%s" does not implement %s.', $name, MetaUiDriver::class)); - } - - /** - * Gives each schema to the driver, with only the meta it supports; a schema - * left without any is skipped. - * - * @param list $schemas - */ - public function build(MetaUiDriver $driver, array $schemas): void - { - foreach ($schemas as $schema) { - $supported = array_filter($schema->definitions, $driver->supports(...)); - - if ($supported !== []) { - $driver->register($schema->withDefinitions($supported)); - } - } - } -} diff --git a/src/Meta/Domain/Contracts/MetaInventoryInterface.php b/src/Meta/Domain/Contracts/MetaInventoryInterface.php deleted file mode 100644 index 99b3a001..00000000 --- a/src/Meta/Domain/Contracts/MetaInventoryInterface.php +++ /dev/null @@ -1,32 +0,0 @@ - $subtypes Post types or taxonomies; empty for every object of the type - * @return array> Values by object ID, one per row - */ - public function values(MetaObjectType $objectType, array $subtypes, string $key, int $limit): array; - - /** - * The keys stored on these objects that do not start with "_", with the - * number of objects carrying each. - * - * @param list $subtypes - * @return array - */ - public function keys(MetaObjectType $objectType, array $subtypes, int $limit): array; -} diff --git a/src/Meta/Domain/Contracts/MetaRegistryInterface.php b/src/Meta/Domain/Contracts/MetaRegistryInterface.php deleted file mode 100644 index 27969c37..00000000 --- a/src/Meta/Domain/Contracts/MetaRegistryInterface.php +++ /dev/null @@ -1,15 +0,0 @@ - - */ - public function getAll(MetaObjectType $objectType, int $objectId, string $key): array; - - /** - * @param string|array $value A string, or an array WordPress serializes - */ - public function update(MetaObjectType $objectType, int $objectId, string $key, string|array $value): void; - - /** - * Replaces every row of a non-single meta. - * - * @param list $values - */ - public function replaceAll(MetaObjectType $objectType, int $objectId, string $key, array $values): void; - - public function delete(MetaObjectType $objectType, int $objectId, string $key): void; -} diff --git a/src/Meta/Domain/Contracts/MetaUiDriver.php b/src/Meta/Domain/Contracts/MetaUiDriver.php deleted file mode 100644 index c97996aa..00000000 --- a/src/Meta/Domain/Contracts/MetaUiDriver.php +++ /dev/null @@ -1,37 +0,0 @@ - $schemas - */ - public function __construct(public array $schemas) {} -} diff --git a/src/Meta/Domain/Exceptions/InvalidMetaDefinitionException.php b/src/Meta/Domain/Exceptions/InvalidMetaDefinitionException.php deleted file mode 100644 index 62e2b956..00000000 --- a/src/Meta/Domain/Exceptions/InvalidMetaDefinitionException.php +++ /dev/null @@ -1,37 +0,0 @@ -key, - self::describeExpected($definition), - get_debug_type($value) - )); - } - - /** - * A value stored in the database that cannot be read as the declared type. - */ - public static function forRead(MetaDefinition $definition, mixed $raw): self - { - return new self(sprintf( - 'The stored value of the meta "%s" cannot be read as %s: %s.', - $definition->key, - self::describeExpected($definition), - is_scalar($raw) ? var_export($raw, true) : get_debug_type($raw) - )); - } - - private static function describeExpected(MetaDefinition $definition): string - { - $expected = $definition->valueClass ?? $definition->wordPressType(); - - return $definition->nullable ? '?'.$expected : $expected; - } -} diff --git a/src/Meta/Domain/Exceptions/MetaValidationException.php b/src/Meta/Domain/Exceptions/MetaValidationException.php deleted file mode 100644 index 757abec4..00000000 --- a/src/Meta/Domain/Exceptions/MetaValidationException.php +++ /dev/null @@ -1,24 +0,0 @@ - $messages The messages of the failed rules - */ - public function __construct( - public readonly MetaDefinition $definition, - public readonly array $messages, - ) { - parent::__construct(sprintf('The meta "%s" is invalid: %s', $definition->key, implode(' ', $messages))); - } -} diff --git a/src/Meta/Domain/Models/MetaDefinition.php b/src/Meta/Domain/Models/MetaDefinition.php deleted file mode 100644 index ed807a21..00000000 --- a/src/Meta/Domain/Models/MetaDefinition.php +++ /dev/null @@ -1,168 +0,0 @@ - $rules Laravel validation rules - * @param bool $single False for an array stored one row per item - * @param MetaDefinition|null $items The item of an array - * @param array $properties The properties of a data object, by property name - * @param Control|null $control The input a UI driver should build; null when no neutral control fits (arrays, objects) - * @param string|null $group The group of fields a UI driver puts the meta in - * @param array $hints Options for UI drivers, by driver - * @param bool $media The value is an attachment ID - * @param bool $public The value may be shown to anyone - */ - public function __construct( - public string $property, - public string $key, - public MetaValueType $valueType, - public ?string $valueClass, - public bool $nullable, - public mixed $default, - 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 ?MetaDefinition $items = null, - public array $properties = [], - public ?Control $control = null, - public ?string $group = null, - public array $hints = [], - public bool $media = false, - public bool $public = false, - ) {} - - /** - * Whether WordPress treats the key as protected (hidden from custom fields and REST by default). - */ - public function isProtected(): bool - { - return str_starts_with($this->key, '_'); - } - - /** - * The `type` passed to `register_meta()`. - */ - public function wordPressType(): string - { - return match ($this->valueType) { - MetaValueType::String, MetaValueType::DateTime => 'string', - MetaValueType::Integer => 'integer', - MetaValueType::Number => 'number', - MetaValueType::Boolean => 'boolean', - MetaValueType::Enum => $this->isIntegerBackedEnum() ? 'integer' : 'string', - // A non-single meta registers the type of one row: WordPress wraps it in an array. - MetaValueType::ArrayOf => $this->single ? 'array' : $this->item()->wordPressType(), - MetaValueType::DataObject => 'object', - }; - } - - /** - * The JSON schema published with the meta in the REST API. - * - * @return array - */ - public function restSchema(): array - { - if ($this->valueType === MetaValueType::ArrayOf) { - return $this->single ? ['type' => 'array', 'items' => $this->item()->restSchema()] : $this->item()->restSchema(); - } - - if ($this->valueType === MetaValueType::DataObject) { - $properties = []; - - foreach ($this->properties as $property) { - $properties[$property->key] = $property->nullableRestSchema(); - } - - return ['type' => 'object', 'properties' => $properties, 'additionalProperties' => false]; - } - - $schema = ['type' => $this->wordPressType()]; - - if ($this->valueType === MetaValueType::DateTime) { - $schema['format'] = 'date-time'; - } - - if ($this->valueType === MetaValueType::Enum) { - /** @var class-string $enum */ - $enum = $this->valueClass; - $schema['enum'] = array_map(static fn (BackedEnum $case): int|string => $case->value, $enum::cases()); - } - - return $schema; - } - - /** - * The item of an array. - */ - public function item(): MetaDefinition - { - return $this->items ?? throw new LogicException(sprintf('The meta "%s" is not an array.', $this->key)); - } - - /** - * Whether the value is an array or an object, stored serialized or in several rows. - */ - public function isStructured(): bool - { - return $this->valueType === MetaValueType::ArrayOf || $this->valueType === MetaValueType::DataObject; - } - - /** - * The REST schema of a property inside an object, accepting null when it does. - * - * @return array - */ - private function nullableRestSchema(): array - { - $schema = $this->restSchema(); - - if ($this->nullable && is_string($schema['type'])) { - $schema['type'] = [$schema['type'], 'null']; - } - - return $schema; - } - - private function isIntegerBackedEnum(): bool - { - /** @var class-string $enum */ - $enum = $this->valueClass; - - return (new ReflectionEnum($enum))->getBackingType()?->getName() === 'int'; - } -} diff --git a/src/Meta/Domain/Models/MetaRecord.php b/src/Meta/Domain/Models/MetaRecord.php deleted file mode 100644 index 57829a0d..00000000 --- a/src/Meta/Domain/Models/MetaRecord.php +++ /dev/null @@ -1,175 +0,0 @@ -capacity; // int - * $event->fill(['capacity' => 250])->save(); - * - * Properties can be named as declared (`startsAt`) or by their key (`starts_at`). - */ -final class MetaRecord -{ - /** - * @var array Values already read or written, by property name - */ - private array $values = []; - - /** - * @var array|null> Stored forms waiting for save(), by property name - */ - private array $pending = []; - - /** - * @param Closure(InvalidMetaValueException, MetaDefinition): mixed $onUnreadable Decides what an unreadable stored value gives - */ - public function __construct( - private readonly MetaSchema $schema, - private readonly int $objectId, - private readonly MetaStoreInterface $store, - private readonly MetaValueCaster $caster, - private readonly Closure $onUnreadable, - private readonly ?MetaValidatorInterface $validator = null, - ) {} - - public function __get(string $name): mixed - { - return $this->get($name); - } - - public function __set(string $name, mixed $value): void - { - $this->set($name, $value); - } - - public function __isset(string $name): bool - { - return $this->schema->find($name) instanceof MetaDefinition && $this->get($name) !== null; - } - - public function objectId(): int - { - return $this->objectId; - } - - /** - * The value of a meta, with its declared PHP type. - */ - public function get(string $name): mixed - { - $definition = $this->definition($name); - - if (! array_key_exists($definition->property, $this->values)) { - $this->values[$definition->property] = $this->read($definition); - } - - return $this->values[$definition->property]; - } - - /** - * Sets a meta, written on `save()`. Null deletes a nullable meta. - * - * @throws InvalidMetaValueException When the value does not match the property type - * @throws MetaValidationException When the value breaks a rule of the meta - */ - public function set(string $name, mixed $value): static - { - $definition = $this->definition($name); - $stored = $this->caster->toStorage($definition, $value); - - if ($stored !== null) { - $this->validator?->validate($definition, $value); - } - - $this->pending[$definition->property] = $stored; - $this->values[$definition->property] = $value; - - return $this; - } - - /** - * @param array $values Values by property name or key - */ - public function fill(array $values): static - { - foreach ($values as $name => $value) { - $this->set($name, $value); - } - - return $this; - } - - /** - * Writes the pending values through WordPress's meta API. - */ - public function save(): static - { - foreach ($this->pending as $property => $stored) { - $definition = $this->schema->definitions[$property]; - - match (true) { - $stored === null => $this->store->delete($this->schema->objectType, $this->objectId, $definition->key), - ! $definition->single && is_array($stored) => $this->store->replaceAll($this->schema->objectType, $this->objectId, $definition->key, array_values(array_map(strval(...), $stored))), - default => $this->store->update($this->schema->objectType, $this->objectId, $definition->key, $stored), - }; - } - - $this->pending = []; - - return $this; - } - - /** - * Every declared meta, by property name. - * - * @return array - */ - public function toArray(): array - { - $values = []; - - foreach (array_keys($this->schema->definitions) as $property) { - $values[$property] = $this->get($property); - } - - return $values; - } - - private function definition(string $name): MetaDefinition - { - return $this->schema->find($name) ?? throw new InvalidArgumentException(sprintf( - '%s declares no meta named "%s".', - $this->schema->declaringClass, - $name - )); - } - - private function read(MetaDefinition $definition): mixed - { - $raw = $definition->single - ? $this->store->get($this->schema->objectType, $this->objectId, $definition->key) - : $this->store->getAll($this->schema->objectType, $this->objectId, $definition->key); - - try { - return $this->caster->toPhp($definition, $raw); - } catch (InvalidMetaValueException $invalidMetaValueException) { - return ($this->onUnreadable)($invalidMetaValueException, $definition); - } - } -} diff --git a/src/Meta/Domain/Models/MetaSchema.php b/src/Meta/Domain/Models/MetaSchema.php deleted file mode 100644 index 660e96e8..00000000 --- a/src/Meta/Domain/Models/MetaSchema.php +++ /dev/null @@ -1,97 +0,0 @@ - $subtypes The post type or taxonomy slugs; empty for every object of the type - * @param array $definitions Definitions keyed by property name - * @param bool $declaresSubtypes Whether the class also declares its post type or taxonomy (`#[PostType]`, `#[Taxonomy]`) - */ - public function __construct( - public string $declaringClass, - public MetaObjectType $objectType, - public array $subtypes, - public array $definitions, - public bool $declaresSubtypes = false, - ) {} - - /** - * Whether some object can carry the meta of both schemas. - */ - public function overlaps(self $other): bool - { - if ($this->objectType !== $other->objectType) { - return false; - } - - return $this->subtypes === [] || $other->subtypes === [] || array_intersect($this->subtypes, $other->subtypes) !== []; - } - - /** - * The objects the meta belong to, for messages: `post "event"`, `user`. - */ - public function ownerName(): string - { - return $this->subtypes === [] - ? $this->objectType->value - : sprintf('%s "%s"', $this->objectType->value, implode('", "', $this->subtypes)); - } - - /** - * The same schema, with only the given definitions. - * - * @param array $definitions - */ - public function withDefinitions(array $definitions): self - { - return new self($this->declaringClass, $this->objectType, $this->subtypes, $definitions, $this->declaresSubtypes); - } - - /** - * Whether a meta of the schema is exposed in REST. - */ - public function exposesInRest(): bool - { - foreach ($this->definitions as $definition) { - if ($definition->showInRest) { - return true; - } - } - - return false; - } - - public function isEmpty(): bool - { - return $this->definitions === []; - } - - /** - * The definition of a meta, found by its property name or its key. - */ - public function find(string $propertyOrKey): ?MetaDefinition - { - if (isset($this->definitions[$propertyOrKey])) { - return $this->definitions[$propertyOrKey]; - } - - foreach ($this->definitions as $definition) { - if ($definition->key === $propertyOrKey) { - return $definition; - } - } - - return null; - } -} diff --git a/src/Meta/Domain/Services/MetaValueCaster.php b/src/Meta/Domain/Services/MetaValueCaster.php deleted file mode 100644 index 69ebee88..00000000 --- a/src/Meta/Domain/Services/MetaValueCaster.php +++ /dev/null @@ -1,320 +0,0 @@ -absentValue($definition); - } - - return $this->read($definition, $raw) ?? throw InvalidMetaValueException::forRead($definition, $raw); - } - - /** - * What an absent meta reads as: the property default, or for a data object - * without one, an instance with its own defaults. - */ - public function absentValue(MetaDefinition $definition): mixed - { - if ($definition->valueType === MetaValueType::DataObject && $definition->default === null && ! $definition->nullable) { - return $this->readObject($definition, []); - } - - return $definition->default; - } - - /** - * The stored form of a value written from PHP. Null means the meta is deleted. - * - * A scalar gives a string; an array a list of strings (one row each) when the - * meta is not single, otherwise a list of JSON values; a data object an array - * of JSON values by property key. - * - * @return string|array|null - * - * @throws InvalidMetaValueException When the value does not match the property type - */ - public function toStorage(MetaDefinition $definition, mixed $value): string|array|null - { - if ($value === null) { - return $definition->nullable ? null : throw InvalidMetaValueException::forWrite($definition, $value); - } - - if ($definition->valueType === MetaValueType::ArrayOf && ! $definition->single) { - return is_array($value) - ? array_values(array_map(fn (mixed $item): string => (string) $this->toStorage($definition->item(), $item), $value)) - : throw InvalidMetaValueException::forWrite($definition, $value); - } - - if ($definition->isStructured()) { - return $this->toJson($definition, $value); - } - - $stored = match ($definition->valueType) { - MetaValueType::String => is_string($value) || $value instanceof Stringable ? (string) $value : null, - MetaValueType::Integer => is_int($value) ? (string) $value : null, - MetaValueType::Number => is_int($value) || is_float($value) ? (string) $value : null, - MetaValueType::Boolean => is_bool($value) ? ($value ? '1' : '0') : null, - MetaValueType::DateTime => $value instanceof DateTimeInterface ? $this->formatDate($value) : null, - MetaValueType::Enum => $value instanceof BackedEnum && $value::class === $definition->valueClass ? (string) $value->value : null, - MetaValueType::ArrayOf, MetaValueType::DataObject => null, - }; - - return $stored ?? throw InvalidMetaValueException::forWrite($definition, $value); - } - - /** - * A value as JSON can carry it, inside a serialized array and in REST: numbers - * and booleans keep their type, dates and enums become strings or integers. - * - * @throws InvalidMetaValueException When the value does not match the declared type - */ - public function toJson(MetaDefinition $definition, mixed $value): mixed - { - if ($value === null) { - return $definition->nullable ? null : throw InvalidMetaValueException::forWrite($definition, $value); - } - - $json = match ($definition->valueType) { - MetaValueType::String => is_string($value) || $value instanceof Stringable ? (string) $value : null, - MetaValueType::Integer => is_int($value) ? $value : null, - MetaValueType::Number => is_int($value) || is_float($value) ? $value : null, - MetaValueType::Boolean => is_bool($value) ? $value : null, - MetaValueType::DateTime => $value instanceof DateTimeInterface ? $this->formatDate($value) : null, - MetaValueType::Enum => $value instanceof BackedEnum && $value::class === $definition->valueClass ? $value->value : null, - MetaValueType::ArrayOf => is_array($value) ? array_values(array_map(fn (mixed $item): mixed => $this->toJson($definition->item(), $item), $value)) : null, - MetaValueType::DataObject => $this->objectToJson($definition, $value), - }; - - return $json ?? throw InvalidMetaValueException::forWrite($definition, $value); - } - - /** - * Normalizes any value written through WordPress (REST, editor, `update_post_meta()`) - * to the stored form. Used as `sanitize_callback`: an unreadable value becomes - * an empty string, which reads back as the default. - */ - public function sanitize(MetaDefinition $definition, mixed $value): mixed - { - // Empty stays empty (an absent value), so sanitizing twice changes nothing. - if ($value === null || $value === '') { - return ''; - } - - try { - $phpValue = $value instanceof DateTimeInterface || $value instanceof BackedEnum || ($definition->valueType === MetaValueType::DataObject && is_object($value)) - ? $value - : $this->toPhp($definition, $value); - - return $this->toStorage($definition, $phpValue) ?? ''; - } catch (InvalidMetaValueException) { - return ''; - } - } - - /** - * The default as the REST API publishes it, or null when there is none. - */ - public function toRestDefault(MetaDefinition $definition): mixed - { - $default = $this->absentValue($definition); - - return match (true) { - $default === null => null, - // A non-single meta has a default per row, which a list cannot give. - $definition->valueType === MetaValueType::ArrayOf && ! $definition->single => null, - $definition->isStructured() => $this->toJson($definition, $default), - $default instanceof BackedEnum => $default->value, - default => $default, - }; - } - - /** - * The value of a raw stored or JSON value, or null when it cannot be read. - */ - private function read(MetaDefinition $definition, mixed $raw): mixed - { - return match ($definition->valueType) { - MetaValueType::String => is_scalar($raw) ? (string) $raw : null, - MetaValueType::Integer => $this->readInteger($raw), - MetaValueType::Number => is_numeric($raw) ? (float) $raw : null, - MetaValueType::Boolean => is_scalar($raw) ? filter_var($raw, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) : null, - MetaValueType::DateTime => $this->readDate($definition, $raw), - MetaValueType::Enum => $this->readEnum($definition, $raw), - MetaValueType::ArrayOf => $this->readArray($definition, $raw), - MetaValueType::DataObject => is_array($raw) ? $this->readObject($definition, $raw) : null, - }; - } - - /** - * @return list|null - */ - private function readArray(MetaDefinition $definition, mixed $raw): ?array - { - if (! is_array($raw)) { - return null; - } - - $items = []; - - foreach ($raw as $item) { - $value = $this->read($definition->item(), $item); - - if ($value === null) { - return null; - } - - $items[] = $value; - } - - return $items; - } - - /** - * An instance of the data object, its properties read from the array; a - * missing property keeps its default. - * - * @param array $raw - */ - private function readObject(MetaDefinition $definition, array $raw): ?object - { - /** @var class-string $class */ - $class = $definition->valueClass; - $object = (new ReflectionClass($class))->newInstanceWithoutConstructor(); - - foreach ($definition->properties as $property) { - if (! array_key_exists($property->key, $raw)) { - $object->{$property->property} = $property->default; - - continue; - } - - $item = $raw[$property->key]; - - if ($item === null) { - if (! $property->nullable) { - return null; - } - - $object->{$property->property} = null; - - continue; - } - - $value = $this->read($property, $item); - - if ($value === null) { - return null; - } - - $object->{$property->property} = $value; - } - - return $object; - } - - /** - * @return array|null - */ - private function objectToJson(MetaDefinition $definition, mixed $value): ?array - { - if (! is_object($value) || ! is_a($value, (string) $definition->valueClass)) { - return null; - } - - $json = []; - - foreach ($definition->properties as $property) { - $json[$property->key] = $this->toJson($property, $value->{$property->property} ?? null); - } - - return $json; - } - - private function readInteger(mixed $raw): ?int - { - $value = is_scalar($raw) ? filter_var($raw, FILTER_VALIDATE_INT) : false; - - return $value === false ? null : $value; - } - - private function readDate(MetaDefinition $definition, mixed $raw): ?DateTimeInterface - { - if (! is_string($raw)) { - return null; - } - - try { - $date = CarbonImmutable::parse($raw, 'UTC'); - } catch (Throwable) { - return null; - } - - $class = (string) $definition->valueClass; - - // A property typed with an interface (DateTimeInterface, CarbonInterface) gets a CarbonImmutable. - return match (true) { - is_a($class, DateTimeImmutable::class, true), is_a($class, DateTime::class, true) => $class::createFromInterface($date), - default => $date, - }; - } - - private function readEnum(MetaDefinition $definition, mixed $raw): ?BackedEnum - { - if (! is_scalar($raw)) { - return null; - } - - /** @var class-string $enum */ - $enum = $definition->valueClass; - - if ($definition->wordPressType() === 'integer') { - $raw = $this->readInteger($raw); - - if ($raw === null) { - return null; - } - } - - return $enum::tryFrom($raw); - } - - private function formatDate(DateTimeInterface $date): string - { - return CarbonImmutable::instance($date)->setTimezone(new DateTimeZone('UTC'))->format(self::DATE_FORMAT); - } -} diff --git a/src/Meta/Infrastructure/Adapters/WordPressMetaInventory.php b/src/Meta/Infrastructure/Adapters/WordPressMetaInventory.php deleted file mode 100644 index 3c25bac5..00000000 --- a/src/Meta/Infrastructure/Adapters/WordPressMetaInventory.php +++ /dev/null @@ -1,116 +0,0 @@ -wpdb(); - [$from, $where, $params] = $this->scope($wpdb, $objectType, $subtypes); - - /** @var list $rows */ - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT m.{$this->column($objectType)} AS object_id, m.meta_value FROM {$from} WHERE m.meta_key = %s{$where} ORDER BY m.{$this->idColumn($objectType)} LIMIT %d", - ...[$key, ...$params, max(1, $limit)] - )); - - $values = []; - - foreach ($rows as $row) { - $values[(int) $row->object_id][] = $this->unserialize($row->meta_value); - } - - return $values; - } - - public function keys(MetaObjectType $objectType, array $subtypes, int $limit): array - { - $wpdb = $this->wpdb(); - [$from, $where, $params] = $this->scope($wpdb, $objectType, $subtypes); - $column = $this->column($objectType); - - /** @var list $rows */ - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT m.meta_key, COUNT(DISTINCT m.{$column}) AS objects FROM {$from} WHERE m.meta_key NOT LIKE %s{$where} GROUP BY m.meta_key ORDER BY objects DESC, m.meta_key LIMIT %d", - ...['\_%', ...$params, max(1, $limit)] - )); - - $keys = []; - - foreach ($rows as $row) { - $keys[$row->meta_key] = (int) $row->objects; - } - - return $keys; - } - - /** - * The tables to read and the condition on the subtypes. - * - * @param list $subtypes - * @return array{0: string, 1: string, 2: list} - */ - private function scope(object $wpdb, MetaObjectType $objectType, array $subtypes): array - { - $placeholders = implode(', ', array_fill(0, count($subtypes), '%s')); - - return match ($objectType) { - MetaObjectType::Post => [ - "{$wpdb->postmeta} m INNER JOIN {$wpdb->posts} o ON o.ID = m.post_id", - " AND o.post_type <> 'revision'".($subtypes === [] ? '' : " AND o.post_type IN ({$placeholders})"), - $subtypes, - ], - MetaObjectType::Term => [ - "{$wpdb->termmeta} m".($subtypes === [] ? '' : " INNER JOIN {$wpdb->term_taxonomy} o ON o.term_id = m.term_id"), - $subtypes === [] ? '' : " AND o.taxonomy IN ({$placeholders})", - $subtypes, - ], - MetaObjectType::User => ["{$wpdb->usermeta} m", '', []], - MetaObjectType::Comment => ["{$wpdb->commentmeta} m", '', []], - }; - } - - private function column(MetaObjectType $objectType): string - { - return $objectType->value.'_id'; - } - - private function idColumn(MetaObjectType $objectType): string - { - return $objectType === MetaObjectType::User ? 'umeta_id' : 'meta_id'; - } - - /** - * A stored value as WordPress would return it, never instantiating a class. - */ - private function unserialize(?string $value): mixed - { - if ($value === null || ! \is_serialized($value)) { - return $value; - } - - return @unserialize(trim($value), ['allowed_classes' => false]); - } - - /** - * WordPress's database object, typed loosely: only its table names, - * prepare() and get_results() are used. - * - * @return \wpdb - */ - private function wpdb(): object - { - return $GLOBALS['wpdb']; - } -} diff --git a/src/Meta/Infrastructure/Adapters/WordPressMetaRegistry.php b/src/Meta/Infrastructure/Adapters/WordPressMetaRegistry.php deleted file mode 100644 index 253b1995..00000000 --- a/src/Meta/Infrastructure/Adapters/WordPressMetaRegistry.php +++ /dev/null @@ -1,133 +0,0 @@ - 0) { - $this->registerNow($schema); - - return; - } - - $this->action->add('init', fn () => $this->registerNow($schema), self::INIT_PRIORITY); - } - - private function registerNow(MetaSchema $schema): void - { - // WordPress leaves `meta` out of a post's REST response unless its post - // type supports custom-fields. Only a post type the class declares is - // changed: one targeted by #[PostMeta] belongs to someone else. - if ($schema->objectType === MetaObjectType::Post && $schema->declaresSubtypes && $schema->exposesInRest()) { - foreach ($schema->subtypes as $postType) { - \add_post_type_support($postType, 'custom-fields'); - } - } - - // An empty subtype registers the meta for every object of the type. - foreach ($schema->subtypes === [] ? [''] : $schema->subtypes as $subtype) { - foreach ($schema->definitions as $definition) { - \register_meta($schema->objectType->value, $definition->key, $this->argumentsFor($schema, $definition, $subtype)); - } - } - } - - /** - * @return array - */ - private function argumentsFor(MetaSchema $schema, MetaDefinition $definition, string $subtype): array - { - $arguments = [ - 'object_subtype' => $subtype, - 'type' => $definition->wordPressType(), - 'single' => $definition->single, - 'sanitize_callback' => $definition->sanitize ?? $this->sanitizerFor($definition), - 'show_in_rest' => $definition->showInRest ? ['schema' => $definition->restSchema()] : false, - ]; - - if ($definition->label !== null) { - $arguments['label'] = $definition->label; - } - - if ($definition->description !== null) { - $arguments['description'] = $definition->description; - } - - $default = $this->caster->toRestDefault($definition); - - if ($default !== null) { - $arguments['default'] = $default; - } - - if ($definition->capability !== null) { - $capability = $definition->capability; - $arguments['auth_callback'] = static fn (bool $allowed, string $key, int $objectId, int $userId): bool => \user_can($userId, $capability); - } - - if ($definition->revisions && $schema->objectType === MetaObjectType::Post) { - $arguments['revisions_enabled'] = true; - } - - return $arguments; - } - - private function sanitizerFor(MetaDefinition $definition): callable - { - // WordPress sanitizes each row of a non-single meta on its own. - if ($definition->valueType === MetaValueType::ArrayOf && ! $definition->single) { - return $this->sanitizerFor($definition->item()); - } - - if ($definition->valueType === MetaValueType::String) { - return 'sanitize_text_field'; - } - - return fn (mixed $value): mixed => $this->sanitizeText($definition, $this->caster->sanitize($definition, $value)); - } - - /** - * Strips HTML from the strings inside an array or an object, as from a string meta. - */ - private function sanitizeText(MetaDefinition $definition, mixed $value): mixed - { - if (! is_array($value)) { - return $definition->valueType === MetaValueType::String && is_string($value) ? \sanitize_text_field($value) : $value; - } - - if ($definition->valueType === MetaValueType::ArrayOf) { - return array_map(fn (mixed $item): mixed => $this->sanitizeText($definition->item(), $item), $value); - } - - foreach ($definition->properties as $property) { - if (array_key_exists($property->key, $value)) { - $value[$property->key] = $this->sanitizeText($property, $value[$property->key]); - } - } - - return $value; - } -} diff --git a/src/Meta/Infrastructure/Adapters/WordPressMetaStore.php b/src/Meta/Infrastructure/Adapters/WordPressMetaStore.php deleted file mode 100644 index bb967fea..00000000 --- a/src/Meta/Infrastructure/Adapters/WordPressMetaStore.php +++ /dev/null @@ -1,46 +0,0 @@ -value, $objectId, $key, true); - } - - public function getAll(MetaObjectType $objectType, int $objectId, string $key): array - { - return array_values((array) \get_metadata_raw($objectType->value, $objectId, $key, false)); - } - - public function update(MetaObjectType $objectType, int $objectId, string $key, string|array $value): void - { - \update_metadata($objectType->value, $objectId, \wp_slash($key), \wp_slash($value)); - } - - public function replaceAll(MetaObjectType $objectType, int $objectId, string $key, array $values): void - { - \delete_metadata($objectType->value, $objectId, \wp_slash($key)); - - foreach ($values as $value) { - \add_metadata($objectType->value, $objectId, \wp_slash($key), \wp_slash($value)); - } - } - - public function delete(MetaObjectType $objectType, int $objectId, string $key): void - { - \delete_metadata($objectType->value, $objectId, \wp_slash($key)); - } -} diff --git a/src/Meta/Infrastructure/Adapters/WordPressRestMetaValidation.php b/src/Meta/Infrastructure/Adapters/WordPressRestMetaValidation.php deleted file mode 100644 index 337a9a9f..00000000 --- a/src/Meta/Infrastructure/Adapters/WordPressRestMetaValidation.php +++ /dev/null @@ -1,128 +0,0 @@ - $handler The matched route handler - */ - public function validate(mixed $response, array $handler, WP_REST_Request $request): mixed - { - $meta = $request->get_param('meta'); - - if ($response instanceof WP_Error || ! is_array($meta) || $meta === [] || $request->get_method() === 'GET') { - return $response; - } - - [$objectType, $subtype] = $this->objectOf($handler['callback'][0] ?? null); - - if (! $objectType instanceof MetaObjectType) { - return $response; - } - - $errors = []; - - foreach ($this->schemas->forObject($objectType, $subtype) as $schema) { - foreach ($schema->definitions as $definition) { - if ($definition->rules === [] || ! array_key_exists($definition->key, $meta)) { - continue; - } - - $message = $this->check($definition, $meta[$definition->key]); - - if ($message !== null) { - $errors['meta.'.$definition->key] = $message; - } - } - } - - if ($errors === []) { - return $response; - } - - return new WP_Error( - 'rest_invalid_param', - /* translators: %s: List of invalid parameters. */ - sprintf(__('Invalid parameter(s): %s'), 'meta'), - ['status' => 400, 'params' => $errors] - ); - } - - private function check(MetaDefinition $definition, mixed $value): ?string - { - try { - // A value of the wrong type is left to WordPress's schema validation. - $phpValue = $this->caster->toPhp($definition, $value); - } catch (InvalidMetaValueException) { - return null; - } - - if ($phpValue === null) { - return null; - } - - try { - $this->validator->validate($definition, $phpValue); - } catch (MetaValidationException $metaValidationException) { - return implode(' ', $metaValidationException->messages); - } - - return null; - } - - /** - * The meta object type and subtype a core REST controller writes. - * - * @return array{0: MetaObjectType|null, 1: string|null} - */ - private function objectOf(mixed $controller): array - { - return match (true) { - $controller instanceof WP_REST_Posts_Controller => [MetaObjectType::Post, $this->property($controller, 'post_type')], - $controller instanceof WP_REST_Terms_Controller => [MetaObjectType::Term, $this->property($controller, 'taxonomy')], - $controller instanceof WP_REST_Users_Controller => [MetaObjectType::User, null], - $controller instanceof WP_REST_Comments_Controller => [MetaObjectType::Comment, null], - default => [null, null], - }; - } - - private function property(object $controller, string $name): ?string - { - $value = (new ReflectionProperty($controller, $name))->getValue($controller); - - return is_string($value) ? $value : null; - } -} diff --git a/src/Meta/UI/Console/MetaAuditCommand.php b/src/Meta/Infrastructure/Laravel/Console/MetaAuditCommand.php similarity index 97% rename from src/Meta/UI/Console/MetaAuditCommand.php rename to src/Meta/Infrastructure/Laravel/Console/MetaAuditCommand.php index 662b6abc..5e290b55 100644 --- a/src/Meta/UI/Console/MetaAuditCommand.php +++ b/src/Meta/Infrastructure/Laravel/Console/MetaAuditCommand.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pollora\Meta\UI\Console; +namespace Pollora\Meta\Infrastructure\Laravel\Console; use Illuminate\Console\Attributes\Description; use Illuminate\Console\Attributes\Signature; diff --git a/src/Meta/UI/Console/MetaListCommand.php b/src/Meta/Infrastructure/Laravel/Console/MetaListCommand.php similarity index 98% rename from src/Meta/UI/Console/MetaListCommand.php rename to src/Meta/Infrastructure/Laravel/Console/MetaListCommand.php index e3d074d4..10e4f420 100644 --- a/src/Meta/UI/Console/MetaListCommand.php +++ b/src/Meta/Infrastructure/Laravel/Console/MetaListCommand.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pollora\Meta\UI\Console; +namespace Pollora\Meta\Infrastructure\Laravel\Console; use BackedEnum; use Illuminate\Console\Attributes\Description; diff --git a/src/Meta/Infrastructure/Services/LaravelMetaValidator.php b/src/Meta/Infrastructure/Laravel/LaravelMetaValidator.php similarity index 98% rename from src/Meta/Infrastructure/Services/LaravelMetaValidator.php rename to src/Meta/Infrastructure/Laravel/LaravelMetaValidator.php index 9fc176e5..6f9ab3f5 100644 --- a/src/Meta/Infrastructure/Services/LaravelMetaValidator.php +++ b/src/Meta/Infrastructure/Laravel/LaravelMetaValidator.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pollora\Meta\Infrastructure\Services; +namespace Pollora\Meta\Infrastructure\Laravel; use BackedEnum; use DateTimeInterface; diff --git a/src/Meta/Infrastructure/Providers/MetaServiceProvider.php b/src/Meta/Infrastructure/Laravel/MetaServiceProvider.php similarity index 95% rename from src/Meta/Infrastructure/Providers/MetaServiceProvider.php rename to src/Meta/Infrastructure/Laravel/MetaServiceProvider.php index b02ceb4a..8a0b8990 100644 --- a/src/Meta/Infrastructure/Providers/MetaServiceProvider.php +++ b/src/Meta/Infrastructure/Laravel/MetaServiceProvider.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pollora\Meta\Infrastructure\Providers; +namespace Pollora\Meta\Infrastructure\Laravel; use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\ServiceProvider; @@ -24,11 +24,9 @@ use Pollora\Meta\Infrastructure\Adapters\WordPressMetaRegistry; use Pollora\Meta\Infrastructure\Adapters\WordPressMetaStore; use Pollora\Meta\Infrastructure\Adapters\WordPressRestMetaValidation; -use Pollora\Meta\Infrastructure\Checks\TypedMetaCheck; -use Pollora\Meta\Infrastructure\Services\LaravelMetaValidator; +use Pollora\Meta\Infrastructure\Laravel\Console\MetaAuditCommand; +use Pollora\Meta\Infrastructure\Laravel\Console\MetaListCommand; use Pollora\Meta\Infrastructure\Services\MetaDiscovery; -use Pollora\Meta\UI\Console\MetaAuditCommand; -use Pollora\Meta\UI\Console\MetaListCommand; use Psr\Log\LoggerInterface; /** diff --git a/src/Meta/Infrastructure/Checks/TypedMetaCheck.php b/src/Meta/Infrastructure/Laravel/TypedMetaCheck.php similarity index 99% rename from src/Meta/Infrastructure/Checks/TypedMetaCheck.php rename to src/Meta/Infrastructure/Laravel/TypedMetaCheck.php index 365d9a8e..3d43b716 100644 --- a/src/Meta/Infrastructure/Checks/TypedMetaCheck.php +++ b/src/Meta/Infrastructure/Laravel/TypedMetaCheck.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pollora\Meta\Infrastructure\Checks; +namespace Pollora\Meta\Infrastructure\Laravel; use Pollora\Doctor\Domain\Contracts\CheckInterface; use Pollora\Doctor\Domain\Enums\RunContext; diff --git a/src/Meta/Infrastructure/Services/MetaDiscovery.php b/src/Meta/Infrastructure/Services/MetaDiscovery.php deleted file mode 100644 index 8f87d2e6..00000000 --- a/src/Meta/Infrastructure/Services/MetaDiscovery.php +++ /dev/null @@ -1,119 +0,0 @@ -isAbstract) { - return; - } - - $class = $structure->namespace.'\\'.$structure->name; - - foreach ($structure->attributes as $attribute) { - if (in_array($attribute->class, self::OWNER_ATTRIBUTES, true)) { - $this->getItems()->add($location, ['class' => $class]); - - return; - } - } - - if ($structure->extends !== null && is_subclass_of($class, ColtPost::class)) { - $this->getItems()->add($location, ['class' => $class, 'model' => true]); - } - } - - public function apply(): void - { - foreach ($this->getItems() as $item) { - /** @var class-string $class */ - $class = $item['class']; - - if ($item['model'] ?? false) { - $this->registerPostModel($class); - - continue; - } - - try { - $schema = $this->builder->build($class); - - if ($schema->isEmpty()) { - continue; - } - - $this->schemas->add($schema); - $this->registry->register($schema); - } catch (\Throwable $throwable) { - $this->schemas->fail($class, $throwable->getMessage()); - $this->logger?->error(sprintf('Failed to register the meta of %s: %s', $class, $throwable->getMessage()), ['exception' => $throwable]); - } - } - } - - /** - * Makes `Post::find()` return the model bound to the post type - * (`protected $postType = 'event'`), so its typed meta are there. - * - * @param class-string $class - */ - private function registerPostModel(string $class): void - { - $postType = (new ReflectionClass($class))->getDefaultProperties()['postType'] ?? null; - - if (is_string($postType) && $postType !== '') { - ColtPost::registerPostType($postType, $class); - } - } - - public function getIdentifier(): string - { - return 'meta'; - } -} diff --git a/src/Meta/Testing/Fixtures/EveryMetaKind.php b/src/Meta/Testing/Fixtures/EveryMetaKind.php deleted file mode 100644 index 6951c926..00000000 --- a/src/Meta/Testing/Fixtures/EveryMetaKind.php +++ /dev/null @@ -1,68 +0,0 @@ - ['width' => 50]])] - public string $text = ''; - - #[Meta(control: Control::Textarea)] - public string $textarea = ''; - - #[Meta(sanitize: 'wp_kses_post')] - public string $richText = ''; - - #[Meta] - public int $number = 0; - - #[Meta] - public float $decimal = 0.0; - - #[Meta] - public bool $toggle = false; - - #[Meta(control: Control::Date)] - public ?CarbonImmutable $date = null; - - #[Meta] - public ?CarbonImmutable $dateTime = null; - - #[Meta] - public MetaObjectType $select = MetaObjectType::Post; - - #[Meta(control: Control::Media)] - public ?int $media = null; - - #[Meta(control: Control::Url)] - public ?string $url = null; - - #[Meta(control: Control::Email)] - public ?string $email = null; - - #[Meta(control: Control::Color)] - public ?string $color = null; - - /** @var list */ - #[Meta(single: false)] - public array $rows = []; - - #[Meta(items: 'int')] - public array $list = []; -} diff --git a/src/Meta/Testing/MetaUiDriverConformance.php b/src/Meta/Testing/MetaUiDriverConformance.php deleted file mode 100644 index dbfc983c..00000000 --- a/src/Meta/Testing/MetaUiDriverConformance.php +++ /dev/null @@ -1,67 +0,0 @@ -toBe([]); - * - * It gives the driver a schema with one meta of every type and control, and - * returns what breaks the contract. The rules about storage (write in the - * schema's stored form, through WordPress's meta API) depend on the plugin the - * driver targets: the driver's own tests cover them. - */ -final class MetaUiDriverConformance -{ - /** - * A schema with one meta of every type and control. - */ - public static function schema(): MetaSchema - { - return (new MetaSchemaBuilder)->build(EveryMetaKind::class); - } - - /** - * What the driver does against the contract; empty when it conforms. - * - * @return list - */ - public static function check(MetaUiDriver $driver): array - { - $schema = self::schema(); - $violations = []; - - foreach ($schema->definitions as $definition) { - try { - if ($driver->supports($definition) !== $driver->supports($definition)) { - $violations[] = sprintf('supports() answers differently for "%s" when asked twice.', $definition->key); - } - } catch (Throwable $throwable) { - $violations[] = sprintf('supports() throws for "%s" (%s): it must answer false instead.', $definition->key, $throwable->getMessage()); - } - } - - if ($violations !== []) { - return $violations; - } - - try { - (new MetaUiDrivers(new Container))->build($driver, [$schema]); - } catch (Throwable $throwable) { - $violations[] = sprintf('register() throws for the meta it supports: %s', $throwable->getMessage()); - } - - return $violations; - } -} diff --git a/src/Providers/PolloraServiceProvider.php b/src/Providers/PolloraServiceProvider.php index efb6d705..7c355907 100755 --- a/src/Providers/PolloraServiceProvider.php +++ b/src/Providers/PolloraServiceProvider.php @@ -12,7 +12,7 @@ use Pollora\Asset\Infrastructure\Providers\AssetServiceProvider; use Pollora\Auth\AuthServiceProvider; use Pollora\Block\Infrastructure\Providers\BlockServiceProvider; -use Pollora\BlockBinding\Infrastructure\Providers\BlockBindingServiceProvider; +use Pollora\BlockBinding\Infrastructure\Laravel\BlockBindingServiceProvider; use Pollora\Collection\Infrastructure\Providers\CollectionServiceProvider; use Pollora\Config\Infrastructure\Providers\ConfigServiceProvider; use Pollora\Dashboard\Infrastructure\Providers\DashboardServiceProvider; @@ -27,13 +27,13 @@ use Pollora\Logging\Infrastructure\Providers\LoggingServiceProvider; use Pollora\Login\Infrastructure\Providers\LoginServiceProvider; use Pollora\Mail\WordPressMailServiceProvider; -use Pollora\Meta\Infrastructure\Providers\MetaServiceProvider; +use Pollora\Meta\Infrastructure\Laravel\MetaServiceProvider; use Pollora\Modules\Infrastructure\Providers\ModuleServiceProvider; use Pollora\Option\Infrastructure\Providers\OptionServiceProvider; use Pollora\Permalink\Infrastructure\Providers\PermalinkServiceProvider; use Pollora\Plugin\Infrastructure\Providers\PluginServiceProvider; use Pollora\PostType\Infrastructure\Providers\PostTypeServiceProvider; -use Pollora\Role\Infrastructure\Providers\RoleServiceProvider; +use Pollora\Role\Infrastructure\Laravel\RoleServiceProvider; use Pollora\Route\Infrastructure\Providers\RouteServiceProvider; use Pollora\Schedule\SchedulerServiceProvider; use Pollora\Taxonomy\Infrastructure\Providers\TaxonomyServiceProvider; diff --git a/src/Role/Application/Services/CapabilityOwnerReader.php b/src/Role/Application/Services/CapabilityOwnerReader.php deleted file mode 100644 index 54b30528..00000000 --- a/src/Role/Application/Services/CapabilityOwnerReader.php +++ /dev/null @@ -1,70 +0,0 @@ -}|null Null when the class is not a post type - */ - public function postType(string $class): ?array - { - if (! class_exists($class)) { - return null; - } - - $reflection = new ReflectionClass($class); - $postType = $reflection->getAttributes(PostType::class)[0] ?? null; - - if ($postType === null) { - return null; - } - - $capabilityType = $reflection->getAttributes(CapabilityType::class)[0] ?? null; - $capabilities = $reflection->getAttributes(PostTypeCapabilities::class)[0] ?? null; - - return [ - 'slug' => $postType->newInstance()->resolveSlug($class), - 'capabilityType' => $capabilityType?->newInstance()->value, - 'overrides' => $capabilities?->newInstance()->value ?? [], - ]; - } - - /** - * @return array{slug: string, capabilities: list}|null Null when the class is not a taxonomy - */ - public function taxonomy(string $class): ?array - { - if (! class_exists($class)) { - return null; - } - - $reflection = new ReflectionClass($class); - $taxonomy = $reflection->getAttributes(Taxonomy::class)[0] ?? null; - - if ($taxonomy === null) { - return null; - } - - $map = ($reflection->getAttributes(TaxonomyCapabilities::class)[0] ?? null)?->newInstance()->value ?? []; - - return [ - 'slug' => $taxonomy->newInstance()->resolveSlug($class), - 'capabilities' => array_values(array_intersect_key($map, array_flip(self::TERM_CAPABILITIES))), - ]; - } -} diff --git a/src/Role/Application/Services/RoleDefinitionBuilder.php b/src/Role/Application/Services/RoleDefinitionBuilder.php deleted file mode 100644 index d2cf93a1..00000000 --- a/src/Role/Application/Services/RoleDefinitionBuilder.php +++ /dev/null @@ -1,204 +0,0 @@ - $superRoles Roles that may not be inherited from - */ - public function __construct( - private CapabilityOwnerReader $owners, - private array $superRoles = ['administrator'], - ) {} - - /** - * @param class-string $class - * - * @throws InvalidRoleDefinitionException - */ - public function build(string $class): RoleDefinition|RoleModification - { - $reflection = new ReflectionClass($class); - $role = ($reflection->getAttributes(Role::class)[0] ?? null)?->newInstance(); - $modifyRole = ($reflection->getAttributes(ModifyRole::class)[0] ?? null)?->newInstance(); - - if ($role instanceof Role && $modifyRole instanceof ModifyRole) { - throw InvalidRoleDefinitionException::forClass($class, 'a class declares a role (#[Role]) or modifies one (#[ModifyRole]), not both.'); - } - - if (! $role instanceof Role && ! $modifyRole instanceof ModifyRole) { - throw InvalidRoleDefinitionException::forClass($class, 'it carries neither #[Role] nor #[ModifyRole].'); - } - - $changes = $this->changes($reflection, $role->allowSensitive ?? $modifyRole->allowSensitive); - - if ($modifyRole instanceof ModifyRole) { - return new RoleModification($modifyRole->slug, $changes, $class); - } - - if (in_array($role->slug, self::CORE_ROLES, true)) { - throw InvalidRoleDefinitionException::forClass($class, sprintf('"%s" is a core role; adjust it with #[ModifyRole(\'%s\')] instead of redeclaring it.', $role->slug, $role->slug)); - } - - $inherits = $role->inherits === null ? null : $this->resolveRoleSlug($class, $role->inherits); - - if ($inherits !== null && in_array($inherits, $this->superRoles, true)) { - throw InvalidRoleDefinitionException::forClass($class, sprintf('a role cannot inherit from the super role "%s".', $inherits)); - } - - return new RoleDefinition( - slug: $role->slug, - label: $role->label ?? Str::headline(class_basename($class)), - inherits: $inherits, - changes: $changes, - declaringClass: $class, - textDomain: $role->textDomain, - ); - } - - /** - * @param ReflectionClass $reflection - */ - private function changes(ReflectionClass $reflection, bool $allowSensitive): RoleChanges - { - $class = $reflection->getName(); - $grants = $this->capabilities($reflection, Grants::class); - $removals = $this->capabilities($reflection, Without::class); - - $both = array_intersect($grants, $removals); - if ($both !== []) { - throw InvalidRoleDefinitionException::forClass($class, sprintf('"%s" is both granted and removed.', implode('", "', $both))); - } - - $sensitive = array_intersect($grants, self::SENSITIVE_CAPABILITIES); - if ($sensitive !== [] && ! $allowSensitive) { - throw InvalidRoleDefinitionException::forClass($class, sprintf( - 'granting "%s" lets a user take over the site; confirm it with allowSensitive: true.', - implode('", "', $sensitive) - )); - } - - $postTypeGrants = []; - foreach ($reflection->getAttributes(GrantsPostType::class) as $attribute) { - $grant = $attribute->newInstance(); - $this->assertPostTypeHasCapabilities($class, $grant->postType); - $postTypeGrants[] = ['postType' => $grant->postType, 'access' => $grant->access]; - } - - $taxonomyGrants = []; - foreach ($reflection->getAttributes(GrantsTaxonomy::class) as $attribute) { - $taxonomy = $attribute->newInstance()->taxonomy; - $this->assertTaxonomyHasCapabilities($class, $taxonomy); - $taxonomyGrants[] = $taxonomy; - } - - return new RoleChanges(array_values(array_unique($grants)), array_values(array_unique($removals)), $postTypeGrants, $taxonomyGrants); - } - - /** - * @param ReflectionClass $reflection - * @param class-string $attributeClass - * @return list - */ - private function capabilities(ReflectionClass $reflection, string $attributeClass): array - { - $capabilities = []; - - foreach ($reflection->getAttributes($attributeClass) as $attribute) { - foreach ($attribute->newInstance()->capabilities as $capability) { - $capabilities[] = $capability instanceof BackedEnum ? (string) $capability->value : $capability; - } - } - - return $capabilities; - } - - private function resolveRoleSlug(string $class, string $reference): string - { - if (! class_exists($reference)) { - return $reference; - } - - $role = ((new ReflectionClass($reference))->getAttributes(Role::class)[0] ?? null)?->newInstance(); - - return $role instanceof Role ? $role->slug : throw InvalidRoleDefinitionException::forClass($class, sprintf('it inherits from %s, which is not a #[Role].', $reference)); - } - - private function assertPostTypeHasCapabilities(string $class, string $postType): void - { - if (! class_exists($postType)) { - return; - } - - $owner = $this->owners->postType($postType); - - if ($owner === null) { - throw InvalidRoleDefinitionException::forClass($class, sprintf('#[GrantsPostType] names %s, which is not a #[PostType].', $postType)); - } - - if ($owner['capabilityType'] === null) { - throw InvalidRoleDefinitionException::forClass($class, sprintf( - 'the post type "%s" shares the capabilities of posts; give %s its own with #[CapabilityType(\'%s\')] and #[MapMetaCap].', - $owner['slug'], - $postType, - str_replace('-', '_', $owner['slug']) - )); - } - } - - private function assertTaxonomyHasCapabilities(string $class, string $taxonomy): void - { - if (! class_exists($taxonomy)) { - return; - } - - $owner = $this->owners->taxonomy($taxonomy); - - if ($owner === null) { - throw InvalidRoleDefinitionException::forClass($class, sprintf('#[GrantsTaxonomy] names %s, which is not a #[Taxonomy].', $taxonomy)); - } - - if ($owner['capabilities'] === []) { - throw InvalidRoleDefinitionException::forClass($class, sprintf( - 'the taxonomy "%s" shares the capabilities of categories; give %s its own with #[Capabilities([...])] (manage_terms, edit_terms, delete_terms, assign_terms).', - $owner['slug'], - $taxonomy - )); - } - } -} diff --git a/src/Role/Application/Services/RoleReference.php b/src/Role/Application/Services/RoleReference.php deleted file mode 100644 index 369f085f..00000000 --- a/src/Role/Application/Services/RoleReference.php +++ /dev/null @@ -1,68 +0,0 @@ - */ - private static array $slugs = []; - - /** - * @throws InvalidArgumentException When a class is given that carries neither #[Role] nor #[ModifyRole] - */ - public static function slug(string $role): string - { - if (! class_exists($role)) { - return $role; - } - - return self::$slugs[$role] ??= self::slugOfClass($role); - } - - /** - * Whether one of the roles a user has is among the given ones, ignoring case - * as `@role` always has. - * - * @param array $userRoles The slugs the user has - * @param array $roles Slugs or role classes - */ - public static function matchesAny(array $userRoles, array $roles): bool - { - $userRoles = array_map(strtolower(...), $userRoles); - - foreach ($roles as $role) { - if (in_array(strtolower(self::slug($role)), $userRoles, true)) { - return true; - } - } - - return false; - } - - /** - * @param class-string $class - */ - private static function slugOfClass(string $class): string - { - $reflection = new ReflectionClass($class); - $attribute = ($reflection->getAttributes(Role::class)[0] ?? $reflection->getAttributes(ModifyRole::class)[0] ?? null)?->newInstance(); - - if (! $attribute instanceof Role && ! $attribute instanceof ModifyRole) { - throw new InvalidArgumentException(sprintf('%s does not name a role: it carries neither #[Role] nor #[ModifyRole].', $class)); - } - - return $attribute->slug; - } -} diff --git a/src/Role/Application/Services/RoleRegistry.php b/src/Role/Application/Services/RoleRegistry.php deleted file mode 100644 index 4032385f..00000000 --- a/src/Role/Application/Services/RoleRegistry.php +++ /dev/null @@ -1,268 +0,0 @@ - */ - private array $definitions = []; - - /** @var list */ - private array $modifications = []; - - /** @var array}> Post types with their own capabilities, by class and by slug */ - private array $postTypes = []; - - /** @var array}> Taxonomies with their own capabilities, by class and by slug */ - private array $taxonomies = []; - - /** @var list */ - private array $capabilitySets = []; - - public function __construct( - private readonly CapabilityOwnerReader $owners, - private readonly PostTypeCapabilityMap $postTypeCapabilities, - ) {} - - /** - * @throws InvalidRoleDefinitionException When the slug is taken or the inheritance loops - */ - public function addRole(RoleDefinition $definition): void - { - $existing = $this->definitions[$definition->slug] ?? null; - - if ($existing instanceof RoleDefinition && $existing->declaringClass !== $definition->declaringClass) { - throw InvalidRoleDefinitionException::forClass($definition->declaringClass, sprintf('the role "%s" is already declared by %s.', $definition->slug, $existing->declaringClass)); - } - - $chain = [$definition->slug]; - $parent = $definition->inherits; - - while ($parent !== null) { - if (in_array($parent, $chain, true)) { - throw InvalidRoleDefinitionException::forClass($definition->declaringClass, sprintf('inheritance loops: %s.', implode(' → ', [...$chain, $parent]))); - } - - if (! isset($this->definitions[$parent])) { - break; - } - - $chain[] = $parent; - $parent = $this->definitions[$parent]->inherits; - } - - $this->definitions[$definition->slug] = $definition; - } - - /** - * @throws InvalidRoleDefinitionException When another class removes what this one grants, or the reverse - */ - public function addModification(RoleModification $modification): void - { - foreach ($this->modifications as $existing) { - if ($existing->slug !== $modification->slug || $existing->declaringClass === $modification->declaringClass) { - continue; - } - - $conflicts = [ - ...array_intersect($existing->changes->grants, $modification->changes->removals), - ...array_intersect($existing->changes->removals, $modification->changes->grants), - ]; - - if ($conflicts !== []) { - throw InvalidRoleDefinitionException::forClass($modification->declaringClass, sprintf( - 'it contradicts %s on the role "%s": "%s" is both granted and removed.', - $existing->declaringClass, - $modification->slug, - implode('", "', array_unique($conflicts)) - )); - } - } - - $this->modifications = [ - ...array_filter($this->modifications, static fn (RoleModification $existing): bool => $existing->declaringClass !== $modification->declaringClass), - $modification, - ]; - } - - /** - * Records a post type class; only one with its own capability type counts. - */ - public function addPostType(string $class): void - { - $postType = $this->owners->postType($class); - - if ($postType === null || $postType['capabilityType'] === null) { - return; - } - - $this->postTypes[$class] = $this->postTypes[$postType['slug']] = [ - 'slug' => $postType['slug'], - 'capabilityType' => $postType['capabilityType'], - 'overrides' => $postType['overrides'], - ]; - } - - /** - * Records a taxonomy class; only one with its own term capabilities counts. - */ - public function addTaxonomy(string $class): void - { - $taxonomy = $this->owners->taxonomy($class); - - if ($taxonomy === null || $taxonomy['capabilities'] === []) { - return; - } - - $this->taxonomies[$class] = $this->taxonomies[$taxonomy['slug']] = $taxonomy; - } - - /** - * @param list $capabilities The values of a #[CapabilitySet] enum - */ - public function addCapabilitySet(array $capabilities): void - { - $this->capabilitySets = array_values(array_unique([...$this->capabilitySets, ...$capabilities])); - } - - public function isEmpty(): bool - { - return $this->definitions === [] && $this->modifications === [] && $this->postTypes === [] && $this->taxonomies === [] && $this->capabilitySets === []; - } - - /** - * Declared roles, with post type and taxonomy grants resolved into capability names. - * - * @param Closure(string): void|null $warn Receives the grants that could not be resolved - * @return list - */ - public function definitions(?Closure $warn = null): array - { - return array_values(array_map( - fn (RoleDefinition $definition): RoleDefinition => $definition->withChanges($this->resolve($definition->changes, $definition->declaringClass, $warn)), - $this->definitions - )); - } - - /** - * Modifications, with post type and taxonomy grants resolved into capability names. - * - * @param Closure(string): void|null $warn Receives the grants that could not be resolved - * @return list - */ - public function modifications(?Closure $warn = null): array - { - return array_values(array_map( - fn (RoleModification $modification): RoleModification => $modification->withChanges($this->resolve($modification->changes, $modification->declaringClass, $warn)), - $this->modifications - )); - } - - /** - * The text domain of each role label that has one, by label. - * - * @return array - */ - public function labelDomains(): array - { - $domains = []; - - foreach ($this->definitions as $definition) { - if ($definition->textDomain !== null) { - $domains[$definition->label] = $definition->textDomain; - } - } - - return $domains; - } - - /** - * Every capability the project declares, for the super roles. - * - * @return list - */ - public function declaredCapabilities(): array - { - $capabilities = $this->capabilitySets; - - foreach ($this->postTypes as $postType) { - $capabilities = [...$capabilities, ...$this->postTypeCapabilities->capabilities($postType['capabilityType'], Access::Editor, $postType['overrides'])]; - } - - foreach ($this->taxonomies as $taxonomy) { - $capabilities = [...$capabilities, ...$taxonomy['capabilities']]; - } - - return array_values(array_unique($capabilities)); - } - - /** - * @param Closure(string): void|null $warn - */ - private function resolve(RoleChanges $changes, string $declaringClass, ?Closure $warn): RoleChanges - { - $grants = $changes->grants; - - foreach ($changes->postTypeGrants as ['postType' => $reference, 'access' => $access]) { - if (! isset($this->postTypes[$reference]) && class_exists($reference)) { - $this->addPostType($reference); - } - - $postType = $this->postTypes[$reference] ?? null; - - if ($postType === null) { - $this->warnUnresolved($warn, $declaringClass, sprintf('the post type "%s" was not found with its own capabilities', $reference)); - - continue; - } - - $grants = [...$grants, ...$this->postTypeCapabilities->capabilities($postType['capabilityType'], $access, $postType['overrides'])]; - } - - foreach ($changes->taxonomyGrants as $reference) { - if (! isset($this->taxonomies[$reference]) && class_exists($reference)) { - $this->addTaxonomy($reference); - } - - $taxonomy = $this->taxonomies[$reference] ?? null; - - if ($taxonomy === null) { - $this->warnUnresolved($warn, $declaringClass, sprintf('the taxonomy "%s" was not found with its own capabilities', $reference)); - - continue; - } - - $grants = [...$grants, ...$taxonomy['capabilities']]; - } - - return $changes->withResolvedGrants($grants); - } - - /** - * @param Closure(string): void|null $warn - */ - private function warnUnresolved(?Closure $warn, string $declaringClass, string $reason): void - { - if ($warn instanceof Closure) { - $warn(sprintf('%s: %s; its grant is ignored.', $declaringClass, $reason)); - } - } -} diff --git a/src/Role/Domain/Contracts/RoleStoreInterface.php b/src/Role/Domain/Contracts/RoleStoreInterface.php deleted file mode 100644 index 435940ca..00000000 --- a/src/Role/Domain/Contracts/RoleStoreInterface.php +++ /dev/null @@ -1,43 +0,0 @@ -> - */ - public function storedRoles(): array; - - /** - * Replaces the stored roles. - * - * @param array> $roles - */ - public function saveStoredRoles(array $roles): void; - - /** - * The roles WordPress knows that a user carries. - * - * @return list - */ - public function rolesOf(int $userId): array; - - /** - * Removes an entry (a role or a capability) from a user's stored capabilities. - */ - public function removeFromUser(int $userId, string $entry): void; - - /** - * Gives a user a role, keeping the others. - */ - public function addRoleToUser(int $userId, string $role): void; -} diff --git a/src/Role/Domain/Contracts/RoleUsageInterface.php b/src/Role/Domain/Contracts/RoleUsageInterface.php deleted file mode 100644 index 42fa480b..00000000 --- a/src/Role/Domain/Contracts/RoleUsageInterface.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ - public function entries(): array; - - /** - * The users carrying an entry, by ID, with their login. - * - * @return array - */ - public function usersWith(string $entry): array; -} diff --git a/src/Role/Domain/Exceptions/InvalidRoleDefinitionException.php b/src/Role/Domain/Exceptions/InvalidRoleDefinitionException.php deleted file mode 100644 index 968881b8..00000000 --- a/src/Role/Domain/Exceptions/InvalidRoleDefinitionException.php +++ /dev/null @@ -1,21 +0,0 @@ - $grants Capability names granted - * @param list $removals Capability names removed - * @param list $postTypeGrants Post types (class or slug) granted at a level - * @param list $taxonomyGrants Taxonomies (class or slug) whose term capabilities are granted - */ - public function __construct( - public array $grants = [], - public array $removals = [], - public array $postTypeGrants = [], - public array $taxonomyGrants = [], - ) {} - - /** - * @param list $grants - */ - public function withResolvedGrants(array $grants): self - { - return new self(array_values(array_unique($grants)), $this->removals); - } -} diff --git a/src/Role/Domain/Models/RoleDefinition.php b/src/Role/Domain/Models/RoleDefinition.php deleted file mode 100644 index 25d38890..00000000 --- a/src/Role/Domain/Models/RoleDefinition.php +++ /dev/null @@ -1,28 +0,0 @@ -slug, $this->label, $this->inherits, $changes, $this->declaringClass, $this->textDomain); - } -} diff --git a/src/Role/Domain/Models/RoleModification.php b/src/Role/Domain/Models/RoleModification.php deleted file mode 100644 index 4c05d8b8..00000000 --- a/src/Role/Domain/Models/RoleModification.php +++ /dev/null @@ -1,25 +0,0 @@ -slug, $changes, $this->declaringClass); - } -} diff --git a/src/Role/Domain/Services/PostTypeCapabilityMap.php b/src/Role/Domain/Services/PostTypeCapabilityMap.php deleted file mode 100644 index ed875478..00000000 --- a/src/Role/Domain/Services/PostTypeCapabilityMap.php +++ /dev/null @@ -1,33 +0,0 @@ - $overrides The #[Capabilities] map, by generic name - * @return list - */ - public function capabilities(string $capabilityType, Access $access, array $overrides = []): array - { - $plural = $capabilityType.'s'; - - return array_map( - static fn (string $generic): string => $overrides[$generic] ?? str_replace('_posts', '_'.$plural, $generic), - $access->genericCapabilities() - ); - } -} diff --git a/src/Role/Domain/Services/RoleCompiler.php b/src/Role/Domain/Services/RoleCompiler.php deleted file mode 100644 index 3e5761d0..00000000 --- a/src/Role/Domain/Services/RoleCompiler.php +++ /dev/null @@ -1,228 +0,0 @@ -, _pollora?: array} - */ -final class RoleCompiler -{ - /** - * Key of the marker Pollora keeps in the role entries it touches. - */ - public const string MARKER = '_pollora'; - - /** - * @param array $storedRoles `WP_Roles::$roles` - * @param list $definitions Declared roles, with resolved grants - * @param list $modifications Modifications, with resolved grants - * @param list $superRoles Roles receiving every declared capability - * @param list $declaredCapabilities Capabilities of post types, taxonomies and capability sets - * @param Closure(string): void|null $warn Receives what could not be applied - * @return array - */ - public function compile( - array $storedRoles, - array $definitions, - array $modifications, - array $superRoles, - array $declaredCapabilities, - ?Closure $warn = null, - ): array { - $roles = $this->undo($storedRoles); - - $declared = []; - foreach ($definitions as $definition) { - $declared[$definition->slug] = $definition; - } - - foreach ($modifications as $modification) { - if (! isset($declared[$modification->slug])) { - $this->modify($roles, $modification, $warn); - } - } - - $resolved = []; - foreach ($declared as $slug => $definition) { - $roles[$slug] = [ - 'name' => $definition->label, - 'capabilities' => $this->capabilitiesOf($slug, $declared, $roles, $resolved, $warn), - self::MARKER => ['managed' => true], - ]; - } - - foreach ($modifications as $modification) { - if (isset($declared[$modification->slug])) { - $this->modify($roles, $modification, $warn); - } - } - - foreach ($superRoles as $superRole) { - if (isset($roles[$superRole])) { - $this->grant($roles[$superRole], $declaredCapabilities); - } - } - - return $roles; - } - - /** - * Removes what a previous compilation added: managed roles, granted capabilities, - * removed capabilities (restored with their original value). - * - * @param array $roles - * @return array - */ - private function undo(array $roles): array - { - foreach ($roles as $slug => $role) { - $marker = $role[self::MARKER] ?? null; - - if (! is_array($marker)) { - continue; - } - - if (($marker['managed'] ?? false) === true) { - unset($roles[$slug]); - - continue; - } - - foreach ($marker['granted'] ?? [] as $capability) { - unset($role['capabilities'][$capability]); - } - - foreach ($marker['removed'] ?? [] as $capability => $value) { - $role['capabilities'][$capability] = $value; - } - - unset($role[self::MARKER]); - $roles[$slug] = $role; - } - - return $roles; - } - - /** - * @param array $declared - * @param array $roles - * @param array> $resolved Memo of capabilities already resolved - * @param Closure(string): void|null $warn - * @param list $resolving Slugs being resolved, to stop a cycle - * @return array - */ - private function capabilitiesOf(string $slug, array $declared, array $roles, array &$resolved, ?Closure $warn, array $resolving = []): array - { - if (isset($resolved[$slug])) { - return $resolved[$slug]; - } - - $definition = $declared[$slug]; - $capabilities = []; - - if ($definition->inherits !== null) { - if (in_array($definition->inherits, $resolving, true)) { - $this->warn($warn, sprintf('The role "%s" inherits from itself through "%s"; inheritance ignored.', $slug, $definition->inherits)); - } elseif (isset($declared[$definition->inherits])) { - $capabilities = $this->capabilitiesOf($definition->inherits, $declared, $roles, $resolved, $warn, [...$resolving, $slug]); - } elseif (isset($roles[$definition->inherits])) { - $capabilities = $roles[$definition->inherits]['capabilities']; - } else { - $this->warn($warn, sprintf('The role "%s" inherits from "%s", which does not exist; it starts with no capability.', $slug, $definition->inherits)); - } - } - - foreach ($definition->changes->grants as $capability) { - $capabilities[$capability] = true; - } - - foreach ($definition->changes->removals as $capability) { - unset($capabilities[$capability]); - } - - return $resolved[$slug] = $capabilities; - } - - /** - * @param array $roles - * @param Closure(string): void|null $warn - */ - private function modify(array &$roles, RoleModification $modification, ?Closure $warn): void - { - if (! isset($roles[$modification->slug])) { - $this->warn($warn, sprintf('%s modifies the role "%s", which does not exist.', $modification->declaringClass, $modification->slug)); - - return; - } - - $role = &$roles[$modification->slug]; - $this->grant($role, $modification->changes->grants); - - foreach ($modification->changes->removals as $capability) { - if (! array_key_exists($capability, $role['capabilities'])) { - continue; - } - - if (! in_array($capability, $role[self::MARKER]['granted'] ?? [], true)) { - $role[self::MARKER]['removed'][$capability] = $role['capabilities'][$capability]; - } - - unset($role['capabilities'][$capability]); - } - } - - /** - * Grants capabilities, remembering those the role did not already have. - * - * @param StoredRole $role - * @param list $capabilities - */ - private function grant(array &$role, array $capabilities): void - { - $managed = ($role[self::MARKER]['managed'] ?? false) === true; - - foreach ($capabilities as $capability) { - if (($role['capabilities'][$capability] ?? false) === true) { - continue; - } - - $role['capabilities'][$capability] = true; - - if (! $managed) { - $role[self::MARKER]['granted'][] = $capability; - } - } - } - - /** - * @param Closure(string): void|null $warn - */ - private function warn(?Closure $warn, string $message): void - { - if ($warn instanceof Closure) { - $warn($message); - } - } -} diff --git a/src/Role/Domain/Services/StoredEntryClassifier.php b/src/Role/Domain/Services/StoredEntryClassifier.php deleted file mode 100644 index 75e49ddc..00000000 --- a/src/Role/Domain/Services/StoredEntryClassifier.php +++ /dev/null @@ -1,42 +0,0 @@ -}> $roles The roles WordPress has, by slug - */ - public function classify(string $entry, array $roles): string - { - if (isset($roles[$entry])) { - return self::ROLE; - } - - foreach ($roles as $role) { - if (array_key_exists($entry, $role['capabilities'] ?? [])) { - return self::CAPABILITY; - } - } - - return self::DEAD; - } -} diff --git a/src/Role/Infrastructure/Adapters/WordPressRoleInjector.php b/src/Role/Infrastructure/Adapters/WordPressRoleInjector.php deleted file mode 100644 index f3f5a32f..00000000 --- a/src/Role/Infrastructure/Adapters/WordPressRoleInjector.php +++ /dev/null @@ -1,133 +0,0 @@ - What the last compilation could not apply - */ - private array $warnings = []; - - /** - * @param list $superRoles - */ - public function __construct( - private readonly RoleRegistry $registry, - private readonly RoleCompiler $compiler, - private readonly array $superRoles = ['administrator'], - private readonly ?LoggerInterface $logger = null, - ) {} - - /** - * Compiles the roles of a `WP_Roles` instance and puts them in place. Hooked on `wp_roles_init`. - */ - public function inject(WP_Roles $wpRoles): void - { - if ($this->registry->isEmpty() && ! $this->hasMarkedRoles($wpRoles->roles)) { - return; - } - - $this->warnings = []; - $warn = function (string $message): void { - $this->warnings[] = $message; - }; - - $roles = $this->compiler->compile( - $wpRoles->roles, - $this->registry->definitions($warn), - $this->registry->modifications($warn), - $this->superRoles, - $this->registry->declaredCapabilities(), - $warn, - ); - - $wpRoles->roles = $roles; - $wpRoles->role_objects = []; - $wpRoles->role_names = []; - - foreach ($roles as $slug => $role) { - $wpRoles->role_objects[$slug] = new WP_Role($slug, $role['capabilities']); - $wpRoles->role_names[$slug] = $role['name']; - } - } - - /** - * Injects declarations added after WordPress built its roles, and recomputes - * the current user's capabilities if they were already computed. - */ - public function refresh(): void - { - $wpRoles = $GLOBALS['wp_roles'] ?? null; - - if (! $wpRoles instanceof WP_Roles) { - return; - } - - $this->inject($wpRoles); - - if (\did_action('set_current_user') > 0) { - $user = \wp_get_current_user(); - - if ($user->exists()) { - $user->get_role_caps(); - } - } - } - - /** - * What the last compilation could not apply (an unknown inherited role, a - * modification of a role that does not exist…). - * - * @return list - */ - public function warnings(): array - { - return array_values(array_unique($this->warnings)); - } - - /** - * Logs what the last compilation could not apply. Hooked late, once every - * location has been discovered. - */ - public function reportWarnings(): void - { - foreach (array_unique($this->warnings) as $warning) { - $this->logger?->warning('Roles: '.$warning); - } - } - - /** - * @param array> $roles - */ - private function hasMarkedRoles(array $roles): bool - { - foreach ($roles as $role) { - if (isset($role[RoleCompiler::MARKER])) { - return true; - } - } - - return false; - } -} diff --git a/src/Role/Infrastructure/Adapters/WordPressRoleLabelTranslator.php b/src/Role/Infrastructure/Adapters/WordPressRoleLabelTranslator.php deleted file mode 100644 index 369b1bd4..00000000 --- a/src/Role/Infrastructure/Adapters/WordPressRoleLabelTranslator.php +++ /dev/null @@ -1,33 +0,0 @@ -registry->labelDomains()[$text] ?? null; - - return $domain === null ? $translation : translate($text, $domain); - } -} diff --git a/src/Role/Infrastructure/Adapters/WordPressRoleStore.php b/src/Role/Infrastructure/Adapters/WordPressRoleStore.php deleted file mode 100644 index 3b9001e6..00000000 --- a/src/Role/Infrastructure/Adapters/WordPressRoleStore.php +++ /dev/null @@ -1,61 +0,0 @@ -optionName(), []); - - return is_array($roles) ? $roles : []; - } - - public function saveStoredRoles(array $roles): void - { - \update_option($this->optionName(), $roles); - } - - public function rolesOf(int $userId): array - { - $user = \get_userdata($userId); - - return $user instanceof \WP_User ? array_values($user->roles) : []; - } - - public function removeFromUser(int $userId, string $entry): void - { - $user = \get_userdata($userId); - - // remove_cap(), not remove_role(): WP_User::$roles only lists roles WordPress - // knows, so remove_role() ignores a role that no longer exists - if ($user instanceof \WP_User) { - $user->remove_cap($entry); - } - } - - public function addRoleToUser(int $userId, string $role): void - { - $user = \get_userdata($userId); - - if ($user instanceof \WP_User) { - $user->add_role($role); - } - } - - private function optionName(): string - { - /** @var \wpdb $wpdb */ - $wpdb = $GLOBALS['wpdb']; - - return $wpdb->get_blog_prefix().'user_roles'; - } -} diff --git a/src/Role/Infrastructure/Adapters/WordPressRoleUsage.php b/src/Role/Infrastructure/Adapters/WordPressRoleUsage.php deleted file mode 100644 index 0fb6138e..00000000 --- a/src/Role/Infrastructure/Adapters/WordPressRoleUsage.php +++ /dev/null @@ -1,77 +0,0 @@ ->|null Stored capabilities by user ID, read once */ - private ?array $capabilities = null; - - public function entries(): array - { - $entries = []; - - foreach ($this->capabilities() as $capabilities) { - foreach ($capabilities as $name => $granted) { - if ($granted) { - $entries[(string) $name] = ($entries[(string) $name] ?? 0) + 1; - } - } - } - - ksort($entries); - - return $entries; - } - - public function usersWith(string $entry): array - { - $users = []; - - foreach ($this->capabilities() as $userId => $capabilities) { - if (! empty($capabilities[$entry])) { - $user = \get_userdata($userId); - $users[$userId] = $user instanceof \WP_User ? $user->user_login : '#'.$userId; - } - } - - return $users; - } - - /** - * @return array> - */ - private function capabilities(): array - { - if ($this->capabilities !== null) { - return $this->capabilities; - } - - /** @var \wpdb $wpdb */ - $wpdb = $GLOBALS['wpdb']; - - /** @var list $rows */ - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT user_id, meta_value FROM {$wpdb->usermeta} WHERE meta_key = %s", - $wpdb->get_blog_prefix().'capabilities' - )); - - $this->capabilities = []; - - foreach ($rows as $row) { - $value = \is_serialized($row->meta_value) ? @unserialize(trim($row->meta_value), ['allowed_classes' => false]) : null; - $this->capabilities[(int) $row->user_id] = is_array($value) ? $value : []; - } - - return $this->capabilities; - } -} diff --git a/src/Role/UI/Console/RoleDumpCommand.php b/src/Role/Infrastructure/Laravel/Console/RoleDumpCommand.php similarity index 98% rename from src/Role/UI/Console/RoleDumpCommand.php rename to src/Role/Infrastructure/Laravel/Console/RoleDumpCommand.php index 90549987..4be02fe7 100644 --- a/src/Role/UI/Console/RoleDumpCommand.php +++ b/src/Role/Infrastructure/Laravel/Console/RoleDumpCommand.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pollora\Role\UI\Console; +namespace Pollora\Role\Infrastructure\Laravel\Console; use Illuminate\Console\Attributes\Description; use Illuminate\Console\Attributes\Signature; diff --git a/src/Role/UI/Console/RoleImportCommand.php b/src/Role/Infrastructure/Laravel/Console/RoleImportCommand.php similarity index 99% rename from src/Role/UI/Console/RoleImportCommand.php rename to src/Role/Infrastructure/Laravel/Console/RoleImportCommand.php index 54cd67b0..205dc1a8 100644 --- a/src/Role/UI/Console/RoleImportCommand.php +++ b/src/Role/Infrastructure/Laravel/Console/RoleImportCommand.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pollora\Role\UI\Console; +namespace Pollora\Role\Infrastructure\Laravel\Console; use Illuminate\Console\Attributes\Description; use Illuminate\Support\Str; diff --git a/src/Role/UI/Console/RoleListCommand.php b/src/Role/Infrastructure/Laravel/Console/RoleListCommand.php similarity index 97% rename from src/Role/UI/Console/RoleListCommand.php rename to src/Role/Infrastructure/Laravel/Console/RoleListCommand.php index 8321a526..8eef1be6 100644 --- a/src/Role/UI/Console/RoleListCommand.php +++ b/src/Role/Infrastructure/Laravel/Console/RoleListCommand.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pollora\Role\UI\Console; +namespace Pollora\Role\Infrastructure\Laravel\Console; use Illuminate\Console\Attributes\Description; use Illuminate\Console\Attributes\Signature; diff --git a/src/Role/UI/Console/RoleMakeCommand.php b/src/Role/Infrastructure/Laravel/Console/RoleMakeCommand.php similarity index 94% rename from src/Role/UI/Console/RoleMakeCommand.php rename to src/Role/Infrastructure/Laravel/Console/RoleMakeCommand.php index de6f1a2e..1bc4d802 100644 --- a/src/Role/UI/Console/RoleMakeCommand.php +++ b/src/Role/Infrastructure/Laravel/Console/RoleMakeCommand.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pollora\Role\UI\Console; +namespace Pollora\Role\Infrastructure\Laravel\Console; use Illuminate\Console\Attributes\Description; use Illuminate\Support\Str; diff --git a/src/Role/UI/Console/RolePruneCommand.php b/src/Role/Infrastructure/Laravel/Console/RolePruneCommand.php similarity index 98% rename from src/Role/UI/Console/RolePruneCommand.php rename to src/Role/Infrastructure/Laravel/Console/RolePruneCommand.php index 4035772a..a383dd06 100644 --- a/src/Role/UI/Console/RolePruneCommand.php +++ b/src/Role/Infrastructure/Laravel/Console/RolePruneCommand.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pollora\Role\UI\Console; +namespace Pollora\Role\Infrastructure\Laravel\Console; use Illuminate\Console\Attributes\Description; use Illuminate\Console\Attributes\Signature; diff --git a/src/Role/UI/Console/RoleShowCommand.php b/src/Role/Infrastructure/Laravel/Console/RoleShowCommand.php similarity index 98% rename from src/Role/UI/Console/RoleShowCommand.php rename to src/Role/Infrastructure/Laravel/Console/RoleShowCommand.php index b73a6ebb..e4757f54 100644 --- a/src/Role/UI/Console/RoleShowCommand.php +++ b/src/Role/Infrastructure/Laravel/Console/RoleShowCommand.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pollora\Role\UI\Console; +namespace Pollora\Role\Infrastructure\Laravel\Console; use Illuminate\Console\Attributes\Description; use Illuminate\Console\Attributes\Signature; diff --git a/src/Role/UI/Console/stubs/role-import.stub b/src/Role/Infrastructure/Laravel/Console/stubs/role-import.stub similarity index 100% rename from src/Role/UI/Console/stubs/role-import.stub rename to src/Role/Infrastructure/Laravel/Console/stubs/role-import.stub diff --git a/src/Role/UI/Console/stubs/role.stub b/src/Role/Infrastructure/Laravel/Console/stubs/role.stub similarity index 100% rename from src/Role/UI/Console/stubs/role.stub rename to src/Role/Infrastructure/Laravel/Console/stubs/role.stub diff --git a/src/Role/Infrastructure/Middleware/EnsureUserHasRole.php b/src/Role/Infrastructure/Laravel/EnsureUserHasRole.php similarity index 96% rename from src/Role/Infrastructure/Middleware/EnsureUserHasRole.php rename to src/Role/Infrastructure/Laravel/EnsureUserHasRole.php index 9a512e8d..3c1da4fc 100644 --- a/src/Role/Infrastructure/Middleware/EnsureUserHasRole.php +++ b/src/Role/Infrastructure/Laravel/EnsureUserHasRole.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pollora\Role\Infrastructure\Middleware; +namespace Pollora\Role\Infrastructure\Laravel; use Closure; use Illuminate\Auth\Access\AuthorizationException; diff --git a/src/Role/UI/View/RoleDirective.php b/src/Role/Infrastructure/Laravel/RoleDirective.php similarity index 93% rename from src/Role/UI/View/RoleDirective.php rename to src/Role/Infrastructure/Laravel/RoleDirective.php index 381044ff..0f975f22 100644 --- a/src/Role/UI/View/RoleDirective.php +++ b/src/Role/Infrastructure/Laravel/RoleDirective.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pollora\Role\UI\View; +namespace Pollora\Role\Infrastructure\Laravel; use Pollora\Role\Application\Services\RoleReference; diff --git a/src/Role/Infrastructure/Providers/RoleServiceProvider.php b/src/Role/Infrastructure/Laravel/RoleServiceProvider.php similarity index 91% rename from src/Role/Infrastructure/Providers/RoleServiceProvider.php rename to src/Role/Infrastructure/Laravel/RoleServiceProvider.php index 1e568772..bdfd20b5 100644 --- a/src/Role/Infrastructure/Providers/RoleServiceProvider.php +++ b/src/Role/Infrastructure/Laravel/RoleServiceProvider.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pollora\Role\Infrastructure\Providers; +namespace Pollora\Role\Infrastructure\Laravel; use Illuminate\Contracts\Foundation\Application; use Illuminate\Routing\Router; @@ -22,16 +22,13 @@ use Pollora\Role\Infrastructure\Adapters\WordPressRoleLabelTranslator; use Pollora\Role\Infrastructure\Adapters\WordPressRoleStore; use Pollora\Role\Infrastructure\Adapters\WordPressRoleUsage; -use Pollora\Role\Infrastructure\Checks\RolesCheck; -use Pollora\Role\Infrastructure\Middleware\EnsureUserHasRole; +use Pollora\Role\Infrastructure\Laravel\Console\RoleDumpCommand; +use Pollora\Role\Infrastructure\Laravel\Console\RoleImportCommand; +use Pollora\Role\Infrastructure\Laravel\Console\RoleListCommand; +use Pollora\Role\Infrastructure\Laravel\Console\RoleMakeCommand; +use Pollora\Role\Infrastructure\Laravel\Console\RolePruneCommand; +use Pollora\Role\Infrastructure\Laravel\Console\RoleShowCommand; use Pollora\Role\Infrastructure\Services\RoleDiscovery; -use Pollora\Role\UI\Console\RoleDumpCommand; -use Pollora\Role\UI\Console\RoleImportCommand; -use Pollora\Role\UI\Console\RoleListCommand; -use Pollora\Role\UI\Console\RoleMakeCommand; -use Pollora\Role\UI\Console\RolePruneCommand; -use Pollora\Role\UI\Console\RoleShowCommand; -use Pollora\Role\UI\View\RoleDirective; use Psr\Log\LoggerInterface; /** diff --git a/src/Role/Infrastructure/Checks/RolesCheck.php b/src/Role/Infrastructure/Laravel/RolesCheck.php similarity index 98% rename from src/Role/Infrastructure/Checks/RolesCheck.php rename to src/Role/Infrastructure/Laravel/RolesCheck.php index 0990f469..bb7c47f3 100644 --- a/src/Role/Infrastructure/Checks/RolesCheck.php +++ b/src/Role/Infrastructure/Laravel/RolesCheck.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pollora\Role\Infrastructure\Checks; +namespace Pollora\Role\Infrastructure\Laravel; use Pollora\Doctor\Domain\Contracts\CheckInterface; use Pollora\Doctor\Domain\Enums\RunContext; diff --git a/src/Role/Infrastructure/Services/RoleDiscovery.php b/src/Role/Infrastructure/Services/RoleDiscovery.php deleted file mode 100644 index 8ca88dbe..00000000 --- a/src/Role/Infrastructure/Services/RoleDiscovery.php +++ /dev/null @@ -1,113 +0,0 @@ - 'role', - ModifyRole::class => 'role', - CapabilitySet::class => 'capabilitySet', - PostType::class => 'postType', - Taxonomy::class => 'taxonomy', - ]; - - public function __construct( - private readonly RoleDefinitionBuilder $builder, - private readonly RoleRegistry $registry, - private readonly WordPressRoleInjector $injector, - private readonly ?LoggerInterface $logger = null, - ) {} - - public function discover(DiscoveryLocationInterface $location, DiscoveredStructure $structure, ?ReflectionCacheInterface $reflectionCache = null): void - { - $isClass = $structure instanceof DiscoveredClass && ! $structure->isAbstract; - - if (! $isClass && ! $structure instanceof DiscoveredEnum) { - return; - } - - foreach ($structure->attributes as $attribute) { - $kind = self::KINDS[$attribute->class] ?? null; - - if ($kind === null || ($kind === 'capabilitySet') === $isClass) { - continue; - } - - $this->getItems()->add($location, ['class' => $structure->namespace.'\\'.$structure->name, 'kind' => $kind]); - - return; - } - } - - public function apply(): void - { - foreach ($this->getItems() as ['class' => $class, 'kind' => $kind]) { - try { - match ($kind) { - 'role' => $this->addRole($class), - 'capabilitySet' => $this->registry->addCapabilitySet(array_map( - static fn (BackedEnum $case): string => (string) $case->value, - $class::cases() - )), - 'postType' => $this->registry->addPostType($class), - 'taxonomy' => $this->registry->addTaxonomy($class), - }; - } catch (\Throwable $throwable) { - $this->logger?->error(sprintf('Failed to register the role declaration %s: %s', $class, $throwable->getMessage()), ['exception' => $throwable]); - } - } - - $this->injector->refresh(); - } - - public function getIdentifier(): string - { - return 'roles'; - } - - /** - * @param class-string $class - */ - private function addRole(string $class): void - { - $declaration = $this->builder->build($class); - - if ($declaration instanceof RoleDefinition) { - $this->registry->addRole($declaration); - } else { - $this->registry->addModification($declaration); - } - } -} diff --git a/tests/Feature/Asset/AssetContainersContractTest.php b/tests/Feature/Asset/AssetContainersContractTest.php new file mode 100644 index 00000000..3cdfc665 --- /dev/null +++ b/tests/Feature/Asset/AssetContainersContractTest.php @@ -0,0 +1,266 @@ +suffix = uniqid(); + $this->names = [ + 'theme' => 'apiary-'.$this->suffix, + 'plugin' => 'acme-'.$this->suffix, + 'module' => 'crm-'.$this->suffix, + ]; + + // The manager AssetFile resolves through the container + $this->assetManager = new AssetManager(Mockery::mock(AssetRetrievalService::class)); + $this->app->instance(AssetManager::class, $this->assetManager); + $this->app->singleton(MissingBuilds::class); + $this->app->singleton(ViteManagerFactory::class, LaravelViteManagerFactory::class); + AssetFile::resolveUsing(Closure::fromCallable(new LaravelAssetFileResolver($this->app))); + + $this->modules = new ModuleAssetManager($this->app); + $this->modules->setupModuleAssetContainer($this->names['theme'], '/themes/apiary', 'theme'); + $this->modules->setupModuleAssetContainer($this->names['plugin'], '/plugins/acme', 'plugin', 'acme'); + $this->modules->setupModuleAssetContainer($this->names['module'], '/modules/Crm', 'module', 'crm'); + + $this->hotFiles = []; + $this->builds = []; + + /** Write the production build of a container: its manifest */ + $this->build = function (string $type, string $name, array $manifest): void { + $directory = public_path('build/'.$type.'/'.$name); + (new Filesystem)->ensureDirectoryExists($directory); + file_put_contents($directory.'/manifest.json', json_encode($manifest)); + $this->builds[] = public_path('build/'.$type); + }; + + /** Start the dev server of a container: its hot file */ + $this->serve = function (string $name, string $url): void { + $this->hotFiles[] = $file = public_path($name.'.hot'); + file_put_contents($file, $url); + }; +}); + +afterEach(function (): void { + AssetFile::resolveUsing(null); + + foreach (array_unique($this->builds) as $directory) { + foreach ($this->names as $name) { + (new Filesystem)->deleteDirectory($directory.'/'.$name); + } + } + + foreach ($this->hotFiles as $file) { + @unlink($file); + } +}); + +describe('the containers of a theme, a plugin and a module', function (): void { + it('name each one after its kind and its slug, and keep the layout @pollora/vite-config builds to', function (): void { + $theme = $this->assetManager->getContainer('theme'); + $plugin = $this->assetManager->getContainer('plugin.acme'); + $module = $this->assetManager->getContainer('module.crm'); + + expect($theme)->toBeInstanceOf(AssetContainer::class) + ->and([$theme->getHotFile(), $theme->getBuildDirectory(), $theme->getManifestPath(), $theme->getBasePath()]) + ->toBe([public_path($this->names['theme'].'.hot'), 'build/theme/'.$this->names['theme'], 'manifest.json', 'resources/assets/']) + ->and([$plugin->getHotFile(), $plugin->getBuildDirectory(), $plugin->getManifestPath(), $plugin->getBasePath()]) + ->toBe([public_path($this->names['plugin'].'.hot'), 'build/plugin/'.$this->names['plugin'], 'manifest.json', 'resources/assets/']) + ->and([$module->getHotFile(), $module->getBuildDirectory(), $module->getManifestPath(), $module->getBasePath()]) + ->toBe([public_path($this->names['module'].'.hot'), 'build/module/'.$this->names['module'], 'manifest.json', 'resources/assets/']) + ->and(array_keys($this->assetManager->containers()))->toBe(['theme', 'plugin.acme', 'module.crm']); + }); + + it('are the ones pollora:doctor expects', function (): void { + expect($this->modules->expectedAssetConfiguration($this->names['plugin'], 'plugin', 'acme'))->toBe([ + 'container' => 'plugin.acme', + 'hot_file' => public_path($this->names['plugin'].'.hot'), + 'build_directory' => 'build/plugin/'.$this->names['plugin'], + 'manifest_path' => 'manifest.json', + ]); + }); +}); + +describe('the root container', function (): void { + it('is the default one, on public/hot and public/build', function (): void { + (new RootAssetManager($this->assetManager))->registerRootAssets(); + + $root = $this->assetManager->getDefaultContainer(); + + expect($root->getName())->toBe('root') + ->and([$root->getHotFile(), $root->getBuildDirectory(), $root->getManifestPath(), $root->getBasePath()]) + ->toBe([public_path('hot'), 'build', 'manifest.json', '']) + ->and($this->assetManager->getContainer('root'))->toBe($root); + }); +}); + +describe('the blocks container of a theme, a plugin or a module', function (): void { + it('shares the hot file, the build and the manifest of its parent, with no base path', function (): void { + $registrar = new class($this->assetManager, Mockery::mock(HookFilter::class)) extends BlockRegistrar + { + public function blocksManager(string $parent): ?ViteManager + { + return $this->getBlocksViteManager($parent); + } + }; + + $manager = $registrar->blocksManager('plugin.acme'); + $parent = $this->assetManager->getContainer('plugin.acme'); + $blocks = $manager->container(); + + expect($blocks->getName())->toBe('plugin.acme.blocks') + ->and([$blocks->getHotFile(), $blocks->getBuildDirectory(), $blocks->getManifestPath(), $blocks->getBasePath()]) + ->toBe([$parent->getHotFile(), $parent->getBuildDirectory(), $parent->getManifestPath(), '']) + ->and($this->assetManager->getContainer('plugin.acme.blocks'))->toBe($blocks) + ->and($registrar->blocksManager('plugin.nothing'))->toBeNull(); + }); +}); + +describe('built for production', function (): void { + beforeEach(function (): void { + ($this->build)('theme', $this->names['theme'], [ + 'resources/assets/app.js' => ['file' => 'assets/app-AAA.js', 'css' => ['assets/app-BBB.css']], + 'resources/assets/app.css' => ['file' => 'assets/app-CCC.css'], + 'resources/assets/images/logo.svg' => ['file' => 'assets/logo-DDD.svg'], + ]); + ($this->build)('plugin', $this->names['plugin'], [ + 'resources/assets/admin.js' => ['file' => 'assets/admin-EEE.js'], + 'resources/views/blocks/card/index.jsx' => ['file' => 'assets/index-FFF.js', 'css' => ['assets/index-GGG.css']], + ]); + ($this->build)('module', $this->names['module'], [ + 'resources/assets/app.js' => ['file' => 'assets/app-HHH.js'], + ]); + }); + + it('gives each container the URLs of its own build', function (): void { + $theme = new ViteManager($this->assetManager->getContainer('theme')); + $plugin = new ViteManager($this->assetManager->getContainer('plugin.acme')); + $module = new ViteManager($this->assetManager->getContainer('module.crm')); + + expect($theme->isRunningHot())->toBeFalse() + ->and($theme->getAssetUrls(['app.js']))->toBe([ + 'js' => [asset('build/theme/'.$this->names['theme'].'/assets/app-AAA.js')], + 'css' => [asset('build/theme/'.$this->names['theme'].'/assets/app-BBB.css')], + ]) + ->and($theme->getAssetUrls(['app.css']))->toBe([ + 'js' => [], + 'css' => [asset('build/theme/'.$this->names['theme'].'/assets/app-CCC.css')], + ]) + ->and($plugin->getAssetUrls(['admin.js']))->toBe([ + 'js' => [asset('build/plugin/'.$this->names['plugin'].'/assets/admin-EEE.js')], + 'css' => [], + ]) + ->and($module->getAssetUrls(['app.js']))->toBe([ + 'js' => [asset('build/module/'.$this->names['module'].'/assets/app-HHH.js')], + 'css' => [], + ]); + }); + + it('resolves a file the way the manifest names it, under the base path of its container', function (): void { + $theme = new ViteManager($this->assetManager->getContainer('theme')); + + expect($theme->asset('images/logo.svg'))->toBe(asset('build/theme/'.$this->names['theme'].'/assets/logo-DDD.svg')) + ->and((string) (new AssetFile('images/logo.svg'))->from('theme')) + ->toBe(asset('build/theme/'.$this->names['theme'].'/assets/logo-DDD.svg')); + }); + + it('resolves block entries from the blocks container, whose base path is empty', function (): void { + $registrar = new class($this->assetManager, Mockery::mock(HookFilter::class)) extends BlockRegistrar + { + public function blocksManager(string $parent): ?ViteManager + { + return $this->getBlocksViteManager($parent); + } + }; + + expect($registrar->blocksManager('plugin.acme')->getAssetUrls(['resources/views/blocks/card/index.jsx']))->toBe([ + 'js' => [asset('build/plugin/'.$this->names['plugin'].'/assets/index-FFF.js')], + 'css' => [asset('build/plugin/'.$this->names['plugin'].'/assets/index-GGG.css')], + ]); + }); + + it('gives nothing, and says so once, for a container whose build is missing', function (): void { + $this->modules->setupModuleAssetContainer('nothing-'.$this->suffix, '/plugins/nothing', 'plugin', 'nothing'); + $unbuilt = new ViteManager($this->assetManager->getContainer('plugin.nothing')); + + expect($unbuilt->getAssetUrls(['admin.js']))->toBe(['js' => [], 'css' => []]) + ->and($unbuilt->isMissingBuild())->toBeTrue() + ->and(array_keys(resolve(MissingBuilds::class)->all()))->toBe(['plugin.nothing']); + }); + + it('answers an empty string for an unknown container', function (): void { + expect((string) (new AssetFile('images/logo.svg'))->from('plugin.unknown'))->toBe(''); + }); +}); + +describe('served by the dev servers', function (): void { + beforeEach(function (): void { + // The three servers run at once: each hot file names its own origin and build path, as @pollora/vite-config writes them + ($this->serve)($this->names['theme'], 'https://site.test:5173/build/theme/'.$this->names['theme']); + ($this->serve)($this->names['plugin'], 'https://site.test:5174/build/plugin/'.$this->names['plugin']); + // The module stays on its production build + ($this->build)('module', $this->names['module'], [ + 'resources/assets/app.js' => ['file' => 'assets/app-HHH.js'], + ]); + }); + + it('sends each container to its own dev server, and leaves the one that is not served on its build', function (): void { + $theme = new ViteManager($this->assetManager->getContainer('theme')); + $plugin = new ViteManager($this->assetManager->getContainer('plugin.acme')); + $module = new ViteManager($this->assetManager->getContainer('module.crm')); + + expect($theme->isRunningHot())->toBeTrue() + ->and($plugin->isRunningHot())->toBeTrue() + ->and($module->isRunningHot())->toBeFalse() + ->and($theme->asset('app.js'))->toBe('https://site.test:5173/build/theme/'.$this->names['theme'].'/resources/assets/app.js') + ->and($plugin->asset('admin.js'))->toBe('https://site.test:5174/build/plugin/'.$this->names['plugin'].'/resources/assets/admin.js') + ->and($module->getAssetUrls(['app.js']))->toBe(['js' => [asset('build/module/'.$this->names['module'].'/assets/app-HHH.js')], 'css' => []]); + }); + + it('gives each served container the Vite client of its own server', function (): void { + $theme = new ViteManager($this->assetManager->getContainer('theme')); + $plugin = new ViteManager($this->assetManager->getContainer('plugin.acme')); + $module = new ViteManager($this->assetManager->getContainer('module.crm')); + + expect($theme->clientUrl())->toBe('https://site.test:5173/build/theme/'.$this->names['theme'].'/@vite/client') + ->and($plugin->clientUrl())->toBe('https://site.test:5174/build/plugin/'.$this->names['plugin'].'/@vite/client') + ->and($module->clientUrl())->toBe(''); + }); + + it('does not count a served container as missing its build', function (): void { + $theme = new ViteManager($this->assetManager->getContainer('theme')); + + expect($theme->isMissingBuild())->toBeFalse() + ->and(resolve(MissingBuilds::class)->all())->toBe([]); + }); + + it('resolves a theme file through the dev server of the theme', function (): void { + expect((string) (new AssetFile('images/logo.svg'))->from('theme')) + ->toBe('https://site.test:5173/build/theme/'.$this->names['theme'].'/resources/assets/images/logo.svg'); + }); +}); diff --git a/tests/Feature/Asset/AssetEnqueuerTest.php b/tests/Feature/Asset/AssetEnqueuerTest.php deleted file mode 100644 index de0fb38c..00000000 --- a/tests/Feature/Asset/AssetEnqueuerTest.php +++ /dev/null @@ -1,374 +0,0 @@ -shouldReceive('isConsole')->andReturn(true); - $detector->shouldReceive('isWpCli')->andReturn(false); - - $this->app->instance(ConsoleDetectorInterface::class, $detector); - $this->app->singleton(ConsoleDetectionService::class, fn (): ConsoleDetectionService => new ConsoleDetectionService($detector)); - - // Bind AssetManager - $this->assetManager = Mockery::mock(AssetManager::class); - $this->app->instance(AssetManager::class, $this->assetManager); - - // Bind HookAction to prevent __destruct from failing - $this->hookAction = Mockery::mock(HookAction::class); - $this->hookAction->shouldReceive('add')->byDefault(); - $this->app->instance(HookAction::class, $this->hookAction); -}); - -describe('AssetEnqueuer', function (): void { - describe('fluent builder', function (): void { - it('chains handle and path', function (): void { - $enqueuer = $this->app->make(AssetEnqueuer::class); - - $result = $enqueuer->handle('my-script')->path('assets/app.js'); - - expect($result)->toBeInstanceOf(AssetEnqueuer::class); - }); - - it('chains dependencies', function (): void { - $enqueuer = $this->app->make(AssetEnqueuer::class); - - $result = $enqueuer->dependencies(['jquery']); - - expect($result)->toBeInstanceOf(AssetEnqueuer::class); - }); - - it('chains version', function (): void { - $enqueuer = $this->app->make(AssetEnqueuer::class); - - $result = $enqueuer->version('1.2.3'); - - expect($result)->toBeInstanceOf(AssetEnqueuer::class); - }); - - it('chains media', function (): void { - $enqueuer = $this->app->make(AssetEnqueuer::class); - - $result = $enqueuer->media('print'); - - expect($result)->toBeInstanceOf(AssetEnqueuer::class); - }); - - it('chains loadInFooter', function (): void { - $enqueuer = $this->app->make(AssetEnqueuer::class); - - $result = $enqueuer->loadInFooter(); - - expect($result)->toBeInstanceOf(AssetEnqueuer::class); - }); - - it('chains loadStrategy', function (): void { - $enqueuer = $this->app->make(AssetEnqueuer::class); - - $result = $enqueuer->loadStrategy('defer'); - - expect($result)->toBeInstanceOf(AssetEnqueuer::class); - }); - - it('chains setType', function (): void { - $enqueuer = $this->app->make(AssetEnqueuer::class); - - $result = $enqueuer->setType('css'); - - expect($result)->toBeInstanceOf(AssetEnqueuer::class); - }); - - it('chains inline', function (): void { - $enqueuer = $this->app->make(AssetEnqueuer::class); - - $result = $enqueuer->inline('body { color: red; }', 'after'); - - expect($result)->toBeInstanceOf(AssetEnqueuer::class); - }); - }); - - describe('context hooks', function (): void { - it('toFrontend sets wp_enqueue_scripts hook', function (): void { - $enqueuer = $this->app->make(AssetEnqueuer::class); - - expect($enqueuer->toFrontend())->toBeInstanceOf(AssetEnqueuer::class); - }); - - it('toBackend sets admin_enqueue_scripts hook', function (): void { - $enqueuer = $this->app->make(AssetEnqueuer::class); - - expect($enqueuer->toBackend())->toBeInstanceOf(AssetEnqueuer::class); - }); - - it('toLoginScreen sets login_enqueue_scripts hook', function (): void { - $enqueuer = $this->app->make(AssetEnqueuer::class); - - expect($enqueuer->toLoginScreen())->toBeInstanceOf(AssetEnqueuer::class); - }); - - it('toCustomizer sets customize_preview_init hook', function (): void { - $enqueuer = $this->app->make(AssetEnqueuer::class); - - expect($enqueuer->toCustomizer())->toBeInstanceOf(AssetEnqueuer::class); - }); - - it('toEditor sets enqueue_block_editor_assets hook', function (): void { - $enqueuer = $this->app->make(AssetEnqueuer::class); - - expect($enqueuer->toEditor())->toBeInstanceOf(AssetEnqueuer::class); - }); - }); - - describe('determineFileType', function (): void { - it('detects CSS files', function (): void { - $enqueuer = $this->app->make(AssetEnqueuer::class); - $enqueuer->path('assets/style.css'); - - $reflection = new ReflectionProperty($enqueuer, 'type'); - expect($reflection->getValue($enqueuer))->toBe('css'); - }); - - it('detects JS files', function (): void { - $enqueuer = $this->app->make(AssetEnqueuer::class); - $enqueuer->path('assets/app.js'); - - $reflection = new ReflectionProperty($enqueuer, 'type'); - expect($reflection->getValue($enqueuer))->toBe('js'); - }); - - it('treats JSX as JS', function (): void { - $enqueuer = $this->app->make(AssetEnqueuer::class); - $enqueuer->path('components/App.jsx'); - - $reflection = new ReflectionProperty($enqueuer, 'type'); - expect($reflection->getValue($enqueuer))->toBe('js'); - }); - - it('treats TSX as JS', function (): void { - $enqueuer = $this->app->make(AssetEnqueuer::class); - $enqueuer->path('components/App.tsx'); - - $reflection = new ReflectionProperty($enqueuer, 'type'); - expect($reflection->getValue($enqueuer))->toBe('js'); - }); - - it('treats TS as JS', function (): void { - $enqueuer = $this->app->make(AssetEnqueuer::class); - $enqueuer->path('assets/app.ts'); - - $reflection = new ReflectionProperty($enqueuer, 'type'); - expect($reflection->getValue($enqueuer))->toBe('js'); - }); - - it('throws on unsupported file type', function (): void { - $enqueuer = $this->app->make(AssetEnqueuer::class); - - expect(fn () => $enqueuer->path('image.png')) - ->toThrow(InvalidArgumentException::class); - }); - }); - - describe('localize', function (): void { - it('stores localization data for JS assets', function (): void { - $enqueuer = $this->app->make(AssetEnqueuer::class); - $enqueuer->path('app.js'); - - $result = $enqueuer->localize('myData', ['key' => 'value']); - - expect($result)->toBeInstanceOf(AssetEnqueuer::class); - - $reflection = new ReflectionProperty($enqueuer, 'localizationData'); - expect($reflection->getValue($enqueuer))->toHaveKey('myData'); - }); - - it('ignores localization for CSS assets', function (): void { - $enqueuer = $this->app->make(AssetEnqueuer::class); - $enqueuer->path('style.css'); - - $enqueuer->localize('myData', ['key' => 'value']); - - $reflection = new ReflectionProperty($enqueuer, 'localizationData'); - expect($reflection->getValue($enqueuer))->toBeEmpty(); - }); - }); - - describe('container', function (): void { - it('skips in console mode', function (): void { - $enqueuer = $this->app->make(AssetEnqueuer::class); - - $result = $enqueuer->container('theme'); - - expect($result)->toBeInstanceOf(AssetEnqueuer::class); - - $reflection = new ReflectionProperty($enqueuer, 'container'); - expect($reflection->getValue($enqueuer))->toBeNull(); - }); - }); - - describe('useVite', function (): void { - it('skips in console mode', function (): void { - $enqueuer = $this->app->make(AssetEnqueuer::class); - - $result = $enqueuer->useVite(); - - expect($result)->toBeInstanceOf(AssetEnqueuer::class); - - $reflection = new ReflectionProperty($enqueuer, 'useVite'); - expect($reflection->getValue($enqueuer))->toBeFalse(); - }); - }); - - describe('Vite entries as script modules', function (): void { - beforeEach(function (): void { - $this->filters = []; - $hookFilter = Mockery::mock(HookFilter::class); - $hookFilter->shouldReceive('add')->andReturnUsing(function (string $hook, Closure $callback) use ($hookFilter): HookFilter { - $this->filters[$hook] = $callback; - - return $hookFilter; - }); - $this->app->instance(HookFilter::class, $hookFilter); - - $this->modules = []; - $this->scripts = []; - $this->registered = []; - Functions\when('wp_enqueue_script_module')->alias(function (string $id, string $src = '', array $deps = [], $version = false, array $args = []): void { - $this->modules[$id] = ['src' => $src, 'deps' => $deps, 'version' => $version, 'args' => $args]; - }); - Functions\when('wp_register_script')->alias(function (string $handle, $src, array $deps = []): void { - $this->registered[$handle] = ['src' => $src, 'deps' => $deps]; - }); - Functions\when('wp_enqueue_script')->alias(function (string $handle, $src = '', array $deps = []): void { - $this->scripts[$handle] = ['src' => $src, 'deps' => $deps]; - }); - Functions\when('sanitize_title')->alias(fn (string $title): string => strtolower(str_replace('.', '-', $title))); - - $this->viteManager = Mockery::mock(ViteManager::class); - $this->viteManager->shouldReceive('isRunningHot')->andReturn(false)->byDefault(); - $this->viteManager->shouldReceive('getAssetUrls')->andReturn([])->byDefault(); - }); - - /** An enqueuer for a built Vite entry, set up the way useVite() would outside the console. */ - function viteEnqueuer(ViteManager $viteManager, array $settings = []): AssetEnqueuer - { - $enqueuer = resolve(AssetEnqueuer::class)->handle('buzz/script'); - - foreach (['useVite' => true, 'viteManager' => $viteManager, 'path' => ['js' => ['https://site.test/build/app-abc.js']], ...$settings] as $property => $value) { - (new ReflectionProperty($enqueuer, $property))->setValue($enqueuer, $value); - } - - return $enqueuer; - } - - it('enqueues the entry as a module on the front end, with no version appended', function (): void { - viteEnqueuer($this->viteManager)->enqueueStyleOrScript('wp_enqueue_scripts'); - - expect($this->modules)->toHaveKey('buzz/script/app-abc-js') - ->and($this->modules['buzz/script/app-abc-js']) - ->toMatchArray(['src' => 'https://site.test/build/app-abc.js', 'version' => null, 'args' => ['in_footer' => false]]) - ->and($this->scripts)->toBe([]); - }); - - it('passes loadInFooter() on as in_footer, as WordPress reads it for its own modules', function (): void { - viteEnqueuer($this->viteManager, ['loadInFooter' => true])->enqueueStyleOrScript('wp_enqueue_scripts'); - - expect($this->modules['buzz/script/app-abc-js']['args'])->toBe(['in_footer' => true]); - }); - - it('enqueues the entry as a module in the admin', function (): void { - viteEnqueuer($this->viteManager)->enqueueStyleOrScript('admin_enqueue_scripts'); - - expect($this->modules)->toHaveKey('buzz/script/app-abc-js'); - }); - - it('keeps a classic script where WordPress prints no modules, such as the editor', function (): void { - viteEnqueuer($this->viteManager)->enqueueStyleOrScript('enqueue_block_editor_assets'); - - expect($this->modules)->toBe([]) - ->and($this->scripts)->toHaveKey('buzz/script/app-abc-js'); - }); - - it('marks the module tag crossorigin, and no other tag', function (): void { - viteEnqueuer($this->viteManager)->enqueueStyleOrScript('wp_enqueue_scripts'); - - $filter = $this->filters['wp_script_attributes']; - - expect($filter(['id' => 'buzz/script/app-abc-js-js-module']))->toHaveKey('crossorigin', true) - ->and($filter(['id' => 'something-else-js-module']))->not->toHaveKey('crossorigin'); - }); - - it('puts dependencies, localized data and inline script on a classic companion', function (): void { - $localized = []; - $inline = []; - Functions\when('wp_localize_script')->alias(function (string $handle, string $name) use (&$localized): void { - $localized[$handle][] = $name; - }); - Functions\when('wp_add_inline_script')->alias(function (string $handle, string $code) use (&$inline): void { - $inline[$handle] = $code; - }); - - viteEnqueuer($this->viteManager, [ - 'dependencies' => ['jquery'], - 'localizationData' => ['buzzData' => ['a' => 1]], - 'inlineContent' => 'window.ready = true;', - ])->enqueueStyleOrScript('wp_enqueue_scripts'); - - expect($this->modules['buzz/script/app-abc-js']['deps'])->toBe([]) - ->and($this->registered['buzz/script/app-abc-js-data'])->toBe(['src' => false, 'deps' => ['jquery']]) - ->and($this->scripts)->toHaveKey('buzz/script/app-abc-js-data') - ->and($localized)->toBe(['buzz/script/app-abc-js-data' => ['buzzData']]) - ->and($inline)->toBe(['buzz/script/app-abc-js-data' => 'window.ready = true;']); - }); - - it('enqueues the Vite client as a module while Vite runs hot', function (): void { - $this->viteManager->shouldReceive('isRunningHot')->andReturn(true); - $this->viteManager->shouldReceive('clientUrl')->andReturn('https://site.test:5173/@vite/client'); - - $callbacks = []; - $hookAction = $this->hookAction; - $hookAction->shouldReceive('add')->andReturnUsing(function (string $hook, callable $callback, int $priority = 10) use (&$callbacks, $hookAction): HookAction { - $callbacks[] = [$hook, $priority, $callback]; - - return $hookAction; - }); - - (function (): void { - $this->loadViteClient('wp_enqueue_scripts'); - })->call(viteEnqueuer($this->viteManager)); - - [$hook, $priority, $callback] = $callbacks[0]; - $callback(); - - expect([$hook, $priority])->toBe(['wp_enqueue_scripts', 1]) - ->and(array_column($this->modules, 'src'))->toBe(['https://site.test:5173/@vite/client']) - ->and(array_column($this->modules, 'version'))->toBe([null]); - }); - }); - - describe('full chain', function (): void { - it('supports complete fluent configuration', function (): void { - $enqueuer = $this->app->make(AssetEnqueuer::class); - - $result = $enqueuer - ->handle('my-app') - ->path('assets/app.js') - ->dependencies(['jquery']) - ->version('1.0.0') - ->loadInFooter() - ->loadStrategy('defer') - ->toFrontend(); - - expect($result)->toBeInstanceOf(AssetEnqueuer::class); - }); - }); -}); diff --git a/tests/Feature/Asset/AssetManagerContainersTest.php b/tests/Feature/Asset/AssetManagerContainersTest.php deleted file mode 100644 index 5b8a0473..00000000 --- a/tests/Feature/Asset/AssetManagerContainersTest.php +++ /dev/null @@ -1,23 +0,0 @@ -addContainer('theme', ['hot_file' => 'theme.hot']); - $manager->addContainer('acme-plugin', []); - - $containers = $manager->containers(); - - expect(array_keys($containers))->toBe(['theme', 'acme-plugin']) - ->and($containers['theme'])->toBeInstanceOf(AssetContainer::class) - ->and($containers['theme']->getName())->toBe('theme'); -}); - -it('lists nothing before a container is added', function (): void { - expect((new AssetManager(Mockery::mock(AssetRetrievalService::class)))->containers())->toBe([]); -}); diff --git a/tests/Feature/Asset/ViteManagerTest.php b/tests/Feature/Asset/ViteManagerTest.php index 6eebeab3..3920cb7a 100644 --- a/tests/Feature/Asset/ViteManagerTest.php +++ b/tests/Feature/Asset/ViteManagerTest.php @@ -6,6 +6,7 @@ use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Facades\Log; use Pollora\Asset\Application\Services\MissingBuilds; +use Pollora\Asset\Infrastructure\Providers\AssetServiceProvider; use Pollora\Asset\Infrastructure\Repositories\AssetContainer; use Pollora\Asset\Infrastructure\Services\ViteManager; use Pollora\Asset\UI\Http\MissingBuildNotice; @@ -132,6 +133,8 @@ describe('MissingBuildNotice', function (): void { beforeEach(function (): void { + // The notice as the provider builds it: manifest paths from the project root, and the doctor command + app()->register(AssetServiceProvider::class); Functions\when('current_user_can')->justReturn(true); Functions\when('esc_html')->returnArg(); Functions\when('esc_html__')->returnArg(); diff --git a/tests/Feature/Extraction/BlockBindingsContractTest.php b/tests/Feature/Extraction/BlockBindingsContractTest.php new file mode 100644 index 00000000..769d4c2c --- /dev/null +++ b/tests/Feature/Extraction/BlockBindingsContractTest.php @@ -0,0 +1,128 @@ +justReturn(0); + Functions\when('number_format_i18n')->alias(fn (float|int $number, int $decimals = 0): string => number_format((float) $number, $decimals, ',', ' ')); + Functions\when('get_block_bindings_source')->justReturn(); + Functions\when('get_option')->alias(fn (string $name): string => $name === 'blogdescription' ? 'Live music' : 'secret'); + + $this->registered = []; + Functions\when('register_block_bindings_source')->alias(function (string $name, array $args): void { + $this->registered[$name] = $args; + }); + + $this->app = new Application(sys_get_temp_dir()); + $this->app->instance('config', new Repository(['app' => ['debug' => false], 'block-bindings' => ['options' => ['blogdescription']]])); + $this->app->instance(LoggerInterface::class, new NullLogger); + $this->app->instance('events', new Dispatcher); + $this->app->instance('validator', new Factory(new Translator(new ArrayLoader, 'en'))); + + $this->hooks = []; + $action = Mockery::mock(Action::class); + $action->shouldReceive('add')->andReturnUsing(function (string $hook, Closure $callback) use ($action): Action { + $this->hooks[$hook][] = $callback; + + return $action; + }); + $this->app->instance(Action::class, $action); + $this->app->instance(Filter::class, Mockery::mock(Filter::class)->shouldIgnoreMissing()); + + (new MetaServiceProvider($this->app))->register(); + $this->provider = new BlockBindingServiceProvider($this->app); + $this->provider->register(); + + $this->app->instance(MetaStoreInterface::class, new ArrayMetaStore(['post' => [7 => ['capacity' => '1200']], 'user' => [5 => ['stage_name' => 'The Hums', 'phone' => '0600000000']]])); + $this->app->instance(ContentVisibilityInterface::class, new FakeVisibility); + $this->app->instance(ValuePresenterInterface::class, new FakePresenter); + + $schemas = $this->app->make(MetaSchemaRepository::class); + foreach ([Concert::class, ArtistProfile::class, VenueExtras::class] as $class) { + $schemas->add($this->app->make(MetaSchemaBuilder::class)->build($class)); + } + + $this->provider->boot(); +}); + +afterEach(function (): void { + \Brain\Monkey\tearDown(); +}); + +it('registers the four sources Pollora ships, with WordPress, on init', function (): void { + foreach ($this->hooks['init'] as $register) { + $register(); + } + + expect(array_keys($this->registered))->toBe(['pollora/post-meta', 'pollora/term-meta', 'pollora/author-meta', 'pollora/option']) + ->and($this->app->make(BindingSourceRegistry::class)->find('pollora/post-meta')?->name)->toBe('pollora/post-meta'); +}); + +it('lists the options of the project, through the container, in the editor data', function (): void { + $data = $this->app->make(BindingEditorData::class)->toArray(); + + expect($data['route'])->toBe('pollora/v1/block-bindings/resolve') + ->and($data['sources']['pollora/option']['fields'])->toBe([['label' => 'Blogdescription', 'args' => ['name' => 'blogdescription'], 'type' => 'string']]) + ->and(array_column(array_column($data['sources']['pollora/author-meta']['fields'][''], 'args'), 'key'))->toBe(['stage_name']) + ->and(array_keys($data['sources']['pollora/post-meta']['fields']))->toContain('concert'); +}); + +it('reads only the listed options, through the container', function (): void { + $source = $this->app->make(OptionSource::class); + + expect($source(new BindingContext(args: ['name' => 'blogdescription'])))->toBe('Live music') + ->and($source(new BindingContext(args: ['name' => 'admin_email'])))->toBeNull(); +}); + +it('resolves a post meta, an author meta and an unlisted key through the resolver the container builds', function (): void { + foreach ($this->hooks['init'] as $register) { + $register(); + } + + $block = boundParagraph(['postId' => 7, 'postType' => 'concert']); + + $resolve = fn (string $source, array $args): mixed => $this->registered[$source]['get_value_callback']($args, $block, 'content'); + + expect($resolve('pollora/post-meta', ['key' => 'capacity']))->toBe('1 200') + ->and($resolve('pollora/post-meta', ['key' => 'backstage_code']))->toBeNull() + ->and($resolve('pollora/option', ['name' => 'blogdescription']))->toBe('Live music') + ->and($resolve('pollora/option', ['name' => 'admin_email']))->toBeNull(); +}); diff --git a/tests/Feature/Extraction/PublicSurfaceTest.php b/tests/Feature/Extraction/PublicSurfaceTest.php new file mode 100644 index 00000000..fa9d88a5 --- /dev/null +++ b/tests/Feature/Extraction/PublicSurfaceTest.php @@ -0,0 +1,119 @@ +> + */ +function describeSurface(string $class): array +{ + $reflection = new ReflectionClass($class); + + $kind = match (true) { + $reflection->isEnum() => 'enum', + $reflection->isInterface() => 'interface', + $reflection->isTrait() => 'trait', + default => ($reflection->isAbstract() ? 'abstract ' : '').($reflection->isFinal() ? 'final ' : '').($reflection->isReadOnly() ? 'readonly ' : '').'class', + }; + + $type = static fn (?ReflectionType $t): string => $t instanceof ReflectionType ? (string) $t : 'mixed'; + + $methods = []; + foreach ($reflection->getMethods() as $method) { + if ($method->isPrivate() || $method->getDeclaringClass()->getName() !== $class) { + continue; + } + + $params = array_map( + static fn (ReflectionParameter $p): string => trim( + ($p->isPassedByReference() ? '&' : '').($p->isVariadic() ? '...' : '').$type($p->getType()).' $'.$p->getName().($p->isOptional() && ! $p->isVariadic() ? ' = ?' : ''), + ), + $method->getParameters(), + ); + $methods[$method->getName()] = ($method->isPublic() ? 'public' : 'protected').($method->isStatic() ? ' static' : '').($method->isAbstract() ? ' abstract' : '') + .' ('.implode(', ', $params).'): '.$type($method->getReturnType()); + } + + ksort($methods); + + $constants = []; + foreach ($reflection->getReflectionConstants() as $constant) { + if ($constant->isPublic() && $constant->getDeclaringClass()->getName() === $class) { + $constants[$constant->getName()] = $constant->isEnumCase() ? 'case' : 'const'; + } + } + + ksort($constants); + + $interfaces = array_values(array_diff($reflection->getInterfaceNames(), $reflection->getParentClass() ? $reflection->getParentClass()->getInterfaceNames() : [])); + sort($interfaces); + + return [ + 'kind' => $kind, + 'extends' => $reflection->getParentClass() ? $reflection->getParentClass()->getName() : null, + 'implements' => $interfaces, + 'constants' => $constants, + 'methods' => $methods, + ]; +} + +/** + * @return list + */ +function surfaceClassesOnDisk(): array +{ + $classes = []; + foreach (SURFACE_MODULES as $module) { + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(dirname(__DIR__, 3).'/src/'.$module, FilesystemIterator::SKIP_DOTS)); + foreach ($iterator as $file) { + if ($file->getExtension() !== 'php' || str_contains($file->getPathname(), '/config/')) { + continue; + } + + $relative = substr($file->getPathname(), strlen(dirname(__DIR__, 3).'/src/'), -4); + $classes[] = 'Pollora\\'.str_replace('/', '\\', $relative); + } + } + + sort($classes); + + return $classes; +} + +it('keeps the name and the public signatures of every Meta, Role and BlockBinding class', function (): void { + $snapshot = __DIR__.'/surface.json'; + + if (getenv('UPDATE_SURFACE') === '1') { + $surface = []; + foreach (surfaceClassesOnDisk() as $class) { + $surface[$class] = describeSurface($class); + } + + file_put_contents($snapshot, json_encode($surface, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)."\n"); + } + + $expected = json_decode((string) file_get_contents($snapshot), true, 512, JSON_THROW_ON_ERROR); + expect($expected)->not->toBeEmpty(); + + $actual = []; + foreach (array_keys($expected) as $class) { + expect(class_exists($class) || interface_exists($class) || trait_exists($class) || enum_exists($class)) + ->toBeTrue("{$class} no longer resolves"); + $actual[$class] = describeSurface($class); + } + + expect($actual)->toEqual($expected); +}); diff --git a/tests/Feature/Extraction/RolesContractTest.php b/tests/Feature/Extraction/RolesContractTest.php new file mode 100644 index 00000000..8575f5bd --- /dev/null +++ b/tests/Feature/Extraction/RolesContractTest.php @@ -0,0 +1,98 @@ +app = new Application(sys_get_temp_dir()); + $this->app->instance('config', new Repository(['roles' => ['super_roles' => ['administrator']]])); + $this->app->instance(LoggerInterface::class, new NullLogger); + + $this->action = Mockery::mock(Action::class)->shouldIgnoreMissing(); + $this->app->instance(Action::class, $this->action); + $this->app->instance(Filter::class, Mockery::mock(Filter::class)->shouldIgnoreMissing()); + $this->app->instance('events', new Dispatcher); + (new RoleServiceProvider($this->app))->register(); + + $this->stored = [ + 'administrator' => ['name' => 'Administrator', 'capabilities' => ['manage_options' => true, 'edit_posts' => true, 'edit_theme_options' => true]], + 'editor' => ['name' => 'Editor', 'capabilities' => ['edit_posts' => true, 'edit_theme_options' => true, 'moderate_comments' => true]], + 'author' => ['name' => 'Author', 'capabilities' => ['read' => true, 'edit_posts' => true, 'publish_posts' => true, 'upload_files' => true]], + 'subscriber' => ['name' => 'Subscriber', 'capabilities' => ['read' => true]], + ]; +}); + +it('compiles the declared roles and modifications into the capabilities WordPress stores', function (): void { + $builder = $this->app->make(RoleDefinitionBuilder::class); + $registry = $this->app->make(RoleRegistry::class); + $registry->addPostType(Event::class); + $registry->addPostType(Venue::class); + $registry->addTaxonomy(Genre::class); + $registry->addRole($builder->build(EventManager::class)); + $registry->addModification($builder->build(EditorAdjustments::class)); + + $roles = $this->app->make(RoleCompiler::class)->compile( + $this->stored, + $registry->definitions(), + $registry->modifications(), + ['administrator'], + $registry->declaredCapabilities(), + ); + + ksort($roles['event_manager']['capabilities']); + ksort($roles['editor']['capabilities']); + ksort($roles['administrator']['capabilities']); + + expect(array_keys($roles))->toBe(['administrator', 'editor', 'author', 'subscriber', 'event_manager']) + ->and($roles['event_manager']['name'])->toBe('Event manager') + ->and($roles['event_manager']['_pollora'])->toBe(['managed' => true]) + ->and($roles['event_manager']['capabilities'])->not->toHaveKey('publish_posts') + ->and($roles['event_manager']['capabilities'])->toHaveKeys(['read', 'export_attendees', 'scan_tickets', 'edit_others_events', 'open_venues', 'manage_genres']) + ->and($roles['editor']['capabilities'])->not->toHaveKey('edit_theme_options') + ->and($roles['editor']['capabilities'])->toHaveKey('scan_tickets') + ->and($roles['administrator']['capabilities'])->toHaveKeys(['edit_others_events', 'manage_genres']) + ->and($roles['administrator']['capabilities'])->not->toHaveKey('export_attendees'); + expect(array_keys($roles['event_manager']['capabilities']))->toBe(['assign_genres', 'delete_events', 'delete_others_events', 'delete_private_events', 'delete_published_events', 'delete_published_venues', 'delete_venues', 'edit_events', 'edit_genres', 'edit_others_events', 'edit_posts', 'edit_private_events', 'edit_published_events', 'edit_published_venues', 'edit_venues', 'export_attendees', 'manage_genres', 'open_venues', 'publish_events', 'read', 'read_private_events', 'scan_tickets', 'upload_files']); +}); + +it('aliases the role middleware and compiles @role once the application has booted', function (): void { + $this->app->instance('router', $router = new Router(new Dispatcher)); + $this->app->instance('blade.compiler', $blade = new BladeCompiler(new Filesystem, sys_get_temp_dir())); + + $provider = new RoleServiceProvider($this->app); + $provider->boot(); + + $this->app->make('router'); + $this->app->booted(static fn (): null => null); + $this->app->boot(); + $this->app->make('blade.compiler'); + + expect($router->getMiddleware()['role'] ?? null)->toBe(EnsureUserHasRole::class) + ->and($blade->compileString("@role('editor') shown @endrole"))->toContain('endif'); +}); diff --git a/tests/Feature/Extraction/TypedMetaContractTest.php b/tests/Feature/Extraction/TypedMetaContractTest.php new file mode 100644 index 00000000..ccb86f04 --- /dev/null +++ b/tests/Feature/Extraction/TypedMetaContractTest.php @@ -0,0 +1,199 @@ + */ + public array $rows = []; + + public function get(MetaObjectType $objectType, int $objectId, string $key): mixed + { + return $this->rows[$this->id($objectType, $objectId, $key)] ?? ''; + } + + public function getAll(MetaObjectType $objectType, int $objectId, string $key): array + { + return (array) ($this->rows[$this->id($objectType, $objectId, $key)] ?? []); + } + + public function update(MetaObjectType $objectType, int $objectId, string $key, string|array $value): void + { + $this->rows[$this->id($objectType, $objectId, $key)] = $value; + } + + public function replaceAll(MetaObjectType $objectType, int $objectId, string $key, array $values): void + { + $this->rows[$this->id($objectType, $objectId, $key)] = $values; + } + + public function delete(MetaObjectType $objectType, int $objectId, string $key): void + { + unset($this->rows[$this->id($objectType, $objectId, $key)]); + } + + private function id(MetaObjectType $objectType, int $objectId, string $key): string + { + return $objectType->value.':'.$objectId.':'.$key; + } +} + +final class RecordingUiDriver implements MetaUiDriver +{ + /** @var list */ + public array $registered = []; + + public function supports(MetaDefinition $definition): bool + { + return true; + } + + public function register(MetaSchema $schema): void + { + foreach ($schema->definitions as $definition) { + $this->registered[] = implode(',', $schema->subtypes).'.'.$definition->key; + } + } +} + +beforeEach(function (): void { + $this->app = new Application(sys_get_temp_dir()); + $this->app->instance('config', new Repository(['app' => ['debug' => false]])); + $this->app->instance('validator', new Factory(new Translator(new ArrayLoader, 'en'))); + $this->app->instance(LoggerInterface::class, new NullLogger); + + $this->hooks = []; + $action = Mockery::mock(Action::class); + $action->shouldReceive('add')->andReturnUsing(function (string $hook, Closure $callback) use ($action): Action { + $this->hooks[$hook] = $callback; + + return $action; + }); + $this->app->instance(Action::class, $action); + $this->app->instance(Filter::class, Mockery::mock(Filter::class)->shouldIgnoreMissing()); + $this->app->instance('events', new Dispatcher); + + $this->provider = new MetaServiceProvider($this->app); + $this->provider->register(); + + $this->store = new MemoryMetaStore; + $this->app->instance(MetaStoreInterface::class, $this->store); + + // What the discovery does at boot: one compiled schema per declaring class + $builder = $this->app->make(MetaSchemaBuilder::class); + $repository = $this->app->make(MetaSchemaRepository::class); + foreach ([Event::class, MemberProfile::class, RatedEvent::class] as $class) { + $repository->add($builder->build($class)); + } + + Facade::setFacadeApplication($this->app); +}); + +afterEach(function (): void { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); +}); + +it('reads the declared defaults, typed, when nothing is stored', function (): void { + $event = Meta::of(Event::class, 42); + + expect($event)->toBeInstanceOf(MetaRecord::class) + ->and($event->objectId())->toBe(42) + ->and($event->capacity)->toBe(0) + ->and($event->price)->toBe(9.5) + ->and($event->soldOut)->toBeFalse() + ->and($event->status)->toBe(EventStatus::Draft) + ->and($event->startsAt)->toBeNull(); +}); + +it('writes through the fluent record under the declared key, and reads back the PHP type', function (): void { + $saved = Meta::of(Event::class, 42)->fill(['capacity' => 250, 'soldOut' => true, 'status' => EventStatus::Published, 'internalRef' => 'X-1'])->save(); + + expect($saved)->toBeInstanceOf(MetaRecord::class) + ->and($this->store->rows)->toHaveKeys(['post:42:capacity', 'post:42:sold_out', 'post:42:status', 'post:42:_event_internal_ref']); + + $again = Meta::of(Event::class, 42); + expect($again->capacity)->toBe(250) + ->and($again->soldOut)->toBeTrue() + ->and($again->status)->toBe(EventStatus::Published) + ->and($again->internalRef)->toBe('X-1') + ->and($again->toArray())->toMatchArray(['capacity' => 250, 'soldOut' => true]); +}); + +it('chains set() and keeps the record', function (): void { + $record = Meta::of(Event::class, 7); + + expect($record->set('capacity', 12))->toBe($record) + ->and($record->get('capacity'))->toBe(12); +}); + +it('stores the meta of users, terms and comments under their own object type', function (): void { + Meta::of(MemberProfile::class, 5)->fill(['newsletterOptIn' => true, 'jobTitle' => 'Chef'])->save(); + + expect($this->store->rows)->toHaveKeys(['user:5:newsletter_opt_in', 'user:5:job_title']) + ->and(Meta::of(MemberProfile::class, 5)->newsletterOptIn)->toBeTrue(); +}); + +it('applies the Laravel rules to writes from PHP, with the message of the rule', function (): void { + $event = Meta::of(RatedEvent::class, 9); + + expect(fn () => $event->set('capacity', 9000))->toThrow(MetaValidationException::class) + ->and(fn () => $event->set('contact', 'not-an-email'))->toThrow(MetaValidationException::class) + ->and(fn () => $event->set('status', EventStatus::Draft))->toThrow(MetaValidationException::class); + + $event->set('capacity', 100)->set('contact', 'a@b.fr'); + expect($event->capacity)->toBe(100); +}); + +it('gives the compiled schemas to tooling, by object type and subtype', function (): void { + $schemas = Meta::schemaFor('post', 'event'); + + expect($schemas)->toHaveCount(1) + ->and($schemas[0]->subtypes)->toBe(['event']) + ->and(array_map(fn (MetaDefinition $d): string => $d->key, $schemas[0]->definitions)) + ->toContain('capacity', 'sold_out', 'starts_at', 'status', '_event_internal_ref'); +}); + +it('hands the schemas to the UI driver picked in meta.ui, once WordPress has registered them', function (): void { + $driver = new RecordingUiDriver; + $this->app->make('config')->set('meta.ui', 'recording'); + Meta::extend('recording', fn (): RecordingUiDriver => $driver); + + $this->provider->boot(); + ($this->hooks['init'])(); + + expect($driver->registered)->toContain('event.capacity', 'event.sold_out', 'rated_event.contact', '.newsletter_opt_in'); +}); diff --git a/tests/Feature/Extraction/surface.json b/tests/Feature/Extraction/surface.json new file mode 100644 index 00000000..abfeb970 --- /dev/null +++ b/tests/Feature/Extraction/surface.json @@ -0,0 +1,558 @@ +{ + "Pollora\\BlockBinding\\Application\\Services\\BindingReferenceInspector": { + "kind": "final readonly class", + "extends": null, + "implements": [], + "constants": [], + "methods": { + "__construct": "public (Pollora\\BlockBinding\\Application\\Services\\BindingSourceRegistry $sources, Pollora\\Meta\\Application\\Services\\MetaSchemaRepository $schemas, array $options = ?): mixed", + "knows": "public (string $sourceName): bool", + "problem": "public (string $sourceName, array $args): ?string" + } + }, + "Pollora\\BlockBinding\\Application\\Services\\BindingResolver": { + "kind": "final class", + "extends": null, + "implements": [], + "constants": { + "SLOW_FIELD_MS": "const" + }, + "methods": { + "__construct": "public (Psr\\Container\\ContainerInterface $container, Pollora\\BlockBinding\\Domain\\Contracts\\ContentVisibilityInterface $visibility, Pollora\\BlockBinding\\Domain\\Contracts\\ValuePresenterInterface $presenter, ?Pollora\\Meta\\Application\\Services\\MetaAccessor $metaAccessor = ?, bool $debug = ?, ?Psr\\Log\\LoggerInterface $logger = ?, ?Pollora\\BlockBinding\\Domain\\Contracts\\MethodInvokerInterface $invoker = ?, ?Pollora\\BlockBinding\\Domain\\Contracts\\BindingEventsInterface $events = ?, ?Pollora\\BlockBinding\\Domain\\Contracts\\PostFinderInterface $posts = ?): mixed", + "preview": "public (Pollora\\BlockBinding\\Domain\\Models\\BindingSource $source, array $args, array $blockContext, string $attribute, ?string $attributeSource = ?): mixed", + "resolve": "public (Pollora\\BlockBinding\\Domain\\Models\\BindingSource $source, array $args, ?WP_Block $block, string $attribute): mixed" + } + }, + "Pollora\\BlockBinding\\Application\\Services\\BindingSourceBuilder": { + "kind": "final class", + "extends": null, + "implements": [], + "constants": [], + "methods": { + "build": "public (string $class): Pollora\\BlockBinding\\Domain\\Models\\BindingSource" + } + }, + "Pollora\\BlockBinding\\Application\\Services\\BindingSourceRegistry": { + "kind": "final class", + "extends": null, + "implements": [], + "constants": [], + "methods": { + "add": "public (Pollora\\BlockBinding\\Domain\\Models\\BindingSource $source): void", + "all": "public (): array", + "find": "public (string $name): ?Pollora\\BlockBinding\\Domain\\Models\\BindingSource" + } + }, + "Pollora\\BlockBinding\\Domain\\Contracts\\BindingEventsInterface": { + "kind": "interface", + "extends": null, + "implements": [], + "constants": [], + "methods": { + "dispatch": "public abstract (Pollora\\BlockBinding\\Domain\\Events\\BindingResolved $event): void", + "isListening": "public abstract (): bool" + } + }, + "Pollora\\BlockBinding\\Domain\\Contracts\\ContentVisibilityInterface": { + "kind": "interface", + "extends": null, + "implements": [], + "constants": [], + "methods": { + "canShowPost": "public abstract (int $postId): bool", + "canShowTerm": "public abstract (int $termId, string $taxonomy): bool" + } + }, + "Pollora\\BlockBinding\\Domain\\Contracts\\MethodInvokerInterface": { + "kind": "interface", + "extends": null, + "implements": [], + "constants": [], + "methods": { + "call": "public abstract (object $instance, string $method, array $parameters): mixed" + } + }, + "Pollora\\BlockBinding\\Domain\\Contracts\\PostFinderInterface": { + "kind": "interface", + "extends": null, + "implements": [], + "constants": [], + "methods": { + "find": "public abstract (int $postId): ?Pollora\\Colt\\Model\\Post" + } + }, + "Pollora\\BlockBinding\\Domain\\Contracts\\ValuePresenterInterface": { + "kind": "interface", + "extends": null, + "implements": [], + "constants": [], + "methods": { + "boolean": "public abstract (bool $value, ?string $true = ?, ?string $false = ?): string", + "html": "public abstract (string $value): string", + "richText": "public abstract (string $value): string", + "url": "public abstract (string $value): string" + } + }, + "Pollora\\BlockBinding\\Domain\\Events\\BindingResolved": { + "kind": "final readonly class", + "extends": null, + "implements": [], + "constants": [], + "methods": { + "__construct": "public (string $source, string $field, string $attribute, ?int $postId, float $milliseconds, bool $cached, bool $hasValue): mixed" + } + }, + "Pollora\\BlockBinding\\Domain\\Exceptions\\InvalidBindingSourceException": { + "kind": "final class", + "extends": "InvalidArgumentException", + "implements": [], + "constants": [], + "methods": { + "duplicateName": "public static (string $name, string $existing, string $class): self", + "forClass": "public static (string $class, string $reason): self", + "forField": "public static (string $class, string $method, string $reason): self" + } + }, + "Pollora\\BlockBinding\\Domain\\Models\\BindingContext": { + "kind": "final class", + "extends": null, + "implements": [], + "constants": [], + "methods": { + "__construct": "public (array $args = ?, string $attribute = ?, ?int $postId = ?, ?string $postType = ?, ?int $termId = ?, ?string $taxonomy = ?, ?WP_Block $block = ?, ?Pollora\\Meta\\Application\\Services\\MetaAccessor $metaAccessor = ?, ?Pollora\\BlockBinding\\Domain\\Contracts\\PostFinderInterface $postFinder = ?): mixed", + "arg": "public (string $name, mixed $default = ?): mixed", + "meta": "public (string $class): ?Pollora\\Meta\\Domain\\Models\\MetaRecord", + "post": "public (): ?Pollora\\Colt\\Model\\Post" + } + }, + "Pollora\\BlockBinding\\Domain\\Models\\BindingFieldDefinition": { + "kind": "final readonly class", + "extends": null, + "implements": [], + "constants": [], + "methods": { + "__construct": "public (string $name, string $label, Pollora\\BlockBinding\\Domain\\Enums\\BindingFieldType $type, string $method): mixed" + } + }, + "Pollora\\BlockBinding\\Domain\\Models\\BindingSource": { + "kind": "final readonly class", + "extends": null, + "implements": [], + "constants": [], + "methods": { + "__construct": "public (string $name, string $label, string $class, array $usesContext, array $postTypes, array $fields): mixed", + "answersFor": "public (?string $postType): bool", + "field": "public (string $name): ?Pollora\\BlockBinding\\Domain\\Models\\BindingFieldDefinition", + "isInvokable": "public (): bool" + } + }, + "Pollora\\BlockBinding\\Infrastructure\\Adapters\\ReflectionMethodInvoker": { + "kind": "final readonly class", + "extends": null, + "implements": [ + "Pollora\\BlockBinding\\Domain\\Contracts\\MethodInvokerInterface" + ], + "constants": [], + "methods": { + "__construct": "public (?Psr\\Container\\ContainerInterface $container = ?): mixed", + "call": "public (object $instance, string $method, array $parameters): mixed" + } + }, + "Pollora\\BlockBinding\\Infrastructure\\Adapters\\WordPressBindingEditorScript": { + "kind": "final readonly class", + "extends": null, + "implements": [], + "constants": { + "HANDLE": "const" + }, + "methods": { + "__construct": "public (Pollora\\BlockBinding\\Infrastructure\\Services\\BindingEditorData $data, string $scriptPath = ?): mixed", + "enqueue": "public (): void" + } + }, + "Pollora\\BlockBinding\\Infrastructure\\Adapters\\WordPressBindingRegistry": { + "kind": "final readonly class", + "extends": null, + "implements": [], + "constants": [], + "methods": { + "__construct": "public (Pollora\\Hook\\Domain\\Contract\\Action $action, Pollora\\BlockBinding\\Application\\Services\\BindingResolver $resolver): mixed", + "register": "public (Pollora\\BlockBinding\\Domain\\Models\\BindingSource $source): void" + } + }, + "Pollora\\BlockBinding\\Infrastructure\\Adapters\\WordPressContentVisibility": { + "kind": "final readonly class", + "extends": null, + "implements": [ + "Pollora\\BlockBinding\\Domain\\Contracts\\ContentVisibilityInterface" + ], + "constants": [], + "methods": { + "canShowPost": "public (int $postId): bool", + "canShowTerm": "public (int $termId, string $taxonomy): bool" + } + }, + "Pollora\\BlockBinding\\Infrastructure\\Adapters\\WordPressValuePresenter": { + "kind": "final readonly class", + "extends": null, + "implements": [ + "Pollora\\BlockBinding\\Domain\\Contracts\\ValuePresenterInterface" + ], + "constants": [], + "methods": { + "boolean": "public (bool $value, ?string $true = ?, ?string $false = ?): string", + "html": "public (string $value): string", + "richText": "public (string $value): string", + "url": "public (string $value): string" + } + }, + "Pollora\\BlockBinding\\Infrastructure\\Laravel\\BlockBindingServiceProvider": { + "kind": "class", + "extends": "Illuminate\\Support\\ServiceProvider", + "implements": [], + "constants": { + "SOURCES": "const" + }, + "methods": { + "boot": "public (): void", + "register": "public (): void" + } + }, + "Pollora\\BlockBinding\\Infrastructure\\Laravel\\BlockBindingsCheck": { + "kind": "final readonly class", + "extends": null, + "implements": [ + "Pollora\\Doctor\\Domain\\Contracts\\CheckInterface" + ], + "constants": [], + "methods": { + "__construct": "public (Pollora\\Doctor\\Infrastructure\\Support\\ProjectModules $modules, Pollora\\BlockBinding\\Application\\Services\\BindingReferenceInspector $inspector): mixed", + "id": "public (): string", + "label": "public (): string", + "run": "public (Pollora\\Doctor\\Domain\\Enums\\RunContext $context): Pollora\\Doctor\\Domain\\Models\\CheckResult", + "runsIn": "public (): array" + } + }, + "Pollora\\BlockBinding\\Infrastructure\\Laravel\\Console\\BindingListCommand": { + "kind": "class", + "extends": "Illuminate\\Console\\Command", + "implements": [], + "constants": [], + "methods": { + "bindableBlocks": "protected (): ?array", + "handle": "public (Pollora\\BlockBinding\\Application\\Services\\BindingSourceRegistry $sources, Pollora\\BlockBinding\\Infrastructure\\Services\\BindingEditorData $editorData): int" + } + }, + "Pollora\\BlockBinding\\Infrastructure\\Laravel\\Console\\MakeBindingCommand": { + "kind": "class", + "extends": "Pollora\\Console\\AbstractGeneratorCommand", + "implements": [], + "constants": [], + "methods": { + "getArguments": "protected (): array", + "getStub": "protected (): string", + "replaceClass": "protected (mixed $stub, mixed $name): string" + } + }, + "Pollora\\BlockBinding\\Infrastructure\\Laravel\\LaravelBindingEvents": { + "kind": "final readonly class", + "extends": null, + "implements": [ + "Pollora\\BlockBinding\\Domain\\Contracts\\BindingEventsInterface" + ], + "constants": [], + "methods": { + "__construct": "public (Illuminate\\Contracts\\Container\\Container $container): mixed", + "dispatch": "public (Pollora\\BlockBinding\\Domain\\Events\\BindingResolved $event): void", + "isListening": "public (): bool" + } + }, + "Pollora\\BlockBinding\\Infrastructure\\Laravel\\LaravelMethodInvoker": { + "kind": "final readonly class", + "extends": null, + "implements": [ + "Pollora\\BlockBinding\\Domain\\Contracts\\MethodInvokerInterface" + ], + "constants": [], + "methods": { + "__construct": "public (Illuminate\\Contracts\\Container\\Container $container): mixed", + "call": "public (object $instance, string $method, array $parameters): mixed" + } + }, + "Pollora\\BlockBinding\\Infrastructure\\Laravel\\LaravelPostFinder": { + "kind": "final class", + "extends": null, + "implements": [ + "Pollora\\BlockBinding\\Domain\\Contracts\\PostFinderInterface" + ], + "constants": [], + "methods": { + "find": "public (int $postId): ?Pollora\\Models\\Post" + } + }, + "Pollora\\BlockBinding\\Infrastructure\\Services\\BindingEditorData": { + "kind": "final readonly class", + "extends": null, + "implements": [], + "constants": { + "REST_NAMESPACE": "const", + "REST_ROUTE": "const" + }, + "methods": { + "__construct": "public (Pollora\\BlockBinding\\Application\\Services\\BindingSourceRegistry $sources, Pollora\\Meta\\Application\\Services\\MetaSchemaRepository $schemas, array $options): mixed", + "toArray": "public (): array" + } + }, + "Pollora\\BlockBinding\\Infrastructure\\Services\\BindingFormatter": { + "kind": "final readonly class", + "extends": null, + "implements": [], + "constants": [], + "methods": { + "__construct": "public (Pollora\\BlockBinding\\Domain\\Contracts\\ValuePresenterInterface $presenter): mixed", + "format": "public (Pollora\\Meta\\Domain\\Models\\MetaDefinition $definition, mixed $value, array $args, string $attribute): string|int|float|bool|null" + } + }, + "Pollora\\BlockBinding\\Infrastructure\\Services\\BlockBindingDiscovery": { + "kind": "final class", + "extends": null, + "implements": [ + "Pollora\\Discovery\\Domain\\Contracts\\DiscoveryInterface" + ], + "constants": [], + "methods": { + "__construct": "public (Pollora\\BlockBinding\\Application\\Services\\BindingSourceBuilder $builder, Pollora\\BlockBinding\\Application\\Services\\BindingSourceRegistry $sources, Pollora\\BlockBinding\\Infrastructure\\Adapters\\WordPressBindingRegistry $registry, ?Psr\\Log\\LoggerInterface $logger = ?): mixed", + "apply": "public (): void", + "discover": "public (Pollora\\Discovery\\Domain\\Contracts\\DiscoveryLocationInterface $location, Spatie\\StructureDiscoverer\\Data\\DiscoveredStructure $structure, ?Pollora\\Discovery\\Domain\\Contracts\\ReflectionCacheInterface $reflectionCache = ?): void", + "getIdentifier": "public (): string", + "getItems": "public (): Pollora\\Discovery\\Domain\\Contracts\\DiscoveryItemsInterface", + "setItems": "public (Pollora\\Discovery\\Domain\\Contracts\\DiscoveryItemsInterface $items): void" + } + }, + "Pollora\\BlockBinding\\Infrastructure\\Sources\\AuthorMetaSource": { + "kind": "final readonly class", + "extends": null, + "implements": [], + "constants": [], + "methods": { + "__construct": "public (Pollora\\BlockBinding\\Infrastructure\\Sources\\TypedMetaReader $reader, Pollora\\BlockBinding\\Infrastructure\\Services\\BindingFormatter $formatter): mixed", + "__invoke": "public (Pollora\\BlockBinding\\Domain\\Models\\BindingContext $context): string|int|float|bool|null" + } + }, + "Pollora\\BlockBinding\\Infrastructure\\Sources\\OptionSource": { + "kind": "final readonly class", + "extends": null, + "implements": [], + "constants": [], + "methods": { + "__construct": "public (array $options): mixed", + "__invoke": "public (Pollora\\BlockBinding\\Domain\\Models\\BindingContext $context): ?string" + } + }, + "Pollora\\BlockBinding\\Infrastructure\\Sources\\PostMetaSource": { + "kind": "final readonly class", + "extends": null, + "implements": [], + "constants": [], + "methods": { + "__construct": "public (Pollora\\BlockBinding\\Infrastructure\\Sources\\TypedMetaReader $reader, Pollora\\BlockBinding\\Infrastructure\\Services\\BindingFormatter $formatter): mixed", + "__invoke": "public (Pollora\\BlockBinding\\Domain\\Models\\BindingContext $context): string|int|float|bool|null" + } + }, + "Pollora\\BlockBinding\\Infrastructure\\Sources\\TermMetaSource": { + "kind": "final readonly class", + "extends": null, + "implements": [], + "constants": [], + "methods": { + "__construct": "public (Pollora\\BlockBinding\\Infrastructure\\Sources\\TypedMetaReader $reader, Pollora\\BlockBinding\\Infrastructure\\Services\\BindingFormatter $formatter, Pollora\\BlockBinding\\Domain\\Contracts\\ContentVisibilityInterface $visibility): mixed", + "__invoke": "public (Pollora\\BlockBinding\\Domain\\Models\\BindingContext $context): string|int|float|bool|null" + } + }, + "Pollora\\BlockBinding\\Infrastructure\\Sources\\TypedMetaReader": { + "kind": "final readonly class", + "extends": null, + "implements": [], + "constants": [], + "methods": { + "__construct": "public (Pollora\\Meta\\Application\\Services\\MetaSchemaRepository $schemas, Pollora\\Meta\\Application\\Services\\MetaAccessor $accessor): mixed", + "exposedInRest": "public static (Pollora\\Meta\\Domain\\Models\\MetaDefinition $definition): bool", + "read": "public (Pollora\\Meta\\Domain\\Enums\\MetaObjectType $objectType, ?string $subtype, int $objectId, string $key, Closure $readable): ?array" + } + }, + "Pollora\\BlockBinding\\UI\\Http\\ResolveBindingsController": { + "kind": "final readonly class", + "extends": null, + "implements": [], + "constants": { + "MAX_BINDINGS": "const" + }, + "methods": { + "__construct": "public (Pollora\\BlockBinding\\Application\\Services\\BindingSourceRegistry $sources, Pollora\\BlockBinding\\Application\\Services\\BindingResolver $resolver): mixed", + "canPreview": "public (WP_REST_Request $request): bool", + "handle": "public (WP_REST_Request $request): array", + "register": "public (): void" + } + }, + "Pollora\\Meta\\Infrastructure\\Laravel\\Console\\MetaAuditCommand": { + "kind": "class", + "extends": "Illuminate\\Console\\Command", + "implements": [], + "constants": [], + "methods": { + "handle": "public (Pollora\\Meta\\Application\\Services\\MetaAuditor $auditor): int" + } + }, + "Pollora\\Meta\\Infrastructure\\Laravel\\Console\\MetaListCommand": { + "kind": "class", + "extends": "Illuminate\\Console\\Command", + "implements": [], + "constants": [], + "methods": { + "handle": "public (Pollora\\Meta\\Application\\Services\\MetaSchemaRepository $schemas): int" + } + }, + "Pollora\\Meta\\Infrastructure\\Laravel\\LaravelMetaValidator": { + "kind": "final readonly class", + "extends": null, + "implements": [ + "Pollora\\Meta\\Domain\\Contracts\\MetaValidatorInterface" + ], + "constants": [], + "methods": { + "__construct": "public (Illuminate\\Contracts\\Validation\\Factory $validator): mixed", + "validate": "public (Pollora\\Meta\\Domain\\Models\\MetaDefinition $definition, mixed $value): void" + } + }, + "Pollora\\Meta\\Infrastructure\\Laravel\\MetaServiceProvider": { + "kind": "class", + "extends": "Illuminate\\Support\\ServiceProvider", + "implements": [], + "constants": [], + "methods": { + "boot": "public (): void", + "register": "public (): void" + } + }, + "Pollora\\Meta\\Infrastructure\\Laravel\\TypedMetaCheck": { + "kind": "final readonly class", + "extends": null, + "implements": [ + "Pollora\\Doctor\\Domain\\Contracts\\CheckInterface" + ], + "constants": [], + "methods": { + "__construct": "public (Pollora\\Meta\\Application\\Services\\MetaSchemaRepository $schemas, Pollora\\Meta\\Application\\Services\\MetaAuditor $auditor): mixed", + "id": "public (): string", + "label": "public (): string", + "run": "public (Pollora\\Doctor\\Domain\\Enums\\RunContext $context): Pollora\\Doctor\\Domain\\Models\\CheckResult", + "runsIn": "public (): array" + } + }, + "Pollora\\Role\\Infrastructure\\Laravel\\Console\\RoleDumpCommand": { + "kind": "class", + "extends": "Illuminate\\Console\\Command", + "implements": [], + "constants": [], + "methods": { + "handle": "public (Pollora\\Role\\Domain\\Contracts\\RoleStoreInterface $store): int" + } + }, + "Pollora\\Role\\Infrastructure\\Laravel\\Console\\RoleImportCommand": { + "kind": "class", + "extends": "Pollora\\Console\\AbstractGeneratorCommand", + "implements": [], + "constants": [], + "methods": { + "execute": "protected (Symfony\\Component\\Console\\Input\\InputInterface $input, Symfony\\Component\\Console\\Output\\OutputInterface $output): int", + "getArguments": "protected (): array", + "getNameInput": "protected (): string", + "getOptions": "protected (): array", + "getStub": "protected (): string", + "handle": "public (): ?bool", + "replaceClass": "protected (mixed $stub, mixed $name): string" + } + }, + "Pollora\\Role\\Infrastructure\\Laravel\\Console\\RoleListCommand": { + "kind": "class", + "extends": "Illuminate\\Console\\Command", + "implements": [], + "constants": [], + "methods": { + "handle": "public (Pollora\\Role\\Application\\Services\\RoleRegistry $registry, Pollora\\Role\\Domain\\Contracts\\RoleUsageInterface $usage): int" + } + }, + "Pollora\\Role\\Infrastructure\\Laravel\\Console\\RoleMakeCommand": { + "kind": "class", + "extends": "Pollora\\Console\\AbstractGeneratorCommand", + "implements": [], + "constants": [], + "methods": { + "getArguments": "protected (): array", + "getStub": "protected (): string", + "replaceClass": "protected (mixed $stub, mixed $name): string" + } + }, + "Pollora\\Role\\Infrastructure\\Laravel\\Console\\RolePruneCommand": { + "kind": "class", + "extends": "Illuminate\\Console\\Command", + "implements": [], + "constants": [], + "methods": { + "handle": "public (Pollora\\Role\\Domain\\Contracts\\RoleUsageInterface $usage, Pollora\\Role\\Domain\\Contracts\\RoleStoreInterface $store, Pollora\\Role\\Application\\Services\\RoleRegistry $registry, Pollora\\Role\\Domain\\Services\\StoredEntryClassifier $classifier): int" + } + }, + "Pollora\\Role\\Infrastructure\\Laravel\\Console\\RoleShowCommand": { + "kind": "class", + "extends": "Illuminate\\Console\\Command", + "implements": [], + "constants": [], + "methods": { + "handle": "public (Pollora\\Role\\Application\\Services\\RoleRegistry $registry): int" + } + }, + "Pollora\\Role\\Infrastructure\\Laravel\\EnsureUserHasRole": { + "kind": "class", + "extends": null, + "implements": [], + "constants": [], + "methods": { + "handle": "public (Illuminate\\Http\\Request $request, Closure $next, ...string $roles): Symfony\\Component\\HttpFoundation\\Response", + "using": "public static (...string $roles): string" + } + }, + "Pollora\\Role\\Infrastructure\\Laravel\\RoleDirective": { + "kind": "final class", + "extends": null, + "implements": [], + "constants": [], + "methods": { + "__invoke": "public (string $expression): string" + } + }, + "Pollora\\Role\\Infrastructure\\Laravel\\RoleServiceProvider": { + "kind": "class", + "extends": "Illuminate\\Support\\ServiceProvider", + "implements": [], + "constants": [], + "methods": { + "boot": "public (): void", + "register": "public (): void" + } + }, + "Pollora\\Role\\Infrastructure\\Laravel\\RolesCheck": { + "kind": "final readonly class", + "extends": null, + "implements": [ + "Pollora\\Doctor\\Domain\\Contracts\\CheckInterface" + ], + "constants": [], + "methods": { + "__construct": "public (Pollora\\Role\\Domain\\Contracts\\RoleUsageInterface $usage, Pollora\\Role\\Infrastructure\\Adapters\\WordPressRoleInjector $injector, Pollora\\Role\\Domain\\Services\\StoredEntryClassifier $classifier = ?): mixed", + "id": "public (): string", + "label": "public (): string", + "run": "public (Pollora\\Doctor\\Domain\\Enums\\RunContext $context): Pollora\\Doctor\\Domain\\Models\\CheckResult", + "runsIn": "public (): array" + } + } +} diff --git a/tests/Unit/Asset/AssetDomainTest.php b/tests/Unit/Asset/AssetDomainTest.php deleted file mode 100644 index f56d63fc..00000000 --- a/tests/Unit/Asset/AssetDomainTest.php +++ /dev/null @@ -1,55 +0,0 @@ - 'js']); - expect($asset->getName())->toBe('main'); - expect($asset->getPath())->toBe('assets/main.js'); - expect($asset->getAttributes())->toBe(['type' => 'js']); - }); -}); - -describe('AssetFile domain model', function (): void { - it('can be instantiated and return filename and container', function (): void { - $file = new AssetFile('assets/app.css'); - expect($file->getFilename())->toBe('assets/app.css'); - expect($file->getAssetContainer())->toBe('theme'); - }); - it('can set a custom asset container', function (): void { - $file = (new AssetFile('assets/app.css'))->from('custom'); - expect($file->getAssetContainer())->toBe('custom'); - }); - it('can be cast to string as filename', function (): void { - $file = new AssetFile('assets/app.css'); - expect((string) $file)->toBe('assets/app.css'); - }); -}); - -describe('ViteManager domain stub', function (): void { - beforeEach(function (): void { - $app = Mockery::mock(Container::class)->makePartial(); - $app->shouldReceive('publicPath')->andReturnUsing(fn ($path = ''): string => '/tmp/public'.($path ? '/'.$path : '')); - $app->instance('path.public', '/tmp/public'); - Container::setInstance($app); - }); - - afterEach(function (): void { - Container::setInstance(new Container); - }); - it('returns stub values for all interface methods', function (): void { - $vite = new ViteManager; - expect($vite->container())->toBeInstanceOf(AssetContainer::class); - expect($vite->getAssetUrls(['entry.js']))->toBeArray()->toBeEmpty(); - expect($vite->asset('foo.js'))->toBe('foo.js'); - expect($vite->isRunningHot())->toBeFalse(); - expect($vite->getViteClientHtml())->toBe(''); - }); -}); diff --git a/tests/Unit/BlockBinding/BindingContextTest.php b/tests/Unit/BlockBinding/BindingContextTest.php deleted file mode 100644 index 67c8b204..00000000 --- a/tests/Unit/BlockBinding/BindingContextTest.php +++ /dev/null @@ -1,35 +0,0 @@ - [7 => ['capacity' => '300']], 'term' => [3 => ['city' => 'Nantes']]]); - $this->accessor = new MetaAccessor(new MetaSchemaRepository, new MetaSchemaBuilder, $store, new MetaValueCaster); -}); - -it('gives the arguments of the binding', function (): void { - $context = new BindingContext(args: ['field' => 'date', 'format' => 'j F Y']); - - expect($context->arg('format'))->toBe('j F Y') - ->and($context->arg('size', 'large'))->toBe('large'); -}); - -it('reads typed meta on the object of the block context that carries them', function (): void { - $context = new BindingContext(postId: 7, postType: 'concert', termId: 3, taxonomy: 'venue', metaAccessor: $this->accessor); - - expect($context->meta(Concert::class)?->capacity)->toBe(300) - ->and($context->meta(VenueExtras::class)?->city)->toBe('Nantes'); -}); - -it('has no meta to give without the object', function (): void { - expect((new BindingContext(metaAccessor: $this->accessor))->meta(Concert::class))->toBeNull(); -}); diff --git a/tests/Unit/BlockBinding/BindingEditorDataTest.php b/tests/Unit/BlockBinding/BindingEditorDataTest.php deleted file mode 100644 index 1a7c95d6..00000000 --- a/tests/Unit/BlockBinding/BindingEditorDataTest.php +++ /dev/null @@ -1,53 +0,0 @@ -add((new BindingSourceBuilder)->build($class)); - } - - $schemas = new MetaSchemaRepository; - - foreach ([Concert::class, ArtistProfile::class, VenueExtras::class] as $class) { - $schemas->add((new MetaSchemaBuilder)->build($class)); - } - - $this->data = (new BindingEditorData($sources, $schemas, new Repository(['block-bindings' => ['options' => ['blogdescription']]])))->toArray(); -}); - -it('gives the editor the route and the fields of a #[BlockBinding] source', function (): void { - expect($this->data['route'])->toBe('pollora/v1/block-bindings/resolve') - ->and($this->data['sources']['acme/event']['postTypes'])->toBe(['event']) - ->and($this->data['sources']['acme/event']['fields'][0])->toBe(['label' => 'Remaining seats', 'args' => ['field' => 'remaining_seats'], 'type' => 'string']); -}); - -it('offers the post meta a binding may show, by post type', function (): void { - $fields = $this->data['sources']['pollora/post-meta']; - $keys = array_map(static fn (array $field): string => $field['args']['key'].':'.$field['type'], $fields['fields']['concert']); - - expect($fields['subtype'])->toBe('postType') - ->and($keys)->toContain('capacity:string', 'starts_at:string', 'cover_image_id:string', 'cover_image_id:number') - ->not->toContain('backstage_code:string', '_promoter_fee:string') - ->and($fields['fields']['concert'][0]['label'])->toBe('Starts At'); -}); - -it('offers the public user meta, the term meta and the listed options', function (): void { - expect(array_column(array_column($this->data['sources']['pollora/author-meta']['fields'][''], 'args'), 'key'))->toBe(['stage_name']) - ->and($this->data['sources']['pollora/term-meta']['fields']['venue'][0]['args'])->toBe(['key' => 'city']) - ->and($this->data['sources']['pollora/option']['fields'])->toBe([['label' => 'Blogdescription', 'args' => ['name' => 'blogdescription'], 'type' => 'string']]); -}); diff --git a/tests/Unit/BlockBinding/BindingFormatterTest.php b/tests/Unit/BlockBinding/BindingFormatterTest.php deleted file mode 100644 index 0c73075f..00000000 --- a/tests/Unit/BlockBinding/BindingFormatterTest.php +++ /dev/null @@ -1,89 +0,0 @@ -build(Concert::class)->definitions[$property]; -} - -/** - * @param array $args - */ -function formatConcert(string $property, mixed $value, array $args = [], string $attribute = 'content'): mixed -{ - return (new BindingFormatter(new FakePresenter))->format(concertMeta($property), $value, $args, $attribute); -} - -beforeEach(function (): void { - Functions\when('number_format_i18n')->alias(fn (float|int $number, int $decimals = 0): string => number_format((float) $number, $decimals, ',', "\u{202F}")); - Functions\when('wp_date')->alias(fn (string $format, int $timestamp): string => gmdate($format, $timestamp)); - Functions\when('get_option')->alias(fn (string $option, mixed $default = false): mixed => $option === 'date_format' ? 'd/m/Y' : $default); - Functions\when('wp_sprintf')->alias(fn (string $pattern, array $items): string => implode(', ', $items)); -}); - -it('formats a number with the site separators and its own decimals', function (): void { - expect(formatConcert('capacity', 1200))->toBe("1\u{202F}200") - ->and(formatConcert('price', 12.5))->toBe('12,5') - ->and(formatConcert('price', 12.5, ['decimals' => 2]))->toBe('12,50'); -}); - -it('formats a date in the site format, or the one given', function (): void { - $date = CarbonImmutable::parse('2026-11-14 09:00:00', 'UTC'); - - expect(formatConcert('startsAt', $date))->toBe('14/11/2026') - ->and(formatConcert('startsAt', $date, ['format' => 'Y-m-d H:i']))->toBe('2026-11-14 09:00'); -}); - -it('words a boolean, with the labels given', function (): void { - expect(formatConcert('soldOut', true))->toBe('Yes') - ->and(formatConcert('soldOut', false, ['true' => 'Complet', 'false' => 'Places disponibles']))->toBe('Places disponibles'); -}); - -it('shows an enum by its label', function (): void { - expect(formatConcert('status', ConcertStatus::Cancelled))->toBe('Cancelled') - ->and(formatConcert('status', ConcertStatus::Announced))->toBe('Coming soon'); -}); - -it('lists the items of an array', function (): void { - expect(formatConcert('genres', ['rock', 'jazz']))->toBe('rock, jazz'); -}); - -it('gives the raw value with format: raw', function (): void { - expect(formatConcert('capacity', 1200, ['format' => 'raw']))->toBe(1200) - ->and(formatConcert('status', ConcertStatus::Cancelled, ['format' => 'raw']))->toBe('cancelled') - ->and(formatConcert('startsAt', CarbonImmutable::parse('2026-11-14 09:00:00', 'UTC'), ['format' => 'raw']))->toBe('2026-11-14T09:00:00+00:00'); -}); - -it('shows the fallback for an empty meta, or keeps the block content', function (): void { - expect(formatConcert('startsAt', null, ['fallback' => 'Date to come']))->toBe('Date to come') - ->and(formatConcert('genres', []))->toBeNull(); -}); - -it('gives an attachment as the bound attribute needs it', function (): void { - Functions\when('get_post_type')->justReturn('attachment'); - Functions\when('wp_get_attachment_image_url')->alias(fn (int $id, string $size): string => "https://example.test/cover-{$size}.jpg"); - Functions\when('get_post_meta')->alias(fn (int $id, string $key): string => $key === '_wp_attachment_image_alt' ? 'The stage' : ''); - Functions\when('wp_get_attachment_caption')->justReturn(''); - - expect(formatConcert('coverImageId', 31, ['size' => 'large'], 'url'))->toBe('url(https://example.test/cover-large.jpg)') - ->and(formatConcert('coverImageId', 31, [], 'url'))->toBe('url(https://example.test/cover-full.jpg)') - ->and(formatConcert('coverImageId', 31, [], 'alt'))->toBe('The stage') - ->and(formatConcert('coverImageId', 31, [], 'id'))->toBe(31) - ->and(formatConcert('coverImageId', 31, [], 'caption'))->toBeNull(); -}); - -it('gives nothing for an ID that is not an attachment', function (): void { - Functions\when('get_post_type')->justReturn('post'); - - expect(formatConcert('coverImageId', 31, [], 'url'))->toBeNull(); -}); diff --git a/tests/Unit/BlockBinding/BindingListCommandTest.php b/tests/Unit/BlockBinding/BindingListCommandTest.php index 8df78d8e..93dfcb2c 100644 --- a/tests/Unit/BlockBinding/BindingListCommandTest.php +++ b/tests/Unit/BlockBinding/BindingListCommandTest.php @@ -2,15 +2,14 @@ declare(strict_types=1); -use Illuminate\Config\Repository; use Illuminate\Console\Attributes\Signature; use Illuminate\Container\Container; use Illuminate\Foundation\Application; use Pollora\BlockBinding\Application\Services\BindingSourceBuilder; use Pollora\BlockBinding\Application\Services\BindingSourceRegistry; -use Pollora\BlockBinding\Infrastructure\Providers\BlockBindingServiceProvider; +use Pollora\BlockBinding\Infrastructure\Laravel\BlockBindingServiceProvider; +use Pollora\BlockBinding\Infrastructure\Laravel\Console\BindingListCommand; use Pollora\BlockBinding\Infrastructure\Services\BindingEditorData; -use Pollora\BlockBinding\UI\Console\BindingListCommand; use Pollora\Meta\Application\Services\MetaSchemaBuilder; use Pollora\Meta\Application\Services\MetaSchemaRepository; use Symfony\Component\Console\Input\ArrayInput; @@ -34,7 +33,7 @@ function runBindingList(array $input = [], ?array $blocks = null): string $schemas->add((new MetaSchemaBuilder)->build(Concert::class)); $app->instance(BindingSourceRegistry::class, $sources); - $app->instance(BindingEditorData::class, new BindingEditorData($sources, $schemas, new Repository(['block-bindings' => ['options' => ['blogname']]]))); + $app->instance(BindingEditorData::class, new BindingEditorData($sources, $schemas, ['blogname'])); $command = new #[Signature('pollora:binding:list {--json}')] class($blocks) extends BindingListCommand { diff --git a/tests/Unit/BlockBinding/BindingReferenceInspectorTest.php b/tests/Unit/BlockBinding/BindingReferenceInspectorTest.php deleted file mode 100644 index f41368a2..00000000 --- a/tests/Unit/BlockBinding/BindingReferenceInspectorTest.php +++ /dev/null @@ -1,61 +0,0 @@ -add((new BindingSourceBuilder)->build($class)); - } - - $schemas = new MetaSchemaRepository; - - foreach ([Concert::class, ArtistProfile::class, VenueExtras::class] as $class) { - $schemas->add((new MetaSchemaBuilder)->build($class)); - } - - $this->inspector = new BindingReferenceInspector($sources, $schemas, ['blogdescription']); -}); - -it('accepts a binding that can show its value', function (string $source, array $args): void { - expect($this->inspector->problem($source, $args))->toBeNull(); -})->with([ - 'a field' => ['acme/event', ['field' => 'remaining_seats']], - 'an invokable source' => ['acme/echo', ['say' => 'hi']], - 'a post meta in REST' => ['pollora/post-meta', ['key' => 'capacity']], - 'a term meta in REST' => ['pollora/term-meta', ['key' => 'city']], - 'a public user meta' => ['pollora/author-meta', ['key' => 'stage_name']], - 'a listed option' => ['pollora/option', ['name' => 'blogdescription']], -]); - -it('says why a binding would leave its block unchanged', function (string $source, array $args, string $problem): void { - expect($this->inspector->problem($source, $args))->toBe($problem); -})->with([ - 'unknown field' => ['acme/event', ['field' => 'seats'], 'the field "seats" does not exist; acme/event has the fields "remaining_seats", "booking_url", "capacity", "sold_out", "summary", "nothing", "broken"'], - 'no field' => ['acme/event', [], 'no "field" argument; acme/event has the fields "remaining_seats", "booking_url", "capacity", "sold_out", "summary", "nothing", "broken"'], - 'undeclared meta' => ['pollora/post-meta', ['key' => 'nope'], 'no #[Meta] declares the post meta "nope"'], - 'meta out of REST' => ['pollora/post-meta', ['key' => 'backstage_code'], 'the meta "backstage_code" is never shown: it is not exposed in REST (showInRest: true) or its key is protected'], - 'protected meta' => ['pollora/post-meta', ['key' => '_promoter_fee'], 'the meta "_promoter_fee" is never shown: it is not exposed in REST (showInRest: true) or its key is protected'], - 'private user meta' => ['pollora/author-meta', ['key' => 'phone'], 'the meta "phone" is never shown: it is not marked #[Meta(public: true)]'], - 'no key' => ['pollora/term-meta', [], 'no "key" argument'], - 'unlisted option' => ['pollora/option', ['name' => 'admin_email'], 'the option "admin_email" is not listed in block-bindings.options'], -]); - -it('leaves the sources it does not know to WordPress', function (): void { - expect($this->inspector->knows('core/post-meta'))->toBeFalse() - ->and($this->inspector->problem('core/post-meta', ['key' => 'anything']))->toBeNull() - ->and($this->inspector->knows('acme/event'))->toBeTrue(); -}); diff --git a/tests/Unit/BlockBinding/BindingResolverTest.php b/tests/Unit/BlockBinding/BindingResolverTest.php index 8c8ecadf..0ca66717 100644 --- a/tests/Unit/BlockBinding/BindingResolverTest.php +++ b/tests/Unit/BlockBinding/BindingResolverTest.php @@ -7,6 +7,8 @@ use Pollora\BlockBinding\Application\Services\BindingResolver; use Pollora\BlockBinding\Application\Services\BindingSourceBuilder; use Pollora\BlockBinding\Domain\Events\BindingResolved; +use Pollora\BlockBinding\Infrastructure\Laravel\LaravelBindingEvents; +use Pollora\BlockBinding\Infrastructure\Laravel\LaravelMethodInvoker; use Psr\Log\LoggerInterface; use Tests\Unit\BlockBinding\Fixtures\EventBinding; use Tests\Unit\BlockBinding\Fixtures\FakePresenter; @@ -20,7 +22,7 @@ $this->container = new Container; $this->visibility = new FakeVisibility; $this->logger = Mockery::mock(LoggerInterface::class); - $this->resolver = fn (bool $debug = false): BindingResolver => new BindingResolver($this->container, $this->visibility, new FakePresenter, null, $debug, $this->logger); + $this->resolver = fn (bool $debug = false): BindingResolver => new BindingResolver($this->container, $this->visibility, new FakePresenter, null, $debug, $this->logger, new LaravelMethodInvoker($this->container), new LaravelBindingEvents($this->container)); $this->event = (new BindingSourceBuilder)->build(EventBinding::class); }); diff --git a/tests/Unit/BlockBinding/BindingSourceBuilderTest.php b/tests/Unit/BlockBinding/BindingSourceBuilderTest.php deleted file mode 100644 index 3ddb988f..00000000 --- a/tests/Unit/BlockBinding/BindingSourceBuilderTest.php +++ /dev/null @@ -1,59 +0,0 @@ -build(EventBinding::class); - - expect($source->name)->toBe('acme/event') - ->and($source->label)->toBe('Event') - ->and($source->usesContext)->toBe(['postId', 'postType']) - ->and($source->postTypes)->toBe(['event']) - ->and(array_keys($source->fields))->toBe(['remaining_seats', 'booking_url', 'capacity', 'sold_out', 'summary', 'nothing', 'broken']) - ->and($source->field('remaining_seats')->label)->toBe('Remaining seats') - ->and($source->field('booking_url')->label)->toBe('Booking Url') - ->and($source->field('booking_url')->type)->toBe(BindingFieldType::Url) - ->and($source->field('capacity')->method)->toBe('seatCount') - ->and($source->isInvokable())->toBeFalse(); -}); - -it('builds an invokable source, labelled after its class', function (): void { - $source = (new BindingSourceBuilder)->build(InvokableBinding::class); - - expect($source->isInvokable())->toBeTrue() - ->and($source->label)->toBe('Invokable Binding') - ->and($source->usesContext)->toBe([]); -}); - -it('refuses a source WordPress would reject or leave empty', function (string $class, string $message): void { - expect(fn (): BindingSource => (new BindingSourceBuilder)->build($class)) - ->toThrow(InvalidBindingSourceException::class, $message); -})->with([ - 'no attribute' => [NotASource::class, 'has no #[BlockBinding] attribute'], - 'uppercase name' => [UppercaseName::class, 'must be "namespace/name"'], - 'no namespace' => [NoNamespace::class, 'must be "namespace/name"'], - 'no field' => [NoField::class, 'mark a public method #[BindingField], or define __invoke()'], - 'untyped field' => [UntypedField::class, 'UntypedField::title() cannot be registered: declare its return type'], - 'array field' => [ArrayField::class, 'it returns array'], - 'field twice' => [DuplicateField::class, 'the field "title" is already answered by heading()'], - 'unknown type' => [UnknownType::class, 'the type "video" is unknown'], - 'private field' => [PrivateField::class, 'a field must be a public, non-static method'], -]); diff --git a/tests/Unit/BlockBinding/BlockBindingDiscoveryTest.php b/tests/Unit/BlockBinding/BlockBindingDiscoveryTest.php deleted file mode 100644 index 7e5ee5f5..00000000 --- a/tests/Unit/BlockBinding/BlockBindingDiscoveryTest.php +++ /dev/null @@ -1,105 +0,0 @@ -registered = []; - Functions\when('did_action')->justReturn(1); - Functions\when('get_block_bindings_source')->alias(fn (string $name): ?object => isset($this->registered[$name]) ? (object) [] : null); - Functions\when('register_block_bindings_source')->alias(function (string $name, array $properties): bool { - $this->registered[$name] = $properties; - - return true; - }); - - $this->sources = new BindingSourceRegistry; - $this->logger = Mockery::mock(LoggerInterface::class); - $resolver = new BindingResolver(new Container, new FakeVisibility, new FakePresenter); - $this->discovery = new BlockBindingDiscovery(new BindingSourceBuilder, $this->sources, new WordPressBindingRegistry(Mockery::mock(Action::class), $resolver), $this->logger); - $this->location = new DiscoveryLocation('Tests\\', __DIR__); - - $this->discover = function (string ...$classes): void { - foreach ($classes as $class) { - $this->discovery->discover($this->location, DiscoveredClass::fromReflection(new ReflectionClass($class))); - } - }; -}); - -it('keeps the #[BlockBinding] classes only', function (): void { - ($this->discover)(EventBinding::class, SeatCounter::class, InvokableBinding::class); - - expect(array_column(iterator_to_array($this->discovery->getItems()), 'class'))->toBe([EventBinding::class, InvokableBinding::class]); -}); - -it('registers each source with WordPress, its label, context and one callback', function (): void { - ($this->discover)(EventBinding::class, InvokableBinding::class); - - $this->discovery->apply(); - - expect(array_keys($this->registered))->toBe(['acme/event', 'acme/echo']) - ->and($this->registered['acme/event']['label'])->toBe('Event') - ->and($this->registered['acme/event']['uses_context'])->toBe(['postId', 'postType']) - ->and(($this->registered['acme/echo']['get_value_callback'])(['say' => 'hello'], boundParagraph([]), 'content'))->toBe('hello') - ->and($this->sources->find('acme/event'))->not->toBeNull(); -}); - -it('waits for init when it has not run yet', function (): void { - Functions\when('did_action')->justReturn(0); - $action = Mockery::mock(Action::class); - $action->shouldReceive('add')->once()->with('init', Mockery::type(Closure::class))->andReturnUsing(function (string $hook, Closure $callback) use ($action): Action { - $callback(); - - return $action; - }); - - (new WordPressBindingRegistry($action, new BindingResolver(new Container, new FakeVisibility, new FakePresenter))) - ->register((new BindingSourceBuilder)->build(InvokableBinding::class)); - - expect(array_keys($this->registered))->toBe(['acme/echo']); -}); - -it('logs a source it cannot register and carries on', function (): void { - $this->logger->shouldReceive('error')->once()->with(Mockery::pattern('/NoField: .*mark a public method/'), Mockery::type('array')); - ($this->discover)(NoField::class, InvokableBinding::class); - - $this->discovery->apply(); - - expect(array_keys($this->registered))->toBe(['acme/echo']); -}); - -it('refuses a source name another class declares', function (): void { - $this->logger->shouldReceive('error')->once()->with(Mockery::pattern('/"acme\/echo" is declared twice/'), Mockery::type('array')); - $this->sources->add((new BindingSourceBuilder)->build(InvokableBinding::class)); - $clone = new #[BlockBinding('acme/echo')] class - { - public function __invoke(): string - { - return ''; - } - }; - $this->discovery->getItems()->add($this->location, ['class' => $clone::class]); - - $this->discovery->apply(); -}); diff --git a/tests/Unit/BlockBinding/BlockBindingServiceProviderTest.php b/tests/Unit/BlockBinding/BlockBindingServiceProviderTest.php index dc6f4fcc..1de75e1d 100644 --- a/tests/Unit/BlockBinding/BlockBindingServiceProviderTest.php +++ b/tests/Unit/BlockBinding/BlockBindingServiceProviderTest.php @@ -10,9 +10,9 @@ use Illuminate\Foundation\Application; use Pollora\BlockBinding\Application\Services\BindingResolver; use Pollora\BlockBinding\Application\Services\BindingSourceRegistry; -use Pollora\BlockBinding\Infrastructure\Providers\BlockBindingServiceProvider; +use Pollora\BlockBinding\Infrastructure\Laravel\BlockBindingServiceProvider; +use Pollora\BlockBinding\Infrastructure\Laravel\Console\MakeBindingCommand; use Pollora\BlockBinding\Infrastructure\Services\BlockBindingDiscovery; -use Pollora\BlockBinding\UI\Console\MakeBindingCommand; use Pollora\Hook\Domain\Contract\Action; use Pollora\Meta\Application\Services\MetaAccessor; use Pollora\Meta\Application\Services\MetaSchemaBuilder; diff --git a/tests/Unit/BlockBinding/BlockBindingsCheckTest.php b/tests/Unit/BlockBinding/BlockBindingsCheckTest.php index 038cd11e..67df082c 100644 --- a/tests/Unit/BlockBinding/BlockBindingsCheckTest.php +++ b/tests/Unit/BlockBinding/BlockBindingsCheckTest.php @@ -7,7 +7,7 @@ use Pollora\BlockBinding\Application\Services\BindingReferenceInspector; use Pollora\BlockBinding\Application\Services\BindingSourceBuilder; use Pollora\BlockBinding\Application\Services\BindingSourceRegistry; -use Pollora\BlockBinding\Infrastructure\Checks\BlockBindingsCheck; +use Pollora\BlockBinding\Infrastructure\Laravel\BlockBindingsCheck; use Pollora\Doctor\Domain\Enums\CheckStatus; use Pollora\Doctor\Domain\Enums\RunContext; use Pollora\Doctor\Infrastructure\Support\ProjectModule; diff --git a/tests/Unit/BlockBinding/PolloraSourcesTest.php b/tests/Unit/BlockBinding/PolloraSourcesTest.php deleted file mode 100644 index ed528b46..00000000 --- a/tests/Unit/BlockBinding/PolloraSourcesTest.php +++ /dev/null @@ -1,93 +0,0 @@ -store = new ArrayMetaStore([ - 'post' => [7 => ['capacity' => '1200', 'backstage_code' => 'X42', '_promoter_fee' => '900']], - 'user' => [5 => ['stage_name' => 'The Hums', 'phone' => '0600000000']], - 'term' => [3 => ['city' => 'Lyon']], - ]); - $schemas = new MetaSchemaRepository; - - foreach ([Concert::class, ArtistProfile::class, VenueExtras::class] as $class) { - $schemas->add((new MetaSchemaBuilder)->build($class)); - } - - $this->reader = new TypedMetaReader($schemas, new MetaAccessor($schemas, new MetaSchemaBuilder, $this->store, new MetaValueCaster)); - $this->formatter = new BindingFormatter(new FakePresenter); - - Functions\when('number_format_i18n')->alias(fn (float|int $number, int $decimals = 0): string => number_format((float) $number, $decimals, ',', ' ')); -}); - -/** - * @param array $args - */ -function concertContext(array $args, ?int $termId = null, ?string $taxonomy = null): BindingContext -{ - return new BindingContext(args: $args, attribute: 'content', postId: 7, postType: 'concert', termId: $termId, taxonomy: $taxonomy); -} - -it('pollora/post-meta reads a declared meta exposed in REST, formatted', function (): void { - $source = new PostMetaSource($this->reader, $this->formatter); - - expect($source(concertContext(['key' => 'capacity'])))->toBe('1 200') - ->and($source(concertContext(['key' => 'capacity', 'format' => 'raw'])))->toBe(1200); -}); - -it('pollora/post-meta reads nothing hidden from REST, protected or undeclared', function (string $key): void { - expect((new PostMetaSource($this->reader, $this->formatter))(concertContext(['key' => $key])))->toBeNull(); -})->with(['not in REST' => ['backstage_code'], 'protected' => ['_promoter_fee'], 'undeclared' => ['unknown_key']]); - -it('pollora/term-meta reads the term of the block context', function (): void { - $source = new TermMetaSource($this->reader, $this->formatter, new FakeVisibility); - - expect($source(concertContext(['key' => 'city'], 3, 'venue')))->toBe('Lyon'); -}); - -it('pollora/term-meta falls back on the queried term, when the visitor can see it', function (): void { - $term = new WP_Term; - $term->term_id = 3; - $term->taxonomy = 'venue'; - - Functions\when('get_queried_object')->justReturn($term); - - expect((new TermMetaSource($this->reader, $this->formatter, new FakeVisibility))(concertContext(['key' => 'city'])))->toBe('Lyon') - ->and((new TermMetaSource($this->reader, $this->formatter, new FakeVisibility(hiddenTerms: [3])))(concertContext(['key' => 'city'])))->toBeNull(); -}); - -it('pollora/author-meta reads only the public meta of the author', function (): void { - Functions\when('get_post_field')->justReturn('5'); - $source = new AuthorMetaSource($this->reader, $this->formatter); - - expect($source(concertContext(['key' => 'stage_name'])))->toBe('The Hums') - ->and($source(concertContext(['key' => 'phone'])))->toBeNull(); -}); - -it('pollora/option reads only the options the project lists', function (): void { - Functions\when('get_option')->alias(fn (string $name): string => $name === 'blogdescription' ? 'Live music' : 'secret'); - $source = new OptionSource(new Repository(['block-bindings' => ['options' => ['blogdescription']]])); - - expect($source(new BindingContext(args: ['name' => 'blogdescription'])))->toBe('Live music') - ->and($source(new BindingContext(args: ['name' => 'admin_email'])))->toBeNull(); -}); diff --git a/tests/Unit/BlockBinding/ResolveBindingsControllerTest.php b/tests/Unit/BlockBinding/ResolveBindingsControllerTest.php deleted file mode 100644 index 2f6e56f6..00000000 --- a/tests/Unit/BlockBinding/ResolveBindingsControllerTest.php +++ /dev/null @@ -1,89 +0,0 @@ - $params - */ -function previewRequest(array $params): WP_REST_Request -{ - $request = Mockery::mock(WP_REST_Request::class); - $request->shouldReceive('get_param')->andReturnUsing(fn (string $name): mixed => $params[$name] ?? null); - - return $request; -} - -beforeEach(function (): void { - Functions\when('get_locale')->justReturn('fr_FR'); - $this->locales = []; - Functions\when('switch_to_locale')->alias(function (string $locale): bool { - $this->locales[] = 'switch:'.$locale; - - return true; - }); - Functions\when('restore_previous_locale')->alias(function (): void { - $this->locales[] = 'restore'; - }); - Functions\when('sanitize_key')->alias(fn (string $key): string => strtolower((string) preg_replace('/[^a-z0-9_\-]/i', '', $key))); - - $sources = new BindingSourceRegistry; - $sources->add((new BindingSourceBuilder)->build(EventBinding::class)); - - $this->visibility = new FakeVisibility; - $this->controller = new ResolveBindingsController($sources, new BindingResolver(new Container, $this->visibility, new FakePresenter)); -}); - -it('answers the values of the bindings sent, by key', function (): void { - $response = $this->controller->handle(previewRequest([ - 'context' => ['postId' => '7', 'postType' => 'event', 'injected' => 'x'], - 'bindings' => [ - ['key' => 'a', 'source' => 'acme/event', 'args' => ['field' => 'remaining_seats'], 'attribute' => 'content'], - ['key' => 'b', 'source' => 'acme/event', 'args' => ['field' => 'booking_url'], 'attribute' => 'url'], - ['key' => 'c', 'source' => 'core/post-meta', 'args' => ['key' => 'secret']], - ['key' => 'd', 'source' => 'acme/event', 'args' => ['field' => 'sold_out']], - ['source' => 'acme/event'], - ], - ])); - - expect($response['values'])->toBe([ - 'a' => '14 seats & more', - 'b' => 'url(https://example.test/book/7)', - 'c' => null, - 'd' => 'Yes', - ]); -}); - -it('shows nothing of a post the editor may not read', function (): void { - $this->visibility->hiddenPosts = [7]; - - $response = $this->controller->handle(previewRequest([ - 'context' => ['postId' => 7, 'postType' => 'event'], - 'bindings' => [['key' => 'a', 'source' => 'acme/event', 'args' => ['field' => 'capacity']]], - ])); - - expect($response['values'])->toBe(['a' => null]); -}); - -it('lets preview only who can edit the post, or edit posts without one', function (): void { - Functions\when('current_user_can')->alias(fn (string $capability, mixed ...$args): bool => $capability === 'edit_post' && $args === [7]); - - expect($this->controller->canPreview(previewRequest(['context' => ['postId' => 7]])))->toBeTrue() - ->and($this->controller->canPreview(previewRequest(['context' => ['postId' => 8]])))->toBeFalse() - ->and($this->controller->canPreview(previewRequest(['context' => []])))->toBeFalse(); -}); - -it('formats the values in the site language, then gives the user theirs back', function (): void { - $this->controller->handle(previewRequest(['bindings' => []])); - - expect($this->locales)->toBe(['switch:fr_FR', 'restore']); -}); diff --git a/tests/Unit/BlockBinding/WordPressBindingEditorScriptTest.php b/tests/Unit/BlockBinding/WordPressBindingEditorScriptTest.php deleted file mode 100644 index e4fd796a..00000000 --- a/tests/Unit/BlockBinding/WordPressBindingEditorScriptTest.php +++ /dev/null @@ -1,37 +0,0 @@ -alias(fn (mixed $data, int $flags = 0): string => json_encode($data, $flags)); - Functions\when('wp_register_script')->alias(function (string $handle, mixed $src, array $deps) use (&$calls): bool { - $calls[] = ['register', $handle, $src, $deps]; - - return true; - }); - Functions\when('wp_add_inline_script')->alias(function (string $handle, string $script, string $position = 'after') use (&$calls): bool { - $calls[] = ['inline', $position, $script]; - - return true; - }); - Functions\when('wp_enqueue_script')->alias(function (string $handle) use (&$calls): void { - $calls[] = ['enqueue', $handle]; - }); - - (new WordPressBindingEditorScript(new BindingEditorData(new BindingSourceRegistry, new MetaSchemaRepository, new Repository)))->enqueue(); - - expect($calls[0])->toBe(['register', 'pollora-block-bindings', false, ['wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-data']]) - ->and($calls[1][1])->toBe('before') - ->and($calls[1][2])->toStartWith('window.polloraBlockBindings = {"route":"pollora/v1/block-bindings/resolve"') - ->and($calls[2][1])->toBe('after') - ->and($calls[2][2])->toStartWith('/**') - ->and($calls[3])->toBe(['enqueue', 'pollora-block-bindings']); -}); diff --git a/tests/Unit/Meta/Fixtures/validator.php b/tests/Unit/Meta/Fixtures/validator.php index 23dcb8be..4c3aa703 100644 --- a/tests/Unit/Meta/Fixtures/validator.php +++ b/tests/Unit/Meta/Fixtures/validator.php @@ -7,7 +7,7 @@ use Illuminate\Translation\ArrayLoader; use Illuminate\Translation\Translator; use Illuminate\Validation\Factory; -use Pollora\Meta\Infrastructure\Services\LaravelMetaValidator; +use Pollora\Meta\Infrastructure\Laravel\LaravelMetaValidator; function metaValidator(): LaravelMetaValidator { diff --git a/tests/Unit/Meta/MetaAccessorTest.php b/tests/Unit/Meta/MetaAccessorTest.php deleted file mode 100644 index 9e3aacdb..00000000 --- a/tests/Unit/Meta/MetaAccessorTest.php +++ /dev/null @@ -1,60 +0,0 @@ -shouldReceive('get')->andReturn($value); - - return $store; -} - -function metaAccessor(MetaStoreInterface $store, bool $debug = false, ?LoggerInterface $logger = null, ?MetaSchemaRepository $schemas = null): MetaAccessor -{ - return new MetaAccessor($schemas ?? new MetaSchemaRepository, new MetaSchemaBuilder, $store, new MetaValueCaster, $debug, $logger); -} - -it('gives the typed meta of an object', function (): void { - $record = metaAccessor(storeReturning('250'))->of(Event::class, 42); - - expect($record->capacity)->toBe(250) - ->and($record->objectId())->toBe(42); -}); - -it('uses the discovered schema when there is one', function (): void { - $store = Mockery::mock(MetaStoreInterface::class); - $store->shouldReceive('get')->with(MetaObjectType::Term, 7, 'color')->once()->andReturn('red'); - $schemas = new MetaSchemaRepository; - $schemas->add((new MetaSchemaBuilder)->build(BookGenre::class)); - - expect(metaAccessor($store, schemas: $schemas)->of(BookGenre::class, 7)->color)->toBe('red'); -}); - -it('refuses a class that declares no meta', function (): void { - metaAccessor(storeReturning(null))->of(NotADeclaration::class, 1); -})->throws(InvalidMetaDefinitionException::class); - -it('throws on an unreadable stored value in debug mode', function (): void { - metaAccessor(storeReturning('many'), debug: true)->of(Event::class, 42)->capacity; -})->throws(InvalidMetaValueException::class, 'The stored value of the meta "capacity" cannot be read as integer'); - -it('reads an unreadable stored value as the default in production, and logs it', function (): void { - $logger = Mockery::mock(LoggerInterface::class); - $logger->shouldReceive('warning')->once()->with(Mockery::pattern('/meta "capacity" cannot be read as integer/')); - - expect(metaAccessor(storeReturning('many'), logger: $logger)->of(Event::class, 42)->capacity)->toBe(0); -}); diff --git a/tests/Unit/Meta/MetaArraysAndObjectsTest.php b/tests/Unit/Meta/MetaArraysAndObjectsTest.php deleted file mode 100644 index 6ab3e570..00000000 --- a/tests/Unit/Meta/MetaArraysAndObjectsTest.php +++ /dev/null @@ -1,190 +0,0 @@ -schema = (new MetaSchemaBuilder)->build(Conference::class); - $this->caster = new MetaValueCaster; -}); - -describe('declaring', function (): void { - it('reads the item type from the docblock or items:', function (): void { - $definitions = $this->schema->definitions; - - expect($definitions['speakers']->valueType)->toBe(MetaValueType::ArrayOf) - ->and($definitions['speakers']->single)->toBeFalse() - ->and($definitions['speakers']->item()->valueType)->toBe(MetaValueType::String) - ->and($definitions['roomIds']->item()->valueType)->toBe(MetaValueType::Integer) - ->and($definitions['statuses']->item()->valueClass)->toBe(EventStatus::class) - ->and($definitions['sessions']->item()->valueType)->toBe(MetaValueType::DataObject) - ->and($definitions['schedule']->valueType)->toBe(MetaValueType::DataObject) - ->and(array_keys($definitions['schedule']->properties))->toBe(['startsAt', 'durationMinutes', 'status', 'room', 'public']); - }); - - it('publishes REST schemas WordPress accepts', function (): void { - $definitions = $this->schema->definitions; - - expect($definitions['speakers']->wordPressType())->toBe('string') - ->and($definitions['speakers']->restSchema())->toBe(['type' => 'string']) - ->and($definitions['roomIds']->wordPressType())->toBe('array') - ->and($definitions['roomIds']->restSchema())->toBe(['type' => 'array', 'items' => ['type' => 'integer']]) - ->and($definitions['schedule']->wordPressType())->toBe('object') - ->and($definitions['schedule']->restSchema())->toBe([ - 'type' => 'object', - 'properties' => [ - 'starts_at' => ['type' => ['string', 'null'], 'format' => 'date-time'], - 'duration_minutes' => ['type' => 'integer'], - 'status' => ['type' => 'string', 'enum' => ['draft', 'published']], - 'room' => ['type' => ['string', 'null']], - 'public' => ['type' => 'boolean'], - ], - 'additionalProperties' => false, - ]); - }); - - it('refuses what it cannot store', function (string $class, string $message): void { - expect(fn (): MetaSchema => (new MetaSchemaBuilder)->build($class))->toThrow(InvalidMetaDefinitionException::class, $message); - })->with([ - 'single: false on a scalar' => [InvalidSingleFalse::class, 'single: false stores one row per item, so it needs an array property'], - 'an array of arrays' => [InvalidNestedArray::class, 'an item cannot be an array'], - 'an unresolvable docblock class' => [InvalidUnknownItem::class, 'the item type Speaker is unknown'], - 'rows of objects' => [InvalidRowsOfObjects::class, 'objects need single: true'], - 'an object with an array' => [InvalidObjectProperty::class, 'ScheduleWithList::$tags: an object property can be'], - ]); -}); - -describe('converting', function (): void { - it('stores one row per item, as strings', function (): void { - expect($this->caster->toStorage($this->schema->definitions['speakers'], ['Ada', 'Grace']))->toBe(['Ada', 'Grace']) - ->and($this->caster->toStorage($this->schema->definitions['statuses'], [EventStatus::Published]))->toBe(['published']) - ->and($this->caster->toPhp($this->schema->definitions['statuses'], ['draft', 'published']))->toBe([EventStatus::Draft, EventStatus::Published]); - }); - - it('stores a single array with JSON values', function (): void { - $roomIds = $this->schema->definitions['roomIds']; - - expect($this->caster->toStorage($roomIds, [3, 7]))->toBe([3, 7]) - ->and($this->caster->toPhp($roomIds, ['3', 7]))->toBe([3, 7]) - ->and(fn () => $this->caster->toStorage($roomIds, ['three']))->toThrow(InvalidMetaValueException::class) - ->and(fn () => $this->caster->toPhp($roomIds, 'not an array'))->toThrow(InvalidMetaValueException::class); - }); - - it('stores a data object as an array, never as a PHP object, and reads it back', function (): void { - $definition = $this->schema->definitions['schedule']; - $schedule = new Schedule(public: false); - $schedule->startsAt = CarbonImmutable::parse('2026-11-14 09:00', 'Europe/Paris'); - $schedule->status = EventStatus::Published; - - $stored = $this->caster->toStorage($definition, $schedule); - $read = $this->caster->toPhp($definition, $stored); - - expect($stored)->toBe([ - 'starts_at' => '2026-11-14T08:00:00+00:00', - 'duration_minutes' => 60, - 'status' => 'published', - 'room' => null, - 'public' => false, - ]) - ->and($read)->toBeInstanceOf(Schedule::class) - ->and($read->startsAt?->toIso8601String())->toBe('2026-11-14T08:00:00+00:00') - ->and($read->status)->toBe(EventStatus::Published) - ->and($read->public)->toBeFalse(); - }); - - it('reads an absent object as an instance with its defaults, and a missing property as its default', function (): void { - $definition = $this->schema->definitions['schedule']; - - expect($this->caster->toPhp($definition, null))->toEqual(new Schedule) - ->and($this->caster->toPhp($definition, ['duration_minutes' => 90])->durationMinutes)->toBe(90) - ->and($this->caster->toPhp($definition, ['duration_minutes' => 90])->public)->toBeTrue() - ->and($this->caster->toPhp($this->schema->definitions['backup'], null))->toBeNull() - ->and(fn () => $this->caster->toPhp($definition, ['duration_minutes' => 'long']))->toThrow(InvalidMetaValueException::class); - }); - - it('stores a list of objects', function (): void { - $sessions = $this->schema->definitions['sessions']; - - $stored = $this->caster->toStorage($sessions, [new Schedule, new Schedule(public: false)]); - - expect($stored)->toHaveCount(2) - ->and($stored[1]['public'])->toBeFalse() - ->and($this->caster->toPhp($sessions, $stored)[1])->toBeInstanceOf(Schedule::class); - }); - - it('sanitizes REST input into the stored form', function (): void { - expect($this->caster->sanitize($this->schema->definitions['roomIds'], ['3', 4]))->toBe([3, 4]) - ->and($this->caster->sanitize($this->schema->definitions['schedule'], ['duration_minutes' => '45']))->toMatchArray(['duration_minutes' => 45, 'public' => true]) - ->and($this->caster->sanitize($this->schema->definitions['roomIds'], 'junk'))->toBe(''); - }); -}); - -describe('reading and writing', function (): void { - it('reads rows and replaces them all on save', function (): void { - $store = Mockery::mock(MetaStoreInterface::class); - $store->shouldReceive('getAll')->once()->with(MetaObjectType::Post, 9, 'speakers')->andReturn(['Ada', 'Grace']); - $store->shouldReceive('replaceAll')->once()->with(MetaObjectType::Post, 9, 'speakers', ['Linus']); - $store->shouldReceive('update')->once()->with(MetaObjectType::Post, 9, 'schedule', Mockery::on(fn (array $value): bool => $value['duration_minutes'] === 30)); - $record = new MetaRecord($this->schema, 9, $store, $this->caster, fn (): mixed => null, metaValidator()); - - expect($record->speakers)->toBe(['Ada', 'Grace']); - - $schedule = new Schedule; - $schedule->durationMinutes = 30; - $record->fill(['speakers' => ['Linus'], 'schedule' => $schedule])->save(); - }); - - it('checks the rules on the whole array', function (): void { - $record = new MetaRecord($this->schema, 9, Mockery::mock(MetaStoreInterface::class), $this->caster, fn (): mixed => null, metaValidator()); - - $record->set('speakers', ['A', 'B', 'C', 'D']); - })->throws(MetaValidationException::class); -}); - -describe('registering', function (): void { - it('registers a non-single meta and sanitizes each row, and strings inside arrays and objects', function (): void { - $calls = []; - Functions\when('did_action')->justReturn(1); - Functions\when('add_post_type_support')->justReturn(); - Functions\when('sanitize_text_field')->alias(fn (string $value): string => strip_tags($value)); - Functions\when('register_meta')->alias(function (string $objectType, string $key, array $args) use (&$calls): bool { - $calls[$key] = $args; - - return true; - }); - - (new WordPressMetaRegistry(Mockery::mock(Action::class), new MetaValueCaster))->register($this->schema); - - expect($calls['speakers']['single'])->toBeFalse() - ->and($calls['speakers']['sanitize_callback'])->toBe('sanitize_text_field') - ->and($calls['speakers'])->not->toHaveKey('default') - ->and($calls['room_ids']['default'])->toBe([]) - ->and($calls['schedule']['default'])->toMatchArray(['duration_minutes' => 60, 'public' => true]) - ->and(($calls['schedule']['sanitize_callback'])(['room' => 'Hall A']))->toMatchArray(['room' => 'Hall A']); - }); -}); diff --git a/tests/Unit/Meta/MetaAuditorTest.php b/tests/Unit/Meta/MetaAuditorTest.php deleted file mode 100644 index 7e936705..00000000 --- a/tests/Unit/Meta/MetaAuditorTest.php +++ /dev/null @@ -1,46 +0,0 @@ -schemas = new MetaSchemaRepository; - $this->schemas->add((new MetaSchemaBuilder)->build(Event::class)); - $this->schemas->add((new MetaSchemaBuilder)->build(ArticleExtras::class)); - - $this->auditor = fn (array $rows): MetaAuditor => new MetaAuditor($this->schemas, new ArrayMetaInventory($rows), new MetaValueCaster); -}); - -it('names the stored values a #[Meta] cannot read as its type', function (): void { - $findings = ($this->auditor)(['post' => ['event' => [ - 10 => ['capacity' => ['250'], 'status' => ['draft']], - 11 => ['capacity' => ['a lot'], 'status' => ['postponed']], - 12 => ['capacity' => ['many']], - ]]])->unreadable(100); - - expect($findings)->toHaveCount(2) - ->and($findings[0])->toMatchArray(['class' => Event::class, 'property' => 'capacity', 'key' => 'capacity', 'owner' => 'post "event"', 'checked' => 3, 'unreadable' => 2, 'objects' => [11, 12]]) - ->and($findings[0]['example'])->toContain("'a lot'") - ->and($findings[1])->toMatchArray(['property' => 'status', 'unreadable' => 1, 'objects' => [11]]); -}); - -it('finds nothing when every stored value reads as its type', function (): void { - expect(($this->auditor)(['post' => ['event' => [10 => ['capacity' => ['250'], 'sold_out' => ['1']]]]])->unreadable(100))->toBe([]); -}); - -it('lists the keys stored on a declared post type that no #[Meta] declares', function (): void { - $findings = ($this->auditor)(['post' => [ - 'event' => [10 => ['capacity' => ['250'], 'old_capacity' => ['200'], '_edit_lock' => ['1']], 11 => ['old_capacity' => ['10']]], - 'post' => [20 => ['legacy_field' => ['x']]], - ]])->undeclared(100); - - // "post" is targeted by #[PostMeta], not declared by the project: its other keys belong to others - expect($findings)->toBe([['owner' => 'post "event"', 'key' => 'old_capacity', 'objects' => 2]]); -}); diff --git a/tests/Unit/Meta/MetaCommandsTest.php b/tests/Unit/Meta/MetaCommandsTest.php index 9637d264..8a6d651c 100644 --- a/tests/Unit/Meta/MetaCommandsTest.php +++ b/tests/Unit/Meta/MetaCommandsTest.php @@ -10,8 +10,8 @@ use Pollora\Meta\Application\Services\MetaSchemaBuilder; use Pollora\Meta\Application\Services\MetaSchemaRepository; use Pollora\Meta\Domain\Services\MetaValueCaster; -use Pollora\Meta\UI\Console\MetaAuditCommand; -use Pollora\Meta\UI\Console\MetaListCommand; +use Pollora\Meta\Infrastructure\Laravel\Console\MetaAuditCommand; +use Pollora\Meta\Infrastructure\Laravel\Console\MetaListCommand; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; use Tests\Unit\Meta\Fixtures\ArrayMetaInventory; diff --git a/tests/Unit/Meta/MetaDefinitionTest.php b/tests/Unit/Meta/MetaDefinitionTest.php deleted file mode 100644 index 0c816b9c..00000000 --- a/tests/Unit/Meta/MetaDefinitionTest.php +++ /dev/null @@ -1,45 +0,0 @@ -definitions = (new MetaSchemaBuilder)->build(Event::class)->definitions; -}); - -it('maps each value type to its register_meta() type', function (string $property, string $type): void { - expect($this->definitions[$property]->wordPressType())->toBe($type); -})->with([ - ['summary', 'string'], - ['startsAt', 'string'], - ['capacity', 'integer'], - ['price', 'number'], - ['soldOut', 'boolean'], - ['status', 'string'], - ['priority', 'integer'], -]); - -it('publishes a REST schema with the date format and the enum values', function (): void { - expect($this->definitions['capacity']->restSchema())->toBe(['type' => 'integer']) - ->and($this->definitions['startsAt']->restSchema())->toBe(['type' => 'string', 'format' => 'date-time']) - ->and($this->definitions['status']->restSchema())->toBe(['type' => 'string', 'enum' => ['draft', 'published']]) - ->and($this->definitions['priority']->restSchema())->toBe(['type' => 'integer', 'enum' => [1, 2]]); -}); - -it('knows a key starting with an underscore is protected', function (): void { - expect($this->definitions['internalRef']->isProtected())->toBeTrue() - ->and($this->definitions['capacity']->isProtected())->toBeFalse(); -}); - -it('finds a definition by property name or by key', function (): void { - $schema = (new MetaSchemaBuilder)->build(Event::class); - - expect($schema->find('startsAt')?->key)->toBe('starts_at') - ->and($schema->find('starts_at')?->property)->toBe('startsAt') - ->and($schema->find('unknown'))->toBeNull() - ->and($schema->isEmpty())->toBeFalse() - ->and((new MetaSchema(Event::class, $schema->objectType, ['event'], []))->isEmpty())->toBeTrue(); -}); diff --git a/tests/Unit/Meta/MetaDiscoveryTest.php b/tests/Unit/Meta/MetaDiscoveryTest.php deleted file mode 100644 index 1390e08e..00000000 --- a/tests/Unit/Meta/MetaDiscoveryTest.php +++ /dev/null @@ -1,97 +0,0 @@ -registry = Mockery::mock(MetaRegistryInterface::class); - $this->schemas = new MetaSchemaRepository; - $this->logger = Mockery::mock(LoggerInterface::class); - $this->discovery = new MetaDiscovery(new MetaSchemaBuilder, $this->schemas, $this->registry, $this->logger); - $this->location = new DiscoveryLocation('Tests\\', __DIR__); - - $this->discover = function (string ...$classes): void { - foreach ($classes as $class) { - $this->discovery->discover($this->location, DiscoveredClass::fromReflection(new ReflectionClass($class))); - } - }; -}); - -it('keeps the classes declaring meta only', function (): void { - ($this->discover)(Event::class, BookGenre::class, ArticleExtras::class, CategoryExtras::class, MemberProfile::class, ReviewMeta::class, NotADeclaration::class, AbstractMetaDeclaration::class); - $this->discovery->discover($this->location, DiscoveredEnum::fromReflection(new ReflectionEnum(EventStatus::class))); - - expect(array_column(iterator_to_array($this->discovery->getItems()), 'class'))->toBe([Event::class, BookGenre::class, ArticleExtras::class, CategoryExtras::class, MemberProfile::class, ReviewMeta::class]); -}); - -it('stores and registers the schema of each declaring class', function (): void { - $registered = []; - $this->registry->shouldReceive('register')->andReturnUsing(function (MetaSchema $schema) use (&$registered): void { - $registered[] = $schema->declaringClass; - }); - ($this->discover)(Event::class, BookGenre::class, NoMeta::class); - - $this->discovery->apply(); - - expect($registered)->toBe([Event::class, BookGenre::class]) - ->and($this->schemas->forClass(Event::class))->toBeInstanceOf(MetaSchema::class) - ->and($this->schemas->forClass(NoMeta::class))->toBeNull(); -}); - -it('logs a declaration it cannot register and carries on', function (): void { - $this->registry->shouldReceive('register')->twice(); - $this->logger->shouldReceive('error')->once()->with(Mockery::pattern('/InvalidArray: .*say what the array holds/'), Mockery::type('array')); - $this->logger->shouldReceive('error')->once()->with(Mockery::pattern('/EventExtras: The meta key "capacity" of post "event" is declared twice/'), Mockery::type('array')); - ($this->discover)(Event::class, InvalidArray::class, EventExtras::class, BookGenre::class); - - $this->discovery->apply(); - - expect(array_keys($this->schemas->failures()))->toBe([InvalidArray::class, EventExtras::class]) - ->and($this->schemas->failures()[InvalidArray::class])->toContain('say what the array holds'); -}); - -it('binds the post models of the project to their post type', function (): void { - ($this->discover)(EventPostModel::class, Page::class); - - $this->discovery->apply(); - - expect((new ReflectionProperty(ColtPost::class, 'postTypes'))->getValue())->toHaveKey('fixture_event', EventPostModel::class); - - ColtPost::clearRegisteredPostTypes(); -}); - -it('identifies itself as meta', function (): void { - expect($this->discovery->getIdentifier())->toBe('meta'); -}); diff --git a/tests/Unit/Meta/MetaRecordTest.php b/tests/Unit/Meta/MetaRecordTest.php deleted file mode 100644 index 9de2f6fd..00000000 --- a/tests/Unit/Meta/MetaRecordTest.php +++ /dev/null @@ -1,184 +0,0 @@ -reads++; - - return $this->stored[$key] ?? null; - } - - public function update(MetaObjectType $objectType, int $objectId, string $key, string|array $value): void - { - $this->writes[] = ['update', $objectType, $objectId, $key, $value]; - $this->stored[$key] = $value; - } - - public function delete(MetaObjectType $objectType, int $objectId, string $key): void - { - $this->writes[] = ['delete', $objectType, $objectId, $key]; - unset($this->stored[$key]); - } - - public function getAll(MetaObjectType $objectType, int $objectId, string $key): array - { - $this->reads++; - - return (array) ($this->stored[$key] ?? []); - } - - public function replaceAll(MetaObjectType $objectType, int $objectId, string $key, array $values): void - { - $this->writes[] = ['replaceAll', $objectType, $objectId, $key, $values]; - $this->stored[$key] = $values; - } - }; -} - -function eventRecord(MetaStoreInterface $store, ?Closure $onUnreadable = null): MetaRecord -{ - return new MetaRecord( - (new MetaSchemaBuilder)->build(Event::class), - 42, - $store, - new MetaValueCaster, - $onUnreadable ?? fn (InvalidMetaValueException $exception): never => throw $exception, - ); -} - -it('reads each meta with its declared type, by property name or key', function (): void { - $record = eventRecord(memoryMetaStore(['capacity' => '250', 'sold_out' => '1', 'starts_at' => '2026-11-14T09:00:00+00:00'])); - - expect($record->capacity)->toBe(250) - ->and($record->soldOut)->toBeTrue() - ->and($record->sold_out)->toBeTrue() - ->and($record->startsAt->toIso8601String())->toBe('2026-11-14T09:00:00+00:00') - ->and($record->objectId())->toBe(42); -}); - -it('reads an absent meta as the property default', function (): void { - $record = eventRecord(memoryMetaStore()); - - expect($record->capacity)->toBe(0) - ->and($record->status)->toBe(EventStatus::Draft) - ->and($record->startsAt)->toBeNull(); -}); - -it('reads each meta from the store once', function (): void { - $store = memoryMetaStore(['capacity' => '250']); - $record = eventRecord($store); - - $record->capacity; - $record->capacity; - - expect($store->reads)->toBe(1); -}); - -it('writes pending values on save, in their stored form', function (): void { - $store = memoryMetaStore(); - $record = eventRecord($store); - - $record->capacity = 250; - $record->fill(['status' => EventStatus::Published, 'sold_out' => true]); - - expect($store->writes)->toBe([]) - ->and($record->capacity)->toBe(250); - - $record->save(); - - expect($store->writes)->toBe([ - ['update', MetaObjectType::Post, 42, 'capacity', '250'], - ['update', MetaObjectType::Post, 42, 'status', 'published'], - ['update', MetaObjectType::Post, 42, 'sold_out', '1'], - ]); -}); - -it('deletes a nullable meta set to null', function (): void { - $store = memoryMetaStore(['starts_at' => '2026-11-14T09:00:00+00:00']); - - eventRecord($store)->set('startsAt', null)->save(); - - expect($store->writes)->toBe([['delete', MetaObjectType::Post, 42, 'starts_at']]); -}); - -it('does not write twice', function (): void { - $store = memoryMetaStore(); - $record = eventRecord($store)->set('capacity', 3)->save()->save(); - - expect($store->writes)->toHaveCount(1) - ->and($record->capacity)->toBe(3); -}); - -it('refuses a value of the wrong type before anything is written', function (): void { - $store = memoryMetaStore(); - - expect(fn (): MetaRecord => eventRecord($store)->fill(['capacity' => 'many']))->toThrow(InvalidMetaValueException::class) - ->and($store->writes)->toBe([]); -}); - -it('refuses a meta the class does not declare', function (): void { - eventRecord(memoryMetaStore())->unknown; -})->throws(InvalidArgumentException::class, 'Tests\Unit\Meta\Fixtures\Event declares no meta named "unknown".'); - -it('hands an unreadable stored value to the unreadable handler', function (): void { - $record = eventRecord( - memoryMetaStore(['capacity' => 'many']), - fn (InvalidMetaValueException $exception, MetaDefinition $definition): int => -1, - ); - - expect($record->capacity)->toBe(-1); -}); - -it('lists every meta by property name', function (): void { - $values = eventRecord(memoryMetaStore(['capacity' => '7']))->toArray(); - - expect($values)->toHaveKeys(['startsAt', 'capacity', 'status']) - ->and($values['capacity'])->toBe(7); -}); - -it('tells which meta are set', function (): void { - $record = eventRecord(memoryMetaStore(['capacity' => '7'])); - - expect(isset($record->capacity))->toBeTrue() - ->and(isset($record->startsAt))->toBeFalse() - ->and(isset($record->unknown))->toBeFalse(); -}); - -it('checks the rules of a meta on write, and keeps nothing pending when one fails', function (): void { - $store = memoryMetaStore(); - $record = new MetaRecord((new MetaSchemaBuilder)->build(RatedEvent::class), 42, $store, new MetaValueCaster, fn (): mixed => null, metaValidator()); - - expect(fn (): MetaRecord => $record->set('capacity', 6000))->toThrow(MetaValidationException::class); - - $record->set('capacity', 300)->set('contact', null)->save(); - - expect($store->stored)->toBe(['capacity' => '300']); -}); diff --git a/tests/Unit/Meta/MetaSchemaBuilderTest.php b/tests/Unit/Meta/MetaSchemaBuilderTest.php deleted file mode 100644 index 53f2477f..00000000 --- a/tests/Unit/Meta/MetaSchemaBuilderTest.php +++ /dev/null @@ -1,159 +0,0 @@ -schema = (new MetaSchemaBuilder)->build(Event::class); - }); - - it('attaches the meta to the post type slug', function (): void { - expect($this->schema->objectType)->toBe(MetaObjectType::Post) - ->and($this->schema->subtypes)->toBe(['event']) - ->and($this->schema->declaringClass)->toBe(Event::class); - }); - - it('keeps only public instance properties carrying #[Meta]', function (): void { - expect(array_keys($this->schema->definitions))->toBe([ - 'startsAt', 'capacity', 'price', 'soldOut', 'status', 'priority', 'subtitle', 'internalRef', 'summary', 'endsAt', - ]); - }); - - it('derives the key from the property name, in snake_case', function (): void { - expect($this->schema->definitions['startsAt']->key)->toBe('starts_at') - ->and($this->schema->definitions['internalRef']->key)->toBe('_event_internal_ref'); - }); - - it('derives the value type from the property type', function (string $property, MetaValueType $type, ?string $class): void { - $definition = $this->schema->definitions[$property]; - - expect($definition->valueType)->toBe($type) - ->and($definition->valueClass)->toBe($class); - })->with([ - 'string' => ['summary', MetaValueType::String, null], - 'int' => ['capacity', MetaValueType::Integer, null], - 'float' => ['price', MetaValueType::Number, null], - 'bool' => ['soldOut', MetaValueType::Boolean, null], - 'date' => ['startsAt', MetaValueType::DateTime, CarbonImmutable::class], - 'string enum' => ['status', MetaValueType::Enum, EventStatus::class], - 'int enum' => ['priority', MetaValueType::Enum, Priority::class], - ]); - - it('takes the default from the initial value, null for a nullable property', function (): void { - expect($this->schema->definitions['capacity']->default)->toBe(0) - ->and($this->schema->definitions['status']->default)->toBe(EventStatus::Draft) - ->and($this->schema->definitions['startsAt']->default)->toBeNull() - ->and($this->schema->definitions['startsAt']->nullable)->toBeTrue() - ->and($this->schema->definitions['capacity']->nullable)->toBeFalse(); - }); - - it('carries the attribute options', function (): void { - $startsAt = $this->schema->definitions['startsAt']; - $internalRef = $this->schema->definitions['internalRef']; - - expect($startsAt->showInRest)->toBeTrue() - ->and($startsAt->label)->toBe('Start') - ->and($startsAt->description)->toBe('When the event starts') - ->and($internalRef->capability)->toBe('manage_options') - ->and($this->schema->definitions['subtitle']->revisions)->toBeTrue() - ->and($this->schema->definitions['summary']->sanitize)->toBe('wp_kses_post'); - }); -}); - -it('attaches the meta of a taxonomy to its slug', function (): void { - $schema = (new MetaSchemaBuilder)->build(BookGenre::class); - - expect($schema->objectType)->toBe(MetaObjectType::Term) - ->and($schema->subtypes)->toBe(['book-genre']) - ->and($schema->definitions['color']->key)->toBe('color'); -}); - -it('refuses a class that declares no meta owner', function (): void { - (new MetaSchemaBuilder)->build(NotADeclaration::class); -})->throws(InvalidMetaDefinitionException::class, 'carries none of #[PostType], #[Taxonomy], #[PostMeta], #[TermMeta], #[UserMeta], #[CommentMeta]'); - -it('refuses a declaration WordPress cannot register', function (string $class, string $message): void { - expect(fn (): MetaSchema => (new MetaSchemaBuilder)->build($class)) - ->toThrow(InvalidMetaDefinitionException::class, $message); -})->with([ - 'union type' => [InvalidUnion::class, 'needs a single type'], - 'untyped property' => [InvalidUntyped::class, 'needs a single type'], - 'array' => [InvalidArray::class, 'say what the array holds'], - 'pure enum' => [InvalidPureEnum::class, 'needs backing values'], - 'no default, not nullable' => [InvalidNoDefault::class, 'give the property a default value or make it nullable'], - 'protected key in REST' => [InvalidProtectedInRest::class, 'the protected key "_secret" can only be exposed in REST with an explicit capability'], - 'revisions on a term' => [InvalidTermRevisions::class, 'revisions only exist for post types'], - 'revisions on a user' => [UserRevisions::class, 'revisions only exist for post types'], - 'same key twice' => [InvalidDuplicateKey::class, 'the key "shared" is already used by $first'], - 'media on a string' => [InvalidMedia::class, 'media: true holds an attachment ID, so it needs an int property'], -]); - -it('attaches the meta of #[PostMeta], #[TermMeta], #[UserMeta] and #[CommentMeta] to their objects', function (string $class, MetaObjectType $objectType, array $subtypes): void { - $schema = (new MetaSchemaBuilder)->build($class); - - expect($schema->objectType)->toBe($objectType) - ->and($schema->subtypes)->toBe($subtypes) - ->and($schema->declaresSubtypes)->toBeFalse() - ->and($schema->isEmpty())->toBeFalse(); -})->with([ - 'post types' => [ArticleExtras::class, MetaObjectType::Post, ['post', 'page']], - 'a taxonomy' => [CategoryExtras::class, MetaObjectType::Term, ['category']], - 'users' => [MemberProfile::class, MetaObjectType::User, []], - 'comments' => [ReviewMeta::class, MetaObjectType::Comment, []], -]); - -it('knows when the class declares its post type or taxonomy', function (): void { - expect((new MetaSchemaBuilder)->build(Event::class)->declaresSubtypes)->toBeTrue() - ->and((new MetaSchemaBuilder)->build(BookGenre::class)->declaresSubtypes)->toBeTrue() - ->and((new MetaSchemaBuilder)->build(Event::class)->exposesInRest())->toBeTrue() - ->and((new MetaSchemaBuilder)->build(ReviewMeta::class)->exposesInRest())->toBeFalse(); -}); - -it('marks an attachment ID and a meta anyone may see', function (): void { - $class = new #[UserMeta] class - { - #[Meta(media: true, public: true)] - public ?int $avatarId = null; - - #[Meta] - public ?string $phone = null; - }; - $definitions = (new MetaSchemaBuilder)->build($class::class)->definitions; - - expect($definitions['avatarId']->media)->toBeTrue() - ->and($definitions['avatarId']->public)->toBeTrue() - ->and($definitions['avatarId']->control)->toBe(Control::Media) - ->and($definitions['phone']->media)->toBeFalse() - ->and($definitions['phone']->public)->toBeFalse(); -}); diff --git a/tests/Unit/Meta/MetaSchemaRepositoryTest.php b/tests/Unit/Meta/MetaSchemaRepositoryTest.php deleted file mode 100644 index 1d7b94b5..00000000 --- a/tests/Unit/Meta/MetaSchemaRepositoryTest.php +++ /dev/null @@ -1,77 +0,0 @@ -build(Event::class); - $genre = (new MetaSchemaBuilder)->build(BookGenre::class); - - $repository->add($event); - $repository->add($genre); - $repository->add($event); - - expect($repository->forClass(Event::class))->toBe($event) - ->and($repository->forClass(EventExtras::class))->toBeNull() - ->and($repository->all())->toBe([$event, $genre]); -}); - -it('refuses a key another class already declares on the same post type', function (): void { - $repository = new MetaSchemaRepository; - $repository->add((new MetaSchemaBuilder)->build(Event::class)); - - $repository->add((new MetaSchemaBuilder)->build(EventExtras::class)); -})->throws( - InvalidMetaDefinitionException::class, - 'The meta key "capacity" of post "event" is declared twice, by Tests\Unit\Meta\Fixtures\Event and by Tests\Unit\Meta\Fixtures\EventExtras.' -); - -it('refuses a key declared twice on objects the schemas share', function (string $first, string $second, string $message): void { - $repository = new MetaSchemaRepository; - $repository->add((new MetaSchemaBuilder)->build($first)); - - expect(fn () => $repository->add((new MetaSchemaBuilder)->build($second)))->toThrow(InvalidMetaDefinitionException::class, $message); -})->with([ - 'a post type in a list' => [ArticleExtras::class, PageExtras::class, 'The meta key "subtitle" of post "page" is declared twice'], - 'users' => [MemberProfile::class, BillingProfile::class, 'The meta key "job_title" of user is declared twice'], -]); - -it('accepts the same key on objects the schemas do not share', function (): void { - $repository = new MetaSchemaRepository; - - $repository->add((new MetaSchemaBuilder)->build(PageExtras::class)); - $repository->add((new MetaSchemaBuilder)->build(Event::class)); - $repository->add((new MetaSchemaBuilder)->build(CategoryExtras::class)); - $repository->add((new MetaSchemaBuilder)->build(BookGenre::class)); - - expect($repository->all())->toHaveCount(4); -}); - -it('finds the schemas an object can carry, and whether a name is declared', function (): void { - $repository = new MetaSchemaRepository; - $repository->add($event = (new MetaSchemaBuilder)->build(Event::class)); - $repository->add($article = (new MetaSchemaBuilder)->build(ArticleExtras::class)); - $repository->add($member = (new MetaSchemaBuilder)->build(MemberProfile::class)); - - expect($repository->forObject(MetaObjectType::Post, 'event'))->toBe([$event]) - ->and($repository->forObject(MetaObjectType::Post, 'page'))->toBe([$article]) - ->and($repository->forObject(MetaObjectType::Post, null))->toBe([]) - ->and($repository->forObject(MetaObjectType::User, null))->toBe([$member]) - ->and($repository->declares(MetaObjectType::Post, 'soldOut'))->toBeTrue() - ->and($repository->declares(MetaObjectType::Post, 'sold_out'))->toBeTrue() - ->and($repository->declares(MetaObjectType::User, 'sold_out'))->toBeFalse() - ->and($repository->declares(MetaObjectType::Post, 'post_title'))->toBeFalse(); -}); diff --git a/tests/Unit/Meta/MetaServiceProviderTest.php b/tests/Unit/Meta/MetaServiceProviderTest.php index 231cd5d8..27faa55a 100644 --- a/tests/Unit/Meta/MetaServiceProviderTest.php +++ b/tests/Unit/Meta/MetaServiceProviderTest.php @@ -17,8 +17,8 @@ use Pollora\Meta\Domain\Models\MetaRecord; use Pollora\Meta\Infrastructure\Adapters\WordPressMetaRegistry; use Pollora\Meta\Infrastructure\Adapters\WordPressMetaStore; -use Pollora\Meta\Infrastructure\Providers\MetaServiceProvider; -use Pollora\Meta\Infrastructure\Services\LaravelMetaValidator; +use Pollora\Meta\Infrastructure\Laravel\LaravelMetaValidator; +use Pollora\Meta\Infrastructure\Laravel\MetaServiceProvider; use Pollora\Meta\Infrastructure\Services\MetaDiscovery; use Pollora\Support\Facades\Meta; use Psr\Log\LoggerInterface; diff --git a/tests/Unit/Meta/MetaUiDriverTest.php b/tests/Unit/Meta/MetaUiDriverTest.php index 3d87b653..831f7671 100644 --- a/tests/Unit/Meta/MetaUiDriverTest.php +++ b/tests/Unit/Meta/MetaUiDriverTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use Illuminate\Config\Repository; -use Illuminate\Container\Container; use Illuminate\Events\Dispatcher; use Illuminate\Foundation\Application; use Illuminate\Translation\ArrayLoader; @@ -11,23 +10,17 @@ use Illuminate\Validation\Factory; use Pollora\Hook\Domain\Contract\Action; use Pollora\Hook\Domain\Contract\Filter; -use Pollora\Meta\Application\Services\MetaAccessor; use Pollora\Meta\Application\Services\MetaSchemaBuilder; use Pollora\Meta\Application\Services\MetaSchemaRepository; use Pollora\Meta\Application\Services\MetaUiDrivers; -use Pollora\Meta\Domain\Contracts\MetaStoreInterface; use Pollora\Meta\Domain\Contracts\MetaUiDriver; use Pollora\Meta\Domain\Enums\Control; -use Pollora\Meta\Domain\Enums\MetaValueType; use Pollora\Meta\Domain\Events\MetaSchemasRegistered; use Pollora\Meta\Domain\Models\MetaDefinition; use Pollora\Meta\Domain\Models\MetaSchema; -use Pollora\Meta\Domain\Services\MetaValueCaster; -use Pollora\Meta\Infrastructure\Providers\MetaServiceProvider; -use Pollora\Meta\Testing\MetaUiDriverConformance; +use Pollora\Meta\Infrastructure\Laravel\MetaServiceProvider; use Psr\Log\LoggerInterface; use Tests\Unit\Meta\Fixtures\Event; -use Tests\Unit\Meta\Fixtures\MemberProfile; /** * A driver that "builds" a field per meta it supports, the way an ACF or Meta @@ -56,90 +49,6 @@ public function register(MetaSchema $schema): void } } -describe('declaring', function (): void { - it('derives a neutral control from the type, unless one is given', function (): void { - $definitions = MetaUiDriverConformance::schema()->definitions; - - expect(array_map(static fn (MetaDefinition $definition): ?Control => $definition->control, $definitions))->toBe([ - 'text' => Control::Text, - 'textarea' => Control::Textarea, - 'richText' => Control::RichText, - 'number' => Control::Number, - 'decimal' => Control::Number, - 'toggle' => Control::Toggle, - 'date' => Control::Date, - 'dateTime' => Control::DateTime, - 'select' => Control::Select, - 'media' => Control::Media, - 'url' => Control::Url, - 'email' => Control::Email, - 'color' => Control::Color, - 'rows' => null, - 'list' => null, - ]) - ->and($definitions['text']->group)->toBe('Basics') - ->and($definitions['text']->hints)->toBe(['any-driver' => ['width' => 50]]); - }); -}); - -describe('drivers', function (): void { - it('builds the fields of the meta a driver supports, and skips a schema left without any', function (): void { - $driver = new FieldRecordingDriver; - $drivers = new MetaUiDrivers(new Container); - $drivers->extend('recording', static fn (): MetaUiDriver => $driver); - - $drivers->build($drivers->driver('recording'), [MetaUiDriverConformance::schema()]); - - expect(array_keys($driver->fields))->not->toContain('rows', 'list') - ->and($driver->fields['text'])->toBe(['control' => 'text', 'label' => 'Text', 'group' => 'Basics', 'hints' => []]) - ->and($driver->fields['select']['control'])->toBe('select'); - }); - - it('resolves a driver registered by class, and refuses an unknown or a wrong one', function (): void { - $drivers = new MetaUiDrivers(new Container); - $drivers->extend('recording', FieldRecordingDriver::class); - $drivers->extend('wrong', static fn (): object => new stdClass); - - expect($drivers->driver('recording'))->toBeInstanceOf(FieldRecordingDriver::class) - ->and($drivers->has('recording'))->toBeTrue() - ->and(fn (): MetaUiDriver => $drivers->driver('acf'))->toThrow(InvalidArgumentException::class, 'No meta UI driver is named "acf"') - ->and(fn (): MetaUiDriver => $drivers->driver('wrong'))->toThrow(InvalidArgumentException::class, 'does not implement'); - }); - - it('checks a driver against the contract', function (): void { - $throwing = new class implements MetaUiDriver - { - public function supports(MetaDefinition $definition): bool - { - return $definition->valueType === MetaValueType::String ?: throw new RuntimeException('no'); - } - - public function register(MetaSchema $schema): void {} - }; - - expect(MetaUiDriverConformance::check(new FieldRecordingDriver))->toBe([]) - ->and(MetaUiDriverConformance::check($throwing))->toContain('supports() throws for "number" (no): it must answer false instead.'); - }); -}); - -describe('the facade service', function (): void { - it('gives the schemas and registers drivers', function (): void { - $repository = new MetaSchemaRepository; - $repository->add($event = (new MetaSchemaBuilder)->build(Event::class)); - $repository->add($member = (new MetaSchemaBuilder)->build(MemberProfile::class)); - - $drivers = new MetaUiDrivers(new Container); - $accessor = new MetaAccessor($repository, new MetaSchemaBuilder, Mockery::mock(MetaStoreInterface::class), new MetaValueCaster, drivers: $drivers); - - $accessor->extend('recording', FieldRecordingDriver::class); - - expect($accessor->schemas())->toBe([$event, $member]) - ->and($accessor->schemaFor('post', 'event'))->toBe([$event]) - ->and($accessor->schemaFor('user'))->toBe([$member]) - ->and($drivers->has('recording'))->toBeTrue(); - }); -}); - describe('on init', function (): void { beforeEach(function (): void { $this->app = new Application(sys_get_temp_dir()); diff --git a/tests/Unit/Meta/MetaValueCasterTest.php b/tests/Unit/Meta/MetaValueCasterTest.php deleted file mode 100644 index 0edcfbc8..00000000 --- a/tests/Unit/Meta/MetaValueCasterTest.php +++ /dev/null @@ -1,172 +0,0 @@ -caster = new MetaValueCaster; - $this->definitions = (new MetaSchemaBuilder)->build(Event::class)->definitions; - $this->definition = fn (string $property): MetaDefinition => $this->definitions[$property]; -}); - -describe('toPhp()', function (): void { - it('returns the default for an absent or empty value', function (mixed $raw): void { - expect($this->caster->toPhp(($this->definition)('capacity'), $raw))->toBe(0) - ->and($this->caster->toPhp(($this->definition)('status'), $raw))->toBe(EventStatus::Draft) - ->and($this->caster->toPhp(($this->definition)('startsAt'), $raw))->toBeNull(); - })->with([null, '']); - - it('reads each type from its stored form', function (string $property, mixed $raw, mixed $expected): void { - expect($this->caster->toPhp(($this->definition)($property), $raw))->toBe($expected); - })->with([ - 'string' => ['summary', 'Hello', 'Hello'], - 'string from a number' => ['summary', 42, '42'], - 'int' => ['capacity', '250', 250], - 'negative int' => ['capacity', '-3', -3], - 'float' => ['price', '12.75', 12.75], - 'string enum' => ['status', 'published', EventStatus::Published], - 'int enum' => ['priority', '2', Priority::High], - ]); - - it('reads booleans tolerantly', function (mixed $raw, bool $expected): void { - expect($this->caster->toPhp(($this->definition)('soldOut'), $raw))->toBe($expected); - })->with([ - ['1', true], ['true', true], ['yes', true], ['on', true], [true, true], - ['0', false], ['false', false], ['no', false], ['off', false], [false, false], - ]); - - it('reads a date as the declared class, in UTC', function (): void { - $startsAt = $this->caster->toPhp(($this->definition)('startsAt'), '2026-11-14T09:00:00+00:00'); - $endsAt = $this->caster->toPhp(($this->definition)('endsAt'), '2026-11-14 18:30:00'); - - expect($startsAt)->toBeInstanceOf(CarbonImmutable::class) - ->and($startsAt->toIso8601String())->toBe('2026-11-14T09:00:00+00:00') - ->and($endsAt)->toBeInstanceOf(CarbonImmutable::class) - ->and($endsAt->toIso8601String())->toBe('2026-11-14T18:30:00+00:00'); - }); - - it('reads a date into a mutable class when the property asks for one', function (): void { - $definition = new MetaDefinition('at', 'at', MetaValueType::DateTime, Carbon::class, true, null); - - expect($this->caster->toPhp($definition, '2026-01-02T03:04:05+00:00'))->toBeInstanceOf(Carbon::class); - }); - - it('refuses a stored value it cannot read as the declared type', function (string $property, mixed $raw): void { - expect(fn () => $this->caster->toPhp(($this->definition)($property), $raw)) - ->toThrow(InvalidMetaValueException::class, 'cannot be read as'); - })->with([ - 'array for a string' => ['summary', ['a']], - 'text for an int' => ['capacity', 'many'], - 'decimal for an int' => ['capacity', '1.5'], - 'array for an int' => ['capacity', ['1']], - 'text for a float' => ['price', 'cheap'], - 'text for a bool' => ['soldOut', 'maybe'], - 'array for a bool' => ['soldOut', ['1']], - 'garbage for a date' => ['startsAt', 'not a date'], - 'number for a date' => ['startsAt', 12], - 'unknown enum case' => ['status', 'archived'], - 'array for an enum' => ['status', ['draft']], - 'text for an int enum' => ['priority', 'high'], - 'unknown int enum case' => ['priority', '9'], - ]); -}); - -describe('toStorage()', function (): void { - it('stores each type as a string', function (string $property, mixed $value, string $expected): void { - expect($this->caster->toStorage(($this->definition)($property), $value))->toBe($expected); - })->with([ - 'string' => ['summary', 'Hello', 'Hello'], - 'stringable' => ['summary', new class implements Stringable - { - public function __toString(): string - { - return 'Stringable'; - } - }, 'Stringable'], - 'int' => ['capacity', 250, '250'], - 'float' => ['price', 12.5, '12.5'], - 'int for a float' => ['price', 3, '3'], - 'true' => ['soldOut', true, '1'], - 'false' => ['soldOut', false, '0'], - 'string enum' => ['status', EventStatus::Published, 'published'], - 'int enum' => ['priority', Priority::High, '2'], - ]); - - it('stores dates in ISO 8601, in UTC', function (): void { - $date = new DateTimeImmutable('2026-11-14 10:00:00', new DateTimeZone('Europe/Paris')); - - expect($this->caster->toStorage(($this->definition)('startsAt'), $date))->toBe('2026-11-14T09:00:00+00:00'); - }); - - it('turns null into a deletion for a nullable meta', function (): void { - expect($this->caster->toStorage(($this->definition)('startsAt'), null))->toBeNull(); - }); - - it('refuses a value of the wrong type', function (string $property, mixed $value, string $message): void { - expect(fn () => $this->caster->toStorage(($this->definition)($property), $value)) - ->toThrow(InvalidMetaValueException::class, $message); - })->with([ - 'null for a non-nullable meta' => ['capacity', null, 'The meta "capacity" expects integer, null given.'], - 'string for an int' => ['capacity', '250', 'expects integer, string given'], - 'int for a string' => ['summary', 12, 'expects string, int given'], - 'int for a bool' => ['soldOut', 1, 'expects boolean, int given'], - 'string for a date' => ['startsAt', '2026-11-14', 'expects ?Carbon\CarbonImmutable, string given'], - 'another enum' => ['status', Priority::High, 'expects Tests\Unit\Meta\Fixtures\EventStatus'], - 'string for a float' => ['price', '1.5', 'expects number, string given'], - ]); -}); - -describe('sanitize()', function (): void { - it('normalizes values written through WordPress to the stored form', function (string $property, mixed $value, string $expected): void { - expect($this->caster->sanitize(($this->definition)($property), $value))->toBe($expected); - })->with([ - 'numeric string for an int' => ['capacity', '42', '42'], - 'yes for a bool' => ['soldOut', 'yes', '1'], - 'bool for a bool' => ['soldOut', false, '0'], - 'enum value' => ['status', 'published', 'published'], - 'enum case' => ['status', EventStatus::Published, 'published'], - 'date string' => ['startsAt', '2026-11-14 10:00:00+01:00', '2026-11-14T09:00:00+00:00'], - 'date object' => ['startsAt', new DateTimeImmutable('2026-11-14T09:00:00+00:00'), '2026-11-14T09:00:00+00:00'], - 'empty for a nullable meta' => ['startsAt', '', ''], - 'empty for a meta with a default' => ['capacity', '', ''], - 'null' => ['capacity', null, ''], - ]); - - it('gives the same result when applied twice', function (string $property, mixed $value): void { - $definition = ($this->definition)($property); - - expect($this->caster->sanitize($definition, $this->caster->sanitize($definition, $value))) - ->toBe($this->caster->sanitize($definition, $value)); - })->with([ - 'unreadable int' => ['capacity', '42abc'], - 'int' => ['capacity', '42'], - 'bool' => ['soldOut', 'yes'], - 'date' => ['startsAt', '2026-11-14 10:00:00+01:00'], - ]); - - it('empties a value it cannot read, which then reads as the default', function (string $property, mixed $value): void { - expect($this->caster->sanitize(($this->definition)($property), $value))->toBe(''); - })->with([ - 'text for an int' => ['capacity', 'many'], - 'unknown enum case' => ['status', 'archived'], - 'wrong enum' => ['status', Priority::Low], - 'date for an int' => ['capacity', new DateTimeImmutable], - ]); -}); - -it('publishes the default in its REST form', function (): void { - expect($this->caster->toRestDefault(($this->definition)('capacity')))->toBe(0) - ->and($this->caster->toRestDefault(($this->definition)('soldOut')))->toBeFalse() - ->and($this->caster->toRestDefault(($this->definition)('status')))->toBe('draft') - ->and($this->caster->toRestDefault(($this->definition)('startsAt')))->toBeNull(); -}); diff --git a/tests/Unit/Meta/TypedMetaCheckTest.php b/tests/Unit/Meta/TypedMetaCheckTest.php index 642c05e7..3c4b7028 100644 --- a/tests/Unit/Meta/TypedMetaCheckTest.php +++ b/tests/Unit/Meta/TypedMetaCheckTest.php @@ -9,7 +9,7 @@ use Pollora\Meta\Application\Services\MetaSchemaBuilder; use Pollora\Meta\Application\Services\MetaSchemaRepository; use Pollora\Meta\Domain\Services\MetaValueCaster; -use Pollora\Meta\Infrastructure\Checks\TypedMetaCheck; +use Pollora\Meta\Infrastructure\Laravel\TypedMetaCheck; use Tests\Unit\Meta\Fixtures\ArrayMetaInventory; use Tests\Unit\Meta\Fixtures\ArticleExtras; use Tests\Unit\Meta\Fixtures\BookGenre; diff --git a/tests/Unit/Meta/WordPressMetaInventoryTest.php b/tests/Unit/Meta/WordPressMetaInventoryTest.php deleted file mode 100644 index 90ca5f25..00000000 --- a/tests/Unit/Meta/WordPressMetaInventoryTest.php +++ /dev/null @@ -1,80 +0,0 @@ -previousWpdb = $GLOBALS['wpdb'] ?? null; - $this->queries = []; - $this->results = []; - // Only what the adapter uses: table names, prepare() and get_results() - $GLOBALS['wpdb'] = new class($this) - { - public string $postmeta = 'wp_postmeta'; - - public string $posts = 'wp_posts'; - - public string $termmeta = 'wp_termmeta'; - - public string $term_taxonomy = 'wp_term_taxonomy'; - - public string $usermeta = 'wp_usermeta'; - - public string $commentmeta = 'wp_commentmeta'; - - public function __construct(private readonly object $test) {} - - public function prepare(string $query, mixed ...$args): string - { - $this->test->queries[] = [$query, $args]; - - return $query; - } - - public function get_results(string $query): array - { - return $this->test->results; - } - }; - Functions\when('is_serialized')->alias(fn (string $value): bool => str_starts_with($value, 'a:') || str_starts_with($value, 'O:')); -}); - -afterEach(function (): void { - $GLOBALS['wpdb'] = $this->previousWpdb; -}); - -it('reads the values of a key on the post types given, revisions left out, never into objects', function (): void { - $this->results = [ - (object) ['object_id' => '5', 'meta_value' => '250'], - (object) ['object_id' => '5', 'meta_value' => 'a:1:{i:0;s:4:"rock";}'], - (object) ['object_id' => '6', 'meta_value' => 'O:8:"stdClass":0:{}'], - ]; - - $values = (new WordPressMetaInventory)->values(MetaObjectType::Post, ['event', 'concert'], 'capacity', 100); - [$query, $args] = $this->queries[0]; - - expect($values[5])->toBe(['250', ['rock']]) - ->and($values[6][0])->toBeInstanceOf(__PHP_Incomplete_Class::class) - ->and($query)->toContain("FROM wp_postmeta m INNER JOIN wp_posts o ON o.ID = m.post_id WHERE m.meta_key = %s AND o.post_type <> 'revision' AND o.post_type IN (%s, %s)") - ->and($args)->toBe(['capacity', 'event', 'concert', 100]); -}); - -it('counts the keys stored on a taxonomy, protected ones left out', function (): void { - $this->results = [(object) ['meta_key' => 'color', 'objects' => '3']]; - - $keys = (new WordPressMetaInventory)->keys(MetaObjectType::Term, ['genre'], 50); - [$query, $args] = $this->queries[0]; - - expect($keys)->toBe(['color' => 3]) - ->and($query)->toContain('FROM wp_termmeta m INNER JOIN wp_term_taxonomy o ON o.term_id = m.term_id WHERE m.meta_key NOT LIKE %s AND o.taxonomy IN (%s)') - ->and($args)->toBe(['\_%', 'genre', 50]); -}); - -it('reads user meta without a subtype', function (): void { - (new WordPressMetaInventory)->values(MetaObjectType::User, [], 'job_title', 10); - - expect($this->queries[0][0])->toContain('SELECT m.user_id AS object_id, m.meta_value FROM wp_usermeta m WHERE m.meta_key = %s ORDER BY m.umeta_id'); -}); diff --git a/tests/Unit/Meta/WordPressMetaRegistryTest.php b/tests/Unit/Meta/WordPressMetaRegistryTest.php deleted file mode 100644 index 2e084d82..00000000 --- a/tests/Unit/Meta/WordPressMetaRegistryTest.php +++ /dev/null @@ -1,150 +0,0 @@ -}> - */ -function registeredEventMeta(string $class = Event::class): array -{ - $calls = []; - Functions\when('did_action')->justReturn(1); - Functions\when('add_post_type_support')->justReturn(); - Functions\when('register_meta')->alias(function (string $objectType, string $key, array $args) use (&$calls): bool { - $calls[$key] = [$objectType, $args]; - - return true; - }); - - (new WordPressMetaRegistry(Mockery::mock(Action::class), new MetaValueCaster)) - ->register((new MetaSchemaBuilder)->build($class)); - - return $calls; -} - -it('waits for init, after post types and taxonomies', function (): void { - Functions\when('did_action')->justReturn(0); - Functions\when('add_post_type_support')->justReturn(); - - Functions\expect('register_meta')->never(); - $action = Mockery::mock(Action::class); - $action->shouldReceive('add')->once()->with('init', Mockery::type(Closure::class), 20)->andReturnUsing(function (string $hook, Closure $callback) use ($action): Action { - Functions\expect('register_meta')->times(10); - $callback(); - - return $action; - }); - - (new WordPressMetaRegistry($action, new MetaValueCaster))->register((new MetaSchemaBuilder)->build(Event::class)); -}); - -it('registers each meta on its post type, single, with its type', function (): void { - [$objectType, $args] = registeredEventMeta()['capacity']; - - expect($objectType)->toBe('post') - ->and($args['object_subtype'])->toBe('event') - ->and($args['type'])->toBe('integer') - ->and($args['single'])->toBeTrue() - ->and($args['default'])->toBe(0) - ->and($args['show_in_rest'])->toBe(['schema' => ['type' => 'integer']]); -}); - -it('registers term meta on the taxonomy', function (): void { - [$objectType, $args] = registeredEventMeta(BookGenre::class)['color']; - - expect($objectType)->toBe('term') - ->and($args['object_subtype'])->toBe('book-genre'); -}); - -it('keeps a meta out of REST unless asked', function (): void { - expect(registeredEventMeta()['price'][1]['show_in_rest'])->toBeFalse(); -}); - -it('passes label and description only when given', function (): void { - $meta = registeredEventMeta(); - - expect($meta['starts_at'][1])->toMatchArray(['label' => 'Start', 'description' => 'When the event starts']) - ->and($meta['capacity'][1])->not->toHaveKeys(['label', 'description']); -}); - -it('leaves out the default of a nullable meta', function (): void { - expect(registeredEventMeta()['starts_at'][1])->not->toHaveKey('default'); -}); - -it('sanitizes strings with sanitize_text_field() and other types with the caster', function (): void { - $meta = registeredEventMeta(); - - expect($meta['subtitle'][1]['sanitize_callback'])->toBe('sanitize_text_field') - ->and(($meta['capacity'][1]['sanitize_callback'])('42abc'))->toBe('') - ->and(($meta['sold_out'][1]['sanitize_callback'])('yes'))->toBe('1'); -}); - -it('uses the declared sanitize callback instead', function (): void { - expect(registeredEventMeta()['summary'][1]['sanitize_callback'])->toBe('wp_kses_post'); -}); - -it('checks the declared capability for writes', function (): void { - $auth = registeredEventMeta()['_event_internal_ref'][1]['auth_callback']; - Functions\expect('user_can')->once()->with(3, 'manage_options')->andReturn(false); - - expect($auth(true, '_event_internal_ref', 42, 3))->toBeFalse(); -}); - -it('leaves write checks to WordPress without a capability', function (): void { - expect(registeredEventMeta()['capacity'][1])->not->toHaveKey('auth_callback'); -}); - -it('enables revisions when asked', function (): void { - $meta = registeredEventMeta(); - - expect($meta['subtitle'][1]['revisions_enabled'])->toBeTrue() - ->and($meta['capacity'][1])->not->toHaveKey('revisions_enabled'); -}); - -it('registers the meta on each post type of the list, and for every user without a subtype', function (): void { - $calls = []; - Functions\when('did_action')->justReturn(1); - Functions\when('add_post_type_support')->justReturn(); - Functions\when('register_meta')->alias(function (string $objectType, string $key, array $args) use (&$calls): bool { - $calls[] = [$objectType, $key, $args['object_subtype']]; - - return true; - }); - $registry = new WordPressMetaRegistry(Mockery::mock(Action::class), new MetaValueCaster); - - $registry->register((new MetaSchemaBuilder)->build(ArticleExtras::class)); - $registry->register((new MetaSchemaBuilder)->build(MemberProfile::class)); - $registry->register((new MetaSchemaBuilder)->build(ReviewMeta::class)); - - expect($calls)->toBe([ - ['post', 'subtitle', 'post'], - ['post', 'subtitle', 'page'], - ['user', 'newsletter_opt_in', ''], - ['user', 'job_title', ''], - ['comment', 'rating', ''], - ]); -}); - -it('adds custom-fields to a declared post type exposing a meta in REST, and to no other', function (): void { - Functions\when('did_action')->justReturn(1); - Functions\when('register_meta')->justReturn(true); - Functions\expect('add_post_type_support')->once()->with('event', 'custom-fields'); - $registry = new WordPressMetaRegistry(Mockery::mock(Action::class), new MetaValueCaster); - - $registry->register((new MetaSchemaBuilder)->build(Event::class)); - $registry->register((new MetaSchemaBuilder)->build(ArticleExtras::class)); - $registry->register((new MetaSchemaBuilder)->build(BookGenre::class)); -}); diff --git a/tests/Unit/Meta/WordPressMetaStoreTest.php b/tests/Unit/Meta/WordPressMetaStoreTest.php deleted file mode 100644 index 1d757125..00000000 --- a/tests/Unit/Meta/WordPressMetaStoreTest.php +++ /dev/null @@ -1,29 +0,0 @@ -alias(fn (string $value): string => addslashes($value)); -}); - -it('reads the raw stored value, without the registered default', function (): void { - Functions\expect('get_metadata_raw')->once()->with('post', 42, 'capacity', true)->andReturn('250'); - - expect((new WordPressMetaStore)->get(MetaObjectType::Post, 42, 'capacity'))->toBe('250'); -}); - -it('writes through update_metadata(), slashed', function (): void { - Functions\expect('update_metadata')->once()->with('term', 7, 'path', 'C:\\\\dir'); - - (new WordPressMetaStore)->update(MetaObjectType::Term, 7, 'path', 'C:\\dir'); -}); - -it('deletes through delete_metadata()', function (): void { - Functions\expect('delete_metadata')->once()->with('post', 42, 'starts_at'); - - (new WordPressMetaStore)->delete(MetaObjectType::Post, 42, 'starts_at'); -}); diff --git a/tests/Unit/Meta/WordPressRestMetaValidationTest.php b/tests/Unit/Meta/WordPressRestMetaValidationTest.php deleted file mode 100644 index 6b770306..00000000 --- a/tests/Unit/Meta/WordPressRestMetaValidationTest.php +++ /dev/null @@ -1,75 +0,0 @@ - 'post_type', 'WP_REST_Terms_Controller' => 'taxonomy'] as $class => $property) { - if (! class_exists($class)) { - eval("class {$class} { public function __construct(protected string \${$property} = '') {} }"); - } -} - -foreach (['WP_REST_Users_Controller', 'WP_REST_Comments_Controller'] as $class) { - if (! class_exists($class)) { - eval("class {$class} {}"); - } -} - -// The same stand-in as the other tests, whichever loads first. -if (! class_exists('WP_REST_Request')) { - eval('class WP_REST_Request { public function __construct(private string $method = "", private string $route = "") {} public function get_route(): string { return $this->route; } }'); -} - -/** - * A REST write of the given meta, as WordPress hands it to the filter. - * - * @param array|null $meta - */ -function restMetaWrite(?array $meta, string $method = 'POST'): WP_REST_Request -{ - $request = Mockery::mock(WP_REST_Request::class); - $request->shouldReceive('get_param')->with('meta')->andReturn($meta); - $request->shouldReceive('get_method')->andReturn($method); - - return $request; -} - -beforeEach(function (): void { - $schemas = new MetaSchemaRepository; - $schemas->add((new MetaSchemaBuilder)->build(RatedEvent::class)); - $schemas->add((new MetaSchemaBuilder)->build(MemberProfile::class)); - - $this->validation = new WordPressRestMetaValidation($schemas, new MetaValueCaster, metaValidator()); - $this->posts = ['callback' => [new WP_REST_Posts_Controller('rated_event'), 'update_item']]; -}); - -it('refuses with a 400 a write that breaks a rule, before anything is stored', function (): void { - expect($this->validation->validate(null, $this->posts, restMetaWrite(['capacity' => 6000])))->toBeInstanceOf(WP_Error::class); -}); - -it('lets through a valid write, a meta without rules, and a value of the wrong type for WordPress to refuse', function (): void { - expect($this->validation->validate(null, $this->posts, restMetaWrite(['capacity' => 300, 'free' => -5])))->toBeNull() - ->and($this->validation->validate(null, $this->posts, restMetaWrite(['capacity' => 'many'])))->toBeNull(); -}); - -it('only looks at writes with meta to a core object route', function (): void { - expect($this->validation->validate(null, $this->posts, restMetaWrite(['capacity' => 6000], 'GET')))->toBeNull() - ->and($this->validation->validate(null, $this->posts, restMetaWrite(null)))->toBeNull() - ->and($this->validation->validate(null, ['callback' => [new WP_REST_Posts_Controller('page'), 'update_item']], restMetaWrite(['capacity' => 6000])))->toBeNull() - ->and($this->validation->validate(null, ['callback' => 'some_function'], restMetaWrite(['capacity' => 6000])))->toBeNull() - ->and($this->validation->validate(null, ['callback' => [new WP_REST_Users_Controller, 'update_item']], restMetaWrite(['capacity' => 6000])))->toBeNull(); -}); - -it('keeps an error an earlier filter returned', function (): void { - $error = new WP_Error; - - expect($this->validation->validate($error, $this->posts, restMetaWrite(['capacity' => 6000])))->toBe($error); -}); diff --git a/tests/Unit/Role/EnsureUserHasRoleTest.php b/tests/Unit/Role/EnsureUserHasRoleTest.php index 382d68c7..9445de74 100644 --- a/tests/Unit/Role/EnsureUserHasRoleTest.php +++ b/tests/Unit/Role/EnsureUserHasRoleTest.php @@ -5,7 +5,7 @@ use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Http\Request; -use Pollora\Role\Infrastructure\Middleware\EnsureUserHasRole; +use Pollora\Role\Infrastructure\Laravel\EnsureUserHasRole; use Symfony\Component\HttpFoundation\Response; use Tests\Unit\Role\Fixtures\EventManager; diff --git a/tests/Unit/Role/PostTypeCapabilityMapTest.php b/tests/Unit/Role/PostTypeCapabilityMapTest.php deleted file mode 100644 index 3ee26621..00000000 --- a/tests/Unit/Role/PostTypeCapabilityMapTest.php +++ /dev/null @@ -1,22 +0,0 @@ -capabilities('event', $access))->toBe($expected); -})->with([ - 'contributor' => [Access::Contributor, ['edit_events', 'delete_events']], - 'author' => [Access::Author, ['edit_events', 'delete_events', 'publish_events', 'edit_published_events', 'delete_published_events']], - 'editor' => [Access::Editor, [ - 'edit_events', 'delete_events', 'publish_events', 'edit_published_events', 'delete_published_events', - 'edit_others_events', 'delete_others_events', 'read_private_events', 'edit_private_events', 'delete_private_events', - ]], -]); - -it('uses the names mapped by #[Capabilities]', function (): void { - expect((new PostTypeCapabilityMap)->capabilities('venue', Access::Author, ['publish_posts' => 'open_venues'])) - ->toBe(['edit_venues', 'delete_venues', 'open_venues', 'edit_published_venues', 'delete_published_venues']); -}); diff --git a/tests/Unit/Role/RoleCommandsTest.php b/tests/Unit/Role/RoleCommandsTest.php index 3ccbef14..7b08379d 100644 --- a/tests/Unit/Role/RoleCommandsTest.php +++ b/tests/Unit/Role/RoleCommandsTest.php @@ -14,8 +14,8 @@ use Pollora\Role\Domain\Services\PostTypeCapabilityMap; use Pollora\Role\Domain\Services\RoleCompiler; use Pollora\Role\Infrastructure\Adapters\WordPressRoleInjector; -use Pollora\Role\UI\Console\RoleListCommand; -use Pollora\Role\UI\Console\RoleShowCommand; +use Pollora\Role\Infrastructure\Laravel\Console\RoleListCommand; +use Pollora\Role\Infrastructure\Laravel\Console\RoleShowCommand; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; use Tests\Unit\Role\Fixtures\EditorAdjustments; diff --git a/tests/Unit/Role/RoleCompilerTest.php b/tests/Unit/Role/RoleCompilerTest.php deleted file mode 100644 index 57c90e9f..00000000 --- a/tests/Unit/Role/RoleCompilerTest.php +++ /dev/null @@ -1,152 +0,0 @@ - ['name' => 'Administrator', 'capabilities' => ['manage_options' => true, 'edit_posts' => true]], - 'editor' => ['name' => 'Editor', 'capabilities' => ['edit_posts' => true, 'edit_theme_options' => true, 'moderate_comments' => true]], - 'author' => ['name' => 'Author', 'capabilities' => ['read' => true, 'edit_posts' => true, 'publish_posts' => true, 'upload_files' => true]], - 'subscriber' => ['name' => 'Subscriber', 'capabilities' => ['read' => true]], - ]; -} - -function role(string $slug, ?string $inherits = null, array $grants = [], array $removals = [], string $label = 'Label'): RoleDefinition -{ - return new RoleDefinition($slug, $label, $inherits, new RoleChanges($grants, $removals), 'Class\\'.$slug); -} - -function modification(string $slug, array $grants = [], array $removals = []): RoleModification -{ - return new RoleModification($slug, new RoleChanges($grants, $removals), 'Class\\Modify'.ucfirst($slug)); -} - -beforeEach(function (): void { - $this->compiler = new RoleCompiler; -}); - -it('builds a declared role from its parent, plus grants, minus removals, and marks it', function (): void { - $roles = $this->compiler->compile(storedRoles(), [role('event_manager', 'author', ['export_attendees'], ['publish_posts'], 'Event manager')], [], [], []); - - expect($roles['event_manager'])->toBe([ - 'name' => 'Event manager', - 'capabilities' => ['read' => true, 'edit_posts' => true, 'upload_files' => true, 'export_attendees' => true], - '_pollora' => ['managed' => true], - ]); -}); - -it('inherits from another declared role, whatever the declaration order', function (): void { - $roles = $this->compiler->compile(storedRoles(), [ - role('steward', 'event_manager', ['scan_tickets']), - role('event_manager', 'subscriber', ['export_attendees']), - ], [], [], []); - - expect($roles['steward']['capabilities'])->toBe(['read' => true, 'export_attendees' => true, 'scan_tickets' => true]); -}); - -it('replaces a stored role with the same slug', function (): void { - $stored = [...storedRoles(), 'event_manager' => ['name' => 'Old', 'capabilities' => ['delete_users' => true]]]; - - $roles = $this->compiler->compile($stored, [role('event_manager', null, ['read'])], [], [], []); - - expect($roles['event_manager']['capabilities'])->toBe(['read' => true]); -}); - -it('drops a marked role that is no longer declared', function (): void { - $stored = [...storedRoles(), 'gone' => ['name' => 'Gone', 'capabilities' => ['read' => true], '_pollora' => ['managed' => true]]]; - - expect($this->compiler->compile($stored, [], [], [], []))->not->toHaveKey('gone'); -}); - -it('modifies an existing role and records what it changed', function (): void { - $roles = $this->compiler->compile(storedRoles(), [], [modification('editor', ['scan_tickets', 'edit_posts'], ['edit_theme_options', 'not_there'])], [], []); - - expect($roles['editor'])->toBe([ - 'name' => 'Editor', - 'capabilities' => ['edit_posts' => true, 'moderate_comments' => true, 'scan_tickets' => true], - '_pollora' => ['granted' => ['scan_tickets'], 'removed' => ['edit_theme_options' => true]], - ]); -}); - -it('undoes previous modifications before applying the current ones', function (): void { - $previous = $this->compiler->compile(storedRoles(), [], [modification('editor', ['old_grant'], ['moderate_comments'])], [], []); - - $roles = $this->compiler->compile($previous, [], [modification('editor', ['scan_tickets'])], [], []); - - expect($roles['editor']['capabilities'])->toBe(['edit_posts' => true, 'edit_theme_options' => true, 'moderate_comments' => true, 'scan_tickets' => true]); -}); - -it('gives back the stored roles once nothing is declared any more', function (): void { - $compiled = $this->compiler->compile( - storedRoles(), - [role('event_manager', 'author', ['export_attendees'])], - [modification('editor', ['scan_tickets'], ['edit_theme_options'])], - ['administrator'], - ['edit_events'], - ); - - // Same roles and capabilities; only the order of the restored keys may differ. - expect($this->compiler->compile($compiled, [], [], ['administrator'], []))->toEqual(storedRoles()); -}); - -it('gives the same result when compiled twice', function (): void { - $arguments = [ - [role('event_manager', 'author', ['export_attendees'], ['publish_posts'])], - [modification('editor', ['scan_tickets'], ['edit_theme_options']), modification('event_manager', ['moderate_comments'])], - ['administrator'], - ['edit_events', 'export_attendees'], - ]; - - $once = $this->compiler->compile(storedRoles(), ...$arguments); - - expect($this->compiler->compile($once, ...$arguments))->toBe($once); -}); - -it('applies modifications of a declared role after building it', function (): void { - $roles = $this->compiler->compile(storedRoles(), [role('event_manager', 'subscriber')], [modification('event_manager', ['moderate_comments'], ['read'])], [], []); - - expect($roles['event_manager']['capabilities'])->toBe(['moderate_comments' => true]); -}); - -it('lets a declared role inherit what a modification added to its parent', function (): void { - $roles = $this->compiler->compile(storedRoles(), [role('event_manager', 'subscriber')], [modification('subscriber', ['view_events'])], [], []); - - expect($roles['event_manager']['capabilities'])->toBe(['read' => true, 'view_events' => true]); -}); - -it('gives the super roles every declared capability', function (): void { - $roles = $this->compiler->compile(storedRoles(), [], [], ['administrator', 'missing_super_role'], ['edit_events', 'edit_posts']); - - expect($roles['administrator']['capabilities'])->toBe(['manage_options' => true, 'edit_posts' => true, 'edit_events' => true]) - ->and($roles['administrator']['_pollora'])->toBe(['granted' => ['edit_events']]); -}); - -it('warns about what it cannot apply', function (): void { - $warnings = []; - $warn = function (string $warning) use (&$warnings): void { - $warnings[] = $warning; - }; - - $roles = $this->compiler->compile(storedRoles(), [ - role('orphan', 'missing_parent', ['read']), - role('loop_a', 'loop_b'), - role('loop_b', 'loop_a', ['read']), - ], [modification('missing_role', ['read'])], [], [], $warn); - - expect($roles['orphan']['capabilities'])->toBe(['read' => true]) - ->and($warnings)->toBe([ - 'Class\ModifyMissing_role modifies the role "missing_role", which does not exist.', - 'The role "orphan" inherits from "missing_parent", which does not exist; it starts with no capability.', - 'The role "loop_b" inherits from itself through "loop_a"; inheritance ignored.', - ]); -}); - -it('warns nowhere when nobody listens', function (): void { - expect($this->compiler->compile(storedRoles(), [role('orphan', 'missing_parent')], [], [], []))->toHaveKey('orphan'); -}); diff --git a/tests/Unit/Role/RoleDefinitionBuilderTest.php b/tests/Unit/Role/RoleDefinitionBuilderTest.php deleted file mode 100644 index c03cefd3..00000000 --- a/tests/Unit/Role/RoleDefinitionBuilderTest.php +++ /dev/null @@ -1,105 +0,0 @@ -builder = new RoleDefinitionBuilder(new CapabilityOwnerReader, ['administrator']); -}); - -it('builds a role from #[Role] and its grants', function (): void { - $role = $this->builder->build(EventManager::class); - - expect($role)->toBeInstanceOf(RoleDefinition::class) - ->and($role->slug)->toBe('event_manager') - ->and($role->label)->toBe('Event manager') - ->and($role->textDomain)->toBeNull() - ->and($role->inherits)->toBe('author') - ->and($role->declaringClass)->toBe(EventManager::class) - ->and($role->changes->grants)->toBe(['export_attendees', 'scan_tickets', 'upload_files']) - ->and($role->changes->removals)->toBe(['publish_posts']) - ->and($role->changes->postTypeGrants)->toBe([ - ['postType' => Event::class, 'access' => Access::Editor], - ['postType' => 'venue', 'access' => Access::Author], - ]) - ->and($role->changes->taxonomyGrants)->toBe([Genre::class]); -}); - -it('resolves an inherited role given by class, and defaults the label to the class name', function (): void { - $role = $this->builder->build(Steward::class); - - expect($role->inherits)->toBe('event_manager') - ->and($role->label)->toBe('Steward') - ->and($role->changes->grants)->toBe(['manage_options']); -}); - -it('keeps the text domain of the label, through resolution', function (): void { - $role = $this->builder->build(Usher::class); - - expect($role->textDomain)->toBe('events') - ->and($role->withChanges($role->changes)->textDomain)->toBe('events'); -}); - -it('builds a modification from #[ModifyRole]', function (): void { - $modification = $this->builder->build(EditorAdjustments::class); - - expect($modification)->toBeInstanceOf(RoleModification::class) - ->and($modification->slug)->toBe('editor') - ->and($modification->changes->grants)->toBe(['scan_tickets']) - ->and($modification->changes->removals)->toBe(['edit_theme_options']) - ->and($modification->changes->postTypeGrants)->toBe([['postType' => Event::class, 'access' => Access::Contributor]]); -}); - -it('refuses a declaration that would grant the wrong rights', function (string $class, string $message): void { - expect(fn () => $this->builder->build($class))->toThrow(InvalidRoleDefinitionException::class, $message); -})->with([ - 'core role redeclared' => [RedeclaresCoreRole::class, '"editor" is a core role; adjust it with #[ModifyRole(\'editor\')]'], - 'inherits a super role' => [InheritsSuperRole::class, 'cannot inherit from the super role "administrator"'], - 'sensitive capability' => [GrantsSensitive::class, 'granting "edit_users" lets a user take over the site; confirm it with allowSensitive: true'], - 'sensitive capability on a modification' => [ModifiesWithSensitive::class, 'granting "install_plugins"'], - 'granted and removed' => [GrantsAndRemoves::class, '"read" is both granted and removed'], - 'role and modification' => [RoleAndModify::class, 'not both'], - 'no attribute' => [NoRoleAttribute::class, 'carries neither #[Role] nor #[ModifyRole]'], - 'post type sharing post capabilities' => [GrantsSharedPostType::class, 'the post type "article" shares the capabilities of posts; give Tests\Unit\Role\Fixtures\Article its own with #[CapabilityType(\'article\')]'], - 'not a post type' => [GrantsNotAPostType::class, 'which is not a #[PostType]'], - 'taxonomy sharing category capabilities' => [GrantsSharedTaxonomy::class, 'the taxonomy "topic" shares the capabilities of categories'], - 'not a taxonomy' => [GrantsNotATaxonomy::class, 'which is not a #[Taxonomy]'], - 'inherits a class that is not a role' => [InheritsNotARole::class, 'which is not a #[Role]'], -]); - -it('reads post type and taxonomy capabilities, and ignores other classes', function (): void { - $owners = new CapabilityOwnerReader; - - expect($owners->postType(Event::class))->toBe(['slug' => 'event', 'capabilityType' => 'event', 'overrides' => []]) - ->and($owners->taxonomy(Genre::class))->toBe(['slug' => 'genre', 'capabilities' => ['manage_genres', 'edit_genres', 'assign_genres']]) - ->and($owners->postType(Genre::class))->toBeNull() - ->and($owners->taxonomy(Event::class))->toBeNull() - ->and($owners->postType('not-a-class'))->toBeNull() - ->and($owners->taxonomy('not-a-class'))->toBeNull(); -}); diff --git a/tests/Unit/Role/RoleDirectiveTest.php b/tests/Unit/Role/RoleDirectiveTest.php index 191956dc..18609d7a 100644 --- a/tests/Unit/Role/RoleDirectiveTest.php +++ b/tests/Unit/Role/RoleDirectiveTest.php @@ -3,7 +3,7 @@ declare(strict_types=1); use Brain\Monkey\Functions; -use Pollora\Role\UI\View\RoleDirective; +use Pollora\Role\Infrastructure\Laravel\RoleDirective; use Tests\Unit\Role\Fixtures\EventManager; /** diff --git a/tests/Unit/Role/RoleDiscoveryTest.php b/tests/Unit/Role/RoleDiscoveryTest.php deleted file mode 100644 index c42a5367..00000000 --- a/tests/Unit/Role/RoleDiscoveryTest.php +++ /dev/null @@ -1,97 +0,0 @@ -registry = new RoleRegistry(new CapabilityOwnerReader, new PostTypeCapabilityMap); - $this->logger = Mockery::mock(LoggerInterface::class); - $this->discovery = new RoleDiscovery( - new RoleDefinitionBuilder(new CapabilityOwnerReader), - $this->registry, - new WordPressRoleInjector($this->registry, new RoleCompiler), - $this->logger, - ); - $this->location = new DiscoveryLocation('Tests\\', __DIR__); - $this->discover = function (string ...$classes): void { - foreach ($classes as $class) { - $structure = enum_exists($class) - ? DiscoveredEnum::fromReflection(new ReflectionEnum($class)) - : DiscoveredClass::fromReflection(new ReflectionClass($class)); - $this->discovery->discover($this->location, $structure); - } - }; -}); - -afterEach(function (): void { - unset($GLOBALS['wp_roles']); -}); - -it('keeps roles, modifications, capability sets, post types and taxonomies', function (): void { - ($this->discover)(EventManager::class, EditorAdjustments::class, EventCap::class, Event::class, Genre::class, AbstractRoleDeclaration::class, RoleOnAnEnum::class, stdClass::class); - - expect(iterator_to_array($this->discovery->getItems()))->toBe([ - ['class' => EventManager::class, 'kind' => 'role'], - ['class' => EditorAdjustments::class, 'kind' => 'role'], - ['class' => EventCap::class, 'kind' => 'capabilitySet'], - ['class' => Event::class, 'kind' => 'postType'], - ['class' => Genre::class, 'kind' => 'taxonomy'], - ]); -}); - -it('fills the registry and injects into the roles WordPress already has', function (): void { - $GLOBALS['wp_roles'] = new WP_Roles; - $GLOBALS['wp_roles']->roles = ['administrator' => ['name' => 'Administrator', 'capabilities' => []], 'author' => ['name' => 'Author', 'capabilities' => ['read' => true]], 'editor' => ['name' => 'Editor', 'capabilities' => []]]; - Brain\Monkey\Functions\when('did_action')->justReturn(0); - ($this->discover)(EventManager::class, EditorAdjustments::class, EventCap::class, Event::class, Article::class, Genre::class); - - $this->discovery->apply(); - - expect($GLOBALS['wp_roles']->roles)->toHaveKey('event_manager') - ->and($GLOBALS['wp_roles']->roles['editor']['capabilities'])->toHaveKeys(['scan_tickets', 'edit_events']) - ->and($GLOBALS['wp_roles']->roles['administrator']['capabilities'])->toHaveKeys(['export_attendees', 'edit_others_events', 'manage_genres']); -}); - -it('logs a declaration it cannot register and carries on', function (): void { - $this->logger->shouldReceive('error')->once()->with(Mockery::pattern('/RedeclaresCoreRole: "editor" is a core role/'), Mockery::type('array')); - ($this->discover)(RedeclaresCoreRole::class, EventManager::class); - - $this->discovery->apply(); - - expect($this->registry->definitions())->toHaveCount(1); -}); - -it('identifies itself as roles', function (): void { - expect($this->discovery->getIdentifier())->toBe('roles'); -}); diff --git a/tests/Unit/Role/RoleReferenceTest.php b/tests/Unit/Role/RoleReferenceTest.php deleted file mode 100644 index 8b7c2a47..00000000 --- a/tests/Unit/Role/RoleReferenceTest.php +++ /dev/null @@ -1,29 +0,0 @@ -toBe('editor'); -}); - -it('reads the slug of a #[Role] or #[ModifyRole] class', function (): void { - expect(RoleReference::slug(EventManager::class))->toBe('event_manager') - ->and(RoleReference::slug(EditorAdjustments::class))->toBe('editor'); -}); - -it('refuses a class that names no role', function (): void { - RoleReference::slug(Venue::class); -})->throws(InvalidArgumentException::class, Venue::class.' does not name a role'); - -it('matches when the user has one of the roles, ignoring case', function (): void { - expect(RoleReference::matchesAny(['author', 'event_manager'], ['editor', EventManager::class]))->toBeTrue() - ->and(RoleReference::matchesAny(['Editor'], ['EDITOR']))->toBeTrue() - ->and(RoleReference::matchesAny(['author'], ['editor', EventManager::class]))->toBeFalse() - ->and(RoleReference::matchesAny([], ['editor']))->toBeFalse() - ->and(RoleReference::matchesAny(['editor'], []))->toBeFalse(); -}); diff --git a/tests/Unit/Role/RoleRegistryTest.php b/tests/Unit/Role/RoleRegistryTest.php deleted file mode 100644 index 9210874d..00000000 --- a/tests/Unit/Role/RoleRegistryTest.php +++ /dev/null @@ -1,124 +0,0 @@ -registry = new RoleRegistry(new CapabilityOwnerReader, new PostTypeCapabilityMap); - $this->build = fn (string $class): RoleDefinition|\Pollora\Role\Domain\Models\RoleModification => (new RoleDefinitionBuilder(new CapabilityOwnerReader))->build($class); -}); - -it('resolves post type and taxonomy grants into capability names', function (): void { - $this->registry->addPostType(Venue::class); - $this->registry->addRole(($this->build)(EventManager::class)); - - $grants = $this->registry->definitions()[0]->changes->grants; - - expect($grants)->toContain('export_attendees', 'upload_files', 'edit_others_events', 'delete_private_events', 'open_venues', 'edit_published_venues', 'manage_genres', 'assign_genres') - ->not->toContain('edit_others_venues') - ->and($grants)->toBe(array_values(array_unique($grants))); -}); - -it('ignores, with a warning, a post type slug it has not discovered', function (): void { - $warnings = []; - $this->registry->addRole(($this->build)(EventManager::class)); - - $grants = $this->registry->definitions(function (string $warning) use (&$warnings): void { - $warnings[] = $warning; - })[0]->changes->grants; - - expect($grants)->not->toContain('open_venues') - ->and($warnings)->toBe(['Tests\Unit\Role\Fixtures\EventManager: the post type "venue" was not found with its own capabilities; its grant is ignored.']); -}); - -it('ignores, with a warning, a taxonomy slug it has not discovered', function (): void { - $warnings = []; - $modification = ($this->build)(EditorAdjustments::class); - $this->registry->addModification($modification->withChanges(new RoleChanges(taxonomyGrants: ['genre']))); - - $this->registry->modifications(function (string $warning) use (&$warnings): void { - $warnings[] = $warning; - }); - - expect($warnings)->toBe(['Tests\Unit\Role\Fixtures\EditorAdjustments: the taxonomy "genre" was not found with its own capabilities; its grant is ignored.']); -}); - -it('collects every declared capability for the super roles', function (): void { - $this->registry->addPostType(Event::class); - $this->registry->addPostType(Article::class); - $this->registry->addTaxonomy(Genre::class); - $this->registry->addTaxonomy(Topic::class); - $this->registry->addCapabilitySet(['export_attendees', 'scan_tickets']); - $this->registry->addCapabilitySet(['scan_tickets']); - - expect($this->registry->declaredCapabilities())->toBe([ - 'export_attendees', 'scan_tickets', - 'edit_events', 'delete_events', 'publish_events', 'edit_published_events', 'delete_published_events', - 'edit_others_events', 'delete_others_events', 'read_private_events', 'edit_private_events', 'delete_private_events', - 'manage_genres', 'edit_genres', 'assign_genres', - ]); -}); - -it('starts empty', function (): void { - expect($this->registry->isEmpty())->toBeTrue(); - - $this->registry->addCapabilitySet(['scan_tickets']); - - expect($this->registry->isEmpty())->toBeFalse(); -}); - -it('refuses a role slug declared by two classes', function (): void { - $this->registry->addRole(($this->build)(EventManager::class)); - $this->registry->addRole(($this->build)(EventManager::class)); - - $this->registry->addRole(($this->build)(DuplicateEventManager::class)); -})->throws(InvalidRoleDefinitionException::class, 'the role "event_manager" is already declared by Tests\Unit\Role\Fixtures\EventManager'); - -it('refuses an inheritance loop', function (): void { - $this->registry->addRole(($this->build)(LoopA::class)); - - $this->registry->addRole(($this->build)(LoopB::class)); -})->throws(InvalidRoleDefinitionException::class, 'inheritance loops: loop_b → loop_a → loop_b'); - -it('refuses modifications of the same role that contradict each other', function (): void { - $this->registry->addModification(($this->build)(EditorAdjustments::class)); - $this->registry->addModification(($this->build)(AlsoAdjustsEditor::class)); - - $this->registry->addModification(($this->build)(ContradictsEditorAdjustments::class)); -})->throws(InvalidRoleDefinitionException::class, 'it contradicts Tests\Unit\Role\Fixtures\EditorAdjustments on the role "editor": "edit_theme_options" is both granted and removed.'); - -it('keeps one modification per class', function (): void { - $this->registry->addModification(($this->build)(EditorAdjustments::class)); - $this->registry->addModification(($this->build)(EditorAdjustments::class)); - - expect($this->registry->modifications())->toHaveCount(1); -}); - -it('lists the text domain of each label that declares one', function (): void { - $this->registry->addRole(($this->build)(Usher::class)); - $this->registry->addRole(($this->build)(LoopB::class)); - - expect($this->registry->labelDomains())->toBe(['Usher' => 'events']); -}); diff --git a/tests/Unit/Role/RoleServiceProviderTest.php b/tests/Unit/Role/RoleServiceProviderTest.php index 90be8ccb..6b853a0e 100644 --- a/tests/Unit/Role/RoleServiceProviderTest.php +++ b/tests/Unit/Role/RoleServiceProviderTest.php @@ -12,11 +12,11 @@ use Pollora\Hook\Domain\Contract\Filter; use Pollora\Role\Application\Services\RoleDefinitionBuilder; use Pollora\Role\Infrastructure\Adapters\WordPressRoleInjector; -use Pollora\Role\Infrastructure\Middleware\EnsureUserHasRole; -use Pollora\Role\Infrastructure\Providers\RoleServiceProvider; +use Pollora\Role\Infrastructure\Laravel\Console\RoleMakeCommand; +use Pollora\Role\Infrastructure\Laravel\EnsureUserHasRole; +use Pollora\Role\Infrastructure\Laravel\RoleDirective; +use Pollora\Role\Infrastructure\Laravel\RoleServiceProvider; use Pollora\Role\Infrastructure\Services\RoleDiscovery; -use Pollora\Role\UI\Console\RoleMakeCommand; -use Pollora\Role\UI\View\RoleDirective; use Psr\Log\LoggerInterface; beforeEach(function (): void { diff --git a/tests/Unit/Role/RoleWritingCommandsTest.php b/tests/Unit/Role/RoleWritingCommandsTest.php index c8e601be..73784cb4 100644 --- a/tests/Unit/Role/RoleWritingCommandsTest.php +++ b/tests/Unit/Role/RoleWritingCommandsTest.php @@ -15,9 +15,9 @@ use Pollora\Role\Domain\Contracts\RoleUsageInterface; use Pollora\Role\Domain\Services\PostTypeCapabilityMap; use Pollora\Role\Domain\Services\StoredEntryClassifier; -use Pollora\Role\UI\Console\RoleDumpCommand; -use Pollora\Role\UI\Console\RoleImportCommand; -use Pollora\Role\UI\Console\RolePruneCommand; +use Pollora\Role\Infrastructure\Laravel\Console\RoleDumpCommand; +use Pollora\Role\Infrastructure\Laravel\Console\RoleImportCommand; +use Pollora\Role\Infrastructure\Laravel\Console\RolePruneCommand; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; use Tests\Unit\Role\Fixtures\EventManager; diff --git a/tests/Unit/Role/RolesCheckTest.php b/tests/Unit/Role/RolesCheckTest.php index c4333593..a86ea3c0 100644 --- a/tests/Unit/Role/RolesCheckTest.php +++ b/tests/Unit/Role/RolesCheckTest.php @@ -11,7 +11,7 @@ use Pollora\Role\Domain\Services\PostTypeCapabilityMap; use Pollora\Role\Domain\Services\RoleCompiler; use Pollora\Role\Infrastructure\Adapters\WordPressRoleInjector; -use Pollora\Role\Infrastructure\Checks\RolesCheck; +use Pollora\Role\Infrastructure\Laravel\RolesCheck; use Tests\Unit\Role\Fixtures\EventManager; use Tests\Unit\Role\Fixtures\FakeRoleUsage; diff --git a/tests/Unit/Role/WordPressRoleInjectorTest.php b/tests/Unit/Role/WordPressRoleInjectorTest.php deleted file mode 100644 index 6ee0b17a..00000000 --- a/tests/Unit/Role/WordPressRoleInjectorTest.php +++ /dev/null @@ -1,115 +0,0 @@ -roles = $roles; - - return $wpRoles; -} - -beforeEach(function (): void { - $this->registry = new RoleRegistry(new CapabilityOwnerReader, new PostTypeCapabilityMap); - $this->logger = Mockery::mock(LoggerInterface::class); - $this->injector = new WordPressRoleInjector($this->registry, new RoleCompiler, ['administrator'], $this->logger); - $this->stored = [ - 'administrator' => ['name' => 'Administrator', 'capabilities' => ['manage_options' => true]], - 'author' => ['name' => 'Author', 'capabilities' => ['read' => true, 'publish_posts' => true]], - ]; -}); - -afterEach(function (): void { - unset($GLOBALS['wp_roles']); -}); - -it('puts the compiled roles, role objects and names into WP_Roles', function (): void { - $this->registry->addRole((new RoleDefinitionBuilder(new CapabilityOwnerReader))->build(EventManager::class)); - $wpRoles = wpRolesWith($this->stored); - - $this->injector->inject($wpRoles); - - expect($wpRoles->roles)->toHaveKeys(['administrator', 'author', 'event_manager']) - ->and($wpRoles->role_names['event_manager'])->toBe('Event manager') - ->and($wpRoles->role_objects['event_manager'])->toBeInstanceOf(WP_Role::class) - ->and($wpRoles->role_objects['event_manager']->capabilities)->toHaveKey('export_attendees') - ->and($wpRoles->role_objects['event_manager']->capabilities)->not->toHaveKey('publish_posts'); -}); - -it('leaves WP_Roles alone when the project declares nothing', function (): void { - $wpRoles = wpRolesWith($this->stored); - - $this->injector->inject($wpRoles); - - expect($wpRoles->roles)->toBe($this->stored) - ->and($wpRoles->role_objects)->toBe([]); -}); - -it('removes what an earlier version of the code left in the database, even when nothing is declared now', function (): void { - $wpRoles = wpRolesWith([...$this->stored, 'gone' => ['name' => 'Gone', 'capabilities' => [], '_pollora' => ['managed' => true]]]); - - $this->injector->inject($wpRoles); - - expect($wpRoles->roles)->toBe($this->stored); -}); - -it('injects into the existing WP_Roles and recomputes the current user capabilities on refresh', function (): void { - $this->registry->addCapabilitySet(['scan_tickets']); - $GLOBALS['wp_roles'] = wpRolesWith($this->stored); - $user = Mockery::mock(); - $user->shouldReceive('exists')->andReturn(true); - $user->shouldReceive('get_role_caps')->once(); - Functions\when('did_action')->justReturn(1); - Functions\when('wp_get_current_user')->justReturn($user); - - $this->injector->refresh(); - - expect($GLOBALS['wp_roles']->roles['administrator']['capabilities'])->toHaveKey('scan_tickets'); -}); - -it('does not touch a current user that is not logged in', function (): void { - $GLOBALS['wp_roles'] = wpRolesWith($this->stored); - $user = Mockery::mock(); - $user->shouldReceive('exists')->andReturn(false); - $user->shouldNotReceive('get_role_caps'); - Functions\when('did_action')->justReturn(1); - Functions\when('wp_get_current_user')->justReturn($user); - - $this->injector->refresh(); -}); - -it('waits for the current user before recomputing capabilities', function (): void { - $GLOBALS['wp_roles'] = wpRolesWith($this->stored); - Functions\when('did_action')->justReturn(0); - Functions\expect('wp_get_current_user')->never(); - - $this->injector->refresh(); -}); - -it('does nothing on refresh before WordPress has its roles', function (): void { - Functions\expect('did_action')->never(); - - $this->injector->refresh(); -}); - -it('logs each warning of the last compilation once', function (): void { - $this->registry->addRole((new RoleDefinitionBuilder(new CapabilityOwnerReader))->build(EventManager::class)); - $this->injector->inject(wpRolesWith($this->stored)); - $this->injector->inject(wpRolesWith($this->stored)); - $this->logger->shouldReceive('warning')->once()->with('Roles: Tests\Unit\Role\Fixtures\EventManager: the post type "venue" was not found with its own capabilities; its grant is ignored.'); - - $this->injector->reportWarnings(); -}); diff --git a/tests/Unit/Role/WordPressRoleLabelTranslatorTest.php b/tests/Unit/Role/WordPressRoleLabelTranslatorTest.php deleted file mode 100644 index 261a37c5..00000000 --- a/tests/Unit/Role/WordPressRoleLabelTranslatorTest.php +++ /dev/null @@ -1,29 +0,0 @@ -addRole((new RoleDefinitionBuilder(new CapabilityOwnerReader))->build(Usher::class)); - - $this->translator = new WordPressRoleLabelTranslator($registry); - Functions\when('translate')->alias(fn (string $text, string $domain): string => $domain === 'events' ? 'Placeur' : $text); -}); - -it("translates a declared label with the role's text domain", function (): void { - expect($this->translator->translate('Usher', 'Usher', 'User role'))->toBe('Placeur'); -}); - -it('keeps a translation WordPress already found, and other texts', function (): void { - expect($this->translator->translate('Huissier', 'Usher', 'User role'))->toBe('Huissier') - ->and($this->translator->translate('Usher', 'Usher', 'noun'))->toBe('Usher') - ->and($this->translator->translate('Editor', 'Editor', 'User role'))->toBe('Editor'); -}); diff --git a/tests/Unit/Role/WordPressRoleStoreTest.php b/tests/Unit/Role/WordPressRoleStoreTest.php deleted file mode 100644 index aa1a5bcf..00000000 --- a/tests/Unit/Role/WordPressRoleStoreTest.php +++ /dev/null @@ -1,55 +0,0 @@ -previousWpdb = $GLOBALS['wpdb'] ?? null; - $GLOBALS['wpdb'] = new class - { - public function get_blog_prefix(): string - { - return 'wp_2_'; - } - }; -}); - -afterEach(function (): void { - $GLOBALS['wpdb'] = $this->previousWpdb; -}); - -it('reads and writes the roles option of the current site', function (): void { - $calls = []; - Functions\when('get_option')->alias(function (string $name) use (&$calls): array { - $calls[] = 'get '.$name; - - return ['editor' => ['name' => 'Editor', 'capabilities' => []]]; - }); - Functions\when('update_option')->alias(function (string $name, array $value) use (&$calls): bool { - $calls[] = 'update '.$name.': '.implode(', ', array_keys($value)); - - return true; - }); - - $store = new WordPressRoleStore; - $stored = $store->storedRoles(); - $store->saveStoredRoles(['author' => ['name' => 'Author', 'capabilities' => []]]); - - expect($stored)->toHaveKey('editor') - ->and($calls)->toBe(['get wp_2_user_roles', 'update wp_2_user_roles: author']); -}); - -it('takes a role WordPress no longer knows off a user through remove_cap()', function (): void { - $user = Mockery::mock('WP_User'); - $user->shouldReceive('remove_cap')->once()->with('event_manager'); - $user->shouldReceive('add_role')->once()->with('subscriber'); - Functions\when('get_userdata')->justReturn($user); - - $store = new WordPressRoleStore; - $store->removeFromUser(3, 'event_manager'); - $store->addRoleToUser(3, 'subscriber'); -}); diff --git a/tests/Unit/Role/WordPressRoleUsageTest.php b/tests/Unit/Role/WordPressRoleUsageTest.php deleted file mode 100644 index 656d60e6..00000000 --- a/tests/Unit/Role/WordPressRoleUsageTest.php +++ /dev/null @@ -1,57 +0,0 @@ -previousWpdb = $GLOBALS['wpdb'] ?? null; - $this->queries = []; - - // Only what the adapter uses: the usermeta table, the blog prefix, prepare() and get_results() - $GLOBALS['wpdb'] = new class($this) - { - public string $usermeta = 'wp_usermeta'; - - public function __construct(private readonly object $test) {} - - public function get_blog_prefix(): string - { - return 'wp_'; - } - - public function prepare(string $query, mixed ...$args): string - { - $this->test->queries[] = [$query, $args]; - - return $query; - } - - public function get_results(string $query): array - { - return [ - (object) ['user_id' => '1', 'meta_value' => serialize(['administrator' => true])], - (object) ['user_id' => '2', 'meta_value' => serialize(['event_manager' => true, 'old_cap' => false])], - (object) ['user_id' => '3', 'meta_value' => serialize(['event_manager' => true, 'moderate_comments' => true])], - (object) ['user_id' => '4', 'meta_value' => 'O:8:"stdClass":0:{}'], - ]; - } - }; - - Functions\when('is_serialized')->alias(fn (string $value): bool => preg_match('/^[aO]:/', $value) === 1); - Functions\when('get_userdata')->alias(fn (int $id): false => false); -}); - -afterEach(function (): void { - $GLOBALS['wpdb'] = $this->previousWpdb; -}); - -it('counts the granted entries users carry, read once from the current site', function (): void { - $usage = new WordPressRoleUsage; - - expect($usage->entries())->toBe(['administrator' => 1, 'event_manager' => 2, 'moderate_comments' => 1]) - ->and($usage->usersWith('event_manager'))->toBe([2 => '#2', 3 => '#3']) - ->and($this->queries)->toHaveCount(1) - ->and($this->queries[0][1])->toBe(['wp_capabilities']); -}); diff --git a/tests/Unit/Support/QueryFacadesTest.php b/tests/Unit/Support/QueryFacadesTest.php new file mode 100644 index 00000000..c598be27 --- /dev/null +++ b/tests/Unit/Support/QueryFacadesTest.php @@ -0,0 +1,49 @@ +app = new Application(sys_get_temp_dir()); + (new QueryServiceProvider($this->app))->register(); + + Facade::clearResolvedInstances(); + Facade::setFacadeApplication($this->app); +}); + +afterEach(function (): void { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); +}); + +it('binds the post, taxonomy, meta and date queries', function (string $key, string $class): void { + expect($this->app->make($key))->toBeInstanceOf($class); +})->with([ + 'posts' => ['wp.query.post', PostQueryBuilder::class], + 'taxonomies' => ['wp.query.taxonomy', TaxQuery::class], + 'meta' => ['wp.query.meta', MetaQuery::class], + 'dates' => ['wp.query.date', DateQuery::class], +]); + +it('resolves the post, taxonomy and meta facades to their query', function (): void { + expect(PostQuery::getFacadeRoot())->toBeInstanceOf(PostQueryBuilder::class) + ->and(TaxQueryFacade::getFacadeRoot())->toBeInstanceOf(TaxQuery::class) + ->and(MetaQueryFacade::getFacadeRoot())->toBeInstanceOf(MetaQuery::class); +}); + +it('builds the arguments of a post query through its facade', function (): void { + expect(PostQuery::select()->postType('page')->postParent(5)->getArguments()) + ->toMatchArray(['post_type' => 'page', 'post_parent' => 5]); +});