Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Library/LibrarySaveAbilityTest.php
T
janniclas.loosenandClaude Opus 5.5 44c1804c4c feat: lit/library-make-global – node to global module, all or nothing (E10)
- NewLibraryEntry: spec and creation of a new library entry, shared by
  lit/library-save and lit/library-make-global (NewDraft, P7, StoredNodeGuard)
- UsageMatcher::referencesAny() (ruling Q7): a global entry never names any
  entry in any spelling Divi renders (P23/P24 forms, Divi 4 shortcodes inside
  Divi 5 values, values casting to 0); used by library-save scope global and
  make-global
- make-global: entry created, node replaced by GlobalInstance::of, page through
  DiviWriter::commit; refused or rolled-back page deletes the entry, a partial
  page write keeps it (the page may show it); sources are pages and posts only

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-27 21:35:48 +02:00

263 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
/**
* 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']);
}
}