Files
college-infos/tests/unit/test_xmpp_sync_adapter.py
Antoine Van Elstraete b61d314b7f 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>
2026-09-08 00:58:40 +02:00

335 lines
12 KiB
Python

"""Tests unitaires pour l'adaptateur SyncXmppChannel.
Ce module teste l'implémentation de :class:`pronote_sync.channels.xmpp.SyncXmppChannel`
qui est un adaptateur wrapant XmppChannel pour fournir une interface synchrone.
Les tests sont conçus pour être exécutés sans réseau, avec des mocks de XmppChannel
ou de slixmpp, et vérifient le comportement de l'envoi synchrone selon la décision D4.
Conformément à D4, SyncXmppChannel.send() utilise asyncio.run() directement sans
créer de nouvelle event loop inutilement. Le comportement est :
- Pas de boucle en cours → asyncio.run(channel.send_async(message))
- Boucle en cours détectée → thread daemon avec asyncio.run() + join(timeout)
- Retourne True en cas de succès, False en cas d'erreur (attrape toute exception)
- Aucun secret dans les logs.
"""
from __future__ import annotations
import asyncio
import threading
from datetime import date
from unittest.mock import AsyncMock, patch
import pytest
from pronote_sync.channels.protocol import Channel
from pronote_sync.channels.xmpp import SyncXmppChannel, XmppMessage
from pronote_sync.config.settings import XmppSettings
# Sentinelles pour tests de non-fuite de secrets
BOT_SENTINEL_JID = "BOT_SENTINEL_JID@example.com"
PASS_SENTINEL_123 = "PASS_SENTINEL_123"
RECIPIENT_SENTINEL = "RECIPIENT_SENTINEL@example.com"
@pytest.fixture
def xmpp_settings() -> XmppSettings:
"""Fixture fournissant des paramètres XMPP valides pour les tests.
:return: Instance de XmppSettings avec des valeurs par défaut valides.
:rtype: XmppSettings
"""
from pydantic import SecretStr
return XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="xmpp.example.com",
to="parent@example.com",
use_tls=True,
)
@pytest.fixture
def xmpp_message() -> XmppMessage:
"""Fixture fournissant un message XMPP minimal pour les tests.
:return: Instance de XmppMessage avec seulement la date cible.
:rtype: XmppMessage
"""
return XmppMessage(
target_date=date(2025, 9, 7),
synthesis=None,
homeworks=(),
changes=(),
messages=(),
external_info=None,
)
class TestSyncXmppChannelSend:
"""Tests unitaires pour la méthode send de SyncXmppChannel.
Ces tests vérifient le comportement de l'envoi synchrone de messages XMPP
selon la décision D4 : utilisation directe de asyncio.run() et retour de
booléen (True/False) sans lever d'exception.
"""
def test_sync_adapter_send_returns_true_on_success(
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
) -> None:
"""Test que send retourne True en cas de succès.
:param xmpp_settings: Paramètres XMPP valides.
:param xmpp_message: Message XMPP minimal.
"""
# Mock XmppChannel.send_async pour simuler un succès
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
def test_sync_adapter_send_returns_false_on_error(
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
) -> None:
"""Test que send retourne False en cas d'erreur.
Vérifie que la méthode ne lève pas d'exception non gérée et retourne False.
:param xmpp_settings: Paramètres XMPP valides.
:param xmpp_message: Message XMPP minimal.
"""
# Mock XmppChannel.send_async pour simuler une erreur
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
def test_sync_adapter_satisfies_channel_protocol(self, xmpp_settings: XmppSettings) -> None:
"""Test que SyncXmppChannel satisfait le protocole Channel.
Vérifie que l'instance est reconnue comme implémentant le protocole.
:param xmpp_settings: Paramètres XMPP valides.
"""
channel = SyncXmppChannel(xmpp_settings)
assert isinstance(channel, Channel)
class TestSyncXmppChannelAsyncioRun:
"""Tests pour la gestion de l'event loop dans SyncXmppChannel selon D4.
Ces tests vérifient que SyncXmppChannel utilise asyncio.run() directement
sans créer de nouvelle event loop inutilement.
"""
def test_sync_adapter_uses_asyncio_run_when_no_loop(
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
) -> None:
"""Test que send utilise asyncio.run() quand aucune boucle n'est en cours.
Vérifie que asyncio.run est appelé avec le coroutine send_async.
:param xmpp_settings: Paramètres XMPP valides.
:param xmpp_message: Message XMPP minimal.
"""
# Mock XmppChannel.send_async pour simuler un succès
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)
# Patch asyncio.run pour vérifier son appel
with patch("pronote_sync.channels.xmpp.asyncio.run") as mock_asyncio_run:
mock_asyncio_run.return_value = True
result = adapter.send(xmpp_message)
# Vérifier que asyncio.run a été appelé
mock_asyncio_run.assert_called_once()
# Vérifier que le résultat est True
assert result is True
def test_sync_adapter_send_returns_false_when_asyncio_run_fails(
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
) -> None:
"""Test que send retourne False quand asyncio.run échoue.
Vérifie que toute exception dans asyncio.run est capturée et retourne False.
:param xmpp_settings: Paramètres XMPP valides.
:param xmpp_message: Message XMPP minimal.
"""
# Mock XmppChannel.send_async pour simuler un succès
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)
# Patch asyncio.run pour lever une exception
with patch("pronote_sync.channels.xmpp.asyncio.run") as mock_asyncio_run:
mock_asyncio_run.side_effect = RuntimeError("Erreur asyncio")
result = adapter.send(xmpp_message)
# 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).
Ces tests vérifient que les secrets (JID, mot de passe, destinataire)
ne sont jamais exposés dans les logs, messages d'erreur ou causes d'exceptions.
"""
def test_sync_adapter_no_secret_in_logs(
self, caplog: pytest.LogCaptureFixture, xmpp_settings: XmppSettings
) -> None:
"""Test que les sentinelles n'apparaissent pas dans les logs en cas d'erreur.
:param caplog: Fixture pytest pour capturer les logs.
:param xmpp_settings: Paramètres XMPP valides.
"""
# Créer des settings avec sentinelles
from pydantic import SecretStr
settings = XmppSettings(
enabled=True,
jid=BOT_SENTINEL_JID,
password=SecretStr(PASS_SENTINEL_123),
host="xmpp.example.com",
to=RECIPIENT_SENTINEL,
use_tls=True,
)
# Mock XmppChannel.send_async pour lever une erreur
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 = settings
channel = SyncXmppChannel(settings)
msg = XmppMessage(
target_date=date(2025, 9, 7),
synthesis=None,
homeworks=(),
changes=(),
messages=(),
external_info=None,
)
# Appeler send qui doit retourner False sans lever d'exception
result = channel.send(msg)
# Vérifier que le résultat est False
assert result is False
# Vérifier que les sentinelles n'apparaissent pas dans les logs
logs = caplog.text
assert BOT_SENTINEL_JID not in logs
assert PASS_SENTINEL_123 not in logs
assert RECIPIENT_SENTINEL not in logs