Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/ThemeBuilder/DiviThemeBuilderTest.php
T
janniclas.loosenandClaude Opus 5.5 e64d32b1f1 fix: tb-assign reattaches only templates no Theme Builder post lists (audit L4)
"Detached" now means for lit/tb-assign what lit/tb-list shows as detached:
DiviThemeBuilder::isListedAnywhere() looks for any _et_template row (live,
draft or Theme Builder library post). A template another Theme Builder post
lists is refused with or without reattach; nothing is written.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-27 23:06:35 +02:00

456 lines
19 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\Divi\ThemeBuilder;
use Brain\Monkey\Actions;
use Brain\Monkey\Filters;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use LIT\MCP\Integrations\Divi\ThemeBuilder\InsertedBeforeFailure;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
use WP_Error;
use WP_Post;
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'));
}
/** Review M-3: one list of every Theme Builder post type. */
public function testPostTypesNameEveryThemeBuilderType(): void
{
self::assertSame(
['et_theme_builder', 'et_template', 'et_header_layout', 'et_body_layout', 'et_footer_layout'],
DiviThemeBuilder::postTypes()
);
}
/** Audit L4: a template is listed when any Theme Builder post's _et_template row names it. */
public function testIsListedAnywhereAsksEveryThemeBuilderPost(): void
{
$db = Mockery::mock(\wpdb::class)->makePartial();
$db->postmeta = 'wp_postmeta';
$sql = [];
$db->allows('get_var')->andReturnUsing(static function (string $query) use (&$sql): ?string {
$sql[] = $query;
return str_contains($query, "'1066'") ? '1' : null;
});
$GLOBALS['wpdb'] = $db;
try {
self::assertTrue((new DiviThemeBuilder())->isListedAnywhere(1066));
self::assertFalse((new DiviThemeBuilder())->isListedAnywhere(2394));
} finally {
unset($GLOBALS['wpdb']);
}
self::assertStringContainsString("meta_key = '_et_template'", $sql[0]);
self::assertStringContainsString('LIMIT 1', $sql[0]);
}
/** 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');
}
}
/**
* The hooks the adapter watches while Divi runs (ruling Q19), captured so a fake Divi can fire them like
* wp_insert_post() does: wp_insert_post_data before the row, clean_post_cache after it.
*
* @return \ArrayObject<string, callable|null> "data" and "id".
*/
private static function captureInsertWatch(): \ArrayObject
{
$hooks = new \ArrayObject(['data' => null, 'id' => null]);
Filters\expectAdded('wp_insert_post_data')->whenHappen(static function (callable $callback) use ($hooks): void {
$hooks['data'] = $callback;
});
Actions\expectAdded('clean_post_cache')->whenHappen(static function (callable $callback) use ($hooks): void {
$hooks['id'] = $callback;
});
return $hooks;
}
/**
* What wp_insert_post() does around the row of post $id of $type.
*
* @param \ArrayObject<string, callable|null> $hooks
*/
private static function insert(\ArrayObject $hooks, int $id, string $type, bool $update = false): void
{
($hooks['data'])(['post_type' => $type], [], [], $update);
($hooks['id'])($id, new WP_Post(['ID' => $id, 'post_type' => $type]));
}
/** Ruling Q19: a throw after Divi's own wp_insert_post() (e.g. a hook's wp_die) still names the new post. */
public function testAThrowAfterTheLayoutInsertNamesTheNewLayout(): void
{
Functions\when('current_user_can')->justReturn(true);
$hooks = self::captureInsertWatch();
Functions\when('et_theme_builder_insert_layout')->alias(static function () use ($hooks): int {
self::insert($hooks, 2101, 'et_body_layout');
throw new RuntimeException('Divi called wp_die(): hook');
});
try {
(new DiviThemeBuilder())->insertLayout('body', 'Person – Inhalt');
self::fail('no exception');
} catch (InsertedBeforeFailure $e) {
self::assertSame([2101], $e->postIds);
self::assertSame('Divi called wp_die(): hook', $e->getMessage());
}
self::assertFalse(has_filter('wp_insert_post_data'));
self::assertFalse(has_action('clean_post_cache'));
}
public function testAThrowAfterTheTemplateInsertNamesTheNewTemplateOnly(): void
{
Functions\when('current_user_can')->justReturn(true);
Functions\when('et_')->justReturn(null);
$hooks = self::captureInsertWatch();
Functions\when('et_theme_builder_store_template')->alias(static function () use ($hooks): int {
// An existing template updated and another type inserted meanwhile are never named.
($hooks['id'])(1066, new WP_Post(['ID' => 1066, 'post_type' => 'et_template']));
self::insert($hooks, 1066, 'et_template', true);
self::insert($hooks, 3000, 'page');
self::insert($hooks, 2100, 'et_template');
($hooks['id'])(2100, new WP_Post(['ID' => 2100, 'post_type' => 'et_template']));
throw new RuntimeException('meta hook failed');
});
try {
(new DiviThemeBuilder())->storeTemplate(1064, ['title' => 'P', 'layouts' => [], 'use_on' => [], 'exclude_from' => []]);
self::fail('no exception');
} catch (InsertedBeforeFailure $e) {
self::assertSame([2100], $e->postIds);
}
}
public function testAThrowBeforeAnyInsertIsAPlainFailure(): void
{
Functions\when('current_user_can')->justReturn(true);
self::captureInsertWatch();
Functions\when('et_theme_builder_insert_layout')->alias(static function (): int {
throw new RuntimeException('early');
});
try {
(new DiviThemeBuilder())->insertLayout('body', 'Inhalt');
self::fail('no exception');
} catch (RuntimeException $e) {
self::assertNotInstanceOf(InsertedBeforeFailure::class, $e);
}
}
/** 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));
}
}