feat(compat): Profile, Gates und Pods über den Kompatibilitätskern
Neu: Tier, Profile, Compatibility, CapabilityLocked, CapabilityGate, CompatibilityComponent, ProvidesCompatibility, DiviProfile sowie PodsFeature, PodsCompatibility und PodsIntegration. Das CapabilityGate sperrt gesperrte und unbekannte Fähigkeits-IDs (übernimmt die Sperre unbekannter IDs vom DiviFeatureGate) und delegiert nur Divi ausdrücklich. Pods-Schema, -Lesen, -Schreiben und -Feldrechte laufen über usable(PodsFeature::…); Schreiben verlangt FieldRead und FieldWrite, damit ein Rollback keine Werte verliert. lit-site-status meldet Pods über die CompatibilityComponent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b511ef6348
commit
c85131603b
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Compatibility;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\AbilityGuard;
|
||||
use LIT\MCP\Abilities\DeclaresRequirements;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The lock of all non-Divi capabilities: a call is refused before it runs while one of the capabilities it declares
|
||||
* (DeclaresRequirements) is locked, and also while a declared ID is unknown to every profile. IDs of
|
||||
* profiles named in $delegated are left to the guard that checks them itself. A refused write is logged with result
|
||||
* "refused"; a refused read changes nothing and is not.
|
||||
*/
|
||||
final class CapabilityGate implements AbilityGuard
|
||||
{
|
||||
/** @param list<string> $delegated IDs of the profiles another guard checks (skipped here), e.g. 'divi'. */
|
||||
public function __construct(
|
||||
private Compatibility $compat,
|
||||
private ?ChangeLog $log = null,
|
||||
private array $delegated = []
|
||||
) {
|
||||
}
|
||||
|
||||
public function refusal(Ability $ability, array $input): ?WP_Error
|
||||
{
|
||||
$capabilities = [];
|
||||
$unknown = [];
|
||||
foreach ($ability instanceof DeclaresRequirements ? $ability->requirements($input) : [] as $id) {
|
||||
$capability = $this->compat->capability($id);
|
||||
if ($capability === null) {
|
||||
$unknown[] = $id;
|
||||
} elseif (! in_array($this->compat->owner($capability)?->id, $this->delegated, true)) {
|
||||
$capabilities[] = $capability;
|
||||
}
|
||||
}
|
||||
|
||||
if ($unknown !== []) {
|
||||
return $this->refuse(
|
||||
$ability,
|
||||
CapabilityLocked::unknown($unknown),
|
||||
implode(', ', $unknown),
|
||||
'unbekannte Fähigkeit ' . implode(', ', $unknown)
|
||||
);
|
||||
}
|
||||
|
||||
$lock = $this->compat->lockOf($capabilities);
|
||||
if ($lock === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$labels = implode(', ', array_map(static fn (FeatureStatus $s): string => $s->feature->label(), $lock->locked));
|
||||
|
||||
return $this->refuse(
|
||||
$ability,
|
||||
$lock,
|
||||
implode(', ', array_unique(array_map(
|
||||
fn (FeatureStatus $s): string => $this->compat->owner($s->feature)?->id ?? '',
|
||||
$lock->locked
|
||||
))),
|
||||
sprintf('%s – %s gesperrt', implode(', ', $lock->plugins), $labels)
|
||||
);
|
||||
}
|
||||
|
||||
private function refuse(Ability $ability, CapabilityLocked $lock, string $objectId, string $reason): WP_Error
|
||||
{
|
||||
if (($ability->annotations()['readonly'] ?? false) !== true) {
|
||||
$this->log?->record(
|
||||
$ability->name(),
|
||||
'compat',
|
||||
$objectId,
|
||||
'Abgelehnt: ' . $reason . '.',
|
||||
result: 'refused'
|
||||
);
|
||||
}
|
||||
|
||||
return $lock->toError();
|
||||
}
|
||||
|
||||
/** A refusal is the whole answer: there is no result to annotate. */
|
||||
public function annotate(Ability $ability, mixed $result): mixed
|
||||
{
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function outputSchema(Ability $ability, ?array $schema): ?array
|
||||
{
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Compatibility;
|
||||
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Abilities\ToolRefusal;
|
||||
use LogicException;
|
||||
use RuntimeException;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* A call refused because a capability it needs is locked, or because no profile knows a capability it declares;
|
||||
* AbilityRegistrar returns toError().
|
||||
*/
|
||||
final class CapabilityLocked extends RuntimeException implements ToolRefusal
|
||||
{
|
||||
/**
|
||||
* @param list<FeatureStatus> $locked The locked capabilities the refused call needs.
|
||||
* @param list<string> $plugins Labels of the plugins they belong to.
|
||||
* @param list<string> $unknown Declared IDs no profile knows.
|
||||
*/
|
||||
private function __construct(
|
||||
public readonly array $locked,
|
||||
public readonly array $plugins,
|
||||
public readonly array $unknown
|
||||
) {
|
||||
parent::__construct(sprintf(
|
||||
'%s locked – %s refused',
|
||||
implode(', ', $plugins) ?: 'unknown capability',
|
||||
implode(', ', $unknown ?: array_map(static fn (FeatureStatus $s): string => $s->feature->id(), $locked))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<FeatureStatus> $locked
|
||||
* @param list<Profile> $profiles The profiles the locked capabilities belong to.
|
||||
* @throws LogicException When $locked is empty – a lock always names what it locks.
|
||||
*/
|
||||
public static function of(array $locked, array $profiles): self
|
||||
{
|
||||
if ($locked === []) {
|
||||
throw new LogicException('a capability lock needs the locked capabilities');
|
||||
}
|
||||
|
||||
return new self($locked, array_map(static fn (Profile $profile): string => $profile->label, $profiles), []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $ids
|
||||
* @throws LogicException When $ids is empty.
|
||||
*/
|
||||
public static function unknown(array $ids): self
|
||||
{
|
||||
if ($ids === []) {
|
||||
throw new LogicException('an unknown-capability lock needs the IDs');
|
||||
}
|
||||
|
||||
return new self([], [], $ids);
|
||||
}
|
||||
|
||||
public function toError(): WP_Error
|
||||
{
|
||||
if ($this->unknown !== []) {
|
||||
return Errors::make(
|
||||
'compat_incompatible',
|
||||
sprintf(
|
||||
'Das Werkzeug verlangt unbekannte Fähigkeiten (%s). Das ist gesperrt. Es wurde nichts geändert.',
|
||||
implode(', ', $this->unknown)
|
||||
),
|
||||
'Den Nutzer informieren, dass LIT MCP aktualisiert werden muss.',
|
||||
423,
|
||||
['unknown' => $this->unknown]
|
||||
);
|
||||
}
|
||||
|
||||
$named = implode(', ', array_map(
|
||||
static fn (FeatureStatus $status): string => '„' . $status->feature->label() . '“',
|
||||
$this->locked
|
||||
));
|
||||
$plugins = implode(', ', $this->plugins);
|
||||
|
||||
return Errors::make(
|
||||
'compat_incompatible',
|
||||
sprintf(
|
||||
'%s ist auf dieser Website nicht mit LIT MCP kompatibel: %s ist gesperrt. Es wurde nichts geändert.',
|
||||
$plugins,
|
||||
$named
|
||||
),
|
||||
sprintf(
|
||||
'Gesperrt: %s. Andere Werkzeuge funktionieren weiter. '
|
||||
. 'Den Nutzer informieren, dass %s geprüft werden muss.',
|
||||
$named,
|
||||
$plugins
|
||||
),
|
||||
423,
|
||||
[
|
||||
'features' => array_map(static fn (FeatureStatus $status): array => $status->summary(), $this->locked),
|
||||
'problems' => array_values(array_unique(array_merge(
|
||||
...array_map(static fn (FeatureStatus $status): array => $status->problems, $this->locked)
|
||||
))),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Compatibility;
|
||||
|
||||
use Closure;
|
||||
use LogicException;
|
||||
use Throwable;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The compatibility state of all third-party plugins, evaluated per Profile and memoised per request. Everything
|
||||
* that cannot be judged (a throwing closure or check, an unknown version under a minimum, an unowned capability)
|
||||
* counts as locked – fail closed.
|
||||
*/
|
||||
final class Compatibility
|
||||
{
|
||||
/** @var array<string, Profile> keyed by profile ID */
|
||||
private array $byId = [];
|
||||
/** @var array<string, array{0: Capability, 1: Profile}> keyed by capability ID */
|
||||
private array $byCapability = [];
|
||||
/** @var array<string, CompatibilityMatrix> */
|
||||
private array $matrices = [];
|
||||
/** @var array<string, ManifestCheck> */
|
||||
private array $checks = [];
|
||||
/** @var Closure(Profile): ManifestCheck */
|
||||
private Closure $check;
|
||||
|
||||
/**
|
||||
* @param list<Profile> $profiles
|
||||
* @param (Closure(Profile): ManifestCheck)|null $check Builds the check of a profile; default: its manifests
|
||||
* without signature checks.
|
||||
* @throws LogicException For a profile ID or a capability ID that occurs twice.
|
||||
*/
|
||||
public function __construct(private array $profiles, ?Closure $check = null)
|
||||
{
|
||||
$this->check = $check ?? static fn (Profile $profile): ManifestCheck => new ManifestCheck(
|
||||
$profile->manifests,
|
||||
signatures: false
|
||||
);
|
||||
foreach ($profiles as $profile) {
|
||||
if (isset($this->byId[$profile->id])) {
|
||||
throw new LogicException('profile ID twice: ' . $profile->id);
|
||||
}
|
||||
$this->byId[$profile->id] = $profile;
|
||||
foreach ($profile->capabilities as $capability) {
|
||||
if (isset($this->byCapability[$capability->id()])) {
|
||||
throw new LogicException('capability ID twice: ' . $capability->id());
|
||||
}
|
||||
$this->byCapability[$capability->id()] = [$capability, $profile];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<Profile> */
|
||||
public function profiles(): array
|
||||
{
|
||||
return $this->profiles;
|
||||
}
|
||||
|
||||
public function profile(string $id): ?Profile
|
||||
{
|
||||
return $this->byId[$id] ?? null;
|
||||
}
|
||||
|
||||
public function capability(string $id): ?Capability
|
||||
{
|
||||
return ($this->byCapability[$id] ?? [null])[0];
|
||||
}
|
||||
|
||||
public function owner(Capability $capability): ?Profile
|
||||
{
|
||||
return ($this->byCapability[$capability->id()] ?? [1 => null])[1];
|
||||
}
|
||||
|
||||
public function active(Profile $profile): bool
|
||||
{
|
||||
return $profile->active === null || self::attempt($profile->active) === true;
|
||||
}
|
||||
|
||||
public function version(Profile $profile): ?string
|
||||
{
|
||||
$version = $profile->version === null ? null : self::attempt($profile->version);
|
||||
|
||||
return is_string($version) ? $version : null;
|
||||
}
|
||||
|
||||
public function matrix(Profile $profile): CompatibilityMatrix
|
||||
{
|
||||
return $this->matrices[$profile->id] ??= $this->build($profile);
|
||||
}
|
||||
|
||||
/** @return list<string> Labels of what is wrong with the plugin; for ReportOnly the problems of the check. */
|
||||
public function problems(Profile $profile): array
|
||||
{
|
||||
if ($profile->tier !== Tier::ReportOnly) {
|
||||
return $this->matrix($profile)->problems();
|
||||
}
|
||||
|
||||
try {
|
||||
return array_values(array_map(
|
||||
static fn (Problem $problem): string => $problem->label,
|
||||
$this->checkOf($profile)->problems()
|
||||
));
|
||||
} catch (Throwable) {
|
||||
return [self::failed($profile)];
|
||||
}
|
||||
}
|
||||
|
||||
public function usable(Capability $capability): bool
|
||||
{
|
||||
$owner = $this->owner($capability);
|
||||
|
||||
return $owner !== null && $this->matrix($owner)->of($capability)->usable();
|
||||
}
|
||||
|
||||
/**
|
||||
* The lock of those of $capabilities that are not usable, null when all are. Unowned ones are left out.
|
||||
*
|
||||
* @param list<Capability> $capabilities
|
||||
*/
|
||||
public function lockOf(array $capabilities): ?CapabilityLocked
|
||||
{
|
||||
$locked = [];
|
||||
$owners = [];
|
||||
foreach ($capabilities as $capability) {
|
||||
$owner = $this->owner($capability);
|
||||
if ($owner === null) {
|
||||
continue;
|
||||
}
|
||||
$status = $this->matrix($owner)->of($capability);
|
||||
if (! $status->usable()) {
|
||||
$locked[$capability->id()] = $status;
|
||||
$owners[$owner->id] = $owner;
|
||||
}
|
||||
}
|
||||
|
||||
return $locked === [] ? null : CapabilityLocked::of(array_values($locked), array_values($owners));
|
||||
}
|
||||
|
||||
private function build(Profile $profile): CompatibilityMatrix
|
||||
{
|
||||
$capabilities = $profile->capabilities;
|
||||
try {
|
||||
if ($profile->tier === Tier::ReleasePerVersion) {
|
||||
return ($profile->matrix)();
|
||||
}
|
||||
if ($profile->tier === Tier::ReportOnly) {
|
||||
return CompatibilityMatrix::allReleased($capabilities);
|
||||
}
|
||||
if (! $this->active($profile)) {
|
||||
return CompatibilityMatrix::allIncompatible(
|
||||
$capabilities,
|
||||
sprintf('%s ist nicht installiert oder nicht aktiv.', $profile->label)
|
||||
);
|
||||
}
|
||||
$tooOld = $this->versionProblem($profile);
|
||||
if ($tooOld !== null) {
|
||||
return CompatibilityMatrix::allIncompatible($capabilities, $tooOld);
|
||||
}
|
||||
|
||||
return CompatibilityMatrix::fromProblems(
|
||||
$capabilities,
|
||||
$this->checkOf($profile)->problems(),
|
||||
null,
|
||||
automatic: true
|
||||
);
|
||||
} catch (Throwable) {
|
||||
return CompatibilityMatrix::allIncompatible($capabilities, self::failed($profile));
|
||||
}
|
||||
}
|
||||
|
||||
private function versionProblem(Profile $profile): ?string
|
||||
{
|
||||
if ($profile->minVersion === null) {
|
||||
return null;
|
||||
}
|
||||
$version = $this->version($profile);
|
||||
if ($version === null) {
|
||||
return sprintf(
|
||||
'Die Version von %s ist unbekannt (Mindestversion %s).',
|
||||
$profile->label,
|
||||
$profile->minVersion
|
||||
);
|
||||
}
|
||||
|
||||
return version_compare($version, $profile->minVersion, '<')
|
||||
? sprintf('%s %s ist älter als die Mindestversion %s.', $profile->label, $version, $profile->minVersion)
|
||||
: null;
|
||||
}
|
||||
|
||||
private function checkOf(Profile $profile): ManifestCheck
|
||||
{
|
||||
return $this->checks[$profile->id] ??= ($this->check)($profile);
|
||||
}
|
||||
|
||||
private static function failed(Profile $profile): string
|
||||
{
|
||||
return sprintf('Die Kompatibilitätsprüfung von %s ist fehlgeschlagen.', $profile->label);
|
||||
}
|
||||
|
||||
/** The closure's result, null when it throws. */
|
||||
private static function attempt(Closure $closure): mixed
|
||||
{
|
||||
try {
|
||||
return $closure();
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Compatibility;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Integrations\Core\EnvironmentComponent;
|
||||
use LogicException;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* One third-party plugin in lit-site-status: not installed, active, or active but broken (problems filled). No
|
||||
* per-capability rows – the problem labels already name what is wrong. A ReleasePerVersion profile (Divi) brings its
|
||||
* own component with the release status. The Compatibility comes per closure because it exists only after the
|
||||
* integrations that provide the profiles.
|
||||
*/
|
||||
final class CompatibilityComponent implements EnvironmentComponent
|
||||
{
|
||||
/**
|
||||
* @param Closure(): Compatibility $compatibility
|
||||
* @throws LogicException For a ReleasePerVersion profile.
|
||||
*/
|
||||
public function __construct(private Profile $profile, private Closure $compatibility)
|
||||
{
|
||||
if ($profile->tier === Tier::ReleasePerVersion) {
|
||||
throw new LogicException(
|
||||
sprintf('profile %s has a release status and needs its own component', $profile->id)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<CompatibilityComponent> One per profile except the ReleasePerVersion ones (own components). */
|
||||
public static function forProfiles(Compatibility $compatibility): array
|
||||
{
|
||||
$profiles = array_filter(
|
||||
$compatibility->profiles(),
|
||||
static fn (Profile $profile): bool => $profile->tier !== Tier::ReleasePerVersion
|
||||
);
|
||||
|
||||
return array_values(array_map(
|
||||
static fn (Profile $profile): self => new self($profile, static fn (): Compatibility => $compatibility),
|
||||
$profiles
|
||||
));
|
||||
}
|
||||
|
||||
/** @return array{id: string, active: bool, version: ?string, problems: list<string>, release: null} */
|
||||
public function describe(): array
|
||||
{
|
||||
$compatibility = ($this->compatibility)();
|
||||
if (! $compatibility->active($this->profile)) {
|
||||
return [
|
||||
'id' => $this->profile->id,
|
||||
'active' => false,
|
||||
'version' => null,
|
||||
'problems' => [],
|
||||
'release' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $this->profile->id,
|
||||
'active' => true,
|
||||
'version' => $compatibility->version($this->profile),
|
||||
'problems' => $compatibility->problems($this->profile),
|
||||
'release' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Compatibility;
|
||||
|
||||
use Closure;
|
||||
use LogicException;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Everything the compatibility layer needs to know about one third-party plugin: its capabilities, what its code
|
||||
* depends on, how to tell whether it is active and which version it has. Pure data; Compatibility evaluates it.
|
||||
*/
|
||||
final class Profile
|
||||
{
|
||||
/**
|
||||
* @param list<Capability> $capabilities
|
||||
* @param list<Manifest> $manifests
|
||||
* @param (Closure(): bool)|null $active Plugin installed and active; null = always.
|
||||
* @param (Closure(): ?string)|null $version The installed version, null result = unknown.
|
||||
* @param string|null $minVersion Lowest supported version (version_compare).
|
||||
* @param (Closure(): CompatibilityMatrix)|null $matrix Required for ReleasePerVersion, unused otherwise.
|
||||
* @throws LogicException For a ReleasePerVersion profile without matrix or a capability ID given twice.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $id,
|
||||
public readonly string $label,
|
||||
public readonly Tier $tier,
|
||||
public readonly array $capabilities,
|
||||
public readonly array $manifests = [],
|
||||
public readonly ?Closure $active = null,
|
||||
public readonly ?Closure $version = null,
|
||||
public readonly ?string $minVersion = null,
|
||||
public readonly ?Closure $matrix = null
|
||||
) {
|
||||
if ($tier === Tier::ReleasePerVersion && $matrix === null) {
|
||||
throw new LogicException(sprintf('profile %s: a ReleasePerVersion profile needs a matrix closure', $id));
|
||||
}
|
||||
|
||||
$ids = array_map(static fn (Capability $capability): string => $capability->id(), $capabilities);
|
||||
$duplicates = array_keys(array_filter(array_count_values($ids), static fn (int $n): bool => $n > 1));
|
||||
if ($duplicates !== []) {
|
||||
throw new LogicException(sprintf('profile %s: capability IDs twice: %s', $id, implode(', ', $duplicates)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Compatibility;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/** An integration that describes the third-party plugin it depends on for the compatibility layer. */
|
||||
interface ProvidesCompatibility
|
||||
{
|
||||
/** @return list<Profile> */
|
||||
public function compatibilities(): array;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Compatibility;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/** How strictly the compatibility layer treats a third-party plugin. */
|
||||
enum Tier: string
|
||||
{
|
||||
/** Problems are only reported; nothing is ever locked. */
|
||||
case ReportOnly = 'report';
|
||||
|
||||
/** A hard problem (or an inactive or too old plugin) locks the affected capabilities; otherwise they are free. */
|
||||
case LockOnFailure = 'lock';
|
||||
|
||||
/** Every plugin version needs a release; the profile brings its own matrix (Divi). */
|
||||
case ReleasePerVersion = 'release';
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Core;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Access;
|
||||
use LIT\MCP\Guides\GuideRepository;
|
||||
use LIT\MCP\Integrations\Integration;
|
||||
@@ -12,8 +13,11 @@ defined('ABSPATH') || exit;
|
||||
|
||||
final class CoreIntegration implements Integration
|
||||
{
|
||||
/** @param list<EnvironmentComponent> $components What lit-site-status reports besides Pods and Events Calendar. */
|
||||
public function __construct(private string $guidesDirectory, private array $components = [])
|
||||
/**
|
||||
* @param (Closure(): list<EnvironmentComponent>)|null $components What lit-site-status reports besides the Events
|
||||
* Calendar; asked when the status is built.
|
||||
*/
|
||||
public function __construct(private string $guidesDirectory, private ?Closure $components = null)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -4,16 +4,21 @@ declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Core;
|
||||
|
||||
use Closure;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Detects the themes/plugins LIT MCP integrates with: the components it is given (Divi, wired in Plugin) first, then
|
||||
* Pods and The Events Calendar.
|
||||
* Detects the themes/plugins LIT MCP integrates with: the components it is given (Divi, Pods; wired in Plugin) first,
|
||||
* then The Events Calendar.
|
||||
*/
|
||||
class EnvironmentProbe
|
||||
{
|
||||
/** @param list<EnvironmentComponent> $components Components of integrations, reported in this order. */
|
||||
public function __construct(private array $components = [])
|
||||
/**
|
||||
* @param (Closure(): list<EnvironmentComponent>)|null $components Components of integrations, reported in this
|
||||
* order; asked per call (the registry that knows them exists later).
|
||||
*/
|
||||
public function __construct(private ?Closure $components = null)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -26,18 +31,11 @@ class EnvironmentProbe
|
||||
$tecActive = class_exists('Tribe__Events__Main');
|
||||
$described = array_map(
|
||||
static fn (EnvironmentComponent $component): array => $component->describe(),
|
||||
$this->components
|
||||
$this->components === null ? [] : ($this->components)()
|
||||
);
|
||||
|
||||
return [
|
||||
...$described,
|
||||
[
|
||||
'id' => 'pods',
|
||||
'active' => defined('PODS_VERSION'),
|
||||
'version' => defined('PODS_VERSION') ? (string) PODS_VERSION : null,
|
||||
'problems' => [],
|
||||
'release' => null,
|
||||
],
|
||||
[
|
||||
'id' => 'the-events-calendar',
|
||||
'active' => $tecActive,
|
||||
|
||||
@@ -9,6 +9,7 @@ use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\ToolAvailability;
|
||||
use LIT\MCP\Admin\SettingsPage;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Compatibility\ProvidesCompatibility;
|
||||
use LIT\MCP\Integrations\Bootable;
|
||||
use LIT\MCP\Integrations\Divi\Admin\DiviTab;
|
||||
use LIT\MCP\Integrations\Divi\Cli\DiviCheckCommand;
|
||||
@@ -75,6 +76,7 @@ use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||||
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
|
||||
use LIT\MCP\Integrations\Divi\Release\FeatureOverview;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility;
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||
@@ -99,7 +101,7 @@ defined('ABSPATH') || exit;
|
||||
* DiviWriter; each binds the composition to its entry via DiviWriter::composition().
|
||||
* contentComposer() gives lit/content-create the Divi short form (spec §6.2). boot() registers Divi's own hooks.
|
||||
*/
|
||||
final class DiviIntegration implements Integration, Bootable
|
||||
final class DiviIntegration implements Integration, Bootable, ProvidesCompatibility
|
||||
{
|
||||
private DiviModules $modules;
|
||||
private DesignSystemReader $design;
|
||||
@@ -186,6 +188,11 @@ final class DiviIntegration implements Integration, Bootable
|
||||
return DiviModules::isAvailable();
|
||||
}
|
||||
|
||||
public function compatibilities(): array
|
||||
{
|
||||
return [DiviProfile::profile()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Divi's own post types (library, Theme Builder, canvases, speculation), which the content tools leave alone:
|
||||
* ContentSchema's excluded post types. Static because the schema is built before this integration (Plugin).
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace LIT\MCP\Integrations\Divi\Release;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\AbilityGuard;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Compatibility\ReleaseState;
|
||||
use WP_Error;
|
||||
@@ -19,8 +18,8 @@ defined('ABSPATH') || exit;
|
||||
* answers divi_incompatible / divi_untested with the affected capability instead of failing inside Divi. A refused
|
||||
* write is logged with result "refused" (as before); a refused read changes nothing and is not. A tool that declares
|
||||
* nothing (RequiresDiviFeatures, or DeclaresRequirements for lit/content-create without divi) is left alone; the
|
||||
* adapters still check each write themselves. A declared ID that is no Divi capability (FeatureSets::unknownIds())
|
||||
* locks the call as divi_incompatible with details.unknown – the lock fails closed.
|
||||
* adapters still check each write themselves. IDs that are no Divi capability are ignored here; CapabilityGate
|
||||
* checks them (and locks the unknown ones).
|
||||
*/
|
||||
final class DiviFeatureGate implements AbilityGuard
|
||||
{
|
||||
@@ -30,11 +29,6 @@ final class DiviFeatureGate implements AbilityGuard
|
||||
|
||||
public function refusal(Ability $ability, array $input): ?WP_Error
|
||||
{
|
||||
$unknown = FeatureSets::unknownIds($ability, $input);
|
||||
if ($unknown !== []) {
|
||||
return $this->refuseUnknown($ability, $unknown);
|
||||
}
|
||||
|
||||
$lock = ReleaseGate::lockOf(FeatureSets::declaredBy($ability, $input));
|
||||
if ($lock === null) {
|
||||
return null;
|
||||
@@ -53,34 +47,6 @@ final class DiviFeatureGate implements AbilityGuard
|
||||
return $lock->toError();
|
||||
}
|
||||
|
||||
/** @param non-empty-list<string> $unknown Declared IDs that are no Divi capability. */
|
||||
private function refuseUnknown(Ability $ability, array $unknown): WP_Error
|
||||
{
|
||||
$status = ReleaseGate::status();
|
||||
$ids = implode(', ', $unknown);
|
||||
if (! self::readsOnly($ability)) {
|
||||
$this->log?->record(
|
||||
$ability->name(),
|
||||
'divi',
|
||||
$status->version,
|
||||
sprintf('Abgelehnt: %s – unbekannte Fähigkeit %s.', $status->versionLabel(), $ids),
|
||||
result: 'refused'
|
||||
);
|
||||
}
|
||||
|
||||
return Errors::make(
|
||||
'divi_incompatible',
|
||||
sprintf(
|
||||
'%s: Das Werkzeug verlangt unbekannte Fähigkeiten (%s). Das ist gesperrt. Es wurde nichts geändert.',
|
||||
$status->versionLabel(),
|
||||
$ids
|
||||
),
|
||||
'Den Nutzer informieren, dass LIT MCP aktualisiert werden muss.',
|
||||
423,
|
||||
['unknown' => $unknown]
|
||||
);
|
||||
}
|
||||
|
||||
/** A refusal is the whole answer: there is no result to annotate (the lock of reading tools is gone, E2). */
|
||||
public function annotate(Ability $ability, mixed $result): mixed
|
||||
{
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Release;
|
||||
|
||||
use LIT\MCP\Compatibility\Profile;
|
||||
use LIT\MCP\Compatibility\Tier;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/** Divi as a compatibility profile: every version needs a release, the matrix comes from the release gate. */
|
||||
final class DiviProfile
|
||||
{
|
||||
public static function profile(): Profile
|
||||
{
|
||||
return new Profile(
|
||||
'divi',
|
||||
'Divi',
|
||||
Tier::ReleasePerVersion,
|
||||
DiviFeature::cases(),
|
||||
array_values(DiviAdapters::manifests()),
|
||||
version: DiviEnvironment::version(...),
|
||||
matrix: static fn () => ReleaseGate::status()->matrix
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,8 @@ final class FeatureSets
|
||||
{
|
||||
/**
|
||||
* The capabilities $ability declares for $input: its own diviFeatures() (the Divi tools) or the IDs a core tool
|
||||
* names through DeclaresRequirements (lit/content-create with divi). Anything else declares none. Every declared
|
||||
* ID is a Divi ID; one that is no DiviFeature is skipped here but locks the call (unknownIds(), fail closed).
|
||||
* names through DeclaresRequirements (lit/content-create with divi). Anything else declares none; an ID that is no
|
||||
* DiviFeature is ignored (CapabilityGate checks the IDs of other integrations and locks unknown ones).
|
||||
*
|
||||
* @param array<string, mixed> $input
|
||||
* @return list<DiviFeature>
|
||||
@@ -44,25 +44,6 @@ final class FeatureSets
|
||||
return self::merge($features);
|
||||
}
|
||||
|
||||
/**
|
||||
* The IDs a core tool declares for $input that are no DiviFeature. Such a call cannot be judged, so
|
||||
* DiviFeatureGate refuses it instead of letting it run unlocked.
|
||||
*
|
||||
* @param array<string, mixed> $input
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function unknownIds(Ability $ability, array $input): array
|
||||
{
|
||||
if ($ability instanceof RequiresDiviFeatures || ! $ability instanceof DeclaresRequirements) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$ability->requirements($input),
|
||||
static fn (string $id): bool => DiviFeature::tryFrom($id) === null
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<DiviFeature> ...$lists
|
||||
* @return list<DiviFeature> Every capability once, in the order of first appearance.
|
||||
|
||||
@@ -5,6 +5,10 @@ declare(strict_types=1);
|
||||
namespace LIT\MCP\Integrations;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Compatibility\Compatibility;
|
||||
use LIT\MCP\Compatibility\Profile;
|
||||
use LIT\MCP\Compatibility\ProvidesCompatibility;
|
||||
use LogicException;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
@@ -17,6 +21,7 @@ final class IntegrationRegistry
|
||||
private ?array $loaded = null;
|
||||
|
||||
private bool $booted = false;
|
||||
private ?Compatibility $compatibility = null;
|
||||
|
||||
public function __construct(Integration ...$integrations)
|
||||
{
|
||||
@@ -63,7 +68,8 @@ final class IntegrationRegistry
|
||||
}
|
||||
|
||||
/**
|
||||
* Abilities of the available integrations, grouped by integration id (registry order).
|
||||
* Abilities of the available integrations, grouped by integration id (registry order). An integration without
|
||||
* abilities (Pods) is no group.
|
||||
*
|
||||
* @return array<string, list<Ability>>
|
||||
*/
|
||||
@@ -71,6 +77,9 @@ final class IntegrationRegistry
|
||||
{
|
||||
$groups = [];
|
||||
foreach ($this->loaded() as [$integration, $own]) {
|
||||
if ($own === []) {
|
||||
continue;
|
||||
}
|
||||
$groups[$integration->id()] = array_merge($groups[$integration->id()] ?? [], $own);
|
||||
}
|
||||
|
||||
@@ -101,6 +110,29 @@ final class IntegrationRegistry
|
||||
return $labels;
|
||||
}
|
||||
|
||||
/** @return list<Profile> Profiles of every registered integration that provides some, available or not. */
|
||||
public function compatibilities(): array
|
||||
{
|
||||
$profiles = [];
|
||||
foreach ($this->integrations as $integration) {
|
||||
if ($integration instanceof ProvidesCompatibility) {
|
||||
array_push($profiles, ...$integration->compatibilities());
|
||||
}
|
||||
}
|
||||
|
||||
return $profiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* The compatibility state of all profiles, one shared instance (its matrices are memoised per request).
|
||||
*
|
||||
* @throws LogicException When two profiles or capabilities share an ID (a programming error, PluginTest).
|
||||
*/
|
||||
public function compatibility(): Compatibility
|
||||
{
|
||||
return $this->compatibility ??= new Compatibility($this->compatibilities());
|
||||
}
|
||||
|
||||
/** @return list<string> Ids of all known integrations, available or not. */
|
||||
public function ids(): array
|
||||
{
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Pods;
|
||||
|
||||
use LIT\MCP\Compatibility\Call;
|
||||
use LIT\MCP\Compatibility\ConstantRequirement;
|
||||
use LIT\MCP\Compatibility\Manifest;
|
||||
use LIT\MCP\Compatibility\Profile;
|
||||
use LIT\MCP\Compatibility\Tier;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Pods as a compatibility profile: the calls PodsFieldSource makes, checked against Pods 3.3.9.2. Argument counts
|
||||
* are those PodsFieldSource passes. A hard problem (missing function or constant, Pods below 2.8) locks the
|
||||
* affected capabilities; Pods has no per-version release.
|
||||
*/
|
||||
final class PodsCompatibility
|
||||
{
|
||||
public static function profile(): Profile
|
||||
{
|
||||
$all = PodsFeature::cases();
|
||||
|
||||
return new Profile(
|
||||
'pods',
|
||||
'Pods',
|
||||
Tier::LockOnFailure,
|
||||
$all,
|
||||
[new Manifest(
|
||||
'Pods',
|
||||
[
|
||||
new Call('pods_api', 0, [PodsFeature::SchemaRead]),
|
||||
new Call('PodsAPI::load_pod', 1, [PodsFeature::SchemaRead]),
|
||||
new Call('pods', 2, [PodsFeature::FieldRead, PodsFeature::FieldWrite]),
|
||||
new Call('Pods::field', 1, [PodsFeature::FieldRead]),
|
||||
new Call('Pods::save', 4, [PodsFeature::FieldWrite]),
|
||||
new Call('pods_has_permissions', 1, [PodsFeature::FieldPermissions]),
|
||||
new Call('pods_permission', 1, [PodsFeature::FieldPermissions]),
|
||||
],
|
||||
[new ConstantRequirement('PODS_VERSION', $all)]
|
||||
)],
|
||||
static fn (): bool => defined('PODS_VERSION'),
|
||||
static fn (): ?string => defined('PODS_VERSION') ? (string) PODS_VERSION : null,
|
||||
'2.8'
|
||||
);
|
||||
}
|
||||
}
|
||||
Generated
+41
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Pods;
|
||||
|
||||
use LIT\MCP\Compatibility\Capability;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
// The installed PHPCompatibility sniffs predate enums (PHP 8.1) and read their methods as plain functions.
|
||||
// phpcs:disable PHPCompatibility.Variables.ForbiddenThisUseContexts.OutsideObjectContext
|
||||
|
||||
/** What the plugin can do with Pods, as far as a Pods update may break it separately. */
|
||||
enum PodsFeature: string implements Capability
|
||||
{
|
||||
case SchemaRead = 'pods-schema-read';
|
||||
case FieldRead = 'pods-field-read';
|
||||
case FieldWrite = 'pods-field-write';
|
||||
case FieldPermissions = 'pods-field-permissions';
|
||||
|
||||
public function id(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::SchemaRead => 'Pods-Schema lesen',
|
||||
self::FieldRead => 'Pods-Felder lesen',
|
||||
self::FieldWrite => 'Pods-Felder schreiben',
|
||||
self::FieldPermissions => 'Pods-Feldrechte prüfen',
|
||||
};
|
||||
}
|
||||
|
||||
public function isWrite(): bool
|
||||
{
|
||||
return $this === self::FieldWrite;
|
||||
}
|
||||
}
|
||||
+36
-9
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Pods;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\FieldSource;
|
||||
use RuntimeException;
|
||||
@@ -21,6 +22,10 @@ defined('ABSPATH') || exit;
|
||||
* They are mapped like Pods' own view does: hidden → HIDDEN; read_only → READONLY; restricted
|
||||
* and not permitted for the current user → READONLY with read_only_restricted, else HIDDEN.
|
||||
* write() refuses every field that is not writable before anything reaches Pods.
|
||||
*
|
||||
* Every part is gated by a compatibility capability ($usable, from Compatibility::usable): the schema (SchemaRead,
|
||||
* else no fields), reading (FieldRead, else null), writing (FieldWrite and FieldRead, else write() throws and the
|
||||
* fields are READONLY) and the permission check (FieldPermissions, else no field counts as permitted).
|
||||
*/
|
||||
final class PodsFieldSource implements FieldSource
|
||||
{
|
||||
@@ -45,8 +50,13 @@ final class PodsFieldSource implements FieldSource
|
||||
/** @var callable(object): ?bool Pods field => null (unrestricted) or whether the current user may use it. */
|
||||
private $permitted;
|
||||
|
||||
public function __construct(?callable $loadPod = null, ?callable $podFor = null, ?callable $permitted = null)
|
||||
{
|
||||
/** @param Closure(PodsFeature): bool $usable Whether a Pods capability may be used (a locked one is never called). */
|
||||
public function __construct(
|
||||
private Closure $usable,
|
||||
?callable $loadPod = null,
|
||||
?callable $podFor = null,
|
||||
?callable $permitted = null
|
||||
) {
|
||||
$this->loadPod = $loadPod ?? static fn (string $name): mixed => pods_api()->load_pod(['name' => $name]);
|
||||
$this->podFor = $podFor ?? static fn (string $name, int $id): mixed => pods($name, $id);
|
||||
$this->permitted = $permitted ?? static function (object $field): ?bool {
|
||||
@@ -59,11 +69,6 @@ final class PodsFieldSource implements FieldSource
|
||||
};
|
||||
}
|
||||
|
||||
public static function isAvailable(): bool
|
||||
{
|
||||
return function_exists('pods_api') && function_exists('pods');
|
||||
}
|
||||
|
||||
public function id(): string
|
||||
{
|
||||
return 'pods';
|
||||
@@ -71,6 +76,10 @@ final class PodsFieldSource implements FieldSource
|
||||
|
||||
public function fieldsFor(string $postType): array
|
||||
{
|
||||
if (! ($this->usable)(PodsFeature::SchemaRead)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pod = ($this->loadPod)($postType);
|
||||
if (! is_object($pod) || $pod->get_type() !== 'post_type') {
|
||||
return [];
|
||||
@@ -91,6 +100,10 @@ final class PodsFieldSource implements FieldSource
|
||||
|
||||
public function read(int $postId, FieldDefinition $field): mixed
|
||||
{
|
||||
if (! ($this->usable)(PodsFeature::FieldRead)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pod = ($this->podFor)((string) get_post_type($postId), $postId);
|
||||
if (! is_object($pod)) {
|
||||
return null;
|
||||
@@ -110,6 +123,10 @@ final class PodsFieldSource implements FieldSource
|
||||
|
||||
public function write(int $postId, string $postType, array $values, array $definitions): void
|
||||
{
|
||||
if (! $this->canWrite()) {
|
||||
throw new RuntimeException('Pods fields cannot be written: Pods is locked by the compatibility check.');
|
||||
}
|
||||
|
||||
$data = [];
|
||||
foreach ($values as $key => $value) {
|
||||
$status = isset($definitions[$key]) ? $definitions[$key]->status : null;
|
||||
@@ -180,11 +197,21 @@ final class PodsFieldSource implements FieldSource
|
||||
return FieldDefinition::HIDDEN;
|
||||
}
|
||||
|
||||
if (($this->permitted)($field) === false) {
|
||||
// Fail closed: without the permission check no field counts as permitted.
|
||||
if (! ($this->usable)(PodsFeature::FieldPermissions) || ($this->permitted)($field) === false) {
|
||||
return self::flag($field, 'read_only_restricted') ? FieldDefinition::READONLY : FieldDefinition::HIDDEN;
|
||||
}
|
||||
|
||||
return self::flag($field, 'read_only') ? FieldDefinition::READONLY : $open;
|
||||
$status = self::flag($field, 'read_only') ? FieldDefinition::READONLY : $open;
|
||||
|
||||
// Pods writing is locked (it needs FieldWrite and FieldRead) → nothing is writable.
|
||||
return $status !== FieldDefinition::HIDDEN && ! $this->canWrite() ? FieldDefinition::READONLY : $status;
|
||||
}
|
||||
|
||||
/** A write snapshots the value first (read), so FieldRead is as necessary as FieldWrite. */
|
||||
private function canWrite(): bool
|
||||
{
|
||||
return ($this->usable)(PodsFeature::FieldWrite) && ($this->usable)(PodsFeature::FieldRead);
|
||||
}
|
||||
|
||||
/** A Pods boolean option (stored as 1/'1'/true, like pods_v_bool()). */
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Pods;
|
||||
|
||||
use LIT\MCP\Compatibility\ProvidesCompatibility;
|
||||
use LIT\MCP\Integrations\Integration;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Pods has no tools of its own (the content tools read its fields through PodsFieldSource); this integration only
|
||||
* brings the compatibility profile. Without abilities it is no tool group.
|
||||
*/
|
||||
final class PodsIntegration implements Integration, ProvidesCompatibility
|
||||
{
|
||||
public function id(): string
|
||||
{
|
||||
return 'pods';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Pods';
|
||||
}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return defined('PODS_VERSION');
|
||||
}
|
||||
|
||||
public function abilities(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function compatibilities(): array
|
||||
{
|
||||
return [PodsCompatibility::profile()];
|
||||
}
|
||||
}
|
||||
+67
-14
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP;
|
||||
|
||||
use LIT\MCP\Abilities\AbilityGuard;
|
||||
use LIT\MCP\Abilities\AbilityRegistrar;
|
||||
use LIT\MCP\Abilities\ExposurePolicy;
|
||||
use LIT\MCP\Abilities\ToolAvailability;
|
||||
@@ -21,14 +22,21 @@ use LIT\MCP\Admin\ToolsTab;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\ChangeLog\ChangeLogCleanup;
|
||||
use LIT\MCP\ChangeLog\ChangeLogInstaller;
|
||||
use LIT\MCP\Compatibility\CapabilityGate;
|
||||
use LIT\MCP\Compatibility\Compatibility;
|
||||
use LIT\MCP\Compatibility\CompatibilityComponent;
|
||||
use LIT\MCP\Guides\InstructionsBuilder;
|
||||
use LIT\MCP\Integrations\Core\CoreIntegration;
|
||||
use LIT\MCP\Integrations\Divi\DiviIntegration;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviFeatureGate;
|
||||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||
use LIT\MCP\Integrations\Media\MediaIntegration;
|
||||
use LIT\MCP\Integrations\Pods\PodsCompatibility;
|
||||
use LIT\MCP\Integrations\Pods\PodsFeature;
|
||||
use LIT\MCP\Integrations\Pods\PodsFieldSource;
|
||||
use LIT\MCP\Integrations\Pods\PodsIntegration;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\FieldSource;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\SampledMetaSource;
|
||||
use LIT\MCP\Integrations\WordPress\WordPressIntegration;
|
||||
@@ -87,11 +95,7 @@ final class Plugin
|
||||
// page reads its tabs.
|
||||
$registry->boot();
|
||||
|
||||
(new AbilityRegistrar(
|
||||
$registry,
|
||||
$policy,
|
||||
[new DiviFeatureGate($changeLog)]
|
||||
))->register();
|
||||
(new AbilityRegistrar($registry, $policy, self::guards($registry, $changeLog)))->register();
|
||||
(new ServerRegistrar(
|
||||
$registry,
|
||||
new InstructionsBuilder($registry, static fn (): array => $schema->postTypeNames(), $policy),
|
||||
@@ -125,6 +129,25 @@ final class Plugin
|
||||
\WPMedia\MCP\OAuth\Bootstrap::instance();
|
||||
}
|
||||
|
||||
/**
|
||||
* The guards of every tool call. DiviFeatureGate judges the Divi IDs; CapabilityGate the IDs of all other profiles
|
||||
* and locks the unknown ones – without it a tool with an unknown ID would run unlocked.
|
||||
*
|
||||
* @return list<AbilityGuard>
|
||||
* @internal Public for PluginTest only.
|
||||
*/
|
||||
public static function guards(IntegrationRegistry $registry, ChangeLog $changeLog): array
|
||||
{
|
||||
return [
|
||||
new DiviFeatureGate($changeLog),
|
||||
new CapabilityGate(
|
||||
$registry->compatibility(),
|
||||
$changeLog,
|
||||
['divi'] // checked by DiviFeatureGate
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The integrations in registry order – the order of the Tools tab, the guide and the server instructions. The
|
||||
* Divi integration and its groups share $tools, which Plugin binds once registry and policy exist.
|
||||
@@ -145,8 +168,20 @@ final class Plugin
|
||||
};
|
||||
$divi = new DiviIntegration($schema, $changeLog, $tools, $allAbilities);
|
||||
|
||||
// lit-site-status: Divi first (its own component), then every other profile in registry order.
|
||||
$components = static function () use (&$registry, $divi): array {
|
||||
if (! $registry instanceof IntegrationRegistry) {
|
||||
return [$divi->environmentComponent()];
|
||||
}
|
||||
|
||||
return [
|
||||
$divi->environmentComponent(),
|
||||
...CompatibilityComponent::forProfiles($registry->compatibility()),
|
||||
];
|
||||
};
|
||||
|
||||
$registry = new IntegrationRegistry(
|
||||
new CoreIntegration(self::pluginDir() . '/docs/guides', [$divi->environmentComponent()]),
|
||||
new CoreIntegration(self::pluginDir() . '/docs/guides', $components),
|
||||
new WordPressIntegration(
|
||||
$schema,
|
||||
$changeLog,
|
||||
@@ -157,7 +192,8 @@ final class Plugin
|
||||
new MediaIntegration($schema, $changeLog, $limits, $divi->mediaUsageSources()),
|
||||
$divi,
|
||||
$divi->library(),
|
||||
$divi->themeBuilder()
|
||||
$divi->themeBuilder(),
|
||||
new PodsIntegration()
|
||||
);
|
||||
|
||||
return $registry;
|
||||
@@ -192,13 +228,12 @@ final class Plugin
|
||||
|
||||
private static function contentSchema(): ContentSchema
|
||||
{
|
||||
// Pods active but unusable: its meta keys (sampled, or registered via Pods' register_meta integration) must not
|
||||
// be written past Pods.
|
||||
$podsUnusable = defined('PODS_VERSION') && ! PodsFieldSource::isAvailable();
|
||||
$sources = [new RegisteredMetaSource(readOnly: $podsUnusable), new SampledMetaSource(readOnly: $podsUnusable)];
|
||||
if (PodsFieldSource::isAvailable()) {
|
||||
array_unshift($sources, new PodsFieldSource());
|
||||
}
|
||||
// The sources exist before the registry, so Pods gets its own Compatibility here – a deliberate, cheap
|
||||
// duplicate of the registry's (a few function_exists checks, memoised per request).
|
||||
$sources = self::schemaSources(
|
||||
new Compatibility([PodsCompatibility::profile()]),
|
||||
defined('PODS_VERSION')
|
||||
);
|
||||
|
||||
// Divi's post types and builder check come in statically: the schema exists before the Divi integration.
|
||||
return new ContentSchema(
|
||||
@@ -207,4 +242,22 @@ final class Plugin
|
||||
DiviIntegration::internalPostTypes()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The field sources, highest priority first. Pods active but its schema locked: PodsFieldSource yields no fields
|
||||
* and the meta sources report the keys read-only, so they cannot be written past Pods.
|
||||
*
|
||||
* @return list<FieldSource>
|
||||
* @internal Public for PluginTest only.
|
||||
*/
|
||||
public static function schemaSources(Compatibility $pods, bool $podsActive): array
|
||||
{
|
||||
$readOnly = $podsActive && ! $pods->usable(PodsFeature::SchemaRead);
|
||||
$sources = [new RegisteredMetaSource(readOnly: $readOnly), new SampledMetaSource(readOnly: $readOnly)];
|
||||
if ($podsActive) {
|
||||
array_unshift($sources, new PodsFieldSource($pods->usable(...)));
|
||||
}
|
||||
|
||||
return $sources;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Support;
|
||||
|
||||
use LIT\MCP\Compatibility\Capability;
|
||||
|
||||
// The installed PHPCompatibility sniffs predate enums (PHP 8.1) and read their methods as plain functions.
|
||||
// phpcs:disable PHPCompatibility.Variables.ForbiddenThisUseContexts.OutsideObjectContext
|
||||
|
||||
/** Capabilities of an invented plugin, for the tests of the compatibility core (which knows no real one). */
|
||||
enum FakeCapability: string implements Capability
|
||||
{
|
||||
case Read = 'fake-read';
|
||||
case Write = 'fake-write';
|
||||
case Other = 'fake-other';
|
||||
|
||||
public function id(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Fake ' . $this->value;
|
||||
}
|
||||
|
||||
public function isWrite(): bool
|
||||
{
|
||||
return $this === self::Write;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Abilities;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\AbilityRegistrar;
|
||||
use LIT\MCP\Abilities\DeclaresRequirements;
|
||||
use LIT\MCP\Compatibility\CapabilityGate;
|
||||
use LIT\MCP\Compatibility\Compatibility;
|
||||
use LIT\MCP\Compatibility\Profile;
|
||||
use LIT\MCP\Compatibility\Tier;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviFeatureGate;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
|
||||
use LIT\MCP\Integrations\Integration;
|
||||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||
use LIT\MCP\Integrations\Pods\PodsCompatibility;
|
||||
use LIT\MCP\Server\RequestContext;
|
||||
use LIT\MCP\Tests\Support\DiviFeatureFixtures;
|
||||
use LIT\MCP\Tests\Support\FakeCapability;
|
||||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* The two guards Plugin hands to AbilityRegistrar, [DiviFeatureGate, CapabilityGate], through the real
|
||||
* permission callback: each ID is judged by exactly one of them and none passes both unjudged.
|
||||
*/
|
||||
final class CompatibilityGatesTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
RequestContext::reset(true);
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
RequestContext::reset();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/** @param list<string> $ids */
|
||||
private function permission(array $ids): bool|WP_Error
|
||||
{
|
||||
$ability = Mockery::mock(Ability::class, DeclaresRequirements::class);
|
||||
$ability->allows('name')->andReturn('lit/test');
|
||||
$ability->allows('label')->andReturn('Test');
|
||||
$ability->allows('description')->andReturn('Test');
|
||||
$ability->allows('inputSchema')->andReturn(['type' => 'object', 'properties' => []]);
|
||||
$ability->allows('outputSchema')->andReturn(null);
|
||||
$ability->allows('annotations')->andReturn(['readonly' => true]);
|
||||
$ability->allows('isDirect')->andReturn(true);
|
||||
$ability->allows('requirements')->andReturn($ids);
|
||||
$ability->allows('permission')->andReturn(true);
|
||||
|
||||
$free = new Profile('fake', 'Fake Plugin', Tier::LockOnFailure, [FakeCapability::Read]);
|
||||
$compat = new Compatibility([DiviProfile::profile(), PodsCompatibility::profile(), $free]);
|
||||
$integration = Mockery::mock(Integration::class);
|
||||
$integration->allows('id')->andReturn('core');
|
||||
$integration->allows('isAvailable')->andReturn(true);
|
||||
$integration->allows('abilities')->andReturn([$ability]);
|
||||
$registrar = new AbilityRegistrar(
|
||||
new IntegrationRegistry($integration),
|
||||
null,
|
||||
[new DiviFeatureGate(FakeChangeLog::forbidden()), new CapabilityGate($compat, FakeChangeLog::forbidden())]
|
||||
);
|
||||
|
||||
return ($registrar->argsFor($ability)['permission_callback'])([]);
|
||||
}
|
||||
|
||||
public function testAFreeNonDiviIdPassesTheDiviGateAndTheCapabilityGate(): void
|
||||
{
|
||||
self::assertTrue($this->permission(['fake-read']));
|
||||
}
|
||||
|
||||
public function testALockedPodsIdPassesTheDiviGateAndIsRefusedByTheCapabilityGate(): void
|
||||
{
|
||||
DiviFeatureFixtures::useStates([]);
|
||||
|
||||
$error = $this->permission(['pods-field-read']);
|
||||
|
||||
self::assertToolError($error, 'compat_incompatible');
|
||||
}
|
||||
|
||||
public function testAnUnknownIdIsRefusedByTheCapabilityGate(): void
|
||||
{
|
||||
DiviFeatureFixtures::useStates([]);
|
||||
|
||||
$payload = self::assertToolError($this->permission(['page-write', 'nobody-knows-me']), 'compat_incompatible');
|
||||
|
||||
self::assertSame(['nobody-knows-me'], $payload['unknown']);
|
||||
}
|
||||
|
||||
public function testAReleasedDiviIdPassesBothGates(): void
|
||||
{
|
||||
DiviFeatureFixtures::useStates([]);
|
||||
|
||||
self::assertTrue($this->permission(['page-write']));
|
||||
}
|
||||
|
||||
public function testALockedDiviIdIsRefusedByTheDiviGateAloneNotTwice(): void
|
||||
{
|
||||
DiviFeatureFixtures::lock(DiviFeature::PageWrite);
|
||||
|
||||
self::assertToolError($this->permission(['page-write']), 'divi_untested');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Compatibility;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\DeclaresRequirements;
|
||||
use LIT\MCP\Compatibility\Call;
|
||||
use LIT\MCP\Compatibility\CapabilityGate;
|
||||
use LIT\MCP\Compatibility\Compatibility;
|
||||
use LIT\MCP\Compatibility\CompatibilityMatrix;
|
||||
use LIT\MCP\Compatibility\Manifest;
|
||||
use LIT\MCP\Compatibility\Profile;
|
||||
use LIT\MCP\Compatibility\Tier;
|
||||
use LIT\MCP\Tests\Support\FakeCapability;
|
||||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
|
||||
final class CapabilityGateTest extends TestCase
|
||||
{
|
||||
/** @param list<string>|null $ids null = the ability does not implement DeclaresRequirements. */
|
||||
private function tool(bool $readonly, ?array $ids): Ability
|
||||
{
|
||||
$ability = $ids === null
|
||||
? Mockery::mock(Ability::class)
|
||||
: Mockery::mock(Ability::class, DeclaresRequirements::class);
|
||||
$ability->allows('name')->andReturn('lit/fake');
|
||||
$ability->allows('annotations')->andReturn(['readonly' => $readonly]);
|
||||
if ($ids !== null) {
|
||||
$ability->allows('requirements')->andReturn($ids);
|
||||
}
|
||||
|
||||
return $ability;
|
||||
}
|
||||
|
||||
/** fake-write is locked (missing call), fake-read free, fake-other belongs to a release-per-version profile. */
|
||||
/** @param list<string> $delegated */
|
||||
private function gate(?FakeChangeLog $log = null, array $delegated = []): CapabilityGate
|
||||
{
|
||||
$lock = new Profile(
|
||||
'fake',
|
||||
'Fake Plugin',
|
||||
Tier::LockOnFailure,
|
||||
[FakeCapability::Read, FakeCapability::Write],
|
||||
[new Manifest('Fake', calls: [new Call('lit_mcp_missing_function', 0, [FakeCapability::Write])])]
|
||||
);
|
||||
$release = new Profile(
|
||||
'release',
|
||||
'Release',
|
||||
Tier::ReleasePerVersion,
|
||||
[FakeCapability::Other],
|
||||
matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allIncompatible(
|
||||
[FakeCapability::Other],
|
||||
'would lock'
|
||||
)
|
||||
);
|
||||
|
||||
return new CapabilityGate(new Compatibility([$lock, $release]), $log, $delegated);
|
||||
}
|
||||
|
||||
public function testAFreeCapabilityPassesAndAWriteIsNotLogged(): void
|
||||
{
|
||||
self::assertNull($this->gate(FakeChangeLog::forbidden())->refusal($this->tool(false, ['fake-read']), []));
|
||||
}
|
||||
|
||||
public function testALockedCapabilityIsRefusedWith423(): void
|
||||
{
|
||||
$error = $this->gate(FakeChangeLog::forbidden())->refusal($this->tool(true, ['fake-read', 'fake-write']), []);
|
||||
|
||||
$payload = self::assertToolError($error, 'compat_incompatible');
|
||||
self::assertSame(['fake-write'], array_column($payload['features'], 'id'));
|
||||
}
|
||||
|
||||
public function testTheIdOfADelegatedProfileIsLeftToItsOwnGate(): void
|
||||
{
|
||||
$gate = $this->gate(FakeChangeLog::forbidden(), ['release']);
|
||||
|
||||
self::assertNull($gate->refusal($this->tool(false, ['fake-other']), []));
|
||||
}
|
||||
|
||||
public function testAReleasePerVersionProfileWithoutDelegationIsLockedByItsMatrix(): void
|
||||
{
|
||||
$payload = self::assertToolError(
|
||||
$this->gate(FakeChangeLog::forbidden())->refusal($this->tool(true, ['fake-other']), []),
|
||||
'compat_incompatible'
|
||||
);
|
||||
|
||||
self::assertSame(['fake-other'], array_column($payload['features'], 'id'));
|
||||
}
|
||||
|
||||
public function testAnUnknownIdLocksTheCall(): void
|
||||
{
|
||||
$payload = self::assertToolError(
|
||||
$this->gate(FakeChangeLog::forbidden())->refusal($this->tool(true, ['fake-read', 'fake-nope']), []),
|
||||
'compat_incompatible'
|
||||
);
|
||||
|
||||
self::assertSame(['fake-nope'], $payload['unknown']);
|
||||
}
|
||||
|
||||
public function testAnUnknownIdOfAWriteIsRefusedWith423AndLogged(): void
|
||||
{
|
||||
$log = new FakeChangeLog();
|
||||
|
||||
$error = $this->gate($log)->refusal($this->tool(false, ['fake-read', 'something-else']), []);
|
||||
|
||||
self::assertSame(423, $error->get_error_data()['status'] ?? null);
|
||||
$log->assertRecorded(
|
||||
['ability' => 'lit/fake', 'object_type' => 'compat', 'object_id' => 'something-else', 'result' => 'refused']
|
||||
);
|
||||
self::assertStringContainsString('unbekannte Fähigkeit something-else', $log->summaries()[0]);
|
||||
}
|
||||
|
||||
public function testARefusedWriteIsLoggedWithTheProfileId(): void
|
||||
{
|
||||
$log = new FakeChangeLog();
|
||||
|
||||
$this->gate($log)->refusal($this->tool(false, ['fake-write']), []);
|
||||
|
||||
$log->assertRecorded(
|
||||
['ability' => 'lit/fake', 'object_type' => 'compat', 'object_id' => 'fake', 'result' => 'refused']
|
||||
);
|
||||
self::assertStringContainsString('Fake Plugin', $log->summaries()[0]);
|
||||
}
|
||||
|
||||
public function testARefusedWriteWithAnUnknownIdIsLogged(): void
|
||||
{
|
||||
$log = new FakeChangeLog();
|
||||
|
||||
$this->gate($log)->refusal($this->tool(false, ['fake-nope']), []);
|
||||
|
||||
$log->assertRecorded(['object_type' => 'compat', 'object_id' => 'fake-nope', 'result' => 'refused']);
|
||||
}
|
||||
|
||||
public function testAnAbilityWithoutRequirementsIsUntouched(): void
|
||||
{
|
||||
$gate = $this->gate(FakeChangeLog::forbidden());
|
||||
$tool = $this->tool(false, null);
|
||||
|
||||
self::assertNull($gate->refusal($tool, []));
|
||||
self::assertSame(['ok' => true], $gate->annotate($tool, ['ok' => true]));
|
||||
self::assertSame(['type' => 'object'], $gate->outputSchema($tool, ['type' => 'object']));
|
||||
self::assertNull($gate->outputSchema($tool, null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Compatibility;
|
||||
|
||||
use LIT\MCP\Abilities\ToolRefusal;
|
||||
use LIT\MCP\Compatibility\CapabilityLocked;
|
||||
use LIT\MCP\Compatibility\FeatureStatus;
|
||||
use LIT\MCP\Compatibility\Profile;
|
||||
use LIT\MCP\Compatibility\ReleaseState;
|
||||
use LIT\MCP\Compatibility\Tier;
|
||||
use LIT\MCP\Tests\Support\FakeCapability;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use LogicException;
|
||||
|
||||
final class CapabilityLockedTest extends TestCase
|
||||
{
|
||||
public function testTheErrorNamesPluginCapabilitiesAndProblems(): void
|
||||
{
|
||||
$profile = new Profile('fake', 'Fake Plugin', Tier::LockOnFailure, [FakeCapability::Write]);
|
||||
$locked = CapabilityLocked::of(
|
||||
[new FeatureStatus(FakeCapability::Write, ReleaseState::Incompatible, ['Fake: x() fehlt'])],
|
||||
[$profile]
|
||||
);
|
||||
|
||||
self::assertInstanceOf(ToolRefusal::class, $locked);
|
||||
self::assertSame('Fake Plugin locked – fake-write refused', $locked->getMessage());
|
||||
$error = $locked->toError();
|
||||
$payload = self::assertToolError($error, 'compat_incompatible');
|
||||
self::assertSame(423, $error->get_error_data()['status']);
|
||||
self::assertStringContainsString('Fake Plugin', $payload['message']);
|
||||
self::assertStringContainsString('Fake fake-write', $payload['message']);
|
||||
self::assertNotSame('', $payload['hint']);
|
||||
self::assertSame(['fake-write'], array_column($payload['features'], 'id'));
|
||||
self::assertSame(['Fake: x() fehlt'], $payload['problems']);
|
||||
}
|
||||
|
||||
public function testALockWithoutLockedCapabilitiesIsAProgrammingError(): void
|
||||
{
|
||||
$this->expectException(LogicException::class);
|
||||
|
||||
CapabilityLocked::of([], []);
|
||||
}
|
||||
|
||||
public function testUnknownIdsLockTheCall(): void
|
||||
{
|
||||
$locked = CapabilityLocked::unknown(['fake-x', 'fake-y']);
|
||||
|
||||
$error = $locked->toError();
|
||||
$payload = self::assertToolError($error, 'compat_incompatible');
|
||||
self::assertSame(423, $error->get_error_data()['status']);
|
||||
self::assertSame(['fake-x', 'fake-y'], $payload['unknown']);
|
||||
self::assertStringContainsString('fake-x, fake-y', $payload['message']);
|
||||
self::assertSame(['fake-x', 'fake-y'], $locked->unknown);
|
||||
}
|
||||
|
||||
public function testUnknownNeedsAtLeastOneId(): void
|
||||
{
|
||||
$this->expectException(LogicException::class);
|
||||
|
||||
CapabilityLocked::unknown([]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Compatibility;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Compatibility\Call;
|
||||
use LIT\MCP\Compatibility\Compatibility;
|
||||
use LIT\MCP\Compatibility\CompatibilityComponent;
|
||||
use LIT\MCP\Compatibility\CompatibilityMatrix;
|
||||
use LIT\MCP\Compatibility\Manifest;
|
||||
use LIT\MCP\Compatibility\Profile;
|
||||
use LIT\MCP\Compatibility\Tier;
|
||||
use LIT\MCP\Integrations\Core\EnvironmentComponent;
|
||||
use LIT\MCP\Tests\Support\FakeCapability;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use LogicException;
|
||||
|
||||
final class CompatibilityComponentTest extends TestCase
|
||||
{
|
||||
private function profile(bool $active, bool $broken = false): Profile
|
||||
{
|
||||
return new Profile(
|
||||
'fake',
|
||||
'Fake Plugin',
|
||||
Tier::LockOnFailure,
|
||||
[FakeCapability::Read, FakeCapability::Write],
|
||||
[new Manifest(
|
||||
'Fake',
|
||||
calls: $broken ? [new Call('lit_mcp_missing_function', 0, [FakeCapability::Write])] : []
|
||||
)],
|
||||
static fn (): bool => $active,
|
||||
static fn (): string => '4.2.0'
|
||||
);
|
||||
}
|
||||
|
||||
private function component(Profile $profile): EnvironmentComponent
|
||||
{
|
||||
return new CompatibilityComponent($profile, static fn (): Compatibility => new Compatibility([$profile]));
|
||||
}
|
||||
|
||||
public function testAnInactivePluginIsReportedAsNotInstalledWithoutProblems(): void
|
||||
{
|
||||
self::assertSame(
|
||||
['id' => 'fake', 'active' => false, 'version' => null, 'problems' => [], 'release' => null],
|
||||
$this->component($this->profile(false))->describe()
|
||||
);
|
||||
}
|
||||
|
||||
public function testAnActiveWorkingPluginHasItsVersionAndNoProblems(): void
|
||||
{
|
||||
self::assertSame(
|
||||
['id' => 'fake', 'active' => true, 'version' => '4.2.0', 'problems' => [], 'release' => null],
|
||||
$this->component($this->profile(true))->describe()
|
||||
);
|
||||
}
|
||||
|
||||
public function testAnActiveButBrokenPluginListsItsProblems(): void
|
||||
{
|
||||
$described = $this->component($this->profile(true, broken: true))->describe();
|
||||
|
||||
self::assertTrue($described['active']);
|
||||
self::assertSame('4.2.0', $described['version']);
|
||||
self::assertNotSame([], $described['problems']);
|
||||
self::assertContainsOnly('string', $described['problems']);
|
||||
self::assertNull($described['release']);
|
||||
}
|
||||
|
||||
public function testTheCompatibilityIsAskedOnlyWhenDescribing(): void
|
||||
{
|
||||
$asked = 0;
|
||||
$profile = $this->profile(true);
|
||||
$component = new CompatibilityComponent($profile, static function () use (&$asked, $profile): Compatibility {
|
||||
$asked++;
|
||||
|
||||
return new Compatibility([$profile]);
|
||||
});
|
||||
|
||||
self::assertSame(0, $asked);
|
||||
$component->describe();
|
||||
self::assertSame(1, $asked);
|
||||
}
|
||||
|
||||
public function testAReleasePerVersionProfileHasItsOwnComponent(): void
|
||||
{
|
||||
$profile = new Profile(
|
||||
'rel',
|
||||
'Rel',
|
||||
Tier::ReleasePerVersion,
|
||||
[FakeCapability::Other],
|
||||
matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allReleased([FakeCapability::Other])
|
||||
);
|
||||
|
||||
$this->expectException(LogicException::class);
|
||||
|
||||
new CompatibilityComponent($profile, static fn (): Compatibility => new Compatibility([$profile]));
|
||||
}
|
||||
|
||||
public function testForProfilesBuildsAComponentPerProfileWithoutReleasePerVersion(): void
|
||||
{
|
||||
$plain = $this->profile(true);
|
||||
$release = new Profile(
|
||||
'rel',
|
||||
'Rel',
|
||||
Tier::ReleasePerVersion,
|
||||
[FakeCapability::Other],
|
||||
matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allReleased([FakeCapability::Other])
|
||||
);
|
||||
|
||||
$components = CompatibilityComponent::forProfiles(new Compatibility([$release, $plain]));
|
||||
|
||||
self::assertSame(['fake'], array_map(static fn (CompatibilityComponent $c): string => $c->describe()['id'], $components));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Compatibility;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Compatibility\Call;
|
||||
use LIT\MCP\Compatibility\Compatibility;
|
||||
use LIT\MCP\Compatibility\CompatibilityMatrix;
|
||||
use LIT\MCP\Compatibility\Manifest;
|
||||
use LIT\MCP\Compatibility\ManifestCheck;
|
||||
use LIT\MCP\Compatibility\Profile;
|
||||
use LIT\MCP\Compatibility\ReleaseState;
|
||||
use LIT\MCP\Compatibility\Tier;
|
||||
use LIT\MCP\Tests\Support\FakeCapability;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use LogicException;
|
||||
use RuntimeException;
|
||||
|
||||
final class CompatibilityTest extends TestCase
|
||||
{
|
||||
private const CAPS = [FakeCapability::Read, FakeCapability::Write];
|
||||
|
||||
/** A manifest whose only call is missing and belongs to fake-write: a hard problem for that capability. */
|
||||
private static function brokenManifest(): Manifest
|
||||
{
|
||||
return new Manifest('Fake', calls: [new Call('lit_mcp_missing_function', 0, [FakeCapability::Write])]);
|
||||
}
|
||||
|
||||
private static function lockProfile(
|
||||
bool $broken = false,
|
||||
?Closure $active = null,
|
||||
?Closure $version = null,
|
||||
?string $minVersion = null,
|
||||
Tier $tier = Tier::LockOnFailure
|
||||
): Profile {
|
||||
return new Profile(
|
||||
'fake',
|
||||
'Fake Plugin',
|
||||
$tier,
|
||||
self::CAPS,
|
||||
$broken ? [self::brokenManifest()] : [],
|
||||
$active,
|
||||
$version,
|
||||
$minVersion
|
||||
);
|
||||
}
|
||||
|
||||
public function testReportOnlyNeverLocksNotEvenWhenInactiveOrBroken(): void
|
||||
{
|
||||
$profile = new Profile(
|
||||
'fake',
|
||||
'Fake',
|
||||
Tier::ReportOnly,
|
||||
self::CAPS,
|
||||
[self::brokenManifest()],
|
||||
static fn (): bool => false,
|
||||
minVersion: '9'
|
||||
);
|
||||
$compat = new Compatibility([$profile]);
|
||||
|
||||
self::assertSame(ReleaseState::Released, $compat->matrix($profile)->state());
|
||||
self::assertNull($compat->lockOf(self::CAPS));
|
||||
self::assertFalse($compat->active($profile));
|
||||
self::assertSame(['Fake: lit_mcp_missing_function()'], $compat->problems($profile));
|
||||
}
|
||||
|
||||
public function testLockOnFailureLocksOnlyWhatAHardProblemConcerns(): void
|
||||
{
|
||||
$compat = new Compatibility([self::lockProfile(broken: true)]);
|
||||
|
||||
self::assertTrue($compat->usable(FakeCapability::Read));
|
||||
self::assertFalse($compat->usable(FakeCapability::Write));
|
||||
self::assertSame([FakeCapability::Write], array_map(
|
||||
static fn ($status) => $status->feature,
|
||||
$compat->lockOf(self::CAPS)?->locked ?? []
|
||||
));
|
||||
}
|
||||
|
||||
public function testLockOnFailureWithoutProblemsIsFree(): void
|
||||
{
|
||||
$compat = new Compatibility([self::lockProfile()]);
|
||||
|
||||
self::assertNull($compat->lockOf(self::CAPS));
|
||||
self::assertSame([], $compat->problems($compat->profile('fake')));
|
||||
}
|
||||
|
||||
public function testAnInactivePluginLocksEverythingWithLockOnFailure(): void
|
||||
{
|
||||
$profile = self::lockProfile(active: static fn (): bool => false);
|
||||
$compat = new Compatibility([$profile]);
|
||||
|
||||
self::assertFalse($compat->active($profile));
|
||||
self::assertFalse($compat->usable(FakeCapability::Read));
|
||||
self::assertSame(['Fake Plugin ist nicht installiert oder nicht aktiv.'], $compat->problems($profile));
|
||||
}
|
||||
|
||||
public function testAVersionBelowTheMinimumLocksEverything(): void
|
||||
{
|
||||
$profile = self::lockProfile(version: static fn (): string => '2.7.9', minVersion: '2.8');
|
||||
$compat = new Compatibility([$profile]);
|
||||
|
||||
self::assertSame('2.7.9', $compat->version($profile));
|
||||
self::assertFalse($compat->usable(FakeCapability::Read));
|
||||
self::assertFalse($compat->usable(FakeCapability::Write));
|
||||
self::assertSame(
|
||||
['Fake Plugin 2.7.9 ist älter als die Mindestversion 2.8.'],
|
||||
$compat->problems($profile)
|
||||
);
|
||||
}
|
||||
|
||||
public function testTheMinimumVersionItselfIsFine(): void
|
||||
{
|
||||
$profile = self::lockProfile(version: static fn (): string => '2.8', minVersion: '2.8');
|
||||
|
||||
self::assertTrue((new Compatibility([$profile]))->usable(FakeCapability::Read));
|
||||
}
|
||||
|
||||
public function testAnUnknownVersionWithAMinimumLocksFailClosed(): void
|
||||
{
|
||||
$profile = self::lockProfile(version: static fn (): ?string => null, minVersion: '2.8');
|
||||
|
||||
self::assertFalse((new Compatibility([$profile]))->usable(FakeCapability::Read));
|
||||
}
|
||||
|
||||
public function testAThrowingActiveOrVersionClosureCountsAsInactiveAndUnknown(): void
|
||||
{
|
||||
$boom = static function (): never {
|
||||
throw new RuntimeException('boom');
|
||||
};
|
||||
$profile = self::lockProfile(active: $boom, version: $boom);
|
||||
$compat = new Compatibility([$profile]);
|
||||
|
||||
self::assertFalse($compat->active($profile));
|
||||
self::assertNull($compat->version($profile));
|
||||
self::assertFalse($compat->usable(FakeCapability::Read));
|
||||
}
|
||||
|
||||
public function testAThrowingCheckLocksEverythingFailClosed(): void
|
||||
{
|
||||
$profile = self::lockProfile();
|
||||
$compat = new Compatibility([$profile], static function (): never {
|
||||
throw new RuntimeException('boom');
|
||||
});
|
||||
|
||||
self::assertFalse($compat->usable(FakeCapability::Read));
|
||||
self::assertFalse($compat->usable(FakeCapability::Write));
|
||||
self::assertSame(
|
||||
['Die Kompatibilitätsprüfung von Fake Plugin ist fehlgeschlagen.'],
|
||||
$compat->problems($profile)
|
||||
);
|
||||
}
|
||||
|
||||
public function testReleasePerVersionTakesItsMatrixFromTheClosure(): void
|
||||
{
|
||||
$profile = new Profile(
|
||||
'fake',
|
||||
'Fake',
|
||||
Tier::ReleasePerVersion,
|
||||
self::CAPS,
|
||||
matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allIncompatible(self::CAPS, 'gesperrt')
|
||||
);
|
||||
$compat = new Compatibility([$profile]);
|
||||
|
||||
self::assertFalse($compat->usable(FakeCapability::Read));
|
||||
self::assertSame(['gesperrt'], $compat->problems($profile));
|
||||
}
|
||||
|
||||
public function testMatrixAndCheckAreComputedOncePerProfile(): void
|
||||
{
|
||||
$checks = 0;
|
||||
$matrices = 0;
|
||||
$lock = self::lockProfile();
|
||||
$release = new Profile(
|
||||
'release',
|
||||
'Release',
|
||||
Tier::ReleasePerVersion,
|
||||
[FakeCapability::Other],
|
||||
matrix: static function () use (&$matrices): CompatibilityMatrix {
|
||||
$matrices++;
|
||||
|
||||
return CompatibilityMatrix::allReleased([FakeCapability::Other]);
|
||||
}
|
||||
);
|
||||
$count = static function (Profile $profile) use (&$checks): ManifestCheck {
|
||||
$checks++;
|
||||
|
||||
return new ManifestCheck($profile->manifests, signatures: false);
|
||||
};
|
||||
$compat = new Compatibility([$lock, $release], $count);
|
||||
|
||||
foreach ([1, 2, 3] as $ignored) {
|
||||
$compat->usable(FakeCapability::Read);
|
||||
$compat->usable(FakeCapability::Other);
|
||||
$compat->problems($lock);
|
||||
}
|
||||
|
||||
self::assertSame(1, $checks);
|
||||
self::assertSame(1, $matrices);
|
||||
}
|
||||
|
||||
public function testLookups(): void
|
||||
{
|
||||
$lock = self::lockProfile();
|
||||
$other = new Profile('other', 'Other', Tier::ReportOnly, [FakeCapability::Other]);
|
||||
$compat = new Compatibility([$lock, $other]);
|
||||
|
||||
self::assertSame([$lock, $other], $compat->profiles());
|
||||
self::assertSame($other, $compat->profile('other'));
|
||||
self::assertNull($compat->profile('nope'));
|
||||
self::assertSame(FakeCapability::Other, $compat->capability('fake-other'));
|
||||
self::assertNull($compat->capability('fake-nope'));
|
||||
self::assertSame($other, $compat->owner(FakeCapability::Other));
|
||||
self::assertSame($lock, $compat->owner(FakeCapability::Read));
|
||||
}
|
||||
|
||||
public function testAnIdTwoProfilesClaimIsAProgrammingError(): void
|
||||
{
|
||||
$this->expectException(LogicException::class);
|
||||
$this->expectExceptionMessage('fake-read');
|
||||
|
||||
new Compatibility([
|
||||
self::lockProfile(),
|
||||
new Profile('other', 'Other', Tier::ReportOnly, [FakeCapability::Read]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function testACapabilityNoProfileOwnsIsNotUsable(): void
|
||||
{
|
||||
self::assertFalse((new Compatibility([]))->usable(FakeCapability::Read));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Compatibility;
|
||||
|
||||
use LIT\MCP\Compatibility\CompatibilityMatrix;
|
||||
use LIT\MCP\Compatibility\Profile;
|
||||
use LIT\MCP\Compatibility\Tier;
|
||||
use LIT\MCP\Tests\Support\FakeCapability;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use LogicException;
|
||||
|
||||
final class ProfileTest extends TestCase
|
||||
{
|
||||
public function testAProfileKeepsWhatItWasGiven(): void
|
||||
{
|
||||
$matrix = static fn (): CompatibilityMatrix => CompatibilityMatrix::allReleased([FakeCapability::Read]);
|
||||
$profile = new Profile(
|
||||
'fake',
|
||||
'Fake Plugin',
|
||||
Tier::ReleasePerVersion,
|
||||
[FakeCapability::Read],
|
||||
matrix: $matrix,
|
||||
minVersion: '2.0'
|
||||
);
|
||||
|
||||
self::assertSame('fake', $profile->id);
|
||||
self::assertSame('Fake Plugin', $profile->label);
|
||||
self::assertSame(Tier::ReleasePerVersion, $profile->tier);
|
||||
self::assertSame([FakeCapability::Read], $profile->capabilities);
|
||||
self::assertSame($matrix, $profile->matrix);
|
||||
self::assertSame('2.0', $profile->minVersion);
|
||||
self::assertSame([], $profile->manifests);
|
||||
}
|
||||
|
||||
public function testAReleasePerVersionProfileWithoutMatrixIsAProgrammingError(): void
|
||||
{
|
||||
$this->expectException(LogicException::class);
|
||||
$this->expectExceptionMessage('needs a matrix closure');
|
||||
|
||||
new Profile('fake', 'Fake', Tier::ReleasePerVersion, [FakeCapability::Read]);
|
||||
}
|
||||
|
||||
public function testCapabilityIdsMustBeUniqueWithinAProfile(): void
|
||||
{
|
||||
$this->expectException(LogicException::class);
|
||||
$this->expectExceptionMessage('fake-read');
|
||||
|
||||
new Profile('fake', 'Fake', Tier::ReportOnly, [FakeCapability::Read, FakeCapability::Read]);
|
||||
}
|
||||
|
||||
public function testTheTiersHaveStableValues(): void
|
||||
{
|
||||
self::assertSame(
|
||||
['report', 'lock', 'release'],
|
||||
array_map(static fn (Tier $t): string => $t->value, Tier::cases())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -36,10 +36,10 @@ final class CoreIntegrationTest extends TestCase
|
||||
return ['id' => 'builder', 'active' => true, 'version' => '9', 'problems' => [], 'release' => null];
|
||||
}
|
||||
};
|
||||
$core = new CoreIntegration(dirname(__DIR__, 3) . '/Fixtures/guides', [$component]);
|
||||
$core = new CoreIntegration(dirname(__DIR__, 3) . '/Fixtures/guides', static fn (): array => [$component]);
|
||||
|
||||
$status = $core->abilities()[1]->execute([]);
|
||||
|
||||
self::assertSame(['builder', 'pods', 'the-events-calendar'], array_column($status['components'], 'id'));
|
||||
self::assertSame(['builder', 'the-events-calendar'], array_column($status['components'], 'id'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,25 +30,38 @@ final class EnvironmentProbeTest extends TestCase
|
||||
};
|
||||
}
|
||||
|
||||
public function testGivenComponentsComeFirstInTheOrderGivenFollowedByPodsAndTheEventsCalendar(): void
|
||||
public function testGivenComponentsComeFirstInTheOrderGivenFollowedByTheEventsCalendar(): void
|
||||
{
|
||||
$probe = new EnvironmentProbe([$this->component('builder', ['Etwas fehlt']), $this->component('shop')]);
|
||||
$probe = new EnvironmentProbe(fn (): array => [$this->component('builder', ['Etwas fehlt']), $this->component('shop')]);
|
||||
|
||||
$components = $probe->components();
|
||||
|
||||
self::assertSame(['builder', 'shop', 'pods', 'the-events-calendar'], array_column($components, 'id'));
|
||||
self::assertSame(['builder', 'shop', 'the-events-calendar'], array_column($components, 'id'));
|
||||
self::assertSame(['Etwas fehlt'], $components[0]['problems']);
|
||||
self::assertSame(['state' => 'released'], $components[1]['release']);
|
||||
}
|
||||
|
||||
public function testWithoutComponentsOnlyPodsAndTheEventsCalendarAreReported(): void
|
||||
public function testComponentsMayBeGivenLazilyAndAreAskedPerCall(): void
|
||||
{
|
||||
$asked = 0;
|
||||
$probe = new EnvironmentProbe(function () use (&$asked): array {
|
||||
$asked++;
|
||||
|
||||
return [$this->component('lazy')];
|
||||
});
|
||||
|
||||
self::assertSame(0, $asked);
|
||||
self::assertSame(['lazy', 'the-events-calendar'], array_column($probe->components(), 'id'));
|
||||
$probe->components();
|
||||
self::assertSame(2, $asked, 'asked per call, never cached: the state may change within a request');
|
||||
}
|
||||
|
||||
public function testWithoutComponentsOnlyTheEventsCalendarIsReported(): void
|
||||
{
|
||||
$components = array_column((new EnvironmentProbe())->components(), null, 'id');
|
||||
|
||||
self::assertSame(['pods', 'the-events-calendar'], array_keys($components));
|
||||
self::assertFalse($components['pods']['active'], 'no Pods in unit tests');
|
||||
self::assertSame([], $components['pods']['problems']);
|
||||
self::assertNull($components['pods']['release']);
|
||||
self::assertSame(['the-events-calendar'], array_keys($components));
|
||||
self::assertNull($components['the-events-calendar']['version']);
|
||||
self::assertNull($components['the-events-calendar']['release']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use LIT\MCP\Abilities\ToolAvailability;
|
||||
use LIT\MCP\Admin\AdminTab;
|
||||
use LIT\MCP\Integrations\Divi\Admin\DiviTab;
|
||||
use LIT\MCP\Admin\SettingsPage;
|
||||
use LIT\MCP\Compatibility\ProvidesCompatibility;
|
||||
use LIT\MCP\Integrations\Core\EnvironmentComponent;
|
||||
use LIT\MCP\Integrations\Divi\DiviBuilderDetector;
|
||||
use LIT\MCP\Integrations\Divi\DiviEnvironmentComponent;
|
||||
@@ -21,6 +22,7 @@ use LIT\MCP\Integrations\Divi\DiviLogoUsage;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
|
||||
use LIT\MCP\Integrations\Integration;
|
||||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||
@@ -121,6 +123,14 @@ final class DiviIntegrationTest extends TestCase
|
||||
self::assertFalse(DiviIntegration::builderEnabledFor('post'));
|
||||
}
|
||||
|
||||
public function testProvidesTheDiviCompatibilityProfile(): void
|
||||
{
|
||||
$integration = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden());
|
||||
|
||||
self::assertInstanceOf(ProvidesCompatibility::class, $integration);
|
||||
self::assertEquals([DiviProfile::profile()], $integration->compatibilities());
|
||||
}
|
||||
|
||||
public function testProvidesTheLibraryGroup(): void
|
||||
{
|
||||
Functions\when('__')->returnArg();
|
||||
|
||||
@@ -265,18 +265,18 @@ final class DiviFeatureGateTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function testAnUnknownRequirementOfTheCoreLocksTheToolAndIsLogged(): void
|
||||
/** The lock of IDs that are no Divi capability is CapabilityGate's (unknown ones fail closed there). */
|
||||
public function testIdsThatAreNoDiviCapabilityAreIgnored(): void
|
||||
{
|
||||
$log = new FakeChangeLog();
|
||||
$tool = $this->contentCreate($this->composerNeeding('page-write', 'something-else'));
|
||||
DiviFeatureFixtures::useStates([
|
||||
'page-write' => ReleaseState::Incompatible,
|
||||
'catalog' => ReleaseState::Incompatible,
|
||||
]);
|
||||
$tool = $this->contentCreate($this->composerNeeding('pods-field-read', 'something-else'));
|
||||
|
||||
$error = $this->gate($log)->refusal($tool, ['post_type' => 'page', 'divi' => [[]]]);
|
||||
$payload = self::assertToolError($error, 'divi_incompatible');
|
||||
|
||||
self::assertSame(['something-else'], $payload['unknown']);
|
||||
self::assertSame(423, $error->get_error_data()['status'] ?? null);
|
||||
self::assertSame('lit/content-create', $log->records[0]['ability']);
|
||||
self::assertSame('refused', $log->records[0]['result']);
|
||||
self::assertNull(
|
||||
$this->gate(FakeChangeLog::forbidden())->refusal($tool, ['post_type' => 'page', 'divi' => [[]]])
|
||||
);
|
||||
}
|
||||
|
||||
public function testAnnotateNeverChangesAResult(): void
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Compatibility\ReleaseState;
|
||||
use LIT\MCP\Compatibility\Tier;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||||
use LIT\MCP\Tests\Support\ReleaseMatrices;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
|
||||
final class DiviProfileTest extends TestCase
|
||||
{
|
||||
private function theme(string $template, string $version = '5.2.1'): void
|
||||
{
|
||||
$theme = Mockery::mock();
|
||||
$theme->allows('get_template')->andReturn($template);
|
||||
$theme->allows('parent')->andReturn(false);
|
||||
$theme->allows('get')->with('Version')->andReturn($version);
|
||||
Functions\when('wp_get_theme')->justReturn($theme);
|
||||
}
|
||||
|
||||
public function testDescribesDiviAsAReleasePerVersionPlugin(): void
|
||||
{
|
||||
$profile = DiviProfile::profile();
|
||||
|
||||
self::assertSame('divi', $profile->id);
|
||||
self::assertSame('Divi', $profile->label);
|
||||
self::assertSame(Tier::ReleasePerVersion, $profile->tier);
|
||||
self::assertSame(DiviFeature::cases(), $profile->capabilities);
|
||||
self::assertEquals(array_values(DiviAdapters::manifests()), $profile->manifests);
|
||||
}
|
||||
|
||||
public function testTheMatrixIsTheReleaseGatesMatrix(): void
|
||||
{
|
||||
$status = ReleaseMatrices::status([DiviFeature::PageWrite], ReleaseState::Incompatible);
|
||||
ReleaseGate::useResolver(static fn () => $status);
|
||||
|
||||
self::assertSame($status->matrix, (DiviProfile::profile()->matrix)());
|
||||
}
|
||||
|
||||
public function testTheVersionComesFromTheDiviTemplateAndThereIsNoActiveCheck(): void
|
||||
{
|
||||
$this->theme('Divi', '5.3.0');
|
||||
$profile = DiviProfile::profile();
|
||||
self::assertNull($profile->active, 'Divi has its own component; DiviIntegration decides availability');
|
||||
self::assertSame('5.3.0', ($profile->version)());
|
||||
|
||||
$this->theme('twentytwentyfive');
|
||||
self::assertNull(($profile->version)());
|
||||
}
|
||||
}
|
||||
@@ -119,20 +119,12 @@ final class FeatureSetsTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function testUnknownIdsNamesTheIdsOfACoreToolThatAreNoDiviFeature(): void
|
||||
public function testDeclaredByIgnoresIdsThatAreNoDiviFeature(): void
|
||||
{
|
||||
$tool = Mockery::mock(Ability::class, DeclaresRequirements::class);
|
||||
$tool->allows('requirements')->with(['divi' => []])->andReturn(['page-write', 'other-integration', 'catalog']);
|
||||
$tool->allows('requirements')->andReturn(['pods-field-read', 'page-write']);
|
||||
|
||||
self::assertSame(['other-integration'], FeatureSets::unknownIds($tool, ['divi' => []]));
|
||||
}
|
||||
|
||||
public function testUnknownIdsIsEmptyForDiviToolsAndAnyOtherTool(): void
|
||||
{
|
||||
$divi = Mockery::mock(Ability::class, RequiresDiviFeatures::class);
|
||||
|
||||
self::assertSame([], FeatureSets::unknownIds($divi, []));
|
||||
self::assertSame([], FeatureSets::unknownIds(Mockery::mock(Ability::class), []));
|
||||
self::assertSame([DiviFeature::PageWrite], FeatureSets::declaredBy($tool, ['divi' => []]));
|
||||
}
|
||||
|
||||
public function testDeclaredByIsEmptyForAnyOtherTool(): void
|
||||
|
||||
@@ -6,6 +6,9 @@ namespace LIT\MCP\Tests\Unit\Integrations;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Compatibility\Profile;
|
||||
use LIT\MCP\Compatibility\ProvidesCompatibility;
|
||||
use LIT\MCP\Compatibility\Tier;
|
||||
use LIT\MCP\Integrations\Bootable;
|
||||
use LIT\MCP\Integrations\Core\CoreIntegration;
|
||||
use LIT\MCP\Integrations\Divi\DiviIntegration;
|
||||
@@ -107,6 +110,34 @@ final class IntegrationRegistryTest extends TestCase
|
||||
self::assertSame(['divi', 'pods'], $booted);
|
||||
}
|
||||
|
||||
public function testCompatibilitiesOfAllProvidersInRegistryOrderEvenUnavailable(): void
|
||||
{
|
||||
$first = new Profile('a', 'A', Tier::ReportOnly, []);
|
||||
$second = new Profile('b', 'B', Tier::ReportOnly, []);
|
||||
$provider = static function (string $id, bool $available, array $profiles): Integration {
|
||||
$integration = Mockery::mock(Integration::class . ', ' . ProvidesCompatibility::class);
|
||||
$integration->allows('id')->andReturn($id);
|
||||
$integration->allows('isAvailable')->andReturn($available);
|
||||
$integration->allows('compatibilities')->andReturn($profiles);
|
||||
|
||||
return $integration;
|
||||
};
|
||||
|
||||
$registry = new IntegrationRegistry(
|
||||
$this->integration('core', true, []),
|
||||
$provider('x', false, [$first]),
|
||||
$provider('y', true, [$second])
|
||||
);
|
||||
|
||||
self::assertSame([$first, $second], $registry->compatibilities());
|
||||
self::assertSame(['a', 'b'], array_map(
|
||||
static fn (Profile $profile): string => $profile->id,
|
||||
$registry->compatibility()->profiles()
|
||||
));
|
||||
self::assertSame($registry->compatibility(), $registry->compatibility(), 'one shared instance');
|
||||
self::assertSame([], (new IntegrationRegistry($this->integration('core', true, [])))->compatibilities());
|
||||
}
|
||||
|
||||
private function ability(string $name, bool $direct): Ability
|
||||
{
|
||||
$ability = Mockery::mock(Ability::class);
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Pods;
|
||||
|
||||
use LIT\MCP\Compatibility\Call;
|
||||
use LIT\MCP\Compatibility\Compatibility;
|
||||
use LIT\MCP\Compatibility\Tier;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
|
||||
use LIT\MCP\Integrations\Pods\PodsCompatibility;
|
||||
use LIT\MCP\Integrations\Pods\PodsFeature;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
|
||||
final class PodsCompatibilityTest extends TestCase
|
||||
{
|
||||
/** @return array<string, Call> symbol => call */
|
||||
private function calls(): array
|
||||
{
|
||||
$calls = [];
|
||||
foreach (PodsCompatibility::profile()->manifests as $manifest) {
|
||||
foreach ($manifest->calls as $call) {
|
||||
$calls[$call->symbol] = $call;
|
||||
}
|
||||
}
|
||||
|
||||
return $calls;
|
||||
}
|
||||
|
||||
public function testDescribesPodsAsALockOnFailurePlugin(): void
|
||||
{
|
||||
$profile = PodsCompatibility::profile();
|
||||
|
||||
self::assertSame('pods', $profile->id);
|
||||
self::assertSame('Pods', $profile->label);
|
||||
self::assertSame(Tier::LockOnFailure, $profile->tier);
|
||||
self::assertSame('2.8', $profile->minVersion);
|
||||
self::assertSame(PodsFeature::cases(), $profile->capabilities);
|
||||
}
|
||||
|
||||
/** @runInSeparateProcess
|
||||
* @preserveGlobalState disabled
|
||||
*/
|
||||
public function testActiveAndVersionFollowThePodsVersionConstant(): void
|
||||
{
|
||||
$profile = PodsCompatibility::profile();
|
||||
self::assertFalse(($profile->active)());
|
||||
self::assertNull(($profile->version)());
|
||||
|
||||
define('PODS_VERSION', '3.3.9.2');
|
||||
|
||||
self::assertTrue(($profile->active)());
|
||||
self::assertSame('3.3.9.2', ($profile->version)());
|
||||
}
|
||||
|
||||
public function testEveryPodsCallOfTheFieldSourceIsInTheManifest(): void
|
||||
{
|
||||
$code = '';
|
||||
foreach (token_get_all((string) file_get_contents(dirname(__DIR__, 3) . '/../src/Integrations/Pods/PodsFieldSource.php')) as $token) {
|
||||
if (is_array($token) && ! in_array($token[0], [T_COMMENT, T_DOC_COMMENT], true)) {
|
||||
$code .= $token[1];
|
||||
} elseif (is_string($token)) {
|
||||
$code .= $token;
|
||||
}
|
||||
}
|
||||
preg_match_all('/\b(pods(?:_[a-z_]+)?)\(/', $code, $found);
|
||||
$used = array_values(array_unique(array_diff($found[1], ['pods_permission_denied'])));
|
||||
|
||||
self::assertContains('pods_api', $used);
|
||||
foreach ($used as $function) {
|
||||
self::assertArrayHasKey($function, $this->calls(), $function . ' is missing in the manifest');
|
||||
}
|
||||
foreach (['PodsAPI::load_pod', 'Pods::field', 'Pods::save'] as $method) {
|
||||
self::assertArrayHasKey($method, $this->calls());
|
||||
}
|
||||
}
|
||||
|
||||
public function testArgumentCountsAreThoseOfTheFieldSource(): void
|
||||
{
|
||||
$args = array_map(static fn (Call $call): ?int => $call->args, $this->calls());
|
||||
|
||||
self::assertSame([
|
||||
'pods_api' => 0,
|
||||
'PodsAPI::load_pod' => 1,
|
||||
'pods' => 2,
|
||||
'Pods::field' => 1,
|
||||
'Pods::save' => 4,
|
||||
'pods_has_permissions' => 1,
|
||||
'pods_permission' => 1,
|
||||
], $args);
|
||||
}
|
||||
|
||||
public function testEveryCapabilityHasACallOrTheConstant(): void
|
||||
{
|
||||
$manifest = PodsCompatibility::profile()->manifests[0];
|
||||
$constantIds = array_map(static fn ($c) => array_map(static fn ($f) => $f->id(), $c->features()), $manifest->constants);
|
||||
|
||||
self::assertSame(['PODS_VERSION'], array_map(static fn ($c) => $c->name, $manifest->constants));
|
||||
self::assertEqualsCanonicalizing(
|
||||
array_map(static fn (PodsFeature $f): string => $f->id(), PodsFeature::cases()),
|
||||
$constantIds[0]
|
||||
);
|
||||
foreach (PodsFeature::cases() as $feature) {
|
||||
$owners = array_filter($manifest->calls, static fn (Call $call): bool => in_array($feature, $call->features(), true));
|
||||
self::assertNotSame([], $owners, $feature->id() . ' has no call');
|
||||
}
|
||||
}
|
||||
|
||||
public function testPodsAndDiviProfilesCoexistWithoutIdCollision(): void
|
||||
{
|
||||
$compatibility = new Compatibility([DiviProfile::profile(), PodsCompatibility::profile()]);
|
||||
|
||||
self::assertSame(['divi', 'pods'], array_map(static fn ($p) => $p->id, $compatibility->profiles()));
|
||||
self::assertSame(PodsFeature::FieldRead, $compatibility->capability('pods-field-read'));
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Pods;
|
||||
|
||||
use LIT\MCP\Compatibility\Capability;
|
||||
use LIT\MCP\Integrations\Pods\PodsFeature;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
|
||||
final class PodsFeatureTest extends TestCase
|
||||
{
|
||||
public function testEveryFeatureIsACapabilityWithAPrefixedId(): void
|
||||
{
|
||||
foreach (PodsFeature::cases() as $feature) {
|
||||
self::assertInstanceOf(Capability::class, $feature);
|
||||
self::assertStringStartsWith('pods-', $feature->id());
|
||||
self::assertSame($feature->value, $feature->id());
|
||||
self::assertNotSame('', $feature->label());
|
||||
}
|
||||
self::assertSame(
|
||||
['pods-schema-read', 'pods-field-read', 'pods-field-write', 'pods-field-permissions'],
|
||||
array_map(static fn (PodsFeature $f): string => $f->id(), PodsFeature::cases())
|
||||
);
|
||||
}
|
||||
|
||||
public function testOnlyFieldWriteWrites(): void
|
||||
{
|
||||
$writing = array_filter(PodsFeature::cases(), static fn (PodsFeature $f): bool => $f->isWrite());
|
||||
|
||||
self::assertSame([PodsFeature::FieldWrite], array_values($writing));
|
||||
}
|
||||
}
|
||||
+139
-7
@@ -5,7 +5,9 @@ declare(strict_types=1);
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Pods;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Closure;
|
||||
use Exception;
|
||||
use LIT\MCP\Integrations\Pods\PodsFeature;
|
||||
use LIT\MCP\Integrations\Pods\PodsFieldSource;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
@@ -30,9 +32,20 @@ final class PodsFieldSourceTest extends TestCase
|
||||
return $field;
|
||||
}
|
||||
|
||||
private function source(?object $pod): PodsFieldSource
|
||||
/** @return Closure(PodsFeature): bool Every capability usable except the given ones. */
|
||||
private static function usableExcept(PodsFeature ...$locked): Closure
|
||||
{
|
||||
return new PodsFieldSource(static fn (string $name) => $pod ?? false, null, static fn (object $field): ?bool => null);
|
||||
return static fn (PodsFeature $feature): bool => ! in_array($feature, $locked, true);
|
||||
}
|
||||
|
||||
private function source(?object $pod, ?Closure $usable = null): PodsFieldSource
|
||||
{
|
||||
return new PodsFieldSource(
|
||||
$usable ?? self::usableExcept(),
|
||||
static fn (string $name) => $pod ?? false,
|
||||
null,
|
||||
static fn (object $field): ?bool => null
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, array<string, mixed>> */
|
||||
@@ -87,7 +100,7 @@ final class PodsFieldSourceTest extends TestCase
|
||||
$pod->allows('get_type')->andReturn('post_type');
|
||||
$pod->allows('get_fields')->andReturn(['role' => $this->field('role', 'text')]);
|
||||
|
||||
$fields = (new PodsFieldSource(static fn (string $name) => $pod))->fieldsFor('lit_person');
|
||||
$fields = (new PodsFieldSource(self::usableExcept(), static fn (string $name) => $pod))->fieldsFor('lit_person');
|
||||
|
||||
self::assertSame(FieldDefinition::HIDDEN, $fields[0]->status);
|
||||
}
|
||||
@@ -120,7 +133,7 @@ final class PodsFieldSourceTest extends TestCase
|
||||
$pod->expects('field')->with(['name' => 'mentor', 'output' => 'ids', 'single' => true])->andReturn('12');
|
||||
$pod->expects('field')->with(['name' => 'photos', 'output' => 'ids', 'single' => false])->andReturn(['3', '4']);
|
||||
$pod->expects('field')->with(['name' => 'role', 'single' => true, 'raw' => true])->andReturn('Leitung');
|
||||
$source = new PodsFieldSource(null, static fn (string $type, int $id) => $pod);
|
||||
$source = new PodsFieldSource(self::usableExcept(), null, static fn (string $type, int $id) => $pod);
|
||||
|
||||
self::assertSame(12, $source->read(5, new FieldDefinition('mentor', 'Mentor', 'relation', 'pods', 'writable')));
|
||||
self::assertSame(
|
||||
@@ -141,7 +154,7 @@ final class PodsFieldSourceTest extends TestCase
|
||||
->with(['role' => '', 'photos' => []], null, null, ['error_mode' => 'exception'])
|
||||
->andReturn(5);
|
||||
|
||||
(new PodsFieldSource(null, static fn () => $pod))
|
||||
(new PodsFieldSource(self::usableExcept(), null, static fn () => $pod))
|
||||
->write(5, 'lit_person', ['role' => null, 'photos' => null], $definitions);
|
||||
}
|
||||
|
||||
@@ -152,7 +165,8 @@ final class PodsFieldSourceTest extends TestCase
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
|
||||
(new PodsFieldSource(null, static fn () => $pod))->write(5, 'lit_person', ['role' => 'x'], []);
|
||||
(new PodsFieldSource(self::usableExcept(), null, static fn () => $pod))
|
||||
->write(5, 'lit_person', ['role' => 'x'], []);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,6 +190,7 @@ final class PodsFieldSourceTest extends TestCase
|
||||
$this->field('cap', 'number', ['restrict_capability' => '1']),
|
||||
]);
|
||||
$source = new PodsFieldSource(
|
||||
self::usableExcept(),
|
||||
static fn (string $name) => $pod,
|
||||
null,
|
||||
static fn (object $field): ?bool => $permitted[$field->get_name()] ?? null
|
||||
@@ -225,7 +240,124 @@ final class PodsFieldSourceTest extends TestCase
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
|
||||
(new PodsFieldSource(null, static fn () => $pod))
|
||||
(new PodsFieldSource(self::usableExcept(), null, static fn () => $pod))
|
||||
->write(5, 'lit_person', ['role' => 'x', 'boss' => 'y'], $definitions);
|
||||
}
|
||||
|
||||
// Capabilities (compatibility layer): a locked capability switches exactly its part of the adapter off.
|
||||
|
||||
public function testWithoutSchemaReadThePodsSchemaIsNotAskedAndEmpty(): void
|
||||
{
|
||||
$loaded = false;
|
||||
$source = new PodsFieldSource(
|
||||
self::usableExcept(PodsFeature::SchemaRead),
|
||||
static function () use (&$loaded): bool {
|
||||
$loaded = true;
|
||||
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
self::assertSame([], $source->fieldsFor('lit_person'));
|
||||
self::assertFalse($loaded, 'Pods\' schema API is not touched while it is locked');
|
||||
}
|
||||
|
||||
public function testWithoutFieldReadAFieldReadsAsNullAndPodsIsNotAsked(): void
|
||||
{
|
||||
$source = new PodsFieldSource(
|
||||
self::usableExcept(PodsFeature::FieldRead),
|
||||
null,
|
||||
static fn () => self::fail('Pods must not be asked')
|
||||
);
|
||||
|
||||
self::assertNull($source->read(5, new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable')));
|
||||
}
|
||||
|
||||
public function testWithoutFieldWriteWriteThrowsBeforeAnythingReachesPods(): void
|
||||
{
|
||||
$source = new PodsFieldSource(
|
||||
self::usableExcept(PodsFeature::FieldWrite),
|
||||
null,
|
||||
static fn () => self::fail('Pods must not be asked')
|
||||
);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
|
||||
$source->write(5, 'lit_person', ['role' => 'x'], [
|
||||
'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', FieldDefinition::WRITABLE),
|
||||
]);
|
||||
}
|
||||
|
||||
public function testWithoutFieldWriteTheFieldsAreReadonlyButStayInTheSchema(): void
|
||||
{
|
||||
$pod = Mockery::mock();
|
||||
$pod->allows('get_type')->andReturn('post_type');
|
||||
$pod->allows('get_fields')->andReturn([
|
||||
$this->field('open', 'text'),
|
||||
$this->field('internal', 'text', ['hidden' => '1']),
|
||||
]);
|
||||
|
||||
$fields = $this->source($pod, self::usableExcept(PodsFeature::FieldWrite))->fieldsFor('lit_person');
|
||||
|
||||
self::assertSame(
|
||||
['open' => FieldDefinition::READONLY, 'internal' => FieldDefinition::HIDDEN],
|
||||
array_column(array_map(
|
||||
static fn (FieldDefinition $f): array => ['key' => $f->key, 'status' => $f->status],
|
||||
$fields
|
||||
), 'status', 'key')
|
||||
);
|
||||
}
|
||||
|
||||
/** Writing needs FieldRead too: the snapshot of a write would be null and a rollback would wipe the value. */
|
||||
public function testWithoutFieldReadTheFieldsAreReadonlyAndWriteThrows(): void
|
||||
{
|
||||
$pod = Mockery::mock();
|
||||
$pod->allows('get_type')->andReturn('post_type');
|
||||
$pod->allows('get_fields')->andReturn([$this->field('open', 'text'), $this->field('odd', 'mystery')]);
|
||||
$usable = self::usableExcept(PodsFeature::FieldRead);
|
||||
|
||||
$fields = $this->source($pod, $usable)->fieldsFor('lit_person');
|
||||
|
||||
self::assertSame([FieldDefinition::READONLY, FieldDefinition::READONLY], array_column($fields, 'status'));
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
(new PodsFieldSource($usable, null, static fn () => self::fail('Pods must not be asked')))->write(
|
||||
5,
|
||||
'lit_person',
|
||||
['role' => 'x'],
|
||||
['role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', FieldDefinition::WRITABLE)]
|
||||
);
|
||||
}
|
||||
|
||||
public function testAnUnknownFieldIsReadonlyWhileWritingIsLocked(): void
|
||||
{
|
||||
$pod = Mockery::mock();
|
||||
$pod->allows('get_type')->andReturn('post_type');
|
||||
$pod->allows('get_fields')->andReturn([$this->field('odd', 'mystery')]);
|
||||
|
||||
$fields = $this->source($pod, self::usableExcept(PodsFeature::FieldWrite))->fieldsFor('lit_person');
|
||||
|
||||
self::assertSame(FieldDefinition::READONLY, $fields[0]->status);
|
||||
}
|
||||
|
||||
/** Fail-closed: without field permissions no field counts as permitted, whatever the injected check says. */
|
||||
public function testWithoutFieldPermissionsNoFieldCountsAsPermitted(): void
|
||||
{
|
||||
$pod = Mockery::mock();
|
||||
$pod->allows('get_type')->andReturn('post_type');
|
||||
$pod->allows('get_fields')->andReturn([
|
||||
$this->field('open', 'text'),
|
||||
$this->field('team', 'text', ['restrict_role' => '1', 'read_only_restricted' => '1']),
|
||||
]);
|
||||
|
||||
$fields = $this->source($pod, self::usableExcept(PodsFeature::FieldPermissions))->fieldsFor('lit_person');
|
||||
|
||||
self::assertSame(
|
||||
['open' => FieldDefinition::HIDDEN, 'team' => FieldDefinition::READONLY],
|
||||
array_column(array_map(
|
||||
static fn (FieldDefinition $f): array => ['key' => $f->key, 'status' => $f->status],
|
||||
$fields
|
||||
), 'status', 'key')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Pods;
|
||||
|
||||
use LIT\MCP\Compatibility\ProvidesCompatibility;
|
||||
use LIT\MCP\Guides\InstructionsBuilder;
|
||||
use LIT\MCP\Integrations\Integration;
|
||||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||
use LIT\MCP\Integrations\Pods\PodsCompatibility;
|
||||
use LIT\MCP\Integrations\Pods\PodsIntegration;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
|
||||
final class PodsIntegrationTest extends TestCase
|
||||
{
|
||||
public function testIsAnIntegrationWithoutAbilitiesThatProvidesItsProfile(): void
|
||||
{
|
||||
$integration = new PodsIntegration();
|
||||
|
||||
self::assertInstanceOf(Integration::class, $integration);
|
||||
self::assertInstanceOf(ProvidesCompatibility::class, $integration);
|
||||
self::assertSame('pods', $integration->id());
|
||||
self::assertSame('Pods', $integration->label());
|
||||
self::assertSame([], $integration->abilities());
|
||||
self::assertEquals([PodsCompatibility::profile()], $integration->compatibilities());
|
||||
}
|
||||
|
||||
public function testIsAvailableOnlyWithPods(): void
|
||||
{
|
||||
self::assertSame(defined('PODS_VERSION'), (new PodsIntegration())->isAvailable());
|
||||
}
|
||||
|
||||
/** An available integration without abilities is no tool group, but it is an active integration. */
|
||||
public function testAnAvailableIntegrationWithoutAbilitiesIsNoGroupButIsNamedAsActive(): void
|
||||
{
|
||||
$pods = new class implements Integration {
|
||||
public function id(): string
|
||||
{
|
||||
return 'pods';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Pods';
|
||||
}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function abilities(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
$registry = new IntegrationRegistry($pods);
|
||||
|
||||
self::assertSame([], $registry->grouped());
|
||||
self::assertSame([], $registry->groupMap());
|
||||
self::assertStringContainsString('Aktive Integrationen: pods.', (new InstructionsBuilder($registry))->build());
|
||||
}
|
||||
}
|
||||
+115
-1
@@ -10,6 +10,19 @@ use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Abilities\ExposurePolicy;
|
||||
use LIT\MCP\Abilities\ToolAvailability;
|
||||
use LIT\MCP\Admin\SettingsPage;
|
||||
use LIT\MCP\Compatibility\CapabilityGate;
|
||||
use LIT\MCP\Compatibility\Compatibility;
|
||||
use LIT\MCP\Compatibility\Profile;
|
||||
use LIT\MCP\Compatibility\Tier;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviFeatureGate;
|
||||
use LIT\MCP\Integrations\Pods\PodsFeature;
|
||||
use LIT\MCP\Integrations\Pods\PodsFieldSource;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\SampledMetaSource;
|
||||
use LIT\MCP\Integrations\Core\SiteStatusAbility;
|
||||
use LIT\MCP\Access;
|
||||
use ReflectionProperty;
|
||||
use WP_User;
|
||||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use LIT\MCP\Limits;
|
||||
@@ -34,7 +47,7 @@ final class PluginTest extends TestCase
|
||||
public function testIntegrationsInRegistryOrder(): void
|
||||
{
|
||||
self::assertSame(
|
||||
['core', 'wordpress', 'media', 'divi', 'divi-library', 'divi-theme-builder'],
|
||||
['core', 'wordpress', 'media', 'divi', 'divi-library', 'divi-theme-builder', 'pods'],
|
||||
self::registry()->ids()
|
||||
);
|
||||
}
|
||||
@@ -68,4 +81,105 @@ final class PluginTest extends TestCase
|
||||
self::assertTrue($tools->groupWritable('divi-theme-builder'));
|
||||
self::assertFalse($tools->allows('lit/divi-insert'), 'no Divi in unit tests: lit/divi-insert not registered');
|
||||
}
|
||||
|
||||
/** Pods has no tools: it is in the registry for its profile only and shows up as no group. */
|
||||
public function testPodsIsRegisteredWithoutBecomingAToolGroup(): void
|
||||
{
|
||||
$registry = self::registry();
|
||||
|
||||
self::assertContains('pods', $registry->ids());
|
||||
self::assertArrayNotHasKey('pods', $registry->grouped());
|
||||
}
|
||||
|
||||
/** lit-site-status keeps its order: Divi, Pods, The Events Calendar. */
|
||||
public function testSiteStatusListsDiviPodsAndTheEventsCalendarInThatOrder(): void
|
||||
{
|
||||
Functions\when('get_bloginfo')->justReturn('x');
|
||||
Functions\when('home_url')->justReturn('https://example.test');
|
||||
Functions\when('wp_get_current_user')->justReturn(new WP_User(1, ['administrator']));
|
||||
Functions\when('get_option')->justReturn(['administrator']);
|
||||
Functions\when('apply_filters')->returnArg(2);
|
||||
$theme = Mockery::mock();
|
||||
$theme->allows('get_template')->andReturn('twentytwentyfive');
|
||||
Functions\when('wp_get_theme')->justReturn($theme);
|
||||
$status = array_values(array_filter(
|
||||
self::registry()->abilities(),
|
||||
static fn ($ability): bool => $ability instanceof SiteStatusAbility
|
||||
))[0];
|
||||
|
||||
$components = $status->execute([])['components'];
|
||||
|
||||
self::assertSame(['divi', 'pods', 'the-events-calendar'], array_column($components, 'id'));
|
||||
$pods = array_column($components, null, 'id')['pods'];
|
||||
self::assertFalse($pods['active'], 'no Pods in unit tests');
|
||||
self::assertSame([], $pods['problems']);
|
||||
}
|
||||
|
||||
public function testTheCompatibilityCoversAllProfilesWithoutIdCollision(): void
|
||||
{
|
||||
$compat = self::registry()->compatibility();
|
||||
|
||||
self::assertSame(['divi', 'pods'], array_map(static fn (Profile $p): string => $p->id, $compat->profiles()));
|
||||
self::assertNotNull($compat->capability('page-write'));
|
||||
self::assertNotNull($compat->capability('pods-field-read'));
|
||||
self::assertSame('divi', $compat->owner($compat->capability('page-write'))?->id);
|
||||
self::assertSame('pods', $compat->owner($compat->capability('pods-field-write'))?->id);
|
||||
self::assertNull($compat->capability('nobody-knows-me'));
|
||||
}
|
||||
|
||||
/** The lock of unknown IDs lives in CapabilityGate: without it a core tool with an odd ID would run unlocked. */
|
||||
public function testBothGuardsAreWiredDiviFirst(): void
|
||||
{
|
||||
$guards = Plugin::guards(self::registry(), FakeChangeLog::forbidden());
|
||||
|
||||
self::assertCount(2, $guards);
|
||||
self::assertInstanceOf(DiviFeatureGate::class, $guards[0]);
|
||||
self::assertInstanceOf(CapabilityGate::class, $guards[1]);
|
||||
}
|
||||
|
||||
private static function readOnly(object $source): bool
|
||||
{
|
||||
return (new ReflectionProperty($source, 'readOnly'))->getValue($source);
|
||||
}
|
||||
|
||||
private static function podsCompat(bool $usable): Compatibility
|
||||
{
|
||||
return new Compatibility([new Profile(
|
||||
'pods',
|
||||
'Pods',
|
||||
Tier::LockOnFailure,
|
||||
PodsFeature::cases(),
|
||||
active: static fn (): bool => $usable
|
||||
)]);
|
||||
}
|
||||
|
||||
public function testWithoutPodsTheMetaSourcesStayWritableAndPodsIsNotASource(): void
|
||||
{
|
||||
$sources = Plugin::schemaSources(self::podsCompat(false), false);
|
||||
|
||||
self::assertCount(2, $sources);
|
||||
self::assertInstanceOf(RegisteredMetaSource::class, $sources[0]);
|
||||
self::assertInstanceOf(SampledMetaSource::class, $sources[1]);
|
||||
self::assertFalse(self::readOnly($sources[0]));
|
||||
self::assertFalse(self::readOnly($sources[1]));
|
||||
}
|
||||
|
||||
public function testWithUsablePodsItsSourceComesFirstAndTheMetaSourcesStayWritable(): void
|
||||
{
|
||||
$sources = Plugin::schemaSources(self::podsCompat(true), true);
|
||||
|
||||
self::assertInstanceOf(PodsFieldSource::class, $sources[0]);
|
||||
self::assertFalse(self::readOnly($sources[1]));
|
||||
self::assertFalse(self::readOnly($sources[2]));
|
||||
}
|
||||
|
||||
/** Pods active but its schema locked: its meta keys must not be written past Pods. */
|
||||
public function testWithActiveButLockedPodsTheMetaSourcesAreReadOnly(): void
|
||||
{
|
||||
$sources = Plugin::schemaSources(self::podsCompat(false), true);
|
||||
|
||||
self::assertInstanceOf(PodsFieldSource::class, $sources[0]);
|
||||
self::assertTrue(self::readOnly($sources[1]));
|
||||
self::assertTrue(self::readOnly($sources[2]));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user