'; 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 testRequirementsAreEmptyWhenTheComposerIsUnavailable(): void { $composer = $this->composer(available: false); $composer->shouldNotReceive('requirements'); self::assertSame( [], $this->ability($composer)->requirements(self::INPUT), 'Divi inactive: execute() answers unsupported ("Divi 5 ist nicht aktiv"), the gate must not refuse first' ); } 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' => '', ])); }); $result = $this->ability($composer, $writer)->execute(self::INPUT + ['fields' => ['role' => 'x']]); self::assertSame(Rev::of(''), $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' => '

x

']); 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'); } }