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,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));
}
}