diff --git a/src/Integrations/WordPress/Menus/MenuOperations.php b/src/Integrations/WordPress/Menus/MenuOperations.php index 307d655..7021301 100644 --- a/src/Integrations/WordPress/Menus/MenuOperations.php +++ b/src/Integrations/WordPress/Menus/MenuOperations.php @@ -66,16 +66,18 @@ final class MenuOperations return 'Objekt erwartet.'; } + $op = $operation['op'] ?? ''; $parentId = (int) ($operation['parent_id'] ?? 0); $parentKey = $parentId > 0 ? 'id:' . $parentId : self::ROOT; - if (! isset($children[$parentKey])) { + // parent_id only matters where something is placed; remove ignores it. + if (in_array($op, ['add', 'move'], true) && ! 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'] ?? '') { + switch ($op) { case 'add': if (! is_array($operation['item'] ?? null)) { return 'item fehlt.'; diff --git a/src/Integrations/WordPress/Menus/MenuSetItemsAbility.php b/src/Integrations/WordPress/Menus/MenuSetItemsAbility.php index 5ab5069..a78706d 100644 --- a/src/Integrations/WordPress/Menus/MenuSetItemsAbility.php +++ b/src/Integrations/WordPress/Menus/MenuSetItemsAbility.php @@ -46,12 +46,17 @@ final class MenuSetItemsAbility implements Ability . '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 ' + . 'geändert. Neue Einträge ohne id: type post|taxonomy mit object_id oder custom mit url (/pfad, ' + . '#anker, http(s), mailto, tel) und title, jeweils optional children. Verlinkt werden nur für dich ' + . 'lesbare, veröffentlichte Inhalte und Begriffe öffentlicher Taxonomien; private Inhalte nie. ' + . 'Entwürfe/ausstehende/geplante Inhalte nur mit allow_unpublished: true – WordPress zeigt sie sofort ' + . 'im Menü, Besucher erhalten bis zur Veröffentlichung einen toten Link. 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.'; + . 'previous_items (Stand vorher) zum Zurückbauen; entfernte Einträge lassen sich daraus nur mit ' + . 'Titel, Ziel und Position neu anlegen (neue id; CSS-Klassen, Linkziel, Beschreibung gehen verloren). ' + . 'Nur tatsächlich geänderte Einträge werden geschrieben.'; } public function inputSchema(): array @@ -81,6 +86,11 @@ final class MenuSetItemsAbility implements Ability 'additionalProperties' => false, ], ], + 'allow_unpublished' => [ + 'type' => 'boolean', + 'description' => 'Links auf Entwürfe/ausstehende/geplante Inhalte erlauben (sofort sichtbar, ' + . 'bis zur Veröffentlichung toter Link).', + ], 'confirm' => [ 'type' => 'boolean', 'description' => 'Nur nötig, wenn Einträge entfernt werden.', @@ -140,7 +150,7 @@ final class MenuSetItemsAbility implements Ability return $tree; } - $plan = $this->writer->plan($tree, $current); + $plan = $this->writer->plan($tree, $current, ($input['allow_unpublished'] ?? false) === true); if ($plan['errors'] !== []) { return Errors::invalidInput('Menübaum ist ungültig; nichts wurde geändert.', $plan['errors']); } @@ -160,19 +170,22 @@ final class MenuSetItemsAbility implements Ability // 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( + $changed = array_sum($stats) > 0; + $summary = $changed + ? sprintf( + 'Menü "%s": %d neu, %d geändert, %d entfernt', + $menu->name, + $stats['created'], + $stats['updated'], + $stats['removed'] + ) + : sprintf('Menü "%s": keine Änderung', $menu->name); + $logId = $changed ? 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 - ); + ) : null; $after = FollowUp::run( fn () => MenuTree::build(array_values($this->currentItems($menuId))), $this->name() . ' re-read', @@ -180,8 +193,12 @@ final class MenuSetItemsAbility implements Ability $warnings ); - return ToolResult::ok($summary . '.') - ->changed((string) $menuId, 'nav_menu', 'updated', (string) $menu->name) + $result = ToolResult::ok($summary . '.'); + if ($changed) { + $result->changed((string) $menuId, 'nav_menu', 'updated', (string) $menu->name); + } + + return $result ->warnings($warnings) ->logId(is_int($logId) ? $logId : null) ->with('items', is_array($after) ? $after : []) diff --git a/src/Integrations/WordPress/Menus/MenuWriter.php b/src/Integrations/WordPress/Menus/MenuWriter.php index b0d248c..d44073e 100644 --- a/src/Integrations/WordPress/Menus/MenuWriter.php +++ b/src/Integrations/WordPress/Menus/MenuWriter.php @@ -17,7 +17,8 @@ use WP_Term; * 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. + * from the currently shown title, url only changes for custom items. Only items whose + * parent, position, title or url actually change are rewritten (and counted as updated). * * 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 @@ -27,6 +28,8 @@ use WP_Term; class MenuWriter { private const NON_LINKABLE = ['revision', 'nav_menu_item', 'attachment']; + /** Statuses WordPress still shows in menus although visitors get a dead link until publication. */ + private const UNPUBLISHED = ['draft', 'pending', 'future']; private const URL_SCHEMES = ['http', 'https', 'mailto', 'tel']; private const URL_RULE = '/pfad, #anker, http(s), mailto, tel'; @@ -39,20 +42,25 @@ class MenuWriter /** @var list Items deleted by the running apply(). */ private array $deleted = []; + /** Set by plan(): links to draft/pending/future posts are allowed. */ + private bool $allowUnpublished = false; + /** * @param list $tree - * @param array $current Current items by ID. + * @param array $current Current items by ID. + * @param bool $allowUnpublished Allow links to draft/pending/future posts (with a warning). * @return array{ * errors: array, * removals: list, * warnings: list * } */ - public function plan(array $tree, array $current): array + public function plan(array $tree, array $current, bool $allowUnpublished = false): array { $errors = []; $warnings = []; $seen = []; + $this->allowUnpublished = $allowUnpublished; $this->validateNodes($tree, 'items', $current, $seen, $errors, $warnings); $removals = []; @@ -195,19 +203,13 @@ class MenuWriter ) { 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; + return $this->checkStatus($post, $warnings); 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)) + // Terms of non-public taxonomies would show internal structure in a public menu. + return $term instanceof WP_Term && is_taxonomy_viewable($term->taxonomy) ? null - : 'object_id muss ein vorhandener, für dich lesbarer Begriff sein.'; + : 'object_id muss ein vorhandener Begriff einer öffentlichen Taxonomie sein.'; case 'custom': if (! $this->validUrl($node['url'] ?? null)) { return 'Individueller Link braucht eine gültige url (' . self::URL_RULE . ').'; @@ -220,6 +222,45 @@ class MenuWriter } } + /** + * WordPress shows menu items of draft/pending/private/future posts on the live site right + * away (only trashed or missing objects are hidden), so private posts are always refused + * and unpublished ones only with allow_unpublished. + * + * @param list $warnings + */ + private function checkStatus(WP_Post $post, array &$warnings): ?string + { + $status = (string) $post->post_status; + if ($status === 'publish') { + return null; + } + + if ($status === 'private') { + return 'Private Inhalte dürfen nicht in öffentlichen Menüs erscheinen (Besucher sähen Titel und Link).'; + } + + if (! in_array($status, self::UNPUBLISHED, true)) { + return sprintf('Einträge mit Status %s können nicht verlinkt werden.', $status); + } + + if (! $this->allowUnpublished) { + return sprintf( + '"%s" ist nicht veröffentlicht (%s), würde aber sofort im Menü erscheinen – als toter Link. ' + . 'Erst veröffentlichen oder allow_unpublished: true setzen.', + $post->post_title, + $status + ); + } + + $warnings[] = sprintf( + 'Eintrag "%s" ist sofort im Menü sichtbar; Besucher erhalten bis zur Veröffentlichung einen toten Link.', + $post->post_title + ); + + return null; + } + /** @param array $node */ private function urlChanges(array $node, object $item): bool { @@ -234,12 +275,17 @@ class MenuWriter private function validUrl(mixed $url): bool { - if (! is_string($url) || $url === '' || preg_match('/\s/', $url) === 1) { + if (! is_string($url) || $url === '' || preg_match('/[\s\x00-\x1f\x7f]/', $url) === 1) { return false; } - if (str_starts_with($url, '/') || str_starts_with($url, '#')) { - return ! str_starts_with($url, '//'); + if (str_starts_with($url, '/')) { + // "//host" and "/\host" are protocol-relative links to another host in browsers. + return ! in_array(substr($url, 1, 1), ['/', '\\'], true); + } + + if (str_starts_with($url, '#')) { + return true; } $scheme = wp_parse_url($url, PHP_URL_SCHEME); @@ -263,22 +309,35 @@ class MenuWriter 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; + $itemId = (int) $existing->ID; + $seen[$itemId] = true; + $titleChanges = $this->titleChanges($node, $existing); + $urlChanges = $this->customUrlChanges($node, $existing); + $moved = $parentId !== (int) $existing->menu_item_parent + || $position !== $this->storedPosition($current, $itemId); + if ($titleChanges || $urlChanges || $moved) { + $args = $this->withNode( + $this->argsFromItem($existing), + $node, + $titleChanges, + $urlChanges, + $parentId, + $position + ); + $this->save($menuId, $itemId, $args); + $this->touched[] = $itemId; + } } else { + $args = $this->withNode( + $this->argsFromNode($node), + $node, + isset($node['title']), + $node['type'] === 'custom', + $parentId, + $position + ); + $itemId = $this->save($menuId, 0, $args); $this->created[] = $itemId; } @@ -286,6 +345,45 @@ class MenuWriter } } + /** + * @param array $args + * @param array $node + * @return array + */ + private function withNode( + array $args, + array $node, + bool $setTitle, + bool $setUrl, + int $parentId, + int $position + ): array { + if ($setTitle) { + $args['menu-item-title'] = (string) $node['title']; + } + if ($setUrl) { + $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'; + + return $args; + } + + /** + * The item's current position: its menu_order, or – when that is missing – its index in + * $current (which is in menu order). + * + * @param array $current + */ + private function storedPosition(array $current, int $itemId): int + { + $order = (int) ($current[$itemId]->menu_order ?? 0); + + return $order > 0 ? $order : (int) array_search($itemId, array_keys($current), true) + 1; + } + /** @param array $node */ private function customUrlChanges(array $node, object $item): bool { @@ -338,14 +436,11 @@ class MenuWriter } } - $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-position'] = $this->storedPosition($current, $itemId); $args['menu-item-status'] = 'publish'; try { $this->save($menuId, $itemId, $args); diff --git a/tests/Unit/Integrations/WordPress/Menus/MenuOperationsTest.php b/tests/Unit/Integrations/WordPress/Menus/MenuOperationsTest.php index 508a81f..68df89a 100644 --- a/tests/Unit/Integrations/WordPress/Menus/MenuOperationsTest.php +++ b/tests/Unit/Integrations/WordPress/Menus/MenuOperationsTest.php @@ -62,4 +62,11 @@ final class MenuOperationsTest extends TestCase $result ); } + + public function testRemoveIgnoresParentId(): void + { + $result = (new MenuOperations())->apply($this->tree(), [['op' => 'remove', 'id' => 3, 'parent_id' => 99]]); + + self::assertSame([1], array_column($result, 'id')); + } } diff --git a/tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php b/tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php index 7d92d6d..c5a9b78 100644 --- a/tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php @@ -128,19 +128,77 @@ final class MenuSetItemsAbilityTest extends TestCase $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(); + Functions\expect('wp_update_nav_menu_item')->never(); $log = Mockery::mock(ChangeLog::class); - $log->allows('record')->andReturn(23); + $log->expects('record')->never(); $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('Menü "Hauptmenü": keine Änderung.', $result['summary']); + self::assertSame([], $result['changed']); + self::assertNull($result['log_id']); self::assertSame($result['previous_items'], $result['items']); } + public function testAllowUnpublishedIsPassedToThePlan(): void + { + $writer = Mockery::mock(MenuWriter::class); + $writer->expects('plan')->with(Mockery::type('array'), Mockery::type('array'), true) + ->andReturn(['errors' => ['items.0' => 'x'], 'removals' => [], 'warnings' => []]); + $ability = new MenuSetItemsAbility($writer, new MenuOperations(), Mockery::mock(ChangeLog::class)); + + $result = $ability->execute( + ['menu_id' => 3, 'items' => [['type' => 'post', 'object_id' => 7]], 'allow_unpublished' => true] + ); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertArrayHasKey('allow_unpublished', $ability->inputSchema()['properties']); + } + + public function testADraftLinkIsRefusedWithoutTheFlagAndWrittenWithAWarningWithIt(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post')->justReturn( + new \WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => 'Neu']) + ); + Functions\when('get_post_type')->justReturn('page'); + Functions\expect('wp_update_nav_menu_item')->once()->andReturn(60); + $log = Mockery::mock(ChangeLog::class); + $log->allows('record')->andReturn(24); + $ability = new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), $log); + $input = ['menu_id' => 3, 'operations' => [['op' => 'add', 'item' => ['type' => 'post', 'object_id' => 7]]]]; + + $refused = $ability->execute($input); + $allowed = $ability->execute($input + ['allow_unpublished' => true]); + + self::assertSame('lit_validation_failed', $refused->get_error_code()); + self::assertStringContainsString('allow_unpublished', $refused->get_error_data()['fields']['items.1']); + self::assertStringContainsString('1 neu', $allowed['summary']); + self::assertStringContainsString('sofort im Menü sichtbar', $allowed['warnings'][0]); + } + + public function testAPrivatePostIsNeverLinked(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post')->justReturn( + new \WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'private', 'post_title' => 'Intern']) + ); + Functions\expect('wp_update_nav_menu_item')->never(); + $ability = new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), Mockery::mock(ChangeLog::class)); + + $result = $ability->execute([ + 'menu_id' => 3, + 'allow_unpublished' => true, + 'operations' => [['op' => 'add', 'item' => ['type' => 'post', 'object_id' => 7]]], + ]); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertStringContainsString('Private Inhalte', $result->get_error_data()['fields']['items.1']); + } + public function testAWriteFailureIsPassedOnAsWriteFailed(): void { $writer = Mockery::mock(MenuWriter::class); @@ -190,5 +248,6 @@ final class MenuSetItemsAbilityTest extends TestCase self::assertSame(['readonly' => false, 'destructive' => true, 'idempotent' => false], $ability->annotations()); self::assertStringContainsString('confirm: true', $ability->description()); self::assertStringContainsString('nur nötig', $ability->description()); + self::assertStringContainsString('CSS-Klassen', $ability->description()); } } diff --git a/tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php b/tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php index 813d7a4..207d8d6 100644 --- a/tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php +++ b/tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php @@ -65,7 +65,7 @@ final class MenuWriterTest extends TestCase ['type' => 'post', 'object_id' => 7], ['type' => 'custom', 'url' => 'javascript:alert(1)', 'title' => 'X'], ['type' => 'custom', 'url' => '/kontakt'], - ], $this->current()); + ], $this->current(), true); self::assertStringContainsString('mehrfach', $plan['errors']['items.0.children.0']); self::assertStringContainsString('kein Eintrag dieses Menüs', $plan['errors']['items.1']); @@ -73,7 +73,45 @@ final class MenuWriterTest extends TestCase 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]); + self::assertSame( + ['Eintrag "Neu" ist sofort im Menü sichtbar; Besucher erhalten bis zur Veröffentlichung einen toten Link.'], + $plan['warnings'] + ); + } + + /** @return array status, allow_unpublished, accepted */ + public static function statuses(): array + { + return [ + 'published' => ['publish', false, true], + 'private is always refused' => ['private', true, false], + 'draft without flag' => ['draft', false, false], + 'pending without flag' => ['pending', false, false], + 'future without flag' => ['future', false, false], + 'draft with flag' => ['draft', true, true], + 'future with flag' => ['future', true, true], + 'trash' => ['trash', true, false], + ]; + } + + /** @dataProvider statuses */ + public function testPostStatusDecidesWhetherItCanBeLinked(string $status, bool $allow, bool $accepted): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post')->justReturn( + new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => $status, 'post_title' => 'Seite']) + ); + + $plan = (new MenuWriter())->plan([['type' => 'post', 'object_id' => 7]], [], $allow); + + self::assertSame($accepted, ! isset($plan['errors']['items.0']), $plan['errors']['items.0'] ?? ''); + self::assertSame($accepted && $status !== 'publish' ? 1 : 0, count($plan['warnings'])); + if ($status === 'private') { + self::assertStringContainsString('Private Inhalte', $plan['errors']['items.0']); + } + if (! $accepted && in_array($status, ['draft', 'pending', 'future'], true)) { + self::assertStringContainsString('allow_unpublished', $plan['errors']['items.0']); + } } public function testPlanRejectsChildrenThatAreNoListAndNodesThatAreNoObject(): void @@ -92,7 +130,6 @@ final class MenuWriterTest extends TestCase 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]], @@ -100,7 +137,10 @@ final class MenuWriterTest extends TestCase ); 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( + 'object_id muss ein vorhandener Begriff einer öffentlichen Taxonomie sein.', + $plan['errors']['items.1'] + ); self::assertSame([], $plan['warnings']); } @@ -126,6 +166,42 @@ final class MenuWriterTest extends TestCase self::assertSame(['items.1' => 'Ungültige url (erlaubt: /pfad, #anker, http(s), mailto, tel).'], $plan['errors']); } + /** @return array */ + public static function badUrls(): array + { + return [ + 'mixed-case javascript' => ['JaVaScRiPt:alert(1)'], + 'embedded tab' => ["java\tscript:alert(1)"], + 'data' => ['data:text/html,'], + 'vbscript' => ['vbscript:msgbox(1)'], + 'protocol-relative' => ['//evil.test/'], + 'protocol-relative backslash' => ['/\\evil.test/'], + 'leading space' => [' https://example.test/'], + 'control character' => ["https://example.test/\x00"], + ]; + } + + /** @dataProvider badUrls */ + public function testDangerousUrlsAreRejected(string $url): void + { + $plan = (new MenuWriter())->plan([['type' => 'custom', 'url' => $url, 'title' => 'X']], []); + + self::assertStringContainsString('url', $plan['errors']['items.0']); + } + + public function testSafeUrlsAreAccepted(): void + { + $plan = (new MenuWriter())->plan([ + ['type' => 'custom', 'url' => '/kontakt', 'title' => 'A'], + ['type' => 'custom', 'url' => '#', 'title' => 'B'], + ['type' => 'custom', 'url' => 'HTTPS://example.test/', 'title' => 'C'], + ['type' => 'custom', 'url' => 'mailto:info@example.test', 'title' => 'D'], + ['type' => 'custom', 'url' => 'tel:+4912345', 'title' => 'E'], + ], []); + + self::assertSame([], $plan['errors']); + } + public function testIdZeroMeansANewItem(): void { $plan = (new MenuWriter())->plan([['id' => 0, 'type' => 'custom', 'url' => '#', 'title' => 'Mehr']], []); @@ -160,22 +236,34 @@ final class MenuWriterTest extends TestCase public function testMenuGetOutputRoundTripsAsANoOp(): void { Functions\expect('get_post')->never(); + Functions\expect('wp_update_nav_menu_item')->never(); + Functions\expect('wp_delete_post')->never(); $current = $this->realistic(); $tree = json_decode((string) json_encode(MenuTree::build(array_values($current))), true); - $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' => 0, 'removed' => 0], $stats); + } + + public function testOnlyMovedItemsAreRewrittenAndKeepTheirData(): void + { + $current = $this->realistic(); + $calls = []; + Functions\when('wp_update_nav_menu_item')->alias(static function (int $menu, int $id, array $args) use (&$calls) { + $calls[$id] = $args; + return $id; + }); + + // Kontakt(13) moves to the front: every item's position shifts by one. + $tree = MenuTree::build(array_values($current)); + array_unshift($tree, array_pop($tree)); + $stats = (new MenuWriter())->apply(3, $tree, $current); + self::assertSame(['created' => 0, 'updated' => 4, 'removed' => 0], $stats); - 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"); @@ -183,7 +271,27 @@ final class MenuWriterTest extends TestCase } 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')); + self::assertSame([1, 2, 3, 4], [$calls[13]['menu-item-position'], $calls[10]['menu-item-position'], + $calls[11]['menu-item-position'], $calls[12]['menu-item-position']]); + } + + public function testMovingOnlyTheLastItemUnderAnotherRewritesOnlyThatItem(): void + { + $current = $this->realistic(); + $calls = []; + Functions\when('wp_update_nav_menu_item')->alias(static function (int $menu, int $id, array $args) use (&$calls) { + $calls[$id] = $args; + return $id; + }); + + $stats = (new MenuWriter())->apply(3, [ + ['id' => 10], + ['id' => 11, 'children' => [['id' => 12], ['id' => 13]]], + ], $current); + + self::assertSame(['created' => 0, 'updated' => 1, 'removed' => 0], $stats); + self::assertSame([13], array_keys($calls)); + self::assertSame(11, $calls[13]['menu-item-parent-id']); } public function testChangedTitleOfAnExistingItemBecomesItsLabel(): void @@ -201,8 +309,8 @@ final class MenuWriterTest extends TestCase ['id' => 13, 'title' => 'Kontakt', 'url' => 'mailto:info@example.test'], ], $current); + self::assertSame([10, 13], array_keys($calls)); self::assertSame('Startseite', $calls[10]['menu-item-title']); - self::assertSame('Wir über uns', $calls[11]['menu-item-title']); self::assertSame('mailto:info@example.test', $calls[13]['menu-item-url']); }