'on', 'queryType' => 'post_types']; private const TERM_LOOP = ['enable' => 'on', 'queryType' => 'post_taxonomies']; protected function setUp(): void { parent::setUp(); $this->original = self::page(); $this->post->post_content = $this->original; $registry = self::registry(); $this->writer = DiviWriterFixture::writer( $this->saved, registry: new DynamicContentMap(static fn (int $postId): array => $registry) ); } /** DiviFixtures' registry plus the loop options these tests name (Divi 5.15.1 groups and fields). */ private static function registry(): array { $field = static fn (string $type): array => ['label' => 'x', 'type' => $type, 'default' => '']; $common = ['before' => $field('text'), 'after' => $field('text'), 'loop_position' => $field('text')]; $option = static fn (string $id, string $group, array $fields): array => [ 'id' => $id, 'label' => $id, 'type' => 'text', 'custom' => false, 'group' => $group, 'fields' => $fields, ]; return DiviFixtures::dynamicContentRegistry() + [ 'loop_post_date' => $option('loop_post_date', 'Loop', $common + [ 'date_format' => $field('select'), 'custom_date_format' => $field('text'), ]), 'loop_term_name' => $option('loop_term_name', 'Loop Terms', $common), 'loop_post_meta_key_manual_custom_field' => $option('loop_post_meta_key_manual_custom_field', 'Loop', $common + [ 'select_loop_meta_key' => $field('select'), 'loop_meta_key' => $field('text'), ]), ]; } /** @param array $attrs */ private static function block(string $name, array $attrs, string $inner = ''): string { $json = (string) json_encode($attrs + ['builderVersion' => '5.2.1'], JSON_UNESCAPED_UNICODE); return sprintf('%s', $name, $json, $inner, $name); } /** @param array $loop */ private static function loop(array $loop): array { return ['module' => ['advanced' => ['loop' => ['desktop' => ['value' => $loop]]]]]; } private static function text(string $value): string { return self::block('text', ['content' => ['innerContent' => ['desktop' => ['value' => $value]]]]); } private static function page(): string { $title = VariableRef::serialize('content', 'loop_post_title', ['before' => '', 'after' => '']); $term = VariableRef::serialize('content', 'loop_term_name', ['before' => '', 'after' => '']); $own = self::block('text', self::loop(self::TERM_LOOP) + [ 'content' => ['innerContent' => ['desktop' => ['value' => $term]]], ]); return '' . self::block('section', [], self::block('row', [], implode('', [ self::block('column', self::loop(self::POST_LOOP), self::text($title) . self::text('

Fest

') . $own), self::block('column', self::loop(self::TERM_LOOP), self::text('

Term

')), self::block('column', [], self::text('

Ohne

')), ]))) . ''; } /** @param array $input */ private function insertInto(string $parent, array $input): mixed { return $this->insert()->execute( ['post_id' => 7, 'tree_rev' => $this->currentRev(), 'parent' => $parent] + $input ); } /** @param list> $changes */ private function changeAll(array $changes): mixed { return $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->currentRev(), 'changes' => $changes]); } /** @param array $input */ private function moveNode(array $input): mixed { return $this->move()->execute(['post_id' => 7, 'tree_rev' => $this->currentRev()] + $input); } /** tree_rev of what the page holds now (the saver puts every saved content on the post). */ private function currentRev(): string { return Rev::of((string) $this->post->post_content); } private static function problems(mixed $error): string { self::assertInstanceOf(WP_Error::class, $error); return implode(' | ', self::errorFields($error)); } public function testInsertUsesTheLoopOfTheTargetParent(): void { $result = $this->insertInto('s0.r0.c0', ['nodes' => [ ['type' => 'text', 'content' => '{{loop:post_date}}'], ['type' => 'text', 'content' => '{{loop:post_title}}'], ]]); self::assertTrue($result['ok'], (string) json_encode($result)); $date = VariableRef::serialize('content', 'loop_post_date', ['before' => '', 'after' => '']); self::assertStringContainsString(str_replace('"', '\\' . 'u0022', $date), $this->saved[0]); $term = $this->insertInto('s0.r0.c1', ['nodes' => [['type' => 'text', 'content' => '{{loop:term_name}}']]]); self::assertTrue($term['ok'], (string) json_encode($term)); } public function testInsertRefusesLoopContentOutsideALoopOrOfAnotherKind(): void { $outside = $this->insertInto('s0.r0.c2', ['nodes' => [['type' => 'text', 'content' => '{{loop:post_date}}']]]); self::assertStringContainsString('braucht eine Divi-Schleife', self::problems($outside)); $term = $this->insertInto('s0.r0.c1', ['nodes' => [['type' => 'text', 'content' => '{{loop:post_date}}']]]); $wrongKind = self::problems($term); self::assertStringContainsString('passt nicht zur Schleife dieses Elements (Typ: Terme)', $wrongKind); $topLevel = $this->insert()->execute([ 'post_id' => 7, 'tree_rev' => $this->currentRev(), 'nodes' => [['type' => 'section', 'modules' => [['type' => 'text', 'content' => '{{loop:post_date}}']]]], ]); self::assertStringContainsString('braucht eine Divi-Schleife', self::problems($topLevel)); self::assertSame([], $this->saved); } /** * Ruling X3, fail closed: a loop a new node of this call switches on counts only once it is saved – loop * content in the same call is refused even where the parent's loop would let it pass (a term loop inside the * post loop c0 would otherwise get post content: Divi reads the post with the term's ID). */ public function testANewLoopInTheSameCallIsRefusedWithLoopContent(): void { $ownLoop = ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]]; foreach (['s0.r0.c0' => '{{loop:post_title}}', 's0.r0.c2' => '{{loop:term_name}}'] as $parent => $content) { $refused = $this->insertInto($parent, ['nodes' => [ ['type' => 'text', 'attrs' => $ownLoop, 'content' => $content], ]]); // c2 has no loop: the composer refuses the loop content itself (the call is refused either way). self::assertStringContainsString( $parent === 's0.r0.c0' ? 'erst nach dem Speichern' : 'braucht eine Divi-Schleife', self::problems($refused), $parent ); } self::assertSame([], $this->saved); $withoutLoopContent = $this->insertInto('s0.r0.c2', ['nodes' => [ ['type' => 'text', 'attrs' => $ownLoop, 'content' => '

Liste

'], ]]); self::assertTrue($withoutLoopContent['ok'], (string) json_encode($withoutLoopContent)); } /** Ruling X3: one Composer per loop scope – changes in a post loop and a term loop in one call. */ public function testUpdateChecksEveryChangeInItsOwnLoop(): void { $result = $this->changeAll([ ['id' => 's0.r0.c0.m1', 'set' => ['content' => '{{loop:post_date}}']], ['id' => 's0.r0.c1.m0', 'set' => ['content' => '{{loop:term_name}}']], ['id' => 's0.r0.c2.m0', 'set' => ['content' => '

Neu

']], ]); self::assertTrue($result['ok'], (string) json_encode($result)); self::assertStringContainsString('loop_term_name', $this->saved[0]); $refused = $this->changeAll([ ['id' => 's0.r0.c0.m1', 'set' => ['content' => '{{loop:term_name}}']], ['id' => 's0.r0.c2.m0', 'set' => ['content' => '{{loop:post_date}}']], ]); $problems = self::errorFields($refused); self::assertStringContainsString('Typ: Beiträge', $problems['changes[0].set.content'] ?? ''); self::assertStringContainsString('braucht eine Divi-Schleife', $problems['changes[1].set.content'] ?? ''); } /** * Ruling X3: the scope of a change takes the loop attrs this call gives the node or an ancestor – a loop * switched on admits its content, a loop switched to another kind refuses the old kind's content. */ public function testUpdateUsesTheLoopsThisCallSets(): void { $switchedOn = $this->changeAll([ ['id' => 's0.r0.c2', 'attrs' => ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]]], ['id' => 's0.r0.c2.m0', 'set' => ['content' => '{{loop:term_name}}']], ]); self::assertTrue($switchedOn['ok'], (string) json_encode($switchedOn)); $own = $this->changeAll([[ 'id' => 's0.r0.c0.m1', 'attrs' => ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]], 'set' => ['content' => '{{loop:term_name}}'], ]]); self::assertTrue($own['ok'], (string) json_encode($own)); $switchedKind = $this->changeAll([ ['id' => 's0.r0.c0', 'attrs' => ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]]], ['id' => 's0.r0.c0.m0', 'set' => ['content' => '{{loop:post_date}}']], ]); $problems = self::errorFields($switchedKind); self::assertStringContainsString('Typ: Terme', $problems['changes[1].set.content'] ?? ''); self::assertCount(2, $this->saved); } public function testMovingLoopContentOutOfItsLoopWarns(): void { $out = $this->moveNode([ 'id' => 's0.r0.c0.m0', 'parent' => 's0.r0.c2', ]); self::assertTrue($out['ok'], 'Divi allows the move (A11): a warning, no refusal'); $warnings = implode(' | ', $out['warnings']); self::assertStringContainsString('loop_post_title', $warnings); self::assertStringContainsString('außerhalb jeder Divi-Schleife', $warnings); $other = $this->moveNode([ 'id' => 's0.r0.c2.m1', 'parent' => 's0.r0.c1', ]); self::assertStringContainsString('Typ: Terme (vorher: keine Schleife)', implode(' | ', $other['warnings'])); } public function testMovesThatKeepTheLoopDoNotWarn(): void { // Path IDs are positions: after this move c0 holds [Fest, title, own loop]. $withinLoop = $this->moveNode([ 'id' => 's0.r0.c0.m0', 'after' => 's0.r0.c0.m1', ]); // m0 is the plain text now: nothing to warn about when it leaves the loop. $plain = $this->moveNode([ 'id' => 's0.r0.c0.m0', 'parent' => 's0.r0.c2', ]); // m1 is the module with its own term loop now: it takes the loop along. $loopItself = $this->moveNode([ 'id' => 's0.r0.c0.m1', 'parent' => 's0.r0.c2', ]); foreach ([$withinLoop, $plain, $loopItself] as $result) { self::assertTrue($result['ok'], (string) json_encode($result)); self::assertStringNotContainsString('Schleife', implode(' | ', $result['warnings'])); } } /** * Fix round 1 (critical): the fail-closed decision reads the composed blocks, not the client's text. The writers * strip control characters and trim values (S35), so these forms become canonical loop content. * * @return array */ public static function obfuscatedLoopContent(): array { $ref = VariableRef::serialize('content', 'loop_post_title', ['before' => '', 'after' => '']); return [ 'control char inside the placeholder' => ["{\x01{loop:post_title}}"], 'leading space before the full form' => [' ' . $ref], 'leading control char before the full form' => ["\x01" . $ref], 'plain placeholder' => ['{{loop:post_title}}'], ]; } /** @dataProvider obfuscatedLoopContent */ public function testANewLoopWithObfuscatedLoopContentIsRefused(string $content): void { $refused = $this->insertInto('s0.r0.c0', ['nodes' => [[ 'type' => 'text', 'attrs' => ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]], 'content' => $content, ]]]); // Whichever check catches it (fail closed on the composed blocks, or the writer's own D9 refusal): refused. self::assertNotSame('', self::problems($refused)); self::assertSame([], $this->saved); } /** Loop content through attrs and in a sibling of the loop node: the whole call fails closed (ruling X3). */ public function testLoopContentViaAttrsOrInASiblingOfANewLoopIsRefused(): void { $ownLoop = ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]]; $ref = VariableRef::serialize('content', 'loop_post_title', ['before' => '', 'after' => '']); $viaAttrs = $this->insertInto('s0.r0.c0', ['nodes' => [[ 'type' => 'text', 'attrs' => $ownLoop + ['content.innerContent' => ['desktop' => $ref]], ]]]); self::assertStringContainsString('erst nach dem Speichern', self::problems($viaAttrs)); $sibling = $this->insertInto('s0.r0.c0', ['nodes' => [ ['type' => 'text', 'attrs' => $ownLoop, 'content' => '

Liste

'], ['type' => 'text', 'content' => "{\x01{loop:post_title}}"], ]]); self::assertStringContainsString('erst nach dem Speichern', self::problems($sibling)); self::assertSame([], $this->saved); } /** Loop content in a descendant of the new loop node (a section with a module) is refused, nothing saved. */ public function testLoopContentBelowANewLoopContainerIsRefused(): void { $refused = $this->insert()->execute([ 'post_id' => 7, 'tree_rev' => $this->currentRev(), 'nodes' => [[ 'type' => 'section', 'attrs' => ['module.advanced.loop' => ['desktop' => self::POST_LOOP]], 'modules' => [['type' => 'text', 'content' => "{\x01{loop:post_title}}"]], ]], ]); self::assertInstanceOf(WP_Error::class, $refused); self::assertSame([], $this->saved); } /** The update path decides on the composed attrs too: a control-char placeholder gets the scope of its node. */ public function testUpdateChecksObfuscatedLoopContentInTheNodesScope(): void { $ok = $this->changeAll([['id' => 's0.r0.c0.m1', 'set' => ['content' => "{\x01{loop:post_date}}"]]]); self::assertTrue($ok['ok'], (string) json_encode($ok)); $refused = $this->changeAll([['id' => 's0.r0.c2.m0', 'set' => ['content' => "{\x01{loop:post_date}}"]]]); self::assertStringContainsString('braucht eine Divi-Schleife', self::problems($refused)); } /** Fix round 1 (important): a loop whose kind changes under unchanged loop_* children warns, as move does. */ public function testUpdateWarnsWhenTheLoopKindChangesUnderUnchangedChildren(): void { $result = $this->changeAll([ ['id' => 's0.r0.c0', 'attrs' => ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]]], ]); self::assertTrue($result['ok'], (string) json_encode($result)); $warnings = implode(' | ', $result['warnings']); self::assertStringContainsString('s0.r0.c0:', $warnings); self::assertStringContainsString('loop_post_title', $warnings); self::assertStringContainsString('Typ: Terme (vorher: Beiträge)', $warnings); self::assertStringNotContainsString('loop_term_name', $warnings, 'a descendant with its own loop keeps it'); } public function testUpdateSwitchingTheLoopOffWarnsAndAKeptKindDoesNot(): void { $same = $this->changeAll([ ['id' => 's0.r0.c0', 'attrs' => ['module.advanced.loop' => ['desktop' => [ 'enable' => 'on', 'queryType' => 'current_page', ]]]], ]); self::assertTrue($same['ok'], (string) json_encode($same)); self::assertSame([], $same['warnings']); $off = $this->changeAll([ ['id' => 's0.r0.c0', 'attrs' => ['module.advanced.loop' => ['desktop' => ['enable' => 'off']]]], ]); self::assertStringContainsString('außerhalb jeder Divi-Schleife', implode(' | ', $off['warnings'])); } private function useChainPage(string $outerType): void { $meta = VariableRef::serialize('content', 'loop_post_meta_key_manual_custom_field', ['loop_meta_key' => 'telefon']); $loop = static fn (string $type): array => self::loop(['enable' => 'on', 'queryType' => $type]); $this->original = '' . self::block('section', $loop($outerType), self::block( 'row', $loop('post_types'), self::block('column', [], self::text('

Fest

') . self::text($meta)) )) . ''; $this->post->post_content = $this->original; } /** Audit M1: a post loop inside a term loop may be empty; the term loop would then read post meta by term ID. */ public function testPostMetaInAPostLoopInsideATermLoopIsRefusedOnInsertAndUpdate(): void { $this->useChainPage('post_taxonomies'); $meta = VariableRef::serialize('content', 'loop_post_meta_key_manual_custom_field', ['loop_meta_key' => 'telefon']); $insert = $this->insertInto('s0.r0.c0', ['nodes' => [['type' => 'text', 'content' => $meta]]]); self::assertStringContainsString('passt nicht zur Schleife', self::problems($insert)); $update = $this->changeAll([['id' => 's0.r0.c0.m0', 'set' => ['content' => $meta]]]); self::assertStringContainsString('passt nicht zur Schleife', self::problems($update)); self::assertSame([], $this->saved); } public function testPostMetaInAPostLoopInsideAPostLoopIsAccepted(): void { $this->useChainPage('post_types'); $meta = VariableRef::serialize('content', 'loop_post_meta_key_manual_custom_field', ['loop_meta_key' => 'telefon']); $insert = $this->insertInto('s0.r0.c0', ['nodes' => [['type' => 'text', 'content' => $meta]]]); self::assertTrue($insert['ok'], (string) json_encode($insert)); $update = $this->changeAll([['id' => 's0.r0.c0.m0', 'set' => ['content' => $meta]]]); self::assertTrue($update['ok'], (string) json_encode($update)); } /** Audit M1: the update warning follows the chain, not only the innermost loop. */ public function testUpdateWarnsWhenAnOuterLoopTurnsIntoAnotherKindUnderMetaContent(): void { $this->useChainPage('post_types'); $result = $this->changeAll([ ['id' => 's0', 'attrs' => ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]]], ]); self::assertTrue($result['ok'], (string) json_encode($result)); $warnings = implode(' | ', $result['warnings']); self::assertStringContainsString('loop_post_meta_key_manual_custom_field', $warnings); self::assertStringContainsString('Typ: Terme > Beiträge (vorher: Beiträge > Beiträge)', $warnings); } /** Audit L2: "terms" is a term loop for Divi, an unsupported target "can show data of another object". */ public function testUpdateWarningNamesTheRealKindOfAnAliasAndNeverCallsAnUnsupportedLoopEmpty(): void { $alias = $this->changeAll([ ['id' => 's0.r0.c0', 'attrs' => ['module.advanced.loop' => ['desktop' => ['enable' => 'on', 'queryType' => 'Terms']]]], ]); self::assertStringContainsString('Typ: Terme (vorher: Beiträge)', implode(' | ', $alias['warnings'])); $this->post->post_content = $this->original; $other = $this->changeAll([ ['id' => 's0.r0.c0', 'attrs' => ['module.advanced.loop' => ['desktop' => ['enable' => 'on', 'queryType' => 'repeater_x']]]], ]); $text = implode(' | ', $other['warnings']); self::assertStringContainsString('kann Daten eines anderen Objekts zeigen', $text); self::assertStringNotContainsString('zeigt sie dort leer', $text); } /** @return array, string}> */ public static function disguisedLoops(): array { return [ 'enable with a space' => [['enable' => ' on', 'queryType' => 'user_roles'], 'list_users'], 'enable with a tag' => [['enable' => 'on', 'queryType' => 'user_roles'], 'list_users'], 'queryType with empty tags' => [['enable' => 'on', 'queryType' => 'user_roles'], 'list_users'], 'any via tag' => [ ['enable' => 'on', 'queryType' => 'post_types', 'subTypes' => [['value' => 'attachment']]], 'read_private_posts', ], 'empty subTypes' => [['enable' => 'on', 'queryType' => 'post_types', 'subTypes' => []], 'read_private_posts'], ]; } /** * Audit L1 (re-review): the check runs on the value the sanitizer leaves – sanitize_text_field turns " on", * "on" or "user_roles" into the values Divi acts on. Through insert and update. * * @dataProvider disguisedLoops * @param array $loop */ public function testDisguisedLoopsAreRefusedWithoutTheRights(array $loop, string $right): void { $registry = self::registry(); $this->writer = DiviWriterFixture::writer( $this->saved, registry: new DynamicContentMap(static fn (int $postId): array => $registry), listUsers: false, readPrivate: false ); $attrs = ['module.advanced.loop' => ['desktop' => $loop]]; $inserted = $this->insertInto('s0.r0.c2', ['nodes' => [['type' => 'text', 'attrs' => $attrs, 'content' => 'x']]]); self::assertStringContainsString($right, self::problems($inserted)); $updated = $this->changeAll([['id' => 's0.r0.c2', 'attrs' => $attrs]]); self::assertStringContainsString($right, self::problems($updated)); self::assertSame([], $this->saved); } private function usePage(string $content): void { $this->original = $content; $this->post->post_content = $content; } /** Re-review: content that never fitted its loop (ACF repeater options) is no reason for a warning on an update. */ public function testUpdatingALoopContainerDoesNotWarnForContentThatNeverFitted(): void { $acf = VariableRef::serialize('content', 'loop_acf_name', ['before' => '', 'after' => '']); $text = self::block('text', ['content' => ['innerContent' => ['desktop' => ['value' => $acf]]]]); $repeater = ['enable' => 'on', 'queryType' => 'repeater_team']; $this->usePage('' . self::block('section', self::loop($repeater), self::block( 'row', [], self::block('column', [], $text) )) . ''); $result = $this->changeAll([[ 'id' => 's0', 'attrs' => ['module.decoration.background' => ['desktop' => ['color' => '#ff0000']]], ]]); self::assertTrue($result['ok'], (string) json_encode($result)); self::assertStringNotContainsString('Schleife', implode(' | ', $result['warnings'])); } /** Re-review (M1): a moved loop with a post meta key option takes the outer term loop into its chain – warned. */ public function testMovingALoopWithMetaKeyContentUnderAnotherKindOfLoopWarns(): void { $meta = VariableRef::serialize( 'content', 'loop_post_meta_key_manual_custom_field', ['before' => '', 'after' => ''] ); $own = self::block('text', self::loop(self::POST_LOOP) + [ 'content' => ['innerContent' => ['desktop' => ['value' => $meta]]], ]); $termColumn = self::block('column', self::loop(self::TERM_LOOP), self::text('y')); $this->usePage('' . self::block('section', [], self::block('row', [], implode('', [ self::block('column', [], $own), $termColumn, ]))) . ''); $result = $this->moveNode(['id' => 's0.r0.c0.m0', 'parent' => 's0.r0.c1']); self::assertTrue($result['ok'], (string) json_encode($result)); $warnings = implode(' | ', $result['warnings']); self::assertStringContainsString('loop_post_meta_key_manual_custom_field', $warnings); self::assertStringContainsString('Typ: Terme > Beiträge (vorher: Beiträge)', $warnings); } /** Two sections: a post loop on its own, and a post loop inside a term loop (s1). */ private function useTwoSectionPage(): void { $inner = static fn (): string => self::block('text', self::loop(self::POST_LOOP)); $this->usePage('' . self::block('section', [], self::block('row', [], self::block('column', [], $inner()))) . self::block('section', self::loop(self::TERM_LOOP), self::block('row', [], self::block('column', [], $inner()))) . ''); } private static function metaChange(string $id): array { $ref = VariableRef::serialize( 'content', 'loop_post_meta_key_manual_custom_field', ['before' => '', 'after' => '', 'loop_meta_key' => 'telefon'] ); return ['id' => $id, 'attrs' => ['content.innerContent' => ['desktop' => $ref]]]; } /** Re-review (M1): the composers of one update call are told apart by the whole chain, not the inner kind. */ public function testMetaKeyContentIsCheckedAgainstTheWholeChainInOneCall(): void { $this->useTwoSectionPage(); $inTerm = $this->changeAll([self::metaChange('s1.r0.c0.m0')]); self::assertStringContainsString('Typ: Beiträge', self::problems($inTerm), 'refused when sent alone'); $both = $this->changeAll([self::metaChange('s0.r0.c0.m0'), self::metaChange('s1.r0.c0.m0')]); self::assertStringContainsString('changes[1]', implode(' ', array_keys(self::errorFields($both)))); self::assertStringContainsString('passt nicht zur Schleife', self::problems($both)); self::assertSame([], $this->saved); $plain = $this->changeAll([self::metaChange('s0.r0.c0.m0')]); self::assertTrue($plain['ok'], (string) json_encode($plain)); } /** Re-review (M1): an outer loop switched to another kind in the same call refuses meta key content below. */ public function testSwitchingTheOuterLoopRefusesMetaKeyContentOfTheInnerLoop(): void { $this->useTwoSectionPage(); $refused = $this->changeAll([ ['id' => 's0', 'attrs' => ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]]], self::metaChange('s0.r0.c0.m0'), ]); self::assertStringContainsString('changes[1]', implode(' ', array_keys(self::errorFields($refused)))); self::assertSame([], $this->saved); } }