*/ private array $specs = []; /** @var list */ private array $entryContents = []; /** @var list Page contents DiviWriter saved. */ private array $saved = []; private FakeChangeLog $log; /** @var list Backups VisibleEdits made. */ private array $backups = []; protected function setUp(): void { parent::setUp(); $this->page = LibraryFixtures::page(DiviFixtures::markup('stage3/page--globale-instanzen.html')); LibraryFixtures::given([$this->page]); Functions\when('wp_revisions_enabled')->justReturn(true); $this->log = DiviWriterFixture::log(); } /** * @param list $deniedRoles * @param list $pageFailures */ private function ability( array $deniedRoles = [], bool $diviWritable = true, bool $unfiltered = false, array $pageFailures = [], ?DiviPageLoader $loader = null, ?DiviWriter $writer = null ): LibraryMakeGlobalAbility { $store = LibraryFixtures::store(); $store->allows('create')->andReturnUsing(function (LibraryItemSpec $spec): int { $this->specs[] = $spec; return 2300; }); $saver = Mockery::mock(DiviSaver::class); $saver->allows('saveNew')->andReturnUsing(function (int $id, string $content, SaveScope $scope): array { $this->entryContents[] = [$id, $content, $scope]; return []; }); $access = LibraryFixtures::access($deniedRoles, true, $store); return new LibraryMakeGlobalAbility( $access, $writer ?? DiviWriterFixture::writer( $this->saved, $pageFailures, $unfiltered, $loader ?? LibraryFixtures::loader($access) ), new NewLibraryEntry($access, $saver, DiviWriterFixture::modules()), $this->log, static fn (): bool => $diviWritable, new VisibleEdits(function (WP_Post $post): int { $this->backups[] = ['post_id' => (int) $post->ID, 'backup_id' => 1071]; return 1071; }) ); } /** @return array */ private function input(array $extra = []): array { return array_merge( ['post_id' => 7, 'tree_rev' => Rev::of((string) $this->page->post_content), 'id' => 's1', 'title' => 'Leistungen'], $extra ); } public function testReplacesTheNodeByAnInstanceOfTheNewGlobalEntry(): void { $result = $this->ability()->execute($this->input()); self::assertTrue($result['ok']); self::assertSame(2300, $result['library_id']); self::assertTrue($this->specs[0]->global); self::assertSame( str_replace("\r\n", "\n", DiviFixtures::markup('stage3/library--library-abschnitt.html')), $this->entryContents[0][1], 'the entry holds exactly the node, framed like the builder' ); self::assertCount(1, $this->saved); self::assertStringContainsString( '' . '' . '' ); LibraryFixtures::given([$this->page]); $result = $this->ability()->execute($this->input(['id' => 's0'])); self::assertToolError($result, 'validation_failed'); self::assertStringContainsString('Code-Modul', implode(' ', self::errorFields($result))); self::assertSame([], $this->specs); self::assertSame([], $this->saved); } /** All or nothing: the page save failing removes the new entry again. */ public function testFailedPageSaveDeletesTheNewEntry(): void { Functions\expect('wp_delete_post')->once()->with(2300, true)->andReturn(new WP_Post(['ID' => 2300])); try { $this->ability([], true, false, [WriteFailed::rolledBack('Die Divi-Seite konnte nicht gespeichert werden.')]) ->execute($this->input()); self::fail('no WriteFailed'); } catch (WriteFailed $e) { self::assertTrue($e->rolledBack); } self::assertCount(1, $this->specs); } public function testRefusesNodesThatAreOrHoldGlobalModules(): void { self::assertToolError($this->ability()->execute($this->input(['id' => 'g0'])), 'validation_failed'); self::assertToolError($this->ability()->execute($this->input(['id' => 's0'])), 'validation_failed'); self::assertSame([], $this->specs); } public function testNeedsSaveGlobalAndInsertRightsAndTheDiviGate(): void { self::assertToolError($this->ability(['edit_global_library'])->execute($this->input()), 'forbidden'); self::assertToolError($this->ability(['add_library'])->execute($this->input()), 'forbidden'); self::assertToolError($this->ability([], false)->execute($this->input()), 'unsupported'); self::assertToolError($this->ability()->execute($this->input(['tree_rev' => 'alt'])), 'rev_mismatch'); self::assertSame([], $this->specs); self::assertFalse($this->ability()->isDirect()); } public function testLogsTheNewEntry(): void { $this->ability()->execute($this->input()); self::assertSame('et_pb_layout', $this->log->records[0]['object_type']); self::assertSame('2300', $this->log->records[0]['object_id']); self::assertStringContainsString('global', $this->log->records[0]['summary']); } /** * Ruling Q7: a Divi 4 shortcode in a text value renders another entry (or, casting to 0, the newest * layout – the entry being created); such a node never becomes a global entry. */ public function testRefusesAShortcodeReferenceInsideAValue(): void { $quote = chr(92) . '"'; foreach (['1058', 'abc'] as $ref) { $this->page = LibraryFixtures::page( '' . '' . '' ); LibraryFixtures::given([$this->page]); $result = $this->ability([], true, true)->execute($this->input(['id' => 's0'])); self::assertToolError($result, 'validation_failed'); self::assertStringContainsString('global_module', implode(' ', self::errorFields($result))); } self::assertSame([], $this->specs); self::assertSame([], $this->saved); } public function testLibraryEntriesAndThemeBuilderLayoutsAreNoSource(): void { $item = LibraryFixtures::item(1055); LibraryFixtures::given([$item]); $fromLibrary = $this->ability()->execute( ['post_id' => 1055, 'tree_rev' => Rev::of((string) $item->post_content), 'id' => 's0', 'title' => 'x'] ); self::assertToolError($fromLibrary, 'validation_failed'); self::assertArrayHasKey('post_id', self::errorFields($fromLibrary)); $body = ThemeBuilderFixtures::layout(ThemeBuilderFixtures::BODY); LibraryFixtures::given([$body]); Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path); $loader = new DiviPageLoader( DiviFixtures::schema(), new Parser(), LibraryFixtures::access(), ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([ThemeBuilderFixtures::template()])) ); $fromLayout = $this->ability([], true, false, [], $loader)->execute( ['post_id' => 1062, 'tree_rev' => Rev::of((string) $body->post_content), 'id' => 's0', 'title' => 'x'] ); self::assertToolError($fromLayout, 'validation_failed'); self::assertStringContainsString('Theme-Builder', $fromLayout->get_error_message()); self::assertSame([], $this->specs); self::assertSame([], $this->saved); } /** A page write that may have been saved keeps the entry – the page may already show it. */ public function testAPartialPageWriteKeepsTheEntry(): void { Functions\expect('wp_delete_post')->never(); $failures = [WriteFailed::partial('Die Divi-Seite konnte nicht gespeichert werden.', postId: 7)]; $design = DiviWriterFixture::designReader(); $writer = new DiviWriter( LibraryFixtures::loader(), DiviWriterFixture::saver($this->saved, $failures), FakeChangeLog::any(), $design, DiviWriterFixture::composition($design) ); try { $this->ability([], true, false, [], null, $writer)->execute($this->input()); self::fail('no WriteFailed'); } catch (WriteFailed $e) { self::assertFalse($e->rolledBack); self::assertSame(2300, $e->details['library_id']); } } /** Pages 8 and 9: a plain copy of the global section 1058; 9 also a variation with another text. */ private function givenCopiesOf1058(): string { $root = LibraryContent::roots(LibraryContent::load( new Parser(), DiviFixtures::markup('stage3/library-global--globaler-abschnitt.html') ))[0]; $copy = Serializer::block($root); $variation = str_replace('Global', 'Lokal', $copy); LibraryFixtures::given([ $this->page, LibraryFixtures::item(1058), new WP_Post(['ID' => 8, 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => 'Team', 'post_content' => '' . $copy . '']), new WP_Post(['ID' => 9, 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => 'Jobs', 'post_content' => '' . $variation . $copy . '']), ]); return $copy; } /** @return array */ private static function targets(array $ids8 = ['s0'], array $ids9 = ['s1'], bool $confirm = false): array { return array_filter([ 'library_id' => 1058, 'targets' => [ ['post_id' => 8, 'tree_rev' => Rev::of((string) get_post(8)->post_content), 'ids' => $ids8], ['post_id' => 9, 'tree_rev' => Rev::of((string) get_post(9)->post_content), 'ids' => $ids9], ], 'confirm' => $confirm ?: null, ], static fn (mixed $value): bool => $value !== null); } public function testReplacingOccurrencesIsADryRunFirstThenEveryPageWithItsRevision(): void { $this->givenCopiesOf1058(); $dry = $this->ability()->execute(self::targets()); self::assertToolError($dry, 'confirm_required'); self::assertSame(2, $dry->get_error_data()['affected_count']); self::assertSame([8, 9], array_column($dry->get_error_data()['affected'], 'post_id')); self::assertSame([], $this->saved); $result = $this->ability()->execute(self::targets(['s0'], ['s1'], true)); self::assertTrue($result['ok']); self::assertSame([8, 9], array_column($result['pages'], 'post_id')); self::assertSame([811, 811], array_column($result['pages'], 'revision_id')); self::assertCount(2, $this->saved); self::assertStringContainsString('{"globalModule":"1058","blockName":"divi/section"', $this->saved[0]); self::assertStringContainsString('Lokal', $this->saved[1], 'the variation stays'); self::assertSame([], $this->specs, 'no new entry'); } public function testTargetsMustBeIdenticalEditableAndCurrent(): void { $this->givenCopiesOf1058(); $variation = $this->ability()->execute(self::targets(['s0'], ['s0'], true)); self::assertToolError($variation, 'validation_failed'); self::assertArrayHasKey('targets.1.ids.0', self::errorFields($variation)); $stale = self::targets(['s0'], ['s1'], true); $stale['targets'][1]['tree_rev'] = 'alt'; self::assertToolError($this->ability()->execute($stale), 'rev_mismatch'); $library = self::targets(['s0'], ['s1'], true); $library['targets'][1] = ['post_id' => 1055, 'tree_rev' => Rev::of((string) LibraryFixtures::item(1055)->post_content), 'ids' => ['s0']]; LibraryFixtures::given([LibraryFixtures::item(1055), LibraryFixtures::item(1058), get_post(8), get_post(9)]); self::assertToolError($this->ability()->execute($library), 'validation_failed'); LibraryFixtures::given([LibraryFixtures::item(1058), get_post(8), get_post(9)]); Functions\when('current_user_can')->alias(static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 9)); self::assertToolError($this->ability()->execute(self::targets(['s0'], ['s1'], true)), 'forbidden'); self::assertSame([], $this->saved, 'nothing was written in any case'); } public function testOnlyGlobalEntriesWithOneReadableRoot(): void { $this->givenCopiesOf1058(); LibraryFixtures::given([LibraryFixtures::item(1055), get_post(8), get_post(9)]); $normal = self::targets(); $normal['library_id'] = 1055; self::assertToolError($this->ability()->execute($normal), 'validation_failed'); } public function testAFailureMidwayNamesThePagesAlreadyChanged(): void { $this->givenCopiesOf1058(); try { $this->ability([], true, false, [null, WriteFailed::rolledBack('Die Divi-Seite konnte nicht gespeichert werden.')]) ->execute(self::targets(['s0'], ['s1'], true)); self::fail('no WriteFailed'); } catch (WriteFailed $e) { self::assertSame([8], $e->details['changed_posts']); } } /** The dry run lists every target with its tree_rev and whether visitors see it (P8a: drafts only listed). */ public function testTheDryRunListsTreeRevAndLiveState(): void { $this->givenCopiesOf1058(); $data = $this->ability()->execute(self::targets())->get_error_data(); self::assertSame(Rev::of((string) get_post(8)->post_content), $data['affected'][0]['tree_rev']); self::assertTrue($data['affected'][0]['live']); self::assertFalse($data['affected'][1]['live']); self::assertSame(['s1'], $data['affected'][1]['ids']); self::assertSame(1, $data['live_count']); } /** Ruling Q7: an entry that names another entry in any spelling never becomes an instance. */ public function testRefusesAnEntryWithAReferenceInAnySpelling(): void { $copy = $this->givenCopiesOf1058(); $quote = chr(92) . '"'; $content = str_replace( 'Globaler Abschnitt', '[et_pb_text global_module=' . $quote . '1059' . $quote . ']x[/et_pb_text]', DiviFixtures::markup('stage3/library-global--globaler-abschnitt.html') ); LibraryFixtures::given([LibraryFixtures::item(1058, ['post_content' => $content]), get_post(8), get_post(9)]); $result = $this->ability()->execute(self::targets(['s0'], ['s1'], true)); self::assertToolError($result, 'validation_failed'); self::assertArrayHasKey('library_id', self::errorFields($result)); self::assertSame([], $this->saved); self::assertNotSame('', $copy); } public function testThemeBuilderLayoutsAreNoTargets(): void { $this->givenCopiesOf1058(); $input = self::targets(['s0'], ['s1'], true); $body = ThemeBuilderFixtures::layout(ThemeBuilderFixtures::BODY); LibraryFixtures::given([LibraryFixtures::item(1058), get_post(8), $body]); Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path); $loader = new DiviPageLoader( DiviFixtures::schema(), new Parser(), LibraryFixtures::access(), ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([ThemeBuilderFixtures::template()])) ); $input['targets'][1] = ['post_id' => 1062, 'tree_rev' => Rev::of((string) $body->post_content), 'ids' => ['s0']]; $result = $this->ability([], true, false, [], $loader)->execute($input); self::assertToolError($result, 'validation_failed'); self::assertArrayHasKey('targets.1.post_id', self::errorFields($result)); self::assertSame([], $this->saved); } /** Replacing needs lit-library-insert's rights (not lit/library-save's) and a writable library group. */ public function testReplacingNeedsInsertRightsOnly(): void { $this->givenCopiesOf1058(); self::assertToolError($this->ability(['add_library'])->execute(self::targets([], [], true)), 'forbidden'); self::assertToolError($this->ability([], false)->execute(self::targets()), 'unsupported'); self::assertToolError($this->ability(['edit_global_library'])->execute(self::targets()), 'confirm_required'); self::assertSame([], $this->saved); } /** Live pages without revisions get VisibleEdits' backup – refused before any page is written where it would lose bytes. */ public function testLivePagesWithoutRevisionsGetABackupOrNothingIsWritten(): void { $this->givenCopiesOf1058(); Functions\when('wp_revisions_enabled')->justReturn(false); $result = $this->ability()->execute(self::targets(['s0'], ['s1'], true)); self::assertSame([['post_id' => 8, 'backup_id' => 1071]], $this->backups); self::assertSame(1071, $result['pages'][0]['backup_id']); self::assertArrayNotHasKey('backup_id', $result['pages'][1], 'drafts are only listed (P8a)'); $this->backups = []; $this->saved = []; $this->givenCopiesOf1058(); Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'unfiltered_html'); self::assertToolError($this->ability()->execute(self::targets(['s0'], ['s1'], true)), 'unsupported'); self::assertSame([], $this->backups); self::assertSame([], $this->saved); } /** A failure on page N: the pages before stay replaced (each with revision), the rest untouched – all named. */ public function testAFailureMidwayReportsEveryPage(): void { $this->givenCopiesOf1058(); try { $this->ability([], true, false, [null, WriteFailed::rolledBack('Die Divi-Seite konnte nicht gespeichert werden.')]) ->execute(self::targets(['s0'], ['s1'], true)); self::fail('no WriteFailed'); } catch (WriteFailed $e) { self::assertSame(9, $e->details['failed_post']); self::assertSame([], $e->details['unchanged_posts']); self::assertSame(811, $e->details['pages'][0]['revision_id']); self::assertStringNotContainsString('Nichts wurde gespeichert', (string) $e->toError()->get_error_message()); } self::assertCount(1, $this->saved); } public function testAFailureOnTheFirstPageLeavesEverythingUnchanged(): void { $this->givenCopiesOf1058(); try { $this->ability([], true, false, [WriteFailed::rolledBack('Die Divi-Seite konnte nicht gespeichert werden.')]) ->execute(self::targets(['s0'], ['s1'], true)); self::fail('no WriteFailed'); } catch (WriteFailed $e) { self::assertSame([], $e->details['changed_posts']); self::assertSame([9], $e->details['unchanged_posts']); } self::assertSame([], $this->saved); } /** @return array */ public static function visibleExtras(): array { return [ 'core paragraph' => ['

ONLY ON 8

'], 'raw text part' => ['

ONLY ON 8

'], ]; } /** * Ruling Q25 (review of Task 15): a copy with extra visible text – a core paragraph or raw HTML between * blocks – is no identical occurrence; replacing it would swap visible content. * * @dataProvider visibleExtras */ public function testACopyWithExtraVisibleTextIsNoIdenticalOccurrence(string $extra): void { $copy = $this->givenCopiesOf1058(); $at = strrpos($copy, ''); $changed = substr($copy, 0, (int) $at) . $extra . substr($copy, (int) $at); LibraryFixtures::given([ LibraryFixtures::item(1058), new WP_Post(['ID' => 8, 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => 'Team', 'post_content' => '' . $changed . '']), get_post(9), ]); $result = $this->ability()->execute(self::targets(['s0'], ['s1'], true)); self::assertToolError($result, 'validation_failed'); self::assertArrayHasKey('targets.0.ids.0', self::errorFields($result)); self::assertSame([], $this->saved); } /** Scope (ruling Q25): only pages and posts – other Divi-enabled types are neither targets nor sources. */ public function testOnlyPagesAndPostsAreTargetsAndSources(): void { $this->givenCopiesOf1058(); $project = get_post(8); $project->post_type = 'project'; LibraryFixtures::given([LibraryFixtures::item(1058), $project, get_post(9)]); $target = $this->ability()->execute(self::targets(['s0'], ['s1'], true)); self::assertToolError($target, 'validation_failed'); self::assertArrayHasKey('targets.0.post_id', self::errorFields($target)); $source = $this->ability()->execute( ['post_id' => 8, 'tree_rev' => Rev::of((string) $project->post_content), 'id' => 's0', 'title' => 'x'] ); self::assertToolError($source, 'validation_failed'); self::assertArrayHasKey('post_id', self::errorFields($source)); self::assertSame([], $this->saved); self::assertSame([], $this->specs); } /** A rolled-back page N>1 keeps DiviSaver's note on the revision that stays. */ public function testAFailureMidwayKeepsTheRevisionNote(): void { $this->givenCopiesOf1058(); $kept = WriteFailed::rolledBack( 'Die Divi-Seite konnte nicht gespeichert werden.', state: \LIT\MCP\Abilities\Errors::notSavedKeeping('Revision 812') ); try { $this->ability([], true, false, [null, $kept])->execute(self::targets(['s0'], ['s1'], true)); self::fail('no WriteFailed'); } catch (WriteFailed $e) { $message = (string) $e->toError()->get_error_message(); self::assertStringContainsString('Revision 812 bleibt erhalten', $message); self::assertStringContainsString('Bereits ersetzt', $message); } } }