fix: refuse private/unpublished menu links, write only changed menu items

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 00:20:34 +02:00
co-authored by Claude Opus 5.5
parent f80cfb9e44
commit a2e9dabb17
6 changed files with 355 additions and 67 deletions
@@ -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());
}
}