Files
WP-LIT-MCP/tests/Unit/Integrations/Media/MediaUsageFinderTest.php
T

437 lines
17 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\Media;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Media\MediaUsageFinder;
use LIT\MCP\Integrations\Media\MediaUsageSource;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use Mockery\MockInterface;
final class MediaUsageFinderTest extends TestCase
{
/** @var list<string> */
private array $queries = [];
/**
* @param array<int, string> $contents Candidate post ID => post_content.
* @param list<object> $featured
* @param list<object> $fields
* @param array<string, mixed> $options Option name => value.
* @param array<string, mixed> $themeMods Theme mod name => value.
* @param list<MediaUsageSource> $sources
*/
private function finder(
array $contents = [],
array $featured = [],
array $fields = [],
array $options = [],
array $themeMods = [],
array $mediaKeys = [],
string $file = '2026/09/foto-scaled.jpg',
array $sources = []
): 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')->alias(static fn (string $name) => $themeMods[$name] ?? false);
Functions\when('post_type_exists')->alias(static fn (string $type): bool => $type !== 'ghost');
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')) {
// No regex here: the PCRE limit test runs this with a backtrack limit of 1.
$list = substr($sql, (int) strpos($sql, 'p.ID IN (') + strlen('p.ID IN ('));
$ids = array_map('intval', explode(',', substr($list, 0, (int) strpos($list, ')'))));
$rows = [];
foreach ($ids as $id) {
if (isset($contents[$id])) {
$rows[] = (object) ['ID' => $id, 'post_content' => $contents[$id]];
}
}
return $rows;
}
return [];
});
return new MediaUsageFinder(static fn (): array => $mediaKeys, $db, $sources);
}
/** @return array<int|string, string> post_id (or title for site usages) => certainty */
private static function certainties(array $usages): array
{
$result = [];
foreach ($usages as $usage) {
$result[$usage['post_id'] === 0 ? $usage['title'] : $usage['post_id']] = $usage['certainty'];
}
return $result;
}
private static function post(int $id, string $title, string $type = 'page'): object
{
return (object) ['ID' => $id, 'post_title' => $title, 'post_type' => $type];
}
public function testCombinesFeaturedFieldSiteAndContentUsages(): void
{
$usages = $this->finder(
[5 => '<img class="wp-image-33">', 9 => '<img class="wp-image-33 size-full">'],
[self::post(5, 'Team')],
[self::post(12, 'Anna', 'lit_person')],
['site_icon' => '33'],
['custom_logo' => 33],
['photo']
)->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 testQueriesArePreparedBoundedAndNewestFirst(): void
{
$this->finder([5 => 'x'], [], [], [], [], ['photo'])->usages(33, 7);
[$featured, $fields, $candidates] = $this->queries;
self::assertStringContainsString("pm.meta_value = '33'", $featured);
self::assertStringContainsString('ORDER BY p.ID DESC LIMIT 8', $featured);
self::assertStringContainsString("pm.meta_key IN ('photo')", $fields);
self::assertStringContainsString('ORDER BY p.ID DESC LIMIT 8', $fields);
$likes = [
'%wp-image-33%', '%"id":33%', '%"id":"33"%', '%"id": 33%', '%"id": "33"%', '%"mediaId":33%',
'%"mediaId":"33"%', '%"mediaId": 33%', '%ids=%33%', '%"ids":%33%', '%galleryIds":%33%',
'%/2026/09/foto%', '%\\\\/2026\\\\/09\\\\/foto%',
];
foreach ($likes as $like) {
self::assertStringContainsString("'" . $like . "'", $candidates, $like);
}
self::assertStringContainsString('ORDER BY p.ID DESC 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]"}}}} /-->',
10 => '<!-- wp:divi/gallery {"image":{"advanced":{"galleryIds":{"desktop":{"value":[12,33]}}}}} /-->',
];
$usages = $this->finder($contents)->usages(33);
self::assertSame(array_fill_keys(range(1, 10), '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)->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',
5 => '<!-- wp:divi/gallery {"image":{"advanced":{"galleryIds":{"desktop":{"value":[330,3]}}}}} /-->',
];
$finder = $this->finder($contents);
self::assertSame([], $finder->usages(33));
self::assertFalse($finder->isUsed(33));
}
public function testCappedContentSearchIsNeverReportedAsUnused(): void
{
$finder = $this->finder(array_fill_keys(range(1, 200), 'nichts'));
self::assertTrue($finder->isUsed(33));
$scan = $finder->scan(33);
self::assertFalse($scan['complete']);
self::assertFalse($scan['truncated']);
self::assertSame(
[[
'post_id' => 0,
'title' => 'Inhaltssuche unvollständig – weitere Verwendungen möglich',
'post_type' => 'content',
'where' => 'content',
'certainty' => 'möglicherweise',
]],
$scan['usages']
);
}
public function testScanReportsTruncationBeyondMax(): void
{
$finder = $this->finder([], [self::post(1, 'A'), self::post(2, 'B'), self::post(3, 'C')]);
$scan = $finder->scan(33, 2);
self::assertCount(2, $scan['usages']);
self::assertTrue($scan['truncated']);
self::assertTrue($scan['complete']);
self::assertFalse($finder->scan(33, 3)['truncated']);
}
public function testRegexFailureCountsAsPossibleUsage(): void
{
$jit = ini_get('pcre.jit');
$backtrack = ini_get('pcre.backtrack_limit');
$finder = $this->finder([7 => str_repeat('wp-image-3 ', 200)]);
ini_set('pcre.jit', '0');
ini_set('pcre.backtrack_limit', '1');
try {
$usages = $finder->usages(33);
} finally {
ini_set('pcre.jit', (string) $jit);
ini_set('pcre.backtrack_limit', (string) $backtrack);
}
self::assertSame([7 => 'möglicherweise'], self::certainties($usages));
}
public function testWidgetsAndThemeMods(): void
{
$usages = $this->finder(
[],
[],
[],
[
'widget_media_image' => [3 => ['attachment_id' => 33, 'url' => 'x'], 4 => ['attachment_id' => 330], '_multiwidget' => 1],
'widget_media_gallery' => [2 => ['ids' => [5, 33]], 3 => ['ids' => [330]], '_multiwidget' => 1],
'widget_block' => [
4 => ['content' => '<!-- wp:image {"id":33} --><figure></figure><!-- /wp:image -->'],
5 => ['content' => '<img src="https://k.de/wp-content/uploads/2026/09/foto-150x150.jpg">'],
6 => ['content' => '<p>nichts</p>'],
'_multiwidget' => 1,
],
],
[
'header_image_data' => (object) ['attachment_id' => 33, 'url' => 'x'],
'background_image' => 'https://k.de/wp-content/uploads/2026/09/foto.jpg',
]
)->usages(33);
self::assertSame(
[
'Header-Bild' => 'sicher',
'Hintergrundbild' => 'möglicherweise',
'Widget media_image-3' => 'sicher',
'Widget media_gallery-2' => 'sicher',
'Widget block-4' => 'sicher',
'Widget block-5' => 'möglicherweise',
],
self::certainties($usages)
);
self::assertSame(['header_image', 'background_image', 'widget', 'widget', 'widget', 'widget'], array_column($usages, 'where'));
}
/**
* @param list<array<string, mixed>> $usages
* @param list<int> $asked Collects the attachment IDs the source is asked about.
*/
private static function source(array $usages, array &$asked = []): MediaUsageSource
{
return new class ($usages, $asked) implements MediaUsageSource {
public function __construct(private array $usages, private array &$asked)
{
}
public function usagesOf(int $attachmentId): array
{
$this->asked[] = $attachmentId;
return $this->usages;
}
};
}
public function testUnreadableEntriesHideTheirTitleAndASourceUsageIsListedAfterTheCoreOnes(): void
{
$logo = MediaUsageFinder::siteUsage('Divi-Logo', 'option', 'divi_logo', MediaUsageFinder::POSSIBLE);
$asked = [];
$usages = $this->finder(
[13 => '<img class="wp-image-33">'],
[self::post(20, 'Geist', 'ghost')],
[],
[],
[],
[],
'2026/09/foto-scaled.jpg',
[self::source([$logo], $asked)]
)->usages(33);
self::assertSame(
[
[
'post_id' => 20, 'title' => '(nicht lesbar)', 'post_type' => 'ghost',
'where' => 'featured', 'certainty' => 'sicher',
],
$logo,
[
'post_id' => 13, 'title' => '(nicht lesbar)', 'post_type' => 'page',
'where' => 'content', 'certainty' => 'sicher',
],
],
$usages
);
self::assertSame([33], $asked);
}
public function testASourcePlaceIsReportedOnceAndSourcesAreSkippedOnceTheLimitIsReached(): void
{
$first = MediaUsageFinder::siteUsage('Ort', 'option', 'place', MediaUsageFinder::POSSIBLE);
$second = MediaUsageFinder::siteUsage('Ort neu', 'option', 'place', MediaUsageFinder::CERTAIN);
$asked = [];
$usages = $this->finder([], [], [], [], [], [], '2026/09/foto.jpg', [
self::source([$first]),
self::source([$second]),
])->usages(33);
self::assertSame([$second], $usages);
$finder = $this->finder([], [self::post(5, 'Team')], [], [], [], [], '2026/09/foto.jpg', [
self::source([$first], $asked),
]);
self::assertTrue($finder->isUsed(33));
self::assertSame([], $asked, 'a usage was found before: the sources are not asked');
}
public function testIdReferenceWinsOverAFileMatchInTheSameContent(): void
{
$usages = $this->finder([5 => '/2026/09/foto.jpg <img class="wp-image-33">'])->usages(33);
self::assertSame([5 => 'sicher'], self::certainties($usages));
}
public function testNoFieldQueryWithoutMediaFieldsAndUnusedMedia(): void
{
self::assertFalse($this->finder()->isUsed(33));
self::assertCount(2, $this->queries);
}
public function testIsUsedStopsAfterTheFirstHit(): void
{
$finder = $this->finder([5 => 'wp-image-33'], [self::post(5, 'Team')], [], [], [], ['photo']);
self::assertTrue($finder->isUsed(33));
self::assertCount(1, $this->queries);
}
public function testWithoutAttachedFileNoFilePatterns(): void
{
$this->finder([], [], [], [], [], [], '')->usages(33);
self::assertStringNotContainsString('foto', $this->queries[1]);
self::assertStringNotContainsString("'%/%'", $this->queries[1]);
}
/**
* A finder whose query $failing (SQL fragment) fails – returns null or sets last_error.
*/
private function failingFinder(string $failing, bool $viaLastError): MediaUsageFinder
{
Functions\when('get_post_meta')->justReturn('2026/09/foto.jpg');
Functions\when('get_option')->justReturn(false);
Functions\when('get_theme_mod')->justReturn(false);
/** @var MockInterface&\wpdb $db */
$db = Mockery::mock(\wpdb::class)->makePartial();
$db->allows('get_results')->andReturnUsing(static function (string $sql) use ($db, $failing, $viaLastError) {
if (! str_contains($sql, $failing)) {
return [];
}
if ($viaLastError) {
$db->last_error = 'Lost connection to MySQL server';
return [];
}
return null;
});
return new MediaUsageFinder(static fn (): array => ['photo'], $db);
}
/** @return mixed Result of $run with error_log() redirected (a PHP internal, not stubbable). */
private static function quietly(callable $run): mixed
{
$file = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $file);
try {
return $run();
} finally {
ini_set('error_log', (string) $previous);
unlink($file);
}
}
public function testAFailedQueryNeverReportsUnused(): void
{
foreach (['_thumbnail_id', 'pm.meta_key IN', 'post_content LIKE'] as $failing) {
foreach ([true, false] as $viaLastError) {
$label = $failing . ($viaLastError ? ' (last_error)' : ' (null)');
self::assertTrue(self::quietly(fn () => $this->failingFinder($failing, $viaLastError)->isUsed(33)), $label);
$scan = self::quietly(fn () => $this->failingFinder($failing, $viaLastError)->scan(33));
self::assertFalse($scan['complete'], $label);
self::assertSame(
[['post_id' => 0, 'title' => MediaUsageFinder::FAILED_TITLE, 'post_type' => 'check', 'where' => 'unknown', 'certainty' => 'möglicherweise']],
$scan['usages'],
$label
);
}
}
}
}