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

312 lines
15 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 LIT\MCP\Tests\Support\DiviWriterFixture;
use LIT\MCP\Tests\Support\LibraryFixtures;
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 S49: tablet/phone (every non-desktop breakpoint) flexTypes are format-checked like desktop. */
public function testUpdateChecksTheColumnClassFormatOnOtherBreakpoints(): void
{
foreach (['tablet', 'phone', 'phoneWide'] as $breakpoint) {
foreach (['4_5', '13_25', 'auto'] as $width) {
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [[
'id' => 's0.r0.c0',
'attrs' => ['module.decoration.sizing' => [
'desktop' => ['flexType' => '8_24'],
$breakpoint => ['flexType' => $width],
]],
]]]);
self::assertInstanceOf(WP_Error::class, $result, $breakpoint . ' ' . $width);
$message = self::clientError($result)['fields']['changes[0].attrs.module.decoration.sizing'];
self::assertStringContainsString($breakpoint, $message);
self::assertStringContainsString('none', $message);
}
}
self::assertSame([], $this->saved);
$valid = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [[
'id' => 's0.r0.c0',
'attrs' => ['module.decoration.sizing' => [
'desktop' => ['flexType' => '8_24'],
'tablet' => ['flexType' => '24_24'],
'phone' => ['flexType' => 'none'],
]],
]]]);
self::assertTrue($valid['ok'], (string) json_encode($valid));
self::assertStringContainsString('"tablet":{"value":{"flexType":"24_24"}},"phone":{"value":{"flexType":"none"}}', $this->saved[0]);
}
/** 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);
}
/** Ruling P13: the outline of a write result names library IDs only with library access. */
public function testTheWriteOutlineNamesLibraryIdsOnlyWithLibraryAccess(): void
{
$content = DiviFixtures::markup('stage3/page--globale-instanzen.html');
$changes = [['id' => 's0.r0.c0.m0', 'set' => ['text' => 'Termin buchen']]];
$hidden = $this->updateOn($content, $changes);
$failures = [];
$this->writer = DiviWriterFixture::writer($this->saved, $failures, false, LibraryFixtures::loader());
$shown = $this->updateOn($content, $changes);
self::assertTrue($hidden['ok']);
self::assertStringContainsString('s0.r0.c0.g0 text', (string) $hidden['outline']);
self::assertStringContainsString('(nur lesbar)', (string) $hidden['outline']);
self::assertStringNotContainsString('1059', (string) json_encode($hidden));
self::assertStringNotContainsString('1058', (string) json_encode($hidden));
self::assertStringContainsString('[global 1059]', (string) $shown['outline']);
}
public function testUpdateOffersPresetStacksGroupPresetsAndClearOverrides(): void
{
$change = $this->update()->inputSchema()['properties']['changes']['items']['properties'];
self::assertSame(['string', 'array', 'null'], $change['preset']['type']);
self::assertSame(['type' => 'string'], $change['preset']['items']);
self::assertSame([1, 10], [$change['preset']['minItems'], $change['preset']['maxItems']]);
self::assertSame('object', $change['group_presets']['type']);
self::assertSame(['string', 'array', 'null'], $change['group_presets']['additionalProperties']['type']);
self::assertSame([1, 10], [
$change['group_presets']['additionalProperties']['minItems'],
$change['group_presets']['additionalProperties']['maxItems'],
]);
self::assertSame('boolean', $change['clear_overrides']['type']);
$description = $this->update()->description();
foreach (['group_presets', 'clear_overrides', 'das letzte Preset'] as $text) {
self::assertStringContainsString($text, $description);
}
self::assertStringContainsString('group_presets', $this->insert()->description());
}
public function testClearOverridesMustBeABoolean(): void
{
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
['id' => 's0.r0.c1.m2', 'clear_overrides' => 'ja'],
]]);
self::assertToolError($result, 'validation_failed');
self::assertSame(
'true oder false erwartet',
self::clientError($result)['fields']['changes[0].clear_overrides']
);
self::assertSame([], $this->saved);
}
/** A pure clear_overrides that finds no covering value changes nothing and saves nothing. */
public function testClearOverridesWithoutAHitIsNoChange(): void
{
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
['id' => 's0.r0.c1.m2', 'clear_overrides' => true],
]]);
self::assertStringStartsWith('Keine Änderung', $result['summary']);
self::assertSame([], $this->saved);
}
}