lit/revision-restore takes only content types, so a write to a library entry or a Theme Builder layout now answers with restore_url (wp-admin revision.php for its revision_id), also per page in lit/divi-replace-text. The guides and readme no longer present lit/revision-restore as the way back for these types. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
282 lines
13 KiB
PHP
282 lines
13 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Rev;
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviReplaceTextAbility;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||
use LIT\MCP\Integrations\Divi\Pages\DynamicFields;
|
||
use LIT\MCP\Integrations\Divi\Pages\VisibleEdits;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use Throwable;
|
||
use WP_Post;
|
||
|
||
/** The Divi write tools on Theme Builder layouts (E1, Task 5). */
|
||
final class DiviWriterThemeBuilderTest extends TestCase
|
||
{
|
||
/** @var list<string> */
|
||
private array $saved = [];
|
||
|
||
/** @var list<Throwable> */
|
||
private array $failures = [];
|
||
|
||
private WP_Post $body;
|
||
|
||
private WP_Post $header;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$this->body = ThemeBuilderFixtures::layout(ThemeBuilderFixtures::BODY);
|
||
$this->header = ThemeBuilderFixtures::layout(ThemeBuilderFixtures::HEADER);
|
||
LibraryFixtures::given([$this->body, $this->header]);
|
||
Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path);
|
||
Functions\when('wp_revisions_enabled')->justReturn(true);
|
||
}
|
||
|
||
/** @param list<Template> $templates */
|
||
private function writer(array $templates, ?VisibleEdits $visible = null, ?DynamicFields $fields = null): DiviWriter
|
||
{
|
||
$access = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store($templates));
|
||
|
||
return DiviWriterFixture::writer(
|
||
$this->saved,
|
||
$this->failures,
|
||
false,
|
||
ThemeBuilderFixtures::loader($access),
|
||
null,
|
||
new ThemeBuilderEdits($access, $visible ?? new VisibleEdits(), $fields)
|
||
);
|
||
}
|
||
|
||
/** @param array<string, mixed> $extra @return array<string, mixed> */
|
||
private function insert(WP_Post $post, array $extra): array
|
||
{
|
||
return array_merge([
|
||
'post_id' => (int) $post->ID,
|
||
'tree_rev' => Rev::of((string) $post->post_content),
|
||
'parent' => 's0.r0.c0',
|
||
'nodes' => [['type' => 'text', 'content' => 'Zusatz']],
|
||
], $extra);
|
||
}
|
||
|
||
public function testInsertIntoALiveLayoutIsADryRunFirst(): void
|
||
{
|
||
$ability = new DiviInsertAbility($this->writer([ThemeBuilderFixtures::template(1066, ['enabled' => true])]));
|
||
|
||
$dry = $ability->execute($this->insert($this->body, []));
|
||
self::assertToolError($dry, 'confirm_required');
|
||
self::assertSame([], $this->saved);
|
||
|
||
$result = $ability->execute($this->insert($this->body, ['confirm' => true]));
|
||
self::assertTrue($result['ok']);
|
||
self::assertCount(1, $this->saved);
|
||
self::assertSame(1066, $result['templates'][0]['id']);
|
||
// Audit L3: lit/revision-restore does not take Theme Builder layouts – the WordPress revision screen does.
|
||
self::assertSame('https://kunde.test/wp-admin/revision.php?revision=811', $result['restore_url']);
|
||
}
|
||
|
||
public function testLayoutOfADisabledTemplateSavesAtOnce(): void
|
||
{
|
||
$result = (new DiviInsertAbility($this->writer([ThemeBuilderFixtures::template()])))
|
||
->execute($this->insert($this->body, []));
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertSame([], $result['templates']);
|
||
self::assertStringContainsString('Zusatz', $this->saved[0]);
|
||
self::assertSame('https://kunde.test/wp-admin/admin.php?page=et_theme_builder', $result['edit_url'] ?? null);
|
||
}
|
||
|
||
public function testDeleteInALiveLayoutAsksOnceForBoth(): void
|
||
{
|
||
$ability = new DiviDeleteAbility($this->writer([ThemeBuilderFixtures::template(1066, ['enabled' => true])]));
|
||
$input = ['post_id' => 1062, 'tree_rev' => Rev::of((string) $this->body->post_content), 'ids' => ['s0.r0.c0.m1']];
|
||
|
||
$dry = $ability->execute($input);
|
||
self::assertToolError($dry, 'confirm_required');
|
||
self::assertArrayHasKey('templates', $dry->get_error_data());
|
||
|
||
$result = $ability->execute($input + ['confirm' => true]);
|
||
self::assertTrue($result['ok']);
|
||
self::assertCount(1, $this->saved);
|
||
}
|
||
|
||
public function testPostContentOnlyInTheBody(): void
|
||
{
|
||
$ability = new DiviInsertAbility($this->writer([]));
|
||
$postContent = ['nodes' => [['type' => 'post-content']]];
|
||
|
||
self::assertToolError($ability->execute($this->insert($this->header, $postContent)), 'validation_failed');
|
||
self::assertTrue($ability->execute($this->insert($this->body, $postContent))['ok']);
|
||
}
|
||
|
||
/** Ruling Q2 / P14: a failed save of a live layout after its backup names the backup. */
|
||
public function testAFailedSaveAfterTheBackupNamesIt(): void
|
||
{
|
||
Functions\when('wp_revisions_enabled')->justReturn(false);
|
||
$this->failures = [WriteFailed::rolledBack(DiviSaver::REASON, new \RuntimeException('hook'), postId: 1062)];
|
||
$backups = 0;
|
||
$visible = new VisibleEdits(static function (WP_Post $post) use (&$backups): int {
|
||
++$backups;
|
||
|
||
return 1071;
|
||
}, static fn (): bool => true);
|
||
$ability = new DiviInsertAbility(
|
||
$this->writer([ThemeBuilderFixtures::template(1066, ['enabled' => true])], $visible)
|
||
);
|
||
|
||
try {
|
||
$ability->execute($this->insert($this->body, ['confirm' => true]));
|
||
self::fail('WriteFailed expected');
|
||
} catch (WriteFailed $e) {
|
||
$error = $e->toError();
|
||
}
|
||
|
||
self::assertSame(1, $backups);
|
||
self::assertSame(1071, $error->get_error_data()['backup_id']);
|
||
self::assertSame(
|
||
VisibleEditsTest::keptRolledBack(1071, 'des Theme-Builder-Layouts'),
|
||
$error->get_error_data()['hint']
|
||
);
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
/** Without a ThemeBuilderEdits (no Theme Builder wiring) a layout is never written. */
|
||
public function testWithoutThemeBuilderEditsLayoutsAreNotWritten(): void
|
||
{
|
||
$access = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([]));
|
||
$writer = DiviWriterFixture::writer($this->saved, $this->failures, false, ThemeBuilderFixtures::loader($access));
|
||
|
||
self::assertToolError((new DiviInsertAbility($writer))->execute($this->insert($this->body, [])), 'unsupported');
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
/** lit/divi-replace-text never sends confirm: a live layout only answers with its templates. */
|
||
public function testReplaceTextLeavesALiveLayoutAndNamesItsTemplates(): void
|
||
{
|
||
Functions\when('get_post_type_object')->justReturn(
|
||
(object) ['cap' => (object) ['edit_posts' => 'edit_pages', 'edit_others_posts' => 'edit_others_pages']]
|
||
);
|
||
$access = ThemeBuilderFixtures::access(
|
||
[],
|
||
true,
|
||
ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1065, ['enabled' => true])])
|
||
);
|
||
$loader = ThemeBuilderFixtures::loader($access);
|
||
$writer = DiviWriterFixture::writer($this->saved, $this->failures, false, $loader, null, new ThemeBuilderEdits($access));
|
||
$ability = new DiviReplaceTextAbility(DiviFixtures::schema(), $loader, $writer, Mockery::mock(\wpdb::class)->makePartial());
|
||
|
||
$result = $ability->execute([
|
||
'search' => 'Globaler Header der Standard-Vorlage',
|
||
'replace' => 'Kopfzeile',
|
||
'dry_run' => false,
|
||
'confirm' => true,
|
||
'targets' => [['post_id' => 1061, 'tree_rev' => Rev::of((string) $this->header->post_content)]],
|
||
]);
|
||
|
||
self::assertIsArray($result, $result instanceof \WP_Error ? $result->get_error_message() : '');
|
||
$entry = $result['results'][0];
|
||
self::assertFalse($entry['ok']);
|
||
self::assertStringContainsString('Theme-Builder-Layout 1061', $entry['error']);
|
||
self::assertStringContainsString('lit-divi-update', $entry['error']);
|
||
self::assertStringNotContainsString('Globale Bibliothekseinträge', $entry['error']);
|
||
self::assertSame(1065, $entry['templates'][0]['id']);
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
/** Ruling Q22: replace-text on a layout shown only by a template the user may not edit names nothing. */
|
||
public function testReplaceTextOnALayoutOfAHiddenTemplateLeaksNothing(): void
|
||
{
|
||
Functions\when('get_post_type_object')->justReturn(
|
||
(object) ['cap' => (object) ['edit_posts' => 'edit_pages', 'edit_others_posts' => 'edit_others_pages']]
|
||
);
|
||
Functions\when('current_user_can')->alias(static fn (string $cap, mixed $id = null): bool => $id !== 1065);
|
||
$access = ThemeBuilderFixtures::access(
|
||
[],
|
||
true,
|
||
ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1065, ['enabled' => true])])
|
||
);
|
||
$loader = ThemeBuilderFixtures::loader($access);
|
||
$writer = DiviWriterFixture::writer($this->saved, $this->failures, false, $loader, null, new ThemeBuilderEdits($access));
|
||
$ability = new DiviReplaceTextAbility(DiviFixtures::schema(), $loader, $writer, Mockery::mock(\wpdb::class)->makePartial());
|
||
|
||
$result = $ability->execute([
|
||
'search' => 'Globaler Header der Standard-Vorlage',
|
||
'replace' => 'Kopfzeile',
|
||
'dry_run' => false,
|
||
'confirm' => true,
|
||
'targets' => [['post_id' => 1061, 'tree_rev' => Rev::of((string) $this->header->post_content)]],
|
||
]);
|
||
|
||
self::assertIsArray($result, $result instanceof \WP_Error ? $result->get_error_message() : '');
|
||
$entry = $result['results'][0];
|
||
self::assertFalse($entry['ok']);
|
||
self::assertStringContainsString('Theme-Builder-Layout 1061', $entry['error']);
|
||
self::assertSame([], $entry['templates']);
|
||
self::assertSame(1, $entry['hidden_templates']);
|
||
self::assertStringNotContainsString('1065', (string) json_encode($entry));
|
||
self::assertStringNotContainsString('Standard-Website-Template', (string) json_encode($entry));
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
/** Review M-12 (a): the field warnings stay in the answer when the live edit is refused for its backup (P9). */
|
||
public function testFieldWarningsSurviveABackupRefusal(): void
|
||
{
|
||
Functions\when('wp_revisions_enabled')->justReturn(false);
|
||
$field = ['nodes' => [['type' => 'text', 'content' => '{{field:telefon}}']], 'confirm' => true];
|
||
$live = ThemeBuilderFixtures::template(1066, ['enabled' => true, 'use_on' => ['singular:post_type:lit_person:all']]);
|
||
$visible = new VisibleEdits(static fn (WP_Post $post): int => 1071, static fn (): bool => false);
|
||
|
||
$refused = (new DiviInsertAbility($this->writer([$live], $visible, self::unknownFields())))
|
||
->execute($this->insert($this->body, $field));
|
||
|
||
self::assertToolError($refused, 'unsupported');
|
||
self::assertStringContainsString('Papierkorb', self::clientError($refused)['message']);
|
||
self::assertStringContainsString('„telefon“', implode(' ', $refused->get_error_data()['warnings']));
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
private static function unknownFields(): DynamicFields
|
||
{
|
||
$fields = Mockery::mock(DynamicFields::class);
|
||
$fields->allows('reason')->andReturn('kein bekanntes Feld dieses Inhaltstyps');
|
||
$fields->allows('note')->andReturnNull();
|
||
|
||
return $fields;
|
||
}
|
||
|
||
/** E12: an unsuitable field in a body is a warning – saved anyway; the dry run of a live body shows it too. */
|
||
public function testFieldWarningsInTheResultAndInTheDryRun(): void
|
||
{
|
||
$field = ['nodes' => [['type' => 'text', 'content' => '{{field:telefon}}']]];
|
||
$disabled = ThemeBuilderFixtures::template(1066, ['use_on' => ['singular:post_type:lit_person:all']]);
|
||
|
||
$result = (new DiviInsertAbility($this->writer([$disabled], null, self::unknownFields())))
|
||
->execute($this->insert($this->body, $field));
|
||
self::assertTrue($result['ok']);
|
||
self::assertCount(1, $this->saved);
|
||
self::assertStringContainsString('„telefon“', implode(' ', $result['warnings']));
|
||
|
||
$live = ThemeBuilderFixtures::template(1066, ['enabled' => true, 'use_on' => ['singular:post_type:lit_person:all']]);
|
||
$dry = (new DiviInsertAbility($this->writer([$live], null, self::unknownFields())))
|
||
->execute($this->insert($this->body, $field));
|
||
self::assertToolError($dry, 'confirm_required');
|
||
self::assertStringContainsString('„telefon“', implode(' ', $dry->get_error_data()['warnings']));
|
||
self::assertCount(1, $this->saved);
|
||
}
|
||
}
|