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:
@@ -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,17 +157,29 @@ 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) {
|
||||
$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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user