WordPress „Zusätzliches CSS“ (in Divi „Eigenes CSS“) über die Design-Pipeline: Probelauf mit Zeilen-Diff, design_rev, Bestätigung, Snapshot, Rollback und Wiederherstellen (Bereich custom_css). Der Guard lehnt </style, <script, expression(, javascript: und neue @import ab und warnt bei fremden Hosts; nur Neues zählt, Bestehendes blockiert keine Änderung. Erfordert edit_css und die Divi-Rolle Theme-Anpassung. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
364 lines
16 KiB
PHP
364 lines
16 KiB
PHP
<?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\AdminTab;
|
||
use LIT\MCP\Integrations\Divi\Admin\DiviTab;
|
||
use LIT\MCP\Admin\SettingsPage;
|
||
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\DiviCheckRoute;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
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 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, tab "Divi" after the plugin's tabs. */
|
||
public function testBootRegistersDivisOwnHooks(): void
|
||
{
|
||
$upgraded = null;
|
||
$tabs = null;
|
||
Actions\expectAdded('upgrader_process_complete')->once()->whenHappen(
|
||
static function (callable $callback) use (&$upgraded): void {
|
||
$upgraded = $callback;
|
||
}
|
||
);
|
||
Actions\expectAdded('rest_api_init')->once()->with(Mockery::on(
|
||
static fn (array $callback): bool => $callback[0] instanceof DiviCheckRoute
|
||
));
|
||
Filters\expectAdded(SettingsPage::TABS_FILTER)->once()->whenHappen(
|
||
static function (callable $callback) use (&$tabs): void {
|
||
$tabs = $callback;
|
||
}
|
||
);
|
||
Functions\expect('delete_transient')->once()->with(ReleaseResolver::CACHE)->andReturn(true);
|
||
$base = Mockery::mock(AdminTab::class);
|
||
|
||
(new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()))->boot();
|
||
self::assertIsCallable($upgraded);
|
||
$upgraded();
|
||
self::assertIsCallable($tabs);
|
||
$all = $tabs([$base]);
|
||
|
||
self::assertCount(2, $all);
|
||
self::assertSame($base, $all[0]);
|
||
self::assertInstanceOf(DiviTab::class, $all[1]);
|
||
}
|
||
|
||
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());
|
||
}
|
||
}
|