Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/ThemeBuilder/ThemeBuilderAccessTest.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

291 lines
15 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\ThemeBuilder;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Post;
final class ThemeBuilderAccessTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Functions\when('get_post_type_object')->justReturn(null);
}
/** Spec §4.5 + F12/F23: edit_others_posts and Divi's role option "Theme Builder". */
public function testUsingTheThemeBuilderNeedsEditOthersPostsAndTheDiviRole(): void
{
LibraryFixtures::given([]);
self::assertNull(ThemeBuilderFixtures::access()->canUse());
self::assertToolError(ThemeBuilderFixtures::access(['theme_builder'])->canUse(), 'forbidden');
self::assertFalse(ThemeBuilderFixtures::access(['theme_builder'])->visible());
self::assertFalse(ThemeBuilderFixtures::access([], true, null, false)->visible());
LibraryFixtures::given([], ['edit_others_posts']);
self::assertToolError(ThemeBuilderFixtures::access()->canUse(), 'forbidden');
}
/** Ruling Q17 M1 (review I-2): what a global library edit may do with Theme Builder posts (GlobalUsages). */
public function testUsageStateOfAGlobalEdit(): void
{
LibraryFixtures::given([]);
self::assertSame(['visible' => true, 'usable' => true], ThemeBuilderFixtures::access()->usageState());
self::assertSame(['visible' => true, 'usable' => false], ThemeBuilderFixtures::access([], false)->usageState());
self::assertSame(
['visible' => false, 'usable' => false],
ThemeBuilderFixtures::access([], true, null, false)->usageState()
);
self::assertSame(
['visible' => false, 'usable' => false],
ThemeBuilderFixtures::access(['theme_builder'])->usageState()
);
LibraryFixtures::given([], ['edit_theme_options']);
self::assertSame(['visible' => false, 'usable' => false], ThemeBuilderFixtures::access()->usageState());
}
/** Ruling Q15: Divi's Theme Builder page also needs edit_theme_options (theme-builder.php:2059-2063). */
public function testWithoutEditThemeOptionsTheThemeBuilderIsHidden(): void
{
LibraryFixtures::given([ThemeBuilderFixtures::layout(1062), ThemeBuilderFixtures::templatePost()], [
'edit_theme_options',
]);
$access = ThemeBuilderFixtures::access();
self::assertToolError($access->canUse(), 'forbidden');
self::assertStringContainsString('edit_theme_options', self::clientError($access->canUse())['message']);
self::assertFalse($access->visible());
self::assertFalse($access->canEditTemplate(ThemeBuilderFixtures::TEMPLATE));
self::assertToolError($access->canCreate(), 'forbidden');
$hidden = $access->layout(1062, false);
self::assertSame(self::clientError($hidden), self::clientError(PostGuard::notFound(1062)));
$template = self::assertToolError($access->template(ThemeBuilderFixtures::TEMPLATE, false), 'not_found');
self::assertSame('Theme-Builder-Vorlage 1066 gibt es nicht.', $template['message'], 'like a missing ID');
}
/** P12 pattern: without access a layout ID answers exactly like a missing one. */
public function testLayoutsAreHiddenLikeMissingIdsWithoutAccess(): void
{
$body = ThemeBuilderFixtures::layout(1062);
LibraryFixtures::given([$body, LibraryFixtures::page('')]);
self::assertInstanceOf(WP_Post::class, ThemeBuilderFixtures::access()->layout(1062, false));
self::assertNull(ThemeBuilderFixtures::access()->layout(7, false), 'a page is no layout');
$missing = ThemeBuilderFixtures::access(['theme_builder'])->layout(1062, false);
$hidden = ThemeBuilderFixtures::access([], true, null, false)->layout(1062, false);
self::assertToolError($missing, 'not_found');
self::assertSame(self::clientError($missing), self::clientError(\LIT\MCP\Integrations\WordPress\PostGuard::notFound(1062)));
self::assertToolError($hidden, 'not_found');
LibraryFixtures::given([$body], ['edit_post']);
self::assertToolError(ThemeBuilderFixtures::access()->layout(1062, false), 'forbidden');
}
public function testWritingALayoutNeedsTheGroupAndAPublishedLayout(): void
{
LibraryFixtures::given([ThemeBuilderFixtures::layout(1062), ThemeBuilderFixtures::layout(1063, ['post_status' => 'trash'])]);
self::assertInstanceOf(WP_Post::class, ThemeBuilderFixtures::access()->layout(1062, true));
self::assertToolError(ThemeBuilderFixtures::access([], false)->layout(1062, true), 'forbidden');
self::assertToolError(ThemeBuilderFixtures::access()->layout(1063, true), 'validation_failed');
}
public function testTemplatesAreTheOnlyTemplateIds(): void
{
LibraryFixtures::given([ThemeBuilderFixtures::templatePost(), ThemeBuilderFixtures::layout(1062)]);
self::assertInstanceOf(WP_Post::class, ThemeBuilderFixtures::access()->template(1066, true));
self::assertToolError(ThemeBuilderFixtures::access()->template(1062, false), 'not_found');
self::assertToolError(ThemeBuilderFixtures::access()->template(999, false), 'not_found');
self::assertToolError(ThemeBuilderFixtures::access()->template('x', false), 'validation_failed');
self::assertToolError(ThemeBuilderFixtures::access(['theme_builder'])->template(1066, false), 'not_found');
self::assertToolError(ThemeBuilderFixtures::access([], false)->template(1066, true), 'forbidden');
}
/** Ruling Q18: a template the user may not edit answers exactly like a missing one (like tb-list, Q8/B11). */
public function testATemplateTheUserMayNotEditReadsLikeAMissingOne(): void
{
LibraryFixtures::given([ThemeBuilderFixtures::templatePost()], ['edit_post']);
foreach ([false, true] as $forWrite) {
$hidden = ThemeBuilderFixtures::access()->template(ThemeBuilderFixtures::TEMPLATE, $forWrite);
$missing = ThemeBuilderFixtures::access()->template(1067, $forWrite);
self::assertToolError($hidden, 'not_found');
self::assertSame(
str_replace('1067', '1066', (string) json_encode(self::clientError($missing))),
(string) json_encode(self::clientError($hidden))
);
}
}
public function testCreatingNeedsPublishRightsAndTheGroup(): void
{
LibraryFixtures::given([]);
self::assertNull(ThemeBuilderFixtures::access()->canCreate());
self::assertToolError(ThemeBuilderFixtures::access([], false)->canCreate(), 'forbidden');
LibraryFixtures::given([], ['publish_posts']);
self::assertToolError(ThemeBuilderFixtures::access()->canCreate(), 'forbidden');
}
/** E7: an unsaved Theme Builder session would later replace the template list. */
public function testAThemeBuilderDraftBlocksWrites(): void
{
self::assertNull(ThemeBuilderFixtures::access()->draftRefusal());
$blocked = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([], 999))->draftRefusal();
self::assertToolError($blocked, 'unsupported');
self::assertStringContainsString('Entwurf', self::clientError($blocked)['message']);
}
/** Ruling Q8/B11: tb-list names a template's ID only to a user who may edit it. */
public function testTemplateIdsAreNamedOnlyToEditors(): void
{
LibraryFixtures::given([], []);
self::assertTrue(ThemeBuilderFixtures::access()->canEditTemplate(1066));
self::assertFalse(ThemeBuilderFixtures::access(['theme_builder'])->canEditTemplate(1066));
self::assertFalse(ThemeBuilderFixtures::access([], true, null, false)->canEditTemplate(1066));
LibraryFixtures::given([], ['edit_post']);
self::assertFalse(ThemeBuilderFixtures::access()->canEditTemplate(1066));
}
/** Ruling Q8/B10: reattach/activate only with published layouts of the right area (Divi trashes marked ones). */
public function testLayoutsMustBePublishedBeforeTheTemplateGoesBackOrLive(): void
{
LibraryFixtures::given([
ThemeBuilderFixtures::layout(1061),
ThemeBuilderFixtures::layout(1062),
ThemeBuilderFixtures::layout(1063),
]);
self::assertNull(ThemeBuilderFixtures::access()->layoutsRefusal(ThemeBuilderFixtures::template()));
self::assertNull(ThemeBuilderFixtures::access()->layoutsRefusal(
ThemeBuilderFixtures::template(ThemeBuilderFixtures::DEFAULT_TEMPLATE)
), 'areas without a layout (ID 0) need nothing');
LibraryFixtures::given([
ThemeBuilderFixtures::layout(1061),
ThemeBuilderFixtures::layout(1062, ['post_status' => 'trash']),
]);
$refusal = ThemeBuilderFixtures::access()->layoutsRefusal(ThemeBuilderFixtures::template());
self::assertToolError($refusal, 'validation_failed');
self::assertSame(['layouts.body', 'layouts.footer'], array_keys(self::errorFields($refusal)));
LibraryFixtures::given([
ThemeBuilderFixtures::layout(1061),
ThemeBuilderFixtures::layout(1063, ['ID' => 1062]),
ThemeBuilderFixtures::layout(1063),
]);
$refusal = ThemeBuilderFixtures::access()->layoutsRefusal(ThemeBuilderFixtures::template());
self::assertSame(['layouts.body'], array_keys(self::errorFields($refusal)), 'a footer is no body');
}
public function testChangeableTemplateNeedsRightsNoDraftNoDefaultAndTheCurrentRev(): void
{
LibraryFixtures::given([ThemeBuilderFixtures::templatePost(1066), ThemeBuilderFixtures::templatePost(1065)]);
$store = ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1065), ThemeBuilderFixtures::template(1066)]);
$access = ThemeBuilderFixtures::access([], true, $store);
$rev = ThemeBuilderFixtures::template(1066)->rev();
self::assertInstanceOf(Template::class, $access->changeableTemplate(['template_id' => 1066, 'template_rev' => $rev]));
self::assertToolError($access->changeableTemplate(['template_id' => 1066, 'template_rev' => 'alt']), 'rev_mismatch');
self::assertToolError(
$access->changeableTemplate(['template_id' => 1065, 'template_rev' => ThemeBuilderFixtures::template(1065)->rev()]),
'unsupported'
);
$draft = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1066)], 999));
self::assertToolError($draft->changeableTemplate(['template_id' => 1066, 'template_rev' => $rev]), 'unsupported');
self::assertToolError(
ThemeBuilderFixtures::access([], false, $store)->changeableTemplate(['template_id' => 1066, 'template_rev' => $rev]),
'forbidden'
);
}
/** Ruling Q18: a template the user may not edit answers like a missing one, also for writing. */
public function testChangeableTemplateHidesTemplatesTheUserMayNotEdit(): void
{
$store = ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1066)]);
$rev = ThemeBuilderFixtures::template(1066)->rev();
LibraryFixtures::given([ThemeBuilderFixtures::templatePost(1066)], ['edit_post']);
$hidden = ThemeBuilderFixtures::access([], true, $store)->changeableTemplate(['template_id' => 1066, 'template_rev' => $rev]);
LibraryFixtures::given([]);
$missing = ThemeBuilderFixtures::access([], true, $store)->changeableTemplate(['template_id' => 1066, 'template_rev' => $rev]);
self::assertToolError($hidden, 'not_found');
self::assertSame(self::clientError($missing), self::clientError($hidden));
}
/**
* Ruling Q16: Divi renders an attached, enabled template whatever its post status (B8) – the off-switch
* (offSwitch: true) reaches it in the trash; everything else about it reads like a missing template.
*/
public function testTheOffSwitchReachesALiveTemplateOutsidePublish(): void
{
$trashed = ThemeBuilderFixtures::template(1066, ['enabled' => true, 'status' => 'trash']);
$input = ['template_id' => 1066, 'template_rev' => $trashed->rev()];
LibraryFixtures::given([ThemeBuilderFixtures::templatePost(1066, ['post_status' => 'trash'])]);
$access = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([$trashed]));
self::assertSame($trashed, $access->changeableTemplate($input, true));
$missing = $access->changeableTemplate($input);
self::assertToolError($missing, 'not_found');
$notLive = [
ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([], 0, [], [$trashed])),
ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([
ThemeBuilderFixtures::template(1066, ['status' => 'trash']),
])),
ThemeBuilderFixtures::access(['theme_builder'], true, ThemeBuilderFixtures::store([$trashed])),
];
foreach ($notLive as $index => $other) {
$refused = $other->changeableTemplate($input, true);
self::assertToolError($refused, 'not_found', (string) $index);
self::assertSame(self::clientError($missing), self::clientError($refused), (string) $index);
}
self::assertToolError(
ThemeBuilderFixtures::access([], false, ThemeBuilderFixtures::store([$trashed]))->changeableTemplate($input, true),
'forbidden'
);
self::assertToolError(
ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([$trashed]))
->changeableTemplate(['template_id' => 1066, 'template_rev' => 'alt'], true),
'rev_mismatch'
);
}
/** Ruling Q21: one split of the live list by canEditTemplate(). */
public function testEditableLiveTemplatesSplitsByTheRightToEdit(): void
{
LibraryFixtures::given([]);
Functions\when('current_user_can')->alias(
static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 1070)
);
$mine = ThemeBuilderFixtures::template(1066);
$other = ThemeBuilderFixtures::template(1070);
$split = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([$mine, $other]))->editableLiveTemplates();
self::assertSame([$mine], $split['templates']);
self::assertSame([$other], $split['hidden']);
}
/** Ruling Q20: the off-switch works while the Theme Builder has unsaved changes; nothing else does. */
public function testTheOffSwitchIgnoresTheThemeBuilderDraft(): void
{
LibraryFixtures::given([ThemeBuilderFixtures::templatePost(1066)]);
$template = ThemeBuilderFixtures::template(1066, ['enabled' => true]);
$access = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([$template], 999));
$input = ['template_id' => 1066, 'template_rev' => $template->rev()];
self::assertSame($template, $access->changeableTemplate($input, true));
self::assertToolError($access->changeableTemplate($input), 'unsupported');
}
}