Beiträge mit Archiven, Beitragsliste und Feed (Arbeitspaket B)

Migration 20260912000600 ergänzt page.excerpt, author,
featured_media_id und published_at sowie die Tabellen term und
page_term. Beitragsmetadaten beschreiben die Seite, nicht ihren
Inhalt, und liegen deshalb als Spalten statt im Blockdokument: das
Archiv sortiert und paginiert damit in SQL, statt für jede
Auflistung alle Dokumente zu dekodieren.

Kategorien und Schlagwörter teilen sich eine Tabelle mit einem
kind-Feld. Beim Löschen einer Seite verschwinden ihre Zuordnungen,
die Begriffe bleiben als wiederverwendbare Etiketten; ein leer
gewordenes Archiv antwortet weiter mit 200 und leerer Liste.

Der Block postlist (Dokumentschema v5) speichert nur Anzahl und
Filter. Die Beiträge stellt der Controller beim Ausliefern zusammen,
damit ein gespeichertes Dokument keinen unveröffentlichten Beitrag
zeigen kann. Archive unter /beitraege, /kategorie/{slug} und
/schlagwort/{slug}, Feed unter /feed.xml; alle vier Segmente sind
für Wurzelseiten reserviert.

Geprüft: 91 PHP-Tests / 347 Assertions unter PHP 8.2.12 und 8.4.22,
12 JavaScript-Modelltests, Lint/Format/Container, Migration zuerst
gegen eine Datenbankkopie. 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 11:25:09 +02:00
1 parent de9d6e3da8
commit ebd5883593
25 files changed
+808 -21

No files matched your search

