feat: Divi save pipeline – tree_rev lock, kses-safe save, revision, flags, cache, ToolResult

DiviSaver/DiviWriter/DiviComposition/DiviChange, WpValueSanitizer (S4),
WpMediaResolver, DiviFlags (S11), DynamicContentMap from Divi's live
registry (S22), S31 wiring, S35 control strip at apply() entry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 14:56:47 +02:00
co-authored by Claude Opus 5.5
parent ea1fd607b2
commit 7762b76305
20 changed files with 1716 additions and 2 deletions
@@ -469,4 +469,46 @@ final class AttrWriterTest extends TestCase
}
}
}
/**
* Ruling S35: controls go once at the entry of apply(), before placeholder resolution – a
* control-split placeholder is the placeholder (resolved and checked), never literal text
* that slipped past the placeholder check. Also deep inside values.
*/
public function testControlCharactersGoBeforePlaceholderResolution(): void
{
$writer = ComposeFixture::attrWriter();
$reference = '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$';
foreach ([chr(0), chr(1), chr(11), chr(127)] as $control) {
$result = $writer->apply(
'divi/heading',
[],
['title.innerContent' => ['desktop' => '{' . $control . '{post:title}}']],
'attrs'
);
self::assertSame([], $result['errors'], (string) ord($control));
self::assertSame($reference, $result['attrs']['title']['innerContent']['desktop']['value']);
// Embedded: refused like "{{post:title}}" inside text.
$embedded = $writer->apply(
'divi/heading',
[],
['title.innerContent' => ['desktop' => 'Hallo {' . $control . '{post:title}}']],
'attrs'
);
self::assertStringContainsString('allein im Wert', $embedded['errors']['attrs.title.innerContent']);
self::assertSame([], $embedded['attrs']);
$deep = $writer->apply(
'divi/button',
[],
['button.innerContent' => ['desktop' => ['text' => "{{post:ti" . $control . "tle}}"]]],
'attrs'
);
self::assertSame([], $deep['errors']);
self::assertSame($reference, $deep['attrs']['button']['innerContent']['desktop']['value']['text']);
}
}
}
@@ -193,7 +193,6 @@ final class ShortformWriterTest extends TestCase
'url: line-break octet marker' => ['url', '$variable%0a(y)$'],
'url: nested line-break octet marker' => ['url', '$variable%0%0aa(y)$'],
'color: tag-split marker' => ['color', '$variable<x>(' . substr($color, 10)],
'color: NUL-split marker' => ['color', '$variable' . chr(0) . '(' . substr($color, 10)],
'color: octet-split marker' => ['color', '$variable%41(' . substr($color, 10)],
];
}
@@ -318,4 +317,38 @@ final class ShortformWriterTest extends TestCase
self::assertSame([], $admin['errors']);
self::assertSame('Hallo', $admin['attrs']['module']['advanced']['html']['desktop']['value']['htmlBefore']);
}
/**
* Ruling S35: controls go once at the entry of apply(), before placeholder resolution and
* trim – "{\0{post:title}}" is the placeholder, checked like any other.
*/
public function testControlCharactersGoBeforePlaceholderResolution(): void
{
$writer = ComposeFixture::shortform();
foreach ([chr(0), chr(1), chr(11), chr(127)] as $control) {
$result = $writer->apply('heading', [], ['text' => '{' . $control . '{post:title}}'], 'n');
self::assertSame([], $result['errors'], (string) ord($control));
self::assertSame(
'$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$',
$result['attrs']['title']['innerContent']['desktop']['value']
);
$embedded = $writer->apply('heading', [], ['text' => 'Hallo {' . $control . '{post:title}}'], 'n');
self::assertStringContainsString('allein im Wert', $embedded['errors']['n.text']);
self::assertSame([], $embedded['attrs']);
// A control-split whole reference IS the reference (S32a/S35): validated and re-serialized.
$reference = '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$';
$split = $writer->apply('divider', [], ['color' => '$variable' . $control . substr($reference, 9)], 'n');
self::assertSame([], $split['errors']);
self::assertSame($reference, $split['attrs']['divider']['advanced']['line']['desktop']['value']['color']);
// Colors: the placeholder resolves after the strip (and trim), then the color check.
$color = $writer->apply('divider', [], ['color' => ' {{color:Nacht' . $control . 'blau}} '], 'n');
self::assertSame([], $color['errors']);
self::assertStringContainsString('gcid-a1b2', $color['attrs']['divider']['advanced']['line']['desktop']['value']['color']);
}
}
}
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Document;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\Compose\TreeEditor;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
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;
final class DiviCompositionTest extends TestCase
{
/** @var list<int> */
private array $registryCalls = [];
private function composition(): DiviComposition
{
$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());
$registry = new DynamicContentMap(function (int $postId): array {
$this->registryCalls[] = $postId;
return DynamicContentMapTest::registry();
});
return new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media(), $registry);
}
private static function siteTitle(): string
{
return VariableRef::serialize('content', 'site_title', ['before' => '', 'after' => '']);
}
public function testOneLiveMapIsSharedByTheFullAndTheShortForm(): void
{
$composition = $this->composition()->forPost(7);
// Full form (AttrWriter).
$full = $composition->attrWriter()->apply(
'divi/text',
[],
['content.innerContent' => ['desktop' => self::siteTitle()]],
'attrs'
);
self::assertSame([], $full['errors']);
self::assertSame(self::siteTitle(), AttrMap::desktop($full['attrs'], 'content.innerContent'));
// Short form through the Composer (ShortformWriter) – same map, same AttrWriter.
$short = $composition->composer()->compose(
[['type' => 'button', 'text' => self::siteTitle()]],
Block::create('divi/column'),
'nodes'
);
self::assertSame([], $short['errors']);
self::assertStringContainsString('site_title', json_encode($short['blocks'][0]->attrs(), JSON_THROW_ON_ERROR));
self::assertSame([7], $this->registryCalls, 'the registry is read once per post');
self::assertSame($composition->attrWriter(), $composition->attrWriter());
self::assertSame($composition->placeholders(), $composition->placeholders());
}
public function testWithoutAPostTheDefaultMapApplies(): void
{
$composition = $this->composition();
$result = $composition->attrWriter()->apply(
'divi/text',
[],
['content.innerContent' => ['desktop' => self::siteTitle()]],
'attrs'
);
self::assertStringContainsString('Unbekannter Name "site_title"', $result['errors']['attrs.content.innerContent']);
self::assertSame([], $this->registryCalls);
}
public function testForPostIsAFreshCompositionPerPost(): void
{
$base = $this->composition();
$seven = $base->forPost(7);
$eight = $base->forPost(8);
self::assertNotSame($base, $seven);
self::assertNotSame($seven->placeholders(), $eight->placeholders());
self::assertNotSame($base->placeholders(), $seven->placeholders());
self::assertSame([7, 8], $this->registryCalls);
}
public function testRulesEditorAndWarnings(): void
{
$composition = $this->composition();
self::assertNull($composition->rules()->violation(Block::create('divi/column'), 'divi/button'));
self::assertNotNull($composition->rules()->violation(null, 'divi/button'));
self::assertSame([], $composition->warnings()->collect(NodeIndex::build(new Document()), []));
self::assertInstanceOf(TreeEditor::class, $composition->editor());
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Pages\DiviFlags;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;
final class DiviFlagsTest extends TestCase
{
public function testSetsBothFlagsAndAcceptsAnUnchangedValue(): void
{
$written = [];
// update_post_meta() is false when the value is already "on" – no failure (ruling S11).
Functions\when('update_post_meta')->alias(static function (int $id, string $key, string $value) use (&$written): bool {
$written[] = "$id:$key=$value";
return false;
});
Functions\when('get_post_meta')->justReturn('on');
DiviFlags::set(7);
self::assertSame(['7:_et_pb_use_divi_5=on', '7:_et_pb_use_builder=on'], $written);
}
public function testThrowsWhenAFlagDoesNotReadBack(): void
{
Functions\when('update_post_meta')->justReturn(true);
Functions\when('get_post_meta')->alias(
static fn (int $id, string $key): string => $key === '_et_pb_use_builder' ? 'off' : 'on'
);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('_et_pb_use_builder');
DiviFlags::set(7);
}
}
@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;
use WP_Error;
use WP_Post;
final class DiviSaverTest extends TestCase
{
private WP_Post $post;
/** @var list<string> */
private array $calls = [];
/** @var array<string, string> */
private array $meta = [];
private string $log = '';
private string|false $previousLog = false;
protected function setUp(): void
{
parent::setUp();
$this->post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_content' => 'ALT']);
Functions\when('wp_save_post_revision')->justReturn(811);
Functions\when('wp_get_post_revisions')->justReturn([811 => new WP_Post(['ID' => 811])]);
Functions\when('update_post_meta')->alias(function (int $id, string $key, string $value): bool {
$this->calls[] = "meta:$key=$value";
$this->meta[$key] = $value;
return true;
});
Functions\when('get_post_meta')->alias(fn (int $id, string $key): string => $this->meta[$key] ?? '');
Functions\when('kses_remove_filters')->alias(function (): void {
$this->calls[] = 'kses off';
});
Functions\when('kses_init_filters')->alias(function (): void {
$this->calls[] = 'kses on';
});
// FollowUp logs failures with error_log() (a PHP internal): keep the test output clean.
$this->log = (string) tempnam(sys_get_temp_dir(), 'lit-log');
$this->previousLog = ini_set('error_log', $this->log);
}
protected function tearDown(): void
{
ini_set('error_log', (string) $this->previousLog);
@unlink($this->log);
parent::tearDown();
}
public function testSavesWithoutKsesForFilteredUsersAndSetsDiviFlags(): void
{
Functions\when('current_user_can')->justReturn(false);
Functions\when('has_filter')->justReturn(10);
Functions\expect('wp_update_post')->once()->with(['ID' => 7, 'post_content' => 'NEU'], true)
->andReturnUsing(function (): int {
$this->calls[] = 'update';
return 7;
});
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU']));
$saved = (new DiviSaver())->save($this->post, 'NEU');
self::assertSame(['revision_id' => 811, 'warnings' => []], $saved);
self::assertSame(
['kses off', 'update', 'kses on', 'meta:_et_pb_use_divi_5=on', 'meta:_et_pb_use_builder=on'],
$this->calls
);
}
public function testKeepsKsesUntouchedForUnfilteredUsersAndWarnsWhenAFilterChangedTheContent(): void
{
Functions\when('current_user_can')->justReturn(true);
Functions\when('wp_update_post')->justReturn(7);
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU, gefiltert']));
$saved = (new DiviSaver())->save($this->post, 'NEU');
self::assertNotContains('kses off', $this->calls);
self::assertSame([DiviSaver::CHANGED_ON_SAVE], $saved['warnings']);
}
public function testKsesStaysUntouchedWhenItIsNotActive(): void
{
Functions\when('current_user_can')->justReturn(false);
Functions\when('has_filter')->justReturn(false);
Functions\when('wp_update_post')->justReturn(7);
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU']));
(new DiviSaver())->save($this->post, 'NEU');
self::assertNotContains('kses off', $this->calls);
self::assertNotContains('kses on', $this->calls);
}
public function testRefusedUpdateIsACleanFailure(): void
{
Functions\when('current_user_can')->justReturn(true);
Functions\when('wp_update_post')->justReturn(new WP_Error('db_update_error', 'DB kaputt'));
try {
(new DiviSaver())->save($this->post, 'NEU');
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
self::assertSame(811, $e->revisionId);
self::assertStringContainsString('Revision 811 bleibt erhalten', $e->toError()->get_error_message());
}
}
public function testAThrowingHookRestoresThePreviousContentAndKsesComesBack(): void
{
Functions\when('current_user_can')->justReturn(false);
Functions\when('has_filter')->justReturn(10);
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_status' => 'draft']));
$written = [];
Functions\when('wp_update_post')->alias(function (array $data) use (&$written): int {
$written[] = $data['post_content'];
$this->calls[] = 'update';
if ($data['post_content'] === 'NEU') {
throw new RuntimeException('save_post hook failed');
}
return 7;
});
try {
(new DiviSaver())->save($this->post, 'NEU');
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
self::assertSame(['NEU', 'ALT'], $written);
// The kses bypass is restored in finally – after the failed save and after the restore.
self::assertSame(['kses off', 'update', 'kses on', 'kses off', 'update', 'kses on'], $this->calls);
}
}
public function testFailedRestoreIsAPartialWrite(): void
{
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_status' => 'draft']));
Functions\when('wp_update_post')->alias(static function (): int {
throw new RuntimeException('database gone');
});
try {
(new DiviSaver())->save($this->post, 'NEU');
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertFalse($e->rolledBack);
self::assertSame('lit_partial_write', $e->toError()->get_error_code());
}
}
public function testRestoreNeverPublishesAMissedSchedule(): void
{
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_post')->justReturn(new WP_Post([
'ID' => 7,
'post_status' => 'future',
'post_date_gmt' => '2020-01-01 00:00:00',
]));
$written = [];
Functions\when('wp_update_post')->alias(static function (array $data) use (&$written): int {
$written[] = $data['post_content'];
throw new RuntimeException('save_post hook failed');
});
try {
(new DiviSaver())->save($this->post, 'NEU');
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertFalse($e->rolledBack);
self::assertSame(['NEU'], $written);
}
}
public function testFlagFailureIsOnlyAWarning(): void
{
Functions\when('current_user_can')->justReturn(true);
Functions\when('wp_update_post')->justReturn(7);
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU']));
Functions\when('get_post_meta')->justReturn('');
$saved = (new DiviSaver())->save($this->post, 'NEU');
self::assertSame(811, $saved['revision_id']);
self::assertCount(1, $saved['warnings']);
self::assertStringContainsString('Divi-Kennzeichen', $saved['warnings'][0]);
self::assertStringContainsString('divi builder flags failed', (string) file_get_contents($this->log));
}
}
@@ -0,0 +1,201 @@
<?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\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Pages\DiviChange;
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
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_Post;
final class DiviWriterTest extends TestCase
{
/** @var list<int> */
private array $registryCalls = [];
private function writer(DiviSaver $saver, ChangeLog $log): DiviWriter
{
$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());
$registry = new DynamicContentMap(function (int $postId): array {
$this->registryCalls[] = $postId;
return DynamicContentMapTest::registry();
});
return new DiviWriter(
new DiviPageLoader(DiviPageLoaderTest::schema()),
$saver,
$log,
$design,
new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media(), $registry)
);
}
public function testPageChecksTheTreeRevLock(): void
{
$content = DiviPageLoaderTest::fixture('synthetic-basic.html');
DiviPageLoaderTest::givenPost($content);
$writer = $this->writer(Mockery::mock(DiviSaver::class), Mockery::mock(ChangeLog::class));
self::assertInstanceOf(DiviPage::class, $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]));
self::assertSame('lit_rev_mismatch', $writer->page(['post_id' => 7])->get_error_code());
$stale = $writer->page(['post_id' => 7, 'tree_rev' => Rev::ofPost(DiviPageLoaderTest::givenPost($content))]);
self::assertSame('lit_rev_mismatch', $stale->get_error_code(), 'the post rev is no tree_rev');
self::assertStringContainsString('nichts wurde', $stale->get_error_message());
}
public function testPageRefusesWhatIsNotWritable(): void
{
$content = DiviPageLoaderTest::fixture('synthetic-basic.html');
DiviPageLoaderTest::givenPost($content);
$writer = new DiviWriter(
new DiviPageLoader(DiviPageLoaderTest::schema(false)),
Mockery::mock(DiviSaver::class),
Mockery::mock(ChangeLog::class),
Mockery::mock(DesignSystemReader::class),
Mockery::mock(DiviComposition::class)
);
self::assertSame('lit_unsupported', $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)])->get_error_code());
}
public function testSectionLabelOfANode(): void
{
DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html'));
$page = (new DiviPageLoader(DiviPageLoaderTest::schema()))->load(7, true);
self::assertSame('Hero', DiviWriter::sectionLabel($page->index, $page->index->find('s0.r0.c1')));
self::assertSame('', DiviWriter::sectionLabel($page->index, null));
}
public function testCompositionIsBoundToThePage(): void
{
$content = DiviPageLoaderTest::fixture('synthetic-basic.html');
DiviPageLoaderTest::givenPost($content);
$writer = $this->writer(Mockery::mock(DiviSaver::class), Mockery::mock(ChangeLog::class));
$page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]);
$writer->composition($page)->placeholders();
self::assertSame([7], $this->registryCalls, 'the live dynamic content map of post 7 (ruling S22)');
}
public function testUnchangedTreeIsNotSaved(): void
{
$content = DiviPageLoaderTest::fixture('synthetic-basic.html');
DiviPageLoaderTest::givenPost($content);
$saver = Mockery::mock(DiviSaver::class);
$saver->expects('save')->never();
$writer = $this->writer($saver, Mockery::mock(ChangeLog::class));
$page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]);
$result = $writer->save('lit/divi-update', $page, new DiviChange('nichts'), []);
self::assertSame(Rev::of($content), $result['tree_rev']);
self::assertStringStartsWith('Keine Änderung', $result['summary']);
}
public function testSavesAndReportsChangedNodesOutlineAndTheNewTreeRev(): void
{
$content = DiviPageLoaderTest::fixture('synthetic-basic.html');
DiviPageLoaderTest::givenPost($content);
$stored = null;
$saver = Mockery::mock(DiviSaver::class);
$saver->expects('save')->andReturnUsing(static function (WP_Post $post, string $new) use (&$stored): array {
$stored = $new;
DiviPageLoaderTest::givenPost($new);
return ['revision_id' => 811, 'warnings' => ['vom Speichern']];
});
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->with('lit/divi-update', 'page', '7', 'Divi "Start": Button geändert', 811)
->andReturn(55);
$input = ['post_id' => 7, 'tree_rev' => Rev::of($content)];
Functions\expect('do_action')->once()->with('lit_mcp_after_save', 7, 'lit/divi-update', $input);
Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true');
$writer = $this->writer($saver, $log);
$page = $writer->page($input);
$button = $page->index->find('s0.r0.c1.m2');
$removed = $page->index->find('s1');
$button->setAttrs(AttrMap::set($button->attrs(), 'button.innerContent.desktop.value.text', 'Neu'));
$page->document->children()[0]->removeChild($removed);
$change = (new DiviChange('Button geändert'))
->changed($button, 'updated')
->removed('s1', $removed)
->warning('eigene Warnung');
$result = $writer->save('lit/divi-update', $page, $change, $input);
self::assertIsString($stored);
self::assertStringNotContainsString('wp:divi/blurb', $stored);
self::assertSame(Rev::of($stored), $result['tree_rev']);
self::assertSame(811, $result['revision_id']);
self::assertSame(55, $result['log_id']);
self::assertSame('https://kunde.test/?page_id=7&et_fb=1', $result['edit_url']);
self::assertSame('https://kunde.test/?page_id=7&preview=true', $result['preview_url']);
self::assertSame('Button geändert (Status unverändert: draft).', $result['summary']);
self::assertSame(
[['s0.r0.c1.m2', 'divi/button', 'updated'], ['s1', 'divi/section', 'deleted']],
array_map(static fn (array $c): array => [$c['id'], $c['type'], $c['action']], $result['changed'])
);
self::assertStringContainsString('Hero', (string) $result['outline']);
self::assertStringNotContainsString('Leistungen', (string) $result['outline'], 'only affected sections');
self::assertContains('eigene Warnung', $result['warnings']);
self::assertContains('vom Speichern', $result['warnings']);
self::assertSame(Rev::ofPost(DiviPageLoaderTest::givenPost($stored)), $result['rev']);
}
public function testFailedRollbackIsLoggedAsPartial(): void
{
$content = DiviPageLoaderTest::fixture('synthetic-basic.html');
DiviPageLoaderTest::givenPost($content);
$saver = Mockery::mock(DiviSaver::class);
$saver->allows('save')->andThrow(new WriteFailed(DiviSaver::REASON, false, 7, 811));
$log = Mockery::mock(ChangeLog::class);
$log->expects('recordPartial')->with('lit/divi-delete', 'page', '7', 'Divi "Start": Änderung nur teilweise zurückgenommen', 811);
$writer = $this->writer($saver, $log);
$page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]);
$page->document->children()[0]->removeChild($page->index->find('s1'));
$this->expectException(WriteFailed::class);
$writer->save('lit/divi-delete', $page, new DiviChange('s1 gelöscht'), []);
}
public function testCleanFailureIsNotLogged(): void
{
$content = DiviPageLoaderTest::fixture('synthetic-basic.html');
DiviPageLoaderTest::givenPost($content);
$saver = Mockery::mock(DiviSaver::class);
$saver->allows('save')->andThrow(new WriteFailed(DiviSaver::REASON, true, 7, 811));
$log = Mockery::mock(ChangeLog::class);
$log->expects('recordPartial')->never();
$log->expects('record')->never();
$writer = $this->writer($saver, $log);
$page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]);
$page->document->children()[0]->removeChild($page->index->find('s1'));
$this->expectException(WriteFailed::class);
$writer->save('lit/divi-delete', $page, new DiviChange('s1 gelöscht'), []);
}
}
@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Tests\Unit\Integrations\Divi\Compose\ComposeFixture;
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;
final class DynamicContentMapTest extends TestCase
{
/**
* Shape of DynamicContentOptions::get_options($pageId, 'edit') in Divi 5.2.1 (excerpt of a
* live dump for a page: no post_categories/post_tags – pages have no such taxonomies).
*
* @return array<string, array<string, mixed>>
*/
public static function registry(): array
{
$text = static fn (): array => ['label' => 'x', 'type' => 'text', 'default' => ''];
$option = static fn (string $id, string $group, array $fields): array => [
'id' => $id, 'label' => $id, 'type' => 'text', 'custom' => false, 'group' => $group, 'fields' => $fields,
];
return [
'post_title' => $option('post_title', 'Default', ['before' => $text(), 'after' => $text()]),
'post_excerpt' => $option('post_excerpt', 'Default', [
'before' => $text(), 'after' => $text(), 'words' => $text(), 'read_more_label' => $text(),
]),
'post_date' => $option('post_date', 'Default', [
'before' => $text(),
'after' => $text(),
'date_format' => ['label' => 'x', 'type' => 'select', 'default' => 'default'],
'custom_date_format' => $text(),
]),
'post_author' => $option('post_author', 'Default', [
'before' => $text(),
'after' => $text(),
'name_format' => ['type' => 'select'],
'link' => ['type' => 'yes_no_button'],
]),
'site_title' => $option('site_title', 'Default', ['before' => $text(), 'after' => $text()]),
'post_link_url' => $option('post_link_url', 'Default', []),
'post_link_url_page' => $option('post_link_url_page', 'Default', ['post_id' => ['type' => 'select_post']]),
'post_featured_image' => $option('post_featured_image', 'Default', []),
'post_meta_key' => $option('post_meta_key', 'Standard', [
'before' => $text(),
'after' => $text(),
'select_meta_key' => ['type' => 'select'],
'meta_key' => $text(),
'enable_html' => ['type' => 'yes_no_button'],
]),
'loop_post_title' => $option('loop_post_title', 'Loop', [
'before' => $text(), 'after' => $text(), 'loop_position' => $text(),
]),
'loop_post_meta_key_manual_custom_field' => $option('loop_post_meta_key_manual_custom_field', 'Loop', [
'loop_meta_key' => $text(),
]),
// Odd entries a third-party filter could add.
'broken_fields' => $option('broken_fields', 'Default', ['a' => 'not-an-array', 'b' => ['type' => 3]]),
'no_fields' => ['id' => 'no_fields', 'type' => 'text'],
7 => $option('7', 'Default', []),
'not_an_array' => 'x',
];
}
public function testFlattensTheRegistryToOptionNameAndFieldTypes(): void
{
$map = DynamicContentMap::fromOptions(self::registry());
// before/after: rich text (Divi outputs them as HTML; text() would trim "Von ").
self::assertSame(['before' => 'richtext', 'after' => 'richtext'], $map['post_title']);
self::assertSame(
['before' => 'richtext', 'after' => 'richtext', 'date_format' => 'select', 'custom_date_format' => 'text'],
$map['post_date']
);
self::assertSame(['post_id' => 'select_post'], $map['post_link_url_page']);
self::assertSame([], $map['post_link_url']);
self::assertSame('select', $map['post_meta_key']['select_meta_key']);
// Fields without a readable type become '' – VariableRef's strictest branch (scalar, no markup).
self::assertSame(['a' => '', 'b' => ''], $map['broken_fields']);
self::assertSame([], $map['no_fields']);
self::assertArrayNotHasKey('not_an_array', $map);
self::assertArrayNotHasKey(7, $map);
}
public function testLoopOptionsNeverEnterTheMap(): void
{
$map = DynamicContentMap::fromOptions(self::registry());
self::assertSame([], array_filter(array_keys($map), static fn (string $name): bool => str_starts_with($name, 'loop_')));
}
public function testPlaceholderOptionsAreAlwaysThere(): void
{
// The map replaces VariableRef's default: every {{post:…}} placeholder must stay usable
// with the settings it writes (before/after), also where the registry lacks the option.
$map = DynamicContentMap::fromOptions(self::registry());
foreach (Placeholders::POST as $option) {
self::assertArrayHasKey($option, $map, $option);
foreach (array_keys(VariableRef::DEFAULT_DYNAMIC_CONTENT[$option]) as $setting) {
self::assertArrayHasKey($setting, $map[$option], "$option.$setting");
}
}
// Supplemented with the default settings.
self::assertSame(['before' => 'richtext', 'after' => 'richtext'], $map['post_categories']);
// Live fields win.
self::assertSame('select', $map['post_date']['date_format']);
self::assertSame('text', $map['post_excerpt']['words']);
$placeholders = new Placeholders(DesignSystemTest::sample(), $map);
foreach (array_keys(Placeholders::POST) as $key) {
$reference = $placeholders->resolve('{{post:' . $key . '}}');
self::assertSame(
$reference,
$placeholders->references()->canonical($reference, ComposeFixture::sanitizer()),
$key
);
}
}
public function testTheMapWorksInVariableRef(): void
{
$references = new VariableRef(DesignSystemTest::sample(), DynamicContentMap::fromOptions(self::registry()));
$sanitizer = ComposeFixture::sanitizer();
$ref = static fn (string $name, array $settings): string => VariableRef::serialize('content', $name, $settings);
// An option only the live registry knows.
$site = $ref('site_title', ['before' => 'Willkommen bei ', 'after' => '']);
self::assertSame($site, $references->canonical($site, $sanitizer));
// before/after are cleaned as rich text (kses for editors) and re-serialized; spaces stay.
self::assertSame(
$ref('post_title', ['before' => 'Neu: ', 'after' => '']),
$references->canonical($ref('post_title', ['before' => '<script>x()</script>Neu: ', 'after' => '']), $sanitizer)
);
// Other text fields go through text().
self::assertSame(
$ref('post_excerpt', ['words' => '20']),
$references->canonical($ref('post_excerpt', ['words' => '<b>20</b>']), $sanitizer)
);
// Meta keys still obey S29/S30, also through the live map.
$meta = $ref('post_meta_key', ['select_meta_key' => 'custom_meta__wp_page_template']);
self::assertRejected(static fn () => $references->canonical($meta, $sanitizer), 'Meta-Schlüssel');
$ok = $ref('post_meta_key', ['meta_key' => 'telefon']);
self::assertSame($ok, $references->canonical($ok, $sanitizer));
// enable_html stays forbidden even though the registry lists it.
$html = $ref('post_meta_key', ['enable_html' => 'on']);
self::assertRejected(static fn () => $references->canonical($html, $sanitizer), 'enable_html');
// Loop content: Divi offers it only inside loops – never through the tools.
$loop = $ref('loop_post_title', []);
self::assertRejected(static fn () => $references->canonical($loop, $sanitizer), 'Schleifen');
}
public function testLiveRegistryForThePost(): void
{
$seen = [];
$map = (new DynamicContentMap(static function (int $postId) use (&$seen): array {
$seen[] = $postId;
return DynamicContentMapTest::registry();
}))->forPost(42);
self::assertSame([42], $seen);
self::assertSame(['before' => 'richtext', 'after' => 'richtext'], $map['site_title'] ?? null);
}
public function testUnavailableRegistryFallsBackToTheDefault(): void
{
self::assertNull((new DynamicContentMap(static fn (): array => []))->forPost(7), 'empty (recursion guard)');
self::assertNull((new DynamicContentMap(static fn (): string => 'x'))->forPost(7), 'no array');
$log = (string) tempnam(sys_get_temp_dir(), 'lit-log');
$previous = ini_set('error_log', $log);
try {
$map = (new DynamicContentMap(static function (): array {
throw new RuntimeException('filter exploded');
}))->forPost(7);
} finally {
ini_set('error_log', (string) $previous);
}
self::assertNull($map);
self::assertStringContainsString('filter exploded', (string) file_get_contents($log));
unlink($log);
// Without Divi (no DynamicContentOptions class, as in these unit tests).
self::assertNull((new DynamicContentMap())->forPost(7));
}
private static function assertRejected(callable $call, string $fragment): void
{
try {
$call();
self::fail('InvalidArgumentException expected');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString($fragment, $e->getMessage());
}
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Pages\WpMediaResolver;
use LIT\MCP\Tests\Unit\TestCase;
final class WpMediaResolverTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Functions\when('wp_attachment_is_image')->alias(static fn (int $id): bool => $id !== 5);
Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $cap === 'read_post' && $id !== 6);
Functions\when('wp_get_attachment_url')->alias(static fn (int $id): string|false => $id === 8 ? false : "https://kunde.test/$id.jpg");
Functions\when('get_post_meta')->justReturn('Alt-Text');
}
public function testReadableImagesOnly(): void
{
$media = new WpMediaResolver();
self::assertSame(['url' => 'https://kunde.test/33.jpg', 'alt' => 'Alt-Text'], $media->image(33));
self::assertNull($media->image(0), 'no id');
self::assertNull($media->image(5), 'no image');
self::assertNull($media->image(6), 'not readable');
self::assertNull($media->image(8), 'no url');
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Pages\WpValueSanitizer;
use LIT\MCP\Tests\Unit\TestCase;
final class WpValueSanitizerTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Functions\when('wp_kses_post')->alias(static fn (string $value): string => 'kses(' . $value . ')');
Functions\when('esc_url_raw')->alias(static fn (string $value): string => 'url(' . $value . ')');
}
public function testEditorsGetKsesForRichTextAndCode(): void
{
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'unfiltered_html');
$sanitizer = new WpValueSanitizer();
self::assertSame('kses(<iframe></iframe>)', $sanitizer->html('<iframe></iframe>'));
self::assertSame('kses(<script>x</script>)', $sanitizer->code('<script>x</script>'));
self::assertFalse($sanitizer->allowsUnfilteredHtml());
}
public function testAdministratorsKeepRichTextAndCodeUnfiltered(): void
{
// Ruling S4: html() depends on unfiltered_html like code() – admin embeds survive updates.
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'unfiltered_html');
$sanitizer = new WpValueSanitizer();
self::assertSame('<iframe src="https://x.test"></iframe>', $sanitizer->html('<iframe src="https://x.test"></iframe>'));
self::assertSame('<script>x</script>', $sanitizer->code('<script>x</script>'));
self::assertTrue($sanitizer->allowsUnfilteredHtml());
}
public function testTextAndUrlDoNotDependOnTheRole(): void
{
Functions\when('current_user_can')->justReturn(true);
$sanitizer = new WpValueSanitizer();
self::assertSame('Hallo', $sanitizer->text('<b>Hallo</b>'));
self::assertSame('url(https://x.test/a b)', $sanitizer->url(' https://x.test/a b '));
}
}