Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php
T

401 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 S40: the S38 overlap rule for new nodes too. */
public function testInsertRefusesShortFormAndAttrsOnTheSamePath(): void
{
$result = $this->insert()->execute([
'post_id' => 7,
'tree_rev' => $this->treeRev(),
'parent' => 's0.r0.c1',
'nodes' => [[
'type' => 'button',
'text' => 'Neu',
'attrs' => ['button.innerContent' => ['desktop' => ['linkUrl' => '/neu']]],
]],
]);
self::assertSame('lit_validation_failed', $result->get_error_code());
self::assertArrayHasKey('nodes[0].attrs.button.innerContent', self::clientError($result)['fields']);
self::assertSame([], $this->saved);
}
/** Ruling S41: a column width change must still fill its row exactly. */
public function testUpdateChecksTheRowWidths(): void
{
$sizing = static fn (string $width): array => ['module.decoration.sizing' => ['desktop' => ['flexType' => $width]]];
$broken = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
['id' => 's0.r0.c0', 'attrs' => $sizing('12_24')],
]]);
self::assertSame('lit_validation_failed', $broken->get_error_code());
self::assertArrayHasKey('changes[0].attrs.module.decoration.sizing', self::clientError($broken)['fields']);
self::assertSame([], $this->saved);
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
['id' => 's0.r0.c0', 'attrs' => $sizing('12_24')],
['id' => 's0.r0.c1', 'attrs' => $sizing('12_24')],
]]);
self::assertTrue($result['ok'], (string) json_encode($result));
self::assertCount(1, $this->saved);
self::assertStringContainsString('"flexType":"12_24"}}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/image', $this->saved[0]);
}
/** @param array<string, mixed> $attrs */
private function updateOn(string $content, array $changes): mixed
{
$this->post->post_content = $content;
return $this->update()->execute(['post_id' => 7, 'tree_rev' => Rev::of($content), 'changes' => $changes]);
}
/** @return array<string, mixed> */
private static function sizing(?string $width): array
{
return ['module.decoration.sizing' => $width === null ? null : ['desktop' => ['flexType' => $width]]];
}
/** Ruling S43: only builder column classes (or null) are accepted as new desktop widths. */
public function testUpdateChecksTheColumnClassFormat(): void
{
foreach (['4_5', '13_25', '12_24,12_24', 'auto'] as $width) {
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
['id' => 's0.r0.c0', 'attrs' => self::sizing($width)],
]]);
self::assertInstanceOf(WP_Error::class, $result, $width);
self::assertStringContainsString('none', self::clientError($result)['fields']['changes[0].attrs.module.decoration.sizing'], $width);
}
self::assertSame([], $this->saved);
}
/** Ruling S43: rows that are no plain exact row are not sum-checked (none, omitted width, wrap). */
public function testUpdateAcceptsWidthChangesInLooseRows(): void
{
$noneRow = str_replace('"flexType":"8_24"', '"flexType":"none"', $this->original);
self::assertTrue($this->updateOn($noneRow, [['id' => 's0.r0.c1', 'attrs' => self::sizing('12_24')]])['ok']);
$omitted = str_replace(
'{"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"8_24"}}}}},"builderVersion":"5.2.1"}',
'{"builderVersion":"5.2.1"}',
$this->original
);
self::assertNotSame($omitted, $this->original);
self::assertTrue($this->updateOn($omitted, [['id' => 's0.r0.c1', 'attrs' => self::sizing('12_24')]])['ok']);
self::assertTrue($this->updateOn($omitted, [['id' => 's0.r0.c1', 'attrs' => self::sizing(null)]])['ok'], 'removal');
$wrap = str_replace(
'<!-- wp:divi/row {"module":{"meta":',
'<!-- wp:divi/row {"module":{"decoration":{"layout":{"desktop":{"value":{"flexWrap":"wrap"}}}},"meta":',
$this->original
);
self::assertNotSame($wrap, $this->original);
self::assertTrue($this->updateOn($wrap, [['id' => 's0.r0.c0', 'attrs' => self::sizing('12_24')]])['ok']);
self::assertTrue($this->updateOn($this->original, [['id' => 's0.r0.c0', 'attrs' => self::sizing('none')]])['ok'], 'none itself');
self::assertCount(5, $this->saved);
}
/** Ruling S43: a plain exact row stays strict; removal counts as Divi's default 24_24. */
public function testUpdateKeepsExactRowsStrict(): void
{
$broken = $this->updateOn($this->original, [['id' => 's0.r0.c0', 'attrs' => self::sizing(null)]]);
self::assertSame('lit_validation_failed', $broken->get_error_code());
$broken = $this->updateOn($this->original, [['id' => 's0.r0.c0', 'attrs' => self::sizing('10_24')]]);
self::assertSame('lit_validation_failed', $broken->get_error_code());
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);
}
}