fix: DesignLock gegen parallele Bestätigungen; confirm ohne design_rev ist ein Eingabefehler

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-16 18:06:52 +02:00
co-authored by Claude Opus 5.5
parent fc8ffeba9e
commit 048cd9353d
10 changed files with 357 additions and 14 deletions
@@ -6,6 +6,7 @@ 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;
@@ -17,11 +18,35 @@ 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 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().
@@ -164,6 +189,118 @@ final class DesignChangeTest extends TestCase
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];
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Design\DesignLock;
use LIT\MCP\Tests\Unit\TestCase;
final class DesignLockTest extends TestCase
{
/** @var array<string, mixed> */
private array $options = [];
protected function setUp(): void
{
parent::setUp();
$this->options = [];
Functions\when('add_option')->alias(function (string $name, mixed $value = '', string $unused = '', mixed $autoload = null): bool {
self::assertFalse($autoload, 'locks are never autoloaded');
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;
});
}
public function testAcquireIsExclusiveUntilReleased(): void
{
$lock = new DesignLock();
self::assertTrue($lock->acquire('presets'));
self::assertIsInt($this->options['lit_mcp_lock_presets']);
self::assertFalse($lock->acquire('presets'));
self::assertTrue($lock->acquire('colors'), 'areas lock independently');
$lock->release('presets');
self::assertTrue($lock->acquire('presets'));
}
public function testAStaleLockIsTakenOver(): void
{
$this->options['lit_mcp_lock_presets'] = time() - DesignLock::STALE_AFTER - 1;
self::assertTrue((new DesignLock())->acquire('presets'));
self::assertGreaterThan(time() - 5, $this->options['lit_mcp_lock_presets']);
}
public function testAFreshLockIsNotTakenOver(): void
{
$this->options['lit_mcp_lock_presets'] = time() - 10;
self::assertFalse((new DesignLock())->acquire('presets'));
}
public function testOptionNamesAndUnknownAreas(): void
{
self::assertSame('lit_mcp_lock_theme_options', DesignLock::option('theme_options'));
$this->expectException(InvalidArgumentException::class);
DesignLock::option('pages');
}
}
@@ -15,6 +15,14 @@ use Mockery;
final class DiviDesignRestoreAbilityTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// DesignLock (final review I-5): the lock is always free here.
Functions\when('add_option')->justReturn(true);
Functions\when('delete_option')->justReturn(true);
}
public function testRestoresWithDryRunAndASnapshotOfTheCurrentState(): void
{
Functions\when('current_user_can')->justReturn(true);
@@ -17,6 +17,14 @@ use Mockery;
final class DiviPresetsDeleteAbilityTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// DesignLock (final review I-5): the lock is always free here.
Functions\when('add_option')->justReturn(true);
Functions\when('delete_option')->justReturn(true);
}
private function ability(UsageReport $report, ?DesignSystemWriter $writer = null, ?SnapshotStore $snapshots = null): DiviPresetsDeleteAbility
{
Functions\when('current_user_can')->justReturn(true);
@@ -36,6 +36,9 @@ final class DiviPresetsSetAbilityTest extends TestCase
$this->stored = PresetBookTest::stored();
$this->snapshots = Mockery::mock(SnapshotStore::class);
Functions\when('current_user_can')->justReturn(true);
// DesignLock (final review I-5): the lock is always free here.
Functions\when('add_option')->justReturn(true);
Functions\when('delete_option')->justReturn(true);
}
private function ability(bool $unfiltered = true, ?GlobalUsages $usages = null, ?DiviPageLoader $loader = null, ?DesignSystemWriter $writer = null): DiviPresetsSetAbility