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:
@@ -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 '));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user