*/ private array $queries = []; /** * @param array $contents Candidate post ID => post_content. * @param list $featured * @param list $fields * @param array $options Option name => value. * @param array $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 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 => '', 9 => ''], [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 => '
', 4 => '', 5 => '
', 6 => '
', 7 => '', 8 => '', 9 => '', 10 => '', ]; $usages = $this->finder($contents)->usages(33); self::assertSame(array_fill_keys(range(1, 10), 'sicher'), self::certainties($usages)); } public function testFileUrlMatchesAreOnlyPossibleUsages(): void { $contents = [ 1 => '', 2 => '[et_pb_image src="https://k.de/wp-content/uploads/2026/09/foto-scaled.jpg"]', 3 => '', ]; self::assertSame( [1 => 'möglicherweise', 2 => 'möglicherweise', 3 => 'möglicherweise'], self::certainties($this->finder($contents)->usages(33)) ); } public function testLookalikesAreNoUsage(): void { $contents = [ 1 => '', 2 => ' ', 3 => '[gallery ids="330,1334"] ', 4 => ' /2025/09/foto.jpg', 5 => '', ]; $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' => '
'], 5 => ['content' => ''], 6 => ['content' => '

nichts

'], '_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 => ''], [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 '])->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 ); } } } }