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 => 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 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); } }