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:
@@ -8,6 +8,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
|
||||
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||||
use Throwable;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
@@ -64,6 +65,15 @@ final class DiviCompatibility
|
||||
static fn (): bool => self::knowsGlobalLayout(),
|
||||
'Taxonomien layout_category, layout_tag (Bibliothek)' =>
|
||||
static fn (): bool => taxonomy_exists('layout_category') && taxonomy_exists('layout_tag'),
|
||||
'Theme Builder: Funktionen et_theme_builder_* und et_()' =>
|
||||
static fn (): bool => (new DiviThemeBuilder())->missing() === [],
|
||||
'Theme Builder: Post-Typen et_template und Layouts' => static fn (): bool => array_filter(
|
||||
array_merge(
|
||||
[DiviThemeBuilder::THEME_BUILDER, DiviThemeBuilder::TEMPLATE],
|
||||
array_values(DiviThemeBuilder::LAYOUT_TYPES)
|
||||
),
|
||||
static fn (string $type): bool => ! post_type_exists($type)
|
||||
) === [],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use RuntimeException;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Adapter for Divi's Theme Builder (E7, E15; decisions note F14–F23; fixtures stage3/stage3b).
|
||||
* Reading uses Divi's own functions (theme-builder.php:378-521). Writing:
|
||||
* - layouts and new templates through et_theme_builder_insert_layout() and et_theme_builder_store_template()
|
||||
* (theme-builder.php:1463-1667). Both wp_die() without edit_others_posts: the right is checked first,
|
||||
* and while they run every wp_die handler throws instead (guarded(), defense in depth);
|
||||
* - the live Theme Builder post's template list, conditions and the enabled flag are mirrored from
|
||||
* et_theme_builder_api_save() (api.php:223-243; admin-ajax only, not loaded in REST requests, F20) and
|
||||
* store_template (theme-builder.php:1545-1583) – store_template on an existing template would also
|
||||
* rewrite its default flag, title and onboarding meta.
|
||||
* Never touched: the Theme Builder draft (auto-draft) and et_theme_builder_trash_draft_and_unused_posts().
|
||||
* Not final so abilities can be tested with a mock.
|
||||
*/
|
||||
class DiviThemeBuilder
|
||||
{
|
||||
public const TEMPLATE = 'et_template';
|
||||
|
||||
/** Post type of the Theme Builder posts (live, draft, library) that list the templates. */
|
||||
public const THEME_BUILDER = 'et_theme_builder';
|
||||
|
||||
/** Area => layout post type (theme-builder.php:7-23). */
|
||||
public const LAYOUT_TYPES = [
|
||||
'header' => 'et_header_layout',
|
||||
'body' => 'et_body_layout',
|
||||
'footer' => 'et_footer_layout',
|
||||
];
|
||||
|
||||
public const TEMPLATE_LIST = '_et_template';
|
||||
public const USE_ON = '_et_use_on';
|
||||
public const EXCLUDE_FROM = '_et_exclude_from';
|
||||
public const ENABLED = '_et_enabled';
|
||||
public const UNUSED = '_et_theme_builder_marked_as_unused';
|
||||
public const BACKUP_OPTION = 'et_tb_templates_backup_';
|
||||
|
||||
/** Divi functions this adapter calls; DiviCompatibility reports missing ones. */
|
||||
public const FUNCTIONS = [
|
||||
'et_theme_builder_get_theme_builder_post_id',
|
||||
'et_theme_builder_get_theme_builder_template_ids',
|
||||
'et_theme_builder_get_template',
|
||||
'et_theme_builder_insert_layout',
|
||||
'et_theme_builder_store_template',
|
||||
'et_theme_builder_get_flat_template_settings_options',
|
||||
'et_theme_builder_clear_wp_cache',
|
||||
'et_',
|
||||
];
|
||||
|
||||
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',
|
||||
];
|
||||
|
||||
/** "header", "body" or "footer" for a layout post type, else null. */
|
||||
public static function areaOf(string $postType): ?string
|
||||
{
|
||||
$area = array_search($postType, self::LAYOUT_TYPES, true);
|
||||
|
||||
return is_string($area) ? $area : null;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function missing(): array
|
||||
{
|
||||
return array_values(array_filter(self::FUNCTIONS, static fn (string $name): bool => ! function_exists($name)));
|
||||
}
|
||||
|
||||
/** The live Theme Builder post (publish, no library TB); $create makes it like Divi's first save. 0 = none. */
|
||||
public function liveId(bool $create = false): int
|
||||
{
|
||||
return function_exists('et_theme_builder_get_theme_builder_post_id')
|
||||
? (int) et_theme_builder_get_theme_builder_post_id(true, $create)
|
||||
: 0;
|
||||
}
|
||||
|
||||
/** The Theme Builder draft (auto-draft) of an unsaved Theme Builder session; 0 = none (F15). */
|
||||
public function draftId(): int
|
||||
{
|
||||
return function_exists('et_theme_builder_get_theme_builder_post_id')
|
||||
? (int) et_theme_builder_get_theme_builder_post_id(false, false)
|
||||
: 0;
|
||||
}
|
||||
|
||||
/** @return list<int> Templates listed by Theme Builder post $themeBuilderId, in Divi's order (F15). */
|
||||
public function templateIds(int $themeBuilderId): array
|
||||
{
|
||||
if ($themeBuilderId <= 0 || ! function_exists('et_theme_builder_get_theme_builder_template_ids')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_map(
|
||||
'intval',
|
||||
(array) et_theme_builder_get_theme_builder_template_ids(true, $themeBuilderId)
|
||||
));
|
||||
}
|
||||
|
||||
/** Template $id as Divi reads it, null when it is no template. */
|
||||
public function template(int $id, bool $attached): ?Template
|
||||
{
|
||||
if (! function_exists('et_theme_builder_get_template')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$divi = et_theme_builder_get_template($id);
|
||||
if (! is_array($divi) || $divi === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$marked = (string) get_post_meta($id, self::UNUSED, true);
|
||||
|
||||
return Template::fromDivi($divi, (string) get_post_status($id), $attached, $marked === '' ? null : $marked);
|
||||
}
|
||||
|
||||
/** @return list<Template> The templates of the live Theme Builder post. */
|
||||
public function liveTemplates(): array
|
||||
{
|
||||
$templates = [];
|
||||
foreach ($this->templateIds($this->liveId()) as $id) {
|
||||
$template = $this->template($id, true);
|
||||
if ($template !== null) {
|
||||
$templates[] = $template;
|
||||
}
|
||||
}
|
||||
|
||||
return $templates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Published templates that no Theme Builder post lists (e.g. removed by a save in a Theme Builder
|
||||
* that was opened before they were created; Divi marks and later trashes them, F19).
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public function detachedIds(int $limit): array
|
||||
{
|
||||
global $wpdb;
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery -- every _et_template row, live, draft and library TB posts.
|
||||
$listed = array_map('intval', (array) $wpdb->get_col($wpdb->prepare(
|
||||
"SELECT meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s",
|
||||
self::TEMPLATE_LIST
|
||||
)));
|
||||
$ids = get_posts([
|
||||
'post_type' => self::TEMPLATE,
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => $limit,
|
||||
'orderby' => 'ID',
|
||||
'order' => 'ASC',
|
||||
'fields' => 'ids',
|
||||
'no_found_rows' => true,
|
||||
'post__not_in' => $listed === [] ? [0] : $listed,
|
||||
]);
|
||||
|
||||
return array_values(array_map('intval', is_array($ids) ? $ids : []));
|
||||
}
|
||||
|
||||
/** @throws RuntimeException */
|
||||
public function insertLayout(string $area, string $title): int
|
||||
{
|
||||
$type = self::LAYOUT_TYPES[$area] ?? throw new RuntimeException('unknown Theme Builder area ' . $area);
|
||||
$this->assertCanWrite('et_theme_builder_insert_layout');
|
||||
$id = $this->guarded(
|
||||
static fn (): mixed => et_theme_builder_insert_layout(['post_type' => $type, 'post_title' => $title])
|
||||
);
|
||||
if (! is_int($id) || $id <= 0) {
|
||||
$reason = $id instanceof WP_Error ? $id->get_error_message() : 'no id';
|
||||
throw new RuntimeException('et_theme_builder_insert_layout refused: ' . $reason);
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* A new template through Divi's store_template (allow_default false) – always disabled and never the
|
||||
* default. Divi drops layout IDs that are no published layout of the area; callers read it back.
|
||||
*
|
||||
* @param array{title: string, layouts: array<string, array{id: int, enabled: bool}>, use_on: list<string>,
|
||||
* exclude_from: list<string>} $template
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function storeTemplate(int $themeBuilderId, array $template): int
|
||||
{
|
||||
$this->assertCanWrite('et_theme_builder_store_template', 'et_');
|
||||
$payload = [
|
||||
'title' => $template['title'],
|
||||
'autogenerated_title' => '0',
|
||||
'default' => '0',
|
||||
'enabled' => '0',
|
||||
'layouts' => $template['layouts'],
|
||||
'use_on' => $template['use_on'],
|
||||
'exclude_from' => $template['exclude_from'],
|
||||
];
|
||||
$id = $this->guarded(static fn (): mixed => et_theme_builder_store_template($themeBuilderId, $payload, false));
|
||||
if (! is_int($id) || $id <= 0) {
|
||||
throw new RuntimeException('et_theme_builder_store_template stored no template');
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends $templateId to the list of Theme Builder post $themeBuilderId in et_theme_builder_api_save()'s
|
||||
* order: the current list as backup option (readers use it meanwhile), the new row, backup removed.
|
||||
* Divi stores one string row per ID (stage3b theme-builder.json); an ID already listed stays listed once.
|
||||
* A Theme Builder editor opened before this call drops the row again on its next save and marks the
|
||||
* template unused (stage3b README finding 10) – detachedIds() finds it, callers unmarkUnused() on reattach.
|
||||
*
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function attach(int $themeBuilderId, int $templateId): void
|
||||
{
|
||||
$existing = get_post_meta($themeBuilderId, self::TEMPLATE_LIST, false);
|
||||
if (is_array($existing) && in_array((string) $templateId, array_map('strval', $existing), true)) {
|
||||
return;
|
||||
}
|
||||
if (is_array($existing) && $existing !== []) {
|
||||
update_option(self::BACKUP_OPTION . $themeBuilderId, $existing);
|
||||
}
|
||||
$added = add_post_meta($themeBuilderId, self::TEMPLATE_LIST, $templateId);
|
||||
delete_option(self::BACKUP_OPTION . $themeBuilderId);
|
||||
if ($added === false) {
|
||||
throw new RuntimeException(
|
||||
sprintf('template %d not added to Theme Builder %d', $templateId, $themeBuilderId)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function detach(int $themeBuilderId, int $templateId): void
|
||||
{
|
||||
delete_post_meta($themeBuilderId, self::TEMPLATE_LIST, (string) $templateId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces both condition lists like store_template (theme-builder.php:1565-1583): delete, then one
|
||||
* row per unique condition.
|
||||
*
|
||||
* @param list<string> $useOn
|
||||
* @param list<string> $excludeFrom
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function setConditions(int $templateId, array $useOn, array $excludeFrom): void
|
||||
{
|
||||
foreach ([self::USE_ON => $useOn, self::EXCLUDE_FROM => $excludeFrom] as $key => $conditions) {
|
||||
delete_post_meta($templateId, $key);
|
||||
foreach (array_values(array_unique($conditions)) as $condition) {
|
||||
if (add_post_meta($templateId, $key, $condition) === false) {
|
||||
throw new RuntimeException(sprintf('%s of template %d not stored', $key, $templateId));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws RuntimeException */
|
||||
public function setEnabled(int $templateId, bool $enabled): void
|
||||
{
|
||||
$value = $enabled ? '1' : '0';
|
||||
update_post_meta($templateId, self::ENABLED, $value);
|
||||
if ((string) get_post_meta($templateId, self::ENABLED, true) !== $value) {
|
||||
throw new RuntimeException(sprintf('_et_enabled of template %d not stored', $templateId));
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes Divi's "unused" mark (F19), as Divi's own save does for every listed post. */
|
||||
public function unmarkUnused(int $postId): void
|
||||
{
|
||||
delete_post_meta($postId, self::UNUSED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Divi's condition list (et_theme_builder_get_flat_template_settings_options()): ID => label and, for
|
||||
* IDs that end in ":" and take an object ID or slug, the object type and its value (e.g. post_type/page).
|
||||
*
|
||||
* @return array<string, array{label: string, type: ?string, value: ?string}>
|
||||
*/
|
||||
public function flatConditions(): array
|
||||
{
|
||||
if (! function_exists('et_theme_builder_get_flat_template_settings_options')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$flat = [];
|
||||
foreach ((array) et_theme_builder_get_flat_template_settings_options() as $id => $setting) {
|
||||
if (! is_array($setting)) {
|
||||
continue;
|
||||
}
|
||||
$options = is_array($setting['options'] ?? null) ? $setting['options'] : [];
|
||||
$flat[(string) $id] = [
|
||||
'label' => wp_strip_all_tags((string) ($setting['label'] ?? $id)),
|
||||
'type' => is_string($options['type'] ?? null) ? $options['type'] : null,
|
||||
'value' => is_string($options['value'] ?? null) ? $options['value'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $flat;
|
||||
}
|
||||
|
||||
/** Divi's Theme Builder cache and every static CSS file (spec §5.6 step 5; api.php:249-254). */
|
||||
public function clearCaches(): void
|
||||
{
|
||||
if (function_exists('et_theme_builder_clear_wp_cache')) {
|
||||
et_theme_builder_clear_wp_cache('all');
|
||||
}
|
||||
if (class_exists('ET_Core_PageResource')) {
|
||||
\ET_Core_PageResource::remove_static_resources('all', 'all', true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Before any Divi write function runs: the functions exist (E15 – no fatal error halfway) and the user
|
||||
* holds edit_others_posts, without which Divi calls wp_die() (theme-builder.php:1464, 1644).
|
||||
*
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
private function assertCanWrite(string ...$functions): void
|
||||
{
|
||||
if (! current_user_can('edit_others_posts')) {
|
||||
throw new RuntimeException('edit_others_posts missing – Divi would call wp_die()');
|
||||
}
|
||||
$missing = array_values(array_intersect($functions, $this->missing()));
|
||||
if ($missing !== []) {
|
||||
throw new RuntimeException('Divi function missing: ' . implode(', ', $missing));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs $call with every wp_die handler replaced by one that throws (F17/F18) and with its output
|
||||
* discarded. Only this request's in-memory hooks change; the handler filters (added at PHP_INT_MAX
|
||||
* with a closure of this call) and every output buffer opened meanwhile are removed on every path.
|
||||
*/
|
||||
private function guarded(callable $call): mixed
|
||||
{
|
||||
$handler = static function (): callable {
|
||||
return static function (mixed $message = ''): never {
|
||||
if ($message instanceof WP_Error) {
|
||||
$message = $message->get_error_message();
|
||||
}
|
||||
throw new RuntimeException(
|
||||
'Divi called wp_die(): ' . (is_string($message) && $message !== '' ? $message : 'without message')
|
||||
);
|
||||
};
|
||||
};
|
||||
foreach (self::DIE_FILTERS as $filter) {
|
||||
add_filter($filter, $handler, PHP_INT_MAX);
|
||||
}
|
||||
$level = ob_get_level();
|
||||
ob_start();
|
||||
|
||||
try {
|
||||
return $call();
|
||||
} finally {
|
||||
while (ob_get_level() > $level) {
|
||||
ob_end_clean();
|
||||
}
|
||||
foreach (self::DIE_FILTERS as $filter) {
|
||||
remove_filter($filter, $handler, PHP_INT_MAX);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use LIT\MCP\Abilities\Rev;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* A Theme Builder template as Divi reads it (et_theme_builder_get_template(), theme-builder.php:471-521;
|
||||
* fixtures stage3/stage3b), plus whether the live Theme Builder post lists it (attached) and Divi's
|
||||
* "unused" mark (set when a Theme Builder save drops it from the list, stage3b README finding 10).
|
||||
* rev() is template_rev, the lock of the Theme Builder tools. No WordPress calls.
|
||||
*/
|
||||
final class Template
|
||||
{
|
||||
public const AREAS = ['header', 'body', 'footer'];
|
||||
|
||||
/**
|
||||
* @param array<string, array{id: int, enabled: bool}> $layouts
|
||||
* @param list<string> $useOn
|
||||
* @param list<string> $excludeFrom
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $id,
|
||||
public readonly string $title,
|
||||
public readonly string $status,
|
||||
public readonly bool $default,
|
||||
public readonly bool $enabled,
|
||||
public readonly array $layouts,
|
||||
public readonly array $useOn,
|
||||
public readonly array $excludeFrom,
|
||||
public readonly bool $attached,
|
||||
public readonly ?string $markedUnused
|
||||
) {
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $divi et_theme_builder_get_template() */
|
||||
public static function fromDivi(array $divi, string $status, bool $attached, ?string $markedUnused): self
|
||||
{
|
||||
$layouts = [];
|
||||
foreach (self::AREAS as $area) {
|
||||
$layout = is_array($divi['layouts'][$area] ?? null) ? $divi['layouts'][$area] : [];
|
||||
$layouts[$area] = ['id' => (int) ($layout['id'] ?? 0), 'enabled' => (bool) ($layout['enabled'] ?? true)];
|
||||
}
|
||||
|
||||
return new self(
|
||||
(int) ($divi['id'] ?? 0),
|
||||
(string) ($divi['title'] ?? ''),
|
||||
$status,
|
||||
(bool) ($divi['default'] ?? false),
|
||||
(bool) ($divi['enabled'] ?? false),
|
||||
$layouts,
|
||||
self::strings($divi['use_on'] ?? []),
|
||||
self::strings($divi['exclude_from'] ?? []),
|
||||
$attached,
|
||||
$markedUnused
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether visitors can see it: listed by the live Theme Builder post and enabled. Divi renders such a
|
||||
* template whatever its post status (et_theme_builder_get_template() and ThemeBuilderRequest only read
|
||||
* the enabled flag), so the status does not count here – fail closed (ruling Q8/B8).
|
||||
*/
|
||||
public function isLive(): bool
|
||||
{
|
||||
return $this->attached && $this->enabled;
|
||||
}
|
||||
|
||||
/** Whether visitors see layout $layoutId as its $area through this template. */
|
||||
public function showsLayout(string $area, int $layoutId): bool
|
||||
{
|
||||
return $this->isLive()
|
||||
&& ($this->layouts[$area]['id'] ?? 0) === $layoutId
|
||||
&& ($this->layouts[$area]['enabled'] ?? false);
|
||||
}
|
||||
|
||||
/** template_rev: changes with everything the Theme Builder tools read or write. */
|
||||
public function rev(): string
|
||||
{
|
||||
$state = [$this->id, $this->title, $this->enabled, $this->layouts];
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure value object, no WordPress.
|
||||
return Rev::of((string) json_encode(
|
||||
array_merge($state, [$this->useOn, $this->excludeFrom, $this->attached]),
|
||||
JSON_UNESCAPED_UNICODE
|
||||
));
|
||||
}
|
||||
|
||||
/** @return list<string> Post types of its singular conditions (singular:post_type:<type>:…). */
|
||||
public function postTypes(): array
|
||||
{
|
||||
$types = [];
|
||||
foreach ($this->useOn as $condition) {
|
||||
$parts = explode(':', $condition);
|
||||
if (($parts[0] ?? '') === 'singular' && ($parts[1] ?? '') === 'post_type' && ($parts[2] ?? '') !== '') {
|
||||
$types[] = $parts[2];
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($types));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'title' => $this->title,
|
||||
'default' => $this->default,
|
||||
'enabled' => $this->enabled,
|
||||
'live' => $this->isLive(),
|
||||
'attached' => $this->attached,
|
||||
'marked_unused' => $this->markedUnused,
|
||||
'template_rev' => $this->rev(),
|
||||
'layouts' => $this->layouts,
|
||||
'use_on' => $this->useOn,
|
||||
'exclude_from' => $this->excludeFrom,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Live templates other than $exceptId that share a condition of $useOn (Divi then picks one of them).
|
||||
*
|
||||
* @param list<Template> $live
|
||||
* @param list<string> $useOn
|
||||
* @return list<array{id: int, title: string, conditions: list<string>}>
|
||||
*/
|
||||
public static function overlaps(array $live, int $exceptId, array $useOn): array
|
||||
{
|
||||
$overlaps = [];
|
||||
foreach ($live as $other) {
|
||||
if ($other->id === $exceptId || ! $other->isLive()) {
|
||||
continue;
|
||||
}
|
||||
$shared = array_values(array_intersect($other->useOn, $useOn));
|
||||
if ($shared !== []) {
|
||||
$overlaps[] = ['id' => $other->id, 'title' => $other->title, 'conditions' => $shared];
|
||||
}
|
||||
}
|
||||
|
||||
return $overlaps;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private static function strings(mixed $list): array
|
||||
{
|
||||
return array_values(array_map('strval', array_filter(is_array($list) ? $list : [], 'is_scalar')));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
|
||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||
use WP_Error;
|
||||
use WP_Post;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Rights on Theme Builder objects (spec §4.5, E3 – never laxer than Divi): the Theme Builder at all needs
|
||||
* edit_others_posts (Divi's own TB requirement, F19/F23) and the Divi role option theme_builder (off for
|
||||
* every role but administrator by default, F12); one layout or template additionally edit_post on it.
|
||||
* Without those, or with the group "Divi Theme Builder" disabled, every TB ID answers like a missing ID
|
||||
* (P12 pattern). The group's "nur lesen" stops writes (E9). Not final so abilities can be tested with a mock.
|
||||
*/
|
||||
class ThemeBuilderAccess
|
||||
{
|
||||
/** Tool group "Divi Theme Builder" (Tools tab). */
|
||||
public const GROUP = 'divi-theme-builder';
|
||||
|
||||
/**
|
||||
* @param (Closure(): bool)|null $writable Group writable (neither "nur lesen" nor disabled); null = always.
|
||||
* @param (Closure(): bool)|null $visible Group not disabled; null = always.
|
||||
*/
|
||||
public function __construct(
|
||||
private DiviThemeBuilder $store,
|
||||
private DiviRoles $roles,
|
||||
private ?Closure $writable = null,
|
||||
private ?Closure $visible = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function store(): DiviThemeBuilder
|
||||
{
|
||||
return $this->store;
|
||||
}
|
||||
|
||||
public function roles(): DiviRoles
|
||||
{
|
||||
return $this->roles;
|
||||
}
|
||||
|
||||
public function canUse(): ?WP_Error
|
||||
{
|
||||
if (! current_user_can('edit_others_posts')) {
|
||||
return Errors::forbidden('Keine Berechtigung für den Divi Theme Builder (edit_others_posts).');
|
||||
}
|
||||
|
||||
return $this->roles->refusal('theme_builder');
|
||||
}
|
||||
|
||||
/** Whether Theme Builder objects exist for this connection: group not disabled and canUse(). */
|
||||
public function visible(): bool
|
||||
{
|
||||
return ($this->visible === null || ($this->visible)()) && $this->canUse() === null;
|
||||
}
|
||||
|
||||
public function writableGate(): ?WP_Error
|
||||
{
|
||||
if ($this->writable === null || ($this->writable)()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Errors::forbidden(
|
||||
'Schreiben im Divi Theme Builder ist für diese Verbindung abgeschaltet (Gruppe „Divi Theme Builder“ auf '
|
||||
. '„nur lesen“ oder deaktiviert).',
|
||||
'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout $postId for the Divi tools (E1): null when it is no Theme Builder layout (the caller goes on);
|
||||
* like a missing ID without visible(); forbidden without edit_post; for writing also the group gate and
|
||||
* a published layout (Divi's TB layouts are always published).
|
||||
*/
|
||||
public function layout(mixed $postId, bool $forWrite): WP_Post|WP_Error|null
|
||||
{
|
||||
$id = PostGuard::id($postId);
|
||||
$post = $id === null ? null : get_post($id);
|
||||
if (! $post instanceof WP_Post || DiviThemeBuilder::areaOf((string) $post->post_type) === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$id = (int) $post->ID;
|
||||
if (! $this->visible()) {
|
||||
return PostGuard::notFound($id);
|
||||
}
|
||||
|
||||
if (! current_user_can('edit_post', $id)) {
|
||||
return Errors::forbidden(sprintf('Keine Berechtigung, Theme-Builder-Layout %d zu bearbeiten.', $id));
|
||||
}
|
||||
|
||||
if (! $forWrite) {
|
||||
return $post;
|
||||
}
|
||||
|
||||
$gate = $this->writableGate();
|
||||
if ($gate !== null) {
|
||||
return $gate;
|
||||
}
|
||||
|
||||
if ((string) $post->post_status !== 'publish') {
|
||||
return Errors::invalidInput(
|
||||
sprintf(
|
||||
'Theme-Builder-Layout %d ist nicht aktiv (Status „%s“); nichts wurde gespeichert.',
|
||||
$id,
|
||||
(string) $post->post_status
|
||||
),
|
||||
['post_id' => 'nur aktive Theme-Builder-Layouts']
|
||||
);
|
||||
}
|
||||
|
||||
return $post;
|
||||
}
|
||||
|
||||
/** Template $templateId for the Theme Builder tools; any other ID, or a hidden Theme Builder, is not found. */
|
||||
public function template(mixed $templateId, bool $forWrite): WP_Post|WP_Error
|
||||
{
|
||||
$id = PostGuard::id($templateId);
|
||||
if ($id === null) {
|
||||
return Errors::invalidInput(
|
||||
'template_id fehlt oder ist keine ID.',
|
||||
['template_id' => 'ID aus lit/tb-list']
|
||||
);
|
||||
}
|
||||
|
||||
$post = get_post($id);
|
||||
if (
|
||||
! $post instanceof WP_Post
|
||||
|| $post->post_type !== DiviThemeBuilder::TEMPLATE
|
||||
|| (string) $post->post_status !== 'publish'
|
||||
|| ! $this->visible()
|
||||
) {
|
||||
return Errors::notFound(
|
||||
sprintf('Theme-Builder-Vorlage %d gibt es nicht.', $id),
|
||||
'Vorlagen mit lit/tb-list auflisten.'
|
||||
);
|
||||
}
|
||||
|
||||
if (! current_user_can('edit_post', $id)) {
|
||||
return Errors::forbidden(sprintf('Keine Berechtigung, Theme-Builder-Vorlage %d zu bearbeiten.', $id));
|
||||
}
|
||||
|
||||
return $forWrite ? ($this->writableGate() ?? $post) : $post;
|
||||
}
|
||||
|
||||
/** Whether template $id may be named to this user (tb-list shows other IDs not at all, ruling Q8/B11). */
|
||||
public function canEditTemplate(int $id): bool
|
||||
{
|
||||
return $this->visible() && current_user_can('edit_post', $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refusal when a layout $template refers to is no longer a published layout of its area (Divi trashes
|
||||
* layouts marked unused after seven days, stage3b README finding 10); reattach and activation need this
|
||||
* first (ruling Q8/B10). Areas without a layout (ID 0) need nothing.
|
||||
*/
|
||||
public function layoutsRefusal(Template $template): ?WP_Error
|
||||
{
|
||||
$fields = [];
|
||||
foreach ($template->layouts as $area => $layout) {
|
||||
if ($layout['id'] <= 0) {
|
||||
continue;
|
||||
}
|
||||
$post = get_post($layout['id']);
|
||||
if (
|
||||
! $post instanceof WP_Post
|
||||
|| $post->post_type !== (DiviThemeBuilder::LAYOUT_TYPES[$area] ?? null)
|
||||
|| (string) $post->post_status !== 'publish'
|
||||
) {
|
||||
$fields['layouts.' . $area] = sprintf(
|
||||
'Layout %d ist kein aktives Theme-Builder-Layout (%s) mehr',
|
||||
$layout['id'],
|
||||
$area
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($fields === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Errors::invalidInput(
|
||||
sprintf(
|
||||
'Theme-Builder-Vorlage %d verweist auf Layouts, die nicht mehr aktiv sind; nichts wurde gespeichert.',
|
||||
$template->id
|
||||
),
|
||||
$fields,
|
||||
'Im Theme Builder (Divi → Theme Builder) die Layouts der Vorlage wiederherstellen oder ersetzen, '
|
||||
. 'dann erneut aufrufen.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creating a template with layouts: canUse(), the group gate and publish_posts of the template and
|
||||
* layout types – Divi publishes them at once (F17, F18).
|
||||
*/
|
||||
public function canCreate(): ?WP_Error
|
||||
{
|
||||
$refusal = $this->canUse() ?? $this->writableGate();
|
||||
if ($refusal !== null) {
|
||||
return $refusal;
|
||||
}
|
||||
|
||||
foreach (array_merge([DiviThemeBuilder::TEMPLATE], array_values(DiviThemeBuilder::LAYOUT_TYPES)) as $type) {
|
||||
$object = get_post_type_object($type);
|
||||
$cap = is_object($object) && isset($object->cap->publish_posts)
|
||||
? (string) $object->cap->publish_posts
|
||||
: 'publish_posts';
|
||||
if (! current_user_can($cap)) {
|
||||
return Errors::forbidden(
|
||||
sprintf('Keine Berechtigung, Inhalte des Theme Builders zu veröffentlichen (%s).', $cap)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** E7: while the Theme Builder has unsaved changes (draft), its later save would replace the template list. */
|
||||
public function draftRefusal(): ?WP_Error
|
||||
{
|
||||
if ($this->store->draftId() <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Errors::unsupported(
|
||||
'Der Divi Theme Builder hat ungespeicherte Änderungen (Entwurf); solange ändert LIT MCP keine Vorlagen – '
|
||||
. 'ein späteres Speichern dort würde die Zuordnung ersetzen. Nichts wurde gespeichert.',
|
||||
'Im Theme Builder (Divi → Theme Builder) speichern oder die Änderungen verwerfen, dann erneut aufrufen.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Support;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
|
||||
use Mockery;
|
||||
use WP_Post;
|
||||
|
||||
/**
|
||||
* The Theme Builder of the tests: the real builder posts of tests/Fixtures/divi/stage3 (live TB post 1064,
|
||||
* default template 1065, template 1066 with header 1061, body 1062, footer 1063 – both disabled), a store
|
||||
* answering from Template values and the access built from them. Write methods of the store are real
|
||||
* unless a test mocks them.
|
||||
*/
|
||||
final class ThemeBuilderFixtures
|
||||
{
|
||||
public const THEME_BUILDER = 1064;
|
||||
public const DEFAULT_TEMPLATE = 1065;
|
||||
public const TEMPLATE = 1066;
|
||||
public const HEADER = 1061;
|
||||
public const BODY = 1062;
|
||||
public const FOOTER = 1063;
|
||||
|
||||
/** @var array<int, array{string, string}> Layout ID => [stage3 fixture file, post type]. */
|
||||
public const LAYOUTS = [
|
||||
1061 => ['tb-header--globaler-header.html', 'et_header_layout'],
|
||||
1062 => ['tb-body--body-mit-dynamic-content.html', 'et_body_layout'],
|
||||
1063 => ['tb-footer--footer.html', 'et_footer_layout'],
|
||||
];
|
||||
|
||||
/** @param array<string, mixed> $fields */
|
||||
public static function layout(int $id, array $fields = []): WP_Post
|
||||
{
|
||||
return new WP_Post(array_merge([
|
||||
'ID' => $id,
|
||||
'post_type' => self::LAYOUTS[$id][1],
|
||||
'post_status' => 'publish',
|
||||
'post_title' => '[Fixture] ' . $id,
|
||||
'post_content' => DiviFixtures::markup('stage3/' . self::LAYOUTS[$id][0]),
|
||||
], $fields));
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $fields */
|
||||
public static function templatePost(int $id = self::TEMPLATE, array $fields = []): WP_Post
|
||||
{
|
||||
return new WP_Post(array_merge([
|
||||
'ID' => $id,
|
||||
'post_type' => DiviThemeBuilder::TEMPLATE,
|
||||
'post_status' => 'publish',
|
||||
'post_title' => $id === self::DEFAULT_TEMPLATE ? 'Standard-Website-Template' : '[Fixture] Vorlage Globale Instanzen',
|
||||
'post_content' => '',
|
||||
], $fields));
|
||||
}
|
||||
|
||||
/**
|
||||
* The template as Divi reads it (et_theme_builder_get_template); $overrides replace keys of that
|
||||
* array and may set status, attached and marked_unused.
|
||||
*
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
public static function template(int $id = self::TEMPLATE, array $overrides = []): Template
|
||||
{
|
||||
$base = $id === self::DEFAULT_TEMPLATE
|
||||
? [
|
||||
'id' => $id,
|
||||
'title' => 'Standard-Website-Template',
|
||||
'default' => true,
|
||||
'enabled' => false,
|
||||
'layouts' => [
|
||||
'header' => ['id' => self::HEADER, 'enabled' => true],
|
||||
'body' => ['id' => 0, 'enabled' => true],
|
||||
'footer' => ['id' => 0, 'enabled' => true],
|
||||
],
|
||||
'use_on' => [],
|
||||
'exclude_from' => [],
|
||||
]
|
||||
: [
|
||||
'id' => $id,
|
||||
'title' => '[Fixture] Vorlage Globale Instanzen',
|
||||
'default' => false,
|
||||
'enabled' => false,
|
||||
'layouts' => [
|
||||
'header' => ['id' => self::HEADER, 'enabled' => true],
|
||||
'body' => ['id' => self::BODY, 'enabled' => true],
|
||||
'footer' => ['id' => self::FOOTER, 'enabled' => true],
|
||||
],
|
||||
'use_on' => ['singular:post_type:page:id:1053'],
|
||||
'exclude_from' => [],
|
||||
];
|
||||
|
||||
return Template::fromDivi(
|
||||
array_merge($base, array_intersect_key($overrides, $base)),
|
||||
(string) ($overrides['status'] ?? 'publish'),
|
||||
(bool) ($overrides['attached'] ?? true),
|
||||
isset($overrides['marked_unused']) ? (string) $overrides['marked_unused'] : null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A store whose live list is $templates, with a TB draft when $draftId > 0, $detached as templates no
|
||||
* Theme Builder post lists and $byIdOnly as templates template() finds but no list names (e.g. the one a
|
||||
* test lets storeTemplate() create).
|
||||
*
|
||||
* @param list<Template> $templates
|
||||
* @param list<Template> $detached
|
||||
* @param list<Template> $byIdOnly
|
||||
*/
|
||||
public static function store(
|
||||
array $templates = [],
|
||||
int $draftId = 0,
|
||||
array $detached = [],
|
||||
array $byIdOnly = []
|
||||
): DiviThemeBuilder {
|
||||
$byId = [];
|
||||
foreach (array_merge($templates, $detached, $byIdOnly) as $template) {
|
||||
$byId[$template->id] = $template;
|
||||
}
|
||||
$store = Mockery::mock(DiviThemeBuilder::class)->makePartial();
|
||||
$store->allows('liveId')->andReturn(self::THEME_BUILDER);
|
||||
$store->allows('draftId')->andReturn($draftId);
|
||||
$store->allows('liveTemplates')->andReturn($templates);
|
||||
$store->allows('templateIds')->andReturn(array_map(static fn (Template $t): int => $t->id, $templates));
|
||||
$store->allows('template')->andReturnUsing(static fn (int $id): ?Template => $byId[$id] ?? null);
|
||||
$store->allows('detachedIds')->andReturn(array_map(static fn (Template $t): int => $t->id, $detached));
|
||||
$store->allows('flatConditions')->andReturn(self::conditions());
|
||||
|
||||
return $store;
|
||||
}
|
||||
|
||||
/** @param list<string> $deniedRoles Divi role options the user lacks. */
|
||||
public static function access(
|
||||
array $deniedRoles = [],
|
||||
bool $writable = true,
|
||||
?DiviThemeBuilder $store = null,
|
||||
bool $visible = true
|
||||
): ThemeBuilderAccess {
|
||||
return new ThemeBuilderAccess(
|
||||
$store ?? self::store(),
|
||||
LibraryFixtures::roles($deniedRoles),
|
||||
static fn (): bool => $writable,
|
||||
static fn (): bool => $visible
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An excerpt of et_theme_builder_get_flat_template_settings_options() as the adapter returns it
|
||||
* (live DDEV dump 2026-09-27 plus the Pods type lit_person).
|
||||
*
|
||||
* @return array<string, array{label: string, type: ?string, value: ?string}>
|
||||
*/
|
||||
public static function conditions(): array
|
||||
{
|
||||
return [
|
||||
'singular:post_type:page:all' => ['label' => 'Alle Seiten', 'type' => null, 'value' => null],
|
||||
'homepage' => ['label' => 'Startseite', 'type' => null, 'value' => null],
|
||||
'singular:post_type:page:id:' => ['label' => 'Bestimmte Seiten', 'type' => 'post_type', 'value' => 'page'],
|
||||
'singular:post_type:page:children:id:' => [
|
||||
'label' => 'Unterseiten bestimmter Seiten', 'type' => 'post_type', 'value' => 'page',
|
||||
],
|
||||
'singular:post_type:lit_person:all' => ['label' => 'Alle Personen', 'type' => null, 'value' => null],
|
||||
'singular:post_type:lit_person:id:' => [
|
||||
'label' => 'Bestimmte Personen', 'type' => 'post_type', 'value' => 'lit_person',
|
||||
],
|
||||
'archive:taxonomy:category:term:id:' => [
|
||||
'label' => 'Bestimmte Kategorien', 'type' => 'taxonomy', 'value' => 'category',
|
||||
],
|
||||
'archive:user:id:' => ['label' => 'Bestimmte Autoren', 'type' => 'user', 'value' => null],
|
||||
'archive:user:role:' => ['label' => 'Autoren mit Rolle', 'type' => 'user_role', 'value' => null],
|
||||
'search' => ['label' => 'Suchergebnisse', 'type' => null, 'value' => null],
|
||||
'404' => ['label' => '404-Seite', 'type' => null, 'value' => null],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -37,8 +37,18 @@ final class DiviCompatibilityTest extends TestCase
|
||||
'et_theme_builder_clear_wp_cache (Theme-Builder-Cache)',
|
||||
'Block-Typ divi/global-layout (Instanzen globaler Module)',
|
||||
'Taxonomien layout_category, layout_tag (Bibliothek)',
|
||||
'Theme Builder: Funktionen et_theme_builder_* und et_()',
|
||||
'Theme Builder: Post-Typen et_template und Layouts',
|
||||
],
|
||||
array_keys(DiviCompatibility::defaults())
|
||||
);
|
||||
}
|
||||
|
||||
public function testChecksTheThemeBuilderInternals(): void
|
||||
{
|
||||
$labels = array_keys(DiviCompatibility::defaults());
|
||||
|
||||
self::assertContains('Theme Builder: Funktionen et_theme_builder_* und et_()', $labels);
|
||||
self::assertContains('Theme Builder: Post-Typen et_template und Layouts', $labels);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
|
||||
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class TemplateTest extends TestCase
|
||||
{
|
||||
public function testReadsDivisTemplateArray(): void
|
||||
{
|
||||
$template = ThemeBuilderFixtures::template();
|
||||
|
||||
self::assertSame(1066, $template->id);
|
||||
self::assertFalse($template->enabled);
|
||||
self::assertSame(['id' => 1062, 'enabled' => true], $template->layouts['body']);
|
||||
self::assertSame(['singular:post_type:page:id:1053'], $template->useOn);
|
||||
self::assertSame(['page'], $template->postTypes());
|
||||
self::assertFalse($template->isLive());
|
||||
}
|
||||
|
||||
/** Ruling Q8/B8: Divi renders every listed, enabled template whatever its post status – fail closed. */
|
||||
public function testLiveMeansAttachedAndEnabledWhateverThePostStatus(): void
|
||||
{
|
||||
self::assertTrue(ThemeBuilderFixtures::template(1066, ['enabled' => true])->isLive());
|
||||
self::assertFalse(ThemeBuilderFixtures::template(1066, ['enabled' => true, 'attached' => false])->isLive());
|
||||
self::assertFalse(ThemeBuilderFixtures::template(1066, ['attached' => true])->isLive());
|
||||
self::assertTrue(ThemeBuilderFixtures::template(1066, ['enabled' => true, 'status' => 'trash'])->isLive());
|
||||
self::assertTrue(ThemeBuilderFixtures::template(1066, ['enabled' => true, 'status' => 'draft'])->isLive());
|
||||
self::assertTrue(ThemeBuilderFixtures::template(1066, ['enabled' => true])->showsLayout('body', 1062));
|
||||
$hiddenBody = ThemeBuilderFixtures::template(1066, [
|
||||
'enabled' => true,
|
||||
'layouts' => [
|
||||
'header' => ['id' => 1061, 'enabled' => true],
|
||||
'body' => ['id' => 1062, 'enabled' => false],
|
||||
'footer' => ['id' => 1063, 'enabled' => true],
|
||||
],
|
||||
]);
|
||||
self::assertFalse($hiddenBody->showsLayout('body', 1062));
|
||||
self::assertFalse(ThemeBuilderFixtures::template()->showsLayout('body', 1062), 'disabled template');
|
||||
}
|
||||
|
||||
/** Stored as Divi stores it (stage3b/tb-template--person.json): layout IDs and flags as strings. */
|
||||
public function testReadsDivisStorageTypes(): void
|
||||
{
|
||||
$template = Template::fromDivi([
|
||||
'id' => '2394',
|
||||
'title' => '[Fixture 3b] Person',
|
||||
'default' => false,
|
||||
'enabled' => false,
|
||||
'layouts' => [
|
||||
'header' => ['id' => '1061', 'enabled' => true, 'override' => true, 'global' => false],
|
||||
'body' => ['id' => '2391', 'enabled' => true],
|
||||
'footer' => ['id' => '0', 'enabled' => true],
|
||||
],
|
||||
'use_on' => ['singular:post_type:lit_fixture_person:all'],
|
||||
'exclude_from' => [],
|
||||
], 'publish', true, '2026-09-27 12:47:02');
|
||||
|
||||
self::assertSame(2394, $template->id);
|
||||
self::assertSame(['id' => 1061, 'enabled' => true], $template->layouts['header']);
|
||||
self::assertSame(['id' => 0, 'enabled' => true], $template->layouts['footer']);
|
||||
self::assertSame(['lit_fixture_person'], $template->postTypes());
|
||||
self::assertSame('2026-09-27 12:47:02', $template->toArray()['marked_unused']);
|
||||
}
|
||||
|
||||
public function testRevChangesWithEveryField(): void
|
||||
{
|
||||
$rev = ThemeBuilderFixtures::template()->rev();
|
||||
|
||||
self::assertSame($rev, ThemeBuilderFixtures::template()->rev());
|
||||
self::assertNotSame($rev, ThemeBuilderFixtures::template(1066, ['enabled' => true])->rev());
|
||||
self::assertNotSame($rev, ThemeBuilderFixtures::template(1066, ['use_on' => ['homepage']])->rev());
|
||||
self::assertNotSame($rev, ThemeBuilderFixtures::template(1066, ['attached' => false])->rev());
|
||||
}
|
||||
|
||||
public function testOverlapsNameOtherLiveTemplatesWithSharedConditions(): void
|
||||
{
|
||||
$other = ThemeBuilderFixtures::template(1070, [
|
||||
'enabled' => true,
|
||||
'title' => 'Seiten',
|
||||
'use_on' => ['singular:post_type:page:all', 'homepage'],
|
||||
]);
|
||||
$disabled = ThemeBuilderFixtures::template(1071, ['use_on' => ['homepage']]);
|
||||
|
||||
self::assertSame(
|
||||
[['id' => 1070, 'title' => 'Seiten', 'conditions' => ['homepage']]],
|
||||
Template::overlaps([$other, $disabled], 1066, ['homepage', 'search'])
|
||||
);
|
||||
self::assertSame([], Template::overlaps([$other], 1070, ['homepage']));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||||
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use WP_Post;
|
||||
|
||||
final class ThemeBuilderAccessTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Functions\when('get_post_type_object')->justReturn(null);
|
||||
}
|
||||
|
||||
/** Spec §4.5 + F12/F23: edit_others_posts and Divi's role option "Theme Builder". */
|
||||
public function testUsingTheThemeBuilderNeedsEditOthersPostsAndTheDiviRole(): void
|
||||
{
|
||||
LibraryFixtures::given([]);
|
||||
self::assertNull(ThemeBuilderFixtures::access()->canUse());
|
||||
self::assertToolError(ThemeBuilderFixtures::access(['theme_builder'])->canUse(), 'forbidden');
|
||||
self::assertFalse(ThemeBuilderFixtures::access(['theme_builder'])->visible());
|
||||
self::assertFalse(ThemeBuilderFixtures::access([], true, null, false)->visible());
|
||||
|
||||
LibraryFixtures::given([], ['edit_others_posts']);
|
||||
self::assertToolError(ThemeBuilderFixtures::access()->canUse(), 'forbidden');
|
||||
}
|
||||
|
||||
/** P12 pattern: without access a layout ID answers exactly like a missing one. */
|
||||
public function testLayoutsAreHiddenLikeMissingIdsWithoutAccess(): void
|
||||
{
|
||||
$body = ThemeBuilderFixtures::layout(1062);
|
||||
LibraryFixtures::given([$body, LibraryFixtures::page('')]);
|
||||
|
||||
self::assertInstanceOf(WP_Post::class, ThemeBuilderFixtures::access()->layout(1062, false));
|
||||
self::assertNull(ThemeBuilderFixtures::access()->layout(7, false), 'a page is no layout');
|
||||
$missing = ThemeBuilderFixtures::access(['theme_builder'])->layout(1062, false);
|
||||
$hidden = ThemeBuilderFixtures::access([], true, null, false)->layout(1062, false);
|
||||
self::assertToolError($missing, 'not_found');
|
||||
self::assertSame(self::clientError($missing), self::clientError(\LIT\MCP\Integrations\WordPress\PostGuard::notFound(1062)));
|
||||
self::assertToolError($hidden, 'not_found');
|
||||
|
||||
LibraryFixtures::given([$body], ['edit_post']);
|
||||
self::assertToolError(ThemeBuilderFixtures::access()->layout(1062, false), 'forbidden');
|
||||
}
|
||||
|
||||
public function testWritingALayoutNeedsTheGroupAndAPublishedLayout(): void
|
||||
{
|
||||
LibraryFixtures::given([ThemeBuilderFixtures::layout(1062), ThemeBuilderFixtures::layout(1063, ['post_status' => 'trash'])]);
|
||||
|
||||
self::assertInstanceOf(WP_Post::class, ThemeBuilderFixtures::access()->layout(1062, true));
|
||||
self::assertToolError(ThemeBuilderFixtures::access([], false)->layout(1062, true), 'forbidden');
|
||||
self::assertToolError(ThemeBuilderFixtures::access()->layout(1063, true), 'validation_failed');
|
||||
}
|
||||
|
||||
public function testTemplatesAreTheOnlyTemplateIds(): void
|
||||
{
|
||||
LibraryFixtures::given([ThemeBuilderFixtures::templatePost(), ThemeBuilderFixtures::layout(1062)]);
|
||||
|
||||
self::assertInstanceOf(WP_Post::class, ThemeBuilderFixtures::access()->template(1066, true));
|
||||
self::assertToolError(ThemeBuilderFixtures::access()->template(1062, false), 'not_found');
|
||||
self::assertToolError(ThemeBuilderFixtures::access()->template(999, false), 'not_found');
|
||||
self::assertToolError(ThemeBuilderFixtures::access()->template('x', false), 'validation_failed');
|
||||
self::assertToolError(ThemeBuilderFixtures::access(['theme_builder'])->template(1066, false), 'not_found');
|
||||
self::assertToolError(ThemeBuilderFixtures::access([], false)->template(1066, true), 'forbidden');
|
||||
}
|
||||
|
||||
public function testCreatingNeedsPublishRightsAndTheGroup(): void
|
||||
{
|
||||
LibraryFixtures::given([]);
|
||||
self::assertNull(ThemeBuilderFixtures::access()->canCreate());
|
||||
self::assertToolError(ThemeBuilderFixtures::access([], false)->canCreate(), 'forbidden');
|
||||
|
||||
LibraryFixtures::given([], ['publish_posts']);
|
||||
self::assertToolError(ThemeBuilderFixtures::access()->canCreate(), 'forbidden');
|
||||
}
|
||||
|
||||
/** E7: an unsaved Theme Builder session would later replace the template list. */
|
||||
public function testAThemeBuilderDraftBlocksWrites(): void
|
||||
{
|
||||
self::assertNull(ThemeBuilderFixtures::access()->draftRefusal());
|
||||
$blocked = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([], 999))->draftRefusal();
|
||||
self::assertToolError($blocked, 'unsupported');
|
||||
self::assertStringContainsString('Entwurf', self::clientError($blocked)['message']);
|
||||
}
|
||||
|
||||
/** Ruling Q8/B11: tb-list names a template's ID only to a user who may edit it. */
|
||||
public function testTemplateIdsAreNamedOnlyToEditors(): void
|
||||
{
|
||||
LibraryFixtures::given([], []);
|
||||
self::assertTrue(ThemeBuilderFixtures::access()->canEditTemplate(1066));
|
||||
self::assertFalse(ThemeBuilderFixtures::access(['theme_builder'])->canEditTemplate(1066));
|
||||
self::assertFalse(ThemeBuilderFixtures::access([], true, null, false)->canEditTemplate(1066));
|
||||
|
||||
LibraryFixtures::given([], ['edit_post']);
|
||||
self::assertFalse(ThemeBuilderFixtures::access()->canEditTemplate(1066));
|
||||
}
|
||||
|
||||
/** Ruling Q8/B10: reattach/activate only with published layouts of the right area (Divi trashes marked ones). */
|
||||
public function testLayoutsMustBePublishedBeforeTheTemplateGoesBackOrLive(): void
|
||||
{
|
||||
LibraryFixtures::given([
|
||||
ThemeBuilderFixtures::layout(1061),
|
||||
ThemeBuilderFixtures::layout(1062),
|
||||
ThemeBuilderFixtures::layout(1063),
|
||||
]);
|
||||
self::assertNull(ThemeBuilderFixtures::access()->layoutsRefusal(ThemeBuilderFixtures::template()));
|
||||
self::assertNull(ThemeBuilderFixtures::access()->layoutsRefusal(
|
||||
ThemeBuilderFixtures::template(ThemeBuilderFixtures::DEFAULT_TEMPLATE)
|
||||
), 'areas without a layout (ID 0) need nothing');
|
||||
|
||||
LibraryFixtures::given([
|
||||
ThemeBuilderFixtures::layout(1061),
|
||||
ThemeBuilderFixtures::layout(1062, ['post_status' => 'trash']),
|
||||
]);
|
||||
$refusal = ThemeBuilderFixtures::access()->layoutsRefusal(ThemeBuilderFixtures::template());
|
||||
self::assertToolError($refusal, 'validation_failed');
|
||||
self::assertSame(['layouts.body', 'layouts.footer'], array_keys(self::errorFields($refusal)));
|
||||
|
||||
LibraryFixtures::given([
|
||||
ThemeBuilderFixtures::layout(1061),
|
||||
ThemeBuilderFixtures::layout(1063, ['ID' => 1062]),
|
||||
ThemeBuilderFixtures::layout(1063),
|
||||
]);
|
||||
$refusal = ThemeBuilderFixtures::access()->layoutsRefusal(ThemeBuilderFixtures::template());
|
||||
self::assertSame(['layouts.body'], array_keys(self::errorFields($refusal)), 'a footer is no body');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user