Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php
T

476 lines
20 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);
}
private function usages(): GlobalUsages
{
$usages = Mockery::mock(GlobalUsages::class);
$usages->allows('of')->andReturn([
'items' => [
['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'publish', 'type' => 'page'],
['post_id' => 1070, 'title' => '(nicht lesbar)', 'status' => 'draft', 'type' => 'page'],
],
'count' => 2,
]);
$usages->allows('liveIds')->andReturnUsing(fn (): array => $this->live);
return $usages;
}
/** @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());
}
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 = Mockery::mock(GlobalUsages::class);
$usages->allows('of')->andReturn([
'items' => [['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'publish', 'type' => 'page']],
'count' => 1,
]);
$usages->allows('liveIds')->andReturn([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)]],
]);
}
}