Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php
T
janniclas.loosenandClaude Opus 5.5 9ed5d9d2fa refactor: rename for intent (L3)
PostChangeResult is PostSaveFollowUp (the after-save pipeline, not a
result); DiviChange::changed() is record() (moved nodes are recorded but
not touched). cleanValue/withoutControls were renamed in wave 2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 18:32:18 +02:00

216 lines
9.2 KiB
PHP

<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Pages\DiviChange;
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
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\DynamicContentMap;
use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Support\DiviWriterFixture;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class DiviWriterTest extends TestCase
{
/** @var list<int> */
private array $registryCalls = [];
private function writer(DiviSaver $saver, ChangeLog $log): DiviWriter
{
$design = DiviWriterFixture::designReader();
$registry = new DynamicContentMap(function (int $postId): array {
$this->registryCalls[] = $postId;
return DiviFixtures::dynamicContentRegistry();
});
return new DiviWriter(
new DiviPageLoader(DiviFixtures::schema()),
$saver,
$log,
$design,
DiviWriterFixture::composition($design, false, $registry)
);
}
public function testPageChecksTheTreeRevLock(): void
{
$content = DiviFixtures::markup('synthetic-basic.html');
DiviFixtures::givenPost($content);
$writer = $this->writer(Mockery::mock(DiviSaver::class), FakeChangeLog::forbidden());
self::assertInstanceOf(DiviPage::class, $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]));
self::assertToolError($writer->page(['post_id' => 7]), 'rev_mismatch');
$stale = $writer->page(['post_id' => 7, 'tree_rev' => Rev::ofPost(DiviFixtures::givenPost($content))]);
self::assertToolError($stale, 'rev_mismatch', 'the post rev is no tree_rev');
self::assertStringContainsString('nichts wurde', $stale->get_error_message());
}
public function testPageRefusesWhatIsNotWritable(): void
{
$content = DiviFixtures::markup('synthetic-basic.html');
DiviFixtures::givenPost($content);
$writer = new DiviWriter(
new DiviPageLoader(DiviFixtures::schema(false)),
Mockery::mock(DiviSaver::class),
FakeChangeLog::forbidden(),
Mockery::mock(DesignSystemReader::class),
Mockery::mock(DiviComposition::class)
);
self::assertToolError($writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]), 'unsupported');
}
public function testSectionLabelOfANode(): void
{
DiviFixtures::givenPost(DiviFixtures::markup('synthetic-basic.html'));
$page = (new DiviPageLoader(DiviFixtures::schema()))->load(7, true);
self::assertSame('Hero', $page->index->sectionLabel($page->index->find('s0.r0.c1')));
self::assertSame('', $page->index->sectionLabel(null));
}
public function testCompositionIsBoundToThePage(): void
{
$content = DiviFixtures::markup('synthetic-basic.html');
DiviFixtures::givenPost($content);
$writer = $this->writer(Mockery::mock(DiviSaver::class), FakeChangeLog::forbidden());
$page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]);
$writer->composition($page)->references();
self::assertSame([7], $this->registryCalls, 'the live dynamic content map of post 7 (ruling S22)');
}
public function testUnchangedTreeIsNotSaved(): void
{
$content = DiviFixtures::markup('synthetic-basic.html');
DiviFixtures::givenPost($content);
$saver = Mockery::mock(DiviSaver::class);
$saver->expects('save')->never();
$writer = $this->writer($saver, FakeChangeLog::forbidden());
$page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]);
$result = $writer->save('lit/divi-update', $page, new DiviChange('nichts'), []);
self::assertSame(Rev::of($content), $result['tree_rev']);
self::assertStringStartsWith('Keine Änderung', $result['summary']);
}
public function testSavesAndReportsChangedNodesOutlineAndTheNewTreeRev(): void
{
$content = DiviFixtures::markup('synthetic-basic.html');
DiviFixtures::givenPost($content);
$stored = null;
$saver = Mockery::mock(DiviSaver::class);
$saver->expects('save')->andReturnUsing(static function (WP_Post $post, string $new) use (&$stored): array {
$stored = $new;
DiviFixtures::givenPost($new);
return ['revision_id' => 811, 'warnings' => ['vom Speichern']];
});
$log = new FakeChangeLog(55);
$input = ['post_id' => 7, 'tree_rev' => Rev::of($content)];
Functions\expect('do_action')->once()->with('lit_mcp_after_save', 7, 'lit/divi-update', $input);
Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true');
$writer = $this->writer($saver, $log);
$page = $writer->page($input);
$button = $page->index->find('s0.r0.c1.m2');
$removed = $page->index->find('s1');
$button->setAttrs(AttrMap::set($button->attrs(), 'button.innerContent.desktop.value.text', 'Neu'));
$page->document->children()[0]->removeChild($removed);
$change = (new DiviChange('Button geändert'))
->record($button, 'updated')
->removed('s1', $removed)
->warning('eigene Warnung');
$result = $writer->save('lit/divi-update', $page, $change, $input);
self::assertIsString($stored);
self::assertStringNotContainsString('wp:divi/blurb', $stored);
self::assertSame(Rev::of($stored), $result['tree_rev']);
self::assertSame(811, $result['revision_id']);
self::assertSame(55, $result['log_id']);
self::assertSame('https://kunde.test/?page_id=7&et_fb=1', $result['edit_url']);
self::assertSame('https://kunde.test/?page_id=7&preview=true', $result['preview_url']);
self::assertSame('Button geändert (Status unverändert: draft).', $result['summary']);
self::assertSame(
[['s0.r0.c1.m2', 'divi/button', 'updated'], ['s1', 'divi/section', 'deleted']],
array_map(static fn (array $c): array => [$c['id'], $c['type'], $c['action']], $result['changed'])
);
self::assertStringContainsString('Hero', (string) $result['outline']);
self::assertStringNotContainsString('Leistungen', (string) $result['outline'], 'only affected sections');
self::assertContains('eigene Warnung', $result['warnings']);
self::assertContains('vom Speichern', $result['warnings']);
self::assertSame(Rev::ofPost(DiviFixtures::givenPost($stored)), $result['rev']);
$log->assertRecorded([
'ability' => 'lit/divi-update',
'object_type' => 'page',
'object_id' => '7',
'summary' => 'Divi "Start": Button geändert',
'revision_id' => 811,
]);
}
public function testFailedRollbackIsLoggedAsPartial(): void
{
$content = DiviFixtures::markup('synthetic-basic.html');
DiviFixtures::givenPost($content);
$saver = Mockery::mock(DiviSaver::class);
$saver->allows('save')->andThrow(WriteFailed::partial(DiviSaver::REASON, postId: 7, revisionId: 811));
$log = FakeChangeLog::partialsOnly();
$writer = $this->writer($saver, $log);
$page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]);
$page->document->children()[0]->removeChild($page->index->find('s1'));
try {
$writer->save('lit/divi-delete', $page, new DiviChange('s1 gelöscht'), []);
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertFalse($e->rolledBack);
}
$log->assertPartials([
'ability' => 'lit/divi-delete',
'object_type' => 'page',
'object_id' => '7',
'summary' => 'Divi "Start": Änderung nur teilweise zurückgenommen',
'revision_id' => 811,
]);
}
public function testCleanFailureIsNotLogged(): void
{
$content = DiviFixtures::markup('synthetic-basic.html');
DiviFixtures::givenPost($content);
$saver = Mockery::mock(DiviSaver::class);
$saver->allows('save')->andThrow(WriteFailed::rolledBack(DiviSaver::REASON, postId: 7, revisionId: 811));
$log = FakeChangeLog::forbidden();
$writer = $this->writer($saver, $log);
$page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]);
$page->document->children()[0]->removeChild($page->index->find('s1'));
try {
$writer->save('lit/divi-delete', $page, new DiviChange('s1 gelöscht'), []);
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
}
$log->assertRecorded();
$log->assertPartials();
}
}