test: split DiviWriteAbilitiesTest per tool (T5)
One test class per Divi write tool on a shared DiviWriteAbilityTestCase; the 30 tests move verbatim (per-test assertion counts identical). Adds the ledger T4 test for a TreeEditor::move() StructureRules refusal. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -126,6 +126,27 @@ final class TreeEditorTest extends TestCase
|
|||||||
self::assertSame($section, NodeIndex::build($document)->find('s1'));
|
self::assertSame($section, NodeIndex::build($document)->find('s1'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ledger T4: move() checks StructureRules for the target parent and leaves the tree unchanged when it refuses. */
|
||||||
|
public function testMoveChecksTheStructureRules(): void
|
||||||
|
{
|
||||||
|
$document = $this->document();
|
||||||
|
$original = Serializer::document($document);
|
||||||
|
$index = NodeIndex::build($document);
|
||||||
|
$editor = new TreeEditor(ComposeFixture::rules());
|
||||||
|
$button = $index->find('s0.r0.c1.m2');
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
'auf oberster Ebene sind nur Abschnitte (section) erlaubt',
|
||||||
|
$editor->move($index, $button, $editor->position($index, []))
|
||||||
|
);
|
||||||
|
self::assertStringContainsString('Zeilen', (string) $editor->move($index, $button, $editor->position($index, ['parent' => 's0'])));
|
||||||
|
self::assertStringContainsString('Spalten entstehen mit der Zeile', (string) $editor->move($index, $button, $editor->position($index, ['parent' => 's0.r0'])));
|
||||||
|
self::assertStringContainsString('accordion-item', (string) $editor->move($index, $button, $editor->position($index, ['parent' => 's1.r0.c1.m0'])));
|
||||||
|
|
||||||
|
self::assertSame($original, Serializer::document($document), 'a refused move changes nothing');
|
||||||
|
self::assertSame($button, NodeIndex::build($document)->find('s0.r0.c1.m2'));
|
||||||
|
}
|
||||||
|
|
||||||
public function testRemoveAndDuplicate(): void
|
public function testRemoveAndDuplicate(): void
|
||||||
{
|
{
|
||||||
$document = $this->document();
|
$document = $this->document();
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Rev;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||||
|
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||||
|
|
||||||
|
final class DiviDeleteAbilityTest extends DiviWriteAbilityTestCase
|
||||||
|
{
|
||||||
|
/** The stored content equals the original minus exactly that block's bytes; revision_id is the restore point. */
|
||||||
|
public function testDeleteAModuleRemovesExactlyItsBytes(): void
|
||||||
|
{
|
||||||
|
$buttonBytes = '<!-- wp:divi/button {"button":{"innerContent":{"desktop":{"value":{"text":"Termin buchen",'
|
||||||
|
. '"linkUrl":"/kontakt","linkTarget":"off"}}}},"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero –'
|
||||||
|
. ' Button"}}}},"builderVersion":"5.2.1"} --><!-- /wp:divi/button -->';
|
||||||
|
self::assertStringContainsString($buttonBytes, $this->original, 'precondition: exact bytes as expected');
|
||||||
|
|
||||||
|
$result = $this->delete()->execute([
|
||||||
|
'post_id' => 7, 'tree_rev' => $this->treeRev(), 'ids' => ['s0.r0.c1.m2'], 'confirm' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame([['id' => 's0.r0.c1.m2', 'type' => 'divi/button', 'action' => 'deleted', 'label' => 'Hero – Button']], $result['changed']);
|
||||||
|
self::assertSame(str_replace($buttonBytes, '', $this->original), $this->saved[0]);
|
||||||
|
self::assertSame(811, $result['revision_id'], 'the restore-point revision');
|
||||||
|
self::assertStringContainsString('Hero', (string) $result['outline'], 'the outline is embedded in the result');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An opaque/global module is removed as a whole – it never gets descended into. */
|
||||||
|
public function testDeleteRemovesAnOpaqueGlobalModuleAsAWhole(): void
|
||||||
|
{
|
||||||
|
$mixed = DiviFixtures::markup('synthetic-mixed.html');
|
||||||
|
$this->post->post_content = $mixed;
|
||||||
|
$globalRow = '<!-- wp:divi/row {"globalModule":"913","module":{"meta":{"adminLabel":{"desktop":{"value":'
|
||||||
|
. '"Globale Zeile"}}}}} --><!-- wp:divi/column {"builderVersion":"5.2.1"} --><!-- wp:divi/text '
|
||||||
|
. '{"content":{"innerContent":{"desktop":{"value":"lokal"}}}} /--><!-- /wp:divi/column --><!-- /wp:divi/row'
|
||||||
|
. ' -->';
|
||||||
|
self::assertStringContainsString($globalRow, $mixed, 'precondition: exact bytes as expected');
|
||||||
|
|
||||||
|
$result = $this->delete()->execute([
|
||||||
|
'post_id' => 7, 'tree_rev' => Rev::of($mixed), 'ids' => ['s1.g1'], 'confirm' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame([['id' => 's1.g1', 'type' => 'divi/row', 'action' => 'deleted', 'label' => 'Globale Zeile']], $result['changed']);
|
||||||
|
self::assertSame(str_replace($globalRow, '', $mixed), $this->saved[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeletingTheLastChildLeavesAValidTree(): void
|
||||||
|
{
|
||||||
|
$mixed = DiviFixtures::markup('synthetic-mixed.html');
|
||||||
|
$this->post->post_content = $mixed;
|
||||||
|
|
||||||
|
$result = $this->delete()->execute([
|
||||||
|
'post_id' => 7, 'tree_rev' => Rev::of($mixed), 'ids' => ['s0.c1.r0.c0.m0'], 'confirm' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertTrue($result['ok'], (string) json_encode($result));
|
||||||
|
$index = NodeIndex::build((new Parser())->parse($this->saved[0]));
|
||||||
|
$emptyColumn = $index->find('s0.c1.r0.c0');
|
||||||
|
self::assertNotNull($emptyColumn, 'the now-empty column-inner is still a valid, findable node');
|
||||||
|
self::assertSame([], $emptyColumn->children());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeleteUnknownIdReturnsNotFound(): void
|
||||||
|
{
|
||||||
|
$result = $this->delete()->execute([
|
||||||
|
'post_id' => 7, 'tree_rev' => $this->treeRev(), 'ids' => ['s9'], 'confirm' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertToolError($result, 'not_found');
|
||||||
|
self::assertSame('not_found', self::clientError($result)['code']);
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same annotations as the existing destructive tools (e.g. MediaDeleteAbility). */
|
||||||
|
public function testDeleteAnnotationsMatchExistingDestructiveTools(): void
|
||||||
|
{
|
||||||
|
self::assertSame(
|
||||||
|
['readonly' => false, 'destructive' => true, 'idempotent' => false],
|
||||||
|
$this->delete()->annotations()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling S14 (Task 7): the deleted node's own confirm question, unaffected by other tests. */
|
||||||
|
public function testDeleteAsksBeforeRemovingNestedNodesOnlyOnce(): void
|
||||||
|
{
|
||||||
|
$duplicated = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'id' => 's1']);
|
||||||
|
self::assertSame('s2', $duplicated['changed'][0]['id']);
|
||||||
|
|
||||||
|
$delete = $this->delete();
|
||||||
|
$ask = $delete->execute(['post_id' => 7, 'tree_rev' => Rev::of($this->saved[0]), 'ids' => ['s2', 's2.r0']]);
|
||||||
|
self::assertToolError($ask, 'confirm_required');
|
||||||
|
self::assertSame(
|
||||||
|
'1 Divi-Knoten würden samt Inhalt entfernt: s2 section "Leistungen (Kopie)" (1 Kind-Knoten).',
|
||||||
|
self::clientError($ask)['message']
|
||||||
|
);
|
||||||
|
|
||||||
|
$deleted = $delete->execute(['post_id' => 7, 'tree_rev' => Rev::of($this->saved[0]), 'ids' => ['s2'], 'confirm' => true]);
|
||||||
|
self::assertSame([['id' => 's2', 'type' => 'divi/section', 'action' => 'deleted', 'label' => 'Leistungen (Kopie)']], $deleted['changed']);
|
||||||
|
self::assertSame($this->original, $this->saved[1], 'deleting the copy restores the exact bytes');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Rev;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||||
|
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||||
|
|
||||||
|
final class DiviDuplicateNodeAbilityTest extends DiviWriteAbilityTestCase
|
||||||
|
{
|
||||||
|
/** The copy sits directly after the original; its own child content is byte-identical, nothing else changes. */
|
||||||
|
public function testDuplicateInsertsTheCopyDirectlyAfterTheOriginal(): void
|
||||||
|
{
|
||||||
|
$sectionOpen = '<!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Leistungen"}}}},'
|
||||||
|
. '"builderVersion":"5.2.1"} -->';
|
||||||
|
$sectionClose = '<!-- /wp:divi/section -->';
|
||||||
|
$start = strpos($this->original, $sectionOpen) + strlen($sectionOpen);
|
||||||
|
$end = strpos($this->original, $sectionClose, $start);
|
||||||
|
$rowBytes = substr($this->original, $start, $end - $start);
|
||||||
|
$original = $sectionOpen . $rowBytes . $sectionClose;
|
||||||
|
self::assertStringContainsString($original, $this->original, 'precondition: exact bytes as expected');
|
||||||
|
|
||||||
|
$result = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'id' => 's1']);
|
||||||
|
|
||||||
|
self::assertSame('s2', $result['changed'][0]['id']);
|
||||||
|
self::assertSame('Leistungen (Kopie)', $result['changed'][0]['label']);
|
||||||
|
self::assertStringContainsString('Leistungen', (string) $result['outline'], 'the outline is embedded in the result');
|
||||||
|
|
||||||
|
$copyOpen = str_replace('"Leistungen"', '"Leistungen (Kopie)"', $sectionOpen);
|
||||||
|
$copy = $copyOpen . $rowBytes . $sectionClose;
|
||||||
|
self::assertSame(
|
||||||
|
str_replace($original, $original . $copy, $this->original),
|
||||||
|
$this->saved[0],
|
||||||
|
'copy directly after the original, byte-identical child content, nothing else changes'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A global module copy stays a byte-identical reference (read-only nodes move/duplicate as a whole). */
|
||||||
|
public function testDuplicateGlobalModuleCopyIsAByteIdenticalReference(): void
|
||||||
|
{
|
||||||
|
$mixed = DiviFixtures::markup('synthetic-mixed.html');
|
||||||
|
$this->post->post_content = $mixed;
|
||||||
|
$globalRow = '<!-- wp:divi/row {"globalModule":"913","module":{"meta":{"adminLabel":{"desktop":{"value":'
|
||||||
|
. '"Globale Zeile"}}}}} --><!-- wp:divi/column {"builderVersion":"5.2.1"} --><!-- wp:divi/text '
|
||||||
|
. '{"content":{"innerContent":{"desktop":{"value":"lokal"}}}} /--><!-- /wp:divi/column --><!-- /wp:divi/row'
|
||||||
|
. ' -->';
|
||||||
|
self::assertStringContainsString($globalRow, $mixed, 'precondition: exact bytes as expected');
|
||||||
|
|
||||||
|
$result = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => Rev::of($mixed), 'id' => 's1.g1']);
|
||||||
|
|
||||||
|
self::assertSame('s1.g2', $result['changed'][0]['id']);
|
||||||
|
self::assertSame('divi/row', $result['changed'][0]['type']);
|
||||||
|
self::assertSame('Globale Zeile', $result['changed'][0]['label'], 'opaque copies keep their label unchanged');
|
||||||
|
self::assertSame(str_replace($globalRow, $globalRow . $globalRow, $mixed), $this->saved[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix round 1 (Important-1): the copy's admin label must be the raw stored desktop value
|
||||||
|
* plus " (Kopie)" – never NodeSummary::label()'s display-shortened text (80-char limit).
|
||||||
|
*/
|
||||||
|
public function testDuplicateCopiesALongAdminLabelVerbatim(): void
|
||||||
|
{
|
||||||
|
$long = str_repeat('Ausführliche Beschriftung, ', 4) . 'Ende';
|
||||||
|
self::assertGreaterThan(80, strlen($long), 'precondition: longer than the NodeSummary::label() display limit');
|
||||||
|
$content = str_replace(
|
||||||
|
'"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero – Button"}}}}',
|
||||||
|
'"module":{"meta":{"adminLabel":{"desktop":{"value":"' . $long . '"}}}}',
|
||||||
|
$this->original
|
||||||
|
);
|
||||||
|
self::assertNotSame($content, $this->original, 'precondition: replaced exactly once');
|
||||||
|
$this->post->post_content = $content;
|
||||||
|
|
||||||
|
$result = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => Rev::of($content), 'id' => 's0.r0.c1.m2']);
|
||||||
|
|
||||||
|
self::assertTrue($result['ok'], (string) json_encode($result));
|
||||||
|
$index = NodeIndex::build((new Parser())->parse($this->saved[0]));
|
||||||
|
$copy = $index->find('s0.r0.c1.m3');
|
||||||
|
self::assertNotNull($copy, 'the duplicate sits right after the original module');
|
||||||
|
self::assertSame(
|
||||||
|
$long . ' (Kopie)',
|
||||||
|
AttrMap::desktop($copy->attrs(), 'module.meta.adminLabel'),
|
||||||
|
'stored verbatim, not shortened/ellipsised for display'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDuplicateCopiesALabelWithNewlinesAndMultipleSpacesVerbatim(): void
|
||||||
|
{
|
||||||
|
$messy = "Erste Zeile mit viel Leerraum\nZweite Zeile";
|
||||||
|
$encoded = substr((string) json_encode($messy, JSON_UNESCAPED_UNICODE), 1, -1);
|
||||||
|
$content = str_replace(
|
||||||
|
'"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero – Button"}}}}',
|
||||||
|
'"module":{"meta":{"adminLabel":{"desktop":{"value":"' . $encoded . '"}}}}',
|
||||||
|
$this->original
|
||||||
|
);
|
||||||
|
self::assertNotSame($content, $this->original, 'precondition: replaced exactly once');
|
||||||
|
$this->post->post_content = $content;
|
||||||
|
|
||||||
|
$result = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => Rev::of($content), 'id' => 's0.r0.c1.m2']);
|
||||||
|
|
||||||
|
self::assertTrue($result['ok'], (string) json_encode($result));
|
||||||
|
$index = NodeIndex::build((new Parser())->parse($this->saved[0]));
|
||||||
|
$copy = $index->find('s0.r0.c1.m3');
|
||||||
|
self::assertNotNull($copy, 'the duplicate sits right after the original module');
|
||||||
|
self::assertSame(
|
||||||
|
$messy . ' (Kopie)',
|
||||||
|
AttrMap::desktop($copy->attrs(), 'module.meta.adminLabel'),
|
||||||
|
'whitespace/newlines kept verbatim, not collapsed'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Rev;
|
||||||
|
|
||||||
|
final class DiviInsertAbilityTest extends DiviWriteAbilityTestCase
|
||||||
|
{
|
||||||
|
public function testInsertComposesAndSavesWithTreeRevAndPostRev(): void
|
||||||
|
{
|
||||||
|
$result = $this->insert()->execute([
|
||||||
|
'post_id' => 7,
|
||||||
|
'tree_rev' => $this->treeRev(),
|
||||||
|
'after' => 's0.r0.c1.m1',
|
||||||
|
'nodes' => [['type' => 'text', 'content' => '<p>Neu</p>']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertTrue($result['ok'], (string) json_encode($result));
|
||||||
|
self::assertCount(1, $this->saved);
|
||||||
|
self::assertSame([['id' => 's0.r0.c1.m2', 'type' => 'divi/text', 'action' => 'inserted', 'label' => 'Hero – Text']], $result['changed']);
|
||||||
|
self::assertSame(Rev::of($this->saved[0]), $result['tree_rev']);
|
||||||
|
self::assertSame(Rev::ofPost($this->post), $result['rev']);
|
||||||
|
self::assertSame(811, $result['revision_id']);
|
||||||
|
self::assertSame(4711, $result['log_id']);
|
||||||
|
self::assertStringStartsWith('s0 section "Hero"', (string) $result['outline']);
|
||||||
|
self::assertSame('https://kunde.test/?page_id=7&et_fb=1', $result['edit_url']);
|
||||||
|
|
||||||
|
// Byte identity: only the new block was added, every other byte is unchanged.
|
||||||
|
$inserted = '<!-- wp:divi/text {"builderVersion":"5.2.1","content":{"innerContent":{"desktop":{"value":'
|
||||||
|
. '"\u003cp\u003eNeu\u003c/p\u003e"}}},"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero – Text"}}}}} '
|
||||||
|
. '--><!-- /wp:divi/text -->';
|
||||||
|
self::assertSame(str_replace('<!-- /wp:divi/text --><!-- wp:divi/button', '<!-- /wp:divi/text -->' . $inserted . '<!-- wp:divi/button', $this->original), $this->saved[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStaleTreeRevAndInvalidNodesWriteNothing(): void
|
||||||
|
{
|
||||||
|
$stale = $this->insert()->execute(['post_id' => 7, 'tree_rev' => '000000000000', 'nodes' => [['type' => 'section']]]);
|
||||||
|
self::assertToolError($stale, 'rev_mismatch');
|
||||||
|
self::assertSame('rev_mismatch', self::clientError($stale)['code'], 'the client sees the code without lit_');
|
||||||
|
self::assertStringContainsString('lit-divi-outline', $stale->get_error_data()['hint']);
|
||||||
|
self::assertSame($stale->get_error_code(), $this->insert()->permission(['post_id' => 7, 'tree_rev' => 'x'])->get_error_code());
|
||||||
|
|
||||||
|
$invalid = $this->insert()->execute([
|
||||||
|
'post_id' => 7,
|
||||||
|
'tree_rev' => $this->treeRev(),
|
||||||
|
'parent' => 's0.r0.c0',
|
||||||
|
'nodes' => [
|
||||||
|
['type' => 'heading', 'attrs' => ['title.fnt' => ['desktop' => 'x']]],
|
||||||
|
['type' => 'button', 'url' => 'javascript:alert(1)', 'text' => 'x', 'nope' => 1],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
self::assertToolError($invalid, 'invalid_attr_path');
|
||||||
|
$client = self::clientError($invalid);
|
||||||
|
self::assertSame('invalid_attr_path', $client['code']);
|
||||||
|
self::assertArrayHasKey('nodes[0].attrs.title.fnt', $client['fields']);
|
||||||
|
self::assertGreaterThanOrEqual(2, count($client['fields']), 'every error of the call in one response (S39)');
|
||||||
|
self::assertStringContainsString('lit-divi-catalog', $client['hint']);
|
||||||
|
|
||||||
|
$wrongPlace = $this->insert()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'nodes' => [['type' => 'button']]]);
|
||||||
|
self::assertToolError($wrongPlace, 'validation_failed');
|
||||||
|
|
||||||
|
$badPosition = $this->insert()->execute([
|
||||||
|
'post_id' => 7,
|
||||||
|
'tree_rev' => $this->treeRev(),
|
||||||
|
'parent' => 's0.r0.c0',
|
||||||
|
'after' => 's0.r0.c1.m1',
|
||||||
|
'nodes' => [['type' => 'text']],
|
||||||
|
]);
|
||||||
|
self::assertToolError($badPosition, 'validation_failed');
|
||||||
|
self::assertArrayHasKey('position', self::clientError($badPosition)['fields']);
|
||||||
|
|
||||||
|
$unknown = $this->insert()->execute([
|
||||||
|
'post_id' => 7,
|
||||||
|
'tree_rev' => $this->treeRev(),
|
||||||
|
'parent' => 's9',
|
||||||
|
'nodes' => [['type' => 'section']],
|
||||||
|
]);
|
||||||
|
self::assertToolError($unknown, 'validation_failed');
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
self::assertSame($this->original, $this->post->post_content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling S9: the first section of an empty page goes into a divi/placeholder. */
|
||||||
|
public function testInsertIntoAnEmptyPageWrapsInAPlaceholder(): void
|
||||||
|
{
|
||||||
|
$this->post->post_content = '';
|
||||||
|
|
||||||
|
$result = $this->insert()->execute([
|
||||||
|
'post_id' => 7,
|
||||||
|
'tree_rev' => Rev::of(''),
|
||||||
|
'nodes' => [['type' => 'section', 'label' => 'Start', 'modules' => [['type' => 'heading', 'text' => 'Hallo']]]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertTrue($result['ok'], (string) json_encode($result));
|
||||||
|
self::assertCount(1, $this->saved);
|
||||||
|
self::assertStringStartsWith('<!-- wp:divi/placeholder --><!-- wp:divi/section ', $this->saved[0]);
|
||||||
|
self::assertStringEndsWith('<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->', $this->saved[0]);
|
||||||
|
self::assertSame('s0', $result['changed'][0]['id']);
|
||||||
|
self::assertSame('Start', $result['changed'][0]['label']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling S40: the S38 overlap rule for new nodes too. */
|
||||||
|
public function testInsertRefusesShortFormAndAttrsOnTheSamePath(): void
|
||||||
|
{
|
||||||
|
$result = $this->insert()->execute([
|
||||||
|
'post_id' => 7,
|
||||||
|
'tree_rev' => $this->treeRev(),
|
||||||
|
'parent' => 's0.r0.c1',
|
||||||
|
'nodes' => [[
|
||||||
|
'type' => 'button',
|
||||||
|
'text' => 'Neu',
|
||||||
|
'attrs' => ['button.innerContent' => ['desktop' => ['linkUrl' => '/neu']]],
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertToolError($result, 'validation_failed');
|
||||||
|
self::assertArrayHasKey('nodes[0].attrs.button.innerContent', self::clientError($result)['fields']);
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Rev;
|
||||||
|
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||||
|
|
||||||
|
final class DiviMoveAbilityTest extends DiviWriteAbilityTestCase
|
||||||
|
{
|
||||||
|
/** Ruling S14: move there and back restores the exact bytes (full-content assertSame, no strlen). */
|
||||||
|
public function testMoveThereAndBackIsByteExact(): void
|
||||||
|
{
|
||||||
|
$moved = $this->move()->execute([
|
||||||
|
'post_id' => 7, 'tree_rev' => $this->treeRev(), 'id' => 's0.r0.c1.m2', 'parent' => 's1.r0.c0',
|
||||||
|
]);
|
||||||
|
self::assertSame([['id' => 's1.r0.c0.m1', 'type' => 'divi/button', 'action' => 'moved', 'label' => 'Hero – Button']], $moved['changed']);
|
||||||
|
self::assertStringContainsString('Leistungen', (string) $moved['outline'], 'the outline is embedded in the result');
|
||||||
|
|
||||||
|
$back = $this->move()->execute([
|
||||||
|
'post_id' => 7, 'tree_rev' => Rev::of($this->saved[0]), 'id' => 's1.r0.c0.m1', 'parent' => 's0.r0.c1',
|
||||||
|
]);
|
||||||
|
self::assertSame([['id' => 's0.r0.c1.m2', 'type' => 'divi/button', 'action' => 'moved', 'label' => 'Hero – Button']], $back['changed']);
|
||||||
|
self::assertSame($this->original, $this->saved[1], 'move there and back restores the exact bytes (S14)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** StructureRules apply on move: a module directly into a section, or into a specialty column. */
|
||||||
|
public function testMoveRefusedByStructureRules(): void
|
||||||
|
{
|
||||||
|
$mixed = DiviFixtures::markup('synthetic-mixed.html');
|
||||||
|
$this->post->post_content = $mixed;
|
||||||
|
$treeRev = Rev::of($mixed);
|
||||||
|
|
||||||
|
// s1 is a regular section: it holds rows, not modules directly.
|
||||||
|
$intoSection = $this->move()->execute([
|
||||||
|
'post_id' => 7, 'tree_rev' => $treeRev, 'id' => 's0.c0.m0', 'parent' => 's1',
|
||||||
|
]);
|
||||||
|
self::assertToolError($intoSection, 'validation_failed');
|
||||||
|
self::assertStringContainsString('Zeilen (row)', self::clientError($intoSection)['fields']['position']);
|
||||||
|
|
||||||
|
// s0.c1 is a specialty column (specialtyColumns set, holds a row-inner): refuses every child.
|
||||||
|
$intoSpecialtyColumn = $this->move()->execute([
|
||||||
|
'post_id' => 7, 'tree_rev' => $treeRev, 'id' => 's0.c0.m0', 'parent' => 's0.c1',
|
||||||
|
]);
|
||||||
|
self::assertToolError($intoSpecialtyColumn, 'validation_failed');
|
||||||
|
self::assertStringContainsString('Spezialspalten', self::clientError($intoSpecialtyColumn)['fields']['position']);
|
||||||
|
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
self::assertSame($mixed, $this->post->post_content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMoveIntoItsOwnDescendantIsRefused(): void
|
||||||
|
{
|
||||||
|
$result = $this->move()->execute([
|
||||||
|
'post_id' => 7, 'tree_rev' => $this->treeRev(), 'id' => 's0', 'parent' => 's0.r0.c1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertToolError($result, 'validation_failed');
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'nicht in sich selbst verschoben',
|
||||||
|
self::clientError($result)['fields']['position']
|
||||||
|
);
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMoveRefusesAStaleTreeRev(): void
|
||||||
|
{
|
||||||
|
$result = $this->move()->execute([
|
||||||
|
'post_id' => 7, 'tree_rev' => '000000000000', 'id' => 's0.r0.c1.m2', 'parent' => 's1.r0.c0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertToolError($result, 'rev_mismatch');
|
||||||
|
self::assertSame('rev_mismatch', self::clientError($result)['code'], 'the client sees the code without lit_');
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Rev;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||||
|
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
final class DiviUpdateAbilityTest extends DiviWriteAbilityTestCase
|
||||||
|
{
|
||||||
|
public function testUpdateChangesSeveralNodesAllOrNothing(): void
|
||||||
|
{
|
||||||
|
$failed = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
|
||||||
|
['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Jetzt buchen']],
|
||||||
|
['id' => 's0.r0.c1.m0', 'set' => ['level' => 'h9']],
|
||||||
|
['id' => 's0.r0.c1.m9', 'label' => 'x'],
|
||||||
|
]]);
|
||||||
|
self::assertToolError($failed, 'validation_failed');
|
||||||
|
$fields = self::clientError($failed)['fields'];
|
||||||
|
self::assertArrayHasKey('changes[1].set.level', $fields);
|
||||||
|
self::assertArrayHasKey('changes[2].id', $fields, 'every error of the call in one response (S39)');
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
|
||||||
|
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
|
||||||
|
['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Jetzt buchen']],
|
||||||
|
['id' => 's0.r0.c1.m0', 'attrs' => ['title.decoration.font.font' => ['phone' => null]]],
|
||||||
|
]]);
|
||||||
|
|
||||||
|
self::assertTrue($result['ok']);
|
||||||
|
self::assertSame(['s0.r0.c1.m2', 's0.r0.c1.m0'], array_column($result['changed'], 'id'));
|
||||||
|
self::assertSame(['updated', 'updated'], array_column($result['changed'], 'action'));
|
||||||
|
self::assertSame(Rev::of($this->saved[0]), $result['tree_rev']);
|
||||||
|
$index = NodeIndex::build((new Parser())->parse($this->saved[0]));
|
||||||
|
self::assertSame('Jetzt buchen', $index->find('s0.r0.c1.m2')?->attrs()['button']['innerContent']['desktop']['value']['text']);
|
||||||
|
self::assertArrayNotHasKey('phone', $index->find('s0.r0.c1.m0')?->attrs()['title']['decoration']['font']['font']);
|
||||||
|
// Untouched section s1 keeps its exact bytes.
|
||||||
|
self::assertStringContainsString(substr($this->original, (int) strpos($this->original, '<!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Leistungen"'), -1), $this->saved[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateWithoutRealChangeSavesNothing(): void
|
||||||
|
{
|
||||||
|
$result = $this->update()->execute([
|
||||||
|
'post_id' => 7,
|
||||||
|
'tree_rev' => $this->treeRev(),
|
||||||
|
'changes' => [['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Termin buchen']]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertStringStartsWith('Keine Änderung', $result['summary']);
|
||||||
|
self::assertSame($this->treeRev(), $result['tree_rev']);
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateRefusesAStaleTreeRev(): void
|
||||||
|
{
|
||||||
|
$result = $this->update()->execute([
|
||||||
|
'post_id' => 7,
|
||||||
|
'tree_rev' => '000000000000',
|
||||||
|
'changes' => [['id' => 's0.r0.c1.m2', 'label' => 'x']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertToolError($result, 'rev_mismatch');
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReadOnlyNodesCannotBeUpdated(): void
|
||||||
|
{
|
||||||
|
$this->post->post_content = DiviFixtures::markup('synthetic-mixed.html');
|
||||||
|
|
||||||
|
$result = $this->update()->execute([
|
||||||
|
'post_id' => 7,
|
||||||
|
'tree_rev' => Rev::of($this->post->post_content),
|
||||||
|
'changes' => [['id' => 's1.g1', 'label' => 'x']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertToolError($result, 'validation_failed');
|
||||||
|
self::assertStringContainsString('nur lesbar', $result->get_error_message());
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling S14: the same node twice in one call would silently lose the first change. */
|
||||||
|
public function testUpdateRefusesADuplicateId(): void
|
||||||
|
{
|
||||||
|
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
|
||||||
|
['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Eins']],
|
||||||
|
['id' => 's0.r0.c1.m2', 'set' => ['url' => '/zwei']],
|
||||||
|
]]);
|
||||||
|
|
||||||
|
self::assertToolError($result, 'validation_failed');
|
||||||
|
self::assertStringContainsString('changes[0]', self::clientError($result)['fields']['changes[1].id']);
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling S38: set and attrs of one change must not reach the same attribute path. */
|
||||||
|
public function testUpdateRefusesSetAndAttrsOnTheSamePath(): void
|
||||||
|
{
|
||||||
|
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [[
|
||||||
|
'id' => 's0.r0.c1.m2',
|
||||||
|
'set' => ['text' => 'Neu'],
|
||||||
|
'attrs' => ['button.innerContent' => ['desktop' => ['linkUrl' => '/neu']]],
|
||||||
|
]]]);
|
||||||
|
|
||||||
|
self::assertToolError($result, 'validation_failed');
|
||||||
|
self::assertArrayHasKey('changes[0].attrs.button.innerContent', self::clientError($result)['fields']);
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling S41: a column width change must still fill its row exactly. */
|
||||||
|
public function testUpdateChecksTheRowWidths(): void
|
||||||
|
{
|
||||||
|
$sizing = static fn (string $width): array => ['module.decoration.sizing' => ['desktop' => ['flexType' => $width]]];
|
||||||
|
|
||||||
|
$broken = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
|
||||||
|
['id' => 's0.r0.c0', 'attrs' => $sizing('12_24')],
|
||||||
|
]]);
|
||||||
|
self::assertToolError($broken, 'validation_failed');
|
||||||
|
self::assertArrayHasKey('changes[0].attrs.module.decoration.sizing', self::clientError($broken)['fields']);
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
|
||||||
|
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
|
||||||
|
['id' => 's0.r0.c0', 'attrs' => $sizing('12_24')],
|
||||||
|
['id' => 's0.r0.c1', 'attrs' => $sizing('12_24')],
|
||||||
|
]]);
|
||||||
|
self::assertTrue($result['ok'], (string) json_encode($result));
|
||||||
|
self::assertCount(1, $this->saved);
|
||||||
|
self::assertStringContainsString('"flexType":"12_24"}}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/image', $this->saved[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $attrs */
|
||||||
|
private function updateOn(string $content, array $changes): mixed
|
||||||
|
{
|
||||||
|
$this->post->post_content = $content;
|
||||||
|
|
||||||
|
return $this->update()->execute(['post_id' => 7, 'tree_rev' => Rev::of($content), 'changes' => $changes]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private static function sizing(?string $width): array
|
||||||
|
{
|
||||||
|
return ['module.decoration.sizing' => $width === null ? null : ['desktop' => ['flexType' => $width]]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling S43: only builder column classes (or null) are accepted as new desktop widths. */
|
||||||
|
public function testUpdateChecksTheColumnClassFormat(): void
|
||||||
|
{
|
||||||
|
foreach (['4_5', '13_25', '12_24,12_24', 'auto'] as $width) {
|
||||||
|
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
|
||||||
|
['id' => 's0.r0.c0', 'attrs' => self::sizing($width)],
|
||||||
|
]]);
|
||||||
|
self::assertInstanceOf(WP_Error::class, $result, $width);
|
||||||
|
self::assertStringContainsString('none', self::clientError($result)['fields']['changes[0].attrs.module.decoration.sizing'], $width);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling S43: rows that are no plain exact row are not sum-checked (none, omitted width, wrap). */
|
||||||
|
public function testUpdateAcceptsWidthChangesInLooseRows(): void
|
||||||
|
{
|
||||||
|
$noneRow = str_replace('"flexType":"8_24"', '"flexType":"none"', $this->original);
|
||||||
|
self::assertTrue($this->updateOn($noneRow, [['id' => 's0.r0.c1', 'attrs' => self::sizing('12_24')]])['ok']);
|
||||||
|
|
||||||
|
$omitted = str_replace(
|
||||||
|
'{"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"8_24"}}}}},"builderVersion":"5.2.1"}',
|
||||||
|
'{"builderVersion":"5.2.1"}',
|
||||||
|
$this->original
|
||||||
|
);
|
||||||
|
self::assertNotSame($omitted, $this->original);
|
||||||
|
self::assertTrue($this->updateOn($omitted, [['id' => 's0.r0.c1', 'attrs' => self::sizing('12_24')]])['ok']);
|
||||||
|
self::assertTrue($this->updateOn($omitted, [['id' => 's0.r0.c1', 'attrs' => self::sizing(null)]])['ok'], 'removal');
|
||||||
|
|
||||||
|
$wrap = str_replace(
|
||||||
|
'<!-- wp:divi/row {"module":{"meta":',
|
||||||
|
'<!-- wp:divi/row {"module":{"decoration":{"layout":{"desktop":{"value":{"flexWrap":"wrap"}}}},"meta":',
|
||||||
|
$this->original
|
||||||
|
);
|
||||||
|
self::assertNotSame($wrap, $this->original);
|
||||||
|
self::assertTrue($this->updateOn($wrap, [['id' => 's0.r0.c0', 'attrs' => self::sizing('12_24')]])['ok']);
|
||||||
|
|
||||||
|
self::assertTrue($this->updateOn($this->original, [['id' => 's0.r0.c0', 'attrs' => self::sizing('none')]])['ok'], 'none itself');
|
||||||
|
self::assertCount(5, $this->saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling S43: a plain exact row stays strict; removal counts as Divi's default 24_24. */
|
||||||
|
public function testUpdateKeepsExactRowsStrict(): void
|
||||||
|
{
|
||||||
|
$broken = $this->updateOn($this->original, [['id' => 's0.r0.c0', 'attrs' => self::sizing(null)]]);
|
||||||
|
self::assertToolError($broken, 'validation_failed');
|
||||||
|
$broken = $this->updateOn($this->original, [['id' => 's0.r0.c0', 'attrs' => self::sizing('10_24')]]);
|
||||||
|
self::assertToolError($broken, 'validation_failed');
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling S7: builder-only paths are refused for users without unfiltered_html – visible to the client. */
|
||||||
|
public function testBuilderOnlyPathsAreRefusedForEditors(): void
|
||||||
|
{
|
||||||
|
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [[
|
||||||
|
'id' => 's0.r0.c1.m1',
|
||||||
|
'attrs' => ['module.decoration.attributes' => ['desktop' => ['attributes' => [['name' => 'onclick', 'value' => 'x']]]]],
|
||||||
|
]]]);
|
||||||
|
|
||||||
|
self::assertToolError($result, 'validation_failed');
|
||||||
|
$client = self::clientError($result);
|
||||||
|
self::assertSame('validation_failed', $client['code']);
|
||||||
|
self::assertStringContainsString('nur im Divi-Builder', $client['fields']['changes[0].attrs.module.decoration.attributes']);
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,653 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
|
||||||
|
|
||||||
use Brain\Monkey\Functions;
|
|
||||||
use LIT\MCP\Abilities\Rev;
|
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
|
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility;
|
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;
|
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviMoveAbility;
|
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
|
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
|
||||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
|
||||||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
|
||||||
use LIT\MCP\Tests\Unit\TestCase;
|
|
||||||
use WP_Error;
|
|
||||||
use WP_Post;
|
|
||||||
|
|
||||||
final class DiviWriteAbilitiesTest extends TestCase
|
|
||||||
{
|
|
||||||
private WP_Post $post;
|
|
||||||
private string $original = '';
|
|
||||||
|
|
||||||
/** @var list<string> Contents handed to DiviSaver::save(). */
|
|
||||||
private array $saved = [];
|
|
||||||
|
|
||||||
private DiviWriter $writer;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
parent::setUp();
|
|
||||||
$this->original = DiviFixtures::markup('synthetic-basic.html');
|
|
||||||
$this->post = DiviFixtures::givenPost($this->original);
|
|
||||||
Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true');
|
|
||||||
$this->writer = DiviWriterFixture::writer($this->saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function treeRev(): string
|
|
||||||
{
|
|
||||||
return Rev::of($this->original);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function insert(): DiviInsertAbility
|
|
||||||
{
|
|
||||||
return new DiviInsertAbility($this->writer);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function update(): DiviUpdateAbility
|
|
||||||
{
|
|
||||||
return new DiviUpdateAbility($this->writer);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function move(): DiviMoveAbility
|
|
||||||
{
|
|
||||||
return new DiviMoveAbility($this->writer);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function delete(): DiviDeleteAbility
|
|
||||||
{
|
|
||||||
return new DiviDeleteAbility($this->writer);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function duplicate(): DiviDuplicateNodeAbility
|
|
||||||
{
|
|
||||||
return new DiviDuplicateNodeAbility($this->writer);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testInsertComposesAndSavesWithTreeRevAndPostRev(): void
|
|
||||||
{
|
|
||||||
$result = $this->insert()->execute([
|
|
||||||
'post_id' => 7,
|
|
||||||
'tree_rev' => $this->treeRev(),
|
|
||||||
'after' => 's0.r0.c1.m1',
|
|
||||||
'nodes' => [['type' => 'text', 'content' => '<p>Neu</p>']],
|
|
||||||
]);
|
|
||||||
|
|
||||||
self::assertTrue($result['ok'], (string) json_encode($result));
|
|
||||||
self::assertCount(1, $this->saved);
|
|
||||||
self::assertSame([['id' => 's0.r0.c1.m2', 'type' => 'divi/text', 'action' => 'inserted', 'label' => 'Hero – Text']], $result['changed']);
|
|
||||||
self::assertSame(Rev::of($this->saved[0]), $result['tree_rev']);
|
|
||||||
self::assertSame(Rev::ofPost($this->post), $result['rev']);
|
|
||||||
self::assertSame(811, $result['revision_id']);
|
|
||||||
self::assertSame(4711, $result['log_id']);
|
|
||||||
self::assertStringStartsWith('s0 section "Hero"', (string) $result['outline']);
|
|
||||||
self::assertSame('https://kunde.test/?page_id=7&et_fb=1', $result['edit_url']);
|
|
||||||
|
|
||||||
// Byte identity: only the new block was added, every other byte is unchanged.
|
|
||||||
$inserted = '<!-- wp:divi/text {"builderVersion":"5.2.1","content":{"innerContent":{"desktop":{"value":'
|
|
||||||
. '"\u003cp\u003eNeu\u003c/p\u003e"}}},"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero – Text"}}}}} '
|
|
||||||
. '--><!-- /wp:divi/text -->';
|
|
||||||
self::assertSame(str_replace('<!-- /wp:divi/text --><!-- wp:divi/button', '<!-- /wp:divi/text -->' . $inserted . '<!-- wp:divi/button', $this->original), $this->saved[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testStaleTreeRevAndInvalidNodesWriteNothing(): void
|
|
||||||
{
|
|
||||||
$stale = $this->insert()->execute(['post_id' => 7, 'tree_rev' => '000000000000', 'nodes' => [['type' => 'section']]]);
|
|
||||||
self::assertToolError($stale, 'rev_mismatch');
|
|
||||||
self::assertSame('rev_mismatch', self::clientError($stale)['code'], 'the client sees the code without lit_');
|
|
||||||
self::assertStringContainsString('lit-divi-outline', $stale->get_error_data()['hint']);
|
|
||||||
self::assertSame($stale->get_error_code(), $this->insert()->permission(['post_id' => 7, 'tree_rev' => 'x'])->get_error_code());
|
|
||||||
|
|
||||||
$invalid = $this->insert()->execute([
|
|
||||||
'post_id' => 7,
|
|
||||||
'tree_rev' => $this->treeRev(),
|
|
||||||
'parent' => 's0.r0.c0',
|
|
||||||
'nodes' => [
|
|
||||||
['type' => 'heading', 'attrs' => ['title.fnt' => ['desktop' => 'x']]],
|
|
||||||
['type' => 'button', 'url' => 'javascript:alert(1)', 'text' => 'x', 'nope' => 1],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
self::assertToolError($invalid, 'invalid_attr_path');
|
|
||||||
$client = self::clientError($invalid);
|
|
||||||
self::assertSame('invalid_attr_path', $client['code']);
|
|
||||||
self::assertArrayHasKey('nodes[0].attrs.title.fnt', $client['fields']);
|
|
||||||
self::assertGreaterThanOrEqual(2, count($client['fields']), 'every error of the call in one response (S39)');
|
|
||||||
self::assertStringContainsString('lit-divi-catalog', $client['hint']);
|
|
||||||
|
|
||||||
$wrongPlace = $this->insert()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'nodes' => [['type' => 'button']]]);
|
|
||||||
self::assertToolError($wrongPlace, 'validation_failed');
|
|
||||||
|
|
||||||
$badPosition = $this->insert()->execute([
|
|
||||||
'post_id' => 7,
|
|
||||||
'tree_rev' => $this->treeRev(),
|
|
||||||
'parent' => 's0.r0.c0',
|
|
||||||
'after' => 's0.r0.c1.m1',
|
|
||||||
'nodes' => [['type' => 'text']],
|
|
||||||
]);
|
|
||||||
self::assertToolError($badPosition, 'validation_failed');
|
|
||||||
self::assertArrayHasKey('position', self::clientError($badPosition)['fields']);
|
|
||||||
|
|
||||||
$unknown = $this->insert()->execute([
|
|
||||||
'post_id' => 7,
|
|
||||||
'tree_rev' => $this->treeRev(),
|
|
||||||
'parent' => 's9',
|
|
||||||
'nodes' => [['type' => 'section']],
|
|
||||||
]);
|
|
||||||
self::assertToolError($unknown, 'validation_failed');
|
|
||||||
self::assertSame([], $this->saved);
|
|
||||||
self::assertSame($this->original, $this->post->post_content);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ruling S9: the first section of an empty page goes into a divi/placeholder. */
|
|
||||||
public function testInsertIntoAnEmptyPageWrapsInAPlaceholder(): void
|
|
||||||
{
|
|
||||||
$this->post->post_content = '';
|
|
||||||
|
|
||||||
$result = $this->insert()->execute([
|
|
||||||
'post_id' => 7,
|
|
||||||
'tree_rev' => Rev::of(''),
|
|
||||||
'nodes' => [['type' => 'section', 'label' => 'Start', 'modules' => [['type' => 'heading', 'text' => 'Hallo']]]],
|
|
||||||
]);
|
|
||||||
|
|
||||||
self::assertTrue($result['ok'], (string) json_encode($result));
|
|
||||||
self::assertCount(1, $this->saved);
|
|
||||||
self::assertStringStartsWith('<!-- wp:divi/placeholder --><!-- wp:divi/section ', $this->saved[0]);
|
|
||||||
self::assertStringEndsWith('<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->', $this->saved[0]);
|
|
||||||
self::assertSame('s0', $result['changed'][0]['id']);
|
|
||||||
self::assertSame('Start', $result['changed'][0]['label']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testUpdateChangesSeveralNodesAllOrNothing(): void
|
|
||||||
{
|
|
||||||
$failed = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
|
|
||||||
['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Jetzt buchen']],
|
|
||||||
['id' => 's0.r0.c1.m0', 'set' => ['level' => 'h9']],
|
|
||||||
['id' => 's0.r0.c1.m9', 'label' => 'x'],
|
|
||||||
]]);
|
|
||||||
self::assertToolError($failed, 'validation_failed');
|
|
||||||
$fields = self::clientError($failed)['fields'];
|
|
||||||
self::assertArrayHasKey('changes[1].set.level', $fields);
|
|
||||||
self::assertArrayHasKey('changes[2].id', $fields, 'every error of the call in one response (S39)');
|
|
||||||
self::assertSame([], $this->saved);
|
|
||||||
|
|
||||||
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
|
|
||||||
['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Jetzt buchen']],
|
|
||||||
['id' => 's0.r0.c1.m0', 'attrs' => ['title.decoration.font.font' => ['phone' => null]]],
|
|
||||||
]]);
|
|
||||||
|
|
||||||
self::assertTrue($result['ok']);
|
|
||||||
self::assertSame(['s0.r0.c1.m2', 's0.r0.c1.m0'], array_column($result['changed'], 'id'));
|
|
||||||
self::assertSame(['updated', 'updated'], array_column($result['changed'], 'action'));
|
|
||||||
self::assertSame(Rev::of($this->saved[0]), $result['tree_rev']);
|
|
||||||
$index = NodeIndex::build((new Parser())->parse($this->saved[0]));
|
|
||||||
self::assertSame('Jetzt buchen', $index->find('s0.r0.c1.m2')?->attrs()['button']['innerContent']['desktop']['value']['text']);
|
|
||||||
self::assertArrayNotHasKey('phone', $index->find('s0.r0.c1.m0')?->attrs()['title']['decoration']['font']['font']);
|
|
||||||
// Untouched section s1 keeps its exact bytes.
|
|
||||||
self::assertStringContainsString(substr($this->original, (int) strpos($this->original, '<!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Leistungen"'), -1), $this->saved[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testUpdateWithoutRealChangeSavesNothing(): void
|
|
||||||
{
|
|
||||||
$result = $this->update()->execute([
|
|
||||||
'post_id' => 7,
|
|
||||||
'tree_rev' => $this->treeRev(),
|
|
||||||
'changes' => [['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Termin buchen']]],
|
|
||||||
]);
|
|
||||||
|
|
||||||
self::assertStringStartsWith('Keine Änderung', $result['summary']);
|
|
||||||
self::assertSame($this->treeRev(), $result['tree_rev']);
|
|
||||||
self::assertSame([], $this->saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testUpdateRefusesAStaleTreeRev(): void
|
|
||||||
{
|
|
||||||
$result = $this->update()->execute([
|
|
||||||
'post_id' => 7,
|
|
||||||
'tree_rev' => '000000000000',
|
|
||||||
'changes' => [['id' => 's0.r0.c1.m2', 'label' => 'x']],
|
|
||||||
]);
|
|
||||||
|
|
||||||
self::assertToolError($result, 'rev_mismatch');
|
|
||||||
self::assertSame([], $this->saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ruling S14: move there and back restores the exact bytes (full-content assertSame, no strlen). */
|
|
||||||
public function testMoveThereAndBackIsByteExact(): void
|
|
||||||
{
|
|
||||||
$moved = $this->move()->execute([
|
|
||||||
'post_id' => 7, 'tree_rev' => $this->treeRev(), 'id' => 's0.r0.c1.m2', 'parent' => 's1.r0.c0',
|
|
||||||
]);
|
|
||||||
self::assertSame([['id' => 's1.r0.c0.m1', 'type' => 'divi/button', 'action' => 'moved', 'label' => 'Hero – Button']], $moved['changed']);
|
|
||||||
self::assertStringContainsString('Leistungen', (string) $moved['outline'], 'the outline is embedded in the result');
|
|
||||||
|
|
||||||
$back = $this->move()->execute([
|
|
||||||
'post_id' => 7, 'tree_rev' => Rev::of($this->saved[0]), 'id' => 's1.r0.c0.m1', 'parent' => 's0.r0.c1',
|
|
||||||
]);
|
|
||||||
self::assertSame([['id' => 's0.r0.c1.m2', 'type' => 'divi/button', 'action' => 'moved', 'label' => 'Hero – Button']], $back['changed']);
|
|
||||||
self::assertSame($this->original, $this->saved[1], 'move there and back restores the exact bytes (S14)');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** StructureRules apply on move: a module directly into a section, or into a specialty column. */
|
|
||||||
public function testMoveRefusedByStructureRules(): void
|
|
||||||
{
|
|
||||||
$mixed = DiviFixtures::markup('synthetic-mixed.html');
|
|
||||||
$this->post->post_content = $mixed;
|
|
||||||
$treeRev = Rev::of($mixed);
|
|
||||||
|
|
||||||
// s1 is a regular section: it holds rows, not modules directly.
|
|
||||||
$intoSection = $this->move()->execute([
|
|
||||||
'post_id' => 7, 'tree_rev' => $treeRev, 'id' => 's0.c0.m0', 'parent' => 's1',
|
|
||||||
]);
|
|
||||||
self::assertToolError($intoSection, 'validation_failed');
|
|
||||||
self::assertStringContainsString('Zeilen (row)', self::clientError($intoSection)['fields']['position']);
|
|
||||||
|
|
||||||
// s0.c1 is a specialty column (specialtyColumns set, holds a row-inner): refuses every child.
|
|
||||||
$intoSpecialtyColumn = $this->move()->execute([
|
|
||||||
'post_id' => 7, 'tree_rev' => $treeRev, 'id' => 's0.c0.m0', 'parent' => 's0.c1',
|
|
||||||
]);
|
|
||||||
self::assertToolError($intoSpecialtyColumn, 'validation_failed');
|
|
||||||
self::assertStringContainsString('Spezialspalten', self::clientError($intoSpecialtyColumn)['fields']['position']);
|
|
||||||
|
|
||||||
self::assertSame([], $this->saved);
|
|
||||||
self::assertSame($mixed, $this->post->post_content);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testMoveIntoItsOwnDescendantIsRefused(): void
|
|
||||||
{
|
|
||||||
$result = $this->move()->execute([
|
|
||||||
'post_id' => 7, 'tree_rev' => $this->treeRev(), 'id' => 's0', 'parent' => 's0.r0.c1',
|
|
||||||
]);
|
|
||||||
|
|
||||||
self::assertToolError($result, 'validation_failed');
|
|
||||||
self::assertStringContainsString(
|
|
||||||
'nicht in sich selbst verschoben',
|
|
||||||
self::clientError($result)['fields']['position']
|
|
||||||
);
|
|
||||||
self::assertSame([], $this->saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testMoveRefusesAStaleTreeRev(): void
|
|
||||||
{
|
|
||||||
$result = $this->move()->execute([
|
|
||||||
'post_id' => 7, 'tree_rev' => '000000000000', 'id' => 's0.r0.c1.m2', 'parent' => 's1.r0.c0',
|
|
||||||
]);
|
|
||||||
|
|
||||||
self::assertToolError($result, 'rev_mismatch');
|
|
||||||
self::assertSame('rev_mismatch', self::clientError($result)['code'], 'the client sees the code without lit_');
|
|
||||||
self::assertSame([], $this->saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The stored content equals the original minus exactly that block's bytes; revision_id is the restore point. */
|
|
||||||
public function testDeleteAModuleRemovesExactlyItsBytes(): void
|
|
||||||
{
|
|
||||||
$buttonBytes = '<!-- wp:divi/button {"button":{"innerContent":{"desktop":{"value":{"text":"Termin buchen",'
|
|
||||||
. '"linkUrl":"/kontakt","linkTarget":"off"}}}},"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero –'
|
|
||||||
. ' Button"}}}},"builderVersion":"5.2.1"} --><!-- /wp:divi/button -->';
|
|
||||||
self::assertStringContainsString($buttonBytes, $this->original, 'precondition: exact bytes as expected');
|
|
||||||
|
|
||||||
$result = $this->delete()->execute([
|
|
||||||
'post_id' => 7, 'tree_rev' => $this->treeRev(), 'ids' => ['s0.r0.c1.m2'], 'confirm' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
self::assertSame([['id' => 's0.r0.c1.m2', 'type' => 'divi/button', 'action' => 'deleted', 'label' => 'Hero – Button']], $result['changed']);
|
|
||||||
self::assertSame(str_replace($buttonBytes, '', $this->original), $this->saved[0]);
|
|
||||||
self::assertSame(811, $result['revision_id'], 'the restore-point revision');
|
|
||||||
self::assertStringContainsString('Hero', (string) $result['outline'], 'the outline is embedded in the result');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An opaque/global module is removed as a whole – it never gets descended into. */
|
|
||||||
public function testDeleteRemovesAnOpaqueGlobalModuleAsAWhole(): void
|
|
||||||
{
|
|
||||||
$mixed = DiviFixtures::markup('synthetic-mixed.html');
|
|
||||||
$this->post->post_content = $mixed;
|
|
||||||
$globalRow = '<!-- wp:divi/row {"globalModule":"913","module":{"meta":{"adminLabel":{"desktop":{"value":'
|
|
||||||
. '"Globale Zeile"}}}}} --><!-- wp:divi/column {"builderVersion":"5.2.1"} --><!-- wp:divi/text '
|
|
||||||
. '{"content":{"innerContent":{"desktop":{"value":"lokal"}}}} /--><!-- /wp:divi/column --><!-- /wp:divi/row'
|
|
||||||
. ' -->';
|
|
||||||
self::assertStringContainsString($globalRow, $mixed, 'precondition: exact bytes as expected');
|
|
||||||
|
|
||||||
$result = $this->delete()->execute([
|
|
||||||
'post_id' => 7, 'tree_rev' => Rev::of($mixed), 'ids' => ['s1.g1'], 'confirm' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
self::assertSame([['id' => 's1.g1', 'type' => 'divi/row', 'action' => 'deleted', 'label' => 'Globale Zeile']], $result['changed']);
|
|
||||||
self::assertSame(str_replace($globalRow, '', $mixed), $this->saved[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testDeletingTheLastChildLeavesAValidTree(): void
|
|
||||||
{
|
|
||||||
$mixed = DiviFixtures::markup('synthetic-mixed.html');
|
|
||||||
$this->post->post_content = $mixed;
|
|
||||||
|
|
||||||
$result = $this->delete()->execute([
|
|
||||||
'post_id' => 7, 'tree_rev' => Rev::of($mixed), 'ids' => ['s0.c1.r0.c0.m0'], 'confirm' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
self::assertTrue($result['ok'], (string) json_encode($result));
|
|
||||||
$index = NodeIndex::build((new Parser())->parse($this->saved[0]));
|
|
||||||
$emptyColumn = $index->find('s0.c1.r0.c0');
|
|
||||||
self::assertNotNull($emptyColumn, 'the now-empty column-inner is still a valid, findable node');
|
|
||||||
self::assertSame([], $emptyColumn->children());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testDeleteUnknownIdReturnsNotFound(): void
|
|
||||||
{
|
|
||||||
$result = $this->delete()->execute([
|
|
||||||
'post_id' => 7, 'tree_rev' => $this->treeRev(), 'ids' => ['s9'], 'confirm' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
self::assertToolError($result, 'not_found');
|
|
||||||
self::assertSame('not_found', self::clientError($result)['code']);
|
|
||||||
self::assertSame([], $this->saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Same annotations as the existing destructive tools (e.g. MediaDeleteAbility). */
|
|
||||||
public function testDeleteAnnotationsMatchExistingDestructiveTools(): void
|
|
||||||
{
|
|
||||||
self::assertSame(
|
|
||||||
['readonly' => false, 'destructive' => true, 'idempotent' => false],
|
|
||||||
$this->delete()->annotations()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ruling S14 (Task 7): the deleted node's own confirm question, unaffected by other tests. */
|
|
||||||
public function testDeleteAsksBeforeRemovingNestedNodesOnlyOnce(): void
|
|
||||||
{
|
|
||||||
$duplicated = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'id' => 's1']);
|
|
||||||
self::assertSame('s2', $duplicated['changed'][0]['id']);
|
|
||||||
|
|
||||||
$delete = $this->delete();
|
|
||||||
$ask = $delete->execute(['post_id' => 7, 'tree_rev' => Rev::of($this->saved[0]), 'ids' => ['s2', 's2.r0']]);
|
|
||||||
self::assertToolError($ask, 'confirm_required');
|
|
||||||
self::assertSame(
|
|
||||||
'1 Divi-Knoten würden samt Inhalt entfernt: s2 section "Leistungen (Kopie)" (1 Kind-Knoten).',
|
|
||||||
self::clientError($ask)['message']
|
|
||||||
);
|
|
||||||
|
|
||||||
$deleted = $delete->execute(['post_id' => 7, 'tree_rev' => Rev::of($this->saved[0]), 'ids' => ['s2'], 'confirm' => true]);
|
|
||||||
self::assertSame([['id' => 's2', 'type' => 'divi/section', 'action' => 'deleted', 'label' => 'Leistungen (Kopie)']], $deleted['changed']);
|
|
||||||
self::assertSame($this->original, $this->saved[1], 'deleting the copy restores the exact bytes');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The copy sits directly after the original; its own child content is byte-identical, nothing else changes. */
|
|
||||||
public function testDuplicateInsertsTheCopyDirectlyAfterTheOriginal(): void
|
|
||||||
{
|
|
||||||
$sectionOpen = '<!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Leistungen"}}}},'
|
|
||||||
. '"builderVersion":"5.2.1"} -->';
|
|
||||||
$sectionClose = '<!-- /wp:divi/section -->';
|
|
||||||
$start = strpos($this->original, $sectionOpen) + strlen($sectionOpen);
|
|
||||||
$end = strpos($this->original, $sectionClose, $start);
|
|
||||||
$rowBytes = substr($this->original, $start, $end - $start);
|
|
||||||
$original = $sectionOpen . $rowBytes . $sectionClose;
|
|
||||||
self::assertStringContainsString($original, $this->original, 'precondition: exact bytes as expected');
|
|
||||||
|
|
||||||
$result = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'id' => 's1']);
|
|
||||||
|
|
||||||
self::assertSame('s2', $result['changed'][0]['id']);
|
|
||||||
self::assertSame('Leistungen (Kopie)', $result['changed'][0]['label']);
|
|
||||||
self::assertStringContainsString('Leistungen', (string) $result['outline'], 'the outline is embedded in the result');
|
|
||||||
|
|
||||||
$copyOpen = str_replace('"Leistungen"', '"Leistungen (Kopie)"', $sectionOpen);
|
|
||||||
$copy = $copyOpen . $rowBytes . $sectionClose;
|
|
||||||
self::assertSame(
|
|
||||||
str_replace($original, $original . $copy, $this->original),
|
|
||||||
$this->saved[0],
|
|
||||||
'copy directly after the original, byte-identical child content, nothing else changes'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A global module copy stays a byte-identical reference (read-only nodes move/duplicate as a whole). */
|
|
||||||
public function testDuplicateGlobalModuleCopyIsAByteIdenticalReference(): void
|
|
||||||
{
|
|
||||||
$mixed = DiviFixtures::markup('synthetic-mixed.html');
|
|
||||||
$this->post->post_content = $mixed;
|
|
||||||
$globalRow = '<!-- wp:divi/row {"globalModule":"913","module":{"meta":{"adminLabel":{"desktop":{"value":'
|
|
||||||
. '"Globale Zeile"}}}}} --><!-- wp:divi/column {"builderVersion":"5.2.1"} --><!-- wp:divi/text '
|
|
||||||
. '{"content":{"innerContent":{"desktop":{"value":"lokal"}}}} /--><!-- /wp:divi/column --><!-- /wp:divi/row'
|
|
||||||
. ' -->';
|
|
||||||
self::assertStringContainsString($globalRow, $mixed, 'precondition: exact bytes as expected');
|
|
||||||
|
|
||||||
$result = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => Rev::of($mixed), 'id' => 's1.g1']);
|
|
||||||
|
|
||||||
self::assertSame('s1.g2', $result['changed'][0]['id']);
|
|
||||||
self::assertSame('divi/row', $result['changed'][0]['type']);
|
|
||||||
self::assertSame('Globale Zeile', $result['changed'][0]['label'], 'opaque copies keep their label unchanged');
|
|
||||||
self::assertSame(str_replace($globalRow, $globalRow . $globalRow, $mixed), $this->saved[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fix round 1 (Important-1): the copy's admin label must be the raw stored desktop value
|
|
||||||
* plus " (Kopie)" – never NodeSummary::label()'s display-shortened text (80-char limit).
|
|
||||||
*/
|
|
||||||
public function testDuplicateCopiesALongAdminLabelVerbatim(): void
|
|
||||||
{
|
|
||||||
$long = str_repeat('Ausführliche Beschriftung, ', 4) . 'Ende';
|
|
||||||
self::assertGreaterThan(80, strlen($long), 'precondition: longer than the NodeSummary::label() display limit');
|
|
||||||
$content = str_replace(
|
|
||||||
'"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero – Button"}}}}',
|
|
||||||
'"module":{"meta":{"adminLabel":{"desktop":{"value":"' . $long . '"}}}}',
|
|
||||||
$this->original
|
|
||||||
);
|
|
||||||
self::assertNotSame($content, $this->original, 'precondition: replaced exactly once');
|
|
||||||
$this->post->post_content = $content;
|
|
||||||
|
|
||||||
$result = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => Rev::of($content), 'id' => 's0.r0.c1.m2']);
|
|
||||||
|
|
||||||
self::assertTrue($result['ok'], (string) json_encode($result));
|
|
||||||
$index = NodeIndex::build((new Parser())->parse($this->saved[0]));
|
|
||||||
$copy = $index->find('s0.r0.c1.m3');
|
|
||||||
self::assertNotNull($copy, 'the duplicate sits right after the original module');
|
|
||||||
self::assertSame(
|
|
||||||
$long . ' (Kopie)',
|
|
||||||
AttrMap::desktop($copy->attrs(), 'module.meta.adminLabel'),
|
|
||||||
'stored verbatim, not shortened/ellipsised for display'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testDuplicateCopiesALabelWithNewlinesAndMultipleSpacesVerbatim(): void
|
|
||||||
{
|
|
||||||
$messy = "Erste Zeile mit viel Leerraum\nZweite Zeile";
|
|
||||||
$encoded = substr((string) json_encode($messy, JSON_UNESCAPED_UNICODE), 1, -1);
|
|
||||||
$content = str_replace(
|
|
||||||
'"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero – Button"}}}}',
|
|
||||||
'"module":{"meta":{"adminLabel":{"desktop":{"value":"' . $encoded . '"}}}}',
|
|
||||||
$this->original
|
|
||||||
);
|
|
||||||
self::assertNotSame($content, $this->original, 'precondition: replaced exactly once');
|
|
||||||
$this->post->post_content = $content;
|
|
||||||
|
|
||||||
$result = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => Rev::of($content), 'id' => 's0.r0.c1.m2']);
|
|
||||||
|
|
||||||
self::assertTrue($result['ok'], (string) json_encode($result));
|
|
||||||
$index = NodeIndex::build((new Parser())->parse($this->saved[0]));
|
|
||||||
$copy = $index->find('s0.r0.c1.m3');
|
|
||||||
self::assertNotNull($copy, 'the duplicate sits right after the original module');
|
|
||||||
self::assertSame(
|
|
||||||
$messy . ' (Kopie)',
|
|
||||||
AttrMap::desktop($copy->attrs(), 'module.meta.adminLabel'),
|
|
||||||
'whitespace/newlines kept verbatim, not collapsed'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Permission denied (edit_post false) for all three new abilities. */
|
|
||||||
public function testPermissionDeniedWhenNotEditable(): void
|
|
||||||
{
|
|
||||||
Functions\when('current_user_can')->justReturn(false);
|
|
||||||
|
|
||||||
foreach ([$this->move(), $this->delete(), $this->duplicate()] as $ability) {
|
|
||||||
$result = $ability->permission(['post_id' => 7, 'tree_rev' => $this->treeRev()]);
|
|
||||||
self::assertInstanceOf(WP_Error::class, $result, $ability->name());
|
|
||||||
self::assertToolError($result, 'forbidden', $ability->name());
|
|
||||||
}
|
|
||||||
self::assertSame([], $this->saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testReadOnlyNodesCannotBeUpdated(): void
|
|
||||||
{
|
|
||||||
$this->post->post_content = DiviFixtures::markup('synthetic-mixed.html');
|
|
||||||
|
|
||||||
$result = $this->update()->execute([
|
|
||||||
'post_id' => 7,
|
|
||||||
'tree_rev' => Rev::of($this->post->post_content),
|
|
||||||
'changes' => [['id' => 's1.g1', 'label' => 'x']],
|
|
||||||
]);
|
|
||||||
|
|
||||||
self::assertToolError($result, 'validation_failed');
|
|
||||||
self::assertStringContainsString('nur lesbar', $result->get_error_message());
|
|
||||||
self::assertSame([], $this->saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ruling S14: the same node twice in one call would silently lose the first change. */
|
|
||||||
public function testUpdateRefusesADuplicateId(): void
|
|
||||||
{
|
|
||||||
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
|
|
||||||
['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Eins']],
|
|
||||||
['id' => 's0.r0.c1.m2', 'set' => ['url' => '/zwei']],
|
|
||||||
]]);
|
|
||||||
|
|
||||||
self::assertToolError($result, 'validation_failed');
|
|
||||||
self::assertStringContainsString('changes[0]', self::clientError($result)['fields']['changes[1].id']);
|
|
||||||
self::assertSame([], $this->saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ruling S38: set and attrs of one change must not reach the same attribute path. */
|
|
||||||
public function testUpdateRefusesSetAndAttrsOnTheSamePath(): void
|
|
||||||
{
|
|
||||||
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [[
|
|
||||||
'id' => 's0.r0.c1.m2',
|
|
||||||
'set' => ['text' => 'Neu'],
|
|
||||||
'attrs' => ['button.innerContent' => ['desktop' => ['linkUrl' => '/neu']]],
|
|
||||||
]]]);
|
|
||||||
|
|
||||||
self::assertToolError($result, 'validation_failed');
|
|
||||||
self::assertArrayHasKey('changes[0].attrs.button.innerContent', self::clientError($result)['fields']);
|
|
||||||
self::assertSame([], $this->saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ruling S40: the S38 overlap rule for new nodes too. */
|
|
||||||
public function testInsertRefusesShortFormAndAttrsOnTheSamePath(): void
|
|
||||||
{
|
|
||||||
$result = $this->insert()->execute([
|
|
||||||
'post_id' => 7,
|
|
||||||
'tree_rev' => $this->treeRev(),
|
|
||||||
'parent' => 's0.r0.c1',
|
|
||||||
'nodes' => [[
|
|
||||||
'type' => 'button',
|
|
||||||
'text' => 'Neu',
|
|
||||||
'attrs' => ['button.innerContent' => ['desktop' => ['linkUrl' => '/neu']]],
|
|
||||||
]],
|
|
||||||
]);
|
|
||||||
|
|
||||||
self::assertToolError($result, 'validation_failed');
|
|
||||||
self::assertArrayHasKey('nodes[0].attrs.button.innerContent', self::clientError($result)['fields']);
|
|
||||||
self::assertSame([], $this->saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ruling S41: a column width change must still fill its row exactly. */
|
|
||||||
public function testUpdateChecksTheRowWidths(): void
|
|
||||||
{
|
|
||||||
$sizing = static fn (string $width): array => ['module.decoration.sizing' => ['desktop' => ['flexType' => $width]]];
|
|
||||||
|
|
||||||
$broken = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
|
|
||||||
['id' => 's0.r0.c0', 'attrs' => $sizing('12_24')],
|
|
||||||
]]);
|
|
||||||
self::assertToolError($broken, 'validation_failed');
|
|
||||||
self::assertArrayHasKey('changes[0].attrs.module.decoration.sizing', self::clientError($broken)['fields']);
|
|
||||||
self::assertSame([], $this->saved);
|
|
||||||
|
|
||||||
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
|
|
||||||
['id' => 's0.r0.c0', 'attrs' => $sizing('12_24')],
|
|
||||||
['id' => 's0.r0.c1', 'attrs' => $sizing('12_24')],
|
|
||||||
]]);
|
|
||||||
self::assertTrue($result['ok'], (string) json_encode($result));
|
|
||||||
self::assertCount(1, $this->saved);
|
|
||||||
self::assertStringContainsString('"flexType":"12_24"}}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/image', $this->saved[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param array<string, mixed> $attrs */
|
|
||||||
private function updateOn(string $content, array $changes): mixed
|
|
||||||
{
|
|
||||||
$this->post->post_content = $content;
|
|
||||||
|
|
||||||
return $this->update()->execute(['post_id' => 7, 'tree_rev' => Rev::of($content), 'changes' => $changes]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
|
||||||
private static function sizing(?string $width): array
|
|
||||||
{
|
|
||||||
return ['module.decoration.sizing' => $width === null ? null : ['desktop' => ['flexType' => $width]]];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ruling S43: only builder column classes (or null) are accepted as new desktop widths. */
|
|
||||||
public function testUpdateChecksTheColumnClassFormat(): void
|
|
||||||
{
|
|
||||||
foreach (['4_5', '13_25', '12_24,12_24', 'auto'] as $width) {
|
|
||||||
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
|
|
||||||
['id' => 's0.r0.c0', 'attrs' => self::sizing($width)],
|
|
||||||
]]);
|
|
||||||
self::assertInstanceOf(WP_Error::class, $result, $width);
|
|
||||||
self::assertStringContainsString('none', self::clientError($result)['fields']['changes[0].attrs.module.decoration.sizing'], $width);
|
|
||||||
}
|
|
||||||
|
|
||||||
self::assertSame([], $this->saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ruling S43: rows that are no plain exact row are not sum-checked (none, omitted width, wrap). */
|
|
||||||
public function testUpdateAcceptsWidthChangesInLooseRows(): void
|
|
||||||
{
|
|
||||||
$noneRow = str_replace('"flexType":"8_24"', '"flexType":"none"', $this->original);
|
|
||||||
self::assertTrue($this->updateOn($noneRow, [['id' => 's0.r0.c1', 'attrs' => self::sizing('12_24')]])['ok']);
|
|
||||||
|
|
||||||
$omitted = str_replace(
|
|
||||||
'{"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"8_24"}}}}},"builderVersion":"5.2.1"}',
|
|
||||||
'{"builderVersion":"5.2.1"}',
|
|
||||||
$this->original
|
|
||||||
);
|
|
||||||
self::assertNotSame($omitted, $this->original);
|
|
||||||
self::assertTrue($this->updateOn($omitted, [['id' => 's0.r0.c1', 'attrs' => self::sizing('12_24')]])['ok']);
|
|
||||||
self::assertTrue($this->updateOn($omitted, [['id' => 's0.r0.c1', 'attrs' => self::sizing(null)]])['ok'], 'removal');
|
|
||||||
|
|
||||||
$wrap = str_replace(
|
|
||||||
'<!-- wp:divi/row {"module":{"meta":',
|
|
||||||
'<!-- wp:divi/row {"module":{"decoration":{"layout":{"desktop":{"value":{"flexWrap":"wrap"}}}},"meta":',
|
|
||||||
$this->original
|
|
||||||
);
|
|
||||||
self::assertNotSame($wrap, $this->original);
|
|
||||||
self::assertTrue($this->updateOn($wrap, [['id' => 's0.r0.c0', 'attrs' => self::sizing('12_24')]])['ok']);
|
|
||||||
|
|
||||||
self::assertTrue($this->updateOn($this->original, [['id' => 's0.r0.c0', 'attrs' => self::sizing('none')]])['ok'], 'none itself');
|
|
||||||
self::assertCount(5, $this->saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ruling S43: a plain exact row stays strict; removal counts as Divi's default 24_24. */
|
|
||||||
public function testUpdateKeepsExactRowsStrict(): void
|
|
||||||
{
|
|
||||||
$broken = $this->updateOn($this->original, [['id' => 's0.r0.c0', 'attrs' => self::sizing(null)]]);
|
|
||||||
self::assertToolError($broken, 'validation_failed');
|
|
||||||
$broken = $this->updateOn($this->original, [['id' => 's0.r0.c0', 'attrs' => self::sizing('10_24')]]);
|
|
||||||
self::assertToolError($broken, 'validation_failed');
|
|
||||||
self::assertSame([], $this->saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ruling S7: builder-only paths are refused for users without unfiltered_html – visible to the client. */
|
|
||||||
public function testBuilderOnlyPathsAreRefusedForEditors(): void
|
|
||||||
{
|
|
||||||
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [[
|
|
||||||
'id' => 's0.r0.c1.m1',
|
|
||||||
'attrs' => ['module.decoration.attributes' => ['desktop' => ['attributes' => [['name' => 'onclick', 'value' => 'x']]]]],
|
|
||||||
]]]);
|
|
||||||
|
|
||||||
self::assertToolError($result, 'validation_failed');
|
|
||||||
$client = self::clientError($result);
|
|
||||||
self::assertSame('validation_failed', $client['code']);
|
|
||||||
self::assertStringContainsString('nur im Divi-Builder', $client['fields']['changes[0].attrs.module.decoration.attributes']);
|
|
||||||
self::assertSame([], $this->saved);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
final class DiviWriteAbilityTest extends DiviWriteAbilityTestCase
|
||||||
|
{
|
||||||
|
/** Permission denied (edit_post false) for all three new abilities. */
|
||||||
|
public function testPermissionDeniedWhenNotEditable(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_user_can')->justReturn(false);
|
||||||
|
|
||||||
|
foreach ([$this->move(), $this->delete(), $this->duplicate()] as $ability) {
|
||||||
|
$result = $ability->permission(['post_id' => 7, 'tree_rev' => $this->treeRev()]);
|
||||||
|
self::assertInstanceOf(WP_Error::class, $result, $ability->name());
|
||||||
|
self::assertToolError($result, 'forbidden', $ability->name());
|
||||||
|
}
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Abilities\Rev;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviMoveAbility;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||||||
|
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||||
|
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared setup of the Divi write tool tests: the synthetic page as post 7, a DiviWriter whose
|
||||||
|
* saver records every stored content in $saved, and one factory per tool.
|
||||||
|
*/
|
||||||
|
abstract class DiviWriteAbilityTestCase extends TestCase
|
||||||
|
{
|
||||||
|
protected WP_Post $post;
|
||||||
|
protected string $original = '';
|
||||||
|
|
||||||
|
/** @var list<string> Contents handed to DiviSaver::save(). */
|
||||||
|
protected array $saved = [];
|
||||||
|
|
||||||
|
protected DiviWriter $writer;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->original = DiviFixtures::markup('synthetic-basic.html');
|
||||||
|
$this->post = DiviFixtures::givenPost($this->original);
|
||||||
|
Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true');
|
||||||
|
$this->writer = DiviWriterFixture::writer($this->saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function treeRev(): string
|
||||||
|
{
|
||||||
|
return Rev::of($this->original);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function insert(): DiviInsertAbility
|
||||||
|
{
|
||||||
|
return new DiviInsertAbility($this->writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function update(): DiviUpdateAbility
|
||||||
|
{
|
||||||
|
return new DiviUpdateAbility($this->writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function move(): DiviMoveAbility
|
||||||
|
{
|
||||||
|
return new DiviMoveAbility($this->writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function delete(): DiviDeleteAbility
|
||||||
|
{
|
||||||
|
return new DiviDeleteAbility($this->writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function duplicate(): DiviDuplicateNodeAbility
|
||||||
|
{
|
||||||
|
return new DiviDuplicateNodeAbility($this->writer);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user