Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Design/DiviThemeOptionsUpdateAbilityTest.php

348 lines
15 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\Design;
use Brain\Monkey\Functions;
use Closure;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Design\DesignChange;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\DiviThemeOptionsUpdateAbility;
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionBook;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionDiff;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsArea;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionSpec;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Support\LockTable;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
final class DiviThemeOptionsUpdateAbilityTest extends TestCase
{
/** @var array<string, int|float|string|false> What the adapter stores (ThemeOptionsAreaTest::adapter()). */
private array $stored = [];
/** @var list<array{0: array<string, int|float|string|false>, 1: list<string>}> */
private array $writes = [];
protected function setUp(): void
{
parent::setUp();
// DesignLock (final review I-5): the lock is always free here.
LockTable::install();
Functions\when('current_user_can')->justReturn(true);
$this->stored = ThemeOptionsAreaTest::stored();
$this->writes = [];
}
/** @param (Closure(string, mixed): mixed)|null $sanitize Divi's sanitizer (default: keeps the value). */
private function ability(
?SnapshotStore $snapshots = null,
?Closure $sanitize = null,
bool $customize = true,
?ThemeOptionsAdapter $options = null
): DiviThemeOptionsUpdateAbility {
return new DiviThemeOptionsUpdateAbility(
$options ?? ThemeOptionsAreaTest::adapter($this->stored, $this->writes, $sanitize, $customize),
FontCheckTest::catalog(),
new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), new FakeChangeLog(3))
);
}
/** A SnapshotStore that expects one snapshot of the stored options and the log ID 3 attached to it. */
private static function snapshots(): SnapshotStore
{
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->expects('save')
->with(
'theme_options',
ThemeOptionBook::fromDivi(ThemeOptionsAreaTest::stored())->data(),
'lit/divi-theme-options-update'
)
->andReturn('theme_options-1');
$snapshots->expects('attachLog')->with('theme_options', 'theme_options-1', 3);
return $snapshots;
}
/**
* Dry run, then confirm with its design_rev.
*
* @param array<string, mixed> $input
* @return array{0: array<string, mixed>, 1: mixed}
*/
private function confirmed(DiviThemeOptionsUpdateAbility $ability, array $input): array
{
$dry = self::assertToolError($ability->execute($input), 'confirm_required');
return [$dry, $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']])];
}
public function testSchemaAndMeta(): void
{
$ability = $this->ability();
$properties = $ability->inputSchema()['properties'];
self::assertSame('lit/divi-theme-options-update', $ability->name());
self::assertFalse($ability->isDirect());
self::assertSame(Annotations::destructive(false), $ability->annotations());
self::assertSame([DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite], $ability->diviFeatures([]));
self::assertSame(ThemeOptionSpec::KEYS, array_keys($properties['set']['properties']));
self::assertFalse($properties['set']['additionalProperties']);
self::assertSame(10, $properties['set']['properties']['body_font_size']['minimum']);
self::assertSame(32, $properties['set']['properties']['body_font_size']['maximum']);
self::assertContains('null', $properties['set']['properties']['heading_font']['type']);
self::assertArrayHasKey('design_rev', $properties);
self::assertSame(['diff', 'design_rev'], array_slice($ability->outputSchema()['required'], -2));
self::assertStringContainsString('confirm: true mit design_rev', $ability->description());
self::assertStringContainsString('lit/divi-colors-set', $ability->description());
}
/** Spec §10, W14: edit_theme_options and Divi's role right "Theme-Anpassung", before anything is read. */
public function testNeedsEditThemeOptionsAndTheCustomizerRight(): void
{
$input = ['set' => ['body_font_size' => 18]];
self::assertToolError($this->ability(customize: false)->execute($input), 'forbidden');
self::assertToolError($this->ability(customize: false)->permission($input), 'forbidden');
Functions\when('current_user_can')->justReturn(false);
self::assertToolError($this->ability()->execute($input), 'forbidden');
self::assertToolError($this->ability()->permission($input), 'forbidden');
self::assertSame([], $this->writes);
}
public function testSetMustBeAnObjectWithAtLeastOneKey(): void
{
foreach ([[], ['set' => []], ['set' => ['body_font']], ['set' => 'body_font']] as $input) {
self::assertToolError($this->ability()->execute($input), 'validation_failed');
}
}
public function testDryRunThenConfirmWritesTheOptions(): void
{
$input = ['set' => ['heading_font' => 'Roboto', 'body_font_size' => '18']];
[$dry, $result] = $this->confirmed($this->ability(self::snapshots()), $input);
self::assertSame(
[['heading_font', 'added'], ['body_font_size', 'changed']],
array_map(static fn (array $row): array => [$row['key'], $row['action']], $dry['diff'])
);
self::assertSame(ThemeOptionBook::fromDivi(ThemeOptionsAreaTest::stored())->rev(), $dry['design_rev']);
self::assertStringContainsString(ThemeOptionDiff::summary($dry['diff']), $dry['message']);
self::assertSame([], $dry['affected'], 'theme options have no page reference (W7)');
self::assertSame(ThemeOptionsArea::SITE_WIDE, $dry['warnings'][0]);
self::assertStringContainsString('Builder', $dry['warnings'][1]);
self::assertSame('theme_options-1', $result['snapshot_id']);
self::assertSame(18, $this->stored['body_font_size'], 'stored as the number Divi keeps');
self::assertSame('Roboto', $this->stored['heading_font']);
self::assertSame(ThemeOptionBook::fromDivi($this->stored)->rev(), $result['design_rev']);
}
public function testAFontNameIsTrimmedBeforeTheCatalogCheck(): void
{
$result = $this->confirmed($this->ability(self::snapshots()), ['set' => ['heading_font' => ' Roboto']])[1];
self::assertSame('theme_options-1', $result['snapshot_id']);
self::assertSame('Roboto', $this->stored['heading_font']);
}
/** Only fonts reach the builder (W7). */
public function testSizesOnlyWarnAboutTheWholeWebsite(): void
{
$dry = self::assertToolError(
$this->ability()->execute(['set' => ['content_width' => 1200]]),
'confirm_required'
);
self::assertSame([ThemeOptionsArea::SITE_WIDE], $dry['warnings']);
}
/** Ruling W3: null resets to Divi's default – the key is deleted, not stored as "none" or false. */
public function testNullResetsToTheDiviDefault(): void
{
[$dry] = $this->confirmed($this->ability(self::snapshots()), ['set' => ['body_font_height' => null]]);
self::assertSame(['reset'], array_column($dry['diff'], 'action'));
self::assertContains('body_font_height', $this->writes[0][1]);
self::assertArrayNotHasKey('body_font_height', $this->stored);
}
public function testNoneAndFalseAreRefusedWithAHint(): void
{
$error = $this->ability()->execute(['set' => ['heading_font' => false, 'body_font' => 'none']]);
self::assertToolError($error, 'validation_failed');
$fields = self::errorFields($error);
self::assertSame(['set.heading_font', 'set.body_font'], array_keys($fields));
self::assertStringContainsString('null', $fields['set.body_font']);
}
public function testAllProblemsAreCollectedAndUnknownKeysRefused(): void
{
$error = $this->ability()->execute(['set' => [
'gutter_width' => 'breit',
'accent_color' => '#ffffff',
'delete' => true,
'body_font_size' => 99,
'body_header_style' => 'bold',
]]);
self::assertToolError($error, 'validation_failed');
$fields = self::errorFields($error);
self::assertSame(
['set.accent_color', 'set.delete', 'set.body_font_size', 'set.body_header_style', 'set.gutter_width'],
array_keys($fields)
);
self::assertSame('unbekanntes Feld', $fields['set.accent_color']);
self::assertStringContainsString('null', $fields['set.delete']);
self::assertSame([], $this->writes);
}
/** Ruling W5: the exact name of a Divi font; unknown names get suggestions before Divi's sanitizer is asked. */
public function testUnknownFontsGetSuggestions(): void
{
$asked = [];
$sanitize = static function (string $key, mixed $value) use (&$asked): mixed {
$asked[] = $key;
return $value;
};
$error = $this->ability(sanitize: $sanitize)->execute(['set' => [
'heading_font' => 'robotto',
'body_font' => 'Xyzzy Grotesk',
'primary_nav_font' => 'Lato',
]]);
self::assertToolError($error, 'validation_failed');
$fields = self::errorFields($error);
self::assertSame(['set.heading_font', 'set.body_font'], array_keys($fields));
self::assertStringContainsString('Roboto', $fields['set.heading_font']);
self::assertStringContainsString('lit/divi-fonts-upload', $fields['set.body_font']);
self::assertSame(['primary_nav_font'], $asked, 'only the known font goes to Divi');
}
/** Divi's sanitizer answers false or changes the value – Divi would store false or round (Global Constraints). */
public function testValuesDiviRejectsOrChangesAreErrors(): void
{
$sanitize = static fn (string $key, mixed $value): mixed => match ($key) {
'body_header_style' => false,
'content_width' => 1000,
default => $value,
};
$error = $this->ability(sanitize: $sanitize)->execute(['set' => [
'body_header_style' => 'italic',
'content_width' => 1200,
'row_padding' => 3,
]]);
self::assertToolError($error, 'validation_failed');
self::assertSame(['set.body_header_style', 'set.content_width'], array_keys(self::errorFields($error)));
}
/** Ruling W4: the value is stored with the type Divi's sanitizer returns. */
public function testTheSanitizedTypeIsStored(): void
{
$sanitize = static fn (string $key, mixed $value): mixed
=> $key === 'body_font_size' ? (string) $value : $value;
$this->confirmed($this->ability(self::snapshots(), $sanitize), ['set' => ['body_font_size' => 20]]);
self::assertSame('20', $this->stored['body_font_size']);
}
/** Ruling W3: a tablet/phone size equal to the size it inherits stays unset and keeps following it. */
public function testATabletSizeEqualToTheInheritedSizeIsNoChange(): void
{
$same = $this->ability()->execute(['set' => ['tablet_body_font_size' => 16]]);
self::assertSame([], $same['diff']);
self::assertStringStartsWith('Keine Änderung', $same['summary']);
$dry = self::assertToolError(
$this->ability()->execute(['set' => ['tablet_body_font_size' => 20, 'body_font_size' => 20]]),
'confirm_required'
);
self::assertSame(['body_font_size'], array_column($dry['diff'], 'key'), 'inherits the new desktop size');
$own = self::assertToolError(
$this->ability()->execute(['set' => ['tablet_body_font_size' => 15]]),
'confirm_required'
);
self::assertSame([['tablet_body_font_size', 'added']], array_map(
static fn (array $row): array => [$row['key'], $row['action']],
$own['diff']
));
self::assertSame([], $this->writes);
}
/** A stored false (Divi's default font) is "not set": null is no change, a font is "added". */
public function testAStoredFalseCountsAsNotSet(): void
{
$this->stored = ['heading_font' => false] + $this->stored;
self::assertSame([], $this->ability()->execute(['set' => ['heading_font' => null]])['diff']);
$dry = self::assertToolError(
$this->ability()->execute(['set' => ['heading_font' => 'Roboto']]),
'confirm_required'
);
self::assertSame([['heading_font', 'added']], array_map(
static fn (array $row): array => [$row['key'], $row['action']],
$dry['diff']
));
}
public function testUnchangedValuesAreNoChange(): void
{
$result = $this->ability()->execute(['set' => ['body_font_size' => 16, 'gutter_width' => null]]);
self::assertSame([], $result['diff']);
self::assertSame([], $this->writes);
}
public function testConfirmNeedsTheDesignRevOfTheDryRun(): void
{
$input = ['set' => ['body_font_size' => 18], 'confirm' => true];
self::assertToolError($this->ability()->execute($input), 'validation_failed');
self::assertToolError($this->ability()->execute($input + ['design_rev' => 'veraltet']), 'rev_mismatch');
self::assertSame([], $this->writes);
}
public function testAFailedWriteIsRolledBackToTheSnapshot(): void
{
$calls = [];
$options = Mockery::mock(ThemeOptionsAdapter::class);
$options->allows('canCustomize')->andReturn(true);
$options->allows('read')->andReturn(ThemeOptionsAreaTest::stored());
$options->allows('sanitize')->andReturnUsing(static fn (string $key, mixed $value): mixed => $value);
$options->allows('write')->andReturnUsing(static function (array $set, array $unset) use (&$calls): array {
$calls[] = $set;
if (count($calls) === 1) {
throw new RuntimeException('Zurücklesen stimmt nicht');
}
return $set;
});
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->expects('save')->andReturn('theme_options-1');
try {
$this->confirmed($this->ability($snapshots, options: $options), ['set' => ['body_font_size' => 18]]);
self::fail('expected WriteFailed');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
self::assertSame('theme_options-1', $e->details['snapshot_id']);
}
self::assertSame(ThemeOptionBook::fromDivi(ThemeOptionsAreaTest::stored())->data(), $calls[1]);
}
}