refactor: ToolAvailability ersetzt die sechs Referenz-Closures in Plugin (Frage vor der Verdrahtung wirft statt still false); DiviIntegration bekommt den Tools-Tab-Zustand als Objekt; Plugin::integrations() mit Verdrahtungstest für Reihenfolge, einmaliges Booten und spätes Binden
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6c59934caa
commit
e6e0f914ba
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Abilities;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\ExposurePolicy;
|
||||
use LIT\MCP\Abilities\ToolAvailability;
|
||||
use LIT\MCP\Integrations\Integration;
|
||||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||
use LogicException;
|
||||
use Mockery;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ToolAvailabilityTest extends TestCase
|
||||
{
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private static function registry(string ...$names): IntegrationRegistry
|
||||
{
|
||||
$abilities = array_map(static function (string $name): Ability {
|
||||
$ability = Mockery::mock(Ability::class);
|
||||
$ability->allows('name')->andReturn($name);
|
||||
$ability->allows('isDirect')->andReturn(false);
|
||||
|
||||
return $ability;
|
||||
}, $names);
|
||||
$integration = Mockery::mock(Integration::class);
|
||||
$integration->allows('id')->andReturn('divi');
|
||||
$integration->allows('isAvailable')->andReturn(true);
|
||||
$integration->allows('abilities')->andReturn($abilities);
|
||||
|
||||
return new IntegrationRegistry($integration);
|
||||
}
|
||||
|
||||
/** The temporal coupling of the former use-by-reference closures: they answered false until Plugin set $policy. */
|
||||
public function testAQuestionBeforeBindIsAWiringErrorNotFalse(): void
|
||||
{
|
||||
$tools = new ToolAvailability();
|
||||
|
||||
foreach ([
|
||||
static fn (): bool => $tools->groupEnabled('divi-library'),
|
||||
static fn (): bool => $tools->groupWritable('divi-library'),
|
||||
static fn (): bool => $tools->allows('lit/divi-insert'),
|
||||
] as $ask) {
|
||||
try {
|
||||
$ask();
|
||||
self::fail('answered before bind()');
|
||||
} catch (LogicException $e) {
|
||||
self::assertSame('ToolAvailability asked before Plugin bound it.', $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A gate built before bind() (as DiviIntegration builds its gates) answers from the policy bound later. */
|
||||
public function testAGateBuiltBeforeBindResolvesLazily(): void
|
||||
{
|
||||
$tools = new ToolAvailability();
|
||||
$libraryVisible = static fn (): bool => $tools->groupEnabled('divi-library');
|
||||
$libraryWritable = static fn (): bool => $tools->groupWritable('divi-library');
|
||||
$diviWritable = static fn (): bool => $tools->allows('lit/divi-insert');
|
||||
|
||||
$tools->bind(self::registry('lit/divi-insert'), new ExposurePolicy(['@readonly:divi-library' => true]));
|
||||
|
||||
self::assertTrue($libraryVisible());
|
||||
self::assertFalse($libraryWritable());
|
||||
self::assertTrue($diviWritable());
|
||||
}
|
||||
|
||||
public function testFollowsTheBoundPolicy(): void
|
||||
{
|
||||
$tools = new ToolAvailability();
|
||||
$tools->bind(
|
||||
self::registry('lit/divi-insert', 'lit/library-insert'),
|
||||
new ExposurePolicy([
|
||||
'@group:divi-theme-builder' => ExposurePolicy::DISABLED,
|
||||
'lit/library-insert' => ExposurePolicy::DISABLED,
|
||||
])
|
||||
);
|
||||
|
||||
self::assertFalse($tools->groupEnabled('divi-theme-builder'));
|
||||
self::assertFalse($tools->groupWritable('divi-theme-builder'));
|
||||
self::assertTrue($tools->groupEnabled('divi-library'));
|
||||
self::assertTrue($tools->groupWritable('divi-library'));
|
||||
self::assertTrue($tools->allows('lit/divi-insert'));
|
||||
self::assertFalse($tools->allows('lit/library-insert'));
|
||||
self::assertFalse($tools->allows('lit/unknown'), 'not registered = not allowed (ExposurePolicy::allows)');
|
||||
}
|
||||
|
||||
public function testBindsOnlyOnce(): void
|
||||
{
|
||||
$tools = new ToolAvailability();
|
||||
$tools->bind(self::registry(), new ExposurePolicy());
|
||||
|
||||
$this->expectException(LogicException::class);
|
||||
$this->expectExceptionMessage('ToolAvailability is already bound.');
|
||||
$tools->bind(self::registry(), new ExposurePolicy());
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ use Brain\Monkey\Actions;
|
||||
use Brain\Monkey\Filters;
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\ExposurePolicy;
|
||||
use LIT\MCP\Abilities\ToolAvailability;
|
||||
use LIT\MCP\Admin\AdminTab;
|
||||
use LIT\MCP\Admin\DiviTab;
|
||||
use LIT\MCP\Admin\SettingsPage;
|
||||
@@ -19,6 +21,8 @@ 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\ReleaseResolver;
|
||||
use LIT\MCP\Integrations\Integration;
|
||||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||
use LIT\MCP\Integrations\Media\MediaUsageSource;
|
||||
use LIT\MCP\Integrations\WordPress\BuilderDetector;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
@@ -183,13 +187,29 @@ final class DiviIntegrationTest extends TestCase
|
||||
self::assertSame(1, $report->blockedHidden);
|
||||
}
|
||||
|
||||
/** A Tools-tab state bound to $policy, with lit/divi-insert registered (the Divi group available). */
|
||||
private static function tools(ExposurePolicy $policy = new ExposurePolicy()): ToolAvailability
|
||||
{
|
||||
$insert = Mockery::mock(Ability::class);
|
||||
$insert->allows('name')->andReturn('lit/divi-insert');
|
||||
$insert->allows('isDirect')->andReturn(true);
|
||||
$divi = Mockery::mock(Integration::class);
|
||||
$divi->allows('id')->andReturn('divi');
|
||||
$divi->allows('isAvailable')->andReturn(true);
|
||||
$divi->allows('abilities')->andReturn([$insert]);
|
||||
$tools = new ToolAvailability();
|
||||
$tools->bind(new IntegrationRegistry($divi), $policy);
|
||||
|
||||
return $tools;
|
||||
}
|
||||
|
||||
/** Ruling P9 (E9): lit/divi-node-copy follows lit/divi-insert's Tools-tab state. */
|
||||
public function testNodeCopyFollowsLitDiviInsert(): void
|
||||
{
|
||||
$integration = new DiviIntegration(
|
||||
Mockery::mock(ContentSchema::class),
|
||||
FakeChangeLog::forbidden(),
|
||||
diviWritable: static fn (): bool => false
|
||||
self::tools(new ExposurePolicy(['lit/divi-insert' => ExposurePolicy::DISABLED]))
|
||||
);
|
||||
$copy = null;
|
||||
foreach ($integration->abilities() as $ability) {
|
||||
@@ -202,6 +222,22 @@ final class DiviIntegrationTest extends TestCase
|
||||
self::assertToolError($copy->permission(['post_id' => 7, 'source_post_id' => 8]), 'unsupported');
|
||||
}
|
||||
|
||||
/** Security M2: content-create's divi input follows lit/divi-insert, asked when content-create asks. */
|
||||
public function testTheShortFormFollowsLitDiviInsert(): void
|
||||
{
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$open = new DiviIntegration($schema, FakeChangeLog::forbidden(), self::tools());
|
||||
$closed = new DiviIntegration(
|
||||
$schema,
|
||||
FakeChangeLog::forbidden(),
|
||||
self::tools(new ExposurePolicy(['lit/divi-insert' => ExposurePolicy::DISABLED]))
|
||||
);
|
||||
|
||||
self::assertTrue($open->contentComposer()->writable());
|
||||
self::assertFalse($closed->contentComposer()->writable());
|
||||
self::assertTrue((new DiviIntegration($schema, FakeChangeLog::forbidden()))->contentComposer()->writable());
|
||||
}
|
||||
|
||||
public function testGivesContentCreateTheDiviShortForm(): void
|
||||
{
|
||||
$integration = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden());
|
||||
@@ -221,21 +257,22 @@ final class DiviIntegrationTest extends TestCase
|
||||
$outline = static fn (DiviIntegration $divi): mixed => $divi->abilities()[0]->permission(['post_id' => 1055]);
|
||||
|
||||
// No Divi in unit tests: DiviRoles allows nothing, so the entry is not found either way (ruling P12);
|
||||
// the wiring shows in which gate the integration asks.
|
||||
$asked = 0;
|
||||
$visible = new DiviIntegration($schema, FakeChangeLog::forbidden(), null, static function () use (&$asked): bool {
|
||||
++$asked;
|
||||
|
||||
return true;
|
||||
});
|
||||
// the wiring shows in which gate the integration asks: an unbound ToolAvailability throws when asked.
|
||||
$visible = new DiviIntegration($schema, FakeChangeLog::forbidden(), self::tools());
|
||||
self::assertToolError($outline($visible), 'not_found');
|
||||
self::assertSame(1, $asked);
|
||||
self::assertToolError($outline(new DiviIntegration($schema, FakeChangeLog::forbidden())), 'not_found');
|
||||
|
||||
$hidden = new DiviIntegration($schema, FakeChangeLog::forbidden(), libraryVisible: static fn (): bool => false);
|
||||
$hidden = new DiviIntegration(
|
||||
$schema,
|
||||
FakeChangeLog::forbidden(),
|
||||
self::tools(new ExposurePolicy(['@group:divi-library' => ExposurePolicy::DISABLED]))
|
||||
);
|
||||
$refusal = $outline($hidden);
|
||||
self::assertInstanceOf(WP_Error::class, $refusal);
|
||||
self::assertToolError($refusal, 'not_found');
|
||||
|
||||
$this->expectExceptionMessage('ToolAvailability asked before Plugin bound it.');
|
||||
$outline(new DiviIntegration($schema, FakeChangeLog::forbidden(), new ToolAvailability()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,16 +284,16 @@ final class DiviIntegrationTest extends TestCase
|
||||
{
|
||||
LibraryFixtures::given([]);
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$asked = 0;
|
||||
$hidden = new DiviIntegration($schema, FakeChangeLog::forbidden(), libraryVisible: static function () use (&$asked): bool {
|
||||
++$asked;
|
||||
|
||||
return false;
|
||||
});
|
||||
$hidden = new DiviIntegration(
|
||||
$schema,
|
||||
FakeChangeLog::forbidden(),
|
||||
self::tools(new ExposurePolicy(['@group:divi-library' => ExposurePolicy::DISABLED]))
|
||||
);
|
||||
|
||||
self::assertFalse($hidden->libraryIdsVisible());
|
||||
self::assertSame(1, $asked);
|
||||
self::assertFalse((new DiviIntegration($schema, FakeChangeLog::forbidden()))->libraryIdsVisible());
|
||||
$this->expectExceptionMessage('ToolAvailability asked before Plugin bound it.');
|
||||
(new DiviIntegration($schema, FakeChangeLog::forbidden(), new ToolAvailability()))->libraryIdsVisible();
|
||||
}
|
||||
|
||||
/** Bootable: Divi's own hooks – upgrader cache reset, check route, tab "Divi" after the plugin's tabs. */
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit;
|
||||
|
||||
use Brain\Monkey\Actions;
|
||||
use Brain\Monkey\Filters;
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Abilities\ExposurePolicy;
|
||||
use LIT\MCP\Abilities\ToolAvailability;
|
||||
use LIT\MCP\Admin\SettingsPage;
|
||||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use LIT\MCP\Limits;
|
||||
use LIT\MCP\Plugin;
|
||||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||
use Mockery;
|
||||
|
||||
/** The wiring of the composition root: Plugin::integrations() and the steps Plugin::boot() takes with it. */
|
||||
final class PluginTest extends TestCase
|
||||
{
|
||||
private static function registry(ToolAvailability $tools = new ToolAvailability()): IntegrationRegistry
|
||||
{
|
||||
return Plugin::integrations(
|
||||
Mockery::mock(ContentSchema::class),
|
||||
FakeChangeLog::forbidden(),
|
||||
new Limits(),
|
||||
$tools
|
||||
);
|
||||
}
|
||||
|
||||
/** Registry order = order of the Tools tab, the guide and the server instructions. */
|
||||
public function testIntegrationsInRegistryOrder(): void
|
||||
{
|
||||
self::assertSame(
|
||||
['core', 'wordpress', 'media', 'divi', 'divi-library', 'divi-theme-builder'],
|
||||
self::registry()->ids()
|
||||
);
|
||||
}
|
||||
|
||||
/** Only the Divi integration has boot steps; a second boot registers nothing twice. */
|
||||
public function testTheBootableIntegrationsBootExactlyOnce(): void
|
||||
{
|
||||
Actions\expectAdded('upgrader_process_complete')->once();
|
||||
Actions\expectAdded('rest_api_init')->once();
|
||||
Filters\expectAdded(SettingsPage::TABS_FILTER)->once();
|
||||
$registry = self::registry();
|
||||
|
||||
$registry->boot();
|
||||
$registry->boot();
|
||||
}
|
||||
|
||||
/**
|
||||
* No temporal coupling: building and booting the integrations never asks the Tools-tab state (an unbound
|
||||
* ToolAvailability throws when asked); bound afterwards, it answers from the bound policy.
|
||||
*/
|
||||
public function testTheToolsTabStateIsAskedOnlyAfterBind(): void
|
||||
{
|
||||
Functions\stubTranslationFunctions();
|
||||
$tools = new ToolAvailability();
|
||||
$registry = self::registry($tools);
|
||||
$registry->boot();
|
||||
|
||||
$tools->bind($registry, new ExposurePolicy(['@group:divi-library' => ExposurePolicy::DISABLED]));
|
||||
|
||||
self::assertFalse($tools->groupEnabled('divi-library'));
|
||||
self::assertTrue($tools->groupWritable('divi-theme-builder'));
|
||||
self::assertFalse($tools->allows('lit/divi-insert'), 'no Divi in unit tests: lit/divi-insert not registered');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user