From 7762b7630567b1d51d58f43a592efed671debe93 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 14:56:47 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Divi=20save=20pipeline=20=E2=80=93=20tr?= =?UTF-8?q?ee=5Frev=20lock,=20kses-safe=20save,=20revision,=20flags,=20cac?= =?UTF-8?q?he,=20ToolResult?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DiviSaver/DiviWriter/DiviComposition/DiviChange, WpValueSanitizer (S4), WpMediaResolver, DiviFlags (S11), DynamicContentMap from Divi's live registry (S22), S31 wiring, S35 control strip at apply() entry. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Integrations/Divi/Compose/AttrWriter.php | 2 + .../Divi/Compose/ShortformWriter.php | 2 + src/Integrations/Divi/Compose/VariableRef.php | 18 +- src/Integrations/Divi/Pages/DiviChange.php | 88 +++++++ .../Divi/Pages/DiviComposition.php | 97 ++++++++ src/Integrations/Divi/Pages/DiviFlags.php | 33 +++ src/Integrations/Divi/Pages/DiviSaver.php | 157 ++++++++++++ src/Integrations/Divi/Pages/DiviWriter.php | 224 ++++++++++++++++++ .../Divi/Pages/DynamicContentMap.php | 105 ++++++++ .../Divi/Pages/WpMediaResolver.php | 28 +++ .../Divi/Pages/WpValueSanitizer.php | 44 ++++ .../Divi/Compose/AttrWriterTest.php | 42 ++++ .../Divi/Compose/ShortformWriterTest.php | 35 ++- .../Divi/Pages/DiviCompositionTest.php | 113 +++++++++ .../Integrations/Divi/Pages/DiviFlagsTest.php | 41 ++++ .../Integrations/Divi/Pages/DiviSaverTest.php | 202 ++++++++++++++++ .../Divi/Pages/DiviWriterTest.php | 201 ++++++++++++++++ .../Divi/Pages/DynamicContentMapTest.php | 205 ++++++++++++++++ .../Divi/Pages/WpMediaResolverTest.php | 32 +++ .../Divi/Pages/WpValueSanitizerTest.php | 49 ++++ 20 files changed, 1716 insertions(+), 2 deletions(-) create mode 100644 src/Integrations/Divi/Pages/DiviChange.php create mode 100644 src/Integrations/Divi/Pages/DiviComposition.php create mode 100644 src/Integrations/Divi/Pages/DiviFlags.php create mode 100644 src/Integrations/Divi/Pages/DiviSaver.php create mode 100644 src/Integrations/Divi/Pages/DiviWriter.php create mode 100644 src/Integrations/Divi/Pages/DynamicContentMap.php create mode 100644 src/Integrations/Divi/Pages/WpMediaResolver.php create mode 100644 src/Integrations/Divi/Pages/WpValueSanitizer.php create mode 100644 tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php create mode 100644 tests/Unit/Integrations/Divi/Pages/DiviFlagsTest.php create mode 100644 tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php create mode 100644 tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php create mode 100644 tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php create mode 100644 tests/Unit/Integrations/Divi/Pages/WpMediaResolverTest.php create mode 100644 tests/Unit/Integrations/Divi/Pages/WpValueSanitizerTest.php diff --git a/src/Integrations/Divi/Compose/AttrWriter.php b/src/Integrations/Divi/Compose/AttrWriter.php index bdd59c5..61e83ff 100644 --- a/src/Integrations/Divi/Compose/AttrWriter.php +++ b/src/Integrations/Divi/Compose/AttrWriter.php @@ -58,6 +58,8 @@ final class AttrWriter */ public function apply(string $module, array $attrs, array $changes, string $field): array { + // Ruling S35: controls go before placeholder resolution ("{\0{post:title}}" is a placeholder). + $changes = VariableRef::withoutControlsDeep($changes); $errors = []; $invalid = []; foreach ($changes as $path => $map) { diff --git a/src/Integrations/Divi/Compose/ShortformWriter.php b/src/Integrations/Divi/Compose/ShortformWriter.php index a709625..d9134ef 100644 --- a/src/Integrations/Divi/Compose/ShortformWriter.php +++ b/src/Integrations/Divi/Compose/ShortformWriter.php @@ -48,6 +48,8 @@ final class ShortformWriter */ public function apply(string $type, array $attrs, array $values, string $field): array { + // Ruling S35: controls go before placeholder resolution and trim ("{\0{post:title}}"). + $values = VariableRef::withoutControlsDeep($values); $fields = $this->modules === null ? ShortformMap::fields($type) : ($this->modules[$type] ?? []); $errors = []; foreach ($values as $name => $value) { diff --git a/src/Integrations/Divi/Compose/VariableRef.php b/src/Integrations/Divi/Compose/VariableRef.php index 7367d58..60cac3d 100644 --- a/src/Integrations/Divi/Compose/VariableRef.php +++ b/src/Integrations/Divi/Compose/VariableRef.php @@ -146,6 +146,21 @@ final class VariableRef return (string) preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value); } + /** + * withoutControls() on every string inside $value (keys stay: odd keys are rejected). Ruling + * S35: AttrWriter::apply() and ShortformWriter::apply() run it once on their raw input, before + * placeholder resolution and trim – so "{\0{post:title}}" is the placeholder, checked as one, + * and never literal text that slipped past the placeholder check. + */ + public static function withoutControlsDeep(mixed $value): mixed + { + if (is_string($value)) { + return self::withoutControls($value); + } + + return is_array($value) ? array_map([self::class, 'withoutControlsDeep'], $value) : $value; + } + /** @param array|stdClass $settings */ public static function serialize(string $type, string $name, array|stdClass $settings): string { @@ -383,7 +398,8 @@ final class VariableRef } // Ruling S29: meta key settings (post_meta_key's meta_key/select_meta_key, loop_meta_key …) - // name a meta key – never a protected one. + // name a meta key – never a protected one. Matched by the key SUFFIX "meta_key" (S29/S30): + // re-check Divi's dynamic content options for new meta key settings on every Divi version bump. if (str_ends_with($key, 'meta_key') && $clean !== '' && ! self::isMetaKey((string) $clean)) { throw new InvalidArgumentException(sprintf( 'Einstellung "%s" im Divi-Verweis: ungültiger Meta-Schlüssel (Buchstaben, Ziffern, "_" und "-", ' diff --git a/src/Integrations/Divi/Pages/DiviChange.php b/src/Integrations/Divi/Pages/DiviChange.php new file mode 100644 index 0000000..82f64c8 --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviChange.php @@ -0,0 +1,88 @@ + */ + private array $changed = []; + + /** @var list */ + private array $touched = []; + + /** @var list */ + private array $warnings = []; + + public function __construct(public readonly string $summary) + { + } + + /** + * A node that still exists after the change: "inserted", "updated", "moved" or "duplicated". + * Every action but "moved" counts for the quality warnings. + */ + public function changed(Block $block, string $action): self + { + $this->changed[] = [ + 'block' => $block, + 'id' => null, + 'type' => $block->name(), + 'action' => $action, + 'label' => NodeSummary::label($block), + ]; + if ($action !== 'moved') { + $this->touched[] = $block; + } + + return $this; + } + + /** A node that is gone; $idBefore is its ID before the change. */ + public function removed(string $idBefore, Block $block): self + { + $this->changed[] = [ + 'block' => null, + 'id' => $idBefore, + 'type' => $block->name(), + 'action' => 'deleted', + 'label' => NodeSummary::label($block), + ]; + + return $this; + } + + public function warning(string $warning): self + { + $this->warnings[] = $warning; + + return $this; + } + + /** @return list */ + public function entries(): array + { + return $this->changed; + } + + /** @return list */ + public function touched(): array + { + return $this->touched; + } + + /** @return list */ + public function warnings(): array + { + return $this->warnings; + } +} diff --git a/src/Integrations/Divi/Pages/DiviComposition.php b/src/Integrations/Divi/Pages/DiviComposition.php new file mode 100644 index 0000000..46cfdd6 --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviComposition.php @@ -0,0 +1,97 @@ +postId = $postId; + $bound->placeholders = null; + $bound->attrWriter = null; + $bound->composer = null; + + return $bound; + } + + /** The one Placeholders instance of this composition (ruling S31). */ + public function placeholders(): Placeholders + { + return $this->placeholders ??= new Placeholders( + $this->design->read(), + $this->postId === null ? null : $this->dynamicContent->forPost($this->postId) + ); + } + + public function attrWriter(): AttrWriter + { + return $this->attrWriter ??= new AttrWriter($this->modules->catalog(), $this->placeholders(), $this->sanitizer); + } + + public function composer(): Composer + { + return $this->composer ??= new Composer( + $this->modules->catalog(), + $this->rules(), + new ShortformWriter($this->placeholders(), $this->media, $this->attrWriter()), + $this->attrWriter(), + $this->design->read(), + $this->modules->builderVersion() + ); + } + + public function rules(): StructureRules + { + return new StructureRules($this->modules->catalog()); + } + + public function editor(): TreeEditor + { + return new TreeEditor($this->rules()); + } + + public function warnings(): Warnings + { + return new Warnings($this->design->read()); + } +} diff --git a/src/Integrations/Divi/Pages/DiviFlags.php b/src/Integrations/Divi/Pages/DiviFlags.php new file mode 100644 index 0000000..d0a7463 --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviFlags.php @@ -0,0 +1,33 @@ + 'on', '_et_pb_use_builder' => 'on']; + + /** @throws RuntimeException When a flag does not read back as set. */ + public static function set(int $postId): void + { + foreach (self::FLAGS as $key => $value) { + update_post_meta($postId, $key, $value); + } + + foreach (self::FLAGS as $key => $value) { + if (get_post_meta($postId, $key, true) !== $value) { + throw new RuntimeException(sprintf('Divi flag %s of post %d is not "%s".', $key, $postId, $value)); + } + } + } +} diff --git a/src/Integrations/Divi/Pages/DiviSaver.php b/src/Integrations/Divi/Pages/DiviSaver.php new file mode 100644 index 0000000..01c4db0 --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviSaver.php @@ -0,0 +1,157 @@ +} + * @throws WriteFailed + */ + public function save(WP_Post $post, string $content): array + { + $postId = (int) $post->ID; + $revisionId = RevisionPoint::capture($postId); + + try { + $result = $this->update($postId, $content); + } catch (Throwable $e) { + $this->restore($postId, (string) $post->post_content, $revisionId, $e); + } + + if ($result instanceof WP_Error || $result === 0) { + $message = $result instanceof WP_Error ? $result->get_error_message() : 'returned 0'; + throw new WriteFailed( + self::REASON, + true, + $postId, + $revisionId, + null, + sprintf('wp_update_post(%d) refused: %s', $postId, $message), + rolledBackState: self::keeping($revisionId) + ); + } + + $warnings = []; + FollowUp::run( + static fn () => DiviFlags::set($postId), + 'divi builder flags', + 'Die Divi-Kennzeichen der Seite konnten nicht gesetzt werden; der Inhalt ist gespeichert – die Seite ' + . 'einmal im Divi-Builder öffnen und speichern.', + $warnings + ); + FollowUp::run( + static function () use ($postId): void { + if (class_exists('ET_Core_PageResource')) { + \ET_Core_PageResource::remove_static_resources($postId, 'all', true); + } + }, + 'divi static css', + 'Der CSS-Cache von Divi konnte nicht geleert werden; die Änderung ist gespeichert, erscheint aber evtl. ' + . 'erst nach „Statische CSS-Dateien leeren“ (Divi → Theme-Optionen → Builder).', + $warnings + ); + + $stored = get_post($postId); + if ($stored instanceof WP_Post && (string) $stored->post_content !== $content) { + $warnings[] = self::CHANGED_ON_SAVE; + } + + return ['revision_id' => $revisionId, 'warnings' => $warnings]; + } + + /** wp_update_post() with kses' filters off for users without unfiltered_html (D7). */ + private function update(int $postId, string $content): int|WP_Error + { + $kses = ! current_user_can('unfiltered_html') + && has_filter('content_save_pre', 'wp_filter_post_kses') !== false; + if ($kses) { + kses_remove_filters(); + } + + try { + return wp_update_post(wp_slash(['ID' => $postId, 'post_content' => $content]), true); + } finally { + if ($kses) { + kses_init_filters(); + } + } + } + + /** @throws WriteFailed Always. */ + private function restore(int $postId, string $before, ?int $revisionId, Throwable $cause): never + { + $detail = sprintf('Saving Divi content of post %d failed: %s', $postId, $cause->getMessage()); + try { + $current = get_post($postId); + if ($current instanceof WP_Post && PostGuard::missedSchedule($current)) { + throw new RuntimeException('restoring would publish a missed schedule'); + } + + $restored = $this->update($postId, $before); + if ($restored instanceof WP_Error || $restored === 0) { + throw new RuntimeException('previous content could not be written back'); + } + } catch (Throwable $e) { + $detail .= '; rollback: ' . $e->getMessage(); + throw new WriteFailed(self::REASON, false, $postId, $revisionId, $cause, $detail); + } + + throw new WriteFailed( + self::REASON, + true, + $postId, + $revisionId, + $cause, + $detail, + rolledBackState: self::keeping($revisionId) + ); + } + + /** write_failed must not claim "nothing was saved" while the restore-point revision stays. */ + private static function keeping(?int $revisionId): ?string + { + return $revisionId === null ? null : Errors::notSavedKeeping(sprintf('Revision %d', $revisionId)); + } +} diff --git a/src/Integrations/Divi/Pages/DiviWriter.php b/src/Integrations/Divi/Pages/DiviWriter.php new file mode 100644 index 0000000..8e7d2a4 --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviWriter.php @@ -0,0 +1,224 @@ +> post_id and tree_rev of every Divi write. */ + public static function inputProperties(): array + { + return [ + 'post_id' => ['type' => 'integer', 'minimum' => 1], + 'tree_rev' => [ + 'type' => 'string', + 'description' => 'tree_rev aus lit-divi-outline bzw. der letzten Divi-Änderung (Pflicht).', + ], + ]; + } + + /** @return array> Target position of insert and move. */ + public static function positionProperties(): array + { + return [ + 'parent' => [ + 'type' => 'string', + 'description' => 'Pfad-ID des Elternknotens (z. B. Spalte "s0.r0.c1"); ohne parent/before/after: ' + . 'Ende der Seite.', + ], + 'before' => ['type' => 'string', 'description' => 'Pfad-ID: vor diesem Knoten einfügen.'], + 'after' => ['type' => 'string', 'description' => 'Pfad-ID: nach diesem Knoten einfügen.'], + 'index' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Position unter parent (0 = zuerst).'], + ]; + } + + /** + * The page to change, after rights, type, layout and the tree_rev lock were checked. + * + * @param array $input + */ + public function page(array $input): DiviPage|WP_Error + { + $page = $this->loader->load($input['post_id'] ?? null, true); + if ($page instanceof WP_Error) { + return $page; + } + + $problem = $this->loader->writable($page); + if ($problem !== null) { + return $problem; + } + + if (! is_string($input['tree_rev'] ?? null) || $input['tree_rev'] !== $page->treeRev()) { + return Errors::revMismatch( + 'Die Divi-Seite wurde seit dem letzten Lesen geändert (tree_rev passt nicht); nichts wurde ' + . 'geschrieben.', + DiviPageLoader::OUTLINE_HINT + ); + } + + return $page; + } + + /** + * The Compose classes for $page: bound to its entry, so placeholders and $variable(…)$ + * references are checked against Divi's dynamic content options for it (ruling S22). + */ + public function composition(DiviPage $page): DiviComposition + { + return $this->composition->forPost((int) $page->post->ID); + } + + /** Section label around $block, for automatic admin labels ("" when none). */ + public static function sectionLabel(NodeIndex $index, ?Block $block): string + { + for ($node = $block; $node !== null; $node = self::parentBlock($index, $node)) { + if ($node->name() === 'divi/section') { + return NodeSummary::label($node); + } + } + + return ''; + } + + /** + * Saves the changed tree of $page and returns the ToolResult array. Call it only when the + * whole input validated – every error before means nothing is written. + * + * @param array $input + * @return array + * @throws WriteFailed + */ + public function save(string $ability, DiviPage $page, DiviChange $change, array $input): array + { + $post = $page->post; + $content = Serializer::document($page->document); + if ($content === (string) $post->post_content) { + return ToolResult::ok('Keine Änderung – die Seite ist bereits so.') + ->rev(Rev::ofPost($post)) + ->with('tree_rev', $page->treeRev()) + ->warnings($change->warnings()) + ->toArray(); + } + + $index = NodeIndex::build($page->document); + $quality = $this->composition->warnings()->collect($index, $change->touched()); + $warnings = array_merge($change->warnings(), $quality); + + try { + $saved = $this->saver->save($post, $content); + } catch (WriteFailed $e) { + if (! $e->rolledBack) { + $this->log->recordPartial( + $ability, + (string) $post->post_type, + (string) $post->ID, + sprintf('Divi "%s": Änderung nur teilweise zurückgenommen', $post->post_title), + $e->revisionId + ); + } + throw $e; + } + + $outline = $this->outline($index, $change); + if ($outline['truncated']) { + $warnings[] = self::OUTLINE_TRUNCATED; + } + + $result = (new PostChangeResult( + $ability, + $this->log, + (int) $post->ID, + 'die Änderung', + array_merge($warnings, $saved['warnings']) + ))->afterSave($input); + $updated = $result->reread($post) ?? $post; + $summary = sprintf('Divi "%s": %s', $post->post_title, $change->summary); + $result->log((string) $post->post_type, $summary, $saved['revision_id']); + + $status = sprintf('%s (Status unverändert: %s).', $change->summary, $updated->post_status); + $tool = $result->result($status, $updated) + ->revisionId($saved['revision_id']) + ->outline($outline['text']) + ->with('tree_rev', Rev::of((string) $updated->post_content)) + ->urls((string) get_preview_post_link($updated), $page->editUrl()); + foreach ($change->entries() as $entry) { + $id = $entry['id'] ?? ($entry['block'] !== null ? $index->idOf($entry['block']) : null); + $tool->changed($id ?? '?', $entry['type'], $entry['action'], $entry['label']); + } + + return $tool->toArray(); + } + + /** + * Outline of the sections that contain changed nodes (breadth limit of ruling S18). + * + * @return array{text: ?string, truncated: bool} + */ + private function outline(NodeIndex $index, DiviChange $change): array + { + $sections = []; + foreach ($change->entries() as $entry) { + $id = $entry['block'] !== null ? $index->idOf($entry['block']) : null; + if ($id !== null) { + $sections[explode('.', $id)[0]] = true; + } + } + + if ($sections === []) { + return ['text' => null, 'truncated' => false]; + } + + $outline = new Outline($this->design->read()->presetNames()); + $texts = []; + $truncated = false; + foreach (array_keys($sections) as $sectionId) { + $texts[] = $outline->text($outline->rows($index, (string) $sectionId)); + $truncated = $truncated || $outline->wasTruncated(); + } + + return ['text' => implode("\n", $texts), 'truncated' => $truncated]; + } + + private static function parentBlock(NodeIndex $index, Block $block): ?Block + { + $parent = $index->parentOf($block); + + return $parent instanceof Block && $parent->name() !== 'divi/placeholder' ? $parent : null; + } +} diff --git a/src/Integrations/Divi/Pages/DynamicContentMap.php b/src/Integrations/Divi/Pages/DynamicContentMap.php new file mode 100644 index 0000000..d050cec --- /dev/null +++ b/src/Integrations/Divi/Pages/DynamicContentMap.php @@ -0,0 +1,105 @@ + [setting key => field type], e.g. post_date => [before => richtext, …, + * date_format => select]. VariableRef routes each setting by that type and keeps its fixed + * rules whatever the map says (enable_html refused, meta keys S29/S30, no markers S28). + * + * - loop_* options never enter the map: Divi offers them only inside loop-enabled modules, + * which the tools do not track – VariableRef refuses them ("nur innerhalb von Divi-Schleifen"). + * - The map REPLACES VariableRef's default, so every Placeholders::POST option is added when + * the registry lacks it, with the settings the placeholders write (VariableRef's default). + * - Registry unavailable (no Divi, empty, error): null – VariableRef's default map applies. + * + * Not final so abilities can be tested with a fake registry. + */ +class DynamicContentMap +{ + private const REGISTRY = 'ET\Builder\Packages\Module\Layout\Components\DynamicContent\DynamicContentOptions'; + + /** + * @param (Closure(int): mixed)|null $registry Registry lookup by post ID (tests); null = Divi's. + */ + public function __construct(private ?Closure $registry = null) + { + } + + /** @return array>|null Null = use VariableRef's default map. */ + public function forPost(int $postId): ?array + { + try { + $options = $this->registry !== null ? ($this->registry)($postId) : self::divi($postId); + } catch (Throwable $e) { + $message = sprintf('[wp-lit-mcp] divi dynamic content options of post %d failed: %s', $postId, $e); + error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + + return null; + } + + return is_array($options) && $options !== [] ? self::fromOptions($options) : null; + } + + /** + * @param array $options get_options() result: name => [fields => [key => [type => …]]]. + * @return array> + */ + public static function fromOptions(array $options): array + { + $map = []; + foreach ($options as $name => $option) { + if (! is_string($name) || $name === '' || str_starts_with($name, 'loop_') || ! is_array($option)) { + continue; + } + + $fields = []; + foreach (is_array($option['fields'] ?? null) ? $option['fields'] : [] as $key => $field) { + if (is_string($key) && $key !== '') { + $fields[$key] = self::type($key, $field); + } + } + $map[$name] = $fields; + } + + foreach (Placeholders::POST as $option) { + $map[$option] = ($map[$option] ?? []) + (VariableRef::DEFAULT_DYNAMIC_CONTENT[$option] ?? []); + } + + return $map; + } + + /** + * Field type of one setting. before/after are "richtext" whatever Divi declares ("text"): Divi + * outputs them as HTML (DynamicContentElements::get_wrapper_element, wp_kses by the author's + * unfiltered_html), and sanitize_text_field() would trim their meaningful spaces ("Von "). + * html() is kses for editors, the S28 marker checks hold either way. No readable type: '' – + * VariableRef's strictest branch (a scalar without markup). + */ + private static function type(string $key, mixed $field): string + { + $type = is_array($field) && is_string($field['type'] ?? null) ? $field['type'] : ''; + + return ($key === 'before' || $key === 'after') && $type === 'text' ? 'richtext' : $type; + } + + private static function divi(int $postId): mixed + { + if (! class_exists(self::REGISTRY)) { + return null; + } + + return call_user_func([self::REGISTRY, 'get_options'], $postId, 'edit'); + } +} diff --git a/src/Integrations/Divi/Pages/WpMediaResolver.php b/src/Integrations/Divi/Pages/WpMediaResolver.php new file mode 100644 index 0000000..a628693 --- /dev/null +++ b/src/Integrations/Divi/Pages/WpMediaResolver.php @@ -0,0 +1,28 @@ + $url, 'alt' => (string) get_post_meta($id, '_wp_attachment_image_alt', true)]; + } +} diff --git a/src/Integrations/Divi/Pages/WpValueSanitizer.php b/src/Integrations/Divi/Pages/WpValueSanitizer.php new file mode 100644 index 0000000..e953198 --- /dev/null +++ b/src/Integrations/Divi/Pages/WpValueSanitizer.php @@ -0,0 +1,44 @@ +allowsUnfilteredHtml() ? $value : wp_kses_post($value); + } + + public function code(string $value): string + { + return $this->allowsUnfilteredHtml() ? $value : wp_kses_post($value); + } + + public function url(string $value): string + { + return esc_url_raw(trim($value)); + } + + public function allowsUnfilteredHtml(): bool + { + return current_user_can('unfiltered_html'); + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php index 78afb71..f996316 100644 --- a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php @@ -469,4 +469,46 @@ final class AttrWriterTest extends TestCase } } } + + /** + * Ruling S35: controls go once at the entry of apply(), before placeholder resolution – a + * control-split placeholder is the placeholder (resolved and checked), never literal text + * that slipped past the placeholder check. Also deep inside values. + */ + public function testControlCharactersGoBeforePlaceholderResolution(): void + { + $writer = ComposeFixture::attrWriter(); + $reference = '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$'; + + foreach ([chr(0), chr(1), chr(11), chr(127)] as $control) { + $result = $writer->apply( + 'divi/heading', + [], + ['title.innerContent' => ['desktop' => '{' . $control . '{post:title}}']], + 'attrs' + ); + self::assertSame([], $result['errors'], (string) ord($control)); + self::assertSame($reference, $result['attrs']['title']['innerContent']['desktop']['value']); + + // Embedded: refused like "{{post:title}}" inside text. + $embedded = $writer->apply( + 'divi/heading', + [], + ['title.innerContent' => ['desktop' => 'Hallo {' . $control . '{post:title}}']], + 'attrs' + ); + self::assertStringContainsString('allein im Wert', $embedded['errors']['attrs.title.innerContent']); + self::assertSame([], $embedded['attrs']); + + $deep = $writer->apply( + 'divi/button', + [], + ['button.innerContent' => ['desktop' => ['text' => "{{post:ti" . $control . "tle}}"]]], + 'attrs' + ); + self::assertSame([], $deep['errors']); + self::assertSame($reference, $deep['attrs']['button']['innerContent']['desktop']['value']['text']); + } + } } + diff --git a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php index f9c0e01..5849acc 100644 --- a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php @@ -193,7 +193,6 @@ final class ShortformWriterTest extends TestCase 'url: line-break octet marker' => ['url', '$variable%0a(y)$'], 'url: nested line-break octet marker' => ['url', '$variable%0%0aa(y)$'], 'color: tag-split marker' => ['color', '$variable(' . substr($color, 10)], - 'color: NUL-split marker' => ['color', '$variable' . chr(0) . '(' . substr($color, 10)], 'color: octet-split marker' => ['color', '$variable%41(' . substr($color, 10)], ]; } @@ -318,4 +317,38 @@ final class ShortformWriterTest extends TestCase self::assertSame([], $admin['errors']); self::assertSame('Hallo', $admin['attrs']['module']['advanced']['html']['desktop']['value']['htmlBefore']); } + + /** + * Ruling S35: controls go once at the entry of apply(), before placeholder resolution and + * trim – "{\0{post:title}}" is the placeholder, checked like any other. + */ + public function testControlCharactersGoBeforePlaceholderResolution(): void + { + $writer = ComposeFixture::shortform(); + + foreach ([chr(0), chr(1), chr(11), chr(127)] as $control) { + $result = $writer->apply('heading', [], ['text' => '{' . $control . '{post:title}}'], 'n'); + self::assertSame([], $result['errors'], (string) ord($control)); + self::assertSame( + '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$', + $result['attrs']['title']['innerContent']['desktop']['value'] + ); + + $embedded = $writer->apply('heading', [], ['text' => 'Hallo {' . $control . '{post:title}}'], 'n'); + self::assertStringContainsString('allein im Wert', $embedded['errors']['n.text']); + self::assertSame([], $embedded['attrs']); + + // A control-split whole reference IS the reference (S32a/S35): validated and re-serialized. + $reference = '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'; + $split = $writer->apply('divider', [], ['color' => '$variable' . $control . substr($reference, 9)], 'n'); + self::assertSame([], $split['errors']); + self::assertSame($reference, $split['attrs']['divider']['advanced']['line']['desktop']['value']['color']); + + // Colors: the placeholder resolves after the strip (and trim), then the color check. + $color = $writer->apply('divider', [], ['color' => ' {{color:Nacht' . $control . 'blau}} '], 'n'); + self::assertSame([], $color['errors']); + self::assertStringContainsString('gcid-a1b2', $color['attrs']['divider']['advanced']['line']['desktop']['value']['color']); + } + } } + diff --git a/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php new file mode 100644 index 0000000..4e6c7c9 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php @@ -0,0 +1,113 @@ + */ + private array $registryCalls = []; + + private function composition(): DiviComposition + { + $modules = Mockery::mock(DiviModules::class); + $modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog()); + $modules->allows('builderVersion')->andReturn('5.2.1'); + $design = Mockery::mock(DesignSystemReader::class); + $design->allows('read')->andReturn(DesignSystemTest::sample()); + $registry = new DynamicContentMap(function (int $postId): array { + $this->registryCalls[] = $postId; + + return DynamicContentMapTest::registry(); + }); + + return new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media(), $registry); + } + + private static function siteTitle(): string + { + return VariableRef::serialize('content', 'site_title', ['before' => '', 'after' => '']); + } + + public function testOneLiveMapIsSharedByTheFullAndTheShortForm(): void + { + $composition = $this->composition()->forPost(7); + + // Full form (AttrWriter). + $full = $composition->attrWriter()->apply( + 'divi/text', + [], + ['content.innerContent' => ['desktop' => self::siteTitle()]], + 'attrs' + ); + self::assertSame([], $full['errors']); + self::assertSame(self::siteTitle(), AttrMap::desktop($full['attrs'], 'content.innerContent')); + + // Short form through the Composer (ShortformWriter) – same map, same AttrWriter. + $short = $composition->composer()->compose( + [['type' => 'button', 'text' => self::siteTitle()]], + Block::create('divi/column'), + 'nodes' + ); + self::assertSame([], $short['errors']); + self::assertStringContainsString('site_title', json_encode($short['blocks'][0]->attrs(), JSON_THROW_ON_ERROR)); + + self::assertSame([7], $this->registryCalls, 'the registry is read once per post'); + self::assertSame($composition->attrWriter(), $composition->attrWriter()); + self::assertSame($composition->placeholders(), $composition->placeholders()); + } + + public function testWithoutAPostTheDefaultMapApplies(): void + { + $composition = $this->composition(); + + $result = $composition->attrWriter()->apply( + 'divi/text', + [], + ['content.innerContent' => ['desktop' => self::siteTitle()]], + 'attrs' + ); + + self::assertStringContainsString('Unbekannter Name "site_title"', $result['errors']['attrs.content.innerContent']); + self::assertSame([], $this->registryCalls); + } + + public function testForPostIsAFreshCompositionPerPost(): void + { + $base = $this->composition(); + $seven = $base->forPost(7); + $eight = $base->forPost(8); + + self::assertNotSame($base, $seven); + self::assertNotSame($seven->placeholders(), $eight->placeholders()); + self::assertNotSame($base->placeholders(), $seven->placeholders()); + self::assertSame([7, 8], $this->registryCalls); + } + + public function testRulesEditorAndWarnings(): void + { + $composition = $this->composition(); + + self::assertNull($composition->rules()->violation(Block::create('divi/column'), 'divi/button')); + self::assertNotNull($composition->rules()->violation(null, 'divi/button')); + self::assertSame([], $composition->warnings()->collect(NodeIndex::build(new Document()), [])); + self::assertInstanceOf(TreeEditor::class, $composition->editor()); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviFlagsTest.php b/tests/Unit/Integrations/Divi/Pages/DiviFlagsTest.php new file mode 100644 index 0000000..751d42a --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviFlagsTest.php @@ -0,0 +1,41 @@ +alias(static function (int $id, string $key, string $value) use (&$written): bool { + $written[] = "$id:$key=$value"; + + return false; + }); + Functions\when('get_post_meta')->justReturn('on'); + + DiviFlags::set(7); + + self::assertSame(['7:_et_pb_use_divi_5=on', '7:_et_pb_use_builder=on'], $written); + } + + public function testThrowsWhenAFlagDoesNotReadBack(): void + { + Functions\when('update_post_meta')->justReturn(true); + Functions\when('get_post_meta')->alias( + static fn (int $id, string $key): string => $key === '_et_pb_use_builder' ? 'off' : 'on' + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('_et_pb_use_builder'); + DiviFlags::set(7); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php b/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php new file mode 100644 index 0000000..cf8981e --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php @@ -0,0 +1,202 @@ + */ + private array $calls = []; + + /** @var array */ + private array $meta = []; + + private string $log = ''; + + private string|false $previousLog = false; + + protected function setUp(): void + { + parent::setUp(); + $this->post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_content' => 'ALT']); + Functions\when('wp_save_post_revision')->justReturn(811); + Functions\when('wp_get_post_revisions')->justReturn([811 => new WP_Post(['ID' => 811])]); + Functions\when('update_post_meta')->alias(function (int $id, string $key, string $value): bool { + $this->calls[] = "meta:$key=$value"; + $this->meta[$key] = $value; + + return true; + }); + Functions\when('get_post_meta')->alias(fn (int $id, string $key): string => $this->meta[$key] ?? ''); + Functions\when('kses_remove_filters')->alias(function (): void { + $this->calls[] = 'kses off'; + }); + Functions\when('kses_init_filters')->alias(function (): void { + $this->calls[] = 'kses on'; + }); + // FollowUp logs failures with error_log() (a PHP internal): keep the test output clean. + $this->log = (string) tempnam(sys_get_temp_dir(), 'lit-log'); + $this->previousLog = ini_set('error_log', $this->log); + } + + protected function tearDown(): void + { + ini_set('error_log', (string) $this->previousLog); + @unlink($this->log); + parent::tearDown(); + } + + public function testSavesWithoutKsesForFilteredUsersAndSetsDiviFlags(): void + { + Functions\when('current_user_can')->justReturn(false); + Functions\when('has_filter')->justReturn(10); + Functions\expect('wp_update_post')->once()->with(['ID' => 7, 'post_content' => 'NEU'], true) + ->andReturnUsing(function (): int { + $this->calls[] = 'update'; + + return 7; + }); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU'])); + + $saved = (new DiviSaver())->save($this->post, 'NEU'); + + self::assertSame(['revision_id' => 811, 'warnings' => []], $saved); + self::assertSame( + ['kses off', 'update', 'kses on', 'meta:_et_pb_use_divi_5=on', 'meta:_et_pb_use_builder=on'], + $this->calls + ); + } + + public function testKeepsKsesUntouchedForUnfilteredUsersAndWarnsWhenAFilterChangedTheContent(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('wp_update_post')->justReturn(7); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU, gefiltert'])); + + $saved = (new DiviSaver())->save($this->post, 'NEU'); + + self::assertNotContains('kses off', $this->calls); + self::assertSame([DiviSaver::CHANGED_ON_SAVE], $saved['warnings']); + } + + public function testKsesStaysUntouchedWhenItIsNotActive(): void + { + Functions\when('current_user_can')->justReturn(false); + Functions\when('has_filter')->justReturn(false); + Functions\when('wp_update_post')->justReturn(7); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU'])); + + (new DiviSaver())->save($this->post, 'NEU'); + + self::assertNotContains('kses off', $this->calls); + self::assertNotContains('kses on', $this->calls); + } + + public function testRefusedUpdateIsACleanFailure(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('wp_update_post')->justReturn(new WP_Error('db_update_error', 'DB kaputt')); + + try { + (new DiviSaver())->save($this->post, 'NEU'); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + self::assertSame(811, $e->revisionId); + self::assertStringContainsString('Revision 811 bleibt erhalten', $e->toError()->get_error_message()); + } + } + + public function testAThrowingHookRestoresThePreviousContentAndKsesComesBack(): void + { + Functions\when('current_user_can')->justReturn(false); + Functions\when('has_filter')->justReturn(10); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_status' => 'draft'])); + $written = []; + Functions\when('wp_update_post')->alias(function (array $data) use (&$written): int { + $written[] = $data['post_content']; + $this->calls[] = 'update'; + if ($data['post_content'] === 'NEU') { + throw new RuntimeException('save_post hook failed'); + } + + return 7; + }); + + try { + (new DiviSaver())->save($this->post, 'NEU'); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + self::assertSame(['NEU', 'ALT'], $written); + // The kses bypass is restored in finally – after the failed save and after the restore. + self::assertSame(['kses off', 'update', 'kses on', 'kses off', 'update', 'kses on'], $this->calls); + } + } + + public function testFailedRestoreIsAPartialWrite(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_status' => 'draft'])); + Functions\when('wp_update_post')->alias(static function (): int { + throw new RuntimeException('database gone'); + }); + + try { + (new DiviSaver())->save($this->post, 'NEU'); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame('lit_partial_write', $e->toError()->get_error_code()); + } + } + + public function testRestoreNeverPublishesAMissedSchedule(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post')->justReturn(new WP_Post([ + 'ID' => 7, + 'post_status' => 'future', + 'post_date_gmt' => '2020-01-01 00:00:00', + ])); + $written = []; + Functions\when('wp_update_post')->alias(static function (array $data) use (&$written): int { + $written[] = $data['post_content']; + throw new RuntimeException('save_post hook failed'); + }); + + try { + (new DiviSaver())->save($this->post, 'NEU'); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame(['NEU'], $written); + } + } + + public function testFlagFailureIsOnlyAWarning(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('wp_update_post')->justReturn(7); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU'])); + Functions\when('get_post_meta')->justReturn(''); + + $saved = (new DiviSaver())->save($this->post, 'NEU'); + + self::assertSame(811, $saved['revision_id']); + self::assertCount(1, $saved['warnings']); + self::assertStringContainsString('Divi-Kennzeichen', $saved['warnings'][0]); + self::assertStringContainsString('divi builder flags failed', (string) file_get_contents($this->log)); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php b/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php new file mode 100644 index 0000000..4bcc6a9 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php @@ -0,0 +1,201 @@ + */ + private array $registryCalls = []; + + private function writer(DiviSaver $saver, ChangeLog $log): DiviWriter + { + $modules = Mockery::mock(DiviModules::class); + $modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog()); + $modules->allows('builderVersion')->andReturn('5.2.1'); + $design = Mockery::mock(DesignSystemReader::class); + $design->allows('read')->andReturn(DesignSystemTest::sample()); + $registry = new DynamicContentMap(function (int $postId): array { + $this->registryCalls[] = $postId; + + return DynamicContentMapTest::registry(); + }); + + return new DiviWriter( + new DiviPageLoader(DiviPageLoaderTest::schema()), + $saver, + $log, + $design, + new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media(), $registry) + ); + } + + public function testPageChecksTheTreeRevLock(): void + { + $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); + DiviPageLoaderTest::givenPost($content); + $writer = $this->writer(Mockery::mock(DiviSaver::class), Mockery::mock(ChangeLog::class)); + + self::assertInstanceOf(DiviPage::class, $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)])); + self::assertSame('lit_rev_mismatch', $writer->page(['post_id' => 7])->get_error_code()); + $stale = $writer->page(['post_id' => 7, 'tree_rev' => Rev::ofPost(DiviPageLoaderTest::givenPost($content))]); + self::assertSame('lit_rev_mismatch', $stale->get_error_code(), 'the post rev is no tree_rev'); + self::assertStringContainsString('nichts wurde', $stale->get_error_message()); + } + + public function testPageRefusesWhatIsNotWritable(): void + { + $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); + DiviPageLoaderTest::givenPost($content); + $writer = new DiviWriter( + new DiviPageLoader(DiviPageLoaderTest::schema(false)), + Mockery::mock(DiviSaver::class), + Mockery::mock(ChangeLog::class), + Mockery::mock(DesignSystemReader::class), + Mockery::mock(DiviComposition::class) + ); + + self::assertSame('lit_unsupported', $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)])->get_error_code()); + } + + public function testSectionLabelOfANode(): void + { + DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html')); + $page = (new DiviPageLoader(DiviPageLoaderTest::schema()))->load(7, true); + + self::assertSame('Hero', DiviWriter::sectionLabel($page->index, $page->index->find('s0.r0.c1'))); + self::assertSame('', DiviWriter::sectionLabel($page->index, null)); + } + + public function testCompositionIsBoundToThePage(): void + { + $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); + DiviPageLoaderTest::givenPost($content); + $writer = $this->writer(Mockery::mock(DiviSaver::class), Mockery::mock(ChangeLog::class)); + $page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]); + + $writer->composition($page)->placeholders(); + + self::assertSame([7], $this->registryCalls, 'the live dynamic content map of post 7 (ruling S22)'); + } + + public function testUnchangedTreeIsNotSaved(): void + { + $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); + DiviPageLoaderTest::givenPost($content); + $saver = Mockery::mock(DiviSaver::class); + $saver->expects('save')->never(); + $writer = $this->writer($saver, Mockery::mock(ChangeLog::class)); + $page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]); + + $result = $writer->save('lit/divi-update', $page, new DiviChange('nichts'), []); + + self::assertSame(Rev::of($content), $result['tree_rev']); + self::assertStringStartsWith('Keine Änderung', $result['summary']); + } + + public function testSavesAndReportsChangedNodesOutlineAndTheNewTreeRev(): void + { + $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); + DiviPageLoaderTest::givenPost($content); + $stored = null; + $saver = Mockery::mock(DiviSaver::class); + $saver->expects('save')->andReturnUsing(static function (WP_Post $post, string $new) use (&$stored): array { + $stored = $new; + DiviPageLoaderTest::givenPost($new); + + return ['revision_id' => 811, 'warnings' => ['vom Speichern']]; + }); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/divi-update', 'page', '7', 'Divi "Start": Button geändert', 811) + ->andReturn(55); + $input = ['post_id' => 7, 'tree_rev' => Rev::of($content)]; + Functions\expect('do_action')->once()->with('lit_mcp_after_save', 7, 'lit/divi-update', $input); + Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true'); + $writer = $this->writer($saver, $log); + $page = $writer->page($input); + $button = $page->index->find('s0.r0.c1.m2'); + $removed = $page->index->find('s1'); + $button->setAttrs(AttrMap::set($button->attrs(), 'button.innerContent.desktop.value.text', 'Neu')); + $page->document->children()[0]->removeChild($removed); + $change = (new DiviChange('Button geändert')) + ->changed($button, 'updated') + ->removed('s1', $removed) + ->warning('eigene Warnung'); + + $result = $writer->save('lit/divi-update', $page, $change, $input); + + self::assertIsString($stored); + self::assertStringNotContainsString('wp:divi/blurb', $stored); + self::assertSame(Rev::of($stored), $result['tree_rev']); + self::assertSame(811, $result['revision_id']); + self::assertSame(55, $result['log_id']); + self::assertSame('https://kunde.test/?page_id=7&et_fb=1', $result['edit_url']); + self::assertSame('https://kunde.test/?page_id=7&preview=true', $result['preview_url']); + self::assertSame('Button geändert (Status unverändert: draft).', $result['summary']); + self::assertSame( + [['s0.r0.c1.m2', 'divi/button', 'updated'], ['s1', 'divi/section', 'deleted']], + array_map(static fn (array $c): array => [$c['id'], $c['type'], $c['action']], $result['changed']) + ); + self::assertStringContainsString('Hero', (string) $result['outline']); + self::assertStringNotContainsString('Leistungen', (string) $result['outline'], 'only affected sections'); + self::assertContains('eigene Warnung', $result['warnings']); + self::assertContains('vom Speichern', $result['warnings']); + self::assertSame(Rev::ofPost(DiviPageLoaderTest::givenPost($stored)), $result['rev']); + } + + public function testFailedRollbackIsLoggedAsPartial(): void + { + $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); + DiviPageLoaderTest::givenPost($content); + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('save')->andThrow(new WriteFailed(DiviSaver::REASON, false, 7, 811)); + $log = Mockery::mock(ChangeLog::class); + $log->expects('recordPartial')->with('lit/divi-delete', 'page', '7', 'Divi "Start": Änderung nur teilweise zurückgenommen', 811); + $writer = $this->writer($saver, $log); + $page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]); + $page->document->children()[0]->removeChild($page->index->find('s1')); + + $this->expectException(WriteFailed::class); + $writer->save('lit/divi-delete', $page, new DiviChange('s1 gelöscht'), []); + } + + public function testCleanFailureIsNotLogged(): void + { + $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); + DiviPageLoaderTest::givenPost($content); + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('save')->andThrow(new WriteFailed(DiviSaver::REASON, true, 7, 811)); + $log = Mockery::mock(ChangeLog::class); + $log->expects('recordPartial')->never(); + $log->expects('record')->never(); + $writer = $this->writer($saver, $log); + $page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]); + $page->document->children()[0]->removeChild($page->index->find('s1')); + + $this->expectException(WriteFailed::class); + $writer->save('lit/divi-delete', $page, new DiviChange('s1 gelöscht'), []); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php new file mode 100644 index 0000000..69b3a4f --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php @@ -0,0 +1,205 @@ +> + */ + public static function registry(): array + { + $text = static fn (): array => ['label' => 'x', 'type' => 'text', 'default' => '']; + $option = static fn (string $id, string $group, array $fields): array => [ + 'id' => $id, 'label' => $id, 'type' => 'text', 'custom' => false, 'group' => $group, 'fields' => $fields, + ]; + + return [ + 'post_title' => $option('post_title', 'Default', ['before' => $text(), 'after' => $text()]), + 'post_excerpt' => $option('post_excerpt', 'Default', [ + 'before' => $text(), 'after' => $text(), 'words' => $text(), 'read_more_label' => $text(), + ]), + 'post_date' => $option('post_date', 'Default', [ + 'before' => $text(), + 'after' => $text(), + 'date_format' => ['label' => 'x', 'type' => 'select', 'default' => 'default'], + 'custom_date_format' => $text(), + ]), + 'post_author' => $option('post_author', 'Default', [ + 'before' => $text(), + 'after' => $text(), + 'name_format' => ['type' => 'select'], + 'link' => ['type' => 'yes_no_button'], + ]), + 'site_title' => $option('site_title', 'Default', ['before' => $text(), 'after' => $text()]), + 'post_link_url' => $option('post_link_url', 'Default', []), + 'post_link_url_page' => $option('post_link_url_page', 'Default', ['post_id' => ['type' => 'select_post']]), + 'post_featured_image' => $option('post_featured_image', 'Default', []), + 'post_meta_key' => $option('post_meta_key', 'Standard', [ + 'before' => $text(), + 'after' => $text(), + 'select_meta_key' => ['type' => 'select'], + 'meta_key' => $text(), + 'enable_html' => ['type' => 'yes_no_button'], + ]), + 'loop_post_title' => $option('loop_post_title', 'Loop', [ + 'before' => $text(), 'after' => $text(), 'loop_position' => $text(), + ]), + 'loop_post_meta_key_manual_custom_field' => $option('loop_post_meta_key_manual_custom_field', 'Loop', [ + 'loop_meta_key' => $text(), + ]), + // Odd entries a third-party filter could add. + 'broken_fields' => $option('broken_fields', 'Default', ['a' => 'not-an-array', 'b' => ['type' => 3]]), + 'no_fields' => ['id' => 'no_fields', 'type' => 'text'], + 7 => $option('7', 'Default', []), + 'not_an_array' => 'x', + ]; + } + + public function testFlattensTheRegistryToOptionNameAndFieldTypes(): void + { + $map = DynamicContentMap::fromOptions(self::registry()); + + // before/after: rich text (Divi outputs them as HTML; text() would trim "Von "). + self::assertSame(['before' => 'richtext', 'after' => 'richtext'], $map['post_title']); + self::assertSame( + ['before' => 'richtext', 'after' => 'richtext', 'date_format' => 'select', 'custom_date_format' => 'text'], + $map['post_date'] + ); + self::assertSame(['post_id' => 'select_post'], $map['post_link_url_page']); + self::assertSame([], $map['post_link_url']); + self::assertSame('select', $map['post_meta_key']['select_meta_key']); + // Fields without a readable type become '' – VariableRef's strictest branch (scalar, no markup). + self::assertSame(['a' => '', 'b' => ''], $map['broken_fields']); + self::assertSame([], $map['no_fields']); + self::assertArrayNotHasKey('not_an_array', $map); + self::assertArrayNotHasKey(7, $map); + } + + public function testLoopOptionsNeverEnterTheMap(): void + { + $map = DynamicContentMap::fromOptions(self::registry()); + + self::assertSame([], array_filter(array_keys($map), static fn (string $name): bool => str_starts_with($name, 'loop_'))); + } + + public function testPlaceholderOptionsAreAlwaysThere(): void + { + // The map replaces VariableRef's default: every {{post:…}} placeholder must stay usable + // with the settings it writes (before/after), also where the registry lacks the option. + $map = DynamicContentMap::fromOptions(self::registry()); + + foreach (Placeholders::POST as $option) { + self::assertArrayHasKey($option, $map, $option); + foreach (array_keys(VariableRef::DEFAULT_DYNAMIC_CONTENT[$option]) as $setting) { + self::assertArrayHasKey($setting, $map[$option], "$option.$setting"); + } + } + // Supplemented with the default settings. + self::assertSame(['before' => 'richtext', 'after' => 'richtext'], $map['post_categories']); + // Live fields win. + self::assertSame('select', $map['post_date']['date_format']); + self::assertSame('text', $map['post_excerpt']['words']); + + $placeholders = new Placeholders(DesignSystemTest::sample(), $map); + foreach (array_keys(Placeholders::POST) as $key) { + $reference = $placeholders->resolve('{{post:' . $key . '}}'); + self::assertSame( + $reference, + $placeholders->references()->canonical($reference, ComposeFixture::sanitizer()), + $key + ); + } + } + + public function testTheMapWorksInVariableRef(): void + { + $references = new VariableRef(DesignSystemTest::sample(), DynamicContentMap::fromOptions(self::registry())); + $sanitizer = ComposeFixture::sanitizer(); + $ref = static fn (string $name, array $settings): string => VariableRef::serialize('content', $name, $settings); + + // An option only the live registry knows. + $site = $ref('site_title', ['before' => 'Willkommen bei ', 'after' => '']); + self::assertSame($site, $references->canonical($site, $sanitizer)); + // before/after are cleaned as rich text (kses for editors) and re-serialized; spaces stay. + self::assertSame( + $ref('post_title', ['before' => 'Neu: ', 'after' => '']), + $references->canonical($ref('post_title', ['before' => 'Neu: ', 'after' => '']), $sanitizer) + ); + // Other text fields go through text(). + self::assertSame( + $ref('post_excerpt', ['words' => '20']), + $references->canonical($ref('post_excerpt', ['words' => '20']), $sanitizer) + ); + // Meta keys still obey S29/S30, also through the live map. + $meta = $ref('post_meta_key', ['select_meta_key' => 'custom_meta__wp_page_template']); + self::assertRejected(static fn () => $references->canonical($meta, $sanitizer), 'Meta-Schlüssel'); + $ok = $ref('post_meta_key', ['meta_key' => 'telefon']); + self::assertSame($ok, $references->canonical($ok, $sanitizer)); + // enable_html stays forbidden even though the registry lists it. + $html = $ref('post_meta_key', ['enable_html' => 'on']); + self::assertRejected(static fn () => $references->canonical($html, $sanitizer), 'enable_html'); + // Loop content: Divi offers it only inside loops – never through the tools. + $loop = $ref('loop_post_title', []); + self::assertRejected(static fn () => $references->canonical($loop, $sanitizer), 'Schleifen'); + } + + public function testLiveRegistryForThePost(): void + { + $seen = []; + $map = (new DynamicContentMap(static function (int $postId) use (&$seen): array { + $seen[] = $postId; + + return DynamicContentMapTest::registry(); + }))->forPost(42); + + self::assertSame([42], $seen); + self::assertSame(['before' => 'richtext', 'after' => 'richtext'], $map['site_title'] ?? null); + } + + public function testUnavailableRegistryFallsBackToTheDefault(): void + { + self::assertNull((new DynamicContentMap(static fn (): array => []))->forPost(7), 'empty (recursion guard)'); + self::assertNull((new DynamicContentMap(static fn (): string => 'x'))->forPost(7), 'no array'); + + $log = (string) tempnam(sys_get_temp_dir(), 'lit-log'); + $previous = ini_set('error_log', $log); + try { + $map = (new DynamicContentMap(static function (): array { + throw new RuntimeException('filter exploded'); + }))->forPost(7); + } finally { + ini_set('error_log', (string) $previous); + } + self::assertNull($map); + self::assertStringContainsString('filter exploded', (string) file_get_contents($log)); + unlink($log); + + // Without Divi (no DynamicContentOptions class, as in these unit tests). + self::assertNull((new DynamicContentMap())->forPost(7)); + } + + private static function assertRejected(callable $call, string $fragment): void + { + try { + $call(); + self::fail('InvalidArgumentException expected'); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString($fragment, $e->getMessage()); + } + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/WpMediaResolverTest.php b/tests/Unit/Integrations/Divi/Pages/WpMediaResolverTest.php new file mode 100644 index 0000000..563a6cd --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/WpMediaResolverTest.php @@ -0,0 +1,32 @@ +alias(static fn (int $id): bool => $id !== 5); + Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $cap === 'read_post' && $id !== 6); + Functions\when('wp_get_attachment_url')->alias(static fn (int $id): string|false => $id === 8 ? false : "https://kunde.test/$id.jpg"); + Functions\when('get_post_meta')->justReturn('Alt-Text'); + } + + public function testReadableImagesOnly(): void + { + $media = new WpMediaResolver(); + + self::assertSame(['url' => 'https://kunde.test/33.jpg', 'alt' => 'Alt-Text'], $media->image(33)); + self::assertNull($media->image(0), 'no id'); + self::assertNull($media->image(5), 'no image'); + self::assertNull($media->image(6), 'not readable'); + self::assertNull($media->image(8), 'no url'); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/WpValueSanitizerTest.php b/tests/Unit/Integrations/Divi/Pages/WpValueSanitizerTest.php new file mode 100644 index 0000000..8debed7 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/WpValueSanitizerTest.php @@ -0,0 +1,49 @@ +alias(static fn (string $value): string => 'kses(' . $value . ')'); + Functions\when('esc_url_raw')->alias(static fn (string $value): string => 'url(' . $value . ')'); + } + + public function testEditorsGetKsesForRichTextAndCode(): void + { + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'unfiltered_html'); + $sanitizer = new WpValueSanitizer(); + + self::assertSame('kses()', $sanitizer->html('')); + self::assertSame('kses()', $sanitizer->code('')); + self::assertFalse($sanitizer->allowsUnfilteredHtml()); + } + + public function testAdministratorsKeepRichTextAndCodeUnfiltered(): void + { + // Ruling S4: html() depends on unfiltered_html like code() – admin embeds survive updates. + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'unfiltered_html'); + $sanitizer = new WpValueSanitizer(); + + self::assertSame('', $sanitizer->html('')); + self::assertSame('', $sanitizer->code('')); + self::assertTrue($sanitizer->allowsUnfilteredHtml()); + } + + public function testTextAndUrlDoNotDependOnTheRole(): void + { + Functions\when('current_user_can')->justReturn(true); + $sanitizer = new WpValueSanitizer(); + + self::assertSame('Hallo', $sanitizer->text('Hallo')); + self::assertSame('url(https://x.test/a b)', $sanitizer->url(' https://x.test/a b ')); + } +}