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']); } } }