Files
WP-LIT-MCP/tests/Unit/Integrations/Media/MediaUsageFinderTest.php
T
janniclas.loosenandClaude Opus 5.5 7f136f6f4c fix: media usage check fails closed on database errors; refused deletes checked for partial state
MediaUsageFinder treats a failed query (null result or last_error) as a possible
usage and an incomplete scan, so media-delete refuses without force. A refused
wp_delete_attachment() is write_failed only when the attachment is intact,
otherwise partial_write. media-update: re-read hint on write_failed, normalized
ids in failures, documented lock resolution and kses on restore.

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

379 lines
16 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\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.
*/
private function finder(
array $contents = [],
array $featured = [],
array $fields = [],
array $options = [],
array $themeMods = [],
array $mediaKeys = [],
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')->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);
}
/** @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'));
}
public function testUnreadableEntriesHideTheirTitleAndDiviLogoIsPossible(): void
{
$usages = $this->finder(
[13 => '<img class="wp-image-33">'],
[self::post(20, 'Geist', 'ghost')],
[],
['et_divi' => ['divi_logo' => 'https://k.de/wp-content/uploads/2026/09/foto.jpg']]
)->usages(33);
self::assertSame(
[
['post_id' => 20, 'title' => '(nicht lesbar)', 'post_type' => 'ghost', 'where' => 'featured', 'certainty' => 'sicher'],
['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">'])->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
);
}
}
}
}