""" Tests für Alerts Agent Repository. Testet CRUD-Operationen für Topics, Items, Rules und Profiles. """ import pytest from datetime import datetime from unittest.mock import MagicMock, patch # Test mit In-Memory SQLite für Isolation from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from alerts_agent.db.models import ( AlertTopicDB, AlertItemDB, AlertRuleDB, AlertProfileDB, AlertSourceEnum, AlertStatusEnum, RelevanceDecisionEnum, FeedTypeEnum, RuleActionEnum ) from alerts_agent.db.repository import ( TopicRepository, AlertItemRepository, RuleRepository, ProfileRepository ) # Nutze classroom_engine Base für konsistente Schemas from classroom_engine.database import Base @pytest.fixture def db_session(): """Erstellt eine In-Memory SQLite-Session für Tests.""" engine = create_engine("sqlite:///:memory:", echo=False) Base.metadata.create_all(engine) SessionLocal = sessionmaker(bind=engine) session = SessionLocal() yield session session.close() # ============================================================================= # TOPIC REPOSITORY TESTS # ============================================================================= class TestTopicRepository: """Tests für TopicRepository.""" def test_create_topic(self, db_session): """Test: Topic erstellen.""" repo = TopicRepository(db_session) topic = repo.create( name="Test Topic", feed_url="https://example.com/feed", feed_type="rss", description="Test Description", ) assert topic.id is not None assert topic.name == "Test Topic" assert topic.feed_url == "https://example.com/feed" assert topic.feed_type == FeedTypeEnum.RSS assert topic.is_active is True def test_get_topic_by_id(self, db_session): """Test: Topic nach ID abrufen.""" repo = TopicRepository(db_session) created = repo.create(name="Find Me") found = repo.get_by_id(created.id) assert found is not None assert found.name == "Find Me" def test_get_topic_not_found(self, db_session): """Test: Topic nicht gefunden.""" repo = TopicRepository(db_session) found = repo.get_by_id("nonexistent-id") assert found is None def test_update_topic(self, db_session): """Test: Topic aktualisieren.""" repo = TopicRepository(db_session) topic = repo.create(name="Original Name") updated = repo.update(topic.id, name="Updated Name", is_active=False) assert updated.name == "Updated Name" assert updated.is_active is False def test_delete_topic(self, db_session): """Test: Topic löschen.""" repo = TopicRepository(db_session) topic = repo.create(name="To Delete") result = repo.delete(topic.id) assert result is True assert repo.get_by_id(topic.id) is None def test_get_all_topics(self, db_session): """Test: Alle Topics abrufen.""" repo = TopicRepository(db_session) repo.create(name="Topic 1") repo.create(name="Topic 2") repo.create(name="Topic 3") topics = repo.get_all() assert len(topics) == 3 def test_get_active_topics(self, db_session): """Test: Nur aktive Topics abrufen.""" repo = TopicRepository(db_session) repo.create(name="Active 1") repo.create(name="Active 2") inactive = repo.create(name="Inactive") repo.update(inactive.id, is_active=False) active_topics = repo.get_all(is_active=True) assert len(active_topics) == 2 # ============================================================================= # ALERT ITEM REPOSITORY TESTS # ============================================================================= class TestAlertItemRepository: """Tests für AlertItemRepository.""" @pytest.fixture def topic_id(self, db_session): """Erstellt ein Test-Topic und gibt die ID zurück.""" topic_repo = TopicRepository(db_session) topic = topic_repo.create(name="Test Topic") return topic.id def test_create_alert(self, db_session, topic_id): """Test: Alert erstellen.""" repo = AlertItemRepository(db_session) alert = repo.create( topic_id=topic_id, title="Test Alert", url="https://example.com/article", snippet="Test snippet content", ) assert alert.id is not None assert alert.title == "Test Alert" assert alert.url_hash is not None assert alert.status == AlertStatusEnum.NEW def test_create_if_not_exists_creates(self, db_session, topic_id): """Test: Alert erstellen wenn nicht existiert.""" repo = AlertItemRepository(db_session) alert = repo.create_if_not_exists( topic_id=topic_id, title="New Alert", url="https://example.com/new", ) assert alert is not None assert alert.title == "New Alert" def test_create_if_not_exists_duplicate(self, db_session, topic_id): """Test: Duplikat wird nicht erstellt.""" repo = AlertItemRepository(db_session) url = "https://example.com/duplicate" first = repo.create_if_not_exists(topic_id=topic_id, title="First", url=url) second = repo.create_if_not_exists(topic_id=topic_id, title="Second", url=url) assert first is not None assert second is None # Duplikat def test_update_scoring(self, db_session, topic_id): """Test: Scoring aktualisieren.""" repo = AlertItemRepository(db_session) alert = repo.create(topic_id=topic_id, title="To Score", url="https://example.com/score") updated = repo.update_scoring( alert_id=alert.id, score=0.85, decision="KEEP", reasons=["relevant"], summary="Important article", model="test-model", ) assert updated.relevance_score == 0.85 assert updated.relevance_decision == RelevanceDecisionEnum.KEEP assert updated.status == AlertStatusEnum.SCORED def test_get_inbox(self, db_session, topic_id): """Test: Inbox abrufen.""" repo = AlertItemRepository(db_session) # Erstelle Alerts mit verschiedenen Decisions alert1 = repo.create(topic_id=topic_id, title="Keep Alert", url="https://example.com/1") repo.update_scoring(alert1.id, 0.9, "KEEP", [], None, None) alert2 = repo.create(topic_id=topic_id, title="Drop Alert", url="https://example.com/2") repo.update_scoring(alert2.id, 0.1, "DROP", [], None, None) alert3 = repo.create(topic_id=topic_id, title="Review Alert", url="https://example.com/3") repo.update_scoring(alert3.id, 0.5, "REVIEW", [], None, None) # Default-Inbox (KEEP + REVIEW) inbox = repo.get_inbox() assert len(inbox) == 2 # KEEP und REVIEW # Nur KEEP keep_only = repo.get_inbox(decision="KEEP") assert len(keep_only) == 1 def test_get_unscored(self, db_session, topic_id): """Test: Unbewertete Alerts abrufen.""" repo = AlertItemRepository(db_session) # Erstelle neue Alerts repo.create(topic_id=topic_id, title="Unscored 1", url="https://example.com/u1") repo.create(topic_id=topic_id, title="Unscored 2", url="https://example.com/u2") # Einen bewerten alert3 = repo.create(topic_id=topic_id, title="Scored", url="https://example.com/s1") repo.update_scoring(alert3.id, 0.5, "REVIEW", [], None, None) unscored = repo.get_unscored() assert len(unscored) == 2 def test_mark_reviewed(self, db_session, topic_id): """Test: Alert als reviewed markieren.""" repo = AlertItemRepository(db_session) alert = repo.create(topic_id=topic_id, title="To Review", url="https://example.com/review") reviewed = repo.mark_reviewed( alert_id=alert.id, is_relevant=True, notes="Good article", tags=["important"], ) assert reviewed.status == AlertStatusEnum.REVIEWED assert reviewed.user_marked_relevant is True assert reviewed.user_notes == "Good article" assert "important" in reviewed.user_tags # ============================================================================= # RULE REPOSITORY TESTS # ============================================================================= class TestRuleRepository: """Tests für RuleRepository.""" def test_create_rule(self, db_session): """Test: Regel erstellen.""" repo = RuleRepository(db_session) rule = repo.create( name="Test Rule", conditions=[{"field": "title", "op": "contains", "value": "test"}], action_type="keep", priority=10, ) assert rule.id is not None assert rule.name == "Test Rule" assert rule.priority == 10 assert rule.is_active is True def test_get_active_rules_ordered(self, db_session): """Test: Aktive Regeln nach Priorität sortiert.""" repo = RuleRepository(db_session) repo.create(name="Low Priority", conditions=[], priority=1) repo.create(name="High Priority", conditions=[], priority=100) repo.create(name="Medium Priority", conditions=[], priority=50) rules = repo.get_active() assert len(rules) == 3 assert rules[0].name == "High Priority" assert rules[1].name == "Medium Priority" assert rules[2].name == "Low Priority" def test_update_rule(self, db_session): """Test: Regel aktualisieren.""" repo = RuleRepository(db_session) rule = repo.create(name="Original", conditions=[], action_type="keep") updated = repo.update(rule.id, name="Updated", action_type="drop") assert updated.name == "Updated" assert updated.action_type == RuleActionEnum.DROP def test_increment_match_count(self, db_session): """Test: Match-Counter erhöhen.""" repo = RuleRepository(db_session) rule = repo.create(name="Matcher", conditions=[]) assert rule.match_count == 0 repo.increment_match_count(rule.id) repo.increment_match_count(rule.id) updated = repo.get_by_id(rule.id) assert updated.match_count == 2 assert updated.last_matched_at is not None # ============================================================================= # PROFILE REPOSITORY TESTS # ============================================================================= class TestProfileRepository: """Tests für ProfileRepository.""" def test_create_default_profile(self, db_session): """Test: Default-Profil erstellen.""" repo = ProfileRepository(db_session) profile = repo.create_default_education_profile() assert profile.id is not None assert len(profile.priorities) > 0 assert "Inklusion" in [p["label"] for p in profile.priorities] def test_get_or_create(self, db_session): """Test: Get-or-Create Pattern.""" repo = ProfileRepository(db_session) # Erstes Mal erstellt profile1 = repo.get_or_create(user_id="user-123") assert profile1 is not None # Zweites Mal holt existierendes profile2 = repo.get_or_create(user_id="user-123") assert profile2.id == profile1.id def test_update_priorities(self, db_session): """Test: Prioritäten aktualisieren.""" repo = ProfileRepository(db_session) profile = repo.get_or_create() new_priorities = [ {"label": "New Priority", "weight": 0.9, "keywords": ["test"]} ] updated = repo.update_priorities(profile.id, new_priorities) assert len(updated.priorities) == 1 assert updated.priorities[0]["label"] == "New Priority" def test_add_feedback_positive(self, db_session): """Test: Positives Feedback hinzufügen.""" repo = ProfileRepository(db_session) profile = repo.get_or_create() initial_kept = profile.total_kept repo.add_feedback( profile_id=profile.id, title="Relevant Article", url="https://example.com/relevant", is_relevant=True, reason="Very informative", ) updated = repo.get_by_id(profile.id) assert updated.total_kept == initial_kept + 1 assert len(updated.positive_examples) == 1 def test_add_feedback_negative(self, db_session): """Test: Negatives Feedback hinzufügen.""" repo = ProfileRepository(db_session) profile = repo.get_or_create() initial_dropped = profile.total_dropped repo.add_feedback( profile_id=profile.id, title="Irrelevant Article", url="https://example.com/irrelevant", is_relevant=False, reason="Off-topic", ) updated = repo.get_by_id(profile.id) assert updated.total_dropped == initial_dropped + 1 assert len(updated.negative_examples) == 1 def test_feedback_limits_examples(self, db_session): """Test: Beispiele werden auf 20 begrenzt.""" repo = ProfileRepository(db_session) profile = repo.get_or_create() # Füge 25 positive Beispiele hinzu for i in range(25): repo.add_feedback( profile_id=profile.id, title=f"Article {i}", url=f"https://example.com/{i}", is_relevant=True, ) updated = repo.get_by_id(profile.id) assert len(updated.positive_examples) == 20 # Max 20 # ============================================================================= # INTEGRATION TESTS # ============================================================================= class TestRepositoryIntegration: """Integration Tests für Repository-Zusammenspiel.""" def test_topic_with_alerts_cascade_delete(self, db_session): """Test: Topic-Löschung löscht auch zugehörige Alerts.""" topic_repo = TopicRepository(db_session) alert_repo = AlertItemRepository(db_session) # Erstelle Topic mit Alerts topic = topic_repo.create(name="To Delete") alert_repo.create(topic_id=topic.id, title="Alert 1", url="https://example.com/1") alert_repo.create(topic_id=topic.id, title="Alert 2", url="https://example.com/2") # Prüfe dass Alerts existieren alerts = alert_repo.get_by_topic(topic.id) assert len(alerts) == 2 # Lösche Topic topic_repo.delete(topic.id) # Alerts sollten auch gelöscht sein (CASCADE) alerts_after = alert_repo.get_by_topic(topic.id) assert len(alerts_after) == 0 def test_scoring_workflow(self, db_session): """Test: Kompletter Scoring-Workflow.""" topic_repo = TopicRepository(db_session) alert_repo = AlertItemRepository(db_session) profile_repo = ProfileRepository(db_session) # Setup topic = topic_repo.create(name="Workflow Test") profile = profile_repo.create_default_education_profile() # Alerts erstellen alert1 = alert_repo.create(topic_id=topic.id, title="Inklusion im Unterricht", url="https://example.com/a1") alert2 = alert_repo.create(topic_id=topic.id, title="Stellenanzeige Lehrer", url="https://example.com/a2") alert3 = alert_repo.create(topic_id=topic.id, title="Neutral News", url="https://example.com/a3") # Scoring simulieren alert_repo.update_scoring(alert1.id, 0.85, "KEEP", ["priority_match"], "Relevant", "test") alert_repo.update_scoring(alert2.id, 0.1, "DROP", ["exclusion_match"], None, "test") alert_repo.update_scoring(alert3.id, 0.5, "REVIEW", [], None, "test") # Stats prüfen by_decision = alert_repo.count_by_decision(topic.id) assert by_decision.get("KEEP", 0) == 1 assert by_decision.get("DROP", 0) == 1 assert by_decision.get("REVIEW", 0) == 1