Files
janniclas.loosenandClaude Opus 5.5 e64d32b1f1 fix: tb-assign reattaches only templates no Theme Builder post lists (audit L4)
"Detached" now means for lit/tb-assign what lit/tb-list shows as detached:
DiviThemeBuilder::isListedAnywhere() looks for any _et_template row (live,
draft or Theme Builder library post). A template another Theme Builder post
lists is refused with or without reattach; nothing is written.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-27 23:06:35 +02:00

379 lines
18 KiB
PHP
Raw Permalink 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']);
}
/**
* Audit L4: reattach takes only templates no Theme Builder post lists (tb-list's detached) – not one that a
* Theme Builder library item or draft lists; nothing is written.
*/
public function testReattachRefusesATemplateAnotherThemeBuilderPostLists(): void
{
$listedElsewhere = ThemeBuilderFixtures::template(1066, ['attached' => false]);
$store = ThemeBuilderFixtures::store([], 0, [], [$listedElsewhere]);
$store->allows('isListedAnywhere')->with(1066)->andReturn(true);
foreach (['setEnabled', 'attach', 'unmarkUnused', 'setConditions'] as $method) {
$store->shouldReceive($method)->never();
}
foreach ([['reattach' => true], []] as $extra) {
$refused = self::ability($store, FakeChangeLog::forbidden())->execute(self::input($listedElsewhere, $extra));
self::assertToolError($refused, 'validation_failed');
self::assertStringContainsString('keine entfernte Vorlage', self::clientError($refused)['message']);
self::assertArrayHasKey('template_id', self::errorFields($refused));
}
}
/** 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));
}
}