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

691 lines
33 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\Pages;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\ChangeLog\ChangeLog;
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\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
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\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Tests\Unit\Integrations\Divi\Compose\ComposeFixture;
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
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 = DiviPageLoaderTest::fixture('synthetic-basic.html');
$this->post = DiviPageLoaderTest::givenPost($this->original);
Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true');
$modules = Mockery::mock(DiviModules::class);
$modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog());
$modules->allows('builderVersion')->andReturn('5.2.1');
$design = Mockery::mock(DesignSystemReader::class);
$design->allows('read')->andReturn(DesignSystemTest::sample());
$composition = new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media());
$saver = Mockery::mock(DiviSaver::class);
$saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content): array {
$this->saved[] = $content;
$post->post_content = $content;
return ['revision_id' => 811, 'warnings' => []];
});
$log = Mockery::mock(ChangeLog::class);
$log->allows('record')->andReturn(4711);
$this->writer = new DiviWriter(
new DiviPageLoader(DiviPageLoaderTest::schema()),
$saver,
$log,
$design,
$composition
);
}
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);
}
/** @return array<string, mixed> The structured error the client sees (message JSON, spec §8.2). */
private static function clientError(WP_Error $error): array
{
return (array) json_decode($error->get_error_message(), true);
}
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::assertSame('lit_rev_mismatch', $stale->get_error_code());
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::assertSame('lit_invalid_attr_path', $invalid->get_error_code());
$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::assertSame('lit_validation_failed', $wrongPlace->get_error_code());
$badPosition = $this->insert()->execute([
'post_id' => 7,
'tree_rev' => $this->treeRev(),
'parent' => 's0.r0.c0',
'after' => 's0.r0.c1.m1',
'nodes' => [['type' => 'text']],
]);
self::assertSame('lit_validation_failed', $badPosition->get_error_code());
self::assertArrayHasKey('position', self::clientError($badPosition)['fields']);
$unknown = $this->insert()->execute([
'post_id' => 7,
'tree_rev' => $this->treeRev(),
'parent' => 's9',
'nodes' => [['type' => 'section']],
]);
self::assertSame('lit_validation_failed', $unknown->get_error_code());
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::assertSame('lit_validation_failed', $failed->get_error_code());
$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::assertSame('lit_rev_mismatch', $result->get_error_code());
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 = DiviPageLoaderTest::fixture('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::assertSame('lit_validation_failed', $intoSection->get_error_code());
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::assertSame('lit_validation_failed', $intoSpecialtyColumn->get_error_code());
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::assertSame('lit_validation_failed', $result->get_error_code());
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::assertSame('lit_rev_mismatch', $result->get_error_code());
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 = DiviPageLoaderTest::fixture('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 = DiviPageLoaderTest::fixture('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::assertSame('lit_not_found', $result->get_error_code());
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::assertSame('lit_confirm_required', $ask->get_error_code());
self::assertSame(
'1 Divi-Knoten würden samt Inhalt entfernt: s2 section "Leistungen (Kopie)" (1 Kind-Knoten).',
json_decode($ask->get_error_message(), true)['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 = DiviPageLoaderTest::fixture('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::assertSame('lit_forbidden', $result->get_error_code(), $ability->name());
}
self::assertSame([], $this->saved);
}
public function testReadOnlyNodesCannotBeUpdated(): void
{
$this->post->post_content = DiviPageLoaderTest::fixture('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::assertSame('lit_validation_failed', $result->get_error_code());
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::assertSame('lit_validation_failed', $result->get_error_code());
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::assertSame('lit_validation_failed', $result->get_error_code());
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::assertSame('lit_validation_failed', $result->get_error_code());
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::assertSame('lit_validation_failed', $broken->get_error_code());
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::assertSame('lit_validation_failed', $broken->get_error_code());
$broken = $this->updateOn($this->original, [['id' => 's0.r0.c0', 'attrs' => self::sizing('10_24')]]);
self::assertSame('lit_validation_failed', $broken->get_error_code());
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::assertSame('lit_validation_failed', $result->get_error_code());
$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);
}
}