diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 9174bb8..ad5b9ca 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -8,6 +8,7 @@ use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility; use LIT\MCP\Integrations\Divi\Pages\DiviComposition; +use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility; use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility; use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility; @@ -22,12 +23,14 @@ use LIT\MCP\Integrations\Divi\Pages\DiviWriter; use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility; use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\Integration; +use LIT\MCP\Integrations\WordPress\Content\ContentComposer; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; /** * Divi 5 pages (spec §5, Etappe 2). Only available when the Divi 5 builder is loaded * (spec §2 principle 4); Divi 4 is never edited (spec §3.4). The write abilities share one * DiviWriter; each binds the composition to its entry via DiviWriter::composition(). + * contentComposer() gives lit/content-create the Divi short form (spec §6.2). */ final class DiviIntegration implements Integration { @@ -50,6 +53,17 @@ final class DiviIntegration implements Integration return DiviModules::isAvailable(); } + /** Divi short form for lit/content-create; available() reports whether Divi 5 is loaded. */ + public function contentComposer(): ContentComposer + { + return new DiviContentComposer( + $this->schema, + new DiviComposition($this->modules, $this->design), + new DiviSaver(), + $this->design + ); + } + public function abilities(): array { $loader = new DiviPageLoader($this->schema); diff --git a/src/Integrations/Divi/Pages/DiviContentComposer.php b/src/Integrations/Divi/Pages/DiviContentComposer.php new file mode 100644 index 0000000..5c39cd7 --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviContentComposer.php @@ -0,0 +1,119 @@ +schema->diviEnabled($postType)) { + return Errors::invalidInput( + 'Für diesen Inhaltstyp ist der Divi-Builder nicht aktiviert; nichts wurde angelegt.', + ['divi' => sprintf('Divi-Builder für "%s" nicht aktiv', $postType)] + ); + } + + $layout = $this->layout($this->composition->forPost(0), $sections); + + return $layout instanceof WP_Error ? $layout : null; + } + + public function write(int $postId, string $postType, array $sections): array|WP_Error + { + $composition = $this->composition->forPost($postId); + $layout = $this->layout($composition, $sections); + if ($layout instanceof WP_Error) { + return $layout; + } + + $index = NodeIndex::build($layout['document']); + $warnings = $composition->warnings()->collect($index, $layout['blocks']); + $outline = new Outline($this->design->read()->presetNames()); + $text = $outline->text($outline->rows($index)); + if ($outline->wasTruncated()) { + $warnings[] = DiviWriter::OUTLINE_TRUNCATED; + } + + $saved = $this->saver->saveNew($postId, Serializer::document($layout['document'])); + + return ['warnings' => array_merge($warnings, $saved), 'outline' => $text]; + } + + /** + * 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 $composed['invalid_paths'] !== [] + ? Errors::invalidAttrPath($composed['errors']) + : Errors::invalidInput('Divi-Inhalt ist ungültig; nichts wurde angelegt.', $composed['errors']); + } + + $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/DiviSaver.php b/src/Integrations/Divi/Pages/DiviSaver.php index 2736367..0ee0c78 100644 --- a/src/Integrations/Divi/Pages/DiviSaver.php +++ b/src/Integrations/Divi/Pages/DiviSaver.php @@ -36,7 +36,8 @@ use WP_Post; * transition_post_status, wp_after_insert_post and third-party listeners. No MCP input reaches * those hooks except the content itself (already cleaned); anything a third-party hook writes * there is saved without kses, as it would be for an administrator. The rollback write in - * restore() uses the same window with the previous stored content. + * restore() uses the same window with the previous stored content; saveNew() (the first layout + * of a new draft, lit/content-create) uses it for its single wp_update_post(). * * Divi's own save filters are NOT kses and are never removed (ruling S7, defense in depth): * Security::initialize() (Divi 5.2.1 Security.php:74-81) registers on wp_insert_post_data @@ -116,6 +117,34 @@ class DiviSaver return ['revision_id' => $revisionId, 'warnings' => $warnings]; } + /** + * The first content of a brand-new draft (lit/content-create with divi, which inserted the + * draft with its normal WordPress fields – title and excerpt went through kses as usual). + * Same kses window as save(), holding only this wp_update_post() and its hooks; no restore + * point (the draft has no earlier state) and no static CSS to clear. The Divi flags are part + * of the write here: without them the new entry is no Divi layout. Every failure throws – + * the caller deletes the draft (all or nothing). + * + * @return list Warnings (CHANGED_ON_SAVE). + * @throws RuntimeException + */ + public function saveNew(int $postId, string $content): array + { + $result = $this->withoutKses( + static fn (): int|WP_Error => wp_update_post(wp_slash(['ID' => $postId, 'post_content' => $content]), true) + ); + if ($result instanceof WP_Error || $result === 0) { + $message = $result instanceof WP_Error ? $result->get_error_message() : 'returned 0'; + throw new RuntimeException(sprintf('wp_update_post(%d) refused the Divi layout: %s', $postId, $message)); + } + + DiviFlags::set($postId); + + $stored = get_post($postId); + + return $stored instanceof WP_Post && (string) $stored->post_content !== $content ? [self::CHANGED_ON_SAVE] : []; + } + /** * Runs $write with kses' filters off for users without unfiltered_html (D7); restored in * finally, right when $write returns or throws. diff --git a/src/Integrations/WordPress/Content/ContentComposer.php b/src/Integrations/WordPress/Content/ContentComposer.php new file mode 100644 index 0000000..df6cddb --- /dev/null +++ b/src/Integrations/WordPress/Content/ContentComposer.php @@ -0,0 +1,42 @@ + $sections + * @return WP_Error|null Every problem of the description; null = valid. + */ + public function check(string $postType, array $sections): ?WP_Error; + + /** + * Composes the description for the new draft $postId and stores it. A WP_Error means the + * description is invalid for this entry (nothing was written) – like a throw, it makes + * lit/content-create delete the draft again. + * + * @param list $sections + * @return array{warnings: list, outline: ?string}|WP_Error + * @throws Throwable When storing failed. + */ + public function write(int $postId, string $postType, array $sections): array|WP_Error; +} diff --git a/src/Integrations/WordPress/Content/ContentCreateAbility.php b/src/Integrations/WordPress/Content/ContentCreateAbility.php index 8bb622c..3211cac 100644 --- a/src/Integrations/WordPress/Content/ContentCreateAbility.php +++ b/src/Integrations/WordPress/Content/ContentCreateAbility.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\WordPress\Content; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Errors; +use LIT\MCP\Abilities\Rev; use LIT\MCP\Abilities\ToolResult; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; @@ -22,6 +23,11 @@ use WP_Post; * afterwards, created terms and the brand-new draft are deleted again and a WriteFailed * is thrown (AbilityRegistrar → lit_write_failed, or lit_partial_write if that cleanup * failed or the insert itself threw; partial writes are logged with result "partial"). + * + * Divi short form (spec §6.2): with `divi` the ContentComposer (Divi integration) checks the whole + * description before the insert; the draft is inserted with its normal WordPress fields only + * (title/excerpt keep kses), then the composer stores the layout (DiviSaver's kses window, spec + * D7) – a refusal or failure there deletes the draft again. */ final class ContentCreateAbility implements Ability { @@ -31,7 +37,8 @@ final class ContentCreateAbility implements Ability public function __construct( private ContentSchema $schema, private PostWriter $writer, - private ChangeLog $log + private ChangeLog $log, + private ?ContentComposer $divi = null ) { } @@ -49,7 +56,9 @@ final class ContentCreateAbility implements Ability { return 'Legt einen neuen Eintrag an – immer als Entwurf (veröffentlichen nur über lit-post-publish). ' . 'Kerndaten, Begriffe, Felder (gegen das Schema aus lit-content-types geprüft) und Beitragsbild ' - . 'in einem Schritt; bei einem Fehler wird nichts angelegt.'; + . 'in einem Schritt; bei einem Fehler wird nichts angelegt. Divi-Seiten direkt mit Inhalt: divi = Liste ' + . 'von Abschnitten in Kurzform (lit-guide topic "divi-shortform"), statt content; das Ergebnis enthält ' + . 'dann tree_rev für die lit-divi-* Werkzeuge.'; } public function inputSchema(): array @@ -59,6 +68,14 @@ final class ContentCreateAbility implements Ability 'properties' => array_merge([ 'post_type' => ['type' => 'string'], 'title' => ['type' => 'string', 'minLength' => 1], + 'divi' => [ + 'type' => 'array', + 'minItems' => 1, + 'maxItems' => 30, + 'items' => ['type' => 'object'], + 'description' => 'Divi-Layout in Kurzform: Abschnitte wie bei lit-divi-insert (nicht zusammen mit ' + . 'content). Nur wenn Divi 5 aktiv und der Divi-Builder für den Inhaltstyp eingeschaltet ist.', + ], ], PostWriter::inputProperties()), 'required' => ['post_type', 'title'], 'additionalProperties' => false, @@ -67,7 +84,7 @@ final class ContentCreateAbility implements Ability public function outputSchema(): ?array { - return ToolResult::schema(); + return ToolResult::schema(['tree_rev' => ['type' => 'string']]); } public function annotations(): array @@ -95,9 +112,18 @@ final class ContentCreateAbility implements Ability } $postType = (string) $input['post_type']; + $withDivi = array_key_exists('divi', $input); + if ($withDivi && ($this->divi === null || ! $this->divi->available())) { + return Errors::unsupported( + 'Divi-Inhalt ist auf dieser Website nicht möglich (Divi 5 ist nicht aktiv); nichts wurde angelegt.', + 'Ohne divi anlegen und den Inhalt über content oder im Editor setzen.' + ); + } + $prepared = $this->writer->prepare($postType, $input, true); - if ($prepared instanceof WP_Error) { - return $prepared; + $problem = self::combined($prepared, $withDivi ? $this->checkDivi($postType, $input) : null); + if ($problem !== null) { + return $problem; } $data = array_merge($this->writer->postData($input), [ @@ -123,21 +149,89 @@ final class ContentCreateAbility implements Ability return $this->refuse($postId, $postType, $denied); } + $layout = null; + try { + $layout = $withDivi ? $this->divi?->write($postId, $postType, self::sections($input)) : null; + } catch (Throwable $e) { + $this->discard($postId, $postType, $e); + } + if ($layout instanceof WP_Error) { + return $this->refuse($postId, $postType, $layout); + } + try { $this->writer->writeExtras($postId, $postType, $prepared, $input); } catch (Throwable $e) { $this->discard($postId, $postType, $e); } - return $this->result($postId, $postType, $input, $prepared['warnings']); + $warnings = array_merge($prepared['warnings'], $layout['warnings'] ?? []); + + return $this->result($postId, $postType, $input, $warnings, $layout['outline'] ?? null); + } + + /** + * The Divi description, checked completely before anything is created (spec §6.2). + * + * @param array $input + */ + private function checkDivi(string $postType, array $input): ?WP_Error + { + if (array_key_exists('content', $input)) { + return Errors::invalidInput('content und divi schließen sich aus; nichts wurde angelegt.', [ + 'content' => 'bei Divi-Inhalt weglassen', + ]); + } + + return $this->divi?->check($postType, self::sections($input)); + } + + /** + * @param array $input + * @return list + */ + private static function sections(array $input): array + { + return is_array($input['divi'] ?? null) ? array_values($input['divi']) : []; + } + + /** + * One error with the fields of both checks (all or nothing: every problem comes back at once); + * invalid_attr_path wins, so its suggestions stay visible. Null when both passed. + * + * @param array|WP_Error $prepared + */ + private static function combined(array|WP_Error $prepared, ?WP_Error $divi): ?WP_Error + { + if (! $prepared instanceof WP_Error || $divi === null) { + return $prepared instanceof WP_Error ? $prepared : $divi; + } + + $fields = array_merge(self::fields($prepared), self::fields($divi)); + if ($fields === []) { + return $prepared; + } + + return $divi->get_error_code() === 'lit_invalid_attr_path' + ? Errors::invalidAttrPath($fields) + : Errors::invalidInput('Eingaben sind ungültig; nichts wurde angelegt.', $fields); + } + + /** @return array */ + private static function fields(WP_Error $error): array + { + $data = $error->get_error_data(); + + return is_array($data) && is_array($data['fields'] ?? null) ? $data['fields'] : []; } /** * @param array $input * @param list $warnings + * @param string|null $outline Divi outline of the new layout (with divi only). * @return array */ - private function result(int $postId, string $postType, array $input, array $warnings): array + private function result(int $postId, string $postType, array $input, array $warnings, ?string $outline): array { // The draft is saved – failures from here on are logged and reported as warnings only. $change = (new PostChangeResult($this->name(), $this->log, $postId, 'der Entwurf', $warnings)) @@ -146,10 +240,16 @@ final class ContentCreateAbility implements Ability $title = $post instanceof WP_Post ? (string) $post->post_title : (string) $input['title']; $change->log($postType, sprintf('Entwurf "%s" angelegt', $title)); - return $change->result(sprintf('"%s" als Entwurf angelegt (ID %d).', $title, $postId), $post) + $result = $change->result(sprintf('"%s" als Entwurf angelegt (ID %d).', $title, $postId), $post) ->changed((string) $postId, $postType, 'created', $title) - ->urls((string) get_preview_post_link($postId), (string) get_edit_post_link($postId, 'raw')) - ->toArray(); + ->urls((string) get_preview_post_link($postId), (string) get_edit_post_link($postId, 'raw')); + if (array_key_exists('divi', $input)) { + // The Divi tools lock on tree_rev (ruling P16): the next lit-divi-* call can use it right away. + $result->outline($outline) + ->with('tree_rev', Rev::of($post instanceof WP_Post ? (string) $post->post_content : '')); + } + + return $result->toArray(); } /** @@ -193,13 +293,14 @@ final class ContentCreateAbility implements Ability } /** - * Meta rights can only be checked once the draft exists (add_post_meta needs its ID): a - * refused field removes the brand-new draft again and is reported like any invalid input. + * Some checks need the draft: meta rights (add_post_meta needs its ID) and the Divi layout + * against the new entry's own dynamic content options. A refusal removes the brand-new draft + * again and is reported like any invalid input. * - * @param array $denied fields. => error + * @param array|WP_Error $denied fields. => error, or the error itself. * @throws WriteFailed When the draft cannot be deleted. */ - private function refuse(int $postId, string $postType, array $denied): WP_Error + private function refuse(int $postId, string $postType, array|WP_Error $denied): WP_Error { try { $deleted = (bool) wp_delete_post($postId, true); @@ -210,17 +311,21 @@ final class ContentCreateAbility implements Ability if (! $deleted) { $summary = sprintf('Entwurf %d: Anlegen nicht zurückgenommen', $postId); $this->logPartial($postType, (string) $postId, $summary); + $fields = $denied instanceof WP_Error ? self::fields($denied) : $denied; throw new WriteFailed( - 'Keine Berechtigung für ' . implode(', ', array_keys($denied)) . '.', + ($denied instanceof WP_Error ? 'Ungültiger Divi-Inhalt: ' : 'Keine Berechtigung für ') + . implode(', ', array_keys($fields)) . '.', false, $postId, null, null, - sprintf('Meta rights denied for new post %d; draft could not be deleted', $postId) + sprintf('New post %d refused after the insert; draft could not be deleted', $postId) ); } - return Errors::invalidInput('Eingaben sind ungültig; nichts wurde gespeichert.', $denied); + return $denied instanceof WP_Error + ? $denied + : Errors::invalidInput('Eingaben sind ungültig; nichts wurde gespeichert.', $denied); } private function logPartial(string $postType, string $postId, string $summary): void diff --git a/src/Integrations/WordPress/WordPressIntegration.php b/src/Integrations/WordPress/WordPressIntegration.php index 3e6e31b..d440821 100644 --- a/src/Integrations/WordPress/WordPressIntegration.php +++ b/src/Integrations/WordPress/WordPressIntegration.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\WordPress; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\Integration; +use LIT\MCP\Integrations\WordPress\Content\ContentComposer; use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility; use LIT\MCP\Integrations\WordPress\Content\ContentGetAbility; use LIT\MCP\Integrations\WordPress\Content\ContentQueryAbility; @@ -45,8 +46,12 @@ use LIT\MCP\Integrations\WordPress\Status\PostUnpublishAbility; */ final class WordPressIntegration implements Integration { - public function __construct(private ContentSchema $schema, private ChangeLog $log) - { + /** @param ContentComposer|null $divi Divi short form for lit/content-create (DiviIntegration). */ + public function __construct( + private ContentSchema $schema, + private ChangeLog $log, + private ?ContentComposer $divi = null + ) { } public function id(): string @@ -70,7 +75,7 @@ final class WordPressIntegration implements Integration new ContentTypesAbility($this->schema), new ContentQueryAbility($this->schema, $presenter), new ContentGetAbility($this->schema, $presenter), - new ContentCreateAbility($this->schema, $writer, $this->log), + new ContentCreateAbility($this->schema, $writer, $this->log, $this->divi), new ContentUpdateAbility($this->schema, $writer, $this->log), new PostPublishAbility($this->schema, $this->log), new PostUnpublishAbility($this->schema, $this->log), diff --git a/src/Plugin.php b/src/Plugin.php index 162fd13..1d8a7ba 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -66,11 +66,12 @@ final class Plugin (new ChangeLogCleanup($changeLog, $limits))->register(); $schema = self::contentSchema(); + $divi = new DiviIntegration($schema, $changeLog); $registry = new IntegrationRegistry( new CoreIntegration(self::pluginDir() . '/docs/guides'), - new WordPressIntegration($schema, $changeLog), + new WordPressIntegration($schema, $changeLog, $divi->contentComposer()), new MediaIntegration($schema, $changeLog, $limits), - new DiviIntegration($schema, $changeLog) + $divi ); $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...)); diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index 81fecd7..004e343 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi; use LIT\MCP\Abilities\Ability; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\Divi\DiviIntegration; +use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Tests\Unit\TestCase; use Mockery; @@ -44,4 +45,14 @@ final class DiviIntegrationTest extends TestCase self::assertNotNull($ability->outputSchema(), $ability->name()); } } + + public function testGivesContentCreateTheDiviShortForm(): void + { + $integration = new DiviIntegration(Mockery::mock(ContentSchema::class), Mockery::mock(ChangeLog::class)); + + $composer = $integration->contentComposer(); + + self::assertInstanceOf(DiviContentComposer::class, $composer); + self::assertFalse($composer->available(), 'no Divi classes in unit tests'); + } } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php b/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php new file mode 100644 index 0000000..7272a4c --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php @@ -0,0 +1,153 @@ + Post IDs the dynamic content registry was asked for. */ + private array $registryCalls = []; + + /** @var array Content handed to DiviSaver::saveNew() by post ID. */ + private array $saved = []; + + /** @var DiviSaver&MockInterface */ + private DiviSaver $saver; + + protected function setUp(): void + { + parent::setUp(); + $this->saver = Mockery::mock(DiviSaver::class); + $this->saver->allows('saveNew')->andReturnUsing(function (int $postId, string $content): array { + $this->saved[$postId] = $content; + + return []; + }); + } + + /** + * @param (callable(int): array)|null $registry Divi's dynamic content options per post ID. + */ + private function composer(bool $diviEnabled = true, ?callable $registry = null): DiviContentComposer + { + $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()); + $map = new DynamicContentMap(function (int $postId) use ($registry): array { + $this->registryCalls[] = $postId; + + return $registry !== null ? $registry($postId) : DynamicContentMapTest::registry(); + }); + + return new DiviContentComposer( + DiviPageLoaderTest::schema($diviEnabled), + new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media(), $map), + $this->saver, + $design + ); + } + + /** @return list> */ + private static function sections(): array + { + return [ + ['type' => 'section', 'label' => 'Hero', 'modules' => [['type' => 'heading', 'text' => 'Willkommen']]], + ['type' => 'section', 'modules' => [['type' => 'image', 'src' => 'https://kunde.test/a.jpg']]], + ]; + } + + public function testChecksBeforeTheEntryExistsWithDivisMapForNoEntry(): void + { + self::assertNull($this->composer()->check('page', self::sections())); + self::assertSame([0], $this->registryCalls, 'Divi\'s options for "no entry yet" (post ID 0)'); + self::assertSame([], $this->saved, 'check writes nothing'); + } + + public function testWritesAPlaceholderWrappedLayoutBoundToTheNewEntry(): void + { + $written = $this->composer()->write(42, 'page', self::sections()); + + self::assertIsArray($written); + self::assertSame([42], $this->registryCalls, 'the final layout uses the live map of the new entry'); + $content = $this->saved[42]; + // Ruling S9: exactly one divi/placeholder around the sections, as TreeEditor does for empty pages. + self::assertStringStartsWith('', $content); + self::assertSame(1, substr_count($content, '')); + $document = (new Parser())->parse($content); + self::assertCount(1, $document->children()); + self::assertCount(2, $document->children()[0]->children()); + self::assertContains('s1.r0.c0.m0: Bild ohne Alt-Text.', $written['warnings']); + self::assertStringContainsString('s0 section "Hero"', (string) $written['outline']); + } + + public function testRefusesTypesWithoutBuilderAndInvalidNodes(): void + { + $disabled = $this->composer(false)->check('post', [['type' => 'section']]); + self::assertInstanceOf(WP_Error::class, $disabled); + self::assertSame('lit_validation_failed', $disabled->get_error_code()); + self::assertArrayHasKey('divi', $disabled->get_error_data()['fields']); + + $invalid = $this->composer()->check('page', [ + ['type' => 'section', 'modules' => [['type' => 'button', 'attrs' => ['x.y' => ['desktop' => 1]]]]], + ['type' => 'section', 'modules' => [['type' => 'button', 'url' => 'javascript:alert(1)', 'text' => 'x']]], + ]); + self::assertInstanceOf(WP_Error::class, $invalid); + self::assertSame('lit_invalid_attr_path', $invalid->get_error_code()); + $fields = $invalid->get_error_data()['fields']; + self::assertArrayHasKey('divi[0].modules[0].modules[0][0].attrs.x.y', $fields); + self::assertGreaterThanOrEqual(2, count($fields), 'every error of every section comes back'); + self::assertSame([], $this->saved); + } + + /** The entry's own map can be narrower than the one for "no entry": then nothing is written. */ + public function testWriteRefusesWhatTheNewEntrysMapDoesNotOffer(): void + { + $registry = static function (int $postId): array { + $options = DynamicContentMapTest::registry(); + if ($postId !== 0) { + unset($options['site_title']); + } + + return $options; + }; + $title = VariableRef::serialize('content', 'site_title', ['before' => '', 'after' => '']); + $sections = [['type' => 'section', 'modules' => [['type' => 'heading', 'text' => $title]]]]; + + self::assertNull($this->composer(true, $registry)->check('page', $sections)); + $written = $this->composer(true, $registry)->write(42, 'page', $sections); + + self::assertInstanceOf(WP_Error::class, $written); + self::assertSame('lit_validation_failed', $written->get_error_code()); + self::assertSame([], $this->saved); + } + + public function testAFailingSaveIsThrown(): void + { + $this->saver = Mockery::mock(DiviSaver::class); + $this->saver->allows('saveNew')->andThrow(new RuntimeException('Divi flag missing')); + + $this->expectException(RuntimeException::class); + $this->composer()->write(42, 'page', self::sections()); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php b/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php index f0001f9..a7d7498 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php @@ -212,4 +212,59 @@ final class DiviSaverTest extends TestCase self::assertStringContainsString('Divi-Kennzeichen', $saved['warnings'][0]); self::assertStringContainsString('divi builder flags failed', (string) file_get_contents($this->log)); } + + public function testSaveNewWritesTheFirstContentWithoutKsesAndSetsTheFlagsStrictly(): void + { + Functions\when('current_user_can')->justReturn(false); + Functions\when('has_filter')->justReturn(10); + Functions\expect('wp_update_post')->once()->with(['ID' => 42, 'post_content' => 'NEU'], true) + ->andReturnUsing(function (): int { + $this->calls[] = 'update'; + + return 42; + }); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 42, 'post_content' => 'NEU'])); + Functions\expect('wp_save_post_revision')->never(); + + self::assertSame([], (new DiviSaver())->saveNew(42, 'NEU')); + self::assertSame( + // Same kses window as save(), but no restore point: the new draft has no earlier state. + [ + 'kses off', 'update', 'kses on', + 'meta:_et_pb_use_divi_5=on', 'meta:_et_pb_use_builder=on', 'meta:_et_pb_show_page_creation=off', + ], + $this->calls + ); + } + + public function testSaveNewReportsAContentChangedOnSaveAndKeepsKsesForUnfilteredUsers(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('has_filter')->justReturn(10); + Functions\when('wp_update_post')->justReturn(42); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 42, 'post_content' => 'ANDERS'])); + + self::assertSame([DiviSaver::CHANGED_ON_SAVE], (new DiviSaver())->saveNew(42, 'NEU')); + self::assertNotContains('kses off', $this->calls); + } + + public function testSaveNewThrowsWhenTheUpdateIsRefusedOrAFlagIsMissing(): void + { + Functions\when('current_user_can')->justReturn(false); + Functions\when('has_filter')->justReturn(10); + Functions\when('wp_update_post')->justReturn(new WP_Error('db', 'kaputt')); + + try { + (new DiviSaver())->saveNew(42, 'NEU'); + self::fail('RuntimeException expected'); + } catch (RuntimeException $e) { + self::assertStringContainsString('kaputt', $e->getMessage()); + } + self::assertSame(['kses off', 'kses on'], $this->calls, 'kses comes back, no flags on a failed write'); + + Functions\when('wp_update_post')->justReturn(42); + Functions\when('get_post_meta')->justReturn(''); + $this->expectException(RuntimeException::class); + (new DiviSaver())->saveNew(42, 'NEU'); + } } diff --git a/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php b/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php new file mode 100644 index 0000000..054490f --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php @@ -0,0 +1,283 @@ +'; + private const SECTIONS = [['type' => 'section']]; + private const INPUT = ['post_type' => 'page', 'title' => 'Start', 'divi' => self::SECTIONS]; + + private function schema(): ContentSchema + { + Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['create_posts' => 'edit_pages']]); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_current_user_id')->justReturn(3); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + $schema->allows('diviEnabled')->andReturn(true); + + return $schema; + } + + /** @return PostWriter&MockInterface */ + private function writer(): PostWriter + { + $writer = Mockery::mock(PostWriter::class); + $writer->allows('prepare')->andReturn(['fields' => [], 'terms' => [], 'warnings' => []]); + $writer->allows('postData')->andReturn(['post_title' => 'Start']); + $writer->allows('deniedMeta')->andReturn([]); + $writer->allows('writeExtras'); + $writer->allows('deleteCreatedTerms'); + + return $writer; + } + + /** @return ContentComposer&MockInterface */ + private function composer(bool $available = true): ContentComposer + { + $composer = Mockery::mock(ContentComposer::class); + $composer->allows('available')->andReturn($available); + $composer->allows('check')->with('page', self::SECTIONS)->andReturn(null); + + return $composer; + } + + private function log(): ChangeLog + { + $log = Mockery::mock(ChangeLog::class); + $log->allows('record')->andReturn(9); + $log->allows('recordPartial'); + + return $log; + } + + private function ability(?ContentComposer $composer, ?PostWriter $writer = null): ContentCreateAbility + { + return new ContentCreateAbility($this->schema(), $writer ?? $this->writer(), $this->log(), $composer); + } + + private function stubStoredDraft(): void + { + Functions\when('get_post')->justReturn(new WP_Post([ + 'ID' => 42, 'post_title' => 'Start', 'post_type' => 'page', 'post_status' => 'draft', 'post_content' => self::MARKUP, + ])); + Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=42&preview=true'); + Functions\when('get_edit_post_link')->justReturn('https://kunde.test/wp-admin/post.php?post=42&action=edit'); + } + + public function testCreatesADiviDraftWithTreeRevOutlineAndWarnings(): void + { + $composer = $this->composer(); + $composer->expects('write')->with(42, 'page', self::SECTIONS) + ->andReturn(['warnings' => ['s0: leer (section ohne Inhalt).'], 'outline' => 's0 divi/section']); + // D7: the insert carries only the normal WordPress fields (kses as usual) – never Divi markup; + // the layout is written by the Divi composer through DiviSaver's single kses window. + Functions\expect('wp_insert_post')->once()->with([ + 'post_title' => 'Start', + 'post_type' => 'page', + 'post_status' => 'draft', + 'post_author' => 3, + ], true)->andReturn(42); + $this->stubStoredDraft(); + + $result = $this->ability($composer)->execute(self::INPUT); + + self::assertTrue($result['ok']); + self::assertSame(Rev::of(self::MARKUP), $result['tree_rev']); + self::assertSame(['s0: leer (section ohne Inhalt).'], $result['warnings']); + self::assertSame('s0 divi/section', $result['outline']); + self::assertStringContainsString('Entwurf', $result['summary'], 'drafts stay drafts'); + self::assertArrayHasKey('tree_rev', $this->ability(null)->outputSchema()['properties']); + self::assertArrayHasKey('divi', $this->ability(null)->inputSchema()['properties']); + } + + public function testWithoutDiviTheComposerIsNotUsed(): void + { + $composer = Mockery::mock(ContentComposer::class); + $composer->expects('available')->never(); + Functions\when('wp_insert_post')->justReturn(42); + $this->stubStoredDraft(); + + $result = $this->ability($composer)->execute(['post_type' => 'page', 'title' => 'Start']); + + self::assertArrayNotHasKey('tree_rev', $result); + } + + public function testDiviNeedsDiviAndExcludesContent(): void + { + Functions\expect('wp_insert_post')->never(); + + $inactive = $this->ability($this->composer(false))->execute(self::INPUT); + self::assertSame('lit_unsupported', $inactive->get_error_code()); + + $none = $this->ability(null)->execute(self::INPUT); + self::assertSame('lit_unsupported', $none->get_error_code()); + + $both = $this->ability($this->composer())->execute(self::INPUT + ['content' => '

x

']); + self::assertSame('lit_validation_failed', $both->get_error_code()); + self::assertArrayHasKey('content', $both->get_error_data()['fields']); + } + + public function testEveryValidationErrorComesBackAndNothingIsCreated(): void + { + Functions\expect('wp_insert_post')->never(); + $composer = Mockery::mock(ContentComposer::class); + $composer->allows('available')->andReturn(true); + $composer->allows('check')->andReturn(Errors::invalidAttrPath(['divi[0].attrs.x' => 'unbekannt'])); + $writer = Mockery::mock(PostWriter::class); + $writer->allows('prepare')->andReturn(Errors::invalidInput('ungültig', ['fields.role' => 'Pflichtfeld fehlt.'])); + + $error = $this->ability($composer, $writer)->execute(self::INPUT); + + self::assertSame('lit_invalid_attr_path', $error->get_error_code()); + self::assertSame( + ['fields.role' => 'Pflichtfeld fehlt.', 'divi[0].attrs.x' => 'unbekannt'], + $error->get_error_data()['fields'] + ); + } + + public function testAFailingLayoutWriteDiscardsTheDraft(): void + { + $composer = $this->composer(); + $composer->allows('write')->andThrow(new RuntimeException('Divi flag missing')); + Functions\when('wp_insert_post')->justReturn(42); + Functions\expect('wp_delete_post')->once()->with(42, true)->andReturn(new WP_Post(['ID' => 42])); + + try { + $this->ability($composer)->execute(self::INPUT); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + } + } + + public function testALayoutTheNewEntryRefusesDeletesTheDraftAndReturnsTheErrors(): void + { + $composer = $this->composer(); + $composer->allows('write')->andReturn(Errors::invalidInput('ungültig', ['divi[0].modules[0].text' => 'nicht angeboten'])); + Functions\when('wp_insert_post')->justReturn(42); + Functions\expect('wp_delete_post')->once()->with(42, true)->andReturn(new WP_Post(['ID' => 42])); + + $error = $this->ability($composer)->execute(self::INPUT); + + self::assertInstanceOf(WP_Error::class, $error); + self::assertSame('lit_validation_failed', $error->get_error_code()); + self::assertArrayHasKey('divi[0].modules[0].text', $error->get_error_data()['fields']); + } + + public function testARefusedLayoutWhoseDraftCannotBeDeletedIsAPartialWrite(): void + { + $composer = $this->composer(); + $composer->allows('write')->andReturn(Errors::invalidInput('ungültig', ['divi[0].modules[0].text' => 'nicht angeboten'])); + Functions\when('wp_insert_post')->justReturn(42); + Functions\expect('wp_delete_post')->once()->andReturn(false); + + try { + $this->ability($composer)->execute(self::INPUT); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame(42, $e->postId); + self::assertStringContainsString('divi[0].modules[0].text', $e->reason); + } + } + + /** + * Rulings S40/S41 hold for content-create too – they live in the Composer: the same path via + * short form and attrs is refused (nothing created); an admin label in attrs wins over label. + */ + public function testShortFormAndAttrsRulesApplyThroughTheRealComposer(): void + { + $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()); + $saved = []; + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('saveNew')->andReturnUsing(static function (int $postId, string $content) use (&$saved): array { + $saved[] = $content; + + return []; + }); + $real = new DiviContentComposer( + $this->schema(), + new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media()), + $saver, + $design + ); + self::assertFalse($real->available(), 'no Divi classes in unit tests'); + // Only available() is replaced (Divi's classes are missing here); check/write are the real ones. + $composer = new class ($real) implements ContentComposer { + public function __construct(private DiviContentComposer $real) + { + } + + public function available(): bool + { + return true; + } + + public function check(string $postType, array $sections): ?WP_Error + { + return $this->real->check($postType, $sections); + } + + public function write(int $postId, string $postType, array $sections): array|WP_Error + { + return $this->real->write($postId, $postType, $sections); + } + }; + Functions\when('is_admin')->justReturn(false); + $this->stubStoredDraft(); + + Functions\expect('wp_insert_post')->never(); + $overlap = $this->ability($composer)->execute(['post_type' => 'page', 'title' => 'Start', 'divi' => [[ + 'type' => 'section', + 'label' => 'Kurz', + 'attrs' => ['module.meta.adminLabel' => ['desktop' => 'Lang']], + 'modules' => [['type' => 'button', 'text' => 'Los', 'attrs' => ['button.innerContent' => ['desktop' => ['text' => 'x']]]]], + ]]]); + self::assertSame('lit_validation_failed', $overlap->get_error_code()); + $fields = $overlap->get_error_data()['fields']; + self::assertArrayHasKey('divi[0].attrs.module.meta.adminLabel', $fields, 'S41: label and attrs label'); + self::assertArrayHasKey('divi[0].modules[0].modules[0][0].attrs.button.innerContent', $fields, 'S40'); + self::assertSame([], $saved); + + Functions\expect('wp_insert_post')->once()->andReturn(42); + $label = $this->ability($composer)->execute(['post_type' => 'page', 'title' => 'Start', 'divi' => [[ + 'type' => 'section', + 'label' => 'Hero', + 'modules' => [['type' => 'heading', 'text' => 'Hallo', 'attrs' => ['module.meta.adminLabel' => ['desktop' => 'Aus attrs']]]], + ]]]); + self::assertTrue($label['ok']); + self::assertStringContainsString('"adminLabel":{"desktop":{"value":"Aus attrs"}}', $saved[0], 'S41: no auto label over it'); + } +} diff --git a/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php index dd64491..946f087 100644 --- a/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php +++ b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php @@ -5,7 +5,9 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\WordPress; use LIT\MCP\Abilities\Ability; +use Brain\Monkey\Functions; use LIT\MCP\ChangeLog\ChangeLog; +use LIT\MCP\Integrations\WordPress\Content\ContentComposer; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\WordPressIntegration; use LIT\MCP\Tests\Unit\TestCase; @@ -61,4 +63,23 @@ final class WordPressIntegrationTest extends TestCase self::assertMatchesRegularExpression('#^lit/[a-z]+(-[a-z]+)+$#', $ability->name()); } } + + public function testContentCreateGetsTheContentComposer(): void + { + Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['create_posts' => 'edit_pages']]); + Functions\when('current_user_can')->justReturn(true); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + $composer = Mockery::mock(ContentComposer::class); + $composer->expects('available')->andReturn(false); + $integration = new WordPressIntegration($schema, Mockery::mock(ChangeLog::class), $composer); + + $create = array_values(array_filter( + $integration->abilities(), + static fn (Ability $ability): bool => $ability->name() === 'lit/content-create' + ))[0]; + $result = $create->execute(['post_type' => 'page', 'title' => 'Start', 'divi' => [['type' => 'section']]]); + + self::assertSame('lit_unsupported', $result->get_error_code()); + } }