Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/ThemeBuilder/ThemeBuilderAccessTest.php
T
janniclas.loosenandClaude Opus 5.5 14bc954ecc fix: Theme Builder minors of the final review (M-4, M-5, M-6, M-9, M-10, M-11, M-12 c)
- tb-list applies the detached limit after the edit filter (reads up to 500
  IDs) and notes when more are not listed (M-4); an invalid template_id is
  invalid input, shared with ThemeBuilderAccess::template() (M-5).
- Templates carry their status; tb-list notes that a live template outside
  publish (e.g. trashed) can only be switched off (M-10).
- changeableTemplate() refuses without canUse() first, like tb-list and
  tb-create-template; the layout/template refusal difference is documented
  (M-9).
- tb-create-template reads the default template from editableLiveTemplates()
  (M-11); the condition-ID character set (role slugs) is documented (M-6);
  tb-assign documents that a failed reattach keeps Divi's unused marks
  removed (M-12 c).

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

322 lines
17 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'
);
}
/**
* Review M-9: without the Theme Builder rights tb-assign and tb-activate refuse like tb-list and
* tb-create-template (forbidden, independent of the ID) instead of "Vorlage X gibt es nicht".
*/
public function testChangeableTemplateRefusesWithoutTheThemeBuilderRightsFirst(): void
{
LibraryFixtures::given([ThemeBuilderFixtures::templatePost(1066)]);
$store = ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1066)]);
$rev = ThemeBuilderFixtures::template(1066)->rev();
foreach ([1066, 4711, 'abc'] as $id) {
$refused = ThemeBuilderFixtures::access(['theme_builder'], true, $store)
->changeableTemplate(['template_id' => $id, 'template_rev' => $rev]);
self::assertToolError($refused, 'forbidden');
self::assertStringNotContainsString((string) $id, self::clientError($refused)['message']);
}
foreach ([true, false] as $offSwitch) {
self::assertToolError(
ThemeBuilderFixtures::access(['theme_builder'], true, $store)
->changeableTemplate(['template_id' => 1066, 'template_rev' => $rev], $offSwitch),
'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([], true, ThemeBuilderFixtures::store([$trashed]), false),
];
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);
}
// Review M-9: without the Theme Builder rights the refusal does not depend on the ID.
self::assertToolError(
ThemeBuilderFixtures::access(['theme_builder'], true, ThemeBuilderFixtures::store([$trashed]))
->changeableTemplate($input, true),
'forbidden'
);
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');
}
}