Files
KantSites/tests/SchedulerTest.php
kantarciandClaude Opus 5 c8a8fa7460 Rebuild DEEG as a fifth installation, add stepped forms and a plugin system, and clear the review backlog
Forms gained steps, conditions, image choices, number fields with units and a
summary step; the DEEG price finder uses all of it. Plugins are manifest plus
Twig: blocks, settings, containers, routes, presets and theme packs, switched
per installation. The review backlog is done: archive pages render through the
normal page pipeline, forms and post lists work inside areas and sections,
repeated sections get their own ids, breakpoints and colours come from the
palette, backup and update work on MySQL and lock each other out.

The task worker no longer shortens a process that has no time limit:
set_time_limit() sets the limit instead of extending it, which turned an
unlimited CLI run into eighty seconds and killed the full test suite far away
from the scheduler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 07:32:35 +02:00

294 lines
12 KiB
PHP

<?php
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
* würde: läuft eine Aufgabe zweimal, wenn zwei Besucher gleichzeitig kommen? Reißt eine kaputte
* Aufgabe die Website mit? Fängt eine, die nicht laufen kann, bei jedem Seitenaufruf neu an?
*/
final class SchedulerTest extends TestCase
{
private Connection $db;
private string $databaseFile;
private string $projectDir;
protected function setUp(): void
{
$this->databaseFile = tempnam(sys_get_temp_dir(), 'kant-tasks-');
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->databaseFile));
TestSchema::build($this->db);
$this->projectDir = sys_get_temp_dir().'/kant-tasks-'.bin2hex(random_bytes(6));
mkdir($this->projectDir.'/var', 0o775, true);
}
protected function tearDown(): void
{
$this->db->close();
gc_collect_cycles();
@unlink($this->databaseFile);
foreach (glob($this->projectDir.'/var/*') ?: [] as $file) {
@unlink($file);
}
@rmdir($this->projectDir.'/var');
@rmdir($this->projectDir);
}
/** @param list<SpyTask> $tasks */
private function scheduler(array $tasks): Scheduler
{
return new Scheduler($this->db, $tasks, $this->projectDir);
}
private function makeDue(string $name): void
{
$this->db->executeStatement('UPDATE scheduled_task SET next_run_at = ? WHERE name = ?', ['2020-01-01 00:00:00', $name]);
}
public function testTheCodeDecidesWhichTasksExist(): void
{
$this->scheduler([new SpyTask('a'), new SpyTask('b')])->sync();
self::assertSame(['a', 'b'], $this->db->fetchFirstColumn('SELECT name FROM scheduled_task ORDER BY name'));
// Nach einem Update kennt der Code eine Aufgabe weniger: ihre Zeile geht mit.
$this->scheduler([new SpyTask('a')])->sync();
self::assertSame(['a'], $this->db->fetchFirstColumn('SELECT name FROM scheduled_task'));
}
public function testANewTaskDoesNotRunInTheVeryNextRequest(): void
{
// Sonst faellt direkt nach einem Update alles gleichzeitig an - im ersten Aufruf danach.
$task = new SpyTask('a');
self::assertSame([], $this->scheduler([$task])->runDue('besuch'));
self::assertSame(0, $task->runs);
}
public function testWhatIsDueRunsAndIsThenRescheduled(): void
{
$task = new SpyTask('a', 600);
$scheduler = $this->scheduler([$task]);
$scheduler->sync();
$this->makeDue('a');
$results = $scheduler->runDue('besuch');
self::assertCount(1, $results);
self::assertSame('ok', $results[0]['status']);
self::assertSame('Lauf 1', $results[0]['message']);
self::assertSame(1, $task->runs);
// Danach ist sie nicht mehr faellig, und ein zweiter Aufruf laesst sie in Ruhe.
self::assertSame([], $scheduler->runDue('besuch'));
self::assertSame(1, $task->runs);
$row = $this->db->fetchAssociative('SELECT * FROM scheduled_task WHERE name = ?', ['a']);
self::assertSame('besuch', $row['last_trigger']);
self::assertGreaterThan(gmdate('Y-m-d H:i:s'), (string) $row['next_run_at']);
self::assertNull($row['locked_until']);
}
/**
* Der eigentliche Zweck der Sperre: zwei Besucher im selben Moment.
*
* Nachgestellt wird der Zustand, den der erste hinterlaesst, waehrend er noch arbeitet -
* faellig, aber gesperrt. Der zweite muss daran vorbeigehen.
*/
public function testALockedTaskIsLeftAloneByTheNextRequest(): void
{
$task = new SpyTask('a');
$scheduler = $this->scheduler([$task]);
$scheduler->sync();
$this->db->executeStatement(
'UPDATE scheduled_task SET next_run_at = ?, locked_until = ? WHERE name = ?',
['2020-01-01 00:00:00', gmdate('Y-m-d H:i:s', time() + 300), 'a'],
);
self::assertSame([], $scheduler->runDue('besuch'));
self::assertSame(0, $task->runs);
}
/** Eine Sperre, deren Prozess das Hosting abgeschossen hat, blockiert nicht fuer immer. */
public function testAnExpiredLockIsTakenOver(): void
{
$task = new SpyTask('a');
$scheduler = $this->scheduler([$task]);
$scheduler->sync();
$this->db->executeStatement(
'UPDATE scheduled_task SET next_run_at = ?, locked_until = ? WHERE name = ?',
['2020-01-01 00:00:00', '2020-01-01 00:15:00', 'a'],
);
self::assertCount(1, $scheduler->runDue('besuch'));
self::assertSame(1, $task->runs);
}
public function testAFailingTaskIsRecordedAndWaitsLongerEachTime(): void
{
$task = new SpyTask('a', 300, new \RuntimeException('Kein Netz.'));
$scheduler = $this->scheduler([$task]);
$scheduler->sync();
$this->makeDue('a');
$results = $scheduler->runDue('besuch');
self::assertSame('fehler', $results[0]['status']);
self::assertStringContainsString('Kein Netz.', $results[0]['message']);
$first = $this->db->fetchAssociative('SELECT * FROM scheduled_task WHERE name = ?', ['a']);
self::assertSame(1, (int) $first['failures']);
// 300 Sekunden Abstand, ein Fehlschlag: der naechste Versuch liegt weiter weg als der
// gewoehnliche Abstand, aber die Sperre ist weg - die Aufgabe haengt nicht fest.
self::assertNull($first['locked_until']);
self::assertGreaterThan(gmdate('Y-m-d H:i:s', time() + 400), (string) $first['next_run_at']);
$this->makeDue('a');
$scheduler->runDue('besuch');
$second = $this->db->fetchAssociative('SELECT * FROM scheduled_task WHERE name = ?', ['a']);
self::assertSame(2, (int) $second['failures']);
self::assertGreaterThan((string) $first['next_run_at'], (string) $second['next_run_at']);
}
public function testASwitchedOffTaskNeverRuns(): void
{
$task = new SpyTask('a');
$scheduler = $this->scheduler([$task]);
$scheduler->sync();
$scheduler->enable('a', false);
$this->makeDue('a');
self::assertSame([], $scheduler->runDue('besuch'));
self::assertSame(0, $task->runs);
// Von Hand gestartet laeuft sie trotzdem: wer den Knopf drueckt, meint sie auch.
$result = $scheduler->runNow('a');
self::assertNotNull($result);
self::assertSame(1, $task->runs);
}
public function testOneRequestDoesNotRunEverythingAtOnce(): void
{
$tasks = [new SpyTask('a'), new SpyTask('b'), new SpyTask('c')];
$scheduler = $this->scheduler($tasks);
$scheduler->sync();
foreach (['a', 'b', 'c'] as $name) {
$this->makeDue($name);
}
self::assertCount(2, $scheduler->runDue('besuch', 2));
self::assertSame(2, array_sum(array_map(static fn (SpyTask $t): int => $t->runs, $tasks)));
}
/**
* Der schnelle Weg: eine Datei sagt, wann sich das Fragen der Datenbank ueberhaupt lohnt.
*
* Ohne diese Eigenschaft kostet der Aufgabenplan jeden Seitenaufruf eine Abfrage - genau das,
* was auf geteiltem Hosting am meisten weh tut.
*/
public function testTheStateFileHoldsTheNextMomentAnythingIsDue(): void
{
$scheduler = $this->scheduler([new SpyTask('a', 600)]);
$scheduler->sync();
$state = $scheduler->fastState();
self::assertTrue($state['traffic']);
self::assertNotNull($state['next']);
self::assertGreaterThan(time(), $state['next']);
self::assertFileExists($this->projectDir.'/'.Scheduler::STATE_FILE);
// Die Schalter ueberleben das Nachtragen des naechsten Zeitpunkts.
$scheduler->setWorkers(false, true);
$this->makeDue('a');
$scheduler->runDue('besuch');
self::assertFalse($scheduler->fastState()['traffic']);
self::assertTrue($scheduler->fastState()['admin']);
}
/**
* Waehrend eine Aufgabe laeuft, schickt die Zustandsdatei die uebrigen Anfragen weg.
*
* Sonst waere jeder Seitenaufruf waehrend einer langen Sicherung wieder bei der Datenbank:
* faellig ist sie ja, sie ist nur schon vergeben.
*/
public function testARunningTaskKeepsTheOtherRequestsOutOfTheDatabase(): void
{
$scheduler = $this->scheduler([new SpyTask('a')]);
$scheduler->sync();
$lockEnds = gmdate('Y-m-d H:i:s', time() + 600);
$this->db->executeStatement(
'UPDATE scheduled_task SET next_run_at = ?, locked_until = ? WHERE name = ?',
['2020-01-01 00:00:00', $lockEnds, 'a'],
);
$scheduler->runDue('besuch');
self::assertSame(strtotime($lockEnds.' UTC'), $scheduler->fastState()['next']);
}
/** Zwischen hochgeladenem Code und ausgefuehrter Migration darf nichts explodieren. */
public function testAMissingTableIsNotAnError(): void
{
$this->db->executeStatement('DROP TABLE scheduled_task');
$task = new SpyTask('a');
self::assertSame([], $this->scheduler([$task])->runDue('besuch'));
self::assertSame([], $this->scheduler([$task])->overview());
self::assertSame(0, $task->runs);
}
public function testTheProtocolKeepsWhatHappened(): void
{
$scheduler = $this->scheduler([new SpyTask('a')]);
$scheduler->sync();
$scheduler->runNow('a');
$lines = $scheduler->protocol();
self::assertCount(1, $lines);
self::assertStringContainsString('a', $lines[0]);
self::assertStringContainsString('ok', $lines[0]);
self::assertStringContainsString('hand', $lines[0]);
}
public function testTheCronKeyDependsOnTheApplicationSecret(): void
{
$one = Scheduler::cronToken('geheimnis-eins');
$two = Scheduler::cronToken('geheimnis-zwei');
self::assertSame(40, strlen($one));
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);
}
}