diff --git a/src/Integrations/Divi/Pages/DiviContentComposer.php b/src/Integrations/Divi/Pages/DiviContentComposer.php index aac0373..13b7d67 100644 --- a/src/Integrations/Divi/Pages/DiviContentComposer.php +++ b/src/Integrations/Divi/Pages/DiviContentComposer.php @@ -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 diff --git a/src/Integrations/Divi/Release/DiviFeatureGate.php b/src/Integrations/Divi/Release/DiviFeatureGate.php new file mode 100644 index 0000000..fb7483a --- /dev/null +++ b/src/Integrations/Divi/Release/DiviFeatureGate.php @@ -0,0 +1,112 @@ + $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|null $schema + * @return array|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)); + } +} diff --git a/src/Integrations/Divi/Release/DiviToolGate.php b/src/Integrations/Divi/Release/DiviToolGate.php deleted file mode 100644 index e620b9d..0000000 --- a/src/Integrations/Divi/Release/DiviToolGate.php +++ /dev/null @@ -1,108 +0,0 @@ - */ - private Closure $groups; - - /** - * @param Closure(): array $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|null $schema - * @return array|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; - } -} diff --git a/src/Integrations/Divi/Release/ReleaseStatus.php b/src/Integrations/Divi/Release/ReleaseStatus.php index 67dc625..15903f5 100644 --- a/src/Integrations/Divi/Release/ReleaseStatus.php +++ b/src/Integrations/Divi/Release/ReleaseStatus.php @@ -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()); diff --git a/src/Plugin.php b/src/Plugin.php index 00c7fe0..90538b6 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -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, diff --git a/tests/Support/DiviFeatureFixtures.php b/tests/Support/DiviFeatureFixtures.php new file mode 100644 index 0000000..ef0869d --- /dev/null +++ b/tests/Support/DiviFeatureFixtures.php @@ -0,0 +1,48 @@ + $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]); + } +} diff --git a/tests/Unit/Abilities/AbilityRegistrarTest.php b/tests/Unit/Abilities/AbilityRegistrarTest.php index 467e9d7..59bea30 100644 --- a/tests/Unit/Abilities/AbilityRegistrarTest.php +++ b/tests/Unit/Abilities/AbilityRegistrarTest.php @@ -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|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 diff --git a/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php b/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php index 8020c92..32d7b7d 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php @@ -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())); diff --git a/tests/Unit/Integrations/Divi/Release/DiviFeatureGateTest.php b/tests/Unit/Integrations/Divi/Release/DiviFeatureGateTest.php new file mode 100644 index 0000000..44c5d7c --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/DiviFeatureGateTest.php @@ -0,0 +1,344 @@ + $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 + ); + } + } + } +} diff --git a/tests/Unit/Integrations/Divi/Release/DiviToolGateTest.php b/tests/Unit/Integrations/Divi/Release/DiviToolGateTest.php deleted file mode 100644 index f8ca2e3..0000000 --- a/tests/Unit/Integrations/Divi/Release/DiviToolGateTest.php +++ /dev/null @@ -1,106 +0,0 @@ - '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']) - ); - } -}