+171 -1
View File
@@ -4,8 +4,10 @@ namespace App\Tests;
use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\MediaLibrary;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PostRepository;
use App\Domain\InvalidDocument;
use App\Domain\InvalidMenu;
use App\Domain\InvalidPage;
@@ -15,8 +17,10 @@ use App\Kernel;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Schema\Schema;
use DoctrineMigrations\Version20260912000100;
use DoctrineMigrations\Version20260912000300;
use DoctrineMigrations\Version20260912000400;
use DoctrineMigrations\Version20260912000500;
use DoctrineMigrations\Version20260912000600;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
@@ -28,6 +32,7 @@ final class CmsTest extends TestCase
private Connection $db;
private PageRepository $pages;
private MenuRepository $menus;
private PostRepository $posts;
private string $path;
protected function setUp(): void
@@ -36,18 +41,22 @@ final class CmsTest extends TestCase
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->path));
$schema = new Schema();
(new Version20260912000100($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);
(new Version20260912000600($this->db, new NullLogger()))->up($schema);
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
$this->db->executeStatement($sql);
}
Version20260912000400::applyIndexes($this->db);
Version20260912000600::applyIndexes($this->db);
$this->db->insert('website', ['id' => 'default', 'name' => 'Kant CMS', '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());
$this->menus = new MenuRepository($this->db);
$this->posts = new PostRepository($this->db, new MediaLibrary($this->db, $this->path.'-media'));
}
protected function tearDown(): void
@@ -496,10 +505,171 @@ final class CmsTest extends TestCase
return $id;
}
public function testArchiveOrdersPostsByDatePaginatesAndHidesPagesAndDrafts(): void
{
// Three published posts with distinct dates, one draft post and one ordinary page.
foreach ([['alt', '2024-01-05'], ['mittel', '2025-06-01'], ['neu', '2026-03-09']] as [$slug, $date]) {
$id = $this->publishedPost($slug, ucfirst($slug), $date);
self::assertNotNull($this->pages->findForEditor($id));
}
$this->pages->createPage('unveroeffentlicht', null, 'post', DemoContent::starter('Unveroeffentlicht', 'post'));
$this->publishedPage('eine-seite', 'Eine Seite');
$first = $this->posts->published(1, null, 2);
self::assertSame(3, $first['total']);
self::assertSame(2, $first['pages']);
self::assertSame(['Neu', 'Mittel'], array_column($first['posts'], 'title'));
self::assertSame(['Alt'], array_column($this->posts->published(2, null, 2)['posts'], 'title'));
// A page is not a post, and an unpublished post stays out of the archive.
foreach ($this->posts->published(1, null, 20)['posts'] as $post) {
self::assertNotSame('Eine Seite', $post['title']);
self::assertNotSame('Unveroeffentlicht', $post['title']);
}
// Out-of-range page numbers clamp instead of showing an empty archive.
self::assertSame(2, $this->posts->published(99, null, 2)['page']);
self::assertSame(1, $this->posts->published(-5, null, 2)['page']);
$html = $this->request('/beitraege')->getContent();
self::assertSame(200, $this->request('/beitraege')->getStatusCode());
self::assertStringContainsString('<time datetime="2026-03-09">09.03.2026</time>', $html);
self::assertStringNotContainsString('Unveroeffentlicht', $html);
self::assertStringContainsString('href="https://cms.test/beitraege"', $html);
}
public function testCategoriesAndTagsGroupPostsAndGetTheirOwnArchives(): void
{
$projekt = $this->publishedPost('kanttresor', 'KantTresor', '2026-02-01', ['Projekte'], ['WordPress', 'DSGVO']);
$this->publishedPost('andere', 'Andere', '2026-01-01', ['Notizen'], []);
$terms = $this->posts->termsFor($projekt);
self::assertSame(['Projekte'], array_column($terms['category'], 'name'));
self::assertSame(['DSGVO', 'WordPress'], array_column($terms['tag'], 'name'));
self::assertSame('/kategorie/projekte', $terms['category'][0]['href']);
self::assertSame('/schlagwort/dsgvo', $terms['tag'][0]['href']);
$category = $this->request('/kategorie/projekte');
self::assertSame(200, $category->getStatusCode());
self::assertStringContainsString('KantTresor', $category->getContent());
self::assertStringNotContainsString('Andere', $category->getContent());
self::assertStringContainsString('KantTresor', $this->request('/schlagwort/wordpress')->getContent());
self::assertSame(404, $this->request('/kategorie/gibt-es-nicht')->getStatusCode());
// A tag slug is not a category slug.
self::assertSame(404, $this->request('/kategorie/dsgvo')->getStatusCode());
// Saving metadata again replaces the assignment instead of adding to it.
$version = $this->pages->findForEditor($projekt)['version'];
$this->posts->saveMetadata($projekt, ['publishedAt' => '2026-02-01'], ['Projekte'], [], $version);
self::assertSame([], $this->posts->termsFor($projekt)['tag']);
// The term itself survives as a reusable label, so its archive answers but lists nothing.
$emptied = $this->request('/schlagwort/wordpress');
self::assertSame(200, $emptied->getStatusCode());
self::assertStringNotContainsString('KantTresor', $emptied->getContent());
}
public function testPostListBlockIsFilledByTheRendererAndRespectsItsFilter(): void
{
$this->publishedPost('projekt-eins', 'Projekt eins', '2026-02-01', ['Projekte'], []);
$this->publishedPost('notiz', 'Eine Notiz', '2026-02-02', ['Notizen'], []);
$document = DemoContent::starter('Startseite');
$document['schemaVersion'] = 5;
$document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => ['count' => 5, 'category' => 'projekte', 'tag' => ''], 'children' => []];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('Projekt eins', $html);
self::assertStringNotContainsString('Eine Notiz', $html);
// The stored block carries no post data; only the filter and the count are content.
$stored = $this->pages->findPublished('home')['document'];
self::assertSame(['count' => 5, 'category' => 'projekte', 'tag' => ''], $stored['blocks'][2]['props']);
}
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('<link>https://cms.test/erster</link>', $xml);
self::assertStringContainsString('<category>Projekte</category>', $xml);
self::assertStringNotContainsString('Geheim', $xml);
self::assertNotFalse(simplexml_load_string($xml), 'The feed must be well-formed XML.');
}
/**
* @return iterable<string, array{array, array, array}>
*/
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'));
$archive = $this->request('/kategorie/projekte');
self::assertSame(200, $archive->getStatusCode());
self::assertStringNotContainsString('Weg', $archive->getContent());
self::assertSame(0, $this->posts->published(1, $this->posts->findTerm('category', 'projekte')['id'])['total']);
}
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;
}
private function request(string $url, string $method = 'GET'): Response
{
$previous = [];
foreach (['APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->path), 'SITE_ORIGIN' => 'https://cms.test'] as $key => $value) {
foreach (['APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->path), 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media'] as $key => $value) {
$previous[$key] = [$_SERVER[$key] ?? null, $_ENV[$key] ?? null];
$_SERVER[$key] = $_ENV[$key] = $value;
}
+3
View File
@@ -16,6 +16,7 @@ use DoctrineMigrations\Version20260912000200;
use DoctrineMigrations\Version20260912000300;
use DoctrineMigrations\Version20260912000400;
use DoctrineMigrations\Version20260912000500;
use DoctrineMigrations\Version20260912000600;
use Psr\Log\NullLogger;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
@@ -47,10 +48,12 @@ final class EditorSecurityTest extends WebTestCase
(new Version20260912000300($this->db, new NullLogger()))->up($schema);
(new Version20260912000400($this->db, new NullLogger()))->up($schema);
(new Version20260912000500($this->db, new NullLogger()))->up($schema);
(new Version20260912000600($this->db, new NullLogger()))->up($schema);
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
$this->db->executeStatement($sql);
}
Version20260912000400::applyIndexes($this->db);
Version20260912000600::applyIndexes($this->db);
$this->db->insert('website', ['id' => 'default', 'name' => 'Test', 'default_locale' => 'de', 'theme' => '{}']);
$this->db->insert('page', ['id' => 'p1', 'slug' => 'home', 'path' => 'home', 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
$pages = new PageRepository($this->db, new DocumentValidator());
+3
View File
@@ -13,6 +13,7 @@ use DoctrineMigrations\Version20260912000200;
use DoctrineMigrations\Version20260912000300;
use DoctrineMigrations\Version20260912000400;
use DoctrineMigrations\Version20260912000500;
use DoctrineMigrations\Version20260912000600;
use Psr\Log\NullLogger;
$directory = dirname(__DIR__).'/var/browser-tests';
@@ -28,10 +29,12 @@ $schema = new Schema();
(new Version20260912000300($db, new NullLogger()))->up($schema);
(new Version20260912000400($db, new NullLogger()))->up($schema);
(new Version20260912000500($db, new NullLogger()))->up($schema);
(new Version20260912000600($db, new NullLogger()))->up($schema);
foreach ($schema->toSql($db->getDatabasePlatform()) as $sql) {
$db->executeStatement($sql);
}
Version20260912000400::applyIndexes($db);
Version20260912000600::applyIndexes($db);
$db->insert('website', ['id' => 'default', 'name' => 'Browser test', 'default_locale' => 'de', 'theme' => '{}']);
$pages = new PageRepository($db, new DocumentValidator());
foreach (DemoContent::pages() as $slug => $document) {
+20
View File
@@ -156,3 +156,23 @@ test('plugin blocks stay movable and intact while the editor refuses to fill or
assert.equal(upgradeDocument(history.document, 4).schemaVersion, 4);
assert.deepEqual(JSON.parse(JSON.stringify(history.document)).blocks.find(b => b.id === 'p1'), plugin);
});
test('the post list block carries only its filter, lifts the schema to v5 and undoes atomically', () => {
const history = new EditorHistory(fixture());
const id = history.change(doc => insertBlock(doc, 'postlist', 's2'));
const block = locate(history.document.blocks, id).block;
// Content is the filter and the count; the posts themselves are added when the page is served.
assert.deepEqual(block.props, { count: 3, category: '', tag: '' });
assert.equal(block.children, undefined);
assert.equal(history.document.schemaVersion, 5);
// Nothing can be nested inside it, and it does not swallow a following block.
assert.equal(canContain(block, { type: 'text' }), false);
const text = history.change(doc => insertBlock(doc, 'text', id));
assert.notEqual(locate(history.document.blocks, text).parent?.id, id);
history.undo(); history.undo();
assert.deepEqual(history.document, fixture());
assert.equal(history.document.schemaVersion, 1);
});