Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/ThemeBuilder/TbAssignAbilityTest.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

358 lines
17 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\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]);
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. */
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');
}
/** 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));
}
}