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