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
@@ -58,7 +58,7 @@ final class DiviIntegrationTest extends TestCase
self::assertSame('Divi-Bibliothek', $library->label());
self::assertFalse($library->isAvailable(), 'no Divi classes in unit tests');
self::assertSame(
['lit/library-list'],
['lit/library-list', 'lit/library-save'],
array_map(static fn (Ability $ability): string => $ability->name(), $library->abilities())
);
foreach ($library->abilities() as $ability) {
@@ -5,7 +5,11 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec;
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Post;
use WP_Term;
@@ -99,4 +103,130 @@ final class DiviLibraryStoreTest extends TestCase
self::assertSame(['scope'], (new DiviLibraryStore())->missingTaxonomies());
}
private static function draft(?FakeChangeLog $log = null): NewDraft
{
return new NewDraft(null, $log ?? FakeChangeLog::forbidden(), 'lit/library-save', 'et_pb_layout', 'Bibliothekseintrag');
}
public function testCreatesAPublishedEntryWithTermsAndMeta(): void
{
Functions\when('taxonomy_exists')->justReturn(true);
$inserted = [];
Functions\when('wp_insert_post')->alias(static function (array $data) use (&$inserted): int {
$inserted = $data;
return 2001;
});
$terms = [];
Functions\when('wp_set_object_terms')->alias(
static function (int $id, array $values, string $taxonomy) use (&$terms): array {
$terms[$taxonomy] = $values;
return [1];
}
);
$meta = [];
Functions\when('update_post_meta')->alias(static function (int $id, string $key, string $value) use (&$meta): bool {
$meta[$key] = $value;
return true;
});
// By reference: an arrow function would capture the still empty $meta.
Functions\when('get_post_meta')->alias(static function (int $id, string $key) use (&$meta): string {
return $meta[$key] ?? '';
});
$spec = new LibraryItemSpec('Öffnungszeiten', 'module', true, 'regular', 'divi/text', 'page');
self::assertSame(2001, (new DiviLibraryStore())->create($spec, self::draft()));
self::assertSame(
['post_type' => 'et_pb_layout', 'post_status' => 'publish', 'post_title' => 'Öffnungszeiten', 'post_content' => ''],
$inserted
);
self::assertSame(['scope' => ['global'], 'layout_type' => ['module'], 'module_width' => ['regular']], $terms);
self::assertSame('[]', $meta['_et_pb_excluded_global_options']);
}
public function testAFailedStepDeletesTheNewEntry(): void
{
Functions\when('taxonomy_exists')->justReturn(true);
Functions\when('wp_insert_post')->justReturn(2001);
Functions\when('wp_set_object_terms')->justReturn(new \WP_Error('invalid_taxonomy', 'Ungültige Taxonomie.'));
Functions\expect('wp_delete_post')->once()->with(2001, true)->andReturn(new WP_Post(['ID' => 2001]));
try {
(new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page'), self::draft());
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
}
}
/** Ruling P6: a failed rollback is an honest partial write, logged as partial (NewDraft). */
public function testAFailedRollbackIsAPartialWrite(): void
{
Functions\when('taxonomy_exists')->justReturn(true);
Functions\when('wp_insert_post')->justReturn(2001);
Functions\when('wp_set_object_terms')->justReturn([1]);
Functions\when('update_post_meta')->justReturn(false);
Functions\when('get_post_meta')->justReturn('');
Functions\when('wp_delete_post')->justReturn(false);
$log = FakeChangeLog::partialsOnly();
try {
(new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page'), self::draft($log));
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertFalse($e->rolledBack);
self::assertSame(2001, $e->postId);
}
$log->assertPartials([
'ability' => 'lit/library-save',
'object_type' => 'et_pb_layout',
'object_id' => '2001',
'summary' => 'Bibliothekseintrag 2001: Anlegen nur teilweise zurückgenommen',
]);
}
/** Ruling P6: a throwing insert hook leaves the entry's existence unknown – partial write without id. */
public function testAThrowingInsertHookIsAPartialWrite(): void
{
Functions\when('taxonomy_exists')->justReturn(true);
Functions\when('wp_insert_post')->alias(static function (): never {
throw new \RuntimeException('hook kaputt');
});
$log = FakeChangeLog::partialsOnly();
try {
(new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page'), self::draft($log));
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertFalse($e->rolledBack);
self::assertNull($e->postId);
}
self::assertCount(1, $log->partials);
}
public function testARefusedInsertCreatesNothing(): void
{
Functions\when('taxonomy_exists')->justReturn(true);
Functions\when('wp_insert_post')->justReturn(new \WP_Error('db_insert_error', 'Datenbankfehler.'));
Functions\expect('wp_delete_post')->never();
try {
(new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page'), self::draft());
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
}
}
public function testRefusesWhenTheLibraryTaxonomiesAreMissing(): void
{
Functions\when('taxonomy_exists')->justReturn(false);
Functions\expect('wp_insert_post')->never();
$this->expectException(WriteFailed::class);
(new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page'), self::draft());
}
}
@@ -142,4 +142,28 @@ final class LibraryAccessTest extends TestCase
self::assertInstanceOf(WP_Post::class, LibraryFixtures::access([], false)->item(1057, false));
self::assertNull(LibraryFixtures::access()->writableGate());
}
public function testSavingNeedsPublishPostsSaveLibraryAndAWritableGroup(): void
{
LibraryFixtures::given([]);
self::assertNull(LibraryFixtures::access()->canSave());
LibraryFixtures::given([], ['publish_posts']);
self::assertToolError(LibraryFixtures::access()->canSave(), 'forbidden');
LibraryFixtures::given([]);
self::assertToolError(LibraryFixtures::access(['save_library'])->canSave(), 'forbidden');
self::assertToolError(LibraryFixtures::access([], false)->canSave(), 'forbidden');
self::assertToolError(LibraryFixtures::access(['divi_library'])->canSave(), 'forbidden');
}
/** Ruling P7: a global entry additionally needs Divi's edit_global_library. */
public function testSavingAGlobalEntryNeedsEditGlobalLibrary(): void
{
LibraryFixtures::given([]);
self::assertNull(LibraryFixtures::access(['edit_global_library'])->canSave());
self::assertToolError(LibraryFixtures::access(['edit_global_library'])->canSave(true), 'forbidden');
self::assertNull(LibraryFixtures::access()->canSave(true));
}
}
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec;
use LIT\MCP\Tests\Support\DiviFixtures;
use PHPUnit\Framework\TestCase;
final class LibraryItemSpecTest extends TestCase
{
private static function pageNode(string $id): Block
{
return NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html')))
->find($id);
}
private static function libraryRoot(string $file): Block
{
return LibraryContent::roots(LibraryContent::load(new Parser(), DiviFixtures::markup('stage3/' . $file)))[0];
}
/** @return array<string, array{string, bool}> Fixture role => [node source, global]. */
public static function builderEntries(): array
{
return [
'library--library-abschnitt' => ['page:s1', false],
'library--library-zeile' => ['page:s1.r0', false],
'library--library-modul' => ['page:s1.r0.c0.m0', false],
'library-global--globaler-abschnitt' => ['library:library-global--globaler-abschnitt.html', true],
'library-global--globales-modul' => ['library:library-global--globales-modul.html', true],
];
}
/**
* Terms and meta as Divi's own "add to library" wrote them (fixtures stage3, JSON of each entry),
* without Divi's broken _et_pb_row_layout ("[object Object]", finding 5).
*
* @dataProvider builderEntries
*/
public function testTermsAndMetaMatchTheBuilderEntries(string $source, bool $global): void
{
[$kind, $ref] = explode(':', $source, 2);
$node = $kind === 'page' ? self::pageNode($ref) : self::libraryRoot($ref);
$role = (string) $this->dataName();
$json = json_decode(DiviFixtures::markup('stage3/' . $role . '.json'), true);
$spec = LibraryItemSpec::forNode($node, DiviFixtures::catalog(), 'Titel', $global, 'page');
self::assertInstanceOf(LibraryItemSpec::class, $spec);
$expectedTerms = [];
foreach (['scope', 'layout_type', 'module_width'] as $taxonomy) {
$expectedTerms[$taxonomy] = array_column($json['terms'][$taxonomy], 'slug');
}
self::assertEquals($expectedTerms, $spec->terms());
$expectedMeta = array_map(static fn (array $values): string => $values[0], $json['meta']);
unset($expectedMeta['_et_pb_row_layout']);
self::assertEquals($expectedMeta, $spec->meta());
}
public function testRefusesInstancesColumnsAndGlobalsInsideGlobals(): void
{
$catalog = DiviFixtures::catalog();
self::assertStringContainsString(
'bereits ein globales Modul (Bibliothekseintrag 1058)',
(string) LibraryItemSpec::forNode(self::pageNode('g0'), $catalog, 'T', false, 'page')
);
self::assertStringContainsString(
'nur Abschnitte, Zeilen und Module',
(string) LibraryItemSpec::forNode(self::pageNode('s1.r0.c0'), $catalog, 'T', false, 'page')
);
self::assertStringContainsString(
'keine globalen Module',
(string) LibraryItemSpec::forNode(self::pageNode('s0'), $catalog, 'T', true, 'page')
);
self::assertInstanceOf(LibraryItemSpec::class, LibraryItemSpec::forNode(self::pageNode('s0'), $catalog, 'T', false, 'page'));
}
/** Ruling P11: opaque roots and instances without a readable id are refused before wrapping. */
public function testRefusesOpaqueRootsAndUnreadableInstances(): void
{
$catalog = DiviFixtures::catalog();
$document = (new Parser())->parse(
'<!-- wp:divi/section {"module":kaputt} --><!-- /wp:divi/section -->'
. '<!-- wp:divi/global-layout {"globalModule":["x"],"blockName":"divi/section"} /-->'
);
[$opaque, $instance] = $document->children();
self::assertTrue($opaque->isOpaque());
self::assertStringContainsString('nicht lesbar', (string) LibraryItemSpec::forNode($opaque, $catalog, 'T', false, 'page'));
self::assertStringContainsString(
'globales Modul',
(string) LibraryItemSpec::forNode($instance, $catalog, 'T', false, 'page')
);
}
public function testFullwidthAndTerms(): void
{
$section = Block::create('divi/section', ['module' => ['advanced' => ['type' => ['desktop' => ['value' => 'fullwidth']]]]]);
$spec = LibraryItemSpec::forNode($section, DiviFixtures::catalog(), 'Kopf', false, 'page');
self::assertSame(['fullwidth'], $spec->terms()['module_width']);
self::assertSame([26], $spec->withTerms([26], [])->terms()['layout_category']);
self::assertArrayNotHasKey('layout_tag', $spec->withTerms([26], [])->terms());
}
}
@@ -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);
}
}
@@ -62,4 +62,26 @@ final class NewDraftTest extends TestCase
self::assertSame(NewDraft::REASON_EXTRAS, $e->reason);
}
}
/** lit/library-save reuses the transaction without a PostWriter and names its entry (ruling P6). */
public function testWithoutAWriterOnlyTheEntryIsDeletedAndPartialsNameTheEntry(): void
{
Functions\when('wp_delete_post')->justReturn(false);
$log = FakeChangeLog::partialsOnly();
$draft = new NewDraft(null, $log, 'lit/library-save', 'et_pb_layout', 'Bibliothekseintrag');
try {
$draft->discard(2001, new RuntimeException('layout kaputt'), 'Grund.');
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertFalse($e->rolledBack);
self::assertSame(2001, $e->postId);
}
$log->assertPartials([
'ability' => 'lit/library-save',
'object_type' => 'et_pb_layout',
'object_id' => '2001',
'summary' => 'Bibliothekseintrag 2001: Anlegen nur teilweise zurückgenommen',
]);
}
}