Files
janniclas.loosenandClaude Opus 5.5 6be2e65432 feat: Pods fields in Theme Builder bodies checked against the template's post types (E12)
Both forms Divi writes are recognized (ruling Q11): custom_meta_<key> and the
dialog form post_meta_key + select_meta_key. Choice fields get a note about the
stored value, relation fields name IDs (F4). Only templates the user may edit
are checked or named; others are counted (Q21 pattern). Warnings only, also in
the dry run of a live body. DynamicFields extracted from lit/divi-dynamic-sources.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 21:02:53 +02:00

475 lines
19 KiB
PHP
Raw Permalink 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\Divi\ThemeBuilder;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DynamicFields;
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use LIT\MCP\Integrations\Divi\ThemeBuilder\InsertedBeforeFailure;
use LIT\MCP\Integrations\Divi\ThemeBuilder\TbCreateTemplateAbility;
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
use LIT\MCP\Integrations\Divi\ThemeBuilder\TemplateConditions;
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
use LIT\MCP\Tests\Support\DiviWriterFixture;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
use Throwable;
use WP_Post;
final class TbCreateTemplateAbilityTest extends TestCase
{
/** @var list<array{int, string, SaveScope}> */
private array $savedNew = [];
protected function setUp(): void
{
parent::setUp();
// The default template's global header 1061 is a published layout the user may edit (ruling Q8).
LibraryFixtures::given([ThemeBuilderFixtures::layout(ThemeBuilderFixtures::HEADER)]);
Functions\when('wp_delete_object_term_relationships')->justReturn(null);
Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path);
}
/** The template 2100 as Divi would store it for input(). */
private static function stored(array $overrides = []): Template
{
return ThemeBuilderFixtures::template(2100, array_merge([
'title' => 'Person',
'layouts' => [
'header' => ['id' => 1061, 'enabled' => true],
'body' => ['id' => 2101, 'enabled' => true],
'footer' => ['id' => 0, 'enabled' => false],
],
'use_on' => ['singular:post_type:lit_person:all'],
'attached' => false,
], $overrides));
}
private static function store(int $draftId = 0, ?Template $stored = null): DiviThemeBuilder
{
return ThemeBuilderFixtures::store(
[ThemeBuilderFixtures::template(1065), ThemeBuilderFixtures::template(1066)],
$draftId,
[],
[$stored ?? self::stored()]
);
}
/** @param list<string> $deniedRoles */
private function ability(
DiviThemeBuilder $store,
?FakeChangeLog $log = null,
array $deniedRoles = [],
?Throwable $saveFailure = null
): TbCreateTemplateAbility {
$saver = Mockery::mock(DiviSaver::class);
$saver->allows('saveNew')->andReturnUsing(
function (int $id, string $content, SaveScope $scope) use ($saveFailure): array {
if ($saveFailure !== null) {
throw $saveFailure;
}
$this->savedNew[] = [$id, $content, $scope];
return [];
}
);
return new TbCreateTemplateAbility(
ThemeBuilderFixtures::access($deniedRoles, true, $store),
new TemplateConditions($store),
DiviWriterFixture::composition(DiviWriterFixture::designReader()),
$saver,
$log ?? DiviWriterFixture::log(),
new ThemeBuilderEdits(ThemeBuilderFixtures::access([], true, $store), fields: self::personFields())
);
}
private static function personFields(): DynamicFields
{
$fields = Mockery::mock(DynamicFields::class);
$fields->allows('reason')->andReturnUsing(
static fn (string $type, string $key): ?string => $key === 'role' ? null : 'kein bekanntes Feld dieses Inhaltstyps'
);
$fields->allows('note')->andReturnNull();
return $fields;
}
/** @return array<string, mixed> */
private static function input(array $extra = []): array
{
return array_merge([
'title' => 'Person',
'header' => ['source' => 'global'],
'body' => [
'source' => 'new',
'nodes' => [[
'type' => 'section',
'label' => 'Person',
'modules' => [
['type' => 'heading', 'text' => '{{post:title}}'],
['type' => 'text', 'content' => '{{field:role}}'],
],
]],
],
'footer' => ['source' => 'hidden'],
'use_on' => ['singular:post_type:lit_person:all'],
], $extra);
}
private static function expectCreation(DiviThemeBuilder $store): void
{
$store->shouldReceive('insertLayout')->once()->with('body', 'Person – Inhalt')->andReturn(2101);
$store->shouldReceive('storeTemplate')->once()->with(1064, [
'title' => 'Person',
'layouts' => [
'header' => ['id' => 1061, 'enabled' => true],
'body' => ['id' => 2101, 'enabled' => true],
'footer' => ['id' => 0, 'enabled' => false],
],
'use_on' => ['singular:post_type:lit_person:all'],
'exclude_from' => [],
])->andReturn(2100);
}
/** @param list<int> $deleted Receives the IDs wp_delete_post() is called with. */
private static function deletions(array &$deleted): void
{
Functions\when('wp_delete_post')->alias(static function (int $id, bool $force) use (&$deleted): WP_Post {
$deleted[] = $id;
return new WP_Post(['ID' => $id]);
});
}
public function testNewTemplateIsDisabledAndAttached(): void
{
$store = self::store();
self::expectCreation($store);
$store->shouldReceive('attach')->once()->with(1064, 2100);
$store->shouldReceive('clearCaches')->once();
$store->shouldReceive('setEnabled')->never();
$log = DiviWriterFixture::log();
$result = $this->ability($store, $log)->execute(self::input());
self::assertTrue($result['ok']);
self::assertSame(2100, $result['template_id']);
self::assertFalse($result['enabled']);
self::assertSame($store->template(2100, true)?->rev(), $result['template_rev']);
self::assertSame(['id' => 2101, 'enabled' => true, 'source' => 'new'], $result['layouts']['body']);
self::assertSame(['id' => 1061, 'enabled' => true, 'source' => 'global'], $result['layouts']['header']);
self::assertSame(['id' => 0, 'enabled' => false, 'source' => 'hidden'], $result['layouts']['footer']);
self::assertCount(1, $this->savedNew);
[$id, $content, $scope] = $this->savedNew[0];
self::assertSame(2101, $id);
self::assertSame(SaveScope::ThemeBuilderLayout, $scope);
self::assertStringStartsWith('<!-- wp:divi/placeholder -->', $content);
self::assertStringContainsString('custom_meta_role', $content);
self::assertContains(TbCreateTemplateAbility::STALE_EDITOR, $result['warnings']);
self::assertStringContainsString('deaktiviert', $result['summary']);
$log->assertRecorded([
'ability' => 'lit/tb-create-template',
'object_type' => 'et_template',
'object_id' => '2100',
]);
self::assertStringContainsString('body 2101', $log->summaries()[0]);
}
public function testNewLayoutsWithoutNodesAndDefaultAreasStoreNoContent(): void
{
$stored = self::stored(['layouts' => [
'header' => ['id' => 2101, 'enabled' => true],
'body' => ['id' => 0, 'enabled' => true],
'footer' => ['id' => 0, 'enabled' => true],
], 'use_on' => []]);
$store = self::store(0, $stored);
$store->shouldReceive('insertLayout')->once()->with('header', 'Eigener Kopf')->andReturn(2101);
$store->shouldReceive('storeTemplate')->once()->andReturn(2100);
$store->shouldReceive('attach')->once()->with(1064, 2100);
$store->shouldReceive('clearCaches')->once();
$result = $this->ability($store)->execute([
'title' => 'Person',
'header' => ['source' => 'new', 'title' => 'Eigener Kopf'],
]);
self::assertTrue($result['ok']);
self::assertSame([], $this->savedNew);
self::assertSame(['id' => 0, 'enabled' => true, 'source' => 'default'], $result['layouts']['body']);
}
/** E7: an unsaved Theme Builder session would later replace the template list. */
public function testRefusesWhileTheThemeBuilderHasADraft(): void
{
$store = self::store(999);
$store->shouldReceive('insertLayout')->never();
$store->shouldReceive('storeTemplate')->never();
$ability = $this->ability($store);
self::assertToolError($ability->permission(self::input()), 'unsupported');
self::assertToolError($ability->execute(self::input()), 'unsupported');
}
public function testInvalidInputCreatesNothing(): void
{
$store = self::store();
$store->shouldReceive('insertLayout')->never();
$result = $this->ability($store)->execute(self::input([
'title' => '<b></b>',
'body' => ['source' => 'global'],
'footer' => ['source' => 'new', 'nodes' => [['type' => 'section', 'modules' => [['type' => 'post-content']]]]],
'use_on' => ['singular:post_type:lit_person:all', 'erfunden'],
]));
self::assertToolError($result, 'validation_failed');
$fields = self::errorFields($result);
self::assertArrayHasKey('title', $fields);
self::assertArrayHasKey('body.source', $fields);
self::assertArrayHasKey('use_on.1', $fields);
self::assertNotSame([], array_filter(array_keys($fields), static fn (string $key): bool => str_starts_with($key, 'footer.nodes')));
}
public function testNodesOnlyWithANewLayout(): void
{
$store = self::store();
$store->shouldReceive('insertLayout')->never();
$result = $this->ability($store)->execute(self::input([
'header' => ['source' => 'hidden', 'nodes' => [['type' => 'section']]],
]));
self::assertToolError($result, 'validation_failed');
self::assertSame('nur mit source "new"', self::errorFields($result)['header.nodes']);
}
/**
* Ruling Q8: a reused ("global") layout must be a published layout of the area the user may edit; missing,
* trashed and not editable read the same.
*/
public function testTheGlobalHeaderMustBeAPublishedEditableLayout(): void
{
$messages = [];
foreach ([[[], []], [[ThemeBuilderFixtures::layout(1061, ['post_status' => 'trash'])], []], [[ThemeBuilderFixtures::layout(1061)], ['edit_post']]] as [$posts, $denied]) {
LibraryFixtures::given($posts, $denied);
$store = self::store();
$store->shouldReceive('insertLayout')->never();
$result = $this->ability($store)->execute(self::input());
self::assertToolError($result, 'validation_failed');
$messages[] = self::errorFields($result)['header.source'] ?? null;
}
self::assertNotNull($messages[0]);
self::assertSame([$messages[0], $messages[0], $messages[0]], $messages);
}
public function testFailedTemplateStoreDeletesTheLayouts(): void
{
$store = self::store();
$store->shouldReceive('insertLayout')->once()->andReturn(2101);
$store->shouldReceive('storeTemplate')->once()->andThrow(new RuntimeException('Divi called wp_die(): '));
$store->shouldReceive('attach')->never();
Functions\expect('wp_delete_post')->once()->with(2101, true)->andReturn(new WP_Post(['ID' => 2101]));
$this->expectException(WriteFailed::class);
$this->ability($store)->execute(self::input());
}
public function testAFailedLayoutSaveDeletesTheLayout(): void
{
$store = self::store();
$store->shouldReceive('insertLayout')->once()->andReturn(2101);
$store->shouldReceive('storeTemplate')->never();
$deleted = [];
self::deletions($deleted);
try {
$this->ability($store, null, [], new RuntimeException('kses window'))->execute(self::input());
self::fail('no WriteFailed');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
}
self::assertSame([2101], $deleted);
}
/** Divi drops layout IDs that are no published layout of the area (theme-builder.php:1486-1496). */
public function testDiviDroppingALayoutIsRolledBack(): void
{
$dropped = self::stored(['layouts' => [
'header' => ['id' => 1061, 'enabled' => true],
'body' => ['id' => 0, 'enabled' => true],
'footer' => ['id' => 0, 'enabled' => false],
]]);
$store = self::store(0, $dropped);
self::expectCreation($store);
$store->shouldReceive('attach')->never();
$deleted = [];
self::deletions($deleted);
try {
$this->ability($store)->execute(self::input());
self::fail('no WriteFailed');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
}
self::assertSame([2100, 2101], $deleted);
}
/** Answer F1 / ruling Q5: a template that reads back enabled is removed again, never left live. */
public function testATemplateStoredEnabledIsRolledBack(): void
{
$store = self::store(0, self::stored(['enabled' => true]));
self::expectCreation($store);
$store->shouldReceive('attach')->never();
$deleted = [];
self::deletions($deleted);
$this->expectException(WriteFailed::class);
try {
$this->ability($store)->execute(self::input());
} finally {
self::assertSame([2100, 2101], $deleted);
}
}
public function testAFailedAttachDetachesAndDeletesEverything(): void
{
$store = self::store();
self::expectCreation($store);
$store->shouldReceive('attach')->once()->andThrow(new RuntimeException('not added'));
$store->shouldReceive('detach')->once()->with(1064, 2100);
$deleted = [];
self::deletions($deleted);
$this->expectException(WriteFailed::class);
try {
$this->ability($store)->execute(self::input());
} finally {
self::assertSame([2100, 2101], $deleted);
}
}
public function testACleanupThatFailsIsAPartialWrite(): void
{
$store = self::store();
$store->shouldReceive('insertLayout')->once()->andReturn(2101);
$store->shouldReceive('storeTemplate')->once()->andThrow(new RuntimeException('store failed'));
Functions\when('wp_delete_post')->justReturn(false);
$log = FakeChangeLog::partialsOnly();
try {
$this->ability($store, $log)->execute(self::input());
self::fail('no WriteFailed');
} catch (WriteFailed $e) {
self::assertFalse($e->rolledBack);
self::assertToolError($e->toError(), 'partial_write');
}
$log->assertPartials(['ability' => 'lit/tb-create-template', 'object_id' => '2101']);
}
public function testNeedsTheThemeBuilderRole(): void
{
$store = self::store();
$store->shouldReceive('insertLayout')->never();
$ability = $this->ability($store, null, ['theme_builder']);
self::assertToolError($ability->permission(self::input()), 'forbidden');
self::assertToolError($ability->execute(self::input()), 'forbidden');
self::assertFalse($ability->isDirect());
self::assertFalse($ability->annotations()['destructive']);
}
public function testNeverOffersToEnable(): void
{
$ability = $this->ability(self::store());
$schema = $ability->inputSchema();
self::assertArrayNotHasKey('enabled', $schema['properties']);
self::assertFalse($schema['additionalProperties']);
self::assertSame(TbCreateTemplateAbility::SOURCES, $schema['properties']['body']['properties']['source']['enum']);
}
/** Ruling Q19: Divi threw after inserting the template (e.g. a hook's wp_die) – that template goes too. */
public function testATemplateInsertedBeforeDiviThrewIsRolledBack(): void
{
$store = self::store();
$store->shouldReceive('insertLayout')->once()->andReturn(2101);
$store->shouldReceive('storeTemplate')->once()->andThrow(
new InsertedBeforeFailure('Divi called wp_die(): hook', [2100], new RuntimeException('hook'))
);
$store->shouldReceive('attach')->never();
$deleted = [];
self::deletions($deleted);
try {
$this->ability($store)->execute(self::input());
self::fail('no WriteFailed');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
}
self::assertSame([2100, 2101], $deleted);
}
public function testALayoutInsertedBeforeDiviThrewIsRolledBack(): void
{
$store = self::store();
$store->shouldReceive('insertLayout')->once()->andThrow(
new InsertedBeforeFailure('Divi called wp_die(): hook', [2101], new RuntimeException('hook'))
);
$store->shouldReceive('storeTemplate')->never();
$deleted = [];
self::deletions($deleted);
$this->expectException(WriteFailed::class);
try {
$this->ability($store)->execute(self::input());
} finally {
self::assertSame([2101], $deleted);
}
}
/** Ruling Q19: the default template counts only when the user may edit it (like tb-list, Q8/B11). */
public function testTheGlobalHeaderNeedsAnEditableDefaultTemplate(): void
{
LibraryFixtures::given([ThemeBuilderFixtures::layout(ThemeBuilderFixtures::HEADER)]);
Functions\when('current_user_can')->alias(
static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post'
&& (int) ($args[0] ?? 0) === ThemeBuilderFixtures::DEFAULT_TEMPLATE)
);
$store = self::store();
$store->shouldReceive('insertLayout')->never();
$result = $this->ability($store)->execute(self::input());
self::assertToolError($result, 'validation_failed');
self::assertArrayHasKey('header.source', self::errorFields($result));
}
public function testAnUnknownFieldInTheNewBodyIsAWarning(): void
{
$store = self::store();
self::expectCreation($store);
$store->shouldReceive('attach')->once();
$store->shouldReceive('clearCaches')->once();
$input = self::input();
$input['body']['nodes'][0]['modules'][] = ['type' => 'text', 'content' => '{{field:telefon}}'];
$result = $this->ability($store)->execute($input);
self::assertTrue($result['ok']);
self::assertNotSame([], array_filter($result['warnings'], static fn (string $w): bool => str_contains($w, '„telefon“')));
self::assertSame([], array_filter($result['warnings'], static fn (string $w): bool => str_contains($w, '„role“')));
}
}