fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
435
backend/tests/test_alerts_topics_api.py
Normal file
435
backend/tests/test_alerts_topics_api.py
Normal file
@@ -0,0 +1,435 @@
|
||||
"""
|
||||
Tests für Alerts Agent Topics API.
|
||||
|
||||
Testet CRUD-Operationen für Topics über die REST-API.
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
# WICHTIG: Models ZUERST importieren damit sie bei Base registriert werden
|
||||
from alerts_agent.db.models import (
|
||||
AlertTopicDB, AlertItemDB, AlertRuleDB, AlertProfileDB,
|
||||
)
|
||||
# Dann Base importieren (hat jetzt die Models in metadata)
|
||||
from classroom_engine.database import Base
|
||||
from alerts_agent.db import get_db
|
||||
from alerts_agent.api.topics import router
|
||||
|
||||
|
||||
# Test-Client Setup
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/alerts")
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def db_engine():
|
||||
"""Erstellt eine In-Memory SQLite-Engine mit Threading-Support."""
|
||||
# StaticPool stellt sicher, dass alle Connections die gleiche DB nutzen
|
||||
# check_same_thread=False erlaubt Cross-Thread-Zugriff (für TestClient)
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
echo=False,
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
# Alle Tables erstellen
|
||||
Base.metadata.create_all(engine)
|
||||
yield engine
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def db_session(db_engine):
|
||||
"""Erstellt eine Session für Tests."""
|
||||
SessionLocal = sessionmaker(bind=db_engine)
|
||||
session = SessionLocal()
|
||||
yield session
|
||||
session.rollback()
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def client(db_session):
|
||||
"""Erstellt einen Test-Client mit überschriebener DB-Dependency."""
|
||||
def override_get_db():
|
||||
try:
|
||||
yield db_session
|
||||
finally:
|
||||
db_session.rollback()
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CREATE TESTS
|
||||
# =============================================================================
|
||||
|
||||
class TestCreateTopic:
|
||||
"""Tests für POST /api/alerts/topics"""
|
||||
|
||||
def test_create_topic_minimal(self, client):
|
||||
"""Test: Topic mit minimalen Daten erstellen."""
|
||||
response = client.post(
|
||||
"/api/alerts/topics",
|
||||
json={"name": "Test Topic"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["name"] == "Test Topic"
|
||||
assert data["is_active"] is True
|
||||
assert data["feed_type"] == "rss"
|
||||
assert data["fetch_interval_minutes"] == 60
|
||||
|
||||
def test_create_topic_full(self, client):
|
||||
"""Test: Topic mit allen Feldern erstellen."""
|
||||
response = client.post(
|
||||
"/api/alerts/topics",
|
||||
json={
|
||||
"name": "Vollständiges Topic",
|
||||
"description": "Eine Beschreibung",
|
||||
"feed_url": "https://example.com/feed.rss",
|
||||
"feed_type": "rss",
|
||||
"fetch_interval_minutes": 30,
|
||||
"is_active": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["name"] == "Vollständiges Topic"
|
||||
assert data["description"] == "Eine Beschreibung"
|
||||
assert data["feed_url"] == "https://example.com/feed.rss"
|
||||
assert data["fetch_interval_minutes"] == 30
|
||||
assert data["is_active"] is False
|
||||
|
||||
def test_create_topic_empty_name_fails(self, client):
|
||||
"""Test: Leerer Name führt zu Fehler."""
|
||||
response = client.post(
|
||||
"/api/alerts/topics",
|
||||
json={"name": ""},
|
||||
)
|
||||
|
||||
assert response.status_code == 422 # Validation Error
|
||||
|
||||
def test_create_topic_invalid_interval(self, client):
|
||||
"""Test: Ungültiges Fetch-Intervall führt zu Fehler."""
|
||||
response = client.post(
|
||||
"/api/alerts/topics",
|
||||
json={
|
||||
"name": "Test",
|
||||
"fetch_interval_minutes": 1, # < 5 ist ungültig
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# READ TESTS
|
||||
# =============================================================================
|
||||
|
||||
class TestReadTopics:
|
||||
"""Tests für GET /api/alerts/topics"""
|
||||
|
||||
def test_list_topics_empty(self, client):
|
||||
"""Test: Leere Topic-Liste."""
|
||||
response = client.get("/api/alerts/topics")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["topics"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_topics(self, client):
|
||||
"""Test: Topics auflisten."""
|
||||
# Erstelle Topics
|
||||
client.post("/api/alerts/topics", json={"name": "Topic 1"})
|
||||
client.post("/api/alerts/topics", json={"name": "Topic 2"})
|
||||
client.post("/api/alerts/topics", json={"name": "Topic 3"})
|
||||
|
||||
response = client.get("/api/alerts/topics")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 3
|
||||
assert len(data["topics"]) == 3
|
||||
|
||||
def test_list_topics_filter_active(self, client):
|
||||
"""Test: Nur aktive Topics auflisten."""
|
||||
# Erstelle aktives und inaktives Topic
|
||||
client.post("/api/alerts/topics", json={"name": "Aktiv", "is_active": True})
|
||||
client.post("/api/alerts/topics", json={"name": "Inaktiv", "is_active": False})
|
||||
|
||||
response = client.get("/api/alerts/topics?is_active=true")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 1
|
||||
assert data["topics"][0]["name"] == "Aktiv"
|
||||
|
||||
def test_get_topic_by_id(self, client):
|
||||
"""Test: Topic nach ID abrufen."""
|
||||
# Erstelle Topic
|
||||
create_response = client.post(
|
||||
"/api/alerts/topics",
|
||||
json={"name": "Find Me"},
|
||||
)
|
||||
topic_id = create_response.json()["id"]
|
||||
|
||||
# Abrufen
|
||||
response = client.get(f"/api/alerts/topics/{topic_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Find Me"
|
||||
|
||||
def test_get_topic_not_found(self, client):
|
||||
"""Test: Topic nicht gefunden."""
|
||||
response = client.get("/api/alerts/topics/nonexistent-id")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# UPDATE TESTS
|
||||
# =============================================================================
|
||||
|
||||
class TestUpdateTopic:
|
||||
"""Tests für PUT /api/alerts/topics/{id}"""
|
||||
|
||||
def test_update_topic_name(self, client):
|
||||
"""Test: Topic-Namen aktualisieren."""
|
||||
# Erstelle Topic
|
||||
create_response = client.post(
|
||||
"/api/alerts/topics",
|
||||
json={"name": "Original"},
|
||||
)
|
||||
topic_id = create_response.json()["id"]
|
||||
|
||||
# Update
|
||||
response = client.put(
|
||||
f"/api/alerts/topics/{topic_id}",
|
||||
json={"name": "Updated"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Updated"
|
||||
|
||||
def test_update_topic_partial(self, client):
|
||||
"""Test: Partielles Update."""
|
||||
# Erstelle Topic
|
||||
create_response = client.post(
|
||||
"/api/alerts/topics",
|
||||
json={
|
||||
"name": "Original",
|
||||
"description": "Desc",
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
topic_id = create_response.json()["id"]
|
||||
|
||||
# Nur is_active ändern
|
||||
response = client.put(
|
||||
f"/api/alerts/topics/{topic_id}",
|
||||
json={"is_active": False},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "Original" # Unverändert
|
||||
assert data["description"] == "Desc" # Unverändert
|
||||
assert data["is_active"] is False # Geändert
|
||||
|
||||
def test_update_topic_not_found(self, client):
|
||||
"""Test: Update für nicht existierendes Topic."""
|
||||
response = client.put(
|
||||
"/api/alerts/topics/nonexistent-id",
|
||||
json={"name": "New Name"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_update_topic_empty_fails(self, client):
|
||||
"""Test: Update ohne Änderungen führt zu Fehler."""
|
||||
# Erstelle Topic
|
||||
create_response = client.post(
|
||||
"/api/alerts/topics",
|
||||
json={"name": "Test"},
|
||||
)
|
||||
topic_id = create_response.json()["id"]
|
||||
|
||||
# Leeres Update
|
||||
response = client.put(
|
||||
f"/api/alerts/topics/{topic_id}",
|
||||
json={},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DELETE TESTS
|
||||
# =============================================================================
|
||||
|
||||
class TestDeleteTopic:
|
||||
"""Tests für DELETE /api/alerts/topics/{id}"""
|
||||
|
||||
def test_delete_topic(self, client):
|
||||
"""Test: Topic löschen."""
|
||||
# Erstelle Topic
|
||||
create_response = client.post(
|
||||
"/api/alerts/topics",
|
||||
json={"name": "To Delete"},
|
||||
)
|
||||
topic_id = create_response.json()["id"]
|
||||
|
||||
# Löschen
|
||||
response = client.delete(f"/api/alerts/topics/{topic_id}")
|
||||
assert response.status_code == 204
|
||||
|
||||
# Prüfen, dass es weg ist
|
||||
get_response = client.get(f"/api/alerts/topics/{topic_id}")
|
||||
assert get_response.status_code == 404
|
||||
|
||||
def test_delete_topic_not_found(self, client):
|
||||
"""Test: Löschen eines nicht existierenden Topics."""
|
||||
response = client.delete("/api/alerts/topics/nonexistent-id")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STATS TESTS
|
||||
# =============================================================================
|
||||
|
||||
class TestTopicStats:
|
||||
"""Tests für GET /api/alerts/topics/{id}/stats"""
|
||||
|
||||
def test_get_topic_stats(self, client):
|
||||
"""Test: Topic-Statistiken abrufen."""
|
||||
# Erstelle Topic
|
||||
create_response = client.post(
|
||||
"/api/alerts/topics",
|
||||
json={"name": "Stats Test"},
|
||||
)
|
||||
topic_id = create_response.json()["id"]
|
||||
|
||||
# Stats abrufen
|
||||
response = client.get(f"/api/alerts/topics/{topic_id}/stats")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["topic_id"] == topic_id
|
||||
assert data["name"] == "Stats Test"
|
||||
assert data["total_alerts"] == 0
|
||||
|
||||
def test_get_stats_not_found(self, client):
|
||||
"""Test: Stats für nicht existierendes Topic."""
|
||||
response = client.get("/api/alerts/topics/nonexistent-id/stats")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ACTIVATION TESTS
|
||||
# =============================================================================
|
||||
|
||||
class TestTopicActivation:
|
||||
"""Tests für Topic-Aktivierung/-Deaktivierung"""
|
||||
|
||||
def test_activate_topic(self, client):
|
||||
"""Test: Topic aktivieren."""
|
||||
# Erstelle inaktives Topic
|
||||
create_response = client.post(
|
||||
"/api/alerts/topics",
|
||||
json={"name": "Inactive", "is_active": False},
|
||||
)
|
||||
topic_id = create_response.json()["id"]
|
||||
|
||||
# Aktivieren
|
||||
response = client.post(f"/api/alerts/topics/{topic_id}/activate")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["is_active"] is True
|
||||
|
||||
def test_deactivate_topic(self, client):
|
||||
"""Test: Topic deaktivieren."""
|
||||
# Erstelle aktives Topic
|
||||
create_response = client.post(
|
||||
"/api/alerts/topics",
|
||||
json={"name": "Active", "is_active": True},
|
||||
)
|
||||
topic_id = create_response.json()["id"]
|
||||
|
||||
# Deaktivieren
|
||||
response = client.post(f"/api/alerts/topics/{topic_id}/deactivate")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["is_active"] is False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FETCH TESTS
|
||||
# =============================================================================
|
||||
|
||||
class TestTopicFetch:
|
||||
"""Tests für POST /api/alerts/topics/{id}/fetch"""
|
||||
|
||||
def test_fetch_topic_no_url(self, client):
|
||||
"""Test: Fetch ohne Feed-URL führt zu Fehler."""
|
||||
# Erstelle Topic ohne URL
|
||||
create_response = client.post(
|
||||
"/api/alerts/topics",
|
||||
json={"name": "No URL"},
|
||||
)
|
||||
topic_id = create_response.json()["id"]
|
||||
|
||||
# Fetch versuchen
|
||||
response = client.post(f"/api/alerts/topics/{topic_id}/fetch")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Feed-URL" in response.json()["detail"]
|
||||
|
||||
def test_fetch_topic_not_found(self, client):
|
||||
"""Test: Fetch für nicht existierendes Topic."""
|
||||
response = client.post("/api/alerts/topics/nonexistent-id/fetch")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
@patch("alerts_agent.ingestion.rss_fetcher.fetch_and_store_feed", new_callable=AsyncMock)
|
||||
def test_fetch_topic_success(self, mock_fetch, client):
|
||||
"""Test: Erfolgreiches Fetchen."""
|
||||
# Mock Setup - async function braucht AsyncMock
|
||||
mock_fetch.return_value = {
|
||||
"new_items": 5,
|
||||
"duplicates_skipped": 2,
|
||||
}
|
||||
|
||||
# Erstelle Topic mit URL
|
||||
create_response = client.post(
|
||||
"/api/alerts/topics",
|
||||
json={
|
||||
"name": "With URL",
|
||||
"feed_url": "https://example.com/feed.rss",
|
||||
},
|
||||
)
|
||||
topic_id = create_response.json()["id"]
|
||||
|
||||
# Fetch ausführen
|
||||
response = client.post(f"/api/alerts/topics/{topic_id}/fetch")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["new_items"] == 5
|
||||
assert data["duplicates_skipped"] == 2
|
||||
Reference in New Issue
Block a user