test: add M10 integration tests and coverage to 99% (M10-U7)

Integration tests validating the 3 M10 acceptance criteria end-to-end
with mocked slixmpp:
1. XmppChannel.send sends formatted direct message
2. XMPP error → PipelineWarning, no unhandled exception
3. No secret in XMPP logs (sentinel-based verification)

Additional unit tests covering daemon-thread branch of SyncXmppChannel,
deferred failed_auth, session timeout, disconnected handler, and
_format_message edge cases. Channels coverage: 99.18%.

All M10 checklist items marked complete in TODO.md.

Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
Co-authored-by: opencode/coder <coder@agents.invalid>
This commit is contained in:
2026-09-08 00:58:40 +02:00
parent e07a6d709d
commit b61d314b7f
4 changed files with 736 additions and 7 deletions

View File

@@ -11,7 +11,7 @@ from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from datetime import date, datetime
from datetime import date, datetime, time
from unittest.mock import patch
import pytest
@@ -20,7 +20,7 @@ from pydantic import SecretStr
from pronote_sync.channels.xmpp import XmppChannel, XmppMessage
from pronote_sync.config.settings import XmppSettings
from pronote_sync.errors import PipelineWarning
from pronote_sync.models.agenda import Lesson
from pronote_sync.models.agenda import Lesson, TheoreticalLesson
from pronote_sync.models.blog import BlogArticle, ExternalInfo
from pronote_sync.models.diff import AgendaChange, AgendaChangeType
from pronote_sync.models.homework import Homework
@@ -305,6 +305,61 @@ class TestXmppChannelFormatMessage:
assert "💬 Messages\nAucun message." in formatted
assert "📢 Informations diverses\nAucune information." in formatted
def test_format_message_with_theoretical_lesson(self) -> None:
"""Test que la matière d'un changement REMOVED provient du cours théorique.
Couvre le cas où ``lesson`` est absent et où le sujet est extrait de
``theoretical_lesson`` dans ``_format_changes``.
"""
theoretical = TheoreticalLesson(
id="th1",
day_of_week=0,
start_time=time(8, 0),
end_time=time(9, 0),
subject="Anglais",
)
change = AgendaChange(
type=AgendaChangeType.REMOVED,
lesson=None,
theoretical_lesson=theoretical,
details="Cours annulé",
)
msg = XmppMessage(
target_date=date(2025, 9, 7),
synthesis=None,
changes=(change,),
external_info=None,
)
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(msg)
assert "Anglais" in formatted
assert "Cours annulé" in formatted
def test_format_message_with_message_content(self) -> None:
"""Test que les messages Pronote sont formatés avec titre et contenu.
Couvre la branche non vide de ``_format_messages``.
"""
message = Message(
id="msg1",
type=MessageType.INFORMATION,
title="Réunion parents-professeurs",
content="Une réunion est organisée le 15/09 à 18h.",
author="CPE",
date=datetime.fromisoformat("2025-09-01T10:00:00"),
read=False,
)
msg = XmppMessage(
target_date=date(2025, 9, 7),
synthesis=None,
messages=(message,),
external_info=None,
)
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(msg)
assert "Réunion parents-professeurs" in formatted
assert "Une réunion est organisée le 15/09 à 18h." in formatted
def test_format_message_html_sanitized(self) -> None:
"""Test que le HTML est supprimé du contenu des devoirs et messages."""
homework = Homework(
@@ -456,6 +511,133 @@ class TestXmppChannelSend:
result = channel.send(msg)
assert result is False
def test_send_incomplete_config_raises_pipeline_warning(self) -> None:
"""Test qu'une configuration XMPP incomplète lève un PipelineWarning.
Vérifie que le canal refuse l'envoi (sans tentative réseau) lorsque
le JID, le mot de passe ou le destinataire sont absents.
"""
settings = XmppSettings(
enabled=True,
jid=None,
password=None,
to=None,
host="xmpp.example.com",
)
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
with pytest.raises(PipelineWarning):
channel.send(msg)
def test_dry_run_without_secrets_logs_safely(self) -> None:
"""Test que le dry-run fonctionne sans champs sensibles renseignés.
Vérifie que ``_secret_values`` gère des champs ``jid``, ``password``
et ``to`` absents (branches ``None`` de la collecte des secrets).
"""
settings = XmppSettings(enabled=True, host="xmpp.example.com")
channel = XmppChannel(settings, dry_run=True)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = channel.send(msg)
assert result is True
def test_send_disconnected_event_runs_handler(self, xmpp_settings: XmppSettings) -> None:
"""Test que le handler ``disconnected`` est bien exécuté lors de l'envoi.
:param xmpp_settings: Paramètres XMPP valides.
"""
class DisconnectClient(MockClientXMPP):
async def connect(self, *args: object, **kwargs: object) -> None:
self.connected = True
if kwargs.get("use_tls", False):
self.features.add("starttls")
for handler in self.handlers["disconnected"]:
await handler()
for handler in self.handlers["session_start"]:
await handler()
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=DisconnectClient):
channel = XmppChannel(xmpp_settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = channel.send(msg)
assert result is True
def test_send_failed_auth_after_connect_raises_pipeline_warning(
self, xmpp_settings: XmppSettings
) -> None:
"""Test qu'un échec d'authentification après connect() lève un PipelineWarning.
Le client établit la session puis l'événement ``failed_auth`` survient
avant la fin de l'attente de session : le canal doit se déconnecter
proprement et lever un PipelineWarning.
:param xmpp_settings: Paramètres XMPP valides.
"""
client_ref: list[MockClientXMPP] = []
def _factory(jid: str, password: str, *args: object, **kwargs: object) -> MockClientXMPP:
instance = MockClientXMPP(jid, password, *args, **kwargs)
client_ref.append(instance)
return instance
async def _wait_with_failed_auth(
fs: list[asyncio.Task[bool]],
*,
timeout: object = None,
return_when: object = asyncio.FIRST_COMPLETED,
) -> tuple[set[asyncio.Task[bool]], set[asyncio.Task[bool]]]:
# Déclenche failed_auth, puis nettoie les tâches de send_async.
for handler in client_ref[0].handlers["failed_auth"]:
await handler()
for task in fs:
task.cancel()
await asyncio.gather(*fs, return_exceptions=True)
return (set(), set())
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=_factory):
channel = XmppChannel(xmpp_settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
with patch("asyncio.wait", new=_wait_with_failed_auth):
with pytest.raises(PipelineWarning):
channel.send(msg)
def test_send_session_timeout_raises_pipeline_warning(
self, xmpp_settings: XmppSettings
) -> None:
"""Test qu'un délai d'attente de session dépassé lève un PipelineWarning.
Aucun événement n'établit la session : l'attente bornée expire et le
canal se déconnecte proprement avant de lever un PipelineWarning.
:param xmpp_settings: Paramètres XMPP valides.
"""
class NoEventClient(MockClientXMPP):
async def connect(self, *args: object, **kwargs: object) -> None:
self.connected = True
if kwargs.get("use_tls", False):
self.features.add("starttls")
async def _empty_wait(
fs: list[asyncio.Task[bool]],
*,
timeout: object = None,
return_when: object = asyncio.FIRST_COMPLETED,
) -> tuple[set[asyncio.Task[bool]], set[asyncio.Task[bool]]]:
# Aucun événement ne survient : on retourne une attente vide.
for task in fs:
task.cancel()
await asyncio.gather(*fs, return_exceptions=True)
return (set(), set())
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=NoEventClient):
channel = XmppChannel(xmpp_settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
with patch("asyncio.wait", new=_empty_wait):
with pytest.raises(PipelineWarning):
channel.send(msg)
@pytest.mark.usefixtures("xmpp_settings")
class TestXmppChannelSecurity:

View File

@@ -16,6 +16,8 @@ créer de nouvelle event loop inutilement. Le comportement est :
from __future__ import annotations
import asyncio
import threading
from datetime import date
from unittest.mock import AsyncMock, patch
@@ -189,6 +191,92 @@ class TestSyncXmppChannelAsyncioRun:
# Vérifier que le résultat est False
assert result is False
def test_sync_adapter_send_with_running_loop_uses_daemon_thread(
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
) -> None:
"""Test que send bascule dans un thread démon quand une event loop est active.
Simule un appel depuis une boucle déjà en cours d'exécution : l'envoi
passe alors par le thread daemon joint avec un timeout, et retourne
True en cas de succès.
:param xmpp_settings: Paramètres XMPP valides.
:param xmpp_message: Message XMPP minimal.
"""
async def _inner() -> None:
with patch("pronote_sync.channels.xmpp.XmppChannel") as MockChannel:
mock_instance = MockChannel.return_value
mock_instance.send_async = AsyncMock(return_value=True)
mock_instance.settings = xmpp_settings
adapter = SyncXmppChannel(xmpp_settings)
result = adapter.send(xmpp_message)
assert result is True
asyncio.run(_inner())
def test_sync_adapter_send_with_running_loop_error_returns_false(
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
) -> None:
"""Test que send retourne False quand le thread démon lève une exception.
Une exception survenue dans le thread daemon est capturée par la cible
du thread puis journalisée sans secret ; send retourne False sans lever.
:param xmpp_settings: Paramètres XMPP valides.
:param xmpp_message: Message XMPP minimal.
"""
async def _inner() -> None:
with patch("pronote_sync.channels.xmpp.XmppChannel") as MockChannel:
mock_instance = MockChannel.return_value
mock_instance.send_async = AsyncMock(
side_effect=RuntimeError("Connexion impossible")
)
mock_instance.settings = xmpp_settings
adapter = SyncXmppChannel(xmpp_settings)
result = adapter.send(xmpp_message)
assert result is False
asyncio.run(_inner())
def test_sync_adapter_send_with_running_loop_timeout_returns_false(
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
) -> None:
"""Test que send retourne False quand le thread démon dépasse le timeout.
Le thread démon reste bloqué au-delà du délai de jointure : send doit
journaliser un avertissement et retourner False sans lever.
:param xmpp_settings: Paramètres XMPP valides.
:param xmpp_message: Message XMPP minimal.
"""
blocker = threading.Event()
async def _blocking_send(message: XmppMessage) -> bool:
while not blocker.is_set():
await asyncio.sleep(0.01)
return True
async def _inner() -> None:
settings = xmpp_settings.model_copy(update={"timeout": 1})
with patch("pronote_sync.channels.xmpp.XmppChannel") as MockChannel:
mock_instance = MockChannel.return_value
mock_instance.send_async = _blocking_send
mock_instance.settings = settings
adapter = SyncXmppChannel(settings)
result = adapter.send(xmpp_message)
assert result is False
asyncio.run(_inner())
blocker.set()
class TestSyncXmppChannelSecurity:
"""Tests de sécurité pour SyncXmppChannel (non-fuite de secrets).