feat: lit/tb-create-template – disabled templates, all or nothing

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 16:09:49 +02:00
co-authored by Claude Opus 5.5
parent 54922a9f7e
commit 42b9b384d5
10 changed files with 1165 additions and 46 deletions
@@ -0,0 +1,386 @@
<?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\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use LIT\MCP\Integrations\Divi\ThemeBuilder\TbCreateTemplateAbility;
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 Mockery;
use RuntimeException;
use Throwable;
use WP_Post;
final class TbCreateTemplateAbilityTest extends TestCase
{
/** @var list<array{int, string, SaveScope}> */
private array $savedNew = [];
protected function setUp(): void
{
parent::setUp();
// The default template's global header 1061 is a published layout the user may edit (ruling Q8).
LibraryFixtures::given([ThemeBuilderFixtures::layout(ThemeBuilderFixtures::HEADER)]);
Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path);
}
/** The template 2100 as Divi would store it for input(). */
private static function stored(array $overrides = []): Template
{
return ThemeBuilderFixtures::template(2100, array_merge([
'title' => 'Person',
'layouts' => [
'header' => ['id' => 1061, 'enabled' => true],
'body' => ['id' => 2101, 'enabled' => true],
'footer' => ['id' => 0, 'enabled' => false],
],
'use_on' => ['singular:post_type:lit_person:all'],
'attached' => false,
], $overrides));
}
private static function store(int $draftId = 0, ?Template $stored = null): DiviThemeBuilder
{
return ThemeBuilderFixtures::store(
[ThemeBuilderFixtures::template(1065), ThemeBuilderFixtures::template(1066)],
$draftId,
[],
[$stored ?? self::stored()]
);
}
/** @param list<string> $deniedRoles */
private function ability(
DiviThemeBuilder $store,
?FakeChangeLog $log = null,
array $deniedRoles = [],
?Throwable $saveFailure = null
): TbCreateTemplateAbility {
$saver = Mockery::mock(DiviSaver::class);
$saver->allows('saveNew')->andReturnUsing(
function (int $id, string $content, SaveScope $scope) use ($saveFailure): array {
if ($saveFailure !== null) {
throw $saveFailure;
}
$this->savedNew[] = [$id, $content, $scope];
return [];
}
);
return new TbCreateTemplateAbility(
ThemeBuilderFixtures::access($deniedRoles, true, $store),
new TemplateConditions($store),
DiviWriterFixture::composition(DiviWriterFixture::designReader()),
$saver,
$log ?? DiviWriterFixture::log()
);
}
/** @return array<string, mixed> */
private static function input(array $extra = []): array
{
return array_merge([
'title' => 'Person',
'header' => ['source' => 'global'],
'body' => [
'source' => 'new',
'nodes' => [[
'type' => 'section',
'label' => 'Person',
'modules' => [
['type' => 'heading', 'text' => '{{post:title}}'],
['type' => 'text', 'content' => '{{field:role}}'],
],
]],
],
'footer' => ['source' => 'hidden'],
'use_on' => ['singular:post_type:lit_person:all'],
], $extra);
}
private static function expectCreation(DiviThemeBuilder $store): void
{
$store->shouldReceive('insertLayout')->once()->with('body', 'Person – Inhalt')->andReturn(2101);
$store->shouldReceive('storeTemplate')->once()->with(1064, [
'title' => 'Person',
'layouts' => [
'header' => ['id' => 1061, 'enabled' => true],
'body' => ['id' => 2101, 'enabled' => true],
'footer' => ['id' => 0, 'enabled' => false],
],
'use_on' => ['singular:post_type:lit_person:all'],
'exclude_from' => [],
])->andReturn(2100);
}
/** @param list<int> $deleted Receives the IDs wp_delete_post() is called with. */
private static function deletions(array &$deleted): void
{
Functions\when('wp_delete_post')->alias(static function (int $id, bool $force) use (&$deleted): WP_Post {
$deleted[] = $id;
return new WP_Post(['ID' => $id]);
});
}
public function testNewTemplateIsDisabledAndAttached(): void
{
$store = self::store();
self::expectCreation($store);
$store->shouldReceive('attach')->once()->with(1064, 2100);
$store->shouldReceive('clearCaches')->once();
$store->shouldReceive('setEnabled')->never();
$log = DiviWriterFixture::log();
$result = $this->ability($store, $log)->execute(self::input());
self::assertTrue($result['ok']);
self::assertSame(2100, $result['template_id']);
self::assertFalse($result['enabled']);
self::assertSame($store->template(2100, true)?->rev(), $result['template_rev']);
self::assertSame(['id' => 2101, 'enabled' => true, 'source' => 'new'], $result['layouts']['body']);
self::assertSame(['id' => 1061, 'enabled' => true, 'source' => 'global'], $result['layouts']['header']);
self::assertSame(['id' => 0, 'enabled' => false, 'source' => 'hidden'], $result['layouts']['footer']);
self::assertCount(1, $this->savedNew);
[$id, $content, $scope] = $this->savedNew[0];
self::assertSame(2101, $id);
self::assertSame(SaveScope::ThemeBuilderLayout, $scope);
self::assertStringStartsWith('<!-- wp:divi/placeholder -->', $content);
self::assertStringContainsString('custom_meta_role', $content);
self::assertContains(TbCreateTemplateAbility::STALE_EDITOR, $result['warnings']);
self::assertStringContainsString('deaktiviert', $result['summary']);
$log->assertRecorded([
'ability' => 'lit/tb-create-template',
'object_type' => 'et_template',
'object_id' => '2100',
]);
self::assertStringContainsString('body 2101', $log->summaries()[0]);
}
public function testNewLayoutsWithoutNodesAndDefaultAreasStoreNoContent(): void
{
$stored = self::stored(['layouts' => [
'header' => ['id' => 2101, 'enabled' => true],
'body' => ['id' => 0, 'enabled' => true],
'footer' => ['id' => 0, 'enabled' => true],
], 'use_on' => []]);
$store = self::store(0, $stored);
$store->shouldReceive('insertLayout')->once()->with('header', 'Eigener Kopf')->andReturn(2101);
$store->shouldReceive('storeTemplate')->once()->andReturn(2100);
$store->shouldReceive('attach')->once()->with(1064, 2100);
$store->shouldReceive('clearCaches')->once();
$result = $this->ability($store)->execute([
'title' => 'Person',
'header' => ['source' => 'new', 'title' => 'Eigener Kopf'],
]);
self::assertTrue($result['ok']);
self::assertSame([], $this->savedNew);
self::assertSame(['id' => 0, 'enabled' => true, 'source' => 'default'], $result['layouts']['body']);
}
/** E7: an unsaved Theme Builder session would later replace the template list. */
public function testRefusesWhileTheThemeBuilderHasADraft(): void
{
$store = self::store(999);
$store->shouldReceive('insertLayout')->never();
$store->shouldReceive('storeTemplate')->never();
$ability = $this->ability($store);
self::assertToolError($ability->permission(self::input()), 'unsupported');
self::assertToolError($ability->execute(self::input()), 'unsupported');
}
public function testInvalidInputCreatesNothing(): void
{
$store = self::store();
$store->shouldReceive('insertLayout')->never();
$result = $this->ability($store)->execute(self::input([
'title' => '<b></b>',
'body' => ['source' => 'global'],
'footer' => ['source' => 'new', 'nodes' => [['type' => 'section', 'modules' => [['type' => 'post-content']]]]],
'use_on' => ['singular:post_type:lit_person:all', 'erfunden'],
]));
self::assertToolError($result, 'validation_failed');
$fields = self::errorFields($result);
self::assertArrayHasKey('title', $fields);
self::assertArrayHasKey('body.source', $fields);
self::assertArrayHasKey('use_on.1', $fields);
self::assertNotSame([], array_filter(array_keys($fields), static fn (string $key): bool => str_starts_with($key, 'footer.nodes')));
}
public function testNodesOnlyWithANewLayout(): void
{
$store = self::store();
$store->shouldReceive('insertLayout')->never();
$result = $this->ability($store)->execute(self::input([
'header' => ['source' => 'hidden', 'nodes' => [['type' => 'section']]],
]));
self::assertToolError($result, 'validation_failed');
self::assertSame('nur mit source "new"', self::errorFields($result)['header.nodes']);
}
/**
* Ruling Q8: a reused ("global") layout must be a published layout of the area the user may edit; missing,
* trashed and not editable read the same.
*/
public function testTheGlobalHeaderMustBeAPublishedEditableLayout(): void
{
$messages = [];
foreach ([[[], []], [[ThemeBuilderFixtures::layout(1061, ['post_status' => 'trash'])], []], [[ThemeBuilderFixtures::layout(1061)], ['edit_post']]] as [$posts, $denied]) {
LibraryFixtures::given($posts, $denied);
$store = self::store();
$store->shouldReceive('insertLayout')->never();
$result = $this->ability($store)->execute(self::input());
self::assertToolError($result, 'validation_failed');
$messages[] = self::errorFields($result)['header.source'] ?? null;
}
self::assertNotNull($messages[0]);
self::assertSame([$messages[0], $messages[0], $messages[0]], $messages);
}
public function testFailedTemplateStoreDeletesTheLayouts(): void
{
$store = self::store();
$store->shouldReceive('insertLayout')->once()->andReturn(2101);
$store->shouldReceive('storeTemplate')->once()->andThrow(new RuntimeException('Divi called wp_die(): '));
$store->shouldReceive('attach')->never();
Functions\expect('wp_delete_post')->once()->with(2101, true)->andReturn(new WP_Post(['ID' => 2101]));
$this->expectException(WriteFailed::class);
$this->ability($store)->execute(self::input());
}
public function testAFailedLayoutSaveDeletesTheLayout(): void
{
$store = self::store();
$store->shouldReceive('insertLayout')->once()->andReturn(2101);
$store->shouldReceive('storeTemplate')->never();
$deleted = [];
self::deletions($deleted);
try {
$this->ability($store, null, [], new RuntimeException('kses window'))->execute(self::input());
self::fail('no WriteFailed');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
}
self::assertSame([2101], $deleted);
}
/** Divi drops layout IDs that are no published layout of the area (theme-builder.php:1486-1496). */
public function testDiviDroppingALayoutIsRolledBack(): void
{
$dropped = self::stored(['layouts' => [
'header' => ['id' => 1061, 'enabled' => true],
'body' => ['id' => 0, 'enabled' => true],
'footer' => ['id' => 0, 'enabled' => false],
]]);
$store = self::store(0, $dropped);
self::expectCreation($store);
$store->shouldReceive('attach')->never();
$deleted = [];
self::deletions($deleted);
try {
$this->ability($store)->execute(self::input());
self::fail('no WriteFailed');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
}
self::assertSame([2100, 2101], $deleted);
}
/** Answer F1 / ruling Q5: a template that reads back enabled is removed again, never left live. */
public function testATemplateStoredEnabledIsRolledBack(): void
{
$store = self::store(0, self::stored(['enabled' => true]));
self::expectCreation($store);
$store->shouldReceive('attach')->never();
$deleted = [];
self::deletions($deleted);
$this->expectException(WriteFailed::class);
try {
$this->ability($store)->execute(self::input());
} finally {
self::assertSame([2100, 2101], $deleted);
}
}
public function testAFailedAttachDetachesAndDeletesEverything(): void
{
$store = self::store();
self::expectCreation($store);
$store->shouldReceive('attach')->once()->andThrow(new RuntimeException('not added'));
$store->shouldReceive('detach')->once()->with(1064, 2100);
$deleted = [];
self::deletions($deleted);
$this->expectException(WriteFailed::class);
try {
$this->ability($store)->execute(self::input());
} finally {
self::assertSame([2100, 2101], $deleted);
}
}
public function testACleanupThatFailsIsAPartialWrite(): void
{
$store = self::store();
$store->shouldReceive('insertLayout')->once()->andReturn(2101);
$store->shouldReceive('storeTemplate')->once()->andThrow(new RuntimeException('store failed'));
Functions\when('wp_delete_post')->justReturn(false);
$log = FakeChangeLog::partialsOnly();
try {
$this->ability($store, $log)->execute(self::input());
self::fail('no WriteFailed');
} catch (WriteFailed $e) {
self::assertFalse($e->rolledBack);
self::assertToolError($e->toError(), 'partial_write');
}
$log->assertPartials(['ability' => 'lit/tb-create-template', 'object_id' => '2101']);
}
public function testNeedsTheThemeBuilderRole(): void
{
$store = self::store();
$store->shouldReceive('insertLayout')->never();
$ability = $this->ability($store, null, ['theme_builder']);
self::assertToolError($ability->permission(self::input()), 'forbidden');
self::assertToolError($ability->execute(self::input()), 'forbidden');
self::assertFalse($ability->isDirect());
self::assertFalse($ability->annotations()['destructive']);
}
public function testNeverOffersToEnable(): void
{
$ability = $this->ability(self::store());
$schema = $ability->inputSchema();
self::assertArrayNotHasKey('enabled', $schema['properties']);
self::assertFalse($schema['additionalProperties']);
self::assertSame(TbCreateTemplateAbility::SOURCES, $schema['properties']['body']['properties']['source']['enum']);
}
}