feat(M5): source blog RSS — fetch, parsing, déduplication et état persistant

Implémentation complète de la source blog RSS du collège :
- BlogRSSClient (sources/blog/rss.py) : client sans état récupérant et
  parsant le flux via feedparser, avec déduplication par ensemble de
  GUIDs connus, cache HTTP conditionnel (ETag/Last-Modified), conversion
  HTML→texte (BeautifulSoup), tri déterministe (date desc puis id asc),
  et mode dégradé (flux invalide/erreur → warning expurgé + liste vide).
- BlogRSSFetchResult (sources/blog/result.py) : résultat immuable
  contenant articles, en-têtes de cache et indicateur not_modified.
- BlogRSSState (sources/blog/state.py) : persistance JSON tolérante
  (GUIDs triés, version, ETag, Last-Modified) avec redaction des chemins
  dans les logs.
- Fixture tests/fixtures/blog_rss.xml : flux RSS 2.0 anonymisé, 3
  articles, dates fixes, ordre non chronologique.
- 38 tests unitaires (22 client + 16 state) couvrant parsing nominal,
  déduplication intra-flux, 304, bozo, erreurs réseau, non-fuite de
  secrets, tri secondaire, persistance d'état et tolérance aux fichiers
  corrompus.
- Documentation : TODO.md M5 coché, GUIDE_DEV_PYTHON.md §5 bis aligné
  avec l'API livrée (known_guids, BlogRSSFetchResult, BlogRSSState).
- Configuration : feedparser ajouté aux additional_dependencies du hook
  mypy pre-commit pour aligner l'environnement isolé avec le .venv.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
This commit is contained in:
2026-09-06 20:23:49 +02:00
parent 2c935ef648
commit 6d1a7a649f
11 changed files with 1895 additions and 161 deletions

View File

