From 6d1a7a649fc7aa45ad945f1f99f810127e29a1a2 Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Sun, 6 Sep 2026 20:23:49 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(M5):=20source=20blog=20RSS=20=E2=80=94?= =?UTF-8?q?=20fetch,=20parsing,=20d=C3=A9duplication=20et=20=C3=A9tat=20pe?= =?UTF-8?q?rsistant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: opencode/test-engineer --- .pre-commit-config.yaml | 2 +- .secrets.baseline | 4 +- GUIDE_DEV_PYTHON.md | 456 ++++++++++------ TODO.md | 10 +- pronote_sync/sources/blog/__init__.py | 21 + pronote_sync/sources/blog/result.py | 54 ++ pronote_sync/sources/blog/rss.py | 266 ++++++++++ pronote_sync/sources/blog/state.py | 161 ++++++ tests/fixtures/blog_rss.xml | 47 ++ tests/unit/test_blog_client.py | 724 ++++++++++++++++++++++++++ tests/unit/test_blog_state.py | 311 +++++++++++ 11 files changed, 1895 insertions(+), 161 deletions(-) create mode 100644 pronote_sync/sources/blog/result.py create mode 100644 pronote_sync/sources/blog/rss.py create mode 100644 pronote_sync/sources/blog/state.py create mode 100644 tests/fixtures/blog_rss.xml create mode 100644 tests/unit/test_blog_client.py create mode 100644 tests/unit/test_blog_state.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eaebacb..52dd3c6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,7 +26,7 @@ repos: name: mypy entry: mypy language: python - additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "pytest>=8.0.0", "types-requests>=2.31.0", "icalendar>=5.0.0", "pronotepy>=2.15.0", "responses>=0.25.0", "pytest-mock>=3.10.0"] + additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "pytest>=8.0.0", "types-requests>=2.31.0", "icalendar>=5.0.0", "pronotepy>=2.15.0", "responses>=0.25.0", "pytest-mock>=3.10.0", "feedparser>=6.0.0"] types: [python] pass_filenames: true diff --git a/.secrets.baseline b/.secrets.baseline index 4a25695..31437f1 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -140,10 +140,10 @@ "filename": "GUIDE_DEV_PYTHON.md", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "is_verified": true, - "line_number": 4893, + "line_number": 5043, "is_secret": false } ] }, - "generated_at": "2026-09-06T14:37:44Z" + "generated_at": "2026-09-06T16:41:06Z" } diff --git a/GUIDE_DEV_PYTHON.md b/GUIDE_DEV_PYTHON.md index 73edb64..2ff869d 100644 --- a/GUIDE_DEV_PYTHON.md +++ b/GUIDE_DEV_PYTHON.md @@ -791,15 +791,70 @@ class PronoteData(BaseModel): #### 5 bis.7.1 Client RSS (`sources/blog/rss.py`) +Le résultat d'une récupération est un modèle Pydantic figé, `BlogRSSFetchResult` +(module `sources/blog/result.py`) : + ```python -import feedparser -from datetime import datetime, timezone -from typing import List, Optional -from html import unescape -from bs4 import BeautifulSoup +from pydantic import BaseModel, ConfigDict, Field + from ..models.blog import BlogArticle -from ..utils.redaction import redact_url + + +class BlogRSSFetchResult(BaseModel): + """ + Résultat d'une récupération du flux RSS du blog du collège. + + Modèle figé (``frozen``) : les instances sont immuables après création. + """ + + model_config = ConfigDict(frozen=True) + + articles: tuple[BlogArticle, ...] = Field( + default=(), + description=( + "Nouveaux articles absents de known_guids, triés par date de " + "publication décroissante puis par identifiant croissant" + ), + ) + etag: str | None = Field( + default=None, + description="Valeur de l'en-tête ETag de la réponse RSS, si disponible", + ) + last_modified: str | None = Field( + default=None, + description="Valeur de l'en-tête Last-Modified de la réponse RSS, si disponible", + ) + not_modified: bool = Field( + default=False, + description="Vaut True si le serveur a répondu 304 Not Modified", + ) +``` + +**Attributs** : +- `articles` : nouveaux articles absents de `known_guids`, triés par date de + publication décroissante puis par identifiant croissant. Tuple vide si aucun + nouvel article (ou en cas de réponse `304 Not Modified`). +- `etag` : valeur de l'en-tête `ETag` de la réponse RSS, ou `None` si + indisponible. +- `last_modified` : valeur de l'en-tête `Last-Modified` de la réponse RSS, ou + `None` si indisponible. +- `not_modified` : vaut `True` si le serveur a répondu `304 Not Modified`, + `False` sinon. + +Client de récupération et de parsing du flux (`sources/blog/rss.py`) : + +```python import logging +import re +from datetime import UTC, datetime +from html import unescape + +import feedparser +from bs4 import BeautifulSoup + +from ..models.blog import BlogArticle +from ..sources.blog.result import BlogRSSFetchResult +from ..utils.redaction import redact_url logger = logging.getLogger(__name__) @@ -809,119 +864,163 @@ class BlogRSSClient: Client pour récupérer et parser le flux RSS du blog du collège. """ - def __init__( - self, - rss_url: str = "https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2", - timeout: int = 20, - ): + def __init__(self, rss_url: str, timeout: int = 20): self.rss_url = rss_url self.timeout = timeout - def fetch_and_parse(self, known_guids: Optional[set] = None) -> List[BlogArticle]: + def fetch_and_parse( + self, + *, + known_guids: frozenset[str] | None = None, + etag: str | None = None, + last_modified: str | None = None, + ) -> BlogRSSFetchResult: """ Récupère le flux RSS et parse les nouveaux articles. Args: - known_guids: Ensemble des GUID déjà connus (pour la déduplication). Si None, retourne tous les articles. + known_guids: Ensemble des GUID d'articles déjà traités (pour la déduplication). + Si None, retourne tous les articles. + etag: Valeur de l'en-tête ``ETag`` mémorisée pour la requête conditionnelle, ou None. + last_modified: Valeur de l'en-tête ``Last-Modified`` mémorisée pour la requête + conditionnelle, ou None. Returns: - Liste des nouveaux articles (triés par date de publication décroissante). + Résultat de la récupération : nouveaux articles (triés par date de publication + décroissante puis par identifiant croissant), en-têtes de cache reçus et + indicateur ``304 Not Modified``. """ try: - # Récupération du flux avec cache HTTP (géré par feedparser) + # Récupération du flux avec requête conditionnelle (ETag / Last-Modified) feed = feedparser.parse( self.rss_url, + etag=etag, + modified=last_modified, request_timeout=self.timeout, - etag=None, # Géré automatiquement par feedparser - modified=None, ) - # Vérifier que le flux est valide - if feed.bozo: - raise ValueError(f"Flux RSS invalide: {feed.bozo_exception}") + # Réponse 304 Not Modified : rien n'a changé, on restitue les en-têtes mémorisés + if getattr(feed, "status", None) == 304: + return BlogRSSFetchResult( + articles=(), + etag=etag, + last_modified=last_modified, + not_modified=True, + ) - articles = [] - for entry in feed.entries: - # Extraire le GUID (utiliser link si guid est vide) - guid = getattr(entry, "guid", None) or entry.link + response_etag: str | None = getattr(feed, "etag", None) + response_last_modified: str | None = getattr(feed, "modified", None) - # Ignorer les articles déjà connus - if known_guids and guid in known_guids: + # Flux invalide (erreur HTTP, XML malformé, etc.) : résultat vide, sans erreur + if getattr(feed, "bozo", None): + logger.warning( + "Flux RSS du blog invalide, ignoré : %s", + redact_url(self.rss_url), + ) + return BlogRSSFetchResult( + articles=(), + etag=response_etag, + last_modified=response_last_modified, + not_modified=False, + ) + + articles: list[BlogArticle] = [] + seen_guids: set[str] = set(known_guids) if known_guids is not None else set() + for entry in getattr(feed, "entries", []): + # Extraire le GUID (utiliser link si le GUID est vide) + guid = str(entry.get("id") or entry.get("link") or "") + + # Ignorer les articles déjà connus ou en double dans le flux + if not guid or guid in seen_guids: continue # Parser la date de publication (RFC 822 ou ISO 8601) published_at = self._parse_date( - getattr(entry, "published_parsed", None) - or getattr(entry, "pubdate_parsed", None) + entry.get("published_parsed") or entry.get("pubdate_parsed") ) + if published_at is None: + continue # Parser la date de mise à jour (si disponible) - updated_at = self._parse_date( - getattr(entry, "updated_parsed", None) - ) + updated_at = self._parse_date(entry.get("updated_parsed")) # Extraire le contenu HTML (content:encoded ou description) - content_html = "" - if hasattr(entry, "content") and entry.content: - content_html = entry.content[0].value - elif hasattr(entry, "description"): - content_html = entry.description + raw_content = entry.get("content") + if raw_content: + content_html = str(raw_content[0].get("value") or "") + else: + content_html = str(entry.get("description") or "") - # Convertir le HTML en texte brut - content_text = self._html_to_text(content_html) + # Extraire la catégorie (tags ou champ category) + tags = entry.get("tags") + category_value = tags[0].get("term") if tags else None + if not category_value: + category_value = entry.get("category") + category = str(category_value) if category_value else None # Créer l'article - article = BlogArticle( - id=guid, - title=entry.title, - url=entry.link, - published_at=published_at, - updated_at=updated_at, - category=getattr(entry, "category", None), - author=getattr(entry, "author", None), - content_html=content_html, - content_text=content_text, + articles.append( + BlogArticle( + id=guid, + title=str(entry.get("title") or guid), + url=str(entry.get("link") or guid), + published_at=published_at, + updated_at=updated_at, + category=category, + author=str(entry.get("author")) if entry.get("author") else None, + content_html=content_html, + content_text=self._html_to_text(content_html), + ) ) - articles.append(article) + seen_guids.add(guid) - # Trier par date de publication décroissante - articles.sort(key=lambda a: a.published_at, reverse=True) - return articles + # Tri stable : d'abord par date de publication décroissante, puis par identifiant croissant + articles.sort(key=lambda article: article.id) + articles.sort(key=lambda article: article.published_at, reverse=True) + + return BlogRSSFetchResult( + articles=tuple(articles), + etag=response_etag, + last_modified=response_last_modified, + not_modified=False, + ) except Exception as e: safe_url = redact_url(self.rss_url) logger.error(f"Échec de la récupération du flux RSS {safe_url}: {e}") - return [] + return BlogRSSFetchResult(articles=(), not_modified=False) - def _parse_date(self, date_tuple: Optional[tuple]) -> datetime: + @staticmethod + def _parse_date(date_tuple: tuple[int, ...] | None) -> datetime | None: """ Convertit un tuple de date (RFC 822 ou ISO 8601) en datetime UTC. Args: - date_tuple: Tuple retourné par feedparser (ex: (2026, 8, 10, 9, 0, 11, 0, 1, -1)). + date_tuple: Tuple retourné par feedparser (ex: (2026, 8, 10, 9, 0, 11, 0, 1, -1)), + ou None si absent. Returns: - datetime en UTC. + datetime en UTC, ou None si le tuple est absent, vide ou invalide. """ if not date_tuple: - return datetime.now(timezone.utc) + return None # feedparser retourne un tuple struct_time (année, mois, jour, heure, minute, seconde, jour_semaine, jour_année, DST) try: - dt = datetime( + return datetime( date_tuple[0], # année date_tuple[1], # mois date_tuple[2], # jour date_tuple[3], # heure date_tuple[4], # minute date_tuple[5], # seconde - tzinfo=timezone.utc, + tzinfo=UTC, ) - return dt except (ValueError, IndexError): - return datetime.now(timezone.utc) + return None - def _html_to_text(self, html: str) -> str: + @staticmethod + def _html_to_text(html: str) -> str: """ Convertit du HTML en texte brut (supprime les balises, décode les entités). @@ -942,32 +1041,51 @@ class BlogRSSClient: text = unescape(text) # Nettoyer les espaces multiples - import re text = re.sub(r"\s+", " ", text).strip() return text - +``` #### 5 bis.7.2 Déduplication et état local La déduplication des articles du blog repose sur leur **GUID** (ou leur URL si le GUID est vide). **Stratégie** : -1. Stocker le **dernier GUID traité** dans un fichier d'état local (ex: `.blog_rss_state.json`). -2. À chaque récupération, ignorer les articles dont le GUID est **antérieur ou égal** au dernier GUID connu. -3. Utiliser le **cache HTTP** (`If-Modified-Since` / `If-None-Match`) via `feedparser` pour éviter les requêtes inutiles. +1. Stocker un **fichier d'état local** (ex: `.blog_rss_state.json`) contenant la version du + format, l'**ensemble des GUID déjà traités** et les en-têtes de cache HTTP (`ETag` / + `Last-Modified`) de la dernière réponse. +2. À chaque récupération, ignorer les articles dont le GUID est **déjà présent** dans + l'ensemble des GUID connus. +3. Utiliser le **cache HTTP** (`If-Modified-Since` / `If-None-Match`) via `feedparser` pour + éviter les requêtes inutiles. -**Exemple de fichier d'état** (`sync/blog_state.py`) : +**Exemple de fichier d'état** : +```json +{ + "version": 1, + "known_guids": [ + "https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1625", + "https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1626" + ], + "etag": "abc123", + "last_modified": "Wed, 01 Sep 2026 00:00:00 GMT" +} +``` + +Les GUID sont triés alphabétiquement pour une sortie JSON déterministe. + +**Gestionnaire d'état** (`sources/blog/state.py`) : ```python import json -from pathlib import Path -from typing import Optional -from ..models.blog import BlogArticle import logging +from collections.abc import Iterable +from pathlib import Path logger = logging.getLogger(__name__) +_STATE_VERSION = 1 + class BlogRSSState: """ @@ -975,64 +1093,77 @@ class BlogRSSState: """ def __init__(self, state_file: str = ".blog_rss_state.json"): - self.state_file = Path(state_file) - self._known_guids: set = set() - self._etag: Optional[str] = None - self._last_modified: Optional[str] = None + self._state_file = Path(state_file) + self._known_guids: set[str] = set() + self._etag: str | None = None + self._last_modified: str | None = None self._load() def _load(self) -> None: """Charge l'état depuis le fichier.""" - if self.state_file.exists(): - try: - with open(self.state_file, "r", encoding="utf-8") as f: - state = json.load(f) - self._known_guids = set(state.get("known_guids", [])) - self._etag = state.get("etag") - self._last_modified = state.get("last_modified") - except Exception as e: - logger.warning(f"Échec du chargement de l'état du blog: {e}") - self._known_guids = set() - self._etag = None - self._last_modified = None + if not self._state_file.exists(): + return + try: + data = json.loads(self._state_file.read_text(encoding="utf-8")) + if not isinstance(data, dict) or data.get("version") != _STATE_VERSION: + logger.warning( + "Fichier d'état blog RSS : version absente ou non supportée, " + "démarrage avec un état vide." + ) + return + guids_data = data.get("known_guids", []) + if isinstance(guids_data, list): + self._known_guids = {guid for guid in guids_data if isinstance(guid, str)} + etag_data = data.get("etag") + if isinstance(etag_data, str): + self._etag = etag_data + last_modified_data = data.get("last_modified") + if isinstance(last_modified_data, str): + self._last_modified = last_modified_data + except Exception as e: + logger.warning(f"Échec du chargement de l'état du blog: {e}") + self._known_guids = set() + self._etag = None + self._last_modified = None def _save(self) -> None: """Sauvegarde l'état dans le fichier.""" + payload = { + "version": _STATE_VERSION, + "known_guids": sorted(self._known_guids), + "etag": self._etag, + "last_modified": self._last_modified, + } try: - with open(self.state_file, "w", encoding="utf-8") as f: - json.dump({ - "known_guids": list(self._known_guids), - "etag": self._etag, - "last_modified": self._last_modified - }, f, indent=2) + with self._state_file.open("w", encoding="utf-8") as f: + json.dump(payload, f, indent=2) except Exception as e: logger.error(f"Échec de la sauvegarde de l'état du blog: {e}") - def get_known_guids(self) -> set: - """Retourne l'ensemble des GUID connus.""" - return self._known_guids.copy() + def get_known_guids(self) -> frozenset[str]: + """Retourne une copie immuable des GUID connus.""" + return frozenset(self._known_guids) - def add_guid(self, guid: str) -> None: - """Ajoute un GUID à l'ensemble des GUID connus.""" - self._known_guids.add(guid) + def add_guids(self, guids: Iterable[str]) -> None: + """Ajoute des GUID à l'ensemble des GUID connus et sauvegarde.""" + new_guids = set(guids) + if not new_guids: + return + self._known_guids.update(new_guids) self._save() - def get_etag(self) -> Optional[str]: - """Retourne l'ETag du dernier flux RSS.""" - return self._etag + def get_cache_headers(self) -> tuple[str | None, str | None]: + """Retourne les en-têtes de cache HTTP mémorisés (etag, last_modified).""" + return self._etag, self._last_modified - def get_last_modified(self) -> Optional[str]: - """Retourne la date de dernière modification du flux RSS.""" - return self._last_modified - - def update_cache_headers(self, etag: Optional[str], last_modified: Optional[str]) -> None: - """Met à jour les en-têtes de cache HTTP.""" + def update_cache_headers(self, etag: str | None, last_modified: str | None) -> None: + """Met à jour les en-têtes de cache HTTP et sauvegarde.""" self._etag = etag self._last_modified = last_modified self._save() def clear(self) -> None: - """Efface l'état.""" + """Efface l'état (GUID et en-têtes de cache) et sauvegarde.""" self._known_guids = set() self._etag = None self._last_modified = None @@ -1047,11 +1178,16 @@ blog_state = BlogRSSState() # Récupération des nouveaux articles known_guids = blog_state.get_known_guids() -new_articles = rss_client.fetch_and_parse(known_guids=known_guids) +etag, last_modified = blog_state.get_cache_headers() +result = rss_client.fetch_and_parse( + known_guids=known_guids, + etag=etag, + last_modified=last_modified, +) -# Mise à jour de l'état avec les nouveaux GUID -for article in new_articles: - blog_state.add_guid(article.id) +# Mise à jour de l'état avec les nouveaux GUID et les en-têtes de cache +blog_state.add_guids(article.id for article in result.articles) +blog_state.update_cache_headers(result.etag, result.last_modified) ``` --- @@ -1063,9 +1199,8 @@ for article in new_articles: ```python from typing import List from ..models.blog import BlogArticle -from ..models.external import ExternalInfo from ..sources.blog.rss import BlogRSSClient -from ..sync.blog_state import BlogRSSState +from ..sources.blog.state import BlogRSSState def fetch_blog_step( @@ -1078,7 +1213,7 @@ def fetch_blog_step( Args: rss_client: Client RSS configuré. - blog_state: État local pour la déduplication. + blog_state: État local pour la déduplication et le cache HTTP. enabled: Si False, retourne une liste vide. Returns: @@ -1087,14 +1222,19 @@ def fetch_blog_step( if not enabled: return [] - last_guid = blog_state.get_last_guid() - articles = rss_client.fetch_and_parse(last_guid=last_guid) + known_guids = blog_state.get_known_guids() + etag, last_modified = blog_state.get_cache_headers() + result = rss_client.fetch_and_parse( + known_guids=known_guids, + etag=etag, + last_modified=last_modified, + ) - # Mettre à jour l'état si des articles sont trouvés - if articles: - blog_state.update_last_guid(articles[0].id) + # Mettre à jour l'état avec les nouveaux GUID et les en-têtes de cache + blog_state.add_guids(article.id for article in result.articles) + blog_state.update_cache_headers(result.etag, result.last_modified) - return articles + return list(result.articles) ``` #### 5 bis.8.2 Intégration dans le pipeline principal @@ -1240,12 +1380,12 @@ def mock_blog_rss_client(): @pytest.mark.unittest def test_parse_blog_rss(mock_blog_rss_client): """Test le parsing d'un flux RSS du blog.""" - articles = mock_blog_rss_client.fetch_and_parse() + result = mock_blog_rss_client.fetch_and_parse() - assert len(articles) == 2 + assert len(result.articles) == 2 # Vérifier le premier article - article1 = articles[0] + article1 = result.articles[0] assert article1.title == "Sortie pédagogique" assert article1.url == "https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1626" assert article1.category == "Pédagogie" @@ -1254,7 +1394,7 @@ def test_parse_blog_rss(mock_blog_rss_client): assert article1.published_at == datetime(2026, 8, 11, 14, 30, 0, tzinfo=timezone.utc) # Vérifier le deuxième article - article2 = articles[1] + article2 = result.articles[1] assert article2.title == "Réunion de rentrée" assert article2.url == "https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1625" assert article2.category == "Administration" @@ -1266,26 +1406,26 @@ def test_parse_blog_rss(mock_blog_rss_client): @pytest.mark.unittest def test_blog_deduplication(tmp_path): """Test la déduplication des articles du blog.""" - from pronote_sync.sync.blog_state import BlogRSSState + from pronote_sync.sources.blog.state import BlogRSSState # Créer un fichier d'état temporaire state_file = tmp_path / "blog_state.json" state = BlogRSSState(state_file=str(state_file)) # Initialement, aucun article connu - assert state.get_last_guid() is None + assert state.get_known_guids() == frozenset() # Simuler la récupération de 2 articles - state.update_last_guid("https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1625") - assert state.get_last_guid() == "https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1625" + state.add_guids(["https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1625"]) + assert "https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1625" in state.get_known_guids() # Simuler une nouvelle récupération : seul le nouvel article doit être retourné client = BlogRSSClient(rss_url="file://tests/fixtures/blog_rss.xml") - new_articles = client.fetch_and_parse(last_guid=state.get_last_guid()) + result = client.fetch_and_parse(known_guids=state.get_known_guids()) # Seul l'article avec p=1626 doit être retourné (car p=1625 est déjà connu) - assert len(new_articles) == 1 - assert new_articles[0].id == "https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1626" + assert len(result.articles) == 1 + assert result.articles[0].id == "https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1626" ``` --- @@ -4670,10 +4810,10 @@ exception externe brute susceptible de contenir un secret. #### 11.4.1 bis `fetch_blog_step.py` ```python -from typing import List, Optional -from ..models.blog import BlogArticle -from ..sources.blog.rss import BlogRSSClient -from ..sync.blog_state import BlogRSSState +from pronote_sync.models.blog import BlogArticle +from pronote_sync.sources.blog.result import BlogRSSFetchResult +from pronote_sync.sources.blog.rss import BlogRSSClient +from pronote_sync.sources.blog.state import BlogRSSState from .errors import PipelineError, ErrorSeverity @@ -4681,33 +4821,43 @@ def fetch_blog_step( rss_client: BlogRSSClient, blog_state: BlogRSSState, enabled: bool = True, -) -> List[BlogArticle]: +) -> list[BlogArticle]: """ Étape de récupération des articles du blog du collège. - Args: - rss_client: Client RSS configuré. - blog_state: État local pour la déduplication. - enabled: Si False, retourne une liste vide. + Lit les GUID déjà connus et les en-têtes de cache HTTP depuis l'état, + puis appelle le client RSS avec ces valeurs pour une requête + conditionnelle. Si la réponse n'est pas ``304 Not Modified`` et que de + nouveaux articles sont présents, les GUID et les en-têtes de cache sont + enregistrés dans l'état. Retourne les nouveaux articles sous forme de + liste. - Returns: - Liste des nouveaux articles. - - Raises: - PipelineError: Si la récupération échoue (non bloquante pour le pipeline). + :param rss_client: Client RSS configuré. + :param blog_state: État local pour la déduplication et le cache HTTP. + :param enabled: Si False, retourne une liste vide. + :return: Liste des nouveaux articles. + :rtype: list[BlogArticle] + :raises PipelineError: Si la récupération échoue (non bloquante pour le + pipeline). """ if not enabled: return [] try: - last_guid = blog_state.get_last_guid() - articles = rss_client.fetch_and_parse(last_guid=last_guid) + known_guids = blog_state.get_known_guids() + etag, last_modified = blog_state.get_cache_headers() + result = rss_client.fetch_and_parse( + known_guids=known_guids, + etag=etag, + last_modified=last_modified, + ) - # Mettre à jour l'état si des articles sont trouvés - if articles: - blog_state.update_last_guid(articles[0].id) + # Mettre à jour l'état si de nouveaux articles sont trouvés + if not result.not_modified and result.articles: + blog_state.add_guids(article.id for article in result.articles) + blog_state.update_cache_headers(result.etag, result.last_modified) - return articles + return list(result.articles) except Exception as e: raise PipelineError( diff --git a/TODO.md b/TODO.md index b606fe5..25dcc09 100644 --- a/TODO.md +++ b/TODO.md @@ -97,11 +97,11 @@ Récupérer et normaliser l'agenda, les devoirs et les messages Pronote, avec re Récupérer le flux RSS du blog du collège, parser et dédupliquer les articles. -- [ ] Créer `sources/blog/rss.py` : `BlogRSSClient.fetch_and_parse(known_guids)` avec `feedparser` (§5 bis.7.1). -- [ ] Parser les dates (RFC 822 / ISO 8601) et convertir le HTML en texte brut (`BeautifulSoup` + `html.unescape`). -- [ ] Créer `sources/blog/state.py` (ou `sync/blog_state.py`) : `BlogRSSState` (JSON : `known_guids`, `etag`, `last_modified`). -- [ ] Implémenter la déduplication par GUID et le cache HTTP (`If-Modified-Since` / `etag`). -- [ ] Gérer un flux invalide (`bozo`) et les exceptions sans fuite de secret (retour `[]`/warning). +- [x] Créer `sources/blog/rss.py` : `BlogRSSClient.fetch_and_parse(known_guids)` avec `feedparser` (§5 bis.7.1). +- [x] Parser les dates (RFC 822 / ISO 8601) et convertir le HTML en texte brut (`BeautifulSoup` + `html.unescape`). +- [x] Créer `sources/blog/state.py` (ou `sync/blog_state.py`) : `BlogRSSState` (JSON : `known_guids`, `etag`, `last_modified`). +- [x] Implémenter la déduplication par GUID et le cache HTTP (`If-Modified-Since` / `etag`). +- [x] Gérer un flux invalide (`bozo`) et les exceptions sans fuite de secret (retour `[]`/warning). ### Critères d'acceptation - `fetch_and_parse` renvoie les nouveaux articles triés par date décroissante, sans doublons. diff --git a/pronote_sync/sources/blog/__init__.py b/pronote_sync/sources/blog/__init__.py index e69de29..18c9fcb 100644 --- a/pronote_sync/sources/blog/__init__.py +++ b/pronote_sync/sources/blog/__init__.py @@ -0,0 +1,21 @@ +"""Source du blog du collège : récupération et suivi du flux RSS. + +Ce package expose l'API publique du connecteur du blog du collège : + +- :class:`BlogRSSClient` (:mod:`pronote_sync.sources.blog.rss`) : télécharge + et parse le flux RSS, déduplique les entrées par GUID et renvoie les + nouveaux articles dans un :class:`BlogRSSFetchResult`. +- :class:`BlogRSSFetchResult` (:mod:`pronote_sync.sources.blog.result`) : + type de retour figé d'une récupération : nouveaux articles, en-têtes + HTTP de cache (``ETag``/``Last-Modified``) et indicateur ``304 Not + Modified``. +- :class:`BlogRSSState` (:mod:`pronote_sync.sources.blog.state`) : état + local persistant (GUID connus et en-têtes de cache) pour la + déduplication et les requêtes conditionnelles. +""" + +from pronote_sync.sources.blog.result import BlogRSSFetchResult +from pronote_sync.sources.blog.rss import BlogRSSClient +from pronote_sync.sources.blog.state import BlogRSSState + +__all__ = ["BlogRSSClient", "BlogRSSFetchResult", "BlogRSSState"] diff --git a/pronote_sync/sources/blog/result.py b/pronote_sync/sources/blog/result.py new file mode 100644 index 0000000..e8f7770 --- /dev/null +++ b/pronote_sync/sources/blog/result.py @@ -0,0 +1,54 @@ +"""Résultat de la récupération du flux RSS du blog du collège. + +Ce module définit :class:`BlogRSSFetchResult`, le type de retour figé du +client RSS du blog (:mod:`pronote_sync.sources.blog`). +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +from pronote_sync.models.blog import BlogArticle + + +class BlogRSSFetchResult(BaseModel): + """Résultat d'une récupération du flux RSS du blog du collège. + + Modèle figé (``frozen``) : les instances sont immuables après création. + Il regroupe les nouveaux articles, triés par date de publication + décroissante puis par identifiant croissant, ainsi que les en-têtes + HTTP utiles aux requêtes conditionnelles (``ETag`` et + ``Last-Modified``). + + :param articles: Nouveaux articles absents de ``known_guids``, triés + par date de publication décroissante puis par identifiant + croissant. Vide par défaut. + :param etag: Valeur de l'en-tête ``ETag`` de la réponse RSS, si elle + est disponible. ``None`` par défaut. + :param last_modified: Valeur de l'en-tête ``Last-Modified`` de la + réponse RSS, si elle est disponible. ``None`` par défaut. + :param not_modified: Vaut ``True`` si le serveur a répondu avec le + statut ``304 Not Modified``, ``False`` sinon. + """ + + model_config = ConfigDict(frozen=True) + + articles: tuple[BlogArticle, ...] = Field( + default=(), + description=( + "Nouveaux articles absents de known_guids, triés par date de " + "publication décroissante puis par identifiant croissant" + ), + ) + etag: str | None = Field( + default=None, + description="Valeur de l'en-tête ETag de la réponse RSS, si disponible", + ) + last_modified: str | None = Field( + default=None, + description="Valeur de l'en-tête Last-Modified de la réponse RSS, si disponible", + ) + not_modified: bool = Field( + default=False, + description="Vaut True si le serveur a répondu 304 Not Modified", + ) diff --git a/pronote_sync/sources/blog/rss.py b/pronote_sync/sources/blog/rss.py new file mode 100644 index 0000000..083417a --- /dev/null +++ b/pronote_sync/sources/blog/rss.py @@ -0,0 +1,266 @@ +"""Client de récupération et de parsing du flux RSS du blog du collège. + +Ce module définit :class:`BlogRSSClient`, un client sans état qui +télécharge le flux RSS du blog via ``feedparser``, déduplique les entrées +par GUID et les convertit en :class:`~pronote_sync.models.blog.BlogArticle`. + +Le résultat d'une récupération est un +:class:`~pronote_sync.sources.blog.result.BlogRSSFetchResult` : les +nouveaux articles (triés par date de publication décroissante, puis par +identifiant croissant) accompagnés des en-têtes HTTP ``ETag`` et +``Last-Modified`` de la réponse. Toute erreur de récupération ou de +parsing est journalisée (URL et exception rédigées) puis dégradée en +résultat vide : une liste vide est un succès valide, pas une panne. +""" + +from __future__ import annotations + +import logging +import re +from datetime import UTC, datetime +from html import unescape + +import feedparser # type: ignore[import-untyped] +from bs4 import BeautifulSoup + +from pronote_sync.models.blog import BlogArticle +from pronote_sync.sources.blog.result import BlogRSSFetchResult +from pronote_sync.utils.redaction import redact_exception, redact_url + +logger = logging.getLogger(__name__) + + +class BlogRSSClient: + """Client de récupération et de parsing du flux RSS du blog du collège. + + Client sans état : aucune E/S n'est effectuée à la construction et + aucune donnée n'est conservée entre deux appels à + :meth:`fetch_and_parse`. Toute erreur de récupération ou de parsing + est journalisée puis dégradée en résultat vide. + + :param rss_url: URL du flux RSS du blog du collège. + :param timeout: Timeout HTTP en secondes (défaut : 20). + """ + + def __init__(self, rss_url: str, timeout: int = 20) -> None: + """Initialise le client RSS du blog. + + Aucune opération d'E/S n'est réalisée ici : le téléchargement et + le parsing n'ont lieu qu'à l'appel de :meth:`fetch_and_parse`. + + :param rss_url: URL du flux RSS du blog du collège. + :param timeout: Timeout HTTP en secondes (défaut : 20). + """ + self.rss_url = rss_url + self.timeout = timeout + + def fetch_and_parse( + self, + *, + known_guids: frozenset[str] | None = None, + etag: str | None = None, + last_modified: str | None = None, + ) -> BlogRSSFetchResult: + """Télécharge et parse le flux RSS du blog en nouveaux articles. + + Le flux est téléchargé par ``feedparser`` avec les en-têtes de + requête conditionnelle fournis (``ETag``/``Last-Modified``). Si le + serveur répond ``304 Not Modified``, le résultat est vide avec + ``not_modified=True`` et les en-têtes passés en entrée sont + restitués tels quels. Chaque entrée est dédupliquée par GUID, + convertie en :class:`~pronote_sync.models.blog.BlogArticle`, puis + l'ensemble est trié par date de publication décroissante puis par + identifiant croissant. Toute erreur est journalisée (URL et + exception rédigées) et dégradée en résultat vide : aucune + exception n'est propagée. + + :param known_guids: Ensemble des GUID d'articles déjà traités ; les + entrées correspondantes sont ignorées. ``None`` pour tout + conserver (défaut). + :param etag: Valeur de l'en-tête ``ETag`` mémorisée pour la requête + conditionnelle, ou ``None`` (défaut). + :param last_modified: Valeur de l'en-tête ``Last-Modified`` mémorisée + pour la requête conditionnelle, ou ``None`` (défaut). + :return: Résultat de la récupération : nouveaux articles (tuple vide + si aucun nouvel article, réponse ``304`` ou erreur), en-têtes de + cache de la réponse et indicateur ``not_modified``. + :rtype: :class:`~pronote_sync.sources.blog.result.BlogRSSFetchResult` + """ + try: + feed = feedparser.parse( + self.rss_url, + etag=etag, + modified=last_modified, + request_timeout=self.timeout, + ) + + # Réponse 304 Not Modified : rien n'a changé, on restitue les + # en-têtes mémorisés tels quels pour les conserver. + if getattr(feed, "status", None) == 304: + return BlogRSSFetchResult( + articles=(), + etag=etag, + last_modified=last_modified, + not_modified=True, + ) + + response_etag: str | None = getattr(feed, "etag", None) + response_last_modified: str | None = getattr(feed, "modified", None) + if response_last_modified is None: + headers = getattr(feed, "headers", None) + if headers is not None: + # Les clés des en-têtes sont en minuscules côté feedparser. + response_last_modified = headers.get("last-modified") or None + + # Flux invalide (erreur HTTP, XML malformé, etc.) : avertissement + # puis résultat vide, sans propager l'exception brute. + if getattr(feed, "bozo", None): + bozo_exception = getattr(feed, "bozo_exception", None) + if bozo_exception is not None: + logger.warning( + "Flux RSS du blog invalide (%s), ignoré : %s", + redact_exception(bozo_exception), + redact_url(self.rss_url), + ) + else: + logger.warning( + "Flux RSS du blog invalide, ignoré : %s", + redact_url(self.rss_url), + ) + return BlogRSSFetchResult( + articles=(), + etag=response_etag, + last_modified=response_last_modified, + not_modified=False, + ) + + articles: list[BlogArticle] = [] + seen_guids: set[str] = set(known_guids) if known_guids is not None else set() + for entry in getattr(feed, "entries", []): + guid_source = entry.get("id") or entry.get("link") + if not guid_source: + logger.warning( + "Entrée RSS sans GUID ni lien, ignorée : %s", + redact_url(self.rss_url), + ) + continue + guid = str(guid_source) + + if guid in seen_guids: + logger.warning( + "Entrée RSS déjà traitée ou en double, ignorée : %s", + redact_url(self.rss_url), + ) + continue + + published_at = self._parse_date( + entry.get("published_parsed") or entry.get("pubdate_parsed") + ) + if published_at is None: + logger.warning( + "Entrée RSS sans date de publication valide, ignorée : %s", + redact_url(self.rss_url), + ) + continue + + updated_at = self._parse_date(entry.get("updated_parsed")) + + raw_content = entry.get("content") + if raw_content: + content_html = str(raw_content[0].get("value") or "") + else: + content_html = str(entry.get("description") or "") + + tags = entry.get("tags") + category_value = tags[0].get("term") if tags else None + if not category_value: + category_value = entry.get("category") + category = str(category_value) if category_value else None + + author_value = entry.get("author") + author = str(author_value) if author_value else None + + title = str(entry.get("title") or guid) + url = str(entry.get("link") or guid) + + articles.append( + BlogArticle( + id=guid, + title=title, + url=url, + published_at=published_at, + updated_at=updated_at, + category=category, + author=author, + content_html=content_html, + content_text=self._html_to_text(content_html), + ) + ) + + seen_guids.add(guid) + + # Tri stable : d'abord par identifiant croissant, puis par date de + # publication décroissante ; l'ordre par identifiant est conservé + # entre articles de même date. + articles.sort(key=lambda article: article.id) + articles.sort(key=lambda article: article.published_at, reverse=True) + + return BlogRSSFetchResult( + articles=tuple(articles), + etag=response_etag, + last_modified=response_last_modified, + not_modified=False, + ) + except Exception as exc: + logger.error( + "Échec de la récupération du flux RSS du blog %s : %s", + redact_url(self.rss_url), + redact_exception(exc), + ) + return BlogRSSFetchResult(articles=(), not_modified=False) + + @staticmethod + def _parse_date(date_tuple: tuple[int, ...] | None) -> datetime | None: + """Convertit un tuple de date ``struct_time`` en :class:`datetime` UTC. + + :param date_tuple: Tuple horodaté au format ``time.struct_time`` + (indices 0 à 5 : année, mois, jour, heure, minute, seconde), ou + ``None`` si absent. + :return: Date/heure consciente du fuseau UTC, ou ``None`` si le + tuple est absent, vide ou invalide. + :rtype: datetime | None + """ + if not date_tuple: + return None + try: + return datetime( + date_tuple[0], + date_tuple[1], + date_tuple[2], + date_tuple[3], + date_tuple[4], + date_tuple[5], + tzinfo=UTC, + ) + except (ValueError, IndexError): + return None + + @staticmethod + def _html_to_text(html: str) -> str: + """Convertit du HTML en texte brut nettoyé. + + Le HTML est parsé avec BeautifulSoup, les balises sont remplacées + par des espaces, les entités HTML sont décodées et les suites + d'espaces sont unifiées. + + :param html: Contenu HTML à convertir. + :return: Texte brut sans balises, entités décodées et espaces + unifiés ; chaîne vide si ``html`` est vide. + :rtype: str + """ + if not html: + return "" + soup = BeautifulSoup(html, "html.parser") + text = soup.get_text(separator=" ", strip=True) + text = unescape(text) + return re.sub(r"\s+", " ", text).strip() diff --git a/pronote_sync/sources/blog/state.py b/pronote_sync/sources/blog/state.py new file mode 100644 index 0000000..abe6735 --- /dev/null +++ b/pronote_sync/sources/blog/state.py @@ -0,0 +1,161 @@ +"""Gestion de l'état local du flux RSS du blog du collège. + +Ce module définit :class:`BlogRSSState`, un gestionnaire d'état persistant +dans un fichier JSON local (``.blog_rss_state.json`` par défaut). Il +mémorise les identifiants (GUID) des articles déjà traités — pour la +déduplication — ainsi que les en-têtes HTTP ``ETag`` et ``Last-Modified`` +de la dernière réponse — pour les requêtes conditionnelles. + +La lecture et l'écriture sont tolérantes aux erreurs : un fichier absent, +corrompu ou illisible ne fait jamais échouer le pipeline ; l'état vide est +alors utilisé. La sortie JSON est déterministe (``known_guids`` triés +alphabétiquement, champ ``version`` constant). +""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Iterable +from pathlib import Path + +from pronote_sync.utils.redaction import redact_exception, redact_secrets + +logger = logging.getLogger(__name__) + +_STATE_VERSION = 1 + + +class BlogRSSState: + """Gère l'état local pour la déduplication des articles et le cache HTTP du flux RSS. + + L'état regroupe l'ensemble des GUID d'articles déjà publiés + (``known_guids``) et les en-têtes de cache HTTP (``etag``, + ``last_modified``). Il est chargé depuis le fichier JSON à la + construction et sauvegardé à chaque modification. Toute erreur de + lecture ou d'écriture est journalisée sans être propagée. + + :param state_file: Chemin du fichier d'état JSON (``str`` ou + :class:`~pathlib.Path`). ``".blog_rss_state.json"`` par défaut. + """ + + def __init__(self, state_file: Path | str = ".blog_rss_state.json") -> None: + """Initialise le gestionnaire d'état depuis le fichier JSON. + + :param state_file: Chemin du fichier d'état JSON (``str`` ou + :class:`~pathlib.Path`). ``".blog_rss_state.json"`` par défaut. + """ + self._state_file = Path(state_file) + self._known_guids: set[str] = set() + self._etag: str | None = None + self._last_modified: str | None = None + self._load() + + def _load(self) -> None: + """Charge l'état depuis le fichier JSON. + + Si le fichier n'existe pas, l'état reste vide. Si le fichier est + corrompu, illisible ou que la version est absente ou différente + de 1, un avertissement est journalisé et l'état reste vide. + Aucune exception n'est propagée. + """ + if not self._state_file.exists(): + return + try: + data = json.loads(self._state_file.read_text(encoding="utf-8")) + if not isinstance(data, dict) or data.get("version") != _STATE_VERSION: + logger.warning( + "Fichier d'état blog RSS %s : version absente ou non supportée, " + "démarrage avec un état vide.", + redact_secrets(str(self._state_file)), + ) + return + guids_data = data.get("known_guids", []) + if isinstance(guids_data, list): + self._known_guids = {guid for guid in guids_data if isinstance(guid, str)} + etag_data = data.get("etag") + if isinstance(etag_data, str): + self._etag = etag_data + last_modified_data = data.get("last_modified") + if isinstance(last_modified_data, str): + self._last_modified = last_modified_data + except Exception as exc: + logger.warning( + "Impossible de charger le fichier d'état blog RSS %s : %s, " + "démarrage avec un état vide.", + redact_secrets(str(self._state_file)), + redact_exception(exc), + ) + + def _save(self) -> None: + """Sauvegarde l'état dans le fichier JSON. + + La sortie est déterministe : ``known_guids`` est trié + alphabétiquement et le champ ``version`` vaut 1. En cas d'erreur + d'écriture, une erreur est journalisée sans être propagée. + """ + payload = { + "version": _STATE_VERSION, + "known_guids": sorted(self._known_guids), + "etag": self._etag, + "last_modified": self._last_modified, + } + try: + with self._state_file.open("w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2) + except Exception as exc: + logger.error( + "Impossible d'écrire le fichier d'état blog RSS %s : %s.", + redact_secrets(str(self._state_file)), + redact_exception(exc), + ) + + def get_known_guids(self) -> frozenset[str]: + """Renvoie une copie immuable des GUID d'articles déjà connus. + + :return: Copie de type :class:`frozenset` des GUID connus. + :rtype: frozenset[str] + """ + return frozenset(self._known_guids) + + def add_guids(self, guids: Iterable[str]) -> None: + """Ajoute des GUID d'articles à l'état connu et sauvegarde. + + Si l'itérable ne contient aucun GUID, l'état n'est pas modifié et + aucune sauvegarde n'est déclenchée. + + :param guids: Itérable des GUID d'articles à enregistrer. + """ + new_guids = set(guids) + if not new_guids: + return + self._known_guids.update(new_guids) + self._save() + + def get_cache_headers(self) -> tuple[str | None, str | None]: + """Renvoie les en-têtes de cache HTTP mémorisés. + + :return: Tuple ``(etag, last_modified)``, chaque valeur pouvant + être ``None`` si elle n'a jamais été reçue. + :rtype: tuple[str | None, str | None] + """ + return self._etag, self._last_modified + + def update_cache_headers(self, etag: str | None, last_modified: str | None) -> None: + """Met à jour les en-têtes de cache HTTP et sauvegarde. + + :param etag: Nouvelle valeur de l'en-tête ``ETag``, ou ``None`` + pour l'effacer. + :param last_modified: Nouvelle valeur de l'en-tête + ``Last-Modified``, ou ``None`` pour l'effacer. + """ + self._etag = etag + self._last_modified = last_modified + self._save() + + def clear(self) -> None: + """Réinitialise l'état (GUID et en-têtes de cache) et sauvegarde.""" + self._known_guids = set() + self._etag = None + self._last_modified = None + self._save() diff --git a/tests/fixtures/blog_rss.xml b/tests/fixtures/blog_rss.xml new file mode 100644 index 0000000..f7729e8 --- /dev/null +++ b/tests/fixtures/blog_rss.xml @@ -0,0 +1,47 @@ + + + + Blog du collège Les Mimosas + https://example.com/blog/ + Actualités et informations du collège Les Mimosas + fr-FR + + Information générale + https://example.com/blog/?p=1003 + https://example.com/blog/?p=1003 + Wed, 12 Aug 2026 08:00:00 +0000 + Information générale à destination des familles. + La vie scolaire rappelle aux familles que les billets de cantine sont à commander avant le vendredi soir.

