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:
1 parent
66ff68abf8
commit
c8a8fa7460
74 files changed
+5352
-243
No files matched your search
@@ -256,7 +256,9 @@ final class ArchiveUpdateTest extends TestCase
|
||||
|
||||
// public/ wird Datei für Datei zusammengeführt statt ersetzt, die drei anderen sind
|
||||
// Beiwerk, das eine laufende Installation nicht liest.
|
||||
$anders = ['public', 'README.md', 'LICENSE', '.env.example'];
|
||||
// plugins/ ebenso Datei für Datei: mitgelieferte Plugins werden aktualisiert, ein vom
|
||||
// Betreiber installiertes bleibt stehen (ADR 102).
|
||||
$anders = ['public', 'plugins', 'README.md', 'LICENSE', '.env.example'];
|
||||
foreach ($liste($mit[1]) as $eintrag) {
|
||||
self::assertContains(
|
||||
$eintrag,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1969,7 +1969,6 @@ final class CmsTest extends TestCase
|
||||
yield 'iconcard with unsafe link' => $wrap(['id' => 'x', 'type' => 'iconcard', 'props' => ['icon' => 'app', 'title' => 'T', 'text' => '', 'href' => 'javascript:alert(1)']]);
|
||||
yield 'rating out of range' => $wrap(['id' => 'x', 'type' => 'quote', 'props' => ['text' => 'T', 'author' => 'A', 'role' => '', 'rating' => 6]]);
|
||||
yield 'rating not a number' => $wrap(['id' => 'x', 'type' => 'quote', 'props' => ['text' => 'T', 'author' => 'A', 'role' => '', 'rating' => '5']]);
|
||||
yield 'empty contact block' => $wrap(['id' => 'x', 'type' => 'contact', 'props' => ['phone' => '', 'email' => '', 'address' => '']]);
|
||||
yield 'invalid phone' => $wrap(['id' => 'x', 'type' => 'contact', 'props' => ['phone' => 'ruf mich an', 'email' => '', 'address' => '']]);
|
||||
yield 'invalid email' => $wrap(['id' => 'x', 'type' => 'contact', 'props' => ['phone' => '', 'email' => 'keine-adresse', 'address' => '']]);
|
||||
yield 'unknown spacer size' => $wrap(['id' => 'x', 'type' => 'spacer', 'props' => ['size' => 'gigantic']]);
|
||||
|
||||
@@ -160,6 +160,8 @@ final class EditorSecurityTest extends WebTestCase
|
||||
['publisher', 'GET', '/admin/system'], ['publisher', 'POST', '/admin/system/wartung'], ['publisher', 'POST', '/admin/system/sicherung'],
|
||||
['publisher', 'GET', '/admin/aufgaben'], ['publisher', 'POST', '/admin/aufgaben/ausfuehren'],
|
||||
['publisher', 'GET', '/admin/benutzer'], ['publisher', 'POST', '/admin/benutzer/neu'],
|
||||
// Ein Plugin bringt Code auf die Website: Administrator.
|
||||
['publisher', 'GET', '/admin/plugins'], ['publisher', 'POST', '/admin/plugins/vergleich/an'], ['publisher', 'POST', '/admin/plugins/vergleich/einstellungen'], ['publisher', 'POST', '/admin/plugins/nachtblau/design'],
|
||||
['publisher', 'POST', '/admin/pages/p1/delete'],
|
||||
// Alles, was sofort auf der Website steht: Veroeffentlicher.
|
||||
['editor', 'GET', '/admin/settings'], ['editor', 'POST', '/admin/settings/redirects'], ['editor', 'POST', '/admin/settings/rechtliches'], ['editor', 'POST', '/admin/settings/not-found'],
|
||||
@@ -180,7 +182,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
$crawler = $client->request('GET', '/admin');
|
||||
self::assertResponseIsSuccessful();
|
||||
$links = $crawler->filter('.nav-item')->each(static fn ($node): string => (string) $node->attr('href'));
|
||||
foreach (['/admin/partials', '/admin/menus', '/admin/forms', '/admin/theme', '/admin/settings', '/admin/system', '/admin/benutzer'] as $hidden) {
|
||||
foreach (['/admin/partials', '/admin/menus', '/admin/forms', '/admin/theme', '/admin/settings', '/admin/system', '/admin/plugins', '/admin/benutzer'] as $hidden) {
|
||||
self::assertNotContains($hidden, $links, $hidden.' gehoert nicht in die Navigation eines Redakteurs.');
|
||||
}
|
||||
self::assertContains('/admin/media', $links);
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
<?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\SiteSettings;
|
||||
use App\Domain\InvalidForm;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Kernel;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Formulare mit Schritten, Bedingungen und den Feldtypen Zahl, Optionsfelder und Mehrfachauswahl.
|
||||
*
|
||||
* Umut wollte für die Immobilienbewertung „kein Kontaktformular weiteres, sondern so ein
|
||||
* ausgebautes Form“ – den Preisfinder der laufenden Website: erst die Objektart, dann Felder je
|
||||
* Objektart, dann Kontakt und Zusammenfassung. Der Kern wurde dafür erweitert statt ein Plugin
|
||||
* zu bauen; diese Tests halten fest, was der Server dabei garantiert.
|
||||
*/
|
||||
final class FormStepsTest extends TestCase
|
||||
{
|
||||
private string $path;
|
||||
private Connection $db;
|
||||
private FormRepository $forms;
|
||||
private PageRepository $pages;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->path = tempnam(sys_get_temp_dir(), 'kant-form-');
|
||||
$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->pages = new PageRepository($this->db, new DocumentValidator(), $settings);
|
||||
$this->forms = new FormRepository($this->db);
|
||||
$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);
|
||||
}
|
||||
|
||||
/** Ein kleiner Preisfinder: Objektart, davon abhängige Eckdaten, ein bedingter Schritt, Kontakt. */
|
||||
private function preisfinder(): array
|
||||
{
|
||||
return [
|
||||
['name' => 'schritt_objekt', 'type' => 'step', 'label' => 'Ihre Immobilie', 'help' => 'Womit können wir helfen?'],
|
||||
['name' => 'immobilienart', 'type' => 'radio', 'label' => 'Objektart', 'required' => true, 'options' => ['Wohnung', 'Haus', 'Grundstück']],
|
||||
['name' => 'schritt_eckdaten', 'type' => 'step', 'label' => 'Eckdaten'],
|
||||
['name' => 'wohnflaeche', 'type' => 'number', 'label' => 'Wohnfläche', 'required' => true, 'min' => 10, 'max' => 2000, 'unit' => 'm²', 'showIf' => ['field' => 'immobilienart', 'values' => ['Wohnung', 'Haus']]],
|
||||
['name' => 'ausstattung', 'type' => 'checkboxes', 'label' => 'Ausstattung', 'required' => true, 'options' => ['Balkon', 'Garage', 'Keller'], 'showIfField' => 'immobilienart', 'showIfValues' => 'Wohnung, Haus'],
|
||||
['name' => 'schritt_grund', 'type' => 'step', 'label' => 'Grundstück', 'showIf' => ['field' => 'immobilienart', 'values' => ['Grundstück']]],
|
||||
['name' => 'flaeche', 'type' => 'number', 'label' => 'Grundstücksfläche', 'required' => true, 'min' => 10, 'unit' => 'm²'],
|
||||
['name' => 'schritt_kontakt', 'type' => 'step', 'label' => 'Kontakt'],
|
||||
['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true],
|
||||
['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Einverstanden'],
|
||||
];
|
||||
}
|
||||
|
||||
private function settings(): array
|
||||
{
|
||||
return ['recipient' => 'deeg@example.org', 'subject' => 'Bewertung', 'replyToField' => 'email', 'success' => 'Danke.', 'summary' => true];
|
||||
}
|
||||
|
||||
public function testStepsConditionsAndNumbersAreNormalisedWhenSaving(): void
|
||||
{
|
||||
$this->forms->save('preisfinder', 'Preisfinder', $this->preisfinder(), $this->settings(), 0);
|
||||
$form = $this->forms->findBySlug('preisfinder');
|
||||
$fields = array_column($form['fields'], null, 'name');
|
||||
|
||||
// Beide Schreibweisen der Bedingung landen in derselben gespeicherten Form.
|
||||
self::assertSame(['field' => 'immobilienart', 'values' => ['Wohnung', 'Haus']], $fields['wohnflaeche']['showIf']);
|
||||
self::assertSame(['field' => 'immobilienart', 'values' => ['Wohnung', 'Haus']], $fields['ausstattung']['showIf']);
|
||||
self::assertSame(['field' => 'immobilienart', 'values' => ['Grundstück']], $fields['schritt_grund']['showIf']);
|
||||
self::assertNull($fields['email']['showIf']);
|
||||
// Grenzen sind Zahlen, die Einheit bleibt; ein Schritt fragt nichts und ist nie Pflicht.
|
||||
self::assertEquals(10, $fields['wohnflaeche']['min']);
|
||||
self::assertEquals(2000, $fields['wohnflaeche']['max']);
|
||||
self::assertSame('m²', $fields['wohnflaeche']['unit']);
|
||||
self::assertNull($fields['flaeche']['max']);
|
||||
self::assertFalse($fields['schritt_objekt']['required']);
|
||||
self::assertTrue($form['settings']['summary']);
|
||||
self::assertTrue($this->forms->needsScript($form));
|
||||
|
||||
// Ein gewöhnliches Kontaktformular braucht das Skript nicht - es hat weder Schritte noch
|
||||
// Bedingungen, und ohne Zusammenfassung bleibt die Seite ohne JavaScript.
|
||||
$this->forms->save('kontakt', 'Kontakt', [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true]], ['recipient' => 'deeg@example.org'], 0);
|
||||
$kontakt = $this->forms->findBySlug('kontakt');
|
||||
self::assertFalse($this->forms->needsScript($kontakt));
|
||||
self::assertFalse($kontakt['settings']['summary']);
|
||||
self::assertNull($kontakt['fields'][0]['showIf'], 'Auch ohne Angabe kennt jedes Feld seine Schlüssel.');
|
||||
}
|
||||
|
||||
public function testABrokenDefinitionIsRefusedWithAReason(): void
|
||||
{
|
||||
$faelle = [
|
||||
'Bedingung auf ein unbekanntes Feld' => [
|
||||
['name' => 'a', 'type' => 'text', 'label' => 'A', 'showIfField' => 'art', 'showIfValues' => 'Haus'],
|
||||
],
|
||||
'Bedingung auf ein Feld, das erst danach kommt' => [
|
||||
['name' => 'a', 'type' => 'text', 'label' => 'A', 'showIfField' => 'art', 'showIfValues' => 'Haus'],
|
||||
['name' => 'art', 'type' => 'select', 'label' => 'Art', 'options' => ['Haus', 'Wohnung']],
|
||||
],
|
||||
'Bedingung auf ein Textfeld' => [
|
||||
['name' => 'art', 'type' => 'text', 'label' => 'Art'],
|
||||
['name' => 'a', 'type' => 'text', 'label' => 'A', 'showIfField' => 'art', 'showIfValues' => 'Haus'],
|
||||
],
|
||||
'Bedingung mit einem Wert, der keine Option ist' => [
|
||||
['name' => 'art', 'type' => 'select', 'label' => 'Art', 'options' => ['Haus', 'Wohnung']],
|
||||
['name' => 'a', 'type' => 'text', 'label' => 'A', 'showIfField' => 'art', 'showIfValues' => 'Schloss'],
|
||||
],
|
||||
'Bedingung ohne Wert' => [
|
||||
['name' => 'art', 'type' => 'select', 'label' => 'Art', 'options' => ['Haus', 'Wohnung']],
|
||||
['name' => 'a', 'type' => 'text', 'label' => 'A', 'showIfField' => 'art', 'showIfValues' => ''],
|
||||
],
|
||||
'Untergrenze über der Obergrenze' => [
|
||||
['name' => 'n', 'type' => 'number', 'label' => 'N', 'min' => '10', 'max' => '5'],
|
||||
],
|
||||
'Grenze, die keine Zahl ist' => [
|
||||
['name' => 'n', 'type' => 'number', 'label' => 'N', 'min' => 'zehn'],
|
||||
],
|
||||
'Optionsfelder mit einer Option' => [
|
||||
['name' => 'r', 'type' => 'radio', 'label' => 'R', 'options' => ['Ja']],
|
||||
],
|
||||
'Option mit Komma' => [
|
||||
['name' => 'r', 'type' => 'radio', 'label' => 'R', 'options' => ['ja, neu', 'ja, alt']],
|
||||
],
|
||||
'nur Schritte' => [
|
||||
['name' => 's', 'type' => 'step', 'label' => 'Schritt'],
|
||||
],
|
||||
'Bild für eine Option, die es nicht gibt' => [
|
||||
['name' => 'r', 'type' => 'radio', 'label' => 'R', 'options' => ['Haus', 'Wohnung'], 'images' => ['Schloss' => str_repeat('a', 32)]],
|
||||
],
|
||||
'Bild, das keine Kennung ist' => [
|
||||
['name' => 'r', 'type' => 'radio', 'label' => 'R', 'options' => ['Haus', 'Wohnung'], 'images' => 'Haus = bild.png'],
|
||||
],
|
||||
];
|
||||
foreach ($faelle as $fall => $fields) {
|
||||
try {
|
||||
$this->forms->save('f', 'F', $fields, ['recipient' => 'a@example.org'], 0);
|
||||
self::fail($fall.' wurde angenommen.');
|
||||
} catch (InvalidForm $e) {
|
||||
self::assertNotSame('', $e->getMessage(), $fall);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testOnlyFieldsWhoseConditionHoldsAreRequiredCheckedAndKept(): void
|
||||
{
|
||||
$this->forms->save('preisfinder', 'Preisfinder', $this->preisfinder(), $this->settings(), 0);
|
||||
$form = $this->forms->findBySlug('preisfinder');
|
||||
|
||||
// Ein Grundstück: Wohnfläche und Ausstattung gelten nicht - nicht verlangt, nicht
|
||||
// gespeichert, auch wenn der Browser etwas mitschickt. Der Grundstücksschritt gilt.
|
||||
$grund = $this->forms->check($form, ['immobilienart' => 'Grundstück', 'wohnflaeche' => '80', 'ausstattung' => ['Balkon'], 'flaeche' => '1250.00', 'email' => 'x@example.org', 'datenschutz' => 'ja']);
|
||||
self::assertSame([], $grund['errors']);
|
||||
self::assertSame('', $grund['values']['wohnflaeche']);
|
||||
self::assertSame('', $grund['values']['ausstattung']);
|
||||
self::assertSame('1250', $grund['values']['flaeche'], 'Eine Zahl wird ohne unnötige Stellen gespeichert.');
|
||||
self::assertArrayNotHasKey('schritt_objekt', $grund['values'], 'Ein Schritt ist keine Antwort.');
|
||||
|
||||
// Eine Wohnung: jetzt sind Wohnfläche und Ausstattung Pflicht, der Grundstücksschritt nicht.
|
||||
$leer = $this->forms->check($form, ['immobilienart' => 'Wohnung', 'email' => 'x@example.org', 'datenschutz' => 'ja']);
|
||||
self::assertSame('Dieses Feld wird benötigt.', $leer['errors']['wohnflaeche']);
|
||||
self::assertSame('Bitte wähle mindestens eine Option.', $leer['errors']['ausstattung']);
|
||||
self::assertArrayNotHasKey('flaeche', $leer['errors']);
|
||||
|
||||
// Zahlen mit Komma, außerhalb der Grenzen, keine Zahl; Mehrfachauswahl mit fremder Option.
|
||||
$wohnung = $this->forms->check($form, ['immobilienart' => 'Wohnung', 'wohnflaeche' => '85,5', 'ausstattung' => ['Balkon', 'Keller'], 'email' => 'x@example.org', 'datenschutz' => 'ja']);
|
||||
self::assertSame([], $wohnung['errors']);
|
||||
self::assertSame('85.5', $wohnung['values']['wohnflaeche']);
|
||||
self::assertSame('Balkon, Keller', $wohnung['values']['ausstattung']);
|
||||
self::assertSame('', $wohnung['values']['flaeche']);
|
||||
|
||||
$zuViel = $this->forms->check($form, ['immobilienart' => 'Haus', 'wohnflaeche' => '5000', 'ausstattung' => ['Garage', 'Pool'], 'email' => 'x@example.org', 'datenschutz' => 'ja']);
|
||||
self::assertSame('Bitte gib einen Wert zwischen 10 und 2000 an.', $zuViel['errors']['wohnflaeche']);
|
||||
self::assertSame('Bitte wähle nur aus den angebotenen Optionen.', $zuViel['errors']['ausstattung']);
|
||||
self::assertSame('Garage', $zuViel['values']['ausstattung'], 'Die fremde Option bleibt weg.');
|
||||
|
||||
$keineZahl = $this->forms->check($form, ['immobilienart' => 'Grundstück', 'flaeche' => 'groß', 'email' => 'x@example.org', 'datenschutz' => 'ja']);
|
||||
self::assertSame('Bitte gib eine Zahl an.', $keineZahl['errors']['flaeche']);
|
||||
$zuKlein = $this->forms->check($form, ['immobilienart' => 'Grundstück', 'flaeche' => '3', 'email' => 'x@example.org', 'datenschutz' => 'ja']);
|
||||
self::assertSame('Bitte gib mindestens 10 an.', $zuKlein['errors']['flaeche']);
|
||||
|
||||
// Eine Objektart, die es nicht gibt: abgelehnt, und nichts hängt davon ab.
|
||||
$fremd = $this->forms->check($form, ['immobilienart' => 'Schloss', 'email' => 'x@example.org', 'datenschutz' => 'ja']);
|
||||
self::assertSame('Bitte wähle eine der angebotenen Optionen.', $fremd['errors']['immobilienart']);
|
||||
self::assertArrayNotHasKey('wohnflaeche', $fremd['errors']);
|
||||
}
|
||||
|
||||
public function testAStepFormIsRenderedAsFieldsetsAndBringsItsScript(): void
|
||||
{
|
||||
$this->forms->save('preisfinder', 'Preisfinder', $this->preisfinder(), $this->settings(), 0);
|
||||
$document = DemoContent::starter('Bewertung');
|
||||
$document['schemaVersion'] = 23;
|
||||
$document['blocks'][] = ['id' => 'preis', 'type' => 'form', 'props' => ['form' => 'preisfinder']];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
|
||||
$antwort = $this->request('/');
|
||||
$html = $antwort->getContent();
|
||||
self::assertSame(200, $antwort->getStatusCode());
|
||||
// Schritte sind Abschnitte mit Titel; ohne Skript stehen sie untereinander.
|
||||
self::assertSame(4, substr_count($html, '<fieldset class="form-step"'));
|
||||
self::assertStringContainsString('<legend class="form-step-title" tabindex="-1">Ihre Immobilie</legend>', $html);
|
||||
self::assertStringContainsString('<p class="form-step-intro">Womit können wir helfen?</p>', $html);
|
||||
self::assertStringContainsString('data-steps data-summary>', $html);
|
||||
// Bedingungen stehen am Feld und am Schritt, für das Skript - der Server prüft sie selbst.
|
||||
self::assertStringContainsString('data-show-if="immobilienart" data-show-values="Wohnung|Haus"', $html);
|
||||
self::assertStringContainsString('<fieldset class="form-step" data-step data-show-if="immobilienart" data-show-values="Grundstück">', $html);
|
||||
// Optionsfelder, Mehrfachauswahl und Zahl mit Einheit.
|
||||
self::assertStringContainsString('type="radio" value="Wohnung" required', $html);
|
||||
self::assertStringContainsString('name="ausstattung[]" type="checkbox" value="Balkon"', $html);
|
||||
self::assertStringContainsString('data-required', $html);
|
||||
self::assertStringContainsString('type="number" step="any" inputmode="decimal"', $html);
|
||||
self::assertStringContainsString('min="10"', $html);
|
||||
self::assertStringContainsString('<span class="form-unit" aria-hidden="true">m²</span>', $html);
|
||||
// Das Skript kommt nur mit diesem Formular, und die Richtlinie erlaubt es dann.
|
||||
self::assertStringContainsString('<script src="/assets/form-steps.js?v=', $html);
|
||||
self::assertStringContainsString("script-src 'self'", (string) $antwort->headers->get('Content-Security-Policy'));
|
||||
|
||||
// Ein gewöhnliches Formular bleibt ohne Skript und ohne Erlaubnis dafür.
|
||||
$this->forms->save('kontakt', 'Kontakt', [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true]], ['recipient' => 'deeg@example.org'], 0);
|
||||
$schlicht = DemoContent::starter('Kontakt');
|
||||
$schlicht['schemaVersion'] = 23;
|
||||
$schlicht['blocks'][] = ['id' => 'kontakt', 'type' => 'form', 'props' => ['form' => 'kontakt']];
|
||||
$version = $this->pages->findForEditor('page-1')['version'];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $schlicht, $version), $version + 1);
|
||||
$zweite = $this->request('/');
|
||||
self::assertStringNotContainsString('form-steps.js', $zweite->getContent());
|
||||
self::assertStringNotContainsString('<fieldset', $zweite->getContent());
|
||||
self::assertStringContainsString("script-src 'none'", (string) $zweite->headers->get('Content-Security-Policy'));
|
||||
}
|
||||
|
||||
public function testAChoiceWithPicturesBecomesClickableCards(): void
|
||||
{
|
||||
// Umut zeigte den Solar-Rechner von Elektro Bernhard: „Wieviele Personen leben im
|
||||
// Haushalt“ als vier Bildkarten. Ein Bild je Option macht aus der Auswahl Karten;
|
||||
// das Kästchen bleibt darin, für Tastatur und Vorlesesoftware.
|
||||
$media = new MediaLibrary($this->db, $this->path.'-media');
|
||||
$file = tempnam(sys_get_temp_dir(), 'kant-option-');
|
||||
$image = imagecreatetruecolor(800, 600);
|
||||
imagepng($image, $file);
|
||||
imagedestroy($image);
|
||||
$bild = $media->upload(new \Symfony\Component\HttpFoundation\File\UploadedFile($file, 'haus.png', test: true));
|
||||
@unlink($file);
|
||||
|
||||
// Aus der Verwaltung kommt die Zuordnung als Zeilen, gespeichert wird sie als Paare.
|
||||
$this->forms->save('personen', 'Personen', [
|
||||
['name' => 'art', 'type' => 'radio', 'label' => 'Objektart', 'required' => true, 'options' => ['Haus', 'Wohnung'], 'images' => "Haus = {$bild['id']}\nWohnung = "],
|
||||
], ['recipient' => 'deeg@example.org'], 0);
|
||||
$form = $this->forms->findBySlug('personen');
|
||||
self::assertSame(['Haus' => $bild['id']], $form['fields'][0]['images']);
|
||||
|
||||
$document = DemoContent::starter('Personen');
|
||||
$document['schemaVersion'] = 23;
|
||||
$document['blocks'][] = ['id' => 'p', 'type' => 'form', 'props' => ['form' => 'personen']];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('form-choice form-choice-images', $html);
|
||||
self::assertSame(2, substr_count($html, 'class="form-choice-card"'));
|
||||
// Die Option mit Bild zeigt es in der kleinsten Fassung; die ohne Bild bleibt eine Karte mit Text.
|
||||
self::assertStringContainsString('<img src="'.$bild['variants'][0]['src'].'" alt="" width="480"', $html);
|
||||
self::assertStringContainsString('<span class="form-choice-text">Wohnung</span>', $html);
|
||||
self::assertSame(1, substr_count($html, '<img src="/media/'), 'Nur die Option mit Bild bekommt eines.');
|
||||
// Keine Schritte, keine Bedingungen: die Bildauswahl braucht kein Skript.
|
||||
self::assertStringNotContainsString('form-steps.js', $html);
|
||||
}
|
||||
|
||||
public function testASubmissionStoresOnlyWhatAppliedAndReRendersWithErrors(): void
|
||||
{
|
||||
$this->forms->save('preisfinder', 'Preisfinder', $this->preisfinder(), $this->settings(), 0);
|
||||
$document = DemoContent::starter('Bewertung');
|
||||
$document['schemaVersion'] = 23;
|
||||
$document['blocks'][] = ['id' => 'preis', 'type' => 'form', 'props' => ['form' => 'preisfinder']];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
|
||||
$issued = time() - 10;
|
||||
$grund = [
|
||||
'page' => 'home', 'block' => 'preis', 'issued' => (string) $issued, 'issuedSignature' => hash_hmac('sha256', (string) $issued, 'test-only'),
|
||||
'immobilienart' => 'Grundstück', 'wohnflaeche' => '99', 'ausstattung' => ['Balkon'], 'flaeche' => '640', 'email' => 'x@example.org', 'datenschutz' => 'ja',
|
||||
];
|
||||
$antwort = $this->request('/formular/preisfinder', 'POST', $grund);
|
||||
self::assertSame(303, $antwort->getStatusCode());
|
||||
self::assertSame('/?gesendet=preisfinder#preis', $antwort->headers->get('Location'));
|
||||
$gespeichert = json_decode((string) $this->db->fetchOne('SELECT data FROM form_submission'), true);
|
||||
self::assertSame('Grundstück', $gespeichert['immobilienart']);
|
||||
self::assertSame('640', $gespeichert['flaeche']);
|
||||
self::assertSame('', $gespeichert['wohnflaeche'], 'Was nicht galt, wird nicht gespeichert.');
|
||||
self::assertSame('', $gespeichert['ausstattung']);
|
||||
|
||||
// Eine Wohnung ohne Wohnfläche: die Seite kommt mit Meldungen zurück, die gewählte
|
||||
// Objektart bleibt angekreuzt, die Mehrfachauswahl behält ihre Kreuze.
|
||||
$wohnung = [...$grund, 'immobilienart' => 'Wohnung', 'wohnflaeche' => '', 'ausstattung' => ['Balkon', 'Keller'], 'flaeche' => ''];
|
||||
$abgelehnt = $this->request('/formular/preisfinder', 'POST', $wohnung);
|
||||
self::assertSame(422, $abgelehnt->getStatusCode());
|
||||
$html = $abgelehnt->getContent();
|
||||
self::assertStringContainsString('Bitte prüfe 1 Eingabe.', $html);
|
||||
self::assertStringContainsString('type="radio" value="Wohnung" checked required', $html);
|
||||
self::assertStringContainsString('value="Balkon" checked', $html);
|
||||
self::assertStringContainsString('value="Keller" checked', $html);
|
||||
self::assertStringContainsString('value="Garage">', $html);
|
||||
self::assertStringContainsString('Dieses Feld wird benötigt.', $html);
|
||||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'), 'Eine abgelehnte Eingabe wird nicht gespeichert.');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,4 +43,15 @@ final class ReleaseOnDisk extends Download
|
||||
|
||||
return $this->antworten[$url] ?? null;
|
||||
}
|
||||
|
||||
/** Wie get(), nur in eine Datei - der Updater laedt das Archiv so, ohne es in den Speicher zu nehmen. */
|
||||
public function toFile(string $url, string $path): bool
|
||||
{
|
||||
$inhalt = $this->get($url);
|
||||
if (null === $inhalt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false !== file_put_contents($path, $inhalt);
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,16 @@
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\EventSubscriber\InstallSubscriber;
|
||||
use App\EventSubscriber\TaskWorkerSubscriber;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Ops\Scheduler;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\TerminateEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
|
||||
/**
|
||||
* Der Aufgabenplan - und zwar die Eigenschaften, an denen er auf einem echten Hosting scheitern
|
||||
@@ -256,4 +262,32 @@ final class SchedulerTest extends TestCase
|
||||
self::assertNotSame($one, $two);
|
||||
self::assertSame($one, Scheduler::cronToken('geheimnis-eins'));
|
||||
}
|
||||
|
||||
public function testTheWorkerNeverShortensAProcessWithoutATimeLimit(): void
|
||||
{
|
||||
// `set_time_limit()` setzt, es verlaengert nicht. Auf einem Prozess ohne Grenze - Konsole,
|
||||
// Testlauf, Hoster mit max_execution_time = 0 - machte der Worker aus "unbegrenzt"
|
||||
// achtzig Sekunden, und danach starb der Prozess irgendwo weit weg von den Aufgaben.
|
||||
$vorher = ini_get('max_execution_time');
|
||||
ini_set('max_execution_time', '0');
|
||||
file_put_contents($this->projectDir.'/'.InstallSubscriber::MARKER, 'test
|
||||
');
|
||||
$task = new SpyTask('a');
|
||||
$scheduler = $this->scheduler([$task]);
|
||||
$scheduler->sync();
|
||||
$this->makeDue('a');
|
||||
// Die Zustandsdatei kennt den alten Zeitpunkt; ohne sie fragt der naechste Aufruf die Datenbank.
|
||||
@unlink($this->projectDir.'/'.Scheduler::STATE_FILE);
|
||||
|
||||
$subscriber = new TaskWorkerSubscriber($scheduler, $this->projectDir, 'admin');
|
||||
$subscriber->onTerminate(new TerminateEvent(
|
||||
$this->createMock(HttpKernelInterface::class),
|
||||
Request::create('/'),
|
||||
new Response(),
|
||||
));
|
||||
|
||||
self::assertSame(1, $task->runs, 'Die faellige Aufgabe soll gelaufen sein.');
|
||||
self::assertSame('0', (string) ini_get('max_execution_time'), 'Ohne Grenze bleibt ohne Grenze.');
|
||||
ini_set('max_execution_time', (string) $vorher);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ use DoctrineMigrations\Version20260912001200;
|
||||
use DoctrineMigrations\Version20260912001300;
|
||||
use DoctrineMigrations\Version20260913000100;
|
||||
use DoctrineMigrations\Version20260914000100;
|
||||
use DoctrineMigrations\Version20260915000100;
|
||||
use DoctrineMigrations\Version20260916000100;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
@@ -49,6 +51,8 @@ final class TestSchema
|
||||
Version20260912001300::class,
|
||||
Version20260913000100::class,
|
||||
Version20260914000100::class,
|
||||
Version20260915000100::class,
|
||||
Version20260916000100::class,
|
||||
] as $migration) {
|
||||
(new $migration($db, new NullLogger()))->up($schema);
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ final class UpdaterTest extends TestCase
|
||||
|
||||
// The order matters, so it is asserted.
|
||||
self::assertSame(
|
||||
['Vorprüfung', 'Ziel bestimmen', 'Sicherung', 'Wartungszustand', 'Code wechseln', 'Fassung prüfen', 'Abhängigkeiten', 'Migration', 'Cache', 'Rauchtest', 'Fertig'],
|
||||
['Vorprüfung', 'Ziel bestimmen', 'Sperre', 'Sicherung', 'Wartungszustand', 'Code wechseln', 'Fassung prüfen', 'Abhängigkeiten', 'Migration', 'Cache', 'Rauchtest', 'Fertig'],
|
||||
array_column($result['steps'], 'step'),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { EditorHistory, createBlock, locate, insertBlock, insertBlockAt, moveBlockTo, dropList, insertionTarget, insertRowAt, CATEGORIES, labels as blockLabels, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, insertRow, blockPath, insertMedia, setEffect, isPluginBlock, blockLabel, blockIcon, canContain, isContainer, setStyle, effectiveStyle, hoverStyle, resetStyleDevice, clearStyle, copyStyle, SCHEMA_VERSION } from '../public/assets/editor-model.js';
|
||||
import { EditorHistory, createBlock, locate, insertBlock, insertBlockAt, moveBlockTo, dropList, insertionTarget, insertRowAt, CATEGORIES, labels as blockLabels, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, insertRow, blockPath, insertMedia, setEffect, isPluginBlock, blockLabel, blockIcon, canContain, isContainer, setStyle, effectiveStyle, hoverStyle, resetStyleDevice, clearStyle, copyStyle, SCHEMA_VERSION, registerPlugins, pluginDefinition } from '../public/assets/editor-model.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { designCss } from '../scripts/build-design.mjs';
|
||||
import { relevantProperty } from '../public/assets/editor-model.js';
|
||||
@@ -600,3 +600,34 @@ test('every module imported by another one is listed in the import map', () => {
|
||||
const fehlend = [...importiert].filter(name => !eingetragen.has(name)).sort();
|
||||
assert.deepEqual(fehlend, [], `ohne Fassungskennung in der Importkarte: ${fehlend.join(', ')}`);
|
||||
});
|
||||
|
||||
test('registered plugin blocks get labels, defaults, container rules and presets with fresh ids', () => {
|
||||
registerPlugins([
|
||||
{ type: 'vergleich.bildvergleich', label: 'Bildvergleich', icon: '⇔', container: false, children: null, props: { position: { kind: 'number', default: 50 }, before: { kind: 'media', default: '' } } },
|
||||
{ type: 'vergleich.galerie', label: 'Vergleichsgalerie', icon: '⊞', container: true, children: ['vergleich.bildvergleich'], props: {} },
|
||||
], [{ id: 'plugin:vergleich:abschnitt', label: 'Abschnitt', plugin: 'Bildvergleich', blocks: [{ id: 'fix', type: 'section', children: [{ id: 'fix2', type: 'vergleich.bildvergleich', props: { position: 30 } }] }] }]);
|
||||
const bild = createBlock('vergleich.bildvergleich');
|
||||
assert.deepEqual(bild.props, { position: 50, before: '' });
|
||||
assert.equal('children' in bild, false);
|
||||
const galerie = createBlock('vergleich.galerie');
|
||||
assert.deepEqual(galerie.children, []);
|
||||
assert.equal(isContainer(galerie), true);
|
||||
assert.equal(canContain(galerie, bild), true, 'the manifest allows the comparison');
|
||||
assert.equal(canContain(galerie, { type: 'text' }), false, 'and nothing else');
|
||||
assert.equal(canContain(bild, { type: 'text' }), false, 'a plain plugin block takes no children');
|
||||
assert.equal(pluginDefinition(galerie).label, 'Vergleichsgalerie');
|
||||
|
||||
const document = { schemaVersion: 4, title: 'P', description: 'D', blocks: [{ id: 's1', type: 'section', children: [] }] };
|
||||
const first = insertPreset(document, 'plugin:vergleich:abschnitt', 's1');
|
||||
const second = insertPreset(document, 'plugin:vergleich:abschnitt', 's1');
|
||||
assert.notEqual(first, second, 'every insertion gets its own ids');
|
||||
assert.notEqual(first, 'fix');
|
||||
assert.equal(document.blocks.length, 3);
|
||||
assert.equal(locate(document.blocks, first).block.children[0].props.position, 30);
|
||||
assert.notEqual(locate(document.blocks, first).block.children[0].id, locate(document.blocks, second).block.children[0].id);
|
||||
|
||||
// Switched off again: the type is unknown once more and the block a placeholder.
|
||||
registerPlugins([], []);
|
||||
assert.equal(pluginDefinition(bild), null);
|
||||
assert.equal(isContainer(galerie), false);
|
||||
});
|
||||
Reference in new issue
Block a user