Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Library/LibraryFindSimilarAbilityTest.php
T
janniclas.loosenandClaude Opus 5.5 4e959833b9 fix: identical means identical bytes – text parts count, pages and posts only (ruling Q25)
- Fingerprint::content() includes non-whitespace text parts (raw HTML between
  blocks, inner HTML of core blocks) and opaque blocks with all their bytes;
  structure() unchanged; find-similar "identical" follows
- make-global mode 2 also compares Fingerprint::canonical() bytes of each node
- sources and targets: post types page and post only (POST_TYPES)
- a rolled-back page N>1 keeps DiviSaver's "Revision X bleibt erhalten"
- guide: escaped divi/global-layout re-expanded by Divi's do_blocks in
  shortcode-module and WooCommerce text output documented as residual risk

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-27 21:59:20 +02:00

245 lines
11 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\Library;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Cursor;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Library\LibraryFindSimilarAbility;
use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Post;
final class LibraryFindSimilarAbilityTest extends TestCase
{
private string $variation = '';
protected function setUp(): void
{
parent::setUp();
$root = LibraryContent::roots(LibraryContent::load(
new Parser(),
DiviFixtures::markup('stage3/library--library-abschnitt.html')
))[0];
$copy = Serializer::block($root);
$this->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' => '<!-- wp:divi/placeholder -->' . $this->variation . $copy . '<!-- /wp:divi/placeholder -->',
]),
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<list<int>> */
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' => '<!-- wp:divi/placeholder -->' . str_repeat($section, $count)
. '<!-- /wp:divi/placeholder -->',
]);
$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, '<!-- /wp:divi/column -->');
$changed = substr($copy, 0, (int) $at) . '<p>ONLY ON 8</p>' . 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' => '<!-- wp:divi/placeholder -->' . $changed . '<!-- /wp:divi/placeholder -->']),
]);
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']
));
}
}