- 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
322 lines
17 KiB
PHP
322 lines
17 KiB
PHP
<?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');
|
||
}
|
||
}
|