Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
588 lines
28 KiB
PHP
588 lines
28 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||
|
||
use LIT\MCP\Abilities\Rev;
|
||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||
use WP_Error;
|
||
|
||
/**
|
||
* Ruling X3: lit/divi-insert, lit/divi-update and lit/divi-move use the loop scope of the node in the tree
|
||
* (LoopContext). The page: s0.r0.c0 runs a post loop (m0 shows loop_post_title, m1 is plain text, m2 runs its own
|
||
* term loop and shows loop_term_name), s0.r0.c1 a term loop, s0.r0.c2 no loop.
|
||
*/
|
||
final class DiviLoopWriteTest extends DiviWriteAbilityTestCase
|
||
{
|
||
private const POST_LOOP = ['enable' => '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<string, mixed> $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('<!-- wp:divi/%s %s -->%s<!-- /wp:divi/%s -->', $name, $json, $inner, $name);
|
||
}
|
||
|
||
/** @param array<string, string> $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 '<!-- wp:divi/placeholder -->' . self::block('section', [], self::block('row', [], implode('', [
|
||
self::block('column', self::loop(self::POST_LOOP), self::text($title) . self::text('<p>Fest</p>') . $own),
|
||
self::block('column', self::loop(self::TERM_LOOP), self::text('<p>Term</p>')),
|
||
self::block('column', [], self::text('<p>Ohne</p>')),
|
||
]))) . '<!-- /wp:divi/placeholder -->';
|
||
}
|
||
|
||
/** @param array<string, mixed> $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<array<string, mixed>> $changes */
|
||
private function changeAll(array $changes): mixed
|
||
{
|
||
return $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->currentRev(), 'changes' => $changes]);
|
||
}
|
||
|
||
/** @param array<string, mixed> $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' => '<p>Liste</p>'],
|
||
]]);
|
||
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' => '<p>Neu</p>']],
|
||
]);
|
||
|
||
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<string, array{0: string}>
|
||
*/
|
||
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' => '<p>Liste</p>'],
|
||
['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 = '<!-- wp:divi/placeholder -->' . self::block('section', $loop($outerType), self::block(
|
||
'row',
|
||
$loop('post_types'),
|
||
self::block('column', [], self::text('<p>Fest</p>') . self::text($meta))
|
||
)) . '<!-- /wp:divi/placeholder -->';
|
||
$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));
|
||
self::assertStringContainsString('loop_post_meta_key_manual_custom_field', implode(' | ', $result['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, array{array<string, mixed>, string}> */
|
||
public static function disguisedLoops(): array
|
||
{
|
||
return [
|
||
'enable with a space' => [['enable' => ' on', 'queryType' => 'user_roles'], 'list_users'],
|
||
'enable with a tag' => [['enable' => 'o<x>n', 'queryType' => 'user_roles'], 'list_users'],
|
||
'queryType with empty tags' => [['enable' => 'on', 'queryType' => 'user<b></b>_roles'], 'list_users'],
|
||
'any via tag' => [
|
||
['enable' => 'on', 'queryType' => 'post_types', 'subTypes' => [['value' => 'att<x>achment']]],
|
||
'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",
|
||
* "o<x>n" or "user<b></b>_roles" into the values Divi acts on. Through insert and update.
|
||
*
|
||
* @dataProvider disguisedLoops
|
||
* @param array<string, mixed> $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('<!-- wp:divi/placeholder -->' . self::block('section', self::loop($repeater), self::block(
|
||
'row',
|
||
[],
|
||
self::block('column', [], $text)
|
||
)) . '<!-- /wp:divi/placeholder -->');
|
||
|
||
$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('<!-- wp:divi/placeholder -->' . self::block('section', [], self::block('row', [], implode('', [
|
||
self::block('column', [], $own),
|
||
$termColumn,
|
||
]))) . '<!-- /wp:divi/placeholder -->');
|
||
|
||
$result = $this->moveNode(['id' => 's0.r0.c0.m0', 'parent' => 's0.r0.c1']);
|
||
|
||
self::assertTrue($result['ok'], (string) json_encode($result));
|
||
self::assertStringContainsString('loop_post_meta_key_manual_custom_field', implode(' | ', $result['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('<!-- wp:divi/placeholder -->'
|
||
. 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())))
|
||
. '<!-- /wp:divi/placeholder -->');
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|