diff --git a/src/Integrations/Divi/Pages/DiviContentComposer.php b/src/Integrations/Divi/Pages/DiviContentComposer.php index e00d946..0288114 100644 --- a/src/Integrations/Divi/Pages/DiviContentComposer.php +++ b/src/Integrations/Divi/Pages/DiviContentComposer.php @@ -6,8 +6,6 @@ namespace LIT\MCP\Integrations\Divi\Pages; use Closure; use LIT\MCP\Abilities\Errors; -use LIT\MCP\Integrations\Divi\BlockTree\Block; -use LIT\MCP\Integrations\Divi\BlockTree\Document; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\Outline; use LIT\MCP\Integrations\Divi\BlockTree\Serializer; @@ -70,7 +68,7 @@ final class DiviContentComposer implements ContentComposer ); } - $layout = $this->layout($this->composition->forPost(0), $sections); + $layout = LayoutBuilder::build($this->composition->forPost(0), $sections, 'divi'); return $layout instanceof WP_Error ? $layout : null; } @@ -78,7 +76,7 @@ final class DiviContentComposer implements ContentComposer public function write(int $postId, string $postType, array $sections): array|WP_Error { $composition = $this->composition->forPost($postId); - $layout = $this->layout($composition, $sections); + $layout = LayoutBuilder::build($composition, $sections, 'divi'); if ($layout instanceof WP_Error) { return $layout; } @@ -98,37 +96,4 @@ final class DiviContentComposer implements ContentComposer 'edit_url' => DiviPage::builderUrl($postId), ]; } - - /** - * The whole description composed and inserted into an empty document – or every error. - * - * @param list $sections - * @return array{document: Document, blocks: list}|WP_Error - */ - private function layout(DiviComposition $composition, array $sections): array|WP_Error - { - if ($sections === []) { - return Errors::invalidInput('Kein Divi-Inhalt angegeben; nichts wurde angelegt.', [ - 'divi' => 'mindestens ein Abschnitt', - ]); - } - - $composed = $composition->composer()->compose(array_values($sections), null, 'divi'); - if ($composed['errors'] !== []) { - return DiviErrors::composed($composed, 'Divi-Inhalt ist ungültig; nichts wurde angelegt.'); - } - - $document = new Document(); - $violation = $composition->editor()->insert( - ['container' => $document, 'parent' => null, 'index' => PHP_INT_MAX], - $composed['blocks'] - ); - if ($violation !== null) { - return Errors::invalidInput('Divi-Inhalt passt nicht auf die Seite; nichts wurde angelegt.', [ - 'divi' => $violation, - ]); - } - - return ['document' => $document, 'blocks' => $composed['blocks']]; - } } diff --git a/src/Integrations/Divi/Pages/LayoutBuilder.php b/src/Integrations/Divi/Pages/LayoutBuilder.php new file mode 100644 index 0000000..77f381d --- /dev/null +++ b/src/Integrations/Divi/Pages/LayoutBuilder.php @@ -0,0 +1,52 @@ + $sections + * @param string $field Input path the errors are reported under (e.g. "divi", "body.nodes"). + * @return array{document: Document, blocks: list}|WP_Error + */ + public static function build(DiviComposition $composition, array $sections, string $field): array|WP_Error + { + if ($sections === []) { + return Errors::invalidInput('Kein Divi-Inhalt angegeben; nichts wurde angelegt.', [ + $field => 'mindestens ein Abschnitt', + ]); + } + + $composed = $composition->composer()->compose(array_values($sections), null, $field); + if ($composed['errors'] !== []) { + return DiviErrors::composed($composed, 'Divi-Inhalt ist ungültig; nichts wurde angelegt.'); + } + + $document = new Document(); + $violation = $composition->editor()->insert( + ['container' => $document, 'parent' => null, 'index' => PHP_INT_MAX], + $composed['blocks'] + ); + if ($violation !== null) { + return Errors::invalidInput('Divi-Inhalt passt nicht auf die Seite; nichts wurde angelegt.', [ + $field => $violation, + ]); + } + + return ['document' => $document, 'blocks' => $composed['blocks']]; + } +} diff --git a/src/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbility.php b/src/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbility.php new file mode 100644 index 0000000..419a0fa --- /dev/null +++ b/src/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbility.php @@ -0,0 +1,385 @@ + 'Kopfzeile', 'body' => 'Inhalt', 'footer' => 'Fußzeile']; + + public function __construct( + private ThemeBuilderAccess $access, + private TemplateConditions $conditions, + private DiviComposition $composition, + private DiviSaver $saver, + private ChangeLog $log + ) { + } + + public function name(): string + { + return 'lit/tb-create-template'; + } + + public function label(): string + { + return 'Theme-Builder-Vorlage anlegen'; + } + + public function description(): string + { + return 'Legt eine Vorlage im Divi Theme Builder an – immer deaktiviert, also ohne Wirkung auf die Website. Je ' + . 'Bereich (header, body, footer) source "new" (neues Layout, optional mit nodes: Abschnitte in Kurz- oder ' + . 'Vollform wie bei lit-divi-insert, z. B. {{post:title}} oder {{field:}}), "global" (Kopf-/Fußzeile ' + . 'der Standardvorlage mitbenutzen), "default" (nichts Eigenes) oder "hidden" (Bereich ausblenden). ' + . 'Bedingungen optional (use_on, exclude_from; IDs aus lit/tb-list mit conditions: true). Aktivieren ' + . 'nur mit lit/tb-activate.'; + } + + public function inputSchema(): array + { + $area = [ + 'type' => 'object', + 'properties' => [ + 'source' => ['type' => 'string', 'enum' => self::SOURCES], + 'title' => ['type' => 'string', 'maxLength' => 200, 'description' => 'Titel des neuen Layouts.'], + 'nodes' => [ + 'type' => 'array', + 'maxItems' => 20, + 'items' => ['type' => 'object'], + 'description' => 'Nur mit source "new": Abschnitte wie bei lit-divi-insert.', + ], + ], + 'required' => ['source'], + 'additionalProperties' => false, + ]; + $conditions = ['type' => 'array', 'maxItems' => TemplateConditions::MAX, 'items' => ['type' => 'string']]; + + return [ + 'type' => 'object', + 'properties' => [ + 'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 200], + 'header' => $area, + 'body' => $area, + 'footer' => $area, + 'use_on' => $conditions, + 'exclude_from' => $conditions, + ], + 'required' => ['title'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema([ + 'template_id' => ['type' => 'integer'], + 'template_rev' => ['type' => 'string'], + 'enabled' => ['type' => 'boolean'], + 'layouts' => ['type' => 'object'], + ], ['template_id', 'template_rev', 'enabled']); + } + + public function annotations(): array + { + return Annotations::write(false); + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + return $this->access->canCreate() ?? $this->access->draftRefusal() ?? true; + } + + /** @throws WriteFailed After the first post exists (everything was rolled back first, if possible). */ + public function execute(array $input): mixed + { + $refusal = $this->access->canCreate() ?? $this->access->draftRefusal(); + if ($refusal !== null) { + return $refusal; + } + + $errors = []; + $title = sanitize_text_field(is_string($input['title'] ?? null) ? $input['title'] : ''); + if ($title === '') { + $errors['title'] = 'nicht leer'; + } + $plan = $this->plan($input, $title, $errors); + $useOn = $this->conditions->check($input['use_on'] ?? null, 'use_on'); + $excludeFrom = $this->conditions->check($input['exclude_from'] ?? null, 'exclude_from'); + $errors = array_merge($errors, $useOn['errors'], $excludeFrom['errors']); + foreach ($plan as $area => $spec) { + if ($spec['nodes'] !== []) { + $layout = LayoutBuilder::build($this->composition->forPost(0, $area), $spec['nodes'], $area . '.nodes'); + if ($layout instanceof WP_Error) { + $errors = array_merge($errors, self::fields($layout, $area)); + } + } + } + + if ($errors !== []) { + return Errors::invalidInput('Die Vorlage ist ungültig; nichts wurde angelegt.', $errors); + } + + return $this->create($title, $plan, $useOn['ids'], $excludeFrom['ids']); + } + + /** + * @param array $input + * @param array $errors + * @return array, id: int}> + */ + private function plan(array $input, string $title, array &$errors): array + { + $default = null; + $plan = []; + foreach (Template::AREAS as $area) { + $spec = is_array($input[$area] ?? null) ? $input[$area] : ['source' => 'default']; + $source = $spec['source'] ?? null; + if (! in_array($source, self::SOURCES, true)) { + $errors[$area . '.source'] = 'new, global, default oder hidden'; + continue; + } + + $nodes = is_array($spec['nodes'] ?? null) ? array_values($spec['nodes']) : []; + if ($nodes !== [] && $source !== 'new') { + $errors[$area . '.nodes'] = 'nur mit source "new"'; + } + + $id = 0; + if ($source === 'global') { + if ($area === 'body') { + $errors['body.source'] = 'für den Inhalt gibt es kein globales Layout'; + continue; + } + $default ??= $this->defaultTemplate(); + $id = $this->reusable($area, $default); + if ($id === null) { + $errors[$area . '.source'] = sprintf( + 'die Standardvorlage hat keine eigene %s, die sich mitbenutzen lässt', + self::AREA_LABELS[$area] + ); + continue; + } + } + + $layoutTitle = sanitize_text_field(is_string($spec['title'] ?? null) ? $spec['title'] : ''); + $plan[$area] = [ + 'source' => (string) $source, + 'title' => $layoutTitle !== '' ? $layoutTitle : sprintf('%s – %s', $title, self::AREA_LABELS[$area]), + 'nodes' => $nodes, + 'id' => $id, + ]; + } + + return $plan; + } + + private function defaultTemplate(): ?Template + { + foreach ($this->access->store()->liveTemplates() as $template) { + if ($template->default) { + return $template; + } + } + + return null; + } + + /** + * The default template's layout of $area when it can be reused: a published layout of that area the user may + * edit (ruling Q8 – Divi would silently drop anything else, F17). Missing, trashed and not editable read the + * same (P12). It is referenced by ID, never copied, so no stored content moves (StoredNodeGuard not needed). + */ + private function reusable(string $area, ?Template $default): ?int + { + $id = $default?->layouts[$area]['id'] ?? 0; + $post = $id > 0 ? get_post($id) : null; + if ( + ! $post instanceof WP_Post + || $post->post_type !== DiviThemeBuilder::LAYOUT_TYPES[$area] + || (string) $post->post_status !== 'publish' + || ! current_user_can('edit_post', $id) + ) { + return null; + } + + return $id; + } + + /** + * @param array, id: int}> $plan + * @param list $useOn + * @param list $excludeFrom + * @return array|WP_Error + * @throws WriteFailed + */ + private function create(string $title, array $plan, array $useOn, array $excludeFrom): array|WP_Error + { + $store = $this->access->store(); + $build = new TemplateBuild($store, $this->log, $this->name()); + $layouts = []; + $warnings = []; + try { + foreach ($plan as $area => $spec) { + $layouts[$area] = ['id' => $spec['id'], 'enabled' => $spec['source'] !== 'hidden']; + if ($spec['source'] !== 'new') { + continue; + } + $id = $build->layout($area, $spec['title']); + $layouts[$area]['id'] = $id; + if ($spec['nodes'] === []) { + continue; + } + $composition = $this->composition->forPost($id, $area); + $layout = LayoutBuilder::build($composition, $spec['nodes'], $area . '.nodes'); + if ($layout instanceof WP_Error) { + return $build->refuse($layout); + } + $warnings = array_merge( + $warnings, + $composition->warnings()->collect(NodeIndex::build($layout['document']), $layout['blocks']), + $this->saver->saveNew( + $id, + Serializer::document($layout['document']), + SaveScope::ThemeBuilderLayout + ) + ); + } + + $themeBuilderId = $build->themeBuilder(); + $templateId = $build->template($themeBuilderId, [ + 'title' => $title, + 'layouts' => $layouts, + 'use_on' => $useOn, + 'exclude_from' => $excludeFrom, + ]); + $mismatch = self::mismatch($store->template($templateId, false), $layouts, $useOn, $excludeFrom); + if ($mismatch !== null) { + throw new RuntimeException($mismatch); + } + $build->attach($themeBuilderId, $templateId); + } catch (WriteFailed $e) { + throw $e; + } catch (Throwable $e) { + $build->rollBack($e); + } + + $after = []; + FollowUp::run( + static fn () => $store->clearCaches(), + 'theme builder cache', + 'Der Cache des Theme Builders konnte nicht geleert werden; die Vorlage ist angelegt (deaktiviert).', + $after + ); + $newLayouts = []; + foreach ($plan as $area => $spec) { + if ($spec['source'] === 'new') { + $newLayouts[] = $area . ' ' . $layouts[$area]['id']; + } + } + $logId = $this->log->record( + $this->name(), + DiviThemeBuilder::TEMPLATE, + (string) $templateId, + sprintf( + 'Theme Builder: Vorlage „%s“ angelegt (deaktiviert; neue Layouts: %s)', + $title, + $newLayouts === [] ? 'keine' : implode(', ', $newLayouts) + ) + ); + $template = $store->template($templateId, true); + $out = []; + foreach ($layouts as $area => $layout) { + $out[$area] = $layout + ['source' => $plan[$area]['source']]; + } + + return ToolResult::ok(sprintf( + 'Theme-Builder-Vorlage „%s“ angelegt – deaktiviert, ohne Wirkung auf die Website. Aktivieren nur mit ' + . 'lit/tb-activate.', + $title + )) + ->with('template_id', $templateId) + ->with('template_rev', $template?->rev() ?? '') + ->with('enabled', false) + ->with('layouts', $out) + ->warnings(array_merge($warnings, $after, [self::STALE_EDITOR])) + ->logId($logId) + ->urls(null, (string) admin_url('admin.php?page=et_theme_builder')) + ->toArray(); + } + + /** + * Why the stored template is not what was asked for (Divi silently drops layout IDs, F17), else null. + * + * @param array $layouts + * @param list $useOn + * @param list $excludeFrom + */ + private static function mismatch(?Template $stored, array $layouts, array $useOn, array $excludeFrom): ?string + { + if ($stored === null) { + return 'template not readable after store'; + } + if ($stored->enabled || $stored->default) { + return 'template stored enabled or as default'; + } + foreach ($layouts as $area => $layout) { + if ($stored->layouts[$area] !== $layout) { + return sprintf('layout %s stored as %d', $area, $stored->layouts[$area]['id']); + } + } + + $same = $stored->useOn === $useOn && $stored->excludeFrom === $excludeFrom; + + return $same ? null : 'conditions stored differently'; + } + + /** @return array The refusal's fields, or its message under ".nodes". */ + private static function fields(WP_Error $error, string $area): array + { + $fields = Errors::fieldsOf($error); + + return $fields !== [] ? $fields : [$area . '.nodes' => Errors::clientMessage($error)]; + } +} diff --git a/src/Integrations/Divi/ThemeBuilder/TemplateBuild.php b/src/Integrations/Divi/ThemeBuilder/TemplateBuild.php new file mode 100644 index 0000000..0aeadcd --- /dev/null +++ b/src/Integrations/Divi/ThemeBuilder/TemplateBuild.php @@ -0,0 +1,163 @@ + */ + private array $created = []; + + /** @var array{0: int, 1: int}|null Theme Builder post, template. */ + private ?array $attached = null; + + public function __construct(private DiviThemeBuilder $store, private ChangeLog $log, private string $ability) + { + } + + /** @throws RuntimeException */ + public function layout(string $area, string $title): int + { + $id = $this->store->insertLayout($area, $title); + $this->created[] = $id; + + return $id; + } + + /** + * The live Theme Builder post; when the site has none yet, Divi creates it like on its first save and the + * new post belongs to this build (B12). + * + * @throws RuntimeException + */ + public function themeBuilder(): int + { + $id = $this->store->liveId(false); + if ($id > 0) { + return $id; + } + + $id = $this->store->liveId(true); + if ($id <= 0) { + throw new RuntimeException('no live Theme Builder post'); + } + $this->created[] = $id; + + return $id; + } + + /** + * @param array{title: string, layouts: array, use_on: list, + * exclude_from: list} $template + * @throws RuntimeException + */ + public function template(int $themeBuilderId, array $template): int + { + $id = $this->store->storeTemplate($themeBuilderId, $template); + $this->created[] = $id; + + return $id; + } + + /** @throws RuntimeException */ + public function attach(int $themeBuilderId, int $templateId): void + { + $this->attached = [$themeBuilderId, $templateId]; + $this->store->attach($themeBuilderId, $templateId); + } + + /** @return list */ + public function created(): array + { + return $this->created; + } + + /** + * A refusal after posts exist (a layout the new post's own dynamic content map refuses): everything created + * is removed, then the refusal is returned. + * + * @throws WriteFailed When the cleanup fails. + */ + public function refuse(WP_Error $refusal): WP_Error + { + $failures = $this->undo(); + if ($failures !== []) { + throw $this->partial(new RuntimeException($failures[0]), ''); + } + + return $refusal; + } + + /** @throws WriteFailed Always. */ + public function rollBack(Throwable $cause): never + { + $failures = $this->undo(); + if ($failures !== []) { + throw $this->partial($cause, '; rollback: ' . implode('; ', $failures)); + } + + throw WriteFailed::rolledBack( + self::REASON, + $cause, + 'Theme Builder template not created: ' . $cause->getMessage() + ); + } + + /** @return list */ + private function undo(): array + { + $steps = []; + if ($this->attached !== null) { + [$themeBuilderId, $templateId] = $this->attached; + $steps[] = fn () => $this->store->detach($themeBuilderId, $templateId); + } + foreach (array_reverse($this->created) as $id) { + $steps[] = static function () use ($id): void { + if (! wp_delete_post($id, true)) { + throw new RuntimeException(sprintf('post %d not deleted', $id)); + } + }; + } + + return Rollback::attempt(...$steps); + } + + private function partial(Throwable $cause, string $detail): WriteFailed + { + $ids = implode(', ', $this->created); + $this->log->recordPartial( + $this->ability, + DiviThemeBuilder::TEMPLATE, + $ids, + sprintf('Theme Builder: Anlegen nicht vollständig zurückgenommen (Beiträge %s)', $ids) + ); + + return WriteFailed::partial( + self::REASON, + $cause, + 'Theme Builder rollback incomplete' . $detail, + state: sprintf('Möglicherweise sind die Beiträge %s noch vorhanden (deaktiviert).', $ids), + hint: sprintf('Im Divi Theme Builder prüfen und die Beiträge %s bei Bedarf löschen.', $ids), + details: ['created_ids' => $this->created] + ); + } +} diff --git a/src/Integrations/Divi/ThemeBuilder/ThemeBuilderAccess.php b/src/Integrations/Divi/ThemeBuilder/ThemeBuilderAccess.php index 691e2eb..03bf700 100644 --- a/src/Integrations/Divi/ThemeBuilder/ThemeBuilderAccess.php +++ b/src/Integrations/Divi/ThemeBuilder/ThemeBuilderAccess.php @@ -124,7 +124,10 @@ class ThemeBuilderAccess return $post; } - /** Template $templateId for the Theme Builder tools; any other ID, or a hidden Theme Builder, is not found. */ + /** + * Template $templateId for the Theme Builder tools. Any other ID, a hidden Theme Builder and a template the + * user may not edit (canEditTemplate) answer identically: not found (ruling Q18, P12 – like tb-list, Q8/B11). + */ public function template(mixed $templateId, bool $forWrite): WP_Post|WP_Error { $id = PostGuard::id($templateId); @@ -140,7 +143,7 @@ class ThemeBuilderAccess ! $post instanceof WP_Post || $post->post_type !== DiviThemeBuilder::TEMPLATE || (string) $post->post_status !== 'publish' - || ! $this->visible() + || ! $this->canEditTemplate($id) ) { return Errors::notFound( sprintf('Theme-Builder-Vorlage %d gibt es nicht.', $id), @@ -148,10 +151,6 @@ class ThemeBuilderAccess ); } - if (! current_user_can('edit_post', $id)) { - return Errors::forbidden(sprintf('Keine Berechtigung, Theme-Builder-Vorlage %d zu bearbeiten.', $id)); - } - return $forWrite ? ($this->writableGate() ?? $post) : $post; } diff --git a/src/Integrations/Divi/ThemeBuilder/ThemeBuilderIntegration.php b/src/Integrations/Divi/ThemeBuilder/ThemeBuilderIntegration.php index 10cee8e..ef4a222 100644 --- a/src/Integrations/Divi/ThemeBuilder/ThemeBuilderIntegration.php +++ b/src/Integrations/Divi/ThemeBuilder/ThemeBuilderIntegration.php @@ -17,8 +17,8 @@ defined('ABSPATH') || exit; * lit/tb-activate, all indirect. Built by DiviIntegration::themeBuilder() so it shares the Theme Builder access * (rights, Tools-tab gates – its id ThemeBuilderAccess::GROUP is what the gates of the Divi tools ask; once this * group is registered, its mode and "nur lesen" switch in the Tools tab drive them). Available with the Divi 5 - * builder, like the Divi group; missing Divi internals show in lit-site-status. Composition, saver and log are - * for the writing tools of the later tasks. + * builder, like the Divi group; missing Divi internals show in lit-site-status. Composition, saver and log serve + * the writing tools. */ final class ThemeBuilderIntegration implements Integration { @@ -51,6 +51,7 @@ final class ThemeBuilderIntegration implements Integration return [ new TbListAbility($this->access, $conditions), + new TbCreateTemplateAbility($this->access, $conditions, $this->composition, $this->saver, $this->log), ]; } } diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index 6629d7d..2f76381 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -78,7 +78,7 @@ final class DiviIntegrationTest extends TestCase self::assertSame('Divi Theme Builder', $group->label()); self::assertFalse($group->isAvailable(), 'no Divi classes in unit tests'); self::assertSame( - ['lit/tb-list'], + ['lit/tb-list', 'lit/tb-create-template'], array_map(static fn (Ability $ability): string => $ability->name(), $group->abilities()) ); foreach ($group->abilities() as $ability) { diff --git a/tests/Unit/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbilityTest.php b/tests/Unit/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbilityTest.php new file mode 100644 index 0000000..fb99a2f --- /dev/null +++ b/tests/Unit/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbilityTest.php @@ -0,0 +1,386 @@ + */ + private array $savedNew = []; + + protected function setUp(): void + { + parent::setUp(); + // The default template's global header 1061 is a published layout the user may edit (ruling Q8). + LibraryFixtures::given([ThemeBuilderFixtures::layout(ThemeBuilderFixtures::HEADER)]); + Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path); + } + + /** The template 2100 as Divi would store it for input(). */ + private static function stored(array $overrides = []): Template + { + return ThemeBuilderFixtures::template(2100, array_merge([ + 'title' => 'Person', + 'layouts' => [ + 'header' => ['id' => 1061, 'enabled' => true], + 'body' => ['id' => 2101, 'enabled' => true], + 'footer' => ['id' => 0, 'enabled' => false], + ], + 'use_on' => ['singular:post_type:lit_person:all'], + 'attached' => false, + ], $overrides)); + } + + private static function store(int $draftId = 0, ?Template $stored = null): DiviThemeBuilder + { + return ThemeBuilderFixtures::store( + [ThemeBuilderFixtures::template(1065), ThemeBuilderFixtures::template(1066)], + $draftId, + [], + [$stored ?? self::stored()] + ); + } + + /** @param list $deniedRoles */ + private function ability( + DiviThemeBuilder $store, + ?FakeChangeLog $log = null, + array $deniedRoles = [], + ?Throwable $saveFailure = null + ): TbCreateTemplateAbility { + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('saveNew')->andReturnUsing( + function (int $id, string $content, SaveScope $scope) use ($saveFailure): array { + if ($saveFailure !== null) { + throw $saveFailure; + } + $this->savedNew[] = [$id, $content, $scope]; + + return []; + } + ); + + return new TbCreateTemplateAbility( + ThemeBuilderFixtures::access($deniedRoles, true, $store), + new TemplateConditions($store), + DiviWriterFixture::composition(DiviWriterFixture::designReader()), + $saver, + $log ?? DiviWriterFixture::log() + ); + } + + /** @return array */ + private static function input(array $extra = []): array + { + return array_merge([ + 'title' => 'Person', + 'header' => ['source' => 'global'], + 'body' => [ + 'source' => 'new', + 'nodes' => [[ + 'type' => 'section', + 'label' => 'Person', + 'modules' => [ + ['type' => 'heading', 'text' => '{{post:title}}'], + ['type' => 'text', 'content' => '{{field:role}}'], + ], + ]], + ], + 'footer' => ['source' => 'hidden'], + 'use_on' => ['singular:post_type:lit_person:all'], + ], $extra); + } + + private static function expectCreation(DiviThemeBuilder $store): void + { + $store->shouldReceive('insertLayout')->once()->with('body', 'Person – Inhalt')->andReturn(2101); + $store->shouldReceive('storeTemplate')->once()->with(1064, [ + 'title' => 'Person', + 'layouts' => [ + 'header' => ['id' => 1061, 'enabled' => true], + 'body' => ['id' => 2101, 'enabled' => true], + 'footer' => ['id' => 0, 'enabled' => false], + ], + 'use_on' => ['singular:post_type:lit_person:all'], + 'exclude_from' => [], + ])->andReturn(2100); + } + + /** @param list $deleted Receives the IDs wp_delete_post() is called with. */ + private static function deletions(array &$deleted): void + { + Functions\when('wp_delete_post')->alias(static function (int $id, bool $force) use (&$deleted): WP_Post { + $deleted[] = $id; + + return new WP_Post(['ID' => $id]); + }); + } + + public function testNewTemplateIsDisabledAndAttached(): void + { + $store = self::store(); + self::expectCreation($store); + $store->shouldReceive('attach')->once()->with(1064, 2100); + $store->shouldReceive('clearCaches')->once(); + $store->shouldReceive('setEnabled')->never(); + $log = DiviWriterFixture::log(); + + $result = $this->ability($store, $log)->execute(self::input()); + + self::assertTrue($result['ok']); + self::assertSame(2100, $result['template_id']); + self::assertFalse($result['enabled']); + self::assertSame($store->template(2100, true)?->rev(), $result['template_rev']); + self::assertSame(['id' => 2101, 'enabled' => true, 'source' => 'new'], $result['layouts']['body']); + self::assertSame(['id' => 1061, 'enabled' => true, 'source' => 'global'], $result['layouts']['header']); + self::assertSame(['id' => 0, 'enabled' => false, 'source' => 'hidden'], $result['layouts']['footer']); + self::assertCount(1, $this->savedNew); + [$id, $content, $scope] = $this->savedNew[0]; + self::assertSame(2101, $id); + self::assertSame(SaveScope::ThemeBuilderLayout, $scope); + self::assertStringStartsWith('', $content); + self::assertStringContainsString('custom_meta_role', $content); + self::assertContains(TbCreateTemplateAbility::STALE_EDITOR, $result['warnings']); + self::assertStringContainsString('deaktiviert', $result['summary']); + $log->assertRecorded([ + 'ability' => 'lit/tb-create-template', + 'object_type' => 'et_template', + 'object_id' => '2100', + ]); + self::assertStringContainsString('body 2101', $log->summaries()[0]); + } + + public function testNewLayoutsWithoutNodesAndDefaultAreasStoreNoContent(): void + { + $stored = self::stored(['layouts' => [ + 'header' => ['id' => 2101, 'enabled' => true], + 'body' => ['id' => 0, 'enabled' => true], + 'footer' => ['id' => 0, 'enabled' => true], + ], 'use_on' => []]); + $store = self::store(0, $stored); + $store->shouldReceive('insertLayout')->once()->with('header', 'Eigener Kopf')->andReturn(2101); + $store->shouldReceive('storeTemplate')->once()->andReturn(2100); + $store->shouldReceive('attach')->once()->with(1064, 2100); + $store->shouldReceive('clearCaches')->once(); + + $result = $this->ability($store)->execute([ + 'title' => 'Person', + 'header' => ['source' => 'new', 'title' => 'Eigener Kopf'], + ]); + + self::assertTrue($result['ok']); + self::assertSame([], $this->savedNew); + self::assertSame(['id' => 0, 'enabled' => true, 'source' => 'default'], $result['layouts']['body']); + } + + /** E7: an unsaved Theme Builder session would later replace the template list. */ + public function testRefusesWhileTheThemeBuilderHasADraft(): void + { + $store = self::store(999); + $store->shouldReceive('insertLayout')->never(); + $store->shouldReceive('storeTemplate')->never(); + $ability = $this->ability($store); + + self::assertToolError($ability->permission(self::input()), 'unsupported'); + self::assertToolError($ability->execute(self::input()), 'unsupported'); + } + + public function testInvalidInputCreatesNothing(): void + { + $store = self::store(); + $store->shouldReceive('insertLayout')->never(); + + $result = $this->ability($store)->execute(self::input([ + 'title' => '', + 'body' => ['source' => 'global'], + 'footer' => ['source' => 'new', 'nodes' => [['type' => 'section', 'modules' => [['type' => 'post-content']]]]], + 'use_on' => ['singular:post_type:lit_person:all', 'erfunden'], + ])); + + self::assertToolError($result, 'validation_failed'); + $fields = self::errorFields($result); + self::assertArrayHasKey('title', $fields); + self::assertArrayHasKey('body.source', $fields); + self::assertArrayHasKey('use_on.1', $fields); + self::assertNotSame([], array_filter(array_keys($fields), static fn (string $key): bool => str_starts_with($key, 'footer.nodes'))); + } + + public function testNodesOnlyWithANewLayout(): void + { + $store = self::store(); + $store->shouldReceive('insertLayout')->never(); + + $result = $this->ability($store)->execute(self::input([ + 'header' => ['source' => 'hidden', 'nodes' => [['type' => 'section']]], + ])); + + self::assertToolError($result, 'validation_failed'); + self::assertSame('nur mit source "new"', self::errorFields($result)['header.nodes']); + } + + /** + * Ruling Q8: a reused ("global") layout must be a published layout of the area the user may edit; missing, + * trashed and not editable read the same. + */ + public function testTheGlobalHeaderMustBeAPublishedEditableLayout(): void + { + $messages = []; + foreach ([[[], []], [[ThemeBuilderFixtures::layout(1061, ['post_status' => 'trash'])], []], [[ThemeBuilderFixtures::layout(1061)], ['edit_post']]] as [$posts, $denied]) { + LibraryFixtures::given($posts, $denied); + $store = self::store(); + $store->shouldReceive('insertLayout')->never(); + + $result = $this->ability($store)->execute(self::input()); + + self::assertToolError($result, 'validation_failed'); + $messages[] = self::errorFields($result)['header.source'] ?? null; + } + self::assertNotNull($messages[0]); + self::assertSame([$messages[0], $messages[0], $messages[0]], $messages); + } + + public function testFailedTemplateStoreDeletesTheLayouts(): void + { + $store = self::store(); + $store->shouldReceive('insertLayout')->once()->andReturn(2101); + $store->shouldReceive('storeTemplate')->once()->andThrow(new RuntimeException('Divi called wp_die(): ')); + $store->shouldReceive('attach')->never(); + Functions\expect('wp_delete_post')->once()->with(2101, true)->andReturn(new WP_Post(['ID' => 2101])); + + $this->expectException(WriteFailed::class); + $this->ability($store)->execute(self::input()); + } + + public function testAFailedLayoutSaveDeletesTheLayout(): void + { + $store = self::store(); + $store->shouldReceive('insertLayout')->once()->andReturn(2101); + $store->shouldReceive('storeTemplate')->never(); + $deleted = []; + self::deletions($deleted); + + try { + $this->ability($store, null, [], new RuntimeException('kses window'))->execute(self::input()); + self::fail('no WriteFailed'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + } + self::assertSame([2101], $deleted); + } + + /** Divi drops layout IDs that are no published layout of the area (theme-builder.php:1486-1496). */ + public function testDiviDroppingALayoutIsRolledBack(): void + { + $dropped = self::stored(['layouts' => [ + 'header' => ['id' => 1061, 'enabled' => true], + 'body' => ['id' => 0, 'enabled' => true], + 'footer' => ['id' => 0, 'enabled' => false], + ]]); + $store = self::store(0, $dropped); + self::expectCreation($store); + $store->shouldReceive('attach')->never(); + $deleted = []; + self::deletions($deleted); + + try { + $this->ability($store)->execute(self::input()); + self::fail('no WriteFailed'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + } + self::assertSame([2100, 2101], $deleted); + } + + /** Answer F1 / ruling Q5: a template that reads back enabled is removed again, never left live. */ + public function testATemplateStoredEnabledIsRolledBack(): void + { + $store = self::store(0, self::stored(['enabled' => true])); + self::expectCreation($store); + $store->shouldReceive('attach')->never(); + $deleted = []; + self::deletions($deleted); + + $this->expectException(WriteFailed::class); + try { + $this->ability($store)->execute(self::input()); + } finally { + self::assertSame([2100, 2101], $deleted); + } + } + + public function testAFailedAttachDetachesAndDeletesEverything(): void + { + $store = self::store(); + self::expectCreation($store); + $store->shouldReceive('attach')->once()->andThrow(new RuntimeException('not added')); + $store->shouldReceive('detach')->once()->with(1064, 2100); + $deleted = []; + self::deletions($deleted); + + $this->expectException(WriteFailed::class); + try { + $this->ability($store)->execute(self::input()); + } finally { + self::assertSame([2100, 2101], $deleted); + } + } + + public function testACleanupThatFailsIsAPartialWrite(): void + { + $store = self::store(); + $store->shouldReceive('insertLayout')->once()->andReturn(2101); + $store->shouldReceive('storeTemplate')->once()->andThrow(new RuntimeException('store failed')); + Functions\when('wp_delete_post')->justReturn(false); + $log = FakeChangeLog::partialsOnly(); + + try { + $this->ability($store, $log)->execute(self::input()); + self::fail('no WriteFailed'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertToolError($e->toError(), 'partial_write'); + } + $log->assertPartials(['ability' => 'lit/tb-create-template', 'object_id' => '2101']); + } + + public function testNeedsTheThemeBuilderRole(): void + { + $store = self::store(); + $store->shouldReceive('insertLayout')->never(); + $ability = $this->ability($store, null, ['theme_builder']); + + self::assertToolError($ability->permission(self::input()), 'forbidden'); + self::assertToolError($ability->execute(self::input()), 'forbidden'); + self::assertFalse($ability->isDirect()); + self::assertFalse($ability->annotations()['destructive']); + } + + public function testNeverOffersToEnable(): void + { + $ability = $this->ability(self::store()); + $schema = $ability->inputSchema(); + + self::assertArrayNotHasKey('enabled', $schema['properties']); + self::assertFalse($schema['additionalProperties']); + self::assertSame(TbCreateTemplateAbility::SOURCES, $schema['properties']['body']['properties']['source']['enum']); + } +} diff --git a/tests/Unit/Integrations/Divi/ThemeBuilder/TemplateBuildTest.php b/tests/Unit/Integrations/Divi/ThemeBuilder/TemplateBuildTest.php new file mode 100644 index 0000000..db5219e --- /dev/null +++ b/tests/Unit/Integrations/Divi/ThemeBuilder/TemplateBuildTest.php @@ -0,0 +1,152 @@ +shouldReceive('insertLayout')->andReturn(2101, 2102); + $store->shouldReceive('storeTemplate')->andReturn(2100); + $store->shouldReceive('attach')->once()->with(1064, 2100); + $store->shouldReceive('detach')->once()->with(1064, 2100); + $deleted = []; + Functions\when('wp_delete_post')->alias(static function (int $id, bool $force) use (&$deleted): WP_Post { + $deleted[] = [$id, $force]; + + return new WP_Post(['ID' => $id]); + }); + $build = new TemplateBuild($store, FakeChangeLog::forbidden(), 'lit/tb-create-template'); + $build->layout('header', 'Kopf'); + $build->layout('body', 'Inhalt'); + $build->template(1064, ['title' => 'Person', 'layouts' => [], 'use_on' => [], 'exclude_from' => []]); + $build->attach(1064, 2100); + + try { + $build->rollBack(new RuntimeException('Divi dropped a layout')); + self::fail('no WriteFailed'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + } + self::assertSame([[2100, true], [2102, true], [2101, true]], $deleted); + } + + /** Ruling Q4/B4: the partial write is logged as a partial row naming the posts left behind. */ + public function testAFailedDeleteIsAPartialWrite(): void + { + $store = ThemeBuilderFixtures::store(); + $store->shouldReceive('insertLayout')->andReturn(2101); + Functions\when('wp_delete_post')->justReturn(false); + $log = FakeChangeLog::partialsOnly(); + $build = new TemplateBuild($store, $log, 'lit/tb-create-template'); + $build->layout('body', 'Inhalt'); + + try { + $build->rollBack(new RuntimeException('store failed')); + self::fail('no WriteFailed'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame([2101], $e->details['created_ids']); + self::assertToolError($e->toError(), 'partial_write'); + } + $log->assertPartials([ + 'ability' => 'lit/tb-create-template', + 'object_type' => 'et_template', + 'object_id' => '2101', + ]); + } + + public function testARefusalAfterTheInsertRemovesTheNewPostsAndReturnsTheRefusal(): void + { + $store = ThemeBuilderFixtures::store(); + $store->shouldReceive('insertLayout')->andReturn(2101); + Functions\expect('wp_delete_post')->once()->with(2101, true)->andReturn(new WP_Post(['ID' => 2101])); + $build = new TemplateBuild($store, FakeChangeLog::forbidden(), 'lit/tb-create-template'); + $build->layout('body', 'Inhalt'); + $refusal = Errors::invalidInput('Divi-Inhalt ist ungültig; nichts wurde angelegt.', ['body.nodes' => 'x']); + + self::assertSame($refusal, $build->refuse($refusal)); + } + + public function testAFailedCleanupAfterARefusalIsAPartialWrite(): void + { + $store = ThemeBuilderFixtures::store(); + $store->shouldReceive('insertLayout')->andReturn(2101); + Functions\when('wp_delete_post')->justReturn(null); + $log = FakeChangeLog::partialsOnly(); + $build = new TemplateBuild($store, $log, 'lit/tb-create-template'); + $build->layout('body', 'Inhalt'); + + try { + $build->refuse(Errors::invalidInput('x', ['body.nodes' => 'x'])); + self::fail('no WriteFailed'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + } + $log->assertPartials(['object_id' => '2101']); + } + + /** Ruling Q8/B12: a live Theme Builder post Divi creates for this build is part of it (all or nothing). */ + public function testALiveThemeBuilderPostCreatedHereIsRolledBackToo(): void + { + $store = Mockery::mock(DiviThemeBuilder::class); + $store->shouldReceive('liveId')->once()->with(false)->andReturn(0); + $store->shouldReceive('liveId')->once()->with(true)->andReturn(3000); + $store->shouldReceive('insertLayout')->andReturn(2101); + $deleted = []; + Functions\when('wp_delete_post')->alias(static function (int $id) use (&$deleted): WP_Post { + $deleted[] = $id; + + return new WP_Post(['ID' => $id]); + }); + $build = new TemplateBuild($store, FakeChangeLog::forbidden(), 'lit/tb-create-template'); + $build->layout('body', 'Inhalt'); + + self::assertSame(3000, $build->themeBuilder()); + self::assertSame([2101, 3000], $build->created()); + try { + $build->rollBack(new RuntimeException('later failure')); + } catch (WriteFailed) { + } + self::assertSame([3000, 2101], $deleted); + } + + public function testAnExistingLiveThemeBuilderPostIsNeverDeleted(): void + { + $store = Mockery::mock(DiviThemeBuilder::class); + $store->shouldReceive('liveId')->once()->with(false)->andReturn(1064); + $store->shouldReceive('liveId')->with(true)->never(); + Functions\expect('wp_delete_post')->never(); + $build = new TemplateBuild($store, FakeChangeLog::forbidden(), 'lit/tb-create-template'); + + self::assertSame(1064, $build->themeBuilder()); + self::assertSame([], $build->created()); + $this->expectException(WriteFailed::class); + $build->rollBack(new RuntimeException('later failure')); + } + + public function testNoLiveThemeBuilderPostThrows(): void + { + $store = Mockery::mock(DiviThemeBuilder::class); + $store->shouldReceive('liveId')->andReturn(0); + $build = new TemplateBuild($store, FakeChangeLog::forbidden(), 'lit/tb-create-template'); + + $this->expectException(RuntimeException::class); + $build->themeBuilder(); + } +} diff --git a/tests/Unit/Integrations/Divi/ThemeBuilder/ThemeBuilderAccessTest.php b/tests/Unit/Integrations/Divi/ThemeBuilder/ThemeBuilderAccessTest.php index b45d846..b659e19 100644 --- a/tests/Unit/Integrations/Divi/ThemeBuilder/ThemeBuilderAccessTest.php +++ b/tests/Unit/Integrations/Divi/ThemeBuilder/ThemeBuilderAccessTest.php @@ -90,6 +90,22 @@ final class ThemeBuilderAccessTest extends TestCase 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([]);