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