'; private const TEXT = ''; private const INSTANCE = ''; /** @var list Post ID and scope of every save. */ private array $saved = []; /** @var list 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 '' . '' . implode('', $modules) . ''; } /** @param array $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 $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 $posts * @param array $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 $modules * @return Closure(): NodeRewrite */ private static function assign(array $modules = ['divi/button']): Closure { return static fn (): NodeRewrite => new class ($modules) implements NodeRewrite { /** @param list $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 $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, ''), 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); } }