feat: Seiten-Umschreiber für site-weite Preset-Änderungen (Probelauf, höchstens 50 Seiten, Revision und Protokoll je Seite)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 16:59:39 +02:00
co-authored by Claude Opus 5.5
parent d36e8286e8
commit d7a991d130
3 changed files with 1104 additions and 0 deletions
@@ -0,0 +1,561 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use Closure;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
use LIT\MCP\Integrations\Divi\Design\NodeRewrite;
use LIT\MCP\Integrations\Divi\Design\PageRewriter;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Library\UsageQuery;
use LIT\MCP\Integrations\Divi\Library\UsageReport;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use Throwable;
use WP_Post;
/** Spec §6.6, rulings R1, R8, R9: the page rewriter behind lit/divi-presets-assign and lit/divi-design-detach. */
final class PageRewriterTest extends TestCase
{
/** The overlap note the test strategy adds to every block it changes. */
public const NOTE = 'lokale Werte überdecken „CTA“';
private const ABILITY = 'lit/divi-presets-assign';
private const SUMMARY = 'Preset „CTA“ zugewiesen';
private const BUTTON = '<!-- wp:divi/button {"builderVersion":"5.2.1"} /-->';
private const TEXT = '<!-- wp:divi/text {"builderVersion":"5.2.1"} /-->';
private const INSTANCE = '<!-- wp:divi/global-layout {"globalModule":"1059","blockName":"divi/button"} /-->';
/** @var list<array{int, SaveScope}> Post ID and scope of every save. */
private array $saved = [];
/** @var list<Throwable|null> Thrown by the n-th save instead of saving (null = save). */
private array $failures = [];
private FakeChangeLog $log;
protected function setUp(): void
{
parent::setUp();
Functions\when('_get_non_cached_ids')->returnArg(1);
Functions\when('_prime_post_caches')->justReturn(null);
Functions\when('wp_cache_delete')->justReturn(true);
Functions\when('wp_revisions_enabled')->justReturn(true);
$this->log = new FakeChangeLog(1);
}
/** One section, row and column holding $modules (IDs s0.r0.c0.m0, …). */
private static function layout(string ...$modules): string
{
return '<!-- wp:divi/placeholder --><!-- wp:divi/section {"builderVersion":"5.2.1"} -->'
. '<!-- wp:divi/row {"builderVersion":"5.2.1"} --><!-- wp:divi/column {"builderVersion":"5.2.1"} -->'
. implode('', $modules)
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->';
}
/** @param array<string, mixed> $fields */
private static function page(int $id, string $content, array $fields = []): WP_Post
{
return new WP_Post(array_merge([
'ID' => $id,
'post_type' => 'page',
'post_status' => 'draft',
'post_title' => 'Seite ' . $id,
'post_content' => $content,
], $fields));
}
/** @param list<int> $blocked */
private static function report(
int $count,
array $blocked = [],
int $live = 0,
bool $tooMany = false,
int $hidden = 0
): UsageReport {
return new UsageReport($count, [], $hidden, $live, $blocked, 0, $tooMany);
}
/**
* The rewriter over $posts: they are the candidates (ID order) and what get_post() finds; the caller has every
* capability. $report answers reportFor() (default: all posts, nothing blocked), $libraryReports report($id).
*
* @param list<WP_Post> $posts
* @param array<int, UsageReport> $libraryReports
*/
private function rewriter(array $posts, ?UsageReport $report = null, array $libraryReports = []): PageRewriter
{
LibraryFixtures::given($posts);
$rows = array_map(
static fn (WP_Post $post): array => [
'id' => (int) $post->ID,
'status' => (string) $post->post_status,
'type' => (string) $post->post_type,
],
$posts
);
$usages = Mockery::mock(GlobalUsages::class);
$usages->allows('rowsFor')->andReturn($rows);
$usages->allows('reportFor')->andReturn($report ?? self::report(count($rows)));
$usages->allows('report')->andReturnUsing(
static fn (int $id): UsageReport => $libraryReports[$id] ?? self::report(0)
);
return new PageRewriter($usages, LibraryFixtures::loader(), $this->saver(), $this->log);
}
/** Records post ID and scope, puts the content on the post and reports revision 900 + n. */
private function saver(): DiviSaver
{
$saver = Mockery::mock(DiviSaver::class);
$saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content, SaveScope $scope): array {
$failure = array_shift($this->failures);
if ($failure !== null) {
throw $failure;
}
$this->saved[] = [(int) $post->ID, $scope];
$post->post_content = $content;
return ['revision_id' => 900 + count($this->saved), 'warnings' => []];
});
return $saver;
}
/**
* A strategy like AssignRewrite: every block of $modules gets the preset stack ["p-cta"] (a block that has it
* stays), each changed block an overlap note; global instances of these modules concern the change.
*
* @param list<string> $modules
* @return Closure(): NodeRewrite
*/
private static function assign(array $modules = ['divi/button']): Closure
{
return static fn (): NodeRewrite => new class ($modules) implements NodeRewrite {
/** @param list<string> $modules */
public function __construct(private array $modules)
{
}
public function rewrite(Block $block): ?array
{
$attrs = $block->attrs();
if (! in_array($block->name(), $this->modules, true) || self::assigned($attrs)) {
return null;
}
return ['modulePreset' => ['p-cta']] + $attrs;
}
public function notes(Block $block, string $nodeId): array
{
return self::assigned($block->attrs()) ? [$nodeId . ': ' . PageRewriterTest::NOTE] : [];
}
public function concernsInstance(Block $instance): bool
{
return in_array(GlobalInstance::effectiveName($instance), $this->modules, true);
}
/** @param array<string, mixed> $attrs */
private static function assigned(array $attrs): bool
{
return ($attrs['modulePreset'] ?? null) === ['p-cta'];
}
};
}
private static function query(): UsageQuery
{
return Mockery::mock(UsageQuery::class);
}
/** Runs $call with error_log() going to a temp file (Log::failure() writes there). */
private static function quietly(Closure $call): mixed
{
$file = (string) tempnam(sys_get_temp_dir(), 'lit-log');
$previous = ini_set('error_log', $file);
try {
return $call();
} finally {
ini_set('error_log', (string) $previous);
unlink($file);
}
}
public function testPreviewListsPagesAndNodesWithoutWriting(): void
{
$rewriter = $this->rewriter(
[
self::page(101, self::layout(self::BUTTON, self::TEXT, self::BUTTON)),
self::page(102, self::layout(self::BUTTON), ['post_status' => 'publish']),
],
self::report(2, [], 1, false, 3)
);
$preview = $rewriter->preview(self::query(), self::assign(), null);
self::assertSame(2, $preview['pages_count']);
self::assertSame(3, $preview['nodes_count']);
self::assertSame([101, 102], array_column($preview['pages'], 'post_id'));
self::assertSame('Seite 101', $preview['pages'][0]['title']);
self::assertSame('page', $preview['pages'][0]['type']);
self::assertSame('draft', $preview['pages'][0]['status']);
self::assertSame('publish', $preview['pages'][1]['status']);
self::assertSame(['s0.r0.c0.m0', 's0.r0.c0.m2'], array_column($preview['pages'][0]['nodes'], 'id'));
self::assertSame(
['s0.r0.c0.m0: ' . self::NOTE, 's0.r0.c0.m2: ' . self::NOTE],
$preview['pages'][0]['warnings']
);
self::assertSame([], $preview['skipped']);
self::assertSame([], $preview['blocked_ids']);
self::assertSame(0, $preview['blocked_hidden_count']);
self::assertSame(3, $preview['hidden_count']);
self::assertSame([], $this->saved, 'the dry run writes nothing');
self::assertSame([], $this->log->records);
}
/** Ruling P8a like GlobalEdits: one blocked live usage stops the whole write; post_ids limit the check. */
public function testBlockedLivePageStopsTheWholeWrite(): void
{
$rewriter = $this->rewriter(
[
self::page(101, self::layout(self::BUTTON), ['post_status' => 'publish']),
self::page(102, self::layout(self::BUTTON), ['post_status' => 'publish']),
],
self::report(2, [102], 2)
);
$preview = $rewriter->preview(self::query(), self::assign(), null);
self::assertSame([102], $preview['blocked_ids']);
self::assertSame(2, $preview['pages_count']);
$error = self::assertToolError(
$rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY),
'forbidden'
);
self::assertSame([102], $error['blocked_ids']);
self::assertStringContainsString('IDs 102', $error['message']);
self::assertStringContainsString('nichts wurde gespeichert', $error['message']);
self::assertSame([], $this->saved);
self::assertSame([], $this->log->records);
$limited = $rewriter->apply(self::ABILITY, self::query(), self::assign(), [101], self::SUMMARY);
self::assertSame([101], array_column($limited['saved'], 'post_id'));
self::assertSame([[101, SaveScope::Page]], $this->saved);
}
public function testTooManyLiveUsagesAreUnsupported(): void
{
$rewriter = $this->rewriter(
[self::page(101, self::layout(self::BUTTON), ['post_status' => 'publish'])],
self::report(GlobalUsages::HARD_CAP + 1, [], GlobalUsages::HARD_CAP + 1, true)
);
$error = self::assertToolError($rewriter->preview(self::query(), self::assign(), null), 'unsupported');
self::assertStringContainsString(sprintf('mehr als %d', GlobalUsages::HARD_CAP), $error['message']);
self::assertToolError(
$rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY),
'unsupported'
);
self::assertSame([], $this->saved);
}
/** A6: 51 pages – 50 saved, remaining 1; the next call saves the last one, remaining 0. */
public function testWritesAtMostFiftyPagesAndTheNextCallTheRest(): void
{
$posts = array_map(
static fn (int $id): WP_Post => self::page($id, self::layout(self::BUTTON)),
range(101, 151)
);
$rewriter = $this->rewriter($posts);
$preview = $rewriter->preview(self::query(), self::assign(), null);
self::assertSame(51, $preview['pages_count']);
self::assertCount(PageRewriter::PAGE_LIMIT, $preview['pages']);
$first = $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY);
self::assertCount(PageRewriter::PAGE_LIMIT, $first['saved']);
self::assertSame(['post_id' => 101, 'revision_id' => 901, 'nodes' => 1], $first['saved'][0]);
self::assertSame(1, $first['remaining']);
self::assertSame('Eintrag 101: s0.r0.c0.m0: ' . self::NOTE, $first['warnings'][0]);
self::assertSame([], $first['skipped']);
self::assertSame(range(101, 150), array_column($this->saved, 0));
self::assertCount(PageRewriter::PAGE_LIMIT, $this->log->records);
self::assertSame(
[
'ability' => self::ABILITY,
'object_type' => 'page',
'object_id' => '101',
'summary' => 'Divi "Seite 101": ' . self::SUMMARY,
'revision_id' => 901,
'snapshot_id' => null,
'result' => 'ok',
],
$this->log->records[0]
);
self::assertStringContainsString('"modulePreset":["p-cta"]', (string) $posts[0]->post_content);
$second = $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY);
self::assertSame([['post_id' => 151, 'revision_id' => 951, 'nodes' => 1]], $second['saved']);
self::assertSame(0, $second['remaining']);
self::assertSame([], $second['skipped']);
}
public function testPageWithoutChangeIsNotSavedOrCounted(): void
{
$rewriter = $this->rewriter([
self::page(101, self::layout(self::TEXT)),
self::page(102, self::layout(self::BUTTON)),
]);
$preview = $rewriter->preview(self::query(), self::assign(), null);
$result = $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY);
self::assertSame(1, $preview['pages_count']);
self::assertSame([102], array_column($result['saved'], 'post_id'));
self::assertSame(0, $result['remaining']);
self::assertSame([], $result['skipped']);
self::assertSame(['102'], array_column($this->log->records, 'object_id'));
}
/** R8: pages loadWritable() refuses (Divi 4, unparsable – fail-closed candidates) are skipped, not remaining. */
public function testRefusedPagesAreSkippedWithTheLoaderMessage(): void
{
$rewriter = $this->rewriter([
self::page(
101,
'[et_pb_section][et_pb_text]Alt[/et_pb_text][/et_pb_section]',
['post_status' => 'publish']
),
self::page(102, '<!-- wp:divi/section --><!-- wp:divi/row -->'),
self::page(103, self::layout(self::BUTTON)),
]);
$result = self::quietly(
fn (): array => $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY)
);
self::assertSame([103], array_column($result['saved'], 'post_id'));
self::assertSame(0, $result['remaining']);
self::assertSame([101, 102], array_column($result['skipped'], 'post_id'));
self::assertStringContainsString('Divi-4-Inhalt', $result['skipped'][0]['reason']);
self::assertStringContainsString('kein gültiges Block-Markup', $result['skipped'][1]['reason']);
}
/** Spec §6.6: a failed page keeps the pages saved before – partial_write with their revisions. */
public function testFailedPageIsPartial(): void
{
$rewriter = $this->rewriter([
self::page(101, self::layout(self::BUTTON)),
self::page(102, self::layout(self::BUTTON)),
self::page(103, self::layout(self::BUTTON)),
]);
$this->failures = [null, null, WriteFailed::rolledBack(DiviSaver::REASON, postId: 103)];
$result = self::quietly(
fn () => $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY)
);
$error = self::assertToolError($result, 'partial_write');
self::assertSame(
[
['post_id' => 101, 'revision_id' => 901, 'nodes' => 1],
['post_id' => 102, 'revision_id' => 902, 'nodes' => 1],
],
$error['saved']
);
self::assertSame(103, $error['post_id']);
self::assertStringContainsString('Eintrag 103: ' . DiviSaver::REASON, $error['message']);
self::assertStringContainsString('2 Einträge davor sind gespeichert', $error['message']);
self::assertStringContainsString('Eintrag 103 blieb unverändert.', $error['message']);
$this->log->assertRecorded(
['object_id' => '101', 'revision_id' => 901],
['object_id' => '102', 'revision_id' => 902]
);
}
/** Like DiviWriter: a page the saver could not roll back is logged as partial. */
public function testPageThatWasNotRolledBackIsLoggedAsPartial(): void
{
$this->log = FakeChangeLog::any();
$rewriter = $this->rewriter([
self::page(101, self::layout(self::BUTTON)),
self::page(102, self::layout(self::BUTTON)),
]);
$this->failures = [null, WriteFailed::partial(DiviSaver::REASON, postId: 102, revisionId: 812)];
$result = self::quietly(
fn () => $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY)
);
$error = self::assertToolError($result, 'partial_write');
self::assertSame(812, $error['revision_id']);
self::assertStringContainsString('1 Eintrag davor ist gespeichert', $error['message']);
self::assertStringContainsString(
'Eintrag 102 ist möglicherweise teilweise geändert (Stand vorher: Revision 812).',
$error['message']
);
$this->log->assertRecorded(['object_id' => '101']);
$this->log->assertPartials([
'object_id' => '102',
'summary' => 'Divi "Seite 102": Änderung nur teilweise zurückgenommen',
'revision_id' => 812,
]);
}
/** Spec §5.5: an unreleased Divi before the first page is the release refusal (AbilityRegistrar). */
public function testLockedDiviBeforeTheFirstPageIsThrown(): void
{
$rewriter = $this->rewriter([self::page(101, self::layout(self::BUTTON))]);
$this->failures = [new DiviLocked(new ReleaseStatus(ReleaseState::Untested, '5.16.0'))];
$this->expectException(DiviLocked::class);
$rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY);
}
/** R8: a global instance is never rewritten; it is skipped with its node ID, the rest of the page is saved. */
public function testGlobalInstanceIsSkippedAndKeptAsItIs(): void
{
$post = self::page(101, self::layout(self::INSTANCE, self::BUTTON));
$rewriter = $this->rewriter([$post]);
$preview = $rewriter->preview(self::query(), self::assign(), null);
self::assertSame(['s0.r0.c0.m0'], array_column($preview['pages'][0]['nodes'], 'id'));
self::assertCount(1, $preview['skipped']);
self::assertSame(101, $preview['skipped'][0]['post_id']);
self::assertStringStartsWith('s0.r0.c0.g0: ', $preview['skipped'][0]['reason']);
$result = $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY);
self::assertSame([101], array_column($result['saved'], 'post_id'));
self::assertStringContainsString(self::INSTANCE, (string) $post->post_content);
self::assertStringContainsString('"modulePreset":["p-cta"]', (string) $post->post_content);
}
/** R8: library entries are usages of their own, saved with DiviWriter's scope. */
public function testLibraryEntriesAreSavedWithTheirScope(): void
{
$rewriter = $this->rewriter([LibraryFixtures::item(1057), LibraryFixtures::item(1059)]);
$result = $rewriter->apply(
self::ABILITY,
self::query(),
self::assign(['divi/blurb', 'divi/text']),
null,
self::SUMMARY
);
self::assertSame([1057, 1059], array_column($result['saved'], 'post_id'));
self::assertSame([[1057, SaveScope::LibraryItem], [1059, SaveScope::GlobalItem]], $this->saved);
$this->log->assertRecorded(
['object_type' => 'et_pb_layout', 'object_id' => '1057'],
['object_type' => 'et_pb_layout', 'object_id' => '1059']
);
}
/** Audit L2: a normal entry that pages show as instance is saved site-wide as well. */
public function testReferencedLibraryEntryIsSavedSiteWide(): void
{
$rewriter = $this->rewriter([LibraryFixtures::item(1057)], null, [1057 => self::report(1)]);
$rewriter->apply(self::ABILITY, self::query(), self::assign(['divi/blurb']), null, self::SUMMARY);
self::assertSame([[1057, SaveScope::GlobalItem]], $this->saved);
}
/** P8a for the pages that show an entry: blocked → the entry is skipped, the others are saved. */
public function testLibraryEntryShownOnBlockedPagesIsSkipped(): void
{
$rewriter = $this->rewriter(
[LibraryFixtures::item(1059), self::page(101, self::layout(self::TEXT))],
null,
[1059 => self::report(1, [7], 1)]
);
$result = $rewriter->apply(self::ABILITY, self::query(), self::assign(['divi/text']), null, self::SUMMARY);
self::assertSame([[101, SaveScope::Page]], $this->saved);
self::assertSame([1059], array_column($result['skipped'], 'post_id'));
self::assertStringContainsString('Bibliothekseintrag 1059', $result['skipped'][0]['reason']);
self::assertStringContainsString('IDs 7', $result['skipped'][0]['reason']);
}
/** No backup copies in a bulk write: a site-wide entry without revisions is skipped. */
public function testSiteWideEntryWithoutRevisionsIsSkipped(): void
{
Functions\when('wp_revisions_enabled')->justReturn(false);
$rewriter = $this->rewriter([LibraryFixtures::item(1057), LibraryFixtures::item(1059)]);
$result = $rewriter->apply(
self::ABILITY,
self::query(),
self::assign(['divi/blurb', 'divi/text']),
null,
self::SUMMARY
);
self::assertSame([[1057, SaveScope::LibraryItem]], $this->saved);
self::assertSame([1059], array_column($result['skipped'], 'post_id'));
self::assertStringContainsString(
'Revisionen sind für Bibliothekseintrag 1059 abgeschaltet',
$result['skipped'][0]['reason']
);
}
public function testPostIdsLimitTheCandidatesAndNameUnchangedIds(): void
{
$rewriter = $this->rewriter([
self::page(101, self::layout(self::BUTTON)),
self::page(102, self::layout(self::BUTTON)),
]);
$preview = $rewriter->preview(self::query(), self::assign(), [102, 999]);
self::assertSame([102], array_column($preview['pages'], 'post_id'));
self::assertSame([['post_id' => 999, 'reason' => PageRewriter::NO_CHANGE]], $preview['skipped']);
self::assertSame(0, $preview['hidden_count']);
}
/** Audit L4: candidates the caller may not read are neither loaded nor named, only counted. */
public function testUnreadableCandidatesAreOnlyCounted(): void
{
$rewriter = $this->rewriter(
[self::page(101, self::layout(self::BUTTON)), self::page(102, self::layout(self::BUTTON))],
self::report(2, [], 0, false, 1)
);
Functions\when('current_user_can')->alias(
static fn (string $cap, mixed ...$args): bool => ! ($cap === 'read_post' && ($args[0] ?? null) === 102)
);
$preview = $rewriter->preview(self::query(), self::assign(), null);
$result = $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY);
self::assertSame([101], array_column($preview['pages'], 'post_id'));
self::assertSame([], $preview['skipped']);
self::assertSame(1, $preview['hidden_count']);
self::assertSame([[101, SaveScope::Page]], $this->saved);
self::assertSame([], $result['skipped']);
}
}