Phase 12: Test-Suite (30 pytest-Tests) + CLAUDE.md aktualisiert
tests/: conftest.py - minimale Env-Vars + sys.path-Setup test_auth.py - Magic-Token + JWT Round-Trip (4 Tests) test_audit.py - diff() + _to_json() Helper (8 Tests) test_models.py - Pydantic-Validierung (7 Tests) test_source_meta.py - Single Source of Truth Konsistenz (7 Tests) test_imports.py - alle Backend-Module importierbar (4 Tests) requirements-dev.txt: pytest, ftfy, pyflakes Tests sind reine Unit-Tests (kein DB-Zugriff, kein HTTP-Server), laufen in <0.5s, geben sofortiges Catch-Net fuer Syntax/Import-Bugs. Aufruf: PYTHONPATH=src ./venv/bin/python -m pytest tests/ -v CLAUDE.md erweitert um: - Sektion Tests (Framework, Pfad, Ausfuehrung) - Sektion Phasen-Historie (alle 12 Phasen der Aufraeum-Aktion 2026-05-09 mit kurzer Erklaerung)
Dieser Commit ist enthalten in:
46
CLAUDE.md
46
CLAUDE.md
@@ -193,3 +193,49 @@ shared:
|
|||||||
locked: "diff anschauen, ueberlegen ob die Monitor-Aenderung im Verwaltungs-Fork sinnvoll ist"
|
locked: "diff anschauen, ueberlegen ob die Monitor-Aenderung im Verwaltungs-Fork sinnvoll ist"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
tests:
|
||||||
|
framework: pytest
|
||||||
|
pfad: tests/
|
||||||
|
ausfuehren: "PYTHONPATH=src ./venv/bin/python -m pytest tests/ -v"
|
||||||
|
install: "./venv/bin/pip install -r requirements-dev.txt"
|
||||||
|
|
||||||
|
abdeckung:
|
||||||
|
test_auth.py: Magic-Token + JWT Round-Trip
|
||||||
|
test_audit.py: diff() + _to_json() Helper
|
||||||
|
test_models.py: Pydantic-Validierung (MagicLink, Org, License, User)
|
||||||
|
test_source_meta.py: Single Source of Truth Konsistenz
|
||||||
|
test_imports.py: alle Backend-Module importierbar (Syntax-Catchnet)
|
||||||
|
|
||||||
|
philosophie:
|
||||||
|
- reine Unit-Tests, kein DB-Zugriff, kein HTTP-Server
|
||||||
|
- schnell (<1 Sekunde fuer das ganze Set)
|
||||||
|
- sollten lokal vor jedem Commit laufen
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phasen-Historie (Aufraeum-Aktion 2026-05-09)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
phasen:
|
||||||
|
P0: Verwaltungs-Staging mit develop-Branch + Auto-Deploy + Promote-UI
|
||||||
|
P0i: Login-Auth komplett auf Magic-Link (Passwort entfernt)
|
||||||
|
P1: Backend-Hygiene Quellen (sys.path-Hack weg, Mojibake gefixt, DDL ausgelagert)
|
||||||
|
P2: Health-Check tenant-faehig + source_health_history (Verlauf bleibt)
|
||||||
|
P3a: Toast-System statt alert/confirm
|
||||||
|
P3b: GET /api/sources/meta - Single Source of Truth fuer Kategorien/Typen
|
||||||
|
P3c: Kundenquellen-Tab Filter+Sort+Bulk-Promote
|
||||||
|
P4: Stats-Bar + Health-Badge inline + Letzter-Treffer-Spalte
|
||||||
|
P5: Audit-Spur pro Quelle (ausklappbares Modal)
|
||||||
|
P6: Verwendungs-Sicht: Aktivitaet 7d/30d + Tenant-Sperren
|
||||||
|
P7: scripts/sync_shared.py + Lock-Mechanismus + Mojibake-fail-safe
|
||||||
|
P8a: Pre-Commit-Hook fuer src/shared/ Drift
|
||||||
|
P8b: Audit-Log UI um resource_id-Filter
|
||||||
|
P8c: Monitor-Repo Mojibake gefixt (source_suggester + source_health)
|
||||||
|
P9: Code-Hygiene - alle pyflakes-Issues bereinigt
|
||||||
|
P10: Bug 2 Buckelwal-Diagnose: Lagentitel-Eigennamen als Pflicht-Keywords
|
||||||
|
P11: Backup-Rotation via Cron (KEEP=5 letzte .bak-Files)
|
||||||
|
P12: Test-Suite (pytest, 30 Tests) + Doku
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
4
requirements-dev.txt
Normale Datei
4
requirements-dev.txt
Normale Datei
@@ -0,0 +1,4 @@
|
|||||||
|
# Dev-/Test-Dependencies (nicht für Production-venv noetig).
|
||||||
|
pytest>=8.0
|
||||||
|
ftfy>=6.0 # fuer scripts/sync_shared.py Mojibake-Reparatur
|
||||||
|
pyflakes>=3.0 # fuer Code-Check
|
||||||
0
tests/__init__.py
Normale Datei
0
tests/__init__.py
Normale Datei
23
tests/conftest.py
Normale Datei
23
tests/conftest.py
Normale Datei
@@ -0,0 +1,23 @@
|
|||||||
|
"""Pytest-Fixtures für die Verwaltung-Tests.
|
||||||
|
|
||||||
|
Setzt minimale Env-Vars, damit src/config.py beim Import nicht scheitert.
|
||||||
|
Tests bleiben Unit-Tests (kein DB-Zugriff, kein HTTP-Server).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# config.py erwartet PORTAL_JWT_SECRET zwingend.
|
||||||
|
# Beim Test-Import setzen wir einen Wert.
|
||||||
|
os.environ.setdefault("PORTAL_JWT_SECRET", "test-secret-not-for-production")
|
||||||
|
os.environ.setdefault("DB_PATH", "/tmp/aegis-test-not-used.db")
|
||||||
|
os.environ.setdefault("SMTP_HOST", "")
|
||||||
|
os.environ.setdefault("SMTP_USER", "")
|
||||||
|
os.environ.setdefault("SMTP_PASSWORD", "")
|
||||||
|
|
||||||
|
# src/ ist der Python-App-Dir
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SRC = ROOT / "src"
|
||||||
|
if str(SRC) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SRC))
|
||||||
51
tests/test_audit.py
Normale Datei
51
tests/test_audit.py
Normale Datei
@@ -0,0 +1,51 @@
|
|||||||
|
"""Tests fuer src/audit.py - diff() + _to_json() Helpers."""
|
||||||
|
import json
|
||||||
|
from audit import diff, _to_json
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_returns_only_changed_fields():
|
||||||
|
before = {"name": "Alt", "status": "active", "max_users": 5}
|
||||||
|
after = {"name": "Neu", "status": "active", "max_users": 5}
|
||||||
|
result = diff(before, after)
|
||||||
|
assert result == {"name": {"old": "Alt", "new": "Neu"}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_no_changes_returns_none():
|
||||||
|
same = {"a": 1, "b": "x"}
|
||||||
|
assert diff(same, dict(same)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_with_none_returns_none():
|
||||||
|
assert diff(None, {"a": 1}) is None
|
||||||
|
assert diff({"a": 1}, None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_added_or_removed_fields():
|
||||||
|
before = {"a": 1}
|
||||||
|
after = {"a": 1, "b": 2}
|
||||||
|
result = diff(before, after)
|
||||||
|
assert result == {"b": {"old": None, "new": 2}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_json_handles_none():
|
||||||
|
assert _to_json(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_json_handles_dict():
|
||||||
|
out = _to_json({"x": 1, "y": "hallo"})
|
||||||
|
assert json.loads(out) == {"x": 1, "y": "hallo"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_json_handles_non_serializable_via_str_default():
|
||||||
|
"""Custom Objekte werden via default=str zu Strings."""
|
||||||
|
class Foo:
|
||||||
|
def __str__(self):
|
||||||
|
return "FooObj"
|
||||||
|
out = _to_json({"obj": Foo()})
|
||||||
|
assert "FooObj" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_json_preserves_umlauts():
|
||||||
|
"""ensure_ascii=False soll deutsche Umlaute durchlassen."""
|
||||||
|
out = _to_json({"name": "Müller"})
|
||||||
|
assert "Müller" in out
|
||||||
38
tests/test_auth.py
Normale Datei
38
tests/test_auth.py
Normale Datei
@@ -0,0 +1,38 @@
|
|||||||
|
"""Tests fuer src/auth.py - Magic-Link-Token + JWT Round-Trip."""
|
||||||
|
import pytest
|
||||||
|
from auth import generate_magic_token, create_token, decode_token
|
||||||
|
|
||||||
|
|
||||||
|
def test_magic_token_is_url_safe_and_random():
|
||||||
|
t1 = generate_magic_token()
|
||||||
|
t2 = generate_magic_token()
|
||||||
|
assert t1 != t2
|
||||||
|
# token_urlsafe(32) -> 43 Zeichen base64-url
|
||||||
|
assert 40 <= len(t1) <= 50
|
||||||
|
# Nur URL-safe Zeichen
|
||||||
|
assert all(c.isalnum() or c in "-_" for c in t1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_jwt_round_trip():
|
||||||
|
token = create_token(admin_id=42, email="info@aegis-sight.de", username="info")
|
||||||
|
payload = decode_token(token)
|
||||||
|
assert payload["sub"] == "42"
|
||||||
|
assert payload["email"] == "info@aegis-sight.de"
|
||||||
|
assert payload["username"] == "info"
|
||||||
|
assert payload["role"] == "portal_admin"
|
||||||
|
assert payload["iss"] == "aegissight-portal"
|
||||||
|
assert payload["aud"] == "aegissight-portal"
|
||||||
|
|
||||||
|
|
||||||
|
def test_jwt_username_default_from_email():
|
||||||
|
"""Wenn kein username uebergeben wird, kommt der local-part der Email."""
|
||||||
|
token = create_token(admin_id=1, email="someone@example.com")
|
||||||
|
payload = decode_token(token)
|
||||||
|
assert payload["username"] == "someone"
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_invalid_token_raises():
|
||||||
|
from fastapi import HTTPException
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
decode_token("not.a.valid.jwt")
|
||||||
|
assert exc.value.status_code == 401
|
||||||
33
tests/test_imports.py
Normale Datei
33
tests/test_imports.py
Normale Datei
@@ -0,0 +1,33 @@
|
|||||||
|
"""Smoke-Test: alle Backend-Module importierbar (Catch-Net fuer Syntax-Errors)."""
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_app_imports():
|
||||||
|
import main # FastAPI app
|
||||||
|
assert hasattr(main, "app")
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_routers_importable():
|
||||||
|
"""Bei Syntax-Fehlern in einem Router crasht das Ganze - hier fangen wir das ab."""
|
||||||
|
for mod in ("auth", "organizations", "licenses", "users",
|
||||||
|
"dashboard", "sources", "token_usage", "audit"):
|
||||||
|
m = importlib.import_module(f"routers.{mod}")
|
||||||
|
assert hasattr(m, "router"), f"routers/{mod} hat keinen router-Objekt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_modules_importable():
|
||||||
|
"""src/shared/ muss eigenstaendig importierbar sein (kein sys.path-Hack)."""
|
||||||
|
from shared.source_rules import discover_source, evaluate_feeds_with_claude
|
||||||
|
from shared.services.source_health import run_health_checks
|
||||||
|
from shared.services.source_suggester import generate_suggestions
|
||||||
|
from shared.agents.claude_client import call_claude
|
||||||
|
assert callable(discover_source)
|
||||||
|
assert callable(run_health_checks)
|
||||||
|
|
||||||
|
|
||||||
|
def test_helpers_importable():
|
||||||
|
from auth import generate_magic_token, create_token, decode_token
|
||||||
|
from audit import log_action, diff, get_client_ip, row_to_dict
|
||||||
|
from email_utils.sender import send_email
|
||||||
|
from email_utils.templates import portal_magic_link_email, invite_email
|
||||||
|
from source_meta import get_meta, category_label, type_label
|
||||||
53
tests/test_models.py
Normale Datei
53
tests/test_models.py
Normale Datei
@@ -0,0 +1,53 @@
|
|||||||
|
"""Tests fuer src/models.py - Pydantic-Validierung."""
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from models import (
|
||||||
|
MagicLinkRequest, MagicLinkResponse,
|
||||||
|
VerifyTokenRequest, TokenResponse,
|
||||||
|
OrgCreate, LicenseCreate, UserCreate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_magic_link_request_accepts_email():
|
||||||
|
r = MagicLinkRequest(email="info@aegis-sight.de")
|
||||||
|
assert r.email == "info@aegis-sight.de"
|
||||||
|
|
||||||
|
|
||||||
|
def test_magic_link_request_rejects_too_short():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
MagicLinkRequest(email="a")
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_token_min_length():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
VerifyTokenRequest(token="abc")
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_response_default_email_empty():
|
||||||
|
r = TokenResponse(access_token="x" * 40, username="info")
|
||||||
|
assert r.email == ""
|
||||||
|
assert r.token_type == "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_org_create_slug_pattern():
|
||||||
|
"""Slug muss lowercase mit Bindestrichen sein."""
|
||||||
|
OrgCreate(name="Test", slug="abc-123")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
OrgCreate(name="Test", slug="Wrong Case")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
OrgCreate(name="Test", slug="under_score")
|
||||||
|
|
||||||
|
|
||||||
|
def test_license_create_type_pattern():
|
||||||
|
LicenseCreate(organization_id=1, license_type="trial")
|
||||||
|
LicenseCreate(organization_id=1, license_type="annual")
|
||||||
|
LicenseCreate(organization_id=1, license_type="permanent")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
LicenseCreate(organization_id=1, license_type="lifetime")
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_create_role_pattern():
|
||||||
|
UserCreate(email="a@b.de", role="member")
|
||||||
|
UserCreate(email="a@b.de", role="org_admin")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
UserCreate(email="a@b.de", role="superuser")
|
||||||
51
tests/test_source_meta.py
Normale Datei
51
tests/test_source_meta.py
Normale Datei
@@ -0,0 +1,51 @@
|
|||||||
|
"""Tests fuer src/source_meta.py - Single Source of Truth fuer Kategorien/Typen."""
|
||||||
|
from source_meta import (
|
||||||
|
SOURCE_CATEGORIES, SOURCE_TYPES,
|
||||||
|
get_meta, category_label, type_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_categories_have_unique_keys():
|
||||||
|
keys = [c["key"] for c in SOURCE_CATEGORIES]
|
||||||
|
assert len(keys) == len(set(keys))
|
||||||
|
|
||||||
|
|
||||||
|
def test_types_have_unique_keys():
|
||||||
|
keys = [t["key"] for t in SOURCE_TYPES]
|
||||||
|
assert len(keys) == len(set(keys))
|
||||||
|
|
||||||
|
|
||||||
|
def test_categories_and_types_have_label():
|
||||||
|
for c in SOURCE_CATEGORIES:
|
||||||
|
assert "key" in c and "label" in c
|
||||||
|
assert isinstance(c["label"], str) and c["label"]
|
||||||
|
for t in SOURCE_TYPES:
|
||||||
|
assert "key" in t and "label" in t
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_meta_shape():
|
||||||
|
meta = get_meta()
|
||||||
|
assert set(meta.keys()) == {"categories", "types"}
|
||||||
|
assert meta["categories"] == SOURCE_CATEGORIES
|
||||||
|
assert meta["types"] == SOURCE_TYPES
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_label_lookup():
|
||||||
|
assert category_label("nachrichtenagentur") == "Nachrichtenagentur"
|
||||||
|
assert category_label("oeffentlich-rechtlich") == "Öffentlich-Rechtlich"
|
||||||
|
# Unbekannter key -> Fallback auf key selbst
|
||||||
|
assert category_label("does-not-exist") == "does-not-exist"
|
||||||
|
|
||||||
|
|
||||||
|
def test_type_label_lookup():
|
||||||
|
assert type_label("rss_feed") == "RSS-Feed"
|
||||||
|
assert type_label("telegram_channel") == "Telegram-Kanal"
|
||||||
|
assert type_label("does-not-exist") == "does-not-exist"
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_includes_aktuelle_themen():
|
||||||
|
"""Phase 3b: Lagen-spezifische Kategorien (cybercrime etc.) müssen drin sein."""
|
||||||
|
keys = {c["key"] for c in SOURCE_CATEGORIES}
|
||||||
|
assert "cybercrime" in keys
|
||||||
|
assert "ukraine-russland-krieg" in keys
|
||||||
|
assert "russische-staatspropaganda" in keys
|
||||||
In neuem Issue referenzieren
Einen Benutzer sperren