feat: media usage finder and media-get tool
Usage search distinguishes certain (ID referenced) and possible (file URL only) usages, hides titles the user cannot read, and keeps queries bounded. MediaGuard checks per-attachment capabilities and refuses trashed media unless the caller opts in. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Media;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Integrations\Media\MediaUsageFinder;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use Mockery\MockInterface;
|
||||
|
||||
final class MediaUsageFinderTest extends TestCase
|
||||
{
|
||||
/** @var list<string> */
|
||||
private array $queries = [];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
* @param array<int, string> $contents Candidate post ID => post_content.
|
||||
* @param list<object> $featured
|
||||
* @param list<object> $fields
|
||||
*/
|
||||
private function finder(
|
||||
array $contents = [],
|
||||
array $featured = [],
|
||||
array $fields = [],
|
||||
array $options = [],
|
||||
int $customLogo = 0,
|
||||
array $mediaKeys = ['photo'],
|
||||
string $file = '2026/09/foto-scaled.jpg'
|
||||
): MediaUsageFinder {
|
||||
Functions\when('get_post_meta')->justReturn($file);
|
||||
Functions\when('get_option')->alias(static fn (string $name) => $options[$name] ?? false);
|
||||
Functions\when('get_theme_mod')->justReturn($customLogo);
|
||||
Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $cap === 'read_post' && $id !== 13);
|
||||
|
||||
/** @var MockInterface&\wpdb $db */
|
||||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||||
$db->allows('get_results')->andReturnUsing(function (string $sql) use ($contents, $featured, $fields): array {
|
||||
$this->queries[] = $sql;
|
||||
if (str_contains($sql, "_thumbnail_id")) {
|
||||
return $featured;
|
||||
}
|
||||
if (str_contains($sql, 'pm.meta_key IN')) {
|
||||
return $fields;
|
||||
}
|
||||
if (str_contains($sql, 'post_content LIKE')) {
|
||||
return array_map(
|
||||
static fn (int $id): object => (object) ['ID' => $id, 'post_title' => 'Seite ' . $id, 'post_type' => 'page'],
|
||||
array_keys($contents)
|
||||
);
|
||||
}
|
||||
if (str_contains($sql, 'p.ID IN')) {
|
||||
$rows = [];
|
||||
foreach ($contents as $id => $content) {
|
||||
if (preg_match('/\b' . $id . '\b/', substr($sql, (int) strpos($sql, 'p.ID IN')))) {
|
||||
$rows[] = (object) ['ID' => $id, 'post_content' => $content];
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
return new MediaUsageFinder(static fn (): array => $mediaKeys, $db);
|
||||
}
|
||||
|
||||
/** @return array<int, string> post_id => certainty */
|
||||
private static function certainties(array $usages): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($usages as $usage) {
|
||||
$result[$usage['post_id']] = $usage['certainty'];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function testCombinesFeaturedFieldContentAndSiteUsages(): void
|
||||
{
|
||||
$usages = $this->finder(
|
||||
[5 => '<img class="wp-image-33">', 9 => '<img class="wp-image-33 size-full">'],
|
||||
[(object) ['ID' => 5, 'post_title' => 'Team', 'post_type' => 'page']],
|
||||
[(object) ['ID' => 12, 'post_title' => 'Anna', 'post_type' => 'lit_person']],
|
||||
['site_icon' => '33'],
|
||||
33
|
||||
)->usages(33);
|
||||
|
||||
self::assertSame(
|
||||
[
|
||||
['post_id' => 5, 'title' => 'Team', 'post_type' => 'page', 'where' => 'featured', 'certainty' => 'sicher'],
|
||||
['post_id' => 12, 'title' => 'Anna', 'post_type' => 'lit_person', 'where' => 'field', 'certainty' => 'sicher'],
|
||||
['post_id' => 0, 'title' => 'Website-Icon', 'post_type' => 'option', 'where' => 'site_icon', 'certainty' => 'sicher'],
|
||||
['post_id' => 0, 'title' => 'Logo (Customizer)', 'post_type' => 'theme_mod', 'where' => 'custom_logo', 'certainty' => 'sicher'],
|
||||
['post_id' => 9, 'title' => 'Seite 9', 'post_type' => 'page', 'where' => 'content', 'certainty' => 'sicher'],
|
||||
],
|
||||
$usages
|
||||
);
|
||||
}
|
||||
|
||||
public function testQueriesArePreparedAndBounded(): void
|
||||
{
|
||||
$this->finder([5 => 'x'])->usages(33, 7);
|
||||
|
||||
[$featured, $fields, $candidates] = $this->queries;
|
||||
self::assertStringContainsString("pm.meta_value = '33'", $featured);
|
||||
self::assertStringContainsString('LIMIT 7', $featured);
|
||||
self::assertStringContainsString("pm.meta_key IN ('photo')", $fields);
|
||||
self::assertStringContainsString('LIMIT 7', $fields);
|
||||
foreach (['%wp-image-33%', '%"id":33%', '%"id":"33"%', '%"mediaId":33%', '%ids=%33%', '%"ids":[%33%', '%galleryIds":%33%', '%/2026/09/foto%', '%\\\\/2026\\\\/09\\\\/foto%'] as $like) {
|
||||
self::assertStringContainsString("'" . $like . "'", $candidates, $like);
|
||||
}
|
||||
self::assertStringContainsString('LIMIT 200', $candidates);
|
||||
self::assertStringNotContainsString('post_content,', $candidates, 'candidates must not load content');
|
||||
}
|
||||
|
||||
public function testCertainContentReferencesByAttachmentId(): void
|
||||
{
|
||||
$contents = [
|
||||
1 => '[gallery ids="4,33,7"]',
|
||||
2 => '[et_pb_gallery gallery_ids="33,8"][/et_pb_gallery]',
|
||||
3 => '<!-- wp:image {"id":33,"sizeSlug":"large"} --><figure></figure><!-- /wp:image -->',
|
||||
4 => '<!-- wp:gallery {"ids":[2,33]} /-->',
|
||||
5 => '<!-- wp:media-text {"mediaId":33,"mediaType":"image"} --><div></div><!-- /wp:media-text -->',
|
||||
6 => '<!-- wp:cover {"url":"x","id":33} --><div></div><!-- /wp:cover -->',
|
||||
7 => '<!-- wp:divi/image {"image":{"innerContent":{"desktop":{"value":{"src":"https://k.de/a.jpg","id":"33"}}}}} /-->',
|
||||
8 => '<!-- wp:divi/gallery {"image":{"advanced":{"galleryIds":{"desktop":{"value":"12,33"}}}}} /-->',
|
||||
9 => '<!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"[et_pb_gallery gallery_ids=\\u002233\\u0022]"}}}} /-->',
|
||||
];
|
||||
|
||||
$usages = $this->finder($contents, [], [], [], 0, [])->usages(33);
|
||||
|
||||
self::assertSame(array_fill_keys(range(1, 9), 'sicher'), self::certainties($usages));
|
||||
}
|
||||
|
||||
public function testFileUrlMatchesAreOnlyPossibleUsages(): void
|
||||
{
|
||||
$contents = [
|
||||
1 => '<img src="https://k.de/wp-content/uploads/2026/09/foto-300x200.jpg">',
|
||||
2 => '[et_pb_image src="https://k.de/wp-content/uploads/2026/09/foto-scaled.jpg"]',
|
||||
3 => '<!-- wp:divi/image {"image":{"innerContent":{"desktop":{"value":{"src":"https:\/\/k.de\/wp-content\/uploads\/2026\/09\/foto.webp"}}}}} /-->',
|
||||
];
|
||||
|
||||
self::assertSame(
|
||||
[1 => 'möglicherweise', 2 => 'möglicherweise', 3 => 'möglicherweise'],
|
||||
self::certainties($this->finder($contents, [], [], [], 0, [])->usages(33))
|
||||
);
|
||||
}
|
||||
|
||||
public function testLookalikesAreNoUsage(): void
|
||||
{
|
||||
$contents = [
|
||||
1 => '<img class="wp-image-330">',
|
||||
2 => '<!-- wp:image {"id":330} /--> <!-- wp:gallery {"ids":[133,334]} /-->',
|
||||
3 => '[gallery ids="330,1334"] <!-- wp:paragraph {"id":33} /-->',
|
||||
4 => '<img src="https://k.de/wp-content/uploads/2026/09/foto-2.jpg"> /2025/09/foto.jpg',
|
||||
];
|
||||
|
||||
self::assertSame([], $this->finder($contents, [], [], [], 0, [])->usages(33));
|
||||
}
|
||||
|
||||
public function testUnreadableEntriesHideTheirTitleAndDiviLogoIsPossible(): void
|
||||
{
|
||||
$usages = $this->finder(
|
||||
[13 => '<img class="wp-image-33">'],
|
||||
[],
|
||||
[],
|
||||
['et_divi' => ['divi_logo' => 'https://k.de/wp-content/uploads/2026/09/foto.jpg']],
|
||||
0,
|
||||
[]
|
||||
)->usages(33);
|
||||
|
||||
self::assertSame(
|
||||
[
|
||||
['post_id' => 0, 'title' => 'Divi-Logo', 'post_type' => 'option', 'where' => 'divi_logo', 'certainty' => 'möglicherweise'],
|
||||
['post_id' => 13, 'title' => '(nicht lesbar)', 'post_type' => 'page', 'where' => 'content', 'certainty' => 'sicher'],
|
||||
],
|
||||
$usages
|
||||
);
|
||||
}
|
||||
|
||||
public function testIdReferenceWinsOverAFileMatchInTheSameContent(): void
|
||||
{
|
||||
$usages = $this->finder([5 => '/2026/09/foto.jpg <img class="wp-image-33">'], [], [], [], 0, [])->usages(33);
|
||||
|
||||
self::assertSame([5 => 'sicher'], self::certainties($usages));
|
||||
}
|
||||
|
||||
public function testNoFieldQueryWithoutMediaFieldsAndUnusedMedia(): void
|
||||
{
|
||||
self::assertFalse($this->finder([], [], [], [], 0, [])->isUsed(33));
|
||||
self::assertCount(2, $this->queries);
|
||||
}
|
||||
|
||||
public function testIsUsedStopsBeforeTheContentScanWhenAlreadyUsed(): void
|
||||
{
|
||||
$finder = $this->finder([5 => 'wp-image-33'], [(object) ['ID' => 5, 'post_title' => 'Team', 'post_type' => 'page']]);
|
||||
|
||||
self::assertTrue($finder->isUsed(33));
|
||||
self::assertCount(1, $this->queries);
|
||||
}
|
||||
|
||||
public function testWithoutAttachedFileNoFilePatterns(): void
|
||||
{
|
||||
$this->finder([], [], [], [], 0, [], '')->usages(33);
|
||||
|
||||
self::assertStringNotContainsString('foto', $this->queries[1]);
|
||||
self::assertStringNotContainsString("'%/%'", $this->queries[1]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user