Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/ThemeBuilder/TbActivateAbilityTest.php
T
janniclas.loosenandClaude Opus 5.5 a31d4340f9 fix: TB dry runs hide uneditable live templates, off-switch despite draft (rulings Q20, Q21)
- ThemeBuilderAccess::editableLiveTemplates() shared by tb-list and the tb-assign/tb-activate dry runs;
  uneditable live templates only as a count (hidden_overlaps, hidden_live)
- tb-activate enabled:false works while the Theme Builder has a draft, with a warning
- possible_overlaps skips identical conditions; reattach records the previous enabled state;
  exclude_from must be a list; tb-activate live from the read-back

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 20:55:53 +02:00

336 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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()
);
}
/** A store with $template on the live list whose read-back follows one setEnabled() call. */
private static function switchable(Template $template): DiviThemeBuilder
{
$current = $template;
$store = ThemeBuilderFixtures::store([$template]);
$store->shouldReceive('template')->andReturnUsing(static function () use (&$current): Template {
return $current;
});
$store->shouldReceive('setEnabled')->once()->with($template->id, ! $template->enabled)->andReturnUsing(
static function (int $id, bool $enabled) use (&$current, $template): void {
$current = ThemeBuilderFixtures::template($id, [
'enabled' => $enabled,
'use_on' => $template->useOn,
]);
}
);
return $store;
}
/** @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 = self::switchable($template);
$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 = self::switchable($template);
$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']);
}
/** Fix round 1: live is what the read-back shows, not what was asked for. */
public function testLiveComesFromTheReadBack(): void
{
$template = ThemeBuilderFixtures::template(1066);
$store = ThemeBuilderFixtures::store([$template]);
$store->shouldReceive('setEnabled')->once()->with(1066, true);
$store->shouldReceive('clearCaches')->once();
$result = self::ability($store)->execute(self::input($template, true, true));
self::assertTrue($result['enabled']);
self::assertFalse($result['live'], 'the read-back still shows the template disabled');
}
/** Ruling Q21: live templates the user may not edit appear in the dry run only as a number. */
public function testDryRunHidesLiveTemplatesTheUserMayNotEdit(): void
{
Functions\when('current_user_can')->alias(
static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 1071)
);
$template = ThemeBuilderFixtures::template(1066, ['use_on' => ['singular:post_type:page:all']]);
$mine = ThemeBuilderFixtures::template(1070, ['enabled' => true, 'title' => 'Meine', 'use_on' => ['homepage']]);
$secret = ThemeBuilderFixtures::template(1071, [
'enabled' => true,
'title' => 'Geheim',
'use_on' => ['singular:post_type:page:all'],
]);
$store = ThemeBuilderFixtures::store([$template, $mine, $secret]);
$store->shouldReceive('setEnabled')->never();
$dry = self::ability($store, FakeChangeLog::forbidden())->execute(self::input($template, true));
self::assertToolError($dry, 'confirm_required');
self::assertSame([], $dry->get_error_data()['overlaps']);
self::assertSame([1070], array_column($dry->get_error_data()['possible_overlaps'], 'id'));
self::assertSame(1, $dry->get_error_data()['hidden_overlaps']);
self::assertStringContainsString('ohne Berechtigung, die sich überschneiden können: 1', self::clientError($dry)['message']);
$all = (string) json_encode([$dry->get_error_data(), self::clientError($dry)], JSON_UNESCAPED_UNICODE);
self::assertStringNotContainsString('1071', $all);
self::assertStringNotContainsString('Geheim', $all);
}
/** Ruling Q20: switching off works while the Theme Builder has unsaved changes – with a warning. */
public function testTheOffSwitchWorksWithADraftAndWarns(): void
{
$template = ThemeBuilderFixtures::template(1066, ['enabled' => true]);
$store = ThemeBuilderFixtures::store([$template], 999);
$store->shouldReceive('setEnabled')->once()->with(1066, false);
$store->shouldReceive('clearCaches')->once();
self::assertTrue(self::ability($store)->permission(self::input($template, false)));
$dry = self::ability($store)->execute(self::input($template, false));
self::assertToolError($dry, 'confirm_required');
self::assertStringContainsString('ungespeicherte Änderungen', self::clientError($dry)['message']);
$result = self::ability($store)->execute(self::input($template, false, true));
self::assertTrue($result['ok']);
self::assertStringContainsString('ungespeicherte Änderungen', implode(' ', $result['warnings']));
}
/** Ruling Q20: the draft exemption is for switching off only. */
public function testTheOnSwitchIsRefusedWithADraft(): void
{
$template = ThemeBuilderFixtures::template(1066);
$store = ThemeBuilderFixtures::store([$template], 999);
$store->shouldReceive('setEnabled')->never();
self::assertToolError(self::ability($store)->permission(self::input($template, true)), 'unsupported');
self::assertToolError(self::ability($store)->execute(self::input($template, true, true)), 'unsupported');
}
}