Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Design/PageRewriterTest.php
T

562 lines
24 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\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']);
}
}