Files
kantarciandClaude Opus 5 c8a8fa7460 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>
2026-09-16 07:32:35 +02:00

376 lines
22 KiB
PHP

<?php
namespace App\Tests;
use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\MediaLibrary;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\SiteSettings;
use App\Domain\InvalidDocument;
use App\Infrastructure\ConnectionFactory;
use App\Kernel;
use App\Plugin\PluginRegistry;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Das Pluginsystem (ADR 102): Manifeste im Ordner plugins/, ein Schalter je Plugin in der
* Datenbank, Bausteine mit geprüften Eigenschaften, Vorlagen und Dateien nur dort, wo der
* Baustein vorkommt. Geprüft am mitgelieferten Referenzplugin „Bildvergleich“ und an einem
* absichtlich kaputten Plugin in einem Testordner.
*/
final class PluginTest extends TestCase
{
private string $path;
private Connection $db;
private PageRepository $pages;
private PluginRegistry $plugins;
private string $projectDir;
protected function setUp(): void
{
$this->projectDir = dirname(__DIR__);
$this->path = tempnam(sys_get_temp_dir(), 'kant-plugin-');
$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')]);
$settings = new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media'));
$this->plugins = new PluginRegistry($this->db, $this->projectDir);
$this->pages = new PageRepository($this->db, new DocumentValidator(plugins: $this->plugins), $settings);
$partials = new PartialRepository($this->db, new DocumentValidator());
foreach (DemoContent::areas() as $kind => $area) {
$partials->create($kind, 'default', $area['name'], $area['document']);
}
}
protected function tearDown(): void
{
$this->db->close();
gc_collect_cycles();
@unlink($this->path);
}
private function uploadImage(): array
{
$media = new MediaLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-plugin-img-');
$image = imagecreatetruecolor(800, 600);
imagepng($image, $file);
imagedestroy($image);
$item = $media->upload(new UploadedFile($file, 'foto.png', test: true));
@unlink($file);
return $item;
}
private function vergleich(array $props, string $id = 'v1'): array
{
$document = DemoContent::starter('Vergleich');
$document['schemaVersion'] = 23;
$document['blocks'][] = ['id' => $id, 'type' => 'vergleich.bildvergleich', 'props' => $props];
return $document;
}
public function testTheReferencePluginIsFoundReadAndSwitchable(): void
{
$all = $this->plugins->all();
self::assertArrayHasKey('vergleich', $all);
$plugin = $all['vergleich'];
self::assertNull($plugin['error']);
self::assertSame('Bildvergleich', $plugin['title']);
self::assertFalse($plugin['active'], 'Ein neues Plugin ist aus, bis jemand es einschaltet.');
self::assertSame([], $this->plugins->blocks());
self::assertNull($this->plugins->block('vergleich.bildvergleich'));
$this->plugins->activate('vergleich');
$block = $this->plugins->block('vergleich.bildvergleich');
self::assertNotNull($block);
self::assertSame('@plugins/vergleich/blocks/bildvergleich.html.twig', $block['template']);
self::assertSame('/plugin-assets/vergleich/assets/bildvergleich.css?v=1.1.0', $block['css']);
self::assertSame('/plugin-assets/vergleich/assets/bildvergleich.js?v=1.1.0', $block['script']);
self::assertSame(['before', 'after', 'labelBefore', 'labelAfter', 'alt', 'position', 'rounded'], array_keys($block['props']));
self::assertSame(['kind' => 'number', 'label' => 'Startposition des Reglers (%)', 'help' => '', 'min' => 5, 'max' => 95, 'default' => 50], $block['props']['position']);
self::assertSame('1.1.0', $this->plugins->all()['vergleich']['activatedVersion']);
// Der Schalter liegt in der Datenbank: eine neue Registry sieht ihn auch.
self::assertTrue((new PluginRegistry($this->db, $this->projectDir))->all()['vergleich']['active']);
$this->plugins->deactivate('vergleich');
self::assertFalse($this->plugins->all()['vergleich']['active']);
self::assertNull($this->plugins->block('vergleich.bildvergleich'));
try {
$this->plugins->activate('gibtesnicht');
self::fail('Ein unbekanntes Plugin darf sich nicht einschalten lassen.');
} catch (\InvalidArgumentException $e) {
self::assertSame('Dieses Plugin gibt es nicht.', $e->getMessage());
}
}
public function testABrokenManifestIsReportedAndNeverActive(): void
{
$dir = sys_get_temp_dir().'/kant-plugins-'.bin2hex(random_bytes(4));
mkdir($dir.'/plugins/kaputt', 0o775, true);
mkdir($dir.'/plugins/leer', 0o775, true);
mkdir($dir.'/plugins/gut/blocks', 0o775, true);
file_put_contents($dir.'/VERSION', "1.17.0\n");
file_put_contents($dir.'/plugins/kaputt/plugin.json', '{"name": "kaputt", "title": "Kaputt", "version": "1.0.0", "blocks": {"karte": {"label": "Karte", "template": "blocks/fehlt.html.twig"}}}');
file_put_contents($dir.'/plugins/leer/plugin.json', '{nicht json');
file_put_contents($dir.'/plugins/gut/plugin.json', json_encode(['name' => 'gut', 'title' => 'Gut', 'version' => '2.0.0', 'requires' => '9.0.0', 'blocks' => ['karte' => ['label' => 'Karte', 'template' => 'blocks/karte.html.twig', 'props' => ['titel' => ['kind' => 'text', 'default' => 'Hallo']]]]]));
file_put_contents($dir.'/plugins/gut/blocks/karte.html.twig', '<div id="{{ block.id }}">{{ props.titel }}</div>');
try {
$registry = new PluginRegistry($this->db, $dir);
$all = $registry->all();
self::assertStringContainsString('fehlt im Pluginordner', (string) $all['kaputt']['error']);
self::assertStringContainsString('kein gültiges JSON', (string) $all['leer']['error']);
self::assertStringContainsString('Braucht KantSites 9.0.0', (string) $all['gut']['error']);
foreach (['kaputt', 'leer', 'gut'] as $name) {
try {
$registry->activate($name);
self::fail($name.' darf sich nicht einschalten lassen.');
} catch (\InvalidArgumentException) {
}
}
self::assertSame([], $registry->blocks());
// Mit passender Fassung ist „gut“ in Ordnung und seine Vorgaben stehen fest.
file_put_contents($dir.'/VERSION', "9.1.0\n");
$registry = new PluginRegistry($this->db, $dir);
self::assertNull($registry->all()['gut']['error']);
$registry->activate('gut');
self::assertSame(['titel' => 'Hallo'], $registry->validateProps('gut.karte', []));
self::assertSame(['titel' => 'Servus'], $registry->validateProps('gut.karte', ['titel' => ' Servus ', 'fremd' => 'weg']), 'Unbekannte Eigenschaften fallen weg, bekannte werden bereinigt.');
} finally {
foreach (['kaputt/plugin.json', 'leer/plugin.json', 'gut/plugin.json', 'gut/blocks/karte.html.twig'] as $file) {
@unlink($dir.'/plugins/'.$file);
}
foreach (['gut/blocks', 'gut', 'kaputt', 'leer'] as $sub) {
@rmdir($dir.'/plugins/'.$sub);
}
@unlink($dir.'/VERSION');
@rmdir($dir.'/plugins');
@rmdir($dir);
}
}
public function testPropsAreCheckedAgainstTheSchemaOnceThePluginIsActive(): void
{
$bild = $this->uploadImage();
// Aus: Erhaltungsvertrag - alles wird gespeichert, nichts geprüft.
$wild = $this->vergleich(['position' => 'sehr weit', 'before' => '<script>', 'unsinn' => true]);
$this->pages->saveDraft('page-1', $wild, 0);
self::assertSame(end($wild['blocks']), end($this->pages->findForEditor('page-1')['document']['blocks']));
// An: dieselben Eigenschaften werden abgewiesen, gültige durchgelassen.
$this->plugins->activate('vergleich');
$version = $this->pages->findForEditor('page-1')['version'];
try {
$this->pages->saveDraft('page-1', $wild, $version);
self::fail('Ein Skript als Bildkennung muss abgewiesen werden.');
} catch (InvalidDocument $e) {
self::assertStringContainsString('„Bild vorher“ muss ein Bild aus der Mediathek sein.', $e->getMessage());
}
$faelle = [
[['position' => 4], 'liegt zwischen 5 und 95'],
[['before' => 'kein-bild'], 'muss ein Bild aus der Mediathek sein'],
[['rounded' => 'ja'], 'ist an oder aus'],
[['labelBefore' => str_repeat('x', 41)], 'auf 40 Zeichen begrenzt'],
];
foreach ($faelle as [$props, $meldung]) {
try {
$this->pages->saveDraft('page-1', $this->vergleich($props), $version);
self::fail(json_encode($props).' muss abgewiesen werden.');
} catch (InvalidDocument $e) {
self::assertStringContainsString($meldung, $e->getMessage());
}
}
$gut = $this->vergleich(['before' => $bild['id'], 'after' => $bild['id'], 'position' => 30, 'labelBefore' => 'Alt', 'labelAfter' => 'Neu', 'alt' => 'Fassade', 'rounded' => false]);
$this->pages->saveDraft('page-1', $gut, $version);
self::assertSame(end($gut['blocks'])['props'], end($this->pages->findForEditor('page-1')['document']['blocks'])['props']);
}
public function testAnActivePluginRendersItsBlockWithAssetsAndAnInactiveOneLeavesAComment(): void
{
$bild = $this->uploadImage();
$document = $this->vergleich(['before' => $bild['id'], 'after' => $bild['id'], 'position' => 30, 'labelBefore' => 'Alt', 'labelAfter' => 'Neu', 'alt' => 'Fassade vor und nach dem Anstrich', 'rounded' => true]);
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
// Aus: nur der Kommentar, keine Datei, kein Skript erlaubt.
$aus = $this->request('/');
self::assertStringContainsString('<!-- Block v1 (vergleich.bildvergleich) wird von dieser Installation nicht dargestellt. -->', $aus->getContent());
self::assertStringNotContainsString('plugin-assets', $aus->getContent());
self::assertStringContainsString("script-src 'none'", (string) $aus->headers->get('Content-Security-Policy'));
$this->plugins->activate('vergleich');
$an = $this->request('/');
$html = $an->getContent();
self::assertStringContainsString('<div id="v1" class="block-bildvergleich bildvergleich-rund bildvergleich-griff-pfeile" data-bildvergleich data-position="30">', $html);
self::assertStringContainsString('alt="Fassade vor und nach dem Anstrich"', $html);
self::assertStringContainsString('<input type="range" min="0" max="100" value="30" step="1">', $html);
self::assertStringContainsString('<link rel="stylesheet" href="/plugin-assets/vergleich/assets/bildvergleich.css?v=1.1.0">', $html);
self::assertStringContainsString('<script src="/plugin-assets/vergleich/assets/bildvergleich.js?v=1.1.0" defer></script>', $html);
self::assertStringContainsString("script-src 'self'", (string) $an->headers->get('Content-Security-Policy'));
self::assertSame(1, substr_count($html, 'bildvergleich.css'), 'Jede Datei einmal, auch wenn der Baustein mehrfach vorkäme.');
// Die Dateien kommen über die eigene Route, mit Typ und langer Haltbarkeit; anderes nicht.
$css = $this->request('/plugin-assets/vergleich/assets/bildvergleich.css');
self::assertSame(200, $css->getStatusCode());
self::assertSame('text/css; charset=UTF-8', $css->headers->get('Content-Type'));
self::assertStringContainsString('immutable', (string) $css->headers->get('Cache-Control'));
self::assertSame(404, $this->request('/plugin-assets/vergleich/plugin.json')->getStatusCode(), 'Das Manifest ist keine öffentliche Datei.');
self::assertSame(404, $this->request('/plugin-assets/vergleich/assets/../plugin.json')->getStatusCode());
self::assertSame(404, $this->request('/plugin-assets/vergleich/blocks/bildvergleich.html.twig')->getStatusCode());
// Ohne Bilder rendert die Vorlage einen leeren Platzhalter und keine Bilder von anderswo.
$this->plugins->deactivate('vergleich');
self::assertSame(404, $this->request('/plugin-assets/vergleich/assets/bildvergleich.css')->getStatusCode(), 'Die Dateien eines ausgeschalteten Plugins gibt es nicht.');
}
public function testDesignTokensAndOwnStylesApplyToPluginBlocks(): void
{
$this->plugins->activate('vergleich');
$document = $this->vergleich(['position' => 50]);
$letzter = array_key_last($document['blocks']);
$document['blocks'][$letzter]['layout'] = ['design' => ['desktop' => ['marginTop' => 'large']], 'effects' => ['entrance' => 'fade', 'delay' => 'none']];
$document['blocks'][$letzter]['style'] = ['desktop' => ['maxWidth' => '600px']];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('class="block-bildvergleich bildvergleich-rund bildvergleich-griff-pfeile d-desktop-marginTop-large fx-entrance-fade fx-delay-none"', $html);
self::assertStringContainsString('#v1{', $html);
}
public function testSettingsAreValidatedStoredAndHandedToTemplatesAndRoutes(): void
{
$this->plugins->activate('vergleich');
self::assertSame(['labelBefore' => 'Vorher', 'labelAfter' => 'Nachher', 'handle' => 'pfeile'], $this->plugins->settingsOf('vergleich'), 'Ohne gespeicherte Werte gelten die Vorgaben des Manifests.');
// Aus der Verwaltung kommen Zeichenketten; ein leeres Kästchen fehlt ganz.
$this->plugins->saveSettings('vergleich', ['labelBefore' => ' Alt ', 'labelAfter' => 'Neu', 'handle' => 'punkt', 'fremd' => 'weg']);
self::assertSame(['labelBefore' => 'Alt', 'labelAfter' => 'Neu', 'handle' => 'punkt'], (new PluginRegistry($this->db, $this->projectDir))->settingsOf('vergleich'));
try {
$this->plugins->saveSettings('vergleich', ['handle' => 'kugel']);
self::fail('Ein Wert außerhalb der Auswahl muss abgewiesen werden.');
} catch (\InvalidArgumentException $e) {
self::assertStringContainsString('Griff des Reglers', $e->getMessage());
}
// Die Vorlage nimmt die Einstellung als Standard, wenn der Baustein keine eigene hat.
$bild = $this->uploadImage();
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $this->vergleich(['before' => $bild['id'], 'after' => $bild['id']]), 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('bildvergleich-griff-punkt', $html);
self::assertStringContainsString('>Alt</span>', $html);
self::assertStringContainsString('>Neu</span>', $html);
// Die eigene Adresse antwortet mit den Einstellungen als JSON - und nur, solange das Plugin an ist.
$daten = $this->request('/vergleich/daten?stadt=Freudenstadt&<x>=1');
self::assertSame(200, $daten->getStatusCode());
self::assertSame('application/json', $daten->headers->get('Content-Type'));
self::assertSame('noindex', $daten->headers->get('X-Robots-Tag'));
self::assertStringContainsString("script-src 'none'", (string) $daten->headers->get('Content-Security-Policy'));
$json = json_decode((string) $daten->getContent(), true);
self::assertSame(['before' => 'Alt', 'after' => 'Neu'], $json['labels']);
self::assertSame(['stadt' => 'Freudenstadt'], $json['echo'], 'Nur saubere Parameternamen erreichen die Vorlage.');
$this->plugins->deactivate('vergleich');
self::assertSame(404, $this->request('/vergleich/daten')->getStatusCode());
}
public function testAContainerBlockRendersItsChildrenAndOnlyTheAllowedOnes(): void
{
$this->plugins->activate('vergleich');
$bild = $this->uploadImage();
$kind = static fn (string $id) => ['id' => $id, 'type' => 'vergleich.bildvergleich', 'props' => ['before' => $bild['id'], 'after' => $bild['id'], 'position' => 40]];
$document = DemoContent::starter('Galerie');
$document['schemaVersion'] = 23;
$document['blocks'][] = ['id' => 'g', 'type' => 'vergleich.galerie', 'props' => ['columns' => '3', 'title' => 'Unsere Arbeiten'], 'children' => [$kind('k1'), $kind('k2')]];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('<div id="g" class="block-vergleichsgalerie vergleichsgalerie-3">', $html);
self::assertStringContainsString('<h3 class="vergleichsgalerie-titel">Unsere Arbeiten</h3>', $html);
self::assertSame(2, substr_count($html, 'class="block-bildvergleich'), 'Die Kinder werden vom Kern gerendert und der Vorlage übergeben.');
self::assertSame(1, substr_count($html, 'bildvergleich.css'), 'Stylesheet einmal, obwohl zwei Bausteine es nennen.');
// Ein fremdes Kind wird beim Speichern abgewiesen, ebenso Kinder in einem Nicht-Container.
$version = $this->pages->findForEditor('page-1')['version'];
$fremd = $document;
$fremd['blocks'][count($fremd['blocks']) - 1]['children'][] = ['id' => 't', 'type' => 'text', 'props' => ['text' => 'Hallo']];
try {
$this->pages->saveDraft('page-1', $fremd, $version);
self::fail('Ein Text gehört nicht in die Vergleichsgalerie.');
} catch (InvalidDocument $e) {
self::assertStringContainsString('passt nur: vergleich.bildvergleich', $e->getMessage());
}
$keinContainer = $this->vergleich(['position' => 50]);
$keinContainer['blocks'][count($keinContainer['blocks']) - 1]['children'] = [['id' => 't', 'type' => 'text', 'props' => ['text' => 'Hallo']]];
try {
$this->pages->saveDraft('page-1', $keinContainer, $version);
self::fail('Ein Bildvergleich nimmt keine Kinder auf.');
} catch (InvalidDocument $e) {
self::assertStringContainsString('nimmt keine Bausteine auf', $e->getMessage());
}
// Der Editor erfährt Container und erlaubte Kinder, und die Vorlage mit ihren Bausteinen.
$editor = array_column($this->plugins->forEditor(), null, 'type');
self::assertTrue($editor['vergleich.galerie']['container']);
self::assertSame(['vergleich.bildvergleich'], $editor['vergleich.galerie']['children']);
self::assertFalse($editor['vergleich.bildvergleich']['container']);
$presets = $this->plugins->presetsForEditor();
self::assertSame('plugin:vergleich:abschnitt', $presets[0]['id']);
self::assertSame('section', $presets[0]['blocks'][0]['type']);
}
public function testSwitchingAPluginOnAndOffLeavesTheDesignAlone(): void
{
// Ein Plugin bringt kein Designpaket mehr mit: Farben, Schriften und Buttons gehören der
// Website und ändern sich nur unter Design. Ein Schalter darf daran nichts drehen.
$theme = new \App\Content\Theme($this->db);
$vorher = $theme->load();
$document = $this->vergleich(['position' => 50]);
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$this->plugins->activate('vergleich');
self::assertSame($vorher, (new \App\Content\Theme($this->db))->load(), 'Einschalten färbt die Website nicht um.');
$this->plugins->deactivate('vergleich');
self::assertSame($vorher, (new \App\Content\Theme($this->db))->load(), 'Ausschalten lässt das Design ebenso in Ruhe.');
// Und die Inhalte bleiben in beiden Richtungen stehen.
self::assertSame(end($document['blocks']), end($this->pages->findPublished('home')['document']['blocks']));
}
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;
}
}
}
}
}