Both forms Divi writes are recognized (ruling Q11): custom_meta_<key> and the dialog form post_meta_key + select_meta_key. Choice fields get a note about the stored value, relation fields name IDs (F4). Only templates the user may edit are checked or named; others are counted (Q21 pattern). Warnings only, also in the dry run of a live body. DynamicFields extracted from lit/divi-dynamic-sources. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
336 lines
15 KiB
PHP
336 lines
15 KiB
PHP
<?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\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([], self::edits([ThemeBuilderFixtures::template(1065)])->showing($page));
|
||
self::assertCount(1, self::edits([ThemeBuilderFixtures::template(1065, ['enabled' => true])])->showing($page));
|
||
}
|
||
|
||
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');
|
||
}
|
||
|
||
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');
|
||
$filtered = $this->recording($live)->prepare($page, ['confirm' => true]);
|
||
self::assertToolError($filtered, 'unsupported');
|
||
self::assertStringContainsString('Theme-Builder-Layout 1062', $filtered->get_error_message());
|
||
self::assertStringContainsString('Theme Builder', (string) 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::assertStringContainsString('Papierkorb', $trash->get_error_message());
|
||
|
||
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]);
|
||
}
|
||
}
|