feat: lit/library-make-global replaces identical occurrences – dry run, confirm, revision per page
- library_id + targets (post_id, tree_rev, ids from lit/library-find-similar): only nodes with the entry's content fingerprint, pages and posts only - every target checked first (DiviWriter::page: edit_post + tree_rev, P8a; StoredNodeGuard of the page on the instance); the entry must be an active global entry with one readable root and no reference in any spelling (Q7) - dry run lists pages with tree_rev, status and live flag; confirm writes each page through DiviWriter::commit (revision, change log); live pages without revisions get VisibleEdits' backup, refused up front where not faithful - failure on page N: earlier pages stay replaced, N rolled back, later pages untouched; the error names changed_posts/pages, failed_post, unchanged_posts Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -7,7 +7,9 @@ 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\BlockTree\LibraryContent;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||
use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec;
|
||||
use LIT\MCP\Integrations\Divi\Library\LibraryMakeGlobalAbility;
|
||||
use LIT\MCP\Integrations\Divi\Library\NewLibraryEntry;
|
||||
@@ -15,6 +17,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||||
use LIT\MCP\Integrations\Divi\Pages\VisibleEdits;
|
||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||
@@ -40,6 +43,9 @@ final class LibraryMakeGlobalAbilityTest extends TestCase
|
||||
|
||||
private FakeChangeLog $log;
|
||||
|
||||
/** @var list<array{post_id: int, backup_id: int}> Backups VisibleEdits made. */
|
||||
private array $backups = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
@@ -85,7 +91,12 @@ final class LibraryMakeGlobalAbilityTest extends TestCase
|
||||
),
|
||||
new NewLibraryEntry($access, $saver, DiviWriterFixture::modules()),
|
||||
$this->log,
|
||||
static fn (): bool => $diviWritable
|
||||
static fn (): bool => $diviWritable,
|
||||
new VisibleEdits(function (WP_Post $post): int {
|
||||
$this->backups[] = ['post_id' => (int) $post->ID, 'backup_id' => 1071];
|
||||
|
||||
return 1071;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -258,4 +269,225 @@ final class LibraryMakeGlobalAbilityTest extends TestCase
|
||||
self::assertSame(2300, $e->details['library_id']);
|
||||
}
|
||||
}
|
||||
/** Pages 8 and 9: a plain copy of the global section 1058; 9 also a variation with another text. */
|
||||
private function givenCopiesOf1058(): string
|
||||
{
|
||||
$root = LibraryContent::roots(LibraryContent::load(
|
||||
new Parser(),
|
||||
DiviFixtures::markup('stage3/library-global--globaler-abschnitt.html')
|
||||
))[0];
|
||||
$copy = Serializer::block($root);
|
||||
$variation = str_replace('Global', 'Lokal', $copy);
|
||||
LibraryFixtures::given([
|
||||
$this->page,
|
||||
LibraryFixtures::item(1058),
|
||||
new WP_Post(['ID' => 8, 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => 'Team',
|
||||
'post_content' => '<!-- wp:divi/placeholder -->' . $copy . '<!-- /wp:divi/placeholder -->']),
|
||||
new WP_Post(['ID' => 9, 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => 'Jobs',
|
||||
'post_content' => '<!-- wp:divi/placeholder -->' . $variation . $copy . '<!-- /wp:divi/placeholder -->']),
|
||||
]);
|
||||
|
||||
return $copy;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private static function targets(array $ids8 = ['s0'], array $ids9 = ['s1'], bool $confirm = false): array
|
||||
{
|
||||
return array_filter([
|
||||
'library_id' => 1058,
|
||||
'targets' => [
|
||||
['post_id' => 8, 'tree_rev' => Rev::of((string) get_post(8)->post_content), 'ids' => $ids8],
|
||||
['post_id' => 9, 'tree_rev' => Rev::of((string) get_post(9)->post_content), 'ids' => $ids9],
|
||||
],
|
||||
'confirm' => $confirm ?: null,
|
||||
], static fn (mixed $value): bool => $value !== null);
|
||||
}
|
||||
|
||||
public function testReplacingOccurrencesIsADryRunFirstThenEveryPageWithItsRevision(): void
|
||||
{
|
||||
$this->givenCopiesOf1058();
|
||||
|
||||
$dry = $this->ability()->execute(self::targets());
|
||||
self::assertToolError($dry, 'confirm_required');
|
||||
self::assertSame(2, $dry->get_error_data()['affected_count']);
|
||||
self::assertSame([8, 9], array_column($dry->get_error_data()['affected'], 'post_id'));
|
||||
self::assertSame([], $this->saved);
|
||||
|
||||
$result = $this->ability()->execute(self::targets(['s0'], ['s1'], true));
|
||||
|
||||
self::assertTrue($result['ok']);
|
||||
self::assertSame([8, 9], array_column($result['pages'], 'post_id'));
|
||||
self::assertSame([811, 811], array_column($result['pages'], 'revision_id'));
|
||||
self::assertCount(2, $this->saved);
|
||||
self::assertStringContainsString('{"globalModule":"1058","blockName":"divi/section"', $this->saved[0]);
|
||||
self::assertStringContainsString('Lokal', $this->saved[1], 'the variation stays');
|
||||
self::assertSame([], $this->specs, 'no new entry');
|
||||
}
|
||||
|
||||
public function testTargetsMustBeIdenticalEditableAndCurrent(): void
|
||||
{
|
||||
$this->givenCopiesOf1058();
|
||||
|
||||
$variation = $this->ability()->execute(self::targets(['s0'], ['s0'], true));
|
||||
self::assertToolError($variation, 'validation_failed');
|
||||
self::assertArrayHasKey('targets.1.ids.0', self::errorFields($variation));
|
||||
|
||||
$stale = self::targets(['s0'], ['s1'], true);
|
||||
$stale['targets'][1]['tree_rev'] = 'alt';
|
||||
self::assertToolError($this->ability()->execute($stale), 'rev_mismatch');
|
||||
|
||||
$library = self::targets(['s0'], ['s1'], true);
|
||||
$library['targets'][1] = ['post_id' => 1055, 'tree_rev' => Rev::of((string) LibraryFixtures::item(1055)->post_content), 'ids' => ['s0']];
|
||||
LibraryFixtures::given([LibraryFixtures::item(1055), LibraryFixtures::item(1058), get_post(8), get_post(9)]);
|
||||
self::assertToolError($this->ability()->execute($library), 'validation_failed');
|
||||
|
||||
LibraryFixtures::given([LibraryFixtures::item(1058), get_post(8), get_post(9)]);
|
||||
Functions\when('current_user_can')->alias(static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 9));
|
||||
self::assertToolError($this->ability()->execute(self::targets(['s0'], ['s1'], true)), 'forbidden');
|
||||
|
||||
self::assertSame([], $this->saved, 'nothing was written in any case');
|
||||
}
|
||||
|
||||
public function testOnlyGlobalEntriesWithOneReadableRoot(): void
|
||||
{
|
||||
$this->givenCopiesOf1058();
|
||||
LibraryFixtures::given([LibraryFixtures::item(1055), get_post(8), get_post(9)]);
|
||||
|
||||
$normal = self::targets();
|
||||
$normal['library_id'] = 1055;
|
||||
self::assertToolError($this->ability()->execute($normal), 'validation_failed');
|
||||
}
|
||||
|
||||
public function testAFailureMidwayNamesThePagesAlreadyChanged(): void
|
||||
{
|
||||
$this->givenCopiesOf1058();
|
||||
|
||||
try {
|
||||
$this->ability([], true, false, [null, WriteFailed::rolledBack('Die Divi-Seite konnte nicht gespeichert werden.')])
|
||||
->execute(self::targets(['s0'], ['s1'], true));
|
||||
self::fail('no WriteFailed');
|
||||
} catch (WriteFailed $e) {
|
||||
self::assertSame([8], $e->details['changed_posts']);
|
||||
}
|
||||
}
|
||||
|
||||
/** The dry run lists every target with its tree_rev and whether visitors see it (P8a: drafts only listed). */
|
||||
public function testTheDryRunListsTreeRevAndLiveState(): void
|
||||
{
|
||||
$this->givenCopiesOf1058();
|
||||
|
||||
$data = $this->ability()->execute(self::targets())->get_error_data();
|
||||
|
||||
self::assertSame(Rev::of((string) get_post(8)->post_content), $data['affected'][0]['tree_rev']);
|
||||
self::assertTrue($data['affected'][0]['live']);
|
||||
self::assertFalse($data['affected'][1]['live']);
|
||||
self::assertSame(['s1'], $data['affected'][1]['ids']);
|
||||
self::assertSame(1, $data['live_count']);
|
||||
}
|
||||
|
||||
/** Ruling Q7: an entry that names another entry in any spelling never becomes an instance. */
|
||||
public function testRefusesAnEntryWithAReferenceInAnySpelling(): void
|
||||
{
|
||||
$copy = $this->givenCopiesOf1058();
|
||||
$quote = chr(92) . '"';
|
||||
$content = str_replace(
|
||||
'Globaler Abschnitt',
|
||||
'[et_pb_text global_module=' . $quote . '1059' . $quote . ']x[/et_pb_text]',
|
||||
DiviFixtures::markup('stage3/library-global--globaler-abschnitt.html')
|
||||
);
|
||||
LibraryFixtures::given([LibraryFixtures::item(1058, ['post_content' => $content]), get_post(8), get_post(9)]);
|
||||
|
||||
$result = $this->ability()->execute(self::targets(['s0'], ['s1'], true));
|
||||
|
||||
self::assertToolError($result, 'validation_failed');
|
||||
self::assertArrayHasKey('library_id', self::errorFields($result));
|
||||
self::assertSame([], $this->saved);
|
||||
self::assertNotSame('', $copy);
|
||||
}
|
||||
|
||||
public function testThemeBuilderLayoutsAreNoTargets(): void
|
||||
{
|
||||
$this->givenCopiesOf1058();
|
||||
$input = self::targets(['s0'], ['s1'], true);
|
||||
$body = ThemeBuilderFixtures::layout(ThemeBuilderFixtures::BODY);
|
||||
LibraryFixtures::given([LibraryFixtures::item(1058), get_post(8), $body]);
|
||||
Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path);
|
||||
$loader = new DiviPageLoader(
|
||||
DiviFixtures::schema(),
|
||||
new Parser(),
|
||||
LibraryFixtures::access(),
|
||||
ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([ThemeBuilderFixtures::template()]))
|
||||
);
|
||||
$input['targets'][1] = ['post_id' => 1062, 'tree_rev' => Rev::of((string) $body->post_content), 'ids' => ['s0']];
|
||||
|
||||
$result = $this->ability([], true, false, [], $loader)->execute($input);
|
||||
|
||||
self::assertToolError($result, 'validation_failed');
|
||||
self::assertArrayHasKey('targets.1.post_id', self::errorFields($result));
|
||||
self::assertSame([], $this->saved);
|
||||
}
|
||||
|
||||
/** Replacing needs lit-library-insert's rights (not lit/library-save's) and a writable library group. */
|
||||
public function testReplacingNeedsInsertRightsOnly(): void
|
||||
{
|
||||
$this->givenCopiesOf1058();
|
||||
|
||||
self::assertToolError($this->ability(['add_library'])->execute(self::targets([], [], true)), 'forbidden');
|
||||
self::assertToolError($this->ability([], false)->execute(self::targets()), 'unsupported');
|
||||
self::assertToolError($this->ability(['edit_global_library'])->execute(self::targets()), 'confirm_required');
|
||||
self::assertSame([], $this->saved);
|
||||
}
|
||||
|
||||
/** Live pages without revisions get VisibleEdits' backup – refused before any page is written where it would lose bytes. */
|
||||
public function testLivePagesWithoutRevisionsGetABackupOrNothingIsWritten(): void
|
||||
{
|
||||
$this->givenCopiesOf1058();
|
||||
Functions\when('wp_revisions_enabled')->justReturn(false);
|
||||
|
||||
$result = $this->ability()->execute(self::targets(['s0'], ['s1'], true));
|
||||
self::assertSame([['post_id' => 8, 'backup_id' => 1071]], $this->backups);
|
||||
self::assertSame(1071, $result['pages'][0]['backup_id']);
|
||||
self::assertArrayNotHasKey('backup_id', $result['pages'][1], 'drafts are only listed (P8a)');
|
||||
|
||||
$this->backups = [];
|
||||
$this->saved = [];
|
||||
$this->givenCopiesOf1058();
|
||||
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'unfiltered_html');
|
||||
self::assertToolError($this->ability()->execute(self::targets(['s0'], ['s1'], true)), 'unsupported');
|
||||
self::assertSame([], $this->backups);
|
||||
self::assertSame([], $this->saved);
|
||||
}
|
||||
|
||||
/** A failure on page N: the pages before stay replaced (each with revision), the rest untouched – all named. */
|
||||
public function testAFailureMidwayReportsEveryPage(): void
|
||||
{
|
||||
$this->givenCopiesOf1058();
|
||||
|
||||
try {
|
||||
$this->ability([], true, false, [null, WriteFailed::rolledBack('Die Divi-Seite konnte nicht gespeichert werden.')])
|
||||
->execute(self::targets(['s0'], ['s1'], true));
|
||||
self::fail('no WriteFailed');
|
||||
} catch (WriteFailed $e) {
|
||||
self::assertSame(9, $e->details['failed_post']);
|
||||
self::assertSame([], $e->details['unchanged_posts']);
|
||||
self::assertSame(811, $e->details['pages'][0]['revision_id']);
|
||||
self::assertStringNotContainsString('Nichts wurde gespeichert', (string) $e->toError()->get_error_message());
|
||||
}
|
||||
self::assertCount(1, $this->saved);
|
||||
}
|
||||
|
||||
public function testAFailureOnTheFirstPageLeavesEverythingUnchanged(): void
|
||||
{
|
||||
$this->givenCopiesOf1058();
|
||||
|
||||
try {
|
||||
$this->ability([], true, false, [WriteFailed::rolledBack('Die Divi-Seite konnte nicht gespeichert werden.')])
|
||||
->execute(self::targets(['s0'], ['s1'], true));
|
||||
self::fail('no WriteFailed');
|
||||
} catch (WriteFailed $e) {
|
||||
self::assertSame([], $e->details['changed_posts']);
|
||||
self::assertSame([9], $e->details['unchanged_posts']);
|
||||
}
|
||||
self::assertSame([], $this->saved);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user