Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
403 lines
17 KiB
PHP
403 lines
17 KiB
PHP
<?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\Divi\Release\ReleaseState;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
|
||
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
|
||
use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility;
|
||
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
|
||
use LIT\MCP\Integrations\WordPress\Content\PostWriter;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||
use LIT\MCP\Tests\Support\SchemaFixture;
|
||
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 = SchemaFixture::managed(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, bool $writable = true, ?WP_Error $refusal = null): ContentComposer
|
||
{
|
||
$composer = Mockery::mock(ContentComposer::class);
|
||
$composer->allows('available')->andReturn($available);
|
||
$composer->allows('writable')->andReturn($writable);
|
||
$composer->allows('refusal')->andReturn($refusal);
|
||
$composer->allows('check')->with('page', self::SECTIONS)->andReturn(null);
|
||
|
||
return $composer;
|
||
}
|
||
|
||
private function log(): ChangeLog
|
||
{
|
||
$log = FakeChangeLog::any(9);
|
||
|
||
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');
|
||
Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=42');
|
||
Functions\when('add_query_arg')->alias(
|
||
static fn (string $key, string $value, string $url): string => $url . '&' . $key . '=' . $value
|
||
);
|
||
}
|
||
|
||
public function testRequirementsAreEmptyWithoutDiviWhateverTheComposerNeeds(): void
|
||
{
|
||
$composer = $this->composer();
|
||
$composer->shouldNotReceive('requirements');
|
||
|
||
self::assertSame([], $this->ability($composer)->requirements(['post_type' => 'page', 'title' => 'Start']));
|
||
self::assertSame([], $this->ability(null)->requirements(self::INPUT), 'no composer: the call is refused anyway');
|
||
}
|
||
|
||
public function testRequirementsWithDiviAreThoseOfTheComposer(): void
|
||
{
|
||
$composer = $this->composer();
|
||
$composer->expects('requirements')->andReturn(['page-write', 'catalog']);
|
||
|
||
self::assertSame(['page-write', 'catalog'], $this->ability($composer)->requirements(self::INPUT));
|
||
}
|
||
|
||
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',
|
||
'edit_url' => 'https://kunde.test/?page_id=42&et_fb=1',
|
||
]);
|
||
// 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']);
|
||
// Review I-1: like every Divi write, the builder – not wp-admin – is where to continue.
|
||
self::assertSame('https://kunde.test/?page_id=42&et_fb=1', $result['edit_url']);
|
||
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']);
|
||
}
|
||
|
||
/**
|
||
* Review m-6: the extras are written after the layout; if that re-saves the entry (e.g. Pods
|
||
* object fields call wp_update_post, a save_post listener) and the layout changes, say so.
|
||
*/
|
||
public function testALayoutChangedByTheExtrasIsReported(): void
|
||
{
|
||
$composer = $this->composer();
|
||
$composer->allows('write')->andReturn(['warnings' => [], 'outline' => 's0 divi/section']);
|
||
Functions\when('wp_insert_post')->justReturn(42);
|
||
$this->stubStoredDraft();
|
||
$writer = Mockery::mock(PostWriter::class);
|
||
$writer->allows('prepare')->andReturn(['fields' => [], 'terms' => [], 'warnings' => []]);
|
||
$writer->allows('postData')->andReturn(['post_title' => 'Start']);
|
||
$writer->allows('deniedMeta')->andReturn([]);
|
||
$writer->expects('writeExtras')->andReturnUsing(static function (): void {
|
||
Functions\when('get_post')->justReturn(new WP_Post([
|
||
'ID' => 42, 'post_title' => 'Start', 'post_type' => 'page', 'post_status' => 'draft',
|
||
'post_content' => '<!-- wp:divi/placeholder /-->',
|
||
]));
|
||
});
|
||
|
||
$result = $this->ability($composer, $writer)->execute(self::INPUT + ['fields' => ['role' => 'x']]);
|
||
|
||
self::assertSame(Rev::of('<!-- wp:divi/placeholder /-->'), $result['tree_rev'], 'the stored state');
|
||
self::assertContains(ContentCreateAbility::LAYOUT_CHANGED, $result['warnings']);
|
||
|
||
$this->stubStoredDraft();
|
||
$unchanged = $this->ability($this->composerWriting(), $this->writer())->execute(self::INPUT);
|
||
self::assertNotContains(ContentCreateAbility::LAYOUT_CHANGED, $unchanged['warnings']);
|
||
}
|
||
|
||
/** @return ContentComposer&MockInterface */
|
||
private function composerWriting(): ContentComposer
|
||
{
|
||
$composer = $this->composer();
|
||
$composer->allows('write')->andReturn(['warnings' => [], 'outline' => null]);
|
||
|
||
return $composer;
|
||
}
|
||
|
||
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::assertToolError($inactive, 'unsupported');
|
||
|
||
$none = $this->ability(null)->execute(self::INPUT);
|
||
self::assertToolError($none, 'unsupported');
|
||
|
||
$both = $this->ability($this->composer())->execute(self::INPUT + ['content' => '<p>x</p>']);
|
||
self::assertToolError($both, 'validation_failed');
|
||
self::assertArrayHasKey('content', self::errorFields($both));
|
||
}
|
||
|
||
/**
|
||
* Security M2: with the Divi group disabled or "nur lesen", or lit/divi-insert disabled, the
|
||
* divi input is refused before anything is created; content-create without divi keeps working.
|
||
*/
|
||
public function testLockedDiviRefusesBeforeTheDraftExists(): void
|
||
{
|
||
$refusal = (new ReleaseStatus(ReleaseState::Untested, '5.3.0'))->refusal();
|
||
Functions\expect('wp_insert_post')->never();
|
||
|
||
$result = $this->ability($this->composer(true, true, $refusal))->execute(self::INPUT);
|
||
|
||
self::assertToolError($result, 'divi_untested');
|
||
}
|
||
|
||
public function testDiviIsRefusedWhenDiviWritesAreSwitchedOff(): void
|
||
{
|
||
Functions\expect('wp_insert_post')->never();
|
||
|
||
$error = $this->ability($this->composer(true, false))->execute(self::INPUT);
|
||
|
||
$payload = self::assertToolError($error, 'unsupported');
|
||
self::assertStringContainsString('Tools', (string) $payload['hint']);
|
||
self::assertStringContainsString('ohne divi', (string) $payload['hint']);
|
||
}
|
||
|
||
public function testWithoutDiviTheGateIsNotAsked(): void
|
||
{
|
||
$composer = Mockery::mock(ContentComposer::class);
|
||
$composer->expects('writable')->never();
|
||
Functions\when('wp_insert_post')->justReturn(42);
|
||
$this->stubStoredDraft();
|
||
|
||
$result = $this->ability($composer)->execute(['post_type' => 'page', 'title' => 'Start']);
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertStringContainsString('/wp-admin/post.php', $result['edit_url'], 'without divi: the editor');
|
||
}
|
||
|
||
public function testEveryValidationErrorComesBackAndNothingIsCreated(): void
|
||
{
|
||
Functions\expect('wp_insert_post')->never();
|
||
$composer = Mockery::mock(ContentComposer::class);
|
||
$composer->allows('available')->andReturn(true);
|
||
$composer->allows('writable')->andReturn(true);
|
||
$composer->allows('refusal')->andReturn(null);
|
||
$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::assertToolError($error, 'invalid_attr_path');
|
||
self::assertSame(
|
||
['fields.role' => 'Pflichtfeld fehlt.', 'divi[0].attrs.x' => 'unbekannt'],
|
||
self::errorFields($error)
|
||
);
|
||
}
|
||
|
||
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);
|
||
// Ruling S50 / ledger T9: the client reads about the layout, not about fields or terms.
|
||
self::assertSame(NewDraft::REASON_LAYOUT, $e->reason);
|
||
self::assertStringContainsString('Divi-Layout', $e->reason);
|
||
}
|
||
}
|
||
|
||
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::assertToolError($error, 'validation_failed');
|
||
self::assertArrayHasKey('divi[0].modules[0].text', self::errorFields($error));
|
||
}
|
||
|
||
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 writable(): bool
|
||
{
|
||
return true;
|
||
}
|
||
|
||
public function refusal(): ?WP_Error
|
||
{
|
||
return null;
|
||
}
|
||
|
||
public function requirements(): array
|
||
{
|
||
return $this->real->requirements();
|
||
}
|
||
|
||
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::assertToolError($overlap, 'validation_failed');
|
||
$fields = self::errorFields($overlap);
|
||
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');
|
||
}
|
||
}
|