Menüs ersetzen die fest verdrahtete Navigation (Arbeitspaket A)

Migration 20260912000500 legt eine Tabelle menu an: ein Menü je Platz
(header, footer) und Sprache, Elementbaum als JSON, Version für
dieselbe Konflikterkennung wie bei Seiten.

Ein Eintrag verweist auf eine Seite, auf ein Sprungziel innerhalb
einer Seite oder auf eine Adresse (HTTPS, lokaler Pfad, mailto:,
tel:). Seitenverweise speichern die Seiten-ID, nie den Pfad - ein
Eintrag folgt damit einer umbenannten Seite. Beim Rendern fallen
Einträge zu gelöschten oder unveröffentlichten Seiten heraus, ohne
den gespeicherten Baum zu ändern und ohne die übrigen Einträge zu
verlieren.

Die Verwaltung nutzt eine nummerierte Zeilenliste statt Drag-and-drop
und funktioniert damit vollständig ohne JavaScript; Untermenüs in der
Ausgabe über details/summary.

tests/bootstrap.php verwirft var/cache/test je Lauf. Ohne Debug prüft
Symfony weder den kompilierten Container noch die Vorlagen auf
Aktualität, was zweimal ein falsches Testergebnis erzeugt hat.

Geprüft: 79 PHP-Tests / 285 Assertions unter PHP 8.2.12 und 8.4.22,
11 JavaScript-Modelltests, Lint/Format/Container, Migration zuerst
gegen eine Datenbankkopie, wiederholtes Seeding. Keine Browser- oder
Lighthouse-Läufe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 10:59:23 +02:00
1 parent 4a60adf7d7
commit de9d6e3da8
22 files changed
+765 -19

No files matched your search

