Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php
T
janniclas.loosenandClaude Opus 5.5 43dc3fb889 fix: global usages follow the Theme Builder access (ruling Q17 M1)
GlobalUsages gets the Theme Builder access injected as a closure
(ThemeBuilderAccess::usageState(), wired at both construction sites through
DiviIntegration::globalUsages()). Theme Builder posts are named only while
the Theme Builder is visible to the account, otherwise only counted (also
in blocked_ids); a global edit that changes them needs canUse(), a writable
"Divi Theme Builder" group and edit_post. Without an injected access the
fail-safe default checks what canUse() checks. The duplicate
THEME_BUILDER_TYPES list gives way to DiviThemeBuilder::postTypes().

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

286 lines
13 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]);
// Ruling P24: case-insensitive whatever the column collation.
self::assertStringContainsString("LOWER(post_content) LIKE '%globalmodule%'", $queries[0]);
self::assertStringContainsString("LOWER(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 = null): 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']);
}
/** @return list<array{id: int, status: string, type: string}> */
private static function themeBuilderRows(): array
{
return [
['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'],
];
}
/**
* Audit L6, ruling Q17 M1 (review I-2): without an injected Theme Builder access, Theme Builder rows need what
* ThemeBuilderAccess::canUse() needs – Divi's role option theme_builder, edit_others_posts and
* edit_theme_options; without them they are neither listed nor named, only counted – and still block.
*/
public function testThemeBuilderRowsNeedTheThemeBuilderRightsByDefault(): void
{
Functions\when('get_post')->justReturn(null);
Functions\when('current_user_can')->justReturn(true);
$with = LibraryFixtures::usages(self::themeBuilderRows(), LibraryFixtures::roles())->report(1058);
self::assertSame([], $with->blocked);
self::assertCount(5, $with->items);
$without = LibraryFixtures::usages(self::themeBuilderRows(), LibraryFixtures::roles(['theme_builder']))
->report(1058);
self::assertSame([1053], array_column($without->items, 'post_id'));
self::assertSame([], $without->blocked);
self::assertSame(4, $without->hidden);
self::assertSame(4, $without->blockedHidden);
self::assertTrue($without->blocks());
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_theme_options');
$noThemeOptions = LibraryFixtures::usages(self::themeBuilderRows(), LibraryFixtures::roles())->report(1058);
self::assertSame(4, $noThemeOptions->blockedHidden);
self::assertSame([], $noThemeOptions->blocked);
}
/** Ruling Q17 M1: a Theme Builder row the account may see but not use (no edit right in the TB) blocks by ID. */
public function testAVisibleButUnusableThemeBuilderRowBlocksByItsId(): void
{
Functions\when('get_post')->justReturn(null);
Functions\when('current_user_can')->justReturn(true);
$asked = 0;
$themeBuilder = static function () use (&$asked): array {
++$asked;
return ['visible' => true, 'usable' => false];
};
$report = LibraryFixtures::usages(self::themeBuilderRows(), null, $themeBuilder)->report(1058);
self::assertCount(5, $report->items);
self::assertSame([1061, 1062, 1063, 1064], $report->blocked);
self::assertSame(1, $asked, 'evaluated once per report');
}
/** Review I-2 scenario B: without Theme Builder visibility a TB row is only counted – never its ID, title or type. */
public function testWithoutThemeBuilderVisibilityRowsAreOnlyCounted(): void
{
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')->justReturn(true);
$report = LibraryFixtures::usages(
self::themeBuilderRows(),
null,
static fn (): array => ['visible' => false, 'usable' => false]
)->report(1058);
self::assertSame(
[['post_id' => 1053, 'title' => 'Titel 1053', 'status' => 'publish', 'type' => 'page']],
$report->items
);
self::assertSame(4, $report->hidden);
self::assertSame([], $report->blocked);
self::assertSame(4, $report->blockedHidden);
$json = (string) json_encode($report->details());
self::assertStringNotContainsString('1061', $json);
self::assertStringNotContainsString('et_header_layout', $json);
}
/** A page row is unaffected by the Theme Builder access. */
public function testPageRowsIgnoreTheThemeBuilderAccess(): void
{
Functions\when('get_post')->justReturn(null);
Functions\when('current_user_can')->alias(static fn (string $cap, mixed $id = null): bool => $cap !== 'edit_post');
$asked = 0;
$themeBuilder = static function () use (&$asked): array {
++$asked;
return ['visible' => false, 'usable' => false];
};
$report = LibraryFixtures::usages(LibraryFixtures::rows([1053]), null, $themeBuilder)->report(1058);
self::assertSame([1053], array_column($report->items, 'post_id'));
self::assertSame([1053], $report->blocked);
self::assertSame(0, $asked, 'no Theme Builder row, no Theme Builder check');
}
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()));
}
}