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:
311
tests/unit/test_blog_state.py
Normal file
311
tests/unit/test_blog_state.py
Normal 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
|
||||
Reference in New Issue
Block a user