diff --git a/src/Integrations/Divi/BlockTree/Outline.php b/src/Integrations/Divi/BlockTree/Outline.php index 5d7920a..5a35327 100644 --- a/src/Integrations/Divi/BlockTree/Outline.php +++ b/src/Integrations/Divi/BlockTree/Outline.php @@ -23,8 +23,11 @@ final class Outline private bool $truncated = false; - /** @param array $presetNames Preset ID => name. */ - public function __construct(private array $presetNames = []) + /** + * @param array $presetNames Preset ID => name. + * @param bool $globalIds Ruling P13: false leaves out the library IDs of global instances. + */ + public function __construct(private array $presetNames = [], private bool $globalIds = true) { } @@ -157,7 +160,7 @@ final class Outline 'editable' => $editable, 'depth' => $depth, 'truncated' => false, - 'global_id' => $global['id'] ?? null, + 'global_id' => $this->globalIds ? ($global['id'] ?? null) : null, ]; } } diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 92bdc1d..752e3e1 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -79,6 +79,12 @@ final class DiviIntegration implements Integration return 'divi'; } + /** Ruling P13: whether this connection may see library IDs (library group visible, library right). */ + public function libraryIdsVisible(): bool + { + return $this->access->revealsIds(); + } + public function label(): string { return __('Divi', 'wp-lit-mcp'); diff --git a/src/Integrations/Divi/Library/LibraryAccess.php b/src/Integrations/Divi/Library/LibraryAccess.php index 271715a..b83ca75 100644 --- a/src/Integrations/Divi/Library/LibraryAccess.php +++ b/src/Integrations/Divi/Library/LibraryAccess.php @@ -80,6 +80,17 @@ class LibraryAccess return $this->roles->refusal('divi_library'); } + /** + * Ruling P13: whether library IDs may be named to this connection at all – the library group + * is not disabled (P9; "nur lesen" still names them) and the user has the library right + * (canList). Otherwise outlines, node-get, write results, text search and the render summary + * say "global module" without the entry's ID, as if it were not stored. + */ + public function revealsIds(): bool + { + return ($this->visible === null || ($this->visible)()) && $this->canList() === null; + } + /** Reading one entry: canList() and edit_post on the entry. */ public function canRead(WP_Post $item): ?WP_Error { diff --git a/src/Integrations/Divi/Pages/DiviNodeGetAbility.php b/src/Integrations/Divi/Pages/DiviNodeGetAbility.php index fd1f761..c598068 100644 --- a/src/Integrations/Divi/Pages/DiviNodeGetAbility.php +++ b/src/Integrations/Divi/Pages/DiviNodeGetAbility.php @@ -130,13 +130,22 @@ final class DiviNodeGetAbility implements Ability $version = $block->attrs()['builderVersion'] ?? null; $global = NodeSummary::globalRef($block); - if ($global !== null) { + if ($global !== null && $this->loader->libraryIdsVisible()) { $global['hint'] = sprintf( 'Globales Modul: Inhalt im Bibliothekseintrag %s bearbeiten (lit-divi-outline mit post_id %s); ' . 'jede Änderung dort wirkt auf alle Seiten mit diesem Modul.', $global['id'], $global['id'] ); + } elseif ($global !== null) { + // Ruling P13: which entry is shown stays unknown without library access (AttrMap::flatten + // already leaves globalModule/globalParent out of attrs). + $global = [ + 'id' => null, + 'block_name' => $global['block_name'], + 'hint' => 'Globales Modul aus der Divi-Bibliothek – nur lesbar; dieses Konto hat keinen Zugang zur ' + . 'Divi-Bibliothek.', + ]; } return [ diff --git a/src/Integrations/Divi/Pages/DiviOutlineAbility.php b/src/Integrations/Divi/Pages/DiviOutlineAbility.php index 70026d9..f11e340 100644 --- a/src/Integrations/Divi/Pages/DiviOutlineAbility.php +++ b/src/Integrations/Divi/Pages/DiviOutlineAbility.php @@ -153,7 +153,7 @@ final class DiviOutlineAbility implements Ability return DiviErrors::unknownNode($rootId, $page->post); } - $outline = new Outline($this->design->read()->presetNames()); + $outline = new Outline($this->design->read()->presetNames(), $this->loader->libraryIdsVisible()); $rows = $outline->rows( $page->index, $rootId, @@ -191,8 +191,12 @@ final class DiviOutlineAbility implements Ability if (in_array(false, array_column($rows, 'editable'), true)) { $hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) lassen sich nur als Ganzes ' - . 'verschieben, duplizieren oder löschen. Globale Module [global ] im Bibliothekseintrag ' - . 'bearbeiten: lit-divi-outline mit post_id – die Änderung wirkt auf alle Seiten.'; + . 'verschieben, duplizieren oder löschen. ' + . ($this->loader->libraryIdsVisible() + ? 'Globale Module [global ] im Bibliothekseintrag bearbeiten: lit-divi-outline mit post_id ' + . ' – die Änderung wirkt auf alle Seiten.' + // Ruling P13: without library access the entry stays unnamed. + : 'Globale Module stammen aus der Divi-Bibliothek, für die dieses Konto keinen Zugang hat.'); } return $hints; diff --git a/src/Integrations/Divi/Pages/DiviPageLoader.php b/src/Integrations/Divi/Pages/DiviPageLoader.php index 5da88a3..f60eded 100644 --- a/src/Integrations/Divi/Pages/DiviPageLoader.php +++ b/src/Integrations/Divi/Pages/DiviPageLoader.php @@ -41,6 +41,12 @@ final class DiviPageLoader return $this->library; } + /** Ruling P13: whether the Divi tools may name library IDs (no library access = never). */ + public function libraryIdsVisible(): bool + { + return $this->library?->revealsIds() ?? false; + } + /** Readers pass $forWrite = false (trashed entries may be read, like lit-content-get). */ public function load(mixed $postId, bool $forWrite): DiviPage|WP_Error { diff --git a/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php b/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php index 7b3e433..4697cea 100644 --- a/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php +++ b/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php @@ -417,6 +417,11 @@ final class DiviReplaceTextAbility implements Ability */ private function hits(DiviPage $page, TextReplacer $replacer): array { - return $this->search->replace($page, $replacer, $this->writer->composition($page)->cleaner()); + return $this->search->replace( + $page, + $replacer, + $this->writer->composition($page)->cleaner(), + $this->loader->libraryIdsVisible() + ); } } diff --git a/src/Integrations/Divi/Pages/DiviTextSearch.php b/src/Integrations/Divi/Pages/DiviTextSearch.php index b74586b..d4ad9a6 100644 --- a/src/Integrations/Divi/Pages/DiviTextSearch.php +++ b/src/Integrations/Divi/Pages/DiviTextSearch.php @@ -68,14 +68,20 @@ final class DiviTextSearch } /** - * Every hit of $page, computed without changing it. + * Every hit of $page, computed without changing it. Without $globalIds (ruling P13: no library + * access) the library IDs a global instance stores are no text to find: an opaque node is + * matched without globalModule/globalParent, as if they were not stored. * * @return array{count: int, samples: list>, * blocks: list}>, skipped_nodes: list} * @throws InvalidArgumentException When a place would be changed unsafely – the page fails. */ - public function replace(DiviPage $page, TextReplacer $replacer, ValueCleaner $cleaner): array - { + public function replace( + DiviPage $page, + TextReplacer $replacer, + ValueCleaner $cleaner, + bool $globalIds = true + ): array { $count = 0; $samples = []; $blocks = []; @@ -83,7 +89,8 @@ final class DiviTextSearch foreach ($page->index->entries() as $entry) { $block = $entry['block']; if ($block->isDivi() && $block->isOpaque()) { - if ($replacer->mentions(Serializer::block($block))) { + $markup = $globalIds ? Serializer::block($block) : self::withoutLibraryIds($block); + if ($replacer->mentions($markup)) { $skipped[] = ['id' => (string) $entry['id'], 'reason' => self::opaqueReason($block)]; } continue; @@ -112,6 +119,22 @@ final class DiviTextSearch return ['count' => $count, 'samples' => $samples, 'blocks' => $blocks, 'skipped_nodes' => $skipped]; } + /** + * Ruling P13: $block's markup with every globalModule/globalParent left out, at every depth – + * only for matching, never saved. + */ + private static function withoutLibraryIds(Block $block): string + { + $attrs = $block->attrs(); + unset($attrs['globalModule'], $attrs['globalParent']); + $markup = ''; + foreach ($block->isVoid() ? [] : $block->parts() as $part) { + $markup .= $part instanceof Block ? self::withoutLibraryIds($part) : $part; + } + + return $markup . ''; + } + /** Why an opaque node is not changed (global modules are edited in the Divi library, Etappe 3). */ private static function opaqueReason(Block $block): string { diff --git a/src/Integrations/Divi/Pages/DiviWriter.php b/src/Integrations/Divi/Pages/DiviWriter.php index eeeecfa..f9a48b6 100644 --- a/src/Integrations/Divi/Pages/DiviWriter.php +++ b/src/Integrations/Divi/Pages/DiviWriter.php @@ -247,7 +247,7 @@ final class DiviWriter return ['text' => null, 'truncated' => false]; } - $outline = new Outline($this->design->read()->presetNames()); + $outline = new Outline($this->design->read()->presetNames(), $this->loader->libraryIdsVisible()); return $outline->summary($index, array_map('strval', array_keys($sections))); } diff --git a/src/Integrations/WordPress/Render/PageRenderSummaryAbility.php b/src/Integrations/WordPress/Render/PageRenderSummaryAbility.php index b111c95..5e14354 100644 --- a/src/Integrations/WordPress/Render/PageRenderSummaryAbility.php +++ b/src/Integrations/WordPress/Render/PageRenderSummaryAbility.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\WordPress\Render; +use Closure; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Annotations; use LIT\MCP\Integrations\WordPress\PostGuard; @@ -22,8 +23,15 @@ final class PageRenderSummaryAbility implements Ability /** At most this many global-module notes per summary. */ private const GLOBAL_NOTES = 20; - public function __construct(private ContentSchema $schema, private PageRenderer $renderer) - { + /** + * @param (Closure(): bool)|null $globalIdsVisible Ruling P13: whether global module IDs may be named + * (library access of this connection); null = always. + */ + public function __construct( + private ContentSchema $schema, + private PageRenderer $renderer, + private ?Closure $globalIdsVisible = null + ) { } public function name(): string @@ -108,13 +116,23 @@ final class PageRenderSummaryAbility implements Ability $notes[] = 'Der Eintrag ist passwortgeschützt; Besucher sehen zuerst das Passwortformular.'; } - foreach (array_slice(self::globalModuleIds((string) $post->post_content), 0, self::GLOBAL_NOTES) as $id) { + $globalIds = self::globalModuleIds((string) $post->post_content); + if ($globalIds !== [] && $this->globalIdsVisible !== null && ! ($this->globalIdsVisible)()) { $notes[] = sprintf( - 'Globales Modul %s ist im Auszug nicht enthalten (beim Aufruf über die Schnittstelle rendert Divi ' - . 'globale Module nicht); Inhalt mit lit-divi-outline mit post_id %s lesen.', - $id, - $id + '%d %s im Auszug nicht enthalten (beim Aufruf über die Schnittstelle rendert Divi globale Module ' + . 'nicht; die Divi-Bibliothek ist für dieses Konto nicht zugänglich).', + count($globalIds), + count($globalIds) === 1 ? 'globales Modul ist' : 'globale Module sind' ); + } else { + foreach (array_slice($globalIds, 0, self::GLOBAL_NOTES) as $id) { + $notes[] = sprintf( + 'Globales Modul %s ist im Auszug nicht enthalten (beim Aufruf über die Schnittstelle rendert Divi ' + . 'globale Module nicht); Inhalt mit lit-divi-outline mit post_id %s lesen.', + $id, + $id + ); + } } return array_merge( diff --git a/src/Integrations/WordPress/WordPressIntegration.php b/src/Integrations/WordPress/WordPressIntegration.php index c3268b6..0792e65 100644 --- a/src/Integrations/WordPress/WordPressIntegration.php +++ b/src/Integrations/WordPress/WordPressIntegration.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\WordPress; +use Closure; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\Integration; use LIT\MCP\Integrations\WordPress\Content\ContentComposer; @@ -21,8 +22,8 @@ use LIT\MCP\Integrations\WordPress\Menus\MenuListAbility; use LIT\MCP\Integrations\WordPress\Menus\MenuOperations; use LIT\MCP\Integrations\WordPress\Menus\MenuSetItemsAbility; use LIT\MCP\Integrations\WordPress\Menus\MenuWriter; -use LIT\MCP\Integrations\WordPress\Render\PageRenderer; use LIT\MCP\Integrations\WordPress\Render\PageRenderSummaryAbility; +use LIT\MCP\Integrations\WordPress\Render\PageRenderer; use LIT\MCP\Integrations\WordPress\Revisions\RevisionRestoreAbility; use LIT\MCP\Integrations\WordPress\Revisions\RevisionsListAbility; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; @@ -49,10 +50,15 @@ defined('ABSPATH') || exit; final class WordPressIntegration implements Integration { /** @param ContentComposer|null $divi Divi short form for lit/content-create (DiviIntegration). */ + /** + * @param (Closure(): bool)|null $globalIdsVisible Ruling P13: whether lit-page-render-summary may name + * global module IDs (the Divi integration's library access); null = always. + */ public function __construct( private ContentSchema $schema, private ChangeLog $log, - private ?ContentComposer $divi = null + private ?ContentComposer $divi = null, + private ?Closure $globalIdsVisible = null ) { } @@ -99,7 +105,7 @@ final class WordPressIntegration implements Integration new MenuAssignLocationAbility($this->log), new WpSettingsGetAbility($whitelist), new WpSettingsUpdateAbility($whitelist, $this->log), - new PageRenderSummaryAbility($this->schema, new PageRenderer()), + new PageRenderSummaryAbility($this->schema, new PageRenderer(), $this->globalIdsVisible), ]; } } diff --git a/src/Plugin.php b/src/Plugin.php index 4b4c5d8..ba352fc 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -105,7 +105,12 @@ final class Plugin ); $registry = new IntegrationRegistry( new CoreIntegration(self::pluginDir() . '/docs/guides'), - new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)), + new WordPressIntegration( + $schema, + $changeLog, + $divi->contentComposer($diviWritable), + $divi->libraryIdsVisible(...) + ), new MediaIntegration($schema, $changeLog, $limits), $divi, $divi->library() diff --git a/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php b/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php index d3a0155..1d6b4fc 100644 --- a/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php +++ b/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php @@ -183,4 +183,27 @@ final class OutlineTest extends TestCase { return (string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/' . $name); } + + /** Ruling P13: without library access the outline says "global", but not which entry. */ + public function testGlobalIdsCanBeHidden(): void + { + $content = DiviFixtures::markup('stage3/page--globale-instanzen.html'); + $outline = new Outline([], false); + $rows = $outline->rows(NodeIndex::build((new Parser())->parse($content))); + + $byId = array_column($rows, null, 'id'); + self::assertNull($byId['g0']['global_id']); + self::assertSame('section', $byId['g0']['type']); + self::assertStringNotContainsString('1058', $outline->text($rows)); + self::assertStringNotContainsString('1059', $outline->text($rows)); + self::assertStringNotContainsString('[global', $outline->text($rows)); + self::assertStringContainsString('g0 section "Global Abschnitt" (nur lesbar)', $outline->text($rows)); + self::assertStringNotContainsString('1058', (string) json_encode($rows)); + self::assertStringNotContainsString('1059', (string) json_encode($rows)); + $summary = $outline->summary(NodeIndex::build((new Parser())->parse($content))); + self::assertStringNotContainsString('1058', (string) $summary['text']); + + $shown = new Outline(); + self::assertStringContainsString('[global 1058]', $shown->text($shown->rows(NodeIndex::build((new Parser())->parse($content))))); + } } diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index 1d62b49..7904b2c 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -123,4 +123,25 @@ final class DiviIntegrationTest extends TestCase self::assertInstanceOf(WP_Error::class, $refusal); self::assertToolError($refusal, 'not_found'); } + + /** + * Ruling P13: library IDs follow the integration's library access – hidden with the group disabled + * and without the library right (no Divi in unit tests: DiviRoles allows nothing). The positive + * cases, "nur lesen" included, are in LibraryAccessTest (defining et_pb_is_allowed here would leak). + */ + public function testLibraryIdsFollowTheLibraryAccess(): void + { + LibraryFixtures::given([]); + $schema = Mockery::mock(ContentSchema::class); + $asked = 0; + $hidden = new DiviIntegration($schema, FakeChangeLog::forbidden(), libraryVisible: static function () use (&$asked): bool { + ++$asked; + + return false; + }); + + self::assertFalse($hidden->libraryIdsVisible()); + self::assertSame(1, $asked); + self::assertFalse((new DiviIntegration($schema, FakeChangeLog::forbidden()))->libraryIdsVisible()); + } } diff --git a/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php b/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php index 3044039..588d0c9 100644 --- a/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php +++ b/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php @@ -180,4 +180,32 @@ final class LibraryAccessTest extends TestCase LibraryFixtures::given([], ['edit_posts']); self::assertToolError(LibraryFixtures::access()->canInsert(), 'forbidden'); } + + /** Ruling P13: library IDs are named only while the library is visible and the user has its right. */ + public function testRevealsIdsOnlyWithTheVisibleLibraryAndItsRight(): void + { + LibraryFixtures::given([]); + + self::assertTrue(LibraryFixtures::access()->revealsIds()); + self::assertFalse(LibraryFixtures::access(['divi_library'])->revealsIds()); + $hidden = new LibraryAccess(LibraryFixtures::store(), LibraryFixtures::roles(), null, static fn (): bool => false); + self::assertFalse($hidden->revealsIds()); + LibraryFixtures::given([], ['edit_posts']); + self::assertFalse(LibraryFixtures::access()->revealsIds()); + } + + /** Ruling P13: "nur lesen" (group read-only) still names library IDs – only "deaktiviert" hides them. */ + public function testAReadOnlyLibraryStillRevealsIds(): void + { + LibraryFixtures::given([]); + + self::assertTrue(LibraryFixtures::access([], false)->revealsIds()); + $readOnly = new LibraryAccess( + LibraryFixtures::store(), + LibraryFixtures::roles(), + static fn (): bool => false, + static fn (): bool => true + ); + self::assertTrue($readOnly->revealsIds()); + } } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php index e934793..612e702 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; +use LIT\MCP\Integrations\Divi\Library\LibraryAccess; use LIT\MCP\Integrations\Divi\Pages\DiviNodeGetAbility; use LIT\MCP\Integrations\Divi\Pages\DiviOutlineAbility; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; @@ -128,7 +129,7 @@ final class DiviReadAbilitiesTest extends TestCase public function testNodeGetNamesTheLibraryEntryOfAGlobalModule(): void { DiviFixtures::givenPost(DiviFixtures::markup('stage3/page--globale-instanzen.html')); - $ability = new DiviNodeGetAbility(new DiviPageLoader(DiviFixtures::schema()), $this->design()); + $ability = new DiviNodeGetAbility(LibraryFixtures::loader(), $this->design()); $global = $ability->execute(['post_id' => 7, 'id' => 'g0']); $plain = $ability->execute(['post_id' => 7, 'id' => 's0']); @@ -140,6 +141,56 @@ final class DiviReadAbilitiesTest extends TestCase self::assertNull($plain['global']); } + /** Ruling P13: without the library right node-get and outline name no library ID. */ + public function testWithoutLibraryAccessNoLibraryIdIsNamed(): void + { + DiviFixtures::givenPost(DiviFixtures::markup('stage3/page--globale-instanzen.html')); + $loaders = [ + 'no divi_library right' => LibraryFixtures::loader(LibraryFixtures::access(['divi_library'])), + 'group disabled' => LibraryFixtures::loader(new LibraryAccess( + LibraryFixtures::store(), + LibraryFixtures::roles(), + null, + static fn (): bool => false + )), + 'no library access at all' => new DiviPageLoader(DiviFixtures::schema()), + ]; + + foreach ($loaders as $case => $loader) { + $nodeGet = new DiviNodeGetAbility($loader, $this->design()); + $global = $nodeGet->execute(['post_id' => 7, 'id' => 'g0']); + $outline = (new DiviOutlineAbility($loader, $this->design()))->execute(['post_id' => 7]); + + self::assertNull($global['global']['id'], $case); + self::assertSame('divi/section', $global['global']['block_name'], $case); + self::assertStringContainsString('Divi-Bibliothek', $global['global']['hint'], $case); + self::assertArrayNotHasKey('globalModule', $global['attrs'], $case); + self::assertFalse($global['editable'], $case); + self::assertStringNotContainsString('1058', (string) json_encode($global), $case); + self::assertStringNotContainsString('1058', (string) json_encode($outline), $case); + self::assertStringNotContainsString('1059', (string) json_encode($outline), $case); + self::assertStringNotContainsString('[global', (string) json_encode($outline), $case); + foreach (array_column($outline['nodes'], 'id') as $id) { + $node = $nodeGet->execute(['post_id' => 7, 'id' => $id]); + self::assertStringNotContainsString('1058', (string) json_encode($node), $case . ' ' . $id); + self::assertStringNotContainsString('1059', (string) json_encode($node), $case . ' ' . $id); + } + } + } + + /** Ruling P13: "nur lesen" (group read-only) does not hide library IDs. */ + public function testAReadOnlyLibraryStillNamesLibraryIds(): void + { + DiviFixtures::givenPost(DiviFixtures::markup('stage3/page--globale-instanzen.html')); + $loader = LibraryFixtures::loader(LibraryFixtures::access([], false)); + + $global = (new DiviNodeGetAbility($loader, $this->design()))->execute(['post_id' => 7, 'id' => 'g0']); + $outline = (new DiviOutlineAbility($loader, $this->design()))->execute(['post_id' => 7]); + + self::assertSame('1058', $global['global']['id']); + self::assertStringContainsString('[global 1058]', $outline['outline']); + } + public function testOutlineOfALibraryItem(): void { LibraryFixtures::given([LibraryFixtures::item(1059)]); diff --git a/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php index bcdb0c8..5f3c69d 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php @@ -15,6 +15,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use LIT\MCP\Integrations\Divi\Pages\DiviReplaceTextAbility; use LIT\MCP\Tests\Support\DiviFixtures; use LIT\MCP\Tests\Support\DiviWriterFixture; +use LIT\MCP\Tests\Support\LibraryFixtures; use LIT\MCP\Tests\Unit\TestCase; use Mockery; use RuntimeException; @@ -38,13 +39,19 @@ final class DiviReplaceTextAbilityTest extends TestCase private array $failures = []; /** @param list $ids Rows of the prefilter query. */ - private function ability(?string $content = null, bool $unfiltered = false, array $ids = [7]): DiviReplaceTextAbility - { + private function ability( + ?string $content = null, + bool $unfiltered = false, + array $ids = [7], + bool $library = false + ): DiviReplaceTextAbility { $this->post = DiviFixtures::givenPost($content ?? DiviFixtures::markup('synthetic-basic.html')); Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['edit_posts' => 'edit_pages', 'edit_others_posts' => 'edit_others_pages']]); Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true'); $schema = DiviFixtures::schema(); - $loader = new DiviPageLoader($schema); + $loader = $library + ? new DiviPageLoader($schema, new Parser(), LibraryFixtures::access()) + : new DiviPageLoader($schema); $db = Mockery::mock(\wpdb::class)->makePartial(); $db->allows('get_col')->andReturnUsing(function (string $sql) use ($ids): array { $this->queries[] = $sql; @@ -420,6 +427,43 @@ final class DiviReplaceTextAbilityTest extends TestCase self::assertSame([], $this->saved); } + /** + * Ruling P13: without library access a search for a library ID finds nothing – the ID a global + * instance (and its children's globalParent) stores is as good as absent; with access it is reported. + */ + public function testWithoutLibraryAccessLibraryIdsAreNoHits(): void + { + $content = DiviFixtures::markup('stage3/page--globale-instanzen.html'); + foreach (['1058', '1059', '"globalModule":"1058"', 'globalParent":"1058'] as $search) { + $ability = $this->ability($content); + $dry = $ability->execute(['search' => $search, 'replace' => 'x']); + $result = $this->replaceFor($ability, ['search' => $search, 'replace' => 'x']); + + self::assertSame([], $dry['skipped_nodes'], $search); + self::assertSame([], $dry['matches'], $search); + self::assertSame([], $result['skipped_nodes'], $search); + self::assertStringNotContainsString('1058', (string) json_encode([$dry, $result]), $search); + self::assertStringNotContainsString('1059', (string) json_encode([$dry, $result]), $search); + self::assertSame([], $this->saved); + } + + $shown = $this->ability($content, false, [7], true)->execute(['search' => '1058', 'replace' => 'x']); + self::assertSame(['g0'], array_column($shown['skipped_nodes'], 'id')); + } + + /** Ruling P13: text of a global instance is still reported without library access (not an ID). */ + public function testWithoutLibraryAccessGlobalTextIsStillReported(): void + { + $global = ['divi/text', ['globalModule' => '123', 'content' => ['innerContent' => ['desktop' => ['value' => '

Müller

']]]]]; + $ability = $this->ability(self::page([$global])); + + $dry = $ability->execute(['search' => 'Müller', 'replace' => 'Schmidt']); + $byId = $ability->execute(['search' => '123', 'replace' => 'x']); + + self::assertSame(['s0.r0.c0.g0'], array_column($dry['skipped_nodes'], 'id')); + self::assertSame([], $byId['skipped_nodes']); + } + /** No effective change (search == replacement, cleaned replacement == match): nothing saved. */ public function testNoEffectiveChangeSavesNothing(): void { diff --git a/tests/Unit/Integrations/Divi/Pages/DiviUpdateAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviUpdateAbilityTest.php index b3b24ee..9741ad1 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviUpdateAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviUpdateAbilityTest.php @@ -8,6 +8,8 @@ use LIT\MCP\Abilities\Rev; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Tests\Support\DiviFixtures; +use LIT\MCP\Tests\Support\DiviWriterFixture; +use LIT\MCP\Tests\Support\LibraryFixtures; use WP_Error; final class DiviUpdateAbilityTest extends DiviWriteAbilityTestCase @@ -240,4 +242,24 @@ final class DiviUpdateAbilityTest extends DiviWriteAbilityTestCase self::assertStringContainsString('nur im Divi-Builder', $client['fields']['changes[0].attrs.module.decoration.attributes']); self::assertSame([], $this->saved); } + + /** Ruling P13: the outline of a write result names library IDs only with library access. */ + public function testTheWriteOutlineNamesLibraryIdsOnlyWithLibraryAccess(): void + { + $content = DiviFixtures::markup('stage3/page--globale-instanzen.html'); + $changes = [['id' => 's0.r0.c0.m0', 'set' => ['text' => 'Termin buchen']]]; + + $hidden = $this->updateOn($content, $changes); + $failures = []; + $this->writer = DiviWriterFixture::writer($this->saved, $failures, false, LibraryFixtures::loader()); + $shown = $this->updateOn($content, $changes); + + self::assertTrue($hidden['ok']); + self::assertStringContainsString('s0.r0.c0.g0 text', (string) $hidden['outline']); + self::assertStringContainsString('(nur lesbar)', (string) $hidden['outline']); + self::assertStringNotContainsString('1059', (string) json_encode($hidden)); + self::assertStringNotContainsString('1058', (string) json_encode($hidden)); + self::assertStringContainsString('[global 1059]', (string) $shown['outline']); + } } + diff --git a/tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php b/tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php index e154723..379201f 100644 --- a/tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php @@ -160,6 +160,44 @@ final class PageRenderSummaryAbilityTest extends TestCase self::assertStringContainsString('lit-divi-outline mit post_id 1059', $globals[1]); } + /** Ruling P13: without library access the note counts global modules instead of naming them. */ + public function testWithoutLibraryAccessGlobalModulesAreOnlyCounted(): void + { + $content = DiviFixtures::markup('stage3/page--globale-instanzen.html'); + $post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'draft', 'post_content' => $content]); + Functions\when('get_post')->justReturn($post); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=7'); + $renderer = Mockery::mock(PageRenderer::class); + $renderer->allows('render')->andReturn('

Text

'); + $ability = new PageRenderSummaryAbility(SchemaFixture::managed(), $renderer, static fn (): bool => false); + + $result = $ability->execute(['post_id' => 7]); + + $joined = implode("\n", $result['notes']); + self::assertStringContainsString('2 globale Module sind im Auszug nicht enthalten', $joined); + self::assertStringNotContainsString('1058', $joined); + self::assertStringNotContainsString('1059', $joined); + self::assertStringNotContainsString('1058', (string) json_encode($result)); + } + + /** Ruling P13: with library access the notes name each entry. */ + public function testWithLibraryAccessGlobalModulesAreNamed(): void + { + $content = DiviFixtures::markup('stage3/page--globale-instanzen.html'); + $post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'draft', 'post_content' => $content]); + Functions\when('get_post')->justReturn($post); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=7'); + $renderer = Mockery::mock(PageRenderer::class); + $renderer->allows('render')->andReturn('

Text

'); + $ability = new PageRenderSummaryAbility(SchemaFixture::managed(), $renderer, static fn (): bool => true); + + $joined = implode("\n", $ability->execute(['post_id' => 7])['notes']); + + self::assertStringContainsString('Globales Modul 1058', $joined); + } + public function testLibraryEntriesAreNoManagedContent(): void { Functions\when('get_post')->justReturn(new WP_Post(['ID' => 1058, 'post_type' => 'et_pb_layout']));