path = tempnam(sys_get_temp_dir(), 'kant-test-');
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->path));
TestSchema::build($this->db);
$this->db->insert('website', ['id' => 'default', 'name' => 'KantSites', 'default_locale' => 'de', 'theme' => '{}']);
foreach (['page-1' => 'home', 'page-2' => 'private'] as $id => $slug) {
$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(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
$this->menus = new MenuRepository($this->db, new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
$this->posts = new PostRepository($this->db, new MediaLibrary($this->db, $this->path.'-media'), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
$this->site = new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media'));
$this->redirects = new RedirectRepository($this->db);
$this->themes = new Theme($this->db);
$this->partials = new PartialRepository($this->db, new DocumentValidator());
// A real installation has a header and a footer; the navigation lives in the header area.
foreach (DemoContent::areas() as $kind => $area) {
$this->partials->create($kind, 'default', $area['name'], $area['document']);
}
}
protected function tearDown(): void
{
$this->db->close();
gc_collect_cycles();
// 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
{
$document = DemoContent::pages()['home'];
$id = $this->pages->saveDraft('page-1', $document, 0);
self::assertNull($this->pages->findPublished('home'));
self::assertSame($document, json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$id]), true));
$this->pages->publish('page-1', $id, 1);
self::assertSame($document, $this->pages->findPublished('home')['document']);
$document['title'] = 'Private change';
$this->pages->saveDraft('page-1', $document, 2);
self::assertNotSame('Private change', $this->pages->findPublished('home')['document']['title']);
}
public function testStaleSaveRollsBackWithoutOrphanRevision(): void
{
$this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0);
try {
$this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0);
self::fail('Stale write was accepted.');
} catch (StaleWrite) {
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision'));
self::assertSame(1, (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']));
}
}
public function testForeignRevisionCannotBePublished(): void
{
$id = $this->pages->saveDraft('page-2', DemoContent::pages()['home'], 0);
$this->expectException(\InvalidArgumentException::class);
$this->pages->publish('page-1', $id, 0);
}
public function testPublicationRejectsStaleVersion(): void
{
$id = $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0);
$this->expectException(StaleWrite::class);
$this->pages->publish('page-1', $id, 0);
}
public function testPublishedRevisionCanBeRestoredAndImmediatelyChangesOutput(): void
{
$original = DemoContent::pages()['home'];
$first = $this->pages->saveDraft('page-1', $original, 0);
$this->pages->publish('page-1', $first, 1);
$changed = $original;
$changed['title'] = 'Second revision';
$second = $this->pages->saveDraft('page-1', $changed, 2);
$this->pages->publish('page-1', $second, 3);
self::assertSame('Second revision', $this->pages->findPublished('home')['document']['title']);
$this->pages->publish('page-1', $first, 4);
self::assertSame($original, $this->pages->findPublished('home')['document']);
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision'));
}
public static function invalidDocuments(): iterable
{
$base = DemoContent::pages()['home'];
$bad = $base;
$bad['schemaVersion'] = 999;
yield 'future schema' => [$bad];
$bad = $base;
$bad['blocks'][] = $base['blocks'][0];
yield 'duplicate IDs' => [$bad];
foreach (['javascript:alert(1)', 'data:text/html,hi', '//evil.test', '/\\evil.test', "https://example.test/\nfoo"] as $url) {
$bad = $base;
$bad['blocks'][0]['children'][3]['props']['href'] = $url;
yield $url => [$bad];
}
$bad = $base;
$bad['blocks'][0]['layout']['tone'] = '" onclick="alert(1)';
yield 'style injection' => [$bad];
foreach ([['desktop' => ['paddingY' => 'url(evil)']], ['watch' => []], ['mobile' => ['columns' => 'two']], ['desktop' => ['unknown' => 'small']], ['mobile' => ['paddingY' => []]]] as $design) {
$bad = $base;
$bad['schemaVersion'] = 2;
$bad['blocks'][0]['layout']['design'] = $design;
yield 'invalid responsive design '.json_encode($design) => [$bad];
}
$bad = $base;
$bad['blocks'][0]['layout']['design'] = ['desktop' => ['paddingY' => 'large']];
yield 'v1 cannot silently use v2 settings' => [$bad];
foreach ([2, 3] as $version) {
$bad = $base;
$bad['schemaVersion'] = $version;
$bad['blocks'][0]['layout']['effects'] = ['shadow' => 2 === $version ? 'soft' : 'evil'];
yield 'invalid effect version '.$version => [$bad];
}
$bad = $base;
$bad['schemaVersion'] = 3;
$bad['blocks'][] = ['id' => 'gallery', 'type' => 'gallery', 'children' => [$base['blocks'][0]]];
yield 'gallery forbids containers' => [$bad];
$bad = $base;
$bad['blocks'][] = ['id' => 'gallery', 'type' => 'gallery', 'children' => []];
yield 'gallery requires v3' => [$bad];
foreach (['javascript:alert(1)', '/image,400w'] as $source) {
$bad = $base;
$bad['schemaVersion'] = 3;
$bad['blocks'][] = ['id' => 'unsafe-image', 'type' => 'image', 'props' => ['src' => '/safe.png', 'alt' => 'Test', 'decorative' => false, 'width' => 400, 'height' => 300, 'sources' => [['src' => $source, 'width' => 400]]]];
yield 'unsafe srcset '.$source => [$bad];
}
$bad = $base;
$bad['blocks'][0]['children'][1]['props']['level'] = 9;
yield 'invalid heading' => [$bad];
$bad = $base;
$bad['blocks'][0]['children'][0]['type'] = 'unknown-plugin';
yield 'unnamespaced unknown type stays rejected' => [$bad];
foreach (['Acme.Slider', 'acme.', '.slider', 'acme..slider', 'acme.slider.extra', 'acme/slider'] as $type) {
$bad = $base;
$bad['schemaVersion'] = 4;
$bad['blocks'][0]['children'][0]['type'] = $type;
yield 'malformed plugin type '.$type => [$bad];
}
$bad = $base;
$bad['blocks'][0]['children'][0]['type'] = 'acme.slider';
yield 'plugin block needs schema v4' => [$bad];
$bad = $base;
$bad['blocks'][0]['children'][] = ['id' => 'image', 'type' => 'image', 'props' => ['src' => '/image.png', 'alt' => '', 'decorative' => false, 'width' => 100, 'height' => 100]];
yield 'missing meaningful alt' => [$bad];
}
#[DataProvider('invalidDocuments')]
public function testRejectsInvalidDocumentsBeforeWriting(array $document): void
{
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
}
public function testInitialHtmlEscapesContentAndHasNoEditorScripts(): void
{
$document = DemoContent::pages()['home'];
$document['blocks'][0]['children'][0]['props']['text'] = '';
$id = $this->pages->saveDraft('page-1', $document, 0);
$this->pages->publish('page-1', $id, 1);
$response = $this->request('/');
self::assertSame(200, $response->getStatusCode());
self::assertStringContainsString('<script>', $response->getContent());
self::assertStringNotContainsString('';
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('digitale Exzellenz', $html);
self::assertStringNotContainsString('alert(1)', $html);
self::assertStringNotContainsString('', $mit->getContent());
self::assertStringContainsString('data-autoplay="7"', $mit->getContent());
self::assertStringContainsString("script-src 'self'", (string) $mit->headers->get('Content-Security-Policy'));
// Das Skript hält sich an die Bewegungsschranke und hört beim ersten Eingriff auf.
$skript = (string) file_get_contents(__DIR__.'/../public/assets/slider.js');
self::assertStringContainsString("matchMedia('(prefers-reduced-motion: reduce)')", $skript);
self::assertStringContainsString("'pointerdown', 'keydown', 'touchstart', 'wheel'", $skript);
}
public function testTheSamePostsCanBeShownInThreeWays(): void
{
// Umut wollte auch für die Auflistung mehrere Darstellungen. Es sind dieselben Daten und
// dasselbe Markup - nur die Klasse wechselt, damit eine Darstellung nichts kostet außer
// ein paar Regeln CSS.
$this->publishedPost('projekt-eins', 'Projekt eins', '2026-02-01', ['Projekte'], []);
// Jeder Schritt hebt die Fassung um eins: erst das Speichern, dann das Veröffentlichen.
$version = 0;
foreach (['karten', 'liste', 'kompakt', 'wechsel'] as $art) {
$document = DemoContent::starter('Übersicht');
$document['schemaVersion'] = 18;
$document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => [
'count' => 4, 'category' => 'projekte', 'tag' => '', 'layout' => $art,
]];
$revision = $this->pages->saveDraft('page-1', $document, $version);
$this->pages->publish('page-1', $revision, ++$version);
++$version;
$html = $this->request('/')->getContent();
self::assertStringContainsString('post-list post-list-'.$art, $html, $art);
self::assertStringContainsString('Projekt eins', $html, $art);
}
// Wechselseitig kehrt jede zweite Zeile um - die Karte selbst ist das Listenelement,
// also zählt nth-child auf ihr und nicht eine Ebene tiefer.
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
self::assertStringContainsString('.post-list-wechsel .post-card:nth-child(even) .post-image{order:2}', $css);
// Und die Nummer kommt aus einem Zähler, nicht aus dem Inhalt: eine Liste, die auf Seite
// zwei wieder bei 01 beginnt, wäre falsch beschriftet, aber sie wäre ehrlich - der Inhalt
// weiß nicht, der wievielte Beitrag er ist.
self::assertStringContainsString('counter-increment:beitrag', $css);
// Die kompakte Darstellung lässt die Bilder ganz weg - sonst wäre sie nicht kompakt.
$document = DemoContent::starter('Übersicht');
$document['schemaVersion'] = 18;
$document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => ['count' => 4, 'category' => 'projekte', 'tag' => '', 'layout' => 'kompakt']];
$revision = $this->pages->saveDraft('page-1', $document, $version);
$this->pages->publish('page-1', $revision, ++$version);
self::assertStringNotContainsString('post-image', $this->request('/')->getContent());
// Und eine erfundene Darstellung wird abgewiesen, statt still auf etwas zurückzufallen.
$document = DemoContent::starter('Übersicht');
$document['schemaVersion'] = 18;
$document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => ['count' => 4, 'layout' => 'kacheln']];
$this->expectException(InvalidDocument::class);
(new DocumentValidator())->validate($document);
}
public function testEntranceEffectsRunOnScrollWhereTheBrowserCanAndOnLoadOtherwise(): void
{
// Umut: „Effekte gibt es keine“. Es gab sie - aber nur beim Zeigen, nicht beim Laden, und
// eine Einblendung beim Laden ist unterhalb der Falz wertlos: sie ist vorbei, bevor
// jemand hinsieht. Mit einer Scroll-Zeitachse läuft dieselbe Animation, wenn der
// Abschnitt in den Blick kommt - ohne eine Zeile JavaScript.
$css = (string) file_get_contents(__DIR__.'/../public/assets/enhancements.css');
self::assertStringContainsString('@supports (animation-timeline: view())', $css);
self::assertStringContainsString('animation-timeline: view()', $css);
// Beides steht hinter der Bewegungsschranke, und der Rückfall ist die Fassung von vorher:
// ein Browser ohne Scroll-Zeitachse zeigt alles, nur eben beim Laden animiert.
$geschuetzt = explode('@media (prefers-reduced-motion: no-preference) {', $css);
self::assertStringNotContainsString('animation-timeline', $geschuetzt[0], 'Keine Bewegung außerhalb der Schranke.');
self::assertSame(1, substr_count($css, '@supports (animation-timeline: view())'));
// Der wichtigste Teil, und er ist mit Messen bezahlt: auf dem Scrollweg wird **keine
// Deckkraft** animiert. Eine Scroll-Zeitachse ist umkehrbar, also stünde alles unterhalb
// der Falz dauerhaft am Anfang der Kurve - bei Deckkraft 0,85 wird aus #3d4d5c ein
// #78838d, und damit 3,86:1 statt 7,1:1. axe hat genau das gemeldet.
self::assertStringContainsString('@keyframes kant-scroll-rise { from { transform: translateY(20px) } to { transform: none } }', $css);
self::assertStringContainsString('@keyframes kant-scroll-fade { from { transform: translateY(10px) } to { transform: none } }', $css);
$scrollteil = explode('@supports (animation-timeline: view())', $css)[1];
self::assertStringNotContainsString('opacity', explode('.fx-delay-long', $scrollteil)[0], 'Auf dem Scrollweg bleibt der Text voll deckend.');
// Und die drei Beitragsvorlagen bringen die Einblendung von sich aus mit.
foreach (array_keys(PostLayouts::all()) as $key) {
$document = PostLayouts::document($key, 'Ein Beitrag');
$mitEffekt = array_filter($document['blocks'], static fn (array $block): bool => isset($block['layout']['effects']['entrance']));
self::assertCount(count($document['blocks']), $mitEffekt, $key.': jeder Abschnitt blendet ein.');
(new DocumentValidator())->validate($document);
}
}
public function testANewPostStartsFromOneOfThreeLayouts(): void
{
// Umut wollte mehrere Aufbauten zur Auswahl. Ein leeres Dokument ist für eine Seite
// richtig - dort weiß niemand vorher, was daraus wird -, für einen Beitrag fast nie.
self::assertSame(['bildkopf', 'klassisch', 'magazin'], array_keys(PostLayouts::all()));
foreach (PostLayouts::all() as $key => $layout) {
$document = PostLayouts::document($key, 'Ein Beitrag');
// Jeder Aufbau ist ein gültiges Dokument mit genau einer Hauptüberschrift.
(new DocumentValidator())->validate($document);
self::assertSame(18, $document['schemaVersion'], $key);
self::assertNotSame('', $layout['description'], $key);
$id = $this->pages->createPage('beitrag-'.$key, null, 'post', $document);
$page = $this->pages->findForEditor($id);
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
$html = $this->request('/beitrag-'.$key)->getContent();
self::assertStringContainsString('Ein Beitrag', $html, $key);
// Jeder Aufbau endet mit einem Weg weiter, keiner im Nichts.
self::assertStringContainsString('block-postnav', $html, $key);
}
// Ein erfundener Aufbau fällt auf die Voreinstellung zurück statt zu scheitern.
self::assertFalse(PostLayouts::exists('gibtsnicht'));
self::assertSame(
PostLayouts::document(PostLayouts::DEFAULT, 'X'),
PostLayouts::document('gibtsnicht', 'X'),
);
}
public function testTheFirstTextOfASectionIsNoLongerSilentlyTurnedIntoALabel(): void
{
// Diese Regel gestaltete nach Position statt nach Absicht: im Fußbereich wurde aus dem
// Firmennamen roter Versalientext, in einer Beitragsvorlage aus dem Einstiegsabsatz eine
// Kleinbeschriftung. Wer eine Beschriftung will, stellt sie ein.
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
self::assertStringNotContainsString('.section-inner>.block-text:first-child', $css);
self::assertStringNotContainsString('.block-column>.block-text:first-child', $css);
}
public function testAPostCanPointToItsNeighboursWithoutStoringThem(): void
{
// Die Beitragsseite endete bisher im Nichts: gelesen, und dann? Der neue Baustein führt
// zum nächsten Beitrag - innerhalb derselben Kategorie, denn von einem Projekt führt man
// zum nächsten Projekt und nicht zur nächsten Pressemitteilung.
foreach ([['eins', '2026-02-01'], ['zwei', '2026-02-02'], ['drei', '2026-02-03']] as [$slug, $datum]) {
$this->publishedPost('projekt-'.$slug, 'Projekt '.$slug, $datum, ['Projekte'], []);
}
$this->publishedPost('notiz', 'Eine Notiz', '2026-02-04', ['Notizen'], []);
$mitte = $this->db->fetchOne('SELECT id FROM page WHERE path = ?', ['projekt-zwei']);
$document = DemoContent::starter('Projekt zwei', 'post');
$document['schemaVersion'] = 18;
$document['blocks'][] = ['id' => 'weiter', 'type' => 'postnav', 'props' => ['previousLabel' => 'Vorheriges Projekt', 'nextLabel' => 'Nächstes Projekt']];
$this->pages->publish($mitte, $this->pages->saveDraft($mitte, $document, $this->pages->findForEditor($mitte)['version']), $this->pages->findForEditor($mitte)['version']);
$html = $this->request('/projekt-zwei')->getContent();
// Neuer steht im Archiv oben, also ist „vorheriges" der neuere Beitrag.
self::assertStringContainsString('Projekt drei', $html);
self::assertStringContainsString('Projekt eins', $html);
self::assertStringContainsString('Vorheriges Projekt', $html);
// Die Notiz gehört in eine andere Kategorie und taucht nicht auf.
self::assertStringNotContainsString('Eine Notiz', $html);
// Und im gespeicherten Dokument steht kein Beitrag - nur die Beschriftungen.
$stored = $this->pages->findPublished('projekt-zwei')['document'];
self::assertSame(['previousLabel' => 'Vorheriges Projekt', 'nextLabel' => 'Nächstes Projekt'], end($stored['blocks'])['props']);
// Am Rand wird nicht umgebrochen: der neueste Beitrag hat keinen Vorgänger.
$neuestes = $this->db->fetchOne('SELECT id FROM page WHERE path = ?', ['projekt-drei']);
$document['title'] = 'Projekt drei';
$this->pages->publish($neuestes, $this->pages->saveDraft($neuestes, $document, $this->pages->findForEditor($neuestes)['version']), $this->pages->findForEditor($neuestes)['version']);
$rand = $this->request('/projekt-drei')->getContent();
self::assertStringNotContainsString('Vorheriges Projekt', $rand);
self::assertStringContainsString('Nächstes Projekt', $rand);
}
public function testTheSiteIconIsMadeFromAPictureOfTheLibrary(): void
{
// Umut: „dann brauchen wir Favicon-Möglichkeit zum Festlegen“. Aus einem Bild der
// Mediathek entstehen beim Speichern zwei quadratische PNG-Dateien; ohne Einstellung
// bleibt es beim mitgelieferten Zeichen.
self::assertStringContainsString('request('/')->getContent());
self::assertSame(404, $this->request('/favicon.png')->getStatusCode());
$media = new MediaLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-symbol-');
// Breit und flach: der Ausschnitt muss quadratisch werden, nicht gestaucht.
$image = imagecreatetruecolor(600, 200);
imagepng($image, $file);
imagedestroy($image);
$item = $media->upload(new UploadedFile($file, 'logo.png', test: true));
@unlink($file);
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => $item['id']]);
self::assertSame($item['id'], $this->site->load()['faviconMediaId']);
$html = $this->request('/')->getContent();
self::assertStringContainsString('', $html);
self::assertStringContainsString('apple-touch-icon.png', $html);
self::assertStringNotContainsString('favicon.svg', $html);
$symbol = $this->request('/favicon.png');
self::assertSame(200, $symbol->getStatusCode());
self::assertSame('image/png', $symbol->headers->get('Content-Type'));
$size = getimagesizefromstring((string) file_get_contents($this->path.'-media/favicon-32.png'));
self::assertSame([32, 32], [$size[0], $size[1]], 'Quadratisch, nicht gestaucht.');
self::assertSame(200, $this->request('/apple-touch-icon.png')->getStatusCode());
// Ein Bild, das es nicht gibt, wird nicht gespeichert - sonst zeigte der Kopf ins Leere.
try {
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => str_repeat('a', 32)]);
self::fail('Eine unbekannte Bildkennung muss abgelehnt werden.');
} catch (InvalidPage) {
}
self::assertSame($item['id'], $this->site->load()['faviconMediaId']);
}
public function testUnderlinedLinksAreAChoiceAndOnlyApplyToRunningText(): void
{
// Umut: „links haben alle underlined“. Im Menü, auf Karten und über Bildern hat ein
// Unterstrich nichts zu suchen; im Fließtext ist er die einzige Kennzeichnung neben der
// Farbe - deshalb dort ein Schalter statt einer stillen Entscheidung.
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
self::assertStringContainsString('.post-image{display:block;position:relative;overflow:hidden;text-decoration:none}', $css);
self::assertStringNotContainsString('[aria-current=page]{text-decoration:underline}', $css);
$theme = new Theme($this->db);
self::assertTrue($theme->load()['base']['linkUnderline'], 'Standard: unterstrichen.');
self::assertStringContainsString('.kant .block-text a,.kant .block-list a,.kant .post-excerpt a{color:var(--k-link);text-decoration:underline}', $theme->css($theme->load()));
$gespeichert = $theme->load();
$theme->save($gespeichert['colors'], $gespeichert['fonts'], [...$gespeichert['base'], 'linkUnderline' => null]);
self::assertFalse($theme->load()['base']['linkUnderline']);
$css = $theme->css($theme->load());
self::assertStringContainsString('text-decoration:none}', $css);
// Beim Überfahren bleibt der Strich in jedem Fall - sonst ist ein Link im Text nicht zu finden.
self::assertStringContainsString('.kant .block-text a:hover,.kant .block-list a:hover,.kant .post-excerpt a:hover{text-decoration:underline}', $css);
}
public function testAWebsiteWithoutPostsNeverMentionsPosts(): void
{
// Umut: „Können wir Beiträge optional machen? Wenn es keine gibt, überall ausblenden.“
// Teilweise war es das schon - die Sitemap ließ das Archiv weg. Das Archiv selbst, der
// Feed, der Verweis im Kopf und der Kasten „hier erscheinen Beiträge“ blieben.
$document = DemoContent::starter('Ohne Blog');
$document['schemaVersion'] = 18;
$document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => ['count' => 4, 'category' => '', 'tag' => '']];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
// Ohne einen einzigen Beitrag: kein Archiv, kein Feed.
self::assertSame(404, $this->request('/beitraege')->getStatusCode());
self::assertSame(404, $this->request('/feed.xml')->getStatusCode());
$html = $this->request('/')->getContent();
self::assertStringNotContainsString('application/rss+xml', $html, 'Kein Verweis auf einen Feed, den es nicht gibt.');
self::assertStringNotContainsString('post-empty', $html, 'Kein Kasten, der Beiträge ankündigt.');
self::assertStringNotContainsString('Hier erscheinen die Beiträge', $html);
// Die Sitemap führt das Archiv ebenfalls nicht.
self::assertStringNotContainsString('/beitraege', $this->request('/sitemap.xml')->getContent());
// Sobald ein Beitrag veröffentlicht ist, ist alles davon wieder da.
$this->publishedPost('erster', 'Erster Beitrag', '2026-02-01', ['Projekte'], []);
self::assertSame(200, $this->request('/beitraege')->getStatusCode());
self::assertSame(200, $this->request('/feed.xml')->getStatusCode());
$mit = $this->request('/')->getContent();
self::assertStringContainsString('application/rss+xml', $mit);
self::assertStringContainsString('Erster Beitrag', $mit);
self::assertStringContainsString('/beitraege', $this->request('/sitemap.xml')->getContent());
// Im Editor bleibt der Hinweis stehen, sonst sucht jemand den Fehler an der leeren Stelle.
$template = (string) file_get_contents(__DIR__.'/../templates/blocks/post-list.html.twig');
self::assertStringContainsString('{% if editing|default(false) %}
', $template);
}
public function testAPostListCanPageThroughEverythingInsteadOfCuttingOff(): void
{
// Umut: „fehlt die Pagination bei Projekte? Es werden nur 12 angezeigt.“ Richtig: eine
// Auswahl zeigt die ersten n, eine Übersicht muss blättern - sonst verschweigt sie, dass
// es mehr gibt. Das ist die eigene Regel aus Arbeitspaket M, die für die Beitragsliste
// eines Dokuments bis hierher nicht galt.
for ($i = 1; $i <= 7; ++$i) {
$this->publishedPost('projekt-'.$i, 'Projekt '.$i, sprintf('2026-02-%02d', $i), ['Projekte'], []);
}
$document = DemoContent::starter('Übersicht');
$document['schemaVersion'] = 17;
$document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => [
'count' => 3, 'category' => 'projekte', 'tag' => '', 'paginate' => true,
]];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$erste = $this->request('/')->getContent();
self::assertSame(3, substr_count($erste, 'post-card'));
self::assertStringContainsString('Seite 1 von 3', $erste);
self::assertStringContainsString('7 Beiträge', $erste);
self::assertStringNotContainsString('Neuere', $erste, 'Auf der ersten Seite gibt es nichts Neueres.');
$dritte = $this->request('/?seite=3')->getContent();
self::assertStringContainsString('Seite 3 von 3', $dritte);
self::assertSame(1, substr_count($dritte, 'post-card'));
self::assertStringNotContainsString('Ältere', $dritte);
// Eine Seitenzahl jenseits des Endes landet auf der letzten Seite statt im Leeren.
self::assertStringContainsString('Seite 3 von 3', $this->request('/?seite=99')->getContent());
// Ohne den Schalter bleibt es eine Auswahl: drei Karten, keine Leiste.
$document['blocks'][array_key_last($document['blocks'])]['props']['paginate'] = false;
$revision = $this->pages->saveDraft('page-1', $document, $this->pages->findForEditor('page-1')['version']);
$this->pages->publish('page-1', $revision, $this->pages->findForEditor('page-1')['version']);
$auswahl = $this->request('/?seite=2')->getContent();
self::assertSame(3, substr_count($auswahl, 'post-card'));
self::assertStringNotContainsString('Seite 1 von', $auswahl);
}
public function testAPostCardReactsToThePointerAndToTheKeyboard(): void
{
// Umut: „es gibt keinen Mausover-Effekt, das fehlt“. Eine Karte, die sich beim Zeigen
// nicht rührt, wirkt tot. Der Hinweis liegt über dem Bild, ist für Vorlesesoftware aber
// nichts Neues - der Titel darunter führt zum selben Ziel.
$this->publishedPost('projekt-eins', 'Projekt eins', '2026-02-01', ['Projekte'], []);
// Der Hinweis liegt über dem Beitragsbild, also braucht der Beitrag eines.
$media = new MediaLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-karte-');
$image = imagecreatetruecolor(600, 400);
imagepng($image, $file);
imagedestroy($image);
$item = $media->upload(new UploadedFile($file, 'karte.png', test: true));
@unlink($file);
$id = $this->db->fetchOne('SELECT id FROM page WHERE path = ?', ['projekt-eins']);
$this->posts->saveMetadata($id, ['featuredMediaId' => $item['id'], 'publishedAt' => '2026-02-01'], ['Projekte'], [], (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', [$id]));
$document = DemoContent::starter('Startseite');
$document['schemaVersion'] = 17;
$document['blocks'][] = ['id' => 'raster', 'type' => 'postlist', 'props' => ['count' => 5, 'category' => 'projekte', 'tag' => '']];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('Ansehen', $html);
// Die Bewegung steht vollständig hinter der Bewegungsschranke, der Hinweis selbst nicht:
// wer weniger Bewegung eingestellt hat, sieht ihn trotzdem.
$effects = (string) file_get_contents(__DIR__.'/../public/assets/enhancements.css');
// Der erste Abschnitt steht vor der ersten Schranke; alles dahinter ist geschützt.
$guarded = explode('@media (prefers-reduced-motion: no-preference) {', $effects);
$geschuetzt = implode('', array_slice($guarded, 1));
self::assertStringContainsString('.post-card:hover, .post-card:focus-within {', $geschuetzt);
self::assertStringContainsString('transform: scale(1.06)', $geschuetzt);
self::assertStringNotContainsString('transform: translateY(-8px)', $guarded[0], 'Keine Bewegung außerhalb der Schranke.');
// Und über die Tastatur erreichbar: der Zustand hängt auch an :focus-within.
$site = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
self::assertStringContainsString('.post-card:hover .post-view,.post-card:focus-within .post-view{opacity:1}', $site);
}
public function testAPostListCanLookLikeAProjectGridInsteadOfABlogArchive(): void
{
// Beim Nachbau von kant-it.de fehlte genau das: das Projektraster dort zeigt Bild und
// Titel, die Beitragsliste zeigte zusätzlich Datum, Autor, Auszug und Kategorie und sah
// damit aus wie ein Blog. Was eine Karte zeigt, ist jetzt einstellbar.
$this->publishedPost('projekt-eins', 'Projekt eins', '2026-02-01', ['Projekte'], []);
$id = $this->db->fetchOne('SELECT id FROM page WHERE path = ?', ['projekt-eins']);
$this->posts->saveMetadata($id, ['excerpt' => 'Ein Auszug, der im Raster stört.', 'author' => 'Umut', 'publishedAt' => '2026-02-01'], ['Projekte'], [], (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', [$id]));
$document = DemoContent::starter('Startseite');
$document['schemaVersion'] = 17;
$document['blocks'][] = ['id' => 'raster', 'type' => 'postlist', 'props' => [
'count' => 5, 'category' => 'projekte', 'tag' => '',
'showMeta' => false, 'showExcerpt' => false, 'showTerms' => false,
], 'children' => []];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('Projekt eins', $html, 'Der Titel bleibt.');
self::assertStringNotContainsString('Ein Auszug, der im Raster stört.', $html);
self::assertStringNotContainsString('von Umut', $html);
self::assertStringNotContainsString('post-terms', $html);
// Und die Gegenprobe: ohne die Schalter bleibt alles wie vorher. Das ist der Fall, der
// fast danebengegangen wäre - „false|default(true)" ist in Twig true, weil der Filter auch
// auf leere Werte greift. Die Einstellung hätte sich speichern lassen und nichts getan.
$document['blocks'][array_key_last($document['blocks'])]['props'] = ['count' => 5, 'category' => 'projekte', 'tag' => ''];
$revision = $this->pages->saveDraft('page-1', $document, $this->pages->findForEditor('page-1')['version']);
$this->pages->publish('page-1', $revision, $this->pages->findForEditor('page-1')['version']);
$html = $this->request('/')->getContent();
self::assertStringContainsString('Ein Auszug, der im Raster stört.', $html);
self::assertStringContainsString('von Umut', $html);
self::assertStringContainsString('post-terms', $html);
}
public function testFeedListsPublishedPostsWithAbsoluteAddresses(): void
{
$this->publishedPost('erster', 'Erster Beitrag', '2026-02-01', ['Projekte'], []);
$this->pages->createPage('geheim', null, 'post', DemoContent::starter('Geheim', 'post'));
$response = $this->request('/feed.xml');
self::assertSame(200, $response->getStatusCode());
self::assertStringContainsString('application/xml', (string) $response->headers->get('Content-Type'));
$xml = $response->getContent();
self::assertStringContainsString('https://cms.test/erster', $xml);
self::assertStringContainsString('Projekte', $xml);
self::assertStringNotContainsString('Geheim', $xml);
self::assertNotFalse(simplexml_load_string($xml), 'The feed must be well-formed XML.');
}
/**
* @return iterable
*/
public static function rejectedPostMetadata(): iterable
{
yield 'oversized excerpt' => [['excerpt' => str_repeat('a', 501)], [], []];
yield 'oversized author' => [['author' => str_repeat('a', 121)], [], []];
yield 'malformed date' => [['publishedAt' => '09.03.2026'], [], []];
yield 'unknown media' => [['featuredMediaId' => str_repeat('a', 32)], [], []];
yield 'malformed media id' => [['featuredMediaId' => 'not-a-media-id'], [], []];
yield 'unusable term name' => [[], ['---'], []];
}
#[DataProvider('rejectedPostMetadata')]
public function testRejectsInvalidPostMetadataWithoutWriting(array $values, array $categories, array $tags): void
{
$id = $this->pages->createPage('beitrag', null, 'post', DemoContent::starter('Beitrag', 'post'));
$version = $this->pages->findForEditor($id)['version'];
try {
$this->posts->saveMetadata($id, $values, $categories, $tags, $version);
self::fail('Expected the metadata to be rejected.');
} catch (InvalidPage) {
}
self::assertSame($version, $this->pages->findForEditor($id)['version']);
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page_term'));
}
public function testDeletingAPostReleasesItsTermAssignments(): void
{
$id = $this->publishedPost('weg', 'Weg', '2026-01-01', ['Projekte'], ['Tag']);
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page_term'));
$this->pages->deletePage($id, $this->pages->findForEditor($id)['version']);
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page_term'));
// The terms themselves stay: they are reusable labels, not content of one post.
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM term'));
// Der Begriff existiert noch, sein Archiv hat aber nichts mehr zu zeigen: eine leere Liste
// unter eigener Adresse ist eine Sackgasse, also 404 statt einer Seite mit Überschrift.
self::assertSame(404, $this->request('/kategorie/projekte')->getStatusCode());
self::assertSame(0, $this->posts->published(1, $this->posts->findTerm('category', 'projekte')['id'])['total']);
// Ein Beitrag in einer anderen Kategorie bringt deren Archiv zurück, dieses hier nicht.
$this->publishedPost('bleibt', 'Bleibt', '2026-01-02', ['Technik'], []);
self::assertSame(200, $this->request('/kategorie/technik')->getStatusCode());
self::assertSame(404, $this->request('/kategorie/projekte')->getStatusCode());
}
public function testPublishingStampsAMissingDateButKeepsAGivenOne(): void
{
$given = $this->publishedPost('mit-datum', 'Mit Datum', '2024-07-04');
self::assertStringStartsWith('2024-07-04', (string) $this->db->fetchOne('SELECT published_at FROM page WHERE id = ?', [$given]));
$without = $this->pages->createPage('ohne-datum', null, 'post', DemoContent::starter('Ohne Datum', 'post'));
$page = $this->pages->findForEditor($without);
$this->pages->publish($without, $page['draft_revision_id'], $page['version']);
self::assertNotNull($this->db->fetchOne('SELECT published_at FROM page WHERE id = ?', [$without]));
// An ordinary page is never stamped.
$plain = $this->publishedPage('normale-seite', 'Normale Seite');
self::assertNull($this->db->fetchOne('SELECT published_at FROM page WHERE id = ?', [$plain]));
}
private function publishedPost(string $slug, string $title, string $date, array $categories = [], array $tags = []): string
{
$id = $this->pages->createPage($slug, null, 'post', DemoContent::starter($title, 'post'));
$this->posts->saveMetadata($id, ['publishedAt' => $date, 'author' => 'Umut'], $categories, $tags, $this->pages->findForEditor($id)['version']);
$page = $this->pages->findForEditor($id);
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
return $id;
}
/** A page built from the section kinds the pilot start page actually uses. */
private static function pilotDocument(): array
{
return [
'schemaVersion' => 6,
'title' => 'Kant-IT',
'description' => 'Ihr IT-Partner.',
'blocks' => [
['id' => 'hero', 'type' => 'section', 'children' => [
['id' => 'h1', 'type' => 'heading', 'props' => ['text' => 'Digitale Exzellenz', 'level' => 1]],
['id' => 'stat1', 'type' => 'stat', 'props' => ['value' => '15+', 'label' => 'Jahre Erfahrung']],
['id' => 'sp', 'type' => 'spacer', 'props' => ['size' => 'large']],
['id' => 'hr', 'type' => 'divider'],
]],
['id' => 'leistungen', 'type' => 'section', 'children' => [
['id' => 'card1', 'type' => 'iconcard', 'props' => ['icon' => 'app', 'title' => 'App-Entwicklung', 'text' => 'Für iOS und Android.', 'href' => '/leistungen']],
['id' => 'card2', 'type' => 'iconcard', 'props' => ['icon' => 'shop', 'title' => 'E-Commerce', 'text' => '', 'href' => '']],
]],
['id' => 'stimmen', 'type' => 'section', 'children' => [
['id' => 'q1', 'type' => 'quote', 'props' => ['text' => 'Kant-IT denkt mit.', 'author' => 'Malerservice Drews', 'role' => 'Zufriedener Kunde', 'rating' => 5]],
['id' => 'logos', 'type' => 'logos', 'children' => [
['id' => 'logo1', 'type' => 'image', 'props' => ['src' => '/assets/image-placeholder.svg', 'alt' => 'Partner', 'decorative' => false, 'width' => 200, 'height' => 80]],
]],
]],
['id' => 'fragen', 'type' => 'accordion', 'children' => [
['id' => 'f1', 'type' => 'accordionitem', 'props' => ['title' => 'Wie schnell antwortet ihr?', 'open' => true], 'children' => [
['id' => 'f1t', 'type' => 'text', 'props' => ['text' => 'In der Regel innerhalb von 24 Stunden.']],
]],
]],
['id' => 'kontakt', 'type' => 'section', 'children' => [
['id' => 'cta1', 'type' => 'cta', 'props' => ['title' => 'Bereit?', 'text' => 'Kostenlose Erstberatung.', 'buttonText' => 'Jetzt anfragen', 'href' => '#kontakt']],
['id' => 'adr', 'type' => 'contact', 'props' => ['phone' => '+49 7221 99 57 150', 'email' => 'info@kant-it.de', 'address' => 'Ooser Hauptstr. 51, Baden-Baden']],
]],
],
];
}
public function testPilotSectionsRoundTripAndRenderWithoutJavaScript(): void
{
$document = self::pilotDocument();
$revision = $this->pages->saveDraft('page-1', $document, 0);
self::assertSame($document, json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true));
$this->pages->publish('page-1', $revision, 1);
$html = $this->request('/')->getContent();
self::assertSame(200, $this->request('/')->getStatusCode());
// Every new block reaches the output.
foreach (['block-iconcard', 'block-stat', 'block-quote', 'block-logos', 'block-cta', 'block-contact', 'block-divider', 'block-spacer', 'block-accordionitem'] as $marker) {
self::assertStringContainsString($marker, $html, $marker);
}
// Contact details become real links, with the phone number stripped for the href.
self::assertStringContainsString('href="tel:+4972219957150"', $html);
self::assertStringContainsString('href="mailto:info@kant-it.de"', $html);
// The accordion is a native details element, so it works without scripting.
self::assertStringContainsString('', $html);
self::assertStringContainsString('Wie schnell antwortet ihr?', $html);
// Icons are inline SVG, not an icon font or an external request.
self::assertStringContainsString('