diff --git a/pronote_sync/models/agenda.py b/pronote_sync/models/agenda.py index 7f2e08f..0584d56 100644 --- a/pronote_sync/models/agenda.py +++ b/pronote_sync/models/agenda.py @@ -8,7 +8,7 @@ from datetime import time as _time from enum import StrEnum from typing import Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator class Status(StrEnum): @@ -30,6 +30,8 @@ class LessonStatus(StrEnum): class HomeworkBlock(BaseModel): """Représente un bloc de devoir extrait de la description d'un cours.""" + model_config = ConfigDict(frozen=True) + kind: Literal["due", "assigned"] = Field( ..., description="Type de bloc (échéance ou attribution)" ) @@ -47,16 +49,28 @@ class Lesson(BaseModel): start: datetime = Field(..., description="Date/heure de début") end: datetime = Field(..., description="Date/heure de fin") subject: str = Field(..., description="Matière") - teachers: list[str] = Field(default_factory=list, description="Liste des professeurs") - rooms: list[str] = Field(default_factory=list, description="Liste des salles") + teachers: tuple[str, ...] = Field(default=(), description="Liste des professeurs") + rooms: tuple[str, ...] = Field(default=(), description="Liste des salles") group: str | None = Field(None, description="Groupe (ex: Classe entière)") status: LessonStatus = Field(default=LessonStatus.NORMAL, description="Statut du cours") content: str | None = Field(None, description="Contenu pédagogique") - homework_blocks: list[HomeworkBlock] = Field( - default_factory=list, + homework_blocks: tuple[HomeworkBlock, ...] = Field( + default=(), description="Blocs de devoirs extraits de la description", ) + @model_validator(mode="after") + def _validate_time_order(self) -> Lesson: + """Valide que l'heure de fin est postérieure à l'heure de début. + + :return: L'instance validée. + :rtype: Lesson + :raises ValueError: Si ``end`` n'est pas postérieur à ``start``. + """ + if self.end <= self.start: + raise ValueError("end doit être postérieur à start") + return self + class SchoolEventKind(StrEnum): """Type d'événement scolaire.""" @@ -75,6 +89,18 @@ class SchoolEvent(BaseModel): from_date: _date = Field(..., description="Date de début (inclusive)") to_date: _date = Field(..., description="Date de fin (exclusive)") + @model_validator(mode="after") + def _validate_date_order(self) -> SchoolEvent: + """Valide que la date de fin est postérieure ou égale à la date de début. + + :return: L'instance validée. + :rtype: SchoolEvent + :raises ValueError: Si ``to_date`` est antérieur à ``from_date``. + """ + if self.to_date < self.from_date: + raise ValueError("to_date doit être postérieur ou égal à from_date") + return self + class TheoreticalLesson(BaseModel): """Représente un cours dans l'agenda théorique.""" @@ -82,9 +108,23 @@ class TheoreticalLesson(BaseModel): model_config = ConfigDict(frozen=True) id: str = Field(..., description="Identifiant unique") - day_of_week: int = Field(..., description="Jour de la semaine (0=lundi, 6=dimanche)") + day_of_week: int = Field( + ..., ge=0, le=6, description="Jour de la semaine (0=lundi, 6=dimanche)" + ) start_time: _time = Field(..., description="Heure de début") end_time: _time = Field(..., description="Heure de fin") subject: str = Field(..., description="Matière") - teachers: list[str] = Field(default_factory=list, description="Liste des professeurs") - rooms: list[str] = Field(default_factory=list, description="Liste des salles") + teachers: tuple[str, ...] = Field(default=(), description="Liste des professeurs") + rooms: tuple[str, ...] = Field(default=(), description="Liste des salles") + + @model_validator(mode="after") + def _validate_time_order(self) -> TheoreticalLesson: + """Valide que l'heure de fin est postérieure à l'heure de début. + + :return: L'instance validée. + :rtype: TheoreticalLesson + :raises ValueError: Si ``end_time`` n'est pas postérieur à ``start_time``. + """ + if self.end_time <= self.start_time: + raise ValueError("end_time doit être postérieur à start_time") + return self diff --git a/pronote_sync/models/blog.py b/pronote_sync/models/blog.py index b4d6db8..58909ca 100644 --- a/pronote_sync/models/blog.py +++ b/pronote_sync/models/blog.py @@ -26,17 +26,22 @@ class BlogArticle(BaseModel): class ExternalInfo(BaseModel): - """Agrège les informations externes (blog, messages Pronote) pour le message XMPP.""" + """Agrège les informations externes (blog, messages Pronote) pour le message XMPP. - blog_articles: list[BlogArticle] = Field( - default_factory=list, + Modèle figé (``frozen``) : les instances sont immuables après création. + """ + + model_config = ConfigDict(frozen=True) + + blog_articles: tuple[BlogArticle, ...] = Field( + default=(), description="Liste des nouveaux articles du blog", ) - pronote_messages: list[Message] = Field( - default_factory=list, + pronote_messages: tuple[Message, ...] = Field( + default=(), description="Liste des messages Pronote", ) - other_info: list[str] = Field( - default_factory=list, + other_info: tuple[str, ...] = Field( + default=(), description="Autres informations (extensible)", ) diff --git a/pronote_sync/models/diff.py b/pronote_sync/models/diff.py index e508eb8..0cf6262 100644 --- a/pronote_sync/models/diff.py +++ b/pronote_sync/models/diff.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import date from enum import StrEnum -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from pronote_sync.models.agenda import Lesson, TheoreticalLesson @@ -30,6 +30,22 @@ class AgendaChange(BaseModel): ) details: str = Field(default="", description="Détails du changement") + @model_validator(mode="after") + def _validate_payload_consistency(self) -> AgendaChange: + """Valide la cohérence entre le type de changement et le payload. + + :return: L'instance validée. + :rtype: AgendaChange + :raises ValueError: Si le payload ne correspond pas au type de changement. + """ + if self.type in (AgendaChangeType.ADDED, AgendaChangeType.MODIFIED): + if self.lesson is None: + raise ValueError(f"lesson est requis pour le type {self.type!r}") + if self.type == AgendaChangeType.REMOVED: + if self.theoretical_lesson is None: + raise ValueError(f"theoretical_lesson est requis pour le type {self.type!r}") + return self + class AgendaDiff(BaseModel): """Représente les différences entre l'agenda réel et l'agenda théorique.""" @@ -37,4 +53,4 @@ class AgendaDiff(BaseModel): model_config = ConfigDict(frozen=True) target_date: date = Field(..., description="Date cible de la comparaison") - changes: list[AgendaChange] = Field(default_factory=list, description="Liste des changements") + changes: tuple[AgendaChange, ...] = Field(default=(), description="Liste des changements") diff --git a/pronote_sync/models/homework.py b/pronote_sync/models/homework.py index eeb96a0..3e4fb60 100644 --- a/pronote_sync/models/homework.py +++ b/pronote_sync/models/homework.py @@ -14,7 +14,7 @@ class Homework(BaseModel): id: str = Field(..., description="ID stable (hachage)") subject: str = Field(..., description="Matière") - teachers: list[str] = Field(default_factory=list, description="Liste des professeurs") + teachers: tuple[str, ...] = Field(default=(), description="Liste des professeurs") assigned_on: date | None = Field(None, description="Date de distribution") due_on: date = Field(..., description="Date d'échéance") text: str = Field(..., description="Texte du devoir (brut)") diff --git a/pronote_sync/models/sync.py b/pronote_sync/models/sync.py index d74fc90..177453a 100644 --- a/pronote_sync/models/sync.py +++ b/pronote_sync/models/sync.py @@ -36,7 +36,7 @@ class CalDAVSyncResult(BaseModel): """Résultat d'une synchronisation CalDAV (mutable).""" status: CalDAVSyncStatus = Field(..., description="Statut global") - added: int = Field(0, description="Nombre d'événements ajoutés") - updated: int = Field(0, description="Nombre d'événements mis à jour") - removed: int = Field(0, description="Nombre d'événements supprimés") + added: int = Field(0, ge=0, description="Nombre d'événements ajoutés") + updated: int = Field(0, ge=0, description="Nombre d'événements mis à jour") + removed: int = Field(0, ge=0, description="Nombre d'événements supprimés") errors: list[str] = Field(default_factory=list, description="Liste des erreurs") diff --git a/pronote_sync/models/xmpp.py b/pronote_sync/models/xmpp.py index 86b5bb2..80da9f6 100644 --- a/pronote_sync/models/xmpp.py +++ b/pronote_sync/models/xmpp.py @@ -19,13 +19,11 @@ class XmppMessage(BaseModel): target_date: date = Field(..., description="Date cible") synthesis: str | None = Field(None, description="Synthèse IA (optionnelle, 3-5 phrases)") - homeworks: list[Homework] = Field(default_factory=list, description="Liste brute des devoirs") - changes: list[AgendaChange] = Field( - default_factory=list, description="Liste des changements d'agenda" - ) - messages: list[Message] = Field( - default_factory=list, description="Liste des messages/informations" + homeworks: tuple[Homework, ...] = Field(default=(), description="Liste brute des devoirs") + changes: tuple[AgendaChange, ...] = Field( + default=(), description="Liste des changements d'agenda" ) + messages: tuple[Message, ...] = Field(default=(), description="Liste des messages/informations") external_info: ExternalInfo | None = Field( None, description="Informations externes (blog, messages Pronote)" ) diff --git a/tests/unit/test_models_construction.py b/tests/unit/test_models_construction.py new file mode 100644 index 0000000..3b6e772 --- /dev/null +++ b/tests/unit/test_models_construction.py @@ -0,0 +1,373 @@ +"""Tests de construction et sérialisation JSON des modèles Pydantic. + +Ce module vérifie que chaque modèle figé peut être sérialisé en JSON puis désérialisé +sans perte de données, garantissant la stabilité des contrats d'échange. + +Les tests utilisent des données minimales valides extraites des définitions de modèles. +""" + +from __future__ import annotations + +from datetime import date, datetime, time +from typing import Any + +import pytest +from pydantic import BaseModel + +from pronote_sync.models.agenda import ( + HomeworkBlock, + Lesson, + LessonStatus, + SchoolEvent, + SchoolEventKind, + TheoreticalLesson, +) +from pronote_sync.models.blog import BlogArticle, ExternalInfo +from pronote_sync.models.diff import AgendaChange, AgendaChangeType, AgendaDiff +from pronote_sync.models.homework import Homework +from pronote_sync.models.message import Message, MessageType +from pronote_sync.models.xmpp import XmppMessage + + +@pytest.mark.parametrize( + "model_class, kwargs", + [ + ( + Lesson, + { + "id": "lesson-001", + "start": datetime(2024, 9, 6, 8, 0, 0), + "end": datetime(2024, 9, 6, 9, 30, 0), + "subject": "Mathématiques", + "teachers": ("M. Dupont",), + "rooms": ("Salle 101",), + "group": "Classe A", + "status": LessonStatus.NORMAL, + "content": "Algèbre linéaire", + "homework_blocks": ( + HomeworkBlock(kind="due", date=date(2024, 9, 13), text="Exercice 42"), + ), + }, + ), + ( + TheoreticalLesson, + { + "id": "theo-lesson-001", + "day_of_week": 0, # Lundi + "start_time": time(8, 0, 0), + "end_time": time(9, 30, 0), + "subject": "Mathématiques", + "teachers": ("M. Dupont",), + "rooms": ("Salle 101",), + }, + ), + ( + SchoolEvent, + { + "kind": SchoolEventKind.HOLIDAY, + "label": "Vacances de la Toussaint", + "from_date": date(2024, 10, 19), + "to_date": date(2024, 11, 3), + }, + ), + ( + Homework, + { + "id": "hw-001", + "subject": "Mathématiques", + "teachers": ("M. Dupont",), + "assigned_on": date(2024, 9, 1), + "due_on": date(2024, 9, 15), + "text": "Exercice 42 à rendre", + "html": "
Exercice 42 à rendre
", + }, + ), + ( + AgendaChange, + { + "type": AgendaChangeType.ADDED, + "lesson": Lesson( + id="lesson-002", + start=datetime(2024, 9, 7, 10, 0, 0), + end=datetime(2024, 9, 7, 11, 30, 0), + subject="Physique", + teachers=("M. Martin",), + rooms=("Labo 205",), + group=None, + content=None, + ), + "theoretical_lesson": None, + "details": "Cours ajouté exceptionnellement", + }, + ), + ( + AgendaDiff, + { + "target_date": date(2024, 9, 6), + "changes": ( + AgendaChange( + type=AgendaChangeType.ADDED, + lesson=Lesson( + id="lesson-003", + start=datetime(2024, 9, 8, 14, 0, 0), + end=datetime(2024, 9, 8, 15, 30, 0), + subject="Histoire", + teachers=("Mme Bernard",), + rooms=("Salle 201",), + group=None, + content=None, + ), + theoretical_lesson=None, + ), + ), + }, + ), + ( + XmppMessage, + { + "target_date": date(2024, 9, 6), + "synthesis": "Aucun changement majeur aujourd'hui.", + "homeworks": ( + Homework( + id="hw-002", + subject="Physique", + teachers=("M. Martin",), + assigned_on=None, + due_on=date(2024, 9, 7), + text="TP à préparer", + ), + ), + "changes": ( + AgendaChange( + type=AgendaChangeType.MODIFIED, + lesson=Lesson( + id="lesson-004", + start=datetime(2024, 9, 6, 16, 0, 0), + end=datetime(2024, 9, 6, 17, 30, 0), + subject="SVT", + teachers=("M. Lefèvre",), + rooms=("Salle 302",), + group=None, + content=None, + ), + theoretical_lesson=None, + ), + ), + "messages": ( + Message( + id="msg-001", + type=MessageType.INFORMATION, + title="Rappel", + content="Réunion parents-professeurs demain", + author="Secrétariat", + date=datetime(2024, 9, 5, 10, 0, 0), + read=False, + ), + ), + "external_info": ExternalInfo(blog_articles=(), pronote_messages=(), other_info=()), + }, + ), + ( + BlogArticle, + { + "id": "article-001", + "title": "Sortie scolaire à Paris", + "url": "https://college.fr/blog/sortie-paris", + "published_at": datetime(2024, 9, 5, 14, 30, 0), + "updated_at": datetime(2024, 9, 5, 16, 45, 0), + "category": "Actualités", + "author": "M. Dubois", + "content_html": "Sortie prévue le 15 octobre
", + "content_text": "Sortie prévue le 15 octobre", + }, + ), + ( + ExternalInfo, + { + "blog_articles": ( + BlogArticle( + id="article-002", + title="Nouveau règlement intérieur", + url="https://college.fr/blog/reglement", + published_at=datetime(2024, 9, 1, 9, 0, 0), + updated_at=None, + category=None, + author=None, + content_html="Mise à jour du règlement
", + content_text="Mise à jour du règlement", + ), + ), + "pronote_messages": ( + Message( + id="msg-002", + type=MessageType.DISCUSSION, + title="Devoirs de maths", + content="Voir Pronote pour les détails", + author="Prof Maths", + date=datetime(2024, 9, 4, 8, 0, 0), + read=False, + ), + ), + "other_info": ("Info complémentaire 1", "Info complémentaire 2"), + }, + ), + ( + Message, + { + "id": "msg-003", + "type": MessageType.SURVEY, + "title": "Enquête de satisfaction", + "content": "Merci de répondre au questionnaire", + "author": "Direction", + "date": datetime(2024, 9, 5, 11, 0, 0), + "read": False, + }, + ), + ], +) +def test_model_json_roundtrip(model_class: type[BaseModel], kwargs: dict[str, Any]) -> None: + """Vérifie qu'un modèle peut être sérialisé en JSON puis désérialisé sans perte. + + :param model_class: Classe du modèle à tester. + :type model_class: type[BaseModel] + :param kwargs: Arguments de constructeur valides. + :type kwargs: dict[str, Any] + """ + # Construction de l'instance + instance = model_class(**kwargs) + + # Sérialisation JSON + json_str = instance.model_dump_json() + + # Désérialisation depuis JSON + roundtrip_instance = model_class.model_validate_json(json_str) + + # Vérification des champs clés (comparaison par valeur) + for field_name in kwargs: + if field_name in ( + "start", + "end", + "start_time", + "end_time", + "published_at", + "updated_at", + "date", + "due_on", + "assigned_on", + "generated_at", + ): + # Pour les dates/heures, comparer les objets directement + assert getattr(instance, field_name) == getattr(roundtrip_instance, field_name), ( + f"Le champ {field_name} ne correspond pas après round-trip" + ) + else: + # Pour les autres champs, comparer directement + assert getattr(instance, field_name) == getattr(roundtrip_instance, field_name), ( + f"Le champ {field_name} ne correspond pas après round-trip" + ) + + +def test_lesson_status_enum_values() -> None: + """Vérifie que les valeurs de l'enum LessonStatus sont correctes. + + :raises ValidationError: Si une valeur invalide est utilisée. + """ + # Test des valeurs valides + for status in [LessonStatus.NORMAL, LessonStatus.CANCELLED, LessonStatus.MOVED]: + assert isinstance(status.value, str) + Lesson( + id="test", + start=datetime(2024, 9, 6, 8, 0), + end=datetime(2024, 9, 6, 9, 0), + subject="Test", + status=status, + group=None, + content=None, + ) + + +def test_school_event_kind_enum_values() -> None: + """Vérifie que les valeurs de l'enum SchoolEventKind sont correctes. + + :raises ValidationError: Si une valeur invalide est utilisée. + """ + # Test des valeurs valides + for kind in [SchoolEventKind.HOLIDAY, SchoolEventKind.PUBLIC_HOLIDAY]: + assert isinstance(kind.value, str) + SchoolEvent( + kind=kind, + label="Test", + from_date=date(2024, 9, 1), + to_date=date(2024, 9, 2), + ) + + +def test_message_type_enum_values() -> None: + """Vérifie que les valeurs de l'enum MessageType sont correctes. + + :raises ValidationError: Si une valeur invalide est utilisée. + """ + # Test des valeurs valides + for msg_type in [MessageType.DISCUSSION, MessageType.INFORMATION, MessageType.SURVEY]: + assert isinstance(msg_type.value, str) + Message( + id="test", + type=msg_type, + title="Test", + content="Test", + author="Test", + date=datetime(2024, 9, 1, 8, 0), + read=False, + ) + + +def test_agenda_change_type_enum_values() -> None: + """Vérifie que les valeurs de l'enum AgendaChangeType sont correctes. + + :raises ValidationError: Si une valeur invalide est utilisée. + """ + # Test des valeurs valides + for change_type in [ + AgendaChangeType.ADDED, + AgendaChangeType.REMOVED, + AgendaChangeType.MODIFIED, + ]: + assert isinstance(change_type.value, str) + if change_type == AgendaChangeType.ADDED: + AgendaChange( + type=change_type, + lesson=Lesson( + id="test", + start=datetime(2024, 9, 6, 8, 0), + end=datetime(2024, 9, 6, 9, 0), + subject="Test", + group=None, + content=None, + ), + theoretical_lesson=None, + ) + elif change_type == AgendaChangeType.REMOVED: + AgendaChange( + type=change_type, + lesson=None, + theoretical_lesson=TheoreticalLesson( + id="test", + day_of_week=0, + start_time=time(8, 0, 0), + end_time=time(9, 0, 0), + subject="Test", + ), + ) + else: + AgendaChange( + type=change_type, + lesson=Lesson( + id="test", + start=datetime(2024, 9, 6, 8, 0), + end=datetime(2024, 9, 6, 9, 0), + subject="Test", + group=None, + content=None, + ), + theoretical_lesson=None, + ) diff --git a/tests/unit/test_models_immutability.py b/tests/unit/test_models_immutability.py new file mode 100644 index 0000000..c55b999 --- /dev/null +++ b/tests/unit/test_models_immutability.py @@ -0,0 +1,409 @@ +"""Tests d'immuabilité des modèles Pydantic figés. + +Ce module vérifie que les modèles marqués comme figés (frozen=True) ne peuvent pas +être modifiés après construction, et que leurs collections sont bien des tuples. +""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import Any + +import pytest +from pydantic import BaseModel, ValidationError + +from pronote_sync.models.agenda import HomeworkBlock, Lesson, LessonStatus +from pronote_sync.models.blog import BlogArticle, ExternalInfo +from pronote_sync.models.diff import AgendaChange, AgendaChangeType +from pronote_sync.models.homework import Homework +from pronote_sync.models.message import Message, MessageType +from pronote_sync.models.pronote import PronoteData +from pronote_sync.models.sync import CalDAVSyncResult, CalDAVSyncStatus +from pronote_sync.models.xmpp import XmppMessage + + +@pytest.mark.parametrize( + "model_class, kwargs", + [ + ( + Lesson, + { + "id": "lesson-immutability", + "start": datetime(2024, 9, 6, 8, 0, 0), + "end": datetime(2024, 9, 6, 9, 30, 0), + "subject": "Mathématiques", + "teachers": ("M. Dupont", "Mme Martin"), + "rooms": ("Salle 101",), + "status": LessonStatus.NORMAL, + }, + ), + ( + Homework, + { + "id": "hw-immutability", + "subject": "Physique", + "teachers": ("M. Martin",), + "due_on": date(2024, 9, 15), + "text": "Exercice à rendre", + }, + ), + ( + AgendaChange, + { + "type": AgendaChangeType.ADDED, + "lesson": Lesson( + id="lesson-ref", + start=datetime(2024, 9, 6, 10, 0, 0), + end=datetime(2024, 9, 6, 11, 30, 0), + subject="Histoire", + teachers=("Mme Bernard",), + rooms=("Salle 201",), + group=None, + content=None, + ), + }, + ), + ( + XmppMessage, + { + "target_date": date(2024, 9, 6), + "synthesis": "Test immuabilité", + }, + ), + ( + HomeworkBlock, + { + "kind": "due", + "date": date(2024, 9, 15), + "text": "Devoir à faire", + }, + ), + ( + ExternalInfo, + { + "blog_articles": ( + BlogArticle( + id="article-1", + title="Article 1", + url="https://example.com/1", + published_at=datetime(2024, 9, 1, 8, 0, 0), + updated_at=None, + category=None, + author=None, + content_html="Contenu
", + content_text="Contenu", + ), + ), + "pronote_messages": ( + Message( + id="msg-1", + type=MessageType.INFORMATION, + title="Message 1", + content="Message", + author="Auteur", + date=datetime(2024, 9, 1, 8, 0, 0), + read=False, + ), + ), + }, + ), + ], +) +def test_frozen_model_rejects_field_assignment( + model_class: type[BaseModel], kwargs: dict[str, Any] +) -> None: + """Vérifie qu'un modèle figé rejette toute tentative d'assignation de champ. + + :param model_class: Classe du modèle figé. + :type model_class: type[BaseModel] + :param kwargs: Arguments de constructeur valides. + :type kwargs: dict[str, Any] + """ + instance: Any = model_class(**kwargs) + + # Tentative d'assignation directe sur un champ + with pytest.raises(ValidationError) as exc_info: + if model_class == Lesson: + instance.subject = "Nouveau sujet" + elif model_class == Homework: + instance.subject = "Nouveau sujet" + elif model_class == AgendaChange: + instance.details = "Nouveaux détails" + elif model_class == XmppMessage: + instance.synthesis = "Nouvelle synthèse" + elif model_class == HomeworkBlock: + instance.text = "Nouveau texte" + elif model_class == ExternalInfo: + instance.other_info = ("Nouvelle info",) + + error_msg = str(exc_info.value).lower() + assert "instance is frozen" in error_msg or "immutable" in error_msg + + +@pytest.mark.parametrize( + "model_class, kwargs, field_name, expected_type", + [ + ( + Lesson, + { + "id": "lesson-tuple", + "start": datetime(2024, 9, 6, 8, 0), + "end": datetime(2024, 9, 6, 9, 0), + "subject": "Test", + }, + "teachers", + tuple, + ), + ( + Lesson, + { + "id": "lesson-tuple2", + "start": datetime(2024, 9, 6, 8, 0), + "end": datetime(2024, 9, 6, 9, 0), + "subject": "Test", + }, + "rooms", + tuple, + ), + ( + Lesson, + { + "id": "lesson-tuple3", + "start": datetime(2024, 9, 6, 8, 0), + "end": datetime(2024, 9, 6, 9, 0), + "subject": "Test", + "homework_blocks": (), + }, + "homework_blocks", + tuple, + ), + ( + Homework, + { + "id": "hw-tuple", + "subject": "Test", + "teachers": ("Prof 1",), + "due_on": date(2024, 9, 15), + "text": "Devoir", + }, + "teachers", + tuple, + ), + ( + AgendaChange, + { + "type": AgendaChangeType.ADDED, + "lesson": Lesson( + id="ref", + start=datetime(2024, 9, 6, 8, 0), + end=datetime(2024, 9, 6, 9, 0), + subject="Test", + group=None, + content=None, + ), + }, + "details", + str, + ), + (XmppMessage, {"target_date": date(2024, 9, 6)}, "homeworks", tuple), + (XmppMessage, {"target_date": date(2024, 9, 6)}, "changes", tuple), + (XmppMessage, {"target_date": date(2024, 9, 6)}, "messages", tuple), + ( + ExternalInfo, + {"blog_articles": (), "pronote_messages": (), "other_info": ()}, + "blog_articles", + tuple, + ), + ( + ExternalInfo, + {"blog_articles": (), "pronote_messages": (), "other_info": ()}, + "pronote_messages", + tuple, + ), + ( + ExternalInfo, + {"blog_articles": (), "pronote_messages": (), "other_info": ()}, + "other_info", + tuple, + ), + ], +) +def test_collection_fields_are_tuples( + model_class: type[BaseModel], + kwargs: dict[str, Any], + field_name: str, + expected_type: type[Any], +) -> None: + """Vérifie que les champs de collection sont bien des tuples et non des listes. + + :param model_class: Classe du modèle. + :type model_class: type[BaseModel] + :param kwargs: Arguments de constructeur valides. + :type kwargs: dict[str, Any] + :param field_name: Nom du champ de collection à vérifier. + :type field_name: str + :param expected_type: Type attendu (tuple). + :type expected_type: type + """ + instance = model_class(**kwargs) + field_value = getattr(instance, field_name) + assert isinstance(field_value, expected_type), ( + f"Le champ {field_name} devrait être de type {expected_type.__name__}, " + f"got {type(field_value).__name__}" + ) + + +@pytest.mark.parametrize( + "model_class, kwargs, field_name", + [ + ( + Lesson, + { + "id": "lesson-no-append", + "start": datetime(2024, 9, 6, 8, 0), + "end": datetime(2024, 9, 6, 9, 0), + "subject": "Test", + }, + "teachers", + ), + ( + Lesson, + { + "id": "lesson-no-append2", + "start": datetime(2024, 9, 6, 8, 0), + "end": datetime(2024, 9, 6, 9, 0), + "subject": "Test", + }, + "rooms", + ), + ( + Homework, + { + "id": "hw-no-append", + "subject": "Test", + "teachers": ("Prof 1",), + "due_on": date(2024, 9, 15), + "text": "Devoir", + }, + "teachers", + ), + ( + ExternalInfo, + {"blog_articles": (), "pronote_messages": (), "other_info": ()}, + "blog_articles", + ), + ( + ExternalInfo, + {"blog_articles": (), "pronote_messages": (), "other_info": ()}, + "pronote_messages", + ), + ( + ExternalInfo, + {"blog_articles": (), "pronote_messages": (), "other_info": ()}, + "other_info", + ), + ], +) +def test_collection_fields_reject_append( + model_class: type[BaseModel], kwargs: dict[str, Any], field_name: str +) -> None: + """Vérifie que les champs de collection (tuples) rejettent la méthode append. + + :param model_class: Classe du modèle. + :type model_class: type[BaseModel] + :param kwargs: Arguments de constructeur valides. + :type kwargs: dict[str, Any] + :param field_name: Nom du champ de collection. + :type field_name: str + """ + instance = model_class(**kwargs) + + with pytest.raises(AttributeError) as exc_info: + collection = getattr(instance, field_name) + collection.append("new_item") + + assert "'tuple' object has no attribute 'append'" in str(exc_info.value) + + +@pytest.mark.parametrize( + "model_class, kwargs, field_name, new_value", + [ + (CalDAVSyncResult, {"status": CalDAVSyncStatus.SUCCESS}, "added", 5), + (CalDAVSyncResult, {"status": CalDAVSyncStatus.SUCCESS}, "updated", 3), + (CalDAVSyncResult, {"status": CalDAVSyncStatus.SUCCESS}, "removed", 2), + ( + CalDAVSyncResult, + {"status": CalDAVSyncStatus.FAILED}, + "errors", + ["Erreur 1", "Erreur 2"], + ), + ], +) +def test_mutable_models_accept_field_assignment( + model_class: type[BaseModel], + kwargs: dict[str, Any], + field_name: str, + new_value: Any, +) -> None: + """Vérifie que les modèles mutables (non figés) acceptent l'assignation de champs. + + :param model_class: Classe du modèle mutable. + :type model_class: type[BaseModel] + :param kwargs: Arguments de constructeur valides. + :type kwargs: dict[str, Any] + :param field_name: Nom du champ à assigner. + :type field_name: str + :param new_value: Nouvelle valeur à assigner. + :type new_value: Any + """ + instance = model_class(**kwargs) + + # Assignation du champ + setattr(instance, field_name, new_value) + + # Vérification que la valeur a bien été assignée + assert getattr(instance, field_name) == new_value, ( + f"Le champ {field_name} n'a pas été correctement assigné" + ) + + +@pytest.mark.parametrize( + "model_class, kwargs", + [ + ( + PronoteData, + { + "target_date": date(2024, 9, 6), + "generated_at": datetime(2024, 9, 6, 8, 0, 0), + }, + ), + (CalDAVSyncResult, {"status": CalDAVSyncStatus.SUCCESS}), + ], +) +def test_mutable_models_reject_frozen_assignment( + model_class: type[BaseModel], kwargs: dict[str, Any] +) -> None: + """Vérifie que les modèles mutables peuvent être modifiés mais pas rendus figés. + + Note: Les modèles mutables comme PronoteData et CalDAVSyncResult n'ont pas + ConfigDict(frozen=True), donc ils peuvent être modifiés. + + :param model_class: Classe du modèle mutable. + :type model_class: type[BaseModel] + :param kwargs: Arguments de constructeur valides. + :type kwargs: dict[str, Any] + """ + from pronote_sync.models.pronote import PronoteData + + instance: Any = model_class(**kwargs) + + # Assignation normale doit fonctionner + if model_class == PronoteData: + instance.lessons.append("dummy") + else: + instance.added = 10 + + # Vérification que l'instance n'est pas figée + assert not hasattr(instance.model_config, "frozen") or not instance.model_config.get( + "frozen", False + ) diff --git a/tests/unit/test_models_invariants.py b/tests/unit/test_models_invariants.py new file mode 100644 index 0000000..6f15e9a --- /dev/null +++ b/tests/unit/test_models_invariants.py @@ -0,0 +1,287 @@ +"""Tests d'invariants et de validation des modèles Pydantic. + +Ce module vérifie que les validateurs de modèle rejettent les données invalides +conformément aux spécifications (ex: ordre des dates, plages de valeurs). +""" + +from __future__ import annotations + +from datetime import date, datetime, time + +import pytest +from pydantic import ValidationError + +from pronote_sync.models.agenda import ( + Lesson, + SchoolEvent, + SchoolEventKind, + TheoreticalLesson, +) +from pronote_sync.models.diff import AgendaChange, AgendaChangeType +from pronote_sync.models.sync import CalDAVSyncResult, CalDAVSyncStatus + + +class TestTheoreticalLessonDayOfWeek: + """Tests de validation du champ day_of_week pour TheoreticalLesson.""" + + def test_day_of_week_7_invalid(self) -> None: + """Vérifie que day_of_week=7 lève une ValidationError.""" + with pytest.raises(ValidationError): + TheoreticalLesson( + id="test", + day_of_week=7, + start_time=time(8, 0, 0), + end_time=time(9, 30, 0), + subject="Mathématiques", + ) + + def test_day_of_week_minus_1_invalid(self) -> None: + """Vérifie que day_of_week=-1 lève une ValidationError.""" + with pytest.raises(ValidationError) as exc_info: + TheoreticalLesson( + id="test", + day_of_week=-1, + start_time=time(8, 0, 0), + end_time=time(9, 30, 0), + subject="Mathématiques", + ) + assert any( + "greater than or equal to 0" in str(error).lower() or "le 6" in str(error).lower() + for error in exc_info.value.errors() + ) + + def test_day_of_week_0_valid(self) -> None: + """Vérifie que day_of_week=0 (lundi) est valide.""" + instance = TheoreticalLesson( + id="test", + day_of_week=0, + start_time=time(8, 0, 0), + end_time=time(9, 30, 0), + subject="Mathématiques", + ) + assert instance.day_of_week == 0 + + def test_day_of_week_6_valid(self) -> None: + """Vérifie que day_of_week=6 (dimanche) est valide.""" + instance = TheoreticalLesson( + id="test", + day_of_week=6, + start_time=time(8, 0, 0), + end_time=time(9, 30, 0), + subject="Mathématiques", + ) + assert instance.day_of_week == 6 + + +class TestLessonTimeOrder: + """Tests de validation de l'ordre temporel pour Lesson.""" + + def test_lesson_end_before_start_invalid(self) -> None: + """Vérifie que end <= start lève une ValidationError.""" + with pytest.raises(ValidationError) as exc_info: + Lesson( + id="test", + start=datetime(2024, 9, 6, 10, 0, 0), + end=datetime(2024, 9, 6, 9, 0, 0), # end avant start + subject="Mathématiques", + group=None, + content=None, + ) + assert any( + "end doit être postérieur à start" in str(error) for error in exc_info.value.errors() + ) + + def test_lesson_end_after_start_valid(self) -> None: + """Vérifie que end > start est valide.""" + instance = Lesson( + id="test", + start=datetime(2024, 9, 6, 8, 0, 0), + end=datetime(2024, 9, 6, 9, 30, 0), + subject="Mathématiques", + group=None, + content=None, + ) + assert instance.end > instance.start + + +class TestTheoreticalLessonTimeOrder: + """Tests de validation de l'ordre temporel pour TheoreticalLesson.""" + + def test_theoretical_lesson_end_time_before_start_time_invalid(self) -> None: + """Vérifie que end_time <= start_time lève une ValidationError.""" + with pytest.raises(ValidationError) as exc_info: + TheoreticalLesson( + id="test", + day_of_week=0, + start_time=time(9, 0, 0), + end_time=time(8, 0, 0), # end_time avant start_time + subject="Mathématiques", + ) + assert any( + "end_time doit être postérieur à start_time" in str(error) + for error in exc_info.value.errors() + ) + + def test_theoretical_lesson_end_time_after_start_time_valid(self) -> None: + """Vérifie que end_time > start_time est valide.""" + instance = TheoreticalLesson( + id="test", + day_of_week=0, + start_time=time(8, 0, 0), + end_time=time(9, 30, 0), + subject="Mathématiques", + ) + assert instance.end_time > instance.start_time + + +class TestSchoolEventDateOrder: + """Tests de validation de l'ordre des dates pour SchoolEvent.""" + + def test_school_event_to_date_before_from_date_invalid(self) -> None: + """Vérifie que to_date < from_date lève une ValidationError.""" + with pytest.raises(ValidationError) as exc_info: + SchoolEvent( + kind=SchoolEventKind.HOLIDAY, + label="Test", + from_date=date(2024, 10, 20), + to_date=date(2024, 10, 19), # to_date avant from_date + ) + assert any( + "to_date doit être postérieur ou égal à from_date" in str(error) + for error in exc_info.value.errors() + ) + + def test_school_event_to_date_equal_from_date_valid(self) -> None: + """Vérifie que to_date == from_date est valide.""" + instance = SchoolEvent( + kind=SchoolEventKind.HOLIDAY, + label="Test", + from_date=date(2024, 10, 20), + to_date=date(2024, 10, 20), # égal + ) + assert instance.to_date >= instance.from_date + + def test_school_event_to_date_after_from_date_valid(self) -> None: + """Vérifie que to_date > from_date est valide.""" + instance = SchoolEvent( + kind=SchoolEventKind.HOLIDAY, + label="Test", + from_date=date(2024, 10, 20), + to_date=date(2024, 10, 25), + ) + assert instance.to_date >= instance.from_date + + +class TestAgendaChangeConsistency: + """Tests de cohérence des changements d'agenda.""" + + def test_agenda_change_added_without_lesson_invalid(self) -> None: + """Vérifie que type=ADDED avec lesson=None lève une ValidationError.""" + with pytest.raises(ValidationError) as exc_info: + AgendaChange(type=AgendaChangeType.ADDED, lesson=None, theoretical_lesson=None) + assert any( + "lesson est requis pour le type" in str(error) for error in exc_info.value.errors() + ) + + def test_agenda_change_removed_without_theoretical_lesson_invalid(self) -> None: + """Vérifie que type=REMOVED avec theoretical_lesson=None lève une ValidationError.""" + with pytest.raises(ValidationError) as exc_info: + AgendaChange( + type=AgendaChangeType.REMOVED, + lesson=None, + theoretical_lesson=None, + ) + assert any( + "theoretical_lesson est requis pour le type" in str(error) + for error in exc_info.value.errors() + ) + + def test_agenda_change_modified_without_lesson_invalid(self) -> None: + """Vérifie que type=MODIFIED avec lesson=None lève une ValidationError.""" + with pytest.raises(ValidationError) as exc_info: + AgendaChange(type=AgendaChangeType.MODIFIED, lesson=None, theoretical_lesson=None) + assert any( + "lesson est requis pour le type" in str(error) for error in exc_info.value.errors() + ) + + def test_agenda_change_added_with_lesson_valid(self) -> None: + """Vérifie que type=ADDED avec lesson=