fix(M5): corrections d'audit — transport HTTP, statuts d'erreur, cache atomique
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 <coder@agents.invalid> Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
This commit is contained in:
@@ -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"<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()
|
||||
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"<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,
|
||||
@@ -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"<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,
|
||||
@@ -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"<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(
|
||||
"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"<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(),
|
||||
)
|
||||
@@ -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"<rss></rss>"
|
||||
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"<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,
|
||||
@@ -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"<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
|
||||
|
||||
Reference in New Issue
Block a user