From 00cd81f1777c8b73aaf091832edd1092c301b7b2 Mon Sep 17 00:00:00 2001 From: claude-dev Date: Sat, 9 May 2026 03:55:30 +0000 Subject: [PATCH] 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) --- CLAUDE.md | 46 +++++++++++++++++++++++++++++++++ requirements-dev.txt | 4 +++ tests/__init__.py | 0 tests/conftest.py | 23 +++++++++++++++++ tests/test_audit.py | 51 +++++++++++++++++++++++++++++++++++++ tests/test_auth.py | 38 ++++++++++++++++++++++++++++ tests/test_imports.py | 33 ++++++++++++++++++++++++ tests/test_models.py | 53 +++++++++++++++++++++++++++++++++++++++ tests/test_source_meta.py | 51 +++++++++++++++++++++++++++++++++++++ 9 files changed, 299 insertions(+) create mode 100644 requirements-dev.txt create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_audit.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_imports.py create mode 100644 tests/test_models.py create mode 100644 tests/test_source_meta.py diff --git a/CLAUDE.md b/CLAUDE.md index 74d7e1f..b45d90c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 +``` + diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..723df07 --- /dev/null +++ b/requirements-dev.txt @@ -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 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d34f60c --- /dev/null +++ b/tests/conftest.py @@ -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)) diff --git a/tests/test_audit.py b/tests/test_audit.py new file mode 100644 index 0000000..30dfe2b --- /dev/null +++ b/tests/test_audit.py @@ -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 diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..f2363cf --- /dev/null +++ b/tests/test_auth.py @@ -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 diff --git a/tests/test_imports.py b/tests/test_imports.py new file mode 100644 index 0000000..ea23ff2 --- /dev/null +++ b/tests/test_imports.py @@ -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 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..e42e58a --- /dev/null +++ b/tests/test_models.py @@ -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") diff --git a/tests/test_source_meta.py b/tests/test_source_meta.py new file mode 100644 index 0000000..34bf83b --- /dev/null +++ b/tests/test_source_meta.py @@ -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