Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Pages/DiviUpdateAbilityTest.php
T
janniclas.loosenandClaude Opus 5.5 7963aa19bd test: split DiviWriteAbilitiesTest per tool (T5)
One test class per Divi write tool on a shared DiviWriteAbilityTestCase;
the 30 tests move verbatim (per-test assertion counts identical). Adds the
ledger T4 test for a TreeEditor::move() StructureRules refusal.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 18:27:59 +02:00

212 lines
10 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 LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Tests\Support\DiviFixtures;
use WP_Error;
final class DiviUpdateAbilityTest extends DiviWriteAbilityTestCase
{
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::assertToolError($failed, 'validation_failed');
$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::assertToolError($result, 'rev_mismatch');
self::assertSame([], $this->saved);
}
public function testReadOnlyNodesCannotBeUpdated(): void
{
$this->post->post_content = DiviFixtures::markup('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::assertToolError($result, 'validation_failed');
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::assertToolError($result, 'validation_failed');
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::assertToolError($result, 'validation_failed');
self::assertArrayHasKey('changes[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::assertToolError($broken, 'validation_failed');
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::assertToolError($broken, 'validation_failed');
$broken = $this->updateOn($this->original, [['id' => 's0.r0.c0', 'attrs' => self::sizing('10_24')]]);
self::assertToolError($broken, 'validation_failed');
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::assertToolError($result, 'validation_failed');
$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);
}
}