Immuabilité : - Collections des modèles frozen : list → tuple (Lesson, TheoreticalLesson, SchoolEvent, AgendaDiff, Homework, XmppMessage, ExternalInfo) - Gel de HomeworkBlock et ExternalInfo (sous-modèles imbriqués dans contrats) - Valeurs par défaut immuables : Field(default=(), ...) Validation de domaine : - TheoreticalLesson.day_of_week : Field(ge=0, le=6) - Lesson/TheoreticalLesson : start < end (validateur de modèle) - SchoolEvent : from_date <= to_date - AgendaChange : cohérence type/payload (ADDED/MODIFIED → lesson, REMOVED → theoretical_lesson) - CalDAVSyncResult : compteurs Field(ge=0) sur added/updated/removed Tests : 66 tests (construction/sérialisation, immuabilité, invariants) couvrant le gel, la mutabilité légitime, et les cas invalides par invariant. Co-authored-by: opencode/coder <coder@agents.invalid> Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
410 lines
13 KiB
Python
410 lines
13 KiB
Python
"""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="<p>Contenu</p>",
|
|
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
|
|
)
|