Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/ThemeBuilder/TbAssignAbilityTest.php
T
janniclas.loosenandClaude Opus 5.5 0975b2f69b feat: lit/tb-assign – conditions with dry run on live templates, reattach
- ThemeBuilderAccess::changeableTemplate(): rights (Q18 not_found), group gate,
  no TB draft, never the default template, current template_rev
- omitted exclude_from stays, [] clears (Q3); live dry run shows use_on and
  exclude_from before/after, overlaps and possible overlaps (B9)
- reattach only with published layouts (B10) and a live TB post; always
  disabled, unmarks template and layouts; failure restores everything

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

310 lines
14 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]);
}
/** 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');
}
}