Files

264 lines
11 KiB
PHP
Raw Permalink 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\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']);
}
}