Files
college-infos/tests/unit/test_blog_client.py
Antoine Van Elstraete bfae1ca87f 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>
2026-09-06 20:58:04 +02:00

1286 lines
42 KiB
Python

"""Tests unitaires pour le client RSS du blog du collège.
Ce module vérifie le comportement du client :class:`BlogRSSClient` pour la
récupération et le parsing du flux RSS du blog. Tous les tests sont unitaires
et utilisent des mocks pour éviter tout accès réseau réel.
Les tests couvrent :
- Le parsing nominal du flux RSS avec déduplication et tri
- La gestion des réponses 304 Not Modified
- La gestion des flux invalides (bozo)
- La gestion des erreurs réseau
- La conversion HTML vers texte
- L'absence de fuite de secrets dans les logs
- Les méthodes statiques de parsing de dates et de conversion HTML
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
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
# --- Fixtures ---
@pytest.fixture
def blog_rss_fixture_path() -> Path:
"""Chemin vers le fichier fixture RSS du blog.
:return: Chemin absolu vers le fichier XML de test.
:rtype: Path
"""
return Path(__file__).parent.parent / "fixtures" / "blog_rss.xml"
@pytest.fixture
def real_parsed_feed(blog_rss_fixture_path: Path) -> feedparser.FeedParserDict:
"""Résultat réel du parsing du fixture RSS par feedparser.
:param blog_rss_fixture_path: Chemin vers le fichier fixture.
:return: Objet FeedParserDict parsé.
:rtype: feedparser.FeedParserDict
"""
return feedparser.parse(str(blog_rss_fixture_path))
@pytest.fixture
def blog_client(blog_rss_fixture_path: Path) -> BlogRSSClient:
"""Instance de BlogRSSClient pointant vers le fixture local.
:param blog_rss_fixture_path: Chemin vers le fichier fixture.
:return: Instance de BlogRSSClient.
:rtype: BlogRSSClient
"""
return BlogRSSClient(rss_url=f"file://{blog_rss_fixture_path}")
# --- Helper functions for mocking ---
def make_mock_entry(
entry_id: str | None = None,
link: str | None = None,
title: str = "Test Article",
published_parsed: tuple[int, ...] | None = None,
pubdate_parsed: tuple[int, ...] | None = None,
updated_parsed: tuple[int, ...] | None = None,
content: list[dict[str, Any]] | None = None,
description: str = "",
tags: list[dict[str, Any]] | None = None,
category: str | None = None,
author: str | None = None,
) -> dict[str, Any]:
"""Crée une entrée RSS mockée sous forme de dict.
:param entry_id: GUID de l'entrée.
:param link: URL de l'entrée.
:param title: Titre de l'entrée.
:param published_parsed: Date de publication parsée (struct_time).
:param pubdate_parsed: Date de publication alternative (struct_time).
:param updated_parsed: Date de mise à jour parsée (struct_time).
:param content: Contenu HTML de l'entrée.
:param description: Description de l'entrée.
:param tags: Tags de l'entrée.
:param category: Catégorie de l'entrée.
:param author: Auteur de l'entrée.
:return: Dict représentant une entrée RSS.
"""
entry: dict[str, Any] = {
"title": title,
"description": description,
"author": author,
"category": category,
"tags": tags,
}
if entry_id is not None:
entry["id"] = entry_id
if link is not None:
entry["link"] = link
if published_parsed is not None:
entry["published_parsed"] = published_parsed
if pubdate_parsed is not None:
entry["pubdate_parsed"] = pubdate_parsed
if updated_parsed is not None:
entry["updated_parsed"] = updated_parsed
if content is not None:
entry["content"] = content
return entry
def make_mock_feed(
entries: list[dict[str, Any]] | None = None,
status: int = 200,
bozo: int = 0,
etag: str | None = None,
modified: str | None = None,
headers: dict[str, str] | None = None,
bozo_exception: Exception | None = None,
) -> Any:
"""Crée un objet FeedParserDict mocké.
:param entries: Liste des entrées RSS.
:param status: Code de statut HTTP.
:param bozo: Indicateur d'erreur de parsing.
:param etag: Valeur de l'en-tête ETag.
:param modified: Valeur de l'en-tête Last-Modified.
:param headers: En-têtes HTTP de la réponse.
:param bozo_exception: Exception associée à l'erreur de parsing.
:return: Objet FeedParserDict mocké.
"""
# feedparser.FeedParserDict est un dict-like, mais nous utilisons un objet
# dynamique pour simuler les attributs nécessaires
feed = type("FeedParserDict", (), {})()
feed.entries = entries or []
feed.status = status
feed.bozo = bozo
feed.etag = etag
feed.modified = modified
feed.headers = headers or {}
if bozo_exception:
feed.bozo_exception = bozo_exception
return feed
# --- Tests ---
def test_fetch_and_parse_nominal(
mocker: pytest_mock.MockerFixture,
real_parsed_feed: feedparser.FeedParserDict,
blog_rss_fixture_path: Path,
) -> None:
"""Vérifie le parsing nominal du flux RSS avec 3 articles triés par date.
Le fixture contient 3 articles dans un ordre non chronologique.
Le résultat doit contenir les 3 articles triés par date décroissante.
:param mocker: Fixture pytest-mock pour le mocking.
:param real_parsed_feed: Résultat réel du parsing du fixture.
:param blog_rss_fixture_path: Chemin vers le fichier fixture.
:return: None
"""
# Mock 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",
return_value=real_parsed_feed,
)
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
result = client.fetch_and_parse()
# Vérification du nombre d'articles
assert len(result.articles) == 3
# Vérification de l'ordre : p=1003 (12 Aug), p=1002 (11 Aug), p=1001 (10 Aug)
assert result.articles[0].id == "https://example.com/blog/?p=1003"
assert result.articles[1].id == "https://example.com/blog/?p=1002"
assert result.articles[2].id == "https://example.com/blog/?p=1001"
# Vérification des titres
assert result.articles[0].title == "Information générale"
assert result.articles[1].title == "Sortie pédagogique au musée"
assert result.articles[2].title == "Réunion de rentrée"
# Vérification des catégories
assert result.articles[0].category is None # Pas de catégorie pour p=1003
assert result.articles[1].category == "Pédagogie"
assert result.articles[2].category == "Administration"
# Vérification des auteurs
assert result.articles[0].author is None # Pas d'auteur pour p=1003
assert result.articles[1].author is None # Pas d'auteur pour p=1002
assert result.articles[2].author == "M. Dupont"
# Vérification des dates de publication
assert result.articles[0].published_at == datetime(2026, 8, 12, 8, 0, 0, tzinfo=UTC)
assert result.articles[1].published_at == datetime(2026, 8, 11, 14, 30, 0, tzinfo=UTC)
assert result.articles[2].published_at == datetime(2026, 8, 10, 9, 0, 11, tzinfo=UTC)
# Vérification que content_text contient des mots attendus
assert "cantine" in result.articles[0].content_text
assert "musée" in result.articles[1].content_text
assert "réunion" in result.articles[2].content_text.lower()
def test_fetch_and_parse_deduplication(
mocker: pytest_mock.MockerFixture,
real_parsed_feed: feedparser.FeedParserDict,
blog_rss_fixture_path: Path,
) -> None:
"""Vérifie la déduplication avec known_guids.
Avec known_guids contenant p=1001, seuls p=1002 et p=1003 doivent être retournés.
:param mocker: Fixture pytest-mock pour le mocking.
:param real_parsed_feed: Résultat réel du parsing du fixture.
: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,
)
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
result = client.fetch_and_parse(known_guids=frozenset({"https://example.com/blog/?p=1001"}))
assert len(result.articles) == 2
# p=1001 doit être exclu
article_ids = {article.id for article in result.articles}
assert "https://example.com/blog/?p=1001" not in article_ids
assert "https://example.com/blog/?p=1002" in article_ids
assert "https://example.com/blog/?p=1003" in article_ids
def test_fetch_and_parse_empty_known_guids(
mocker: pytest_mock.MockerFixture,
real_parsed_feed: feedparser.FeedParserDict,
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,
)
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
result = client.fetch_and_parse(known_guids=frozenset())
assert len(result.articles) == 3
def test_fetch_and_parse_known_guids_none(
mocker: pytest_mock.MockerFixture,
real_parsed_feed: feedparser.FeedParserDict,
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,
)
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
result = client.fetch_and_parse(known_guids=None)
assert len(result.articles) == 3
def test_fetch_and_parse_304_not_modified(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Vérifie la gestion de la réponse 304 Not Modified.
Doit retourner not_modified=True, articles vide, et conserver les en-têtes d'entrée.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
# Mock 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.requests.get",
return_value=mock_response,
)
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
result = client.fetch_and_parse(
etag="input_etag",
last_modified="input_last_modified",
)
assert result.not_modified is True
assert result.articles == ()
assert result.etag == "input_etag"
assert result.last_modified == "input_last_modified"
def test_fetch_and_parse_bozo_invalid_feed(
mocker: pytest_mock.MockerFixture,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie la gestion d'un flux invalide (bozo=1).
Doit retourner articles vide, not_modified=False, et logger un avertissement.
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.
:return: None
"""
with caplog.at_level(logging.WARNING):
mock_exception = ValueError("Invalid XML")
mock_feed = make_mock_feed(
bozo=1,
bozo_exception=mock_exception,
)
# 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(etag="input_etag", last_modified="input_modified")
assert result.articles == ()
assert result.not_modified is False
# 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
# L'URL est présente dans le log mais sans secrets (pas de paramètres sensibles)
assert "blog/feed" in caplog.text
def test_fetch_and_parse_network_error(
mocker: pytest_mock.MockerFixture,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie la gestion d'une erreur réseau.
Doit retourner articles vide, not_modified=False, logger une erreur, et ne pas propager l'exception.
:param mocker: Fixture pytest-mock pour le mocking.
:param caplog: Fixture pour capturer les logs.
:return: None
"""
with caplog.at_level(logging.ERROR):
mock_exception = ConnectionError("Network error")
mocker.patch(
"pronote_sync.sources.blog.rss.requests.get",
side_effect=mock_exception,
)
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
# Ne doit pas lever d'exception
result = client.fetch_and_parse()
assert result.articles == ()
assert result.not_modified is False
# Vérification du log d'erreur
assert "Échec de la récupération du flux RSS du blog" in caplog.text
def test_fetch_and_parse_no_secret_leak_in_logs(
mocker: pytest_mock.MockerFixture,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie qu'aucun secret ne fuit dans les logs en cas d'erreur.
:param mocker: Fixture pytest-mock pour le mocking.
:param caplog: Fixture pour capturer les logs.
:return: None
"""
with caplog.at_level(logging.ERROR):
# Exception contenant un secret
secret = "SECRET_TOKEN_123" # pragma: allowlist secret
mock_exception = ConnectionError(f"Error with token: {secret}")
mocker.patch(
"pronote_sync.sources.blog.rss.requests.get",
side_effect=mock_exception,
)
client = BlogRSSClient(rss_url=f"https://example.com/blog/feed?token={secret}")
result = client.fetch_and_parse()
assert result.articles == ()
assert result.not_modified is False
# Vérification qu'aucun secret ne fuit dans les logs
assert secret not in caplog.text
def test_fetch_and_parse_entry_without_date(
mocker: pytest_mock.MockerFixture,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie qu'une entrée sans date de publication est ignorée.
:param mocker: Fixture pytest-mock pour le mocking.
:param caplog: Fixture pour capturer les logs.
:return: None
"""
with caplog.at_level(logging.WARNING):
entry = make_mock_entry(
entry_id="test-id",
link="https://example.com/test",
title="Test Article",
published_parsed=None,
pubdate_parsed=None,
)
mock_feed = make_mock_feed(entries=[entry])
# 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()
assert len(result.articles) == 0
assert "Entrée RSS sans date de publication valide" in caplog.text
def test_fetch_and_parse_entry_without_guid(
mocker: pytest_mock.MockerFixture,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie qu'une entrée sans GUID ni lien est ignorée.
:param mocker: Fixture pytest-mock pour le mocking.
:param caplog: Fixture pour capturer les logs.
:return: None
"""
with caplog.at_level(logging.WARNING):
entry = make_mock_entry(
entry_id=None,
link=None,
title="Test Article",
published_parsed=(2026, 8, 10, 9, 0, 11, 0, 222, 0),
)
mock_feed = make_mock_feed(entries=[entry])
# 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()
assert len(result.articles) == 0
assert "Entrée RSS sans GUID ni lien" in caplog.text
def test_fetch_and_parse_html_to_text_conversion(
mocker: pytest_mock.MockerFixture,
real_parsed_feed: feedparser.FeedParserDict,
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,
)
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
result = client.fetch_and_parse()
for article in result.articles:
# Vérification qu'il n'y a pas de balises HTML dans content_text
assert "<p>" not in article.content_text
assert "</p>" not in article.content_text
assert "<a" not in article.content_text
assert "</a>" not in article.content_text
assert "<![CDATA[" not in article.content_text
# Vérification que le contenu textuel est présent
assert len(article.content_text) > 0
def test_fetch_and_parse_etag_last_modified_returned(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Vérifie que les en-têtes ETag et Last-Modified sont retournés.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
# Mock 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,
)
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
result = client.fetch_and_parse()
assert result.etag == "abc123"
assert result.last_modified == "Wed, 01 Sep 2026 00:00:00 GMT"
assert result.not_modified is False
def test_fetch_and_parse_passes_etag_to_requests(
mocker: pytest_mock.MockerFixture,
) -> None:
"""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_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="abc123",
last_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:
"""Vérifie le parsing d'une date valide.
:return: None
"""
date_tuple = (2026, 8, 10, 9, 0, 11, 0, 222, 0)
result = BlogRSSClient._parse_date(date_tuple)
expected = datetime(2026, 8, 10, 9, 0, 11, tzinfo=UTC)
assert result == expected
def test_parse_date_none() -> None:
"""Vérifie le parsing d'une date None.
:return: None
"""
result = BlogRSSClient._parse_date(None)
assert result is None
def test_parse_date_invalid() -> None:
"""Vérifie le parsing d'une date invalide (tuple trop court).
:return: None
"""
result = BlogRSSClient._parse_date((2026,))
assert result is None
def test_html_to_text_basic() -> None:
"""Vérifie la conversion HTML vers texte de base.
:return: None
"""
html = "<p>Hello <a href='x'>world</a></p>"
result = BlogRSSClient._html_to_text(html)
assert result == "Hello world"
def test_html_to_text_empty() -> None:
"""Vérifie la conversion HTML vers texte avec une chaîne vide.
:return: None
"""
result = BlogRSSClient._html_to_text("")
assert result == ""
def test_html_to_text_entities() -> None:
"""Vérifie la conversion HTML vers texte avec des entités HTML.
:return: None
"""
html = "<p>Café &amp; croissant</p>"
result = BlogRSSClient._html_to_text(html)
assert result == "Café & croissant"
def test_fetch_and_parse_sort_deterministic(
mocker: pytest_mock.MockerFixture,
real_parsed_feed: feedparser.FeedParserDict,
blog_rss_fixture_path: Path,
) -> None:
"""Vérifie que le tri des articles est déterministe.
Deux appels successifs doivent retourner les articles dans le même ordre.
:param mocker: Fixture pytest-mock pour le mocking.
:param real_parsed_feed: Résultat réel du parsing du fixture.
: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,
)
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
result1 = client.fetch_and_parse()
result2 = client.fetch_and_parse()
# Vérification que les deux résultats sont identiques
assert len(result1.articles) == len(result2.articles)
for article1, article2 in zip(result1.articles, result2.articles, strict=True):
assert article1.id == article2.id
assert article1.title == article2.title
assert article1.published_at == article2.published_at
# Vérification de l'ordre : p=1003 (Aug 12), p=1002 (Aug 11), p=1001 (Aug 10)
assert result1.articles[0].id == "https://example.com/blog/?p=1003"
assert result1.articles[1].id == "https://example.com/blog/?p=1002"
assert result1.articles[2].id == "https://example.com/blog/?p=1001"
def test_fetch_and_parse_duplicate_guid_in_feed(
mocker: pytest_mock.MockerFixture,
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
"""
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 (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(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Vérifie le tri secondaire par identifiant croissant pour les articles de même date.
Deux articles avec la même date de publication doivent être triés par id croissant.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
# Création de deux entrées avec la même date mais des id différents
entry_a = make_mock_entry(
entry_id="guid-a",
link="https://example.com/a",
title="Article A",
published_parsed=(2026, 8, 10, 9, 0, 0, 0, 222, 0),
)
entry_b = make_mock_entry(
entry_id="guid-b",
link="https://example.com/b",
title="Article B",
published_parsed=(2026, 8, 10, 9, 0, 0, 0, 222, 0),
)
mock_feed = make_mock_feed(entries=[entry_b, entry_a]) # Ordre initial : b, a
# 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()
# Doit retourner les deux articles triés par id croissant
assert len(result.articles) == 2
assert result.articles[0].id == "guid-a" # a doit venir avant b
assert result.articles[1].id == "guid-b"
# --- 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