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()); } }