justReturn((object) ['term_id' => 3, 'name' => 'Hauptmenü']); Functions\when('wp_get_nav_menu_items')->justReturn([self::item(10, 0, 'Home', 'custom', '/')]); } /** The menu_rev of the items wp_get_nav_menu_items() currently returns (what lit/menu-get would show). */ private static function rev(): string { return Rev::ofMenu(wp_get_nav_menu_items(3)); } public function testAStaleOrMissingMenuRevIsRefusedAndNothingIsWritten(): void { $writer = Mockery::mock(MenuWriter::class); $writer->expects('apply')->never(); $ability = new MenuSetItemsAbility($writer, new MenuOperations(), FakeChangeLog::forbidden()); $stale = $ability->execute(['menu_id' => 3, 'menu_rev' => 'abcdefabcdef', 'items' => [['id' => 10]]]); $missing = $ability->execute(['menu_id' => 3, 'items' => [['id' => 10]]]); self::assertSame('lit_rev_mismatch', $stale->get_error_code()); self::assertStringContainsString('lit/menu-get', $stale->get_error_data()['hint']); self::assertStringContainsString('Menü', $stale->get_error_message()); self::assertSame('lit_rev_mismatch', $missing->get_error_code()); self::assertContains('menu_rev', $ability->inputSchema()['required']); self::assertStringContainsString('menu_rev', $ability->description()); } public function testTheResultCarriesTheNewMenuRev(): void { $writer = Mockery::mock(MenuWriter::class); $writer->allows('plan')->andReturn(['errors' => [], 'removals' => [], 'warnings' => []]); $writer->allows('apply')->andReturn(['created' => 0, 'updated' => 1, 'removed' => 0]); $log = new FakeChangeLog(30); $result = (new MenuSetItemsAbility($writer, new MenuOperations(), $log))->execute( ['menu_id' => 3, 'menu_rev' => self::rev(), 'items' => [['id' => 10]]] ); self::assertSame(self::rev(), $result['menu_rev']); } public function testItemsAndOperationsAreMutuallyExclusive(): void { $ability = new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), FakeChangeLog::forbidden()); self::assertSame('lit_validation_failed', $ability->execute(['menu_id' => 3, 'menu_rev' => self::rev()])->get_error_code()); self::assertSame( 'lit_validation_failed', $ability->execute(['menu_id' => 3, 'menu_rev' => self::rev(), '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(), FakeChangeLog::forbidden()); 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(), FakeChangeLog::forbidden()); $result = $ability->execute(['menu_id' => 3, 'menu_rev' => self::rev(), '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(), FakeChangeLog::forbidden()); $result = $ability->execute([ 'menu_id' => 3, 'menu_rev' => self::rev(), '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(), FakeChangeLog::forbidden()); $result = $ability->execute(['menu_id' => 3, 'menu_rev' => self::rev(), '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 = new FakeChangeLog(21); $result = (new MenuSetItemsAbility($writer, new MenuOperations(), $log))->execute([ 'menu_id' => 3, 'menu_rev' => self::rev(), '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']); $log->assertRecorded(['ability' => 'lit/menu-set-items', 'object_type' => 'nav_menu', 'object_id' => '3']); } 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 = new FakeChangeLog(22); $result = (new MenuSetItemsAbility($writer, new MenuOperations(), $log))->execute( ['menu_id' => 3, 'menu_rev' => self::rev(), '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\expect('wp_delete_post')->never(); Functions\expect('wp_update_nav_menu_item')->never(); $log = FakeChangeLog::forbidden(); $result = (new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), $log))->execute( ['menu_id' => 3, 'menu_rev' => self::rev(), 'items' => MenuTree::build($items)] ); 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(), FakeChangeLog::forbidden()); $result = $ability->execute( ['menu_id' => 3, 'menu_rev' => self::rev(), '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 = new FakeChangeLog(24); $ability = new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), $log); $input = ['menu_id' => 3, 'menu_rev' => self::rev(), '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(), FakeChangeLog::forbidden()); $result = $ability->execute([ 'menu_id' => 3, 'menu_rev' => self::rev(), '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); $writer->allows('plan')->andReturn(['errors' => [], 'removals' => [], 'warnings' => []]); $writer->expects('apply')->andThrow(WriteFailed::rolledBack('Menü nicht gespeichert.')); $log = FakeChangeLog::forbidden(); $ability = new MenuSetItemsAbility($writer, new MenuOperations(), $log); $this->expectException(WriteFailed::class); $ability->execute(['menu_id' => 3, 'menu_rev' => self::rev(), 'items' => [['id' => 10]]]); } public function testAPartialWriteIsLoggedAsPartial(): void { $writer = Mockery::mock(MenuWriter::class); $writer->allows('plan')->andReturn(['errors' => [], 'removals' => [], 'warnings' => []]); $writer->expects('apply')->andThrow(WriteFailed::partial('Menü nicht gespeichert.')); $log = FakeChangeLog::partialsOnly(); $ability = new MenuSetItemsAbility($writer, new MenuOperations(), $log); try { $ability->execute(['menu_id' => 3, 'menu_rev' => self::rev(), 'items' => [['id' => 10]]]); self::fail('WriteFailed expected'); } catch (WriteFailed $e) { self::assertFalse($e->rolledBack); } $log->assertPartials(['ability' => 'lit/menu-set-items', 'object_type' => 'nav_menu', 'object_id' => '3']); } 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 = new FakeChangeLog(1, 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, 'menu_rev' => self::rev(), '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(), FakeChangeLog::forbidden()); self::assertSame('lit_forbidden', $ability->permission(['menu_id' => 3, 'menu_rev' => self::rev()])->get_error_code()); } public function testIsDestructiveAndDescribesWhenConfirmIsNeeded(): void { $ability = new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), FakeChangeLog::forbidden()); 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()); self::assertStringContainsString('nur für neu verlinkte Einträge', $ability->description()); } }