Files
KantSites/tests/TaskScreenTest.php
T
kantarciandClaude Opus 5 05ea703655
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Fassung 1.6.0: der Aufgabenplan wird veroeffentlicht
Wiederkehrende Aufgaben ohne Cronjob (ADR 073): Sicherung, Statistik kuerzen,
geplante Veroeffentlichung, Updatepruefung und Aufraeumen laufen als Plan in
der Tabelle scheduled_task - eine Zeile je Aufgabe mit Faelligkeit, Sperre und
letztem Ergebnis. Auf geteiltem Hosting gibt es oft keinen Cronjob; der Plan
haengt stattdessen an Anfragen und an einem Takt aus dem geoeffneten Backend.

Diese Arbeit entstand in einer parallel laufenden Sitzung und lag bis hierher
unveroeffentlicht im Arbeitsverzeichnis: die Releases 1.5.0 bis 1.5.5 wurden
bewusst aus dem jeweiligen Tag gebaut und enthielten sie deshalb nicht.

Vor der Veroeffentlichung geprueft, nicht bloss eingepackt:

- phpunit 452/452, npm test 28/28, npm run test:editor vollstaendig.
- Die Migration 20260914000100 legt nur die Tabelle scheduled_task an und nimmt
  sie beim Zurueckrollen wieder weg. Sie fasst keine bestehenden Daten an.
- Seit einer Stunde keine Schreibzugriffe der anderen Sitzung mehr.

Dabei zwei Fehler behoben:

- config/services.yaml verwendet !tagged_iterator. Das ist gueltiges Symfony,
  aber "lint:yaml config" kennt eigene Tags nur mit --parse-tags und gab 1
  zurueck - der CI-Lauf waere daran gescheitert. Flag ergaenzt.
- AdminHeadersSubscriber.php (aus 1.5.3) entsprach nicht dem Stil von
  php-cs-fixer; composer lint gab 8 zurueck. Behoben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 08:30:13 +02:00

169 lines
7.8 KiB
PHP
Raw Permalink 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'] 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(5, (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;
}
}