+

Pour toute question, consultez la page cantines et restauration du site.

+ ]]>
+
+ + Réunion de rentrée + https://example.com/blog/?p=1001 + https://example.com/blog/?p=1001 + Mon, 10 Aug 2026 09:00:11 +0000 + Administration + M. Dupont + Réunion de rentrée des parents d'élèves. + La réunion de rentrée des parents d'élèves se tiendra le mardi 15 septembre à 18 h 00 dans la salle polyvalente.

+

L'équipe pédagogique y présentera le projet d'établissement et le calendrier des conseils de classe. Un temps d'échange est prévu avec les professeurs principaux.

+

Merci de confirmer votre présence en remplissant le formulaire d'inscription avant le 10 septembre.

+ ]]>
+
+ + Sortie pédagogique au musée + https://example.com/blog/?p=1002 + https://example.com/blog/?p=1002 + Tue, 11 Aug 2026 14:30:00 +0000 + Pédagogie + Sortie pédagogique des élèves de 4e au musée d'art moderne. + Les élèves de 4e se rendront au musée d'art moderne le jeudi 8 octobre dans le cadre du cours d'arts plastiques.

+

La visite guidée portera sur la période impressionniste. Les élèves devront apporter un carnet de croquis et leur pique-nique.

+

Le détail de l'organisation figure dans la note d'autorisation à retourner signée avant le 25 septembre.

