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

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