Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
710 lines
31 KiB
PHP
710 lines
31 KiB
PHP
<?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\BlockTree\Json;
|
||
use LIT\MCP\Integrations\Divi\Design\VariableDetachRewrite;
|
||
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\DiviPageLoader;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
|
||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||
use LIT\MCP\Tests\Support\ReleaseMatrices;
|
||
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);
|
||
* $loader defaults to one whose user may see library IDs.
|
||
*
|
||
* @param list<WP_Post> $posts
|
||
* @param array<int, UsageReport> $libraryReports
|
||
*/
|
||
private function rewriter(
|
||
array $posts,
|
||
?UsageReport $report = null,
|
||
array $libraryReports = [],
|
||
?DiviPageLoader $loader = null
|
||
): 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, $loader ?? 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);
|
||
}
|
||
|
||
/** A named live post the caller may not edit blocks the whole call (forbidden), also with post_ids. */
|
||
public function testNamedBlockedLivePageRefusesTheCallEvenWithPostIds(): 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(), [101, 102]);
|
||
self::assertSame([102], $preview['blocked_ids']);
|
||
|
||
$error = self::assertToolError(
|
||
$rewriter->apply(self::ABILITY, self::query(), self::assign(), [101, 102], self::SUMMARY),
|
||
'forbidden'
|
||
);
|
||
self::assertSame([102], $error['blocked_ids']);
|
||
self::assertSame([], $this->saved);
|
||
self::assertSame([], $this->log->records);
|
||
}
|
||
|
||
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'));
|
||
}
|
||
|
||
/** V9: a page that only mentions the variable inside a longer text is not saved, but reported as skipped. */
|
||
public function testVariableReferencesLeftBehindAreSkippedEvenOnAPageThatIsNotSaved(): void
|
||
{
|
||
$embedded = '<!-- wp:divi/text ' . Json::encode(['content' => ['innerContent' => ['desktop' => ['value' => 'Ab '
|
||
. VariableDetachRewriteTest::ref('gvid-radius0001') . ' Rundung']]]]) . ' /-->';
|
||
$whole = '<!-- wp:divi/text ' . Json::encode(['content' => ['innerContent' => ['desktop' => ['value'
|
||
=> VariableDetachRewriteTest::ref('gvid-radius0001')]]]]) . ' /-->';
|
||
$rewriter = $this->rewriter([
|
||
self::page(101, self::layout($embedded)),
|
||
self::page(102, self::layout($whole, $embedded)),
|
||
]);
|
||
$rewrite = static fn (): NodeRewrite => new VariableDetachRewrite('gvid-radius0001', '8px', 'numbers');
|
||
|
||
$preview = $rewriter->preview(self::query(), $rewrite, null);
|
||
$result = $rewriter->apply(self::ABILITY, self::query(), $rewrite, null, self::SUMMARY);
|
||
|
||
self::assertSame(1, $preview['pages_count']);
|
||
self::assertSame(
|
||
[101, 102],
|
||
array_column($preview['skipped'], 'post_id')
|
||
);
|
||
self::assertStringStartsWith('s0.r0.c0.m0: Die Variable steht hier eingebettet', $preview['skipped'][0]['reason']);
|
||
self::assertStringStartsWith('s0.r0.c0.m1: ', $preview['skipped'][1]['reason']);
|
||
self::assertSame([102], array_column($result['saved'], 'post_id'));
|
||
self::assertSame([101, 102], array_column($result['skipped'], 'post_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(ReleaseMatrices::uniform(ReleaseState::Untested, '5.16.0'), [DiviFeature::PageWrite]),
|
||
];
|
||
|
||
$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);
|
||
}
|
||
|
||
/** Ruling P13: a user who may see library IDs is told which entry to edit. */
|
||
public function testGlobalInstanceReasonNamesTheEntryWhenIdsAreVisible(): void
|
||
{
|
||
$rewriter = $this->rewriter([self::page(101, self::layout(self::INSTANCE, self::BUTTON))]);
|
||
|
||
$preview = $rewriter->preview(self::query(), self::assign(), null);
|
||
$result = $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY);
|
||
|
||
self::assertSame(
|
||
's0.r0.c0.g0: globales Modul – im Bibliothekseintrag 1059 ändern',
|
||
$preview['skipped'][0]['reason']
|
||
);
|
||
self::assertSame($preview['skipped'], $result['skipped']);
|
||
}
|
||
|
||
/** Ruling P13: without the library right the reason names no library ID (dry run and write). */
|
||
public function testGlobalInstanceReasonHidesTheEntryWithoutLibraryRights(): void
|
||
{
|
||
$loader = LibraryFixtures::loader(LibraryFixtures::access(['divi_library']));
|
||
$rewriter = $this->rewriter([self::page(101, self::layout(self::INSTANCE, self::BUTTON))], null, [], $loader);
|
||
self::assertFalse($loader->libraryIdsVisible());
|
||
|
||
$preview = $rewriter->preview(self::query(), self::assign(), null);
|
||
$result = $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY);
|
||
|
||
$expected = [['post_id' => 101, 'reason' => 's0.r0.c0.g0: globales Modul – im Divi-Builder ändern']];
|
||
self::assertSame($expected, $preview['skipped']);
|
||
self::assertSame($expected, $result['skipped']);
|
||
self::assertSame([101], array_column($result['saved'], 'post_id'));
|
||
self::assertStringNotContainsString('1059', (string) json_encode([$preview, $result]));
|
||
}
|
||
|
||
/** 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']);
|
||
self::assertSame(1, $result['hidden_count'], 'detach must not say "all done" with unreadable usages');
|
||
}
|
||
|
||
/** Unreadable live usages the caller may not edit refuse the call (forbidden) as a count; preview lists them. */
|
||
public function testUnreadableBlockedLivePagesRefuseTheCallAsACount(): void
|
||
{
|
||
$report = new UsageReport(2, [], 1, 2, [102], 2, false);
|
||
$rewriter = $this->rewriter(
|
||
[
|
||
self::page(101, self::layout(self::BUTTON), ['post_status' => 'publish']),
|
||
self::page(102, self::layout(self::BUTTON), ['post_status' => 'publish']),
|
||
],
|
||
$report
|
||
);
|
||
|
||
$preview = $rewriter->preview(self::query(), self::assign(), null);
|
||
self::assertSame([102], $preview['blocked_ids']);
|
||
self::assertSame(2, $preview['blocked_hidden_count']);
|
||
|
||
$error = self::assertToolError(
|
||
$rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY),
|
||
'forbidden'
|
||
);
|
||
self::assertSame([102], $error['blocked_ids']);
|
||
self::assertSame(2, $error['blocked_hidden_count']);
|
||
self::assertStringContainsString('2 Einträge, die dieses Konto nicht lesen darf', $error['message']);
|
||
self::assertSame([], $this->saved);
|
||
self::assertSame([], $this->log->records);
|
||
}
|
||
|
||
/**
|
||
* Above HARD_CAP live usages the per-row edit check does not run (blocked stays empty): post_ids are not refused,
|
||
* and a named live page the caller may not edit is skipped by loadWritable() instead of forbidden.
|
||
*/
|
||
public function testPostIdsAboveTheHardCapAreNotRefusedAndAnUneditablePageIsSkipped(): 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(GlobalUsages::HARD_CAP + 1, [], GlobalUsages::HARD_CAP + 1, true)
|
||
);
|
||
Functions\when('current_user_can')->alias(
|
||
static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? null) === 101)
|
||
);
|
||
|
||
self::assertToolError($rewriter->preview(self::query(), self::assign(), null), 'unsupported');
|
||
$preview = $rewriter->preview(self::query(), self::assign(), [101, 102]);
|
||
$result = $rewriter->apply(self::ABILITY, self::query(), self::assign(), [101, 102], self::SUMMARY);
|
||
|
||
self::assertSame([], $preview['blocked_ids']);
|
||
self::assertSame([102], array_column($result['saved'], 'post_id'));
|
||
self::assertSame([101], array_column($result['skipped'], 'post_id'));
|
||
self::assertSame([[102, SaveScope::Page]], $this->saved);
|
||
}
|
||
}
|