Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Pages/DiviWriterThemeBuilderTest.php
janniclas.loosenandClaude Opus 5.5 771f3794a0 fix: failed library and layout saves name the revision screen (audit L3)
A failed save of a library entry or Theme Builder layout now carries
restore_url when a revision exists; a partial write's hint points to the
WordPress revision screen and lit-divi-outline instead of
lit/revisions-list and lit/revision-restore, which answer not found for
these types. Pages keep the default hint.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-27 23:25:16 +02:00

299 lines
14 KiB
PHP
Raw Permalink 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\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);
}
/** Audit L3 on the failure path: a failed layout save names the WordPress revision screen. */
public function testAFailedSaveNamesTheRevisionScreen(): void
{
$this->failures = [WriteFailed::rolledBack(DiviSaver::REASON, new \RuntimeException('hook'), postId: 1062, revisionId: 811)];
$ability = new DiviInsertAbility($this->writer([ThemeBuilderFixtures::template()]));
try {
$ability->execute($this->insert($this->body, []));
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
$error = $e->toError();
}
self::assertSame('https://kunde.test/wp-admin/revision.php?revision=811', $error->get_error_data()['restore_url']);
self::assertStringNotContainsString('lit/revision-restore', $error->get_error_data()['hint']);
}
/** 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);
}
}