Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/DiviIntegrationTest.php
T

357 lines
16 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\AdminTab;
use LIT\MCP\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',
];
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());
}
}