Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php

283 lines
13 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Pages\DiviDynamicSourcesAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class DiviDynamicSourcesAbilityTest extends TestCase
{
/** @var list<int> Post IDs Divi's registry was asked for. */
private array $registryCalls = [];
protected function setUp(): void
{
parent::setUp();
Functions\when('get_post_type_object')->justReturn(
(object) ['name' => 'page', 'cap' => (object) ['edit_posts' => 'edit_pages']]
);
Functions\when('current_user_can')->justReturn(true);
}
private function schema(): ContentSchema
{
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(true);
$schema->allows('fields')->with('page')->andReturn([
'telefon' => new FieldDefinition('telefon', 'Telefon', 'string', 'meta', FieldDefinition::WRITABLE),
'notiz' => new FieldDefinition('notiz', 'Notiz', 'text', 'pods', FieldDefinition::WRITABLE),
'foto' => new FieldDefinition('foto', 'Foto', 'file', 'pods', FieldDefinition::WRITABLE),
'projekte' => new FieldDefinition('projekte', 'Projekte', 'relation', 'pods', FieldDefinition::WRITABLE),
'_intern' => new FieldDefinition('_intern', '_intern', 'string', 'meta', FieldDefinition::WRITABLE),
'geheim' => new FieldDefinition('geheim', 'Geheim', 'string', 'pods', FieldDefinition::HIDDEN),
'gefunden' => new FieldDefinition('gefunden', 'gefunden', 'unknown', 'sample', FieldDefinition::UNKNOWN),
'aktiv' => new FieldDefinition('aktiv', 'Aktiv', 'boolean', 'pods', FieldDefinition::WRITABLE),
'kennwort' => new FieldDefinition('kennwort', 'Kennwort', 'password', 'pods', FieldDefinition::WRITABLE),
]);
return $schema;
}
/**
* @param list<string> $deniedRoles
* @param array<array-key, mixed>|null $registry
*/
private function ability(
array $deniedRoles = [],
?string $storage = 'meta',
?array $registry = null,
?DiviPageLoader $loader = null
): DiviDynamicSourcesAbility {
$registry ??= DiviFixtures::dynamicContentRegistry();
return new DiviDynamicSourcesAbility(
$this->schema(),
new DynamicContentMap(function (int $id) use ($registry): array {
$this->registryCalls[] = $id;
return $registry;
}),
LibraryFixtures::roles($deniedRoles),
static fn (string $postType): int => 831,
static fn (string $postType): ?string => $storage,
$loader
);
}
/**
* Ruling X6: the ability with a loader over post 7 – s0.r0.c0 runs a term loop, s0.r0.c1 none – and a
* registry with one post and one term loop option (Divi 5.15.1 groups and fields).
*/
private function withLoopPage(): DiviDynamicSourcesAbility
{
$loop = ['module' => ['advanced' => ['loop' => ['desktop' => ['value' => [
'enable' => 'on', 'queryType' => 'post_taxonomies',
]]]]]];
$text = '<!-- wp:divi/text {"builderVersion":"5.2.1"} --><!-- /wp:divi/text -->';
$column = static fn (array $attrs): string => '<!-- wp:divi/column '
. json_encode($attrs + ['builderVersion' => '5.2.1']) . ' -->' . $text . '<!-- /wp:divi/column -->';
DiviFixtures::givenPost(
'<!-- wp:divi/placeholder --><!-- wp:divi/section {"builderVersion":"5.2.1"} --><!-- wp:divi/row '
. '{"builderVersion":"5.2.1"} -->' . $column($loop) . $column([])
. '<!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->'
);
$field = static fn (string $type): array => ['label' => 'x', 'type' => $type, 'default' => ''];
$common = ['before' => $field('text'), 'after' => $field('text'), 'loop_position' => $field('text')];
$registry = DiviFixtures::dynamicContentRegistry() + [
'loop_post_date' => ['label' => 'Datum', 'group' => 'Loop', 'fields' => $common + [
'date_format' => $field('select'),
]],
'loop_term_name' => ['label' => 'Name', 'group' => 'Loop Terms', 'fields' => $common],
];
return $this->ability([], 'meta', $registry, new DiviPageLoader(DiviFixtures::schema()));
}
/** Ruling X6: post_id + node add the loop sources of the node's loop kind, read from that entry. */
public function testANodeInALoopAddsTheLoopSourcesOfItsKind(): void
{
$result = $this->withLoopPage()->execute(['post_type' => 'page', 'post_id' => 7, 'node' => 's0.r0.c0.m0']);
self::assertSame(7, $result['sample_post_id'], 'the entry itself, not a sample');
self::assertSame([7], array_values(array_unique($this->registryCalls)));
self::assertSame(
['node' => 's0.r0.c0.m0', 'query_type' => 'post_taxonomies', 'label' => 'Terme'],
$result['loop']
);
// The fixture registry also has the meta key option of each kind: only the term one fits, no short form.
self::assertSame(
['loop_term_name', 'loop_term_meta_key_manual_custom_field'],
array_column($result['loop_sources'], 'name')
);
self::assertSame('{{loop:term_name}}', $result['loop_sources'][0]['placeholder']);
self::assertNull($result['loop_sources'][1]['placeholder']);
self::assertEquals(
['before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text'],
(array) $result['loop_sources'][0]['settings']
);
self::assertSame([], array_filter(
array_column($result['sources'], 'name'),
static fn (string $name): bool => str_starts_with($name, 'loop_')
));
}
public function testANodeOutsideALoopGetsNoLoopSources(): void
{
$result = $this->withLoopPage()->execute(['post_type' => 'page', 'post_id' => 7, 'node' => 's0.r0.c1.m0']);
self::assertSame(['node' => 's0.r0.c1.m0', 'query_type' => null, 'label' => null], $result['loop']);
self::assertSame([], $result['loop_sources']);
self::assertStringContainsString('kein Schleifen-Kontext', implode(' ', $result['notes']));
}
public function testWithoutANodeTheAnswerHasNoLoopPart(): void
{
$result = $this->ability()->execute(['post_type' => 'page']);
self::assertArrayNotHasKey('loop', $result);
self::assertArrayNotHasKey('loop_sources', $result);
}
public function testPostIdAndNodeComeTogetherAndNeedTheEntry(): void
{
$ability = $this->withLoopPage();
foreach ([['post_id' => 7], ['node' => 's0']] as $half) {
self::assertToolError($ability->permission(['post_type' => 'page'] + $half), 'validation_failed');
self::assertToolError($ability->execute(['post_type' => 'page'] + $half), 'validation_failed');
}
$node = ['post_type' => 'page', 'post_id' => 7, 'node' => 's0'];
self::assertToolError($ability->execute(['node' => 's9'] + $node), 'not_found');
self::assertToolError($ability->execute(['node' => 'x'] + $node), 'validation_failed');
self::assertToolError($ability->execute(['post_type' => 'post'] + $node), 'validation_failed');
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_post');
self::assertToolError($ability->permission($node), 'forbidden');
self::assertToolError($ability->execute($node), 'forbidden');
self::assertSame([], $this->registryCalls, 'nothing is read for a refused entry');
}
public function testListsDiviSourcesWithTheirPlaceholders(): void
{
$result = $this->ability()->execute(['post_type' => 'page']);
$sources = array_column($result['sources'], null, 'name');
self::assertSame(831, $result['sample_post_id']);
self::assertSame('{{post:title}}', $sources['post_title']['placeholder']);
self::assertSame('{{link:<ID>}}', $sources['post_link_url_page']['placeholder']);
self::assertNull($sources['site_title']['placeholder']);
self::assertArrayNotHasKey('loop_post_title', $sources);
self::assertEquals(['post_id' => 'select_post'], (array) $sources['post_link_url_page']['settings']);
self::assertFalse($this->ability()->isDirect());
self::assertTrue($this->ability()->annotations()['readonly']);
self::assertSame('lit/divi-dynamic-sources', $this->ability()->name());
}
/** Review M-9: {{link:}} refuses attachments (status inherit), so their link sources get no hint. */
public function testNoLinkHintForAttachmentsOrInternalTypes(): void
{
$registry = DiviFixtures::dynamicContentRegistry();
foreach (['post_link_url_attachment', 'post_link_url__et_pb_speculation'] as $name) {
$registry[$name] = $registry['post_link_url_page'];
$registry[$name]['id'] = $name;
}
$sources = array_column($this->ability([], 'meta', $registry)->execute(['post_type' => 'page'])['sources'], null, 'name');
self::assertSame('{{link:<ID>}}', $sources['post_link_url_page']['placeholder']);
self::assertNull($sources['post_link_url_attachment']['placeholder']);
self::assertNull($sources['post_link_url__et_pb_speculation']['placeholder']);
}
public function testOffersTextLikeFieldsAndNamesTheOthers(): void
{
$result = $this->ability()->execute(['post_type' => 'page']);
self::assertSame(['telefon', 'notiz'], array_column($result['fields'], 'key'));
self::assertSame('{{field:notiz}}', $result['fields'][1]['placeholder']);
$omitted = array_column($result['omitted'], 'reason', 'key');
self::assertSame(['foto', 'projekte', '_intern', 'gefunden', 'aktiv', 'kennwort'], array_keys($omitted));
self::assertStringContainsString('nur IDs', $omitted['foto']);
self::assertStringContainsString('nur IDs', $omitted['projekte']);
self::assertStringContainsString('„_“', $omitted['_intern']);
// Ruling P9: unknown field types are not offered.
self::assertStringContainsString('unbekannt', $omitted['gefunden']);
self::assertStringContainsString('nicht textartig', $omitted['aktiv']);
self::assertStringContainsString('nicht textartig', $omitted['kennwort']);
}
public function testProtectedAndHiddenFieldsLeakNothing(): void
{
$result = $this->ability()->execute(['post_type' => 'page']);
$json = (string) json_encode($result);
// Hidden fields are neither offered nor named (security audit M4).
self::assertStringNotContainsString('geheim', $json);
self::assertStringNotContainsString('Geheim', $json);
// The protected key is named (so the client knows why) but never offered with a placeholder.
self::assertStringNotContainsString('{{field:_intern}}', $json);
self::assertSame(['key', 'label', 'reason'], array_keys($result['omitted'][2]));
}
public function testSourcesNeverNameProtectedMetaOptions(): void
{
$registry = DiviFixtures::dynamicContentRegistry() + [
'custom_meta__edit_lock' => ['label' => '_edit_lock', 'group' => 'Custom', 'fields' => []],
];
$result = $this->ability([], 'meta', $registry)->execute(['post_type' => 'page']);
self::assertStringNotContainsString('_edit_lock', (string) json_encode($result));
}
public function testPodsTableStorageIsNotReadableByDivi(): void
{
$result = $this->ability([], 'table')->execute(['post_type' => 'page']);
self::assertSame(['telefon'], array_column($result['fields'], 'key'));
self::assertStringContainsString('Tabellen', array_column($result['omitted'], 'reason', 'key')['notiz']);
}
public function testFieldListNeedsTheDiviRole(): void
{
$result = $this->ability(['read_dynamic_content_custom_fields'])->execute(['post_type' => 'page']);
self::assertSame([], $result['fields']);
self::assertSame([], $result['omitted']);
self::assertStringContainsString('Rollen-Editor', implode(' ', $result['notes']));
}
public function testWithoutDivisRegistryThePlaceholderSourcesAreShown(): void
{
$result = $this->ability([], 'meta', [])->execute(['post_type' => 'page']);
self::assertContains('post_title', array_column($result['sources'], 'name'));
self::assertStringContainsString('nicht verfügbar', implode(' ', $result['notes']));
}
public function testUnknownPostTypeIsNotFound(): void
{
Functions\when('get_post_type_object')->justReturn(null);
self::assertToolError($this->ability()->permission(['post_type' => 'nix']), 'not_found');
self::assertToolError($this->ability()->execute(['post_type' => 'nix']), 'not_found');
}
public function testNeedsTheTypesEditCapability(): void
{
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_pages');
self::assertToolError($this->ability()->permission(['post_type' => 'page']), 'forbidden');
}
}