feat: DiviFeatureGate ersetzt DiviToolGate – Ablehnung je Fähigkeit für Lese- und Schreib-Tools, Details nach D6, kein Hinweis mehr in warnings; DiviContentComposer::refusal prüft seine Fähigkeiten
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
fdbda8012c
commit
74cf0c54a8
@@ -11,7 +11,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\Outline;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviFeatureGate;
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
@@ -81,9 +81,7 @@ final class DiviContentComposer implements ContentComposer
|
||||
|
||||
public function refusal(): ?WP_Error
|
||||
{
|
||||
$status = ReleaseGate::status();
|
||||
|
||||
return $status->writable() ? null : $status->refusal();
|
||||
return DiviFeatureGate::lockFor(self::FEATURES)?->toError();
|
||||
}
|
||||
|
||||
public function check(string $postType, array $sections): ?WP_Error
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Release;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\AbilityGuard;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The central release lock of the Divi tools (spec §5.5), per capability: a call is refused before it runs while one
|
||||
* of the capabilities it declares for its input is not usable – reading and writing tools alike, so a reading tool
|
||||
* 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.
|
||||
*/
|
||||
final class DiviFeatureGate implements AbilityGuard
|
||||
{
|
||||
public function __construct(private ?ChangeLog $log = null)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* The lock for the usable-ness of $features, or null when every one is usable. Public: the Divi short form of
|
||||
* lit/content-create asks the same question for its own capabilities (DiviContentComposer::refusal()).
|
||||
*
|
||||
* @param list<DiviFeature> $features
|
||||
*/
|
||||
public static function lockFor(array $features): ?DiviLocked
|
||||
{
|
||||
$unusable = [];
|
||||
foreach ($features as $feature) {
|
||||
if (! ReleaseGate::feature($feature)->usable()) {
|
||||
$unusable[$feature->value] = $feature;
|
||||
}
|
||||
}
|
||||
|
||||
return $unusable === [] ? null : new DiviLocked(ReleaseGate::status(), array_values($unusable));
|
||||
}
|
||||
|
||||
public function refusal(Ability $ability, array $input): ?WP_Error
|
||||
{
|
||||
$lock = self::lockFor(FeatureSets::declaredBy($ability, $input));
|
||||
if ($lock === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! self::readsOnly($ability)) {
|
||||
$this->log?->record(
|
||||
$ability->name(),
|
||||
'divi',
|
||||
$lock->status->version,
|
||||
self::summary($lock),
|
||||
result: 'refused'
|
||||
);
|
||||
}
|
||||
|
||||
return $lock->toError();
|
||||
}
|
||||
|
||||
/** 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
|
||||
{
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Divi tools keep the optional warnings property in their output schema (as before the per-capability lock);
|
||||
* lit/content-create has its own.
|
||||
*
|
||||
* @param array<string, mixed>|null $schema
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function outputSchema(Ability $ability, ?array $schema): ?array
|
||||
{
|
||||
if ($schema === null || ($schema['type'] ?? null) !== 'object' || ! $ability instanceof RequiresDiviFeatures) {
|
||||
return $schema;
|
||||
}
|
||||
|
||||
$properties = is_array($schema['properties'] ?? null) ? $schema['properties'] : [];
|
||||
$properties['warnings'] ??= ['type' => 'array', 'items' => ['type' => 'string']];
|
||||
$schema['properties'] = $properties;
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
private static function readsOnly(Ability $ability): bool
|
||||
{
|
||||
return ($ability->annotations()['readonly'] ?? false) === true;
|
||||
}
|
||||
|
||||
private static function summary(DiviLocked $lock): string
|
||||
{
|
||||
$parts = array_map(
|
||||
static fn (DiviFeature $feature): string => sprintf(
|
||||
'%s %s',
|
||||
$feature->label(),
|
||||
$lock->status->matrix->of($feature)->state === ReleaseState::Incompatible
|
||||
? 'inkompatibel'
|
||||
: 'nicht freigegeben'
|
||||
),
|
||||
$lock->features
|
||||
);
|
||||
|
||||
return sprintf('Abgelehnt: %s – %s.', $lock->status->versionLabel(), implode(', ', $parts));
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Release;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\AbilityGuard;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The central release lock of the Divi tools (spec §5.5): a writing tool of the groups Divi, Divi-Bibliothek or Divi
|
||||
* Theme Builder is refused before it runs while Divi is not released (logged with result "refused"); a reading tool
|
||||
* runs and gets the notice in warnings. Other tools that reach Divi (lit/content-create with divi) meet the adapter
|
||||
* lock (ReleaseGate::assertWritable()). The AbilityRegistrar receives it as an AbilityGuard from Plugin.
|
||||
*/
|
||||
final class DiviToolGate implements AbilityGuard
|
||||
{
|
||||
public const GROUPS = ['divi', LibraryAccess::GROUP, ThemeBuilderAccess::GROUP];
|
||||
|
||||
/** @var Closure(): array<string, string> */
|
||||
private Closure $groups;
|
||||
|
||||
/**
|
||||
* @param Closure(): array<string, string> $groups Ability name => integration ID (IntegrationRegistry::groupMap).
|
||||
*/
|
||||
public function __construct(Closure $groups, private ?ChangeLog $log = null)
|
||||
{
|
||||
$this->groups = $groups;
|
||||
}
|
||||
|
||||
public function covers(Ability $ability): bool
|
||||
{
|
||||
return in_array(($this->groups)()[$ability->name()] ?? '', self::GROUPS, true);
|
||||
}
|
||||
|
||||
public function refusal(Ability $ability, array $input): ?WP_Error
|
||||
{
|
||||
if (! $this->covers($ability) || self::readsOnly($ability)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$status = ReleaseGate::status();
|
||||
if ($status->writable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->log?->record(
|
||||
$ability->name(),
|
||||
'divi',
|
||||
$status->version,
|
||||
sprintf(
|
||||
'Abgelehnt: %s ist %s.',
|
||||
$status->versionLabel(),
|
||||
$status->state === ReleaseState::Incompatible ? 'inkompatibel' : 'nicht freigegeben'
|
||||
),
|
||||
result: 'refused'
|
||||
);
|
||||
|
||||
return $status->refusal();
|
||||
}
|
||||
|
||||
public function annotate(Ability $ability, mixed $result): mixed
|
||||
{
|
||||
if (! is_array($result) || array_is_list($result) || ! $this->covers($ability) || ! self::readsOnly($ability)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$status = ReleaseGate::status();
|
||||
if ($status->writable()) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$warnings = is_array($result['warnings'] ?? null) ? array_values($result['warnings']) : [];
|
||||
$warnings[] = $status->notice();
|
||||
$result['warnings'] = $warnings;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $schema
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function outputSchema(Ability $ability, ?array $schema): ?array
|
||||
{
|
||||
if ($schema === null || ($schema['type'] ?? null) !== 'object' || ! $this->covers($ability)) {
|
||||
return $schema;
|
||||
}
|
||||
|
||||
$properties = is_array($schema['properties'] ?? null) ? $schema['properties'] : [];
|
||||
$properties['warnings'] ??= ['type' => 'array', 'items' => ['type' => 'string']];
|
||||
$schema['properties'] = $properties;
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
private static function readsOnly(Ability $ability): bool
|
||||
{
|
||||
return ($ability->annotations()['readonly'] ?? false) === true;
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ final class ReleaseStatus
|
||||
return new self(ReleaseState::Released, $version, $source);
|
||||
}
|
||||
|
||||
/** Every writing feature is usable – for callers that know no feature (DiviToolGate, DiviContentComposer). */
|
||||
/** Every writing feature is usable – for callers that know no feature (general write lock of DiviLocked). */
|
||||
public function writable(): bool
|
||||
{
|
||||
return $this->matrix->usable(CompatibilityMatrix::writeFeatures());
|
||||
|
||||
+2
-2
@@ -24,7 +24,7 @@ use LIT\MCP\ChangeLog\ChangeLogInstaller;
|
||||
use LIT\MCP\Guides\InstructionsBuilder;
|
||||
use LIT\MCP\Integrations\Core\CoreIntegration;
|
||||
use LIT\MCP\Integrations\Divi\DiviIntegration;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviToolGate;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviFeatureGate;
|
||||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||
use LIT\MCP\Integrations\Media\MediaIntegration;
|
||||
use LIT\MCP\Integrations\Pods\PodsFieldSource;
|
||||
@@ -84,7 +84,7 @@ final class Plugin
|
||||
(new AbilityRegistrar(
|
||||
$registry,
|
||||
$policy,
|
||||
[new DiviToolGate($registry->groupMap(...), $changeLog)]
|
||||
[new DiviFeatureGate($changeLog)]
|
||||
))->register();
|
||||
(new ServerRegistrar(
|
||||
$registry,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Support;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\Release\CompatibilityMatrix;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||||
use LIT\MCP\Integrations\Divi\Release\FeatureStatus;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
|
||||
|
||||
/** A release status in which single Divi capabilities are locked – for guard and tool tests. */
|
||||
final class DiviFeatureFixtures
|
||||
{
|
||||
/**
|
||||
* Makes ReleaseGate answer with $states; every capability not named is released. TestCase resets the gate.
|
||||
*
|
||||
* @param array<string, ReleaseState> $states DiviFeature value => state
|
||||
*/
|
||||
public static function useStates(array $states, string $version = '5.3.0'): void
|
||||
{
|
||||
$features = [];
|
||||
$overall = ReleaseState::Released;
|
||||
foreach (DiviFeature::cases() as $feature) {
|
||||
$state = $states[$feature->value] ?? ReleaseState::Released;
|
||||
$features[$feature->value] = new FeatureStatus(
|
||||
$feature,
|
||||
$state,
|
||||
$state === ReleaseState::Released ? [] : ['Abweichung bei ' . $feature->label()],
|
||||
null
|
||||
);
|
||||
if ($state === ReleaseState::Incompatible || ($state === ReleaseState::Untested
|
||||
&& $overall === ReleaseState::Released)) {
|
||||
$overall = $state;
|
||||
}
|
||||
}
|
||||
|
||||
$status = new ReleaseStatus($overall, $version, null, [], null, matrix: new CompatibilityMatrix($features));
|
||||
ReleaseGate::useResolver(static fn (): ReleaseStatus => $status);
|
||||
}
|
||||
|
||||
public static function lock(DiviFeature $feature, ReleaseState $state = ReleaseState::Untested): void
|
||||
{
|
||||
self::useStates([$feature->value => $state]);
|
||||
}
|
||||
}
|
||||
@@ -12,14 +12,17 @@ use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Abilities\ExposurePolicy;
|
||||
use LIT\MCP\Abilities\ToolRefusal;
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviFeatureGate;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviToolGate;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
|
||||
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
|
||||
use LIT\MCP\Integrations\Integration;
|
||||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||
use LIT\MCP\Server\RequestContext;
|
||||
use LIT\MCP\Tests\Support\DiviFeatureFixtures;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use RuntimeException;
|
||||
@@ -60,12 +63,18 @@ final class AbilityRegistrarTest extends TestCase
|
||||
$integration->allows('abilities')->andReturn([$ability]);
|
||||
$registry = new IntegrationRegistry($integration);
|
||||
|
||||
return new AbilityRegistrar($registry, null, [new DiviToolGate($registry->groupMap(...))]);
|
||||
return new AbilityRegistrar($registry, null, [new DiviFeatureGate()]);
|
||||
}
|
||||
|
||||
private function ability(bool $readonly = true): Mockery\MockInterface
|
||||
/** @param list<DiviFeature>|null $features null = a tool that declares no Divi features */
|
||||
private function ability(bool $readonly = true, ?array $features = null): Mockery\MockInterface
|
||||
{
|
||||
$ability = Mockery::mock(Ability::class);
|
||||
$ability = $features === null
|
||||
? Mockery::mock(Ability::class)
|
||||
: Mockery::mock(Ability::class, RequiresDiviFeatures::class);
|
||||
if ($features !== null) {
|
||||
$ability->allows('diviFeatures')->andReturn($features);
|
||||
}
|
||||
$ability->allows('name')->andReturn('lit/test');
|
||||
$ability->allows('label')->andReturn('Test');
|
||||
$ability->allows('description')->andReturn('Test ability');
|
||||
@@ -222,15 +231,52 @@ final class AbilityRegistrarTest extends TestCase
|
||||
unlink($log);
|
||||
}
|
||||
|
||||
public function testUntestedDiviRefusesAWritingDiviToolBeforeItRuns(): void
|
||||
public function testALockedFeatureRefusesAWritingDiviToolBeforeItRuns(): void
|
||||
{
|
||||
ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
|
||||
$ability = $this->ability(false);
|
||||
DiviFeatureFixtures::lock(DiviFeature::PageWrite);
|
||||
$ability = $this->ability(false, [DiviFeature::PageRead, DiviFeature::PageWrite]);
|
||||
$ability->expects('execute')->never();
|
||||
|
||||
$args = $this->diviRegistrar($ability)->argsFor($ability);
|
||||
|
||||
self::assertToolError(($args['execute_callback'])([]), 'divi_untested');
|
||||
$payload = self::assertToolError(($args['execute_callback'])([]), 'divi_untested');
|
||||
self::assertSame(['page-write'], array_column($payload['features'], 'id'));
|
||||
}
|
||||
|
||||
public function testALockedFeatureRefusesAReadingDiviToolToo(): void
|
||||
{
|
||||
DiviFeatureFixtures::lock(DiviFeature::DesignRead, ReleaseState::Incompatible);
|
||||
$ability = $this->ability(true, [DiviFeature::PageRead, DiviFeature::DesignRead]);
|
||||
$ability->expects('execute')->never();
|
||||
|
||||
$args = $this->diviRegistrar($ability)->argsFor($ability);
|
||||
|
||||
self::assertToolError(($args['execute_callback'])([]), 'divi_incompatible');
|
||||
}
|
||||
|
||||
public function testAToolRunsAndKeepsItsResultWhileOnlyOtherFeaturesAreLocked(): void
|
||||
{
|
||||
DiviFeatureFixtures::lock(DiviFeature::PresetWrite);
|
||||
$ability = $this->ability(false, [DiviFeature::PageRead, DiviFeature::PageWrite]);
|
||||
$ability->expects('execute')->once()->with(['post_id' => 3])->andReturn(['ok' => true]);
|
||||
|
||||
$args = $this->diviRegistrar($ability)->argsFor($ability);
|
||||
|
||||
self::assertSame(['ok' => true], ($args['execute_callback'])(['post_id' => 3]), 'no notice is added (E2)');
|
||||
}
|
||||
|
||||
public function testAToolThatDeclaresNoFeaturesIsNotGated(): void
|
||||
{
|
||||
DiviFeatureFixtures::useStates(array_fill_keys(
|
||||
array_map(static fn (DiviFeature $feature): string => $feature->value, DiviFeature::cases()),
|
||||
ReleaseState::Incompatible
|
||||
));
|
||||
$ability = $this->ability(false);
|
||||
$ability->expects('execute')->once()->andReturn(['ok' => true]);
|
||||
|
||||
$args = $this->diviRegistrar($ability)->argsFor($ability);
|
||||
|
||||
self::assertSame(['ok' => true], ($args['execute_callback'])([]));
|
||||
}
|
||||
|
||||
public function testALockThrownInsideAWriteBecomesTheLockError(): void
|
||||
@@ -242,6 +288,18 @@ final class AbilityRegistrarTest extends TestCase
|
||||
self::assertToolError($this->executeLogged($ability), 'divi_incompatible');
|
||||
}
|
||||
|
||||
public function testAFeatureLockThrownInsideAWriteBecomesTheFeatureError(): void
|
||||
{
|
||||
DiviFeatureFixtures::lock(DiviFeature::LibraryWrite);
|
||||
$locked = new DiviLocked(ReleaseGate::status(), [DiviFeature::LibraryWrite]);
|
||||
$ability = $this->ability();
|
||||
$ability->allows('execute')->andThrow(WriteFailed::rolledBack('Nicht gespeichert.', $locked));
|
||||
|
||||
$payload = self::assertToolError($this->executeLogged($ability), 'divi_untested');
|
||||
|
||||
self::assertSame(['library-write'], array_column($payload['features'], 'id'));
|
||||
}
|
||||
|
||||
public function testAPartialWriteStaysPartialEvenWithALockInside(): void
|
||||
{
|
||||
$locked = new DiviLocked(new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
|
||||
@@ -408,13 +466,16 @@ final class AbilityRegistrarTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function testDiviReadToolsGetWarningsInTheirSchema(): void
|
||||
public function testDiviToolsGetWarningsInTheirSchemaOthersDoNot(): void
|
||||
{
|
||||
$ability = $this->ability();
|
||||
|
||||
$args = $this->diviRegistrar($ability)->argsFor($ability);
|
||||
$divi = $this->ability(true, [DiviFeature::PageRead]);
|
||||
$core = $this->ability();
|
||||
|
||||
$args = $this->diviRegistrar($divi)->argsFor($divi);
|
||||
self::assertArrayHasKey('warnings', $args['output_schema']['properties']);
|
||||
|
||||
$coreArgs = $this->diviRegistrar($core)->argsFor($core);
|
||||
self::assertSame(['type' => 'object'], $coreArgs['output_schema']);
|
||||
}
|
||||
|
||||
public function testRegisterAbilitiesCallsWordPressForEachAbility(): void
|
||||
|
||||
@@ -10,6 +10,8 @@ use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
|
||||
use LIT\MCP\Tests\Support\DiviFeatureFixtures;
|
||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
@@ -80,6 +82,30 @@ final class DiviContentComposerTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function testRefusesNothingWhileItsCapabilitiesAreUsable(): void
|
||||
{
|
||||
DiviFeatureFixtures::useStates([
|
||||
'preset-write' => ReleaseState::Incompatible,
|
||||
'library-write' => ReleaseState::Untested,
|
||||
'tb-write' => ReleaseState::Incompatible,
|
||||
]);
|
||||
|
||||
self::assertNull($this->composer()->refusal(), 'capabilities of other tools do not matter');
|
||||
}
|
||||
|
||||
public function testRefusesWithTheLockedCapabilitiesOfANewEntry(): void
|
||||
{
|
||||
DiviFeatureFixtures::useStates([
|
||||
'page-write' => ReleaseState::Untested,
|
||||
'dynamic-sources' => ReleaseState::Incompatible,
|
||||
'preset-write' => ReleaseState::Untested,
|
||||
]);
|
||||
|
||||
$payload = self::assertToolError($this->composer()->refusal(), 'divi_incompatible');
|
||||
|
||||
self::assertSame(['page-write', 'dynamic-sources'], array_column($payload['features'], 'id'));
|
||||
}
|
||||
|
||||
public function testChecksBeforeTheEntryExistsWithDivisMapForNoEntry(): void
|
||||
{
|
||||
self::assertNull($this->composer()->check('page', self::sections()));
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\DeclaresRequirements;
|
||||
use LIT\MCP\Integrations\Divi\DiviIntegration;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviFeatureGate;
|
||||
use LIT\MCP\Integrations\Divi\Release\FeatureSets;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
|
||||
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
|
||||
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
|
||||
use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Content\PostWriter;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use LIT\MCP\Tests\Support\DiviFeatureFixtures;
|
||||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use WP_Error;
|
||||
|
||||
final class DiviFeatureGateTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @param list<DiviFeature> $features What diviFeatures() returns for any input.
|
||||
* @return Ability&RequiresDiviFeatures
|
||||
*/
|
||||
private function tool(string $name, bool $readonly, array $features): Ability
|
||||
{
|
||||
$ability = Mockery::mock(Ability::class, RequiresDiviFeatures::class);
|
||||
$ability->allows('name')->andReturn($name);
|
||||
$ability->allows('annotations')->andReturn(['readonly' => $readonly]);
|
||||
$ability->allows('diviFeatures')->andReturn($features);
|
||||
|
||||
return $ability;
|
||||
}
|
||||
|
||||
private function gate(?FakeChangeLog $log = null): DiviFeatureGate
|
||||
{
|
||||
return new DiviFeatureGate($log);
|
||||
}
|
||||
|
||||
public function testReleasedDiviRefusesNothingAndAddsNothing(): void
|
||||
{
|
||||
$gate = $this->gate(FakeChangeLog::forbidden());
|
||||
$write = $this->tool('lit/divi-insert', false, [DiviFeature::PageWrite, DiviFeature::Catalog]);
|
||||
$read = $this->tool('lit/divi-outline', true, [DiviFeature::PageRead]);
|
||||
|
||||
self::assertNull($gate->refusal($write, []));
|
||||
self::assertNull($gate->refusal($read, []));
|
||||
self::assertSame(['ok' => true], $gate->annotate($read, ['ok' => true]));
|
||||
}
|
||||
|
||||
public function testAReadingToolIsRefusedWhenOneOfItsFeaturesIsLocked(): void
|
||||
{
|
||||
DiviFeatureFixtures::lock(DiviFeature::DesignRead);
|
||||
$read = $this->tool('lit/divi-outline', true, [DiviFeature::PageRead, DiviFeature::DesignRead]);
|
||||
|
||||
// A refused read changes nothing: no change log entry (the fake allows none).
|
||||
$error = $this->gate(FakeChangeLog::forbidden())->refusal($read, ['post_id' => 7]);
|
||||
$payload = self::assertToolError($error, 'divi_untested');
|
||||
|
||||
self::assertSame(423, $error->get_error_data()['status']);
|
||||
self::assertSame(['design-read'], array_column($payload['features'], 'id'));
|
||||
}
|
||||
|
||||
public function testAWritingToolIsRefusedWhenItsWriteFeatureIsLockedAsIncompatible(): void
|
||||
{
|
||||
DiviFeatureFixtures::lock(DiviFeature::PageWrite, ReleaseState::Incompatible);
|
||||
$log = new FakeChangeLog();
|
||||
$write = $this->tool('lit/divi-insert', false, [DiviFeature::PageRead, DiviFeature::PageWrite]);
|
||||
|
||||
$payload = self::assertToolError($this->gate($log)->refusal($write, []), 'divi_incompatible');
|
||||
|
||||
self::assertSame('incompatible', $payload['features'][0]['state']);
|
||||
self::assertSame(
|
||||
'Abgelehnt: Divi 5.3.0 – Seiten schreiben inkompatibel.',
|
||||
$log->records[0]['summary']
|
||||
);
|
||||
}
|
||||
|
||||
public function testAWritingToolIsRefusedWhenOnlyADeclaredReadFeatureIsLocked(): void
|
||||
{
|
||||
$write = $this->tool('lit/divi-insert', false, [
|
||||
DiviFeature::PageRead,
|
||||
DiviFeature::PageWrite,
|
||||
DiviFeature::Catalog,
|
||||
DiviFeature::DesignRead,
|
||||
]);
|
||||
|
||||
foreach ([DiviFeature::Catalog, DiviFeature::DesignRead, DiviFeature::PageRead] as $readFeature) {
|
||||
DiviFeatureFixtures::lock($readFeature);
|
||||
$payload = self::assertToolError(
|
||||
$this->gate(new FakeChangeLog())->refusal($write, []),
|
||||
'divi_untested'
|
||||
);
|
||||
self::assertSame([$readFeature->value], array_column($payload['features'], 'id'));
|
||||
}
|
||||
|
||||
DiviFeatureFixtures::lock(DiviFeature::LibraryRead);
|
||||
self::assertNull(
|
||||
$this->gate(FakeChangeLog::forbidden())->refusal($write, []),
|
||||
'an unrelated locked read feature does not refuse'
|
||||
);
|
||||
}
|
||||
|
||||
public function testAToolThatDoesNotNeedTheLockedFeatureRuns(): void
|
||||
{
|
||||
DiviFeatureFixtures::useStates([
|
||||
'preset-write' => ReleaseState::Incompatible,
|
||||
'library-write' => ReleaseState::Untested,
|
||||
'tb-write' => ReleaseState::Incompatible,
|
||||
]);
|
||||
$gate = $this->gate(FakeChangeLog::forbidden());
|
||||
$insert = $this->tool('lit/divi-insert', false, [
|
||||
DiviFeature::PageRead,
|
||||
DiviFeature::PageWrite,
|
||||
DiviFeature::Catalog,
|
||||
DiviFeature::DesignRead,
|
||||
DiviFeature::DynamicSources,
|
||||
]);
|
||||
|
||||
self::assertNull($gate->refusal($insert, []), 'a write tool runs while only other write features are locked');
|
||||
self::assertNull($gate->refusal($this->tool('lit/divi-catalog', true, [DiviFeature::Catalog]), []));
|
||||
self::assertNull($gate->refusal($this->tool('lit/divi-design-snapshots', true, []), []), 'declares nothing');
|
||||
}
|
||||
|
||||
public function testTheErrorNamesOnlyTheUnusableFeaturesOfTheCallEachOnce(): void
|
||||
{
|
||||
DiviFeatureFixtures::useStates([
|
||||
'page-write' => ReleaseState::Untested,
|
||||
'roles' => ReleaseState::Untested,
|
||||
'tb-write' => ReleaseState::Incompatible,
|
||||
]);
|
||||
$tool = $this->tool('lit/divi-insert', false, [
|
||||
DiviFeature::PageWrite,
|
||||
DiviFeature::Roles,
|
||||
DiviFeature::PageWrite,
|
||||
DiviFeature::Catalog,
|
||||
]);
|
||||
|
||||
$payload = self::assertToolError($this->gate(new FakeChangeLog())->refusal($tool, []), 'divi_untested');
|
||||
|
||||
self::assertSame(['page-write', 'roles'], array_column($payload['features'], 'id'));
|
||||
self::assertSame(
|
||||
['Seiten schreiben', 'Divi-Rollen'],
|
||||
array_column($payload['features'], 'label')
|
||||
);
|
||||
self::assertSame('untested', $payload['features'][0]['state']);
|
||||
self::assertIsArray($payload['features'][0]['problems']);
|
||||
self::assertStringContainsString('Seiten schreiben', $payload['hint']);
|
||||
self::assertStringContainsString('Divi-Rollen', $payload['hint']);
|
||||
self::assertStringContainsString('weiter', $payload['hint'], 'other Divi tools keep working');
|
||||
}
|
||||
|
||||
public function testAnIncompatibleFeatureAmongUntestedOnesMakesTheCodeIncompatible(): void
|
||||
{
|
||||
DiviFeatureFixtures::useStates([
|
||||
'page-write' => ReleaseState::Untested,
|
||||
'catalog' => ReleaseState::Incompatible,
|
||||
]);
|
||||
$tool = $this->tool('lit/divi-update', false, [DiviFeature::PageWrite, DiviFeature::Catalog]);
|
||||
|
||||
self::assertToolError($this->gate(new FakeChangeLog())->refusal($tool, []), 'divi_incompatible');
|
||||
}
|
||||
|
||||
public function testTheToolIsAskedForTheInputOfTheCall(): void
|
||||
{
|
||||
DiviFeatureFixtures::lock(DiviFeature::LibraryWrite);
|
||||
$tool = Mockery::mock(Ability::class, RequiresDiviFeatures::class);
|
||||
$tool->allows('name')->andReturn('lit/divi-insert');
|
||||
$tool->allows('annotations')->andReturn(['readonly' => false]);
|
||||
$tool->allows('diviFeatures')->with(['post_id' => 1])->andReturn([DiviFeature::PageWrite]);
|
||||
$tool->allows('diviFeatures')->with(['post_id' => 2])
|
||||
->andReturn([DiviFeature::PageWrite, DiviFeature::LibraryWrite]);
|
||||
$gate = $this->gate(new FakeChangeLog());
|
||||
|
||||
self::assertNull($gate->refusal($tool, ['post_id' => 1]));
|
||||
self::assertToolError($gate->refusal($tool, ['post_id' => 2]), 'divi_untested');
|
||||
}
|
||||
|
||||
public function testAToolWithoutDeclarationIsNeverTouchedEvenWithEverythingLocked(): void
|
||||
{
|
||||
DiviFeatureFixtures::useStates(array_fill_keys(
|
||||
array_map(static fn (DiviFeature $f): string => $f->value, DiviFeature::cases()),
|
||||
ReleaseState::Incompatible
|
||||
));
|
||||
$plain = Mockery::mock(Ability::class);
|
||||
$plain->allows('name')->andReturn('lit/content-update');
|
||||
|
||||
self::assertNull($this->gate(FakeChangeLog::forbidden())->refusal($plain, ['post_id' => 1]));
|
||||
}
|
||||
|
||||
private function contentCreate(?ContentComposer $composer): ContentCreateAbility
|
||||
{
|
||||
return new ContentCreateAbility(
|
||||
Mockery::mock(ContentSchema::class),
|
||||
Mockery::mock(PostWriter::class),
|
||||
FakeChangeLog::forbidden(),
|
||||
$composer
|
||||
);
|
||||
}
|
||||
|
||||
private function composerNeeding(string ...$ids): ContentComposer
|
||||
{
|
||||
$composer = Mockery::mock(ContentComposer::class);
|
||||
$composer->allows('requirements')->andReturn(array_values($ids));
|
||||
|
||||
return $composer;
|
||||
}
|
||||
|
||||
public function testContentCreateWithoutDiviIsUntouchedWhateverIsLocked(): void
|
||||
{
|
||||
DiviFeatureFixtures::lock(DiviFeature::PageWrite, ReleaseState::Incompatible);
|
||||
$tool = $this->contentCreate($this->composerNeeding('page-write'));
|
||||
|
||||
self::assertInstanceOf(DeclaresRequirements::class, $tool);
|
||||
self::assertNull(
|
||||
$this->gate(FakeChangeLog::forbidden())->refusal($tool, ['post_type' => 'page', 'title' => 'Start'])
|
||||
);
|
||||
}
|
||||
|
||||
public function testContentCreateWithDiviIsRefusedWhenAFeatureOfTheComposerIsLocked(): void
|
||||
{
|
||||
DiviFeatureFixtures::lock(DiviFeature::PageWrite);
|
||||
$log = new FakeChangeLog();
|
||||
$tool = $this->contentCreate($this->composerNeeding('page-read', 'page-write', 'catalog'));
|
||||
$input = ['post_type' => 'page', 'title' => 'Start', 'divi' => [['type' => 'section']]];
|
||||
|
||||
$payload = self::assertToolError($this->gate($log)->refusal($tool, $input), 'divi_untested');
|
||||
|
||||
self::assertSame(['page-write'], array_column($payload['features'], 'id'));
|
||||
self::assertSame('lit/content-create', $log->records[0]['ability']);
|
||||
}
|
||||
|
||||
public function testContentCreateWithDiviRunsWhenOnlyOtherFeaturesAreLocked(): void
|
||||
{
|
||||
DiviFeatureFixtures::useStates([
|
||||
'preset-write' => ReleaseState::Untested,
|
||||
'tb-write' => ReleaseState::Untested,
|
||||
]);
|
||||
$tool = $this->contentCreate($this->composerNeeding('page-read', 'page-write', 'catalog'));
|
||||
|
||||
self::assertNull(
|
||||
$this->gate(FakeChangeLog::forbidden())->refusal($tool, ['post_type' => 'page', 'divi' => [[]]])
|
||||
);
|
||||
}
|
||||
|
||||
public function testAnUnknownRequirementOfTheCoreIsIgnored(): void
|
||||
{
|
||||
DiviFeatureFixtures::lock(DiviFeature::PageWrite);
|
||||
$tool = $this->contentCreate($this->composerNeeding('something-else'));
|
||||
|
||||
self::assertNull(
|
||||
$this->gate(FakeChangeLog::forbidden())->refusal($tool, ['post_type' => 'page', 'divi' => [[]]])
|
||||
);
|
||||
}
|
||||
|
||||
public function testAnnotateNeverChangesAResult(): void
|
||||
{
|
||||
DiviFeatureFixtures::lock(DiviFeature::PageWrite);
|
||||
$gate = $this->gate();
|
||||
$read = $this->tool('lit/divi-outline', true, [DiviFeature::PageRead]);
|
||||
$error = new WP_Error('lit_not_found', 'x');
|
||||
|
||||
self::assertSame(['post_id' => 7], $gate->annotate($read, ['post_id' => 7]), 'E2: no notice any more');
|
||||
self::assertSame(['warnings' => ['alt']], $gate->annotate($read, ['warnings' => ['alt']]));
|
||||
self::assertSame([1, 2], $gate->annotate($read, [1, 2]));
|
||||
self::assertSame($error, $gate->annotate($read, $error));
|
||||
}
|
||||
|
||||
public function testDiviToolsKeepTheOptionalWarningsPropertyInTheirOutputSchema(): void
|
||||
{
|
||||
$gate = $this->gate();
|
||||
$read = $this->tool('lit/divi-outline', true, []);
|
||||
|
||||
$properties = ['post_id' => ['type' => 'integer']];
|
||||
$schema = $gate->outputSchema($read, ['type' => 'object', 'properties' => $properties]);
|
||||
$warnings = ['type' => 'array', 'items' => ['type' => 'string']];
|
||||
self::assertSame($warnings, $schema['properties']['warnings'] ?? null);
|
||||
self::assertSame($properties['post_id'], $schema['properties']['post_id']);
|
||||
|
||||
$own = ['type' => 'object', 'properties' => ['warnings' => ['type' => 'array']]];
|
||||
self::assertSame($own, $gate->outputSchema($read, $own), 'an existing warnings property stays');
|
||||
self::assertNull($gate->outputSchema($read, null));
|
||||
self::assertSame(['type' => 'array'], $gate->outputSchema($read, ['type' => 'array']));
|
||||
}
|
||||
|
||||
public function testToolsOfTheCoreKeepTheirOutputSchema(): void
|
||||
{
|
||||
$gate = $this->gate();
|
||||
$create = $this->contentCreate(null);
|
||||
|
||||
self::assertSame(['type' => 'object'], $gate->outputSchema($create, ['type' => 'object']));
|
||||
self::assertSame(
|
||||
['type' => 'object'],
|
||||
$gate->outputSchema(Mockery::mock(Ability::class), ['type' => 'object'])
|
||||
);
|
||||
}
|
||||
|
||||
public function testLockForIsNullForUsableFeaturesAndNamesTheUnusableOnesOtherwise(): void
|
||||
{
|
||||
self::assertNull(DiviFeatureGate::lockFor([]));
|
||||
self::assertNull(DiviFeatureGate::lockFor([DiviFeature::PageWrite]));
|
||||
|
||||
DiviFeatureFixtures::lock(DiviFeature::Catalog);
|
||||
$lock = DiviFeatureGate::lockFor([DiviFeature::PageWrite, DiviFeature::Catalog, DiviFeature::Catalog]);
|
||||
|
||||
self::assertNotNull($lock);
|
||||
self::assertSame([DiviFeature::Catalog], $lock->features);
|
||||
self::assertSame('5.3.0', $lock->status->version);
|
||||
}
|
||||
|
||||
/**
|
||||
* The real tools of the three Divi groups: locking one capability refuses exactly the tools that declare it
|
||||
* (reading and writing alike) and no other.
|
||||
*/
|
||||
public function testLockingOneFeatureRefusesExactlyTheRealToolsThatDeclareIt(): void
|
||||
{
|
||||
Functions\when('__')->returnArg();
|
||||
Functions\when('get_post_type')->justReturn(false);
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
$divi = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden());
|
||||
$tools = [...$divi->abilities(), ...$divi->library()->abilities(), ...$divi->themeBuilder()->abilities()];
|
||||
self::assertNotEmpty($tools);
|
||||
|
||||
foreach (DiviFeature::cases() as $feature) {
|
||||
DiviFeatureFixtures::lock($feature);
|
||||
$gate = $this->gate(FakeChangeLog::any());
|
||||
foreach ($tools as $tool) {
|
||||
$declares = in_array($feature, FeatureSets::declaredBy($tool, []), true);
|
||||
self::assertSame(
|
||||
$declares,
|
||||
$gate->refusal($tool, []) instanceof WP_Error,
|
||||
$tool->name() . ' / ' . $feature->value
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviToolGate;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
|
||||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use WP_Error;
|
||||
|
||||
final class DiviToolGateTest extends TestCase
|
||||
{
|
||||
private const GROUPS = [
|
||||
'lit/divi-insert' => 'divi',
|
||||
'lit/divi-outline' => 'divi',
|
||||
'lit/library-save' => 'divi-library',
|
||||
'lit/tb-create-template' => 'divi-theme-builder',
|
||||
'lit/content-create' => 'wordpress',
|
||||
];
|
||||
|
||||
private function ability(string $name, bool $readonly): Ability
|
||||
{
|
||||
$ability = Mockery::mock(Ability::class);
|
||||
$ability->allows('name')->andReturn($name);
|
||||
$ability->allows('annotations')->andReturn(['readonly' => $readonly]);
|
||||
|
||||
return $ability;
|
||||
}
|
||||
|
||||
private function gate(?FakeChangeLog $log = null): DiviToolGate
|
||||
{
|
||||
return new DiviToolGate(static fn (): array => self::GROUPS, $log);
|
||||
}
|
||||
|
||||
private function untested(): void
|
||||
{
|
||||
ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
|
||||
}
|
||||
|
||||
public function testReleasedDiviRefusesNothingAndAddsNothing(): void
|
||||
{
|
||||
$gate = $this->gate(FakeChangeLog::forbidden());
|
||||
|
||||
self::assertNull($gate->refusal($this->ability('lit/divi-insert', false), []));
|
||||
self::assertSame(['ok' => true], $gate->annotate($this->ability('lit/divi-outline', true), ['ok' => true]));
|
||||
}
|
||||
|
||||
public function testWriteToolOfEveryDiviGroupIsRefused(): void
|
||||
{
|
||||
$this->untested();
|
||||
$log = new FakeChangeLog();
|
||||
$gate = $this->gate($log);
|
||||
|
||||
foreach (['lit/divi-insert', 'lit/library-save', 'lit/tb-create-template'] as $name) {
|
||||
self::assertToolError($gate->refusal($this->ability($name, false), []), 'divi_untested', $name);
|
||||
}
|
||||
self::assertNull($gate->refusal($this->ability('lit/content-create', false), []), 'not a Divi tool (adapter lock)');
|
||||
self::assertNull($gate->refusal($this->ability('lit/divi-outline', true), []), 'reading stays allowed');
|
||||
self::assertSame(['refused', 'refused', 'refused'], array_column($log->records, 'result'));
|
||||
self::assertSame('Abgelehnt: Divi 5.3.0 ist nicht freigegeben.', $log->records[0]['summary']);
|
||||
}
|
||||
|
||||
public function testReadResultsKeepTheirShapeAndGetTheNotice(): void
|
||||
{
|
||||
$this->untested();
|
||||
$gate = $this->gate();
|
||||
$read = $this->ability('lit/divi-outline', true);
|
||||
$notice = 'Achtung: Divi 5.3.0 ist für LIT MCP ungetestet; Lesen funktioniert, Schreiben ist gesperrt.';
|
||||
|
||||
self::assertSame(['post_id' => 7, 'warnings' => [$notice]], $gate->annotate($read, ['post_id' => 7]));
|
||||
self::assertSame(
|
||||
['warnings' => ['alt', $notice]],
|
||||
$gate->annotate($read, ['warnings' => ['alt']])
|
||||
);
|
||||
self::assertSame([1, 2], $gate->annotate($read, [1, 2]), 'a list stays a list');
|
||||
$error = new WP_Error('lit_not_found', 'x');
|
||||
self::assertSame($error, $gate->annotate($read, $error));
|
||||
$other = $this->ability('lit/content-create', true);
|
||||
self::assertSame(['post_id' => 7], $gate->annotate($other, ['post_id' => 7]));
|
||||
}
|
||||
|
||||
public function testOutputSchemaAllowsWarnings(): void
|
||||
{
|
||||
$gate = $this->gate();
|
||||
$read = $this->ability('lit/divi-outline', true);
|
||||
|
||||
$properties = ['post_id' => ['type' => 'integer']];
|
||||
$schema = $gate->outputSchema($read, ['type' => 'object', 'properties' => $properties]);
|
||||
$warnings = ['type' => 'array', 'items' => ['type' => 'string']];
|
||||
self::assertSame($warnings, $schema['properties']['warnings'] ?? null);
|
||||
|
||||
$own = ['type' => 'object', 'properties' => ['warnings' => ['type' => 'array']]];
|
||||
self::assertSame($own, $gate->outputSchema($read, $own), 'an existing warnings property stays');
|
||||
self::assertNull($gate->outputSchema($read, null));
|
||||
self::assertSame(
|
||||
['type' => 'object'],
|
||||
$gate->outputSchema($this->ability('lit/content-create', true), ['type' => 'object'])
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user