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', "root.'/plugins/'.$name.'/plugin.php', true); } } }