VisibleEditsTest asserts the exact refusal messages (no unfiltered_html, trash disabled) and the keepingBackup() hints for rolled-back and partial failures with each caller's label and entry. The callers' own tests drive GlobalEdits, ThemeBuilderEdits (through DiviWriter) and make-global with revisions off and assert the same sentences. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
610 lines
27 KiB
PHP
610 lines
27 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Abilities\Rev;
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||
use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec;
|
||
use LIT\MCP\Integrations\Divi\Library\LibraryMakeGlobalAbility;
|
||
use LIT\MCP\Integrations\Divi\Library\NewLibraryEntry;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||
use LIT\MCP\Integrations\Divi\Pages\VisibleEdits;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||
use LIT\MCP\Tests\Unit\Integrations\Divi\Pages\VisibleEditsTest;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use Throwable;
|
||
use WP_Post;
|
||
|
||
final class LibraryMakeGlobalAbilityTest extends TestCase
|
||
{
|
||
private const BACKUP_HINT = 'Revisionen aktivieren (WP_POST_REVISIONS) oder die Seite im Divi-Builder bearbeiten.';
|
||
|
||
private WP_Post $page;
|
||
|
||
/** @var list<LibraryItemSpec> */
|
||
private array $specs = [];
|
||
|
||
/** @var list<array{int, string, SaveScope}> */
|
||
private array $entryContents = [];
|
||
|
||
/** @var list<string> Page contents DiviWriter saved. */
|
||
private array $saved = [];
|
||
|
||
private FakeChangeLog $log;
|
||
|
||
/** @var list<array{post_id: int, backup_id: int}> Backups VisibleEdits made. */
|
||
private array $backups = [];
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$this->page = LibraryFixtures::page(DiviFixtures::markup('stage3/page--globale-instanzen.html'));
|
||
LibraryFixtures::given([$this->page]);
|
||
Functions\when('wp_revisions_enabled')->justReturn(true);
|
||
$this->log = DiviWriterFixture::log();
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $deniedRoles
|
||
* @param list<Throwable> $pageFailures
|
||
*/
|
||
private function ability(
|
||
array $deniedRoles = [],
|
||
bool $diviWritable = true,
|
||
bool $unfiltered = false,
|
||
array $pageFailures = [],
|
||
?DiviPageLoader $loader = null,
|
||
?DiviWriter $writer = null
|
||
): LibraryMakeGlobalAbility {
|
||
$store = LibraryFixtures::store();
|
||
$store->allows('create')->andReturnUsing(function (LibraryItemSpec $spec): int {
|
||
$this->specs[] = $spec;
|
||
|
||
return 2300;
|
||
});
|
||
$saver = Mockery::mock(DiviSaver::class);
|
||
$saver->allows('saveNew')->andReturnUsing(function (int $id, string $content, SaveScope $scope): array {
|
||
$this->entryContents[] = [$id, $content, $scope];
|
||
|
||
return [];
|
||
});
|
||
$access = LibraryFixtures::access($deniedRoles, true, $store);
|
||
|
||
return new LibraryMakeGlobalAbility(
|
||
$access,
|
||
$writer ?? DiviWriterFixture::writer(
|
||
$this->saved,
|
||
$pageFailures,
|
||
$unfiltered,
|
||
$loader ?? LibraryFixtures::loader($access)
|
||
),
|
||
new NewLibraryEntry($access, $saver, DiviWriterFixture::modules()),
|
||
$this->log,
|
||
static fn (): bool => $diviWritable,
|
||
new VisibleEdits(function (WP_Post $post): int {
|
||
$this->backups[] = ['post_id' => (int) $post->ID, 'backup_id' => 1071];
|
||
|
||
return 1071;
|
||
})
|
||
);
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
private function input(array $extra = []): array
|
||
{
|
||
return array_merge(
|
||
['post_id' => 7, 'tree_rev' => Rev::of((string) $this->page->post_content), 'id' => 's1', 'title' => 'Leistungen'],
|
||
$extra
|
||
);
|
||
}
|
||
|
||
public function testReplacesTheNodeByAnInstanceOfTheNewGlobalEntry(): void
|
||
{
|
||
$result = $this->ability()->execute($this->input());
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertSame(2300, $result['library_id']);
|
||
self::assertTrue($this->specs[0]->global);
|
||
self::assertSame(
|
||
str_replace("\r\n", "\n", DiviFixtures::markup('stage3/library--library-abschnitt.html')),
|
||
$this->entryContents[0][1],
|
||
'the entry holds exactly the node, framed like the builder'
|
||
);
|
||
self::assertCount(1, $this->saved);
|
||
self::assertStringContainsString(
|
||
'<!-- wp:divi/global-layout {"globalModule":"2300","blockName":"divi/section","localAttrs":',
|
||
$this->saved[0]
|
||
);
|
||
self::assertStringNotContainsString(
|
||
'<!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Abschnitt"',
|
||
$this->saved[0],
|
||
'the plain section is gone'
|
||
);
|
||
self::assertNotSame([], array_filter($result['warnings'], static fn (string $w): bool => str_contains($w, 'lit/library-find-similar')));
|
||
}
|
||
|
||
public function testEditorCannotMakeACodeModuleGlobal(): void
|
||
{
|
||
$this->page = LibraryFixtures::page(
|
||
'<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
|
||
. '<!-- wp:divi/code {"content":{"innerContent":{"desktop":{"value":"<b>x</b>"}}}} /-->'
|
||
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->'
|
||
);
|
||
LibraryFixtures::given([$this->page]);
|
||
|
||
$result = $this->ability()->execute($this->input(['id' => 's0']));
|
||
|
||
self::assertToolError($result, 'validation_failed');
|
||
self::assertStringContainsString('Code-Modul', implode(' ', self::errorFields($result)));
|
||
self::assertSame([], $this->specs);
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
/** All or nothing: the page save failing removes the new entry again. */
|
||
public function testFailedPageSaveDeletesTheNewEntry(): void
|
||
{
|
||
Functions\expect('wp_delete_post')->once()->with(2300, true)->andReturn(new WP_Post(['ID' => 2300]));
|
||
|
||
try {
|
||
$this->ability([], true, false, [WriteFailed::rolledBack('Die Divi-Seite konnte nicht gespeichert werden.')])
|
||
->execute($this->input());
|
||
self::fail('no WriteFailed');
|
||
} catch (WriteFailed $e) {
|
||
self::assertTrue($e->rolledBack);
|
||
}
|
||
self::assertCount(1, $this->specs);
|
||
}
|
||
|
||
public function testRefusesNodesThatAreOrHoldGlobalModules(): void
|
||
{
|
||
self::assertToolError($this->ability()->execute($this->input(['id' => 'g0'])), 'validation_failed');
|
||
self::assertToolError($this->ability()->execute($this->input(['id' => 's0'])), 'validation_failed');
|
||
self::assertSame([], $this->specs);
|
||
}
|
||
|
||
public function testNeedsSaveGlobalAndInsertRightsAndTheDiviGate(): void
|
||
{
|
||
self::assertToolError($this->ability(['edit_global_library'])->execute($this->input()), 'forbidden');
|
||
self::assertToolError($this->ability(['add_library'])->execute($this->input()), 'forbidden');
|
||
self::assertToolError($this->ability([], false)->execute($this->input()), 'unsupported');
|
||
self::assertToolError($this->ability()->execute($this->input(['tree_rev' => 'alt'])), 'rev_mismatch');
|
||
self::assertSame([], $this->specs);
|
||
self::assertFalse($this->ability()->isDirect());
|
||
}
|
||
|
||
public function testLogsTheNewEntry(): void
|
||
{
|
||
$this->ability()->execute($this->input());
|
||
|
||
self::assertSame('et_pb_layout', $this->log->records[0]['object_type']);
|
||
self::assertSame('2300', $this->log->records[0]['object_id']);
|
||
self::assertStringContainsString('global', $this->log->records[0]['summary']);
|
||
}
|
||
|
||
/**
|
||
* Ruling Q7: a Divi 4 shortcode in a text value renders another entry (or, casting to 0, the newest
|
||
* layout – the entry being created); such a node never becomes a global entry.
|
||
*/
|
||
public function testRefusesAShortcodeReferenceInsideAValue(): void
|
||
{
|
||
$quote = chr(92) . '"';
|
||
foreach (['1058', 'abc'] as $ref) {
|
||
$this->page = LibraryFixtures::page(
|
||
'<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
|
||
. '<!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"[et_pb_text global_module='
|
||
. $quote . $ref . $quote . ']x[/et_pb_text]"}}}} /-->'
|
||
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->'
|
||
);
|
||
LibraryFixtures::given([$this->page]);
|
||
|
||
$result = $this->ability([], true, true)->execute($this->input(['id' => 's0']));
|
||
|
||
self::assertToolError($result, 'validation_failed');
|
||
self::assertStringContainsString('global_module', implode(' ', self::errorFields($result)));
|
||
}
|
||
self::assertSame([], $this->specs);
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
public function testLibraryEntriesAndThemeBuilderLayoutsAreNoSource(): void
|
||
{
|
||
$item = LibraryFixtures::item(1055);
|
||
LibraryFixtures::given([$item]);
|
||
$fromLibrary = $this->ability()->execute(
|
||
['post_id' => 1055, 'tree_rev' => Rev::of((string) $item->post_content), 'id' => 's0', 'title' => 'x']
|
||
);
|
||
self::assertToolError($fromLibrary, 'validation_failed');
|
||
self::assertArrayHasKey('post_id', self::errorFields($fromLibrary));
|
||
|
||
$body = ThemeBuilderFixtures::layout(ThemeBuilderFixtures::BODY);
|
||
LibraryFixtures::given([$body]);
|
||
Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path);
|
||
$loader = new DiviPageLoader(
|
||
DiviFixtures::schema(),
|
||
new Parser(),
|
||
LibraryFixtures::access(),
|
||
ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([ThemeBuilderFixtures::template()]))
|
||
);
|
||
$fromLayout = $this->ability([], true, false, [], $loader)->execute(
|
||
['post_id' => 1062, 'tree_rev' => Rev::of((string) $body->post_content), 'id' => 's0', 'title' => 'x']
|
||
);
|
||
self::assertToolError($fromLayout, 'validation_failed');
|
||
self::assertStringContainsString('Theme-Builder', $fromLayout->get_error_message());
|
||
|
||
self::assertSame([], $this->specs);
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
/** A page write that may have been saved keeps the entry – the page may already show it. */
|
||
public function testAPartialPageWriteKeepsTheEntry(): void
|
||
{
|
||
Functions\expect('wp_delete_post')->never();
|
||
|
||
$failures = [WriteFailed::partial('Die Divi-Seite konnte nicht gespeichert werden.', postId: 7)];
|
||
$design = DiviWriterFixture::designReader();
|
||
$writer = new DiviWriter(
|
||
LibraryFixtures::loader(),
|
||
DiviWriterFixture::saver($this->saved, $failures),
|
||
FakeChangeLog::any(),
|
||
$design,
|
||
DiviWriterFixture::composition($design)
|
||
);
|
||
|
||
try {
|
||
$this->ability([], true, false, [], null, $writer)->execute($this->input());
|
||
self::fail('no WriteFailed');
|
||
} catch (WriteFailed $e) {
|
||
self::assertFalse($e->rolledBack);
|
||
self::assertSame(2300, $e->details['library_id']);
|
||
}
|
||
}
|
||
/** Pages 8 and 9: a plain copy of the global section 1058; 9 also a variation with another text. */
|
||
private function givenCopiesOf1058(): string
|
||
{
|
||
$root = LibraryContent::roots(LibraryContent::load(
|
||
new Parser(),
|
||
DiviFixtures::markup('stage3/library-global--globaler-abschnitt.html')
|
||
))[0];
|
||
$copy = Serializer::block($root);
|
||
$variation = str_replace('Global', 'Lokal', $copy);
|
||
LibraryFixtures::given([
|
||
$this->page,
|
||
LibraryFixtures::item(1058),
|
||
new WP_Post(['ID' => 8, 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => 'Team',
|
||
'post_content' => '<!-- wp:divi/placeholder -->' . $copy . '<!-- /wp:divi/placeholder -->']),
|
||
new WP_Post(['ID' => 9, 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => 'Jobs',
|
||
'post_content' => '<!-- wp:divi/placeholder -->' . $variation . $copy . '<!-- /wp:divi/placeholder -->']),
|
||
]);
|
||
|
||
return $copy;
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
private static function targets(array $ids8 = ['s0'], array $ids9 = ['s1'], bool $confirm = false): array
|
||
{
|
||
return array_filter([
|
||
'library_id' => 1058,
|
||
'targets' => [
|
||
['post_id' => 8, 'tree_rev' => Rev::of((string) get_post(8)->post_content), 'ids' => $ids8],
|
||
['post_id' => 9, 'tree_rev' => Rev::of((string) get_post(9)->post_content), 'ids' => $ids9],
|
||
],
|
||
'confirm' => $confirm ?: null,
|
||
], static fn (mixed $value): bool => $value !== null);
|
||
}
|
||
|
||
public function testReplacingOccurrencesIsADryRunFirstThenEveryPageWithItsRevision(): void
|
||
{
|
||
$this->givenCopiesOf1058();
|
||
|
||
$dry = $this->ability()->execute(self::targets());
|
||
self::assertToolError($dry, 'confirm_required');
|
||
self::assertSame(2, $dry->get_error_data()['affected_count']);
|
||
self::assertSame([8, 9], array_column($dry->get_error_data()['affected'], 'post_id'));
|
||
self::assertSame([], $this->saved);
|
||
|
||
$result = $this->ability()->execute(self::targets(['s0'], ['s1'], true));
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertSame([8, 9], array_column($result['pages'], 'post_id'));
|
||
self::assertSame([811, 811], array_column($result['pages'], 'revision_id'));
|
||
self::assertCount(2, $this->saved);
|
||
self::assertStringContainsString('{"globalModule":"1058","blockName":"divi/section"', $this->saved[0]);
|
||
self::assertStringContainsString('Lokal', $this->saved[1], 'the variation stays');
|
||
self::assertSame([], $this->specs, 'no new entry');
|
||
}
|
||
|
||
public function testTargetsMustBeIdenticalEditableAndCurrent(): void
|
||
{
|
||
$this->givenCopiesOf1058();
|
||
|
||
$variation = $this->ability()->execute(self::targets(['s0'], ['s0'], true));
|
||
self::assertToolError($variation, 'validation_failed');
|
||
self::assertArrayHasKey('targets.1.ids.0', self::errorFields($variation));
|
||
|
||
$stale = self::targets(['s0'], ['s1'], true);
|
||
$stale['targets'][1]['tree_rev'] = 'alt';
|
||
self::assertToolError($this->ability()->execute($stale), 'rev_mismatch');
|
||
|
||
$library = self::targets(['s0'], ['s1'], true);
|
||
$library['targets'][1] = ['post_id' => 1055, 'tree_rev' => Rev::of((string) LibraryFixtures::item(1055)->post_content), 'ids' => ['s0']];
|
||
LibraryFixtures::given([LibraryFixtures::item(1055), LibraryFixtures::item(1058), get_post(8), get_post(9)]);
|
||
self::assertToolError($this->ability()->execute($library), 'validation_failed');
|
||
|
||
LibraryFixtures::given([LibraryFixtures::item(1058), get_post(8), get_post(9)]);
|
||
Functions\when('current_user_can')->alias(static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 9));
|
||
self::assertToolError($this->ability()->execute(self::targets(['s0'], ['s1'], true)), 'forbidden');
|
||
|
||
self::assertSame([], $this->saved, 'nothing was written in any case');
|
||
}
|
||
|
||
public function testOnlyGlobalEntriesWithOneReadableRoot(): void
|
||
{
|
||
$this->givenCopiesOf1058();
|
||
LibraryFixtures::given([LibraryFixtures::item(1055), get_post(8), get_post(9)]);
|
||
|
||
$normal = self::targets();
|
||
$normal['library_id'] = 1055;
|
||
self::assertToolError($this->ability()->execute($normal), 'validation_failed');
|
||
}
|
||
|
||
public function testAFailureMidwayNamesThePagesAlreadyChanged(): void
|
||
{
|
||
$this->givenCopiesOf1058();
|
||
|
||
try {
|
||
$this->ability([], true, false, [null, WriteFailed::rolledBack('Die Divi-Seite konnte nicht gespeichert werden.')])
|
||
->execute(self::targets(['s0'], ['s1'], true));
|
||
self::fail('no WriteFailed');
|
||
} catch (WriteFailed $e) {
|
||
self::assertSame([8], $e->details['changed_posts']);
|
||
}
|
||
}
|
||
|
||
/** The dry run lists every target with its tree_rev and whether visitors see it (P8a: drafts only listed). */
|
||
public function testTheDryRunListsTreeRevAndLiveState(): void
|
||
{
|
||
$this->givenCopiesOf1058();
|
||
|
||
$data = $this->ability()->execute(self::targets())->get_error_data();
|
||
|
||
self::assertSame(Rev::of((string) get_post(8)->post_content), $data['affected'][0]['tree_rev']);
|
||
self::assertTrue($data['affected'][0]['live']);
|
||
self::assertFalse($data['affected'][1]['live']);
|
||
self::assertSame(['s1'], $data['affected'][1]['ids']);
|
||
self::assertSame(1, $data['live_count']);
|
||
}
|
||
|
||
/** Ruling Q7: an entry that names another entry in any spelling never becomes an instance. */
|
||
public function testRefusesAnEntryWithAReferenceInAnySpelling(): void
|
||
{
|
||
$copy = $this->givenCopiesOf1058();
|
||
$quote = chr(92) . '"';
|
||
$content = str_replace(
|
||
'Globaler Abschnitt',
|
||
'[et_pb_text global_module=' . $quote . '1059' . $quote . ']x[/et_pb_text]',
|
||
DiviFixtures::markup('stage3/library-global--globaler-abschnitt.html')
|
||
);
|
||
LibraryFixtures::given([LibraryFixtures::item(1058, ['post_content' => $content]), get_post(8), get_post(9)]);
|
||
|
||
$result = $this->ability()->execute(self::targets(['s0'], ['s1'], true));
|
||
|
||
self::assertToolError($result, 'validation_failed');
|
||
self::assertArrayHasKey('library_id', self::errorFields($result));
|
||
self::assertSame([], $this->saved);
|
||
self::assertNotSame('', $copy);
|
||
}
|
||
|
||
public function testThemeBuilderLayoutsAreNoTargets(): void
|
||
{
|
||
$this->givenCopiesOf1058();
|
||
$input = self::targets(['s0'], ['s1'], true);
|
||
$body = ThemeBuilderFixtures::layout(ThemeBuilderFixtures::BODY);
|
||
LibraryFixtures::given([LibraryFixtures::item(1058), get_post(8), $body]);
|
||
Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path);
|
||
$loader = new DiviPageLoader(
|
||
DiviFixtures::schema(),
|
||
new Parser(),
|
||
LibraryFixtures::access(),
|
||
ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([ThemeBuilderFixtures::template()]))
|
||
);
|
||
$input['targets'][1] = ['post_id' => 1062, 'tree_rev' => Rev::of((string) $body->post_content), 'ids' => ['s0']];
|
||
|
||
$result = $this->ability([], true, false, [], $loader)->execute($input);
|
||
|
||
self::assertToolError($result, 'validation_failed');
|
||
self::assertArrayHasKey('targets.1.post_id', self::errorFields($result));
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
/** Replacing needs lit-library-insert's rights (not lit/library-save's) and a writable library group. */
|
||
public function testReplacingNeedsInsertRightsOnly(): void
|
||
{
|
||
$this->givenCopiesOf1058();
|
||
|
||
self::assertToolError($this->ability(['add_library'])->execute(self::targets([], [], true)), 'forbidden');
|
||
self::assertToolError($this->ability([], false)->execute(self::targets()), 'unsupported');
|
||
self::assertToolError($this->ability(['edit_global_library'])->execute(self::targets()), 'confirm_required');
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
/** Live pages without revisions get VisibleEdits' backup – refused before any page is written where it would lose bytes. */
|
||
public function testLivePagesWithoutRevisionsGetABackupOrNothingIsWritten(): void
|
||
{
|
||
$this->givenCopiesOf1058();
|
||
Functions\when('wp_revisions_enabled')->justReturn(false);
|
||
|
||
$result = $this->ability()->execute(self::targets(['s0'], ['s1'], true));
|
||
self::assertSame([['post_id' => 8, 'backup_id' => 1071]], $this->backups);
|
||
self::assertSame(1071, $result['pages'][0]['backup_id']);
|
||
self::assertArrayNotHasKey('backup_id', $result['pages'][1], 'drafts are only listed (P8a)');
|
||
|
||
$this->backups = [];
|
||
$this->saved = [];
|
||
$this->givenCopiesOf1058();
|
||
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'unfiltered_html');
|
||
$refused = $this->ability()->execute(self::targets(['s0'], ['s1'], true));
|
||
self::assertToolError($refused, 'unsupported');
|
||
self::assertSame(sprintf(VisibleEditsTest::UNFAITHFUL, 'Seite 8'), Errors::clientMessage($refused));
|
||
self::assertSame(self::BACKUP_HINT, $refused->get_error_data()['hint']);
|
||
self::assertSame([], $this->backups);
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
/** Review I-3 (ruling Q17 M3): the trash refusal and a failure after the backup name the page. */
|
||
public function testTheBackupWordingNamesThePage(): void
|
||
{
|
||
$this->givenCopiesOf1058();
|
||
Functions\when('wp_revisions_enabled')->justReturn(false);
|
||
$noFailures = [];
|
||
$ability = new LibraryMakeGlobalAbility(
|
||
LibraryFixtures::access(),
|
||
DiviWriterFixture::writer($this->saved, $noFailures, false, LibraryFixtures::loader()),
|
||
new NewLibraryEntry(LibraryFixtures::access(), Mockery::mock(DiviSaver::class), DiviWriterFixture::modules()),
|
||
$this->log,
|
||
null,
|
||
new VisibleEdits(static fn (WP_Post $post): int => 1071, static fn (): bool => false)
|
||
);
|
||
$noTrash = $ability->execute(self::targets(['s0'], ['s1'], true));
|
||
self::assertToolError($noTrash, 'unsupported');
|
||
self::assertSame(sprintf(VisibleEditsTest::NO_TRASH, 'Seite 8'), Errors::clientMessage($noTrash));
|
||
self::assertSame(self::BACKUP_HINT, $noTrash->get_error_data()['hint']);
|
||
self::assertSame([], $this->saved);
|
||
|
||
$this->givenCopiesOf1058();
|
||
try {
|
||
$this->ability([], true, false, [WriteFailed::rolledBack('Grund.')])
|
||
->execute(self::targets(['s0'], ['s1'], true));
|
||
self::fail('no WriteFailed');
|
||
} catch (WriteFailed $e) {
|
||
self::assertSame(1071, $e->details['backup_id']);
|
||
self::assertSame(VisibleEditsTest::keptRolledBack(1071, 'der Seite'), $e->toError()->get_error_data()['hint']);
|
||
self::assertStringContainsString(
|
||
Errors::notSavedKeeping('Sicherung 1071 im Papierkorb'),
|
||
Errors::clientMessage($e->toError())
|
||
);
|
||
}
|
||
}
|
||
|
||
/** A failure on page N: the pages before stay replaced (each with revision), the rest untouched – all named. */
|
||
public function testAFailureMidwayReportsEveryPage(): void
|
||
{
|
||
$this->givenCopiesOf1058();
|
||
|
||
try {
|
||
$this->ability([], true, false, [null, WriteFailed::rolledBack('Die Divi-Seite konnte nicht gespeichert werden.')])
|
||
->execute(self::targets(['s0'], ['s1'], true));
|
||
self::fail('no WriteFailed');
|
||
} catch (WriteFailed $e) {
|
||
self::assertSame(9, $e->details['failed_post']);
|
||
self::assertSame([], $e->details['unchanged_posts']);
|
||
self::assertSame(811, $e->details['pages'][0]['revision_id']);
|
||
self::assertStringNotContainsString('Nichts wurde gespeichert', (string) $e->toError()->get_error_message());
|
||
}
|
||
self::assertCount(1, $this->saved);
|
||
}
|
||
|
||
public function testAFailureOnTheFirstPageLeavesEverythingUnchanged(): void
|
||
{
|
||
$this->givenCopiesOf1058();
|
||
|
||
try {
|
||
$this->ability([], true, false, [WriteFailed::rolledBack('Die Divi-Seite konnte nicht gespeichert werden.')])
|
||
->execute(self::targets(['s0'], ['s1'], true));
|
||
self::fail('no WriteFailed');
|
||
} catch (WriteFailed $e) {
|
||
self::assertSame([], $e->details['changed_posts']);
|
||
self::assertSame([9], $e->details['unchanged_posts']);
|
||
}
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
/** @return array<string, array{string}> */
|
||
public static function visibleExtras(): array
|
||
{
|
||
return [
|
||
'core paragraph' => ['<!-- wp:paragraph --><p>ONLY ON 8</p><!-- /wp:paragraph -->'],
|
||
'raw text part' => ['<p>ONLY ON 8</p>'],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Ruling Q25 (review of Task 15): a copy with extra visible text – a core paragraph or raw HTML between
|
||
* blocks – is no identical occurrence; replacing it would swap visible content.
|
||
*
|
||
* @dataProvider visibleExtras
|
||
*/
|
||
public function testACopyWithExtraVisibleTextIsNoIdenticalOccurrence(string $extra): void
|
||
{
|
||
$copy = $this->givenCopiesOf1058();
|
||
$at = strrpos($copy, '<!-- /wp:divi/column -->');
|
||
$changed = substr($copy, 0, (int) $at) . $extra . substr($copy, (int) $at);
|
||
LibraryFixtures::given([
|
||
LibraryFixtures::item(1058),
|
||
new WP_Post(['ID' => 8, 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => 'Team',
|
||
'post_content' => '<!-- wp:divi/placeholder -->' . $changed . '<!-- /wp:divi/placeholder -->']),
|
||
get_post(9),
|
||
]);
|
||
|
||
$result = $this->ability()->execute(self::targets(['s0'], ['s1'], true));
|
||
|
||
self::assertToolError($result, 'validation_failed');
|
||
self::assertArrayHasKey('targets.0.ids.0', self::errorFields($result));
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
/** Scope (ruling Q25): only pages and posts – other Divi-enabled types are neither targets nor sources. */
|
||
public function testOnlyPagesAndPostsAreTargetsAndSources(): void
|
||
{
|
||
$this->givenCopiesOf1058();
|
||
$project = get_post(8);
|
||
$project->post_type = 'project';
|
||
LibraryFixtures::given([LibraryFixtures::item(1058), $project, get_post(9)]);
|
||
|
||
$target = $this->ability()->execute(self::targets(['s0'], ['s1'], true));
|
||
self::assertToolError($target, 'validation_failed');
|
||
self::assertArrayHasKey('targets.0.post_id', self::errorFields($target));
|
||
|
||
$source = $this->ability()->execute(
|
||
['post_id' => 8, 'tree_rev' => Rev::of((string) $project->post_content), 'id' => 's0', 'title' => 'x']
|
||
);
|
||
self::assertToolError($source, 'validation_failed');
|
||
self::assertArrayHasKey('post_id', self::errorFields($source));
|
||
self::assertSame([], $this->saved);
|
||
self::assertSame([], $this->specs);
|
||
}
|
||
|
||
/** A rolled-back page N>1 keeps DiviSaver's note on the revision that stays. */
|
||
public function testAFailureMidwayKeepsTheRevisionNote(): void
|
||
{
|
||
$this->givenCopiesOf1058();
|
||
$kept = WriteFailed::rolledBack(
|
||
'Die Divi-Seite konnte nicht gespeichert werden.',
|
||
state: \LIT\MCP\Abilities\Errors::notSavedKeeping('Revision 812')
|
||
);
|
||
|
||
try {
|
||
$this->ability([], true, false, [null, $kept])->execute(self::targets(['s0'], ['s1'], true));
|
||
self::fail('no WriteFailed');
|
||
} catch (WriteFailed $e) {
|
||
$message = (string) $e->toError()->get_error_message();
|
||
self::assertStringContainsString('Revision 812 bleibt erhalten', $message);
|
||
self::assertStringContainsString('Bereits ersetzt', $message);
|
||
}
|
||
}
|
||
}
|