Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
282 lines
10 KiB
PHP
282 lines
10 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\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\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\Unit\TestCase;
|
|
use Mockery;
|
|
use RuntimeException;
|
|
|
|
final class DesignChangeTest extends TestCase
|
|
{
|
|
/** A DesignArea over an array; $failWrite/$failRestore make write()/the rollback throw. */
|
|
private static function area(array &$stored, int $failWrites = 0, bool $verify = true): DesignArea
|
|
{
|
|
return new class ($stored, $failWrites, $verify) implements DesignArea {
|
|
public array $writes = [];
|
|
|
|
public function __construct(private array &$stored, private int $failWrites, private bool $verify)
|
|
{
|
|
}
|
|
|
|
public function name(): string
|
|
{
|
|
return 'presets';
|
|
}
|
|
|
|
public function label(): string
|
|
{
|
|
return 'Presets';
|
|
}
|
|
|
|
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 verify(array $expected, array $actual): bool
|
|
{
|
|
return $this->verify;
|
|
}
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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']);
|
|
}
|
|
|
|
public function testLockedDiviWritesNoSnapshot(): void
|
|
{
|
|
ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(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 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], [['v' => 1], true]], $area->writes);
|
|
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);
|
|
}
|
|
}
|