Files
WP-LIT-MCP/tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php
T
janniclas.loosenandClaude Opus 5.5 60290c85a5 test: AssertsToolErrors helper for tool error assertions (T3)
tests/Support/AssertsToolErrors (used by TestCase): assertToolError($result, code)
checks the WP_Error code lit_<code> and the code of the client JSON and returns the
payload; clientError() decodes the payload; errorFields() returns the fields and
asserts they exist. Replaces the private clientError() decoders of
DiviWriteAbilitiesTest/DiviReplaceTextAbilityTest, 216 single-line
assertSame('lit_…', …->get_error_code()) assertions, 24 get_error_data()['fields']
digs and two json_decode(message) reads. ErrorsTest keeps json_decode: it tests
the format itself. Tests 1404 -> 1409 (AssertsToolErrorsTest); no test has fewer
assertions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 17:55:35 +02:00

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::assertToolError($e->toError(), 'write_failed');
}
$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::assertToolError($error, 'partial_write');
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']);
}
}
}