Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
368 lines
17 KiB
PHP
368 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
|
|
|
|
use Brain\Monkey\Functions;
|
|
use LIT\MCP\Abilities\Ability;
|
|
use LIT\MCP\Abilities\DeclaresRequirements;
|
|
use LIT\MCP\Integrations\Divi\DiviIntegration;
|
|
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
|
use LIT\MCP\Integrations\Divi\Release\FeatureSets;
|
|
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
|
|
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
|
|
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
|
use LIT\MCP\Integrations\WordPress\WordPressIntegration;
|
|
use LIT\MCP\Tests\Support\FakeChangeLog;
|
|
use LIT\MCP\Tests\Unit\TestCase;
|
|
use Mockery;
|
|
|
|
/**
|
|
* The table of the Divi capabilities every tool of the groups divi, divi-library and divi-theme-builder and
|
|
* lit/content-create declare (plan "Divi-Freigabe je Fähigkeit", Task 8). The table is the contract: a tool that
|
|
* needs more or less than listed here changes it here, on purpose.
|
|
*/
|
|
final class DiviToolFeaturesTest extends TestCase
|
|
{
|
|
private const PAGE = 5;
|
|
private const LIBRARY_ENTRY = 7;
|
|
private const LAYOUT = 9;
|
|
|
|
/** Post ID => post type for get_post_type(); every other ID names no entry. */
|
|
private const TYPES = [
|
|
self::PAGE => 'page',
|
|
self::LIBRARY_ENTRY => 'et_pb_layout',
|
|
self::LAYOUT => 'et_body_layout',
|
|
];
|
|
|
|
/** What a tool needs whatever its input names: tool => capabilities (DiviFeature values). */
|
|
private const EXPECTED = [
|
|
// Pages (divi)
|
|
'lit/divi-outline' => ['page-read', 'design-read'],
|
|
'lit/divi-node-get' => ['page-read', 'design-read'],
|
|
'lit/divi-catalog' => ['catalog'],
|
|
'lit/divi-design-system' => ['design-read'],
|
|
'lit/divi-insert' => ['page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources'],
|
|
'lit/divi-update' => ['page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources'],
|
|
'lit/divi-move' => ['page-read', 'page-write', 'catalog', 'design-read'],
|
|
'lit/divi-delete' => ['page-read', 'page-write', 'catalog', 'design-read'],
|
|
'lit/divi-duplicate-node' => ['page-read', 'page-write', 'catalog', 'design-read'],
|
|
'lit/divi-replace-text' => ['page-read', 'catalog', 'design-read', 'dynamic-sources'],
|
|
'lit/divi-node-copy' => ['page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources'],
|
|
'lit/divi-dynamic-sources' => ['dynamic-sources', 'roles'],
|
|
// Design system (divi)
|
|
'lit/divi-preset-get' => ['design-read', 'catalog'],
|
|
'lit/divi-presets-set' => ['design-read', 'preset-write', 'catalog'],
|
|
'lit/divi-presets-delete' => ['design-read', 'preset-write'],
|
|
'lit/divi-design-snapshots' => [],
|
|
'lit/divi-design-restore' => ['design-read', 'preset-write', 'color-write', 'variable-write'],
|
|
'lit/divi-presets-assign' => [
|
|
'page-read', 'page-write', 'catalog', 'design-read', 'preset-write', 'library-read', 'library-write',
|
|
'tb-write', 'roles',
|
|
],
|
|
'lit/divi-design-detach' => [
|
|
'page-read', 'page-write', 'catalog', 'design-read', 'preset-write', 'library-read', 'library-write',
|
|
'tb-write', 'roles',
|
|
],
|
|
// Library (divi-library)
|
|
'lit/library-list' => ['library-read', 'roles'],
|
|
'lit/library-find-similar' => ['page-read', 'catalog', 'roles'],
|
|
'lit/library-insert' => [
|
|
'page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources', 'library-read', 'roles',
|
|
],
|
|
'lit/library-make-global' => [
|
|
'page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources', 'library-read', 'library-write',
|
|
'roles',
|
|
],
|
|
'lit/library-save' => [
|
|
'page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources', 'library-write', 'roles',
|
|
],
|
|
// Theme Builder (divi-theme-builder)
|
|
'lit/tb-list' => ['tb-read', 'roles'],
|
|
'lit/tb-create-template' => ['tb-read', 'tb-write', 'roles'],
|
|
'lit/tb-assign' => ['tb-read', 'tb-write', 'roles'],
|
|
'lit/tb-activate' => ['tb-read', 'tb-write', 'roles'],
|
|
// WordPress: without the divi key it stays in the core
|
|
'lit/content-create' => [],
|
|
];
|
|
|
|
/** The compose set of a new or changed page. */
|
|
private const COMPOSE = ['page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources'];
|
|
|
|
/**
|
|
* Input-dependent additions: label => [tool, input, capabilities for that input].
|
|
*
|
|
* @return array<string, array{0: string, 1: array<string, mixed>, 2: list<string>}>
|
|
*/
|
|
public static function inputCases(): array
|
|
{
|
|
$page = self::PAGE;
|
|
$library = self::LIBRARY_ENTRY;
|
|
$layout = self::LAYOUT;
|
|
$libraryWrite = ['roles', 'library-read', 'library-write'];
|
|
$layoutWrite = ['roles', 'tb-read', 'tb-write'];
|
|
|
|
return [
|
|
'outline of a page' => ['lit/divi-outline', ['post_id' => $page], ['page-read', 'design-read']],
|
|
'outline of a library entry' => [
|
|
'lit/divi-outline', ['post_id' => $library], ['page-read', 'design-read', 'roles'],
|
|
],
|
|
'outline of a layout' => [
|
|
'lit/divi-outline', ['post_id' => $layout], ['page-read', 'design-read', 'roles'],
|
|
],
|
|
'node-get of a library entry' => [
|
|
'lit/divi-node-get', ['post_id' => $library, 'id' => 's0'], ['page-read', 'design-read', 'roles'],
|
|
],
|
|
'outline of an unknown entry' => [
|
|
'lit/divi-outline', ['post_id' => 999], ['page-read', 'design-read'],
|
|
],
|
|
'outline without post_id' => ['lit/divi-outline', [], ['page-read', 'design-read']],
|
|
'insert into a page' => ['lit/divi-insert', ['post_id' => $page], self::COMPOSE],
|
|
'insert into a library entry' => [
|
|
'lit/divi-insert', ['post_id' => $library], array_merge(self::COMPOSE, $libraryWrite),
|
|
],
|
|
'update of a layout' => [
|
|
'lit/divi-update', ['post_id' => $layout], array_merge(self::COMPOSE, $layoutWrite),
|
|
],
|
|
'move in a layout' => [
|
|
'lit/divi-move', ['post_id' => $layout],
|
|
array_merge(['page-read', 'page-write', 'catalog', 'design-read'], $layoutWrite),
|
|
],
|
|
'delete with a numeric string ID' => [
|
|
'lit/divi-delete', ['post_id' => (string) $library],
|
|
array_merge(['page-read', 'page-write', 'catalog', 'design-read'], $libraryWrite),
|
|
],
|
|
'node-copy of a page into a page' => [
|
|
'lit/divi-node-copy', ['post_id' => $page, 'source_post_id' => $page], self::COMPOSE,
|
|
],
|
|
'node-copy out of a library entry' => [
|
|
'lit/divi-node-copy', ['post_id' => $page, 'source_post_id' => $library],
|
|
array_merge(self::COMPOSE, ['roles']),
|
|
],
|
|
'node-copy into a library entry' => [
|
|
'lit/divi-node-copy', ['post_id' => $library, 'source_post_id' => $page],
|
|
array_merge(self::COMPOSE, $libraryWrite),
|
|
],
|
|
'replace-text dry run by default' => [
|
|
'lit/divi-replace-text', ['search' => 'a', 'replace' => 'b'],
|
|
['page-read', 'catalog', 'design-read', 'dynamic-sources'],
|
|
],
|
|
'replace-text dry run explicit' => [
|
|
'lit/divi-replace-text', ['dry_run' => true, 'targets' => [['post_id' => $library]]],
|
|
['page-read', 'catalog', 'design-read', 'dynamic-sources'],
|
|
],
|
|
'replace-text on pages' => [
|
|
'lit/divi-replace-text', ['dry_run' => false, 'targets' => [['post_id' => $page, 'tree_rev' => 'x']]],
|
|
array_merge(['page-read', 'catalog', 'design-read', 'dynamic-sources'], ['page-write']),
|
|
],
|
|
'replace-text on a library entry' => [
|
|
'lit/divi-replace-text',
|
|
['dry_run' => false, 'targets' => [['post_id' => $page], ['post_id' => $library], 'bad']],
|
|
array_merge(['page-read', 'catalog', 'design-read', 'dynamic-sources', 'page-write'], $libraryWrite),
|
|
],
|
|
'presets-set without from_node' => [
|
|
'lit/divi-presets-set', ['items' => [['module' => 'button', 'name' => 'x']]],
|
|
['design-read', 'preset-write', 'catalog'],
|
|
],
|
|
'presets-set from a page node' => [
|
|
'lit/divi-presets-set', ['items' => [['from_node' => ['post_id' => $page, 'id' => 's0']]]],
|
|
['design-read', 'preset-write', 'catalog', 'page-read'],
|
|
],
|
|
'presets-set from a library node' => [
|
|
'lit/divi-presets-set', ['items' => [1, ['from_node' => ['post_id' => $library, 'id' => 's0']]]],
|
|
['design-read', 'preset-write', 'catalog', 'page-read', 'roles'],
|
|
],
|
|
'design-detach of a variable' => [
|
|
'lit/divi-design-detach', ['variable' => 'Eckenradius'],
|
|
[
|
|
'page-read', 'page-write', 'catalog', 'design-read', 'preset-write', 'library-read',
|
|
'library-write', 'tb-write', 'roles', 'variable-write',
|
|
],
|
|
],
|
|
'design-restore of presets' => [
|
|
'lit/divi-design-restore', ['snapshot_id' => 'presets-20261016T100000Z-aaaaaa'],
|
|
['design-read', 'preset-write'],
|
|
],
|
|
'design-restore of colors' => [
|
|
'lit/divi-design-restore', ['snapshot_id' => 'colors-20261016T100000Z-aaaaaa'],
|
|
['design-read', 'color-write'],
|
|
],
|
|
'design-restore of variables' => [
|
|
'lit/divi-design-restore', ['snapshot_id' => 'variables-20261016T100000Z-aaaaaa'],
|
|
['design-read', 'variable-write'],
|
|
],
|
|
'library-list' => ['lit/library-list', [], ['library-read', 'roles']],
|
|
'library-list of one entry' => [
|
|
'lit/library-list', ['id' => $library], ['library-read', 'roles', 'page-read', 'design-read'],
|
|
],
|
|
'library-insert into a layout' => [
|
|
'lit/library-insert', ['post_id' => $layout, 'library_id' => $library],
|
|
array_merge(
|
|
['page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources', 'library-read'],
|
|
$layoutWrite
|
|
),
|
|
],
|
|
'tb-create-template without nodes' => [
|
|
'lit/tb-create-template', ['title' => 'T', 'header' => ['source' => 'new']],
|
|
['tb-read', 'tb-write', 'roles'],
|
|
],
|
|
'tb-create-template with nodes' => [
|
|
'lit/tb-create-template', ['title' => 'T', 'body' => ['source' => 'new', 'nodes' => [[]]]],
|
|
array_merge(['tb-read', 'tb-write', 'roles'], self::COMPOSE),
|
|
],
|
|
'tb-create-template with empty nodes' => [
|
|
'lit/tb-create-template', ['title' => 'T', 'footer' => ['source' => 'new', 'nodes' => []]],
|
|
['tb-read', 'tb-write', 'roles'],
|
|
],
|
|
'content-create without divi' => ['lit/content-create', ['post_type' => 'page'], []],
|
|
'content-create with divi' => [
|
|
'lit/content-create', ['post_type' => 'page', 'divi' => [[]]], self::COMPOSE,
|
|
],
|
|
];
|
|
}
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
Functions\when('__')->returnArg();
|
|
Functions\when('get_post_type')->alias(static fn (int $id): string|false => self::TYPES[$id] ?? false);
|
|
Functions\when('current_user_can')->justReturn(true);
|
|
}
|
|
|
|
/** @return array<string, Ability> name => ability of the three Divi groups and lit/content-create */
|
|
private function abilities(): array
|
|
{
|
|
$divi = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden());
|
|
$composer = Mockery::mock(ContentComposer::class);
|
|
$composer->allows('available')->andReturn(true);
|
|
$composer->allows('requirements')->andReturn(self::COMPOSE);
|
|
$wordPress = new WordPressIntegration(
|
|
Mockery::mock(ContentSchema::class),
|
|
FakeChangeLog::forbidden(),
|
|
$composer
|
|
);
|
|
|
|
$found = [];
|
|
foreach ([
|
|
...$divi->abilities(),
|
|
...$divi->library()->abilities(),
|
|
...$divi->themeBuilder()->abilities(),
|
|
] as $ability) {
|
|
$found[$ability->name()] = $ability;
|
|
}
|
|
foreach ($wordPress->abilities() as $ability) {
|
|
if ($ability->name() === 'lit/content-create') {
|
|
$found[$ability->name()] = $ability;
|
|
}
|
|
}
|
|
|
|
return $found;
|
|
}
|
|
|
|
/**
|
|
* @param list<DiviFeature> $features
|
|
* @return list<string> The values, sorted.
|
|
*/
|
|
private static function values(array $features): array
|
|
{
|
|
$values = array_map(static fn (DiviFeature $feature): string => $feature->value, $features);
|
|
sort($values);
|
|
|
|
return $values;
|
|
}
|
|
|
|
/** @param list<string> $values */
|
|
private static function sorted(array $values): array
|
|
{
|
|
sort($values);
|
|
|
|
return array_values(array_unique($values));
|
|
}
|
|
|
|
public function testEveryToolOfTheDiviGroupsDeclaresItsFeatures(): void
|
|
{
|
|
$abilities = $this->abilities();
|
|
|
|
foreach ($abilities as $name => $ability) {
|
|
if ($name === 'lit/content-create') {
|
|
self::assertInstanceOf(DeclaresRequirements::class, $ability, $name);
|
|
self::assertNotInstanceOf(RequiresDiviFeatures::class, $ability, 'the core imports no Divi class');
|
|
continue;
|
|
}
|
|
self::assertInstanceOf(RequiresDiviFeatures::class, $ability, $name);
|
|
}
|
|
}
|
|
|
|
public function testEveryRowOfTheTableHasAnAbilityAndTheOtherWayRound(): void
|
|
{
|
|
$abilities = array_keys($this->abilities());
|
|
|
|
self::assertSame([], array_values(array_diff(array_keys(self::EXPECTED), $abilities)), 'row without ability');
|
|
self::assertSame([], array_values(array_diff($abilities, array_keys(self::EXPECTED))), 'ability without row');
|
|
}
|
|
|
|
public function testEveryToolDeclaresExactlyTheTableForAnInputThatNamesNothing(): void
|
|
{
|
|
foreach ($this->abilities() as $name => $ability) {
|
|
self::assertSame(
|
|
self::sorted(self::EXPECTED[$name] ?? ['<no row>']),
|
|
self::values(FeatureSets::declaredBy($ability, [])),
|
|
$name
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $input
|
|
* @param list<string> $expected
|
|
* @dataProvider inputCases
|
|
*/
|
|
public function testInputDependentFeatures(string $tool, array $input, array $expected): void
|
|
{
|
|
$ability = $this->abilities()[$tool];
|
|
|
|
self::assertSame(self::sorted($expected), self::values(FeatureSets::declaredBy($ability, $input)));
|
|
}
|
|
|
|
public function testNoToolDeclaresAFeatureTwiceAndEveryIdIsAFeature(): void
|
|
{
|
|
foreach ($this->abilities() as $name => $ability) {
|
|
$declared = $ability instanceof RequiresDiviFeatures
|
|
? array_map(static fn (DiviFeature $f): string => $f->value, $ability->diviFeatures([]))
|
|
: [];
|
|
self::assertSame($declared, array_values(array_unique($declared)), $name);
|
|
}
|
|
foreach (self::EXPECTED as $name => $ids) {
|
|
foreach ($ids as $id) {
|
|
self::assertNotNull(DiviFeature::tryFrom($id), $name . ': ' . $id);
|
|
}
|
|
}
|
|
}
|
|
|
|
public function testEveryWritingToolNeedsTheWriteFeatureOfEverythingItSaves(): void
|
|
{
|
|
$writes = [
|
|
'lit/divi-insert' => 'page-write', 'lit/divi-update' => 'page-write', 'lit/divi-move' => 'page-write',
|
|
'lit/divi-delete' => 'page-write', 'lit/divi-duplicate-node' => 'page-write',
|
|
'lit/divi-node-copy' => 'page-write', 'lit/library-insert' => 'page-write',
|
|
'lit/library-make-global' => 'library-write', 'lit/library-save' => 'library-write',
|
|
'lit/divi-presets-set' => 'preset-write', 'lit/divi-presets-delete' => 'preset-write',
|
|
'lit/divi-design-restore' => 'preset-write', 'lit/divi-design-detach' => 'preset-write',
|
|
'lit/divi-presets-assign' => 'page-write', 'lit/tb-assign' => 'tb-write', 'lit/tb-activate' => 'tb-write',
|
|
'lit/tb-create-template' => 'tb-write',
|
|
];
|
|
foreach ($writes as $name => $feature) {
|
|
self::assertContains($feature, self::EXPECTED[$name], $name);
|
|
}
|
|
// Read-only tools never need a write capability.
|
|
foreach (['lit/divi-outline', 'lit/divi-node-get', 'lit/divi-catalog', 'lit/divi-design-system',
|
|
'lit/divi-dynamic-sources', 'lit/divi-preset-get', 'lit/library-list', 'lit/library-find-similar',
|
|
'lit/tb-list', 'lit/divi-design-snapshots'] as $name) {
|
|
foreach (self::EXPECTED[$name] as $id) {
|
|
self::assertFalse(DiviFeature::from($id)->isWrite(), $name . ': ' . $id);
|
|
}
|
|
}
|
|
}
|
|
}
|