"""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 "
" not in article.content_text assert "
" not in article.content_text assert "" not in article.content_text assert " 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 = "Hello world
" 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 = "Café & croissant
" 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"