Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php
T
janniclas.loosenandClaude Opus 5.5 395049ec97 fix: global-edit rules for every library entry that pages show (L2)
Divi renders any referenced et_pb_layout, whatever its scope term. A normal
entry with usages now gets the dry run, confirm, P8a, backup and the
site-wide GlobalItem scope; lit-divi-delete's notice and the library-list
detail name its pages too. The usage scan is the same single query.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 04:14:58 +02:00

568 lines
24 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\GlobalEdits;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviReplaceTextAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
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 WP_Post;
/** The Divi write tools on library entries (E2, answer F2). */
final class GlobalEditsTest extends TestCase
{
/** @var list<array{string, SaveScope}> Content and scope handed to the saver. */
private array $saved = [];
private int $backups = 0;
/** Change log of the writer; null = DiviWriterFixture::log() (records only). */
private ?FakeChangeLog $log = null;
/** Thrown by the saver instead of saving, if set. */
private ?\Throwable $failure = null;
/** @var list<int> Live (published, private, scheduled) posts that show the global entry (ruling P8a). */
private array $live = [1053];
private bool $trash = true;
protected function setUp(): void
{
parent::setUp();
Functions\when('wp_revisions_enabled')->justReturn(true);
Functions\when('_prime_post_caches')->justReturn(null);
}
/** @var list<int> Normal (non-global) entries that pages nevertheless show (audit L2). */
private array $referenced = [];
/**
* For the global entries and $this->referenced: the live posts ($this->live; 1053 published, the
* others private) and draft 1070. Other entries are shown nowhere.
*/
private function usages(): GlobalUsages
{
return LibraryFixtures::usages(fn (int $id): array => ! LibraryFixtures::ITEMS[$id][2]
&& ! in_array($id, $this->referenced, true) ? [] : array_merge(
array_map(
static fn (int $id): array => ['id' => $id, 'status' => $id === 1053 ? 'publish' : 'private', 'type' => 'page'],
$this->live
),
[['id' => 1070, 'status' => 'draft', 'type' => 'page']]
));
}
/** @param list<string> $deniedRoles */
private function writer(bool $writable = true, array $deniedRoles = []): DiviWriter
{
$saver = Mockery::mock(DiviSaver::class);
$saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content, SaveScope $scope): array {
if ($this->failure !== null) {
throw $this->failure;
}
$this->saved[] = [$content, $scope];
$post->post_content = $content;
return ['revision_id' => 811, 'warnings' => []];
});
$access = LibraryFixtures::access($deniedRoles, $writable);
$design = DiviWriterFixture::designReader();
$globals = new GlobalEdits(
$access,
$this->usages(),
function (WP_Post $item): int {
++$this->backups;
return 1071;
},
trashEnabled: fn (): bool => $this->trash
);
return new DiviWriter(
LibraryFixtures::loader($access),
$saver,
$this->log ?? DiviWriterFixture::log(),
$design,
DiviWriterFixture::composition($design),
$globals
);
}
private static function rev(int $id): string
{
return Rev::of((string) LibraryFixtures::item($id)->post_content);
}
/** @return array<string, mixed> */
private static function relabel(int $id, bool $confirm = false): array
{
return array_merge(
['post_id' => $id, 'tree_rev' => self::rev($id), 'changes' => [['id' => 'm0', 'label' => 'Neu']]],
$confirm ? ['confirm' => true] : []
);
}
/** A user with every capability but $cap on the posts $ids. */
private static function denyOn(string $cap, array $ids): void
{
Functions\when('current_user_can')->alias(
static fn (string $asked, mixed ...$args): bool => ! ($asked === $cap
&& ($ids === [] || in_array((int) ($args[0] ?? 0), $ids, true)))
);
}
public function testPlainLibraryItemIsSavedWithoutConfirmAndWithoutDiviFlags(): void
{
LibraryFixtures::given([LibraryFixtures::item(1057)]);
$result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1057));
self::assertTrue($result['ok']);
self::assertSame(SaveScope::LibraryItem, $this->saved[0][1]);
self::assertStringStartsWith('<!-- wp:divi/placeholder --><!-- wp:divi/blurb ', $this->saved[0][0]);
self::assertArrayNotHasKey('affected', $result);
}
/**
* Audit L2: Divi renders every referenced entry, global scope or not – a normal entry that pages
* show gets the dry run, P8a and the site-wide scope of a global one.
*/
public function testAReferencedNormalEntryIsEditedLikeAGlobalOne(): void
{
LibraryFixtures::given([LibraryFixtures::item(1057)]);
$this->referenced = [1057];
$this->live = [1053, 1080];
$dryRun = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1057));
self::denyOn('edit_post', [1080]);
$blocked = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1057, true));
LibraryFixtures::given([LibraryFixtures::item(1057)]);
$done = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1057, true));
self::assertToolError($dryRun, 'confirm_required');
self::assertStringContainsString('Bibliothekseintrag 1057', $dryRun->get_error_message());
self::assertStringContainsString('als globales Modul', $dryRun->get_error_message());
self::assertSame(3, $dryRun->get_error_data()['affected_count']);
self::assertToolError($blocked, 'forbidden');
self::assertTrue($done['ok']);
self::assertCount(1, $this->saved);
self::assertSame(SaveScope::GlobalItem, $this->saved[0][1]);
}
public function testDeleteInAReferencedNormalEntryNamesTheAffectedPages(): void
{
LibraryFixtures::given([LibraryFixtures::item(1055)]);
$this->referenced = [1055];
$result = (new DiviDeleteAbility($this->writer()))->execute(
['post_id' => 1055, 'tree_rev' => self::rev(1055), 'ids' => ['s0.r0.c0.m0']]
);
self::assertToolError($result, 'confirm_required');
self::assertSame(2, $result->get_error_data()['affected_count']);
}
public function testGlobalItemDryRunNamesTheAffectedPagesAndSavesNothing(): void
{
LibraryFixtures::given([LibraryFixtures::item(1059)]);
$result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059));
self::assertToolError($result, 'confirm_required');
self::assertStringContainsString('wirkt sofort auf 2 Seiten', $result->get_error_message());
self::assertSame(1053, $result->get_error_data()['affected'][0]['post_id']);
self::assertSame(2, $result->get_error_data()['affected_count']);
self::assertSame([], $this->saved);
}
public function testConfirmedGlobalEditClearsEveryPageAndKeepsARevision(): void
{
LibraryFixtures::given([LibraryFixtures::item(1059)]);
$result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
self::assertTrue($result['ok']);
self::assertSame(SaveScope::GlobalItem, $this->saved[0][1]);
self::assertSame(811, $result['revision_id']);
self::assertSame(2, $result['affected_count']);
self::assertArrayNotHasKey('backup_id', $result);
self::assertSame(0, $this->backups);
}
public function testWithoutRevisionsABackupIsMadeFirst(): void
{
Functions\when('wp_revisions_enabled')->justReturn(false);
LibraryFixtures::given([LibraryFixtures::item(1059)]);
$result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
self::assertSame(1071, $result['backup_id']);
self::assertSame(1, $this->backups);
}
/** Ruling P9 (M-6): PostCloner's backup goes through kses – for these users it would not be byte-faithful. */
public function testWithoutRevisionsAUserWithoutUnfilteredHtmlIsRefused(): void
{
Functions\when('wp_revisions_enabled')->justReturn(false);
LibraryFixtures::given([LibraryFixtures::item(1059)]);
self::denyOn('unfiltered_html', []);
$result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
self::assertToolError($result, 'unsupported');
self::assertStringContainsString('Revisionen', $result->get_error_message());
self::assertSame(0, $this->backups);
self::assertSame([], $this->saved);
}
/** Ruling P9 (M-7): with the trash disabled the backup would be deleted at once. */
public function testWithoutRevisionsADisabledTrashIsRefused(): void
{
Functions\when('wp_revisions_enabled')->justReturn(false);
LibraryFixtures::given([LibraryFixtures::item(1059)]);
$this->trash = false;
$result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
self::assertToolError($result, 'unsupported');
self::assertStringContainsString('Papierkorb', $result->get_error_message());
self::assertSame(0, $this->backups);
self::assertSame([], $this->saved);
}
/** Revisions on: neither unfiltered_html nor the trash matter (the revision is taken in the kses window). */
public function testWithRevisionsAFilteredUserMayConfirm(): void
{
LibraryFixtures::given([LibraryFixtures::item(1059)]);
self::denyOn('unfiltered_html', []);
$this->trash = false;
$result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
self::assertTrue($result['ok']);
self::assertSame(SaveScope::GlobalItem, $this->saved[0][1]);
}
/** Ruling P8: a confirmed global edit needs edit_post on every published page that shows the entry. */
public function testConfirmIsRefusedWhenAPublishedPageShowingTheEntryIsNotEditable(): void
{
LibraryFixtures::given([LibraryFixtures::item(1059)]);
$this->live = [1053, 1080];
self::denyOn('edit_post', [1080]);
$dryRun = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059));
$result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
self::assertToolError($dryRun, 'confirm_required', 'the dry run still lists the pages');
self::assertToolError($result, 'forbidden');
self::assertStringContainsString('1080', $result->get_error_message());
self::assertStringContainsString('veröffentlichten, privaten oder geplanten', $result->get_error_message());
self::assertStringNotContainsString('1053', $result->get_error_message());
self::assertSame([], $this->saved);
self::assertSame(0, $this->backups);
}
/**
* Ruling P8a: private and scheduled posts render (or will) – each blocks when the user may not
* edit it; drafts are listed in the dry run but never block (they are not among liveIds()).
*/
public function testPrivateAndScheduledPagesBlockButDraftsDoNot(): void
{
LibraryFixtures::given([LibraryFixtures::item(1059)]);
foreach ([1081, 1082] as $private) {
$this->live = [1053, $private];
self::denyOn('edit_post', [$private, 1070]);
$result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
self::assertToolError($result, 'forbidden');
self::assertStringContainsString((string) $private, $result->get_error_message());
}
self::assertSame([], $this->saved);
$this->live = [1053];
$result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
self::assertTrue($result['ok'], 'draft 1070 (not editable) does not block');
}
/** Ruling P14 (M-1): the dry run already says which posts would block the confirm. */
public function testTheDryRunNamesThePostsThatWouldBlock(): void
{
LibraryFixtures::given([LibraryFixtures::item(1059)]);
$this->live = [1053, 1081];
self::denyOn('edit_post', [1081]);
$dryRun = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059));
self::assertToolError($dryRun, 'confirm_required');
self::assertSame([1081], $dryRun->get_error_data()['blocked_ids']);
self::assertStringContainsString('1081', $dryRun->get_error_message());
self::assertStringContainsString('abgelehnt', $dryRun->get_error_message());
}
/** Audit L4: posts the caller may not read appear only as counts – no ID, status or type. */
public function testUnreadablePostsAppearOnlyAsCounts(): void
{
LibraryFixtures::given([LibraryFixtures::item(1059)]);
$this->live = [1053, 1081];
Functions\when('current_user_can')->alias(
static fn (string $cap, mixed ...$args): bool => ! (in_array($cap, ['read_post', 'edit_post'], true)
&& in_array((int) ($args[0] ?? 0), [1070, 1081], true))
);
$dryRun = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059));
$refused = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
self::assertToolError($dryRun, 'confirm_required');
$data = $dryRun->get_error_data();
self::assertSame([1053], array_column($data['affected'], 'post_id'));
self::assertSame(3, $data['affected_count']);
self::assertSame(2, $data['hidden_count']);
self::assertSame(1, $data['blocked_hidden_count']);
self::assertArrayNotHasKey('blocked_ids', $data);
self::assertStringNotContainsString('1081', $dryRun->get_error_message());
self::assertStringNotContainsString('1070', $dryRun->get_error_message());
self::assertStringNotContainsString('1081', (string) wp_json_encode($data));
self::assertToolError($refused, 'forbidden');
self::assertStringNotContainsString('1081', $refused->get_error_message());
self::assertStringContainsString('nicht lesen darf', $refused->get_error_message());
self::assertSame([], $this->saved);
}
/** Audit L1: above the hard cap a confirmed global edit is refused without checking each post. */
public function testAboveTheHardCapTheConfirmIsRefused(): void
{
LibraryFixtures::given([LibraryFixtures::item(1059)]);
$this->live = range(20001, 20001 + GlobalUsages::HARD_CAP);
$edits = 0;
Functions\when('current_user_can')->alias(static function (string $cap, mixed ...$args) use (&$edits): bool {
$edits += $cap === 'edit_post' && ($args[0] ?? 0) !== 1059 ? 1 : 0;
return true;
});
$dryRun = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059));
$refused = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
self::assertToolError($dryRun, 'confirm_required');
self::assertStringContainsString('mehr als 2000', $dryRun->get_error_message());
self::assertCount(GlobalUsages::LIMIT, $dryRun->get_error_data()['affected']);
self::assertSame(GlobalUsages::HARD_CAP + 2, $dryRun->get_error_data()['affected_count']);
self::assertToolError($refused, 'unsupported');
self::assertStringContainsString('Divi-Builder', $refused->get_error_data()['hint']);
self::assertSame(0, $edits, 'no edit_post check per post above the cap');
self::assertSame([], $this->saved);
}
public function testADryRunWithoutBlockersHasNoBlockedIds(): void
{
LibraryFixtures::given([LibraryFixtures::item(1059)]);
$dryRun = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059));
self::assertArrayNotHasKey('blocked_ids', $dryRun->get_error_data());
}
/** Ruling P14 (I-2): a failed save after the backup names it. */
public function testAFailedSaveAfterTheBackupNamesIt(): void
{
Functions\when('wp_revisions_enabled')->justReturn(false);
LibraryFixtures::given([LibraryFixtures::item(1059)]);
$this->failure = WriteFailed::rolledBack(DiviSaver::REASON, new \RuntimeException('hook'), postId: 1059);
try {
(new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
$error = $e->toError();
}
self::assertTrue($e->rolledBack);
self::assertSame(1071, $error->get_error_data()['backup_id']);
self::assertStringContainsString('Sicherung 1071 liegt im Papierkorb', $error->get_error_data()['hint']);
self::assertStringNotContainsString('Nichts wurde gespeichert', $error->get_error_message());
self::assertSame(1, $this->backups);
}
public function testAPartialSaveAfterTheBackupNamesIt(): void
{
Functions\when('wp_revisions_enabled')->justReturn(false);
LibraryFixtures::given([LibraryFixtures::item(1059)]);
$this->failure = WriteFailed::partial(DiviSaver::REASON, new \RuntimeException('hook'), postId: 1059);
$this->log = FakeChangeLog::partialsOnly();
try {
(new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
$error = $e->toError();
}
self::assertToolError($error, 'partial_write');
self::assertSame(1071, $error->get_error_data()['backup_id']);
self::assertSame(1059, $error->get_error_data()['post_id']);
self::assertStringContainsString('Sicherung 1071 liegt im Papierkorb', $error->get_error_data()['hint']);
self::assertCount(1, $this->log->partials);
}
/** Ruling P14 (M-2): one page is "Seite". */
public function testDeleteNoticeUsesTheSingularForOnePage(): void
{
LibraryFixtures::given([LibraryFixtures::item(1058)]);
$usages = LibraryFixtures::usages(LibraryFixtures::rows([1053]));
$access = LibraryFixtures::access();
$design = DiviWriterFixture::designReader();
$writer = new DiviWriter(
LibraryFixtures::loader($access),
Mockery::mock(DiviSaver::class),
DiviWriterFixture::log(),
$design,
DiviWriterFixture::composition($design),
new GlobalEdits($access, $usages)
);
$result = (new DiviDeleteAbility($writer))->execute(
['post_id' => 1058, 'tree_rev' => self::rev(1058), 'ids' => ['s0.r0.c0.m1']]
);
self::assertStringContainsString('wirkt auf 1 Seite (siehe affected)', $result->get_error_message());
}
public function testRefusesToEmptyOrDoubleTheRoot(): void
{
LibraryFixtures::given([LibraryFixtures::item(1058)]);
$emptied = (new DiviDeleteAbility($this->writer()))->execute(
['post_id' => 1058, 'tree_rev' => self::rev(1058), 'ids' => ['s0'], 'confirm' => true]
);
$doubled = (new DiviDuplicateNodeAbility($this->writer()))->execute(
['post_id' => 1058, 'tree_rev' => self::rev(1058), 'id' => 's0', 'confirm' => true]
);
self::assertToolError($emptied, 'validation_failed');
self::assertStringContainsString('nicht leer', $emptied->get_error_message());
self::assertToolError($doubled, 'validation_failed');
self::assertStringContainsString('genau ein divi/section', $doubled->get_error_message());
self::assertSame([], $this->saved);
}
public function testDeleteInAGlobalItemNamesTheAffectedPagesBeforeConfirm(): void
{
LibraryFixtures::given([LibraryFixtures::item(1058)]);
$result = (new DiviDeleteAbility($this->writer()))->execute(
['post_id' => 1058, 'tree_rev' => self::rev(1058), 'ids' => ['s0.r0.c0.m1']]
);
self::assertToolError($result, 'confirm_required');
self::assertStringContainsString('Globaler Bibliothekseintrag: wirkt auf 2 Seiten', $result->get_error_message());
self::assertSame(['s0.r0.c0.m1'], $result->get_error_data()['nodes']);
self::assertCount(2, $result->get_error_data()['affected']);
}
public function testConfirmWithAStaleTreeRevWritesNothing(): void
{
LibraryFixtures::given([LibraryFixtures::item(1059)]);
$result = (new DiviUpdateAbility($this->writer()))->execute(
['tree_rev' => 'abcdef012345'] + self::relabel(1059, true)
);
self::assertToolError($result, 'rev_mismatch');
self::assertSame([], $this->saved);
}
public function testGlobalItemsNeedEditGlobalLibraryAndAWritableGroup(): void
{
LibraryFixtures::given([LibraryFixtures::item(1059), LibraryFixtures::item(1057)]);
self::assertToolError(
(new DiviUpdateAbility($this->writer(true, ['edit_global_library'])))->permission(self::relabel(1059)),
'forbidden'
);
self::assertToolError((new DiviUpdateAbility($this->writer(false)))->permission(self::relabel(1057)), 'forbidden');
self::assertTrue(
(new DiviUpdateAbility($this->writer(true, ['edit_global_library'])))->permission(self::relabel(1057))
);
}
public function testTheOutputSchemaDeclaresTheGlobalFields(): void
{
$properties = (new DiviUpdateAbility($this->writer()))->outputSchema()['properties'];
self::assertSame('array', $properties['affected']['type']);
self::assertSame('integer', $properties['affected_count']['type']);
self::assertSame('integer', $properties['backup_id']['type']);
}
/** Ruling P1: lit-divi-replace-text saves library entries through DiviWriter::commit(). */
public function testReplaceTextSavesAPlainLibraryItemWithoutDiviFlags(): void
{
LibraryFixtures::given([LibraryFixtures::item(1057)]);
$result = $this->replaceText('Library-Modul', 'Bibliotheksmodul', 1057);
self::assertTrue($result['results'][0]['ok'], (string) ($result['results'][0]['error'] ?? ''));
self::assertSame(1, $result['results'][0]['count']);
self::assertSame(SaveScope::LibraryItem, $this->saved[0][1]);
self::assertStringContainsString('Bibliotheksmodul', $this->saved[0][0]);
}
/** Ruling P1 / answer F2: a global entry is never changed without the dry run that names its pages. */
public function testReplaceTextNeverChangesAGlobalItemAndNamesItsPages(): void
{
LibraryFixtures::given([LibraryFixtures::item(1059)]);
$result = $this->replaceText('Öffnungszeiten', 'Zeiten', 1059);
$entry = $result['results'][0];
self::assertFalse($entry['ok']);
self::assertStringContainsString('wirkt sofort auf 2 Seiten', $entry['error']);
self::assertStringContainsString('lit-divi-update', $entry['error']);
self::assertSame(2, $entry['affected_count']);
self::assertSame(1053, $entry['affected'][0]['post_id']);
self::assertSame([], $this->saved);
self::assertSame(0, $this->backups);
}
/** @return array<string, mixed> */
private function replaceText(string $search, string $replace, int $id): array
{
Functions\when('get_post_type_object')->justReturn(
(object) ['cap' => (object) ['edit_posts' => 'edit_pages', 'edit_others_posts' => 'edit_others_pages']]
);
$writer = $this->writer();
$ability = new DiviReplaceTextAbility(
DiviFixtures::schema(),
LibraryFixtures::loader(LibraryFixtures::access()),
$writer,
Mockery::mock(\wpdb::class)->makePartial()
);
return $ability->execute([
'search' => $search,
'replace' => $replace,
'dry_run' => false,
'confirm' => true,
'targets' => [['post_id' => $id, 'tree_rev' => self::rev($id)]],
]);
}
}