feat: lit/tb-assign – conditions with dry run on live templates, reattach
- ThemeBuilderAccess::changeableTemplate(): rights (Q18 not_found), group gate, no TB draft, never the default template, current template_rev - omitted exclude_from stays, [] clears (Q3); live dry run shows use_on and exclude_from before/after, overlaps and possible overlaps (B9) - reattach only with published layouts (B10) and a live TB post; always disabled, unmarks template and layouts; failure restores everything Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,329 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Ability;
|
||||||
|
use LIT\MCP\Abilities\Annotations;
|
||||||
|
use LIT\MCP\Abilities\Errors;
|
||||||
|
use LIT\MCP\Abilities\FollowUp;
|
||||||
|
use LIT\MCP\Abilities\Rollback;
|
||||||
|
use LIT\MCP\Abilities\ToolResult;
|
||||||
|
use LIT\MCP\Abilities\WriteFailed;
|
||||||
|
use LIT\MCP\ChangeLog\ChangeLog;
|
||||||
|
use Throwable;
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* lit/tb-assign (spec §5.7): replaces the conditions of a template (_et_use_on/_et_exclude_from, only from
|
||||||
|
* TemplateConditions). use_on is required; an omitted exclude_from stays as it is, an explicit [] clears it
|
||||||
|
* (ruling Q3/B3). A live template changes the website at once, so it is a dry run first and needs confirm: true
|
||||||
|
* (ruling Q9/B22), listing both lists before/after and overlaps (identical and possible, B9) with other live
|
||||||
|
* templates for the resulting lists. reattach: true puts a template a stale Theme Builder save removed back on the
|
||||||
|
* live list (only on the user's request, B19) – always disabled, Divi's "unused" marks removed from it and its
|
||||||
|
* layouts, which must still be published (B10). Templates have no revisions: the previous conditions are in the
|
||||||
|
* answer and the change log; a failed write restores them (and undoes the reattach).
|
||||||
|
*/
|
||||||
|
final class TbAssignAbility implements Ability
|
||||||
|
{
|
||||||
|
public const REASON = 'Die Bedingungen der Vorlage konnten nicht gespeichert werden.';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private ThemeBuilderAccess $access,
|
||||||
|
private TemplateConditions $conditions,
|
||||||
|
private ChangeLog $log
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'lit/tb-assign';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return 'Theme-Builder-Vorlage zuordnen';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Setzt die Bedingungen einer Theme-Builder-Vorlage neu: use_on ersetzt die bisherigen (z. B. '
|
||||||
|
. '"singular:post_type:person:all" = alle Einträge des Inhaltstyps person), exclude_from ebenso – fehlt '
|
||||||
|
. 'exclude_from, bleiben die Ausnahmen, [] entfernt sie. IDs aus lit/tb-list mit conditions: true; '
|
||||||
|
. 'template_rev aus lit/tb-list ist Pflicht. Bei einer aktiven Vorlage wirkt das sofort auf der Website: '
|
||||||
|
. 'erst Probelauf (vorher/nachher, Überschneidungen), dann nach Rückfrage beim Nutzer confirm: true. '
|
||||||
|
. 'reattach: true ordnet eine Vorlage wieder zu, die ein Speichern im Theme Builder entfernt hat (nur auf '
|
||||||
|
. 'ausdrücklichen Wunsch des Nutzers; sie bleibt deaktiviert). Die Standardvorlage nur im Theme Builder.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inputSchema(): array
|
||||||
|
{
|
||||||
|
$conditions = ['type' => 'array', 'maxItems' => TemplateConditions::MAX, 'items' => ['type' => 'string']];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'template_id' => ['type' => 'integer', 'minimum' => 1],
|
||||||
|
'template_rev' => ['type' => 'string', 'description' => 'template_rev aus lit/tb-list.'],
|
||||||
|
'use_on' => $conditions,
|
||||||
|
'exclude_from' => $conditions + [
|
||||||
|
'description' => 'Fehlt es, bleiben die bisherigen Ausnahmen; [] entfernt sie.',
|
||||||
|
],
|
||||||
|
'reattach' => ['type' => 'boolean'],
|
||||||
|
'confirm' => ['type' => 'boolean'],
|
||||||
|
],
|
||||||
|
'required' => ['template_id', 'template_rev', 'use_on'],
|
||||||
|
'additionalProperties' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function outputSchema(): ?array
|
||||||
|
{
|
||||||
|
return ToolResult::schema([
|
||||||
|
'template_id' => ['type' => 'integer'],
|
||||||
|
'template_rev' => ['type' => 'string'],
|
||||||
|
'live' => ['type' => 'boolean'],
|
||||||
|
'previous' => ['type' => 'object'],
|
||||||
|
], ['template_id', 'template_rev', 'live']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function annotations(): array
|
||||||
|
{
|
||||||
|
return Annotations::destructive(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDirect(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function permission(array $input): bool|WP_Error
|
||||||
|
{
|
||||||
|
$template = $this->access->changeableTemplate($input);
|
||||||
|
|
||||||
|
return $template instanceof WP_Error ? $template : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws WriteFailed */
|
||||||
|
public function execute(array $input): mixed
|
||||||
|
{
|
||||||
|
$template = $this->access->changeableTemplate($input);
|
||||||
|
if ($template instanceof WP_Error) {
|
||||||
|
return $template;
|
||||||
|
}
|
||||||
|
|
||||||
|
$useOn = is_array($input['use_on'] ?? null)
|
||||||
|
? $this->conditions->check($input['use_on'], 'use_on')
|
||||||
|
: ['ids' => [], 'errors' => ['use_on' => 'Liste von Bedingungs-IDs erwartet (leere Liste = keine)']];
|
||||||
|
$excludeFrom = array_key_exists('exclude_from', $input)
|
||||||
|
? $this->conditions->check($input['exclude_from'] ?? [], 'exclude_from')
|
||||||
|
: ['ids' => $template->excludeFrom, 'errors' => []];
|
||||||
|
$errors = array_merge($useOn['errors'], $excludeFrom['errors']);
|
||||||
|
if ($errors !== []) {
|
||||||
|
return Errors::invalidInput('Bedingungen sind ungültig; nichts wurde gespeichert.', $errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reattach = ! $template->attached && ($input['reattach'] ?? false) === true;
|
||||||
|
$refusal = $this->reattachRefusal($template, $reattach);
|
||||||
|
if ($refusal !== null) {
|
||||||
|
return $refusal;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($template->isLive() && ($input['confirm'] ?? false) !== true) {
|
||||||
|
return $this->dryRun($template, $useOn['ids'], $excludeFrom['ids']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->write($template, $useOn['ids'], $excludeFrom['ids'], $reattach);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A detached template needs reattach: true, a live Theme Builder post and published layouts (B10). */
|
||||||
|
private function reattachRefusal(Template $template, bool $reattach): ?WP_Error
|
||||||
|
{
|
||||||
|
if ($template->attached) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $reattach) {
|
||||||
|
$marked = $template->markedUnused === null
|
||||||
|
? ''
|
||||||
|
: sprintf(' (seit %s als ungenutzt markiert; Divi löscht sie 7 Tage danach)', $template->markedUnused);
|
||||||
|
|
||||||
|
return Errors::invalidInput(
|
||||||
|
sprintf(
|
||||||
|
'Vorlage %d ist im Theme Builder nicht mehr zugeordnet%s; nichts wurde gespeichert.',
|
||||||
|
$template->id,
|
||||||
|
$marked
|
||||||
|
),
|
||||||
|
[
|
||||||
|
'reattach' => 'nur auf Wunsch des Nutzers mit reattach: true wieder zuordnen (die Vorlage bleibt '
|
||||||
|
. 'deaktiviert)',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->access->store()->liveId() <= 0) {
|
||||||
|
return Errors::unsupported(
|
||||||
|
'Der Divi Theme Builder wurde noch nie gespeichert; es gibt keine Liste, der die Vorlage wieder '
|
||||||
|
. 'zugeordnet werden kann. Nichts wurde gespeichert.',
|
||||||
|
'Den Theme Builder einmal speichern (Divi → Theme Builder), dann erneut aufrufen.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->access->layoutsRefusal($template);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $useOn
|
||||||
|
* @param list<string> $excludeFrom
|
||||||
|
*/
|
||||||
|
private function dryRun(Template $template, array $useOn, array $excludeFrom): WP_Error
|
||||||
|
{
|
||||||
|
$live = $this->access->store()->liveTemplates();
|
||||||
|
|
||||||
|
return Errors::confirmRequired(
|
||||||
|
sprintf(
|
||||||
|
'Vorlage „%s“ ist aktiv; die neuen Bedingungen (use_on) und Ausnahmen (exclude_from) wirken sofort auf '
|
||||||
|
. 'der Website – vorher/nachher unter before/after. Nichts wurde gespeichert (Probelauf).',
|
||||||
|
$template->title
|
||||||
|
),
|
||||||
|
[
|
||||||
|
'before' => $this->labels($template->useOn, $template->excludeFrom),
|
||||||
|
'after' => $this->labels($useOn, $excludeFrom),
|
||||||
|
'overlaps' => Template::overlaps($live, $template->id, $useOn),
|
||||||
|
'possible_overlaps' => Template::possibleOverlaps($live, $template->id, $useOn),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $useOn
|
||||||
|
* @param list<string> $excludeFrom
|
||||||
|
* @return array{use_on: list<string>, exclude_from: list<string>}
|
||||||
|
*/
|
||||||
|
private function labels(array $useOn, array $excludeFrom): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'use_on' => array_map($this->conditions->label(...), $useOn),
|
||||||
|
'exclude_from' => array_map($this->conditions->label(...), $excludeFrom),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $useOn
|
||||||
|
* @param list<string> $excludeFrom
|
||||||
|
* @return array<string, mixed>
|
||||||
|
* @throws WriteFailed
|
||||||
|
*/
|
||||||
|
private function write(Template $template, array $useOn, array $excludeFrom, bool $reattach): array
|
||||||
|
{
|
||||||
|
$store = $this->access->store();
|
||||||
|
$themeBuilderId = $store->liveId();
|
||||||
|
try {
|
||||||
|
if ($reattach) {
|
||||||
|
$store->setEnabled($template->id, false);
|
||||||
|
$store->attach($themeBuilderId, $template->id);
|
||||||
|
foreach (array_merge([$template->id], array_column($template->layouts, 'id')) as $id) {
|
||||||
|
if ($id > 0) {
|
||||||
|
$store->unmarkUnused($id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$store->setConditions($template->id, $useOn, $excludeFrom);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->undo($template, $reattach, $themeBuilderId, $e);
|
||||||
|
}
|
||||||
|
|
||||||
|
$warnings = [];
|
||||||
|
if ($template->isLive()) {
|
||||||
|
FollowUp::run(
|
||||||
|
static fn () => $store->clearCaches(),
|
||||||
|
'theme builder cache',
|
||||||
|
'Der Cache des Theme Builders konnte nicht geleert werden; die Bedingungen sind gespeichert, wirken '
|
||||||
|
. 'aber evtl. erst nach „Statische CSS-Dateien leeren“ (Divi → Theme-Optionen → Builder).',
|
||||||
|
$warnings
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ($reattach) {
|
||||||
|
$warnings[] = TbCreateTemplateAbility::STALE_EDITOR;
|
||||||
|
}
|
||||||
|
$logId = $this->log->record(
|
||||||
|
$this->name(),
|
||||||
|
DiviThemeBuilder::TEMPLATE,
|
||||||
|
(string) $template->id,
|
||||||
|
sprintf(
|
||||||
|
'Theme Builder: Bedingungen von „%s“ gesetzt%s (vorher: %s; Ausnahmen vorher: %s)',
|
||||||
|
$template->title,
|
||||||
|
$reattach ? ', wieder zugeordnet (deaktiviert)' : '',
|
||||||
|
self::listed($template->useOn),
|
||||||
|
self::listed($template->excludeFrom)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$after = $store->template($template->id, $template->attached || $reattach);
|
||||||
|
$live = $after?->isLive() ?? false;
|
||||||
|
|
||||||
|
return ToolResult::ok(sprintf(
|
||||||
|
'Bedingungen der Vorlage „%s“ gesetzt%s%s.',
|
||||||
|
$template->title,
|
||||||
|
$reattach ? ' und wieder zugeordnet' : '',
|
||||||
|
$live
|
||||||
|
? ' – wirkt sofort auf der Website; zurück mit lit/tb-assign und den Bedingungen unter previous, '
|
||||||
|
. 'ausblenden mit lit/tb-activate (enabled: false)'
|
||||||
|
: ' (Vorlage deaktiviert, ohne Wirkung auf die Website)'
|
||||||
|
))
|
||||||
|
->with('template_id', $template->id)
|
||||||
|
->with('template_rev', $after?->rev() ?? '')
|
||||||
|
->with('live', $live)
|
||||||
|
->with('previous', ['use_on' => $template->useOn, 'exclude_from' => $template->excludeFrom])
|
||||||
|
->warnings($warnings)
|
||||||
|
->logId($logId)
|
||||||
|
->urls(null, (string) admin_url('admin.php?page=et_theme_builder'))
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $conditions */
|
||||||
|
private static function listed(array $conditions): string
|
||||||
|
{
|
||||||
|
return $conditions === [] ? 'keine' : implode(', ', $conditions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws WriteFailed Always. */
|
||||||
|
private function undo(Template $template, bool $reattach, int $themeBuilderId, Throwable $cause): never
|
||||||
|
{
|
||||||
|
$store = $this->access->store();
|
||||||
|
$steps = [static fn () => $store->setConditions($template->id, $template->useOn, $template->excludeFrom)];
|
||||||
|
if ($reattach) {
|
||||||
|
$steps[] = static fn () => $store->detach($themeBuilderId, $template->id);
|
||||||
|
$steps[] = static fn () => $store->setEnabled($template->id, $template->enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
$failures = Rollback::attempt(...$steps);
|
||||||
|
if ($failures === []) {
|
||||||
|
throw WriteFailed::rolledBack(self::REASON, $cause, 'tb-assign failed: ' . $cause->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->log->recordPartial(
|
||||||
|
$this->name(),
|
||||||
|
DiviThemeBuilder::TEMPLATE,
|
||||||
|
(string) $template->id,
|
||||||
|
sprintf(
|
||||||
|
'Theme Builder: Bedingungen von „%s“ nur teilweise zurückgenommen (%s)',
|
||||||
|
$template->title,
|
||||||
|
implode('; ', $failures)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
throw WriteFailed::partial(
|
||||||
|
self::REASON,
|
||||||
|
$cause,
|
||||||
|
'tb-assign rollback: ' . implode('; ', $failures),
|
||||||
|
$template->id,
|
||||||
|
hint: sprintf(
|
||||||
|
'Mit lit/tb-list prüfen; vorher galt: %s; Ausnahmen: %s.',
|
||||||
|
self::listed($template->useOn),
|
||||||
|
self::listed($template->excludeFrom)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,6 +145,75 @@ final class Template
|
|||||||
return $overlaps;
|
return $overlaps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live templates other than $exceptId whose conditions are not identical to one of $useOn but may cover
|
||||||
|
* the same pages (ruling Q8/B9 – "kann sich überschneiden"): same family, e.g. all pages vs. one page,
|
||||||
|
* all archives vs. one category archive, posts in a category vs. any single post, the front page vs. pages.
|
||||||
|
* Deliberately generous – the dry run is the user's only preview. Identical conditions are overlaps().
|
||||||
|
*
|
||||||
|
* @param list<Template> $live
|
||||||
|
* @param list<string> $useOn
|
||||||
|
* @return list<array{id: int, title: string, conditions: list<string>}>
|
||||||
|
*/
|
||||||
|
public static function possibleOverlaps(array $live, int $exceptId, array $useOn): array
|
||||||
|
{
|
||||||
|
$overlaps = [];
|
||||||
|
foreach ($live as $other) {
|
||||||
|
if ($other->id === $exceptId || ! $other->isLive()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$conditions = [];
|
||||||
|
foreach ($other->useOn as $theirs) {
|
||||||
|
foreach ($useOn as $ours) {
|
||||||
|
if ($theirs !== $ours && self::mayOverlap($theirs, $ours)) {
|
||||||
|
$conditions[] = $theirs;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($conditions !== []) {
|
||||||
|
$overlaps[] = ['id' => $other->id, 'title' => $other->title, 'conditions' => $conditions];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $overlaps;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function mayOverlap(string $a, string $b): bool
|
||||||
|
{
|
||||||
|
foreach (self::scopesOf($a) as $x) {
|
||||||
|
foreach (self::scopesOf($b) as $y) {
|
||||||
|
if ($x === $y || str_starts_with($x, $y . ':') || str_starts_with($y, $x . ':')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page families a condition covers (Divi's IDs, theme-builder.php:689-1060): singular:post_type:<type>,
|
||||||
|
* archive:taxonomy:<taxonomy>, archive:post_type:<type>, archive:user, archive:date; "<kind>:all" and posts
|
||||||
|
* in a term (singular:taxonomy:…) the whole kind; the front page may be a page or the posts archive.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private static function scopesOf(string $condition): array
|
||||||
|
{
|
||||||
|
if ($condition === 'homepage') {
|
||||||
|
return ['homepage', 'singular:post_type:page', 'archive:post_type:post'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = explode(':', $condition);
|
||||||
|
$second = $parts[1] ?? '';
|
||||||
|
if (($parts[0] === 'singular' && $second === 'taxonomy') || $second === 'all') {
|
||||||
|
return [$parts[0]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [implode(':', array_slice($parts, 0, in_array($second, ['post_type', 'taxonomy'], true) ? 3 : 2))];
|
||||||
|
}
|
||||||
|
|
||||||
/** @return list<string> */
|
/** @return list<string> */
|
||||||
private static function strings(mixed $list): array
|
private static function strings(mixed $list): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -154,6 +154,23 @@ class ThemeBuilderAccess
|
|||||||
return $forWrite ? ($this->writableGate() ?? $post) : $post;
|
return $forWrite ? ($this->writableGate() ?? $post) : $post;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A template the Theme Builder tools may change (lit/tb-assign, lit/tb-activate): rights and group gate
|
||||||
|
* (template(), a template the user may not edit reads as missing – ruling Q18), no Theme Builder draft (E7),
|
||||||
|
* never the default template (E8), the current template_rev.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $input template_id, template_rev
|
||||||
|
*/
|
||||||
|
public function changeableTemplate(array $input): Template|WP_Error
|
||||||
|
{
|
||||||
|
$post = $this->template($input['template_id'] ?? null, true);
|
||||||
|
if ($post instanceof WP_Error) {
|
||||||
|
return $post;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->checkedTemplate((int) $post->ID, $input);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether template $id may be named to this user (tb-list shows other IDs not at all, ruling Q8/B11).
|
* Whether template $id may be named to this user (tb-list shows other IDs not at all, ruling Q8/B11).
|
||||||
* $id must come from Divi's own template list (templateIds()/liveTemplates()/detachedIds()): this checks
|
* $id must come from Divi's own template list (templateIds()/liveTemplates()/detachedIds()): this checks
|
||||||
@@ -244,4 +261,42 @@ class ThemeBuilderAccess
|
|||||||
'Im Theme Builder (Divi → Theme Builder) speichern oder die Änderungen verwerfen, dann erneut aufrufen.'
|
'Im Theme Builder (Divi → Theme Builder) speichern oder die Änderungen verwerfen, dann erneut aufrufen.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The draft, default and template_rev checks of changeableTemplate() for template $id (rights already checked).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $input
|
||||||
|
*/
|
||||||
|
private function checkedTemplate(int $id, array $input): Template|WP_Error
|
||||||
|
{
|
||||||
|
$refusal = $this->draftRefusal();
|
||||||
|
if ($refusal !== null) {
|
||||||
|
return $refusal;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attached = in_array($id, $this->store->templateIds($this->store->liveId()), true);
|
||||||
|
$template = $this->store->template($id, $attached);
|
||||||
|
if ($template === null) {
|
||||||
|
return Errors::notFound(
|
||||||
|
sprintf('Theme-Builder-Vorlage %d gibt es nicht.', $id),
|
||||||
|
'Vorlagen mit lit/tb-list auflisten.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($template->default) {
|
||||||
|
return Errors::unsupported(
|
||||||
|
'Die Standardvorlage ändert LIT MCP nicht; nichts wurde gespeichert.',
|
||||||
|
'Die Standardvorlage im Divi Theme Builder bearbeiten.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($input['template_rev'] ?? null) || $input['template_rev'] !== $template->rev()) {
|
||||||
|
return Errors::revMismatch(
|
||||||
|
'Die Vorlage wurde seit dem Lesen geändert (template_rev passt nicht); nichts wurde gespeichert.',
|
||||||
|
'Mit lit/tb-list neu lesen und mit der neuen template_rev wiederholen.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $template;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ final class ThemeBuilderIntegration implements Integration
|
|||||||
return [
|
return [
|
||||||
new TbListAbility($this->access, $conditions),
|
new TbListAbility($this->access, $conditions),
|
||||||
new TbCreateTemplateAbility($this->access, $conditions, $this->composition, $this->saver, $this->log),
|
new TbCreateTemplateAbility($this->access, $conditions, $this->composition, $this->saver, $this->log),
|
||||||
|
new TbAssignAbility($this->access, $conditions, $this->log),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ final class DiviIntegrationTest extends TestCase
|
|||||||
self::assertSame('Divi Theme Builder', $group->label());
|
self::assertSame('Divi Theme Builder', $group->label());
|
||||||
self::assertFalse($group->isAvailable(), 'no Divi classes in unit tests');
|
self::assertFalse($group->isAvailable(), 'no Divi classes in unit tests');
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
['lit/tb-list', 'lit/tb-create-template'],
|
['lit/tb-list', 'lit/tb-create-template', 'lit/tb-assign'],
|
||||||
array_map(static fn (Ability $ability): string => $ability->name(), $group->abilities())
|
array_map(static fn (Ability $ability): string => $ability->name(), $group->abilities())
|
||||||
);
|
);
|
||||||
foreach ($group->abilities() as $ability) {
|
foreach ($group->abilities() as $ability) {
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Abilities\WriteFailed;
|
||||||
|
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||||||
|
use LIT\MCP\Integrations\Divi\ThemeBuilder\TbAssignAbility;
|
||||||
|
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
|
||||||
|
use LIT\MCP\Integrations\Divi\ThemeBuilder\TemplateConditions;
|
||||||
|
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||||||
|
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||||
|
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||||||
|
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class TbAssignAbilityTest extends TestCase
|
||||||
|
{
|
||||||
|
private const PERSONS = 'singular:post_type:lit_person:all';
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
self::givenPosts();
|
||||||
|
Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $bodyFields */
|
||||||
|
private static function givenPosts(array $bodyFields = []): void
|
||||||
|
{
|
||||||
|
LibraryFixtures::given([
|
||||||
|
ThemeBuilderFixtures::templatePost(1066),
|
||||||
|
ThemeBuilderFixtures::templatePost(1065),
|
||||||
|
ThemeBuilderFixtures::layout(1061),
|
||||||
|
ThemeBuilderFixtures::layout(1062, $bodyFields),
|
||||||
|
ThemeBuilderFixtures::layout(1063),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ability(DiviThemeBuilder $store, ?FakeChangeLog $log = null): TbAssignAbility
|
||||||
|
{
|
||||||
|
return new TbAssignAbility(
|
||||||
|
ThemeBuilderFixtures::access([], true, $store),
|
||||||
|
new TemplateConditions($store),
|
||||||
|
$log ?? DiviWriterFixture::log()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private static function input(Template $template, array $extra = []): array
|
||||||
|
{
|
||||||
|
return array_merge(
|
||||||
|
['template_id' => $template->id, 'template_rev' => $template->rev(), 'use_on' => [self::PERSONS]],
|
||||||
|
$extra
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSetsConditionsOfADisabledTemplateAtOnce(): void
|
||||||
|
{
|
||||||
|
$template = ThemeBuilderFixtures::template(1066);
|
||||||
|
$store = ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1065), $template]);
|
||||||
|
$store->shouldReceive('setConditions')->once()->with(1066, [self::PERSONS], []);
|
||||||
|
$store->shouldReceive('clearCaches')->never();
|
||||||
|
|
||||||
|
$result = self::ability($store)->execute(self::input($template));
|
||||||
|
|
||||||
|
self::assertTrue($result['ok']);
|
||||||
|
self::assertFalse($result['live']);
|
||||||
|
self::assertSame(['use_on' => ['singular:post_type:page:id:1053'], 'exclude_from' => []], $result['previous']);
|
||||||
|
self::assertStringContainsString('ohne Wirkung auf die Website', $result['summary']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling Q9/B22: live → dry run + confirm; ruling Q3/B3: the dry run shows both lists before and after. */
|
||||||
|
public function testActiveTemplateNeedsConfirm(): void
|
||||||
|
{
|
||||||
|
$template = ThemeBuilderFixtures::template(1066, ['enabled' => true]);
|
||||||
|
$other = ThemeBuilderFixtures::template(1070, ['enabled' => true, 'title' => 'Personen alt', 'use_on' => [self::PERSONS]]);
|
||||||
|
$store = ThemeBuilderFixtures::store([$template, $other]);
|
||||||
|
$calls = 0;
|
||||||
|
$store->shouldReceive('setConditions')->andReturnUsing(static function () use (&$calls): void {
|
||||||
|
++$calls;
|
||||||
|
});
|
||||||
|
$store->shouldReceive('clearCaches')->once();
|
||||||
|
|
||||||
|
$dry = self::ability($store)->execute(self::input($template));
|
||||||
|
self::assertToolError($dry, 'confirm_required');
|
||||||
|
self::assertSame(0, $calls);
|
||||||
|
self::assertSame(['use_on' => ['Bestimmte Seiten: 1053'], 'exclude_from' => []], $dry->get_error_data()['before']);
|
||||||
|
self::assertSame(['use_on' => ['Alle Personen'], 'exclude_from' => []], $dry->get_error_data()['after']);
|
||||||
|
self::assertSame(
|
||||||
|
[['id' => 1070, 'title' => 'Personen alt', 'conditions' => [self::PERSONS]]],
|
||||||
|
$dry->get_error_data()['overlaps']
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = self::ability($store)->execute(self::input($template, ['confirm' => true]));
|
||||||
|
self::assertTrue($result['ok']);
|
||||||
|
self::assertTrue($result['live']);
|
||||||
|
self::assertStringContainsString('wirkt sofort', $result['summary']);
|
||||||
|
self::assertSame(1, $calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling Q3/B3: an omitted exclude_from stays as it is; an explicit [] clears it – shown in the dry run. */
|
||||||
|
public function testOmittedExcludeFromStaysAndAnEmptyListClearsIt(): void
|
||||||
|
{
|
||||||
|
$template = ThemeBuilderFixtures::template(1066, ['exclude_from' => ['singular:post_type:page:id:1054']]);
|
||||||
|
$store = ThemeBuilderFixtures::store([$template]);
|
||||||
|
$written = [];
|
||||||
|
$store->shouldReceive('setConditions')->andReturnUsing(
|
||||||
|
static function (int $id, array $useOn, array $excludeFrom) use (&$written): void {
|
||||||
|
$written[] = $excludeFrom;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
self::ability($store)->execute(self::input($template));
|
||||||
|
self::ability($store)->execute(self::input($template, ['exclude_from' => []]));
|
||||||
|
self::ability($store)->execute(self::input($template, ['exclude_from' => ['homepage']]));
|
||||||
|
|
||||||
|
self::assertSame([['singular:post_type:page:id:1054'], [], ['homepage']], $written);
|
||||||
|
|
||||||
|
$live = ThemeBuilderFixtures::template(1066, ['enabled' => true, 'exclude_from' => ['singular:post_type:page:id:1054']]);
|
||||||
|
$dry = self::ability(ThemeBuilderFixtures::store([$live]))->execute(self::input($live, ['exclude_from' => []]));
|
||||||
|
self::assertToolError($dry, 'confirm_required');
|
||||||
|
self::assertSame(['Bestimmte Seiten: 1054'], $dry->get_error_data()['before']['exclude_from']);
|
||||||
|
self::assertSame([], $dry->get_error_data()['after']['exclude_from']);
|
||||||
|
self::assertStringContainsString('Ausnahmen', self::clientError($dry)['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling Q8/B9: other live templates of the same family are named as possible overlaps. */
|
||||||
|
public function testDryRunNamesPossibleOverlaps(): void
|
||||||
|
{
|
||||||
|
$template = ThemeBuilderFixtures::template(1066, ['enabled' => true]);
|
||||||
|
$pages = ThemeBuilderFixtures::template(1070, [
|
||||||
|
'enabled' => true,
|
||||||
|
'title' => 'Alle Seiten',
|
||||||
|
'use_on' => ['singular:post_type:page:all'],
|
||||||
|
]);
|
||||||
|
$store = ThemeBuilderFixtures::store([$template, $pages]);
|
||||||
|
|
||||||
|
$dry = self::ability($store)->execute(self::input($template, ['use_on' => ['homepage']]));
|
||||||
|
|
||||||
|
self::assertSame([], $dry->get_error_data()['overlaps']);
|
||||||
|
self::assertSame(
|
||||||
|
[['id' => 1070, 'title' => 'Alle Seiten', 'conditions' => ['singular:post_type:page:all']]],
|
||||||
|
$dry->get_error_data()['possible_overlaps']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReattachKeepsTheTemplateDisabledAndUnmarksItsLayouts(): void
|
||||||
|
{
|
||||||
|
$detached = ThemeBuilderFixtures::template(1066, [
|
||||||
|
'enabled' => true,
|
||||||
|
'attached' => false,
|
||||||
|
'marked_unused' => '2026-09-20 08:00:00',
|
||||||
|
]);
|
||||||
|
$store = ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1065)], 0, [$detached]);
|
||||||
|
|
||||||
|
$refused = self::ability($store)->execute(self::input($detached));
|
||||||
|
self::assertToolError($refused, 'validation_failed');
|
||||||
|
self::assertArrayHasKey('reattach', self::errorFields($refused));
|
||||||
|
self::assertStringContainsString('2026-09-20', self::clientError($refused)['message']);
|
||||||
|
|
||||||
|
$store->shouldReceive('setEnabled')->once()->with(1066, false);
|
||||||
|
$store->shouldReceive('attach')->once()->with(1064, 1066);
|
||||||
|
$unmarked = [];
|
||||||
|
$store->shouldReceive('unmarkUnused')->andReturnUsing(static function (int $id) use (&$unmarked): void {
|
||||||
|
$unmarked[] = $id;
|
||||||
|
});
|
||||||
|
$store->shouldReceive('setConditions')->once()->with(1066, [self::PERSONS], []);
|
||||||
|
|
||||||
|
$log = DiviWriterFixture::log();
|
||||||
|
$result = self::ability($store, $log)->execute(self::input($detached, ['reattach' => true]));
|
||||||
|
|
||||||
|
self::assertTrue($result['ok']);
|
||||||
|
self::assertSame([1066, 1061, 1062, 1063], $unmarked);
|
||||||
|
self::assertStringContainsString('wieder zugeordnet', $log->summaries()[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling Q8/B10: a layout Divi trashed meanwhile blocks the reattach; nothing is written. */
|
||||||
|
public function testReattachNeedsPublishedLayouts(): void
|
||||||
|
{
|
||||||
|
self::givenPosts(['post_status' => 'trash']);
|
||||||
|
$detached = ThemeBuilderFixtures::template(1066, ['attached' => false]);
|
||||||
|
$store = ThemeBuilderFixtures::store([], 0, [$detached]);
|
||||||
|
foreach (['setEnabled', 'attach', 'unmarkUnused', 'setConditions'] as $method) {
|
||||||
|
$store->shouldReceive($method)->never();
|
||||||
|
}
|
||||||
|
|
||||||
|
$refused = self::ability($store, FakeChangeLog::forbidden())->execute(self::input($detached, ['reattach' => true]));
|
||||||
|
|
||||||
|
self::assertToolError($refused, 'validation_failed');
|
||||||
|
self::assertSame(['layouts.body'], array_keys(self::errorFields($refused)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFailedReattachDetachesAndRestoresTheFlag(): void
|
||||||
|
{
|
||||||
|
$detached = ThemeBuilderFixtures::template(1066, ['enabled' => true, 'attached' => false]);
|
||||||
|
$store = ThemeBuilderFixtures::store([], 0, [$detached]);
|
||||||
|
$steps = [];
|
||||||
|
$store->shouldReceive('setEnabled')->andReturnUsing(static function (int $id, bool $on) use (&$steps): void {
|
||||||
|
$steps[] = 'enabled:' . ($on ? '1' : '0');
|
||||||
|
});
|
||||||
|
$store->shouldReceive('attach')->andReturnUsing(static function () use (&$steps): void {
|
||||||
|
$steps[] = 'attach';
|
||||||
|
});
|
||||||
|
$store->shouldReceive('unmarkUnused');
|
||||||
|
$store->shouldReceive('setConditions')->andReturnUsing(static function (int $id, array $useOn) use (&$steps): void {
|
||||||
|
$steps[] = 'conditions:' . implode(',', $useOn);
|
||||||
|
if ($useOn === [self::PERSONS]) {
|
||||||
|
throw new RuntimeException('add_post_meta failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$store->shouldReceive('detach')->once()->with(1064, 1066)->andReturnUsing(static function () use (&$steps): void {
|
||||||
|
$steps[] = 'detach';
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
self::ability($store, FakeChangeLog::forbidden())->execute(self::input($detached, ['reattach' => true]));
|
||||||
|
self::fail('no WriteFailed');
|
||||||
|
} catch (WriteFailed $e) {
|
||||||
|
self::assertTrue($e->rolledBack);
|
||||||
|
}
|
||||||
|
self::assertSame(
|
||||||
|
['enabled:0', 'attach', 'conditions:' . self::PERSONS, 'conditions:singular:post_type:page:id:1053', 'detach', 'enabled:1'],
|
||||||
|
$steps
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFailureRestoresThePreviousConditions(): void
|
||||||
|
{
|
||||||
|
$template = ThemeBuilderFixtures::template(1066);
|
||||||
|
$store = ThemeBuilderFixtures::store([$template]);
|
||||||
|
$calls = [];
|
||||||
|
$store->shouldReceive('setConditions')->andReturnUsing(static function (int $id, array $useOn) use (&$calls): void {
|
||||||
|
$calls[] = $useOn;
|
||||||
|
if (count($calls) === 1) {
|
||||||
|
throw new RuntimeException('add_post_meta failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
self::ability($store, FakeChangeLog::forbidden())->execute(self::input($template));
|
||||||
|
self::fail('no WriteFailed');
|
||||||
|
} catch (WriteFailed $e) {
|
||||||
|
self::assertTrue($e->rolledBack);
|
||||||
|
}
|
||||||
|
self::assertSame([[self::PERSONS], ['singular:post_type:page:id:1053']], $calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFailedRestoreIsAPartialWrite(): void
|
||||||
|
{
|
||||||
|
$template = ThemeBuilderFixtures::template(1066);
|
||||||
|
$store = ThemeBuilderFixtures::store([$template]);
|
||||||
|
$store->shouldReceive('setConditions')->andThrow(new RuntimeException('database gone'));
|
||||||
|
$log = FakeChangeLog::partialsOnly();
|
||||||
|
|
||||||
|
try {
|
||||||
|
self::ability($store, $log)->execute(self::input($template));
|
||||||
|
self::fail('no WriteFailed');
|
||||||
|
} catch (WriteFailed $e) {
|
||||||
|
self::assertFalse($e->rolledBack);
|
||||||
|
}
|
||||||
|
$log->assertPartials(['ability' => 'lit/tb-assign', 'object_type' => 'et_template', 'object_id' => '1066']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testChangeLogKeepsThePreviousConditions(): void
|
||||||
|
{
|
||||||
|
$template = ThemeBuilderFixtures::template(1066, ['exclude_from' => ['homepage']]);
|
||||||
|
$store = ThemeBuilderFixtures::store([$template]);
|
||||||
|
$store->shouldReceive('setConditions');
|
||||||
|
$log = DiviWriterFixture::log();
|
||||||
|
|
||||||
|
$result = self::ability($store, $log)->execute(self::input($template));
|
||||||
|
|
||||||
|
self::assertStringContainsString('singular:post_type:page:id:1053', $log->summaries()[0]);
|
||||||
|
self::assertStringContainsString('homepage', $log->summaries()[0]);
|
||||||
|
self::assertSame(['use_on' => ['singular:post_type:page:id:1053'], 'exclude_from' => ['homepage']], $result['previous']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInvalidConditionsAndMissingUseOnWriteNothing(): void
|
||||||
|
{
|
||||||
|
$template = ThemeBuilderFixtures::template(1066);
|
||||||
|
$store = ThemeBuilderFixtures::store([$template]);
|
||||||
|
$store->shouldReceive('setConditions')->never();
|
||||||
|
|
||||||
|
self::assertToolError(self::ability($store)->execute(self::input($template, ['use_on' => ['erfunden']])), 'validation_failed');
|
||||||
|
self::assertToolError(
|
||||||
|
self::ability($store)->execute(self::input($template, ['exclude_from' => ['erfunden']])),
|
||||||
|
'validation_failed'
|
||||||
|
);
|
||||||
|
$noList = self::input($template);
|
||||||
|
unset($noList['use_on']);
|
||||||
|
self::assertToolError(self::ability($store)->execute($noList), 'validation_failed');
|
||||||
|
self::assertTrue(self::ability($store)->annotations()['destructive']);
|
||||||
|
self::assertFalse(self::ability($store)->isDirect());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPermissionUsesTheSameGate(): void
|
||||||
|
{
|
||||||
|
$template = ThemeBuilderFixtures::template(1066);
|
||||||
|
$store = ThemeBuilderFixtures::store([$template]);
|
||||||
|
|
||||||
|
self::assertTrue(self::ability($store)->permission(self::input($template)));
|
||||||
|
self::assertToolError(self::ability($store)->permission(self::input($template, ['template_rev' => 'x'])), 'rev_mismatch');
|
||||||
|
self::assertToolError(self::ability($store)->permission(self::input($template, ['template_id' => 4711])), 'not_found');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,4 +92,41 @@ final class TemplateTest extends TestCase
|
|||||||
);
|
);
|
||||||
self::assertSame([], Template::overlaps([$other], 1070, ['homepage']));
|
self::assertSame([], Template::overlaps([$other], 1070, ['homepage']));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ruling Q8/B9: conditions of the same family may overlap (Divi then picks one template). */
|
||||||
|
public function testPossibleOverlapsNameOtherLiveTemplatesOfTheSameFamily(): void
|
||||||
|
{
|
||||||
|
$pages = ThemeBuilderFixtures::template(1070, [
|
||||||
|
'enabled' => true,
|
||||||
|
'title' => 'Seiten',
|
||||||
|
'use_on' => ['singular:post_type:page:all', 'search'],
|
||||||
|
]);
|
||||||
|
$archives = ThemeBuilderFixtures::template(1071, ['enabled' => true, 'title' => 'Archive', 'use_on' => ['archive:all']]);
|
||||||
|
$inTerm = ThemeBuilderFixtures::template(1072, [
|
||||||
|
'enabled' => true,
|
||||||
|
'title' => 'Beiträge einer Kategorie',
|
||||||
|
'use_on' => ['singular:taxonomy:category:term:id:5'],
|
||||||
|
]);
|
||||||
|
$disabled = ThemeBuilderFixtures::template(1073, ['use_on' => ['singular:post_type:page:all']]);
|
||||||
|
$live = [$pages, $archives, $inTerm, $disabled];
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
[
|
||||||
|
['id' => 1070, 'title' => 'Seiten', 'conditions' => ['singular:post_type:page:all']],
|
||||||
|
['id' => 1072, 'title' => 'Beiträge einer Kategorie', 'conditions' => ['singular:taxonomy:category:term:id:5']],
|
||||||
|
],
|
||||||
|
Template::possibleOverlaps($live, 1066, ['singular:post_type:page:id:1053'])
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
[['id' => 1071, 'title' => 'Archive', 'conditions' => ['archive:all']]],
|
||||||
|
Template::possibleOverlaps($live, 1066, ['archive:taxonomy:category:term:id:5'])
|
||||||
|
);
|
||||||
|
self::assertSame([], Template::possibleOverlaps($live, 1066, ['search']), 'identical ones are overlaps()');
|
||||||
|
self::assertSame([], Template::possibleOverlaps($live, 1066, ['404']));
|
||||||
|
self::assertSame(
|
||||||
|
['id' => 1070, 'title' => 'Seiten', 'conditions' => ['singular:post_type:page:all']],
|
||||||
|
Template::possibleOverlaps($live, 1066, ['homepage'])[0],
|
||||||
|
'a static front page is a page'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
|
||||||
|
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
|
||||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||||||
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||||||
@@ -166,4 +167,39 @@ final class ThemeBuilderAccessTest extends TestCase
|
|||||||
$refusal = ThemeBuilderFixtures::access()->layoutsRefusal(ThemeBuilderFixtures::template());
|
$refusal = ThemeBuilderFixtures::access()->layoutsRefusal(ThemeBuilderFixtures::template());
|
||||||
self::assertSame(['layouts.body'], array_keys(self::errorFields($refusal)), 'a footer is no body');
|
self::assertSame(['layouts.body'], array_keys(self::errorFields($refusal)), 'a footer is no body');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testChangeableTemplateNeedsRightsNoDraftNoDefaultAndTheCurrentRev(): void
|
||||||
|
{
|
||||||
|
LibraryFixtures::given([ThemeBuilderFixtures::templatePost(1066), ThemeBuilderFixtures::templatePost(1065)]);
|
||||||
|
$store = ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1065), ThemeBuilderFixtures::template(1066)]);
|
||||||
|
$access = ThemeBuilderFixtures::access([], true, $store);
|
||||||
|
$rev = ThemeBuilderFixtures::template(1066)->rev();
|
||||||
|
|
||||||
|
self::assertInstanceOf(Template::class, $access->changeableTemplate(['template_id' => 1066, 'template_rev' => $rev]));
|
||||||
|
self::assertToolError($access->changeableTemplate(['template_id' => 1066, 'template_rev' => 'alt']), 'rev_mismatch');
|
||||||
|
self::assertToolError(
|
||||||
|
$access->changeableTemplate(['template_id' => 1065, 'template_rev' => ThemeBuilderFixtures::template(1065)->rev()]),
|
||||||
|
'unsupported'
|
||||||
|
);
|
||||||
|
$draft = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1066)], 999));
|
||||||
|
self::assertToolError($draft->changeableTemplate(['template_id' => 1066, 'template_rev' => $rev]), 'unsupported');
|
||||||
|
self::assertToolError(
|
||||||
|
ThemeBuilderFixtures::access([], false, $store)->changeableTemplate(['template_id' => 1066, 'template_rev' => $rev]),
|
||||||
|
'forbidden'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling Q18: a template the user may not edit answers like a missing one, also for writing. */
|
||||||
|
public function testChangeableTemplateHidesTemplatesTheUserMayNotEdit(): void
|
||||||
|
{
|
||||||
|
$store = ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1066)]);
|
||||||
|
$rev = ThemeBuilderFixtures::template(1066)->rev();
|
||||||
|
LibraryFixtures::given([ThemeBuilderFixtures::templatePost(1066)], ['edit_post']);
|
||||||
|
$hidden = ThemeBuilderFixtures::access([], true, $store)->changeableTemplate(['template_id' => 1066, 'template_rev' => $rev]);
|
||||||
|
LibraryFixtures::given([]);
|
||||||
|
$missing = ThemeBuilderFixtures::access([], true, $store)->changeableTemplate(['template_id' => 1066, 'template_rev' => $rev]);
|
||||||
|
|
||||||
|
self::assertToolError($hidden, 'not_found');
|
||||||
|
self::assertSame(self::clientError($missing), self::clientError($hidden));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user