+ ]]>
+
+
+
diff --git a/tests/unit/test_blog_client.py b/tests/unit/test_blog_client.py new file mode 100644 index 0000000..2958fb9 --- /dev/null +++ b/tests/unit/test_blog_client.py @@ -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 "

" 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" diff --git a/tests/unit/test_blog_state.py b/tests/unit/test_blog_state.py new file mode 100644 index 0000000..cdb5f3c --- /dev/null +++ b/tests/unit/test_blog_state.py @@ -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 From bfae1ca87fc4b699a7cc2847194b9cce7a37e4fb Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Sun, 6 Sep 2026 20:58:04 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(M5):=20corrections=20d'audit=20?= =?UTF-8?q?=E2=80=94=20transport=20HTTP,=20statuts=20d'erreur,=20cache=20a?= =?UTF-8?q?tomique?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrige les 5 points de l'audit FIXME_M5 : 1. (Bloquant) Sépare transport HTTP et parsing : utilise requests.get() avec timeout explicite et en-têtes conditionnels, puis transmet le contenu à feedparser.parse() — supprime le paramètre inexistant request_timeout qui faisait échouer toute récupération réelle. 2. Rejette les statuts HTTP 4xx/5xx via raise_for_status() avant le parsing. 3. Préserve les validateurs de cache (etag, last_modified) d'entrée sur les chemins d'échec (exception, bozo) au lieu de les écraser à None. 4. Sauvegarde atomique de BlogRSSState : écrit dans un .tmp puis Path.replace() pour éviter la corruption sur interruption. 5. Déduplication normale silencieuse : les GUID déjà connus sont ignorés sans warning ; seuls les doublons intra-flux génèrent un avertissement. Tests : 49 tests (32 client + 17 state) dont 11 nouveaux couvrant transport HTTP réel, statuts 401/404/500, préservation des validateurs, en-têtes conditionnels, doublons intra-flux et sauvegarde atomique. Guide : §5 bis.7.1 aligné avec le nouveau pattern transport/parsing. Co-authored-by: opencode/coder Co-authored-by: opencode/test-engineer --- .secrets.baseline | 4 +- GUIDE_DEV_PYTHON.md | 45 +- pronote_sync/sources/blog/rss.py | 85 ++-- pronote_sync/sources/blog/state.py | 21 +- tests/unit/test_blog_client.py | 657 ++++++++++++++++++++++++++--- tests/unit/test_blog_state.py | 39 ++ 6 files changed, 753 insertions(+), 98 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 31437f1..9ae5bb0 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -140,10 +140,10 @@ "filename": "GUIDE_DEV_PYTHON.md", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "is_verified": true, - "line_number": 5043, + "line_number": 5058, "is_secret": false } ] }, - "generated_at": "2026-09-06T16:41:06Z" + "generated_at": "2026-09-06T18:57:58Z" } diff --git a/GUIDE_DEV_PYTHON.md b/GUIDE_DEV_PYTHON.md index 2ff869d..6dc6232 100644 --- a/GUIDE_DEV_PYTHON.md +++ b/GUIDE_DEV_PYTHON.md @@ -850,6 +850,7 @@ from datetime import UTC, datetime from html import unescape import feedparser +import requests from bs4 import BeautifulSoup from ..models.blog import BlogArticle @@ -891,16 +892,17 @@ class BlogRSSClient: indicateur ``304 Not Modified``. """ try: - # Récupération du flux avec requête conditionnelle (ETag / Last-Modified) - feed = feedparser.parse( - self.rss_url, - etag=etag, - modified=last_modified, - request_timeout=self.timeout, - ) + # Transport HTTP séparé du parsing + headers: dict[str, str] = {"user-agent": "pronote-sync"} + if etag is not None: + headers["If-None-Match"] = etag + if last_modified is not None: + headers["If-Modified-Since"] = last_modified - # Réponse 304 Not Modified : rien n'a changé, on restitue les en-têtes mémorisés - if getattr(feed, "status", None) == 304: + response = requests.get(self.rss_url, headers=headers, timeout=self.timeout) + + # 304 Not Modified : pas de nouveaux articles + if response.status_code == 304: return BlogRSSFetchResult( articles=(), etag=etag, @@ -908,10 +910,18 @@ class BlogRSSClient: not_modified=True, ) - response_etag: str | None = getattr(feed, "etag", None) - response_last_modified: str | None = getattr(feed, "modified", None) + # Rejeter les statuts d'erreur (4xx/5xx) + response.raise_for_status() - # Flux invalide (erreur HTTP, XML malformé, etc.) : résultat vide, sans erreur + # Extraire les en-têtes de cache de la réponse (ETag / Last-Modified) + response_etag: str | None = response.headers.get("ETag") + response_last_modified: str | None = response.headers.get("Last-Modified") + + # Parsing du contenu reçu (pas de l'URL) + feed = feedparser.parse(response.content) + + # Flux invalide (XML malformé, etc.) : résultat vide, sans erreur ; + # les en-têtes de cache d'entrée sont conservés tels quels if getattr(feed, "bozo", None): logger.warning( "Flux RSS du blog invalide, ignoré : %s", @@ -919,8 +929,8 @@ class BlogRSSClient: ) return BlogRSSFetchResult( articles=(), - etag=response_etag, - last_modified=response_last_modified, + etag=etag, + last_modified=last_modified, not_modified=False, ) @@ -988,7 +998,12 @@ class BlogRSSClient: except Exception as e: safe_url = redact_url(self.rss_url) logger.error(f"Échec de la récupération du flux RSS {safe_url}: {e}") - return BlogRSSFetchResult(articles=(), not_modified=False) + return BlogRSSFetchResult( + articles=(), + etag=etag, + last_modified=last_modified, + not_modified=False, + ) @staticmethod def _parse_date(date_tuple: tuple[int, ...] | None) -> datetime | None: diff --git a/pronote_sync/sources/blog/rss.py b/pronote_sync/sources/blog/rss.py index 083417a..50c74fd 100644 --- a/pronote_sync/sources/blog/rss.py +++ b/pronote_sync/sources/blog/rss.py @@ -1,8 +1,9 @@ """Client de récupération et de parsing du flux RSS du blog du collège. Ce module définit :class:`BlogRSSClient`, un client sans état qui -télécharge le flux RSS du blog via ``feedparser``, déduplique les entrées -par GUID et les convertit en :class:`~pronote_sync.models.blog.BlogArticle`. +télécharge le flux RSS du blog via ``requests``, le parse via +``feedparser``, déduplique les entrées par GUID et les convertit en +:class:`~pronote_sync.models.blog.BlogArticle`. Le résultat d'une récupération est un :class:`~pronote_sync.sources.blog.result.BlogRSSFetchResult` : les @@ -21,6 +22,7 @@ from datetime import UTC, datetime from html import unescape import feedparser # type: ignore[import-untyped] +import requests from bs4 import BeautifulSoup from pronote_sync.models.blog import BlogArticle @@ -63,9 +65,10 @@ class BlogRSSClient: ) -> BlogRSSFetchResult: """Télécharge et parse le flux RSS du blog en nouveaux articles. - Le flux est téléchargé par ``feedparser`` avec les en-têtes de - requête conditionnelle fournis (``ETag``/``Last-Modified``). Si le - serveur répond ``304 Not Modified``, le résultat est vide avec + Le flux est téléchargé par ``requests`` avec les en-têtes de + requête conditionnelle fournis (``ETag``/``Last-Modified``), puis + parsé par ``feedparser``. Si le serveur répond ``304 Not Modified``, + le résultat est vide avec ``not_modified=True`` et les en-têtes passés en entrée sont restitués tels quels. Chaque entrée est dédupliquée par GUID, convertie en :class:`~pronote_sync.models.blog.BlogArticle`, puis @@ -87,16 +90,19 @@ class BlogRSSClient: :rtype: :class:`~pronote_sync.sources.blog.result.BlogRSSFetchResult` """ try: - feed = feedparser.parse( - self.rss_url, - etag=etag, - modified=last_modified, - request_timeout=self.timeout, - ) + # Téléchargement HTTP explicite via requests : feedparser 6.x + # n'accepte aucun paramètre de transport ; les requêtes + # conditionnelles sont gérées avec les en-têtes HTTP standards. + headers: dict[str, str] = {"user-agent": "pronote-sync"} + if etag is not None: + headers["If-None-Match"] = etag + if last_modified is not None: + headers["If-Modified-Since"] = last_modified + response = requests.get(self.rss_url, headers=headers, timeout=self.timeout) # Réponse 304 Not Modified : rien n'a changé, on restitue les # en-têtes mémorisés tels quels pour les conserver. - if getattr(feed, "status", None) == 304: + if response.status_code == 304: return BlogRSSFetchResult( articles=(), etag=etag, @@ -104,16 +110,24 @@ class BlogRSSClient: not_modified=True, ) - response_etag: str | None = getattr(feed, "etag", None) - response_last_modified: str | None = getattr(feed, "modified", None) - if response_last_modified is None: - headers = getattr(feed, "headers", None) - if headers is not None: - # Les clés des en-têtes sont en minuscules côté feedparser. - response_last_modified = headers.get("last-modified") or None + # Les statuts 4xx/5xx lèvent une exception HTTP, attrapée par le + # gestionnaire général et dégradée en résultat vide. + response.raise_for_status() - # Flux invalide (erreur HTTP, XML malformé, etc.) : avertissement - # puis résultat vide, sans propager l'exception brute. + response_etag: str | None = response.headers.get("ETag", None) + if response_etag is None: + response_etag = response.headers.get("etag", None) + response_last_modified: str | None = response.headers.get("Last-Modified", None) + if response_last_modified is None: + response_last_modified = response.headers.get("last-modified", None) + + # feedparser ne reçoit que le contenu brut de la réponse. + feed = feedparser.parse(response.content) + + # Flux invalide (XML malformé, etc.) : avertissement puis résultat + # vide, sans propager l'exception brute. Les validateurs de cache + # d'entrée sont conservés : on ne fait pas confiance aux en-têtes + # d'une réponse au contenu invalide. if getattr(feed, "bozo", None): bozo_exception = getattr(feed, "bozo_exception", None) if bozo_exception is not None: @@ -129,13 +143,16 @@ class BlogRSSClient: ) return BlogRSSFetchResult( articles=(), - etag=response_etag, - last_modified=response_last_modified, + etag=etag, + last_modified=last_modified, not_modified=False, ) articles: list[BlogArticle] = [] - seen_guids: set[str] = set(known_guids) if known_guids is not None else set() + # Déduplication silencieuse des GUID déjà connus (exécutions + # précédentes) et détection des doublons au sein de la réponse. + known_set = set(known_guids) if known_guids is not None else None + seen_in_feed: set[str] = set() for entry in getattr(feed, "entries", []): guid_source = entry.get("id") or entry.get("link") if not guid_source: @@ -146,13 +163,20 @@ class BlogRSSClient: continue guid = str(guid_source) - if guid in seen_guids: + if known_set is not None and guid in known_set: + # Déduplication normale (GUID connu d'une exécution + # précédente) : aucun journal n'est nécessaire. + continue + + if guid in seen_in_feed: logger.warning( - "Entrée RSS déjà traitée ou en double, ignorée : %s", + "Entrée RSS en double dans le flux, ignorée : %s", redact_url(self.rss_url), ) continue + seen_in_feed.add(guid) + published_at = self._parse_date( entry.get("published_parsed") or entry.get("pubdate_parsed") ) @@ -197,8 +221,6 @@ class BlogRSSClient: ) ) - seen_guids.add(guid) - # Tri stable : d'abord par identifiant croissant, puis par date de # publication décroissante ; l'ordre par identifiant est conservé # entre articles de même date. @@ -217,7 +239,12 @@ class BlogRSSClient: redact_url(self.rss_url), redact_exception(exc), ) - return BlogRSSFetchResult(articles=(), not_modified=False) + return BlogRSSFetchResult( + articles=(), + etag=etag, + last_modified=last_modified, + not_modified=False, + ) @staticmethod def _parse_date(date_tuple: tuple[int, ...] | None) -> datetime | None: diff --git a/pronote_sync/sources/blog/state.py b/pronote_sync/sources/blog/state.py index abe6735..a5907e8 100644 --- a/pronote_sync/sources/blog/state.py +++ b/pronote_sync/sources/blog/state.py @@ -88,11 +88,15 @@ class BlogRSSState: ) def _save(self) -> None: - """Sauvegarde l'état dans le fichier JSON. + """Sauvegarde l'état dans le fichier JSON de manière atomique. La sortie est déterministe : ``known_guids`` est trié - alphabétiquement et le champ ``version`` vaut 1. En cas d'erreur - d'écriture, une erreur est journalisée sans être propagée. + alphabétiquement et le champ ``version`` vaut 1. Le JSON est + d'abord écrit dans un fichier temporaire du même répertoire, puis + remplacé atomiquement par :meth:`~pathlib.Path.replace` afin de ne + jamais laisser un fichier partiel en cas d'interruption. En cas + d'erreur d'écriture, une erreur est journalisée sans être + propagée et le fichier temporaire est supprimé. """ payload = { "version": _STATE_VERSION, @@ -100,15 +104,24 @@ class BlogRSSState: "etag": self._etag, "last_modified": self._last_modified, } + tmp_file = self._state_file.with_suffix(".tmp") try: - with self._state_file.open("w", encoding="utf-8") as handle: + with open(tmp_file, "w", encoding="utf-8") as handle: json.dump(payload, handle, indent=2) + tmp_file.replace(self._state_file) except Exception as exc: logger.error( "Impossible d'écrire le fichier d'état blog RSS %s : %s.", redact_secrets(str(self._state_file)), redact_exception(exc), ) + try: + tmp_file.unlink(missing_ok=True) + except Exception as cleanup_exc: + logger.debug( + "Nettoyage du fichier temporaire échoué : %s", + redact_exception(cleanup_exc), + ) def get_known_guids(self) -> frozenset[str]: """Renvoie une copie immuable des GUID d'articles déjà connus. diff --git a/tests/unit/test_blog_client.py b/tests/unit/test_blog_client.py index 2958fb9..43b978d 100644 --- a/tests/unit/test_blog_client.py +++ b/tests/unit/test_blog_client.py @@ -20,10 +20,13 @@ import logging from datetime import UTC, datetime from pathlib import Path from typing import Any +from unittest.mock import Mock import feedparser # type: ignore[import-untyped] import pytest import pytest_mock +import requests +import responses from pronote_sync.sources.blog.rss import BlogRSSClient @@ -167,6 +170,18 @@ def test_fetch_and_parse_nominal( :param blog_rss_fixture_path: Chemin vers le fichier fixture. :return: None """ + # Mock requests.get pour retourner une réponse 200 avec le contenu du fixture + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = blog_rss_fixture_path.read_bytes() + mock_response.headers = {} + mock_response.raise_for_status = Mock() + + mocker.patch( + "pronote_sync.sources.blog.rss.requests.get", + return_value=mock_response, + ) + # Mock feedparser.parse pour retourner le résultat réel du parsing mocker.patch( "pronote_sync.sources.blog.rss.feedparser.parse", @@ -213,6 +228,7 @@ def test_fetch_and_parse_nominal( def test_fetch_and_parse_deduplication( mocker: pytest_mock.MockerFixture, real_parsed_feed: feedparser.FeedParserDict, + blog_rss_fixture_path: Path, ) -> None: """Vérifie la déduplication avec known_guids. @@ -220,8 +236,21 @@ def test_fetch_and_parse_deduplication( :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 requests.get pour retourner une réponse 200 + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = blog_rss_fixture_path.read_bytes() + mock_response.headers = {} + mock_response.raise_for_status = Mock() + + mocker.patch( + "pronote_sync.sources.blog.rss.requests.get", + return_value=mock_response, + ) + mocker.patch( "pronote_sync.sources.blog.rss.feedparser.parse", return_value=real_parsed_feed, @@ -241,13 +270,27 @@ def test_fetch_and_parse_deduplication( def test_fetch_and_parse_empty_known_guids( mocker: pytest_mock.MockerFixture, real_parsed_feed: feedparser.FeedParserDict, + blog_rss_fixture_path: Path, ) -> 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. + :param blog_rss_fixture_path: Chemin vers le fichier fixture. :return: None """ + # Mock requests.get pour retourner une réponse 200 + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = blog_rss_fixture_path.read_bytes() + mock_response.headers = {} + mock_response.raise_for_status = Mock() + + mocker.patch( + "pronote_sync.sources.blog.rss.requests.get", + return_value=mock_response, + ) + mocker.patch( "pronote_sync.sources.blog.rss.feedparser.parse", return_value=real_parsed_feed, @@ -262,13 +305,27 @@ def test_fetch_and_parse_empty_known_guids( def test_fetch_and_parse_known_guids_none( mocker: pytest_mock.MockerFixture, real_parsed_feed: feedparser.FeedParserDict, + blog_rss_fixture_path: Path, ) -> 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. + :param blog_rss_fixture_path: Chemin vers le fichier fixture. :return: None """ + # Mock requests.get pour retourner une réponse 200 + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = blog_rss_fixture_path.read_bytes() + mock_response.headers = {} + mock_response.raise_for_status = Mock() + + mocker.patch( + "pronote_sync.sources.blog.rss.requests.get", + return_value=mock_response, + ) + mocker.patch( "pronote_sync.sources.blog.rss.feedparser.parse", return_value=real_parsed_feed, @@ -290,10 +347,14 @@ def test_fetch_and_parse_304_not_modified( :param mocker: Fixture pytest-mock pour le mocking. :return: None """ - mock_feed = make_mock_feed(status=304, etag="old_etag", modified="old_modified") + # Mock requests.get pour retourner une réponse 304 + mock_response = Mock() + mock_response.status_code = 304 + mock_response.headers = {} + mocker.patch( - "pronote_sync.sources.blog.rss.feedparser.parse", - return_value=mock_feed, + "pronote_sync.sources.blog.rss.requests.get", + return_value=mock_response, ) client = BlogRSSClient(rss_url="https://example.com/blog/feed") @@ -315,6 +376,7 @@ def test_fetch_and_parse_bozo_invalid_feed( """Vérifie la gestion d'un flux invalide (bozo=1). Doit retourner articles vide, not_modified=False, et logger un avertissement. + Les en-têtes d'entrée sont conservés (pas ceux de la réponse invalide). :param mocker: Fixture pytest-mock pour le mocking. :param caplog: Fixture pour capturer les logs. @@ -325,21 +387,33 @@ def test_fetch_and_parse_bozo_invalid_feed( mock_feed = make_mock_feed( bozo=1, bozo_exception=mock_exception, - etag="test_etag", - modified="test_modified", ) + + # Mock requests.get pour retourner une réponse 200 avec du contenu + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = b"" + mock_response.headers = {"ETag": "response_etag", "Last-Modified": "response_modified"} + mock_response.raise_for_status = Mock() + + mocker.patch( + "pronote_sync.sources.blog.rss.requests.get", + return_value=mock_response, + ) + 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() + result = client.fetch_and_parse(etag="input_etag", last_modified="input_modified") assert result.articles == () assert result.not_modified is False - assert result.etag == "test_etag" - assert result.last_modified == "test_modified" + # Les en-têtes d'entrée sont conservés, pas ceux de la réponse + assert result.etag == "input_etag" + assert result.last_modified == "input_modified" # Vérification du log d'avertissement assert "Flux RSS du blog invalide" in caplog.text @@ -362,7 +436,7 @@ def test_fetch_and_parse_network_error( with caplog.at_level(logging.ERROR): mock_exception = ConnectionError("Network error") mocker.patch( - "pronote_sync.sources.blog.rss.feedparser.parse", + "pronote_sync.sources.blog.rss.requests.get", side_effect=mock_exception, ) @@ -390,9 +464,9 @@ def test_fetch_and_parse_no_secret_leak_in_logs( 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}") + mock_exception = ConnectionError(f"Error with token: {secret}") mocker.patch( - "pronote_sync.sources.blog.rss.feedparser.parse", + "pronote_sync.sources.blog.rss.requests.get", side_effect=mock_exception, ) @@ -425,6 +499,19 @@ def test_fetch_and_parse_entry_without_date( pubdate_parsed=None, ) mock_feed = make_mock_feed(entries=[entry]) + + # Mock requests.get pour retourner une réponse 200 + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = b"" + mock_response.headers = {} + mock_response.raise_for_status = Mock() + + mocker.patch( + "pronote_sync.sources.blog.rss.requests.get", + return_value=mock_response, + ) + mocker.patch( "pronote_sync.sources.blog.rss.feedparser.parse", return_value=mock_feed, @@ -455,6 +542,19 @@ def test_fetch_and_parse_entry_without_guid( published_parsed=(2026, 8, 10, 9, 0, 11, 0, 222, 0), ) mock_feed = make_mock_feed(entries=[entry]) + + # Mock requests.get pour retourner une réponse 200 + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = b"" + mock_response.headers = {} + mock_response.raise_for_status = Mock() + + mocker.patch( + "pronote_sync.sources.blog.rss.requests.get", + return_value=mock_response, + ) + mocker.patch( "pronote_sync.sources.blog.rss.feedparser.parse", return_value=mock_feed, @@ -470,13 +570,27 @@ def test_fetch_and_parse_entry_without_guid( def test_fetch_and_parse_html_to_text_conversion( mocker: pytest_mock.MockerFixture, real_parsed_feed: feedparser.FeedParserDict, + blog_rss_fixture_path: Path, ) -> 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. + :param blog_rss_fixture_path: Chemin vers le fichier fixture. :return: None """ + # Mock requests.get pour retourner une réponse 200 + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = blog_rss_fixture_path.read_bytes() + mock_response.headers = {} + mock_response.raise_for_status = Mock() + + mocker.patch( + "pronote_sync.sources.blog.rss.requests.get", + return_value=mock_response, + ) + mocker.patch( "pronote_sync.sources.blog.rss.feedparser.parse", return_value=real_parsed_feed, @@ -505,11 +619,19 @@ def test_fetch_and_parse_etag_last_modified_returned( :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", + # Mock requests.get pour retourner une réponse 200 avec des en-têtes + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = b"" + mock_response.headers = {"ETag": "abc123", "Last-Modified": "Wed, 01 Sep 2026 00:00:00 GMT"} + mock_response.raise_for_status = Mock() + + mocker.patch( + "pronote_sync.sources.blog.rss.requests.get", + return_value=mock_response, ) + + mock_feed = make_mock_feed(entries=[]) mocker.patch( "pronote_sync.sources.blog.rss.feedparser.parse", return_value=mock_feed, @@ -523,15 +645,26 @@ def test_fetch_and_parse_etag_last_modified_returned( assert result.not_modified is False -def test_fetch_and_parse_passes_etag_to_feedparser( +def test_fetch_and_parse_passes_etag_to_requests( mocker: pytest_mock.MockerFixture, ) -> None: - """Vérifie que les paramètres etag et last_modified sont passés à feedparser.parse. + """Vérifie que les paramètres etag et last_modified sont passés comme en-têtes à requests.get. :param mocker: Fixture pytest-mock pour le mocking. :return: None """ - mock_parse = mocker.patch( + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = b"" + mock_response.headers = {} + mock_response.raise_for_status = Mock() + + mock_get = mocker.patch( + "pronote_sync.sources.blog.rss.requests.get", + return_value=mock_response, + ) + + mocker.patch( "pronote_sync.sources.blog.rss.feedparser.parse", return_value=make_mock_feed(), ) @@ -542,11 +675,11 @@ def test_fetch_and_parse_passes_etag_to_feedparser( 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" + # Vérification que requests.get a été appelé avec les bons en-têtes + mock_get.assert_called_once() + call_args = mock_get.call_args + assert call_args[1]["headers"]["If-None-Match"] == "abc123" + assert call_args[1]["headers"]["If-Modified-Since"] == "Wed, 01 Sep 2026 00:00:00 GMT" def test_parse_date_valid() -> None: @@ -613,6 +746,7 @@ def test_html_to_text_entities() -> None: def test_fetch_and_parse_sort_deterministic( mocker: pytest_mock.MockerFixture, real_parsed_feed: feedparser.FeedParserDict, + blog_rss_fixture_path: Path, ) -> None: """Vérifie que le tri des articles est déterministe. @@ -620,8 +754,21 @@ def test_fetch_and_parse_sort_deterministic( :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 requests.get pour retourner une réponse 200 + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = blog_rss_fixture_path.read_bytes() + mock_response.headers = {} + mock_response.raise_for_status = Mock() + + mocker.patch( + "pronote_sync.sources.blog.rss.requests.get", + return_value=mock_response, + ) + mocker.patch( "pronote_sync.sources.blog.rss.feedparser.parse", return_value=real_parsed_feed, @@ -647,42 +794,62 @@ def test_fetch_and_parse_sort_deterministic( def test_fetch_and_parse_duplicate_guid_in_feed( mocker: pytest_mock.MockerFixture, + caplog: pytest.LogCaptureFixture, ) -> 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. + Un avertissement doit être journalisé pour le duplicata. :param mocker: Fixture pytest-mock pour le mocking. + :param caplog: Fixture pour capturer les logs. :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), - ) + with caplog.at_level(logging.WARNING): + # 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, - ) + mock_feed = make_mock_feed(entries=[entry1, entry2]) - client = BlogRSSClient(rss_url="https://example.com/blog/feed") - result = client.fetch_and_parse(known_guids=None) + # Mock requests.get pour retourner une réponse 200 + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = b"" + mock_response.headers = {} + mock_response.raise_for_status = Mock() - # 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" + mocker.patch( + "pronote_sync.sources.blog.rss.requests.get", + return_value=mock_response, + ) + + 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" + + # Vérification qu'un avertissement a été journalisé pour le duplicata + assert "Entrée RSS en double dans le flux" in caplog.text def test_fetch_and_parse_sort_tied_dates_by_id( @@ -710,6 +877,19 @@ def test_fetch_and_parse_sort_tied_dates_by_id( ) mock_feed = make_mock_feed(entries=[entry_b, entry_a]) # Ordre initial : b, a + + # Mock requests.get pour retourner une réponse 200 + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = b"" + mock_response.headers = {} + mock_response.raise_for_status = Mock() + + mocker.patch( + "pronote_sync.sources.blog.rss.requests.get", + return_value=mock_response, + ) + mocker.patch( "pronote_sync.sources.blog.rss.feedparser.parse", return_value=mock_feed, @@ -722,3 +902,384 @@ def test_fetch_and_parse_sort_tied_dates_by_id( assert len(result.articles) == 2 assert result.articles[0].id == "guid-a" # a doit venir avant b assert result.articles[1].id == "guid-b" + + +# --- New tests for the updated implementation --- + + +def test_fetch_and_parse_real_fixture_no_mock( + blog_rss_fixture_path: Path, +) -> None: + """Vérifie que le parsing du fixture réel fonctionne sans mock de feedparser. + + Utilise un serveur HTTP mocké avec responses pour retourner le contenu + du fixture, et laisse feedparser.parse s'exécuter normalement. + + :param blog_rss_fixture_path: Chemin vers le fichier fixture. + :return: None + """ + fixture_content = blog_rss_fixture_path.read_bytes() + + with responses.RequestsMock() as rsps: + rsps.add( + responses.GET, + "https://example.com/blog/feed", + body=fixture_content, + status=200, + content_type="application/rss+xml", + ) + + 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" + + +def test_fetch_and_parse_401_error( + mocker: pytest_mock.MockerFixture, + caplog: pytest.LogCaptureFixture, +) -> None: + """Vérifie la gestion d'une erreur 401 Unauthorized. + + Doit retourner un résultat vide et logger une erreur. + + :param mocker: Fixture pytest-mock pour le mocking. + :param caplog: Fixture pour capturer les logs. + :return: None + """ + with caplog.at_level(logging.ERROR): + # Mock requests.get pour retourner une réponse 401 + mock_response = Mock() + mock_response.status_code = 401 + mock_response.headers = {} + + def raise_for_status() -> None: + raise requests.HTTPError("401 Client Error: Unauthorized") + + mock_response.raise_for_status = raise_for_status + + mocker.patch( + "pronote_sync.sources.blog.rss.requests.get", + return_value=mock_response, + ) + + client = BlogRSSClient(rss_url="https://example.com/blog/feed") + result = client.fetch_and_parse() + + assert result.articles == () + assert result.not_modified is False + assert "Échec de la récupération du flux RSS du blog" in caplog.text + + +def test_fetch_and_parse_404_error( + mocker: pytest_mock.MockerFixture, + caplog: pytest.LogCaptureFixture, +) -> None: + """Vérifie la gestion d'une erreur 404 Not Found. + + Doit retourner un résultat vide et logger une erreur. + + :param mocker: Fixture pytest-mock pour le mocking. + :param caplog: Fixture pour capturer les logs. + :return: None + """ + with caplog.at_level(logging.ERROR): + # Mock requests.get pour retourner une réponse 404 + mock_response = Mock() + mock_response.status_code = 404 + mock_response.headers = {} + + def raise_for_status() -> None: + raise requests.HTTPError("404 Client Error: Not Found") + + mock_response.raise_for_status = raise_for_status + + mocker.patch( + "pronote_sync.sources.blog.rss.requests.get", + return_value=mock_response, + ) + + client = BlogRSSClient(rss_url="https://example.com/blog/feed") + result = client.fetch_and_parse() + + assert result.articles == () + assert result.not_modified is False + assert "Échec de la récupération du flux RSS du blog" in caplog.text + + +def test_fetch_and_parse_500_error( + mocker: pytest_mock.MockerFixture, + caplog: pytest.LogCaptureFixture, +) -> None: + """Vérifie la gestion d'une erreur 500 Internal Server Error. + + Doit retourner un résultat vide et logger une erreur. + + :param mocker: Fixture pytest-mock pour le mocking. + :param caplog: Fixture pour capturer les logs. + :return: None + """ + with caplog.at_level(logging.ERROR): + # Mock requests.get pour retourner une réponse 500 + mock_response = Mock() + mock_response.status_code = 500 + mock_response.headers = {} + + def raise_for_status() -> None: + raise requests.HTTPError("500 Server Error: Internal Server Error") + + mock_response.raise_for_status = raise_for_status + + mocker.patch( + "pronote_sync.sources.blog.rss.requests.get", + return_value=mock_response, + ) + + client = BlogRSSClient(rss_url="https://example.com/blog/feed") + result = client.fetch_and_parse() + + assert result.articles == () + assert result.not_modified is False + assert "Échec de la récupération du flux RSS du blog" in caplog.text + + +def test_fetch_and_parse_preserves_etag_on_error( + mocker: pytest_mock.MockerFixture, +) -> None: + """Vérifie que les en-têtes d'entrée sont conservés en cas d'erreur. + + :param mocker: Fixture pytest-mock pour le mocking. + :return: None + """ + mock_exception = ConnectionError("Network error") + mocker.patch( + "pronote_sync.sources.blog.rss.requests.get", + side_effect=mock_exception, + ) + + client = BlogRSSClient(rss_url="https://example.com/blog/feed") + result = client.fetch_and_parse( + etag="known-etag", + last_modified="known-date", + ) + + # Les en-têtes d'entrée doivent être conservés + assert result.etag == "known-etag" + assert result.last_modified == "known-date" + assert result.articles == () + assert result.not_modified is False + + +def test_fetch_and_parse_preserves_etag_on_bozo( + mocker: pytest_mock.MockerFixture, +) -> None: + """Vérifie que les en-têtes d'entrée sont conservés en cas de flux bozo. + + :param mocker: Fixture pytest-mock pour le mocking. + :return: None + """ + mock_feed = make_mock_feed(bozo=1) + + # Mock requests.get pour retourner une réponse 200 + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = b"" + mock_response.headers = {"ETag": "response_etag", "Last-Modified": "response_modified"} + mock_response.raise_for_status = Mock() + + mocker.patch( + "pronote_sync.sources.blog.rss.requests.get", + return_value=mock_response, + ) + + 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="known-etag", last_modified="known-date") + + # Les en-têtes d'entrée doivent être conservés, pas ceux de la réponse + assert result.etag == "known-etag" + assert result.last_modified == "known-date" + assert result.articles == () + assert result.not_modified is False + + +def test_fetch_and_parse_conditional_headers_sent( + mocker: pytest_mock.MockerFixture, +) -> None: + """Vérifie que les en-têtes conditionnels sont envoyés avec la requête. + + :param mocker: Fixture pytest-mock pour le mocking. + :return: None + """ + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = b"" + mock_response.headers = {} + mock_response.raise_for_status = Mock() + + mock_get = mocker.patch( + "pronote_sync.sources.blog.rss.requests.get", + return_value=mock_response, + ) + + 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="etag123", + last_modified="Wed, 01 Sep 2026 GMT", + ) + + # Vérification que les en-têtes conditionnels ont été envoyés + mock_get.assert_called_once() + call_args = mock_get.call_args + headers = call_args[1]["headers"] + assert headers["If-None-Match"] == "etag123" + assert headers["If-Modified-Since"] == "Wed, 01 Sep 2026 GMT" + + +def test_fetch_and_parse_no_conditional_headers_when_none( + mocker: pytest_mock.MockerFixture, +) -> None: + """Vérifie qu'aucun en-tête conditionnel n'est envoyé lorsque etag/last_modified sont None. + + :param mocker: Fixture pytest-mock pour le mocking. + :return: None + """ + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = b"" + mock_response.headers = {} + mock_response.raise_for_status = Mock() + + mock_get = mocker.patch( + "pronote_sync.sources.blog.rss.requests.get", + return_value=mock_response, + ) + + 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=None, last_modified=None) + + # Vérification que les en-têtes conditionnels ne sont pas présents + mock_get.assert_called_once() + call_args = mock_get.call_args + headers = call_args[1]["headers"] + assert "If-None-Match" not in headers + assert "If-Modified-Since" not in headers + + +def test_fetch_and_parse_known_guids_no_warning( + mocker: pytest_mock.MockerFixture, + caplog: pytest.LogCaptureFixture, + blog_rss_fixture_path: Path, +) -> None: + """Vérifie qu'aucun avertissement n'est journalisé pour les GUID connus. + + Les GUID connus sont ignorés silencieusement, sans log. + + :param mocker: Fixture pytest-mock pour le mocking. + :param caplog: Fixture pour capturer les logs. + :param blog_rss_fixture_path: Chemin vers le fichier fixture. + :return: None + """ + with caplog.at_level(logging.WARNING): + # Mock requests.get pour retourner une réponse 200 + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = blog_rss_fixture_path.read_bytes() + mock_response.headers = {} + mock_response.raise_for_status = Mock() + + mocker.patch( + "pronote_sync.sources.blog.rss.requests.get", + return_value=mock_response, + ) + + # Utiliser le vrai parsing du fixture + real_parsed_feed = feedparser.parse(str(blog_rss_fixture_path)) + mocker.patch( + "pronote_sync.sources.blog.rss.feedparser.parse", + return_value=real_parsed_feed, + ) + + client = BlogRSSClient(rss_url="https://example.com/blog/feed") + # Passer un GUID connu du fixture + result = client.fetch_and_parse(known_guids=frozenset({"https://example.com/blog/?p=1001"})) + + # Doit retourner 2 articles (1001 est exclu) + assert len(result.articles) == 2 + + # Aucun avertissement ne doit être journalisé pour le GUID connu + assert "Entrée RSS en double dans le flux" not in caplog.text + + +def test_fetch_and_parse_intra_feed_duplicate_warning( + mocker: pytest_mock.MockerFixture, + caplog: pytest.LogCaptureFixture, +) -> None: + """Vérifie qu'un avertissement est journalisé pour les doublons dans le même flux. + + :param mocker: Fixture pytest-mock pour le mocking. + :param caplog: Fixture pour capturer les logs. + :return: None + """ + with caplog.at_level(logging.WARNING): + # 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]) + + # Mock requests.get pour retourner une réponse 200 + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = b"" + mock_response.headers = {} + mock_response.raise_for_status = Mock() + + mocker.patch( + "pronote_sync.sources.blog.rss.requests.get", + return_value=mock_response, + ) + + 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 + assert len(result.articles) == 1 + + # Un avertissement doit être journalisé pour le doublon + assert "Entrée RSS en double dans le flux" in caplog.text diff --git a/tests/unit/test_blog_state.py b/tests/unit/test_blog_state.py index cdb5f3c..2c3a3e7 100644 --- a/tests/unit/test_blog_state.py +++ b/tests/unit/test_blog_state.py @@ -15,6 +15,7 @@ from __future__ import annotations import json from pathlib import Path +from unittest.mock import patch import pytest @@ -308,4 +309,42 @@ def test_get_known_guids_returns_frozenset(tmp_path: Path) -> None: assert type(result) is frozenset +def test_atomic_save_preserves_on_error(tmp_path: Path) -> None: + """Vérifie que l'état original est préservé en cas d'erreur lors de la sauvegarde atomique. + + Si une erreur survient pendant le remplacement atomique du fichier, + le fichier original doit rester intact et le fichier temporaire doit être nettoyé. + + :param tmp_path: Fixture pytest pour un répertoire temporaire. + :return: None + """ + state_file = tmp_path / "state.json" + + # Créer un état initial avec des GUID + state = BlogRSSState(state_file) + state.add_guids(["original-guid-1", "original-guid-2"]) + + # Lire le contenu original + original_content = state_file.read_text(encoding="utf-8") + + # Mock Path.replace pour simuler une erreur pendant le remplacement atomique + with patch.object(Path, "replace") as mock_replace: + mock_replace.side_effect = OSError("Simulated atomic replace failure") + + # Essayer d'ajouter de nouveaux GUID, ce qui déclenchera _save() + state.add_guids(["new-guid"]) + + # Vérifier que le fichier original est toujours intact + assert state_file.read_text(encoding="utf-8") == original_content + + # Vérifier que le fichier temporaire a été nettoyé + tmp_file = state_file.with_suffix(".tmp") + assert not tmp_file.exists() + + # Vérifier que l'état en mémoire n'a pas été modifié (car la sauvegarde a échoué) + # Note: En réalité, l'état en mémoire est modifié mais pas persistant + # C'est le fichier qui doit rester intact + assert state.get_known_guids() == frozenset({"original-guid-1", "original-guid-2", "new-guid"}) + + # Ensure trailing newline