Contents handed to DiviSaver::save(). */ private array $saved = []; private DiviWriter $writer; protected function setUp(): void { parent::setUp(); $this->original = DiviPageLoaderTest::fixture('synthetic-basic.html'); $this->post = DiviPageLoaderTest::givenPost($this->original); Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true'); $modules = Mockery::mock(DiviModules::class); $modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog()); $modules->allows('builderVersion')->andReturn('5.2.1'); $design = Mockery::mock(DesignSystemReader::class); $design->allows('read')->andReturn(DesignSystemTest::sample()); $composition = new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media()); $saver = Mockery::mock(DiviSaver::class); $saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content): array { $this->saved[] = $content; $post->post_content = $content; return ['revision_id' => 811, 'warnings' => []]; }); $log = Mockery::mock(ChangeLog::class); $log->allows('record')->andReturn(4711); $this->writer = new DiviWriter( new DiviPageLoader(DiviPageLoaderTest::schema()), $saver, $log, $design, $composition ); } private function treeRev(): string { return Rev::of($this->original); } private function insert(): DiviInsertAbility { return new DiviInsertAbility($this->writer); } private function update(): DiviUpdateAbility { return new DiviUpdateAbility($this->writer); } private function move(): DiviMoveAbility { return new DiviMoveAbility($this->writer); } private function delete(): DiviDeleteAbility { return new DiviDeleteAbility($this->writer); } private function duplicate(): DiviDuplicateNodeAbility { return new DiviDuplicateNodeAbility($this->writer); } /** @return array The structured error the client sees (message JSON, spec §8.2). */ private static function clientError(WP_Error $error): array { return (array) json_decode($error->get_error_message(), true); } public function testInsertComposesAndSavesWithTreeRevAndPostRev(): void { $result = $this->insert()->execute([ 'post_id' => 7, 'tree_rev' => $this->treeRev(), 'after' => 's0.r0.c1.m1', 'nodes' => [['type' => 'text', 'content' => '

Neu

']], ]); self::assertTrue($result['ok'], (string) json_encode($result)); self::assertCount(1, $this->saved); self::assertSame([['id' => 's0.r0.c1.m2', 'type' => 'divi/text', 'action' => 'inserted', 'label' => 'Hero – Text']], $result['changed']); self::assertSame(Rev::of($this->saved[0]), $result['tree_rev']); self::assertSame(Rev::ofPost($this->post), $result['rev']); self::assertSame(811, $result['revision_id']); self::assertSame(4711, $result['log_id']); self::assertStringStartsWith('s0 section "Hero"', (string) $result['outline']); self::assertSame('https://kunde.test/?page_id=7&et_fb=1', $result['edit_url']); // Byte identity: only the new block was added, every other byte is unchanged. $inserted = ''; self::assertSame(str_replace('' . $inserted . '', $this->saved[0]); self::assertSame('s0', $result['changed'][0]['id']); self::assertSame('Start', $result['changed'][0]['label']); } public function testUpdateChangesSeveralNodesAllOrNothing(): void { $failed = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [ ['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Jetzt buchen']], ['id' => 's0.r0.c1.m0', 'set' => ['level' => 'h9']], ['id' => 's0.r0.c1.m9', 'label' => 'x'], ]]); self::assertSame('lit_validation_failed', $failed->get_error_code()); $fields = self::clientError($failed)['fields']; self::assertArrayHasKey('changes[1].set.level', $fields); self::assertArrayHasKey('changes[2].id', $fields, 'every error of the call in one response (S39)'); self::assertSame([], $this->saved); $result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [ ['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Jetzt buchen']], ['id' => 's0.r0.c1.m0', 'attrs' => ['title.decoration.font.font' => ['phone' => null]]], ]]); self::assertTrue($result['ok']); self::assertSame(['s0.r0.c1.m2', 's0.r0.c1.m0'], array_column($result['changed'], 'id')); self::assertSame(['updated', 'updated'], array_column($result['changed'], 'action')); self::assertSame(Rev::of($this->saved[0]), $result['tree_rev']); $index = NodeIndex::build((new Parser())->parse($this->saved[0])); self::assertSame('Jetzt buchen', $index->find('s0.r0.c1.m2')?->attrs()['button']['innerContent']['desktop']['value']['text']); self::assertArrayNotHasKey('phone', $index->find('s0.r0.c1.m0')?->attrs()['title']['decoration']['font']['font']); // Untouched section s1 keeps its exact bytes. self::assertStringContainsString(substr($this->original, (int) strpos($this->original, ''; self::assertStringContainsString($buttonBytes, $this->original, 'precondition: exact bytes as expected'); $result = $this->delete()->execute([ 'post_id' => 7, 'tree_rev' => $this->treeRev(), 'ids' => ['s0.r0.c1.m2'], 'confirm' => true, ]); self::assertSame([['id' => 's0.r0.c1.m2', 'type' => 'divi/button', 'action' => 'deleted', 'label' => 'Hero – Button']], $result['changed']); self::assertSame(str_replace($buttonBytes, '', $this->original), $this->saved[0]); self::assertSame(811, $result['revision_id'], 'the restore-point revision'); self::assertStringContainsString('Hero', (string) $result['outline'], 'the outline is embedded in the result'); } /** An opaque/global module is removed as a whole – it never gets descended into. */ public function testDeleteRemovesAnOpaqueGlobalModuleAsAWhole(): void { $mixed = DiviPageLoaderTest::fixture('synthetic-mixed.html'); $this->post->post_content = $mixed; $globalRow = ''; self::assertStringContainsString($globalRow, $mixed, 'precondition: exact bytes as expected'); $result = $this->delete()->execute([ 'post_id' => 7, 'tree_rev' => Rev::of($mixed), 'ids' => ['s1.g1'], 'confirm' => true, ]); self::assertSame([['id' => 's1.g1', 'type' => 'divi/row', 'action' => 'deleted', 'label' => 'Globale Zeile']], $result['changed']); self::assertSame(str_replace($globalRow, '', $mixed), $this->saved[0]); } public function testDeletingTheLastChildLeavesAValidTree(): void { $mixed = DiviPageLoaderTest::fixture('synthetic-mixed.html'); $this->post->post_content = $mixed; $result = $this->delete()->execute([ 'post_id' => 7, 'tree_rev' => Rev::of($mixed), 'ids' => ['s0.c1.r0.c0.m0'], 'confirm' => true, ]); self::assertTrue($result['ok'], (string) json_encode($result)); $index = NodeIndex::build((new Parser())->parse($this->saved[0])); $emptyColumn = $index->find('s0.c1.r0.c0'); self::assertNotNull($emptyColumn, 'the now-empty column-inner is still a valid, findable node'); self::assertSame([], $emptyColumn->children()); } public function testDeleteUnknownIdReturnsNotFound(): void { $result = $this->delete()->execute([ 'post_id' => 7, 'tree_rev' => $this->treeRev(), 'ids' => ['s9'], 'confirm' => true, ]); self::assertSame('lit_not_found', $result->get_error_code()); self::assertSame('not_found', self::clientError($result)['code']); self::assertSame([], $this->saved); } /** Same annotations as the existing destructive tools (e.g. MediaDeleteAbility). */ public function testDeleteAnnotationsMatchExistingDestructiveTools(): void { self::assertSame( ['readonly' => false, 'destructive' => true, 'idempotent' => false], $this->delete()->annotations() ); } /** Ruling S14 (Task 7): the deleted node's own confirm question, unaffected by other tests. */ public function testDeleteAsksBeforeRemovingNestedNodesOnlyOnce(): void { $duplicated = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'id' => 's1']); self::assertSame('s2', $duplicated['changed'][0]['id']); $delete = $this->delete(); $ask = $delete->execute(['post_id' => 7, 'tree_rev' => Rev::of($this->saved[0]), 'ids' => ['s2', 's2.r0']]); self::assertSame('lit_confirm_required', $ask->get_error_code()); self::assertSame( '1 Divi-Knoten würden samt Inhalt entfernt: s2 section "Leistungen (Kopie)" (1 Kind-Knoten).', json_decode($ask->get_error_message(), true)['message'] ); $deleted = $delete->execute(['post_id' => 7, 'tree_rev' => Rev::of($this->saved[0]), 'ids' => ['s2'], 'confirm' => true]); self::assertSame([['id' => 's2', 'type' => 'divi/section', 'action' => 'deleted', 'label' => 'Leistungen (Kopie)']], $deleted['changed']); self::assertSame($this->original, $this->saved[1], 'deleting the copy restores the exact bytes'); } /** The copy sits directly after the original; its own child content is byte-identical, nothing else changes. */ public function testDuplicateInsertsTheCopyDirectlyAfterTheOriginal(): void { $sectionOpen = ''; $sectionClose = ''; $start = strpos($this->original, $sectionOpen) + strlen($sectionOpen); $end = strpos($this->original, $sectionClose, $start); $rowBytes = substr($this->original, $start, $end - $start); $original = $sectionOpen . $rowBytes . $sectionClose; self::assertStringContainsString($original, $this->original, 'precondition: exact bytes as expected'); $result = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'id' => 's1']); self::assertSame('s2', $result['changed'][0]['id']); self::assertSame('Leistungen (Kopie)', $result['changed'][0]['label']); self::assertStringContainsString('Leistungen', (string) $result['outline'], 'the outline is embedded in the result'); $copyOpen = str_replace('"Leistungen"', '"Leistungen (Kopie)"', $sectionOpen); $copy = $copyOpen . $rowBytes . $sectionClose; self::assertSame( str_replace($original, $original . $copy, $this->original), $this->saved[0], 'copy directly after the original, byte-identical child content, nothing else changes' ); } /** A global module copy stays a byte-identical reference (read-only nodes move/duplicate as a whole). */ public function testDuplicateGlobalModuleCopyIsAByteIdenticalReference(): void { $mixed = DiviPageLoaderTest::fixture('synthetic-mixed.html'); $this->post->post_content = $mixed; $globalRow = ''; self::assertStringContainsString($globalRow, $mixed, 'precondition: exact bytes as expected'); $result = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => Rev::of($mixed), 'id' => 's1.g1']); self::assertSame('s1.g2', $result['changed'][0]['id']); self::assertSame('divi/row', $result['changed'][0]['type']); self::assertSame('Globale Zeile', $result['changed'][0]['label'], 'opaque copies keep their label unchanged'); self::assertSame(str_replace($globalRow, $globalRow . $globalRow, $mixed), $this->saved[0]); } /** * Fix round 1 (Important-1): the copy's admin label must be the raw stored desktop value * plus " (Kopie)" – never NodeSummary::label()'s display-shortened text (80-char limit). */ public function testDuplicateCopiesALongAdminLabelVerbatim(): void { $long = str_repeat('Ausführliche Beschriftung, ', 4) . 'Ende'; self::assertGreaterThan(80, strlen($long), 'precondition: longer than the NodeSummary::label() display limit'); $content = str_replace( '"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero – Button"}}}}', '"module":{"meta":{"adminLabel":{"desktop":{"value":"' . $long . '"}}}}', $this->original ); self::assertNotSame($content, $this->original, 'precondition: replaced exactly once'); $this->post->post_content = $content; $result = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => Rev::of($content), 'id' => 's0.r0.c1.m2']); self::assertTrue($result['ok'], (string) json_encode($result)); $index = NodeIndex::build((new Parser())->parse($this->saved[0])); $copy = $index->find('s0.r0.c1.m3'); self::assertNotNull($copy, 'the duplicate sits right after the original module'); self::assertSame( $long . ' (Kopie)', AttrMap::desktop($copy->attrs(), 'module.meta.adminLabel'), 'stored verbatim, not shortened/ellipsised for display' ); } public function testDuplicateCopiesALabelWithNewlinesAndMultipleSpacesVerbatim(): void { $messy = "Erste Zeile mit viel Leerraum\nZweite Zeile"; $encoded = substr((string) json_encode($messy, JSON_UNESCAPED_UNICODE), 1, -1); $content = str_replace( '"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero – Button"}}}}', '"module":{"meta":{"adminLabel":{"desktop":{"value":"' . $encoded . '"}}}}', $this->original ); self::assertNotSame($content, $this->original, 'precondition: replaced exactly once'); $this->post->post_content = $content; $result = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => Rev::of($content), 'id' => 's0.r0.c1.m2']); self::assertTrue($result['ok'], (string) json_encode($result)); $index = NodeIndex::build((new Parser())->parse($this->saved[0])); $copy = $index->find('s0.r0.c1.m3'); self::assertNotNull($copy, 'the duplicate sits right after the original module'); self::assertSame( $messy . ' (Kopie)', AttrMap::desktop($copy->attrs(), 'module.meta.adminLabel'), 'whitespace/newlines kept verbatim, not collapsed' ); } /** Permission denied (edit_post false) for all three new abilities. */ public function testPermissionDeniedWhenNotEditable(): void { Functions\when('current_user_can')->justReturn(false); foreach ([$this->move(), $this->delete(), $this->duplicate()] as $ability) { $result = $ability->permission(['post_id' => 7, 'tree_rev' => $this->treeRev()]); self::assertInstanceOf(WP_Error::class, $result, $ability->name()); self::assertSame('lit_forbidden', $result->get_error_code(), $ability->name()); } self::assertSame([], $this->saved); } public function testReadOnlyNodesCannotBeUpdated(): void { $this->post->post_content = DiviPageLoaderTest::fixture('synthetic-mixed.html'); $result = $this->update()->execute([ 'post_id' => 7, 'tree_rev' => Rev::of($this->post->post_content), 'changes' => [['id' => 's1.g1', 'label' => 'x']], ]); self::assertSame('lit_validation_failed', $result->get_error_code()); self::assertStringContainsString('nur lesbar', $result->get_error_message()); self::assertSame([], $this->saved); } /** Ruling S14: the same node twice in one call would silently lose the first change. */ public function testUpdateRefusesADuplicateId(): void { $result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [ ['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Eins']], ['id' => 's0.r0.c1.m2', 'set' => ['url' => '/zwei']], ]]); self::assertSame('lit_validation_failed', $result->get_error_code()); self::assertStringContainsString('changes[0]', self::clientError($result)['fields']['changes[1].id']); self::assertSame([], $this->saved); } /** Ruling S38: set and attrs of one change must not reach the same attribute path. */ public function testUpdateRefusesSetAndAttrsOnTheSamePath(): void { $result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [[ 'id' => 's0.r0.c1.m2', 'set' => ['text' => 'Neu'], 'attrs' => ['button.innerContent' => ['desktop' => ['linkUrl' => '/neu']]], ]]]); self::assertSame('lit_validation_failed', $result->get_error_code()); self::assertArrayHasKey('changes[0].attrs.button.innerContent', self::clientError($result)['fields']); self::assertSame([], $this->saved); } /** Ruling S40: the S38 overlap rule for new nodes too. */ public function testInsertRefusesShortFormAndAttrsOnTheSamePath(): void { $result = $this->insert()->execute([ 'post_id' => 7, 'tree_rev' => $this->treeRev(), 'parent' => 's0.r0.c1', 'nodes' => [[ 'type' => 'button', 'text' => 'Neu', 'attrs' => ['button.innerContent' => ['desktop' => ['linkUrl' => '/neu']]], ]], ]); self::assertSame('lit_validation_failed', $result->get_error_code()); self::assertArrayHasKey('nodes[0].attrs.button.innerContent', self::clientError($result)['fields']); self::assertSame([], $this->saved); } /** Ruling S41: a column width change must still fill its row exactly. */ public function testUpdateChecksTheRowWidths(): void { $sizing = static fn (string $width): array => ['module.decoration.sizing' => ['desktop' => ['flexType' => $width]]]; $broken = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [ ['id' => 's0.r0.c0', 'attrs' => $sizing('12_24')], ]]); self::assertSame('lit_validation_failed', $broken->get_error_code()); self::assertArrayHasKey('changes[0].attrs.module.decoration.sizing', self::clientError($broken)['fields']); self::assertSame([], $this->saved); $result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [ ['id' => 's0.r0.c0', 'attrs' => $sizing('12_24')], ['id' => 's0.r0.c1', 'attrs' => $sizing('12_24')], ]]); self::assertTrue($result['ok'], (string) json_encode($result)); self::assertCount(1, $this->saved); self::assertStringContainsString('"flexType":"12_24"}}}}},"builderVersion":"5.2.1"} -->