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:
@@ -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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user