feat: externe Links auf Erreichbarkeit prüfen (check-ext-links)

Neuer Befehl `python -m scanner check-ext-links`:
- Liest alle externen <a>-Links aus dem letzten Snapshot
- Prüft jeden per HEAD-Request (Fallback auf GET bei HTTP 405)
- Meldet 4xx/5xx und Verbindungsfehler mit Quelladressen
- Report unter reports/<ts>_ext-links/
- Exit-Code 1 bei kaputten Links, 0 wenn alle erreichbar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-06-12 04:14:50 +02:00
commit ecd01abf98
3 changed files with 263 additions and 1 deletions

82
tests/test_ext_links.py Normal file
View file

@ -0,0 +1,82 @@
"""Tests for external link reachability check."""
from unittest.mock import MagicMock, patch
import pytest
import requests
from scanner.checker import check_external_links
def _mock_response(status_code=200, url="https://example.com/"):
resp = MagicMock()
resp.status_code = status_code
resp.url = url
return resp
class TestCheckExternalLinks:
def test_reachable_link_recorded(self):
with patch("scanner.checker.requests.Session") as MockSession:
session = MockSession.return_value.__enter__.return_value
MockSession.return_value = MagicMock()
inst = MockSession.return_value
inst.head.return_value = _mock_response(200)
inst.close = MagicMock()
results = check_external_links(["https://example.com/"], delay=0)
assert results["https://example.com/"]["status"] == 200
assert results["https://example.com/"]["error"] is None
def test_broken_link_404_recorded(self):
with patch("scanner.checker.requests.Session") as MockSession:
inst = MockSession.return_value
inst.head.return_value = _mock_response(404, "https://example.com/gone")
inst.close = MagicMock()
results = check_external_links(["https://example.com/gone"], delay=0)
assert results["https://example.com/gone"]["status"] == 404
def test_connection_error_recorded(self):
with patch("scanner.checker.requests.Session") as MockSession:
inst = MockSession.return_value
inst.head.side_effect = requests.exceptions.ConnectionError("refused")
inst.close = MagicMock()
results = check_external_links(["https://unreachable.example.com/"], delay=0)
r = results["https://unreachable.example.com/"]
assert r["status"] is None
assert "refused" in r["error"]
def test_head_405_falls_back_to_get(self):
with patch("scanner.checker.requests.Session") as MockSession:
inst = MockSession.return_value
inst.head.return_value = _mock_response(405)
get_resp = _mock_response(200)
get_resp.close = MagicMock()
inst.get.return_value = get_resp
inst.close = MagicMock()
results = check_external_links(["https://example.com/no-head"], delay=0)
inst.get.assert_called_once()
assert results["https://example.com/no-head"]["status"] == 200
def test_multiple_urls_all_checked(self):
urls = [f"https://example.com/{i}" for i in range(3)]
with patch("scanner.checker.requests.Session") as MockSession:
inst = MockSession.return_value
inst.head.return_value = _mock_response(200)
inst.close = MagicMock()
results = check_external_links(urls, delay=0)
assert len(results) == 3
assert inst.head.call_count == 3
def test_empty_list_returns_empty(self):
with patch("scanner.checker.requests.Session"):
results = check_external_links([], delay=0)
assert results == {}