"""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 )