Files
KantSites/tests/TaskScreenTest.php
T
kantarciandClaude Fable 5.2 7d0369bada Serve images without PHP, add the 720 and 1280 steps, trim two hot paths
- Image widths are 480, 720, 960, 1280, 1600 and 2400. A phone needed
  721 pixels and got 960. Existing images receive the new steps from
  a task; stored pages pick them up at render time, so no document is
  rewritten. Mobile backgrounds use 720.
- A variant served once is mirrored to public/media, and a rewrite rule
  lets the web server deliver it from there. var/media stays the source.
- The hero preload follows the image each device really shows.
- The post navigation reads the order and then two posts instead of
  up to 500 documents.

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

169 lines
7.8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Tests;
use App\Infrastructure\ConnectionFactory;
use App\Kernel;
use App\Ops\Scheduler;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* Die drei Auslöser, wie sie über HTTP erreichbar sind.
*
* Geprüft wird nicht das Aussehen, sondern wer was darf: Der Bildschirm gehört Administratoren,
* der Takt der Verwaltung braucht eine Anmeldung und ein gültiges Formular, und der URL-Cronjob
* hängt an einem Schlüssel, der sich nicht erraten lässt. Jede dieser Stellen startet Arbeit auf
* dem Server – eine offene wäre eine Einladung.
*/
final class TaskScreenTest extends WebTestCase
{
private const FIXTURE_PASSWORD = 'Fixture-only-password';
private const SECRET = 'isolated-test-secret';
private Connection $db;
private string $path;
private array $previous;
protected static function getKernelClass(): string
{
return Kernel::class;
}
protected function setUp(): void
{
$this->path = tempnam(sys_get_temp_dir(), 'kant-tasks-web-');
$url = 'sqlite:///'.str_replace('\\', '/', $this->path);
$this->previous = [$_SERVER, $_ENV];
foreach (['APP_SECRET' => self::SECRET, 'DATABASE_URL' => $url, 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media'] as $key => $value) {
$_SERVER[$key] = $_ENV[$key] = $value;
}
$this->db = ConnectionFactory::create($url);
TestSchema::build($this->db);
$this->db->insert('website', ['id' => 'default', 'name' => 'Test', 'default_locale' => 'de', 'theme' => '{}']);
foreach (['admin' => 'ROLE_ADMIN', 'editor' => 'ROLE_EDITOR'] as $username => $role) {
$this->db->insert('cms_user', [
'id' => bin2hex(random_bytes(8)),
'username' => $username,
'password_hash' => password_hash(self::FIXTURE_PASSWORD, PASSWORD_BCRYPT, ['cost' => 4]),
'roles' => json_encode([$role], JSON_THROW_ON_ERROR),
'created_at' => gmdate('Y-m-d H:i:s'),
'password_changed_at' => gmdate('Y-m-d H:i:s'),
]);
}
}
protected function tearDown(): void
{
parent::tearDown();
$this->db->close();
gc_collect_cycles();
@unlink($this->path);
[$_SERVER, $_ENV] = $this->previous;
}
public function testTheScreenNamesEveryTaskAndTheWayWithoutVisitors(): void
{
$client = $this->login();
$client->request('GET', '/admin/aufgaben');
self::assertSame(200, $client->getResponse()->getStatusCode());
$html = (string) $client->getResponse()->getContent();
foreach (['publish-due', 'stats-prune', 'backup', 'update-check', 'cleanup', 'zentrale'] as $task) {
self::assertStringContainsString($task, $html);
}
// Die Einschränkung gehört auf den Bildschirm und nicht in die Dokumentation: wer sie
// nicht kennt, sucht den Fehler später an der falschen Stelle.
self::assertStringContainsString('Ohne Besuch und ohne geöffnete Verwaltung passiert nichts.', $html);
self::assertStringContainsString('/cms-cron/'.Scheduler::cronToken(self::SECRET), $html);
// Und der Plan steht danach in der Datenbank - eine Zeile je Aufgabe, nicht mehr.
self::assertSame(7, (int) $this->db->fetchOne('SELECT COUNT(*) FROM scheduled_task'));
}
public function testStartingWorkNeedsAnAdministratorAndAValidForm(): void
{
self::ensureKernelShutdown();
$anonymous = self::createClient();
$anonymous->followRedirects(false);
$anonymous->request('POST', '/admin/aufgaben/ausfuehren');
self::assertSame('http://localhost/admin/login', $anonymous->getResponse()->headers->get('Location'));
$editor = $this->login('editor');
$editor->request('GET', '/admin/aufgaben');
self::assertSame(403, $editor->getResponse()->getStatusCode(), 'Eine Redakteurin verwaltet den Plan nicht.');
$admin = $this->login();
$admin->request('POST', '/admin/aufgaben/ausfuehren', ['_csrf_token' => 'erfunden', 'name' => 'publish-due']);
$admin->followRedirect();
self::assertStringContainsString('Sitzung ist abgelaufen', (string) $admin->getResponse()->getContent());
self::assertNull($this->db->fetchOne('SELECT last_status FROM scheduled_task WHERE name = ?', ['publish-due']));
}
public function testAnAdministratorCanRunATaskFromTheScreen(): void
{
$client = $this->login();
$crawler = $client->request('GET', '/admin/aufgaben');
$client->submit($crawler->filter('#run-publish-due')->form());
$client->followRedirect();
self::assertStringContainsString('Geplante Veröffentlichungen', (string) $client->getResponse()->getContent());
$row = $this->db->fetchAssociative('SELECT * FROM scheduled_task WHERE name = ?', ['publish-due']);
self::assertSame('ok', $row['last_status']);
self::assertSame('hand', $row['last_trigger']);
self::assertNull($row['locked_until'], 'Nach dem Lauf ist die Sperre weg - sonst blockiert ein Klick die Aufgabe für eine Viertelstunde.');
}
/** Der zweite Auslöser: das geöffnete Backend. Er gehört jedem angemeldeten Konto, nicht nur der Verwaltung. */
public function testTheOpenBackendDrivesThePlan(): void
{
$editor = $this->login('editor');
$editor->request('GET', '/admin');
$token = $this->tokenFrom((string) $editor->getResponse()->getContent());
self::assertNotSame('', $token, 'Jeder Bildschirm der Verwaltung bringt den Takt mit.');
$editor->request('POST', '/admin/aufgaben/takt', ['_csrf_token' => $token]);
self::assertSame(200, $editor->getResponse()->getStatusCode());
$answer = json_decode((string) $editor->getResponse()->getContent(), true);
self::assertIsArray($answer);
self::assertArrayHasKey('ran', $answer);
// Ohne gültiges Formular passiert nichts, auch für ein angemeldetes Konto nicht.
$editor->request('POST', '/admin/aufgaben/takt', ['_csrf_token' => 'erfunden']);
self::assertSame(403, $editor->getResponse()->getStatusCode());
}
/** Der dritte Auslöser: ein Aufruf von außen, der nur mit dem richtigen Schlüssel etwas tut. */
public function testTheUrlCronAnswersOnlyToItsKey(): void
{
self::ensureKernelShutdown();
$client = self::createClient();
$client->request('GET', '/cms-cron/'.str_repeat('a', 40));
self::assertSame(404, $client->getResponse()->getStatusCode(), 'Ein falscher Schlüssel erfährt nicht einmal, dass es die Stelle gibt.');
$client->request('GET', '/cms-cron/'.Scheduler::cronToken(self::SECRET));
self::assertSame(200, $client->getResponse()->getStatusCode());
self::assertStringContainsString('text/plain', (string) $client->getResponse()->headers->get('Content-Type'));
self::assertStringContainsString('noindex', (string) $client->getResponse()->headers->get('X-Robots-Tag'));
self::assertStringContainsString('Aufgabe(n)', (string) $client->getResponse()->getContent());
}
private function tokenFrom(string $html): string
{
return 1 === preg_match('#<meta name="kant-tick-token" content="([^"]+)"#', $html, $match) ? $match[1] : '';
}
private function login(string $username = 'admin'): KernelBrowser
{
self::ensureKernelShutdown();
$client = self::createClient();
$client->followRedirects(false);
$crawler = $client->request('GET', '/admin/login');
$client->submit($crawler->selectButton('Anmelden')->form(['_username' => $username, '_password' => self::FIXTURE_PASSWORD]));
$client->followRedirect();
return $client;
}
}