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:
1 parent
4a60adf7d7
commit
de9d6e3da8
22 files changed
+765
-19
No files matched your search
@@ -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 = [];
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Tests;
|
||||
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Kernel;
|
||||
@@ -14,6 +15,7 @@ use DoctrineMigrations\Version20260912000100;
|
||||
use DoctrineMigrations\Version20260912000200;
|
||||
use DoctrineMigrations\Version20260912000300;
|
||||
use DoctrineMigrations\Version20260912000400;
|
||||
use DoctrineMigrations\Version20260912000500;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
@@ -44,6 +46,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
(new Version20260912000200($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000300($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);
|
||||
}
|
||||
@@ -101,7 +104,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
public function testAnonymousCannotReadDraftOrWrite(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
foreach (['/admin', '/admin/pages/p1', '/admin/api/pages/p1', '/admin/media', '/admin/api/media'] as $url) {
|
||||
foreach (['/admin', '/admin/pages/p1', '/admin/api/pages/p1', '/admin/media', '/admin/api/media', '/admin/menus'] as $url) {
|
||||
$client->request('GET', $url);
|
||||
self::assertResponseRedirects('http://localhost/admin/login');
|
||||
self::assertStringNotContainsString('schemaVersion', $client->getResponse()->getContent());
|
||||
@@ -110,7 +113,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
$this->post($client, $action, []);
|
||||
self::assertResponseRedirects('http://localhost/admin/login');
|
||||
}
|
||||
foreach ([['/admin/pages', ['title' => 'Geschmuggelt', 'slug' => 'geschmuggelt']], ['/admin/pages/p1/delete', ['expectedVersion' => 2]]] as [$url, $body]) {
|
||||
foreach ([['/admin/pages', ['title' => 'Geschmuggelt', 'slug' => 'geschmuggelt']], ['/admin/pages/p1/delete', ['expectedVersion' => 2]], ['/admin/menus/header', ['name' => 'X', 'expectedVersion' => 0]]] as [$url, $body]) {
|
||||
$client->request('POST', $url, $body);
|
||||
self::assertResponseRedirects('http://localhost/admin/login');
|
||||
}
|
||||
@@ -160,6 +163,68 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testMenuFormBuildsATreeFromFlatRowsAndReplacesTheFixedNavigation(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
$crawler = $client->request('GET', '/admin/menus');
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
// Rows are ordered by position and nested by level, so the form needs no JavaScript.
|
||||
$client->request('POST', '/admin/menus/header', [
|
||||
'_csrf_token' => $crawler->filter('.menu-form input[name="_csrf_token"]')->first()->attr('value'),
|
||||
'name' => 'Hauptnavigation',
|
||||
'expectedVersion' => 0,
|
||||
'items' => [
|
||||
['position' => 30, 'level' => 1, 'label' => 'Extern', 'type' => 'url', 'url' => 'https://hausklar.app'],
|
||||
['position' => 10, 'level' => 1, 'label' => 'Start', 'type' => 'page', 'pageId' => 'p1', 'anchor' => ''],
|
||||
['position' => 20, 'level' => 2, 'label' => 'Kontakt', 'type' => 'page', 'pageId' => 'p1', 'anchor' => '#kontakt'],
|
||||
['position' => 40, 'level' => 1, 'label' => ' ', 'type' => 'page', 'pageId' => 'p1'],
|
||||
],
|
||||
]);
|
||||
self::assertResponseRedirects('/admin/menus');
|
||||
|
||||
$items = json_decode($this->db->fetchOne('SELECT items FROM menu WHERE slot = ?', ['header']), true);
|
||||
self::assertSame(['Start', 'Extern'], array_column($items, 'label'));
|
||||
self::assertSame('Kontakt', $items[0]['children'][0]['label']);
|
||||
// A leading hash in the anchor field is accepted and normalised away.
|
||||
self::assertSame('kontakt', $items[0]['children'][0]['anchor']);
|
||||
// The blank row is an add slot, not an entry.
|
||||
self::assertCount(2, $items);
|
||||
|
||||
$public = $client->request('GET', '/')->filter('nav[aria-label="Hauptnavigation"] a');
|
||||
// The navigation comes from the menu now, not from the links hard-coded in the template.
|
||||
self::assertSame(['/', '/#kontakt', 'https://hausklar.app'], $public->extract(['href']));
|
||||
}
|
||||
|
||||
public function testMenuWritesNeedAnEditorRoleACsrfTokenAndTheCurrentVersion(): void
|
||||
{
|
||||
$menus = new MenuRepository($this->db);
|
||||
$menus->save('header', 'Hauptnavigation', [['label' => 'Start', 'type' => 'page', 'pageId' => 'p1', 'anchor' => '', 'children' => []]], 0);
|
||||
|
||||
$client = $this->login('editor');
|
||||
$crawler = $client->request('GET', '/admin/menus');
|
||||
$token = $crawler->filter('.menu-form input[name="_csrf_token"]')->first()->attr('value');
|
||||
$entry = ['position' => 10, 'level' => 1, 'label' => 'Geaendert', 'type' => 'page', 'pageId' => 'p1', 'anchor' => ''];
|
||||
|
||||
$client->request('POST', '/admin/menus/header', ['_csrf_token' => 'wrong', 'name' => 'X', 'expectedVersion' => 1, 'items' => [$entry]]);
|
||||
self::assertResponseRedirects('/admin/menus');
|
||||
self::assertSame('Hauptnavigation', $this->db->fetchOne('SELECT name FROM menu WHERE slot = ?', ['header']));
|
||||
|
||||
// A stale version must not overwrite a newer menu.
|
||||
$client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Veraltet', 'expectedVersion' => 0, 'items' => [$entry]]);
|
||||
self::assertResponseRedirects('/admin/menus');
|
||||
self::assertSame('Hauptnavigation', $this->db->fetchOne('SELECT name FROM menu WHERE slot = ?', ['header']));
|
||||
|
||||
// An unsafe target is refused with a message instead of being stored.
|
||||
$client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Neu', 'expectedVersion' => 1, 'items' => [['position' => 10, 'level' => 1, 'label' => 'Boese', 'type' => 'url', 'url' => 'javascript:alert(1)']]]);
|
||||
$client->followRedirect();
|
||||
self::assertSelectorExists('.notice.error');
|
||||
self::assertStringNotContainsString('javascript:', $this->db->fetchOne('SELECT items FROM menu WHERE slot = ?', ['header']));
|
||||
|
||||
$client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Neu', 'expectedVersion' => 1, 'items' => [$entry]]);
|
||||
self::assertSame('Neu', $this->db->fetchOne('SELECT name FROM menu WHERE slot = ?', ['header']));
|
||||
}
|
||||
|
||||
public function testMediaUploadReencodingCsrfMetadataConflictAndArchive(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
// The test kernel runs with debug off, so Symfony does not check whether the compiled container or
|
||||
// the compiled templates are still current. A changed constructor or template would otherwise be
|
||||
// answered from a stale cache and make the suite report something that is not the code under test.
|
||||
// Dropping the test cache once per run costs about a second and removes that whole class of
|
||||
// false results.
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
|
||||
$cache = dirname(__DIR__).'/var/cache/test';
|
||||
if (is_dir($cache)) {
|
||||
$entries = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($cache, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST,
|
||||
);
|
||||
foreach ($entries as $entry) {
|
||||
$entry->isDir() ? @rmdir($entry->getPathname()) : @unlink($entry->getPathname());
|
||||
}
|
||||
@rmdir($cache);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use DoctrineMigrations\Version20260912000100;
|
||||
use DoctrineMigrations\Version20260912000200;
|
||||
use DoctrineMigrations\Version20260912000300;
|
||||
use DoctrineMigrations\Version20260912000400;
|
||||
use DoctrineMigrations\Version20260912000500;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
$directory = dirname(__DIR__).'/var/browser-tests';
|
||||
@@ -26,6 +27,7 @@ $schema = new Schema();
|
||||
(new Version20260912000200($db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000300($db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000400($db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000500($db, new NullLogger()))->up($schema);
|
||||
foreach ($schema->toSql($db->getDatabasePlatform()) as $sql) {
|
||||
$db->executeStatement($sql);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user