Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
493 lines
20 KiB
PHP
493 lines
20 KiB
PHP
<?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\Release\Call;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
|
||
use LIT\MCP\Integrations\Divi\Release\Manifest;
|
||
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(Manifest::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));
|
||
}
|
||
}
|