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:
2026-09-06 20:58:04 +02:00
parent 6d1a7a649f
commit bfae1ca87f
6 changed files with 753 additions and 98 deletions

View File

@@ -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