Indirect abilities are written lit/<name> (via mcp-adapter-execute-ability) in guides, descriptions and hints, direct tools lit-<name>; guides use the client-visible codes (write_failed, partial_write) and say exactly which tools return a revision_id. ToolNamingTest keeps guides and sources consistent with Ability::isDirect() and checks the overview's direct-tool list. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
368 lines
16 KiB
PHP
368 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Menus;
|
|
|
|
use Brain\Monkey\Functions;
|
|
use LIT\MCP\Abilities\WriteFailed;
|
|
use LIT\MCP\Integrations\WordPress\Menus\MenuTree;
|
|
use LIT\MCP\Integrations\WordPress\Menus\MenuWriter;
|
|
use LIT\MCP\Tests\Unit\TestCase;
|
|
use Mockery;
|
|
use WP_Error;
|
|
use WP_Post;
|
|
use WP_Term;
|
|
|
|
final class MenuWriterTest extends TestCase
|
|
{
|
|
use MenuFixtures;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
Functions\when('wp_parse_url')->alias('parse_url');
|
|
Functions\when('esc_url_raw')->returnArg();
|
|
}
|
|
|
|
/** @return array<int, object> */
|
|
private function current(): array
|
|
{
|
|
return [
|
|
10 => self::item(10, 0, 'Home', 'custom', '/'),
|
|
11 => self::item(11, 0, 'Über uns'),
|
|
];
|
|
}
|
|
|
|
/** @return array<int, object> Start(10), Über uns(11) > Team(12), Kontakt(13, custom) */
|
|
private function realistic(): array
|
|
{
|
|
$items = [
|
|
self::item(10, 0, 'Start'),
|
|
self::item(11, 0, 'Über uns'),
|
|
self::item(12, 11, 'Team'),
|
|
self::item(13, 0, 'Kontakt', 'custom', '/kontakt'),
|
|
];
|
|
$items[0]->url = 'https://example.test/';
|
|
$items[1]->url = 'https://example.test/ueber-uns/';
|
|
$items[1]->post_title = 'Wir über uns';
|
|
$items[1]->title = 'Wir über uns';
|
|
$items[2]->url = 'https://example.test/ueber-uns/team/';
|
|
|
|
return array_combine(array_map(static fn (object $item): int => $item->ID, $items), $items);
|
|
}
|
|
|
|
public function testPlanFindsErrorsRemovalsAndWarnings(): void
|
|
{
|
|
Functions\when('current_user_can')->justReturn(true);
|
|
Functions\when('get_post')->alias(static fn (int $id) => $id === 7
|
|
? new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => 'Neu'])
|
|
: null);
|
|
|
|
$plan = (new MenuWriter())->plan([
|
|
['id' => 11, 'children' => [['id' => 11]]],
|
|
['id' => 55],
|
|
['type' => 'post', 'object_id' => 7],
|
|
['type' => 'custom', 'url' => 'javascript:alert(1)', 'title' => 'X'],
|
|
['type' => 'custom', 'url' => '/kontakt'],
|
|
], $this->current(), true);
|
|
|
|
self::assertStringContainsString('mehrfach', $plan['errors']['items.0.children.0']);
|
|
self::assertStringContainsString('kein Eintrag dieses Menüs', $plan['errors']['items.1']);
|
|
self::assertArrayNotHasKey('items.2', $plan['errors']);
|
|
self::assertStringContainsString('url', $plan['errors']['items.3']);
|
|
self::assertStringContainsString('title', $plan['errors']['items.4']);
|
|
self::assertSame([['id' => 10, 'title' => 'Home']], $plan['removals']);
|
|
self::assertSame(
|
|
['Eintrag "Neu" ist sofort im Menü sichtbar; Besucher erhalten bis zur Veröffentlichung einen toten Link.'],
|
|
$plan['warnings']
|
|
);
|
|
}
|
|
|
|
/** @return array<string, array{0: string, 1: bool, 2: bool}> status, allow_unpublished, accepted */
|
|
public static function statuses(): array
|
|
{
|
|
return [
|
|
'published' => ['publish', false, true],
|
|
'private is always refused' => ['private', true, false],
|
|
'draft without flag' => ['draft', false, false],
|
|
'pending without flag' => ['pending', false, false],
|
|
'future without flag' => ['future', false, false],
|
|
'draft with flag' => ['draft', true, true],
|
|
'future with flag' => ['future', true, true],
|
|
'trash' => ['trash', true, false],
|
|
];
|
|
}
|
|
|
|
/** @dataProvider statuses */
|
|
public function testPostStatusDecidesWhetherItCanBeLinked(string $status, bool $allow, bool $accepted): void
|
|
{
|
|
Functions\when('current_user_can')->justReturn(true);
|
|
Functions\when('get_post')->justReturn(
|
|
new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => $status, 'post_title' => 'Seite'])
|
|
);
|
|
|
|
$plan = (new MenuWriter())->plan([['type' => 'post', 'object_id' => 7]], [], $allow);
|
|
|
|
self::assertSame($accepted, ! isset($plan['errors']['items.0']), $plan['errors']['items.0'] ?? '');
|
|
self::assertSame($accepted && $status !== 'publish' ? 1 : 0, count($plan['warnings']));
|
|
if ($status === 'private') {
|
|
self::assertStringContainsString('Private Inhalte', $plan['errors']['items.0']);
|
|
}
|
|
if (! $accepted && in_array($status, ['draft', 'pending', 'future'], true)) {
|
|
self::assertStringContainsString('allow_unpublished', $plan['errors']['items.0']);
|
|
}
|
|
}
|
|
|
|
public function testPlanRejectsChildrenThatAreNoListAndNodesThatAreNoObject(): void
|
|
{
|
|
$plan = (new MenuWriter())->plan([['id' => 11, 'children' => 'x'], 'y'], $this->current());
|
|
|
|
self::assertSame('Liste erwartet.', $plan['errors']['items.0.children']);
|
|
self::assertSame('Objekt erwartet.', $plan['errors']['items.1']);
|
|
}
|
|
|
|
public function testLinkingNeedsReadAccessAndDoesNotRevealTheObject(): void
|
|
{
|
|
Functions\when('get_post')->justReturn(
|
|
new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'private', 'post_title' => 'Geheim'])
|
|
);
|
|
Functions\when('get_term')->justReturn(new WP_Term(['term_id' => 4, 'taxonomy' => 'internal']));
|
|
Functions\when('is_taxonomy_viewable')->justReturn(false);
|
|
Functions\expect('current_user_can')->with('read_post', 7)->andReturn(false);
|
|
|
|
$plan = (new MenuWriter())->plan(
|
|
[['type' => 'post', 'object_id' => 7], ['type' => 'taxonomy', 'object_id' => 4]],
|
|
[]
|
|
);
|
|
|
|
self::assertSame('object_id muss ein vorhandener, für dich lesbarer Eintrag sein.', $plan['errors']['items.0']);
|
|
self::assertSame(
|
|
'object_id muss ein vorhandener Begriff einer öffentlichen Taxonomie sein.',
|
|
$plan['errors']['items.1']
|
|
);
|
|
self::assertSame([], $plan['warnings']);
|
|
}
|
|
|
|
public function testViewableTermsCanBeLinked(): void
|
|
{
|
|
Functions\when('get_term')->justReturn(new WP_Term(['term_id' => 4, 'taxonomy' => 'category']));
|
|
Functions\when('is_taxonomy_viewable')->justReturn(true);
|
|
|
|
$plan = (new MenuWriter())->plan([['type' => 'taxonomy', 'object_id' => 4]], []);
|
|
|
|
self::assertSame([], $plan['errors']);
|
|
}
|
|
|
|
public function testInformationalFieldsOfExistingItemsAreIgnored(): void
|
|
{
|
|
Functions\expect('get_post')->never();
|
|
|
|
$plan = (new MenuWriter())->plan([
|
|
['id' => 11, 'type' => 'custom', 'object' => 'custom', 'object_id' => 5, 'url' => 'https://anders.test/'],
|
|
['id' => 10, 'url' => 'javascript:alert(1)'],
|
|
], $this->current());
|
|
|
|
self::assertSame(['items.1' => 'Ungültige url (erlaubt: /pfad, #anker, http(s), mailto, tel).'], $plan['errors']);
|
|
}
|
|
|
|
/** @return array<string, array{0: string}> */
|
|
public static function badUrls(): array
|
|
{
|
|
return [
|
|
'mixed-case javascript' => ['JaVaScRiPt:alert(1)'],
|
|
'embedded tab' => ["java\tscript:alert(1)"],
|
|
'data' => ['data:text/html,<script>alert(1)</script>'],
|
|
'vbscript' => ['vbscript:msgbox(1)'],
|
|
'protocol-relative' => ['//evil.test/'],
|
|
'protocol-relative backslash' => ['/\\evil.test/'],
|
|
'leading space' => [' https://example.test/'],
|
|
'control character' => ["https://example.test/\x00"],
|
|
];
|
|
}
|
|
|
|
/** @dataProvider badUrls */
|
|
public function testDangerousUrlsAreRejected(string $url): void
|
|
{
|
|
$plan = (new MenuWriter())->plan([['type' => 'custom', 'url' => $url, 'title' => 'X']], []);
|
|
|
|
self::assertStringContainsString('url', $plan['errors']['items.0']);
|
|
}
|
|
|
|
public function testSafeUrlsAreAccepted(): void
|
|
{
|
|
$plan = (new MenuWriter())->plan([
|
|
['type' => 'custom', 'url' => '/kontakt', 'title' => 'A'],
|
|
['type' => 'custom', 'url' => '#', 'title' => 'B'],
|
|
['type' => 'custom', 'url' => 'HTTPS://example.test/', 'title' => 'C'],
|
|
['type' => 'custom', 'url' => 'mailto:info@example.test', 'title' => 'D'],
|
|
['type' => 'custom', 'url' => 'tel:+4912345', 'title' => 'E'],
|
|
], []);
|
|
|
|
self::assertSame([], $plan['errors']);
|
|
}
|
|
|
|
public function testIdZeroMeansANewItem(): void
|
|
{
|
|
$plan = (new MenuWriter())->plan([['id' => 0, 'type' => 'custom', 'url' => '#', 'title' => 'Mehr']], []);
|
|
|
|
self::assertSame([], $plan['errors']);
|
|
}
|
|
|
|
public function testApplyWritesInTreeOrderKeepsExistingDataAndRemovesTheRest(): void
|
|
{
|
|
Functions\expect('wp_update_nav_menu_item')->once()->with(3, 11, Mockery::on(static fn (array $args): bool =>
|
|
$args['menu-item-title'] === 'Wir'
|
|
&& $args['menu-item-type'] === 'post_type'
|
|
&& $args['menu-item-object-id'] === 111
|
|
&& $args['menu-item-parent-id'] === 0
|
|
&& $args['menu-item-position'] === 1
|
|
&& $args['menu-item-status'] === 'publish'))->andReturn(11);
|
|
Functions\expect('wp_update_nav_menu_item')->once()->with(3, 0, Mockery::on(static fn (array $args): bool =>
|
|
$args['menu-item-type'] === 'custom'
|
|
&& $args['menu-item-url'] === '/kontakt'
|
|
&& $args['menu-item-title'] === 'Kontakt'
|
|
&& $args['menu-item-parent-id'] === 11
|
|
&& $args['menu-item-position'] === 2))->andReturn(12);
|
|
Functions\expect('wp_delete_post')->once()->with(10, true)->andReturn(new WP_Post(['ID' => 10]));
|
|
|
|
$stats = (new MenuWriter())->apply(3, [
|
|
['id' => 11, 'title' => 'Wir', 'children' => [['type' => 'custom', 'url' => '/kontakt', 'title' => 'Kontakt']]],
|
|
], $this->current());
|
|
|
|
self::assertSame(['created' => 1, 'updated' => 1, 'removed' => 1], $stats);
|
|
}
|
|
|
|
public function testMenuGetOutputRoundTripsAsANoOp(): void
|
|
{
|
|
Functions\expect('get_post')->never();
|
|
Functions\expect('wp_update_nav_menu_item')->never();
|
|
Functions\expect('wp_delete_post')->never();
|
|
$current = $this->realistic();
|
|
$tree = json_decode((string) json_encode(MenuTree::build(array_values($current))), true);
|
|
|
|
$writer = new MenuWriter();
|
|
$plan = $writer->plan($tree, $current);
|
|
$stats = $writer->apply(3, $tree, $current);
|
|
|
|
self::assertSame(['errors' => [], 'removals' => [], 'warnings' => []], $plan);
|
|
self::assertSame(['created' => 0, 'updated' => 0, 'removed' => 0], $stats);
|
|
}
|
|
|
|
public function testOnlyMovedItemsAreRewrittenAndKeepTheirData(): void
|
|
{
|
|
$current = $this->realistic();
|
|
$calls = [];
|
|
Functions\when('wp_update_nav_menu_item')->alias(static function (int $menu, int $id, array $args) use (&$calls) {
|
|
$calls[$id] = $args;
|
|
return $id;
|
|
});
|
|
|
|
// Kontakt(13) moves to the front: every item's position shifts by one.
|
|
$tree = MenuTree::build(array_values($current));
|
|
array_unshift($tree, array_pop($tree));
|
|
$stats = (new MenuWriter())->apply(3, $tree, $current);
|
|
|
|
self::assertSame(['created' => 0, 'updated' => 4, 'removed' => 0], $stats);
|
|
foreach ($current as $id => $item) {
|
|
self::assertSame($item->post_title, $calls[$id]['menu-item-title'], "title of $id");
|
|
self::assertSame((int) $item->menu_item_parent, $calls[$id]['menu-item-parent-id'], "parent of $id");
|
|
self::assertSame((int) $item->object_id, $calls[$id]['menu-item-object-id'], "object of $id");
|
|
}
|
|
self::assertSame('', $calls[10]['menu-item-url']);
|
|
self::assertSame('/kontakt', $calls[13]['menu-item-url']);
|
|
self::assertSame([1, 2, 3, 4], [$calls[13]['menu-item-position'], $calls[10]['menu-item-position'],
|
|
$calls[11]['menu-item-position'], $calls[12]['menu-item-position']]);
|
|
}
|
|
|
|
public function testMovingOnlyTheLastItemUnderAnotherRewritesOnlyThatItem(): void
|
|
{
|
|
$current = $this->realistic();
|
|
$calls = [];
|
|
Functions\when('wp_update_nav_menu_item')->alias(static function (int $menu, int $id, array $args) use (&$calls) {
|
|
$calls[$id] = $args;
|
|
return $id;
|
|
});
|
|
|
|
$stats = (new MenuWriter())->apply(3, [
|
|
['id' => 10],
|
|
['id' => 11, 'children' => [['id' => 12], ['id' => 13]]],
|
|
], $current);
|
|
|
|
self::assertSame(['created' => 0, 'updated' => 1, 'removed' => 0], $stats);
|
|
self::assertSame([13], array_keys($calls));
|
|
self::assertSame(11, $calls[13]['menu-item-parent-id']);
|
|
}
|
|
|
|
public function testChangedTitleOfAnExistingItemBecomesItsLabel(): void
|
|
{
|
|
$current = $this->realistic();
|
|
$calls = [];
|
|
Functions\when('wp_update_nav_menu_item')->alias(static function (int $menu, int $id, array $args) use (&$calls) {
|
|
$calls[$id] = $args;
|
|
return $id;
|
|
});
|
|
|
|
(new MenuWriter())->apply(3, [
|
|
['id' => 10, 'title' => 'Startseite'],
|
|
['id' => 11, 'title' => 'Wir über uns', 'children' => [['id' => 12]]],
|
|
['id' => 13, 'title' => 'Kontakt', 'url' => 'mailto:info@example.test'],
|
|
], $current);
|
|
|
|
self::assertSame([10, 13], array_keys($calls));
|
|
self::assertSame('Startseite', $calls[10]['menu-item-title']);
|
|
self::assertSame('mailto:info@example.test', $calls[13]['menu-item-url']);
|
|
}
|
|
|
|
public function testAFailedWriteRestoresThePreviousTree(): void
|
|
{
|
|
$calls = [];
|
|
Functions\when('wp_update_nav_menu_item')->alias(static function (int $menu, int $id, array $args) use (&$calls) {
|
|
$calls[] = [$id, $args];
|
|
return count($calls) === 3 ? new WP_Error('db', 'kaputt') : ($id > 0 ? $id : 50);
|
|
});
|
|
Functions\expect('wp_delete_post')->once()->with(50, true)->andReturn(new WP_Post(['ID' => 50]));
|
|
|
|
try {
|
|
(new MenuWriter())->apply(3, [
|
|
['id' => 11, 'title' => 'Wir', 'children' => [
|
|
['type' => 'custom', 'url' => '/a', 'title' => 'A'],
|
|
['type' => 'custom', 'url' => '/b', 'title' => 'B'],
|
|
]],
|
|
], $this->current());
|
|
self::fail('WriteFailed expected');
|
|
} catch (WriteFailed $e) {
|
|
self::assertTrue($e->rolledBack);
|
|
self::assertStringContainsString('kaputt', $e->getMessage());
|
|
self::assertSame('lit_write_failed', $e->toError()->get_error_code());
|
|
}
|
|
|
|
$restore = $calls[3];
|
|
self::assertSame(11, $restore[0]);
|
|
self::assertSame('', $restore[1]['menu-item-title']);
|
|
self::assertSame(0, $restore[1]['menu-item-parent-id']);
|
|
self::assertSame(2, $restore[1]['menu-item-position']);
|
|
self::assertCount(4, $calls);
|
|
}
|
|
|
|
public function testAFailedRemovalAfterAnotherRemovalIsAPartialWrite(): void
|
|
{
|
|
Functions\when('wp_update_nav_menu_item')->alias(static fn (int $menu, int $id): int => $id);
|
|
Functions\expect('wp_delete_post')->once()->with(10, true)->andReturn(new WP_Post(['ID' => 10]));
|
|
Functions\expect('wp_delete_post')->once()->with(11, true)->andReturn(false);
|
|
|
|
try {
|
|
(new MenuWriter())->apply(3, [], $this->current());
|
|
self::fail('WriteFailed expected');
|
|
} catch (WriteFailed $e) {
|
|
self::assertFalse($e->rolledBack);
|
|
$error = $e->toError();
|
|
self::assertSame('lit_partial_write', $error->get_error_code());
|
|
self::assertSame(3, $error->get_error_data()['menu_id']);
|
|
self::assertSame('Home', $error->get_error_data()['previous_items'][0]['title']);
|
|
self::assertStringContainsString('lit/menu-get', $error->get_error_data()['hint']);
|
|
self::assertStringContainsString('mcp-adapter-execute-ability', $error->get_error_data()['hint']);
|
|
}
|
|
}
|
|
}
|