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
379 lines
16 KiB
PHP
379 lines
16 KiB
PHP
<?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
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|