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
@@ -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'), []);
}
}