Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
514 lines
19 KiB
PHP
514 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
|
|
|
use LIT\MCP\Integrations\Divi\Design\DesignArea;
|
|
use LIT\MCP\Integrations\Divi\Design\DesignChange;
|
|
use LIT\MCP\Integrations\Divi\Design\DesignLock;
|
|
use LIT\MCP\Integrations\Divi\Design\DesignPlan;
|
|
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
|
|
use LIT\MCP\Integrations\Divi\Design\SnapshotTooLarge;
|
|
use LIT\MCP\Integrations\Divi\Library\UsageReport;
|
|
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
|
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
|
|
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
|
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
|
|
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
|
|
use LIT\MCP\Abilities\WriteFailed;
|
|
use LIT\MCP\Tests\Support\FakeChangeLog;
|
|
use LIT\MCP\Tests\Support\ReleaseMatrices;
|
|
use LIT\MCP\Tests\Unit\TestCase;
|
|
use Brain\Monkey\Functions;
|
|
use Mockery;
|
|
use RuntimeException;
|
|
|
|
final class DesignChangeTest extends TestCase
|
|
{
|
|
/** @var array<string, mixed> Options as add_option()/delete_option() see them (DesignLock). */
|
|
private array $options = [];
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->options = [];
|
|
Functions\when('add_option')->alias(function (string $name, mixed $value = ''): bool {
|
|
if (array_key_exists($name, $this->options)) {
|
|
return false;
|
|
}
|
|
$this->options[$name] = $value;
|
|
|
|
return true;
|
|
});
|
|
Functions\when('get_option')->alias(fn (string $name, mixed $default = false): mixed => $this->options[$name] ?? $default);
|
|
Functions\when('delete_option')->alias(function (string $name): bool {
|
|
unset($this->options[$name]);
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* A DesignArea over an array; the first $failWrites calls of write()/restore() throw, $differences are the
|
|
* value warnings of differences().
|
|
*/
|
|
private static function area(
|
|
array &$stored,
|
|
int $failWrites = 0,
|
|
bool $verify = true,
|
|
array $differences = []
|
|
): DesignArea {
|
|
return new class ($stored, $failWrites, $verify, $differences) implements DesignArea {
|
|
public array $writes = [];
|
|
|
|
public array $restores = [];
|
|
|
|
public function __construct(
|
|
private array &$stored,
|
|
private int $failWrites,
|
|
private bool $verify,
|
|
private array $differences
|
|
) {
|
|
}
|
|
|
|
public function name(): string
|
|
{
|
|
return 'presets';
|
|
}
|
|
|
|
public function label(): string
|
|
{
|
|
return 'Presets';
|
|
}
|
|
|
|
public function feature(): DiviFeature
|
|
{
|
|
return DiviFeature::PresetWrite;
|
|
}
|
|
|
|
public function raw(): array
|
|
{
|
|
return $this->stored;
|
|
}
|
|
|
|
public function rev(array $raw): string
|
|
{
|
|
return substr(md5((string) json_encode($raw)), 0, 12);
|
|
}
|
|
|
|
public function write(array $raw, bool $mayRemove): array
|
|
{
|
|
$this->writes[] = [$raw, $mayRemove];
|
|
if ($this->failWrites > 0) {
|
|
--$this->failWrites;
|
|
throw new RuntimeException('divi refused');
|
|
}
|
|
$this->stored = $raw;
|
|
|
|
return $raw;
|
|
}
|
|
|
|
public function restore(array $raw): array
|
|
{
|
|
$this->restores[] = $raw;
|
|
if ($this->failWrites > 0) {
|
|
--$this->failWrites;
|
|
throw new RuntimeException('divi refused the restore');
|
|
}
|
|
$this->stored = $raw;
|
|
|
|
return $raw;
|
|
}
|
|
|
|
public function verify(array $expected, array $actual): bool
|
|
{
|
|
return $this->verify;
|
|
}
|
|
|
|
public function differences(array $expected, array $actual): array
|
|
{
|
|
return $this->differences;
|
|
}
|
|
};
|
|
}
|
|
|
|
private static function plan(array $raw, ?UsageReport $usages = null): callable
|
|
{
|
|
return static fn (array $current): DesignPlan => new DesignPlan(
|
|
$raw,
|
|
[['kind' => 'module', 'scope' => 'divi/button', 'id' => 'a', 'name' => 'CTA', 'action' => 'added']],
|
|
'Preset „CTA“ wird angelegt.',
|
|
$usages
|
|
);
|
|
}
|
|
|
|
private static function usages(array $blocked = []): UsageReport
|
|
{
|
|
return new UsageReport(1, [['post_id' => 3, 'title' => 'Start', 'status' => 'publish', 'type' => 'page']],
|
|
0, 1, $blocked, 0, false);
|
|
}
|
|
|
|
public function testDryRunListsDiffAffectedAndRevAndSavesNothing(): void
|
|
{
|
|
$stored = ['v' => 1];
|
|
$area = self::area($stored);
|
|
$snapshots = Mockery::mock(SnapshotStore::class);
|
|
$snapshots->shouldNotReceive('save');
|
|
|
|
$result = (new DesignChange($snapshots, FakeChangeLog::forbidden()))
|
|
->run('lit/divi-presets-set', $area, [], self::plan(['v' => 2], self::usages()));
|
|
|
|
$details = self::assertToolError($result, 'confirm_required');
|
|
self::assertStringContainsString('Preset „CTA“ wird angelegt. Nichts wurde gespeichert (Probelauf).', $result->get_error_message());
|
|
self::assertSame('added', $details['diff'][0]['action']);
|
|
self::assertSame($area->rev(['v' => 1]), $details['design_rev']);
|
|
self::assertSame(3, $details['affected'][0]['post_id']);
|
|
self::assertSame(1, $details['affected_count']);
|
|
self::assertSame([], $area->writes);
|
|
}
|
|
|
|
public function testNothingToDoIsNoDryRun(): void
|
|
{
|
|
$stored = ['v' => 1];
|
|
$empty = static fn (array $current): DesignPlan => new DesignPlan($current, [], 'Nichts.');
|
|
|
|
$result = (new DesignChange(Mockery::mock(SnapshotStore::class), FakeChangeLog::forbidden()))
|
|
->run('lit/divi-presets-set', self::area($stored), [], $empty);
|
|
|
|
self::assertTrue($result['ok']);
|
|
self::assertSame([], $result['diff']);
|
|
self::assertStringStartsWith('Keine Änderung', $result['summary']);
|
|
}
|
|
|
|
public function testStaleDesignRevWritesNothing(): void
|
|
{
|
|
$stored = ['v' => 1];
|
|
$area = self::area($stored);
|
|
$snapshots = Mockery::mock(SnapshotStore::class);
|
|
$snapshots->shouldNotReceive('save');
|
|
|
|
$result = (new DesignChange($snapshots, FakeChangeLog::forbidden()))
|
|
->run('lit/divi-presets-set', $area, ['confirm' => true, 'design_rev' => 'veraltet'], self::plan(['v' => 2]));
|
|
|
|
self::assertToolError($result, 'rev_mismatch');
|
|
self::assertStringContainsString('Presets wurden seit dem Probelauf geändert', $result->get_error_message());
|
|
self::assertSame([], $area->writes);
|
|
}
|
|
|
|
/** Final review M-9: confirm without design_rev is a missing input, not a stale one. */
|
|
public function testConfirmWithoutDesignRevIsInvalidInput(): void
|
|
{
|
|
$stored = ['v' => 1];
|
|
$area = self::area($stored);
|
|
$snapshots = Mockery::mock(SnapshotStore::class);
|
|
$snapshots->shouldNotReceive('save');
|
|
|
|
$result = (new DesignChange($snapshots, FakeChangeLog::forbidden()))
|
|
->run('lit/divi-presets-set', $area, ['confirm' => true], self::plan(['v' => 2]));
|
|
|
|
$details = self::assertToolError($result, 'validation_failed');
|
|
self::assertSame('design_rev fehlt; nichts wurde gespeichert.', $details['message']);
|
|
self::assertSame('aus dem Probelauf übernehmen', $details['fields']['design_rev']);
|
|
self::assertSame([], $area->writes);
|
|
}
|
|
|
|
/** Final review I-5: a confirm while another change of the area runs writes nothing. */
|
|
public function testAHeldLockRefusesTheConfirm(): void
|
|
{
|
|
$this->options['lit_mcp_lock_presets'] = time();
|
|
$stored = ['v' => 1];
|
|
$area = self::area($stored);
|
|
$snapshots = Mockery::mock(SnapshotStore::class);
|
|
$snapshots->shouldNotReceive('save');
|
|
|
|
$result = (new DesignChange($snapshots, FakeChangeLog::forbidden(), new DesignLock()))->run(
|
|
'lit/divi-presets-set',
|
|
$area,
|
|
['confirm' => true, 'design_rev' => $area->rev($stored)],
|
|
self::plan(['v' => 2])
|
|
);
|
|
|
|
self::assertToolError($result, 'conflict');
|
|
self::assertSame(409, $result->get_error_data()['status']);
|
|
self::assertStringContainsString('andere Änderung an den Presets', $result->get_error_message());
|
|
self::assertSame([], $area->writes);
|
|
self::assertArrayHasKey('lit_mcp_lock_presets', $this->options, 'a foreign lock is not released');
|
|
}
|
|
|
|
public function testTheLockIsHeldWhileWritingAndReleasedAfterSuccess(): void
|
|
{
|
|
$stored = ['v' => 1];
|
|
$area = self::area($stored);
|
|
$snapshots = Mockery::mock(SnapshotStore::class);
|
|
$snapshots->expects('save')->andReturnUsing(function (): string {
|
|
self::assertArrayHasKey('lit_mcp_lock_presets', $this->options, 'snapshot under the lock');
|
|
|
|
return 'presets-1';
|
|
});
|
|
$snapshots->expects('attachLog')->andReturnUsing(function (): void {
|
|
self::assertArrayHasKey('lit_mcp_lock_presets', $this->options, 'log under the lock');
|
|
});
|
|
|
|
$result = (new DesignChange($snapshots, new FakeChangeLog(9)))->run(
|
|
'lit/divi-presets-set',
|
|
$area,
|
|
['confirm' => true, 'design_rev' => $area->rev($stored)],
|
|
self::plan(['v' => 2])
|
|
);
|
|
|
|
self::assertTrue($result['ok']);
|
|
self::assertArrayNotHasKey('lit_mcp_lock_presets', $this->options);
|
|
}
|
|
|
|
public function testTheLockIsReleasedAfterAFailedWrite(): void
|
|
{
|
|
$stored = ['v' => 1];
|
|
$area = self::area($stored, 1);
|
|
$snapshots = Mockery::mock(SnapshotStore::class);
|
|
$snapshots->expects('save')->andReturn('presets-1');
|
|
|
|
try {
|
|
(new DesignChange($snapshots, FakeChangeLog::forbidden()))->run(
|
|
'lit/divi-presets-set',
|
|
$area,
|
|
['confirm' => true, 'design_rev' => $area->rev($stored)],
|
|
self::plan(['v' => 2])
|
|
);
|
|
self::fail('expected WriteFailed');
|
|
} catch (WriteFailed) {
|
|
self::assertArrayNotHasKey('lit_mcp_lock_presets', $this->options);
|
|
}
|
|
}
|
|
|
|
/** The design_rev is checked against the inventory read under the lock, not before it. */
|
|
public function testTheRevIsCheckedOnTheInventoryReadUnderTheLock(): void
|
|
{
|
|
$stored = ['v' => 1];
|
|
$area = self::area($stored);
|
|
$rev = $area->rev($stored);
|
|
$snapshots = Mockery::mock(SnapshotStore::class);
|
|
$snapshots->shouldNotReceive('save');
|
|
Functions\when('add_option')->alias(function (string $name, mixed $value = '') use (&$stored): bool {
|
|
$stored = ['v' => 'parallel'];
|
|
$this->options[$name] = $value;
|
|
|
|
return true;
|
|
});
|
|
|
|
$result = (new DesignChange($snapshots, FakeChangeLog::forbidden()))->run(
|
|
'lit/divi-presets-set',
|
|
$area,
|
|
['confirm' => true, 'design_rev' => $rev],
|
|
self::plan(['v' => 2])
|
|
);
|
|
|
|
self::assertToolError($result, 'rev_mismatch');
|
|
self::assertSame([], $area->writes);
|
|
self::assertArrayNotHasKey('lit_mcp_lock_presets', $this->options);
|
|
}
|
|
|
|
public function testBlockedUsagesRefuseTheConfirm(): void
|
|
{
|
|
$stored = ['v' => 1];
|
|
$area = self::area($stored);
|
|
$snapshots = Mockery::mock(SnapshotStore::class);
|
|
$snapshots->shouldNotReceive('save');
|
|
|
|
$result = (new DesignChange($snapshots, FakeChangeLog::forbidden()))->run(
|
|
'lit/divi-presets-set',
|
|
$area,
|
|
['confirm' => true, 'design_rev' => $area->rev($stored)],
|
|
self::plan(['v' => 2], self::usages([3]))
|
|
);
|
|
|
|
$details = self::assertToolError($result, 'forbidden');
|
|
self::assertSame([3], $details['blocked_ids']);
|
|
}
|
|
|
|
public function testConfirmTakesASnapshotWritesAndLogs(): void
|
|
{
|
|
$stored = ['v' => 1];
|
|
$area = self::area($stored);
|
|
$snapshots = Mockery::mock(SnapshotStore::class);
|
|
$snapshots->expects('save')->with('presets', ['v' => 1], 'lit/divi-presets-set')->andReturn('presets-1');
|
|
$snapshots->expects('attachLog')->with('presets', 'presets-1', 9);
|
|
$log = new FakeChangeLog(9);
|
|
|
|
$result = (new DesignChange($snapshots, $log))->run(
|
|
'lit/divi-presets-set',
|
|
$area,
|
|
['confirm' => true, 'design_rev' => $area->rev(['v' => 1])],
|
|
self::plan(['v' => 2])
|
|
);
|
|
|
|
self::assertTrue($result['ok']);
|
|
self::assertSame('presets-1', $result['snapshot_id']);
|
|
self::assertSame(9, $result['log_id']);
|
|
self::assertSame($area->rev(['v' => 2]), $result['design_rev']);
|
|
self::assertSame(['v' => 2], $stored);
|
|
self::assertSame('presets', $log->records[0]['object_id']);
|
|
self::assertSame('divi_design', $log->records[0]['object_type']);
|
|
self::assertSame('presets-1', $log->records[0]['snapshot_id']);
|
|
}
|
|
|
|
/** Final review I-3b: values Divi changed while saving are reported as warnings. */
|
|
public function testValuesDiviChangedWhileSavingAreWarnings(): void
|
|
{
|
|
$stored = ['v' => 1];
|
|
$area = self::area($stored, 0, true, ['Divi hat die Einstellungen von Preset „CTA“ beim Speichern bereinigt.']);
|
|
$snapshots = Mockery::mock(SnapshotStore::class);
|
|
$snapshots->expects('save')->andReturn('presets-1');
|
|
$snapshots->expects('attachLog');
|
|
|
|
$result = (new DesignChange($snapshots, new FakeChangeLog(9)))->run(
|
|
'lit/divi-presets-set',
|
|
$area,
|
|
['confirm' => true, 'design_rev' => $area->rev($stored)],
|
|
self::plan(['v' => 2])
|
|
);
|
|
|
|
self::assertTrue($result['ok']);
|
|
self::assertContains('Divi hat die Einstellungen von Preset „CTA“ beim Speichern bereinigt.', $result['warnings']);
|
|
}
|
|
|
|
public function testLockedDiviWritesNoSnapshot(): void
|
|
{
|
|
ReleaseGate::useResolver(
|
|
static fn (): ReleaseStatus => ReleaseMatrices::uniform(ReleaseState::Untested, '5.16.0')
|
|
);
|
|
$stored = ['v' => 1];
|
|
$area = self::area($stored);
|
|
$snapshots = Mockery::mock(SnapshotStore::class);
|
|
$snapshots->shouldNotReceive('save');
|
|
|
|
$this->expectException(DiviLocked::class);
|
|
|
|
(new DesignChange($snapshots, FakeChangeLog::forbidden()))->run(
|
|
'lit/divi-presets-set',
|
|
$area,
|
|
['confirm' => true, 'design_rev' => $area->rev($stored)],
|
|
self::plan(['v' => 2])
|
|
);
|
|
}
|
|
|
|
public function testTheLockFollowsTheFeatureOfTheArea(): void
|
|
{
|
|
$others = array_values(array_filter(
|
|
DiviFeature::cases(),
|
|
static fn (DiviFeature $feature): bool => $feature !== DiviFeature::PresetWrite
|
|
));
|
|
ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseMatrices::status($others));
|
|
$stored = ['v' => 1];
|
|
$area = self::area($stored);
|
|
$snapshots = Mockery::mock(SnapshotStore::class);
|
|
$snapshots->expects('save')->andReturn('s1');
|
|
$snapshots->expects('attachLog');
|
|
|
|
$written = (new DesignChange($snapshots, new FakeChangeLog(9)))->run(
|
|
'lit/divi-presets-set',
|
|
$area,
|
|
['confirm' => true, 'design_rev' => $area->rev($stored)],
|
|
self::plan(['v' => 2])
|
|
);
|
|
self::assertTrue($written['ok'], 'every other feature locked: presets are still written');
|
|
|
|
ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseMatrices::status([DiviFeature::PresetWrite]));
|
|
try {
|
|
(new DesignChange(Mockery::mock(SnapshotStore::class), FakeChangeLog::forbidden()))->run(
|
|
'lit/divi-presets-set',
|
|
$area,
|
|
['confirm' => true, 'design_rev' => $area->rev($stored)],
|
|
self::plan(['v' => 3])
|
|
);
|
|
self::fail('preset-write locked must refuse');
|
|
} catch (DiviLocked $locked) {
|
|
self::assertSame([DiviFeature::PresetWrite], $locked->features);
|
|
}
|
|
}
|
|
|
|
public function testTooLargeSnapshotRefusesTheChange(): void
|
|
{
|
|
$stored = ['v' => 1];
|
|
$area = self::area($stored);
|
|
$snapshots = Mockery::mock(SnapshotStore::class);
|
|
$snapshots->expects('save')->andThrow(new SnapshotTooLarge(3000000));
|
|
|
|
$result = (new DesignChange($snapshots, FakeChangeLog::forbidden()))->run(
|
|
'lit/divi-presets-set',
|
|
$area,
|
|
['confirm' => true, 'design_rev' => $area->rev($stored)],
|
|
self::plan(['v' => 2])
|
|
);
|
|
|
|
self::assertToolError($result, 'snapshot_too_large');
|
|
self::assertSame([], $area->writes);
|
|
}
|
|
|
|
public function testFailedWriteRestoresTheSnapshot(): void
|
|
{
|
|
$stored = ['v' => 1];
|
|
$area = self::area($stored, 1);
|
|
$snapshots = Mockery::mock(SnapshotStore::class);
|
|
$snapshots->expects('save')->andReturn('presets-1');
|
|
|
|
try {
|
|
(new DesignChange($snapshots, FakeChangeLog::forbidden()))->run(
|
|
'lit/divi-presets-set',
|
|
$area,
|
|
['confirm' => true, 'design_rev' => $area->rev($stored)],
|
|
self::plan(['v' => 2])
|
|
);
|
|
self::fail('expected WriteFailed');
|
|
} catch (WriteFailed $e) {
|
|
self::assertTrue($e->rolledBack);
|
|
self::assertSame('presets-1', $e->details['snapshot_id']);
|
|
}
|
|
|
|
self::assertSame([[['v' => 2], false]], $area->writes);
|
|
self::assertSame([['v' => 1]], $area->restores, 'the rollback writes the snapshot back with restore()');
|
|
self::assertSame(['v' => 1], $stored);
|
|
}
|
|
|
|
public function testReadBackMismatchRestoresTheSnapshot(): void
|
|
{
|
|
$stored = ['v' => 1];
|
|
$area = self::area($stored, 0, false);
|
|
$snapshots = Mockery::mock(SnapshotStore::class);
|
|
$snapshots->expects('save')->andReturn('presets-1');
|
|
|
|
$this->expectException(WriteFailed::class);
|
|
|
|
(new DesignChange($snapshots, FakeChangeLog::forbidden()))->run(
|
|
'lit/divi-presets-set',
|
|
$area,
|
|
['confirm' => true, 'design_rev' => $area->rev($stored)],
|
|
self::plan(['v' => 2])
|
|
);
|
|
}
|
|
|
|
public function testFailedRollbackIsPartial(): void
|
|
{
|
|
$stored = ['v' => 1];
|
|
$area = self::area($stored, 2);
|
|
$snapshots = Mockery::mock(SnapshotStore::class);
|
|
$snapshots->expects('save')->andReturn('presets-1');
|
|
$log = FakeChangeLog::partialsOnly();
|
|
|
|
try {
|
|
(new DesignChange($snapshots, $log))->run(
|
|
'lit/divi-presets-set',
|
|
$area,
|
|
['confirm' => true, 'design_rev' => $area->rev($stored)],
|
|
self::plan(['v' => 2])
|
|
);
|
|
self::fail('expected WriteFailed');
|
|
} catch (WriteFailed $e) {
|
|
self::assertFalse($e->rolledBack);
|
|
self::assertStringContainsString('presets-1', (string) $e->hint);
|
|
}
|
|
|
|
self::assertCount(1, $log->partials);
|
|
}
|
|
}
|