Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php
T
janniclas.loosenandClaude Opus 5.5 fec5d9da7b fix: GlobalUsages finds every reference Divi renders, fail closed (F1, ruling P23)
Candidates come from one ID/status/type query: content with globalModule,
global_module or a JSON-escaped letter. Their content is read in chunks of
50 and parsed by UsageMatcher: Divi 5 openers are json-decoded (escaped keys
and values, leading zeros, any depth), and Divi 4 global_module attributes
are read in any quoting. An undecodable escaped opener counts as a possible
reference. Beyond 5000 candidates the rest count unread. The guide names
the formats and the wp_block parity. Also fixes the grammar of the
blocked-hidden count.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 04:42:46 +02:00

201 lines
8.8 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\Divi\Library;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class GlobalUsagesTest extends TestCase
{
/**
* Ruling P23 (re-review F1): candidates by a wide, fail-closed prefilter (ID, status and type
* only), then their content in chunks, parsed in PHP (UsageMatcher) – every format Divi renders.
*/
public function testFindsEveryFormatDiviRendersFromCandidates(): void
{
$bs = chr(92);
$contents = [
1053 => '<!-- wp:divi/global-layout {"globalModule":"1058","blockName":"divi/text"} /-->',
1054 => '[et_pb_section global_module="01058"][/et_pb_section]',
1055 => '<!-- wp:divi/global-layout {"' . $bs . 'u0067lobalModule":"1058"} /-->',
1056 => '<!-- wp:divi/global-layout {"globalModule":"' . $bs . 'u00310' . '58"} /-->',
1057 => '<!-- wp:divi/global-layout {"globalModule":"1059"} /-->',
1061 => '<!-- wp:divi/global-layout {"' . $bs . 'u0067lobalModule":"1058",} /-->',
];
$db = Mockery::mock(\wpdb::class)->makePartial();
$queries = [];
$db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$queries, $contents): array {
$queries[] = $sql;
if (str_contains($sql, 'post_content FROM')) {
preg_match('/IN \(([0-9, ]+)\)/', $sql, $in);
$ids = array_map('intval', explode(',', $in[1]));
return array_map(static fn (int $id): array => ['ID' => (string) $id, 'post_content' => $contents[$id]], $ids);
}
return array_map(
static fn (int $id): array => ['ID' => (string) $id, 'post_status' => 'publish', 'post_type' => 'page'],
array_keys($contents)
);
});
$rows = (new GlobalUsages($db))->rows(1058);
self::assertSame([1053, 1054, 1055, 1056, 1061], array_column($rows, 'id'), '1061: undecodable, fail closed');
self::assertStringStartsWith('SELECT ID, post_status, post_type FROM', $queries[0]);
self::assertStringNotContainsString('post_content,', $queries[0]);
self::assertStringContainsString("post_content LIKE '%globalModule%'", $queries[0]);
self::assertStringContainsString("post_content LIKE '%global" . $bs . "_module%'", $queries[0]);
self::assertStringContainsString("post_content REGEXP '" . $bs . $bs . 'u00[4-7][0-9a-fA-F]' . "'", $queries[0]);
self::assertStringContainsString("post_status NOT IN ('trash', 'auto-draft', 'inherit')", $queries[0]);
self::assertStringContainsString("post_type <> 'revision'", $queries[0]);
self::assertCount(2, $queries, 'one candidate query, one content chunk');
}
/** Audit L1: contents are read in chunks; beyond the parse cap candidates count as references. */
public function testReadsContentsInChunksAndFailsClosedBeyondTheCap(): void
{
$total = GlobalUsages::PARSE_CAP + 3;
$db = Mockery::mock(\wpdb::class)->makePartial();
$chunks = 0;
$db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$chunks, $total): array {
if (str_contains($sql, 'post_content FROM')) {
++$chunks;
preg_match('/IN \(([0-9, ]+)\)/', $sql, $in);
return array_map(
static fn (string $id): array => ['ID' => trim($id), 'post_content' => 'nichts'],
explode(',', $in[1])
);
}
return array_map(
static fn (int $id): array => ['ID' => (string) $id, 'post_status' => 'draft', 'post_type' => 'page'],
range(1, $total)
);
});
$rows = (new GlobalUsages($db))->rows(1058);
self::assertSame((int) ceil(GlobalUsages::PARSE_CAP / GlobalUsages::CONTENT_CHUNK), $chunks);
self::assertSame(range(GlobalUsages::PARSE_CAP + 1, $total), array_column($rows, 'id'));
}
/** Audit L4: readable posts with title, unreadable ones only counted. */
public function testListsReadablePostsAndCountsTheOthers(): void
{
$usages = LibraryFixtures::usages([
['id' => 1053, 'status' => 'draft', 'type' => 'page'],
['id' => 1061, 'status' => 'private', 'type' => 'et_header_layout'],
]);
Functions\when('get_post')->alias(
static fn (int $id): WP_Post => new WP_Post(['ID' => $id, 'post_title' => 'Titel ' . $id])
);
Functions\when('current_user_can')->alias(
static fn (string $cap, int $id): bool => $id !== 1061
);
$report = $usages->report(1058);
self::assertSame(2, $report->count);
self::assertSame(
[['post_id' => 1053, 'title' => 'Titel 1053', 'status' => 'draft', 'type' => 'page']],
$report->items
);
self::assertSame(1, $report->hidden);
self::assertSame(1, $report->live);
self::assertSame([], $report->blocked);
self::assertSame(1, $report->blockedHidden);
self::assertSame(
['affected' => $report->items, 'affected_count' => 2, 'hidden_count' => 1, 'blocked_hidden_count' => 1],
$report->details()
);
}
/** Audit L1: rights are checked in chunks, each with its own primed cache that is dropped afterwards. */
public function testChecksLargeSetsInChunksAndReleasesTheCache(): void
{
$usages = LibraryFixtures::usages(LibraryFixtures::rows(range(1, 450)));
$primed = [];
Functions\when('_prime_post_caches')->alias(static function (array $ids) use (&$primed): void {
$primed[] = count($ids);
});
$deleted = 0;
Functions\when('wp_cache_delete')->alias(static function () use (&$deleted): bool {
++$deleted;
return true;
});
Functions\when('get_post')->justReturn(null);
Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id % 100 !== 0);
$report = $usages->report(1058);
self::assertSame([GlobalUsages::CHUNK, GlobalUsages::CHUNK, 50], $primed);
self::assertSame(450, $deleted);
self::assertSame(450, $report->count);
self::assertCount(GlobalUsages::LIMIT, $report->items);
self::assertSame(4, $report->hidden);
self::assertSame(4, $report->blockedHidden);
self::assertTrue($report->blocks());
}
/** Audit L1: above the hard cap nothing is checked for edit_post, and the report blocks. */
public function testAboveTheHardCapNoPostIsCheckedForEditing(): void
{
$usages = LibraryFixtures::usages(LibraryFixtures::rows(range(1, GlobalUsages::HARD_CAP + 1)));
Functions\when('get_post')->justReturn(null);
$asked = [];
Functions\when('current_user_can')->alias(static function (string $cap) use (&$asked): bool {
$asked[$cap] = ($asked[$cap] ?? 0) + 1;
return true;
});
$report = $usages->report(1058);
self::assertTrue($report->tooMany);
self::assertTrue($report->blocks());
self::assertArrayNotHasKey('edit_post', $asked);
self::assertSame(GlobalUsages::CHECK_CAP, $asked['read_post']);
}
/** Audit L6: Theme Builder layouts additionally need Divi's role option theme_builder. */
public function testThemeBuilderLayoutsNeedTheThemeBuilderRole(): void
{
$rows = [
['id' => 1053, 'status' => 'publish', 'type' => 'page'],
['id' => 1061, 'status' => 'publish', 'type' => 'et_header_layout'],
['id' => 1062, 'status' => 'publish', 'type' => 'et_body_layout'],
['id' => 1063, 'status' => 'publish', 'type' => 'et_footer_layout'],
['id' => 1064, 'status' => 'publish', 'type' => 'et_template'],
];
Functions\when('get_post')->justReturn(null);
Functions\when('current_user_can')->justReturn(true);
$without = LibraryFixtures::usages($rows, LibraryFixtures::roles(['theme_builder']))->report(1058);
$with = LibraryFixtures::usages($rows, LibraryFixtures::roles())->report(1058);
self::assertSame([1061, 1062, 1063, 1064], $without->blocked);
self::assertSame([], $with->blocked);
}
public function testAListingSkipsTheEditChecks(): void
{
$usages = LibraryFixtures::usages(LibraryFixtures::rows([1053]));
Functions\when('get_post')->justReturn(null);
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_post');
$report = $usages->report(1058, false);
self::assertFalse($report->blocks());
self::assertSame(['affected', 'affected_count'], array_keys($report->listing()));
}
}