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>
This commit is contained in:
2026-09-27 20:55:53 +02:00
co-authored by Claude Opus 5.5
parent 64fc439cb2
commit a31d4340f9
11 changed files with 355 additions and 37 deletions
+1 -1
View File
@@ -126,7 +126,7 @@ final class ThemeBuilderFixtures
$store->allows('draftId')->andReturn($draftId);
$store->allows('liveTemplates')->andReturn($templates);
$store->allows('templateIds')->andReturn(array_map(static fn (Template $t): int => $t->id, $templates));
$store->allows('template')->andReturnUsing(static fn (int $id): ?Template => $byId[$id] ?? null);
$store->allows('template')->andReturnUsing(static fn (int $id): ?Template => $byId[$id] ?? null)->byDefault();
$store->allows('detachedIds')->andReturn(array_map(static fn (Template $t): int => $t->id, $detached));
$store->allows('flatConditions')->andReturn(self::conditions());
@@ -50,6 +50,26 @@ final class TbActivateAbilityTest extends TestCase
);
}
/** 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
{
@@ -91,8 +111,7 @@ final class TbActivateAbilityTest extends TestCase
public function testConfirmedActivationSwitchesOnlyTheFlag(): void
{
$template = ThemeBuilderFixtures::template(1066);
$store = ThemeBuilderFixtures::store([$template]);
$store->shouldReceive('setEnabled')->once()->with(1066, true);
$store = self::switchable($template);
$store->shouldReceive('clearCaches')->once();
$store->shouldReceive('setConditions')->never();
$store->shouldReceive('attach')->never();
@@ -112,8 +131,7 @@ final class TbActivateAbilityTest extends TestCase
public function testDeactivationAlsoNeedsConfirm(): void
{
$template = ThemeBuilderFixtures::template(1066, ['enabled' => true]);
$store = ThemeBuilderFixtures::store([$template]);
$store->shouldReceive('setEnabled')->once()->with(1066, false);
$store = self::switchable($template);
$store->shouldReceive('clearCaches')->once();
$dry = self::ability($store)->execute(self::input($template, false));
@@ -243,4 +261,75 @@ final class TbActivateAbilityTest extends TestCase
}
$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');
}
}
@@ -176,6 +176,8 @@ final class TbAssignAbilityTest extends TestCase
self::assertTrue($result['ok']);
self::assertSame([1066, 1061, 1062, 1063], $unmarked);
self::assertStringContainsString('wieder zugeordnet', $log->summaries()[0]);
self::assertStringContainsString('vorher aktiviert', $log->summaries()[0]);
self::assertTrue($result['previous']['enabled']);
}
/** Ruling Q8/B10: a layout Divi trashed meanwhile blocks the reattach; nothing is written. */
@@ -306,4 +308,50 @@ final class TbAssignAbilityTest extends TestCase
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');
}
/** Ruling Q21: live templates the user may not edit appear only as a number in the dry run. */
public function testDryRunHidesLiveTemplatesTheUserMayNotEdit(): void
{
Functions\when('current_user_can')->alias(
static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && in_array($args[0] ?? 0, [1071, 1072], true))
);
$template = ThemeBuilderFixtures::template(1066, ['enabled' => true]);
$mine = ThemeBuilderFixtures::template(1070, ['enabled' => true, 'title' => 'Meine', 'use_on' => [self::PERSONS]]);
$secret = ThemeBuilderFixtures::template(1071, ['enabled' => true, 'title' => 'Geheim', 'use_on' => [self::PERSONS]]);
$unrelated = ThemeBuilderFixtures::template(1072, ['enabled' => true, 'title' => 'Geheim 2', 'use_on' => ['404']]);
$store = ThemeBuilderFixtures::store([$template, $mine, $secret, $unrelated]);
$store->shouldReceive('setConditions')->never();
$dry = self::ability($store)->execute(self::input($template));
self::assertToolError($dry, 'confirm_required');
self::assertSame([1070], array_column($dry->get_error_data()['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: the draft exemption is for the off-switch only – tb-assign stays refused. */
public function testRefusedWhileTheThemeBuilderHasADraft(): void
{
$template = ThemeBuilderFixtures::template(1066, ['enabled' => true]);
$store = ThemeBuilderFixtures::store([$template], 999);
$store->shouldReceive('setConditions')->never();
self::assertToolError(self::ability($store)->execute(self::input($template, ['confirm' => true])), 'unsupported');
}
public function testExcludeFromMustBeAList(): void
{
$template = ThemeBuilderFixtures::template(1066, ['exclude_from' => ['homepage']]);
$store = ThemeBuilderFixtures::store([$template]);
$store->shouldReceive('setConditions')->never();
$refused = self::ability($store)->execute(self::input($template, ['exclude_from' => null]));
self::assertToolError($refused, 'validation_failed');
self::assertArrayHasKey('exclude_from', self::errorFields($refused));
}
}
@@ -144,6 +144,26 @@ final class TbListAbilityTest extends TestCase
);
}
/** Ruling Q21: active templates the user may not edit only as a number, never by ID or title. */
public function testCountsActiveTemplatesTheUserCannotEdit(): void
{
self::denyEditing([1070, 1071]);
$store = ThemeBuilderFixtures::store([
ThemeBuilderFixtures::template(1066),
ThemeBuilderFixtures::template(1070, ['enabled' => true, 'title' => 'Geheim']),
ThemeBuilderFixtures::template(1071, ['title' => 'Geheim aus']),
]);
$result = self::ability($store)->execute([]);
self::assertSame([1066], array_column($result['templates'], 'id'));
self::assertSame(1, $result['hidden_live']);
self::assertStringContainsString('Weitere aktive Vorlagen ohne Berechtigung: 1', implode(' ', $result['notes']));
$encoded = (string) json_encode($result, JSON_UNESCAPED_UNICODE);
self::assertStringNotContainsString('1070', $encoded);
self::assertStringNotContainsString('Geheim', $encoded);
}
/** Ruling Q8/B11: layout titles only for layouts the user may edit. */
public function testLayoutTitlesOnlyForEditableLayouts(): void
{
@@ -129,4 +129,39 @@ final class TemplateTest extends TestCase
'a static front page is a page'
);
}
/** Fix round 1: a condition the template shares exactly is in overlaps() only, never twice. */
public function testPossibleOverlapsSkipIdenticalConditions(): void
{
$other = ThemeBuilderFixtures::template(1070, [
'enabled' => true,
'title' => 'Seiten',
'use_on' => ['homepage', 'singular:post_type:page:all'],
]);
$useOn = ['homepage', 'singular:post_type:page:id:1053'];
self::assertSame(['homepage'], Template::overlaps([$other], 1066, $useOn)[0]['conditions']);
self::assertSame(['singular:post_type:page:all'], Template::possibleOverlaps([$other], 1066, $useOn)[0]['conditions']);
}
/** Ruling Q21: overlapping templates the user may not edit only as a number. */
public function testOverlapDetailsCountHiddenTemplatesOnly(): void
{
$mine = ThemeBuilderFixtures::template(1070, ['enabled' => true, 'title' => 'Meine', 'use_on' => ['search']]);
$hiddenExact = ThemeBuilderFixtures::template(1071, ['enabled' => true, 'use_on' => ['search', 'homepage']]);
$hiddenPossible = ThemeBuilderFixtures::template(1072, ['enabled' => true, 'use_on' => ['singular:post_type:page:all']]);
$hiddenOther = ThemeBuilderFixtures::template(1073, ['enabled' => true, 'use_on' => ['404']]);
$details = Template::overlapDetails(
['templates' => [$mine], 'hidden' => [$hiddenExact, $hiddenPossible, $hiddenOther]],
1066,
['search', 'homepage']
);
self::assertSame([['id' => 1070, 'title' => 'Meine', 'conditions' => ['search']]], $details['overlaps']);
self::assertSame([], $details['possible_overlaps']);
self::assertSame(2, $details['hidden_overlaps']);
self::assertStringContainsString('ohne Berechtigung', Template::hiddenOverlapsNote(2));
self::assertSame('', Template::hiddenOverlapsNote(0));
}
}
@@ -241,4 +241,31 @@ final class ThemeBuilderAccessTest extends TestCase
'rev_mismatch'
);
}
/** Ruling Q21: one split of the live list by canEditTemplate(). */
public function testEditableLiveTemplatesSplitsByTheRightToEdit(): void
{
LibraryFixtures::given([]);
Functions\when('current_user_can')->alias(
static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 1070)
);
$mine = ThemeBuilderFixtures::template(1066);
$other = ThemeBuilderFixtures::template(1070);
$split = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([$mine, $other]))->editableLiveTemplates();
self::assertSame([$mine], $split['templates']);
self::assertSame([$other], $split['hidden']);
}
/** Ruling Q20: the off-switch works while the Theme Builder has unsaved changes; nothing else does. */
public function testTheOffSwitchIgnoresTheThemeBuilderDraft(): void
{
LibraryFixtures::given([ThemeBuilderFixtures::templatePost(1066)]);
$template = ThemeBuilderFixtures::template(1066, ['enabled' => true]);
$access = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([$template], 999));
$input = ['template_id' => 1066, 'template_rev' => $template->rev()];
self::assertSame($template, $access->changeableTemplate($input, true));
self::assertToolError($access->changeableTemplate($input), 'unsupported');
}
}