Rebuild DEEG as a fifth installation, add stepped forms and a plugin system, and clear the review backlog

Forms gained steps, conditions, image choices, number fields with units and a
summary step; the DEEG price finder uses all of it. Plugins are manifest plus
Twig: blocks, settings, containers, routes, presets and theme packs, switched
per installation. The review backlog is done: archive pages render through the
normal page pipeline, forms and post lists work inside areas and sections,
repeated sections get their own ids, breakpoints and colours come from the
palette, backup and update work on MySQL and lock each other out.

The task worker no longer shortens a process that has no time limit:
set_time_limit() sets the limit instead of extending it, which turned an
unlimited CLI run into eighty seconds and killed the full test suite far away
from the scheduler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-16 07:32:35 +02:00
1 parent 66ff68abf8
commit c8a8fa7460
74 files changed
+5352 -243

No files matched your search

+308
View File
@@ -0,0 +1,308 @@
<?php
namespace App\Tests;
use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\FormRepository;
use App\Content\MediaLibrary;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\PostRepository;
use App\Content\SiteSettings;
use App\Content\Theme;
use App\Domain\InvalidDocument;
use App\Infrastructure\ConnectionFactory;
use App\Kernel;
use App\Ops\ArchiveUpdate;
use App\Ops\CommandRunner;
use App\Ops\Updater;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Die Rückstände aus der Gesamtprüfung (ADR 100), am 16.09.2026 abgearbeitet (ADR 104): Archivseiten
* durch die Aufbaukette der Seiten, Formulare und Beitragslisten in Bereichen, Kopien mehrfach
* verknüpfter Sektionen, Canonical und Blätterverweise, Palette bis in Fehlerfarben und Farbschema,
* leere Bausteine direkt nach dem Einfügen, eine Sperre für Updates und eine .htaccess, die ihre
* eingeschalteten Zeilen behält.
*/
final class BacklogTest extends TestCase
{
private string $path;
private Connection $db;
private PageRepository $pages;
private PartialRepository $partials;
private PostRepository $posts;
private SiteSettings $site;
protected function setUp(): void
{
$this->path = tempnam(sys_get_temp_dir(), 'kant-backlog-');
$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' => '{}']);
$this->db->insert('page', ['id' => 'page-1', 'slug' => 'home', 'path' => 'home', 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
$media = new MediaLibrary($this->db, $this->path.'-media');
$this->site = new SiteSettings($this->db, $media);
$this->pages = new PageRepository($this->db, new DocumentValidator(), $this->site);
$this->partials = new PartialRepository($this->db, new DocumentValidator());
$this->posts = new PostRepository($this->db, $media, $this->site);
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();
@unlink($this->path);
}
private function publishHome(array $document): void
{
$document['schemaVersion'] = 23;
$version = (int) ($this->pages->findForEditor('page-1')['version'] ?? 0);
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, $version), $version + 1);
}
private function post(string $slug, string $title, string $date, array $categories = []): string
{
$id = $this->pages->createPage($slug, null, 'post', DemoContent::starter($title, 'post'));
$this->posts->saveMetadata($id, ['publishedAt' => $date], $categories, [], $this->pages->findForEditor($id)['version']);
$seite = $this->pages->findForEditor($id);
$this->pages->publish($id, $seite['draft_revision_id'], $seite['version']);
return $id;
}
public function testArchivesRunThroughThePageChainWithFooterSectionsAndPagination(): void
{
for ($i = 1; $i <= 12; ++$i) {
$this->post('beitrag-'.$i, 'Beitrag '.$i, sprintf('2026-01-%02d', $i), ['Projekte']);
}
// Der Fuß verknüpft eine Sektion mit eigenem CSS - das Archiv muss beides zeigen.
$sektion = DemoContent::starter('Fußsektion');
$sektion['schemaVersion'] = 23;
$sektion['blocks'] = [['id' => 'fuss-hinweis', 'type' => 'text', 'props' => ['text' => 'Aus der Fußsektion'], 'css' => '& p{letter-spacing:0.3em}']];
$this->partials->create('section', 'fusssektion', 'Fußsektion', $sektion);
$footer = $this->partials->findByKind('footer');
$doc = $footer['document'];
$doc['schemaVersion'] = 23;
$doc['blocks'][] = ['id' => 'fuss-ref', 'type' => 'partialref', 'props' => ['partial' => 'fusssektion']];
$this->partials->save($footer['id'], $doc, $footer['version']);
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'scrollTop' => '1', 'scrollTopLabel' => 'Nach oben']);
$antwort = $this->request('/beitraege');
$html = $antwort->getContent();
self::assertSame(200, $antwort->getStatusCode());
self::assertStringContainsString('<h1', $html);
self::assertStringContainsString('Beiträge', $html);
self::assertStringContainsString('Aus der Fußsektion', $html, 'Der Fuß samt verknüpfter Sektion steht unter dem Archiv.');
self::assertStringContainsString('#fuss-hinweis p{letter-spacing:0.3em}', $html, 'Eigenes CSS der Sektion kommt mit.');
self::assertStringContainsString('scroll-top', $html, 'Der Nach-oben-Knopf ist da wie auf jeder Seite.');
self::assertStringContainsString('<link rel="canonical" href="https://cms.test/beitraege">', $html);
self::assertStringContainsString('<link rel="next" href="https://cms.test/beitraege?seite=2">', $html);
self::assertStringNotContainsString('rel="prev"', $html);
self::assertStringContainsString('12 Beiträge', $html);
$zwei = $this->request('/beitraege?seite=2')->getContent();
self::assertStringContainsString('<link rel="canonical" href="https://cms.test/beitraege?seite=2">', $zwei);
self::assertStringContainsString('<link rel="prev" href="https://cms.test/beitraege">', $zwei);
self::assertStringNotContainsString('rel="next"', $zwei);
self::assertStringContainsString('Beitrag 1<', $zwei, 'Die ältesten Beiträge stehen auf der zweiten Seite.');
$kategorie = $this->request('/kategorie/projekte')->getContent();
self::assertStringContainsString('Kategorie', $kategorie);
self::assertStringContainsString('<link rel="canonical" href="https://cms.test/kategorie/projekte">', $kategorie);
self::assertStringContainsString('BreadcrumbList', $kategorie, 'Die Spur Beiträge → Kategorie steht in den strukturierten Daten.');
self::assertSame(404, $this->request('/kategorie/leer')->getStatusCode());
}
public function testAPaginatedPostListOnAPageGetsCanonicalAndPrevNext(): void
{
for ($i = 1; $i <= 5; ++$i) {
$this->post('n-'.$i, 'Notiz '.$i, sprintf('2026-02-%02d', $i));
}
$document = DemoContent::starter('Neues');
$document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => ['count' => 2, 'category' => '', 'tag' => '', 'layout' => 'liste', 'paginate' => true]];
$this->publishHome($document);
$eins = $this->request('/')->getContent();
self::assertStringContainsString('<link rel="canonical" href="https://cms.test/">', $eins);
self::assertStringContainsString('<link rel="next" href="https://cms.test/?seite=2">', $eins);
$drei = $this->request('/?seite=3')->getContent();
self::assertStringContainsString('<link rel="canonical" href="https://cms.test/?seite=3">', $drei);
self::assertStringContainsString('<link rel="prev" href="https://cms.test/?seite=2">', $drei);
self::assertStringNotContainsString('rel="next"', $drei);
}
public function testAFormInTheFooterWorksLikeOneOnThePage(): void
{
(new FormRepository($this->db))->save('kontakt', 'Kontakt', [
['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true],
['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Einverstanden'],
], ['recipient' => 'info@example.org', 'buttonColor' => '#123456'], 0);
$footer = $this->partials->findByKind('footer');
$doc = $footer['document'];
$doc['schemaVersion'] = 23;
$doc['blocks'][] = ['id' => 'fuss-formular', 'type' => 'form', 'props' => ['form' => 'kontakt']];
$this->partials->save($footer['id'], $doc, $footer['version']);
$this->publishHome(DemoContent::starter('Start'));
$html = $this->request('/')->getContent();
self::assertStringContainsString('<form class="block-form" data-form="kontakt"', $html, 'Das Formular im Fuß wird gerendert, nicht als „nicht vorhanden“ abgetan.');
self::assertStringContainsString('.block-form[data-form="kontakt"]{', $html, 'Seine Farben stehen im Stylesheet der Seite.');
// Abgeschickt ohne E-Mail: die Seite kommt mit Meldung zurück, am Formular im Fuß.
$issued = time() - 10;
$antwort = $this->request('/formular/kontakt', 'POST', ['page' => 'home', 'block' => 'fuss-formular', 'issued' => (string) $issued, 'issuedSignature' => hash_hmac('sha256', (string) $issued, 'test-only'), 'email' => '', 'datenschutz' => 'ja']);
self::assertSame(422, $antwort->getStatusCode());
self::assertStringContainsString('Dieses Feld wird benötigt.', $antwort->getContent());
}
public function testASectionLinkedTwiceRendersWithoutDuplicateIds(): void
{
$sektion = DemoContent::starter('Band');
$sektion['schemaVersion'] = 23;
$sektion['blocks'] = [['id' => 'band-sektion', 'type' => 'section', 'anchor' => 'band', 'children' => [['id' => 'band-text', 'type' => 'text', 'props' => ['text' => 'Zweimal da'], 'style' => ['desktop' => ['color' => '#112233']]]]]];
$this->partials->create('section', 'band', 'Band', $sektion);
$document = DemoContent::starter('Doppelt');
$document['blocks'][] = ['id' => 'ref-1', 'type' => 'partialref', 'props' => ['partial' => 'band']];
$document['blocks'][] = ['id' => 'ref-2', 'type' => 'partialref', 'props' => ['partial' => 'band']];
$this->publishHome($document);
$html = $this->request('/')->getContent();
self::assertSame(1, substr_count($html, 'id="band-text"'));
self::assertSame(1, substr_count($html, 'id="band-text-2"'), 'Die zweite Verknüpfung bekommt eigene Kennungen.');
self::assertSame(2, substr_count($html, 'Zweimal da'));
self::assertSame(1, substr_count($html, 'id="band"'), 'Das Sprungziel gibt es nur einmal.');
self::assertStringContainsString('#band-text{', $html);
self::assertStringContainsString('#band-text-2{', $html, 'Auch die Kopie hat ihren Stil.');
}
public function testFreshlyInsertedBlocksAreValidAndRenderNothingPublicly(): void
{
$document = DemoContent::starter('Leer');
$document['blocks'][] = ['id' => 'k', 'type' => 'contact', 'props' => ['phone' => '', 'email' => '', 'address' => '']];
$document['blocks'][] = ['id' => 'f', 'type' => 'form', 'props' => ['form' => '']];
$document['blocks'][] = ['id' => 's', 'type' => 'partialref', 'props' => ['partial' => '']];
$document['blocks'][] = ['id' => 'e', 'type' => 'embed', 'props' => ['group' => 'external', 'title' => '', 'provider' => '', 'url' => '', 'hint' => '', 'height' => 400]];
$this->publishHome($document);
$html = $this->request('/')->getContent();
foreach (['id="k"', 'id="f"', 'id="s"', 'id="e"', 'nicht mehr vorhanden', 'block-unavailable'] as $spur) {
self::assertStringNotContainsString($spur, $html, $spur.' darf öffentlich nicht auftauchen.');
}
// Mit Inhalt gelten die alten Regeln weiter.
$document['blocks'][array_key_last($document['blocks'])]['props']['url'] = 'http://unsicher.example';
try {
$this->publishHome($document);
self::fail('Eine Adresse ohne HTTPS bleibt abgelehnt.');
} catch (InvalidDocument) {
}
$document = DemoContent::starter('Legal');
$document['blocks'][] = ['id' => 'l', 'type' => 'legal', 'props' => ['label' => str_repeat('x', 61)]];
try {
$this->publishHome($document);
self::fail('Die Beschriftung der Rechtszeile ist begrenzt.');
} catch (InvalidDocument) {
}
}
public function testADarkPaletteSwitchesTheColourSchemeAndTheErrorColours(): void
{
$theme = new Theme($this->db);
$theme->save(
[['key' => 'nacht', 'name' => 'Nacht', 'value' => '#101a2b'], ['key' => 'hell', 'name' => 'Hell', 'value' => '#f4f1ea']],
[['key' => 'body', 'name' => 'Text', 'family' => 'Arial', 'source' => 'system', 'weights' => [400, 700], 'fallback' => 'sans-serif']],
['fontFamily' => 'body', 'headingFontFamily' => 'body', 'fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '@hell', 'backgroundColor' => '@nacht', 'linkColor' => '@hell', 'containerWidth' => '1180px', 'headingScale' => 1.25, 'linkUnderline' => true],
);
$this->publishHome(DemoContent::starter('Dunkel'));
$html = $this->request('/')->getContent();
self::assertStringContainsString('--k-scheme:dark', $html);
self::assertStringContainsString('--k-error:#ff9b8a', $html);
self::assertStringContainsString('--k-error-bg:color-mix(in srgb,#ff9b8a 12%,#101a2b)', $html);
self::assertGreaterThan(0.5, Theme::luminance('#f4f1ea'));
self::assertLessThan(0.1, Theme::luminance('#101a2b'));
// Die Stylesheets kennen keine festen Fehlerfarben und kein festes Weiß mehr.
$css = (string) file_get_contents(dirname(__DIR__).'/public/assets/site.css');
self::assertStringNotContainsString('color:#8b2f22', $css);
self::assertStringNotContainsString('color:white', $css);
self::assertStringContainsString('color-scheme:var(--k-scheme,light)', $css);
self::assertStringContainsString('@media print', $css);
self::assertStringNotContainsString('max-width:650px', $css, 'Eine Grenze zwischen Tablet und Telefon: 767 px.');
self::assertStringNotContainsString('max-width:650px', (string) file_get_contents(dirname(__DIR__).'/public/assets/design.css'));
}
public function testUpdatesLockEachOtherOutAndTheHtaccessKeepsItsEnabledLines(): void
{
$root = sys_get_temp_dir().'/kant-lock-'.bin2hex(random_bytes(4));
mkdir($root.'/var', 0o775, true);
mkdir($root.'/public', 0o775, true);
try {
$updater = new Updater($root, 'sqlite:///'.$root.'/var/cms.db', new CommandRunner(), $this->db);
$erste = $updater->lock();
self::assertIsResource($erste);
self::assertNull($updater->lock(), 'Solange die erste Sperre steht, bekommt niemand eine zweite.');
flock($erste, LOCK_UN);
fclose($erste);
$zweite = $updater->lock();
self::assertIsResource($zweite);
fclose($zweite);
// Die alte .htaccess hat HTTPS und HSTS eingeschaltet, das Release liefert sie auskommentiert.
$alt = "RewriteEngine On\nRewriteCond %{HTTPS} !=on\nRewriteCond %{HTTP:X-Forwarded-Proto} !=https\nRewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]\nHeader always set Strict-Transport-Security \"max-age=31536000\"\n";
$neu = "RewriteEngine On\n# neu im Release\n#RewriteCond %{HTTPS} !=on\n#RewriteCond %{HTTP:X-Forwarded-Proto} !=https\n#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]\n#Header always set Strict-Transport-Security \"max-age=31536000; includeSubDomains\"\n";
file_put_contents($root.'/public/.htaccess', $alt);
file_put_contents($root.'/var/neu.htaccess', $neu);
$merge = new \ReflectionMethod(ArchiveUpdate::class, 'mergeHtaccess');
$archiv = (new \ReflectionClass(ArchiveUpdate::class))->newInstanceWithoutConstructor();
$merge->invoke($archiv, $root.'/var/neu.htaccess', $root.'/public/.htaccess');
$ergebnis = (string) file_get_contents($root.'/public/.htaccess');
self::assertStringContainsString("# neu im Release\nRewriteCond %{HTTPS} !=on\nRewriteCond %{HTTP:X-Forwarded-Proto} !=https\nRewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]\nHeader always set Strict-Transport-Security \"max-age=31536000; includeSubDomains\"", $ergebnis, 'Die neue Datei, aber mit den eingeschalteten Zeilen.');
// Waren sie aus, bleiben sie aus.
file_put_contents($root.'/public/.htaccess', $neu);
$merge->invoke($archiv, $root.'/var/neu.htaccess', $root.'/public/.htaccess');
self::assertSame($neu, (string) file_get_contents($root.'/public/.htaccess'));
} finally {
@unlink($root.'/public/.htaccess');
@unlink($root.'/var/neu.htaccess');
@unlink($root.'/var/update.lock');
@rmdir($root.'/public');
@rmdir($root.'/var');
@rmdir($root);
}
}
private function request(string $url, string $method = 'GET', array $body = []): Response
{
$previous = [];
foreach (['APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->path), 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media', 'FONT_DIRECTORY' => $this->path.'-fonts', 'ASSET_DIRECTORY' => $this->path.'-assets'] as $key => $value) {
$previous[$key] = [$_SERVER[$key] ?? null, $_ENV[$key] ?? null];
$_SERVER[$key] = $_ENV[$key] = $value;
}
$kernel = new Kernel('test', false);
try {
$request = Request::create($url, $method, $body);
$response = $kernel->handle($request);
$kernel->terminate($request, $response);
return $response;
} finally {
$kernel->shutdown();
foreach ($previous as $key => [$server, $env]) {
if (null === $server) {
unset($_SERVER[$key]);
} else {
$_SERVER[$key] = $server;
}
if (null === $env) {
unset($_ENV[$key]);
} else {
$_ENV[$key] = $env;
}
}
}
}
}