Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
413 lines
19 KiB
PHP
413 lines
19 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\Design\ColorBook;
|
||
use LIT\MCP\Integrations\Divi\Design\ColorRestorePlanner;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignChange;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||
use LIT\MCP\Integrations\Divi\Design\DiviDesignRestoreAbility;
|
||
use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter;
|
||
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetRestorePlanner;
|
||
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
|
||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionBook;
|
||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
|
||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsRestorePlanner;
|
||
use LIT\MCP\Integrations\Divi\Design\VariableRestorePlanner;
|
||
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||
use LIT\MCP\Tests\Support\LockTable;
|
||
use LIT\MCP\Tests\Support\UsageFixture;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use Mockery\MockInterface;
|
||
|
||
final class DiviDesignRestoreAbilityTest extends TestCase
|
||
{
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
// DesignLock (final review I-5): the lock is always free here.
|
||
LockTable::install();
|
||
}
|
||
|
||
public function testRestoresWithDryRunAndASnapshotOfTheCurrentState(): void
|
||
{
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
$older = PresetBookTest::stored();
|
||
unset($older['module']['divi/button']['items']['bbbbbbbbbb']);
|
||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||
$writer->allows('presets')->andReturn(PresetBookTest::stored());
|
||
$writer->allows('canUseVisualBuilder')->andReturn(true);
|
||
$writer->expects('savePresets')->with(Mockery::type('array'), true)->andReturn($older);
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->allows('get')->with('presets', 'presets-20261016T100000Z-aaaaaa')->andReturn(['id' => 'presets-20261016T100000Z-aaaaaa', 'data' => $older,
|
||
'created' => 'x', 'user_id' => 1, 'ability' => 'lit/divi-presets-set', 'log_id' => 2, 'size' => 1]);
|
||
$snapshots->expects('save')->with('presets', Mockery::type('array'), 'lit/divi-design-restore')->andReturn('presets-3');
|
||
$snapshots->expects('attachLog');
|
||
$ability = new DiviDesignRestoreAbility(
|
||
new DesignChange($snapshots, new FakeChangeLog(8)),
|
||
$snapshots,
|
||
new PresetRestorePlanner($writer, UsageFixture::none())
|
||
);
|
||
|
||
$dry = self::assertToolError($ability->execute(['snapshot_id' => 'presets-20261016T100000Z-aaaaaa']), 'confirm_required');
|
||
self::assertSame('removed', $dry['diff'][0]['action']);
|
||
|
||
$result = $ability->execute(['snapshot_id' => 'presets-20261016T100000Z-aaaaaa', 'confirm' => true, 'design_rev' => $dry['design_rev']]);
|
||
self::assertSame('presets-3', $result['snapshot_id']);
|
||
}
|
||
|
||
private static function snapshotOf(array $data): array
|
||
{
|
||
return ['id' => 'presets-20261016T100000Z-aaaaaa', 'data' => $data, 'created' => 'x', 'user_id' => 1,
|
||
'ability' => 'lit/divi-presets-set', 'log_id' => 2, 'size' => 1];
|
||
}
|
||
|
||
private static function restoring(array $current, array $snapshot, bool $unfiltered): DiviDesignRestoreAbility
|
||
{
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||
$writer->allows('presets')->andReturn($current);
|
||
$writer->allows('canUseVisualBuilder')->andReturn(true);
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->allows('get')->andReturn(self::snapshotOf($snapshot));
|
||
$snapshots->shouldNotReceive('save');
|
||
|
||
return new DiviDesignRestoreAbility(
|
||
new DesignChange($snapshots, FakeChangeLog::forbidden()),
|
||
$snapshots,
|
||
new PresetRestorePlanner($writer, UsageFixture::none(), static fn (): bool => $unfiltered)
|
||
);
|
||
}
|
||
|
||
/** Final review I-2: markup in the snapshot or in the current inventory is lost for editors – refused. */
|
||
public function testEditorIsRefusedWhenTheSnapshotOrTheInventoryCarriesMarkup(): void
|
||
{
|
||
$plain = PresetBookTest::stored();
|
||
$markup = PresetBookTest::storedWithMarkup();
|
||
$older = $plain;
|
||
unset($older['module']['divi/button']['items']['cccccccccc']);
|
||
$input = ['snapshot_id' => 'presets-20261016T100000Z-aaaaaa'];
|
||
|
||
$inSnapshot = self::assertToolError(self::restoring($plain, $markup, false)->execute($input), 'forbidden');
|
||
self::assertStringContainsString('unfiltered_html', $inSnapshot['message']);
|
||
self::assertToolError(self::restoring($markup, $older, false)->execute($input), 'forbidden');
|
||
|
||
self::assertToolError(self::restoring($plain, $markup, true)->execute($input), 'confirm_required');
|
||
self::assertToolError(self::restoring($plain, $older, false)->execute($input), 'confirm_required');
|
||
}
|
||
|
||
/** T13: restoring needs the preset rights. */
|
||
public function testNeedsEditThemeOptions(): void
|
||
{
|
||
Functions\when('current_user_can')->justReturn(false);
|
||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||
$writer->allows('canUseVisualBuilder')->andReturn(true);
|
||
$writer->shouldNotReceive('savePresets');
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->shouldNotReceive('get');
|
||
$ability = new DiviDesignRestoreAbility(
|
||
new DesignChange($snapshots, FakeChangeLog::forbidden()),
|
||
$snapshots,
|
||
new PresetRestorePlanner($writer, UsageFixture::none())
|
||
);
|
||
$input = ['snapshot_id' => 'presets-20261016T100000Z-aaaaaa', 'confirm' => true, 'design_rev' => 'x'];
|
||
|
||
self::assertToolError($ability->permission($input), 'forbidden');
|
||
self::assertToolError($ability->execute($input), 'forbidden');
|
||
}
|
||
|
||
public function testUnknownSnapshots(): void
|
||
{
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->allows('get')->andReturn(null);
|
||
$ability = self::allAreas($snapshots, self::colorStore([]), self::variableStore([]));
|
||
|
||
self::assertToolError($ability->execute(['snapshot_id' => 'presets-20261016T100000Z-abcdef']), 'not_found');
|
||
self::assertToolError($ability->execute(['snapshot_id' => 'colors-20261016T100000Z-abcdef']), 'not_found');
|
||
self::assertToolError($ability->execute(['snapshot_id' => 'variables-20261016T100000Z-abcdef']), 'not_found');
|
||
self::assertToolError(
|
||
$ability->execute(['snapshot_id' => 'theme_options-20261016T100000Z-abcdef']),
|
||
'not_found'
|
||
);
|
||
self::assertToolError($ability->execute(['snapshot_id' => 'kaputt']), 'validation_failed');
|
||
}
|
||
|
||
/** A snapshot area whose planner is not wired stays unsupported (W11) – never a silent no-op. */
|
||
public function testAnAreaWithoutAPlannerIsUnsupported(): void
|
||
{
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||
$writer->allows('canUseVisualBuilder')->andReturn(true);
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->shouldNotReceive('get');
|
||
$ability = new DiviDesignRestoreAbility(
|
||
new DesignChange($snapshots, FakeChangeLog::forbidden()),
|
||
$snapshots,
|
||
new PresetRestorePlanner($writer, UsageFixture::none())
|
||
);
|
||
|
||
$error = self::assertToolError(
|
||
$ability->execute(['snapshot_id' => ThemeOptionsRestorePlannerTest::SNAPSHOT_ID]),
|
||
'unsupported'
|
||
);
|
||
self::assertStringNotContainsString('4d', $error['hint']);
|
||
}
|
||
|
||
/** The ability with the planners of all four areas; the user has every right unless a store says otherwise. */
|
||
private static function allAreas(
|
||
SnapshotStore $snapshots,
|
||
GlobalColorsAdapter $colors,
|
||
GlobalVariablesAdapter $variables,
|
||
?FakeChangeLog $log = null,
|
||
?ThemeOptionsAdapter $options = null,
|
||
?GlobalUsages $usages = null
|
||
): DiviDesignRestoreAbility {
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||
$writer->allows('canUseVisualBuilder')->andReturn(true);
|
||
$writer->allows('presets')->andReturn(PresetBookTest::stored());
|
||
$writer->shouldNotReceive('savePresets');
|
||
$noOptions = [];
|
||
|
||
return new DiviDesignRestoreAbility(
|
||
new DesignChange($snapshots, $log ?? FakeChangeLog::forbidden()),
|
||
$snapshots,
|
||
new PresetRestorePlanner($writer, $usages ?? UsageFixture::none()),
|
||
new ColorRestorePlanner($writer, $colors, $usages ?? UsageFixture::none()),
|
||
new VariableRestorePlanner($writer, $variables, $usages ?? UsageFixture::none()),
|
||
new ThemeOptionsRestorePlanner($options ?? self::optionStore($noOptions))
|
||
);
|
||
}
|
||
|
||
/**
|
||
* A theme options adapter that reads what it last wrote, starting with $stored (ThemeOptionsAreaTest::adapter()).
|
||
*
|
||
* @param array<string, int|float|string|false> $stored
|
||
*/
|
||
private static function optionStore(array &$stored, bool $customize = true): ThemeOptionsAdapter
|
||
{
|
||
$writes = [];
|
||
|
||
return ThemeOptionsAreaTest::adapter($stored, $writes, null, $customize);
|
||
}
|
||
|
||
/**
|
||
* A colors adapter that reads what it last saved, starting with $stored.
|
||
*
|
||
* @param array<string, array<string, mixed>> $stored
|
||
*/
|
||
private static function colorStore(array $stored): GlobalColorsAdapter&MockInterface
|
||
{
|
||
$colors = Mockery::mock(GlobalColorsAdapter::class);
|
||
$colors->allows('read')->andReturnUsing(static function () use (&$stored): array {
|
||
return $stored;
|
||
});
|
||
$colors->allows('save')->andReturnUsing(static function (array $entries, bool $replace) use (&$stored): void {
|
||
self::assertTrue($replace, 'a restore replaces every color (V4)');
|
||
$stored = $entries;
|
||
});
|
||
|
||
return $colors;
|
||
}
|
||
|
||
/**
|
||
* A variables adapter that reads what it last saved, starting with $stored.
|
||
*
|
||
* @param array<string, array<string, array<string, mixed>>> $stored
|
||
*/
|
||
private static function variableStore(array $stored, bool $manage = true): GlobalVariablesAdapter&MockInterface
|
||
{
|
||
$variables = Mockery::mock(GlobalVariablesAdapter::class);
|
||
$variables->allows('canManage')->andReturn($manage);
|
||
$variables->allows('read')->andReturnUsing(static function () use (&$stored): array {
|
||
return $stored;
|
||
});
|
||
$variables->allows('save')->andReturnUsing(static function (array $data) use (&$stored): void {
|
||
$stored = $data;
|
||
});
|
||
|
||
return $variables;
|
||
}
|
||
|
||
/** @param array<array-key, mixed> $data */
|
||
private static function snapshotIn(string $area, string $id, array $data): array
|
||
{
|
||
return ['id' => $id, 'data' => $data, 'created' => 'x', 'user_id' => 1, 'ability' => 'lit/divi-' . $area . '-set',
|
||
'log_id' => 2, 'size' => 1];
|
||
}
|
||
|
||
/** Audit L4 / ruling P8a: a colors restore touching a color a live page the caller may not edit uses is refused. */
|
||
public function testRestoringAColorUsedOnAnUneditableLivePageIsForbidden(): void
|
||
{
|
||
$id = ColorRestorePlannerTest::SNAPSHOT_ID;
|
||
$colors = self::colorStore(ColorRestorePlannerTest::current());
|
||
$colors->shouldNotReceive('save');
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->allows('get')->with('colors', $id)
|
||
->andReturn(self::snapshotIn('colors', $id, ColorRestorePlannerTest::snapshot()));
|
||
$snapshots->shouldNotReceive('save');
|
||
$ability = self::allAreas(
|
||
$snapshots,
|
||
$colors,
|
||
self::variableStore([]),
|
||
FakeChangeLog::forbidden(),
|
||
null,
|
||
UsageFixture::blocked()
|
||
);
|
||
|
||
$dry = self::assertToolError($ability->execute(['snapshot_id' => $id]), 'confirm_required');
|
||
self::assertSame(7, $dry['affected'][0]['post_id'] ?? null);
|
||
|
||
$error = self::assertToolError(
|
||
$ability->execute(['snapshot_id' => $id, 'confirm' => true, 'design_rev' => $dry['design_rev']]),
|
||
'forbidden'
|
||
);
|
||
self::assertSame([7], $error['blocked_ids']);
|
||
}
|
||
|
||
public function testRestoresColorsWithDryRunAndASnapshotOfTheCurrentColors(): void
|
||
{
|
||
$id = ColorRestorePlannerTest::SNAPSHOT_ID;
|
||
$colors = self::colorStore(ColorRestorePlannerTest::current());
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->allows('get')->with('colors', $id)
|
||
->andReturn(self::snapshotIn('colors', $id, ColorRestorePlannerTest::snapshot()));
|
||
$snapshots->expects('save')
|
||
->with('colors', ColorBook::fromDivi(ColorRestorePlannerTest::current())->data(), 'lit/divi-design-restore')
|
||
->andReturn('colors-5');
|
||
$snapshots->expects('attachLog');
|
||
$ability = self::allAreas($snapshots, $colors, self::variableStore([]), new FakeChangeLog(8));
|
||
|
||
$dry = self::assertToolError($ability->execute(['snapshot_id' => $id]), 'confirm_required');
|
||
self::assertSame(['changed', 'removed'], array_column($dry['diff'], 'action'));
|
||
self::assertStringContainsString('„Akzent“', $dry['warnings'][0]);
|
||
|
||
$result = $ability->execute(['snapshot_id' => $id, 'confirm' => true, 'design_rev' => $dry['design_rev']]);
|
||
self::assertSame('colors-5', $result['snapshot_id']);
|
||
self::assertSame(ColorBook::fromDivi(ColorRestorePlannerTest::snapshot())->data(), $colors->read());
|
||
}
|
||
|
||
public function testRestoresVariablesAndKeepsNewerOnesArchived(): void
|
||
{
|
||
$id = VariableRestorePlannerTest::SNAPSHOT_ID;
|
||
$variables = self::variableStore(VariableRestorePlannerTest::current());
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->allows('get')->with('variables', $id)
|
||
->andReturn(self::snapshotIn('variables', $id, VariableRestorePlannerTest::snapshot()));
|
||
$snapshots->expects('save')
|
||
->with('variables', Mockery::type('array'), 'lit/divi-design-restore')
|
||
->andReturn('variables-5');
|
||
$snapshots->expects('attachLog');
|
||
$ability = self::allAreas($snapshots, self::colorStore([]), $variables, new FakeChangeLog(8));
|
||
|
||
$dry = self::assertToolError($ability->execute(['snapshot_id' => $id]), 'confirm_required');
|
||
self::assertStringContainsString('„Neu“', $dry['warnings'][0]);
|
||
|
||
$result = $ability->execute(['snapshot_id' => $id, 'confirm' => true, 'design_rev' => $dry['design_rev']]);
|
||
self::assertSame('variables-5', $result['snapshot_id']);
|
||
$stored = $variables->read();
|
||
self::assertSame('4px', $stored['numbers']['gvid-radius0001']['value']);
|
||
self::assertSame('archived', $stored['numbers']['gvid-neu0000001']['status']);
|
||
}
|
||
|
||
public function testRestoresThemeOptionsAndResetsTheKeysTheSnapshotLacks(): void
|
||
{
|
||
$id = ThemeOptionsRestorePlannerTest::SNAPSHOT_ID;
|
||
$stored = ThemeOptionsRestorePlannerTest::current();
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->allows('get')->with('theme_options', $id)
|
||
->andReturn(self::snapshotIn('theme_options', $id, ThemeOptionsRestorePlannerTest::snapshot()));
|
||
$snapshots->expects('save')
|
||
->with(
|
||
'theme_options',
|
||
ThemeOptionBook::fromDivi(ThemeOptionsRestorePlannerTest::current())->data(),
|
||
'lit/divi-design-restore'
|
||
)
|
||
->andReturn('theme_options-5');
|
||
$snapshots->expects('attachLog');
|
||
$ability = self::allAreas(
|
||
$snapshots,
|
||
self::colorStore([]),
|
||
self::variableStore([]),
|
||
new FakeChangeLog(8),
|
||
self::optionStore($stored)
|
||
);
|
||
|
||
$dry = self::assertToolError($ability->execute(['snapshot_id' => $id]), 'confirm_required');
|
||
self::assertSame(['added', 'changed', 'reset'], array_column($dry['diff'], 'action'));
|
||
self::assertStringContainsString('Divi-Standard', $dry['warnings'][1]);
|
||
|
||
$result = $ability->execute(['snapshot_id' => $id, 'confirm' => true, 'design_rev' => $dry['design_rev']]);
|
||
self::assertSame('theme_options-5', $result['snapshot_id']);
|
||
self::assertSame(
|
||
ThemeOptionBook::fromDivi(ThemeOptionsRestorePlannerTest::snapshot())->data(),
|
||
ThemeOptionBook::fromDivi($stored)->data()
|
||
);
|
||
}
|
||
|
||
/** Spec §10, V10: the rights are those of the snapshot's area. */
|
||
public function testRightsFollowTheSnapshotArea(): void
|
||
{
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->shouldNotReceive('get');
|
||
$options = [];
|
||
$ability = self::allAreas(
|
||
$snapshots,
|
||
self::colorStore([]),
|
||
self::variableStore([], false),
|
||
null,
|
||
self::optionStore($options, false)
|
||
);
|
||
|
||
self::assertToolError(
|
||
$ability->permission(['snapshot_id' => VariableRestorePlannerTest::SNAPSHOT_ID]),
|
||
'forbidden'
|
||
);
|
||
self::assertToolError(
|
||
$ability->execute(['snapshot_id' => VariableRestorePlannerTest::SNAPSHOT_ID]),
|
||
'forbidden'
|
||
);
|
||
self::assertToolError(
|
||
$ability->permission(['snapshot_id' => ThemeOptionsRestorePlannerTest::SNAPSHOT_ID]),
|
||
'forbidden'
|
||
);
|
||
self::assertTrue($ability->permission(['snapshot_id' => ColorRestorePlannerTest::SNAPSHOT_ID]));
|
||
self::assertTrue($ability->permission(['snapshot_id' => 'presets-20261016T100000Z-aaaaaa']));
|
||
self::assertToolError($ability->permission(['snapshot_id' => 'kaputt']), 'forbidden', 'fail closed');
|
||
}
|
||
|
||
public function testFeaturesFollowTheSnapshotArea(): void
|
||
{
|
||
$ability = self::allAreas(Mockery::mock(SnapshotStore::class), self::colorStore([]), self::variableStore([]));
|
||
|
||
self::assertSame(
|
||
[DiviFeature::DesignRead, DiviFeature::ColorWrite],
|
||
$ability->diviFeatures(['snapshot_id' => ColorRestorePlannerTest::SNAPSHOT_ID])
|
||
);
|
||
self::assertSame(
|
||
[DiviFeature::DesignRead, DiviFeature::VariableWrite],
|
||
$ability->diviFeatures(['snapshot_id' => VariableRestorePlannerTest::SNAPSHOT_ID])
|
||
);
|
||
self::assertSame(
|
||
[DiviFeature::DesignRead, DiviFeature::PresetWrite],
|
||
$ability->diviFeatures(['snapshot_id' => 'presets-20261016T100000Z-aaaaaa'])
|
||
);
|
||
self::assertSame(
|
||
[DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite],
|
||
$ability->diviFeatures(['snapshot_id' => ThemeOptionsRestorePlannerTest::SNAPSHOT_ID])
|
||
);
|
||
self::assertSame(
|
||
[DiviFeature::DesignRead, DiviFeature::PresetWrite, DiviFeature::ColorWrite, DiviFeature::VariableWrite,
|
||
DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite],
|
||
$ability->diviFeatures([])
|
||
);
|
||
}
|
||
}
|