Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Design/DiviDesignDetachAbilityTest.php
T

501 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use Closure;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Design\DesignChange;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\DetachRewrite;
use LIT\MCP\Integrations\Divi\Design\DiviDesignDetachAbility;
use LIT\MCP\Integrations\Divi\Design\PageRewriter;
use LIT\MCP\Integrations\Divi\Design\PresetBook;
use LIT\MCP\Integrations\Divi\Design\PresetUsageQuery;
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Library\UsageReport;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Support\ReleaseMatrices;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use Mockery\MockInterface;
use ReflectionProperty;
use RuntimeException;
final class DiviDesignDetachAbilityTest extends TestCase
{
private const LESETEXT = ['group' => 'font-body', 'preset' => 'Lesetext'];
protected function setUp(): void
{
parent::setUp();
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(
PageRewriter $pages,
?DesignSystemWriter $writer = null,
?SnapshotStore $snapshots = null,
?FakeChangeLog $log = null,
?UsageReport $report = null,
bool $unfiltered = true
): DiviDesignDetachAbility {
$usages = Mockery::mock(GlobalUsages::class);
$usages->allows('reportFor')->andReturn($report ?? new UsageReport(0, [], 0, 0, [], 0, false));
return new DiviDesignDetachAbility(
$writer ?? self::readOnlyWriter(),
new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), $log ?? FakeChangeLog::forbidden()),
$usages,
$pages,
static fn (): bool => $unfiltered
);
}
/**
* Reads $stored (default PresetBookTest::stored()), never saves.
*
* @param array<string, mixed>|null $stored
*/
private static function readOnlyWriter(?array $stored = null): DesignSystemWriter&MockInterface
{
$writer = Mockery::mock(DesignSystemWriter::class);
$writer->allows('presets')->andReturn($stored ?? PresetBookTest::stored());
$writer->allows('canUseVisualBuilder')->andReturn(true);
$writer->expects('savePresets')->never();
return $writer;
}
private static function untouched(): PageRewriter&MockInterface
{
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('preview')->never();
$pages->expects('apply')->never();
return $pages;
}
/** Matches the explicit-only query and a DetachRewrite strategy of a preview()/apply() call. */
private static function explicitQuery(): Closure
{
return static function (PresetUsageQuery $query): bool {
$targets = (new ReflectionProperty(PresetUsageQuery::class, 'targets'))->getValue($query);
return array_column($targets, 'default') === [false];
};
}
private static function detaching(): Closure
{
return static fn (Closure $rewrite): bool => $rewrite() instanceof DetachRewrite;
}
public function testNeedsEditThemeOptionsAndTheVisualBuilder(): void
{
$ability = $this->ability(self::untouched());
Functions\when('current_user_can')->justReturn(false);
self::assertToolError($ability->permission(['preset' => self::LESETEXT]), 'forbidden');
self::assertToolError($ability->execute(['preset' => self::LESETEXT]), 'forbidden');
}
public function testVariablesFollowWithStage4c(): void
{
$error = self::assertToolError(
$this->ability(self::untouched())->execute(['variable' => 'Eckenradius']),
'unsupported'
);
self::assertStringContainsString('Variablen folgen mit Etappe 4c.', $error['hint']);
}
public function testUnknownAndAmbiguousPresets(): void
{
$ability = $this->ability(self::untouched());
$unknown = $ability->execute(['preset' => ['module' => 'button', 'preset' => 'Fehlt']]);
self::assertToolError($unknown, 'not_found');
$ambiguous = $ability->execute(['preset' => ['module' => 'button', 'preset' => 'Doppelt']]);
self::assertToolError($ambiguous, 'validation_failed');
self::assertStringContainsString('bbbbbbbbbb, cccccccccc', self::errorFields($ambiguous)['preset.preset']);
$form = $ability->execute(['preset' => ['preset' => 'Lesetext']]);
self::assertArrayHasKey('preset', self::errorFields($form));
}
public function testDryRunListsPagesAndTheDiffOfTheNestingPresets(): void
{
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('preview')
->with(Mockery::on(self::explicitQuery()), Mockery::on(self::detaching()), null)
->andReturn(PageRewriteReportTest::preview(1));
$pages->expects('apply')->never();
$dry = self::assertToolError($this->ability($pages)->execute(['preset' => self::LESETEXT]), 'confirm_required');
self::assertSame(12, $dry['pages'][0]['post_id']);
self::assertSame([['dddddddddd', 'changed']], array_map(
static fn (array $row): array => [$row['id'], $row['action']],
$dry['diff']
));
self::assertSame(PresetBook::fromDivi(PresetBookTest::stored())->rev(), $dry['design_rev']);
self::assertStringContainsString('design_rev', $dry['message']);
self::assertSame([], $dry['warnings']);
}
public function testConfirmWithAStaleDesignRevWritesNeitherPresetsNorPages(): void
{
$error = self::assertToolError(
$this->ability(self::untouched())->execute([
'preset' => self::LESETEXT,
'confirm' => true,
'design_rev' => 'veraltet',
]),
'rev_mismatch'
);
self::assertStringContainsString('nichts wurde gespeichert', $error['message']);
}
public function testConfirmRemovesTheNestedReferencesFirstThenThePages(): void
{
$calls = [];
$writer = Mockery::mock(DesignSystemWriter::class);
$writer->allows('presets')->andReturn(PresetBookTest::stored());
$writer->allows('canUseVisualBuilder')->andReturn(true);
$writer->expects('savePresets')
->with(
Mockery::on(static fn (array $rest): bool => $rest['module'][1]['items'][0]['groupPresets'] === []),
false
)
->andReturnUsing(static function () use (&$calls): array {
$calls[] = 'presets';
return PresetBook::fromDivi(PresetBookTest::stored())->withoutNestedRef('gggggggggg')->data();
});
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->expects('save')->andReturn('presets-4');
$snapshots->expects('attachLog');
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('preview')->andReturn(PageRewriteReportTest::preview(1));
$pages->expects('apply')
->with(
'lit/divi-design-detach',
Mockery::on(self::explicitQuery()),
Mockery::on(self::detaching()),
null,
'Entkoppeln von Gruppen-Preset divi/font-body „Lesetext“'
)
->andReturnUsing(static function () use (&$calls): array {
$calls[] = 'pages';
return PageRewriteReportTest::applied(1, 0);
});
$ability = $this->ability($pages, $writer, $snapshots, new FakeChangeLog(7));
$dry = self::assertToolError($ability->execute(['preset' => self::LESETEXT]), 'confirm_required');
$result = $ability->execute([
'preset' => self::LESETEXT,
'confirm' => true,
'design_rev' => $dry['design_rev'],
]);
self::assertSame(['presets', 'pages'], $calls);
self::assertSame('presets-4', $result['snapshot_id']);
self::assertSame(7, $result['log_id']);
self::assertSame('dddddddddd', $result['diff'][0]['id']);
self::assertSame(12, $result['saved'][0]['post_id']);
self::assertStringStartsWith('Presets: 1 geändert.', $result['summary']);
self::assertStringEndsWith('Jetzt kann lit/divi-presets-delete das Preset löschen.', $result['summary']);
}
public function testAPageErrorAfterThePresetStepSaysThePresetsAreChanged(): void
{
$writer = Mockery::mock(DesignSystemWriter::class);
$writer->allows('presets')->andReturn(PresetBookTest::stored());
$writer->allows('canUseVisualBuilder')->andReturn(true);
$writer->expects('savePresets')
->andReturn(PresetBook::fromDivi(PresetBookTest::stored())->withoutNestedRef('gggggggggg')->data());
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->expects('save')->andReturn('presets-4');
$snapshots->expects('attachLog');
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('apply')->andReturn(Errors::partialWrite('Seite 13 nicht gespeichert.', 13));
$rev = PresetBook::fromDivi(PresetBookTest::stored())->rev();
$error = self::assertToolError(
$this->ability($pages, $writer, $snapshots, new FakeChangeLog(7))->execute([
'preset' => self::LESETEXT,
'confirm' => true,
'design_rev' => $rev,
]),
'partial_write'
);
self::assertStringContainsString('bereits entfernt (Snapshot presets-4)', $error['message']);
self::assertSame('presets-4', $error['snapshot_id']);
self::assertSame(13, $error['post_id']);
}
public function testPostIdsTouchOnlyThePages(): void
{
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('preview')
->with(Mockery::on(self::explicitQuery()), Mockery::on(self::detaching()), [12])
->andReturn(PageRewriteReportTest::preview(1));
$pages->expects('apply')
->with('lit/divi-design-detach', Mockery::any(), Mockery::any(), [12], Mockery::type('string'))
->andReturn(PageRewriteReportTest::applied(1, 0));
$ability = $this->ability($pages);
$input = ['preset' => self::LESETEXT, 'post_ids' => [12]];
$dry = self::assertToolError($ability->execute($input), 'confirm_required');
self::assertArrayNotHasKey('diff', $dry);
$result = $ability->execute($input + ['confirm' => true]);
self::assertSame([], $result['diff']);
self::assertNull($result['design_rev']);
self::assertNull($result['snapshot_id']);
self::assertStringNotContainsString('lit/divi-presets-delete', $result['summary']);
}
public function testADefaultPresetLosesOnlyItsExplicitReferences(): void
{
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('preview')
->with(Mockery::on(self::explicitQuery()), Mockery::on(self::detaching()), null)
->andReturn(PageRewriteReportTest::preview(1));
$dry = self::assertToolError(
$this->ability($pages)->execute(['preset' => ['module' => 'text', 'preset' => 'Fließtext']]),
'confirm_required'
);
self::assertSame(
['„Fließtext“ ist das Standard-Preset von divi/text: Divi wendet es weiter auf Module ohne eigenes '
. 'Preset an; entfernt werden nur die ausdrücklichen Verweise.'],
$dry['warnings']
);
self::assertArrayNotHasKey('diff', $dry, 'module presets are never nested');
}
public function testNothingLeftIsNoConfirmAndPointsToDelete(): void
{
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('preview')->andReturn(PageRewriteReportTest::preview(0));
$result = $this->ability($pages)->execute(['preset' => ['module' => 'button', 'preset' => 'CTA primär']]);
self::assertTrue($result['ok']);
self::assertSame(
'Keine Verweise auf Modul-Preset divi/button „CTA primär“ gefunden – nichts zu tun. Jetzt kann '
. 'lit/divi-presets-delete das Preset löschen.',
$result['summary']
);
}
public function testUnreadablePostsKeepTheDeleteHintBack(): void
{
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('apply')->andReturn(PageRewriteReportTest::applied(1, 0, [], 3));
$result = $this->ability($pages)->execute([
'preset' => ['module' => 'button', 'preset' => 'CTA primär'],
'confirm' => true,
]);
self::assertStringNotContainsString('lit/divi-presets-delete', $result['summary']);
self::assertStringNotContainsString('erledigt', $result['summary']);
self::assertStringContainsString('3 für diesen Zugang nicht sichtbare Fundstellen', $result['summary']);
self::assertSame(3, $result['hidden_count']);
}
public function testNoUnreadablePostsKeepTheDeleteHint(): void
{
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('apply')->andReturn(PageRewriteReportTest::applied(1, 0));
$result = $this->ability($pages)->execute([
'preset' => ['module' => 'button', 'preset' => 'CTA primär'],
'confirm' => true,
]);
self::assertStringContainsString('lit/divi-presets-delete', $result['summary']);
self::assertStringNotContainsString('nicht sichtbare', $result['summary']);
}
public function testRemainingPagesAskForAnotherCallWithoutTheDeleteHint(): void
{
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('apply')->andReturn(PageRewriteReportTest::applied(2, 2));
$result = $this->ability($pages)->execute([
'preset' => ['module' => 'button', 'preset' => 'CTA primär'],
'confirm' => true,
]);
self::assertSame(2, $result['remaining']);
self::assertStringContainsString('Noch 2 Seiten offen', $result['summary']);
self::assertStringNotContainsString('lit/divi-presets-delete', $result['summary']);
}
/** Saves the preset step once (snapshot presets-4, log 7); for page failures after it. */
private function abilityAfterThePresetStep(PageRewriter $pages): DiviDesignDetachAbility
{
$writer = Mockery::mock(DesignSystemWriter::class);
$writer->allows('presets')->andReturn(PresetBookTest::stored());
$writer->allows('canUseVisualBuilder')->andReturn(true);
$writer->expects('savePresets')
->andReturn(PresetBook::fromDivi(PresetBookTest::stored())->withoutNestedRef('gggggggggg')->data());
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->expects('save')->andReturn('presets-4');
$snapshots->expects('attachLog');
return $this->ability($pages, $writer, $snapshots, new FakeChangeLog(7));
}
/** @return array<string, mixed> The confirmed nested call with the current design_rev. */
private static function confirmedLesetext(?array $stored = null): array
{
return [
'preset' => self::LESETEXT,
'confirm' => true,
'design_rev' => PresetBook::fromDivi($stored ?? PresetBookTest::stored())->rev(),
];
}
/** @return array<string, array{RuntimeException, string}> */
public static function firstPageFailures(): array
{
return [
'rolled back' => [WriteFailed::rolledBack('Seite 12 nicht gespeichert.'), 'write_failed'],
'Divi locked' => [new DiviLocked(
ReleaseMatrices::uniform(ReleaseState::Untested, '5.16.0'),
[DiviFeature::PageWrite]
), 'divi_untested'],
'Divi locked inside a rollback' => [
WriteFailed::rolledBack(
'Seite 12 nicht gespeichert.',
new DiviLocked(ReleaseMatrices::uniform(ReleaseState::Untested, '5.16.0'), [DiviFeature::PageWrite])
),
'divi_untested',
],
'partial' => [WriteFailed::partial('Seite 12 nicht gespeichert.', postId: 12), 'partial_write'],
];
}
/** @dataProvider firstPageFailures */
public function testAFirstPageFailureAfterThePresetStepSaysThePresetsAreChanged(
RuntimeException $failure,
string $code
): void {
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('apply')->andThrow($failure);
// error_log() is a PHP internal and cannot be stubbed; redirect it to a temp file instead.
$log = (string) tempnam(sys_get_temp_dir(), 'lit');
$previous = ini_set('error_log', $log);
try {
$result = $this->abilityAfterThePresetStep($pages)->execute(self::confirmedLesetext());
$logged = (string) file_get_contents($log);
} finally {
ini_set('error_log', (string) $previous);
unlink($log);
}
$error = self::assertToolError($result, $code);
self::assertStringContainsString('lit/divi-design-detach failed', $logged);
self::assertStringContainsString('bereits entfernt (Snapshot presets-4)', $error['message']);
self::assertStringNotContainsString('Nichts wurde gespeichert', $error['message']);
self::assertSame('presets-4', $error['snapshot_id']);
self::assertSame('dddddddddd', $error['diff'][0]['id']);
}
/** @dataProvider firstPageFailures */
public function testAFirstPageFailureWithoutAPresetStepIsThrownAsBefore(RuntimeException $failure): void
{
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('apply')->andThrow($failure);
$this->expectExceptionObject($failure);
$this->ability($pages)->execute([
'preset' => ['module' => 'button', 'preset' => 'CTA primär'],
'confirm' => true,
]);
}
/** @return array<string, array{UsageReport}> */
public static function blockingUsages(): array
{
return [
'live page without edit_post' => [new UsageReport(1, [], 0, 1, [12], 0, false)],
'too many live pages' => [new UsageReport(600, [], 0, 600, [], 0, true)],
];
}
/**
* Ruling P8a: the preset plan carries the page usages, so a blocked live page refuses before the presets.
*
* @dataProvider blockingUsages
*/
public function testABlockedLivePageRefusesBeforeThePresetsAreWritten(UsageReport $report): void
{
$ability = $this->ability(self::untouched(), null, null, null, $report);
$error = self::assertToolError($ability->execute(self::confirmedLesetext()), 'forbidden');
self::assertStringContainsString('nichts wurde gespeichert', $error['message']);
}
/** Final review I-2: the preset step saves the whole inventory, so HTML/CSS elsewhere needs unfiltered_html. */
public function testMarkupInOtherPresetsNeedsUnfilteredHtml(): void
{
$stored = PresetBookTest::storedWithMarkup();
$pages = Mockery::mock(PageRewriter::class);
$pages->allows('preview')->andReturn(PageRewriteReportTest::preview(1));
$pages->expects('apply')->never();
$ability = $this->ability($pages, self::readOnlyWriter($stored), null, null, null, false);
$dry = self::assertToolError($ability->execute(['preset' => self::LESETEXT]), 'forbidden');
$confirmed = self::assertToolError($ability->execute(self::confirmedLesetext($stored)), 'forbidden');
self::assertStringContainsString('unfiltered_html', $dry['message']);
self::assertStringContainsString('unfiltered_html', $confirmed['message']);
}
public function testNestingWithoutADesignRevWritesNeitherPresetsNorPages(): void
{
$error = $this->ability(self::untouched())->execute(['preset' => self::LESETEXT, 'confirm' => true]);
self::assertToolError($error, 'validation_failed');
self::assertArrayHasKey('design_rev', self::errorFields($error));
}
public function testSchemaAndMeta(): void
{
$ability = $this->ability(self::untouched());
$schema = $ability->inputSchema();
self::assertSame('lit/divi-design-detach', $ability->name());
self::assertFalse($ability->isDirect());
self::assertSame(['readonly' => false, 'destructive' => false, 'idempotent' => true], $ability->annotations());
self::assertFalse($schema['additionalProperties']);
self::assertSame(['preset'], $schema['properties']['preset']['required']);
self::assertArrayHasKey('design_rev', $schema['properties']);
self::assertArrayHasKey('variable', $schema['properties']);
self::assertSame(['saved', 'remaining', 'skipped'], array_slice($ability->outputSchema()['required'], -3));
self::assertStringContainsString('lit/divi-presets-delete', $ability->description());
}
}