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:
Jan-Niclas Loosen
2026-10-18 21:56:36 +02:00
co-authored by Claude Opus 5.5
parent 6c59934caa
commit e6e0f914ba
6 changed files with 376 additions and 109 deletions
@@ -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. */