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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user