+122
View File
@@ -4,8 +4,10 @@ namespace App\Tests;
use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Domain\InvalidDocument;
use App\Domain\InvalidMenu;
use App\Domain\InvalidPage;
use App\Domain\StaleWrite;
use App\Infrastructure\ConnectionFactory;
@@ -14,6 +16,7 @@ use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Schema\Schema;
use DoctrineMigrations\Version20260912000100;
use DoctrineMigrations\Version20260912000400;
use DoctrineMigrations\Version20260912000500;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
@@ -24,6 +27,7 @@ final class CmsTest extends TestCase
{
private Connection $db;
private PageRepository $pages;
private MenuRepository $menus;
private string $path;
protected function setUp(): void
@@ -33,6 +37,7 @@ final class CmsTest extends TestCase
$schema = new Schema();
(new Version20260912000100($this->db, new NullLogger()))->up($schema);
(new Version20260912000400($this->db, new NullLogger()))->up($schema);
(new Version20260912000500($this->db, new NullLogger()))->up($schema);
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
$this->db->executeStatement($sql);
}
@@ -42,6 +47,7 @@ final class CmsTest extends TestCase
$this->db->insert('page', ['id' => $id, 'slug' => $slug, 'path' => $slug, 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
}
$this->pages = new PageRepository($this->db, new DocumentValidator());
$this->menus = new MenuRepository($this->db);
}
protected function tearDown(): void
@@ -374,6 +380,122 @@ final class CmsTest extends TestCase
self::assertStringContainsString('>Mit Erweiterung</h1>', $html);
}
public function testMenuRendersPageAnchorAndExternalTargetsWithCurrentMarker(): void
{
$about = $this->publishedPage('ueber-uns', 'Ueber uns');
$this->menus->save('header', 'Hauptnavigation', [
['label' => 'Start', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => '', 'children' => [
['label' => 'Kontakt', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => 'kontakt', 'children' => []],
]],
['label' => 'Ueber uns', 'type' => 'page', 'pageId' => $about, 'anchor' => '', 'children' => []],
['label' => 'HausKlar', 'type' => 'url', 'url' => 'https://hausklar.app', 'children' => []],
['label' => 'Anrufen', 'type' => 'url', 'url' => 'tel:+4972219957150', 'children' => []],
], 0);
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('href="/#kontakt"', $html);
self::assertStringContainsString('href="/ueber-uns"', $html);
self::assertStringContainsString('href="https://hausklar.app"', $html);
self::assertStringContainsString('href="tel:+4972219957150"', $html);
// The home link is the current page; an anchor inside it is not.
self::assertSame(1, substr_count($html, 'aria-current="page"'));
self::assertStringContainsString('<a href="/" aria-current="page">Start</a>', $html);
// On another page the marker moves and nothing else changes.
$other = $this->request('/ueber-uns')->getContent();
self::assertStringContainsString('<a href="/ueber-uns" aria-current="page">Ueber uns</a>', $other);
self::assertStringNotContainsString('<a href="/" aria-current="page">', $other);
}
public function testMenuSurvivesDeletedAndUnpublishedPagesWithoutLosingOtherEntries(): void
{
$doomed = $this->publishedPage('temporaer', 'Temporaer');
$draft = $this->pages->createPage('entwurf', null, 'page', DemoContent::starter('Entwurf'));
$this->menus->save('header', 'Hauptnavigation', [
['label' => 'Verschwindet', 'type' => 'page', 'pageId' => $doomed, 'anchor' => '', 'children' => [
['label' => 'Bleibt als Kind', 'type' => 'url', 'url' => 'https://example.com', 'children' => []],
]],
['label' => 'Noch Entwurf', 'type' => 'page', 'pageId' => $draft, 'anchor' => '', 'children' => []],
['label' => 'Extern', 'type' => 'url', 'url' => 'https://hausklar.app', 'children' => []],
], 0);
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
$this->pages->deletePage($doomed, $this->pages->findForEditor($doomed)['version']);
$html = $this->request('/')->getContent();
self::assertStringNotContainsString('Verschwindet', $html);
// An unpublished page must not appear in public navigation.
self::assertStringNotContainsString('Noch Entwurf', $html);
self::assertStringNotContainsString('/entwurf', $html);
// The rest of the menu is intact, including the child of the removed branch.
self::assertStringContainsString('Bleibt als Kind', $html);
self::assertStringContainsString('href="https://hausklar.app"', $html);
// The stored items are untouched: publishing the draft brings its entry back.
self::assertCount(3, $this->menus->all()['header']['items']);
$page = $this->pages->findForEditor($draft);
$this->pages->publish($draft, $page['draft_revision_id'], $page['version']);
self::assertStringContainsString('Noch Entwurf', $this->request('/')->getContent());
}
/**
* @return iterable<string, array{array}>
*/
public static function rejectedMenuItems(): iterable
{
$page = static fn (array $extra = []): array => [...['label' => 'Eintrag', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => '', 'children' => []], ...$extra];
yield 'empty label' => [[$page(['label' => ' '])]];
yield 'oversized label' => [[$page(['label' => str_repeat('a', 81)])]];
yield 'unknown type' => [[$page(['type' => 'script'])]];
yield 'missing page' => [[$page(['pageId' => ''])]];
yield 'anchor with hash' => [[$page(['anchor' => 'a#b'])]];
yield 'anchor starting with digit' => [[$page(['anchor' => '1kontakt'])]];
yield 'javascript url' => [[['label' => 'X', 'type' => 'url', 'url' => 'javascript:alert(1)', 'children' => []]]];
yield 'plain http url' => [[['label' => 'X', 'type' => 'url', 'url' => 'http://example.com', 'children' => []]]];
yield 'protocol relative url' => [[['label' => 'X', 'type' => 'url', 'url' => '//evil.example', 'children' => []]]];
yield 'third level' => [[$page(['children' => [$page(['children' => [$page()]])]])]];
}
#[DataProvider('rejectedMenuItems')]
public function testRejectsInvalidMenuItemsWithoutWriting(array $items): void
{
try {
$this->menus->save('header', 'Hauptnavigation', $items, 0);
self::fail('Expected the menu to be rejected.');
} catch (InvalidMenu) {
}
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM menu'));
}
public function testMenuRejectsUnknownSlotOversizeAndStaleWrites(): void
{
$item = ['label' => 'Start', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => '', 'children' => []];
try {
$this->menus->save('sidebar', 'Seitenleiste', [$item], 0);
self::fail('Expected an unknown slot to be rejected.');
} catch (InvalidMenu) {
}
try {
$this->menus->save('header', 'Hauptnavigation', array_fill(0, MenuRepository::MAX_ITEMS + 1, $item), 0);
self::fail('Expected the item limit to apply.');
} catch (InvalidMenu) {
}
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM menu'));
$this->menus->save('header', 'Hauptnavigation', [$item], 0);
self::assertSame(1, $this->menus->all()['header']['version']);
$this->expectException(StaleWrite::class);
$this->menus->save('header', 'Geaendert', [$item], 0);
}
private function publishedPage(string $slug, string $title): string
{
$id = $this->pages->createPage($slug, null, 'page', DemoContent::starter($title));
$page = $this->pages->findForEditor($id);
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
return $id;
}
private function request(string $url, string $method = 'GET'): Response
{
$previous = [];