Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php
T
janniclas.loosenandClaude Opus 5.5 e8f69f67ea fix: bounded usage scan and count-only unreadable posts in global edits (L1, L4)
GlobalUsages runs one query per request (ID, status, type – no content),
checks read_post/edit_post in chunks of 200 with a primed and released post
cache, lists at most 50 readable posts and refuses a confirmed global edit
above 2000 live references (unsupported, edit in the Divi builder). Posts the
caller may not read appear only as hidden_count / blocked_hidden_count;
blocked_ids names readable posts only.

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

522 lines
22 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);
}
/** The live posts ($this->live; 1053 published, the others private) and draft 1070. */
private function usages(): GlobalUsages
{
return LibraryFixtures::usages(fn (): array => 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);
}
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)]],
]);
}
}