@@ -0,0 +1,311 @@
"""Tests unitaires pour le gestionnaire d'état du flux RSS du blog.
Ce module valide le comportement de :class:`BlogRSSState` dans
:mod:`pronote_sync.sources.blog.state`. Les tests couvrent :
- La persistance des GUID connus et des en-têtes de cache HTTP,
- La tolérance aux erreurs (fichier absent, corrompu, version incompatible),
- Le tri alphabétique des GUID lors de la sauvegarde,
- La réinitialisation complète de l'état.
Tous les tests utilisent des fichiers temporaires via la fixture ``tmp_path``.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from pronote_sync.sources.blog.state import BlogRSSState
def test_state_file_absent_empty_state(tmp_path: Path) -> None:
"""Vérifie qu'un fichier d'état absent initialise un état vide.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "nonexistent.json"
state = BlogRSSState(state_file)
assert state.get_known_guids() == frozenset()
assert state.get_cache_headers() == (None, None)
def test_add_guids_persists(tmp_path: Path) -> None:
"""Vérifie que l'ajout de GUID persiste dans le fichier JSON.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
state = BlogRSSState(state_file)
state.add_guids(["guid-2", "guid-1", "guid-3"])
assert state.get_known_guids() == frozenset({"guid-1", "guid-2", "guid-3"})
# Vérification du contenu du fichier
saved_data = json.loads(state_file.read_text(encoding="utf-8"))
assert saved_data["known_guids"] == ["guid-1", "guid-2", "guid-3"]
def test_add_guids_empty_noop(tmp_path: Path) -> None:
"""Vérifie que l'ajout d'une liste vide ne modifie pas le fichier.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
state = BlogRSSState(state_file)
# Ajout initial de GUID
state.add_guids(["guid-1"])
original_content = state_file.read_text(encoding="utf-8")
# Ajout d'une liste vide
state.add_guids([])
# Vérification que le fichier n'a pas été modifié (comparaison par contenu)
assert state_file.read_text(encoding="utf-8") == original_content
def test_state_load_persisted_guids(tmp_path: Path) -> None:
"""Vérifie que les GUID persistés sont rechargés dans une nouvelle instance.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
# Création et sauvegarde de l'état initial
state1 = BlogRSSState(state_file)
state1.add_guids(["guid-1", "guid-2"])
# Création d'une nouvelle instance avec le même fichier
state2 = BlogRSSState(state_file)
assert state2.get_known_guids() == frozenset({"guid-1", "guid-2"})
def test_state_load_cache_headers(tmp_path: Path) -> None:
"""Vérifie que les en-têtes de cache persistés sont rechargés.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
# Création et sauvegarde des en-têtes de cache
state1 = BlogRSSState(state_file)
state1.update_cache_headers("etag-123", "Wed, 01 Sep 2026 GMT")
# Création d'une nouvelle instance avec le même fichier
state2 = BlogRSSState(state_file)
assert state2.get_cache_headers() == ("etag-123", "Wed, 01 Sep 2026 GMT")
def test_corrupt_json_warning(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""Vérifie qu'un fichier JSON corrompu déclenche un avertissement et initialise un état vide.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:param caplog: Fixture pytest pour capturer les logs.
:return: None
"""
state_file = tmp_path / "corrupt.json"
state_file.write_text("not json{", encoding="utf-8")
with caplog.at_level("WARNING"):
state = BlogRSSState(state_file)
assert state.get_known_guids() == frozenset()
assert state.get_cache_headers() == (None, None)
assert "Impossible de charger le fichier d'état blog RSS" in caplog.text
def test_wrong_version_warning(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""Vérifie qu'une version incompatible déclenche un avertissement et initialise un état vide.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:param caplog: Fixture pytest pour capturer les logs.
:return: None
"""
state_file = tmp_path / "wrong_version.json"
state_file.write_text(
json.dumps({"version": 99, "known_guids": ["x"], "etag": None, "last_modified": None}),
encoding="utf-8",
)
with caplog.at_level("WARNING"):
state = BlogRSSState(state_file)
assert state.get_known_guids() == frozenset()
assert state.get_cache_headers() == (None, None)
assert "version absente ou non supportée" in caplog.text
def test_missing_version_warning(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""Vérifie qu'un fichier sans champ version déclenche un avertissement et initialise un état vide.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:param caplog: Fixture pytest pour capturer les logs.
:return: None
"""
state_file = tmp_path / "missing_version.json"
state_file.write_text(
json.dumps({"known_guids": ["x"], "etag": None, "last_modified": None}),
encoding="utf-8",
)
with caplog.at_level("WARNING"):
state = BlogRSSState(state_file)
assert state.get_known_guids() == frozenset()
assert state.get_cache_headers() == (None, None)
assert "version absente ou non supportée" in caplog.text
def test_known_guids_sorted_on_save(tmp_path: Path) -> None:
"""Vérifie que les GUID sont triés alphabétiquement lors de la sauvegarde.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
state = BlogRSSState(state_file)
state.add_guids(["c-guid", "a-guid", "b-guid"])
saved_data = json.loads(state_file.read_text(encoding="utf-8"))
assert saved_data["known_guids"] == ["a-guid", "b-guid", "c-guid"]
def test_clear_resets_state(tmp_path: Path) -> None:
"""Vérifie que la méthode clear réinitialise complètement l'état.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
state = BlogRSSState(state_file)
# Ajout de GUID et d'en-têtes de cache
state.add_guids(["guid-1", "guid-2"])
state.update_cache_headers("etag-123", "Wed, 01 Sep 2026 GMT")
# Réinitialisation
state.clear()
assert state.get_known_guids() == frozenset()
assert state.get_cache_headers() == (None, None)
# Vérification du contenu du fichier
saved_data = json.loads(state_file.read_text(encoding="utf-8"))
assert saved_data["known_guids"] == []
assert saved_data["etag"] is None
assert saved_data["last_modified"] is None
def test_clear_persists_to_file(tmp_path: Path) -> None:
"""Vérifie que la réinitialisation est persistée dans le fichier.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
# Création, ajout de données et réinitialisation
state1 = BlogRSSState(state_file)
state1.add_guids(["guid-1"])
state1.update_cache_headers("etag-123", "Wed, 01 Sep 2026 GMT")
state1.clear()
# Création d'une nouvelle instance avec le même fichier
state2 = BlogRSSState(state_file)
assert state2.get_known_guids() == frozenset()
assert state2.get_cache_headers() == (None, None)
def test_str_path_converted_to_path(tmp_path: Path) -> None:
"""Vérifie qu'un chemin de type str est converti en Path.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = str(tmp_path / "state.json")
state = BlogRSSState(state_file)
state.add_guids(["guid-1"])
assert Path(state_file).exists()
def test_update_cache_headers_none_values(tmp_path: Path) -> None:
"""Vérifie que la mise à jour avec des valeurs None fonctionne correctement.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
state = BlogRSSState(state_file)
state.update_cache_headers(None, None)
assert state.get_cache_headers() == (None, None)
# Vérification du contenu du fichier
saved_data = json.loads(state_file.read_text(encoding="utf-8"))
assert saved_data["etag"] is None
assert saved_data["last_modified"] is None
def test_add_guids_multiple_calls(tmp_path: Path) -> None:
"""Vérifie que plusieurs appels à add_guids accumulent les GUID.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
state = BlogRSSState(state_file)
state.add_guids(["guid-1"])
state.add_guids(["guid-2"])
assert state.get_known_guids() == frozenset({"guid-1", "guid-2"})
def test_version_in_saved_file(tmp_path: Path) -> None:
"""Vérifie que le champ version est présent dans le fichier sauvegardé.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
state = BlogRSSState(state_file)
state.add_guids(["guid-1"])
saved_data = json.loads(state_file.read_text(encoding="utf-8"))
assert saved_data["version"] == 1
def test_get_known_guids_returns_frozenset(tmp_path: Path) -> None:
"""Vérifie que get_known_guids retourne un frozenset.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
state = BlogRSSState(state_file)
state.add_guids(["guid-1", "guid-2"])
result = state.get_known_guids()
assert type(result) is frozenset
# Ensure trailing newline