From c85131603b5bf503feb0295fecc8da511985e058 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 6 Nov 2026 12:26:54 +0100 Subject: [PATCH] =?UTF-8?q?feat(compat):=20Profile,=20Gates=20und=20Pods?= =?UTF-8?q?=20=C3=BCber=20den=20Kompatibilit=C3=A4tskern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/Compatibility/CapabilityGate.php | 96 ++++++++ src/Compatibility/CapabilityLocked.php | 108 ++++++++ src/Compatibility/Compatibility.php | 213 ++++++++++++++++ src/Compatibility/CompatibilityComponent.php | 70 ++++++ src/Compatibility/Profile.php | 48 ++++ src/Compatibility/ProvidesCompatibility.php | 14 ++ src/Compatibility/Tier.php | 20 ++ src/Integrations/Core/CoreIntegration.php | 8 +- src/Integrations/Core/EnvironmentProbe.php | 22 +- src/Integrations/Divi/DiviIntegration.php | 9 +- .../Divi/Release/DiviFeatureGate.php | 38 +-- src/Integrations/Divi/Release/DiviProfile.php | 27 ++ src/Integrations/Divi/Release/FeatureSets.php | 23 +- src/Integrations/IntegrationRegistry.php | 34 ++- src/Integrations/Pods/PodsCompatibility.php | 49 ++++ src/Integrations/Pods/PodsFeature.php | 41 +++ src/Integrations/Pods/PodsFieldSource.php | 45 +++- src/Integrations/Pods/PodsIntegration.php | 42 ++++ src/Plugin.php | 81 ++++-- tests/Support/FakeCapability.php | 33 +++ .../Unit/Abilities/CompatibilityGatesTest.php | 113 +++++++++ .../Unit/Compatibility/CapabilityGateTest.php | 147 +++++++++++ .../Compatibility/CapabilityLockedTest.php | 64 +++++ .../CompatibilityComponentTest.php | 115 +++++++++ .../Unit/Compatibility/CompatibilityTest.php | 233 ++++++++++++++++++ tests/Unit/Compatibility/ProfileTest.php | 60 +++++ .../Integrations/Core/CoreIntegrationTest.php | 4 +- .../Core/EnvironmentProbeTest.php | 29 ++- .../Integrations/Divi/DiviIntegrationTest.php | 10 + .../Divi/Release/DiviFeatureGateTest.php | 20 +- .../Divi/Release/DiviProfileTest.php | 58 +++++ .../Divi/Release/FeatureSetsTest.php | 14 +- .../Integrations/IntegrationRegistryTest.php | 31 +++ .../Pods/PodsCompatibilityTest.php | 116 +++++++++ .../Integrations/Pods/PodsFeatureTest.php | 33 +++ .../Integrations/Pods/PodsFieldSourceTest.php | 146 ++++++++++- .../Integrations/Pods/PodsIntegrationTest.php | 64 +++++ tests/Unit/PluginTest.php | 116 ++++++++- 38 files changed, 2259 insertions(+), 135 deletions(-) create mode 100644 src/Compatibility/CapabilityGate.php create mode 100644 src/Compatibility/CapabilityLocked.php create mode 100644 src/Compatibility/Compatibility.php create mode 100644 src/Compatibility/CompatibilityComponent.php create mode 100644 src/Compatibility/Profile.php create mode 100644 src/Compatibility/ProvidesCompatibility.php create mode 100644 src/Compatibility/Tier.php create mode 100644 src/Integrations/Divi/Release/DiviProfile.php create mode 100644 src/Integrations/Pods/PodsCompatibility.php create mode 100644 src/Integrations/Pods/PodsFeature.php create mode 100644 src/Integrations/Pods/PodsIntegration.php create mode 100644 tests/Support/FakeCapability.php create mode 100644 tests/Unit/Abilities/CompatibilityGatesTest.php create mode 100644 tests/Unit/Compatibility/CapabilityGateTest.php create mode 100644 tests/Unit/Compatibility/CapabilityLockedTest.php create mode 100644 tests/Unit/Compatibility/CompatibilityComponentTest.php create mode 100644 tests/Unit/Compatibility/CompatibilityTest.php create mode 100644 tests/Unit/Compatibility/ProfileTest.php create mode 100644 tests/Unit/Integrations/Divi/Release/DiviProfileTest.php create mode 100644 tests/Unit/Integrations/Pods/PodsCompatibilityTest.php create mode 100644 tests/Unit/Integrations/Pods/PodsFeatureTest.php create mode 100644 tests/Unit/Integrations/Pods/PodsIntegrationTest.php diff --git a/src/Compatibility/CapabilityGate.php b/src/Compatibility/CapabilityGate.php new file mode 100644 index 0000000..854ef48 --- /dev/null +++ b/src/Compatibility/CapabilityGate.php @@ -0,0 +1,96 @@ + $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; + } +} diff --git a/src/Compatibility/CapabilityLocked.php b/src/Compatibility/CapabilityLocked.php new file mode 100644 index 0000000..8629d64 --- /dev/null +++ b/src/Compatibility/CapabilityLocked.php @@ -0,0 +1,108 @@ + $locked The locked capabilities the refused call needs. + * @param list $plugins Labels of the plugins they belong to. + * @param list $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 $locked + * @param list $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 $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) + ))), + ] + ); + } +} diff --git a/src/Compatibility/Compatibility.php b/src/Compatibility/Compatibility.php new file mode 100644 index 0000000..602add1 --- /dev/null +++ b/src/Compatibility/Compatibility.php @@ -0,0 +1,213 @@ + keyed by profile ID */ + private array $byId = []; + /** @var array keyed by capability ID */ + private array $byCapability = []; + /** @var array */ + private array $matrices = []; + /** @var array */ + private array $checks = []; + /** @var Closure(Profile): ManifestCheck */ + private Closure $check; + + /** + * @param list $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 */ + 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 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 $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; + } + } +} diff --git a/src/Compatibility/CompatibilityComponent.php b/src/Compatibility/CompatibilityComponent.php new file mode 100644 index 0000000..2d894c9 --- /dev/null +++ b/src/Compatibility/CompatibilityComponent.php @@ -0,0 +1,70 @@ +tier === Tier::ReleasePerVersion) { + throw new LogicException( + sprintf('profile %s has a release status and needs its own component', $profile->id) + ); + } + } + + /** @return list 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, 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, + ]; + } +} diff --git a/src/Compatibility/Profile.php b/src/Compatibility/Profile.php new file mode 100644 index 0000000..3774457 --- /dev/null +++ b/src/Compatibility/Profile.php @@ -0,0 +1,48 @@ + $capabilities + * @param list $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))); + } + } +} diff --git a/src/Compatibility/ProvidesCompatibility.php b/src/Compatibility/ProvidesCompatibility.php new file mode 100644 index 0000000..9252a74 --- /dev/null +++ b/src/Compatibility/ProvidesCompatibility.php @@ -0,0 +1,14 @@ + */ + public function compatibilities(): array; +} diff --git a/src/Compatibility/Tier.php b/src/Compatibility/Tier.php new file mode 100644 index 0000000..c4a7a3b --- /dev/null +++ b/src/Compatibility/Tier.php @@ -0,0 +1,20 @@ + $components What lit-site-status reports besides Pods and Events Calendar. */ - public function __construct(private string $guidesDirectory, private array $components = []) + /** + * @param (Closure(): list)|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) { } diff --git a/src/Integrations/Core/EnvironmentProbe.php b/src/Integrations/Core/EnvironmentProbe.php index 240973e..e944a3b 100644 --- a/src/Integrations/Core/EnvironmentProbe.php +++ b/src/Integrations/Core/EnvironmentProbe.php @@ -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 $components Components of integrations, reported in this order. */ - public function __construct(private array $components = []) + /** + * @param (Closure(): list)|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, diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 55bdf35..65898c2 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -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). diff --git a/src/Integrations/Divi/Release/DiviFeatureGate.php b/src/Integrations/Divi/Release/DiviFeatureGate.php index 9e27f4d..5d25cd2 100644 --- a/src/Integrations/Divi/Release/DiviFeatureGate.php +++ b/src/Integrations/Divi/Release/DiviFeatureGate.php @@ -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 $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 { diff --git a/src/Integrations/Divi/Release/DiviProfile.php b/src/Integrations/Divi/Release/DiviProfile.php new file mode 100644 index 0000000..e2d68b7 --- /dev/null +++ b/src/Integrations/Divi/Release/DiviProfile.php @@ -0,0 +1,27 @@ + ReleaseGate::status()->matrix + ); + } +} diff --git a/src/Integrations/Divi/Release/FeatureSets.php b/src/Integrations/Divi/Release/FeatureSets.php index 6a4b022..e23a456 100644 --- a/src/Integrations/Divi/Release/FeatureSets.php +++ b/src/Integrations/Divi/Release/FeatureSets.php @@ -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 $input * @return list @@ -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 $input - * @return list - */ - 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 ...$lists * @return list Every capability once, in the order of first appearance. diff --git a/src/Integrations/IntegrationRegistry.php b/src/Integrations/IntegrationRegistry.php index 2a59956..7ed0baf 100644 --- a/src/Integrations/IntegrationRegistry.php +++ b/src/Integrations/IntegrationRegistry.php @@ -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> */ @@ -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 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 Ids of all known integrations, available or not. */ public function ids(): array { diff --git a/src/Integrations/Pods/PodsCompatibility.php b/src/Integrations/Pods/PodsCompatibility.php new file mode 100644 index 0000000..edec2a8 --- /dev/null +++ b/src/Integrations/Pods/PodsCompatibility.php @@ -0,0 +1,49 @@ + defined('PODS_VERSION'), + static fn (): ?string => defined('PODS_VERSION') ? (string) PODS_VERSION : null, + '2.8' + ); + } +} diff --git a/src/Integrations/Pods/PodsFeature.php b/src/Integrations/Pods/PodsFeature.php new file mode 100644 index 0000000..e98ad2b --- /dev/null +++ b/src/Integrations/Pods/PodsFeature.php @@ -0,0 +1,41 @@ +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; + } +} diff --git a/src/Integrations/Pods/PodsFieldSource.php b/src/Integrations/Pods/PodsFieldSource.php index b612cfa..7fc09e1 100644 --- a/src/Integrations/Pods/PodsFieldSource.php +++ b/src/Integrations/Pods/PodsFieldSource.php @@ -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()). */ diff --git a/src/Integrations/Pods/PodsIntegration.php b/src/Integrations/Pods/PodsIntegration.php new file mode 100644 index 0000000..7d040ae --- /dev/null +++ b/src/Integrations/Pods/PodsIntegration.php @@ -0,0 +1,42 @@ +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 + * @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 + * @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; + } } diff --git a/tests/Support/FakeCapability.php b/tests/Support/FakeCapability.php new file mode 100644 index 0000000..da0ea02 --- /dev/null +++ b/tests/Support/FakeCapability.php @@ -0,0 +1,33 @@ +value; + } + + public function label(): string + { + return 'Fake ' . $this->value; + } + + public function isWrite(): bool + { + return $this === self::Write; + } +} diff --git a/tests/Unit/Abilities/CompatibilityGatesTest.php b/tests/Unit/Abilities/CompatibilityGatesTest.php new file mode 100644 index 0000000..850767d --- /dev/null +++ b/tests/Unit/Abilities/CompatibilityGatesTest.php @@ -0,0 +1,113 @@ +justReturn(true); + } + + protected function tearDown(): void + { + RequestContext::reset(); + parent::tearDown(); + } + + /** @param list $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'); + } +} diff --git a/tests/Unit/Compatibility/CapabilityGateTest.php b/tests/Unit/Compatibility/CapabilityGateTest.php new file mode 100644 index 0000000..07fa5db --- /dev/null +++ b/tests/Unit/Compatibility/CapabilityGateTest.php @@ -0,0 +1,147 @@ +|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 $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)); + } +} diff --git a/tests/Unit/Compatibility/CapabilityLockedTest.php b/tests/Unit/Compatibility/CapabilityLockedTest.php new file mode 100644 index 0000000..a35ba9c --- /dev/null +++ b/tests/Unit/Compatibility/CapabilityLockedTest.php @@ -0,0 +1,64 @@ +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([]); + } +} diff --git a/tests/Unit/Compatibility/CompatibilityComponentTest.php b/tests/Unit/Compatibility/CompatibilityComponentTest.php new file mode 100644 index 0000000..7d3590f --- /dev/null +++ b/tests/Unit/Compatibility/CompatibilityComponentTest.php @@ -0,0 +1,115 @@ + $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)); + } +} diff --git a/tests/Unit/Compatibility/CompatibilityTest.php b/tests/Unit/Compatibility/CompatibilityTest.php new file mode 100644 index 0000000..6dbccb3 --- /dev/null +++ b/tests/Unit/Compatibility/CompatibilityTest.php @@ -0,0 +1,233 @@ + 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)); + } +} diff --git a/tests/Unit/Compatibility/ProfileTest.php b/tests/Unit/Compatibility/ProfileTest.php new file mode 100644 index 0000000..53194b1 --- /dev/null +++ b/tests/Unit/Compatibility/ProfileTest.php @@ -0,0 +1,60 @@ + 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()) + ); + } +} diff --git a/tests/Unit/Integrations/Core/CoreIntegrationTest.php b/tests/Unit/Integrations/Core/CoreIntegrationTest.php index fb5135f..8d62653 100644 --- a/tests/Unit/Integrations/Core/CoreIntegrationTest.php +++ b/tests/Unit/Integrations/Core/CoreIntegrationTest.php @@ -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')); } } diff --git a/tests/Unit/Integrations/Core/EnvironmentProbeTest.php b/tests/Unit/Integrations/Core/EnvironmentProbeTest.php index 58822d6..6d595a8 100644 --- a/tests/Unit/Integrations/Core/EnvironmentProbeTest.php +++ b/tests/Unit/Integrations/Core/EnvironmentProbeTest.php @@ -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']); } } diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index 08f42d0..dc1f093 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -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(); diff --git a/tests/Unit/Integrations/Divi/Release/DiviFeatureGateTest.php b/tests/Unit/Integrations/Divi/Release/DiviFeatureGateTest.php index 5ca6ef0..cfc2d7b 100644 --- a/tests/Unit/Integrations/Divi/Release/DiviFeatureGateTest.php +++ b/tests/Unit/Integrations/Divi/Release/DiviFeatureGateTest.php @@ -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 diff --git a/tests/Unit/Integrations/Divi/Release/DiviProfileTest.php b/tests/Unit/Integrations/Divi/Release/DiviProfileTest.php new file mode 100644 index 0000000..bc143b5 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/DiviProfileTest.php @@ -0,0 +1,58 @@ +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)()); + } +} diff --git a/tests/Unit/Integrations/Divi/Release/FeatureSetsTest.php b/tests/Unit/Integrations/Divi/Release/FeatureSetsTest.php index 131382e..1847320 100644 --- a/tests/Unit/Integrations/Divi/Release/FeatureSetsTest.php +++ b/tests/Unit/Integrations/Divi/Release/FeatureSetsTest.php @@ -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 diff --git a/tests/Unit/Integrations/IntegrationRegistryTest.php b/tests/Unit/Integrations/IntegrationRegistryTest.php index 20bc4d9..c2d0682 100644 --- a/tests/Unit/Integrations/IntegrationRegistryTest.php +++ b/tests/Unit/Integrations/IntegrationRegistryTest.php @@ -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); diff --git a/tests/Unit/Integrations/Pods/PodsCompatibilityTest.php b/tests/Unit/Integrations/Pods/PodsCompatibilityTest.php new file mode 100644 index 0000000..8ca7146 --- /dev/null +++ b/tests/Unit/Integrations/Pods/PodsCompatibilityTest.php @@ -0,0 +1,116 @@ + 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')); + } +} diff --git a/tests/Unit/Integrations/Pods/PodsFeatureTest.php b/tests/Unit/Integrations/Pods/PodsFeatureTest.php new file mode 100644 index 0000000..e720f43 --- /dev/null +++ b/tests/Unit/Integrations/Pods/PodsFeatureTest.php @@ -0,0 +1,33 @@ +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)); + } +} diff --git a/tests/Unit/Integrations/Pods/PodsFieldSourceTest.php b/tests/Unit/Integrations/Pods/PodsFieldSourceTest.php index e078094..389906b 100644 --- a/tests/Unit/Integrations/Pods/PodsFieldSourceTest.php +++ b/tests/Unit/Integrations/Pods/PodsFieldSourceTest.php @@ -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> */ @@ -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') + ); + } } diff --git a/tests/Unit/Integrations/Pods/PodsIntegrationTest.php b/tests/Unit/Integrations/Pods/PodsIntegrationTest.php new file mode 100644 index 0000000..ebcc7ab --- /dev/null +++ b/tests/Unit/Integrations/Pods/PodsIntegrationTest.php @@ -0,0 +1,64 @@ +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()); + } +} diff --git a/tests/Unit/PluginTest.php b/tests/Unit/PluginTest.php index acd54e6..e767f4f 100644 --- a/tests/Unit/PluginTest.php +++ b/tests/Unit/PluginTest.php @@ -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])); + } }