variation = str_replace('Library-Blurb', 'Anderer Blurb', $copy); $posts = [ LibraryFixtures::page(DiviFixtures::markup('stage3/page--globale-instanzen.html')), new WP_Post([ 'ID' => 8, 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => 'Über uns', 'post_content' => '' . $this->variation . $copy . '', ]), new WP_Post(['ID' => 9, 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => 'Fremd', 'post_content' => $copy]), ]; $items = array_map(static fn (int $id): WP_Post => LibraryFixtures::item($id), array_keys(LibraryFixtures::ITEMS)); LibraryFixtures::given(array_merge($posts, $items)); Functions\when('current_user_can')->alias( static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 9) ); Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) [ 'edit_posts' => 'edit_posts', 'edit_others_posts' => 'edit_others_posts', ]]); Functions\when('_get_non_cached_ids')->returnArg(1); Functions\when('_prime_post_caches')->alias(function (array $ids): void { $this->primed[] = $ids; }); Functions\when('wp_cache_delete')->justReturn(true); } /** @var list> */ private array $primed = []; private static function ability(array $deniedRoles = []): LibraryFindSimilarAbility { $access = LibraryFixtures::access($deniedRoles); return new LibraryFindSimilarAbility($access, LibraryFixtures::loader($access), DiviFixtures::schema()); } public function testFindsIdenticalAndStructurallySimilarNodesOfEditableEntries(): void { $query = []; Functions\when('get_posts')->alias(static function (array $args) use (&$query): array { $query = $args; return [7, 8, 9]; }); $result = self::ability()->execute(['library_id' => 1055, 'post_type' => 'page']); self::assertSame(1055, $result['reference']['library_id']); self::assertSame('divi/section', $result['reference']['type']); $found = array_map(static fn (array $m): string => $m['post_id'] . ' ' . $m['id'] . ' ' . $m['match'], $result['matches']); self::assertSame(['7 s1 identical', '8 s0 structure', '8 s1 identical'], $found, 'post 9 is not editable'); self::assertSame(Rev::of((string) get_post(8)->post_content), $result['matches'][1]['tree_rev']); self::assertSame(3, $result['scanned']); self::assertNull($result['next_cursor']); self::assertSame(['page'], $query['post_type']); self::assertSame(101, $query['posts_per_page']); self::assertSame('ids', $query['fields']); } public function testANodeOfAPageIsTheReferenceAndIsNotItsOwnMatch(): void { Functions\when('get_posts')->justReturn([7, 8]); $result = self::ability()->execute(['post_id' => 7, 'id' => 's1', 'post_type' => 'page', 'limit' => 1]); self::assertSame(['s1'], [$result['reference']['id']]); self::assertSame([], array_filter($result['matches'], static fn (array $m): bool => $m['post_id'] === 7)); self::assertSame(1, $result['scanned']); self::assertSame(Cursor::encode(1), $result['next_cursor']); } public function testRefusals(): void { Functions\when('get_posts')->justReturn([]); self::assertToolError(self::ability(['divi_library'])->execute(['library_id' => 1055]), 'forbidden'); self::assertToolError(self::ability()->execute([]), 'validation_failed'); self::assertToolError(self::ability()->execute(['library_id' => 1054]), 'validation_failed', 'a layout entry has several roots'); self::assertToolError(self::ability()->execute(['post_id' => 7, 'id' => 'g0']), 'validation_failed'); self::assertToolError(self::ability()->execute(['library_id' => 1055, 'cursor' => '%%%']), 'validation_failed'); self::assertTrue(self::ability()->annotations()['readonly']); } /** No N+1: the contents come in chunks of CONTENT_CHUNK through one primed post cache each. */ public function testLoadsTheContentsInChunks(): void { $ids = range(1000, 1000 + LibraryFindSimilarAbility::CONTENT_CHUNK + 4); Functions\when('get_posts')->justReturn($ids); $result = self::ability()->execute(['library_id' => 1055, 'post_type' => 'page', 'limit' => 200]); self::assertSame(count($ids), $result['scanned']); self::assertSame([array_slice($ids, 0, LibraryFindSimilarAbility::CONTENT_CHUNK), array_slice( $ids, LibraryFindSimilarAbility::CONTENT_CHUNK )], $this->primed); } /** Without edit_others_posts the query itself keeps to the user's own posts (no cursor oracle). */ public function testWithoutEditOthersPostsOnlyOwnPostsAreQueried(): void { $query = []; Functions\when('get_posts')->alias(static function (array $args) use (&$query): array { $query = $args; return []; }); Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_others_posts'); Functions\when('get_current_user_id')->justReturn(42); self::ability()->execute(['library_id' => 1055, 'post_type' => 'page']); self::assertSame(42, $query['author']); } /** Library entries and Theme Builder layouts are never scanned (they have their own gates). */ public function testLibraryAndThemeBuilderTypesAreNotSearchable(): void { Functions\expect('get_posts')->never(); foreach (['et_pb_layout', 'et_body_layout', 'et_header_layout', 'et_footer_layout', 'et_template'] as $type) { self::assertToolError( self::ability()->execute(['library_id' => 1055, 'post_type' => $type]), 'not_found' ); } } /** With the library group hidden the library entry is answered like a missing ID (P9/P12). */ public function testAHiddenLibraryAnswersLikeAMissingEntry(): void { Functions\when('get_posts')->justReturn([]); $access = new \LIT\MCP\Integrations\Divi\Library\LibraryAccess( LibraryFixtures::store(), LibraryFixtures::roles(), static fn (): bool => true, static fn (): bool => false ); $ability = new LibraryFindSimilarAbility($access, LibraryFixtures::loader($access), DiviFixtures::schema()); $hidden = $ability->execute(['library_id' => 1055, 'post_type' => 'page']); $missing = $ability->execute(['library_id' => 4711, 'post_type' => 'page']); self::assertToolError($hidden, 'not_found'); self::assertSame( str_replace('1055', 'X', $hidden->get_error_message()), str_replace('4711', 'X', $missing->get_error_message()) ); } /** At most MAX_MATCHES matches: the scan stops after that post, says so and resumes behind it. */ public function testStopsAtMaxMatchesWithANoteAndACursor(): void { $section = Serializer::block(LibraryContent::roots(LibraryContent::load( new Parser(), DiviFixtures::markup('stage3/library--library-abschnitt.html') ))[0]); $many = static fn (int $id, int $count): WP_Post => new WP_Post([ 'ID' => $id, 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => 'Viele ' . $id, 'post_content' => '' . str_repeat($section, $count) . '', ]); $items = array_map(static fn (int $id): WP_Post => LibraryFixtures::item($id), array_keys(LibraryFixtures::ITEMS)); LibraryFixtures::given(array_merge([$many(20, 150), $many(21, 120), $many(22, 5)], $items)); Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) []]); Functions\when('get_posts')->justReturn([20, 21, 22]); $result = self::ability()->execute(['library_id' => 1055, 'post_type' => 'page']); self::assertCount(LibraryFindSimilarAbility::MAX_MATCHES, $result['matches']); self::assertSame(2, $result['scanned']); self::assertSame(Cursor::encode(2), $result['next_cursor']); self::assertStringContainsString('200', $result['note']); Functions\when('get_posts')->justReturn([22]); $rest = self::ability()->execute(['library_id' => 1055, 'post_type' => 'page', 'cursor' => Cursor::encode(2)]); self::assertCount(5, $rest['matches']); self::assertArrayNotHasKey('note', $rest); } /** Ruling Q25: raw HTML next to the blocks is visible – such a copy is only "structure", never "identical". */ public function testExtraVisibleTextIsNotIdentical(): void { $root = LibraryContent::roots(LibraryContent::load( new Parser(), DiviFixtures::markup('stage3/library--library-abschnitt.html') ))[0]; $copy = Serializer::block($root); $at = strrpos($copy, ''); $changed = substr($copy, 0, (int) $at) . '

ONLY ON 8

' . substr($copy, (int) $at); LibraryFixtures::given([ LibraryFixtures::item(1055), new WP_Post(['ID' => 8, 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => 'Über uns', 'post_content' => '' . $changed . '']), ]); Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) [ 'edit_posts' => 'edit_posts', 'edit_others_posts' => 'edit_others_posts', ]]); Functions\when('get_posts')->justReturn([8]); $result = self::ability()->execute(['library_id' => 1055, 'post_type' => 'page']); self::assertSame(['8 s0 structure'], array_map( static fn (array $m): string => $m['post_id'] . ' ' . $m['id'] . ' ' . $m['match'], $result['matches'] )); } }