Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/DiviIntegrationTest.php
T
Jan-Niclas LoosenandClaude Opus 5.5 b40b81f585 refactor(divi): DiviTab geht im Reiter „Compatibility“ auf
Der eigene Divi-Reiter entfällt. DiviAdminExtras behält Admin-Hinweis, Orphan-Bereinigung vor der Ausgabe (lit_mcp_compat_profile_load), Hinweis zum nächsten Schritt und zur privaten Testseite (lit_mcp_compat_profile_intro), Zustandstexte mit Quelle (lit_mcp_compat_explanation), Automatik-Notiz und Visual-Builder-Hinweis (lit_mcp_compat_profile_extra) sowie die Spalten Quelle, Probleme und Betroffene Tools über die neue, gegen fehlerhafte Callbacks abgesicherte Erweiterung lit_mcp_compat_capability_columns. Texte, Doku, CSS (lit-mcp-compat-*) und Smoke auf den neuen Reiter umgestellt, tote divi_*-Hinweise entfernt.

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

372 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi;
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\CompatibilityTab;
use LIT\MCP\Admin\SettingsPage;
use LIT\MCP\Compatibility\ProvidesCompatibility;
use LIT\MCP\Integrations\Core\EnvironmentComponent;
use LIT\MCP\Integrations\Divi\DiviBuilderDetector;
use LIT\MCP\Integrations\Divi\DiviEnvironmentComponent;
use LIT\MCP\Integrations\Divi\DiviIntegration;
use LIT\MCP\Integrations\Divi\DiviLogoUsage;
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
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;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Support\FeatureFakeTool;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Error;
final class DiviIntegrationTest extends TestCase
{
/** Grows with every task that adds Divi abilities – keep in the order of abilities(). */
private const EXPECTED = [
'lit/divi-outline',
'lit/divi-node-get',
'lit/divi-catalog',
'lit/divi-design-system',
'lit/divi-insert',
'lit/divi-update',
'lit/divi-move',
'lit/divi-delete',
'lit/divi-duplicate-node',
'lit/divi-replace-text',
'lit/divi-node-copy',
'lit/divi-dynamic-sources',
'lit/divi-preset-get',
'lit/divi-presets-set',
'lit/divi-presets-delete',
'lit/divi-design-snapshots',
'lit/divi-design-restore',
'lit/divi-presets-assign',
'lit/divi-design-detach',
'lit/divi-colors-set',
'lit/divi-variables-set',
'lit/divi-theme-options-get',
'lit/divi-theme-options-update',
'lit/divi-custom-css-get',
'lit/divi-custom-css-update',
'lit/divi-fonts-upload',
];
public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void
{
$integration = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden());
self::assertSame('divi', $integration->id());
self::assertFalse($integration->isAvailable(), 'no Divi classes in unit tests');
self::assertSame(
self::EXPECTED,
array_map(static fn (Ability $ability): string => $ability->name(), $integration->abilities())
);
foreach ($integration->abilities() as $ability) {
self::assertSame('object', $ability->inputSchema()['type'], $ability->name());
self::assertFalse($ability->inputSchema()['additionalProperties'], $ability->name());
self::assertNotNull($ability->outputSchema(), $ability->name());
}
}
/** Spec §3: the ports the core uses (Plugin wires them) are Divi's own implementations. */
public function testProvidesThePortsTheCoreUses(): void
{
$integration = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden());
self::assertInstanceOf(BuilderDetector::class, $integration->builderDetector());
self::assertInstanceOf(DiviBuilderDetector::class, $integration->builderDetector());
$sources = $integration->mediaUsageSources();
self::assertCount(1, $sources);
self::assertInstanceOf(MediaUsageSource::class, $sources[0]);
self::assertInstanceOf(DiviLogoUsage::class, $sources[0]);
self::assertInstanceOf(EnvironmentComponent::class, $integration->environmentComponent());
self::assertInstanceOf(DiviEnvironmentComponent::class, $integration->environmentComponent());
}
/** The content tools leave the same post types alone as before the schema stopped knowing Divi. */
public function testDiviPostTypesAddedToTheSchemaGiveTheFormerExcludedList(): void
{
self::assertSame(
[
'attachment', 'nav_menu_item', 'revision', 'wp_block', 'wp_template', 'wp_template_part',
'wp_navigation', 'wp_global_styles', 'wp_font_family', 'wp_font_face', 'et_pb_layout',
'et_theme_builder', 'et_template', 'et_header_layout', 'et_body_layout', 'et_footer_layout',
'et_pb_canvas', '_et_pb_speculation',
],
array_merge(ContentSchema::EXCLUDED, DiviIntegration::internalPostTypes())
);
}
public function testTheSchemaAsksDiviWhetherTheBuilderIsOnForAPostType(): void
{
Functions\when('et_builder_enabled_for_post_type')->alias(static fn (string $type): bool => $type === 'page');
self::assertTrue(DiviIntegration::builderEnabledFor('page'));
self::assertFalse(DiviIntegration::builderEnabledFor('post'));
}
public function testProvidesTheDiviCompatibilityProfile(): void
{
$integration = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden());
self::assertInstanceOf(ProvidesCompatibility::class, $integration);
self::assertEquals([DiviProfile::profile()], $integration->compatibilities());
}
public function testProvidesTheLibraryGroup(): void
{
Functions\when('__')->returnArg();
$library = (new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()))->library();
self::assertSame('divi-library', $library->id());
self::assertSame('Divi-Bibliothek', $library->label());
self::assertFalse($library->isAvailable(), 'no Divi classes in unit tests');
self::assertSame(
[
'lit/library-list',
'lit/library-save',
'lit/library-insert',
'lit/library-find-similar',
'lit/library-make-global',
],
array_map(static fn (Ability $ability): string => $ability->name(), $library->abilities())
);
foreach ($library->abilities() as $ability) {
self::assertFalse($ability->inputSchema()['additionalProperties'], $ability->name());
self::assertNotNull($ability->outputSchema(), $ability->name());
}
}
public function testProvidesTheThemeBuilderGroup(): void
{
Functions\when('__')->returnArg();
$group = (new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()))->themeBuilder();
self::assertSame('divi-theme-builder', $group->id());
self::assertSame('Divi Theme Builder', $group->label());
self::assertFalse($group->isAvailable(), 'no Divi classes in unit tests');
self::assertSame(
['lit/tb-list', 'lit/tb-create-template', 'lit/tb-assign', 'lit/tb-activate'],
array_map(static fn (Ability $ability): string => $ability->name(), $group->abilities())
);
foreach ($group->abilities() as $ability) {
self::assertFalse($ability->inputSchema()['additionalProperties'], $ability->name());
self::assertNotNull($ability->outputSchema(), $ability->name());
self::assertFalse($ability->isDirect(), $ability->name());
}
}
/**
* Review I-2 (ruling Q17 M1): the library usages follow the Theme Builder access – without it (here: no Divi
* role check in unit tests, so canUse() fails) TB layouts are counted and block, never named.
* ThemeBuilderAccessTest covers usageState() itself.
*/
public function testGlobalUsagesFollowTheThemeBuilderAccess(): void
{
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_post')->justReturn(null);
Functions\when('_get_non_cached_ids')->returnArg(1);
Functions\when('_prime_post_caches')->justReturn(null);
Functions\when('wp_cache_delete')->justReturn(true);
$db = Mockery::mock(\wpdb::class)->makePartial();
$db->allows('get_results')->andReturnUsing(static fn (string $sql): array => str_contains($sql, 'post_content FROM')
? [
['ID' => '1053', 'post_content' => '<!-- wp:divi/global-layout {"globalModule":"1058"} /-->'],
['ID' => '1061', 'post_content' => '<!-- wp:divi/global-layout {"globalModule":"1058"} /-->'],
]
: [
['ID' => '1053', 'post_status' => 'publish', 'post_type' => 'page'],
['ID' => '1061', 'post_status' => 'publish', 'post_type' => 'et_header_layout'],
]);
$report = (new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()))
->globalUsages($db)
->report(1058);
self::assertSame([1053], array_column($report->items, 'post_id'));
self::assertSame([], $report->blocked);
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(),
self::tools(new ExposurePolicy(['lit/divi-insert' => ExposurePolicy::DISABLED]))
);
$copy = null;
foreach ($integration->abilities() as $ability) {
if ($ability->name() === 'lit/divi-node-copy') {
$copy = $ability;
}
}
self::assertNotNull($copy);
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());
$composer = $integration->contentComposer();
self::assertInstanceOf(DiviContentComposer::class, $composer);
self::assertFalse($composer->available(), 'no Divi classes in unit tests');
}
/** E1/E3: the Divi read tools reach library entries; ruling P9: a hidden library leaves them not found. */
public function testTheReadToolsReachTheLibraryUnlessItIsHidden(): void
{
LibraryFixtures::given([LibraryFixtures::item(1055)]);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(false);
$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: an unbound ToolAvailability throws when asked.
$visible = new DiviIntegration($schema, FakeChangeLog::forbidden(), self::tools());
self::assertToolError($outline($visible), 'not_found');
self::assertToolError($outline(new DiviIntegration($schema, FakeChangeLog::forbidden())), 'not_found');
$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()));
}
/**
* Ruling P13: library IDs follow the integration's library access – hidden with the group disabled
* and without the library right (no Divi in unit tests: DiviRoles allows nothing). The positive
* cases, "nur lesen" included, are in LibraryAccessTest (defining et_pb_is_allowed here would leak).
*/
public function testLibraryIdsFollowTheLibraryAccess(): void
{
LibraryFixtures::given([]);
$schema = Mockery::mock(ContentSchema::class);
$hidden = new DiviIntegration(
$schema,
FakeChangeLog::forbidden(),
self::tools(new ExposurePolicy(['@group:divi-library' => ExposurePolicy::DISABLED]))
);
self::assertFalse($hidden->libraryIdsVisible());
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, extras in the tab "Compatibility" (no own tab). */
public function testBootRegistersDivisOwnHooks(): void
{
$upgraded = null;
Actions\expectAdded('upgrader_process_complete')->once()->whenHappen(
static function (callable $callback) use (&$upgraded): void {
$upgraded = $callback;
}
);
$changed = null;
Actions\expectAdded('lit_mcp_compat_changed')->once()->whenHappen(
static function (callable $callback) use (&$changed): void {
$changed = $callback;
}
);
// The Divi admin extras hook into the admin notices and the tab "Compatibility"; Divi has no tab of its own.
Filters\expectAdded(SettingsPage::TABS_FILTER)->never();
Actions\expectAdded('admin_notices')->once();
Actions\expectAdded(CompatibilityTab::LOAD_ACTION)->once();
Actions\expectAdded(CompatibilityTab::EXTRA_ACTION)->once();
Filters\expectAdded(CompatibilityTab::COLUMNS_FILTER)->once();
Functions\expect('delete_transient')->once()->with(ReleaseResolver::CACHE)->andReturn(true);
(new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()))->boot();
self::assertIsCallable($upgraded);
$upgraded();
// The route announces a finished check; only Divi's own profile resets the release gate (once, no mirrors).
self::assertIsCallable($changed);
$changed('pods');
$changed('divi');
}
public function testTheFeatureOverviewFindsTheToolsOfAllIntegrations(): void
{
$integration = new DiviIntegration(
Mockery::mock(ContentSchema::class),
FakeChangeLog::forbidden(),
allAbilities: static fn (): array => [
new FeatureFakeTool('lit/divi-insert', true, [DiviFeature::PageWrite]),
new FeatureFakeTool('lit/library-insert', true, [DiviFeature::PageWrite, DiviFeature::LibraryRead]),
]
);
self::assertSame(
['page-write' => ['lit-divi-insert', 'lit-library-insert'], 'library-read' => ['lit-library-insert']],
$integration->featureOverview()->toolsByFeature()
);
}
public function testWithoutAnAbilityListTheOverviewKnowsNoTools(): void
{
$integration = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden());
self::assertSame([], $integration->featureOverview()->toolsByFeature());
}
}