fix: M1 exclude_paths '/' jamulix, M2 seed-should_crawl filter, M3 alert dedup

- M1: Removed '/' from jamulix.de exclude_paths (blockaded ALL paths via startswith)
- M2: Seed URLs now pass through should_crawl() in crawler.py
- M3: Alert deduplication via last_alert.json (hash of reasons+level+score)
- Added 11 tests for alert deduplication (277 total, all green)
This commit is contained in:
Dieter Schlüter 2026-06-14 21:12:36 +02:00
commit 248788e282
4 changed files with 190 additions and 4 deletions

View file

@ -1,10 +1,20 @@
"""Tests for scanner/alerter.py — alert dispatch (e-mail + webhook)."""
import json
import os
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import MagicMock, call, patch
import pytest
from scanner.alerter import _make_subject, _format_body, send_alert
from scanner.alerter import (
_compute_alert_key,
_load_last_alert,
_save_last_alert,
_make_subject,
_format_body,
send_alert,
)
# ---------------------------------------------------------------------------
@ -38,8 +48,9 @@ def _cfg(
pw_env: str = "TEST_SMTP_PW",
webhook_enabled: bool = False,
webhook_url: str = "",
data_dir: str | None = None,
) -> dict:
return {
cfg: dict = {
"alerting": {
"min_level": min_level,
"email": {
@ -58,6 +69,9 @@ def _cfg(
},
}
}
if data_dir is not None:
cfg["data_dir"] = data_dir
return cfg
# ---------------------------------------------------------------------------
@ -284,3 +298,93 @@ class TestSendAlertWebhook:
# green is below min_level yellow → suppressed anyway, but let's test enabled+green=red path
cfg["alerting"]["min_level"] = "green"
send_alert(_report("green"), cfg)
# ---------------------------------------------------------------------------
# Deduplication
# ---------------------------------------------------------------------------
class TestComputeAlertKey:
def test_same_reasons_same_key(self):
r1 = _report("red", "https://a.de")
r2 = _report("red", "https://b.de") # anderer Target, gleiche reasons/level/score
assert _compute_alert_key(r1) == _compute_alert_key(r2)
def test_different_score_different_key(self):
r1 = _report("yellow") # score=30
r2 = _report("red") # score=80
assert _compute_alert_key(r1) != _compute_alert_key(r2)
def test_different_reasons_different_key(self):
r1 = _report("yellow")
r1["assessment"]["reasons"] = ["Grund A"]
r2 = _report("yellow")
r2["assessment"]["reasons"] = ["Grund B"]
assert _compute_alert_key(r1) != _compute_alert_key(r2)
def test_reasons_order_independent(self):
r1 = _report("yellow")
r1["assessment"]["reasons"] = ["B", "A", "C"]
r2 = _report("yellow")
r2["assessment"]["reasons"] = ["C", "A", "B"] # gleiche, andere Reihenfolge
assert _compute_alert_key(r1) == _compute_alert_key(r2)
class TestLoadSaveLastAlert:
def test_load_missing_file_returns_empty(self):
with TemporaryDirectory() as tmp:
state = _load_last_alert(Path(tmp))
assert state == {}
def test_load_corrupt_json_returns_empty(self):
with TemporaryDirectory() as tmp:
(Path(tmp) / "last_alert.json").write_text("{invalid")
state = _load_last_alert(Path(tmp))
assert state == {}
def test_save_and_load_roundtrip(self):
with TemporaryDirectory() as tmp:
d = Path(tmp)
_save_last_alert(d, "abc123")
state = _load_last_alert(d)
assert state["key"] == "abc123"
assert "sent_at" in state
class TestSendAlertDedup:
@patch("scanner.alerter._send_email")
def test_first_alert_sent(self, mock_email):
with TemporaryDirectory() as tmp:
cfg = _cfg(data_dir=tmp)
send_alert(_report("yellow"), cfg)
mock_email.assert_called_once()
@patch("scanner.alerter._send_email")
def test_identical_second_alert_suppressed(self, mock_email):
with TemporaryDirectory() as tmp:
cfg = _cfg(data_dir=tmp)
report = _report("yellow")
send_alert(report, cfg)
send_alert(report, cfg) # gleiche reasons → unterdrückt
assert mock_email.call_count == 1
@patch("scanner.alerter._send_email")
def test_changed_reasons_triggers_new_alert(self, mock_email):
with TemporaryDirectory() as tmp:
cfg = _cfg(data_dir=tmp)
r1 = _report("yellow")
r1["assessment"]["reasons"] = ["Grund A"]
send_alert(r1, cfg)
r2 = _report("yellow")
r2["assessment"]["reasons"] = ["Grund B"] # anderer Grund
send_alert(r2, cfg)
assert mock_email.call_count == 2
@patch("scanner.alerter._send_email")
def test_no_data_dir_sends_every_time(self, mock_email):
cfg = _cfg(data_dir=None) # kein data_dir → keine Dedup
send_alert(_report("yellow"), cfg)
send_alert(_report("yellow"), cfg)
assert mock_email.call_count == 2