Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
264 lines
11 KiB
PHP
264 lines
11 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Rev;
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
|
||
use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec;
|
||
use LIT\MCP\Integrations\Divi\Library\LibrarySaveAbility;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use RuntimeException;
|
||
use WP_Post;
|
||
use WP_Term;
|
||
|
||
final class LibrarySaveAbilityTest extends TestCase
|
||
{
|
||
private WP_Post $page;
|
||
|
||
/** @var list<LibraryItemSpec> */
|
||
private array $specs = [];
|
||
|
||
/** @var list<array{int, string, SaveScope}> */
|
||
private array $contents = [];
|
||
|
||
/** @var list<string> Contents DiviWriter saved (the source page must never be written). */
|
||
private array $saved = [];
|
||
|
||
private FakeChangeLog $log;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$this->page = LibraryFixtures::page(DiviFixtures::markup('stage3/page--globale-instanzen.html'));
|
||
LibraryFixtures::given([$this->page]);
|
||
Functions\when('get_term_by')->alias(static fn (string $field, string $name, string $taxonomy): WP_Term|false => $name === 'Fixture'
|
||
? new WP_Term(['term_id' => 26, 'name' => 'Fixture', 'taxonomy' => $taxonomy])
|
||
: false);
|
||
$this->log = new FakeChangeLog(4711);
|
||
}
|
||
|
||
/** @param list<string> $deniedRoles */
|
||
private function ability(array $deniedRoles = [], bool $writable = true, ?\Throwable $layoutFailure = null): LibrarySaveAbility
|
||
{
|
||
$store = LibraryFixtures::store();
|
||
$store->allows('create')->andReturnUsing(function (LibraryItemSpec $spec): int {
|
||
$this->specs[] = $spec;
|
||
|
||
return 2001;
|
||
});
|
||
$saver = Mockery::mock(DiviSaver::class);
|
||
$saver->allows('saveNew')->andReturnUsing(function (int $id, string $content, SaveScope $scope) use ($layoutFailure): array {
|
||
if ($layoutFailure !== null) {
|
||
throw $layoutFailure;
|
||
}
|
||
$this->contents[] = [$id, $content, $scope];
|
||
LibraryFixtures::given([$this->page, LibraryFixtures::item(1055, ['ID' => 2001, 'post_content' => $content])]);
|
||
|
||
return [];
|
||
});
|
||
$access = LibraryFixtures::access($deniedRoles, $writable, $store);
|
||
$failures = [];
|
||
|
||
return new LibrarySaveAbility(
|
||
$access,
|
||
LibraryFixtures::loader($access),
|
||
DiviWriterFixture::writer($this->saved, $failures, false, LibraryFixtures::loader($access)),
|
||
$saver,
|
||
$this->log,
|
||
DiviWriterFixture::modules()
|
||
);
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
private function input(array $extra = []): array
|
||
{
|
||
return array_merge(
|
||
['post_id' => 7, 'tree_rev' => Rev::of((string) $this->page->post_content), 'id' => 's1', 'title' => 'Leistungen'],
|
||
$extra
|
||
);
|
||
}
|
||
|
||
public function testSavesASectionLikeTheBuilder(): void
|
||
{
|
||
$result = $this->ability()->execute($this->input(['categories' => ['Fixture']]));
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertSame(2001, $result['library_id']);
|
||
self::assertSame('section', $this->specs[0]->layoutType);
|
||
self::assertSame([26], $this->specs[0]->terms()['layout_category']);
|
||
self::assertSame(
|
||
str_replace("\r\n", "\n", DiviFixtures::markup('stage3/library--library-abschnitt.html')),
|
||
$this->contents[0][1]
|
||
);
|
||
self::assertSame(SaveScope::LibraryItem, $this->contents[0][2]);
|
||
self::assertSame(Rev::of($this->contents[0][1]), $result['tree_rev']);
|
||
self::assertSame('et_pb_layout', $this->log->records[0]['object_type']);
|
||
self::assertSame('2001', $this->log->records[0]['object_id']);
|
||
}
|
||
|
||
public function testSavesAGlobalModule(): void
|
||
{
|
||
$result = $this->ability()->execute($this->input(['id' => 's0.r0.c0.m0', 'title' => 'Kontakt-Button', 'scope' => 'global']));
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertTrue($this->specs[0]->global);
|
||
self::assertSame('divi/button', DiviLibraryStore::metaFor($this->specs[0])['_et_pb_module_type']);
|
||
self::assertSame('[]', DiviLibraryStore::metaFor($this->specs[0])['_et_pb_excluded_global_options']);
|
||
}
|
||
|
||
public function testRefusesAGlobalInstanceAndColumnsAndCreatesNothing(): void
|
||
{
|
||
self::assertToolError($this->ability()->execute($this->input(['id' => 'g0'])), 'validation_failed');
|
||
self::assertToolError($this->ability()->execute($this->input(['id' => 's1.r0.c0'])), 'validation_failed');
|
||
self::assertSame([], $this->specs);
|
||
}
|
||
|
||
public function testStaleSourceTreeRevCreatesNothing(): void
|
||
{
|
||
self::assertToolError($this->ability()->execute($this->input(['tree_rev' => 'abcdef012345'])), 'rev_mismatch');
|
||
self::assertSame([], $this->specs);
|
||
}
|
||
|
||
public function testNeedsPublishPostsAndTheDiviRole(): void
|
||
{
|
||
self::assertToolError($this->ability(['save_library'])->permission($this->input()), 'forbidden');
|
||
self::assertToolError($this->ability([], false)->permission($this->input()), 'forbidden');
|
||
LibraryFixtures::given([$this->page], ['publish_posts']);
|
||
self::assertToolError($this->ability()->permission($this->input()), 'forbidden');
|
||
self::assertSame([], $this->specs);
|
||
}
|
||
|
||
public function testEditorCannotSaveACodeModule(): void
|
||
{
|
||
$this->page = LibraryFixtures::page(
|
||
'<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
|
||
. '<!-- wp:divi/code {"content":{"innerContent":{"desktop":{"value":"<b>x</b>"}}}} /-->'
|
||
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->'
|
||
);
|
||
LibraryFixtures::given([$this->page]);
|
||
|
||
$result = $this->ability()->execute($this->input(['id' => 's0']));
|
||
|
||
self::assertToolError($result, 'validation_failed');
|
||
self::assertStringContainsString('Code-Modul', implode(' ', \LIT\MCP\Abilities\Errors::fieldsOf($result)));
|
||
self::assertSame([], $this->specs);
|
||
}
|
||
|
||
public function testUnknownCategoryCreatesNothing(): void
|
||
{
|
||
$result = $this->ability()->execute($this->input(['categories' => ['Gibt es nicht']]));
|
||
|
||
self::assertToolError($result, 'validation_failed');
|
||
self::assertSame([], $this->specs);
|
||
}
|
||
|
||
/** Ruling P6: the layout write failing rolls the new entry back through NewDraft (write_failed). */
|
||
public function testFailedLayoutWriteDeletesTheEntry(): void
|
||
{
|
||
Functions\expect('wp_delete_post')->once()->with(2001, true)->andReturn(true);
|
||
|
||
try {
|
||
$this->ability([], true, new RuntimeException('hook failed'))->execute($this->input());
|
||
self::fail('WriteFailed expected');
|
||
} catch (WriteFailed $e) {
|
||
self::assertTrue($e->rolledBack);
|
||
self::assertToolError($e->toError(), 'write_failed');
|
||
}
|
||
self::assertSame([], $this->log->records);
|
||
}
|
||
|
||
/** Ruling P6: when the rollback fails too, the answer is partial_write and the change log says so. */
|
||
public function testFailedRollbackIsAPartialWrite(): void
|
||
{
|
||
Functions\when('wp_delete_post')->justReturn(false);
|
||
$this->log = FakeChangeLog::any(4711);
|
||
|
||
try {
|
||
$this->ability([], true, new RuntimeException('hook failed'))->execute($this->input());
|
||
self::fail('WriteFailed expected');
|
||
} catch (WriteFailed $e) {
|
||
self::assertFalse($e->rolledBack);
|
||
self::assertSame(2001, $e->postId);
|
||
self::assertToolError($e->toError(), 'partial_write');
|
||
}
|
||
self::assertSame('et_pb_layout', $this->log->partials[0]['object_type']);
|
||
self::assertSame('2001', $this->log->partials[0]['object_id']);
|
||
}
|
||
|
||
/** Ruling P7: scope "global" also needs Divi's edit_global_library. */
|
||
public function testGlobalScopeNeedsEditGlobalLibrary(): void
|
||
{
|
||
$input = $this->input(['id' => 's0.r0.c0.m0', 'scope' => 'global']);
|
||
|
||
self::assertToolError($this->ability(['edit_global_library'])->permission($input), 'forbidden');
|
||
self::assertToolError($this->ability(['edit_global_library'])->execute($input), 'forbidden');
|
||
self::assertTrue($this->ability(['edit_global_library'])->permission($this->input()));
|
||
self::assertSame([], $this->specs);
|
||
}
|
||
|
||
public function testTheSourcePageIsNeverWritten(): void
|
||
{
|
||
$this->ability()->execute($this->input(['scope' => 'normal']));
|
||
|
||
self::assertSame([], $this->saved);
|
||
self::assertCount(1, $this->contents);
|
||
self::assertSame(2001, $this->contents[0][0]);
|
||
}
|
||
|
||
/** Line breaks between blocks are unified like LibraryContent::load() (fixture finding 9). */
|
||
public function testLineBreaksBetweenBlocksAreUnified(): void
|
||
{
|
||
$this->page = LibraryFixtures::page(
|
||
"<!-- wp:divi/placeholder --><!-- wp:divi/section -->\r\n<!-- wp:divi/row -->\r\n<!-- wp:divi/column -->"
|
||
. '<!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"<p>a</p>"}}}} /-->'
|
||
. "<!-- /wp:divi/column -->\r\n<!-- /wp:divi/row -->\r\n<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->"
|
||
);
|
||
LibraryFixtures::given([$this->page]);
|
||
|
||
$result = $this->ability()->execute($this->input(['id' => 's0']));
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertStringNotContainsString("\r", $this->contents[0][1]);
|
||
self::assertStringContainsString("<!-- wp:divi/section -->\n<!-- wp:divi/row -->", $this->contents[0][1]);
|
||
}
|
||
|
||
public function testUnknownScopeCreatesNothing(): void
|
||
{
|
||
self::assertToolError($this->ability()->execute($this->input(['scope' => 'site'])), 'validation_failed');
|
||
self::assertSame([], $this->specs);
|
||
}
|
||
|
||
/**
|
||
* Ruling Q7: a global entry never renders another entry, in any spelling Divi renders – here a Divi 4
|
||
* shortcode inside a text value; a normal entry may hold it (it is copied, not shown as a module).
|
||
*/
|
||
public function testGlobalScopeRefusesReferencesInAnySpelling(): void
|
||
{
|
||
$quote = chr(92) . '"';
|
||
$this->page = LibraryFixtures::page(
|
||
'<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
|
||
. '<!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"[et_pb_text global_module='
|
||
. $quote . '1058' . $quote . ']x[/et_pb_text]"}}}} /-->'
|
||
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->'
|
||
);
|
||
LibraryFixtures::given([$this->page]);
|
||
|
||
$global = $this->ability()->execute($this->input(['id' => 's0', 'scope' => 'global']));
|
||
self::assertToolError($global, 'validation_failed');
|
||
self::assertSame([], $this->specs);
|
||
|
||
self::assertTrue($this->ability()->execute($this->input(['id' => 's0']))['ok']);
|
||
}
|
||
}
|