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'); } }