diff --git a/src/Integrations/Divi/Design/ColorRestorePlanner.php b/src/Integrations/Divi/Design/ColorRestorePlanner.php index 4207064..dfc4c64 100644 --- a/src/Integrations/Divi/Design/ColorRestorePlanner.php +++ b/src/Integrations/Divi/Design/ColorRestorePlanner.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\Design; +use LIT\MCP\Integrations\Divi\Library\GlobalUsages; use LIT\MCP\Integrations\Divi\Release\DiviFeature; use WP_Error; @@ -19,8 +20,11 @@ final class ColorRestorePlanner implements RestorePlanner private const REMOVED = 'Farben, die es im Snapshot nicht gibt, entfallen (%s); Seiten und Presets, die sie ' . 'verwenden, behalten den Verweis ohne Wert.'; - public function __construct(private DesignSystemWriter $writer, private GlobalColorsAdapter $colors) - { + public function __construct( + private DesignSystemWriter $writer, + private GlobalColorsAdapter $colors, + private GlobalUsages $usages + ) { } public function area(): string @@ -64,6 +68,13 @@ final class ColorRestorePlanner implements RestorePlanner ); } - return new DesignPlan($after->data(), $diff, $summary, null, $warnings, true); + return new DesignPlan( + $after->data(), + $diff, + $summary, + RestoreUsages::ofReferences($this->usages, $diff), + $warnings, + true + ); } } diff --git a/src/Integrations/Divi/Design/PresetRestorePlanner.php b/src/Integrations/Divi/Design/PresetRestorePlanner.php index 89f07b7..339dff2 100644 --- a/src/Integrations/Divi/Design/PresetRestorePlanner.php +++ b/src/Integrations/Divi/Design/PresetRestorePlanner.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\Design; use Closure; +use LIT\MCP\Integrations\Divi\Library\GlobalUsages; +use LIT\MCP\Integrations\Divi\Library\UsageReport; use LIT\MCP\Integrations\Divi\Release\DiviFeature; use WP_Error; @@ -21,8 +23,11 @@ final class PresetRestorePlanner implements RestorePlanner . 'ohne das Recht unfiltered_html verloren ginge; nichts wurde gespeichert.'; /** @param (Closure(): bool)|null $unfiltered Whether the user may save unfiltered HTML (DesignRights). */ - public function __construct(private DesignSystemWriter $writer, private ?Closure $unfiltered = null) - { + public function __construct( + private DesignSystemWriter $writer, + private GlobalUsages $usages, + private ?Closure $unfiltered = null + ) { $this->unfiltered ??= DesignRights::unfilteredHtml(); } @@ -65,10 +70,32 @@ final class PresetRestorePlanner implements RestorePlanner $after->data(), $diff, sprintf('Snapshot %s wiederherstellen – %s', $snapshotId, PresetDiff::summary($diff)), - null, + $this->usagesOf($before, $diff), ['Seiten, die Presets verwenden, die es im Snapshot nicht gibt, behalten den Verweis; das Modul fällt ' . 'dann auf das Standard-Preset zurück.'], true ); } + + /** + * Audit L4: the pages using a preset the restore changes or removes (spec §4 step 4, ruling P8a); a default + * preset also counts its implicit users (like lit/divi-presets-delete). The orphan warning stays (spec §5). + * + * @param list> $diff + */ + private function usagesOf(PresetBook $before, array $diff): ?UsageReport + { + $targets = []; + foreach ($diff as $row) { + if (in_array($row['action'], RestoreUsages::TOUCHING, true)) { + $scope = new PresetScope((string) $row['kind'], (string) $row['scope']); + $targets[] = ['scope' => $scope, 'id' => (string) $row['id'], + 'default' => $before->defaultId($scope) === $row['id']]; + } + } + + return $targets === [] + ? null + : $this->usages->reportFor(PresetUsageQuery::withGroups($targets, $this->writer->groupsOf(...))); + } } diff --git a/src/Integrations/Divi/Design/RestoreUsages.php b/src/Integrations/Divi/Design/RestoreUsages.php new file mode 100644 index 0000000..b77605c --- /dev/null +++ b/src/Integrations/Divi/Design/RestoreUsages.php @@ -0,0 +1,36 @@ +> $diff Rows with "id" (gcid-/gvid-) and "action" + */ + public static function ofReferences(GlobalUsages $usages, array $diff): ?UsageReport + { + $ids = []; + foreach ($diff as $row) { + if (in_array($row['action'], self::TOUCHING, true)) { + $ids[] = (string) $row['id']; + } + } + + return $ids === [] ? null : $usages->reportFor(new ReferenceUsageQuery(array_values(array_unique($ids)))); + } +} diff --git a/src/Integrations/Divi/Design/VariableRestorePlanner.php b/src/Integrations/Divi/Design/VariableRestorePlanner.php index e4a3542..831629b 100644 --- a/src/Integrations/Divi/Design/VariableRestorePlanner.php +++ b/src/Integrations/Divi/Design/VariableRestorePlanner.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\Design; +use LIT\MCP\Integrations\Divi\Library\GlobalUsages; use LIT\MCP\Integrations\Divi\Release\DiviFeature; use WP_Error; @@ -19,8 +20,11 @@ final class VariableRestorePlanner implements RestorePlanner private const KEPT = 'Variable „%s“ gibt es im Snapshot nicht; sie bleibt archiviert erhalten (Divi löscht keine ' . 'Variablen).'; - public function __construct(private DesignSystemWriter $writer, private GlobalVariablesAdapter $variables) - { + public function __construct( + private DesignSystemWriter $writer, + private GlobalVariablesAdapter $variables, + private GlobalUsages $usages + ) { } public function area(): string @@ -55,7 +59,7 @@ final class VariableRestorePlanner implements RestorePlanner $after->data(), $diff, sprintf('Snapshot %s wiederherstellen – %s', $snapshotId, VariableDiff::summary($diff)), - null, + RestoreUsages::ofReferences($this->usages, $diff), array_map(static fn (array $row): string => sprintf(self::KEPT, $row['label']), $before->onlyHere($older)) ); } diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 1832c64..ac219d3 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -301,9 +301,9 @@ final class DiviIntegration implements Integration, Bootable new DiviDesignRestoreAbility( $this->designChange(), new SnapshotStore(), - new PresetRestorePlanner($this->designWriter, DesignRights::unfilteredHtml()), - new ColorRestorePlanner($this->designWriter, $this->colorsAdapter), - new VariableRestorePlanner($this->designWriter, $this->variablesAdapter), + new PresetRestorePlanner($this->designWriter, $this->globalUsages(), DesignRights::unfilteredHtml()), + new ColorRestorePlanner($this->designWriter, $this->colorsAdapter, $this->globalUsages()), + new VariableRestorePlanner($this->designWriter, $this->variablesAdapter, $this->globalUsages()), new ThemeOptionsRestorePlanner($this->themeOptionsAdapter) ), new DiviPresetsAssignAbility( diff --git a/tests/Support/UsageFixture.php b/tests/Support/UsageFixture.php new file mode 100644 index 0000000..33da9b0 --- /dev/null +++ b/tests/Support/UsageFixture.php @@ -0,0 +1,47 @@ + 7, 'title' => 'Start', 'status' => 'publish', 'type' => 'page']], + 0, + 1, + [7], + 0, + false + ); + } + + public static function reporting(UsageReport $report): GlobalUsages&MockInterface + { + $usages = Mockery::mock(GlobalUsages::class); + $usages->allows('reportFor')->andReturn($report); + + return $usages; + } +} diff --git a/tests/Unit/Integrations/Divi/Design/ColorRestorePlannerTest.php b/tests/Unit/Integrations/Divi/Design/ColorRestorePlannerTest.php index c554cc4..b358d77 100644 --- a/tests/Unit/Integrations/Divi/Design/ColorRestorePlannerTest.php +++ b/tests/Unit/Integrations/Divi/Design/ColorRestorePlannerTest.php @@ -11,7 +11,10 @@ use LIT\MCP\Integrations\Divi\Design\ColorRestorePlanner; use LIT\MCP\Integrations\Divi\Design\DesignPlan; use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter; +use LIT\MCP\Integrations\Divi\Design\ReferenceUsageQuery; +use LIT\MCP\Integrations\Divi\Library\GlobalUsages; use LIT\MCP\Integrations\Divi\Release\DiviFeature; +use LIT\MCP\Tests\Support\UsageFixture; use LIT\MCP\Tests\Unit\TestCase; use Mockery; @@ -41,12 +44,16 @@ final class ColorRestorePlannerTest extends TestCase return ['gcid-brand00001' => self::color('Marke', '#0000ff')]; } - private static function planner(bool $visualBuilder = true): ColorRestorePlanner + private static function planner(bool $visualBuilder = true, ?GlobalUsages $usages = null): ColorRestorePlanner { $writer = Mockery::mock(DesignSystemWriter::class); $writer->allows('canUseVisualBuilder')->andReturn($visualBuilder); - return new ColorRestorePlanner($writer, Mockery::mock(GlobalColorsAdapter::class)); + return new ColorRestorePlanner( + $writer, + Mockery::mock(GlobalColorsAdapter::class), + $usages ?? UsageFixture::none() + ); } public function testAreaFeaturesAndDesignArea(): void @@ -104,4 +111,32 @@ final class ColorRestorePlannerTest extends TestCase self::assertTrue($plan->isEmpty()); self::assertSame([], $plan->warnings); } + + /** Audit L4: the usages of changed and removed colors (not added ones) go into the plan, so P8a can block. */ + public function testReportsThePagesUsingChangedOrRemovedColors(): void + { + $usages = Mockery::mock(GlobalUsages::class); + $usages->expects('reportFor')->once()->with(Mockery::on( + static fn (mixed $query): bool => $query instanceof ReferenceUsageQuery + && $query->matches('gcid-brand00001') && $query->matches('gcid-akzent0001') + && ! $query->matches('gcid-other00001') + ))->andReturn(UsageFixture::blockedReport()); + + $plan = self::planner(true, $usages)->plan(self::current(), self::snapshot(), self::SNAPSHOT_ID); + + self::assertInstanceOf(DesignPlan::class, $plan); + self::assertSame([7], $plan->usages?->blocked); + self::assertTrue($plan->usages->blocks()); + } + + public function testNoUsageLookupWithoutAChangedOrRemovedColor(): void + { + $usages = Mockery::mock(GlobalUsages::class); + $usages->expects('reportFor')->never(); + + $plan = self::planner(true, $usages)->plan(self::snapshot(), self::snapshot(), self::SNAPSHOT_ID); + + self::assertInstanceOf(DesignPlan::class, $plan); + self::assertNull($plan->usages); + } } diff --git a/tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php index 9715944..d962680 100644 --- a/tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php @@ -18,9 +18,11 @@ use LIT\MCP\Integrations\Divi\Design\ThemeOptionBook; use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter; use LIT\MCP\Integrations\Divi\Design\ThemeOptionsRestorePlanner; use LIT\MCP\Integrations\Divi\Design\VariableRestorePlanner; +use LIT\MCP\Integrations\Divi\Library\GlobalUsages; use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Tests\Support\FakeChangeLog; use LIT\MCP\Tests\Support\LockTable; +use LIT\MCP\Tests\Support\UsageFixture; use LIT\MCP\Tests\Unit\TestCase; use Mockery; use Mockery\MockInterface; @@ -51,7 +53,7 @@ final class DiviDesignRestoreAbilityTest extends TestCase $ability = new DiviDesignRestoreAbility( new DesignChange($snapshots, new FakeChangeLog(8)), $snapshots, - new PresetRestorePlanner($writer) + new PresetRestorePlanner($writer, UsageFixture::none()) ); $dry = self::assertToolError($ability->execute(['snapshot_id' => 'presets-20261016T100000Z-aaaaaa']), 'confirm_required'); @@ -80,7 +82,7 @@ final class DiviDesignRestoreAbilityTest extends TestCase return new DiviDesignRestoreAbility( new DesignChange($snapshots, FakeChangeLog::forbidden()), $snapshots, - new PresetRestorePlanner($writer, static fn (): bool => $unfiltered) + new PresetRestorePlanner($writer, UsageFixture::none(), static fn (): bool => $unfiltered) ); } @@ -113,7 +115,7 @@ final class DiviDesignRestoreAbilityTest extends TestCase $ability = new DiviDesignRestoreAbility( new DesignChange($snapshots, FakeChangeLog::forbidden()), $snapshots, - new PresetRestorePlanner($writer) + new PresetRestorePlanner($writer, UsageFixture::none()) ); $input = ['snapshot_id' => 'presets-20261016T100000Z-aaaaaa', 'confirm' => true, 'design_rev' => 'x']; @@ -148,7 +150,7 @@ final class DiviDesignRestoreAbilityTest extends TestCase $ability = new DiviDesignRestoreAbility( new DesignChange($snapshots, FakeChangeLog::forbidden()), $snapshots, - new PresetRestorePlanner($writer) + new PresetRestorePlanner($writer, UsageFixture::none()) ); $error = self::assertToolError( @@ -164,7 +166,8 @@ final class DiviDesignRestoreAbilityTest extends TestCase GlobalColorsAdapter $colors, GlobalVariablesAdapter $variables, ?FakeChangeLog $log = null, - ?ThemeOptionsAdapter $options = null + ?ThemeOptionsAdapter $options = null, + ?GlobalUsages $usages = null ): DiviDesignRestoreAbility { Functions\when('current_user_can')->justReturn(true); $writer = Mockery::mock(DesignSystemWriter::class); @@ -176,9 +179,9 @@ final class DiviDesignRestoreAbilityTest extends TestCase return new DiviDesignRestoreAbility( new DesignChange($snapshots, $log ?? FakeChangeLog::forbidden()), $snapshots, - new PresetRestorePlanner($writer), - new ColorRestorePlanner($writer, $colors), - new VariableRestorePlanner($writer, $variables), + new PresetRestorePlanner($writer, $usages ?? UsageFixture::none()), + new ColorRestorePlanner($writer, $colors, $usages ?? UsageFixture::none()), + new VariableRestorePlanner($writer, $variables, $usages ?? UsageFixture::none()), new ThemeOptionsRestorePlanner($options ?? self::optionStore($noOptions)) ); } @@ -240,6 +243,35 @@ final class DiviDesignRestoreAbilityTest extends TestCase 'log_id' => 2, 'size' => 1]; } + /** Audit L4 / ruling P8a: a colors restore touching a color a live page the caller may not edit uses is refused. */ + public function testRestoringAColorUsedOnAnUneditableLivePageIsForbidden(): void + { + $id = ColorRestorePlannerTest::SNAPSHOT_ID; + $colors = self::colorStore(ColorRestorePlannerTest::current()); + $colors->shouldNotReceive('save'); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->allows('get')->with('colors', $id) + ->andReturn(self::snapshotIn('colors', $id, ColorRestorePlannerTest::snapshot())); + $snapshots->shouldNotReceive('save'); + $ability = self::allAreas( + $snapshots, + $colors, + self::variableStore([]), + FakeChangeLog::forbidden(), + null, + UsageFixture::blocked() + ); + + $dry = self::assertToolError($ability->execute(['snapshot_id' => $id]), 'confirm_required'); + self::assertSame(7, $dry['affected'][0]['post_id'] ?? null); + + $error = self::assertToolError( + $ability->execute(['snapshot_id' => $id, 'confirm' => true, 'design_rev' => $dry['design_rev']]), + 'forbidden' + ); + self::assertSame([7], $error['blocked_ids']); + } + public function testRestoresColorsWithDryRunAndASnapshotOfTheCurrentColors(): void { $id = ColorRestorePlannerTest::SNAPSHOT_ID; diff --git a/tests/Unit/Integrations/Divi/Design/PresetRestorePlannerTest.php b/tests/Unit/Integrations/Divi/Design/PresetRestorePlannerTest.php index a3d2be0..a2c3895 100644 --- a/tests/Unit/Integrations/Divi/Design/PresetRestorePlannerTest.php +++ b/tests/Unit/Integrations/Divi/Design/PresetRestorePlannerTest.php @@ -8,15 +8,21 @@ use LIT\MCP\Integrations\Divi\Design\DesignPlan; use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; use LIT\MCP\Integrations\Divi\Design\PresetArea; use LIT\MCP\Integrations\Divi\Design\PresetRestorePlanner; +use LIT\MCP\Integrations\Divi\Design\PresetUsageQuery; +use LIT\MCP\Integrations\Divi\Library\GlobalUsages; use LIT\MCP\Integrations\Divi\Release\DiviFeature; +use LIT\MCP\Tests\Support\UsageFixture; use LIT\MCP\Tests\Unit\TestCase; use Mockery; final class PresetRestorePlannerTest extends TestCase { - private static function planner(bool $unfiltered = true): PresetRestorePlanner + private static function planner(bool $unfiltered = true, ?GlobalUsages $usages = null): PresetRestorePlanner { - return new PresetRestorePlanner(Mockery::mock(DesignSystemWriter::class), static fn (): bool => $unfiltered); + return new PresetRestorePlanner( + Mockery::mock(DesignSystemWriter::class), + $usages ?? UsageFixture::none(), + static fn (): bool => $unfiltered); } public function testAreaFeaturesAndDesignArea(): void @@ -51,4 +57,36 @@ final class PresetRestorePlannerTest extends TestCase self::assertToolError($plan, 'forbidden'); } + + /** Audit L4: removed and changed presets are looked up (spec §5 orphan warning stays); P8a can block. */ + public function testReportsThePagesUsingRemovedPresets(): void + { + $older = PresetBookTest::stored(); + unset($older['module']['divi/button']['items']['bbbbbbbbbb']); + $older['module']['divi/button']['items']['aaaaaaaaaa']['attrs'] = ['button' => ['x' => 2]]; + $usages = Mockery::mock(GlobalUsages::class); + $usages->expects('reportFor')->once()->with(Mockery::on( + static fn (mixed $query): bool => $query instanceof PresetUsageQuery + && $query->matches('') + && $query->matches('') + && ! $query->matches('') + ))->andReturn(UsageFixture::blockedReport()); + + $plan = self::planner(true, $usages)->plan(PresetBookTest::stored(), $older, 'presets-1'); + + self::assertInstanceOf(DesignPlan::class, $plan); + self::assertTrue($plan->usages?->blocks()); + self::assertStringContainsString('Standard-Preset', $plan->warnings[0]); + } + + public function testNoUsageLookupWithoutChangedOrRemovedPresets(): void + { + $usages = Mockery::mock(GlobalUsages::class); + $usages->expects('reportFor')->never(); + + $plan = self::planner(true, $usages)->plan(PresetBookTest::stored(), PresetBookTest::stored(), 'presets-1'); + + self::assertInstanceOf(DesignPlan::class, $plan); + self::assertNull($plan->usages); + } } diff --git a/tests/Unit/Integrations/Divi/Design/VariableRestorePlannerTest.php b/tests/Unit/Integrations/Divi/Design/VariableRestorePlannerTest.php index 06554be..f7b9995 100644 --- a/tests/Unit/Integrations/Divi/Design/VariableRestorePlannerTest.php +++ b/tests/Unit/Integrations/Divi/Design/VariableRestorePlannerTest.php @@ -11,7 +11,10 @@ use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter; use LIT\MCP\Integrations\Divi\Design\VariableArea; use LIT\MCP\Integrations\Divi\Design\VariableBook; use LIT\MCP\Integrations\Divi\Design\VariableRestorePlanner; +use LIT\MCP\Integrations\Divi\Design\ReferenceUsageQuery; +use LIT\MCP\Integrations\Divi\Library\GlobalUsages; use LIT\MCP\Integrations\Divi\Release\DiviFeature; +use LIT\MCP\Tests\Support\UsageFixture; use LIT\MCP\Tests\Unit\TestCase; use Mockery; @@ -41,14 +44,14 @@ final class VariableRestorePlannerTest extends TestCase return ['numbers' => ['gvid-radius0001' => self::number('gvid-radius0001', 'Eckenradius', '4px')]]; } - private static function planner(bool $manage = true): VariableRestorePlanner + private static function planner(bool $manage = true, ?GlobalUsages $usages = null): VariableRestorePlanner { $writer = Mockery::mock(DesignSystemWriter::class); $writer->allows('canUseVisualBuilder')->andReturn(true); $variables = Mockery::mock(GlobalVariablesAdapter::class); $variables->allows('canManage')->andReturn($manage); - return new VariableRestorePlanner($writer, $variables); + return new VariableRestorePlanner($writer, $variables, $usages ?? UsageFixture::none()); } public function testAreaFeaturesAndDesignArea(): void @@ -89,4 +92,19 @@ final class VariableRestorePlannerTest extends TestCase $plan->warnings ); } + + /** Audit L4: changed and archived variables are looked up; the plan carries the report for P8a. */ + public function testReportsThePagesUsingChangedOrArchivedVariables(): void + { + $usages = Mockery::mock(GlobalUsages::class); + $usages->expects('reportFor')->once()->with(Mockery::on( + static fn (mixed $query): bool => $query instanceof ReferenceUsageQuery + && $query->matches('gvid-radius0001') && $query->matches('gvid-neu0000001') + ))->andReturn(UsageFixture::blockedReport()); + + $plan = self::planner(true, $usages)->plan(self::current(), self::snapshot(), self::SNAPSHOT_ID); + + self::assertInstanceOf(DesignPlan::class, $plan); + self::assertTrue($plan->usages?->blocks()); + } }