SEO, Weiterleitungen und strukturierte Daten (Arbeitspaket E)
Migration 20260912000800 ergänzt page.robots, page.og_media_id, website.settings und die Tabelle redirect. Alles Maschinenlesbare wird aus dem veröffentlichten Bestand abgeleitet, nichts separat gepflegt: die Sitemap listet nur veröffentlichte, indexierbare Seiten samt Beitragsübersicht und Begriffsarchiven, und dieselbe Einstellung setzt auch das noindex-Meta - beide Aussagen können nicht auseinanderlaufen. Strukturierte Daten entstehen nur aus gepflegten Werten: ohne Angaben wird kein leerer Organization-Block ausgegeben. JSON-LD ist ein Datenblock, kein Skript, daher bleibt script-src 'none' unberührt. Weiterleitungen greifen vor dem 404, aber nach der Seitensuche: eine vorhandene Seite gewinnt immer, sonst könnte ein alter Eintrag eine neue Seite unerreichbar machen. Ketten werden abgewiesen. robots.txt nimmt zusätzliche Zeilen nur nach festem Muster an, damit ein Eintrag die Datei nicht umdeuten kann. llms.txt ist standardmäßig aus. Geprüft: 128 PHP-Tests / 507 Assertions unter PHP 8.2.12 und 8.4.22, 14 JavaScript-Modelltests, Lint/Format/Container. Dazu eine reine Sichtprüfung im Browser auf einer lokalen Demoseite - weiterhin keine axe- oder Lighthouse-Messung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d43bd91069
commit
a40c2bb27e
30 files changed
+1012
-38
No files matched your search
+193
-1
@@ -8,6 +8,8 @@ use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PostRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidMenu;
|
||||
use App\Domain\InvalidPage;
|
||||
@@ -22,6 +24,7 @@ use DoctrineMigrations\Version20260912000400;
|
||||
use DoctrineMigrations\Version20260912000500;
|
||||
use DoctrineMigrations\Version20260912000600;
|
||||
use DoctrineMigrations\Version20260912000700;
|
||||
use DoctrineMigrations\Version20260912000800;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
@@ -34,6 +37,8 @@ final class CmsTest extends TestCase
|
||||
private PageRepository $pages;
|
||||
private MenuRepository $menus;
|
||||
private PostRepository $posts;
|
||||
private SiteSettings $site;
|
||||
private RedirectRepository $redirects;
|
||||
private string $path;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -47,6 +52,7 @@ final class CmsTest extends TestCase
|
||||
(new Version20260912000500($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000600($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000700($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000800($this->db, new NullLogger()))->up($schema);
|
||||
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
|
||||
$this->db->executeStatement($sql);
|
||||
}
|
||||
@@ -59,13 +65,24 @@ final class CmsTest extends TestCase
|
||||
$this->pages = new PageRepository($this->db, new DocumentValidator());
|
||||
$this->menus = new MenuRepository($this->db);
|
||||
$this->posts = new PostRepository($this->db, new MediaLibrary($this->db, $this->path.'-media'));
|
||||
$this->site = new SiteSettings($this->db);
|
||||
$this->redirects = new RedirectRepository($this->db);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->db->close();
|
||||
gc_collect_cycles();
|
||||
unlink($this->path);
|
||||
// Each request() boots its own kernel with its own connection to the same file. On Windows
|
||||
// the handle is sometimes released a moment after the kernel is gone, so a single unlink
|
||||
// can fail on a database that is otherwise finished with. Retry briefly, then leave the
|
||||
// temporary file to the operating system rather than failing a passing test.
|
||||
for ($attempt = 0; $attempt < 20 && file_exists($this->path); ++$attempt) {
|
||||
if (@unlink($this->path)) {
|
||||
return;
|
||||
}
|
||||
usleep(25000);
|
||||
}
|
||||
}
|
||||
|
||||
public function testDocumentRoundTripAndDraftPrivacy(): void
|
||||
@@ -856,6 +873,181 @@ final class CmsTest extends TestCase
|
||||
$this->pages->saveDraft('page-1', $old, 1);
|
||||
}
|
||||
|
||||
public function testSitemapListsOnlyPublishedIndexablePagesAndPointsRobotsAtIt(): void
|
||||
{
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
$hidden = $this->publishedPage('intern', 'Intern');
|
||||
$this->posts->saveMetadata($hidden, ['robots' => 'noindex'], [], [], $this->pages->findForEditor($hidden)['version']);
|
||||
$this->publishedPage('offen', 'Offen');
|
||||
$this->pages->createPage('entwurf', null, 'page', DemoContent::starter('Entwurf'));
|
||||
$this->publishedPost('ein-beitrag', 'Ein Beitrag', '2026-02-01', ['Projekte'], []);
|
||||
|
||||
$sitemap = $this->request('/sitemap.xml');
|
||||
self::assertSame(200, $sitemap->getStatusCode());
|
||||
self::assertStringContainsString('application/xml', (string) $sitemap->headers->get('Content-Type'));
|
||||
$xml = $sitemap->getContent();
|
||||
self::assertNotFalse(simplexml_load_string($xml), 'The sitemap must be well-formed XML.');
|
||||
self::assertStringContainsString('<loc>https://cms.test/</loc>', $xml);
|
||||
self::assertStringContainsString('<loc>https://cms.test/offen</loc>', $xml);
|
||||
self::assertStringContainsString('<loc>https://cms.test/beitraege</loc>', $xml);
|
||||
self::assertStringContainsString('<loc>https://cms.test/kategorie/projekte</loc>', $xml);
|
||||
// A noindex page and an unpublished page have no business in a sitemap.
|
||||
self::assertStringNotContainsString('/intern', $xml);
|
||||
self::assertStringNotContainsString('/entwurf', $xml);
|
||||
|
||||
$robots = $this->request('/robots.txt');
|
||||
self::assertSame(200, $robots->getStatusCode());
|
||||
self::assertStringContainsString('text/plain', (string) $robots->headers->get('Content-Type'));
|
||||
self::assertStringContainsString('Disallow: /admin', $robots->getContent());
|
||||
self::assertStringContainsString('Sitemap: https://cms.test/sitemap.xml', $robots->getContent());
|
||||
|
||||
// A page excluded from indexing says so on the page itself as well.
|
||||
self::assertStringContainsString('<meta name="robots" content="noindex, follow">', $this->request('/intern')->getContent());
|
||||
self::assertStringNotContainsString('name="robots"', $this->request('/offen')->getContent());
|
||||
}
|
||||
|
||||
public function testRedirectsAnswerBeforeTheNotFoundAndRefuseChainsAndLoops(): void
|
||||
{
|
||||
$this->publishedPage('leistungen', 'Leistungen');
|
||||
$this->redirects->save('alte-seite', 'leistungen', 301);
|
||||
$this->redirects->save('weg', '', 410);
|
||||
|
||||
$moved = $this->request('/alte-seite');
|
||||
self::assertSame(301, $moved->getStatusCode());
|
||||
self::assertSame('/leistungen', $moved->headers->get('Location'));
|
||||
// A deliberately removed address is gone, not merely missing.
|
||||
self::assertSame(410, $this->request('/weg')->getStatusCode());
|
||||
self::assertSame(404, $this->request('/nie-dagewesen')->getStatusCode());
|
||||
|
||||
foreach ([['alte-seite', 'alte-seite', 301], ['noch-eine', 'alte-seite', 301], ['Gross', 'leistungen', 301], ['ok', 'leistungen', 302]] as [$from, $to, $status]) {
|
||||
try {
|
||||
$this->redirects->save($from, $to, $status);
|
||||
self::fail(sprintf('Expected /%s -> /%s (%d) to be rejected.', $from, $to, $status));
|
||||
} catch (InvalidPage) {
|
||||
}
|
||||
}
|
||||
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM redirect'));
|
||||
|
||||
// An existing page always wins over a redirect with the same address.
|
||||
$this->redirects->save('leistungen', 'offen', 301);
|
||||
self::assertSame(200, $this->request('/leistungen')->getStatusCode());
|
||||
}
|
||||
|
||||
public function testStructuredDataComesOnlyFromMaintainedValues(): void
|
||||
{
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
// Nothing maintained yet: no empty organisation block is emitted.
|
||||
self::assertStringNotContainsString('application/ld+json', $this->request('/')->getContent());
|
||||
|
||||
$this->site->save([
|
||||
'legalName' => 'Kant-IT Solutions', 'phone' => '+49 7221 9957150', 'email' => 'info@kant-it.de',
|
||||
'street' => 'Ooser Hauptstr. 51', 'postalCode' => '76532', 'city' => 'Baden-Baden', 'country' => 'de',
|
||||
'sameAs' => "https://hausklar.app\nhttps://hausklar.app", 'localBusiness' => '1',
|
||||
]);
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('<script type="application/ld+json">', $html);
|
||||
preg_match('#<script type="application/ld\+json">(.*?)</script>#s', $html, $match);
|
||||
$data = json_decode(html_entity_decode($match[1], ENT_QUOTES), true);
|
||||
self::assertSame('LocalBusiness', $data['@type']);
|
||||
self::assertSame('Kant-IT Solutions', $data['name']);
|
||||
self::assertSame('Baden-Baden', $data['address']['addressLocality']);
|
||||
self::assertSame('DE', $data['address']['addressCountry']);
|
||||
// Duplicates are collapsed, and nothing is invented.
|
||||
self::assertSame(['https://hausklar.app'], $data['sameAs']);
|
||||
self::assertArrayNotHasKey('description', $data);
|
||||
|
||||
// A post carries Article data built from its own metadata.
|
||||
$post = $this->publishedPost('ein-beitrag', 'Ein Beitrag', '2026-02-01');
|
||||
self::assertNotNull($this->pages->findForEditor($post));
|
||||
$article = $this->request('/ein-beitrag')->getContent();
|
||||
self::assertStringContainsString('"@type":"Article"', $article);
|
||||
self::assertStringContainsString('"headline":"Ein Beitrag"', $article);
|
||||
self::assertStringContainsString('"name":"Umut"', $article);
|
||||
}
|
||||
|
||||
public function testBreadcrumbTrailAndItsStructuredDataFollowTheStoredHierarchy(): void
|
||||
{
|
||||
$parent = $this->publishedPage('leistungen', 'Leistungen');
|
||||
self::assertNotNull($this->pages->findForEditor($parent));
|
||||
$childId = $this->pages->createPage('webdesign', $parent, 'page', (static function (): array {
|
||||
$document = DemoContent::starter('Webdesign');
|
||||
$document['schemaVersion'] = 9;
|
||||
$document['blocks'][] = ['id' => 'krumen', 'type' => 'breadcrumb'];
|
||||
|
||||
return $document;
|
||||
})());
|
||||
$child = $this->pages->findForEditor($childId);
|
||||
$this->pages->publish($childId, $child['draft_revision_id'], $child['version']);
|
||||
|
||||
$html = $this->request('/leistungen/webdesign')->getContent();
|
||||
self::assertStringContainsString('aria-label="Brotkrumen"', $html);
|
||||
self::assertStringContainsString('<a href="/leistungen">Leistungen</a>', $html);
|
||||
self::assertStringContainsString('<span aria-current="page">Webdesign</span>', $html);
|
||||
self::assertStringContainsString('"@type":"BreadcrumbList"', $html);
|
||||
self::assertStringContainsString('"item":"https://cms.test/leistungen"', $html);
|
||||
|
||||
// A page directly under the root gets no trail, so no empty navigation appears.
|
||||
self::assertStringNotContainsString('aria-label="Brotkrumen"', $this->request('/leistungen')->getContent());
|
||||
}
|
||||
|
||||
public function testLlmsTxtIsOffByDefaultAndListsOnlyPublicIndexableContent(): void
|
||||
{
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
self::assertSame(404, $this->request('/llms.txt')->getStatusCode());
|
||||
|
||||
$hidden = $this->publishedPage('intern', 'Intern');
|
||||
$this->posts->saveMetadata($hidden, ['robots' => 'noindex'], [], [], $this->pages->findForEditor($hidden)['version']);
|
||||
$this->pages->createPage('entwurf', null, 'page', DemoContent::starter('Entwurf'));
|
||||
$this->site->save(['llmsTxt' => '1', 'legalName' => 'Kant-IT', 'description' => 'Ihr IT-Partner.', 'country' => 'DE']);
|
||||
|
||||
$response = $this->request('/llms.txt');
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
$body = $response->getContent();
|
||||
self::assertStringContainsString('# Kant-IT', $body);
|
||||
self::assertStringContainsString('> Ihr IT-Partner.', $body);
|
||||
self::assertStringContainsString('https://cms.test/', $body);
|
||||
self::assertStringNotContainsString('Intern', $body);
|
||||
self::assertStringNotContainsString('Entwurf', $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{array}>
|
||||
*/
|
||||
public static function rejectedSettings(): iterable
|
||||
{
|
||||
yield 'invalid email' => [['email' => 'keine-adresse', 'country' => 'DE']];
|
||||
yield 'invalid phone' => [['phone' => 'ruf an', 'country' => 'DE']];
|
||||
yield 'invalid country' => [['country' => 'Deutschland']];
|
||||
yield 'insecure profile' => [['country' => 'DE', 'sameAs' => 'http://example.com']];
|
||||
yield 'local business without address' => [['country' => 'DE', 'localBusiness' => '1']];
|
||||
}
|
||||
|
||||
#[DataProvider('rejectedSettings')]
|
||||
public function testRejectsInvalidCompanyDataWithoutWriting(array $input): void
|
||||
{
|
||||
try {
|
||||
$this->site->save($input);
|
||||
self::fail('Expected the settings to be rejected.');
|
||||
} catch (InvalidPage) {
|
||||
}
|
||||
self::assertNull($this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']));
|
||||
}
|
||||
|
||||
public function testRobotsExtraOnlyAcceptsPlainDirectiveLines(): void
|
||||
{
|
||||
$this->site->save([
|
||||
'country' => 'DE',
|
||||
'robotsExtra' => "Disallow: /geheim\n# ein Kommentar\n<script>alert(1)</script>\nSitemap: https://fremde.example/sitemap.xml",
|
||||
]);
|
||||
$body = $this->request('/robots.txt')->getContent();
|
||||
self::assertStringContainsString('Disallow: /geheim', $body);
|
||||
self::assertStringContainsString('# ein Kommentar', $body);
|
||||
// Neither markup nor a second sitemap declaration survives the filter.
|
||||
self::assertStringNotContainsString('<script>', $body);
|
||||
self::assertStringNotContainsString('fremde.example', $body);
|
||||
self::assertSame(1, substr_count($body, 'Sitemap:'));
|
||||
}
|
||||
|
||||
private function request(string $url, string $method = 'GET'): Response
|
||||
{
|
||||
$previous = [];
|
||||
|
||||
Reference in new issue
Block a user