A library entry as source now requires LibraryAccess::canInsert() (add_library, writable library group) and follows lit-library-insert's Tools-tab state (disabled -> unsupported). DiviPageLoader::library() gets its caller. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
307 lines
11 KiB
PHP
307 lines
11 KiB
PHP
<?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\Integrations\Divi\BlockTree\NodeIndex;
|
|
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
|
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
|
use LIT\MCP\Integrations\Divi\Library\GlobalEdits;
|
|
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
|
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
|
|
use LIT\MCP\Integrations\Divi\Pages\DiviNodeCopyAbility;
|
|
use LIT\MCP\Tests\Support\DiviFixtures;
|
|
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
|
use LIT\MCP\Tests\Support\LibraryFixtures;
|
|
use LIT\MCP\Tests\Unit\TestCase;
|
|
use Mockery;
|
|
use WP_Post;
|
|
|
|
final class DiviNodeCopyAbilityTest extends TestCase
|
|
{
|
|
private WP_Post $target;
|
|
private WP_Post $source;
|
|
|
|
/** @var list<string> */
|
|
private array $saved = [];
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->target = LibraryFixtures::page(DiviFixtures::markup('synthetic-basic.html'));
|
|
$this->source = LibraryFixtures::page(
|
|
DiviFixtures::markup('stage3/page--globale-instanzen.html'),
|
|
['ID' => 8, 'post_title' => 'Quelle']
|
|
);
|
|
LibraryFixtures::given([$this->target, $this->source, LibraryFixtures::item(1055)]);
|
|
}
|
|
|
|
private function ability(
|
|
bool $diviWritable = true,
|
|
bool $unfiltered = false,
|
|
?LibraryAccess $access = null,
|
|
bool $libraryInsert = true
|
|
): DiviNodeCopyAbility {
|
|
$access ??= LibraryFixtures::access();
|
|
$loader = LibraryFixtures::loader($access);
|
|
$failures = [];
|
|
$usages = Mockery::mock(GlobalUsages::class);
|
|
$usages->allows('of')->andReturn(['items' => [], 'count' => 0]);
|
|
$globals = new GlobalEdits($access, $usages);
|
|
|
|
return new DiviNodeCopyAbility(
|
|
DiviWriterFixture::writer($this->saved, $failures, $unfiltered, $loader, $globals),
|
|
$loader,
|
|
static fn (): bool => $diviWritable,
|
|
static fn (): bool => $libraryInsert
|
|
);
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private function input(array $extra = []): array
|
|
{
|
|
return array_merge([
|
|
'post_id' => 7,
|
|
'tree_rev' => Rev::of((string) $this->target->post_content),
|
|
'source_post_id' => 8,
|
|
'source_tree_rev' => Rev::of((string) $this->source->post_content),
|
|
'source_id' => 's1',
|
|
'after' => 's0',
|
|
], $extra);
|
|
}
|
|
|
|
private function sourceBytes(string $id): string
|
|
{
|
|
return Serializer::block(
|
|
NodeIndex::build((new Parser())->parse((string) $this->source->post_content))->find($id)
|
|
);
|
|
}
|
|
|
|
public function testCopiesANodeByteForByteBetweenPages(): void
|
|
{
|
|
$bytes = $this->sourceBytes('s1');
|
|
|
|
$result = $this->ability()->execute($this->input());
|
|
|
|
self::assertTrue($result['ok']);
|
|
self::assertCount(1, $this->saved, 'only the target is written');
|
|
self::assertStringContainsString($bytes, $this->saved[0]);
|
|
self::assertSame('s1', $result['changed'][0]['id']);
|
|
self::assertFalse($this->ability()->isDirect());
|
|
}
|
|
|
|
public function testLeavesTheRestOfTheTargetByteIdentical(): void
|
|
{
|
|
$original = (string) $this->target->post_content;
|
|
$bytes = $this->sourceBytes('s1');
|
|
|
|
$this->ability()->execute($this->input());
|
|
|
|
self::assertSame($original, str_replace($bytes, '', $this->saved[0]));
|
|
self::assertSame(
|
|
DiviFixtures::markup('stage3/page--globale-instanzen.html'),
|
|
(string) $this->source->post_content,
|
|
'the source is only read'
|
|
);
|
|
}
|
|
|
|
public function testGlobalInstancesStayLinked(): void
|
|
{
|
|
$result = $this->ability()->execute($this->input(['source_id' => 'g0']));
|
|
|
|
self::assertTrue($result['ok']);
|
|
self::assertStringContainsString('<!-- wp:divi/global-layout {"globalModule":"1058"', $this->saved[0]);
|
|
self::assertStringContainsString($this->sourceBytes('g0'), $this->saved[0]);
|
|
}
|
|
|
|
public function testStaleSourceOrTargetWritesNothing(): void
|
|
{
|
|
self::assertToolError(
|
|
$this->ability()->execute($this->input(['source_tree_rev' => 'abcdef012345'])),
|
|
'rev_mismatch'
|
|
);
|
|
self::assertToolError($this->ability()->execute($this->input(['tree_rev' => 'abcdef012345'])), 'rev_mismatch');
|
|
self::assertToolError($this->ability()->execute($this->input(['source_id' => 's9'])), 'not_found');
|
|
self::assertSame([], $this->saved);
|
|
}
|
|
|
|
public function testEditorCannotCarryBuilderOnlyValuesToAnotherPage(): void
|
|
{
|
|
$this->source = LibraryFixtures::page(
|
|
'<!-- wp:divi/placeholder --><!-- wp:divi/section {"css":{"desktop":{"value":{"freeForm":"body{display:none}"}}}} -->'
|
|
. '<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->',
|
|
['ID' => 8]
|
|
);
|
|
LibraryFixtures::given([$this->target, $this->source]);
|
|
|
|
$result = $this->ability()->execute($this->input(['source_id' => 's0']));
|
|
|
|
self::assertToolError($result, 'validation_failed');
|
|
self::assertSame([], $this->saved);
|
|
}
|
|
|
|
public function testAnAdminMayCopyBuilderOnlyValues(): void
|
|
{
|
|
$this->source = LibraryFixtures::page(
|
|
'<!-- wp:divi/placeholder --><!-- wp:divi/section {"css":{"desktop":{"value":{"freeForm":"body{display:none}"}}}} -->'
|
|
. '<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->',
|
|
['ID' => 8]
|
|
);
|
|
LibraryFixtures::given([$this->target, $this->source]);
|
|
|
|
$result = $this->ability(unfiltered: true)->execute($this->input(['source_id' => 's0']));
|
|
|
|
self::assertTrue($result['ok']);
|
|
self::assertStringContainsString('body{display:none}', $this->saved[0]);
|
|
}
|
|
|
|
public function testTheSourceNeedsItsOwnRights(): void
|
|
{
|
|
Functions\when('current_user_can')->alias(
|
|
static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 8)
|
|
);
|
|
|
|
self::assertToolError($this->ability()->permission($this->input()), 'forbidden');
|
|
self::assertToolError($this->ability()->execute($this->input()), 'forbidden');
|
|
self::assertSame([], $this->saved);
|
|
}
|
|
|
|
public function testFollowsLitDiviInsertExposure(): void
|
|
{
|
|
$ability = $this->ability(diviWritable: false);
|
|
|
|
self::assertToolError($ability->permission($this->input()), 'unsupported');
|
|
self::assertToolError($ability->execute($this->input()), 'unsupported');
|
|
self::assertSame([], $this->saved);
|
|
}
|
|
|
|
public function testCopiesIntoALibraryEntry(): void
|
|
{
|
|
$item = LibraryFixtures::item(1055);
|
|
|
|
$result = $this->ability()->execute($this->input([
|
|
'post_id' => 1055,
|
|
'tree_rev' => Rev::of((string) $item->post_content),
|
|
'source_id' => 's0.r0.c0.m0',
|
|
'after' => null,
|
|
'parent' => 's0.r0.c0',
|
|
]));
|
|
|
|
self::assertTrue($result['ok']);
|
|
self::assertStringContainsString('Kontakt aufnehmen', $this->saved[0]);
|
|
}
|
|
|
|
public function testCopiesFromALibraryEntry(): void
|
|
{
|
|
$item = LibraryFixtures::item(1055);
|
|
|
|
$result = $this->ability()->execute($this->input([
|
|
'source_post_id' => 1055,
|
|
'source_tree_rev' => Rev::of((string) $item->post_content),
|
|
'source_id' => 's0',
|
|
]));
|
|
|
|
self::assertTrue($result['ok']);
|
|
self::assertStringContainsString('Library-Blurb', $this->saved[0]);
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private function librarySource(): array
|
|
{
|
|
return $this->input([
|
|
'source_post_id' => 1055,
|
|
'source_tree_rev' => Rev::of((string) LibraryFixtures::item(1055)->post_content),
|
|
'source_id' => 's0',
|
|
]);
|
|
}
|
|
|
|
public function testALibrarySourceNeedsAddLibrary(): void
|
|
{
|
|
$ability = $this->ability(access: LibraryFixtures::access(['add_library']));
|
|
|
|
self::assertToolError($ability->permission($this->librarySource()), 'forbidden');
|
|
self::assertToolError($ability->execute($this->librarySource()), 'forbidden');
|
|
self::assertSame([], $this->saved);
|
|
}
|
|
|
|
public function testALibrarySourceNeedsAWritableLibraryGroup(): void
|
|
{
|
|
$ability = $this->ability(access: LibraryFixtures::access(writable: false));
|
|
|
|
self::assertToolError($ability->execute($this->librarySource()), 'forbidden');
|
|
self::assertSame([], $this->saved);
|
|
}
|
|
|
|
public function testALibrarySourceFollowsLibraryInsertExposure(): void
|
|
{
|
|
$ability = $this->ability(libraryInsert: false);
|
|
|
|
self::assertToolError($ability->permission($this->librarySource()), 'unsupported');
|
|
self::assertToolError($ability->execute($this->librarySource()), 'unsupported');
|
|
self::assertSame([], $this->saved);
|
|
}
|
|
|
|
public function testPageSourcesNeedNeitherAddLibraryNorLibraryInsert(): void
|
|
{
|
|
$ability = $this->ability(access: LibraryFixtures::access(['add_library']), libraryInsert: false);
|
|
|
|
self::assertTrue($ability->execute($this->input())['ok']);
|
|
}
|
|
|
|
public function testAHiddenLibraryIsNoSource(): void
|
|
{
|
|
$item = LibraryFixtures::item(1055);
|
|
$access = new LibraryAccess(
|
|
LibraryFixtures::store(),
|
|
LibraryFixtures::roles(),
|
|
static fn (): bool => true,
|
|
static fn (): bool => false
|
|
);
|
|
|
|
$result = $this->ability(access: $access)->execute($this->input([
|
|
'source_post_id' => 1055,
|
|
'source_tree_rev' => Rev::of((string) $item->post_content),
|
|
'source_id' => 's0',
|
|
]));
|
|
|
|
self::assertToolError($result, 'not_found');
|
|
self::assertSame([], $this->saved);
|
|
}
|
|
|
|
public function testNeverNestsAnInstanceInAGlobalEntry(): void
|
|
{
|
|
$item = LibraryFixtures::item(1058);
|
|
LibraryFixtures::given([$this->target, $this->source, $item]);
|
|
|
|
$result = $this->ability()->execute($this->input([
|
|
'post_id' => 1058,
|
|
'tree_rev' => Rev::of((string) $item->post_content),
|
|
'source_id' => 's0.r0.c0.g0',
|
|
'after' => null,
|
|
'parent' => 's0.r0.c0',
|
|
'confirm' => true,
|
|
]));
|
|
|
|
self::assertToolError($result, 'validation_failed');
|
|
self::assertStringContainsString('keine globalen Module', $result->get_error_message());
|
|
self::assertSame([], $this->saved);
|
|
}
|
|
|
|
public function testRefusesAPlaceThatBreaksTheStructure(): void
|
|
{
|
|
$result = $this->ability()->execute($this->input(['after' => null, 'parent' => 's0.r0.c0']));
|
|
|
|
self::assertToolError($result, 'validation_failed');
|
|
self::assertSame([], $this->saved);
|
|
}
|
|
|
|
public function testRefusesAMissingSourceId(): void
|
|
{
|
|
self::assertToolError($this->ability()->execute($this->input(['source_id' => null])), 'not_found');
|
|
self::assertSame([], $this->saved);
|
|
}
|
|
}
|