Files
KantSites/tests/BacklogTest.php
T
kantarciandClaude Fable 5.2 b08862b139 Back up unsaved editor work locally, structure the settings, fix archive accessibility
- The editor keeps unsaved changes in the browser and offers them
  after a crash, a closed tab or an expired session. No timed save on
  the server: every save is a full revision.
- The save status stays visible in narrow windows.
- Settings get a sticky section bar, two folded groups and a save bar
  that stays in view.
- The admin post list fetches terms and images once, not per post.
- Archive post titles are second-level headings, the archive head
  keeps full text contrast, the skip link sits above a sticky header,
  a linked image without alternative text names its link.
- The editor check starts through the dev router like the others.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 13:01:23 +01:00

406 lines
24 KiB
PHP

<?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\Domain\StaleWrite;
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.');
// Auf die Hauptüberschrift folgt im Archiv die zweite Ebene, nicht die dritte - sonst
// springt die Gliederung, und Lighthouse zieht dafür Punkte bei der Barrierefreiheit ab.
self::assertMatchesRegularExpression('#<div class="post-body">\s*<h2><a href="/[a-z0-9-]+">#', $html);
self::assertStringNotContainsString('<h3><a', $html);
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' => FormRepository::stamp('kontakt', $issued, 'test-only'), 'email' => '', 'datenschutz' => 'ja']);
self::assertSame(422, $antwort->getStatusCode());
self::assertStringContainsString('Dieses Feld wird benötigt.', $antwort->getContent());
}
public function testAPageWhoseStoredCssNoLongerPassesStaysReachableAndEditable(): void
{
// Gespeichert wurde, was beim Speichern galt. Ist eine Regel inzwischen strenger, fällt das
// CSS dieses einen Blocks weg (ADR 116) - die Seite antwortet nicht mit 500 und lässt sich
// zum Reparieren öffnen. Vorher warf schon das Lesen.
$document = DemoContent::starter('Altbestand');
$document['blocks'][] = ['id' => 'alt-text', 'type' => 'text', 'props' => ['text' => 'Bleibt lesbar'], 'css' => 'color: red;'];
$this->publishHome($document);
$alt = '& ~ p { color: red }';
try {
(new DocumentValidator())->validate([...$document, 'schemaVersion' => 23, 'blocks' => [...array_slice($document['blocks'], 0, -1), ['id' => 'alt-text', 'type' => 'text', 'props' => ['text' => 'Bleibt lesbar'], 'css' => $alt]]], writing: true);
self::fail('Dieses CSS muss beim Speichern abgelehnt werden, sonst prüft der Test nichts.');
} catch (InvalidDocument) {
}
foreach ($this->db->fetchAllAssociative('SELECT id, document FROM revision WHERE page_id = ?', ['page-1']) as $fassung) {
$this->db->update('revision', ['document' => str_replace('color: red;', $alt, $fassung['document'])], ['id' => $fassung['id']]);
}
$antwort = $this->request('/');
self::assertSame(200, $antwort->getStatusCode());
self::assertStringContainsString('Bleibt lesbar', $antwort->getContent());
self::assertStringNotContainsString('~ p', $antwort->getContent(), 'Das abgelehnte CSS wird nicht ausgegeben.');
$entwurf = $this->pages->findForEditor('page-1');
self::assertSame($alt, end($entwurf['document']['blocks'])['css'], 'Im Editor steht es noch da und lässt sich korrigieren.');
// Speichern verlangt weiter gültiges CSS.
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $entwurf['document'], $entwurf['version']);
}
public function testSettingsAndThemeRefuseAWriteFromAnOutdatedForm(): void
{
// Zwei Admins haben dasselbe Formular offen. Der zweite überschrieb bisher still, was der
// erste gespeichert hatte. Jetzt trägt das Formular eine Kennung des geladenen Stands.
$werte = ['legalName' => 'Erste GmbH'];
$geladen = $this->site->stamp();
$this->site->save($werte, $geladen);
self::assertNotSame($geladen, $this->site->stamp());
try {
$this->site->save([...$werte, 'legalName' => 'Zweite GmbH'], $geladen);
self::fail('Der veraltete Stand muss abgelehnt werden.');
} catch (StaleWrite) {
}
self::assertSame('Erste GmbH', $this->site->load()['legalName']);
$this->site->save([...$werte, 'legalName' => 'Zweite GmbH'], $this->site->stamp());
self::assertSame('Zweite GmbH', $this->site->load()['legalName']);
$theme = new Theme($this->db);
$stand = $theme->load();
$geladen = $theme->stamp();
$theme->save($stand['colors'], $stand['fonts'], [...$stand['base'], 'linkUnderline' => null], null, $geladen);
try {
$theme->save($stand['colors'], $stand['fonts'], $stand['base'], null, $geladen);
self::fail('Der veraltete Stand muss abgelehnt werden.');
} catch (StaleWrite) {
}
self::assertFalse($theme->load()['base']['linkUnderline'], 'Die erste Änderung bleibt.');
}
public function testAFormInTheFooterCanBeSentFromAnArchive(): 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'], 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'));
$this->post('erster', 'Erster Beitrag', '2026-01-01 10:00', ['Neues']);
self::assertStringContainsString('name="page" value="beitraege"', $this->request('/beitraege')->getContent());
// Ein Archiv ist keine gespeicherte Seite. Vorher endete das Absenden hier in einer 404,
// bevor irgendetwas gespeichert oder verschickt war.
$body = fn (string $page, string $email): array => ['page' => $page, 'block' => 'fuss-formular', 'issued' => (string) (time() - 10), 'issuedSignature' => FormRepository::stamp('kontakt', time() - 10, 'test-only'), 'email' => $email, 'datenschutz' => 'ja'];
$fehler = $this->request('/formular/kontakt', 'POST', $body('beitraege', ''));
self::assertSame(422, $fehler->getStatusCode());
self::assertStringContainsString('Dieses Feld wird benötigt.', $fehler->getContent());
self::assertStringContainsString('Erster Beitrag', $fehler->getContent(), 'Die Meldung steht im Archiv, nicht auf einer Fehlerseite.');
foreach (['beitraege', 'kategorie/neues'] as $n => $archiv) {
$antwort = $this->request('/formular/kontakt', 'POST', $body($archiv, 'kunde'.$n.'@example.org'));
self::assertSame(303, $antwort->getStatusCode());
self::assertSame('/'.$archiv.'?gesendet=kontakt#fuss-formular', $antwort->headers->get('Location'));
self::assertSame($n + 1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'), 'Die Anfrage ist gespeichert.');
}
self::assertSame(200, $this->request('/beitraege?gesendet=kontakt')->getStatusCode());
self::assertSame(404, $this->request('/formular/kontakt', 'POST', $body('gibt-es-nicht', 'x@example.org'))->getStatusCode(), 'Eine unbekannte Seite bleibt eine unbekannte Seite.');
}
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;
}
}
}
}
}