feat: lit/tb-activate – the explicit switch, always a dry run first (answer F1)

- only _et_enabled; dry run in both directions lists conditions, exclusions,
  layouts, overlaps and possible overlaps (B9); confirm: true switches
- enabling needs conditions and published layouts (B10); a detached
  template needs reattach first; the default template is refused
- the off-switch reaches an attached, enabled template outside publish,
  e.g. in the trash (ruling Q16); result names the fast way back
- every switch in the change log; failed switch rolled back

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 16:33:29 +02:00
co-authored by Claude Opus 5.5
parent 0975b2f69b
commit 64fc439cb2
6 changed files with 598 additions and 4 deletions
@@ -0,0 +1,275 @@
<?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-activate (answer F1): the one place where LIT MCP makes a template visible to visitors or hides it
* again – _et_enabled, nothing else. Always a dry run first (conditions with labels, layouts, identical and
* possible overlaps with other live templates – ruling Q8/B9), then confirm: true; both directions, every switch
* in the change log. Refused for the default template, a template the live Theme Builder does not list (reattach
* first) and, when enabling, one without conditions (it would apply nowhere) or with a layout that is no longer
* published (B10). Switching off is the fast way back and fail-safe: it also reaches a live template that is not
* published, e.g. in the trash (ruling Q16), and never needs the layouts.
*/
final class TbActivateAbility implements Ability
{
public const REASON = 'Die Vorlage konnte nicht umgeschaltet werden.';
public function __construct(
private ThemeBuilderAccess $access,
private TemplateConditions $conditions,
private ChangeLog $log
) {
}
public function name(): string
{
return 'lit/tb-activate';
}
public function label(): string
{
return 'Theme-Builder-Vorlage aktivieren';
}
public function description(): string
{
return 'Aktiviert (enabled: true) oder deaktiviert (enabled: false) eine Vorlage des Divi Theme Builders – die '
. 'einzige Stelle, an der LIT MCP eine Vorlage für Besucher sichtbar macht. Immer zuerst Probelauf '
. '(Bedingungen, Layouts, Überschneidungen mit anderen aktiven Vorlagen), dann nach Rückfrage beim Nutzer '
. 'confirm: true. Deaktivieren ist der schnelle Weg zurück. template_rev aus lit/tb-list ist Pflicht; die '
. 'Standardvorlage nur im Theme Builder.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'template_id' => ['type' => 'integer', 'minimum' => 1],
'template_rev' => ['type' => 'string', 'description' => 'template_rev aus lit/tb-list.'],
'enabled' => ['type' => 'boolean'],
'confirm' => ['type' => 'boolean'],
],
'required' => ['template_id', 'template_rev', 'enabled'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema([
'template_id' => ['type' => 'integer'],
'template_rev' => ['type' => 'string'],
'enabled' => ['type' => 'boolean'],
'live' => ['type' => 'boolean'],
], ['template_id', 'template_rev', 'enabled', '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, ($input['enabled'] ?? null) === false);
return $template instanceof WP_Error ? $template : true;
}
/** @throws WriteFailed */
public function execute(array $input): mixed
{
$enabled = $input['enabled'] ?? null;
$template = $this->access->changeableTemplate($input, $enabled === false);
if ($template instanceof WP_Error) {
return $template;
}
if (! is_bool($enabled)) {
return Errors::invalidInput('enabled fehlt; nichts wurde gespeichert.', ['enabled' => 'true oder false']);
}
if (! $template->attached) {
return Errors::invalidInput(
sprintf('Vorlage %d ist im Theme Builder nicht zugeordnet; nichts wurde gespeichert.', $template->id),
['template_id' => 'zuerst mit lit/tb-assign und reattach: true wieder zuordnen']
);
}
if ($template->enabled === $enabled) {
return $this->result($template, sprintf(
'Keine Änderung – die Vorlage „%s“ ist bereits %s.',
$template->title,
$enabled ? 'aktiv' : 'deaktiviert'
), null);
}
if ($enabled) {
$refusal = $this->activationRefusal($template);
if ($refusal !== null) {
return $refusal;
}
}
if (($input['confirm'] ?? false) !== true) {
return $this->dryRun($template, $enabled);
}
return $this->switch($template, $enabled);
}
/** Enabling needs conditions and published layouts (B10). */
private function activationRefusal(Template $template): ?WP_Error
{
if ($template->useOn === []) {
return Errors::invalidInput(
'Ohne Bedingungen gilt eine Vorlage nirgends; zuerst mit lit/tb-assign Bedingungen setzen. Nichts '
. 'wurde gespeichert.',
['template_id' => 'keine Bedingungen (use_on)']
);
}
return $this->access->layoutsRefusal($template);
}
private function dryRun(Template $template, bool $enabled): WP_Error
{
$labels = array_map($this->conditions->label(...), $template->useOn);
$message = $enabled
? sprintf(
'Aktivieren: Die Vorlage „%s“ gilt danach sofort für Besucher (%s). Überschneidungen mit anderen '
. 'aktiven Vorlagen unter overlaps/possible_overlaps. Nichts wurde gespeichert (Probelauf).',
$template->title,
implode('; ', $labels)
)
: sprintf(
'Deaktivieren: Die Vorlage „%s“%s gilt danach nicht mehr (%s); dort zeigt Divi wieder eine andere '
. 'Vorlage, die Standardvorlage oder das Theme. Nichts wurde gespeichert (Probelauf).',
$template->title,
$template->status === 'publish' ? '' : sprintf(' (im Papierkorb oder Status „%s“)', $template->status),
implode('; ', $labels)
);
$live = $enabled ? $this->access->store()->liveTemplates() : [];
return Errors::confirmRequired($message, [
'use_on' => $labels,
'exclude_from' => array_map($this->conditions->label(...), $template->excludeFrom),
'layouts' => $template->layouts,
'overlaps' => Template::overlaps($live, $template->id, $template->useOn),
'possible_overlaps' => Template::possibleOverlaps($live, $template->id, $template->useOn),
]);
}
/**
* @return array<string, mixed>
* @throws WriteFailed
*/
private function switch(Template $template, bool $enabled): array
{
$store = $this->access->store();
try {
$store->setEnabled($template->id, $enabled);
} catch (Throwable $e) {
$this->undo($template, $e);
}
$warnings = [];
FollowUp::run(
static fn () => $store->clearCaches(),
'theme builder cache',
'Der Cache des Theme Builders konnte nicht geleert werden; die Änderung ist gespeichert, wirkt aber evtl. '
. 'erst nach „Statische CSS-Dateien leeren“ (Divi → Theme-Optionen → Builder).',
$warnings
);
$logId = $this->log->record(
$this->name(),
DiviThemeBuilder::TEMPLATE,
(string) $template->id,
sprintf(
'Theme Builder: Vorlage „%s“ %s (Bedingungen: %s)',
$template->title,
$enabled ? 'aktiviert' : 'deaktiviert',
$template->useOn === [] ? 'keine' : implode(', ', $template->useOn)
)
);
$after = $store->template($template->id, true) ?? $template;
$summary = $enabled
? sprintf(
'Vorlage „%s“ aktiviert – sie gilt jetzt für Besucher. Schneller Weg zurück: lit/tb-activate mit '
. 'enabled: false (template_rev aus dieser Antwort).',
$template->title
)
: sprintf('Vorlage „%s“ deaktiviert – sie gilt nicht mehr für Besucher.', $template->title);
return $this->result($after, $summary, $logId, $enabled, $warnings);
}
/** @throws WriteFailed Always. */
private function undo(Template $template, Throwable $cause): never
{
$store = $this->access->store();
$failures = Rollback::attempt(static fn () => $store->setEnabled($template->id, $template->enabled));
if ($failures === []) {
throw WriteFailed::rolledBack(self::REASON, $cause, 'tb-activate failed: ' . $cause->getMessage());
}
$this->log->recordPartial(
$this->name(),
DiviThemeBuilder::TEMPLATE,
(string) $template->id,
sprintf('Theme Builder: Umschalten von „%s“ nicht zurückgenommen (%s)', $template->title, $failures[0])
);
throw WriteFailed::partial(
self::REASON,
$cause,
'tb-activate rollback: ' . $failures[0],
$template->id,
hint: 'Mit lit/tb-list prüfen, ob die Vorlage aktiv ist.'
);
}
/**
* @param list<string> $warnings
* @return array<string, mixed>
*/
private function result(
Template $template,
string $summary,
?int $logId,
?bool $enabled = null,
array $warnings = []
): array {
return ToolResult::ok($summary)
->with('template_id', $template->id)
->with('template_rev', $template->rev())
->with('enabled', $enabled ?? $template->enabled)
->with('live', $enabled ?? $template->isLive())
->warnings($warnings)
->logId($logId)
->urls(null, (string) admin_url('admin.php?page=et_theme_builder'))
->toArray();
}
}
@@ -157,15 +157,27 @@ class ThemeBuilderAccess
/**
* 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.
* never the default template (E8), the current template_rev. $offSwitch (lit/tb-activate with enabled: false,
* ruling Q16): also a template visitors see although it is not published (e.g. in the trash – Divi renders
* every attached, enabled template, B8), so it can always be switched off; any other unpublished template
* still reads as missing.
*
* @param array<string, mixed> $input template_id, template_rev
*/
public function changeableTemplate(array $input): Template|WP_Error
public function changeableTemplate(array $input, bool $offSwitch = false): Template|WP_Error
{
$post = $this->template($input['template_id'] ?? null, true);
if ($post instanceof WP_Error) {
return $post;
$id = $offSwitch && Errors::codeOf($post) === 'not_found'
? $this->liveUnpublished($input['template_id'] ?? null)
: null;
if ($id === null) {
return $post;
}
$gate = $this->writableGate();
return $gate ?? $this->checkedTemplate($id, $input);
}
return $this->checkedTemplate((int) $post->ID, $input);
@@ -262,6 +274,27 @@ class ThemeBuilderAccess
);
}
/**
* ID of a template that is not published but live (listed by the live Theme Builder post and enabled) and the
* user may edit; null otherwise – the raw lookup of the off-switch (ruling Q16).
*/
private function liveUnpublished(mixed $templateId): ?int
{
$id = PostGuard::id($templateId);
$post = $id === null ? null : get_post($id);
if (
! $post instanceof WP_Post
|| $post->post_type !== DiviThemeBuilder::TEMPLATE
|| (string) $post->post_status === 'publish'
|| ! $this->canEditTemplate((int) $post->ID)
|| ! in_array((int) $post->ID, $this->store->templateIds($this->store->liveId()), true)
) {
return null;
}
return $this->store->template((int) $post->ID, true)?->isLive() === true ? (int) $post->ID : null;
}
/**
* The draft, default and template_rev checks of changeableTemplate() for template $id (rights already checked).
*
@@ -53,6 +53,7 @@ final class ThemeBuilderIntegration implements Integration
new TbListAbility($this->access, $conditions),
new TbCreateTemplateAbility($this->access, $conditions, $this->composition, $this->saver, $this->log),
new TbAssignAbility($this->access, $conditions, $this->log),
new TbActivateAbility($this->access, $conditions, $this->log),
];
}
}
@@ -78,7 +78,7 @@ final class DiviIntegrationTest extends TestCase
self::assertSame('Divi Theme Builder', $group->label());
self::assertFalse($group->isAvailable(), 'no Divi classes in unit tests');
self::assertSame(
['lit/tb-list', 'lit/tb-create-template', 'lit/tb-assign'],
['lit/tb-list', 'lit/tb-create-template', 'lit/tb-assign', 'lit/tb-activate'],
array_map(static fn (Ability $ability): string => $ability->name(), $group->abilities())
);
foreach ($group->abilities() as $ability) {
@@ -0,0 +1,246 @@
<?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\TbActivateAbility;
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 TbActivateAbilityTest extends TestCase
{
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> $templateFields
* @param array<string, mixed> $bodyFields
*/
private static function givenPosts(array $templateFields = [], array $bodyFields = []): void
{
LibraryFixtures::given([
ThemeBuilderFixtures::templatePost(1066, $templateFields),
ThemeBuilderFixtures::templatePost(1065),
ThemeBuilderFixtures::layout(1061),
ThemeBuilderFixtures::layout(1062, $bodyFields),
ThemeBuilderFixtures::layout(1063),
]);
}
private static function ability(DiviThemeBuilder $store, ?FakeChangeLog $log = null): TbActivateAbility
{
return new TbActivateAbility(
ThemeBuilderFixtures::access([], true, $store),
new TemplateConditions($store),
$log ?? DiviWriterFixture::log()
);
}
/** @return array<string, mixed> */
private static function input(Template $template, bool $enabled, bool $confirm = false): array
{
return array_filter([
'template_id' => $template->id,
'template_rev' => $template->rev(),
'enabled' => $enabled,
'confirm' => $confirm ?: null,
], static fn (mixed $value): bool => $value !== null);
}
public function testActivationIsAlwaysADryRunFirst(): void
{
$template = ThemeBuilderFixtures::template(1066);
$other = ThemeBuilderFixtures::template(1070, [
'enabled' => true,
'title' => 'Seite 1053',
'use_on' => ['singular:post_type:page:id:1053'],
]);
$pages = ThemeBuilderFixtures::template(1071, [
'enabled' => true,
'title' => 'Alle Seiten',
'use_on' => ['singular:post_type:page:all'],
]);
$store = ThemeBuilderFixtures::store([$template, $other, $pages]);
$store->shouldReceive('setEnabled')->never();
$dry = self::ability($store, FakeChangeLog::forbidden())->execute(self::input($template, true));
self::assertToolError($dry, 'confirm_required');
self::assertStringContainsString('Aktivieren', self::clientError($dry)['message']);
self::assertSame(['Bestimmte Seiten: 1053'], $dry->get_error_data()['use_on']);
self::assertSame([], $dry->get_error_data()['exclude_from']);
self::assertSame(1070, $dry->get_error_data()['overlaps'][0]['id']);
self::assertSame(1071, $dry->get_error_data()['possible_overlaps'][0]['id']);
self::assertSame(1062, $dry->get_error_data()['layouts']['body']['id']);
}
public function testConfirmedActivationSwitchesOnlyTheFlag(): void
{
$template = ThemeBuilderFixtures::template(1066);
$store = ThemeBuilderFixtures::store([$template]);
$store->shouldReceive('setEnabled')->once()->with(1066, true);
$store->shouldReceive('clearCaches')->once();
$store->shouldReceive('setConditions')->never();
$store->shouldReceive('attach')->never();
$log = DiviWriterFixture::log();
$result = self::ability($store, $log)->execute(self::input($template, true, true));
self::assertTrue($result['ok']);
self::assertTrue($result['enabled']);
self::assertTrue($result['live']);
self::assertStringContainsString('aktiviert', $result['summary']);
self::assertStringContainsString('enabled: false', $result['summary'], 'the way back is named');
self::assertSame(4711, $result['log_id']);
self::assertStringContainsString('aktiviert', $log->summaries()[0]);
}
public function testDeactivationAlsoNeedsConfirm(): void
{
$template = ThemeBuilderFixtures::template(1066, ['enabled' => true]);
$store = ThemeBuilderFixtures::store([$template]);
$store->shouldReceive('setEnabled')->once()->with(1066, false);
$store->shouldReceive('clearCaches')->once();
$dry = self::ability($store)->execute(self::input($template, false));
self::assertToolError($dry, 'confirm_required');
self::assertStringContainsString('Deaktivieren', self::clientError($dry)['message']);
self::assertSame([], $dry->get_error_data()['overlaps']);
$result = self::ability($store)->execute(self::input($template, false, true));
self::assertTrue($result['ok']);
self::assertFalse($result['enabled']);
self::assertFalse($result['live']);
}
/** Ruling Q16: an attached, enabled template in the trash is still rendered – the off-switch reaches it. */
public function testDeactivatesALiveTemplateInTheTrash(): void
{
self::givenPosts(['post_status' => 'trash']);
$trashed = ThemeBuilderFixtures::template(1066, ['enabled' => true, 'status' => 'trash']);
$store = ThemeBuilderFixtures::store([$trashed]);
$store->shouldReceive('setEnabled')->once()->with(1066, false);
$store->shouldReceive('clearCaches')->once();
self::assertTrue(self::ability($store)->permission(self::input($trashed, false)));
self::assertToolError(self::ability($store)->permission(self::input($trashed, true)), 'not_found');
self::assertToolError(self::ability($store)->execute(self::input($trashed, true, true)), 'not_found');
$dry = self::ability($store)->execute(self::input($trashed, false));
self::assertToolError($dry, 'confirm_required');
self::assertStringContainsString('Papierkorb', self::clientError($dry)['message']);
self::assertTrue(self::ability($store)->execute(self::input($trashed, false, true))['ok']);
}
/** Ruling Q8/B10: enabling needs published layouts; switching off never does. */
public function testActivationNeedsPublishedLayoutsDeactivationDoesNot(): void
{
self::givenPosts([], ['post_status' => 'trash']);
$off = ThemeBuilderFixtures::template(1066);
$store = ThemeBuilderFixtures::store([$off]);
$store->shouldReceive('setEnabled')->never();
$refused = self::ability($store, FakeChangeLog::forbidden())->execute(self::input($off, true));
self::assertToolError($refused, 'validation_failed');
self::assertSame(['layouts.body'], array_keys(self::errorFields($refused)));
$on = ThemeBuilderFixtures::template(1066, ['enabled' => true]);
$store = ThemeBuilderFixtures::store([$on]);
$store->shouldReceive('setEnabled')->once()->with(1066, false);
$store->shouldReceive('clearCaches');
self::assertTrue(self::ability($store)->execute(self::input($on, false, true))['ok']);
}
public function testRefusesTemplatesWithoutConditionsOrDetached(): void
{
$empty = ThemeBuilderFixtures::template(1066, ['use_on' => []]);
$store = ThemeBuilderFixtures::store([$empty]);
$store->shouldReceive('setEnabled')->never();
self::assertToolError(self::ability($store)->execute(self::input($empty, true, true)), 'validation_failed');
$detached = ThemeBuilderFixtures::template(1066, ['attached' => false]);
$store = ThemeBuilderFixtures::store([], 0, [$detached]);
$store->shouldReceive('setEnabled')->never();
$refused = self::ability($store)->execute(self::input($detached, true, true));
self::assertToolError($refused, 'validation_failed');
self::assertStringContainsString('reattach', (string) json_encode(self::errorFields($refused)));
}
public function testNoChangeWhenAlreadyInThatState(): void
{
$template = ThemeBuilderFixtures::template(1066);
$store = ThemeBuilderFixtures::store([$template]);
$store->shouldReceive('setEnabled')->never();
$result = self::ability($store, FakeChangeLog::forbidden())->execute(self::input($template, false));
self::assertStringContainsString('Keine Änderung', $result['summary']);
self::assertTrue(self::ability($store)->annotations()['destructive']);
self::assertFalse(self::ability($store)->isDirect());
}
public function testMissingEnabledAndTheDefaultTemplateAreRefused(): void
{
$template = ThemeBuilderFixtures::template(1066);
$store = ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1065), $template]);
$store->shouldReceive('setEnabled')->never();
$input = self::input($template, true, true);
unset($input['enabled']);
self::assertToolError(self::ability($store)->execute($input), 'validation_failed');
self::assertToolError(
self::ability($store)->execute(self::input(ThemeBuilderFixtures::template(1065), true, true)),
'unsupported'
);
}
public function testAFailedSwitchIsRolledBack(): void
{
$template = ThemeBuilderFixtures::template(1066);
$store = ThemeBuilderFixtures::store([$template]);
$calls = [];
$store->shouldReceive('setEnabled')->andReturnUsing(static function (int $id, bool $on) use (&$calls): void {
$calls[] = $on;
if ($on) {
throw new RuntimeException('_et_enabled not stored');
}
});
$store->shouldReceive('clearCaches')->never();
try {
self::ability($store, FakeChangeLog::forbidden())->execute(self::input($template, true, true));
self::fail('no WriteFailed');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
}
self::assertSame([true, false], $calls);
}
public function testAFailedRollbackIsAPartialWrite(): void
{
$template = ThemeBuilderFixtures::template(1066);
$store = ThemeBuilderFixtures::store([$template]);
$store->shouldReceive('setEnabled')->andThrow(new RuntimeException('database gone'));
$log = FakeChangeLog::partialsOnly();
try {
self::ability($store, $log)->execute(self::input($template, true, true));
self::fail('no WriteFailed');
} catch (WriteFailed $e) {
self::assertFalse($e->rolledBack);
}
$log->assertPartials(['ability' => 'lit/tb-activate', 'object_type' => 'et_template', 'object_id' => '1066']);
}
}
@@ -202,4 +202,43 @@ final class ThemeBuilderAccessTest extends TestCase
self::assertToolError($hidden, 'not_found');
self::assertSame(self::clientError($missing), self::clientError($hidden));
}
/**
* Ruling Q16: Divi renders an attached, enabled template whatever its post status (B8) – the off-switch
* (offSwitch: true) reaches it in the trash; everything else about it reads like a missing template.
*/
public function testTheOffSwitchReachesALiveTemplateOutsidePublish(): void
{
$trashed = ThemeBuilderFixtures::template(1066, ['enabled' => true, 'status' => 'trash']);
$input = ['template_id' => 1066, 'template_rev' => $trashed->rev()];
LibraryFixtures::given([ThemeBuilderFixtures::templatePost(1066, ['post_status' => 'trash'])]);
$access = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([$trashed]));
self::assertSame($trashed, $access->changeableTemplate($input, true));
$missing = $access->changeableTemplate($input);
self::assertToolError($missing, 'not_found');
$notLive = [
ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([], 0, [], [$trashed])),
ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([
ThemeBuilderFixtures::template(1066, ['status' => 'trash']),
])),
ThemeBuilderFixtures::access(['theme_builder'], true, ThemeBuilderFixtures::store([$trashed])),
];
foreach ($notLive as $index => $other) {
$refused = $other->changeableTemplate($input, true);
self::assertToolError($refused, 'not_found', (string) $index);
self::assertSame(self::clientError($missing), self::clientError($refused), (string) $index);
}
self::assertToolError(
ThemeBuilderFixtures::access([], false, ThemeBuilderFixtures::store([$trashed]))->changeableTemplate($input, true),
'forbidden'
);
self::assertToolError(
ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([$trashed]))
->changeableTemplate(['template_id' => 1066, 'template_rev' => 'alt'], true),
'rev_mismatch'
);
}
}