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

681 lines
29 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\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'));
}
/** 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);
}
}