Files
WP-LIT-MCP/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php
T
janniclas.loosenandClaude Opus 5.5 3d96a41366 test: shared Divi fixtures in tests/Support (T1)
- DiviFixtures: markup(), givenPost(), schema(), designSystem(), catalog(),
  dynamicContentRegistry(), maliciousRefs() - moved verbatim out of
  DiviPageLoaderTest, DesignSystemTest, ModuleCatalogTest, DynamicContentMapTest
  and VariableRefTest (its malicious() provider now returns maliciousRefs()).
- ComposeFixture moves to tests/Support.
- DiviWriterFixture: modules(), designReader(), composition(), a recording saver(),
  log() and writer(&$saved, &$failures, unfiltered, loader) for the five tests that
  wired the Divi write stack by hand.
No test cases or assertions changed (1397 / 5834).

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

275 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\Pages\DiviContentComposer;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
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\Support\DiviWriterFixture;
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
{
$design = DiviWriterFixture::designReader();
$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(),
DiviWriterFixture::composition($design),
$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');
}
}