Phase 1: Kern mit Plugin-System, Auth/Rollen, Migrations, Plugin-Stubs

- redaktionskern (src/): schlanker Kern — App-Fabrik, Plugin-Loader
  (Entry-Points + plugins/-Verzeichnis), Migrations-Laufzeit
  (schema_migrations pro Plugin, SQLite-/Postgres-portabel),
  Auth mit Argon2id + Session-Cookies, Rollen admin/redakteur/rezensent,
  Benutzerverwaltung für Admins
- plugins/: 8 ladbare Stubs (neuheiten, dedup, planung, archiv,
  erinnerung, benachrichtigung, audit-log, export) nach Plugin-Vertrag
- Frontend: Jinja2 + Tailwind (CDN) + HTMX + Alpine.js, UI deutsch
- Tests: 28 pytest-Fälle (Loader, Lifecycle, Entry-Points, Migrations-
  Idempotenz, Login/Logout, Rollen-Zugriff, Benutzerverwaltung)
- Docker/Podman: Compose (Traefik-Labels) + Dockerfile (uv)
- README.md mit Setup-Anleitung
This commit is contained in:
Flo Hartmann
2026-08-21 18:35:09 +00:00
parent 72f016a263
commit 96a37e878b
50 changed files with 3343 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
"""Tests: Plugin-Loader laedt alle Stubs, Lifecycle-Hooks, Entry-Points."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from redaktionskern.contracts import BasePlugin, NavEntry
from redaktionskern.plugin_loader import PluginError, PluginRegistry
ERWARTETE_STUBS = {
"neuheiten",
"dedup",
"planung",
"archiv",
"erinnerung",
"benachrichtigung",
"audit-log",
"export",
}
def test_alle_stubs_werden_geladen(app):
namen = {p.name for p in app.state.registry.all()}
assert ERWARTETE_STUBS <= namen
def test_on_load_wurde_aufgerufen(app):
for plugin in app.state.registry.all():
if plugin.name in ERWARTETE_STUBS:
assert plugin.loaded, f"Plugin {plugin.name} ist nicht geladen."
assert plugin.context is not None
def test_stubs_haben_navigation(app):
for name in ERWARTETE_STUBS:
plugin = app.state.registry.get(name)
assert len(plugin.navigation()) == 1
def test_plugin_seiten_erreichbar(client):
from tests.conftest import melde_an
melde_an(client)
for name in sorted(ERWARTETE_STUBS):
antwort = client.get(f"/{name}")
assert antwort.status_code == 200, name
assert "ist geladen" in antwort.text
def test_on_unload_beim_herunterfahren(app):
plugin = app.state.registry.get("neuheiten")
with TestClient(app):
assert plugin.loaded
assert not plugin.loaded
def test_doppelte_plugin_namen_abgelehnt():
class A(BasePlugin):
name = "a"
registry = PluginRegistry()
registry.register(A())
with pytest.raises(PluginError):
registry.register(A())
def test_entry_point_plugins_werden_geladen(monkeypatch, settings_ohne_plugins):
"""Entry-Point-Erkennung via importlib.metadata (hier simuliert)."""
import redaktionskern.plugin_loader as loader
externes_plugin = loader._als_plugin(ExternesPlugin())
class FakeEntryPoint:
name = "extern"
def load(self):
return externes_plugin
monkeypatch.setattr(loader, "entry_points", lambda group: [FakeEntryPoint()])
from redaktionskern.app import create_app
app = create_app(settings_ohne_plugins)
geladen = app.state.registry.get("extern")
assert geladen is externes_plugin
assert geladen.loaded
class ExternesPlugin(BasePlugin):
"""Simuliert ein als Paket installiertes Plugin (Entry-Point)."""
name = "extern"
title = "Externes Plugin"
description = "Test-Plugin via Entry-Point."
def navigation(self) -> list[NavEntry]:
return []