Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
283 lines
13 KiB
PHP
283 lines
13 KiB
PHP
<?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');
|
||
}
|
||
}
|