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>
This commit is contained in:
@@ -78,7 +78,7 @@ final class DiviIntegrationTest extends TestCase
|
||||
self::assertSame('Divi Theme Builder', $group->label());
|
||||
self::assertFalse($group->isAvailable(), 'no Divi classes in unit tests');
|
||||
self::assertSame(
|
||||
['lit/tb-list', 'lit/tb-create-template'],
|
||||
['lit/tb-list', 'lit/tb-create-template', 'lit/tb-assign'],
|
||||
array_map(static fn (Ability $ability): string => $ability->name(), $group->abilities())
|
||||
);
|
||||
foreach ($group->abilities() as $ability) {
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -92,4 +92,41 @@ final class TemplateTest extends TestCase
|
||||
);
|
||||
self::assertSame([], Template::overlaps([$other], 1070, ['homepage']));
|
||||
}
|
||||
|
||||
/** Ruling Q8/B9: conditions of the same family may overlap (Divi then picks one template). */
|
||||
public function testPossibleOverlapsNameOtherLiveTemplatesOfTheSameFamily(): void
|
||||
{
|
||||
$pages = ThemeBuilderFixtures::template(1070, [
|
||||
'enabled' => true,
|
||||
'title' => 'Seiten',
|
||||
'use_on' => ['singular:post_type:page:all', 'search'],
|
||||
]);
|
||||
$archives = ThemeBuilderFixtures::template(1071, ['enabled' => true, 'title' => 'Archive', 'use_on' => ['archive:all']]);
|
||||
$inTerm = ThemeBuilderFixtures::template(1072, [
|
||||
'enabled' => true,
|
||||
'title' => 'Beiträge einer Kategorie',
|
||||
'use_on' => ['singular:taxonomy:category:term:id:5'],
|
||||
]);
|
||||
$disabled = ThemeBuilderFixtures::template(1073, ['use_on' => ['singular:post_type:page:all']]);
|
||||
$live = [$pages, $archives, $inTerm, $disabled];
|
||||
|
||||
self::assertSame(
|
||||
[
|
||||
['id' => 1070, 'title' => 'Seiten', 'conditions' => ['singular:post_type:page:all']],
|
||||
['id' => 1072, 'title' => 'Beiträge einer Kategorie', 'conditions' => ['singular:taxonomy:category:term:id:5']],
|
||||
],
|
||||
Template::possibleOverlaps($live, 1066, ['singular:post_type:page:id:1053'])
|
||||
);
|
||||
self::assertSame(
|
||||
[['id' => 1071, 'title' => 'Archive', 'conditions' => ['archive:all']]],
|
||||
Template::possibleOverlaps($live, 1066, ['archive:taxonomy:category:term:id:5'])
|
||||
);
|
||||
self::assertSame([], Template::possibleOverlaps($live, 1066, ['search']), 'identical ones are overlaps()');
|
||||
self::assertSame([], Template::possibleOverlaps($live, 1066, ['404']));
|
||||
self::assertSame(
|
||||
['id' => 1070, 'title' => 'Seiten', 'conditions' => ['singular:post_type:page:all']],
|
||||
Template::possibleOverlaps($live, 1066, ['homepage'])[0],
|
||||
'a static front page is a page'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
|
||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||||
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||||
@@ -166,4 +167,39 @@ final class ThemeBuilderAccessTest extends TestCase
|
||||
$refusal = ThemeBuilderFixtures::access()->layoutsRefusal(ThemeBuilderFixtures::template());
|
||||
self::assertSame(['layouts.body'], array_keys(self::errorFields($refusal)), 'a footer is no body');
|
||||
}
|
||||
|
||||
public function testChangeableTemplateNeedsRightsNoDraftNoDefaultAndTheCurrentRev(): void
|
||||
{
|
||||
LibraryFixtures::given([ThemeBuilderFixtures::templatePost(1066), ThemeBuilderFixtures::templatePost(1065)]);
|
||||
$store = ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1065), ThemeBuilderFixtures::template(1066)]);
|
||||
$access = ThemeBuilderFixtures::access([], true, $store);
|
||||
$rev = ThemeBuilderFixtures::template(1066)->rev();
|
||||
|
||||
self::assertInstanceOf(Template::class, $access->changeableTemplate(['template_id' => 1066, 'template_rev' => $rev]));
|
||||
self::assertToolError($access->changeableTemplate(['template_id' => 1066, 'template_rev' => 'alt']), 'rev_mismatch');
|
||||
self::assertToolError(
|
||||
$access->changeableTemplate(['template_id' => 1065, 'template_rev' => ThemeBuilderFixtures::template(1065)->rev()]),
|
||||
'unsupported'
|
||||
);
|
||||
$draft = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1066)], 999));
|
||||
self::assertToolError($draft->changeableTemplate(['template_id' => 1066, 'template_rev' => $rev]), 'unsupported');
|
||||
self::assertToolError(
|
||||
ThemeBuilderFixtures::access([], false, $store)->changeableTemplate(['template_id' => 1066, 'template_rev' => $rev]),
|
||||
'forbidden'
|
||||
);
|
||||
}
|
||||
|
||||
/** Ruling Q18: a template the user may not edit answers like a missing one, also for writing. */
|
||||
public function testChangeableTemplateHidesTemplatesTheUserMayNotEdit(): void
|
||||
{
|
||||
$store = ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1066)]);
|
||||
$rev = ThemeBuilderFixtures::template(1066)->rev();
|
||||
LibraryFixtures::given([ThemeBuilderFixtures::templatePost(1066)], ['edit_post']);
|
||||
$hidden = ThemeBuilderFixtures::access([], true, $store)->changeableTemplate(['template_id' => 1066, 'template_rev' => $rev]);
|
||||
LibraryFixtures::given([]);
|
||||
$missing = ThemeBuilderFixtures::access([], true, $store)->changeableTemplate(['template_id' => 1066, 'template_rev' => $rev]);
|
||||
|
||||
self::assertToolError($hidden, 'not_found');
|
||||
self::assertSame(self::clientError($missing), self::clientError($hidden));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user