Files
KantSites/tests/PluginServerTest.php
T
kantarciandClaude Opus 5.5 9825bac3b3
CMS quality / php (8.2) (push) Waiting to run
CMS quality / php (8.4) (push) Waiting to run
CMS quality / browser (push) Waiting to run
CMS quality / mysql (push) Waiting to run
Let plugins run their own PHP code
A plugin can now bring a plugin.php that registers hooks, scheduled tasks, its own tables, public pages and admin screens. Failures stay contained to the plugin; a form on a plugin page can be submitted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:56:14 +01:00

163 lines
7.5 KiB
PHP

<?php
namespace App\Tests;
use App\Content\MediaLibrary;
use App\Infrastructure\ConnectionFactory;
use App\Ops\Scheduler;
use App\Plugin\PluginRegistry;
use App\Plugin\PluginRuntime;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\TestCase;
/** Plugins mit eigenem PHP-Code (ADR 127): laden, Hooks, Aufgaben, Seiten, Fehler eingrenzen. */
final class PluginServerTest extends TestCase
{
private string $root;
private Connection $db;
private PluginRegistry $plugins;
private PluginRuntime $runtime;
protected function setUp(): void
{
$this->root = sys_get_temp_dir().'/kant-plugin-server-'.bin2hex(random_bytes(6));
mkdir($this->root.'/plugins', 0o775, true);
file_put_contents($this->root.'/VERSION', "1.39.0\n");
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->root).'/test.db');
TestSchema::build($this->db);
$this->fresh();
}
protected function tearDown(): void
{
unset($this->runtime, $this->plugins);
$this->db->close();
$remove = static function (string $path) use (&$remove): void {
foreach (glob($path.'/*') ?: [] as $entry) {
is_dir($entry) ? $remove($entry) : @unlink($entry);
}
@rmdir($path);
};
$remove($this->root);
}
public function testAPluginRegistersHooksTasksPagesAndItsOwnTable(): void
{
$this->plugin('demo', <<<'PHP'
$api->onInstall(fn () => $api->db()->executeStatement('CREATE TABLE IF NOT EXISTS '.$api->table('zeile').' (wert TEXT)'));
$api->addFilter('liste', fn (array $w) => [...$w, 'spaet'], 20);
$api->addFilter('liste', fn (array $w) => [...$w, 'frueh'], 5);
$api->addTask('takt', 'Takt', 'Erklaerung', 600, fn () => 'gelaufen');
$api->addPage('/objekte/{slug}', fn (array $p) => 'gibt-es' === $p['slug'] ? ['title' => 'T', 'description' => 'D', 'blocks' => [], 'status' => 410, 'robots' => 'noindex'] : null);
$api->addData('zahl', fn (array $a) => $a['x'] * 2);
PHP);
$this->runtime->activate('demo');
$this->db->executeStatement('INSERT INTO plugin_demo_zeile (wert) VALUES (?)', ['ja']);
self::assertSame(['frueh', 'spaet'], $this->runtime->applyFilters('liste', []));
self::assertSame(['plugin-demo-takt'], array_map(static fn ($t) => $t->name(), $this->runtime->tasks()));
self::assertSame('gelaufen', $this->runtime->tasks()[0]->run());
self::assertSame(42, $this->runtime->data('demo', 'zahl', ['x' => 21]));
$seite = $this->runtime->page('/objekte/gibt-es');
self::assertSame(410, $seite['status']);
self::assertSame('noindex', $seite['robots']);
self::assertSame('objekte/gibt-es', $seite['path']);
self::assertNull($this->runtime->page('/objekte/gibt-es-nicht'));
self::assertNull($this->runtime->page('/objekte/a/b'));
// Der Aufgabenplan kennt die Aufgabe, und nach dem Ausschalten nicht mehr.
$plan = new Scheduler($this->db, [], $this->root, $this->runtime);
$plan->pluginsChanged();
self::assertSame('plugin-demo-takt', $this->db->fetchOne('SELECT name FROM scheduled_task'));
self::assertSame('ok', $plan->runNow('plugin-demo-takt')['status']);
$this->runtime->deactivate('demo');
$plan->pluginsChanged();
self::assertFalse($this->db->fetchOne('SELECT name FROM scheduled_task'));
self::assertSame([], $this->runtime->applyFilters('liste', []));
// Eigene Tabellen überleben das Ausschalten.
self::assertSame('ja', $this->db->fetchOne('SELECT wert FROM plugin_demo_zeile'));
}
public function testAFaultyPluginStaysContained(): void
{
$this->plugin('kaputt', 'throw new \RuntimeException("Beim Laden gescheitert");');
$this->plugin('wackel', <<<'PHP'
$api->addFilter('wert', function ($w) { throw new \RuntimeException('Filterfehler'); });
$api->addFilter('wert', fn ($w) => $w.'+gut', 20);
$api->addPage('/wackel', function () { throw new \RuntimeException('Seitenfehler'); });
PHP);
try {
$this->runtime->activate('kaputt');
self::fail('Ein Plugin, das nicht lädt, darf nicht eingeschaltet bleiben.');
} catch (\InvalidArgumentException $e) {
self::assertStringContainsString('Beim Laden gescheitert', $e->getMessage());
}
self::assertFalse($this->plugins->all()['kaputt']['active']);
$this->runtime->activate('wackel');
self::assertSame('x+gut', $this->runtime->applyFilters('wert', 'x'));
self::assertNull($this->runtime->page('/wackel'));
self::assertSame('Seitenfehler', $this->runtime->errors()['wackel']);
}
public function testAFailedInstallLeavesThePluginOff(): void
{
$this->plugin('demo', '$api->onInstall(function () { throw new \RuntimeException("Keine Tabelle"); });');
$this->expectException(\InvalidArgumentException::class);
try {
$this->runtime->activate('demo');
} finally {
self::assertFalse($this->plugins->all()['demo']['active']);
}
}
public function testANewVersionRunsTheInstallAgainOnce(): void
{
$code = '$api->onInstall(fn () => $api->db()->executeStatement("INSERT INTO media (id, title, alt, variants, archived, version, created_at) VALUES (lower(hex(randomblob(16))), \'t\', \'\', \'[]\', 0, 1, \'2026-01-01 00:00:00\')"));';
$this->plugin('demo', $code);
$this->runtime->activate('demo');
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'));
$this->plugin('demo', $code, '1.1.0');
$this->fresh();
$this->runtime->tasks();
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'));
$this->fresh();
$this->runtime->tasks();
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'));
}
public function testAPluginCannotTakeCoreAddressesOrOtherFolders(): void
{
$this->plugin('demo', '$api->addPage("/admin/{x}", fn () => null);');
try {
$this->runtime->activate('demo');
self::fail('Die Verwaltung gehört dem Kern.');
} catch (\InvalidArgumentException $e) {
self::assertStringContainsString('gehört dem Kern', $e->getMessage());
}
file_put_contents($this->root.'/plugins/demo/plugin.json', json_encode(['name' => 'demo', 'title' => 'D', 'version' => '1.0.0', 'main' => '../fremd.php'], JSON_THROW_ON_ERROR));
$this->fresh();
self::assertNotNull($this->plugins->all()['demo']['error']);
}
private function fresh(): void
{
$this->plugins = new PluginRegistry($this->db, $this->root);
$this->runtime = new PluginRuntime($this->plugins, $this->db, new MediaLibrary($this->db, $this->root.'/media'));
}
private function plugin(string $name, string $body, string $version = '1.0.0'): void
{
@mkdir($this->root.'/plugins/'.$name);
file_put_contents($this->root.'/plugins/'.$name.'/plugin.json', json_encode(['name' => $name, 'title' => $name, 'version' => $version, 'main' => 'plugin.php'], JSON_THROW_ON_ERROR));
file_put_contents($this->root.'/plugins/'.$name.'/plugin.php', "<?php\nreturn static function (\\App\\Plugin\\PluginApi \$api): void {\n".$body."\n};\n");
clearstatcache();
if (function_exists('opcache_invalidate')) {
opcache_invalidate($this->root.'/plugins/'.$name.'/plugin.php', true);
}
}
}