Files
WP-LIT-MCP/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php
T
janniclas.loosenandClaude Opus 5.5 5965e32acb feat: lit/content-create with Divi short form
New input divi (1-30 sections, short form like lit-divi-insert) builds a Divi 5
layout in one divi/placeholder. The whole description is checked before the
insert (Divi's map for "no entry yet"); the draft is inserted with its normal
WordPress fields only, then DiviContentComposer composes again bound to the new
entry and stores it via DiviSaver::saveNew (same kses window as save(), strict
DiviFlags). A refusal or failure deletes the draft. Result adds tree_rev and the
outline.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 16:46:50 +02:00

284 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Content;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility;
use LIT\MCP\Integrations\WordPress\Content\PostWriter;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Tests\Unit\Integrations\Divi\Compose\ComposeFixture;
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use Mockery\MockInterface;
use RuntimeException;
use WP_Error;
use WP_Post;
final class ContentCreateDiviTest extends TestCase
{
private const MARKUP = '<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->';
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' => '<p>x</p>']);
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');
}
}