feat: declarative and operation-based menu item editing

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 00:14:25 +02:00
co-authored by Claude Opus 5.5
parent 954f683dd2
commit f80cfb9e44
11 changed files with 1357 additions and 9 deletions
+17 -7
View File
@@ -112,24 +112,34 @@ final class Errors
/**
* A write failed and could not be rolled back completely – something may have been saved.
* $state and $hint replace the post-oriented defaults for writes that are not about a post
* (e.g. menus); $extra is merged into the error details.
*
* @param array<string, mixed> $extra
*/
public static function partialWrite(string $reason, ?int $postId = null, ?int $revisionId = null): WP_Error
{
$state = $postId === null
public static function partialWrite(
string $reason,
?int $postId = null,
?int $revisionId = null,
?string $state = null,
?string $hint = null,
array $extra = []
): WP_Error {
$state ??= $postId === null
? 'Möglicherweise wurde trotzdem ein Entwurf angelegt.'
: sprintf('Eintrag %d ist möglicherweise teilweise geändert bzw. angelegt worden', $postId)
. ($revisionId === null ? '.' : sprintf(' (Stand vorher: Revision %d).', $revisionId));
$details = array_filter(
$details = array_merge($extra, array_filter(
['post_id' => $postId, 'revision_id' => $revisionId],
static fn (?int $id): bool => $id !== null
);
));
return self::make(
'partial_write',
trim('Speichern fehlgeschlagen: ' . $reason) . ' Die Änderung konnte nicht vollständig zurückgenommen '
. 'werden. ' . $state,
'Den Aufruf nicht einfach wiederholen. Zuerst den aktuellen Stand prüfen (lit-content-get, bei einem '
. 'neuen Entwurf lit-content-query) und ggf. über lit-revisions-list den vorherigen Stand '
$hint ?? 'Den Aufruf nicht einfach wiederholen. Zuerst den aktuellen Stand prüfen (lit-content-get, bei '
. 'einem neuen Entwurf lit-content-query) und ggf. über lit-revisions-list den vorherigen Stand '
. 'wiederherstellen; dem Nutzer Bescheid geben.',
500,
$details
+15 -2
View File
@@ -13,16 +13,22 @@ use WP_Error;
* written. getMessage() is the internal detail for the PHP error log; $reason is the
* client-safe German text. The AbilityRegistrar logs the detail and returns toError():
* lit_write_failed when everything was rolled back, lit_partial_write otherwise.
* $state/$hint/$details customise the partial-write error for writes that are not about a
* post (see Errors::partialWrite()).
*/
final class WriteFailed extends RuntimeException
{
/** @param array<string, mixed> $details Extra error details for an incomplete rollback. */
public function __construct(
public readonly string $reason,
public readonly bool $rolledBack,
public readonly ?int $postId = null,
public readonly ?int $revisionId = null,
?Throwable $previous = null,
string $detail = ''
string $detail = '',
public readonly ?string $state = null,
public readonly ?string $hint = null,
public readonly array $details = []
) {
$internal = $detail !== '' ? $detail : ($previous !== null ? $previous->getMessage() : $reason);
parent::__construct(
@@ -36,6 +42,13 @@ final class WriteFailed extends RuntimeException
{
return $this->rolledBack
? Errors::writeFailed($this->reason)
: Errors::partialWrite($this->reason, $this->postId, $this->revisionId);
: Errors::partialWrite(
$this->reason,
$this->postId,
$this->revisionId,
$this->state,
$this->hint,
$this->details
);
}
}
@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Menus;
use LIT\MCP\Abilities\Errors;
use WP_Error;
/**
* Applies add/move/remove operations to the current menu tree and returns the
* desired tree for MenuWriter – so single operations and the declarative mode
* share one validated write path.
*/
final class MenuOperations
{
private const ROOT = 'root';
/**
* @param list<array<string, mixed>> $tree Current tree (MenuTree::build()).
* @param list<mixed> $operations
* @return list<array<string, mixed>>|WP_Error
*/
public function apply(array $tree, array $operations): array|WP_Error
{
$nodes = [];
$children = [self::ROOT => []];
$this->flatten($tree, self::ROOT, $nodes, $children);
foreach ($operations as $index => $operation) {
$error = $this->applyOne($operation, (int) $index, $nodes, $children);
if ($error !== null) {
return Errors::invalidInput(
'Menü-Operation ungültig; nichts wurde geändert.',
['operations.' . $index => $error]
);
}
}
return $this->nest(self::ROOT, $nodes, $children);
}
/**
* @param list<array<string, mixed>> $tree
* @param array<string, array<string, mixed>> $nodes
* @param array<string, list<string>> $children
*/
private function flatten(array $tree, string $parent, array &$nodes, array &$children): void
{
foreach ($tree as $item) {
$key = 'id:' . (int) $item['id'];
$nodes[$key] = ['id' => (int) $item['id']];
$children[$parent][] = $key;
$children[$key] = [];
$this->flatten((array) ($item['children'] ?? []), $key, $nodes, $children);
}
}
/**
* @param array<string, array<string, mixed>> $nodes
* @param array<string, list<string>> $children
*/
private function applyOne(mixed $operation, int $index, array &$nodes, array &$children): ?string
{
if (! is_array($operation)) {
return 'Objekt erwartet.';
}
$parentId = (int) ($operation['parent_id'] ?? 0);
$parentKey = $parentId > 0 ? 'id:' . $parentId : self::ROOT;
if (! isset($children[$parentKey])) {
return 'parent_id ist kein Eintrag dieses Menüs.';
}
$position = isset($operation['position']) ? (int) $operation['position'] : null;
$key = 'id:' . (int) ($operation['id'] ?? 0);
switch ($operation['op'] ?? '') {
case 'add':
if (! is_array($operation['item'] ?? null)) {
return 'item fehlt.';
}
$newKey = 'new:' . $index;
$nodes[$newKey] = array_diff_key($operation['item'], ['id' => true, 'children' => true]);
$children[$newKey] = [];
$this->insert($children[$parentKey], $newKey, $position);
return null;
case 'move':
if (! isset($nodes[$key])) {
return 'id ist kein Eintrag dieses Menüs.';
}
if ($key === $parentKey || $this->isDescendant($parentKey, $key, $children)) {
return 'Ein Eintrag kann nicht unter sich selbst verschoben werden.';
}
$this->detach($key, $children);
$this->insert($children[$parentKey], $key, $position);
return null;
case 'remove':
if (! isset($nodes[$key])) {
return 'id ist kein Eintrag dieses Menüs.';
}
[$owner, $at] = $this->detach($key, $children);
array_splice($children[$owner], $at, 0, $children[$key]);
unset($nodes[$key], $children[$key]);
return null;
default:
return 'op muss add, move oder remove sein.';
}
}
/** @param list<string> $list */
private function insert(array &$list, string $key, ?int $position): void
{
if ($position === null || $position >= count($list)) {
$list[] = $key;
return;
}
array_splice($list, max(0, $position), 0, [$key]);
}
/**
* @param array<string, list<string>> $children
* @return array{0: string, 1: int} Former parent key and index.
*/
private function detach(string $key, array &$children): array
{
foreach ($children as $parent => $list) {
$index = array_search($key, $list, true);
if ($index !== false) {
array_splice($children[$parent], (int) $index, 1);
return [(string) $parent, (int) $index];
}
}
return [self::ROOT, count($children[self::ROOT])];
}
/** @param array<string, list<string>> $children */
private function isDescendant(string $candidate, string $ancestor, array $children): bool
{
foreach ($children[$ancestor] ?? [] as $child) {
if ($child === $candidate || $this->isDescendant($candidate, $child, $children)) {
return true;
}
}
return false;
}
/**
* @param array<string, array<string, mixed>> $nodes
* @param array<string, list<string>> $children
* @return list<array<string, mixed>>
*/
private function nest(string $parent, array $nodes, array $children): array
{
$result = [];
foreach ($children[$parent] ?? [] as $key) {
$result[] = array_merge($nodes[$key], ['children' => $this->nest($key, $nodes, $children)]);
}
return $result;
}
}
@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Menus;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use WP_Error;
/**
* lit/menu-set-items: sets a menu's items, declaratively (items = whole target tree) or
* as add/move/remove operations. Both run through one path: operations are applied to
* the current tree (MenuOperations), the resulting tree is validated as a whole
* (MenuWriter::plan) before anything is written, removals need confirm: true, and the
* answer always carries previous_items so a mistake can be rebuilt. Write failures
* surface as WriteFailed (lit_write_failed / lit_partial_write) from MenuWriter::apply.
*/
final class MenuSetItemsAbility implements Ability
{
public function __construct(
private MenuWriter $writer,
private MenuOperations $operations,
private ChangeLog $log
) {
}
public function name(): string
{
return 'lit/menu-set-items';
}
public function label(): string
{
return 'Menüeinträge setzen';
}
public function description(): string
{
return 'Setzt die Einträge eines Menüs. Entweder items = vollständiger Zielbaum oder operations = Liste aus '
. '{op: add, item, parent_id?, position?}, {op: move, id, parent_id?, position?}, {op: remove, id} '
. '(Kinder eines entfernten Eintrags rücken an seine Stelle; position 0-basiert, ohne = ans Ende). '
. 'Der Baum aus lit-menu-get kann bearbeitet unverändert zurückgegeben werden: bestehende Einträge '
. 'werden über id erkannt, type/object/object_id und bei post/taxonomy auch url sind nur informativ '
. 'und werden ignoriert; title wird nur bei Änderung zur eigenen Beschriftung, url nur bei custom '
. 'geändert. Neue Einträge ohne id: type post|taxonomy mit object_id (muss für dich lesbar sein) oder '
. 'custom mit url (/pfad, #anker, http(s), mailto, tel) und title, jeweils optional children. Das '
. 'Ziel eines bestehenden Eintrags ändern: entfernen und neu anlegen. Alles wird vorab geprüft, dann '
. 'ganz oder gar nicht geschrieben. confirm: true ist nur nötig, wenn dabei Einträge wegfallen – '
. 'Menüeinträge haben keinen Papierkorb und werden endgültig gelöscht. Die Antwort enthält '
. 'previous_items (Stand vorher) zum Zurückbauen.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'menu_id' => ['type' => 'integer', 'minimum' => 1],
'items' => [
'type' => 'array',
'items' => ['type' => 'object'],
'description' => 'Vollständiger Zielbaum (Knoten wie in lit-menu-get, neue Einträge ohne id).',
],
'operations' => [
'type' => 'array',
'minItems' => 1,
'items' => [
'type' => 'object',
'properties' => [
'op' => ['type' => 'string', 'enum' => ['add', 'move', 'remove']],
'id' => ['type' => 'integer', 'minimum' => 1],
'item' => ['type' => 'object'],
'parent_id' => ['type' => 'integer', 'minimum' => 0],
'position' => ['type' => 'integer', 'minimum' => 0],
],
'required' => ['op'],
'additionalProperties' => false,
],
],
'confirm' => [
'type' => 'boolean',
'description' => 'Nur nötig, wenn Einträge entfernt werden.',
],
],
'required' => ['menu_id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
$tree = ['type' => 'array', 'items' => MenuTree::nodeSchema()];
return ToolResult::schema(['items' => $tree, 'previous_items' => $tree], ['items', 'previous_items']);
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => true, 'idempotent' => false];
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
return current_user_can('edit_theme_options')
? true
: Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).');
}
public function execute(array $input): mixed
{
$menuId = (int) ($input['menu_id'] ?? 0);
$menu = wp_get_nav_menu_object($menuId);
if (! is_object($menu)) {
return Errors::notFound(sprintf('Menü %d nicht gefunden.', $menuId), 'Menüs über lit-menu-list abrufen.');
}
if (array_key_exists('items', $input) === array_key_exists('operations', $input)) {
return Errors::invalidInput(
'Entweder items (Gesamtbaum) oder operations angeben – genau eines von beiden.',
['items' => 'oder operations']
);
}
$current = $this->currentItems($menuId);
$previous = MenuTree::build(array_values($current));
$tree = array_key_exists('items', $input)
? (array) $input['items']
: $this->operations->apply($previous, (array) $input['operations']);
if ($tree instanceof WP_Error) {
return $tree;
}
$plan = $this->writer->plan($tree, $current);
if ($plan['errors'] !== []) {
return Errors::invalidInput('Menübaum ist ungültig; nichts wurde geändert.', $plan['errors']);
}
if ($plan['removals'] !== [] && ($input['confirm'] ?? false) !== true) {
return Errors::confirmRequired(
sprintf(
'Folgende Menüeinträge würden endgültig entfernt: %s.',
implode(', ', array_column($plan['removals'], 'title'))
),
['removals' => $plan['removals']]
);
}
// Throws WriteFailed (after its own rollback) – the registrar turns it into the error.
$stats = $this->writer->apply($menuId, $tree, $current);
// The menu is saved – from here on failures are logged and reported as warnings only.
$warnings = $plan['warnings'];
$summary = sprintf(
'Menü "%s": %d neu, %d geändert, %d entfernt',
$menu->name,
$stats['created'],
$stats['updated'],
$stats['removed']
);
$logId = FollowUp::run(
fn () => $this->log->record($this->name(), 'nav_menu', (string) $menuId, $summary),
$this->name() . ' change log',
'Änderungsprotokoll konnte nicht geschrieben werden; das Menü ist gespeichert.',
$warnings
);
$after = FollowUp::run(
fn () => MenuTree::build(array_values($this->currentItems($menuId))),
$this->name() . ' re-read',
'Das Menü ist gespeichert, der neue Stand konnte aber nicht gelesen werden; mit lit-menu-get abrufen.',
$warnings
);
return ToolResult::ok($summary . '.')
->changed((string) $menuId, 'nav_menu', 'updated', (string) $menu->name)
->warnings($warnings)
->logId(is_int($logId) ? $logId : null)
->with('items', is_array($after) ? $after : [])
->with('previous_items', $previous)
->toArray();
}
/** @return array<int, object> The menu's items by ID, in menu order. */
private function currentItems(int $menuId): array
{
$items = wp_get_nav_menu_items($menuId);
$current = [];
foreach (is_array($items) ? $items : [] as $item) {
$current[(int) $item->ID] = $item;
}
return $current;
}
}
@@ -0,0 +1,418 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Menus;
use LIT\MCP\Abilities\WriteFailed;
use RuntimeException;
use Throwable;
use WP_Post;
use WP_Term;
/**
* Validates a desired menu tree against the current items and writes it.
*
* Node shape = the lit/menu-get node (P4 ruling): an existing item is identified by
* its id alone; type/object/object_id and – for post/taxonomy items – url are
* informational and ignored. Existing items keep all their data (classes, target,
* description, custom label, …); title only becomes a custom label when it differs
* from the currently shown title, url only changes for custom items.
*
* apply() is all-or-nothing as far as WordPress allows: on a failure it deletes the
* items it created and rewrites the touched items with their previous data. Deleted
* menu items cannot be restored with their IDs, so a failure after a deletion is a
* partial write. Not final so the ability can be tested with a mock.
*/
class MenuWriter
{
private const NON_LINKABLE = ['revision', 'nav_menu_item', 'attachment'];
private const URL_SCHEMES = ['http', 'https', 'mailto', 'tel'];
private const URL_RULE = '/pfad, #anker, http(s), mailto, tel';
/** @var list<int> Items created by the running apply(). */
private array $created = [];
/** @var list<int> Existing items rewritten by the running apply(). */
private array $touched = [];
/** @var list<int> Items deleted by the running apply(). */
private array $deleted = [];
/**
* @param list<mixed> $tree
* @param array<int, object> $current Current items by ID.
* @return array{
* errors: array<string, string>,
* removals: list<array{id: int, title: string}>,
* warnings: list<string>
* }
*/
public function plan(array $tree, array $current): array
{
$errors = [];
$warnings = [];
$seen = [];
$this->validateNodes($tree, 'items', $current, $seen, $errors, $warnings);
$removals = [];
foreach ($current as $id => $item) {
if (! isset($seen[$id])) {
$removals[] = ['id' => (int) $id, 'title' => (string) $item->title];
}
}
return ['errors' => $errors, 'removals' => $removals, 'warnings' => $warnings];
}
/**
* @param list<array<string, mixed>> $tree Validated by plan().
* @param array<int, object> $current Current items by ID, in menu order.
* @return array{created: int, updated: int, removed: int}
* @throws WriteFailed When saving failed; rolledBack tells whether the previous tree was restored.
*/
public function apply(int $menuId, array $tree, array $current): array
{
$this->created = [];
$this->touched = [];
$this->deleted = [];
try {
$position = 0;
$seen = [];
$this->writeNodes($menuId, $tree, 0, $current, $position, $seen);
foreach (array_keys($current) as $id) {
if (! isset($seen[$id])) {
$this->remove((int) $id);
}
}
} catch (Throwable $e) {
$this->fail($menuId, $current, $e);
}
return [
'created' => count($this->created),
'updated' => count($this->touched),
'removed' => count($this->deleted),
];
}
/**
* @param array<int, object> $current
* @param array<int, true> $seen
* @param array<string, string> $errors
* @param list<string> $warnings
*/
private function validateNodes(
mixed $nodes,
string $path,
array $current,
array &$seen,
array &$errors,
array &$warnings
): void {
if (! is_array($nodes) || ! array_is_list($nodes)) {
$errors[$path] = 'Liste erwartet.';
return;
}
foreach ($nodes as $index => $node) {
$nodePath = $path . '.' . $index;
if (! is_array($node)) {
$errors[$nodePath] = 'Objekt erwartet.';
continue;
}
$error = self::isExisting($node)
? $this->checkExisting($node, $current, $seen)
: $this->checkNew($node, $warnings);
if ($error !== null) {
$errors[$nodePath] = $error;
}
if (array_key_exists('children', $node)) {
$this->validateNodes($node['children'], $nodePath . '.children', $current, $seen, $errors, $warnings);
}
}
}
/** @param array<string, mixed> $node A node without id (or id 0/null) is a new item. */
private static function isExisting(array $node): bool
{
return isset($node['id']) && $node['id'] !== 0;
}
/**
* @param array<string, mixed> $node
* @param array<int, object> $current
* @param array<int, true> $seen
*/
private function checkExisting(array $node, array $current, array &$seen): ?string
{
$id = $node['id'];
if (! is_int($id) || ! isset($current[$id])) {
return 'id ist kein Eintrag dieses Menüs (für neue Einträge id weglassen).';
}
if (isset($seen[$id])) {
return sprintf('Eintrag %d kommt mehrfach vor.', $id);
}
$seen[$id] = true;
if (array_key_exists('title', $node) && ! is_string($node['title'])) {
return 'title muss Text sein.';
}
// P4: url of post/taxonomy items is derived by WordPress – informational, ignored.
if ((string) $current[$id]->type === 'custom' && $this->urlChanges($node, $current[$id])) {
return $this->validUrl($node['url']) ? null : 'Ungültige url (erlaubt: ' . self::URL_RULE . ').';
}
return null;
}
/**
* @param array<string, mixed> $node
* @param list<string> $warnings
*/
private function checkNew(array $node, array &$warnings): ?string
{
if (array_key_exists('title', $node) && ! is_string($node['title'])) {
return 'title muss Text sein.';
}
$objectId = $node['object_id'] ?? null;
switch ($node['type'] ?? null) {
case 'post':
$post = is_int($objectId) ? get_post($objectId) : null;
// Same answer for "missing" and "not readable": no probing of private posts via menus.
if (
! $post instanceof WP_Post
|| in_array($post->post_type, self::NON_LINKABLE, true)
|| ! current_user_can('read_post', $objectId)
) {
return 'object_id muss ein vorhandener, für dich lesbarer Eintrag sein.';
}
if ($post->post_status !== 'publish') {
$warnings[] = sprintf(
'"%s" ist nicht veröffentlicht und erscheint erst nach dem Veröffentlichen im Menü.',
$post->post_title
);
}
return null;
case 'taxonomy':
$term = is_int($objectId) ? get_term($objectId) : null;
return $term instanceof WP_Term
&& (is_taxonomy_viewable($term->taxonomy) || current_user_can('edit_term', $objectId))
? null
: 'object_id muss ein vorhandener, für dich lesbarer Begriff sein.';
case 'custom':
if (! $this->validUrl($node['url'] ?? null)) {
return 'Individueller Link braucht eine gültige url (' . self::URL_RULE . ').';
}
return is_string($node['title'] ?? null) && trim($node['title']) !== ''
? null
: 'Individueller Link braucht einen title.';
default:
return 'type muss post, taxonomy oder custom sein.';
}
}
/** @param array<string, mixed> $node */
private function urlChanges(array $node, object $item): bool
{
return array_key_exists('url', $node) && $node['url'] !== (string) $item->url;
}
/** @param array<string, mixed> $node */
private function titleChanges(array $node, object $item): bool
{
return isset($node['title']) && (string) $node['title'] !== (string) $item->title;
}
private function validUrl(mixed $url): bool
{
if (! is_string($url) || $url === '' || preg_match('/\s/', $url) === 1) {
return false;
}
if (str_starts_with($url, '/') || str_starts_with($url, '#')) {
return ! str_starts_with($url, '//');
}
$scheme = wp_parse_url($url, PHP_URL_SCHEME);
return is_string($scheme) && in_array(strtolower($scheme), self::URL_SCHEMES, true);
}
/**
* @param list<array<string, mixed>> $nodes
* @param array<int, object> $current
* @param array<int, true> $seen
*/
private function writeNodes(
int $menuId,
array $nodes,
int $parentId,
array $current,
int &$position,
array &$seen
): void {
foreach ($nodes as $node) {
$position++;
$existing = self::isExisting($node) ? $current[(int) $node['id']] : null;
$args = $existing !== null ? $this->argsFromItem($existing) : $this->argsFromNode($node);
if ($existing === null ? isset($node['title']) : $this->titleChanges($node, $existing)) {
$args['menu-item-title'] = (string) $node['title'];
}
if ($existing === null ? $node['type'] === 'custom' : $this->customUrlChanges($node, $existing)) {
$args['menu-item-url'] = esc_url_raw((string) $node['url']);
}
$args['menu-item-parent-id'] = $parentId;
$args['menu-item-position'] = $position;
$args['menu-item-status'] = 'publish';
$itemId = $this->save($menuId, $existing !== null ? (int) $existing->ID : 0, $args);
if ($existing !== null) {
$seen[(int) $existing->ID] = true;
$this->touched[] = (int) $existing->ID;
} else {
$this->created[] = $itemId;
}
$this->writeNodes($menuId, (array) ($node['children'] ?? []), $itemId, $current, $position, $seen);
}
}
/** @param array<string, mixed> $node */
private function customUrlChanges(array $node, object $item): bool
{
return (string) $item->type === 'custom' && $this->urlChanges($node, $item);
}
/**
* @param array<string, mixed> $args
* @throws RuntimeException
*/
private function save(int $menuId, int $itemId, array $args): int
{
$result = wp_update_nav_menu_item($menuId, $itemId, $args);
if (is_wp_error($result) || (int) $result <= 0) {
$message = is_wp_error($result) ? $result->get_error_message() : 'no item ID returned';
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- never rendered as HTML
throw new RuntimeException(sprintf('Menu item %d could not be saved: %s', $itemId, $message));
}
return (int) $result;
}
/** @throws RuntimeException */
private function remove(int $itemId): void
{
// Menu items have no trash in WordPress; the core menu editor deletes them the same way.
$deleted = wp_delete_post($itemId, true);
if (! $deleted instanceof WP_Post) {
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- never rendered as HTML
throw new RuntimeException(sprintf('Menu item %d could not be deleted.', $itemId));
}
$this->deleted[] = $itemId;
}
/**
* Undoes the running apply() and throws. Created items are deleted, touched items get
* their previous data, parent and position back. Deleted items cannot come back with
* their IDs – then (or when a restore step fails) the write is partial.
*
* @param array<int, object> $current
* @throws WriteFailed Always.
*/
private function fail(int $menuId, array $current, Throwable $cause): never
{
$problems = [];
foreach ($this->created as $itemId) {
if (! wp_delete_post($itemId, true) instanceof WP_Post) {
$problems[] = sprintf('created item %d not deleted', $itemId);
}
}
$order = array_flip(array_keys($current));
foreach ($this->touched as $itemId) {
$item = $current[$itemId];
$args = $this->argsFromItem($item);
$args['menu-item-parent-id'] = (int) $item->menu_item_parent;
$args['menu-item-position'] = isset($item->menu_order) && (int) $item->menu_order > 0
? (int) $item->menu_order
: $order[$itemId] + 1;
$args['menu-item-status'] = 'publish';
try {
$this->save($menuId, $itemId, $args);
} catch (Throwable $e) {
$problems[] = $e->getMessage();
}
}
$rolledBack = $problems === [] && $this->deleted === [];
$detail = $cause->getMessage()
. ($this->deleted !== [] ? '; already deleted: ' . implode(', ', $this->deleted) : '')
. ($problems !== [] ? '; rollback: ' . implode('; ', $problems) : '');
throw new WriteFailed(
'Das Menü konnte nicht gespeichert werden.',
$rolledBack,
null,
null,
$cause,
$detail,
sprintf('Menü %d ist möglicherweise teilweise geändert worden.', $menuId),
'Den Aufruf nicht einfach wiederholen. Zuerst den aktuellen Stand mit lit-menu-get prüfen und ihn ggf. '
. 'mit lit-menu-set-items anhand von previous_items (Stand vorher) wiederherstellen; entfernte '
. 'Einträge müssen dabei neu angelegt werden. Dem Nutzer Bescheid geben.',
['menu_id' => $menuId, 'previous_items' => MenuTree::build(array_values($current))]
);
}
/** @return array<string, mixed> The item's stored data, so a rewrite keeps everything. */
private function argsFromItem(object $item): array
{
$isCustom = (string) $item->type === 'custom';
return [
'menu-item-object-id' => (int) $item->object_id,
'menu-item-object' => (string) $item->object,
'menu-item-type' => (string) $item->type,
'menu-item-title' => (string) $item->post_title,
'menu-item-url' => $isCustom ? (string) $item->url : '',
'menu-item-description' => (string) $item->description,
'menu-item-attr-title' => (string) $item->attr_title,
'menu-item-target' => (string) $item->target,
'menu-item-classes' => implode(' ', array_filter((array) $item->classes)),
'menu-item-xfn' => (string) $item->xfn,
];
}
/**
* @param array<string, mixed> $node Validated new node.
* @return array<string, mixed>
*/
private function argsFromNode(array $node): array
{
$objectId = (int) ($node['object_id'] ?? 0);
return match ($node['type']) {
'post' => [
'menu-item-type' => 'post_type',
'menu-item-object' => (string) get_post_type($objectId),
'menu-item-object-id' => $objectId,
],
'taxonomy' => [
'menu-item-type' => 'taxonomy',
'menu-item-object' => (string) get_term($objectId)->taxonomy,
'menu-item-object-id' => $objectId,
],
default => ['menu-item-type' => 'custom', 'menu-item-object' => 'custom', 'menu-item-url' => ''],
};
}
}
@@ -17,6 +17,9 @@ use LIT\MCP\Integrations\WordPress\Menus\MenuAssignLocationAbility;
use LIT\MCP\Integrations\WordPress\Menus\MenuCreateAbility;
use LIT\MCP\Integrations\WordPress\Menus\MenuGetAbility;
use LIT\MCP\Integrations\WordPress\Menus\MenuListAbility;
use LIT\MCP\Integrations\WordPress\Menus\MenuOperations;
use LIT\MCP\Integrations\WordPress\Menus\MenuSetItemsAbility;
use LIT\MCP\Integrations\WordPress\Menus\MenuWriter;
use LIT\MCP\Integrations\WordPress\Revisions\RevisionRestoreAbility;
use LIT\MCP\Integrations\WordPress\Revisions\RevisionsListAbility;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
@@ -73,6 +76,7 @@ final class WordPressIntegration implements Integration
new MenuListAbility(),
new MenuGetAbility(),
new MenuCreateAbility($this->log),
new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), $this->log),
new MenuAssignLocationAbility($this->log),
];
}
+17
View File
@@ -29,4 +29,21 @@ final class WriteFailedTest extends TestCase
self::assertSame(5, $error->get_error_data()['post_id']);
self::assertSame(811, $error->get_error_data()['revision_id']);
}
public function testIncompleteRollbackCanDescribeItsOwnStateHintAndDetails(): void
{
$error = (new WriteFailed(
'Menü nicht gespeichert.',
false,
state: 'Das Menü ist teilweise geändert.',
hint: 'Mit lit-menu-get prüfen.',
details: ['menu_id' => 3]
))->toError();
self::assertSame('lit_partial_write', $error->get_error_code());
self::assertStringContainsString('Das Menü ist teilweise geändert.', $error->get_error_message());
self::assertStringNotContainsString('Entwurf', $error->get_error_message());
self::assertSame('Mit lit-menu-get prüfen.', $error->get_error_data()['hint']);
self::assertSame(3, $error->get_error_data()['menu_id']);
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Menus;
use LIT\MCP\Integrations\WordPress\Menus\MenuOperations;
use LIT\MCP\Tests\Unit\TestCase;
final class MenuOperationsTest extends TestCase
{
/** @return list<array<string, mixed>> Start(1) > Team(2); Kontakt(3) */
private function tree(): array
{
return [
['id' => 1, 'title' => 'Start', 'children' => [['id' => 2, 'title' => 'Team', 'children' => []]]],
['id' => 3, 'title' => 'Kontakt', 'children' => []],
];
}
public function testAddInsertsANewNodeAtThePosition(): void
{
$result = (new MenuOperations())->apply($this->tree(), [
['op' => 'add', 'item' => ['type' => 'custom', 'url' => '/blog', 'title' => 'Blog'], 'position' => 1],
]);
self::assertSame(1, $result[0]['id']);
self::assertSame(['type' => 'custom', 'url' => '/blog', 'title' => 'Blog', 'children' => []], $result[1]);
self::assertSame(3, $result[2]['id']);
}
public function testMoveAndRemoveKeepChildren(): void
{
$result = (new MenuOperations())->apply($this->tree(), [
['op' => 'move', 'id' => 3, 'parent_id' => 1, 'position' => 0],
['op' => 'remove', 'id' => 1],
]);
self::assertSame([3, 2], array_column($result, 'id'));
self::assertSame([], $result[0]['children']);
}
public function testInvalidOperationsAreReportedWithTheirIndex(): void
{
$operations = new MenuOperations();
$unknown = $operations->apply($this->tree(), [['op' => 'move', 'id' => 99]]);
$cycle = $operations->apply($this->tree(), [['op' => 'move', 'id' => 1, 'parent_id' => 2]]);
$badOp = $operations->apply($this->tree(), [['op' => 'rename', 'id' => 1]]);
self::assertArrayHasKey('operations.0', $unknown->get_error_data()['fields']);
self::assertStringContainsString('unter sich selbst', $cycle->get_error_data()['fields']['operations.0']);
self::assertStringContainsString('add, move oder remove', $badOp->get_error_data()['fields']['operations.0']);
}
public function testExistingNodesKeepOnlyTheirIdSoTheWriterKeepsTheirData(): void
{
$result = (new MenuOperations())->apply($this->tree(), [['op' => 'move', 'id' => 2]]);
self::assertSame(
[['id' => 1, 'children' => []], ['id' => 3, 'children' => []], ['id' => 2, 'children' => []]],
$result
);
}
}
@@ -0,0 +1,194 @@
<?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\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Menus\MenuOperations;
use LIT\MCP\Integrations\WordPress\Menus\MenuSetItemsAbility;
use LIT\MCP\Integrations\WordPress\Menus\MenuTree;
use LIT\MCP\Integrations\WordPress\Menus\MenuWriter;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
final class MenuSetItemsAbilityTest extends TestCase
{
use MenuFixtures;
protected function setUp(): void
{
parent::setUp();
Functions\when('wp_get_nav_menu_object')->justReturn((object) ['term_id' => 3, 'name' => 'Hauptmenü']);
Functions\when('wp_get_nav_menu_items')->justReturn([self::item(10, 0, 'Home', 'custom', '/')]);
}
public function testItemsAndOperationsAreMutuallyExclusive(): void
{
$ability = new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), Mockery::mock(ChangeLog::class));
self::assertSame('lit_validation_failed', $ability->execute(['menu_id' => 3])->get_error_code());
self::assertSame(
'lit_validation_failed',
$ability->execute(['menu_id' => 3, 'items' => [], 'operations' => [['op' => 'remove', 'id' => 10]]])
->get_error_code()
);
}
public function testUnknownMenuIsNotFound(): void
{
Functions\when('wp_get_nav_menu_object')->justReturn(false);
$ability = new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), Mockery::mock(ChangeLog::class));
self::assertSame('lit_not_found', $ability->execute(['menu_id' => 9, 'items' => []])->get_error_code());
}
public function testRemovalsNeedConfirmationAndNothingIsWritten(): void
{
Functions\expect('wp_update_nav_menu_item')->never();
Functions\expect('wp_delete_post')->never();
$ability = new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), Mockery::mock(ChangeLog::class));
$result = $ability->execute(['menu_id' => 3, 'items' => []]);
self::assertSame('lit_confirm_required', $result->get_error_code());
self::assertSame([['id' => 10, 'title' => 'Home']], $result->get_error_data()['removals']);
}
public function testAnInvalidTreeWritesNothing(): void
{
Functions\when('wp_parse_url')->alias('parse_url');
Functions\expect('wp_update_nav_menu_item')->never();
Functions\expect('wp_delete_post')->never();
$ability = new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), Mockery::mock(ChangeLog::class));
$result = $ability->execute([
'menu_id' => 3,
'confirm' => true,
'items' => [['id' => 10], ['type' => 'custom', 'url' => 'javascript:alert(1)', 'title' => 'X']],
]);
self::assertSame('lit_validation_failed', $result->get_error_code());
self::assertArrayHasKey('items.1', $result->get_error_data()['fields']);
}
public function testInvalidOperationsWriteNothing(): void
{
$writer = Mockery::mock(MenuWriter::class);
$writer->expects('apply')->never();
$ability = new MenuSetItemsAbility($writer, new MenuOperations(), Mockery::mock(ChangeLog::class));
$result = $ability->execute(['menu_id' => 3, 'operations' => [['op' => 'move', 'id' => 99]]]);
self::assertSame('lit_validation_failed', $result->get_error_code());
}
public function testAppliesAndReturnsTheTreeBeforeAndAfter(): void
{
$writer = Mockery::mock(MenuWriter::class);
$writer->allows('plan')->andReturn(['errors' => [], 'removals' => [], 'warnings' => []]);
$writer->expects('apply')->andReturn(['created' => 1, 'updated' => 1, 'removed' => 0]);
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->with('lit/menu-set-items', 'nav_menu', '3', Mockery::type('string'))->andReturn(21);
$result = (new MenuSetItemsAbility($writer, new MenuOperations(), $log))->execute([
'menu_id' => 3,
'operations' => [['op' => 'add', 'item' => ['type' => 'custom', 'url' => '/blog', 'title' => 'Blog']]],
]);
self::assertSame('Home', $result['previous_items'][0]['title']);
self::assertSame(21, $result['log_id']);
self::assertStringContainsString('1 neu, 1 geändert, 0 entfernt', $result['summary']);
}
public function testConfirmedRemovalsAreWrittenAndTheOldTreeIsReturned(): void
{
$writer = Mockery::mock(MenuWriter::class);
$writer->allows('plan')->andReturn(
['errors' => [], 'removals' => [['id' => 10, 'title' => 'Home']], 'warnings' => []]
);
$writer->expects('apply')->with(3, [], Mockery::type('array'))
->andReturn(['created' => 0, 'updated' => 0, 'removed' => 1]);
$log = Mockery::mock(ChangeLog::class);
$log->allows('record')->andReturn(22);
$result = (new MenuSetItemsAbility($writer, new MenuOperations(), $log))->execute(
['menu_id' => 3, 'items' => [], 'confirm' => true]
);
self::assertSame(10, $result['previous_items'][0]['id']);
self::assertStringContainsString('0 neu, 0 geändert, 1 entfernt', $result['summary']);
}
public function testMenuGetOutputIsAcceptedAsInput(): void
{
$items = [self::item(10, 0, 'Start'), self::item(11, 10, 'Team'), self::item(12, 0, 'Kontakt', 'custom', '/k')];
$items[0]->url = 'https://example.test/';
Functions\when('wp_get_nav_menu_items')->justReturn($items);
Functions\when('wp_update_nav_menu_item')->alias(static fn (int $menu, int $id): int => $id);
Functions\expect('wp_delete_post')->never();
$log = Mockery::mock(ChangeLog::class);
$log->allows('record')->andReturn(23);
$result = (new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), $log))->execute(
['menu_id' => 3, 'items' => MenuTree::build($items)]
);
self::assertStringContainsString('0 neu, 3 geändert, 0 entfernt', $result['summary']);
self::assertSame($result['previous_items'], $result['items']);
}
public function testAWriteFailureIsPassedOnAsWriteFailed(): void
{
$writer = Mockery::mock(MenuWriter::class);
$writer->allows('plan')->andReturn(['errors' => [], 'removals' => [], 'warnings' => []]);
$writer->expects('apply')->andThrow(new WriteFailed('Menü nicht gespeichert.', true));
$ability = new MenuSetItemsAbility($writer, new MenuOperations(), Mockery::mock(ChangeLog::class));
$this->expectException(WriteFailed::class);
$ability->execute(['menu_id' => 3, 'items' => [['id' => 10]]]);
}
public function testAChangeLogFailureIsOnlyAWarning(): void
{
$writer = Mockery::mock(MenuWriter::class);
$writer->allows('plan')->andReturn(['errors' => [], 'removals' => [], 'warnings' => ['"X" ist nicht veröffentlicht']]);
$writer->allows('apply')->andReturn(['created' => 0, 'updated' => 1, 'removed' => 0]);
$log = Mockery::mock(ChangeLog::class);
$log->allows('record')->andThrow(new RuntimeException('db down'));
$file = (string) tempnam(sys_get_temp_dir(), 'lit');
$errorLog = ini_set('error_log', $file);
try {
$result = (new MenuSetItemsAbility($writer, new MenuOperations(), $log))->execute(
['menu_id' => 3, 'items' => [['id' => 10]]]
);
} finally {
ini_set('error_log', (string) $errorLog);
unlink($file);
}
self::assertNull($result['log_id']);
self::assertCount(2, $result['warnings']);
}
public function testPermissionNeedsThemeOptionRights(): void
{
Functions\when('current_user_can')->justReturn(false);
$ability = new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), Mockery::mock(ChangeLog::class));
self::assertSame('lit_forbidden', $ability->permission(['menu_id' => 3])->get_error_code());
}
public function testIsDestructiveAndDescribesWhenConfirmIsNeeded(): void
{
$ability = new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), Mockery::mock(ChangeLog::class));
self::assertSame(['readonly' => false, 'destructive' => true, 'idempotent' => false], $ability->annotations());
self::assertStringContainsString('confirm: true', $ability->description());
self::assertStringContainsString('nur nötig', $ability->description());
}
}
@@ -0,0 +1,258 @@
<?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());
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::assertStringContainsString('"Neu" ist nicht veröffentlicht', $plan['warnings'][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);
Functions\expect('current_user_can')->with('edit_term', 4)->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, für dich lesbarer Begriff 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']);
}
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();
$current = $this->realistic();
$tree = json_decode((string) json_encode(MenuTree::build(array_values($current))), true);
$calls = [];
Functions\when('wp_update_nav_menu_item')->alias(static function (int $menu, int $id, array $args) use (&$calls) {
$calls[$id] = $args;
return $id;
});
Functions\expect('wp_delete_post')->never();
$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' => 4, 'removed' => 0], $stats);
self::assertSame([10, 11, 12, 13], array_keys($calls));
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], array_column($calls, 'menu-item-position'));
}
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('Startseite', $calls[10]['menu-item-title']);
self::assertSame('Wir über uns', $calls[11]['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']);
}
}
}
@@ -31,6 +31,7 @@ final class WordPressIntegrationTest extends TestCase
'lit/menu-list',
'lit/menu-get',
'lit/menu-create',
'lit/menu-set-items',
'lit/menu-assign-location',
];