A failed save of a library entry or Theme Builder layout now carries restore_url when a revision exists; a partial write's hint points to the WordPress revision screen and lit-divi-outline instead of lit/revisions-list and lit/revision-restore, which answer not found for these types. Pages keep the default hint. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
619 lines
27 KiB
PHP
619 lines
27 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Errors;
|
||
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\Integrations\Divi\Pages\VisibleEditsTest;
|
||
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
|
||
{
|
||
private const BACKUP_HINT = 'Revisionen für die Divi-Bibliothek aktivieren (WP_POST_REVISIONS) oder den Eintrag im '
|
||
. 'Divi-Builder bearbeiten.';
|
||
|
||
/** @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']);
|
||
// Audit L3: lit/revision-restore does not take library entries – the WordPress revision screen does.
|
||
self::assertSame('https://kunde.test/wp-admin/revision.php?revision=811', $result['restore_url']);
|
||
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::assertSame(
|
||
sprintf(VisibleEditsTest::UNFAITHFUL, 'Bibliothekseintrag 1059'),
|
||
Errors::clientMessage($result)
|
||
);
|
||
self::assertSame(self::BACKUP_HINT, $result->get_error_data()['hint']);
|
||
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::assertSame(sprintf(VisibleEditsTest::NO_TRASH, 'Bibliothekseintrag 1059'), Errors::clientMessage($result));
|
||
self::assertSame(self::BACKUP_HINT, $result->get_error_data()['hint']);
|
||
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('1 Eintrag, den dieses Konto 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::assertSame(
|
||
VisibleEditsTest::keptRolledBack(1071, 'des Bibliothekseintrags'),
|
||
$error->get_error_data()['hint']
|
||
);
|
||
self::assertStringContainsString(Errors::notSavedKeeping('Sicherung 1071 im Papierkorb'), Errors::clientMessage($error));
|
||
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::assertSame(VisibleEditsTest::keptPartial(1071, 'des Bibliothekseintrags'), $error->get_error_data()['hint']);
|
||
self::assertCount(1, $this->log->partials);
|
||
}
|
||
|
||
/**
|
||
* Audit L3 on the failure path (re-review): lit/revision-restore answers not found for library entries, so a
|
||
* failed save names the WordPress revision screen instead.
|
||
*/
|
||
public function testAFailedSaveNamesTheRevisionScreenNotRevisionRestore(): void
|
||
{
|
||
LibraryFixtures::given([LibraryFixtures::item(1059)]);
|
||
$this->log = FakeChangeLog::partialsOnly();
|
||
$cause = new \RuntimeException('hook');
|
||
foreach (
|
||
[
|
||
WriteFailed::partial(DiviSaver::REASON, $cause, postId: 1059, revisionId: 811),
|
||
WriteFailed::rolledBack(DiviSaver::REASON, $cause, postId: 1059, revisionId: 811),
|
||
] as $failure
|
||
) {
|
||
$this->failure = $failure;
|
||
try {
|
||
(new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
|
||
self::fail('WriteFailed expected');
|
||
} catch (WriteFailed $e) {
|
||
$error = $e->toError();
|
||
}
|
||
|
||
$data = $error->get_error_data();
|
||
self::assertSame('https://kunde.test/wp-admin/revision.php?revision=811', $data['restore_url']);
|
||
self::assertStringNotContainsString('lit/revision-restore', $data['hint']);
|
||
self::assertStringNotContainsString('lit/revisions-list', $data['hint']);
|
||
if (! $failure->rolledBack) {
|
||
self::assertStringContainsString('restore_url', $data['hint']);
|
||
self::assertStringContainsString('Revision 811', $data['hint']);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 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]);
|
||
self::assertSame('https://kunde.test/wp-admin/revision.php?revision=811', $result['results'][0]['restore_url']);
|
||
}
|
||
|
||
/** 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)]],
|
||
]);
|
||
}
|
||
}
|