feat: Divi tools write library entries; global ones with dry run, confirm and backup (E2, F2)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
<?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\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\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;
|
||||
|
||||
/** @var list<int> Published pages that show the global entry (ruling P8). */
|
||||
private array $published = [1053];
|
||||
|
||||
private bool $trash = true;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Functions\when('wp_revisions_enabled')->justReturn(true);
|
||||
}
|
||||
|
||||
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('publishedIds')->andReturnUsing(fn (): array => $this->published);
|
||||
|
||||
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 {
|
||||
$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,
|
||||
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->published = [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 stays unchanged');
|
||||
self::assertToolError($result, 'forbidden');
|
||||
self::assertStringContainsString('1080', $result->get_error_message());
|
||||
self::assertStringNotContainsString('1053', $result->get_error_message());
|
||||
self::assertSame([], $this->saved);
|
||||
self::assertSame(0, $this->backups);
|
||||
}
|
||||
|
||||
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)]],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
|
||||
final class GlobalUsagesTest extends TestCase
|
||||
{
|
||||
public function testFindsInstancesLikeDiviAndHidesUnreadableTitles(): void
|
||||
{
|
||||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||||
$queries = [];
|
||||
$db->allows('get_var')->andReturnUsing(static function (string $sql) use (&$queries): string {
|
||||
$queries[] = $sql;
|
||||
|
||||
return '3';
|
||||
});
|
||||
$db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$queries): array {
|
||||
$queries[] = $sql;
|
||||
|
||||
return [
|
||||
['ID' => '1053', 'post_title' => 'Globale Instanzen', 'post_status' => 'draft', 'post_type' => 'page'],
|
||||
['ID' => '1061', 'post_title' => 'Geheim', 'post_status' => 'private', 'post_type' => 'et_header_layout'],
|
||||
];
|
||||
});
|
||||
Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id !== 1061);
|
||||
|
||||
$usages = (new GlobalUsages($db))->of(1058);
|
||||
|
||||
self::assertSame(3, $usages['count']);
|
||||
self::assertSame(
|
||||
[
|
||||
['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'draft', 'type' => 'page'],
|
||||
['post_id' => 1061, 'title' => '(nicht lesbar)', 'status' => 'private', 'type' => 'et_header_layout'],
|
||||
],
|
||||
$usages['items']
|
||||
);
|
||||
self::assertStringContainsString("LIKE '%\"globalModule\":\"1058\"%'", $queries[0]);
|
||||
self::assertStringContainsString("post_status NOT IN ('trash', 'auto-draft', 'inherit')", $queries[1]);
|
||||
self::assertStringContainsString('LIMIT 50', $queries[1]);
|
||||
}
|
||||
|
||||
/** Ruling P8: every published page showing the entry, beyond the listing limit. */
|
||||
public function testPublishedIdsAreUnlimited(): void
|
||||
{
|
||||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||||
$queries = [];
|
||||
$db->allows('get_col')->andReturnUsing(static function (string $sql) use (&$queries): array {
|
||||
$queries[] = $sql;
|
||||
|
||||
return ['1053', '1080'];
|
||||
});
|
||||
|
||||
self::assertSame([1053, 1080], (new GlobalUsages($db))->publishedIds(1059));
|
||||
self::assertStringContainsString("LIKE '%\"globalModule\":\"1059\"%'", $queries[0]);
|
||||
self::assertStringContainsString("post_status = 'publish'", $queries[0]);
|
||||
self::assertStringContainsString("post_type <> 'revision'", $queries[0]);
|
||||
self::assertStringNotContainsString('LIMIT', $queries[0]);
|
||||
}
|
||||
}
|
||||
@@ -113,4 +113,33 @@ final class LibraryAccessTest extends TestCase
|
||||
self::assertSame($missing->get_error_data(), $refusal->get_error_data());
|
||||
}
|
||||
}
|
||||
|
||||
public function testEditingNeedsSaveLibraryAPublishedItemAWritableGroupAndForGlobalsEditGlobalLibrary(): void
|
||||
{
|
||||
LibraryFixtures::given([
|
||||
LibraryFixtures::item(1057),
|
||||
LibraryFixtures::item(1059),
|
||||
LibraryFixtures::item(1055, ['post_status' => 'trash']),
|
||||
]);
|
||||
|
||||
self::assertInstanceOf(WP_Post::class, LibraryFixtures::access()->item(1057, true));
|
||||
self::assertToolError(LibraryFixtures::access(['save_library'])->item(1057, true), 'forbidden');
|
||||
$readonly = LibraryFixtures::access([], false)->item(1057, true);
|
||||
self::assertToolError($readonly, 'forbidden');
|
||||
self::assertStringContainsString('Divi-Bibliothek', $readonly->get_error_message());
|
||||
self::assertToolError(LibraryFixtures::access()->item(1055, true), 'validation_failed');
|
||||
self::assertInstanceOf(WP_Post::class, LibraryFixtures::access()->item(1059, true));
|
||||
self::assertToolError(LibraryFixtures::access(['edit_global_library'])->item(1059, true), 'forbidden');
|
||||
self::assertInstanceOf(WP_Post::class, LibraryFixtures::access(['edit_global_library'])->item(1057, true));
|
||||
}
|
||||
|
||||
public function testEditingNeedsEditPostOnTheEntryAndReadOnlyStillReads(): void
|
||||
{
|
||||
LibraryFixtures::given([LibraryFixtures::item(1057)], ['edit_post']);
|
||||
self::assertToolError(LibraryFixtures::access()->item(1057, true), 'forbidden');
|
||||
|
||||
LibraryFixtures::given([LibraryFixtures::item(1057)]);
|
||||
self::assertInstanceOf(WP_Post::class, LibraryFixtures::access([], false)->item(1057, false));
|
||||
self::assertNull(LibraryFixtures::access()->writableGate());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user