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,724 @@
"""Tests unitaires pour le client RSS du blog du collège.
Ce module vérifie le comportement du client :class:`BlogRSSClient` pour la
récupération et le parsing du flux RSS du blog. Tous les tests sont unitaires
et utilisent des mocks pour éviter tout accès réseau réel.
Les tests couvrent :
- Le parsing nominal du flux RSS avec déduplication et tri
- La gestion des réponses 304 Not Modified
- La gestion des flux invalides (bozo)
- La gestion des erreurs réseau
- La conversion HTML vers texte
- L'absence de fuite de secrets dans les logs
- Les méthodes statiques de parsing de dates et de conversion HTML
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import feedparser # type: ignore[import-untyped]
import pytest
import pytest_mock
from pronote_sync.sources.blog.rss import BlogRSSClient
# --- Fixtures ---
@pytest.fixture
def blog_rss_fixture_path() -> Path:
"""Chemin vers le fichier fixture RSS du blog.
:return: Chemin absolu vers le fichier XML de test.
:rtype: Path
"""
return Path(__file__).parent.parent / "fixtures" / "blog_rss.xml"
@pytest.fixture
def real_parsed_feed(blog_rss_fixture_path: Path) -> feedparser.FeedParserDict:
"""Résultat réel du parsing du fixture RSS par feedparser.
:param blog_rss_fixture_path: Chemin vers le fichier fixture.
:return: Objet FeedParserDict parsé.
:rtype: feedparser.FeedParserDict
"""
return feedparser.parse(str(blog_rss_fixture_path))
@pytest.fixture
def blog_client(blog_rss_fixture_path: Path) -> BlogRSSClient:
"""Instance de BlogRSSClient pointant vers le fixture local.
:param blog_rss_fixture_path: Chemin vers le fichier fixture.
:return: Instance de BlogRSSClient.
:rtype: BlogRSSClient
"""
return BlogRSSClient(rss_url=f"file://{blog_rss_fixture_path}")
# --- Helper functions for mocking ---
def make_mock_entry(
entry_id: str | None = None,
link: str | None = None,
title: str = "Test Article",
published_parsed: tuple[int, ...] | None = None,
pubdate_parsed: tuple[int, ...] | None = None,
updated_parsed: tuple[int, ...] | None = None,
content: list[dict[str, Any]] | None = None,
description: str = "",
tags: list[dict[str, Any]] | None = None,
category: str | None = None,
author: str | None = None,
) -> dict[str, Any]:
"""Crée une entrée RSS mockée sous forme de dict.
:param entry_id: GUID de l'entrée.
:param link: URL de l'entrée.
:param title: Titre de l'entrée.
:param published_parsed: Date de publication parsée (struct_time).
:param pubdate_parsed: Date de publication alternative (struct_time).
:param updated_parsed: Date de mise à jour parsée (struct_time).
:param content: Contenu HTML de l'entrée.
:param description: Description de l'entrée.
:param tags: Tags de l'entrée.
:param category: Catégorie de l'entrée.
:param author: Auteur de l'entrée.
:return: Dict représentant une entrée RSS.
"""
entry: dict[str, Any] = {
"title": title,
"description": description,
"author": author,
"category": category,
"tags": tags,
}
if entry_id is not None:
entry["id"] = entry_id
if link is not None:
entry["link"] = link
if published_parsed is not None:
entry["published_parsed"] = published_parsed
if pubdate_parsed is not None:
entry["pubdate_parsed"] = pubdate_parsed
if updated_parsed is not None:
entry["updated_parsed"] = updated_parsed
if content is not None:
entry["content"] = content
return entry
def make_mock_feed(
entries: list[dict[str, Any]] | None = None,
status: int = 200,
bozo: int = 0,
etag: str | None = None,
modified: str | None = None,
headers: dict[str, str] | None = None,
bozo_exception: Exception | None = None,
) -> Any:
"""Crée un objet FeedParserDict mocké.
:param entries: Liste des entrées RSS.
:param status: Code de statut HTTP.
:param bozo: Indicateur d'erreur de parsing.
:param etag: Valeur de l'en-tête ETag.
:param modified: Valeur de l'en-tête Last-Modified.
:param headers: En-têtes HTTP de la réponse.
:param bozo_exception: Exception associée à l'erreur de parsing.
:return: Objet FeedParserDict mocké.
"""
# feedparser.FeedParserDict est un dict-like, mais nous utilisons un objet
# dynamique pour simuler les attributs nécessaires
feed = type("FeedParserDict", (), {})()
feed.entries = entries or []
feed.status = status
feed.bozo = bozo
feed.etag = etag
feed.modified = modified
feed.headers = headers or {}
if bozo_exception:
feed.bozo_exception = bozo_exception
return feed
# --- Tests ---
def test_fetch_and_parse_nominal(
mocker: pytest_mock.MockerFixture,
real_parsed_feed: feedparser.FeedParserDict,
blog_rss_fixture_path: Path,
) -> None:
"""Vérifie le parsing nominal du flux RSS avec 3 articles triés par date.
Le fixture contient 3 articles dans un ordre non chronologique.
Le résultat doit contenir les 3 articles triés par date décroissante.
:param mocker: Fixture pytest-mock pour le mocking.
:param real_parsed_feed: Résultat réel du parsing du fixture.
:param blog_rss_fixture_path: Chemin vers le fichier fixture.
:return: None
"""
# Mock feedparser.parse pour retourner le résultat réel du parsing
mocker.patch(
"pronote_sync.sources.blog.rss.feedparser.parse",
return_value=real_parsed_feed,
)
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
result = client.fetch_and_parse()
# Vérification du nombre d'articles
assert len(result.articles) == 3
# Vérification de l'ordre : p=1003 (12 Aug), p=1002 (11 Aug), p=1001 (10 Aug)
assert result.articles[0].id == "https://example.com/blog/?p=1003"
assert result.articles[1].id == "https://example.com/blog/?p=1002"
assert result.articles[2].id == "https://example.com/blog/?p=1001"
# Vérification des titres
assert result.articles[0].title == "Information générale"
assert result.articles[1].title == "Sortie pédagogique au musée"
assert result.articles[2].title == "Réunion de rentrée"
# Vérification des catégories
assert result.articles[0].category is None # Pas de catégorie pour p=1003
assert result.articles[1].category == "Pédagogie"
assert result.articles[2].category == "Administration"
# Vérification des auteurs
assert result.articles[0].author is None # Pas d'auteur pour p=1003
assert result.articles[1].author is None # Pas d'auteur pour p=1002
assert result.articles[2].author == "M. Dupont"
# Vérification des dates de publication
assert result.articles[0].published_at == datetime(2026, 8, 12, 8, 0, 0, tzinfo=UTC)
assert result.articles[1].published_at == datetime(2026, 8, 11, 14, 30, 0, tzinfo=UTC)
assert result.articles[2].published_at == datetime(2026, 8, 10, 9, 0, 11, tzinfo=UTC)
# Vérification que content_text contient des mots attendus
assert "cantine" in result.articles[0].content_text
assert "musée" in result.articles[1].content_text
assert "réunion" in result.articles[2].content_text.lower()
def test_fetch_and_parse_deduplication(
mocker: pytest_mock.MockerFixture,
real_parsed_feed: feedparser.FeedParserDict,
) -> None:
"""Vérifie la déduplication avec known_guids.
Avec known_guids contenant p=1001, seuls p=1002 et p=1003 doivent être retournés.
:param mocker: Fixture pytest-mock pour le mocking.
:param real_parsed_feed: Résultat réel du parsing du fixture.
:return: None
"""
mocker.patch(
"pronote_sync.sources.blog.rss.feedparser.parse",
return_value=real_parsed_feed,
)
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
result = client.fetch_and_parse(known_guids=frozenset({"https://example.com/blog/?p=1001"}))
assert len(result.articles) == 2
# p=1001 doit être exclu
article_ids = {article.id for article in result.articles}
assert "https://example.com/blog/?p=1001" not in article_ids
assert "https://example.com/blog/?p=1002" in article_ids
assert "https://example.com/blog/?p=1003" in article_ids
def test_fetch_and_parse_empty_known_guids(
mocker: pytest_mock.MockerFixture,
real_parsed_feed: feedparser.FeedParserDict,
) -> None:
"""Vérifie que known_guids vide (frozenset()) retourne tous les articles.
:param mocker: Fixture pytest-mock pour le mocking.
:param real_parsed_feed: Résultat réel du parsing du fixture.
:return: None
"""
mocker.patch(
"pronote_sync.sources.blog.rss.feedparser.parse",
return_value=real_parsed_feed,
)
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
result = client.fetch_and_parse(known_guids=frozenset())
assert len(result.articles) == 3
def test_fetch_and_parse_known_guids_none(
mocker: pytest_mock.MockerFixture,
real_parsed_feed: feedparser.FeedParserDict,
) -> None:
"""Vérifie que known_guids=None retourne tous les articles.
:param mocker: Fixture pytest-mock pour le mocking.
:param real_parsed_feed: Résultat réel du parsing du fixture.
:return: None
"""
mocker.patch(
"pronote_sync.sources.blog.rss.feedparser.parse",
return_value=real_parsed_feed,
)
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
result = client.fetch_and_parse(known_guids=None)
assert len(result.articles) == 3
def test_fetch_and_parse_304_not_modified(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Vérifie la gestion de la réponse 304 Not Modified.
Doit retourner not_modified=True, articles vide, et conserver les en-têtes d'entrée.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
mock_feed = make_mock_feed(status=304, etag="old_etag", modified="old_modified")
mocker.patch(
"pronote_sync.sources.blog.rss.feedparser.parse",
return_value=mock_feed,
)
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
result = client.fetch_and_parse(
etag="input_etag",
last_modified="input_last_modified",
)
assert result.not_modified is True
assert result.articles == ()
assert result.etag == "input_etag"
assert result.last_modified == "input_last_modified"
def test_fetch_and_parse_bozo_invalid_feed(
mocker: pytest_mock.MockerFixture,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie la gestion d'un flux invalide (bozo=1).
Doit retourner articles vide, not_modified=False, et logger un avertissement.
:param mocker: Fixture pytest-mock pour le mocking.
:param caplog: Fixture pour capturer les logs.
:return: None
"""
with caplog.at_level(logging.WARNING):
mock_exception = ValueError("Invalid XML")
mock_feed = make_mock_feed(
bozo=1,
bozo_exception=mock_exception,
etag="test_etag",
modified="test_modified",
)
mocker.patch(
"pronote_sync.sources.blog.rss.feedparser.parse",
return_value=mock_feed,
)
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
result = client.fetch_and_parse()
assert result.articles == ()
assert result.not_modified is False
assert result.etag == "test_etag"
assert result.last_modified == "test_modified"
# Vérification du log d'avertissement
assert "Flux RSS du blog invalide" in caplog.text
# L'URL est présente dans le log mais sans secrets (pas de paramètres sensibles)
assert "blog/feed" in caplog.text
def test_fetch_and_parse_network_error(
mocker: pytest_mock.MockerFixture,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie la gestion d'une erreur réseau.
Doit retourner articles vide, not_modified=False, logger une erreur, et ne pas propager l'exception.
:param mocker: Fixture pytest-mock pour le mocking.
:param caplog: Fixture pour capturer les logs.
:return: None
"""
with caplog.at_level(logging.ERROR):
mock_exception = ConnectionError("Network error")
mocker.patch(
"pronote_sync.sources.blog.rss.feedparser.parse",
side_effect=mock_exception,
)
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
# Ne doit pas lever d'exception
result = client.fetch_and_parse()
assert result.articles == ()
assert result.not_modified is False
# Vérification du log d'erreur
assert "Échec de la récupération du flux RSS du blog" in caplog.text
def test_fetch_and_parse_no_secret_leak_in_logs(
mocker: pytest_mock.MockerFixture,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie qu'aucun secret ne fuit dans les logs en cas d'erreur.
:param mocker: Fixture pytest-mock pour le mocking.
:param caplog: Fixture pour capturer les logs.
:return: None
"""
with caplog.at_level(logging.ERROR):
# Exception contenant un secret
secret = "SECRET_TOKEN_123" # pragma: allowlist secret
mock_exception = ValueError(f"Error with token: {secret}")
mocker.patch(
"pronote_sync.sources.blog.rss.feedparser.parse",
side_effect=mock_exception,
)
client = BlogRSSClient(rss_url=f"https://example.com/blog/feed?token={secret}")
result = client.fetch_and_parse()
assert result.articles == ()
assert result.not_modified is False
# Vérification qu'aucun secret ne fuit dans les logs
assert secret not in caplog.text
def test_fetch_and_parse_entry_without_date(
mocker: pytest_mock.MockerFixture,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie qu'une entrée sans date de publication est ignorée.
:param mocker: Fixture pytest-mock pour le mocking.
:param caplog: Fixture pour capturer les logs.
:return: None
"""
with caplog.at_level(logging.WARNING):
entry = make_mock_entry(
entry_id="test-id",
link="https://example.com/test",
title="Test Article",
published_parsed=None,
pubdate_parsed=None,
)
mock_feed = make_mock_feed(entries=[entry])
mocker.patch(
"pronote_sync.sources.blog.rss.feedparser.parse",
return_value=mock_feed,
)
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
result = client.fetch_and_parse()
assert len(result.articles) == 0
assert "Entrée RSS sans date de publication valide" in caplog.text
def test_fetch_and_parse_entry_without_guid(
mocker: pytest_mock.MockerFixture,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie qu'une entrée sans GUID ni lien est ignorée.
:param mocker: Fixture pytest-mock pour le mocking.
:param caplog: Fixture pour capturer les logs.
:return: None
"""
with caplog.at_level(logging.WARNING):
entry = make_mock_entry(
entry_id=None,
link=None,
title="Test Article",
published_parsed=(2026, 8, 10, 9, 0, 11, 0, 222, 0),
)
mock_feed = make_mock_feed(entries=[entry])
mocker.patch(
"pronote_sync.sources.blog.rss.feedparser.parse",
return_value=mock_feed,
)
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
result = client.fetch_and_parse()
assert len(result.articles) == 0
assert "Entrée RSS sans GUID ni lien" in caplog.text
def test_fetch_and_parse_html_to_text_conversion(
mocker: pytest_mock.MockerFixture,
real_parsed_feed: feedparser.FeedParserDict,
) -> None:
"""Vérifie que content_text ne contient pas de balises HTML.
:param mocker: Fixture pytest-mock pour le mocking.
:param real_parsed_feed: Résultat réel du parsing du fixture.
:return: None
"""
mocker.patch(
"pronote_sync.sources.blog.rss.feedparser.parse",
return_value=real_parsed_feed,
)
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
result = client.fetch_and_parse()
for article in result.articles:
# Vérification qu'il n'y a pas de balises HTML dans content_text
assert "<p>" not in article.content_text
assert "</p>" not in article.content_text
assert "<a" not in article.content_text
assert "</a>" not in article.content_text
assert "<![CDATA[" not in article.content_text
# Vérification que le contenu textuel est présent
assert len(article.content_text) > 0
def test_fetch_and_parse_etag_last_modified_returned(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Vérifie que les en-têtes ETag et Last-Modified sont retournés.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
mock_feed = make_mock_feed(
entries=[],
etag="abc123",
modified="Wed, 01 Sep 2026 00:00:00 GMT",
)
mocker.patch(
"pronote_sync.sources.blog.rss.feedparser.parse",
return_value=mock_feed,
)
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
result = client.fetch_and_parse()
assert result.etag == "abc123"
assert result.last_modified == "Wed, 01 Sep 2026 00:00:00 GMT"
assert result.not_modified is False
def test_fetch_and_parse_passes_etag_to_feedparser(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Vérifie que les paramètres etag et last_modified sont passés à feedparser.parse.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
mock_parse = mocker.patch(
"pronote_sync.sources.blog.rss.feedparser.parse",
return_value=make_mock_feed(),
)
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
client.fetch_and_parse(
etag="abc123",
last_modified="Wed, 01 Sep 2026 00:00:00 GMT",
)
# Vérification que feedparser.parse a été appelé avec les bons paramètres
mock_parse.assert_called_once()
call_args = mock_parse.call_args
assert call_args[1]["etag"] == "abc123"
assert call_args[1]["modified"] == "Wed, 01 Sep 2026 00:00:00 GMT"
def test_parse_date_valid() -> None:
"""Vérifie le parsing d'une date valide.
:return: None
"""
date_tuple = (2026, 8, 10, 9, 0, 11, 0, 222, 0)
result = BlogRSSClient._parse_date(date_tuple)
expected = datetime(2026, 8, 10, 9, 0, 11, tzinfo=UTC)
assert result == expected
def test_parse_date_none() -> None:
"""Vérifie le parsing d'une date None.
:return: None
"""
result = BlogRSSClient._parse_date(None)
assert result is None
def test_parse_date_invalid() -> None:
"""Vérifie le parsing d'une date invalide (tuple trop court).
:return: None
"""
result = BlogRSSClient._parse_date((2026,))
assert result is None
def test_html_to_text_basic() -> None:
"""Vérifie la conversion HTML vers texte de base.
:return: None
"""
html = "<p>Hello <a href='x'>world</a></p>"
result = BlogRSSClient._html_to_text(html)
assert result == "Hello world"
def test_html_to_text_empty() -> None:
"""Vérifie la conversion HTML vers texte avec une chaîne vide.
:return: None
"""
result = BlogRSSClient._html_to_text("")
assert result == ""
def test_html_to_text_entities() -> None:
"""Vérifie la conversion HTML vers texte avec des entités HTML.
:return: None
"""
html = "<p>Café &amp; croissant</p>"
result = BlogRSSClient._html_to_text(html)
assert result == "Café & croissant"
def test_fetch_and_parse_sort_deterministic(
mocker: pytest_mock.MockerFixture,
real_parsed_feed: feedparser.FeedParserDict,
) -> None:
"""Vérifie que le tri des articles est déterministe.
Deux appels successifs doivent retourner les articles dans le même ordre.
:param mocker: Fixture pytest-mock pour le mocking.
:param real_parsed_feed: Résultat réel du parsing du fixture.
:return: None
"""
mocker.patch(
"pronote_sync.sources.blog.rss.feedparser.parse",
return_value=real_parsed_feed,
)
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
result1 = client.fetch_and_parse()
result2 = client.fetch_and_parse()
# Vérification que les deux résultats sont identiques
assert len(result1.articles) == len(result2.articles)
for article1, article2 in zip(result1.articles, result2.articles, strict=True):
assert article1.id == article2.id
assert article1.title == article2.title
assert article1.published_at == article2.published_at
# Vérification de l'ordre : p=1003 (Aug 12), p=1002 (Aug 11), p=1001 (Aug 10)
assert result1.articles[0].id == "https://example.com/blog/?p=1003"
assert result1.articles[1].id == "https://example.com/blog/?p=1002"
assert result1.articles[2].id == "https://example.com/blog/?p=1001"
def test_fetch_and_parse_duplicate_guid_in_feed(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Vérifie la déduplication des entrées avec le même GUID dans un même flux.
Deux entrées avec le même GUID (même id et même link) doivent donner un seul article.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
# Création de deux entrées avec le même GUID
entry1 = make_mock_entry(
entry_id="duplicate-guid",
link="https://example.com/duplicate",
title="First Article",
published_parsed=(2026, 8, 10, 9, 0, 0, 0, 222, 0),
)
entry2 = make_mock_entry(
entry_id="duplicate-guid",
link="https://example.com/duplicate",
title="Second Article",
published_parsed=(2026, 8, 11, 10, 0, 0, 0, 223, 0),
)
mock_feed = make_mock_feed(entries=[entry1, entry2])
mocker.patch(
"pronote_sync.sources.blog.rss.feedparser.parse",
return_value=mock_feed,
)
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
result = client.fetch_and_parse(known_guids=None)
# Doit retourner un seul article (le second duplicata est ignoré)
assert len(result.articles) == 1
# Le premier article doit être conservé (celui avec la date la plus ancienne)
assert result.articles[0].id == "duplicate-guid"
assert result.articles[0].title == "First Article"
def test_fetch_and_parse_sort_tied_dates_by_id(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Vérifie le tri secondaire par identifiant croissant pour les articles de même date.
Deux articles avec la même date de publication doivent être triés par id croissant.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
# Création de deux entrées avec la même date mais des id différents
entry_a = make_mock_entry(
entry_id="guid-a",
link="https://example.com/a",
title="Article A",
published_parsed=(2026, 8, 10, 9, 0, 0, 0, 222, 0),
)
entry_b = make_mock_entry(
entry_id="guid-b",
link="https://example.com/b",
title="Article B",
published_parsed=(2026, 8, 10, 9, 0, 0, 0, 222, 0),
)
mock_feed = make_mock_feed(entries=[entry_b, entry_a]) # Ordre initial : b, a
mocker.patch(
"pronote_sync.sources.blog.rss.feedparser.parse",
return_value=mock_feed,
)
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
result = client.fetch_and_parse()
# Doit retourner les deux articles triés par id croissant
assert len(result.articles) == 2
assert result.articles[0].id == "guid-a" # a doit venir avant b
assert result.articles[1].id == "guid-b"

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