diff --git a/src/Abilities/Errors.php b/src/Abilities/Errors.php index f201cd3..60d1884 100644 --- a/src/Abilities/Errors.php +++ b/src/Abilities/Errors.php @@ -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 $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 diff --git a/src/Abilities/WriteFailed.php b/src/Abilities/WriteFailed.php index eb6b4eb..936a5da 100644 --- a/src/Abilities/WriteFailed.php +++ b/src/Abilities/WriteFailed.php @@ -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 $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 + ); } } diff --git a/src/Integrations/WordPress/Menus/MenuOperations.php b/src/Integrations/WordPress/Menus/MenuOperations.php new file mode 100644 index 0000000..307d655 --- /dev/null +++ b/src/Integrations/WordPress/Menus/MenuOperations.php @@ -0,0 +1,165 @@ +> $tree Current tree (MenuTree::build()). + * @param list $operations + * @return list>|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> $tree + * @param array> $nodes + * @param array> $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> $nodes + * @param array> $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 $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> $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> $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> $nodes + * @param array> $children + * @return list> + */ + 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; + } +} diff --git a/src/Integrations/WordPress/Menus/MenuSetItemsAbility.php b/src/Integrations/WordPress/Menus/MenuSetItemsAbility.php new file mode 100644 index 0000000..5ab5069 --- /dev/null +++ b/src/Integrations/WordPress/Menus/MenuSetItemsAbility.php @@ -0,0 +1,203 @@ + '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 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; + } +} diff --git a/src/Integrations/WordPress/Menus/MenuWriter.php b/src/Integrations/WordPress/Menus/MenuWriter.php new file mode 100644 index 0000000..b0d248c --- /dev/null +++ b/src/Integrations/WordPress/Menus/MenuWriter.php @@ -0,0 +1,418 @@ + Items created by the running apply(). */ + private array $created = []; + + /** @var list Existing items rewritten by the running apply(). */ + private array $touched = []; + + /** @var list Items deleted by the running apply(). */ + private array $deleted = []; + + /** + * @param list $tree + * @param array $current Current items by ID. + * @return array{ + * errors: array, + * removals: list, + * warnings: list + * } + */ + 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> $tree Validated by plan(). + * @param array $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 $current + * @param array $seen + * @param array $errors + * @param list $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 $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 $node + * @param array $current + * @param array $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 $node + * @param list $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 $node */ + private function urlChanges(array $node, object $item): bool + { + return array_key_exists('url', $node) && $node['url'] !== (string) $item->url; + } + + /** @param array $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> $nodes + * @param array $current + * @param array $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 $node */ + private function customUrlChanges(array $node, object $item): bool + { + return (string) $item->type === 'custom' && $this->urlChanges($node, $item); + } + + /** + * @param array $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 $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 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 $node Validated new node. + * @return array + */ + 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' => ''], + }; + } +} diff --git a/src/Integrations/WordPress/WordPressIntegration.php b/src/Integrations/WordPress/WordPressIntegration.php index a87aaf8..a682a28 100644 --- a/src/Integrations/WordPress/WordPressIntegration.php +++ b/src/Integrations/WordPress/WordPressIntegration.php @@ -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), ]; } diff --git a/tests/Unit/Abilities/WriteFailedTest.php b/tests/Unit/Abilities/WriteFailedTest.php index c0b2e4d..f9a3fe5 100644 --- a/tests/Unit/Abilities/WriteFailedTest.php +++ b/tests/Unit/Abilities/WriteFailedTest.php @@ -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']); + } } diff --git a/tests/Unit/Integrations/WordPress/Menus/MenuOperationsTest.php b/tests/Unit/Integrations/WordPress/Menus/MenuOperationsTest.php new file mode 100644 index 0000000..508a81f --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Menus/MenuOperationsTest.php @@ -0,0 +1,65 @@ +> 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 + ); + } +} diff --git a/tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php b/tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php new file mode 100644 index 0000000..7d92d6d --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php @@ -0,0 +1,194 @@ +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()); + } +} diff --git a/tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php b/tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php new file mode 100644 index 0000000..813d7a4 --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php @@ -0,0 +1,258 @@ +alias('parse_url'); + Functions\when('esc_url_raw')->returnArg(); + } + + /** @return array */ + private function current(): array + { + return [ + 10 => self::item(10, 0, 'Home', 'custom', '/'), + 11 => self::item(11, 0, 'Über uns'), + ]; + } + + /** @return array 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']); + } + } +} diff --git a/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php index 18a66e6..6e59c55 100644 --- a/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php +++ b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php @@ -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', ];