feat: lit/divi-insert and lit/divi-update

All-or-nothing at ability level (S39), duplicate ids refused (S14),
set and attrs on the same path refused (S38), invalid_attr_path error.

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:
2026-09-26 15:15:37 +02:00
co-authored by Claude Opus 5.5
parent b8cb01812e
commit 110beb7698
11 changed files with 711 additions and 5 deletions
@@ -0,0 +1,295 @@
<?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\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\DiviInsertAbility;
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);
}
/** @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);
}
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 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);
}
}