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>
163 lines
7.5 KiB
PHP
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);
|
|
}
|
|
}
|
|
}
|