feat: lit/library-save creates library entries like Divi (E4)

Reuses NewDraft for insert and rollback (P6), requires edit_global_library
for scope global (P7), refuses opaque and global roots before wrapping (P11)
and checks the node with StoredNodeGuard. The source page is never written.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 02:12:50 +02:00
co-authored by Claude Opus 5.5
parent 8fa285a265
commit 5035092805
14 changed files with 1112 additions and 21 deletions
@@ -0,0 +1,240 @@
<?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\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', $this->specs[0]->meta()['_et_pb_module_type']);
self::assertSame('[]', $this->specs[0]->meta()['_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);
}
}