merge: corrections d'audit FIXME_M5 dans M5 blog RSS
Intègre les corrections de la revue indépendante (FIXME_M5.md) : - Transport HTTP séparé du parsing (requests.get + feedparser.parse) - Rejet des statuts HTTP d'erreur (raise_for_status) - Préservation des validateurs de cache sur les chemins d'échec - Sauvegarde atomique de l'état (tmp + Path.replace) - Déduplication normale silencieuse Co-authored-by: opencode/coder <coder@agents.invalid> Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
This commit is contained in:
@@ -140,10 +140,10 @@
|
|||||||
"filename": "GUIDE_DEV_PYTHON.md",
|
"filename": "GUIDE_DEV_PYTHON.md",
|
||||||
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
||||||
"is_verified": true,
|
"is_verified": true,
|
||||||
"line_number": 5043,
|
"line_number": 5058,
|
||||||
"is_secret": false
|
"is_secret": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"generated_at": "2026-09-06T16:41:06Z"
|
"generated_at": "2026-09-06T18:57:58Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -850,6 +850,7 @@ from datetime import UTC, datetime
|
|||||||
from html import unescape
|
from html import unescape
|
||||||
|
|
||||||
import feedparser
|
import feedparser
|
||||||
|
import requests
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from ..models.blog import BlogArticle
|
from ..models.blog import BlogArticle
|
||||||
@@ -891,16 +892,17 @@ class BlogRSSClient:
|
|||||||
indicateur ``304 Not Modified``.
|
indicateur ``304 Not Modified``.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Récupération du flux avec requête conditionnelle (ETag / Last-Modified)
|
# Transport HTTP séparé du parsing
|
||||||
feed = feedparser.parse(
|
headers: dict[str, str] = {"user-agent": "pronote-sync"}
|
||||||
self.rss_url,
|
if etag is not None:
|
||||||
etag=etag,
|
headers["If-None-Match"] = etag
|
||||||
modified=last_modified,
|
if last_modified is not None:
|
||||||
request_timeout=self.timeout,
|
headers["If-Modified-Since"] = last_modified
|
||||||
)
|
|
||||||
|
|
||||||
# Réponse 304 Not Modified : rien n'a changé, on restitue les en-têtes mémorisés
|
response = requests.get(self.rss_url, headers=headers, timeout=self.timeout)
|
||||||
if getattr(feed, "status", None) == 304:
|
|
||||||
|
# 304 Not Modified : pas de nouveaux articles
|
||||||
|
if response.status_code == 304:
|
||||||
return BlogRSSFetchResult(
|
return BlogRSSFetchResult(
|
||||||
articles=(),
|
articles=(),
|
||||||
etag=etag,
|
etag=etag,
|
||||||
@@ -908,10 +910,18 @@ class BlogRSSClient:
|
|||||||
not_modified=True,
|
not_modified=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
response_etag: str | None = getattr(feed, "etag", None)
|
# Rejeter les statuts d'erreur (4xx/5xx)
|
||||||
response_last_modified: str | None = getattr(feed, "modified", None)
|
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):
|
if getattr(feed, "bozo", None):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Flux RSS du blog invalide, ignoré : %s",
|
"Flux RSS du blog invalide, ignoré : %s",
|
||||||
@@ -919,8 +929,8 @@ class BlogRSSClient:
|
|||||||
)
|
)
|
||||||
return BlogRSSFetchResult(
|
return BlogRSSFetchResult(
|
||||||
articles=(),
|
articles=(),
|
||||||
etag=response_etag,
|
etag=etag,
|
||||||
last_modified=response_last_modified,
|
last_modified=last_modified,
|
||||||
not_modified=False,
|
not_modified=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -988,7 +998,12 @@ class BlogRSSClient:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
safe_url = redact_url(self.rss_url)
|
safe_url = redact_url(self.rss_url)
|
||||||
logger.error(f"Échec de la récupération du flux RSS {safe_url}: {e}")
|
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
|
@staticmethod
|
||||||
def _parse_date(date_tuple: tuple[int, ...] | None) -> datetime | None:
|
def _parse_date(date_tuple: tuple[int, ...] | None) -> datetime | None:
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""Client de récupération et de parsing du flux RSS du blog du collège.
|
"""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
|
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
|
télécharge le flux RSS du blog via ``requests``, le parse via
|
||||||
par GUID et les convertit en :class:`~pronote_sync.models.blog.BlogArticle`.
|
``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
|
Le résultat d'une récupération est un
|
||||||
:class:`~pronote_sync.sources.blog.result.BlogRSSFetchResult` : les
|
:class:`~pronote_sync.sources.blog.result.BlogRSSFetchResult` : les
|
||||||
@@ -21,6 +22,7 @@ from datetime import UTC, datetime
|
|||||||
from html import unescape
|
from html import unescape
|
||||||
|
|
||||||
import feedparser # type: ignore[import-untyped]
|
import feedparser # type: ignore[import-untyped]
|
||||||
|
import requests
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from pronote_sync.models.blog import BlogArticle
|
from pronote_sync.models.blog import BlogArticle
|
||||||
@@ -63,9 +65,10 @@ class BlogRSSClient:
|
|||||||
) -> BlogRSSFetchResult:
|
) -> BlogRSSFetchResult:
|
||||||
"""Télécharge et parse le flux RSS du blog en nouveaux articles.
|
"""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
|
Le flux est téléchargé par ``requests`` avec les en-têtes de
|
||||||
requête conditionnelle fournis (``ETag``/``Last-Modified``). Si le
|
requête conditionnelle fournis (``ETag``/``Last-Modified``), puis
|
||||||
serveur répond ``304 Not Modified``, le résultat est vide avec
|
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
|
``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,
|
restitués tels quels. Chaque entrée est dédupliquée par GUID,
|
||||||
convertie en :class:`~pronote_sync.models.blog.BlogArticle`, puis
|
convertie en :class:`~pronote_sync.models.blog.BlogArticle`, puis
|
||||||
@@ -87,16 +90,19 @@ class BlogRSSClient:
|
|||||||
:rtype: :class:`~pronote_sync.sources.blog.result.BlogRSSFetchResult`
|
:rtype: :class:`~pronote_sync.sources.blog.result.BlogRSSFetchResult`
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
feed = feedparser.parse(
|
# Téléchargement HTTP explicite via requests : feedparser 6.x
|
||||||
self.rss_url,
|
# n'accepte aucun paramètre de transport ; les requêtes
|
||||||
etag=etag,
|
# conditionnelles sont gérées avec les en-têtes HTTP standards.
|
||||||
modified=last_modified,
|
headers: dict[str, str] = {"user-agent": "pronote-sync"}
|
||||||
request_timeout=self.timeout,
|
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
|
# Réponse 304 Not Modified : rien n'a changé, on restitue les
|
||||||
# en-têtes mémorisés tels quels pour les conserver.
|
# en-têtes mémorisés tels quels pour les conserver.
|
||||||
if getattr(feed, "status", None) == 304:
|
if response.status_code == 304:
|
||||||
return BlogRSSFetchResult(
|
return BlogRSSFetchResult(
|
||||||
articles=(),
|
articles=(),
|
||||||
etag=etag,
|
etag=etag,
|
||||||
@@ -104,16 +110,24 @@ class BlogRSSClient:
|
|||||||
not_modified=True,
|
not_modified=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
response_etag: str | None = getattr(feed, "etag", None)
|
# Les statuts 4xx/5xx lèvent une exception HTTP, attrapée par le
|
||||||
response_last_modified: str | None = getattr(feed, "modified", None)
|
# gestionnaire général et dégradée en résultat vide.
|
||||||
if response_last_modified is None:
|
response.raise_for_status()
|
||||||
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
|
response_etag: str | None = response.headers.get("ETag", None)
|
||||||
# puis résultat vide, sans propager l'exception brute.
|
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):
|
if getattr(feed, "bozo", None):
|
||||||
bozo_exception = getattr(feed, "bozo_exception", None)
|
bozo_exception = getattr(feed, "bozo_exception", None)
|
||||||
if bozo_exception is not None:
|
if bozo_exception is not None:
|
||||||
@@ -129,13 +143,16 @@ class BlogRSSClient:
|
|||||||
)
|
)
|
||||||
return BlogRSSFetchResult(
|
return BlogRSSFetchResult(
|
||||||
articles=(),
|
articles=(),
|
||||||
etag=response_etag,
|
etag=etag,
|
||||||
last_modified=response_last_modified,
|
last_modified=last_modified,
|
||||||
not_modified=False,
|
not_modified=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
articles: list[BlogArticle] = []
|
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", []):
|
for entry in getattr(feed, "entries", []):
|
||||||
guid_source = entry.get("id") or entry.get("link")
|
guid_source = entry.get("id") or entry.get("link")
|
||||||
if not guid_source:
|
if not guid_source:
|
||||||
@@ -146,13 +163,20 @@ class BlogRSSClient:
|
|||||||
continue
|
continue
|
||||||
guid = str(guid_source)
|
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(
|
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),
|
redact_url(self.rss_url),
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
seen_in_feed.add(guid)
|
||||||
|
|
||||||
published_at = self._parse_date(
|
published_at = self._parse_date(
|
||||||
entry.get("published_parsed") or entry.get("pubdate_parsed")
|
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
|
# Tri stable : d'abord par identifiant croissant, puis par date de
|
||||||
# publication décroissante ; l'ordre par identifiant est conservé
|
# publication décroissante ; l'ordre par identifiant est conservé
|
||||||
# entre articles de même date.
|
# entre articles de même date.
|
||||||
@@ -217,7 +239,12 @@ class BlogRSSClient:
|
|||||||
redact_url(self.rss_url),
|
redact_url(self.rss_url),
|
||||||
redact_exception(exc),
|
redact_exception(exc),
|
||||||
)
|
)
|
||||||
return BlogRSSFetchResult(articles=(), not_modified=False)
|
return BlogRSSFetchResult(
|
||||||
|
articles=(),
|
||||||
|
etag=etag,
|
||||||
|
last_modified=last_modified,
|
||||||
|
not_modified=False,
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_date(date_tuple: tuple[int, ...] | None) -> datetime | None:
|
def _parse_date(date_tuple: tuple[int, ...] | None) -> datetime | None:
|
||||||
|
|||||||
@@ -88,11 +88,15 @@ class BlogRSSState:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _save(self) -> None:
|
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é
|
La sortie est déterministe : ``known_guids`` est trié
|
||||||
alphabétiquement et le champ ``version`` vaut 1. En cas d'erreur
|
alphabétiquement et le champ ``version`` vaut 1. Le JSON est
|
||||||
d'écriture, une erreur est journalisée sans être propagée.
|
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 = {
|
payload = {
|
||||||
"version": _STATE_VERSION,
|
"version": _STATE_VERSION,
|
||||||
@@ -100,15 +104,24 @@ class BlogRSSState:
|
|||||||
"etag": self._etag,
|
"etag": self._etag,
|
||||||
"last_modified": self._last_modified,
|
"last_modified": self._last_modified,
|
||||||
}
|
}
|
||||||
|
tmp_file = self._state_file.with_suffix(".tmp")
|
||||||
try:
|
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)
|
json.dump(payload, handle, indent=2)
|
||||||
|
tmp_file.replace(self._state_file)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Impossible d'écrire le fichier d'état blog RSS %s : %s.",
|
"Impossible d'écrire le fichier d'état blog RSS %s : %s.",
|
||||||
redact_secrets(str(self._state_file)),
|
redact_secrets(str(self._state_file)),
|
||||||
redact_exception(exc),
|
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]:
|
def get_known_guids(self) -> frozenset[str]:
|
||||||
"""Renvoie une copie immuable des GUID d'articles déjà connus.
|
"""Renvoie une copie immuable des GUID d'articles déjà connus.
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ import logging
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
import feedparser # type: ignore[import-untyped]
|
import feedparser # type: ignore[import-untyped]
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_mock
|
import pytest_mock
|
||||||
|
import requests
|
||||||
|
import responses
|
||||||
|
|
||||||
from pronote_sync.sources.blog.rss import BlogRSSClient
|
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.
|
:param blog_rss_fixture_path: Chemin vers le fichier fixture.
|
||||||
:return: None
|
: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
|
# Mock feedparser.parse pour retourner le résultat réel du parsing
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||||
@@ -213,6 +228,7 @@ def test_fetch_and_parse_nominal(
|
|||||||
def test_fetch_and_parse_deduplication(
|
def test_fetch_and_parse_deduplication(
|
||||||
mocker: pytest_mock.MockerFixture,
|
mocker: pytest_mock.MockerFixture,
|
||||||
real_parsed_feed: feedparser.FeedParserDict,
|
real_parsed_feed: feedparser.FeedParserDict,
|
||||||
|
blog_rss_fixture_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Vérifie la déduplication avec known_guids.
|
"""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 mocker: Fixture pytest-mock pour le mocking.
|
||||||
:param real_parsed_feed: Résultat réel du parsing du fixture.
|
:param real_parsed_feed: Résultat réel du parsing du fixture.
|
||||||
|
:param blog_rss_fixture_path: Chemin vers le fichier fixture.
|
||||||
:return: None
|
: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(
|
mocker.patch(
|
||||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||||
return_value=real_parsed_feed,
|
return_value=real_parsed_feed,
|
||||||
@@ -241,13 +270,27 @@ def test_fetch_and_parse_deduplication(
|
|||||||
def test_fetch_and_parse_empty_known_guids(
|
def test_fetch_and_parse_empty_known_guids(
|
||||||
mocker: pytest_mock.MockerFixture,
|
mocker: pytest_mock.MockerFixture,
|
||||||
real_parsed_feed: feedparser.FeedParserDict,
|
real_parsed_feed: feedparser.FeedParserDict,
|
||||||
|
blog_rss_fixture_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Vérifie que known_guids vide (frozenset()) retourne tous les articles.
|
"""Vérifie que known_guids vide (frozenset()) retourne tous les articles.
|
||||||
|
|
||||||
:param mocker: Fixture pytest-mock pour le mocking.
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
:param real_parsed_feed: Résultat réel du parsing du fixture.
|
:param real_parsed_feed: Résultat réel du parsing du fixture.
|
||||||
|
:param blog_rss_fixture_path: Chemin vers le fichier fixture.
|
||||||
:return: None
|
: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(
|
mocker.patch(
|
||||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||||
return_value=real_parsed_feed,
|
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(
|
def test_fetch_and_parse_known_guids_none(
|
||||||
mocker: pytest_mock.MockerFixture,
|
mocker: pytest_mock.MockerFixture,
|
||||||
real_parsed_feed: feedparser.FeedParserDict,
|
real_parsed_feed: feedparser.FeedParserDict,
|
||||||
|
blog_rss_fixture_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Vérifie que known_guids=None retourne tous les articles.
|
"""Vérifie que known_guids=None retourne tous les articles.
|
||||||
|
|
||||||
:param mocker: Fixture pytest-mock pour le mocking.
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
:param real_parsed_feed: Résultat réel du parsing du fixture.
|
:param real_parsed_feed: Résultat réel du parsing du fixture.
|
||||||
|
:param blog_rss_fixture_path: Chemin vers le fichier fixture.
|
||||||
:return: None
|
: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(
|
mocker.patch(
|
||||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||||
return_value=real_parsed_feed,
|
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.
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
:return: None
|
: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(
|
mocker.patch(
|
||||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
"pronote_sync.sources.blog.rss.requests.get",
|
||||||
return_value=mock_feed,
|
return_value=mock_response,
|
||||||
)
|
)
|
||||||
|
|
||||||
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
|
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).
|
"""Vérifie la gestion d'un flux invalide (bozo=1).
|
||||||
|
|
||||||
Doit retourner articles vide, not_modified=False, et logger un avertissement.
|
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 mocker: Fixture pytest-mock pour le mocking.
|
||||||
:param caplog: Fixture pour capturer les logs.
|
:param caplog: Fixture pour capturer les logs.
|
||||||
@@ -325,21 +387,33 @@ def test_fetch_and_parse_bozo_invalid_feed(
|
|||||||
mock_feed = make_mock_feed(
|
mock_feed = make_mock_feed(
|
||||||
bozo=1,
|
bozo=1,
|
||||||
bozo_exception=mock_exception,
|
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"<rss></rss>"
|
||||||
|
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(
|
mocker.patch(
|
||||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||||
return_value=mock_feed,
|
return_value=mock_feed,
|
||||||
)
|
)
|
||||||
|
|
||||||
client = BlogRSSClient(rss_url="https://example.com/blog/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.articles == ()
|
||||||
assert result.not_modified is False
|
assert result.not_modified is False
|
||||||
assert result.etag == "test_etag"
|
# Les en-têtes d'entrée sont conservés, pas ceux de la réponse
|
||||||
assert result.last_modified == "test_modified"
|
assert result.etag == "input_etag"
|
||||||
|
assert result.last_modified == "input_modified"
|
||||||
|
|
||||||
# Vérification du log d'avertissement
|
# Vérification du log d'avertissement
|
||||||
assert "Flux RSS du blog invalide" in caplog.text
|
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):
|
with caplog.at_level(logging.ERROR):
|
||||||
mock_exception = ConnectionError("Network error")
|
mock_exception = ConnectionError("Network error")
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
"pronote_sync.sources.blog.rss.requests.get",
|
||||||
side_effect=mock_exception,
|
side_effect=mock_exception,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -390,9 +464,9 @@ def test_fetch_and_parse_no_secret_leak_in_logs(
|
|||||||
with caplog.at_level(logging.ERROR):
|
with caplog.at_level(logging.ERROR):
|
||||||
# Exception contenant un secret
|
# Exception contenant un secret
|
||||||
secret = "SECRET_TOKEN_123" # pragma: allowlist 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(
|
mocker.patch(
|
||||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
"pronote_sync.sources.blog.rss.requests.get",
|
||||||
side_effect=mock_exception,
|
side_effect=mock_exception,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -425,6 +499,19 @@ def test_fetch_and_parse_entry_without_date(
|
|||||||
pubdate_parsed=None,
|
pubdate_parsed=None,
|
||||||
)
|
)
|
||||||
mock_feed = make_mock_feed(entries=[entry])
|
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"<rss></rss>"
|
||||||
|
mock_response.headers = {}
|
||||||
|
mock_response.raise_for_status = Mock()
|
||||||
|
|
||||||
|
mocker.patch(
|
||||||
|
"pronote_sync.sources.blog.rss.requests.get",
|
||||||
|
return_value=mock_response,
|
||||||
|
)
|
||||||
|
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||||
return_value=mock_feed,
|
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),
|
published_parsed=(2026, 8, 10, 9, 0, 11, 0, 222, 0),
|
||||||
)
|
)
|
||||||
mock_feed = make_mock_feed(entries=[entry])
|
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"<rss></rss>"
|
||||||
|
mock_response.headers = {}
|
||||||
|
mock_response.raise_for_status = Mock()
|
||||||
|
|
||||||
|
mocker.patch(
|
||||||
|
"pronote_sync.sources.blog.rss.requests.get",
|
||||||
|
return_value=mock_response,
|
||||||
|
)
|
||||||
|
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||||
return_value=mock_feed,
|
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(
|
def test_fetch_and_parse_html_to_text_conversion(
|
||||||
mocker: pytest_mock.MockerFixture,
|
mocker: pytest_mock.MockerFixture,
|
||||||
real_parsed_feed: feedparser.FeedParserDict,
|
real_parsed_feed: feedparser.FeedParserDict,
|
||||||
|
blog_rss_fixture_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Vérifie que content_text ne contient pas de balises HTML.
|
"""Vérifie que content_text ne contient pas de balises HTML.
|
||||||
|
|
||||||
:param mocker: Fixture pytest-mock pour le mocking.
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
:param real_parsed_feed: Résultat réel du parsing du fixture.
|
:param real_parsed_feed: Résultat réel du parsing du fixture.
|
||||||
|
:param blog_rss_fixture_path: Chemin vers le fichier fixture.
|
||||||
:return: None
|
: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(
|
mocker.patch(
|
||||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||||
return_value=real_parsed_feed,
|
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.
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
mock_feed = make_mock_feed(
|
# Mock requests.get pour retourner une réponse 200 avec des en-têtes
|
||||||
entries=[],
|
mock_response = Mock()
|
||||||
etag="abc123",
|
mock_response.status_code = 200
|
||||||
modified="Wed, 01 Sep 2026 00:00:00 GMT",
|
mock_response.content = b"<rss></rss>"
|
||||||
|
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(
|
mocker.patch(
|
||||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||||
return_value=mock_feed,
|
return_value=mock_feed,
|
||||||
@@ -523,15 +645,26 @@ def test_fetch_and_parse_etag_last_modified_returned(
|
|||||||
assert result.not_modified is False
|
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,
|
mocker: pytest_mock.MockerFixture,
|
||||||
) -> None:
|
) -> 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.
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
mock_parse = mocker.patch(
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.content = b"<rss></rss>"
|
||||||
|
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",
|
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||||
return_value=make_mock_feed(),
|
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",
|
last_modified="Wed, 01 Sep 2026 00:00:00 GMT",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Vérification que feedparser.parse a été appelé avec les bons paramètres
|
# Vérification que requests.get a été appelé avec les bons en-têtes
|
||||||
mock_parse.assert_called_once()
|
mock_get.assert_called_once()
|
||||||
call_args = mock_parse.call_args
|
call_args = mock_get.call_args
|
||||||
assert call_args[1]["etag"] == "abc123"
|
assert call_args[1]["headers"]["If-None-Match"] == "abc123"
|
||||||
assert call_args[1]["modified"] == "Wed, 01 Sep 2026 00:00:00 GMT"
|
assert call_args[1]["headers"]["If-Modified-Since"] == "Wed, 01 Sep 2026 00:00:00 GMT"
|
||||||
|
|
||||||
|
|
||||||
def test_parse_date_valid() -> None:
|
def test_parse_date_valid() -> None:
|
||||||
@@ -613,6 +746,7 @@ def test_html_to_text_entities() -> None:
|
|||||||
def test_fetch_and_parse_sort_deterministic(
|
def test_fetch_and_parse_sort_deterministic(
|
||||||
mocker: pytest_mock.MockerFixture,
|
mocker: pytest_mock.MockerFixture,
|
||||||
real_parsed_feed: feedparser.FeedParserDict,
|
real_parsed_feed: feedparser.FeedParserDict,
|
||||||
|
blog_rss_fixture_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Vérifie que le tri des articles est déterministe.
|
"""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 mocker: Fixture pytest-mock pour le mocking.
|
||||||
:param real_parsed_feed: Résultat réel du parsing du fixture.
|
:param real_parsed_feed: Résultat réel du parsing du fixture.
|
||||||
|
:param blog_rss_fixture_path: Chemin vers le fichier fixture.
|
||||||
:return: None
|
: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(
|
mocker.patch(
|
||||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||||
return_value=real_parsed_feed,
|
return_value=real_parsed_feed,
|
||||||
@@ -647,14 +794,18 @@ def test_fetch_and_parse_sort_deterministic(
|
|||||||
|
|
||||||
def test_fetch_and_parse_duplicate_guid_in_feed(
|
def test_fetch_and_parse_duplicate_guid_in_feed(
|
||||||
mocker: pytest_mock.MockerFixture,
|
mocker: pytest_mock.MockerFixture,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Vérifie la déduplication des entrées avec le même GUID dans un même flux.
|
"""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.
|
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 mocker: Fixture pytest-mock pour le mocking.
|
||||||
|
:param caplog: Fixture pour capturer les logs.
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
# Création de deux entrées avec le même GUID
|
# Création de deux entrées avec le même GUID
|
||||||
entry1 = make_mock_entry(
|
entry1 = make_mock_entry(
|
||||||
entry_id="duplicate-guid",
|
entry_id="duplicate-guid",
|
||||||
@@ -670,6 +821,19 @@ def test_fetch_and_parse_duplicate_guid_in_feed(
|
|||||||
)
|
)
|
||||||
|
|
||||||
mock_feed = make_mock_feed(entries=[entry1, entry2])
|
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"<rss></rss>"
|
||||||
|
mock_response.headers = {}
|
||||||
|
mock_response.raise_for_status = Mock()
|
||||||
|
|
||||||
|
mocker.patch(
|
||||||
|
"pronote_sync.sources.blog.rss.requests.get",
|
||||||
|
return_value=mock_response,
|
||||||
|
)
|
||||||
|
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||||
return_value=mock_feed,
|
return_value=mock_feed,
|
||||||
@@ -684,6 +848,9 @@ def test_fetch_and_parse_duplicate_guid_in_feed(
|
|||||||
assert result.articles[0].id == "duplicate-guid"
|
assert result.articles[0].id == "duplicate-guid"
|
||||||
assert result.articles[0].title == "First Article"
|
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(
|
def test_fetch_and_parse_sort_tied_dates_by_id(
|
||||||
mocker: pytest_mock.MockerFixture,
|
mocker: pytest_mock.MockerFixture,
|
||||||
@@ -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_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"<rss></rss>"
|
||||||
|
mock_response.headers = {}
|
||||||
|
mock_response.raise_for_status = Mock()
|
||||||
|
|
||||||
|
mocker.patch(
|
||||||
|
"pronote_sync.sources.blog.rss.requests.get",
|
||||||
|
return_value=mock_response,
|
||||||
|
)
|
||||||
|
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"pronote_sync.sources.blog.rss.feedparser.parse",
|
"pronote_sync.sources.blog.rss.feedparser.parse",
|
||||||
return_value=mock_feed,
|
return_value=mock_feed,
|
||||||
@@ -722,3 +902,384 @@ def test_fetch_and_parse_sort_tied_dates_by_id(
|
|||||||
assert len(result.articles) == 2
|
assert len(result.articles) == 2
|
||||||
assert result.articles[0].id == "guid-a" # a doit venir avant b
|
assert result.articles[0].id == "guid-a" # a doit venir avant b
|
||||||
assert result.articles[1].id == "guid-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"<rss></rss>"
|
||||||
|
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"<rss></rss>"
|
||||||
|
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"<rss></rss>"
|
||||||
|
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"<rss></rss>"
|
||||||
|
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
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -308,4 +309,42 @@ def test_get_known_guids_returns_frozenset(tmp_path: Path) -> None:
|
|||||||
assert type(result) is frozenset
|
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
|
# Ensure trailing newline
|
||||||
|
|||||||
Reference in New Issue
Block a user