From bb4d9b3baf6dd16103580ac413b1f0f5d3490ca9 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 16 Oct 2026 17:39:58 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=C3=84nderungs-Pipeline=20des=20Designs?= =?UTF-8?q?ystems=20(Probelauf,=20design=5Frev,=20Snapshot,=20Rollback)=20?= =?UTF-8?q?und=20Bereich=20presets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/Integrations/Divi/Design/DesignArea.php | 41 +++ src/Integrations/Divi/Design/DesignChange.php | 239 +++++++++++++++ src/Integrations/Divi/Design/DesignPlan.php | 36 +++ src/Integrations/Divi/Design/PresetArea.php | 55 ++++ .../Divi/Design/DesignChangeTest.php | 281 ++++++++++++++++++ .../Divi/Design/PresetAreaTest.php | 46 +++ 6 files changed, 698 insertions(+) create mode 100644 src/Integrations/Divi/Design/DesignArea.php create mode 100644 src/Integrations/Divi/Design/DesignChange.php create mode 100644 src/Integrations/Divi/Design/DesignPlan.php create mode 100644 src/Integrations/Divi/Design/PresetArea.php create mode 100644 tests/Unit/Integrations/Divi/Design/DesignChangeTest.php create mode 100644 tests/Unit/Integrations/Divi/Design/PresetAreaTest.php diff --git a/src/Integrations/Divi/Design/DesignArea.php b/src/Integrations/Divi/Design/DesignArea.php new file mode 100644 index 0000000..9923e56 --- /dev/null +++ b/src/Integrations/Divi/Design/DesignArea.php @@ -0,0 +1,41 @@ + */ + public function raw(): array; + + /** @param array $raw */ + public function rev(array $raw): string; + + /** + * @param array $raw + * @return array The inventory as read back after writing. + */ + public function write(array $raw, bool $mayRemove): array; + + /** + * Whether Divi stored what was written – sanitizing may change values, never which entries exist. + * + * @param array $expected + * @param array $actual + */ + public function verify(array $expected, array $actual): bool; +} diff --git a/src/Integrations/Divi/Design/DesignChange.php b/src/Integrations/Divi/Design/DesignChange.php new file mode 100644 index 0000000..c08770c --- /dev/null +++ b/src/Integrations/Divi/Design/DesignChange.php @@ -0,0 +1,239 @@ +> Input properties every design write tool takes. */ + public static function inputProperties(): array + { + return [ + 'confirm' => [ + 'type' => 'boolean', + 'description' => 'Erst nach dem Probelauf und der Rückfrage beim Nutzer.', + ], + 'design_rev' => [ + 'type' => 'string', + 'description' => 'design_rev aus dem Probelauf (Pflicht mit confirm).', + ], + ]; + } + + /** @return array */ + public static function outputSchema(): array + { + return ToolResult::schema([ + 'diff' => ['type' => 'array', 'items' => ['type' => 'object']], + 'design_rev' => ['type' => 'string'], + ], ['diff', 'design_rev']); + } + + /** + * @param array $input The tool input (confirm, design_rev). + * @param callable(array): (DesignPlan|WP_Error) $plan + * @return array|WP_Error + * @throws WriteFailed + * @throws DiviLocked + */ + public function run(string $ability, DesignArea $area, array $input, callable $plan): array|WP_Error + { + $current = $area->raw(); + $rev = $area->rev($current); + $planned = $plan($current); + if ($planned instanceof WP_Error) { + return $planned; + } + + if ($planned->isEmpty()) { + return ToolResult::ok('Keine Änderung – ' . $planned->summary) + ->with('diff', []) + ->with('design_rev', $rev) + ->warnings($planned->warnings) + ->toArray(); + } + + if (($input['confirm'] ?? false) !== true) { + return Errors::confirmRequired( + $planned->summary . ' Nichts wurde gespeichert (Probelauf).', + ['diff' => $planned->diff, 'design_rev' => $rev, 'warnings' => $planned->warnings] + + ($planned->usages?->listing() ?? ['affected' => [], 'affected_count' => 0]) + + ($planned->usages !== null && $planned->usages->blocked !== [] + ? ['blocked_ids' => array_slice($planned->usages->blocked, 0, self::LISTED_IDS)] : []) + ); + } + + if (($input['design_rev'] ?? null) !== $rev) { + return Errors::revMismatch( + sprintf('Die %s wurden seit dem Probelauf geändert; nichts wurde gespeichert.', $area->label()), + 'Erneut ohne confirm aufrufen (Probelauf), dem Nutzer die Änderung zeigen und mit der neuen ' + . 'design_rev bestätigen.' + ); + } + + $blocked = $this->blocked($planned); + if ($blocked !== null) { + return $blocked; + } + + ReleaseGate::assertWritable(); + try { + $snapshotId = $this->snapshots->save($area->name(), $current, $ability); + } catch (SnapshotTooLarge $e) { + return Errors::make( + 'snapshot_too_large', + sprintf( + 'Die %s sind zu groß für eine Sicherung (%d Bytes, höchstens %d); nichts wurde gespeichert.', + $area->label(), + $e->size, + SnapshotStore::MAX_BYTES + ), + 'Ohne Sicherung schreibt LIT MCP nicht. Die Änderung im Divi-Builder vornehmen oder unbenutzte ' + . 'Einträge dort entfernen.', + 413 + ); + } + + $saved = $this->write($ability, $area, $planned, $current, $snapshotId); + $logId = $this->log->record( + $ability, + self::OBJECT_TYPE, + $area->name(), + $planned->summary, + snapshotId: $snapshotId + ); + $this->snapshots->attachLog($area->name(), $snapshotId, $logId); + + return ToolResult::ok($planned->summary) + ->snapshotId($snapshotId) + ->logId($logId) + ->warnings($planned->warnings) + ->with('diff', $planned->diff) + ->with('design_rev', $area->rev($saved)) + ->toArray(); + } + + /** Spec §4 / rulings P8, P8a: a confirmed change is refused while the caller may not edit a live post it changes. */ + private function blocked(DesignPlan $plan): ?WP_Error + { + $usages = $plan->usages; + if ($usages === null || ! $usages->blocks()) { + return null; + } + + return Errors::make( + 'forbidden', + $usages->tooMany + ? 'Die Änderung betrifft zu viele veröffentlichte Seiten, um sie einzeln zu prüfen; nichts wurde ' + . 'gespeichert.' + : 'Die Änderung wirkt auf veröffentlichte Seiten, die dieses Konto nicht bearbeiten darf; nichts wurde ' + . 'gespeichert.', + 'Einen Administrator die Änderung vornehmen lassen oder sie im Divi-Builder ausführen.', + 403, + ['blocked_ids' => array_slice($usages->blocked, 0, self::LISTED_IDS)] + + ($usages->blockedHidden > 0 ? ['blocked_hidden_count' => $usages->blockedHidden] : []) + ); + } + + /** + * @param array $current + * @return array + * @throws WriteFailed + */ + private function write( + string $ability, + DesignArea $area, + DesignPlan $plan, + array $current, + string $snapshotId + ): array { + try { + $saved = $area->write($plan->raw, $plan->mayRemove); + if (! $area->verify($plan->raw, $saved)) { + throw new RuntimeException(sprintf('%s read back differs from what was written', $area->name())); + } + + return $saved; + } catch (Throwable $e) { + if (DiviLocked::in($e) !== null) { + throw $e; + } + $this->rollBack($ability, $area, $current, $snapshotId, $e); + } + } + + /** + * @param array $current + * @throws WriteFailed Always. + */ + private function rollBack( + string $ability, + DesignArea $area, + array $current, + string $snapshotId, + Throwable $cause + ): never { + $reason = sprintf('Die %s konnten nicht gespeichert werden.', $area->label()); + $failures = Rollback::attempt(static fn () => $area->write($current, true)); + if ($failures === []) { + throw WriteFailed::rolledBack( + $reason, + $cause, + sprintf('design %s write failed: %s', $area->name(), $cause->getMessage()), + details: ['snapshot_id' => $snapshotId] + ); + } + + $this->log->recordPartial( + $ability, + self::OBJECT_TYPE, + $area->name(), + sprintf('%s nicht zurückgenommen (%s); Snapshot %s', $area->label(), $failures[0], $snapshotId) + ); + throw WriteFailed::partial( + $reason, + $cause, + sprintf('design %s rollback failed: %s', $area->name(), $failures[0]), + state: sprintf( + 'Die %s sind möglicherweise teilweise geändert (Stand vorher: Snapshot %s).', + $area->label(), + $snapshotId + ), + hint: sprintf( + 'Den Aufruf nicht wiederholen. Mit lit/divi-design-restore und snapshot_id %s (Probelauf, dann ' + . 'confirm: true) den vorherigen Stand wiederherstellen und dem Nutzer Bescheid geben.', + $snapshotId + ), + details: ['snapshot_id' => $snapshotId] + ); + } +} diff --git a/src/Integrations/Divi/Design/DesignPlan.php b/src/Integrations/Divi/Design/DesignPlan.php new file mode 100644 index 0000000..b8673ae --- /dev/null +++ b/src/Integrations/Divi/Design/DesignPlan.php @@ -0,0 +1,36 @@ + $raw + * @param list> $diff + * @param list $warnings + */ + public function __construct( + public readonly array $raw, + public readonly array $diff, + public readonly string $summary, + public readonly ?UsageReport $usages = null, + public readonly array $warnings = [], + public readonly bool $mayRemove = false + ) { + } + + public function isEmpty(): bool + { + return $this->diff === []; + } +} diff --git a/src/Integrations/Divi/Design/PresetArea.php b/src/Integrations/Divi/Design/PresetArea.php new file mode 100644 index 0000000..edabcce --- /dev/null +++ b/src/Integrations/Divi/Design/PresetArea.php @@ -0,0 +1,55 @@ +writer->presets())->data(); + } + + public function rev(array $raw): string + { + return PresetBook::fromDivi($raw)->rev(); + } + + public function write(array $raw, bool $mayRemove): array + { + return PresetBook::fromDivi($this->writer->savePresets(PresetBook::fromDivi($raw)->toRest(), $mayRemove)) + ->data(); + } + + public function verify(array $expected, array $actual): bool + { + $structural = array_filter( + PresetDiff::between(PresetBook::fromDivi($expected), PresetBook::fromDivi($actual)), + static fn (array $row): bool => $row['action'] !== 'changed' + ); + + return $structural === []; + } +} diff --git a/tests/Unit/Integrations/Divi/Design/DesignChangeTest.php b/tests/Unit/Integrations/Divi/Design/DesignChangeTest.php new file mode 100644 index 0000000..8ebf0c7 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/DesignChangeTest.php @@ -0,0 +1,281 @@ +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); + } +} diff --git a/tests/Unit/Integrations/Divi/Design/PresetAreaTest.php b/tests/Unit/Integrations/Divi/Design/PresetAreaTest.php new file mode 100644 index 0000000..7ff765f --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/PresetAreaTest.php @@ -0,0 +1,46 @@ +allows('presets')->andReturn($stored); + $writer->expects('savePresets') + ->with(PresetBook::fromDivi($stored)->toRest(), true) + ->andReturn($stored); + $area = new PresetArea($writer); + + self::assertSame(['presets', 'Presets'], [$area->name(), $area->label()]); + self::assertSame(PresetBook::fromDivi($stored)->data(), $area->raw()); + self::assertSame(PresetBook::fromDivi($stored)->rev(), $area->rev($stored)); + self::assertSame(PresetBook::fromDivi($stored)->data(), $area->write($stored, true)); + } + + public function testVerifyIgnoresSanitizedValuesButNotMissingPresets(): void + { + $area = new PresetArea(Mockery::mock(DesignSystemWriter::class)); + $expected = PresetBookTest::stored(); + $sanitized = $expected; + $sanitized['module']['divi/button']['items']['aaaaaaaaaa']['attrs'] = ['button' => ['x' => 'bereinigt']]; + $missing = $expected; + unset($missing['module']['divi/button']['items']['bbbbbbbbbb']); + $otherDefault = $expected; + $otherDefault['module']['divi/text']['default'] = ''; + + self::assertTrue($area->verify($expected, $sanitized)); + self::assertFalse($area->verify($expected, $missing)); + self::assertFalse($area->verify($expected, $otherDefault)); + } +}