Files
WP-LIT-MCP/tests/Unit/PluginTest.php
T
Jan-Niclas LoosenandClaude Opus 5.5 85f4138b36 feat(admin): Prüfroute je Profil und Reiter „Compatibility“
Die generische CheckRoute (POST lit-mcp/v1/<id>-check, für Divi weiter /divi-check) ersetzt DiviCheckRoute: Lock je Profil mit Besitzer-Token, Bericht je Profil, Rücksprung auf den neuen Reiter und Hook lit_mcp_compat_changed (Divi lädt darauf seinen Freigabe-Status neu). Der Reiter „Compatibility“ zeigt je Plugin Zustand (Wort und Icon), Hinweis zur Stufe, Fähigkeiten, getestete und freigegebene Versionen und den letzten Bericht, mit Prüfen, Freigeben und Zurückziehen (Recht manage_options, Allowlist der Profil-ID, Nonce je Profil). Freigeben serverseitig nur mit frischem grünem Bericht. Der Load-Hook lit_mcp_compat_profile_load bereitet den Abbau des DiviTab vor, der bis dahin parallel läuft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:15:59 +01:00

207 lines
8.3 KiB
PHP

<?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\CompatibilityTab;
use LIT\MCP\Admin\SettingsPage;
use LIT\MCP\Compatibility\CapabilityGate;
use LIT\MCP\Compatibility\Compatibility;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Integrations\Divi\Release\DiviFeatureGate;
use LIT\MCP\Integrations\Pods\PodsFeature;
use LIT\MCP\Integrations\Pods\PodsFieldSource;
use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource;
use LIT\MCP\Integrations\WordPress\Schema\SampledMetaSource;
use LIT\MCP\Integrations\Core\SiteStatusAbility;
use LIT\MCP\Access;
use ReflectionProperty;
use WP_User;
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', 'pods'],
self::registry()->ids()
);
}
/** Only the Divi integration has boot steps (upgrader hook, compat listener, tab); a second boot registers nothing twice. */
public function testTheBootableIntegrationsBootExactlyOnce(): void
{
Actions\expectAdded('upgrader_process_complete')->once();
Actions\expectAdded('lit_mcp_compat_changed')->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');
}
/** Pods has no tools: it is in the registry for its profile only and shows up as no group. */
public function testPodsIsRegisteredWithoutBecomingAToolGroup(): void
{
$registry = self::registry();
self::assertContains('pods', $registry->ids());
self::assertArrayNotHasKey('pods', $registry->grouped());
}
/** lit-site-status keeps its order: Divi, Pods, The Events Calendar. */
public function testSiteStatusListsDiviPodsAndTheEventsCalendarInThatOrder(): void
{
Functions\when('get_bloginfo')->justReturn('x');
Functions\when('home_url')->justReturn('https://example.test');
Functions\when('wp_get_current_user')->justReturn(new WP_User(1, ['administrator']));
Functions\when('get_option')->justReturn(['administrator']);
Functions\when('apply_filters')->returnArg(2);
$theme = Mockery::mock();
$theme->allows('get_template')->andReturn('twentytwentyfive');
Functions\when('wp_get_theme')->justReturn($theme);
$status = array_values(array_filter(
self::registry()->abilities(),
static fn ($ability): bool => $ability instanceof SiteStatusAbility
))[0];
$components = $status->execute([])['components'];
self::assertSame(['divi', 'pods', 'the-events-calendar'], array_column($components, 'id'));
$pods = array_column($components, null, 'id')['pods'];
self::assertFalse($pods['active'], 'no Pods in unit tests');
self::assertSame([], $pods['problems']);
}
public function testTheCompatibilityCoversAllProfilesWithoutIdCollision(): void
{
$compat = self::registry()->compatibility();
self::assertSame(['divi', 'pods'], array_map(static fn (Profile $p): string => $p->id, $compat->profiles()));
self::assertNotNull($compat->capability('page-write'));
self::assertNotNull($compat->capability('pods-field-read'));
self::assertSame('divi', $compat->owner($compat->capability('page-write'))?->id);
self::assertSame('pods', $compat->owner($compat->capability('pods-field-write'))?->id);
self::assertNull($compat->capability('nobody-knows-me'));
}
/** The lock of unknown IDs lives in CapabilityGate: without it a core tool with an odd ID would run unlocked. */
public function testBothGuardsAreWiredDiviFirst(): void
{
$guards = Plugin::guards(self::registry(), FakeChangeLog::forbidden());
self::assertCount(2, $guards);
self::assertInstanceOf(DiviFeatureGate::class, $guards[0]);
self::assertInstanceOf(CapabilityGate::class, $guards[1]);
}
/** The tab "Compatibility" gets the registry's Compatibility: one section per profile, Divi and Pods. */
public function testTheCompatibilityTabShowsEveryProfileOfTheRegistry(): void
{
Functions\stubEscapeFunctions();
Functions\stubTranslationFunctions();
$theme = Mockery::mock();
$theme->allows('get_template')->andReturn('twentytwentyfive');
Functions\when('wp_get_theme')->justReturn($theme);
$tab = Plugin::compatibilityTab(self::registry(), FakeChangeLog::forbidden());
ob_start();
$tab->render();
$html = (string) ob_get_clean();
self::assertInstanceOf(CompatibilityTab::class, $tab);
self::assertSame('compatibility', $tab->slug());
self::assertStringContainsString('id="lit-mcp-compat-divi"', $html);
self::assertStringContainsString('id="lit-mcp-compat-pods"', $html);
}
private static function readOnly(object $source): bool
{
return (new ReflectionProperty($source, 'readOnly'))->getValue($source);
}
private static function podsCompat(bool $usable): Compatibility
{
return new Compatibility([new Profile(
id: 'pods',
label: 'Pods',
tier: Tier::LockOnFailure,
capabilities: PodsFeature::cases(),
active: static fn (): bool => $usable
)]);
}
public function testWithoutPodsTheMetaSourcesStayWritableAndPodsIsNotASource(): void
{
$sources = Plugin::schemaSources(self::podsCompat(false), false);
self::assertCount(2, $sources);
self::assertInstanceOf(RegisteredMetaSource::class, $sources[0]);
self::assertInstanceOf(SampledMetaSource::class, $sources[1]);
self::assertFalse(self::readOnly($sources[0]));
self::assertFalse(self::readOnly($sources[1]));
}
public function testWithUsablePodsItsSourceComesFirstAndTheMetaSourcesStayWritable(): void
{
$sources = Plugin::schemaSources(self::podsCompat(true), true);
self::assertInstanceOf(PodsFieldSource::class, $sources[0]);
self::assertFalse(self::readOnly($sources[1]));
self::assertFalse(self::readOnly($sources[2]));
}
/** Pods active but its schema locked: its meta keys must not be written past Pods. */
public function testWithActiveButLockedPodsTheMetaSourcesAreReadOnly(): void
{
$sources = Plugin::schemaSources(self::podsCompat(false), true);
self::assertInstanceOf(PodsFieldSource::class, $sources[0]);
self::assertTrue(self::readOnly($sources[1]));
self::assertTrue(self::readOnly($sources[2]));
}
}