Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/ThemeBuilder/DiviThemeBuilderTest.php
T
Jan-Niclas LoosenandClaude Opus 5.5 b511ef6348 refactor(compat): Prüfbausteine nach src/Compatibility, Matrix und ManifestCheck generisch
Requirement, Call, ConstantRequirement, Mirror, Problem, FeatureStatus, CompatibilityMatrix, Manifest, ManifestCheck sowie Fingerprints und LineDiff liegen jetzt im divi-freien Kern. Neu: GlobalRequirement, CompatibilityMatrix::fromProblems(), ManifestCheck-Option signatures, DiviManifestCheck::create() und DiviAreas. Divi-Verhalten unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:03:30 +01:00

493 lines
20 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\Compatibility\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviAreas;
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 testIsTheRegisteredThemeBuilderAdapterWithItsMirrors(): void
{
$manifest = DiviThemeBuilder::manifest();
self::assertContains(DiviThemeBuilder::class, DiviAdapters::CLASSES);
self::assertSame(DiviAreas::THEME_BUILDER, $manifest->area);
self::assertSame(DiviThemeBuilder::postTypes(), $manifest->postTypes);
self::assertSame(
['tb-api-save', 'tb-store-template', 'tb-load-setting-options'],
array_map(static fn ($mirror): string => $mirror->id, $manifest->mirrors)
);
self::assertContains('et_theme_builder_store_template()', array_map(
static fn (Call $call): string => $call->label(),
$manifest->calls
));
}
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()
);
}
/** Review M-1: one site-wide clear – every static CSS file and the Theme Builder cache. */
public function testClearCachesClearsTheSiteWideCaches(): void
{
\ET_Core_PageResource::$removed = [];
$cleared = [];
Functions\when('et_theme_builder_clear_wp_cache')->alias(static function (string $what) use (&$cleared): void {
$cleared[] = $what;
});
(new DiviThemeBuilder())->clearCaches();
self::assertSame([['all', 'all', true]], \ET_Core_PageResource::$removed);
self::assertSame(['all'], $cleared);
}
/** 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). Own process:
* no other test may have stubbed these names yet, or function_exists() would report them as present
* for the rest of the run (Brain Monkey defines a stubbed function process-wide).
*
* @runInSeparateProcess
*/
public function testMissingDiviFunctionsAreRefusedBeforeWriting(): void
{
Functions\when('current_user_can')->justReturn(true);
foreach ([
static fn (DiviThemeBuilder $store) => $store->insertLayout('body', 'Person – Inhalt'),
static fn (DiviThemeBuilder $store) => $store->storeTemplate(1064, [
'title' => 'Person', 'layouts' => [], 'use_on' => [], 'exclude_from' => [],
]),
] as $call) {
try {
$call(new DiviThemeBuilder());
self::fail('Divi was called although it is missing');
} catch (RuntimeException $e) {
self::assertStringContainsString('Divi function 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));
}
}