Files
janniclas.loosenandClaude Opus 5.5 79f2fdbda6 test: fix the P9/P14 backup wording of every VisibleEdits caller (ruling Q17 M3)
VisibleEditsTest asserts the exact refusal messages (no unfiltered_html,
trash disabled) and the keepingBackup() hints for rolled-back and partial
failures with each caller's label and entry. The callers' own tests drive
GlobalEdits, ThemeBuilderEdits (through DiviWriter) and make-global with
revisions off and assert the same sentences.

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

389 lines
18 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\ThemeBuilder;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Pages\DiviChange;
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
use LIT\MCP\Integrations\Divi\Pages\DynamicFields;
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use LIT\MCP\Integrations\Divi\Pages\VisibleEdits;
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
use LIT\MCP\Tests\Unit\Integrations\Divi\Pages\VisibleEditsTest;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class ThemeBuilderEditsTest extends TestCase
{
private int $backups = 0;
private bool $trash = true;
protected function setUp(): void
{
parent::setUp();
Functions\when('wp_revisions_enabled')->justReturn(true);
Functions\when('current_user_can')->justReturn(true);
}
private static function page(int $id, string $area): DiviPage
{
$post = ThemeBuilderFixtures::layout($id);
$document = (new Parser())->parse((string) $post->post_content);
return new DiviPage($post, $document, NodeIndex::build($document), false, $area);
}
/** @param list<Template> $templates */
private static function edits(array $templates): ThemeBuilderEdits
{
return new ThemeBuilderEdits(ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store($templates)));
}
/**
* With a recording backup (ID 1071) and the trash switch of $this->trash.
*
* @param list<Template> $templates
*/
private function recording(array $templates): ThemeBuilderEdits
{
return new ThemeBuilderEdits(
ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store($templates)),
new VisibleEdits(
function (WP_Post $post): int {
++$this->backups;
return 1071;
},
fn (): bool => $this->trash
)
);
}
public function testLiveLayoutNeedsConfirmAndNamesTheTemplates(): void
{
$edits = self::edits([ThemeBuilderFixtures::template(1066, ['enabled' => true])]);
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
$dry = $edits->prepare($page, []);
self::assertToolError($dry, 'confirm_required');
self::assertStringContainsString('sichtbar', self::clientError($dry)['message']);
self::assertSame(
[['id' => 1066, 'title' => '[Fixture] Vorlage Globale Instanzen', 'use_on' => ['singular:post_type:page:id:1053']]],
$dry->get_error_data()['templates']
);
$confirmed = $edits->prepare($page, ['confirm' => true]);
self::assertSame(SaveScope::ThemeBuilderLayout, $confirmed['scope']);
self::assertCount(1, $confirmed['details']['templates']);
}
/** The global header: the default template shows it once it is enabled. */
public function testTheDefaultTemplatesHeaderIsLiveWhenItIsEnabled(): void
{
$page = self::page(ThemeBuilderFixtures::HEADER, 'header');
self::assertSame(['templates' => [], 'hidden' => 0], self::edits([ThemeBuilderFixtures::template(1065)])->showing($page));
self::assertCount(
1,
self::edits([ThemeBuilderFixtures::template(1065, ['enabled' => true])])->showing($page)['templates']
);
}
public function testLayoutsOfDisabledOrDetachedTemplatesSaveAtOnce(): void
{
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
$prepared = self::edits([
ThemeBuilderFixtures::template(1066),
ThemeBuilderFixtures::template(1067, ['enabled' => true, 'attached' => false]),
])->prepare($page, []);
self::assertSame(SaveScope::ThemeBuilderLayout, $prepared['scope']);
self::assertSame(['templates' => []], $prepared['details']);
}
/** Ruling Q8: attached and enabled is live, whatever the template's post status (fail closed). */
public function testATrashedButEnabledTemplateStillNeedsConfirm(): void
{
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
$edits = self::edits([ThemeBuilderFixtures::template(1066, ['enabled' => true, 'status' => 'trash'])]);
self::assertToolError($edits->prepare($page, []), 'confirm_required');
}
/**
* Ruling Q22 (Q21 pattern): a live template the user may not edit is only counted – never its ID, title or
* conditions – but still decides that confirm is needed.
*/
public function testLiveTemplatesTheUserMayNotEditAreOnlyCounted(): void
{
Functions\when('current_user_can')->alias(static fn (string $cap, mixed $id = null): bool => $id !== 1067);
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
$mine = ThemeBuilderFixtures::template(1066, ['enabled' => true]);
$theirs = ThemeBuilderFixtures::template(1067, ['enabled' => true, 'title' => 'Geheim', 'use_on' => ['singular:post_type:page:id:4711']]);
$dry = self::edits([$mine, $theirs])->prepare($page, []);
self::assertToolError($dry, 'confirm_required');
$data = $dry->get_error_data();
self::assertSame([1066], array_column($data['templates'], 'id'));
self::assertSame(1, $data['hidden_templates']);
self::assertStringContainsString('2 aktive Vorlagen, davon 1 ohne Berechtigung', self::clientError($dry)['message']);
$json = (string) json_encode($data) . self::clientError($dry)['message'];
self::assertStringNotContainsString('1067', $json);
self::assertStringNotContainsString('Geheim', $json);
self::assertStringNotContainsString('4711', $json);
}
/** Ruling Q22: a layout shown only by templates the user may not edit still needs confirm (fail closed, F1/Q9). */
public function testALayoutShownOnlyByHiddenTemplatesStillNeedsConfirm(): void
{
Functions\when('current_user_can')->alias(static fn (string $cap, mixed $id = null): bool => $id !== 1067);
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
$edits = self::edits([ThemeBuilderFixtures::template(1067, ['enabled' => true])]);
$dry = $edits->prepare($page, []);
self::assertToolError($dry, 'confirm_required');
self::assertSame([], $dry->get_error_data()['templates']);
self::assertSame(1, $dry->get_error_data()['hidden_templates']);
$confirmed = $edits->prepare($page, ['confirm' => true]);
self::assertSame(SaveScope::ThemeBuilderLayout, $confirmed['scope']);
self::assertSame(['templates' => [], 'hidden_templates' => 1], $confirmed['details']);
$own = Errors::confirmRequired('3 Knoten löschen? Nichts wurde gelöscht.', ['ids' => ['s0']]);
$merged = $edits->notice($page, $own);
self::assertStringContainsString('sichtbar', self::clientError($merged)['message']);
self::assertSame([], $merged->get_error_data()['templates']);
self::assertSame(1, $merged->get_error_data()['hidden_templates']);
}
public function testNoticeExtendsAToolsOwnConfirm(): void
{
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
$own = Errors::confirmRequired('3 Knoten löschen? Nichts wurde gelöscht.', ['ids' => ['s0']]);
$merged = self::edits([ThemeBuilderFixtures::template(1066, ['enabled' => true])])->notice($page, $own);
self::assertToolError($merged, 'confirm_required');
self::assertStringContainsString('3 Knoten löschen?', self::clientError($merged)['message']);
self::assertStringContainsString('sichtbar', self::clientError($merged)['message']);
self::assertSame(['s0'], $merged->get_error_data()['ids']);
self::assertCount(1, $merged->get_error_data()['templates']);
self::assertSame($own, self::edits([])->notice($page, $own));
}
/** Ruling Q2: a confirmed live edit gets a backup when revisions are off, like a global entry. */
public function testWithoutRevisionsAConfirmedLiveEditMakesABackupFirst(): void
{
Functions\when('wp_revisions_enabled')->justReturn(false);
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
$edits = $this->recording([ThemeBuilderFixtures::template(1066, ['enabled' => true])]);
self::assertToolError($edits->prepare($page, []), 'confirm_required');
self::assertSame(0, $this->backups, 'no backup in the dry run');
$confirmed = $edits->prepare($page, ['confirm' => true]);
self::assertSame(1071, $confirmed['details']['backup_id']);
self::assertSame(1, $this->backups);
}
public function testWithRevisionsNoBackupIsMade(): void
{
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
$confirmed = $this->recording([ThemeBuilderFixtures::template(1066, ['enabled' => true])])
->prepare($page, ['confirm' => true]);
self::assertArrayNotHasKey('backup_id', $confirmed['details']);
self::assertSame(0, $this->backups);
}
/** A layout nobody sees needs no backup (like a plain library entry): the revision, if any, is enough. */
public function testALayoutNobodySeesGetsNoBackup(): void
{
Functions\when('wp_revisions_enabled')->justReturn(false);
$prepared = $this->recording([ThemeBuilderFixtures::template(1066)])
->prepare(self::page(ThemeBuilderFixtures::BODY, 'body'), []);
self::assertSame(['templates' => []], $prepared['details']);
self::assertSame(0, $this->backups);
}
/** Ruling P9 via Q2: the backup must be faithful (unfiltered_html) and must survive (trash). */
public function testWithoutRevisionsUnfaithfulOrLostBackupsAreRefused(): void
{
Functions\when('wp_revisions_enabled')->justReturn(false);
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
$live = [ThemeBuilderFixtures::template(1066, ['enabled' => true])];
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'unfiltered_html');
$hint = 'Revisionen aktivieren (WP_POST_REVISIONS) oder das Layout im Divi Theme Builder bearbeiten.';
$filtered = $this->recording($live)->prepare($page, ['confirm' => true]);
self::assertToolError($filtered, 'unsupported');
self::assertSame(sprintf(VisibleEditsTest::UNFAITHFUL, 'Theme-Builder-Layout 1062'), Errors::clientMessage($filtered));
self::assertSame($hint, self::clientError($filtered)['hint']);
Functions\when('current_user_can')->justReturn(true);
$this->trash = false;
$trash = $this->recording($live)->prepare($page, ['confirm' => true]);
self::assertToolError($trash, 'unsupported');
self::assertSame(sprintf(VisibleEditsTest::NO_TRASH, 'Theme-Builder-Layout 1062'), Errors::clientMessage($trash));
self::assertSame($hint, self::clientError($trash)['hint']);
self::assertSame(0, $this->backups);
}
/** @param array<string, mixed> $settings */
private static function textWith(string $option, array $settings = ['before' => '', 'after' => '']): Block
{
return Block::create('divi/text', ['content' => ['innerContent' => ['desktop' => [
'value' => VariableRef::serialize('content', $option, $settings),
]]]]);
}
/** Divi's dialog form of a Pods field (stage3b README finding 12, ruling Q11). */
private static function dialogText(string $key): Block
{
return self::textWith('post_meta_key', [
'before' => '',
'after' => '',
'select_meta_key' => 'custom_meta_' . $key,
'meta_key' => '',
'date_format' => 'default',
'custom_date_format' => '',
'enable_html' => 'off',
]);
}
/** @var list<array{string, string}> */
private array $asked = [];
private function fields(): DynamicFields
{
$fields = Mockery::mock(DynamicFields::class);
$fields->allows('reason')->andReturnUsing(function (string $type, string $key): ?string {
$this->asked[] = [$type, $key];
return match ($key) {
'role', 'level' => null,
'mentor' => 'Beziehungs- oder Dateifeld – Divi zeigt davon nur IDs',
default => 'kein bekanntes Feld dieses Inhaltstyps',
};
});
$fields->allows('note')->andReturnUsing(static fn (string $type, string $key): ?string => $key === 'level'
? 'Auswahlfeld – Divi zeigt den gespeicherten Wert, nicht die Beschriftung'
: null);
return $fields;
}
/** @param list<Template> $templates */
private function checking(array $templates): ThemeBuilderEdits
{
return new ThemeBuilderEdits(
ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store($templates)),
fields: $this->fields()
);
}
/** E12 / answer F4: fields are checked against the post types of the templates that use the body. */
public function testFieldWarningsNameUnsuitableFieldsOfTheTemplatesPostTypes(): void
{
$edits = $this->checking([ThemeBuilderFixtures::template(1066, ['use_on' => ['singular:post_type:lit_person:all']])]);
$change = (new DiviChange('2 Divi-Knoten eingefügt'))
->record(self::textWith('custom_meta_role'), 'inserted')
->record(self::textWith('custom_meta_mentor'), 'inserted');
$warnings = $edits->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $change);
self::assertCount(1, $warnings);
self::assertStringContainsString('„mentor“', $warnings[0]);
self::assertStringContainsString('lit_person: Beziehungs', $warnings[0]);
self::assertSame([], $edits->fieldWarnings(self::page(ThemeBuilderFixtures::HEADER, 'header'), $change));
}
/** Ruling Q11: Divi's dialog form (post_meta_key + select_meta_key) is checked like custom_meta_<key>. */
public function testTheDialogFormIsCheckedToo(): void
{
$edits = $this->checking([ThemeBuilderFixtures::template(1066, ['use_on' => ['singular:post_type:lit_person:all']])]);
$change = (new DiviChange('x'))
->record(self::dialogText('role'), 'changed')
->record(self::dialogText('telefon'), 'changed');
$warnings = $edits->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $change);
self::assertCount(1, $warnings);
self::assertStringContainsString('„telefon“', $warnings[0]);
self::assertSame([['lit_person', 'role'], ['lit_person', 'telefon']], $this->asked);
}
/** Answer F4: a choice field shows its stored value – said once per field, with the post type. */
public function testChoiceFieldsNameTheStoredValue(): void
{
$edits = $this->checking([ThemeBuilderFixtures::template(1066, ['use_on' => ['singular:post_type:lit_person:all']])]);
$change = (new DiviChange('x'))->record(self::dialogText('level'), 'changed');
$warnings = $edits->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $change);
self::assertCount(1, $warnings);
self::assertStringContainsString('„level“', $warnings[0]);
self::assertStringContainsString('lit_person: Auswahlfeld – Divi zeigt den gespeicherten Wert', $warnings[0]);
}
public function testWithoutAPostTypeConditionTheFieldsAreUnchecked(): void
{
$edits = $this->checking([ThemeBuilderFixtures::template(1066, ['use_on' => ['homepage']])]);
$change = (new DiviChange('x'))->record(self::textWith('custom_meta_role'), 'inserted');
$warnings = $edits->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $change);
self::assertCount(1, $warnings);
self::assertStringContainsString('ungeprüft', $warnings[0]);
self::assertSame([], $this->asked);
}
public function testNodesWithoutFieldsOrWithoutAFieldSourceGiveNoWarning(): void
{
$template = ThemeBuilderFixtures::template(1066, ['use_on' => ['singular:post_type:lit_person:all']]);
$plain = (new DiviChange('x'))->record(self::textWith('post_title'), 'inserted');
self::assertSame([], $this->checking([$template])->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $plain));
$fields = (new DiviChange('x'))->record(self::textWith('custom_meta_telefon'), 'inserted');
self::assertSame([], self::edits([$template])->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $fields));
}
/** Ruling Q21 pattern: templates the user may not edit are counted, never their post types. */
public function testTemplatesTheUserMayNotEditLeakNoPostType(): void
{
Functions\when('current_user_can')->alias(static fn (string $cap, mixed $id = null): bool => $id !== 1067);
$mine = ThemeBuilderFixtures::template(1066, ['use_on' => ['singular:post_type:lit_person:all']]);
$theirs = ThemeBuilderFixtures::template(1067, ['use_on' => ['singular:post_type:geheim:all']]);
$change = (new DiviChange('x'))->record(self::textWith('custom_meta_telefon'), 'inserted');
$warnings = $this->checking([$mine, $theirs])->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $change);
self::assertSame([['lit_person', 'telefon']], $this->asked);
self::assertStringNotContainsString('geheim', implode(' ', $warnings));
self::assertStringNotContainsString('1067', implode(' ', $warnings));
self::assertStringContainsString('ohne Berechtigung', implode(' ', $warnings));
$this->asked = [];
$only = $this->checking([$theirs])->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $change);
self::assertSame([], $this->asked);
self::assertCount(1, $only);
self::assertStringContainsString('ungeprüft', $only[0]);
self::assertStringContainsString('ohne Berechtigung', $only[0]);
self::assertStringNotContainsString('geheim', $only[0]);
}
}