Seiten verschieben, Papierkorb, Fassungen, Zeitplan (Arbeitspaket F)
Migration 20260912000900 ergänzt page.publish_at, page.deleted_at und revision.seq. Verschieben nimmt den ganzen Teilbaum mit und hinterlässt für jede alte Adresse ein 301. Kollisionen, Tiefengrenze und das Verschieben unter eigene Unterseiten werden vor der ersten Änderung abgewiesen; alles in einer Transaktion, weil ein halb verschobener Teilbaum Seiten unerreichbar machen würde. Der Papierkorb ersetzt das sofortige Löschen für die Editorrolle: alle Fassungen bleiben, öffentlich 404, verschwunden aus Navigation, Archiven, Feed, Sitemap, llms.txt und Editorliste. Unterseiten gehen mit, beim Wiederherstellen muss das Elternteil zuerst zurück. Eine Fassung wiederherzustellen überschreibt nichts, sondern legt eine neue an. revision.seq behebt dabei einen echten Fehler: die Reihenfolge des Verlaufs war nicht eindeutig, weil created_at nur Sekunden kennt und mehrere Speichervorgänge in dieselbe Sekunde fallen - "vorherige Fassung wiederherstellen" hätte die falsche treffen können. Geplante Veröffentlichung über cms:publish-due. Ohne Cron bleibt die Seite ein Entwurf, was die sichere Richtung ist. Eine frei wählbare Startseite fehlt bewusst: der Pfad home gilt an elf Stellen als Wurzel, das ist ein eigener Schritt (ADR 022). Geprüft: 160 PHP-Tests / 622 Assertions unter PHP 8.2.12 und 8.4.22, 15 JavaScript-Modelltests, vollständiger Editortest mit axe, Migration zuerst gegen eine Datenbankkopie samt Kontrolle des Backfills. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ba56add678
commit
6afad30956
16 files changed
+750
-17
No files matched your search
@@ -26,6 +26,7 @@ use DoctrineMigrations\Version20260912000500;
|
||||
use DoctrineMigrations\Version20260912000600;
|
||||
use DoctrineMigrations\Version20260912000700;
|
||||
use DoctrineMigrations\Version20260912000800;
|
||||
use DoctrineMigrations\Version20260912000900;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
@@ -55,11 +56,13 @@ final class CmsTest extends TestCase
|
||||
(new Version20260912000600($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000700($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000800($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000900($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);
|
||||
Version20260912000900::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')]);
|
||||
@@ -1209,6 +1212,158 @@ final class CmsTest extends TestCase
|
||||
self::assertSame(Theme::DEFAULTS['colors'], $this->themes->load()['colors']);
|
||||
}
|
||||
|
||||
public function testMovingAPageCarriesItsSubtreeAndLeavesRedirectsBehind(): void
|
||||
{
|
||||
$parent = $this->publishedPage('leistungen', 'Leistungen');
|
||||
$child = $this->publishedChild('webdesign', 'Webdesign', $parent);
|
||||
$grandchild = $this->publishedChild('shop', 'Shop', $child);
|
||||
foreach (['leistungen', 'leistungen/webdesign', 'leistungen/webdesign/shop'] as $path) {
|
||||
self::assertSame(200, $this->request('/'.$path)->getStatusCode(), $path);
|
||||
}
|
||||
|
||||
$moved = $this->pages->movePage($parent, 'services', null, $this->pages->findForEditor($parent)['version'], $this->redirects);
|
||||
self::assertSame(3, $moved, 'The page and both descendants change address.');
|
||||
|
||||
// Every new address answers and every old one redirects permanently.
|
||||
foreach ([
|
||||
'services' => 'leistungen',
|
||||
'services/webdesign' => 'leistungen/webdesign',
|
||||
'services/webdesign/shop' => 'leistungen/webdesign/shop',
|
||||
] as $now => $before) {
|
||||
self::assertSame(200, $this->request('/'.$now)->getStatusCode(), $now);
|
||||
$old = $this->request('/'.$before);
|
||||
self::assertSame(301, $old->getStatusCode(), $before);
|
||||
self::assertSame('/'.$now, $old->headers->get('Location'), $before);
|
||||
}
|
||||
// The grandchild kept its own slug and its parent link.
|
||||
self::assertSame('shop', $this->db->fetchOne('SELECT slug FROM page WHERE id = ?', [$grandchild]));
|
||||
self::assertSame($child, $this->db->fetchOne('SELECT parent_id FROM page WHERE id = ?', [$grandchild]));
|
||||
}
|
||||
|
||||
public function testMoveRefusesCyclesCollisionsAndTheHomePage(): void
|
||||
{
|
||||
$parent = $this->publishedPage('leistungen', 'Leistungen');
|
||||
$child = $this->publishedChild('webdesign', 'Webdesign', $parent);
|
||||
$other = $this->publishedPage('beratung', 'Beratung');
|
||||
|
||||
$cases = [
|
||||
'into its own child' => [$parent, 'leistungen', $child],
|
||||
'into itself' => [$parent, 'leistungen', $parent],
|
||||
'onto a taken address' => [$other, 'leistungen', null],
|
||||
'with a slug that is no slug' => [$other, 'mit leerzeichen', null],
|
||||
];
|
||||
foreach ($cases as $name => [$page, $slug, $target]) {
|
||||
try {
|
||||
$this->pages->movePage($page, $slug, $target, $this->pages->findForEditor($page)['version'], $this->redirects);
|
||||
self::fail(sprintf('Expected "%s" to be refused.', $name));
|
||||
} catch (InvalidPage) {
|
||||
}
|
||||
}
|
||||
// The home page keeps its address; a different start page is a settings matter.
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
try {
|
||||
$this->pages->movePage('page-1', 'start', null, $this->pages->findForEditor('page-1')['version'], $this->redirects);
|
||||
self::fail('Expected the home page to be refused.');
|
||||
} catch (InvalidPage) {
|
||||
}
|
||||
// Nothing moved and no redirect was invented.
|
||||
self::assertSame(['beratung', 'home', 'leistungen', 'leistungen/webdesign', 'private'], $this->db->fetchFirstColumn('SELECT path FROM page ORDER BY path'));
|
||||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM redirect'));
|
||||
}
|
||||
|
||||
public function testTrashHidesAPageEverywhereAndRestoreBringsItBack(): void
|
||||
{
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
$parent = $this->publishedPage('leistungen', 'Leistungen');
|
||||
$child = $this->publishedChild('webdesign', 'Webdesign', $parent);
|
||||
$post = $this->publishedPost('ein-beitrag', 'Ein Beitrag', '2026-02-01', ['Projekte'], []);
|
||||
$this->menus->save('header', 'Hauptnavigation', [
|
||||
['label' => 'Leistungen', 'type' => 'page', 'pageId' => $parent, 'anchor' => '', 'children' => []],
|
||||
], 0);
|
||||
self::assertStringContainsString('Leistungen', $this->request('/')->getContent());
|
||||
|
||||
// Trashing takes the subtree along.
|
||||
self::assertSame(2, $this->pages->trashPage($parent, $this->pages->findForEditor($parent)['version']));
|
||||
self::assertSame(404, $this->request('/leistungen')->getStatusCode());
|
||||
self::assertSame(404, $this->request('/leistungen/webdesign')->getStatusCode());
|
||||
self::assertStringNotContainsString('Leistungen', $this->request('/')->getContent(), 'A trashed page leaves the navigation.');
|
||||
self::assertStringNotContainsString('/leistungen', $this->request('/sitemap.xml')->getContent());
|
||||
self::assertNull($this->pages->findForEditor($parent), 'A trashed page is out of the editor listing.');
|
||||
self::assertSame([$parent, $child], array_column($this->pages->trash(), 'id'));
|
||||
|
||||
// A trashed post disappears from the archive and the feed as well.
|
||||
$this->pages->trashPage($post, $this->db->fetchOne('SELECT version FROM page WHERE id = ?', [$post]));
|
||||
self::assertSame(0, $this->posts->published()['total']);
|
||||
self::assertStringNotContainsString('Ein Beitrag', $this->request('/feed.xml')->getContent());
|
||||
|
||||
// A child cannot come back before its parent, because its address needs it.
|
||||
try {
|
||||
$this->pages->restorePage($child);
|
||||
self::fail('Expected the child to wait for its parent.');
|
||||
} catch (InvalidPage) {
|
||||
}
|
||||
$this->pages->restorePage($parent);
|
||||
$this->pages->restorePage($child);
|
||||
self::assertSame(200, $this->request('/leistungen/webdesign')->getStatusCode());
|
||||
// Only the post is left in the bin; restoring one page does not restore another.
|
||||
self::assertSame([$post], array_column($this->pages->trash(), 'id'));
|
||||
}
|
||||
|
||||
public function testRestoringARevisionKeepsTheWholeHistory(): void
|
||||
{
|
||||
$first = DemoContent::starter('Erste Fassung');
|
||||
$second = DemoContent::starter('Zweite Fassung');
|
||||
$this->pages->saveDraft('page-1', $first, 0);
|
||||
$this->pages->saveDraft('page-1', $second, 1);
|
||||
$revisions = $this->pages->revisions('page-1');
|
||||
self::assertSame(['Zweite Fassung', 'Erste Fassung'], array_column($revisions, 'title'), 'Newest first.');
|
||||
self::assertTrue($revisions[0]['draft']);
|
||||
|
||||
$oldest = end($revisions);
|
||||
$this->pages->restoreRevision('page-1', $oldest['id'], 2);
|
||||
self::assertSame('Erste Fassung', $this->pages->findForEditor('page-1')['document']['title']);
|
||||
// Restoring adds a revision instead of overwriting one, so nothing is lost.
|
||||
self::assertCount(3, $this->pages->revisions('page-1'));
|
||||
self::assertSame('Zweite Fassung', $this->pages->revisions('page-1')[1]['title']);
|
||||
|
||||
$foreign = $this->pages->saveDraft('page-2', DemoContent::starter('Fremd'), 0);
|
||||
$this->expectException(InvalidPage::class);
|
||||
$this->pages->restoreRevision('page-1', $foreign, 3);
|
||||
}
|
||||
|
||||
public function testScheduledPublicationHappensOnlyWhenItIsDue(): void
|
||||
{
|
||||
$id = $this->pages->createPage('geplant', null, 'page', DemoContent::starter('Geplant'));
|
||||
$this->pages->schedule($id, gmdate('Y-m-d H:i', time() + 86400), $this->pages->findForEditor($id)['version']);
|
||||
self::assertSame([], $this->pages->publishDue(), 'A future date publishes nothing.');
|
||||
self::assertSame(404, $this->request('/geplant')->getStatusCode());
|
||||
|
||||
$this->pages->schedule($id, gmdate('Y-m-d H:i', time() - 3600), $this->pages->findForEditor($id)['version']);
|
||||
self::assertSame(['geplant'], $this->pages->publishDue());
|
||||
self::assertSame(200, $this->request('/geplant')->getStatusCode());
|
||||
// The schedule is consumed, so a later run does not publish again.
|
||||
self::assertNull($this->db->fetchOne('SELECT publish_at FROM page WHERE id = ?', [$id]));
|
||||
self::assertSame([], $this->pages->publishDue());
|
||||
|
||||
// A trashed page is never published by the schedule.
|
||||
$trashed = $this->pages->createPage('versteckt', null, 'page', DemoContent::starter('Versteckt'));
|
||||
$this->pages->schedule($trashed, gmdate('Y-m-d H:i', time() - 3600), $this->pages->findForEditor($trashed)['version']);
|
||||
$this->pages->trashPage($trashed, $this->db->fetchOne('SELECT version FROM page WHERE id = ?', [$trashed]));
|
||||
self::assertSame([], $this->pages->publishDue());
|
||||
|
||||
$this->expectException(InvalidPage::class);
|
||||
$this->pages->schedule($id, '12.09.2026', $this->db->fetchOne('SELECT version FROM page WHERE id = ?', [$id]));
|
||||
}
|
||||
|
||||
private function publishedChild(string $slug, string $title, string $parentId): string
|
||||
{
|
||||
$id = $this->pages->createPage($slug, $parentId, '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 = [];
|
||||
|
||||
Reference in new issue
Block a user