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:
claude-dev
2026-05-09 03:55:30 +00:00
Ursprung 9000750df2
Commit 00cd81f177
9 geänderte Dateien mit 299 neuen und 0 gelöschten Zeilen

Datei anzeigen

@@ -193,3 +193,49 @@ shared:
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
Datei anzeigen

@@ -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
Datei anzeigen

23
tests/conftest.py Normale Datei
Datei anzeigen

@@ -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
Datei anzeigen

@@ -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
Datei anzeigen

@@ -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
Datei anzeigen

@@ -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
Datei anzeigen

@@ -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
Datei anzeigen

@@ -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