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>
467 lines
16 KiB
Python
467 lines
16 KiB
Python
"""
|
|
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
|