feat: Theme Builder adapter, template value and per-object access

DiviThemeBuilder wraps Divi's Theme Builder: reads through Divi's functions,
writes layouts/templates through insert_layout/store_template behind a
wp_die guard (edit_others_posts and function checks first, every handler
filter and output buffer restored in finally), list/conditions/enabled
mirrored from Divi's own save. Template: live = attached and enabled,
whatever the post status (ruling Q8/B8). ThemeBuilderAccess: edit_others_posts
plus Divi's theme_builder role, hidden IDs answer like missing ones,
canEditTemplate (B11) and layoutsRefusal (B10). DiviCompatibility checks the
Theme Builder functions and post types (E15).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 15:18:42 +02:00
co-authored by Claude Opus 5.5
parent 82ef69a99c
commit d04a508751
9 changed files with 1515 additions and 0 deletions
@@ -0,0 +1,330 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
use Brain\Monkey\Filters;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
use WP_Error;
final class DiviThemeBuilderTest extends TestCase
{
public function testAreaOfTheLayoutTypes(): void
{
self::assertSame('header', DiviThemeBuilder::areaOf('et_header_layout'));
self::assertSame('body', DiviThemeBuilder::areaOf('et_body_layout'));
self::assertSame('footer', DiviThemeBuilder::areaOf('et_footer_layout'));
self::assertNull(DiviThemeBuilder::areaOf('et_template'));
self::assertNull(DiviThemeBuilder::areaOf('page'));
}
/** F17/F18: Divi wp_die()s without edit_others_posts – the adapter never gets there. */
public function testWithoutEditOthersPostsDiviIsNeverCalled(): void
{
Functions\when('current_user_can')->justReturn(false);
Functions\expect('et_theme_builder_insert_layout')->never();
Functions\expect('et_theme_builder_store_template')->never();
foreach ([
static fn () => (new DiviThemeBuilder())->insertLayout('body', 'Person – Inhalt'),
static fn () => (new DiviThemeBuilder())->storeTemplate(1064, [
'title' => 'Person', 'layouts' => [], 'use_on' => [], 'exclude_from' => [],
]),
] as $call) {
try {
$call();
self::fail('Divi was called without edit_others_posts');
} catch (RuntimeException $e) {
self::assertStringContainsString('edit_others_posts', $e->getMessage());
}
}
}
/** While Divi runs, every wp_die handler throws instead (defense in depth); afterwards WordPress' own again. */
public function testDiviIsOnlyCalledWithTheRightAndWithoutWpDie(): void
{
Functions\when('current_user_can')->justReturn(true);
$seen = [];
Functions\when('et_theme_builder_insert_layout')->alias(static function (array $options) use (&$seen): int {
$seen = ['options' => $options, 'guarded' => has_filter('wp_die_json_handler') !== false];
return 2101;
});
self::assertSame(2101, (new DiviThemeBuilder())->insertLayout('body', 'Person – Inhalt'));
self::assertSame(['post_type' => 'et_body_layout', 'post_title' => 'Person – Inhalt'], $seen['options']);
self::assertTrue($seen['guarded'], 'wp_die throws while Divi runs');
self::assertFalse(has_filter('wp_die_json_handler'), 'and only then');
}
public function testStoreTemplateIsAlwaysDisabledAndNeverDefault(): void
{
Functions\when('current_user_can')->justReturn(true);
Functions\when('et_')->justReturn(null);
$call = [];
Functions\when('et_theme_builder_store_template')->alias(
static function (int $tb, array $template, bool $allowDefault) use (&$call): int {
$call = [$tb, $template, $allowDefault];
return 2100;
}
);
$id = (new DiviThemeBuilder())->storeTemplate(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' => [],
]);
self::assertSame(2100, $id);
self::assertSame(1064, $call[0]);
self::assertSame('0', $call[1]['enabled']);
self::assertSame('0', $call[1]['default']);
self::assertSame('0', $call[1]['autogenerated_title']);
self::assertFalse($call[2], 'allow_default');
self::assertSame(['id' => 0, 'enabled' => false], $call[1]['layouts']['footer']);
}
/** Mirrors et_theme_builder_api_save(): backup option, append, remove the backup (api.php:223-243). */
public function testAttachAppendsLikeDivisSave(): void
{
Functions\when('get_post_meta')->justReturn(['1065', '1066']);
Functions\expect('update_option')->once()->with('et_tb_templates_backup_1064', ['1065', '1066']);
Functions\expect('add_post_meta')->once()->with(1064, '_et_template', 2100)->andReturn(501);
Functions\expect('delete_option')->once()->with('et_tb_templates_backup_1064');
(new DiviThemeBuilder())->attach(1064, 2100);
}
public function testSetConditionsReplacesBothListsWithoutDuplicates(): void
{
$calls = [];
Functions\when('delete_post_meta')->alias(static function (int $id, string $key) use (&$calls): bool {
$calls[] = ['delete', $key];
return true;
});
Functions\when('add_post_meta')->alias(static function (int $id, string $key, string $value) use (&$calls): int {
$calls[] = ['add', $key, $value];
return 1;
});
(new DiviThemeBuilder())->setConditions(
2100,
['singular:post_type:lit_person:all', 'singular:post_type:lit_person:all'],
['singular:post_type:lit_person:id:12']
);
self::assertSame([
['delete', '_et_use_on'],
['add', '_et_use_on', 'singular:post_type:lit_person:all'],
['delete', '_et_exclude_from'],
['add', '_et_exclude_from', 'singular:post_type:lit_person:id:12'],
], $calls);
}
/** Ruling B16: PHP turns the key '404' into an int; the label and the object type/value are read. */
public function testFlatConditionsReadLabelsAndObjectTypes(): void
{
Functions\when('wp_strip_all_tags')->returnArg();
Functions\when('et_theme_builder_get_flat_template_settings_options')->justReturn([
'404' => ['id' => '404', 'label' => '404-Seite'],
'singular:post_type:page:id:' => [
'label' => 'Bestimmte Seiten',
'options' => ['type' => 'post_type', 'value' => 'page'],
],
]);
$flat = (new DiviThemeBuilder())->flatConditions();
self::assertSame(['label' => '404-Seite', 'type' => null, 'value' => null], $flat['404']);
self::assertSame('post_type', $flat['singular:post_type:page:id:']['type']);
self::assertSame('page', $flat['singular:post_type:page:id:']['value']);
}
private const DIE_FILTERS = [
'wp_die_handler', 'wp_die_ajax_handler', 'wp_die_json_handler', 'wp_die_jsonp_handler',
'wp_die_xml_handler', 'wp_die_xmlrpc_handler',
];
/** A wp_die() inside Divi becomes an exception; every handler filter is gone again afterwards. */
public function testWpDieInsideDiviThrowsAndEveryHandlerIsRestored(): void
{
Functions\when('current_user_can')->justReturn(true);
$factory = null;
Filters\expectAdded('wp_die_handler')->once()->whenHappen(
static function (callable $callback, int $priority) use (&$factory): void {
self::assertSame(PHP_INT_MAX, $priority);
$factory = $callback;
}
);
$guarded = [];
Functions\when('et_theme_builder_insert_layout')->alias(static function () use (&$factory, &$guarded): int {
foreach (self::DIE_FILTERS as $filter) {
$guarded[$filter] = has_filter($filter) !== false;
}
// What wp_die() does: apply_filters( 'wp_die_handler', '_default_wp_die_handler' ), then call it.
$handler = $factory('_default_wp_die_handler');
$handler(new WP_Error('divi', 'Sorry, you are not allowed'), '', []);
return 2101;
});
try {
(new DiviThemeBuilder())->insertLayout('body', 'Person – Inhalt');
self::fail('wp_die did not throw');
} catch (RuntimeException $e) {
self::assertSame('Divi called wp_die(): Sorry, you are not allowed', $e->getMessage());
}
self::assertSame(array_fill_keys(self::DIE_FILTERS, true), $guarded);
foreach (self::DIE_FILTERS as $filter) {
self::assertFalse(has_filter($filter), $filter . ' restored');
}
}
/** Nothing Divi prints reaches the MCP response, on success and on failure; buffers stay balanced. */
public function testOutputOfDiviNeverLeaks(): void
{
Functions\when('current_user_can')->justReturn(true);
$level = ob_get_level();
$calls = 0;
Functions\when('et_theme_builder_insert_layout')->alias(static function () use (&$calls): int {
echo 'Notice: Divi';
ob_start();
echo 'unclosed buffer';
if (++$calls === 2) {
throw new RuntimeException('Divi failed');
}
return 2101;
});
$this->expectOutputString('');
self::assertSame(2101, (new DiviThemeBuilder())->insertLayout('body', 'Person – Inhalt'));
self::assertSame($level, ob_get_level());
try {
(new DiviThemeBuilder())->insertLayout('body', 'Person – Inhalt');
self::fail('no exception');
} catch (RuntimeException $e) {
self::assertSame('Divi failed', $e->getMessage());
}
self::assertSame($level, ob_get_level());
self::assertFalse(has_filter('wp_die_handler'));
}
public function testARefusedLayoutNamesDivisReason(): void
{
Functions\when('current_user_can')->justReturn(true);
Functions\when('et_theme_builder_insert_layout')->justReturn(new WP_Error('db', 'Datenbankfehler'));
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Datenbankfehler');
(new DiviThemeBuilder())->insertLayout('header', 'Kopf');
}
public function testAnUnknownAreaIsRefusedBeforeDivi(): void
{
Functions\when('current_user_can')->justReturn(true);
Functions\expect('et_theme_builder_insert_layout')->never();
$this->expectException(RuntimeException::class);
(new DiviThemeBuilder())->insertLayout('sidebar', 'Seite');
}
/** E15: a Divi function that moved is reported, never called (no fatal error mid-write). */
public function testMissingDiviFunctionsAreRefusedBeforeWriting(): void
{
Functions\when('current_user_can')->justReturn(true);
Functions\expect('et_theme_builder_insert_layout')->never();
Functions\expect('et_theme_builder_store_template')->never();
$store = Mockery::mock(DiviThemeBuilder::class)->makePartial();
$store->allows('missing')->andReturn(['et_theme_builder_insert_layout', 'et_']);
foreach ([
static fn () => $store->insertLayout('body', 'Person – Inhalt'),
static fn () => $store->storeTemplate(1064, [
'title' => 'Person', 'layouts' => [], 'use_on' => [], 'exclude_from' => [],
]),
] as $call) {
try {
$call();
self::fail('Divi was called although it is missing');
} catch (RuntimeException $e) {
self::assertStringContainsString('missing', $e->getMessage());
}
}
}
/** Befund 10 / storage format: _et_template holds each ID once, as a string row. */
public function testAttachNeverListsATemplateTwice(): void
{
Functions\when('get_post_meta')->justReturn(['1065', '2100']);
Functions\expect('update_option')->never();
Functions\expect('add_post_meta')->never();
(new DiviThemeBuilder())->attach(1064, 2100);
self::assertTrue(true);
}
public function testAFailedAttachThrowsAndLeavesNoBackup(): void
{
Functions\when('get_post_meta')->justReturn(['1065']);
Functions\when('update_option')->justReturn(true);
Functions\when('add_post_meta')->justReturn(false);
Functions\expect('delete_option')->once()->with('et_tb_templates_backup_1064');
$this->expectException(RuntimeException::class);
(new DiviThemeBuilder())->attach(1064, 2100);
}
public function testDetachRemovesTheStoredRow(): void
{
Functions\expect('delete_post_meta')->once()->with(1064, '_et_template', '2100')->andReturn(true);
(new DiviThemeBuilder())->detach(1064, 2100);
}
public function testSetEnabledStoresDivisFlagAndReadsItBack(): void
{
Functions\expect('update_post_meta')->once()->with(2100, '_et_enabled', '1');
Functions\when('get_post_meta')->justReturn('1');
(new DiviThemeBuilder())->setEnabled(2100, true);
}
public function testSetEnabledThrowsWhenTheFlagDidNotStick(): void
{
Functions\when('update_post_meta')->justReturn(false);
Functions\when('get_post_meta')->justReturn('1');
$this->expectException(RuntimeException::class);
(new DiviThemeBuilder())->setEnabled(2100, false);
}
public function testTemplateReadsStatusAndUnusedMark(): void
{
Functions\when('et_theme_builder_get_template')->alias(static fn (int $id): array => $id === 2394 ? [
'id' => 2394, 'title' => '[Fixture 3b] Person', 'default' => false, 'enabled' => false,
'layouts' => ['header' => ['id' => 1061, 'enabled' => true]], 'use_on' => [], 'exclude_from' => [],
] : []);
Functions\when('get_post_meta')->justReturn('2026-09-27 12:47:02');
Functions\when('get_post_status')->justReturn('publish');
$template = (new DiviThemeBuilder())->template(2394, false);
self::assertNotNull($template);
self::assertSame('2026-09-27 12:47:02', $template->markedUnused);
self::assertFalse($template->attached);
self::assertNull((new DiviThemeBuilder())->template(7, true));
}
}