feat(M7): synchronisation différentielle CalDAV
Implémente la synchronisation des événements Pronote vers un calendrier CalDAV (Nextcloud) de façon idempotente et sécurisée. Production : - sync/serialization.py : sérialisation Lesson/Homework/SchoolEvent vers VEVENT, signature sémantique (exclut DTSTAMP/CREATED/LAST-MODIFIED), enveloppe VCALENDAR complète avec VERSION:2.0 et PRODID - sync/caldav.py : passerelle CalDAV isolant caldav>=1.3.0, résolution du calendrier via principal().calendars() avec boundary matching, upsert par UID (fetch-then-save), exceptions expurgées et __context__ propre, mot de passe non stocké en clair, context manager - sync/planner.py : calcul explicite du CalDAVSyncPlan (add/update/remove par comparaison de signatures sémantiques, routage par préfixe d'UID) - sync/executor.py : exécution du plan avec dry-run (aucune écriture), isolation des erreurs par événement, statut FAILED/SKIPPED/SUCCESS - sync/synchronizer.py : orchestration en trois phases (scan, plan, exécution), SKIPPED si CalDAV non configuré - sync/__init__.py : export synchronize() - sources/pronote/client.py : normalisation UID via normalize_pronote_uid/ generate_deterministic_uid (parité avec ical.py) - config/settings.py : CalDAVSettings durci (url SecretStr, validation HTTPS, allow_insecure_http pour localhost, serializer redact_url) Tests (381 passés, couverture 95.58%) : - tests/unit/test_sync_serialization.py (21 tests) - tests/unit/test_caldav_planner.py (16 tests) - tests/unit/test_caldav_executor.py (18 tests) - tests/unit/test_caldav_gateway.py (24 tests) - tests/unit/test_caldav_security.py (18 tests) - tests/unit/test_uid_equivalence.py (8 tests) - tests/integration/test_caldav_sync.py (11 tests, faux serveur en mémoire) - tests/conftest.py : fixtures partagées Documentation : - GUIDE_DEV_PYTHON.md §7 : API réelle caldav>=1.3.0, principal().calendars(), VCALENDAR complet, upsert par UID, pas d'état local, événements non gérés protégés, CalDAVSettings durci (SecretStr, HTTPS, allow_insecure_http) - TODO.md : M7 coché - .env.example : CALDAV_ALLOW_INSECURE_HTTP=false Co-authored-by: opencode/coder <coder@agents.invalid> Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid> Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
This commit is contained in:
622
tests/unit/test_caldav_executor.py
Normal file
622
tests/unit/test_caldav_executor.py
Normal file
@@ -0,0 +1,622 @@
|
||||
"""Tests unitaires pour l'exécuteur de synchronisation CalDAV.
|
||||
|
||||
Ce module vérifie que :class:`CalDAVSyncExecutor` applique correctement
|
||||
un plan de synchronisation contre une passerelle CalDAV mockée, avec
|
||||
la bonne gestion du mode dry-run, des compteurs et des erreurs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from pronote_sync.errors import PronoteSyncError
|
||||
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent, SchoolEventKind
|
||||
from pronote_sync.models.homework import Homework
|
||||
from pronote_sync.models.sync import CalDAVSyncPlan, CalDAVSyncStatus
|
||||
from pronote_sync.sync.executor import CalDAVSyncExecutor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
# --- Helper fixtures ---
|
||||
|
||||
|
||||
def _make_lesson(
|
||||
lesson_id: str = "L-1234",
|
||||
subject: str = "Mathématiques",
|
||||
start: datetime | None = None,
|
||||
end: datetime | None = None,
|
||||
status: LessonStatus = LessonStatus.NORMAL,
|
||||
content: str | None = None,
|
||||
group: str | None = None,
|
||||
) -> Lesson:
|
||||
"""Fabrique un cours Pronote pour les tests.
|
||||
|
||||
:param lesson_id: Identifiant du cours.
|
||||
:param subject: Matière.
|
||||
:param start: Date/heure de début.
|
||||
:param end: Date/heure de fin.
|
||||
:param status: Statut du cours.
|
||||
:param content: Contenu pédagogique.
|
||||
:param group: Groupe.
|
||||
:return: Instance de Lesson.
|
||||
:rtype: Lesson
|
||||
"""
|
||||
if start is None:
|
||||
start = datetime(2026, 1, 15, 8, 0)
|
||||
if end is None:
|
||||
end = datetime(2026, 1, 15, 9, 0)
|
||||
return Lesson(
|
||||
id=lesson_id,
|
||||
start=start,
|
||||
end=end,
|
||||
subject=subject,
|
||||
status=status,
|
||||
content=content,
|
||||
group=group,
|
||||
)
|
||||
|
||||
|
||||
def _make_homework(
|
||||
homework_id: str = "HW-5678",
|
||||
subject: str = "Mathématiques",
|
||||
due_on: date | None = None,
|
||||
text: str = "Exercice 1 à 5",
|
||||
assigned_on: date | None = None,
|
||||
) -> Homework:
|
||||
"""Fabrique un devoir Pronote pour les tests.
|
||||
|
||||
:param homework_id: Identifiant du devoir.
|
||||
:param subject: Matière.
|
||||
:param due_on: Date d'échéance.
|
||||
:param text: Texte du devoir.
|
||||
:param assigned_on: Date de distribution.
|
||||
:return: Instance de Homework.
|
||||
:rtype: Homework
|
||||
"""
|
||||
if due_on is None:
|
||||
due_on = date(2026, 1, 20)
|
||||
return Homework(
|
||||
id=homework_id,
|
||||
subject=subject,
|
||||
due_on=due_on,
|
||||
text=text,
|
||||
assigned_on=assigned_on,
|
||||
)
|
||||
|
||||
|
||||
def _make_school_event(
|
||||
label: str = "Vacances de Noël",
|
||||
from_date: date | None = None,
|
||||
to_date: date | None = None,
|
||||
kind: SchoolEventKind = SchoolEventKind.HOLIDAY,
|
||||
) -> SchoolEvent:
|
||||
"""Fabrique un événement scolaire pour les tests.
|
||||
|
||||
:param label: Libellé de l'événement.
|
||||
:param from_date: Date de début.
|
||||
:param to_date: Date de fin.
|
||||
:param kind: Type d'événement.
|
||||
:return: Instance de SchoolEvent.
|
||||
:rtype: SchoolEvent
|
||||
"""
|
||||
if from_date is None:
|
||||
from_date = date(2026, 12, 20)
|
||||
if to_date is None:
|
||||
to_date = date(2027, 1, 5)
|
||||
return SchoolEvent(
|
||||
label=label,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
kind=kind,
|
||||
)
|
||||
|
||||
|
||||
# --- Fixtures ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gateway() -> MagicMock:
|
||||
"""Fournit une passerelle CalDAV mockée.
|
||||
|
||||
:return: MagicMock configuré comme une CalDAVGateway.
|
||||
:rtype: MagicMock
|
||||
"""
|
||||
gateway = MagicMock()
|
||||
gateway.upsert_event = MagicMock()
|
||||
gateway.delete_event = MagicMock()
|
||||
return gateway
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def empty_plan() -> CalDAVSyncPlan:
|
||||
"""Fournit un plan de synchronisation vide.
|
||||
|
||||
:return: CalDAVSyncPlan vide.
|
||||
:rtype: CalDAVSyncPlan
|
||||
"""
|
||||
return CalDAVSyncPlan()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plan_with_lesson_add() -> CalDAVSyncPlan:
|
||||
"""Fournit un plan avec un cours à ajouter.
|
||||
|
||||
:return: CalDAVSyncPlan avec un cours à ajouter.
|
||||
:rtype: CalDAVSyncPlan
|
||||
"""
|
||||
lesson = _make_lesson(lesson_id="L-1234")
|
||||
return CalDAVSyncPlan(lessons_to_add=[lesson])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plan_with_lesson_update() -> CalDAVSyncPlan:
|
||||
"""Fournit un plan avec un cours à mettre à jour.
|
||||
|
||||
:return: CalDAVSyncPlan avec un cours à mettre à jour.
|
||||
:rtype: CalDAVSyncPlan
|
||||
"""
|
||||
lesson = _make_lesson(lesson_id="L-1234")
|
||||
return CalDAVSyncPlan(lessons_to_update=[lesson])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plan_with_lesson_remove() -> CalDAVSyncPlan:
|
||||
"""Fournit un plan avec un cours à supprimer.
|
||||
|
||||
:return: CalDAVSyncPlan avec un cours à supprimer.
|
||||
:rtype: CalDAVSyncPlan
|
||||
"""
|
||||
return CalDAVSyncPlan(lessons_to_remove=["L-1234"])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plan_with_homework_add() -> CalDAVSyncPlan:
|
||||
"""Fournit un plan avec un devoir à ajouter.
|
||||
|
||||
:return: CalDAVSyncPlan avec un devoir à ajouter.
|
||||
:rtype: CalDAVSyncPlan
|
||||
"""
|
||||
homework = _make_homework(homework_id="HW-5678")
|
||||
return CalDAVSyncPlan(homeworks_to_add=[homework])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plan_with_homework_remove() -> CalDAVSyncPlan:
|
||||
"""Fournit un plan avec un devoir à supprimer.
|
||||
|
||||
:return: CalDAVSyncPlan avec un devoir à supprimer.
|
||||
:rtype: CalDAVSyncPlan
|
||||
"""
|
||||
return CalDAVSyncPlan(homeworks_to_remove=["homework-HW-5678"])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plan_with_school_event_add() -> CalDAVSyncPlan:
|
||||
"""Fournit un plan avec un événement scolaire à ajouter.
|
||||
|
||||
:return: CalDAVSyncPlan avec un événement scolaire à ajouter.
|
||||
:rtype: CalDAVSyncPlan
|
||||
"""
|
||||
school_event = _make_school_event()
|
||||
return CalDAVSyncPlan(school_events_to_add=[school_event])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plan_with_school_event_remove() -> CalDAVSyncPlan:
|
||||
"""Fournit un plan avec un événement scolaire à supprimer.
|
||||
|
||||
:return: CalDAVSyncPlan avec un événement scolaire à supprimer.
|
||||
:rtype: CalDAVSyncPlan
|
||||
"""
|
||||
return CalDAVSyncPlan(school_events_to_remove=["school-event-Vacances-2026-12-20"])
|
||||
|
||||
|
||||
# --- Dry-run tests ---
|
||||
|
||||
|
||||
def test_dry_run_add_does_not_call_gateway(
|
||||
mock_gateway: MagicMock, plan_with_lesson_add: CalDAVSyncPlan
|
||||
) -> None:
|
||||
"""Vérifie que dry_run=True n'appelle pas gateway.upsert_event pour un ajout.
|
||||
|
||||
:param mock_gateway: Passerelle mockée.
|
||||
:param plan_with_lesson_add: Plan avec un cours à ajouter.
|
||||
:return: None
|
||||
"""
|
||||
executor = CalDAVSyncExecutor(mock_gateway, dry_run=True)
|
||||
result = executor.execute(plan_with_lesson_add)
|
||||
|
||||
mock_gateway.upsert_event.assert_not_called()
|
||||
assert result.added == 1
|
||||
assert result.updated == 0
|
||||
assert result.removed == 0
|
||||
|
||||
|
||||
def test_dry_run_update_does_not_call_gateway(
|
||||
mock_gateway: MagicMock, plan_with_lesson_update: CalDAVSyncPlan
|
||||
) -> None:
|
||||
"""Vérifie que dry_run=True n'appelle pas gateway.upsert_event pour une mise à jour.
|
||||
|
||||
:param mock_gateway: Passerelle mockée.
|
||||
:param plan_with_lesson_update: Plan avec un cours à mettre à jour.
|
||||
:return: None
|
||||
"""
|
||||
executor = CalDAVSyncExecutor(mock_gateway, dry_run=True)
|
||||
result = executor.execute(plan_with_lesson_update)
|
||||
|
||||
mock_gateway.upsert_event.assert_not_called()
|
||||
assert result.added == 0
|
||||
assert result.updated == 1
|
||||
assert result.removed == 0
|
||||
|
||||
|
||||
def test_dry_run_delete_does_not_call_gateway(
|
||||
mock_gateway: MagicMock, plan_with_lesson_remove: CalDAVSyncPlan
|
||||
) -> None:
|
||||
"""Vérifie que dry_run=True n'appelle pas gateway.delete_event.
|
||||
|
||||
:param mock_gateway: Passerelle mockée.
|
||||
:param plan_with_lesson_remove: Plan avec un cours à supprimer.
|
||||
:return: None
|
||||
"""
|
||||
executor = CalDAVSyncExecutor(mock_gateway, dry_run=True)
|
||||
result = executor.execute(plan_with_lesson_remove)
|
||||
|
||||
mock_gateway.delete_event.assert_not_called()
|
||||
assert result.added == 0
|
||||
assert result.updated == 0
|
||||
assert result.removed == 1
|
||||
|
||||
|
||||
def test_dry_run_all_operations(
|
||||
mock_gateway: MagicMock,
|
||||
) -> None:
|
||||
"""Vérifie que dry_run=True fonctionne pour toutes les catégories.
|
||||
|
||||
:param mock_gateway: Passerelle mockée.
|
||||
:return: None
|
||||
"""
|
||||
plan = CalDAVSyncPlan(
|
||||
lessons_to_add=[_make_lesson(lesson_id="L-0001")],
|
||||
lessons_to_update=[_make_lesson(lesson_id="L-0002")],
|
||||
lessons_to_remove=["L-0003"],
|
||||
homeworks_to_add=[_make_homework(homework_id="HW-0001")],
|
||||
homeworks_to_update=[_make_homework(homework_id="HW-0002")],
|
||||
homeworks_to_remove=["homework-HW-0003"],
|
||||
school_events_to_add=[_make_school_event()],
|
||||
school_events_to_update=[_make_school_event(label="Événement 2")],
|
||||
school_events_to_remove=["school-event-Événement-2026-01-01"],
|
||||
)
|
||||
|
||||
executor = CalDAVSyncExecutor(mock_gateway, dry_run=True)
|
||||
result = executor.execute(plan)
|
||||
|
||||
mock_gateway.upsert_event.assert_not_called()
|
||||
mock_gateway.delete_event.assert_not_called()
|
||||
|
||||
assert result.added == 3 # 1 lesson + 1 homework + 1 school event
|
||||
assert result.updated == 3 # 1 lesson + 1 homework + 1 school event
|
||||
assert result.removed == 3 # 1 lesson + 1 homework + 1 school event
|
||||
|
||||
|
||||
# --- Real execution tests ---
|
||||
|
||||
|
||||
def test_real_add_calls_gateway(
|
||||
mock_gateway: MagicMock, plan_with_lesson_add: CalDAVSyncPlan
|
||||
) -> None:
|
||||
"""Vérifie que dry_run=False appelle gateway.upsert_event pour un ajout.
|
||||
|
||||
:param mock_gateway: Passerelle mockée.
|
||||
:param plan_with_lesson_add: Plan avec un cours à ajouter.
|
||||
:return: None
|
||||
"""
|
||||
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
|
||||
result = executor.execute(plan_with_lesson_add)
|
||||
|
||||
mock_gateway.upsert_event.assert_called_once()
|
||||
# Vérifier que les arguments sont (texte iCalendar, uid)
|
||||
call_args = mock_gateway.upsert_event.call_args
|
||||
assert isinstance(call_args[0][0], str)
|
||||
assert "BEGIN:VEVENT" in call_args[0][0]
|
||||
assert call_args[0][1] == "L-1234"
|
||||
assert result.added == 1
|
||||
|
||||
|
||||
def test_real_update_calls_gateway(
|
||||
mock_gateway: MagicMock, plan_with_lesson_update: CalDAVSyncPlan
|
||||
) -> None:
|
||||
"""Vérifie que dry_run=False appelle gateway.upsert_event pour une mise à jour.
|
||||
|
||||
:param mock_gateway: Passerelle mockée.
|
||||
:param plan_with_lesson_update: Plan avec un cours à mettre à jour.
|
||||
:return: None
|
||||
"""
|
||||
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
|
||||
result = executor.execute(plan_with_lesson_update)
|
||||
|
||||
mock_gateway.upsert_event.assert_called_once()
|
||||
assert result.updated == 1
|
||||
|
||||
|
||||
def test_real_delete_calls_gateway(
|
||||
mock_gateway: MagicMock, plan_with_lesson_remove: CalDAVSyncPlan
|
||||
) -> None:
|
||||
"""Vérifie que dry_run=False appelle gateway.delete_event.
|
||||
|
||||
:param mock_gateway: Passerelle mockée.
|
||||
:param plan_with_lesson_remove: Plan avec un cours à supprimer.
|
||||
:return: None
|
||||
"""
|
||||
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
|
||||
result = executor.execute(plan_with_lesson_remove)
|
||||
|
||||
mock_gateway.delete_event.assert_called_once_with("L-1234")
|
||||
assert result.removed == 1
|
||||
|
||||
|
||||
def test_real_execution_all_operations(
|
||||
mock_gateway: MagicMock,
|
||||
) -> None:
|
||||
"""Vérifie que dry_run=False appelle la passerelle pour toutes les opérations.
|
||||
|
||||
:param mock_gateway: Passerelle mockée.
|
||||
:return: None
|
||||
"""
|
||||
plan = CalDAVSyncPlan(
|
||||
lessons_to_add=[_make_lesson(lesson_id="L-0001")],
|
||||
lessons_to_update=[_make_lesson(lesson_id="L-0002")],
|
||||
lessons_to_remove=["L-0003"],
|
||||
homeworks_to_add=[_make_homework(homework_id="HW-0001")],
|
||||
homeworks_to_remove=["homework-HW-0002"],
|
||||
school_events_to_add=[_make_school_event()],
|
||||
school_events_to_remove=["school-event-Vacances-2026-12-20"],
|
||||
)
|
||||
|
||||
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
|
||||
result = executor.execute(plan)
|
||||
|
||||
# upsert_event appelé pour les ajouts et mises à jour
|
||||
# 1 lesson add + 1 lesson update + 1 homework add + 1 school event add = 4
|
||||
assert mock_gateway.upsert_event.call_count == 4
|
||||
# delete_event appelé pour les suppressions
|
||||
assert mock_gateway.delete_event.call_count == 3 # 1 lesson + 1 homework + 1 school event
|
||||
|
||||
assert result.added == 3 # 1 lesson + 1 homework + 1 school event
|
||||
assert result.updated == 1 # 1 lesson
|
||||
assert result.removed == 3 # 1 lesson + 1 homework + 1 school event
|
||||
|
||||
|
||||
# --- Error handling tests ---
|
||||
|
||||
|
||||
def test_error_isolation_save_failure(
|
||||
mock_gateway: MagicMock,
|
||||
) -> None:
|
||||
"""Vérifie qu'une erreur sur upsert_event est capturée et le batch continue.
|
||||
|
||||
:param mock_gateway: Passerelle mockée.
|
||||
:return: None
|
||||
"""
|
||||
lesson1 = _make_lesson(lesson_id="L-0001")
|
||||
lesson2 = _make_lesson(lesson_id="L-0002")
|
||||
|
||||
# Configurer le mock pour lever une erreur sur le premier appel
|
||||
mock_gateway.upsert_event.side_effect = [
|
||||
PronoteSyncError("Échec de l'écriture"),
|
||||
None, # Le deuxième appel réussit
|
||||
]
|
||||
|
||||
plan = CalDAVSyncPlan(lessons_to_add=[lesson1, lesson2])
|
||||
|
||||
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
|
||||
result = executor.execute(plan)
|
||||
|
||||
# Les deux appels ont été tentés
|
||||
assert mock_gateway.upsert_event.call_count == 2
|
||||
# Une erreur a été capturée
|
||||
assert len(result.errors) == 1
|
||||
assert "Échec de l'écriture" in result.errors[0]
|
||||
# Le compteur d'ajouts est à 1 (seul le deuxième a réussi)
|
||||
assert result.added == 1
|
||||
# Le statut est FAILED car il y a des erreurs
|
||||
assert result.status == CalDAVSyncStatus.FAILED
|
||||
|
||||
|
||||
def test_error_isolation_delete_failure(
|
||||
mock_gateway: MagicMock,
|
||||
) -> None:
|
||||
"""Vérifie qu'une erreur sur delete_event est capturée et le batch continue.
|
||||
|
||||
:param mock_gateway: Passerelle mockée.
|
||||
:return: None
|
||||
"""
|
||||
mock_gateway.delete_event.side_effect = PronoteSyncError("Échec de la suppression")
|
||||
|
||||
plan = CalDAVSyncPlan(
|
||||
lessons_to_remove=["L-0001", "L-0002"],
|
||||
)
|
||||
|
||||
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
|
||||
result = executor.execute(plan)
|
||||
|
||||
# Les deux appels ont été tentés
|
||||
assert mock_gateway.delete_event.call_count == 2
|
||||
# Deux erreurs ont été capturées
|
||||
assert len(result.errors) == 2
|
||||
assert all("Échec de la suppression" in err for err in result.errors)
|
||||
# Aucun compteur de suppression n'a été incrémenté
|
||||
assert result.removed == 0
|
||||
# Le statut est FAILED
|
||||
assert result.status == CalDAVSyncStatus.FAILED
|
||||
|
||||
|
||||
def test_error_isolation_mixed_operations(
|
||||
mock_gateway: MagicMock,
|
||||
) -> None:
|
||||
"""Vérifie que les erreurs sont isolées entre différentes opérations.
|
||||
|
||||
:param mock_gateway: Passerelle mockée.
|
||||
:return: None
|
||||
"""
|
||||
lesson = _make_lesson(lesson_id="L-0001")
|
||||
|
||||
# upsert_event échoue, delete_event réussit
|
||||
mock_gateway.upsert_event.side_effect = PronoteSyncError("Échec save")
|
||||
mock_gateway.delete_event.return_value = None
|
||||
|
||||
plan = CalDAVSyncPlan(
|
||||
lessons_to_add=[lesson],
|
||||
lessons_to_remove=["L-0002"],
|
||||
)
|
||||
|
||||
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
|
||||
result = executor.execute(plan)
|
||||
|
||||
assert mock_gateway.upsert_event.call_count == 1
|
||||
assert mock_gateway.delete_event.call_count == 1
|
||||
assert len(result.errors) == 1
|
||||
assert "Échec save" in result.errors[0]
|
||||
assert result.added == 0
|
||||
assert result.removed == 1
|
||||
assert result.status == CalDAVSyncStatus.FAILED
|
||||
|
||||
|
||||
# --- Status tests ---
|
||||
|
||||
|
||||
def test_status_skipped_when_no_operations(
|
||||
mock_gateway: MagicMock, empty_plan: CalDAVSyncPlan
|
||||
) -> None:
|
||||
"""Vérifie que le statut est SKIPPED quand aucune opération n'est à effectuer.
|
||||
|
||||
:param mock_gateway: Passerelle mockée.
|
||||
:param empty_plan: Plan vide.
|
||||
:return: None
|
||||
"""
|
||||
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
|
||||
result = executor.execute(empty_plan)
|
||||
|
||||
assert result.status == CalDAVSyncStatus.SKIPPED
|
||||
assert result.added == 0
|
||||
assert result.updated == 0
|
||||
assert result.removed == 0
|
||||
|
||||
|
||||
def test_status_success_when_no_errors(
|
||||
mock_gateway: MagicMock, plan_with_lesson_add: CalDAVSyncPlan
|
||||
) -> None:
|
||||
"""Vérifie que le statut est SUCCESS quand les opérations réussissent sans erreur.
|
||||
|
||||
:param mock_gateway: Passerelle mockée.
|
||||
:param plan_with_lesson_add: Plan avec un cours à ajouter.
|
||||
:return: None
|
||||
"""
|
||||
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
|
||||
result = executor.execute(plan_with_lesson_add)
|
||||
|
||||
assert result.status == CalDAVSyncStatus.SUCCESS
|
||||
|
||||
|
||||
def test_status_failed_when_errors_present(
|
||||
mock_gateway: MagicMock,
|
||||
) -> None:
|
||||
"""Vérifie que le statut est FAILED quand des erreurs sont présentes.
|
||||
|
||||
:param mock_gateway: Passerelle mockée.
|
||||
:return: None
|
||||
"""
|
||||
lesson = _make_lesson(lesson_id="L-0001")
|
||||
mock_gateway.upsert_event.side_effect = PronoteSyncError("Échec")
|
||||
|
||||
plan = CalDAVSyncPlan(lessons_to_add=[lesson])
|
||||
|
||||
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
|
||||
result = executor.execute(plan)
|
||||
|
||||
assert result.status == CalDAVSyncStatus.FAILED
|
||||
|
||||
|
||||
# --- Homework and School Event tests ---
|
||||
|
||||
|
||||
def test_homework_add_real_execution(
|
||||
mock_gateway: MagicMock, plan_with_homework_add: CalDAVSyncPlan
|
||||
) -> None:
|
||||
"""Vérifie l'exécution réelle pour l'ajout d'un devoir.
|
||||
|
||||
:param mock_gateway: Passerelle mockée.
|
||||
:param plan_with_homework_add: Plan avec un devoir à ajouter.
|
||||
:return: None
|
||||
"""
|
||||
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
|
||||
result = executor.execute(plan_with_homework_add)
|
||||
|
||||
mock_gateway.upsert_event.assert_called_once()
|
||||
call_args = mock_gateway.upsert_event.call_args
|
||||
vcalendar_text = call_args[0][0]
|
||||
assert "homework-HW-5678" in vcalendar_text
|
||||
assert call_args[0][1] == "homework-HW-5678"
|
||||
assert "Devoir: Mathématiques" in vcalendar_text
|
||||
assert result.added == 1
|
||||
|
||||
|
||||
def test_homework_delete_real_execution(
|
||||
mock_gateway: MagicMock, plan_with_homework_remove: CalDAVSyncPlan
|
||||
) -> None:
|
||||
"""Vérifie l'exécution réelle pour la suppression d'un devoir.
|
||||
|
||||
:param mock_gateway: Passerelle mockée.
|
||||
:param plan_with_homework_remove: Plan avec un devoir à supprimer.
|
||||
:return: None
|
||||
"""
|
||||
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
|
||||
result = executor.execute(plan_with_homework_remove)
|
||||
|
||||
mock_gateway.delete_event.assert_called_once_with("homework-HW-5678")
|
||||
assert result.removed == 1
|
||||
|
||||
|
||||
def test_school_event_add_real_execution(
|
||||
mock_gateway: MagicMock, plan_with_school_event_add: CalDAVSyncPlan
|
||||
) -> None:
|
||||
"""Vérifie l'exécution réelle pour l'ajout d'un événement scolaire.
|
||||
|
||||
:param mock_gateway: Passerelle mockée.
|
||||
:param plan_with_school_event_add: Plan avec un événement scolaire à ajouter.
|
||||
:return: None
|
||||
"""
|
||||
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
|
||||
result = executor.execute(plan_with_school_event_add)
|
||||
|
||||
mock_gateway.upsert_event.assert_called_once()
|
||||
call_args = mock_gateway.upsert_event.call_args
|
||||
vcalendar_text = call_args[0][0]
|
||||
assert "school-event-Vacances de Noël-2026-12-20" in vcalendar_text
|
||||
assert call_args[0][1] == "school-event-Vacances de Noël-2026-12-20"
|
||||
assert result.added == 1
|
||||
|
||||
|
||||
def test_school_event_delete_real_execution(
|
||||
mock_gateway: MagicMock, plan_with_school_event_remove: CalDAVSyncPlan
|
||||
) -> None:
|
||||
"""Vérifie l'exécution réelle pour la suppression d'un événement scolaire.
|
||||
|
||||
:param mock_gateway: Passerelle mockée.
|
||||
:param plan_with_school_event_remove: Plan avec un événement scolaire à supprimer.
|
||||
:return: None
|
||||
"""
|
||||
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
|
||||
result = executor.execute(plan_with_school_event_remove)
|
||||
|
||||
mock_gateway.delete_event.assert_called_once_with("school-event-Vacances-2026-12-20")
|
||||
assert result.removed == 1
|
||||
|
||||
|
||||
# Ensure trailing newline
|
||||
716
tests/unit/test_caldav_gateway.py
Normal file
716
tests/unit/test_caldav_gateway.py
Normal file
@@ -0,0 +1,716 @@
|
||||
"""Tests unitaires pour la passerelle CalDAV.
|
||||
|
||||
Ce module vérifie que :class:`CalDAVGateway` gère correctement la connexion,
|
||||
la liste des événements gérés, l'écriture et la suppression d'événements,
|
||||
avec une attention particulière à la sécurité (masquage des secrets).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from caldav.lib.error import NotFoundError
|
||||
from icalendar import Calendar, Event
|
||||
from pydantic import SecretStr
|
||||
|
||||
from pronote_sync.config.settings import CalDAVSettings
|
||||
from pronote_sync.errors import PronoteSyncError
|
||||
from pronote_sync.sync.caldav import CalDAVGateway
|
||||
from pronote_sync.sync.serialization import MANAGED_PROPERTY, MANAGED_VALUE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
# --- Fixtures ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def caldav_settings() -> CalDAVSettings:
|
||||
"""Fournit des paramètres CalDAV valides pour les tests.
|
||||
|
||||
:return: Instance de CalDAVSettings.
|
||||
:rtype: CalDAVSettings
|
||||
"""
|
||||
return CalDAVSettings(
|
||||
url=SecretStr("https://caldav.example.com"),
|
||||
username="testuser",
|
||||
password=SecretStr("testpass123"),
|
||||
calendar_path="/pronote-sync/",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def caldav_settings_http_localhost() -> CalDAVSettings:
|
||||
"""Fournit des paramètres CalDAV avec HTTP pour localhost.
|
||||
|
||||
:return: Instance de CalDAVSettings.
|
||||
:rtype: CalDAVSettings
|
||||
"""
|
||||
return CalDAVSettings(
|
||||
url=SecretStr("http://localhost:5232"),
|
||||
username="testuser",
|
||||
password=SecretStr("testpass123"),
|
||||
calendar_path="/pronote-sync/",
|
||||
allow_insecure_http=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client_factory() -> MagicMock:
|
||||
"""Fournit une usine de clients CalDAV mockée.
|
||||
|
||||
Le client retourné expose un principal dont la liste de calendriers
|
||||
contient un calendrier dont le chemin se termine par ``pronote-sync``,
|
||||
ce qui permet à :meth:`~pronote_sync.sync.caldav.CalDAVGateway.connect`
|
||||
de le résoudre par découverte.
|
||||
|
||||
:return: MagicMock configuré pour retourner un client, un principal et
|
||||
un calendrier mockés.
|
||||
:rtype: MagicMock
|
||||
"""
|
||||
mock_factory = MagicMock()
|
||||
|
||||
# Configurer le mock pour retourner un client
|
||||
mock_client = MagicMock()
|
||||
mock_factory.return_value = mock_client
|
||||
|
||||
# Configurer le client pour exposer un principal avec un calendrier
|
||||
mock_principal = MagicMock()
|
||||
mock_client.principal.return_value = mock_principal
|
||||
|
||||
mock_calendar = MagicMock()
|
||||
mock_calendar.url = "https://caldav.example.com/calendars/testuser/pronote-sync/"
|
||||
mock_principal.calendars.return_value = [mock_calendar]
|
||||
|
||||
return mock_factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_managed_event() -> MagicMock:
|
||||
"""Fournit un événement CalDAV mocké avec le marqueur de gestion.
|
||||
|
||||
:return: MagicMock configuré comme un événement géré.
|
||||
:rtype: MagicMock
|
||||
"""
|
||||
mock_event = MagicMock()
|
||||
# Créer un VEVENT avec le marqueur de gestion
|
||||
vevent = Event()
|
||||
vevent.add("UID", "test-uid-123")
|
||||
vevent.add("SUMMARY", "Test Event")
|
||||
vevent.add(MANAGED_PROPERTY, MANAGED_VALUE)
|
||||
mock_event.icalendar_component = Calendar()
|
||||
mock_event.icalendar_component.add_component(vevent)
|
||||
return mock_event
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_unmanaged_event() -> MagicMock:
|
||||
"""Fournit un événement CalDAV mocké sans le marqueur de gestion.
|
||||
|
||||
:return: MagicMock configuré comme un événement non géré.
|
||||
:rtype: MagicMock
|
||||
"""
|
||||
mock_event = MagicMock()
|
||||
# Créer un VEVENT sans le marqueur de gestion
|
||||
vevent = Event()
|
||||
vevent.add("UID", "unmanaged-uid-456")
|
||||
vevent.add("SUMMARY", "Unmanaged Event")
|
||||
mock_event.icalendar_component = Calendar()
|
||||
mock_event.icalendar_component.add_component(vevent)
|
||||
return mock_event
|
||||
|
||||
|
||||
# --- Connection tests ---
|
||||
|
||||
|
||||
def test_connect_success(
|
||||
caldav_settings: CalDAVSettings,
|
||||
mock_client_factory: MagicMock,
|
||||
) -> None:
|
||||
"""Vérifie que connect() configure le client et résout le calendrier.
|
||||
|
||||
La résolution passe par la découverte CalDAV (``principal()`` puis
|
||||
``calendars()``) et non par une concaténation d'URL.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:param mock_client_factory: Usine de clients mockée.
|
||||
:return: None
|
||||
"""
|
||||
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory)
|
||||
gateway.connect()
|
||||
|
||||
# Vérifier que l'usine a été appelée avec les bons paramètres
|
||||
mock_client_factory.assert_called_once()
|
||||
call_kwargs = mock_client_factory.call_args[1]
|
||||
assert call_kwargs["url"] == "https://caldav.example.com"
|
||||
assert call_kwargs["username"] == "testuser"
|
||||
assert call_kwargs["password"] == "testpass123"
|
||||
|
||||
# Vérifier que le calendrier a été résolu par découverte
|
||||
mock_client = mock_client_factory.return_value
|
||||
mock_client.principal.assert_called_once_with()
|
||||
mock_principal = mock_client.principal.return_value
|
||||
mock_principal.calendars.assert_called_once_with()
|
||||
|
||||
mock_calendar = mock_principal.calendars.return_value[0]
|
||||
assert gateway._calendar is mock_calendar
|
||||
|
||||
|
||||
def test_connect_calendar_not_found_raises(
|
||||
caldav_settings: CalDAVSettings,
|
||||
mock_client_factory: MagicMock,
|
||||
) -> None:
|
||||
"""Vérifie que connect() lève PronoteSyncError si aucun calendrier ne correspond.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:param mock_client_factory: Usine de clients mockée.
|
||||
:return: None
|
||||
"""
|
||||
mock_principal = mock_client_factory.return_value.principal.return_value
|
||||
other_calendar = MagicMock()
|
||||
other_calendar.url = "https://caldav.example.com/calendars/testuser/autre-calendrier/"
|
||||
mock_principal.calendars.return_value = [other_calendar]
|
||||
|
||||
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory)
|
||||
|
||||
with pytest.raises(PronoteSyncError) as exc_info:
|
||||
gateway.connect()
|
||||
|
||||
assert "introuvable" in str(exc_info.value)
|
||||
assert exc_info.value.__cause__ is None
|
||||
assert exc_info.value.__context__ is None
|
||||
|
||||
|
||||
def test_connect_calendar_ambiguous_raises(
|
||||
caldav_settings: CalDAVSettings,
|
||||
mock_client_factory: MagicMock,
|
||||
) -> None:
|
||||
"""Vérifie que connect() lève PronoteSyncError si plusieurs calendriers correspondent.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:param mock_client_factory: Usine de clients mockée.
|
||||
:return: None
|
||||
"""
|
||||
mock_principal = mock_client_factory.return_value.principal.return_value
|
||||
cal1 = MagicMock()
|
||||
cal1.url = "https://caldav.example.com/calendars/testuser/pronote-sync/"
|
||||
cal2 = MagicMock()
|
||||
cal2.url = "https://caldav.example.com/calendars/autreuser/pronote-sync/"
|
||||
mock_principal.calendars.return_value = [cal1, cal2]
|
||||
|
||||
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory)
|
||||
|
||||
with pytest.raises(PronoteSyncError) as exc_info:
|
||||
gateway.connect()
|
||||
|
||||
assert "ambigu" in str(exc_info.value)
|
||||
assert exc_info.value.__cause__ is None
|
||||
assert exc_info.value.__context__ is None
|
||||
|
||||
|
||||
def test_connect_calendar_path_boundary_not_matched(
|
||||
caldav_settings: CalDAVSettings,
|
||||
mock_client_factory: MagicMock,
|
||||
) -> None:
|
||||
"""Vérifie que connect() ne résout pas un chemin partageant un préfixe.
|
||||
|
||||
Un calendrier dont le chemin se termine par ``pronote-sync`` sans
|
||||
frontière de composant (ex : ``.../not-pronote-sync/``) ne doit pas
|
||||
correspondre au ``calendar_path`` configuré ``/pronote-sync/``.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:param mock_client_factory: Usine de clients mockée.
|
||||
:return: None
|
||||
"""
|
||||
mock_principal = mock_client_factory.return_value.principal.return_value
|
||||
boundary_calendar = MagicMock()
|
||||
boundary_calendar.url = "https://caldav.example.com/calendars/testuser/not-pronote-sync/"
|
||||
mock_principal.calendars.return_value = [boundary_calendar]
|
||||
|
||||
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory)
|
||||
|
||||
with pytest.raises(PronoteSyncError) as exc_info:
|
||||
gateway.connect()
|
||||
|
||||
assert "introuvable" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_connect_failure_raises_pronote_sync_error(
|
||||
caldav_settings: CalDAVSettings,
|
||||
) -> None:
|
||||
"""Vérifie que connect() lève PronoteSyncError en cas d'échec de connexion.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:return: None
|
||||
"""
|
||||
|
||||
# Créer une usine qui lève une exception
|
||||
def failing_factory(*args: object, **kwargs: object) -> None:
|
||||
raise ConnectionError("Connection failed")
|
||||
|
||||
gateway = CalDAVGateway(caldav_settings, client_factory=failing_factory)
|
||||
|
||||
with pytest.raises(PronoteSyncError) as exc_info:
|
||||
gateway.connect()
|
||||
|
||||
# Vérifier que le message ne contient pas l'URL brute
|
||||
error_message = str(exc_info.value)
|
||||
assert "caldav.example.com" in error_message # L'URL rédigée doit être présente
|
||||
assert "testpass123" not in error_message # Le mot de passe ne doit pas être présent
|
||||
assert "from None" in str(exc_info.typename) or exc_info.value.__cause__ is None
|
||||
|
||||
|
||||
def test_connect_missing_credentials_raises(
|
||||
caldav_settings: CalDAVSettings,
|
||||
) -> None:
|
||||
"""Vérifie que connect() lève PronoteSyncError si les identifiants sont manquants.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:return: None
|
||||
"""
|
||||
# Créer des paramètres sans URL
|
||||
incomplete_settings = CalDAVSettings(
|
||||
url=None,
|
||||
username=None,
|
||||
password=None,
|
||||
calendar_path="/pronote-sync/",
|
||||
)
|
||||
|
||||
gateway = CalDAVGateway(incomplete_settings)
|
||||
|
||||
with pytest.raises(PronoteSyncError) as exc_info:
|
||||
gateway.connect()
|
||||
|
||||
assert "incomplète" in str(exc_info.value)
|
||||
|
||||
|
||||
# --- list_managed_events tests ---
|
||||
|
||||
|
||||
def test_list_managed_events_returns_only_managed(
|
||||
caldav_settings: CalDAVSettings,
|
||||
mock_client_factory: MagicMock,
|
||||
mock_managed_event: MagicMock,
|
||||
mock_unmanaged_event: MagicMock,
|
||||
) -> None:
|
||||
"""Vérifie que list_managed_events ne retourne que les événements gérés.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:param mock_client_factory: Usine de clients mockée.
|
||||
:param mock_managed_event: Événement géré mocké.
|
||||
:param mock_unmanaged_event: Événement non géré mocké.
|
||||
:return: None
|
||||
"""
|
||||
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory)
|
||||
gateway.connect()
|
||||
|
||||
# Configurer le calendrier pour retourner les deux événements
|
||||
mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[
|
||||
0
|
||||
]
|
||||
mock_calendar.date_search.return_value = [mock_managed_event, mock_unmanaged_event]
|
||||
|
||||
start = datetime(2026, 1, 1)
|
||||
end = datetime(2026, 12, 31)
|
||||
result = gateway.list_managed_events(start, end)
|
||||
|
||||
# Seuls les événements gérés doivent être retournés
|
||||
assert len(result) == 1
|
||||
uid, vevent = result[0]
|
||||
assert uid == "test-uid-123"
|
||||
assert str(vevent.get("UID")) == "test-uid-123"
|
||||
|
||||
|
||||
def test_list_managed_events_not_connected_raises(
|
||||
caldav_settings: CalDAVSettings,
|
||||
) -> None:
|
||||
"""Vérifie que list_managed_events lève PronoteSyncError si non connecté.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:return: None
|
||||
"""
|
||||
gateway = CalDAVGateway(caldav_settings)
|
||||
# Ne pas appeler connect()
|
||||
|
||||
with pytest.raises(PronoteSyncError) as exc_info:
|
||||
gateway.list_managed_events(datetime(2026, 1, 1), datetime(2026, 12, 31))
|
||||
|
||||
assert "non connectée" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_list_managed_events_caldav_error_raises(
|
||||
caldav_settings: CalDAVSettings,
|
||||
mock_client_factory: MagicMock,
|
||||
) -> None:
|
||||
"""Vérifie que list_managed_events lève PronoteSyncError en cas d'erreur CalDAV.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:param mock_client_factory: Usine de clients mockée.
|
||||
:return: None
|
||||
"""
|
||||
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory)
|
||||
gateway.connect()
|
||||
|
||||
# Configurer le calendrier pour lever une exception
|
||||
mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[
|
||||
0
|
||||
]
|
||||
mock_calendar.date_search.side_effect = Exception("CalDAV error")
|
||||
|
||||
with pytest.raises(PronoteSyncError) as exc_info:
|
||||
gateway.list_managed_events(datetime(2026, 1, 1), datetime(2026, 12, 31))
|
||||
|
||||
# Vérifier que le message ne contient pas de secret
|
||||
error_message = str(exc_info.value)
|
||||
assert "testpass123" not in error_message
|
||||
assert exc_info.value.__context__ is None
|
||||
|
||||
|
||||
# --- upsert_event tests ---
|
||||
|
||||
|
||||
def test_upsert_event_creates_new_event_when_uid_missing(
|
||||
caldav_settings: CalDAVSettings,
|
||||
mock_client_factory: MagicMock,
|
||||
) -> None:
|
||||
"""Vérifie que upsert_event crée un événement si l'UID est introuvable.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:param mock_client_factory: Usine de clients mockée.
|
||||
:return: None
|
||||
"""
|
||||
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory)
|
||||
gateway.connect()
|
||||
|
||||
mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[
|
||||
0
|
||||
]
|
||||
mock_calendar.get_event_by_uid.side_effect = NotFoundError("Event not found")
|
||||
|
||||
vcalendar_text = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nUID:test-123\nEND:VEVENT\nEND:VCALENDAR"
|
||||
|
||||
gateway.upsert_event(vcalendar_text, "test-123")
|
||||
|
||||
mock_calendar.get_event_by_uid.assert_called_once_with("test-123")
|
||||
mock_calendar.add_event.assert_called_once_with(ical=vcalendar_text)
|
||||
|
||||
|
||||
def test_upsert_event_updates_existing_event_by_uid(
|
||||
caldav_settings: CalDAVSettings,
|
||||
mock_client_factory: MagicMock,
|
||||
) -> None:
|
||||
"""Vérifie que upsert_event remplace le contenu d'un événement existant.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:param mock_client_factory: Usine de clients mockée.
|
||||
:return: None
|
||||
"""
|
||||
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory)
|
||||
gateway.connect()
|
||||
|
||||
mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[
|
||||
0
|
||||
]
|
||||
mock_event = MagicMock()
|
||||
mock_calendar.get_event_by_uid.return_value = mock_event
|
||||
|
||||
vcalendar_text = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nUID:test-123\nEND:VEVENT\nEND:VCALENDAR"
|
||||
|
||||
gateway.upsert_event(vcalendar_text, "test-123")
|
||||
|
||||
mock_calendar.get_event_by_uid.assert_called_once_with("test-123")
|
||||
assert mock_event.data == vcalendar_text
|
||||
mock_event.save.assert_called_once()
|
||||
mock_calendar.add_event.assert_not_called()
|
||||
|
||||
|
||||
def test_upsert_event_not_connected_raises(
|
||||
caldav_settings: CalDAVSettings,
|
||||
) -> None:
|
||||
"""Vérifie que upsert_event lève PronoteSyncError si non connecté.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:return: None
|
||||
"""
|
||||
gateway = CalDAVGateway(caldav_settings)
|
||||
|
||||
with pytest.raises(PronoteSyncError) as exc_info:
|
||||
gateway.upsert_event("BEGIN:VCALENDAR\nEND:VCALENDAR", "test-123")
|
||||
|
||||
assert "non connectée" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_upsert_event_caldav_error_raises(
|
||||
caldav_settings: CalDAVSettings,
|
||||
mock_client_factory: MagicMock,
|
||||
) -> None:
|
||||
"""Vérifie que upsert_event lève PronoteSyncError en cas d'erreur CalDAV.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:param mock_client_factory: Usine de clients mockée.
|
||||
:return: None
|
||||
"""
|
||||
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory)
|
||||
gateway.connect()
|
||||
|
||||
mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[
|
||||
0
|
||||
]
|
||||
mock_calendar.get_event_by_uid.side_effect = Exception("Save error")
|
||||
|
||||
with pytest.raises(PronoteSyncError) as exc_info:
|
||||
gateway.upsert_event("BEGIN:VCALENDAR\nEND:VCALENDAR", "test-123")
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert "testpass123" not in error_message
|
||||
assert exc_info.value.__cause__ is None
|
||||
assert exc_info.value.__context__ is None
|
||||
|
||||
|
||||
# --- delete_event tests ---
|
||||
|
||||
|
||||
def test_delete_event_calls_calendar(
|
||||
caldav_settings: CalDAVSettings,
|
||||
mock_client_factory: MagicMock,
|
||||
) -> None:
|
||||
"""Vérifie que delete_event appelle event.delete() sur l'événement trouvé.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:param mock_client_factory: Usine de clients mockée.
|
||||
:return: None
|
||||
"""
|
||||
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory)
|
||||
gateway.connect()
|
||||
|
||||
# Configurer le calendrier pour retourner un événement mocké
|
||||
mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[
|
||||
0
|
||||
]
|
||||
mock_event = MagicMock()
|
||||
mock_calendar.get_event_by_uid.return_value = mock_event
|
||||
|
||||
gateway.delete_event("test-uid-123")
|
||||
|
||||
mock_calendar.get_event_by_uid.assert_called_once_with("test-uid-123")
|
||||
mock_event.delete.assert_called_once()
|
||||
|
||||
|
||||
def test_delete_event_not_connected_raises(
|
||||
caldav_settings: CalDAVSettings,
|
||||
) -> None:
|
||||
"""Vérifie que delete_event lève PronoteSyncError si non connecté.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:return: None
|
||||
"""
|
||||
gateway = CalDAVGateway(caldav_settings)
|
||||
|
||||
with pytest.raises(PronoteSyncError) as exc_info:
|
||||
gateway.delete_event("test-uid-123")
|
||||
|
||||
assert "non connectée" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_delete_event_caldav_error_raises(
|
||||
caldav_settings: CalDAVSettings,
|
||||
mock_client_factory: MagicMock,
|
||||
) -> None:
|
||||
"""Vérifie que delete_event lève PronoteSyncError en cas d'erreur CalDAV.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:param mock_client_factory: Usine de clients mockée.
|
||||
:return: None
|
||||
"""
|
||||
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory)
|
||||
gateway.connect()
|
||||
|
||||
mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[
|
||||
0
|
||||
]
|
||||
mock_calendar.get_event_by_uid.side_effect = Exception("Delete error")
|
||||
|
||||
with pytest.raises(PronoteSyncError) as exc_info:
|
||||
gateway.delete_event("test-uid-123")
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert "testpass123" not in error_message
|
||||
assert exc_info.value.__cause__ is None
|
||||
assert exc_info.value.__context__ is None
|
||||
|
||||
|
||||
# --- Context manager tests ---
|
||||
|
||||
|
||||
def test_context_manager_calls_connect_and_close(
|
||||
caldav_settings: CalDAVSettings,
|
||||
mock_client_factory: MagicMock,
|
||||
) -> None:
|
||||
"""Vérifie que le gestionnaire de contexte appelle connect() et close().
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:param mock_client_factory: Usine de clients mockée.
|
||||
:return: None
|
||||
"""
|
||||
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory)
|
||||
|
||||
with gateway:
|
||||
# À l'intérieur du contexte, le client doit être configuré
|
||||
assert gateway._client is not None
|
||||
assert gateway._calendar is not None
|
||||
|
||||
# Après la sortie du contexte, le client doit être réinitialisé
|
||||
assert gateway._client is None
|
||||
assert gateway._calendar is None
|
||||
|
||||
|
||||
def test_context_manager_connect_failure(
|
||||
caldav_settings: CalDAVSettings,
|
||||
) -> None:
|
||||
"""Vérifie que le gestionnaire de contexte propage l'erreur de connexion.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:return: None
|
||||
"""
|
||||
|
||||
def failing_factory(*args: object, **kwargs: object) -> None:
|
||||
raise ConnectionError("Connection failed")
|
||||
|
||||
gateway = CalDAVGateway(caldav_settings, client_factory=failing_factory)
|
||||
|
||||
with pytest.raises(PronoteSyncError):
|
||||
with gateway:
|
||||
pass # Ne doit pas être atteint
|
||||
|
||||
|
||||
# --- Security tests ---
|
||||
|
||||
|
||||
def test_no_plaintext_password_in_vars(
|
||||
caldav_settings: CalDAVSettings,
|
||||
) -> None:
|
||||
"""Vérifie que vars(gateway) ne contient pas le mot de passe en clair.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:return: None
|
||||
"""
|
||||
gateway = CalDAVGateway(caldav_settings)
|
||||
|
||||
gateway_vars = vars(gateway)
|
||||
|
||||
# Vérifier que le mot de passe n'est pas en clair
|
||||
assert "testpass123" not in str(gateway_vars)
|
||||
|
||||
# Vérifier que l'URL brute avec credentials n'est pas en clair
|
||||
# Note: _redacted_url contient l'URL sans credentials, ce qui est acceptable
|
||||
assert "testuser:testpass123@" not in str(gateway_vars)
|
||||
|
||||
|
||||
def test_repr_does_not_leak_password(
|
||||
caldav_settings: CalDAVSettings,
|
||||
) -> None:
|
||||
"""Vérifie que repr(gateway) ne fuit pas le mot de passe.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:return: None
|
||||
"""
|
||||
gateway = CalDAVGateway(caldav_settings)
|
||||
|
||||
repr_str = repr(gateway)
|
||||
assert "testpass123" not in repr_str
|
||||
|
||||
|
||||
def test_str_does_not_leak_password(
|
||||
caldav_settings: CalDAVSettings,
|
||||
) -> None:
|
||||
"""Vérifie que str(gateway) ne fuit pas le mot de passe.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:return: None
|
||||
"""
|
||||
gateway = CalDAVGateway(caldav_settings)
|
||||
|
||||
str_str = str(gateway)
|
||||
assert "testpass123" not in str_str
|
||||
|
||||
|
||||
def test_redacted_url_in_error_message(
|
||||
caldav_settings: CalDAVSettings,
|
||||
) -> None:
|
||||
"""Vérifie que les messages d'erreur contiennent l'URL rédigée, pas l'URL brute.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:return: None
|
||||
"""
|
||||
|
||||
def failing_factory(*args: object, **kwargs: object) -> None:
|
||||
raise ConnectionError("Connection failed")
|
||||
|
||||
gateway = CalDAVGateway(caldav_settings, client_factory=failing_factory)
|
||||
|
||||
try:
|
||||
gateway.connect()
|
||||
except PronoteSyncError as exc:
|
||||
error_message = str(exc)
|
||||
# L'URL doit être rédigée (sans credentials)
|
||||
assert "caldav.example.com" in error_message
|
||||
# Le mot de passe ne doit pas être présent
|
||||
assert "testpass123" not in error_message
|
||||
# L'URL complète avec credentials ne doit pas être présente
|
||||
assert "https://testuser:testpass123@caldav.example.com" not in error_message
|
||||
|
||||
|
||||
def test_close_clears_secrets(
|
||||
caldav_settings: CalDAVSettings,
|
||||
mock_client_factory: MagicMock,
|
||||
) -> None:
|
||||
"""Vérifie que close() réinitialise les secrets.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:param mock_client_factory: Usine de clients mockée.
|
||||
:return: None
|
||||
"""
|
||||
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory)
|
||||
gateway.connect()
|
||||
|
||||
# Avant close(), les secrets sont présents
|
||||
assert gateway._url_secret is not None
|
||||
assert gateway._password_secret is not None
|
||||
|
||||
gateway.close()
|
||||
|
||||
# Après close(), les secrets sont réinitialisés
|
||||
assert gateway._url_secret is None
|
||||
assert gateway._password_secret is None
|
||||
assert gateway._client is None
|
||||
assert gateway._calendar is None
|
||||
|
||||
|
||||
# --- HTTP localhost tests ---
|
||||
|
||||
|
||||
def test_http_localhost_allowed_with_flag(
|
||||
caldav_settings_http_localhost: CalDAVSettings,
|
||||
mock_client_factory: MagicMock,
|
||||
) -> None:
|
||||
"""Vérifie que HTTP est autorisé pour localhost avec allow_insecure_http=True.
|
||||
|
||||
:param caldav_settings_http_localhost: Paramètres CalDAV avec HTTP pour localhost.
|
||||
:param mock_client_factory: Usine de clients mockée.
|
||||
:return: None
|
||||
"""
|
||||
gateway = CalDAVGateway(caldav_settings_http_localhost, client_factory=mock_client_factory)
|
||||
|
||||
# La validation doit réussir (pas d'erreur levée)
|
||||
# Le client_factory est appelé avec l'URL HTTP
|
||||
gateway.connect()
|
||||
|
||||
call_kwargs = mock_client_factory.call_args[1]
|
||||
assert call_kwargs["url"] == "http://localhost:5232"
|
||||
|
||||
|
||||
# Ensure trailing newline
|
||||
581
tests/unit/test_caldav_planner.py
Normal file
581
tests/unit/test_caldav_planner.py
Normal file
@@ -0,0 +1,581 @@
|
||||
"""Tests unitaires pour le planificateur de synchronisation CalDAV.
|
||||
|
||||
Ce module vérifie que la fonction :func:`compute_plan` produit correctement
|
||||
les listes d'ajouts, mises à jour et suppressions pour chaque catégorie
|
||||
(cours, devoirs, événements scolaires) en comparant les données Pronote
|
||||
normalisées aux événements distants marqués comme gérés.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from icalendar import Event
|
||||
|
||||
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent, SchoolEventKind
|
||||
from pronote_sync.models.homework import Homework
|
||||
from pronote_sync.models.pronote import PronoteData
|
||||
from pronote_sync.sync.planner import compute_plan
|
||||
from pronote_sync.sync.serialization import (
|
||||
lesson_to_vevent,
|
||||
)
|
||||
|
||||
# --- Helper fixtures ---
|
||||
|
||||
|
||||
def _make_lesson(
|
||||
lesson_id: str = "L-1234",
|
||||
subject: str = "Mathématiques",
|
||||
start: datetime | None = None,
|
||||
end: datetime | None = None,
|
||||
status: LessonStatus = LessonStatus.NORMAL,
|
||||
content: str | None = None,
|
||||
group: str | None = None,
|
||||
) -> Lesson:
|
||||
"""Fabrique un cours Pronote pour les tests.
|
||||
|
||||
:param lesson_id: Identifiant du cours.
|
||||
:param subject: Matière.
|
||||
:param start: Date/heure de début.
|
||||
:param end: Date/heure de fin.
|
||||
:param status: Statut du cours.
|
||||
:param content: Contenu pédagogique.
|
||||
:param group: Groupe.
|
||||
:return: Instance de Lesson.
|
||||
:rtype: Lesson
|
||||
"""
|
||||
if start is None:
|
||||
start = datetime(2026, 1, 15, 8, 0)
|
||||
if end is None:
|
||||
end = datetime(2026, 1, 15, 9, 0)
|
||||
return Lesson(
|
||||
id=lesson_id,
|
||||
start=start,
|
||||
end=end,
|
||||
subject=subject,
|
||||
status=status,
|
||||
content=content,
|
||||
group=group,
|
||||
)
|
||||
|
||||
|
||||
def _make_homework(
|
||||
homework_id: str = "HW-5678",
|
||||
subject: str = "Mathématiques",
|
||||
due_on: date | None = None,
|
||||
text: str = "Exercice 1 à 5",
|
||||
assigned_on: date | None = None,
|
||||
) -> Homework:
|
||||
"""Fabrique un devoir Pronote pour les tests.
|
||||
|
||||
:param homework_id: Identifiant du devoir.
|
||||
:param subject: Matière.
|
||||
:param due_on: Date d'échéance.
|
||||
:param text: Texte du devoir.
|
||||
:param assigned_on: Date de distribution.
|
||||
:return: Instance de Homework.
|
||||
:rtype: Homework
|
||||
"""
|
||||
if due_on is None:
|
||||
due_on = date(2026, 1, 20)
|
||||
return Homework(
|
||||
id=homework_id,
|
||||
subject=subject,
|
||||
due_on=due_on,
|
||||
text=text,
|
||||
assigned_on=assigned_on,
|
||||
)
|
||||
|
||||
|
||||
def _make_school_event(
|
||||
label: str = "Vacances de Noël",
|
||||
from_date: date | None = None,
|
||||
to_date: date | None = None,
|
||||
kind: SchoolEventKind = SchoolEventKind.HOLIDAY,
|
||||
) -> SchoolEvent:
|
||||
"""Fabrique un événement scolaire pour les tests.
|
||||
|
||||
:param label: Libellé de l'événement.
|
||||
:param from_date: Date de début.
|
||||
:param to_date: Date de fin.
|
||||
:param kind: Type d'événement.
|
||||
:return: Instance de SchoolEvent.
|
||||
:rtype: SchoolEvent
|
||||
"""
|
||||
if from_date is None:
|
||||
from_date = date(2026, 12, 20)
|
||||
if to_date is None:
|
||||
to_date = date(2027, 1, 5)
|
||||
return SchoolEvent(
|
||||
label=label,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
kind=kind,
|
||||
)
|
||||
|
||||
|
||||
def _make_vevent(
|
||||
uid: str,
|
||||
summary: str,
|
||||
dtstart: datetime,
|
||||
dtend: datetime,
|
||||
status: str = "CONFIRMED",
|
||||
categories: list[str] | None = None,
|
||||
) -> Event:
|
||||
"""Fabrique un VEVENT iCalendar pour les tests.
|
||||
|
||||
:param uid: UID de l'événement.
|
||||
:param summary: Résumé.
|
||||
:param dtstart: Date/heure de début.
|
||||
:param dtend: Date/heure de fin.
|
||||
:param status: Statut.
|
||||
:param categories: Catégories.
|
||||
:return: Instance de Event.
|
||||
:rtype: Event
|
||||
"""
|
||||
from icalendar import vDatetime
|
||||
|
||||
event = Event()
|
||||
event.add("UID", uid)
|
||||
event.add("SUMMARY", summary)
|
||||
event.add("DTSTART", vDatetime(dtstart))
|
||||
event.add("DTEND", vDatetime(dtend))
|
||||
event.add("STATUS", status)
|
||||
if categories:
|
||||
event.add("CATEGORIES", categories)
|
||||
# Ajouter le marqueur de gestion
|
||||
from pronote_sync.sync.serialization import MANAGED_PROPERTY, MANAGED_VALUE
|
||||
|
||||
event.add(MANAGED_PROPERTY, MANAGED_VALUE)
|
||||
return event
|
||||
|
||||
|
||||
def _make_pronote_data(
|
||||
lessons: list[Lesson] | None = None,
|
||||
homeworks: list[Homework] | None = None,
|
||||
school_events: list[SchoolEvent] | None = None,
|
||||
) -> PronoteData:
|
||||
"""Fabrique des données Pronote pour les tests.
|
||||
|
||||
:param lessons: Liste des cours.
|
||||
:param homeworks: Liste des devoirs.
|
||||
:param school_events: Liste des événements scolaires.
|
||||
:return: Instance de PronoteData.
|
||||
:rtype: PronoteData
|
||||
"""
|
||||
return PronoteData(
|
||||
lessons=lessons or [],
|
||||
homeworks=homeworks or [],
|
||||
school_events=school_events or [],
|
||||
messages=[],
|
||||
target_date=date(2026, 1, 15),
|
||||
generated_at=datetime(2026, 1, 15, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
# --- Tests for lessons ---
|
||||
|
||||
|
||||
def test_lesson_add_when_not_in_remote() -> None:
|
||||
"""Vérifie qu'un cours non présent à distance va dans lessons_to_add.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
lesson = _make_lesson(lesson_id="L-1234")
|
||||
pronote_data = _make_pronote_data(lessons=[lesson])
|
||||
remote_managed: list[tuple[str, Event]] = []
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
assert len(plan.lessons_to_add) == 1
|
||||
assert plan.lessons_to_add[0].id == "L-1234"
|
||||
assert len(plan.lessons_to_update) == 0
|
||||
assert len(plan.lessons_to_remove) == 0
|
||||
|
||||
|
||||
def test_lesson_update_when_signature_differs() -> None:
|
||||
"""Vérifie qu'un cours présent à distance avec une signature différente va dans lessons_to_update.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
lesson = _make_lesson(lesson_id="L-1234", subject="Mathématiques")
|
||||
pronote_data = _make_pronote_data(lessons=[lesson])
|
||||
|
||||
# Créer un VEVENT distant avec un sujet différent
|
||||
remote_event = _make_vevent(
|
||||
uid="L-1234",
|
||||
summary="Physique", # Différent
|
||||
dtstart=datetime(2026, 1, 15, 8, 0),
|
||||
dtend=datetime(2026, 1, 15, 9, 0),
|
||||
)
|
||||
remote_managed: list[tuple[str, Event]] = [("L-1234", remote_event)]
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
assert len(plan.lessons_to_add) == 0
|
||||
assert len(plan.lessons_to_update) == 1
|
||||
assert plan.lessons_to_update[0].id == "L-1234"
|
||||
assert len(plan.lessons_to_remove) == 0
|
||||
|
||||
|
||||
def test_lesson_idempotent_when_signature_same() -> None:
|
||||
"""Vérifie qu'un cours présent à distance avec la même signature n'apparaît dans aucune liste.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
lesson = _make_lesson(lesson_id="L-1234", subject="Mathématiques")
|
||||
pronote_data = _make_pronote_data(lessons=[lesson])
|
||||
|
||||
# Créer un VEVENT distant avec les mêmes propriétés
|
||||
remote_event = lesson_to_vevent(lesson)
|
||||
remote_managed: list[tuple[str, Event]] = [("L-1234", remote_event)]
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
assert len(plan.lessons_to_add) == 0
|
||||
assert len(plan.lessons_to_update) == 0
|
||||
assert len(plan.lessons_to_remove) == 0
|
||||
|
||||
|
||||
def test_lesson_remove_when_not_in_local() -> None:
|
||||
"""Vérifie qu'un UID distant non présent en local va dans lessons_to_remove.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
pronote_data = _make_pronote_data(lessons=[])
|
||||
|
||||
remote_event = _make_vevent(
|
||||
uid="L-9999",
|
||||
summary="Ancien cours",
|
||||
dtstart=datetime(2026, 1, 15, 8, 0),
|
||||
dtend=datetime(2026, 1, 15, 9, 0),
|
||||
)
|
||||
remote_managed: list[tuple[str, Event]] = [("L-9999", remote_event)]
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
assert len(plan.lessons_to_add) == 0
|
||||
assert len(plan.lessons_to_update) == 0
|
||||
assert len(plan.lessons_to_remove) == 1
|
||||
assert plan.lessons_to_remove[0] == "L-9999"
|
||||
|
||||
|
||||
def test_lesson_cancelled_preserved() -> None:
|
||||
"""Vérifie qu'un cours annulé est traité normalement (ajout/mise à jour).
|
||||
|
||||
:return: None
|
||||
"""
|
||||
lesson = _make_lesson(lesson_id="L-1234", status=LessonStatus.CANCELLED)
|
||||
pronote_data = _make_pronote_data(lessons=[lesson])
|
||||
remote_managed: list[tuple[str, Event]] = []
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
# Un cours annulé doit aller dans lessons_to_add comme n'importe quel autre cours
|
||||
assert len(plan.lessons_to_add) == 1
|
||||
assert plan.lessons_to_add[0].id == "L-1234"
|
||||
assert plan.lessons_to_add[0].status == LessonStatus.CANCELLED
|
||||
|
||||
|
||||
# --- Tests for homeworks ---
|
||||
|
||||
|
||||
def test_homework_add_when_not_in_remote() -> None:
|
||||
"""Vérifie qu'un devoir non présent à distance va dans homeworks_to_add.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
homework = _make_homework(homework_id="HW-5678")
|
||||
pronote_data = _make_pronote_data(homeworks=[homework])
|
||||
remote_managed: list[tuple[str, Event]] = []
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
assert len(plan.homeworks_to_add) == 1
|
||||
assert plan.homeworks_to_add[0].id == "HW-5678"
|
||||
assert len(plan.homeworks_to_update) == 0
|
||||
assert len(plan.homeworks_to_remove) == 0
|
||||
|
||||
|
||||
def test_homework_update_when_signature_differs() -> None:
|
||||
"""Vérifie qu'un devoir présent à distance avec une signature différente va dans homeworks_to_update.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
homework = _make_homework(homework_id="HW-5678", subject="Mathématiques")
|
||||
pronote_data = _make_pronote_data(homeworks=[homework])
|
||||
|
||||
# Créer un VEVENT distant avec un sujet différent
|
||||
remote_event = _make_vevent(
|
||||
uid="homework-HW-5678",
|
||||
summary="Devoir: Physique", # Différent
|
||||
dtstart=datetime(2026, 1, 20, 8, 0),
|
||||
dtend=datetime(2026, 1, 20, 18, 0),
|
||||
)
|
||||
remote_managed: list[tuple[str, Event]] = [("homework-HW-5678", remote_event)]
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
assert len(plan.homeworks_to_add) == 0
|
||||
assert len(plan.homeworks_to_update) == 1
|
||||
assert plan.homeworks_to_update[0].id == "HW-5678"
|
||||
assert len(plan.homeworks_to_remove) == 0
|
||||
|
||||
|
||||
def test_homework_remove_when_not_in_local() -> None:
|
||||
"""Vérifie qu'un UID de devoir distant non présent en local va dans homeworks_to_remove.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
pronote_data = _make_pronote_data(homeworks=[])
|
||||
|
||||
remote_event = _make_vevent(
|
||||
uid="homework-HW-9999",
|
||||
summary="Devoir: Ancien devoir",
|
||||
dtstart=datetime(2026, 1, 20, 8, 0),
|
||||
dtend=datetime(2026, 1, 20, 18, 0),
|
||||
)
|
||||
remote_managed: list[tuple[str, Event]] = [("homework-HW-9999", remote_event)]
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
assert len(plan.homeworks_to_add) == 0
|
||||
assert len(plan.homeworks_to_update) == 0
|
||||
assert len(plan.homeworks_to_remove) == 1
|
||||
assert plan.homeworks_to_remove[0] == "homework-HW-9999"
|
||||
|
||||
|
||||
# --- Tests for school events ---
|
||||
|
||||
|
||||
def test_school_event_add_when_not_in_remote() -> None:
|
||||
"""Vérifie qu'un événement scolaire non présent à distance va dans school_events_to_add.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
school_event = _make_school_event(
|
||||
label="Vacances de Noël",
|
||||
from_date=date(2026, 12, 20),
|
||||
)
|
||||
pronote_data = _make_pronote_data(school_events=[school_event])
|
||||
remote_managed: list[tuple[str, Event]] = []
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
assert len(plan.school_events_to_add) == 1
|
||||
assert plan.school_events_to_add[0].label == "Vacances de Noël"
|
||||
assert len(plan.school_events_to_update) == 0
|
||||
assert len(plan.school_events_to_remove) == 0
|
||||
|
||||
|
||||
def test_school_event_update_when_signature_differs() -> None:
|
||||
"""Vérifie qu'un événement scolaire présent à distance avec une signature différente
|
||||
va dans school_events_to_update.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
school_event = _make_school_event(
|
||||
label="Vacances de Noël",
|
||||
from_date=date(2026, 12, 20),
|
||||
to_date=date(2027, 1, 5),
|
||||
kind=SchoolEventKind.HOLIDAY,
|
||||
)
|
||||
pronote_data = _make_pronote_data(school_events=[school_event])
|
||||
|
||||
# Créer un VEVENT distant avec le même UID mais un libellé différent
|
||||
# L'UID doit correspondre à celui généré par school_event_to_vevent
|
||||
remote_event = _make_vevent(
|
||||
uid="school-event-Vacances de Noël-2026-12-20",
|
||||
summary="Vacances d'hiver", # Différent du local "Vacances de Noël"
|
||||
dtstart=datetime(2026, 12, 20, 0, 0),
|
||||
dtend=datetime(2027, 1, 5, 0, 0),
|
||||
)
|
||||
remote_managed: list[tuple[str, Event]] = [
|
||||
("school-event-Vacances de Noël-2026-12-20", remote_event)
|
||||
]
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
assert len(plan.school_events_to_add) == 0
|
||||
assert len(plan.school_events_to_update) == 1
|
||||
assert len(plan.school_events_to_remove) == 0
|
||||
|
||||
|
||||
def test_school_event_remove_when_not_in_local() -> None:
|
||||
"""Vérifie qu'un UID d'événement scolaire distant non présent en local
|
||||
va dans school_events_to_remove.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
pronote_data = _make_pronote_data(school_events=[])
|
||||
|
||||
remote_event = _make_vevent(
|
||||
uid="school-event-Ancien événement-2026-01-01",
|
||||
summary="Ancien événement",
|
||||
dtstart=datetime(2026, 1, 1, 0, 0),
|
||||
dtend=datetime(2026, 1, 2, 0, 0),
|
||||
)
|
||||
remote_managed: list[tuple[str, Event]] = [
|
||||
("school-event-Ancien événement-2026-01-01", remote_event)
|
||||
]
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
assert len(plan.school_events_to_add) == 0
|
||||
assert len(plan.school_events_to_update) == 0
|
||||
assert len(plan.school_events_to_remove) == 1
|
||||
assert plan.school_events_to_remove[0] == "school-event-Ancien événement-2026-01-01"
|
||||
|
||||
|
||||
# --- Tests for UID routing ---
|
||||
|
||||
|
||||
def test_uid_routing_homework_to_remove() -> None:
|
||||
"""Vérifie qu'un UID distant commençant par 'homework-' va dans homeworks_to_remove.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
pronote_data = _make_pronote_data(lessons=[], homeworks=[], school_events=[])
|
||||
|
||||
remote_event = _make_vevent(
|
||||
uid="homework-HW-9999",
|
||||
summary="Devoir à supprimer",
|
||||
dtstart=datetime(2026, 1, 20, 8, 0),
|
||||
dtend=datetime(2026, 1, 20, 18, 0),
|
||||
)
|
||||
remote_managed: list[tuple[str, Event]] = [("homework-HW-9999", remote_event)]
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
# Ne doit PAS aller dans lessons_to_remove
|
||||
assert len(plan.lessons_to_remove) == 0
|
||||
assert len(plan.homeworks_to_remove) == 1
|
||||
assert plan.homeworks_to_remove[0] == "homework-HW-9999"
|
||||
|
||||
|
||||
def test_uid_routing_school_event_to_remove() -> None:
|
||||
"""Vérifie qu'un UID distant commençant par 'school-event-' va dans school_events_to_remove.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
pronote_data = _make_pronote_data(lessons=[], homeworks=[], school_events=[])
|
||||
|
||||
remote_event = _make_vevent(
|
||||
uid="school-event-Vacances-2026-12-20",
|
||||
summary="Événement à supprimer",
|
||||
dtstart=datetime(2026, 12, 20, 0, 0),
|
||||
dtend=datetime(2027, 1, 5, 0, 0),
|
||||
)
|
||||
remote_managed: list[tuple[str, Event]] = [("school-event-Vacances-2026-12-20", remote_event)]
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
# Ne doit PAS aller dans lessons_to_remove
|
||||
assert len(plan.lessons_to_remove) == 0
|
||||
assert len(plan.school_events_to_remove) == 1
|
||||
assert plan.school_events_to_remove[0] == "school-event-Vacances-2026-12-20"
|
||||
|
||||
|
||||
# --- Tests for empty inputs ---
|
||||
|
||||
|
||||
def test_empty_inputs_empty_plan() -> None:
|
||||
"""Vérifie que des entrées vides produisent un plan vide.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
pronote_data = _make_pronote_data(lessons=[], homeworks=[], school_events=[])
|
||||
remote_managed: list[tuple[str, Event]] = []
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
assert len(plan.lessons_to_add) == 0
|
||||
assert len(plan.lessons_to_update) == 0
|
||||
assert len(plan.lessons_to_remove) == 0
|
||||
assert len(plan.homeworks_to_add) == 0
|
||||
assert len(plan.homeworks_to_update) == 0
|
||||
assert len(plan.homeworks_to_remove) == 0
|
||||
assert len(plan.school_events_to_add) == 0
|
||||
assert len(plan.school_events_to_update) == 0
|
||||
assert len(plan.school_events_to_remove) == 0
|
||||
|
||||
|
||||
# --- Tests for mixed scenarios ---
|
||||
|
||||
|
||||
def test_mixed_scenario() -> None:
|
||||
"""Vérifie un scénario mixte avec ajouts, mises à jour et suppressions.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
# Données locales
|
||||
lesson1 = _make_lesson(lesson_id="L-0001") # Nouveau
|
||||
lesson2 = _make_lesson(lesson_id="L-0002", subject="Mathématiques") # À mettre à jour
|
||||
homework1 = _make_homework(homework_id="HW-0001") # Nouveau
|
||||
|
||||
pronote_data = _make_pronote_data(
|
||||
lessons=[lesson1, lesson2],
|
||||
homeworks=[homework1],
|
||||
school_events=[],
|
||||
)
|
||||
|
||||
# Événements distants
|
||||
# L-0002 existe mais avec un sujet différent
|
||||
remote_lesson2 = _make_vevent(
|
||||
uid="L-0002",
|
||||
summary="Physique",
|
||||
dtstart=datetime(2026, 1, 15, 8, 0),
|
||||
dtend=datetime(2026, 1, 15, 9, 0),
|
||||
)
|
||||
# L-0003 n'existe plus localement
|
||||
remote_lesson3 = _make_vevent(
|
||||
uid="L-0003",
|
||||
summary="Ancien cours",
|
||||
dtstart=datetime(2026, 1, 15, 8, 0),
|
||||
dtend=datetime(2026, 1, 15, 9, 0),
|
||||
)
|
||||
|
||||
remote_managed: list[tuple[str, Event]] = [
|
||||
("L-0002", remote_lesson2),
|
||||
("L-0003", remote_lesson3),
|
||||
]
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
# Ajouts
|
||||
assert len(plan.lessons_to_add) == 1
|
||||
assert plan.lessons_to_add[0].id == "L-0001"
|
||||
assert len(plan.homeworks_to_add) == 1
|
||||
assert plan.homeworks_to_add[0].id == "HW-0001"
|
||||
|
||||
# Mises à jour
|
||||
assert len(plan.lessons_to_update) == 1
|
||||
assert plan.lessons_to_update[0].id == "L-0002"
|
||||
|
||||
# Suppressions
|
||||
assert len(plan.lessons_to_remove) == 1
|
||||
assert plan.lessons_to_remove[0] == "L-0003"
|
||||
|
||||
|
||||
def test_unmanaged_events_not_in_remote_managed() -> None:
|
||||
"""Vérifie que remote_managed ne contient que des événements gérés.
|
||||
|
||||
Le contrat indique que remote_managed ne contient déjà que des événements
|
||||
marqués comme gérés. Le planner ne doit pas filtrer.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
lesson = _make_lesson(lesson_id="L-1234")
|
||||
pronote_data = _make_pronote_data(lessons=[lesson])
|
||||
|
||||
# remote_managed ne contient que des événements gérés (par hypothèse)
|
||||
# Donc pas besoin de tester le filtrage ici - c'est la responsabilité de list_managed_events
|
||||
remote_managed: list[tuple[str, Event]] = []
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
# Le cours doit être dans lessons_to_add
|
||||
assert len(plan.lessons_to_add) == 1
|
||||
|
||||
|
||||
# Ensure trailing newline
|
||||
379
tests/unit/test_caldav_security.py
Normal file
379
tests/unit/test_caldav_security.py
Normal file
@@ -0,0 +1,379 @@
|
||||
"""Tests de sécurité pour la passerelle CalDAV et sa configuration.
|
||||
|
||||
Ce module vérifie que les secrets (URL, mot de passe) ne fuient jamais dans
|
||||
les représentations textuelles, les logs, les messages d'erreur ou les
|
||||
chaînages d'exceptions de la configuration CalDAV et de la passerelle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr, ValidationError
|
||||
|
||||
from pronote_sync.config.settings import CalDAVSettings
|
||||
from pronote_sync.errors import PronoteSyncError
|
||||
from pronote_sync.sync.caldav import CalDAVGateway
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from _pytest.logging import LogCaptureFixture
|
||||
|
||||
|
||||
# Sentinelles pour détecter les fuites de secrets dans les tests
|
||||
SENTINEL_URL = "https://user:pass-super-secret-12345@caldav.example.com/secret-path/"
|
||||
SENTINEL_PASSWORD = "super-secret-password-67890"
|
||||
SENTINEL_HTTP_URL = "http://caldav.example.com/"
|
||||
SENTINEL_HTTP_LOCALHOST = "http://localhost:5232/caldav/"
|
||||
SENTINEL_HTTP_NON_LOOPBACK = "http://insecure.example.com/caldav/"
|
||||
|
||||
|
||||
class TestCalDAVSettingsSecurity:
|
||||
"""Tests de sécurité pour la configuration CalDAV (CalDAVSettings)."""
|
||||
|
||||
def test_url_redaction_in_repr(self) -> None:
|
||||
"""Vérifie que l'URL brute n'apparaît pas dans repr(settings)."""
|
||||
settings = CalDAVSettings(
|
||||
url=SecretStr(SENTINEL_URL),
|
||||
username="test-user",
|
||||
password=SecretStr(SENTINEL_PASSWORD),
|
||||
calendar_path="/cal/",
|
||||
)
|
||||
repr_str = repr(settings)
|
||||
assert SENTINEL_URL not in repr_str
|
||||
assert "pass-super-secret-12345" not in repr_str
|
||||
# L'URL est masquée par redact_url qui retourne REDACTED_URL ou une URL avec REDACTED
|
||||
assert "REDACTED" in repr_str or "**********" in repr_str
|
||||
|
||||
def test_url_redaction_in_str(self) -> None:
|
||||
"""Vérifie que l'URL brute n'apparaît pas dans str(settings)."""
|
||||
settings = CalDAVSettings(
|
||||
url=SecretStr(SENTINEL_URL),
|
||||
username="test-user",
|
||||
password=SecretStr(SENTINEL_PASSWORD),
|
||||
calendar_path="/cal/",
|
||||
)
|
||||
str_str = str(settings)
|
||||
assert SENTINEL_URL not in str_str
|
||||
assert "pass-super-secret-12345" not in str_str
|
||||
# L'URL est masquée par redact_url
|
||||
assert "REDACTED" in str_str or "**********" in str_str
|
||||
|
||||
def test_url_redaction_in_model_dump(self) -> None:
|
||||
"""Vérifie que l'URL brute n'apparaît pas dans model_dump()."""
|
||||
settings = CalDAVSettings(
|
||||
url=SecretStr(SENTINEL_URL),
|
||||
username="test-user",
|
||||
password=SecretStr(SENTINEL_PASSWORD),
|
||||
calendar_path="/cal/",
|
||||
)
|
||||
dumped = settings.model_dump()
|
||||
# Vérifie que l'URL n'est pas dans les valeurs du dict
|
||||
for value in dumped.values():
|
||||
if isinstance(value, str):
|
||||
assert SENTINEL_URL not in value
|
||||
assert "pass-super-secret-12345" not in value
|
||||
# Vérifie que la version rédigée est présente
|
||||
assert "REDACTED" in str(dumped)
|
||||
|
||||
def test_password_not_in_repr(self) -> None:
|
||||
"""Vérifie que le mot de passe n'apparaît pas dans repr(settings)."""
|
||||
settings = CalDAVSettings(
|
||||
url=SecretStr("https://caldav.example.com/"),
|
||||
username="test-user",
|
||||
password=SecretStr(SENTINEL_PASSWORD),
|
||||
calendar_path="/cal/",
|
||||
)
|
||||
repr_str = repr(settings)
|
||||
assert SENTINEL_PASSWORD not in repr_str
|
||||
assert "**********" in repr_str
|
||||
|
||||
def test_password_not_in_str(self) -> None:
|
||||
"""Vérifie que le mot de passe n'apparaît pas dans str(settings)."""
|
||||
settings = CalDAVSettings(
|
||||
url=SecretStr("https://caldav.example.com/"),
|
||||
username="test-user",
|
||||
password=SecretStr(SENTINEL_PASSWORD),
|
||||
calendar_path="/cal/",
|
||||
)
|
||||
str_str = str(settings)
|
||||
assert SENTINEL_PASSWORD not in str_str
|
||||
assert "**********" in str_str
|
||||
|
||||
def test_https_enforcement(self) -> None:
|
||||
"""Vérifie que HTTP (non-localhost) est rejeté par défaut."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
CalDAVSettings(
|
||||
url=SecretStr(SENTINEL_HTTP_URL),
|
||||
username="test-user",
|
||||
password=SecretStr(SENTINEL_PASSWORD),
|
||||
calendar_path="/cal/",
|
||||
)
|
||||
assert exc_info.value.error_count() >= 1
|
||||
|
||||
def test_https_accepted(self) -> None:
|
||||
"""Vérifie que HTTPS est accepté sans erreur."""
|
||||
settings = CalDAVSettings(
|
||||
url=SecretStr("https://caldav.example.com/"),
|
||||
username="test-user",
|
||||
password=SecretStr(SENTINEL_PASSWORD),
|
||||
calendar_path="/cal/",
|
||||
)
|
||||
assert settings.url is not None
|
||||
|
||||
def test_http_localhost_without_flag(self) -> None:
|
||||
"""Vérifie que HTTP localhost est rejeté sans allow_insecure_http."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
CalDAVSettings(
|
||||
url=SecretStr(SENTINEL_HTTP_LOCALHOST),
|
||||
username="test-user",
|
||||
password=SecretStr(SENTINEL_PASSWORD),
|
||||
calendar_path="/cal/",
|
||||
)
|
||||
assert exc_info.value.error_count() >= 1
|
||||
|
||||
def test_http_localhost_with_flag(self) -> None:
|
||||
"""Vérifie que HTTP localhost est accepté avec allow_insecure_http=True."""
|
||||
settings = CalDAVSettings(
|
||||
url=SecretStr(SENTINEL_HTTP_LOCALHOST),
|
||||
username="test-user",
|
||||
password=SecretStr(SENTINEL_PASSWORD),
|
||||
calendar_path="/cal/",
|
||||
allow_insecure_http=True,
|
||||
)
|
||||
assert settings.url is not None
|
||||
|
||||
def test_http_non_loopback_with_flag(self) -> None:
|
||||
"""Vérifie que HTTP non-loopback est rejeté même avec allow_insecure_http=True."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
CalDAVSettings(
|
||||
url=SecretStr(SENTINEL_HTTP_NON_LOOPBACK),
|
||||
username="test-user",
|
||||
password=SecretStr(SENTINEL_PASSWORD),
|
||||
calendar_path="/cal/",
|
||||
allow_insecure_http=True,
|
||||
)
|
||||
assert exc_info.value.error_count() >= 1
|
||||
|
||||
def test_validation_error_message_safe(self) -> None:
|
||||
"""Vérifie que les messages d'erreur de validation ne contiennent pas l'URL brute."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
CalDAVSettings(
|
||||
url=SecretStr(SENTINEL_HTTP_URL),
|
||||
username="test-user",
|
||||
password=SecretStr(SENTINEL_PASSWORD),
|
||||
calendar_path="/cal/",
|
||||
)
|
||||
error_str = str(exc_info.value)
|
||||
assert SENTINEL_HTTP_URL not in error_str
|
||||
assert "caldav.example.com" not in error_str
|
||||
|
||||
|
||||
class TestCalDAVGatewaySecurity:
|
||||
"""Tests de sécurité pour la passerelle CalDAV (CalDAVGateway)."""
|
||||
|
||||
def test_password_not_stored_in_plaintext(self) -> None:
|
||||
"""Vérifie que le mot de passe n'est pas stocké en clair sur l'instance."""
|
||||
settings = CalDAVSettings(
|
||||
url=SecretStr("https://caldav.example.com/"),
|
||||
username="test-user",
|
||||
password=SecretStr(SENTINEL_PASSWORD),
|
||||
calendar_path="/cal/",
|
||||
)
|
||||
gateway = CalDAVGateway(settings)
|
||||
# Vérifie que le mot de passe en clair n'est dans aucun attribut
|
||||
for attr_name in vars(gateway):
|
||||
attr_value = getattr(gateway, attr_name)
|
||||
if isinstance(attr_value, str):
|
||||
assert SENTINEL_PASSWORD not in attr_value
|
||||
elif isinstance(attr_value, SecretStr):
|
||||
# SecretStr peut contenir le secret, mais pas en clair
|
||||
assert SENTINEL_PASSWORD not in str(attr_value)
|
||||
|
||||
def test_error_messages_redacted(self, caplog: LogCaptureFixture) -> None:
|
||||
"""Vérifie que les messages d'erreur ne contiennent pas de secrets."""
|
||||
|
||||
def _leaky_client_factory(**kwargs: Any) -> None:
|
||||
# Utiliser un message d'erreur qui contient des secrets dans un format détectable
|
||||
raise Exception(f"Connection failed to {SENTINEL_URL}?token={SENTINEL_PASSWORD}")
|
||||
|
||||
settings = CalDAVSettings(
|
||||
url=SecretStr(SENTINEL_URL),
|
||||
username="test-user",
|
||||
password=SecretStr(SENTINEL_PASSWORD),
|
||||
calendar_path="/cal/",
|
||||
)
|
||||
gateway = CalDAVGateway(settings, client_factory=_leaky_client_factory)
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
with pytest.raises(PronoteSyncError) as exc_info:
|
||||
gateway.connect()
|
||||
|
||||
# Vérifie que le message d'erreur ne contient pas les sentinelles
|
||||
error_msg = str(exc_info.value)
|
||||
assert SENTINEL_URL not in error_msg
|
||||
assert SENTINEL_PASSWORD not in error_msg
|
||||
|
||||
# Vérifie que les logs ne contiennent pas les sentinelles
|
||||
for record in caplog.records:
|
||||
log_msg = record.getMessage()
|
||||
assert SENTINEL_URL not in log_msg
|
||||
assert SENTINEL_PASSWORD not in log_msg
|
||||
|
||||
def test_exception_cause_and_context_is_none(self) -> None:
|
||||
"""Vérifie que PronoteSyncError.__cause__ et __context__ sont None."""
|
||||
|
||||
def _leaky_client_factory(**kwargs: Any) -> None:
|
||||
raise Exception(f"Connection failed to {SENTINEL_URL}?token={SENTINEL_PASSWORD}")
|
||||
|
||||
settings = CalDAVSettings(
|
||||
url=SecretStr(SENTINEL_URL),
|
||||
username="test-user",
|
||||
password=SecretStr(SENTINEL_PASSWORD),
|
||||
calendar_path="/cal/",
|
||||
)
|
||||
gateway = CalDAVGateway(settings, client_factory=_leaky_client_factory)
|
||||
|
||||
with pytest.raises(PronoteSyncError) as exc_info:
|
||||
gateway.connect()
|
||||
|
||||
assert exc_info.value.__cause__ is None
|
||||
assert exc_info.value.__context__ is None
|
||||
|
||||
def test_logs_redacted_on_list_managed_events_error(self, caplog: LogCaptureFixture) -> None:
|
||||
"""Vérifie que les logs sont expurgés lors d'une erreur dans list_managed_events."""
|
||||
settings = CalDAVSettings(
|
||||
url=SecretStr(SENTINEL_URL),
|
||||
username="test-user",
|
||||
password=SecretStr(SENTINEL_PASSWORD),
|
||||
calendar_path="/cal/",
|
||||
)
|
||||
|
||||
# Créer un mock de client qui lève une exception avec des secrets
|
||||
mock_client = MagicMock()
|
||||
mock_principal = MagicMock()
|
||||
mock_calendar = MagicMock()
|
||||
mock_calendar.url = "https://caldav.example.com/cal/"
|
||||
mock_calendar.date_search.side_effect = Exception(
|
||||
f"Search failed at {SENTINEL_URL}?token={SENTINEL_PASSWORD}"
|
||||
)
|
||||
mock_principal.calendars.return_value = [mock_calendar]
|
||||
mock_client.principal.return_value = mock_principal
|
||||
|
||||
gateway = CalDAVGateway(settings, client_factory=lambda **kw: mock_client)
|
||||
gateway._client = mock_client
|
||||
gateway._calendar = mock_calendar
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
with pytest.raises(PronoteSyncError) as exc_info:
|
||||
gateway.list_managed_events(start=datetime(2026, 1, 15), end=datetime(2026, 1, 20))
|
||||
|
||||
# Vérifie que le message d'erreur ne contient pas les sentinelles
|
||||
error_msg = str(exc_info.value)
|
||||
assert SENTINEL_URL not in error_msg
|
||||
assert SENTINEL_PASSWORD not in error_msg
|
||||
|
||||
# Vérifie que les logs ne contiennent pas les sentinelles
|
||||
for record in caplog.records:
|
||||
log_msg = record.getMessage()
|
||||
assert SENTINEL_URL not in log_msg
|
||||
assert SENTINEL_PASSWORD not in log_msg
|
||||
|
||||
# Vérifie que l'exception n'est chaînée à aucune exception brute
|
||||
assert exc_info.value.__cause__ is None
|
||||
assert exc_info.value.__context__ is None
|
||||
|
||||
def test_logs_redacted_on_upsert_event_error(self, caplog: LogCaptureFixture) -> None:
|
||||
"""Vérifie que les logs sont expurgés lors d'une erreur dans upsert_event."""
|
||||
settings = CalDAVSettings(
|
||||
url=SecretStr(SENTINEL_URL),
|
||||
username="test-user",
|
||||
password=SecretStr(SENTINEL_PASSWORD),
|
||||
calendar_path="/cal/",
|
||||
)
|
||||
|
||||
# Créer un mock de calendrier dont la recherche par UID lève une
|
||||
# exception avec des secrets (simule une fuite de la bibliothèque caldav)
|
||||
mock_calendar = MagicMock()
|
||||
mock_calendar.get_event_by_uid.side_effect = Exception(
|
||||
f"Save failed at {SENTINEL_URL}?token={SENTINEL_PASSWORD}"
|
||||
)
|
||||
|
||||
gateway = CalDAVGateway(settings)
|
||||
gateway._calendar = mock_calendar
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
with pytest.raises(PronoteSyncError) as exc_info:
|
||||
gateway.upsert_event("BEGIN:VCALENDAR\nEND:VCALENDAR", "test-uid")
|
||||
|
||||
# Vérifie que le message d'erreur ne contient pas les sentinelles
|
||||
error_msg = str(exc_info.value)
|
||||
assert SENTINEL_URL not in error_msg
|
||||
assert SENTINEL_PASSWORD not in error_msg
|
||||
|
||||
# Vérifie que les logs ne contiennent pas les sentinelles
|
||||
for record in caplog.records:
|
||||
log_msg = record.getMessage()
|
||||
assert SENTINEL_URL not in log_msg
|
||||
assert SENTINEL_PASSWORD not in log_msg
|
||||
|
||||
# Vérifie que l'exception n'est chaînée à aucune exception brute
|
||||
assert exc_info.value.__cause__ is None
|
||||
assert exc_info.value.__context__ is None
|
||||
|
||||
def test_logs_redacted_on_delete_event_error(self, caplog: LogCaptureFixture) -> None:
|
||||
"""Vérifie que les logs sont expurgés lors d'une erreur dans delete_event."""
|
||||
settings = CalDAVSettings(
|
||||
url=SecretStr(SENTINEL_URL),
|
||||
username="test-user",
|
||||
password=SecretStr(SENTINEL_PASSWORD),
|
||||
calendar_path="/cal/",
|
||||
)
|
||||
|
||||
# Créer un mock de calendrier qui lève une exception avec des secrets
|
||||
mock_calendar = MagicMock()
|
||||
mock_calendar.get_event_by_uid.side_effect = Exception(
|
||||
f"Delete failed at {SENTINEL_URL}?token={SENTINEL_PASSWORD}"
|
||||
)
|
||||
|
||||
gateway = CalDAVGateway(settings)
|
||||
gateway._calendar = mock_calendar
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
with pytest.raises(PronoteSyncError) as exc_info:
|
||||
gateway.delete_event("test-uid")
|
||||
|
||||
# Vérifie que le message d'erreur ne contient pas les sentinelles
|
||||
error_msg = str(exc_info.value)
|
||||
assert SENTINEL_URL not in error_msg
|
||||
assert SENTINEL_PASSWORD not in error_msg
|
||||
|
||||
# Vérifie que les logs ne contiennent pas les sentinelles
|
||||
for record in caplog.records:
|
||||
log_msg = record.getMessage()
|
||||
assert SENTINEL_URL not in log_msg
|
||||
assert SENTINEL_PASSWORD not in log_msg
|
||||
|
||||
# Vérifie que l'exception n'est chaînée à aucune exception brute
|
||||
assert exc_info.value.__cause__ is None
|
||||
assert exc_info.value.__context__ is None
|
||||
|
||||
def test_redacted_url_stored_in_gateway(self) -> None:
|
||||
"""Vérifie que l'URL rédigée est stockée sur l'instance de la passerelle."""
|
||||
settings = CalDAVSettings(
|
||||
url=SecretStr(SENTINEL_URL),
|
||||
username="test-user",
|
||||
password=SecretStr(SENTINEL_PASSWORD),
|
||||
calendar_path="/cal/",
|
||||
)
|
||||
gateway = CalDAVGateway(settings)
|
||||
# Vérifie que l'URL rédigée est stockée
|
||||
assert gateway._redacted_url is not None
|
||||
assert SENTINEL_URL not in gateway._redacted_url
|
||||
assert "REDACTED" in gateway._redacted_url
|
||||
# Vérifie que l'URL brute n'est pas stockée en clair
|
||||
assert gateway._url_secret is not None
|
||||
assert SENTINEL_URL not in str(gateway._url_secret)
|
||||
488
tests/unit/test_sync_serialization.py
Normal file
488
tests/unit/test_sync_serialization.py
Normal file
@@ -0,0 +1,488 @@
|
||||
"""Tests unitaires pour la sérialisation des modèles Pronote en VEVENT iCalendar.
|
||||
|
||||
Ce module vérifie que les fonctions de conversion des modèles Pronote
|
||||
(:class:`Lesson`, :class:`Homework`, :class:`SchoolEvent`) en composants
|
||||
:class:`icalendar.Event` produisent les propriétés attendues (UID, SUMMARY,
|
||||
DTSTART, DTEND, STATUS, CATEGORIES, etc.) et que la signature sémantique
|
||||
déterministe est correctement calculée.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from icalendar import Calendar, Event
|
||||
|
||||
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent, SchoolEventKind
|
||||
from pronote_sync.models.homework import Homework
|
||||
from pronote_sync.sync.serialization import (
|
||||
MANAGED_PROPERTY,
|
||||
MANAGED_VALUE,
|
||||
component_to_signature,
|
||||
homework_to_vevent,
|
||||
lesson_to_vevent,
|
||||
model_to_vcalendar_text,
|
||||
school_event_to_vevent,
|
||||
)
|
||||
|
||||
# --- Helper fixtures ---
|
||||
|
||||
|
||||
def _make_lesson(
|
||||
lesson_id: str = "L-1234",
|
||||
subject: str = "Mathématiques",
|
||||
start: datetime | None = None,
|
||||
end: datetime | None = None,
|
||||
status: LessonStatus = LessonStatus.NORMAL,
|
||||
teachers: tuple[str, ...] = ("Prof Dupont",),
|
||||
rooms: tuple[str, ...] = ("Salle 101",),
|
||||
content: str | None = None,
|
||||
group: str | None = None,
|
||||
) -> Lesson:
|
||||
"""Fabrique un cours Pronote pour les tests.
|
||||
|
||||
:param lesson_id: Identifiant du cours.
|
||||
:param subject: Matière.
|
||||
:param start: Date/heure de début.
|
||||
:param end: Date/heure de fin.
|
||||
:param status: Statut du cours.
|
||||
:param teachers: Professeurs.
|
||||
:param rooms: Salles.
|
||||
:param content: Contenu pédagogique.
|
||||
:param group: Groupe.
|
||||
:return: Instance de Lesson.
|
||||
:rtype: Lesson
|
||||
"""
|
||||
if start is None:
|
||||
start = datetime(2026, 1, 15, 8, 0)
|
||||
if end is None:
|
||||
end = datetime(2026, 1, 15, 9, 0)
|
||||
return Lesson(
|
||||
id=lesson_id,
|
||||
start=start,
|
||||
end=end,
|
||||
subject=subject,
|
||||
teachers=teachers,
|
||||
rooms=rooms,
|
||||
status=status,
|
||||
content=content,
|
||||
group=group,
|
||||
)
|
||||
|
||||
|
||||
def _make_homework(
|
||||
homework_id: str = "HW-5678",
|
||||
subject: str = "Mathématiques",
|
||||
due_on: date | None = None,
|
||||
text: str = "Exercice 1 à 5",
|
||||
teachers: tuple[str, ...] = ("Prof Dupont",),
|
||||
assigned_on: date | None = None,
|
||||
) -> Homework:
|
||||
"""Fabrique un devoir Pronote pour les tests.
|
||||
|
||||
:param homework_id: Identifiant du devoir.
|
||||
:param subject: Matière.
|
||||
:param due_on: Date d'échéance.
|
||||
:param text: Texte du devoir.
|
||||
:param teachers: Professeurs.
|
||||
:param assigned_on: Date de distribution.
|
||||
:return: Instance de Homework.
|
||||
:rtype: Homework
|
||||
"""
|
||||
if due_on is None:
|
||||
due_on = date(2026, 1, 20)
|
||||
return Homework(
|
||||
id=homework_id,
|
||||
subject=subject,
|
||||
due_on=due_on,
|
||||
text=text,
|
||||
teachers=teachers,
|
||||
assigned_on=assigned_on,
|
||||
)
|
||||
|
||||
|
||||
def _make_school_event(
|
||||
label: str = "Vacances de Noël",
|
||||
from_date: date | None = None,
|
||||
to_date: date | None = None,
|
||||
kind: SchoolEventKind = SchoolEventKind.HOLIDAY,
|
||||
) -> SchoolEvent:
|
||||
"""Fabrique un événement scolaire pour les tests.
|
||||
|
||||
:param label: Libellé de l'événement.
|
||||
:param from_date: Date de début.
|
||||
:param to_date: Date de fin.
|
||||
:param kind: Type d'événement.
|
||||
:return: Instance de SchoolEvent.
|
||||
:rtype: SchoolEvent
|
||||
"""
|
||||
if from_date is None:
|
||||
from_date = date(2026, 12, 20)
|
||||
if to_date is None:
|
||||
to_date = date(2027, 1, 5)
|
||||
return SchoolEvent(
|
||||
label=label,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
kind=kind,
|
||||
)
|
||||
|
||||
|
||||
# --- lesson_to_vevent tests ---
|
||||
|
||||
|
||||
def test_model_to_vcalendar_text_produces_complete_vcalendar() -> None:
|
||||
"""Vérifie que la sérialisation produit un VCALENDAR complet avec un VEVENT.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
lesson = _make_lesson()
|
||||
output = model_to_vcalendar_text(lesson)
|
||||
calendar = Calendar.from_ical(output)
|
||||
|
||||
assert "VERSION:2.0" in output
|
||||
assert "PRODID" in output
|
||||
assert output.startswith("BEGIN:VCALENDAR")
|
||||
|
||||
vevents = calendar.walk("VEVENT")
|
||||
assert len(vevents) == 1
|
||||
|
||||
|
||||
def test_lesson_to_vevent_normal() -> None:
|
||||
"""Vérifie qu'un cours normal produit un VEVENT avec les bonnes propriétés.
|
||||
|
||||
Un cours normal doit avoir :
|
||||
- UID = id du cours
|
||||
- SUMMARY = matière
|
||||
- DTSTART/DTEND = dates de début/fin
|
||||
- STATUS = CONFIRMED
|
||||
- CATEGORIES contient "Pronote"
|
||||
- MANAGED_PROPERTY présent avec MANAGED_VALUE
|
||||
"""
|
||||
lesson = _make_lesson()
|
||||
event = lesson_to_vevent(lesson)
|
||||
|
||||
assert str(event.get("UID")) == "L-1234"
|
||||
assert str(event.get("SUMMARY")) == "Mathématiques"
|
||||
assert event.get("DTSTART").dt == datetime(2026, 1, 15, 8, 0)
|
||||
assert event.get("DTEND").dt == datetime(2026, 1, 15, 9, 0)
|
||||
assert str(event.get("STATUS")) == "CONFIRMED"
|
||||
|
||||
categories = event.get("CATEGORIES")
|
||||
assert categories is not None
|
||||
assert "Pronote" in categories.cats
|
||||
|
||||
assert str(event.get(MANAGED_PROPERTY)) == MANAGED_VALUE
|
||||
|
||||
|
||||
def test_lesson_to_vevent_cancelled() -> None:
|
||||
"""Vérifie qu'un cours annulé a STATUS=CANCELLED et CATEGORIES contient 'Annulé'.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
lesson = _make_lesson(status=LessonStatus.CANCELLED)
|
||||
event = lesson_to_vevent(lesson)
|
||||
|
||||
assert str(event.get("STATUS")) == "CANCELLED"
|
||||
categories = event.get("CATEGORIES")
|
||||
assert categories is not None
|
||||
assert "Pronote" in categories.cats
|
||||
assert "Annulé" in categories.cats
|
||||
|
||||
|
||||
def test_lesson_to_vevent_moved() -> None:
|
||||
"""Vérifie qu'un cours déplacé a CATEGORIES contient 'Déplacé'.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
lesson = _make_lesson(status=LessonStatus.MOVED)
|
||||
event = lesson_to_vevent(lesson)
|
||||
|
||||
assert str(event.get("STATUS")) == "CONFIRMED"
|
||||
categories = event.get("CATEGORIES")
|
||||
assert categories is not None
|
||||
assert "Pronote" in categories.cats
|
||||
assert "Déplacé" in categories.cats
|
||||
|
||||
|
||||
def test_lesson_to_vevent_description() -> None:
|
||||
"""Vérifie que la description contient tous les champs renseignés.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
lesson = _make_lesson(
|
||||
subject="Mathématiques",
|
||||
teachers=("Prof Dupont", "Prof Martin"),
|
||||
rooms=("Salle 101", "Salle 102"),
|
||||
content="Chapitre 1",
|
||||
)
|
||||
event = lesson_to_vevent(lesson)
|
||||
description = str(event.get("DESCRIPTION"))
|
||||
|
||||
assert "Matière: Mathématiques" in description
|
||||
assert "Professeur(s): Prof Dupont, Prof Martin" in description
|
||||
assert "Salle(s): Salle 101, Salle 102" in description
|
||||
assert "Contenu: Chapitre 1" in description
|
||||
|
||||
|
||||
# --- homework_to_vevent tests ---
|
||||
|
||||
|
||||
def test_homework_to_vevent_uid_prefix() -> None:
|
||||
"""Vérifie que l'UID d'un devoir est préfixé par 'homework-'.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
homework = _make_homework()
|
||||
event = homework_to_vevent(homework)
|
||||
|
||||
assert str(event.get("UID")) == "homework-HW-5678"
|
||||
|
||||
|
||||
def test_homework_to_vevent_status() -> None:
|
||||
"""Vérifie qu'un devoir a STATUS=NEEDS-ACTION.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
homework = _make_homework()
|
||||
event = homework_to_vevent(homework)
|
||||
|
||||
assert str(event.get("STATUS")) == "NEEDS-ACTION"
|
||||
|
||||
|
||||
def test_homework_to_vevent_categories() -> None:
|
||||
"""Vérifie que les CATEGORIES d'un devoir contiennent 'Pronote' et 'Devoir'.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
homework = _make_homework()
|
||||
event = homework_to_vevent(homework)
|
||||
|
||||
categories = event.get("CATEGORIES")
|
||||
assert categories is not None
|
||||
assert "Pronote" in categories.cats
|
||||
assert "Devoir" in categories.cats
|
||||
|
||||
|
||||
def test_homework_to_vevent_dtstart_dtend() -> None:
|
||||
"""Vérifie que DTSTART et DTEND couvrent la journée d'échéance (08:00-18:00).
|
||||
|
||||
:return: None
|
||||
"""
|
||||
homework = _make_homework(due_on=date(2026, 1, 20))
|
||||
event = homework_to_vevent(homework)
|
||||
|
||||
assert event.get("DTSTART").dt == datetime(2026, 1, 20, 8, 0)
|
||||
assert event.get("DTEND").dt == datetime(2026, 1, 20, 18, 0)
|
||||
|
||||
|
||||
def test_homework_to_vevent_summary() -> None:
|
||||
"""Vérifie que le SUMMARY d'un devoir est préfixé par 'Devoir: '.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
homework = _make_homework(subject="Mathématiques")
|
||||
event = homework_to_vevent(homework)
|
||||
|
||||
assert str(event.get("SUMMARY")) == "Devoir: Mathématiques"
|
||||
|
||||
|
||||
def test_homework_to_vevent_managed_marker() -> None:
|
||||
"""Vérifie que le marqueur MANAGED_PROPERTY est présent.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
homework = _make_homework()
|
||||
event = homework_to_vevent(homework)
|
||||
|
||||
assert str(event.get(MANAGED_PROPERTY)) == MANAGED_VALUE
|
||||
|
||||
|
||||
# --- school_event_to_vevent tests ---
|
||||
|
||||
|
||||
def test_school_event_to_vevent_uid_prefix() -> None:
|
||||
"""Vérifie que l'UID d'un événement scolaire est préfixé correctement.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
school_event = _make_school_event(
|
||||
label="Vacances de Noël",
|
||||
from_date=date(2026, 12, 20),
|
||||
)
|
||||
event = school_event_to_vevent(school_event)
|
||||
|
||||
assert str(event.get("UID")) == "school-event-Vacances de Noël-2026-12-20"
|
||||
|
||||
|
||||
def test_school_event_to_vevent_status() -> None:
|
||||
"""Vérifie qu'un événement scolaire a STATUS=CONFIRMED.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
school_event = _make_school_event()
|
||||
event = school_event_to_vevent(school_event)
|
||||
|
||||
assert str(event.get("STATUS")) == "CONFIRMED"
|
||||
|
||||
|
||||
def test_school_event_to_vevent_categories() -> None:
|
||||
"""Vérifie que les CATEGORIES contiennent 'Pronote' et la valeur du kind.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
school_event = _make_school_event(kind=SchoolEventKind.HOLIDAY)
|
||||
event = school_event_to_vevent(school_event)
|
||||
|
||||
categories = event.get("CATEGORIES")
|
||||
assert categories is not None
|
||||
assert "Pronote" in categories.cats
|
||||
assert "holiday" in categories.cats
|
||||
|
||||
|
||||
def test_school_event_to_vevent_dtstart_dtend() -> None:
|
||||
"""Vérifie que DTSTART et DTEND sont des vDate (pas vDatetime).
|
||||
|
||||
:return: None
|
||||
"""
|
||||
school_event = _make_school_event(
|
||||
from_date=date(2026, 12, 20),
|
||||
to_date=date(2027, 1, 5),
|
||||
)
|
||||
event = school_event_to_vevent(school_event)
|
||||
|
||||
from icalendar import vDate
|
||||
|
||||
assert isinstance(event.get("DTSTART"), vDate)
|
||||
assert isinstance(event.get("DTEND"), vDate)
|
||||
assert event.get("DTSTART").dt == date(2026, 12, 20)
|
||||
assert event.get("DTEND").dt == date(2027, 1, 5)
|
||||
|
||||
|
||||
def test_school_event_to_vevent_managed_marker() -> None:
|
||||
"""Vérifie que le marqueur MANAGED_PROPERTY est présent.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
school_event = _make_school_event()
|
||||
event = school_event_to_vevent(school_event)
|
||||
|
||||
assert str(event.get(MANAGED_PROPERTY)) == MANAGED_VALUE
|
||||
|
||||
|
||||
# --- component_to_signature tests ---
|
||||
|
||||
|
||||
def test_component_to_signature_ignores_volatile_properties() -> None:
|
||||
"""Vérifie que deux VEVENTs ne différant que par DTSTAMP/CREATED/LAST-MODIFIED/SEQUENCE
|
||||
produisent la même signature.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
# Créer deux événements identiques sauf pour les propriétés volatiles
|
||||
event1 = Event()
|
||||
event1.add("UID", "test-uid")
|
||||
event1.add("SUMMARY", "Test Event")
|
||||
event1.add("DTSTART", datetime(2026, 1, 15, 8, 0))
|
||||
event1.add("DTEND", datetime(2026, 1, 15, 9, 0))
|
||||
event1.add("STATUS", "CONFIRMED")
|
||||
event1.add("DTSTAMP", datetime(2026, 1, 1, 0, 0)) # Différent
|
||||
event1.add("CREATED", datetime(2026, 1, 1, 0, 0)) # Différent
|
||||
|
||||
event2 = Event()
|
||||
event2.add("UID", "test-uid")
|
||||
event2.add("SUMMARY", "Test Event")
|
||||
event2.add("DTSTART", datetime(2026, 1, 15, 8, 0))
|
||||
event2.add("DTEND", datetime(2026, 1, 15, 9, 0))
|
||||
event2.add("STATUS", "CONFIRMED")
|
||||
event2.add("DTSTAMP", datetime(2026, 1, 2, 0, 0)) # Différent
|
||||
event2.add("LAST-MODIFIED", datetime(2026, 1, 2, 0, 0)) # Différent
|
||||
event2.add("SEQUENCE", 1) # Différent
|
||||
|
||||
sig1 = component_to_signature(event1)
|
||||
sig2 = component_to_signature(event2)
|
||||
|
||||
assert sig1 == sig2
|
||||
|
||||
|
||||
def test_component_to_signature_different_summary() -> None:
|
||||
"""Vérifie que deux VEVENTs avec SUMMARY différent produisent des signatures différentes.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
event1 = Event()
|
||||
event1.add("UID", "test-uid")
|
||||
event1.add("SUMMARY", "Event 1")
|
||||
event1.add("DTSTART", datetime(2026, 1, 15, 8, 0))
|
||||
event1.add("DTEND", datetime(2026, 1, 15, 9, 0))
|
||||
event1.add("STATUS", "CONFIRMED")
|
||||
|
||||
event2 = Event()
|
||||
event2.add("UID", "test-uid")
|
||||
event2.add("SUMMARY", "Event 2") # Différent
|
||||
event2.add("DTSTART", datetime(2026, 1, 15, 8, 0))
|
||||
event2.add("DTEND", datetime(2026, 1, 15, 9, 0))
|
||||
event2.add("STATUS", "CONFIRMED")
|
||||
|
||||
sig1 = component_to_signature(event1)
|
||||
sig2 = component_to_signature(event2)
|
||||
|
||||
assert sig1 != sig2
|
||||
|
||||
|
||||
def test_component_to_signature_deterministic() -> None:
|
||||
"""Vérifie que la signature est déterministe (même entrée → même sortie).
|
||||
|
||||
:return: None
|
||||
"""
|
||||
event = Event()
|
||||
event.add("UID", "test-uid")
|
||||
event.add("SUMMARY", "Test Event")
|
||||
event.add("DTSTART", datetime(2026, 1, 15, 8, 0))
|
||||
event.add("DTEND", datetime(2026, 1, 15, 9, 0))
|
||||
event.add("STATUS", "CONFIRMED")
|
||||
|
||||
sig1 = component_to_signature(event)
|
||||
sig2 = component_to_signature(event)
|
||||
|
||||
assert sig1 == sig2
|
||||
|
||||
|
||||
def test_component_to_signature_includes_categories() -> None:
|
||||
"""Vérifie que les CATEGORIES sont incluses dans la signature (triées).
|
||||
|
||||
:return: None
|
||||
"""
|
||||
event = Event()
|
||||
event.add("UID", "test-uid")
|
||||
event.add("SUMMARY", "Test Event")
|
||||
event.add("DTSTART", datetime(2026, 1, 15, 8, 0))
|
||||
event.add("DTEND", datetime(2026, 1, 15, 9, 0))
|
||||
event.add("STATUS", "CONFIRMED")
|
||||
event.add("CATEGORIES", ["Pronote", "Devoir"])
|
||||
|
||||
sig = component_to_signature(event)
|
||||
|
||||
# Les catégories doivent apparaître dans la signature
|
||||
assert "categories=devoir,pronote" in sig
|
||||
|
||||
|
||||
def test_component_to_signature_includes_managed_property() -> None:
|
||||
"""Vérifie que MANAGED_PROPERTY est incluse dans la signature.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
event = Event()
|
||||
event.add("UID", "test-uid")
|
||||
event.add("SUMMARY", "Test Event")
|
||||
event.add("DTSTART", datetime(2026, 1, 15, 8, 0))
|
||||
event.add("DTEND", datetime(2026, 1, 15, 9, 0))
|
||||
event.add("STATUS", "CONFIRMED")
|
||||
event.add(MANAGED_PROPERTY, MANAGED_VALUE)
|
||||
|
||||
sig = component_to_signature(event)
|
||||
|
||||
assert f"managed={MANAGED_VALUE.lower()}" in sig
|
||||
|
||||
|
||||
# Ensure trailing newline
|
||||
189
tests/unit/test_uid_equivalence.py
Normal file
189
tests/unit/test_uid_equivalence.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""Tests unitaires pour l'équivalence des UIDs entre les sources iCal et pronotepy.
|
||||
|
||||
Ce module vérifie que les fonctions de normalisation et de génération
|
||||
d'UIDs produisent les mêmes résultats pour les mêmes données, garantissant
|
||||
qu'aucun doublon ou suppression artificielle ne se produit lors du
|
||||
changement de source.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from icalendar import Calendar
|
||||
|
||||
from pronote_sync.utils.uid import generate_deterministic_uid, normalize_pronote_uid
|
||||
|
||||
# --- Test 1: normalize_pronote_uid strips temporal suffix ---
|
||||
|
||||
|
||||
def test_normalize_pronote_uid_strips_temporal_suffix() -> None:
|
||||
"""Vérifie que normalize_pronote_uid produit un UID canonique stable.
|
||||
|
||||
Un UID brut Pronote avec suffixe temporel comme
|
||||
``L-12345-20260905T120000Z-Index-Education`` doit être normalisé en
|
||||
``L-12345``.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
raw_uid = "L-12345-20260905T120000Z-Index-Education"
|
||||
normalized = normalize_pronote_uid(raw_uid)
|
||||
assert normalized == "L-12345"
|
||||
|
||||
|
||||
def test_normalize_pronote_uid_strips_education_suffix_only() -> None:
|
||||
"""Vérifie que normalize_pronote_uid retire aussi le suffixe -Index-Education seul.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
raw_uid = "L-12345-Index-Education"
|
||||
normalized = normalize_pronote_uid(raw_uid)
|
||||
assert normalized == "L-12345"
|
||||
|
||||
|
||||
def test_normalize_pronote_uid_idempotent() -> None:
|
||||
"""Vérifie que normalize_pronote_uid est idempotente.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
raw_uid = "L-12345"
|
||||
normalized = normalize_pronote_uid(raw_uid)
|
||||
assert normalized == "L-12345"
|
||||
|
||||
|
||||
# --- Test 2: generate_deterministic_uid is stable ---
|
||||
|
||||
|
||||
def test_generate_deterministic_uid_is_stable() -> None:
|
||||
"""Vérifie que generate_deterministic_uid produit le même UID pour les mêmes entrées.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
start = datetime(2026, 9, 5, 8, 0, 0)
|
||||
end = datetime(2026, 9, 5, 9, 0, 0)
|
||||
subject = "Mathématiques"
|
||||
teachers = ["M. Dupont"]
|
||||
rooms = ["204"]
|
||||
group = "Classe entière"
|
||||
|
||||
uid1 = generate_deterministic_uid(
|
||||
start=start, end=end, subject=subject, teachers=teachers, rooms=rooms, group=group
|
||||
)
|
||||
uid2 = generate_deterministic_uid(
|
||||
start=start, end=end, subject=subject, teachers=teachers, rooms=rooms, group=group
|
||||
)
|
||||
|
||||
assert uid1 == uid2
|
||||
assert len(uid1) == 12
|
||||
assert all(c in "0123456789abcdef" for c in uid1)
|
||||
|
||||
|
||||
def test_generate_deterministic_uid_ignores_order() -> None:
|
||||
"""Vérifie que l'ordre des enseignants/salles n'affecte pas le résultat.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
start = datetime(2026, 9, 5, 8, 0, 0)
|
||||
end = datetime(2026, 9, 5, 9, 0, 0)
|
||||
subject = "Mathématiques"
|
||||
|
||||
uid1 = generate_deterministic_uid(
|
||||
start=start,
|
||||
end=end,
|
||||
subject=subject,
|
||||
teachers=["M. Dupont", "Mme Martin"],
|
||||
rooms=["204", "205"],
|
||||
group=None,
|
||||
)
|
||||
uid2 = generate_deterministic_uid(
|
||||
start=start,
|
||||
end=end,
|
||||
subject=subject,
|
||||
teachers=["Mme Martin", "M. Dupont"],
|
||||
rooms=["205", "204"],
|
||||
group=None,
|
||||
)
|
||||
|
||||
assert uid1 == uid2
|
||||
|
||||
|
||||
# --- Test 3: iCal and pronotepy produce same UID with raw UID ---
|
||||
|
||||
|
||||
def test_ical_and_pronotepy_produce_same_uid_with_raw_uid() -> None:
|
||||
"""Vérifie que iCal et pronotepy produisent le même UID avec un UID brut.
|
||||
|
||||
Comme les deux sources utilisent normalize_pronote_uid, le résultat doit
|
||||
être identique.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
raw_uid = "Edt_12345@index-education.net-20260905T120000Z-Index-Education"
|
||||
|
||||
# Simulation du chemin iCal
|
||||
ical_normalized = normalize_pronote_uid(raw_uid)
|
||||
|
||||
# Simulation du chemin pronotepy
|
||||
pronotepy_normalized = normalize_pronote_uid(raw_uid)
|
||||
|
||||
assert ical_normalized == pronotepy_normalized
|
||||
assert ical_normalized == "Edt_12345@index-education.net"
|
||||
|
||||
|
||||
# --- Test 4: iCal and pronotepy produce same UID with fallback ---
|
||||
|
||||
|
||||
def test_ical_and_pronotepy_produce_same_uid_with_fallback() -> None:
|
||||
"""Vérifie que iCal et pronotepy produisent le même UID déterministe en l'absence d'UID brut.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
start = datetime(2026, 9, 5, 8, 0, 0)
|
||||
end = datetime(2026, 9, 5, 9, 0, 0)
|
||||
subject = "Mathématiques"
|
||||
teachers = ["M. Dupont"]
|
||||
rooms = ["204"]
|
||||
group = "Classe entière"
|
||||
|
||||
# Simulation du chemin iCal (parse_ical utilise generate_deterministic_uid)
|
||||
ical_uid = generate_deterministic_uid(
|
||||
start=start, end=end, subject=subject, teachers=teachers, rooms=rooms, group=group
|
||||
)
|
||||
|
||||
# Simulation du chemin pronotepy (get_lessons utilise generate_deterministic_uid)
|
||||
pronotepy_uid = generate_deterministic_uid(
|
||||
start=start, end=end, subject=subject, teachers=teachers, rooms=rooms, group=group
|
||||
)
|
||||
|
||||
assert ical_uid == pronotepy_uid
|
||||
|
||||
|
||||
# --- Test 5: UID from iCal fixture is normalized ---
|
||||
|
||||
|
||||
def test_uid_from_ical_fixture_is_normalized() -> None:
|
||||
"""Vérifie que les UIDs extraits du fixture pronote-4e.ics sont normalisés correctement.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
|
||||
with open(fixture_path, "rb") as f:
|
||||
cal = Calendar.from_ical(f.read())
|
||||
|
||||
normalized_uids = []
|
||||
for component in cal.walk("VEVENT"):
|
||||
raw_uid = str(component.get("UID"))
|
||||
normalized = normalize_pronote_uid(raw_uid)
|
||||
normalized_uids.append(normalized)
|
||||
|
||||
# Vérifie que tous les UIDs sont normalisés (plus courts que les UIDs bruts)
|
||||
assert len(normalized_uids) >= 2
|
||||
|
||||
# Vérifie que les UIDs normalisés ne contiennent pas de suffixes temporels
|
||||
for uid in normalized_uids:
|
||||
assert "-Index-Education" not in uid
|
||||
assert "-2026" not in uid # Pas de suffixe temporel
|
||||
|
||||
# Vérifie que le premier UID est bien normalisé
|
||||
assert "Edt_12345@index-education.net" in normalized_uids
|
||||
Reference in New Issue
Block a user