Neu: Tier, Profile, Compatibility, CapabilityLocked, CapabilityGate, CompatibilityComponent, ProvidesCompatibility, DiviProfile sowie PodsFeature, PodsCompatibility und PodsIntegration. Das CapabilityGate sperrt gesperrte und unbekannte Fähigkeits-IDs (übernimmt die Sperre unbekannter IDs vom DiviFeatureGate) und delegiert nur Divi ausdrücklich. Pods-Schema, -Lesen, -Schreiben und -Feldrechte laufen über usable(PodsFeature::…); Schreiben verlangt FieldRead und FieldWrite, damit ein Rollback keine Werte verliert. lit-site-status meldet Pods über die CompatibilityComponent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
351 lines
15 KiB
PHP
351 lines
15 KiB
PHP
<?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\Compatibility\ReleaseState;
|
||
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\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('available')->andReturn(true);
|
||
$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 testContentCreateWithDiviOnASiteWithoutDiviIsLeftToTheToolItself(): void
|
||
{
|
||
DiviFeatureFixtures::lock(DiviFeature::PageWrite, ReleaseState::Incompatible);
|
||
$composer = Mockery::mock(ContentComposer::class);
|
||
$composer->allows('available')->andReturn(false);
|
||
$composer->shouldNotReceive('requirements');
|
||
$tool = $this->contentCreate($composer);
|
||
|
||
self::assertNull(
|
||
$this->gate(FakeChangeLog::forbidden())->refusal($tool, ['post_type' => 'page', 'divi' => [[]]]),
|
||
'no divi_incompatible and no refused entry: execute() answers "Divi 5 ist nicht aktiv"'
|
||
);
|
||
}
|
||
|
||
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' => [[]]])
|
||
);
|
||
}
|
||
|
||
/** The lock of IDs that are no Divi capability is CapabilityGate's (unknown ones fail closed there). */
|
||
public function testIdsThatAreNoDiviCapabilityAreIgnored(): void
|
||
{
|
||
DiviFeatureFixtures::useStates([
|
||
'page-write' => ReleaseState::Incompatible,
|
||
'catalog' => ReleaseState::Incompatible,
|
||
]);
|
||
$tool = $this->contentCreate($this->composerNeeding('pods-field-read', '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'])
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|