fix(M3): immuabilité et validation des modèles — correction des écarts FIXME_M3
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>
This commit is contained in:
373
tests/unit/test_models_construction.py
Normal file
373
tests/unit/test_models_construction.py
Normal file
@@ -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": "<p>Exercice 42 à rendre</p>",
|
||||
},
|
||||
),
|
||||
(
|
||||
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": "<p>Sortie prévue le 15 octobre</p>",
|
||||
"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="<p>Mise à jour du règlement</p>",
|
||||
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,
|
||||
)
|
||||
409
tests/unit/test_models_immutability.py
Normal file
409
tests/unit/test_models_immutability.py
Normal file
@@ -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="<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
|
||||
)
|
||||
287
tests/unit/test_models_invariants.py
Normal file
287
tests/unit/test_models_invariants.py
Normal file
@@ -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=<valide> est valide."""
|
||||
lesson = Lesson(
|
||||
id="lesson-valid",
|
||||
start=datetime(2024, 9, 6, 8, 0, 0),
|
||||
end=datetime(2024, 9, 6, 9, 30, 0),
|
||||
subject="Mathématiques",
|
||||
group=None,
|
||||
content=None,
|
||||
)
|
||||
instance = AgendaChange(
|
||||
type=AgendaChangeType.ADDED,
|
||||
lesson=lesson,
|
||||
theoretical_lesson=None,
|
||||
)
|
||||
assert instance.type == AgendaChangeType.ADDED
|
||||
assert instance.lesson is not None
|
||||
|
||||
def test_agenda_change_removed_with_theoretical_lesson_valid(self) -> None:
|
||||
"""Vérifie que type=REMOVED avec theoretical_lesson=<valide> est valide."""
|
||||
theoretical_lesson = TheoreticalLesson(
|
||||
id="theo-lesson-valid",
|
||||
day_of_week=0,
|
||||
start_time=time(8, 0, 0),
|
||||
end_time=time(9, 30, 0),
|
||||
subject="Mathématiques",
|
||||
)
|
||||
instance = AgendaChange(
|
||||
type=AgendaChangeType.REMOVED,
|
||||
lesson=None,
|
||||
theoretical_lesson=theoretical_lesson,
|
||||
)
|
||||
assert instance.type == AgendaChangeType.REMOVED
|
||||
assert instance.theoretical_lesson is not None
|
||||
|
||||
def test_agenda_change_modified_with_lesson_valid(self) -> None:
|
||||
"""Vérifie que type=MODIFIED avec lesson=<valide> est valide."""
|
||||
lesson = Lesson(
|
||||
id="lesson-valid-mod",
|
||||
start=datetime(2024, 9, 6, 10, 0, 0),
|
||||
end=datetime(2024, 9, 6, 11, 30, 0),
|
||||
subject="Physique",
|
||||
group=None,
|
||||
content=None,
|
||||
)
|
||||
instance = AgendaChange(
|
||||
type=AgendaChangeType.MODIFIED,
|
||||
lesson=lesson,
|
||||
theoretical_lesson=None,
|
||||
)
|
||||
assert instance.type == AgendaChangeType.MODIFIED
|
||||
assert instance.lesson is not None
|
||||
|
||||
|
||||
class TestCalDAVSyncResultInvariants:
|
||||
"""Tests de validation des champs numériques de CalDAVSyncResult."""
|
||||
|
||||
def test_caldav_sync_result_added_negative_invalid(self) -> None:
|
||||
"""Vérifie que added=-1 lève une ValidationError."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=-1, updated=0, removed=0)
|
||||
assert any("greater than or equal to 0" in str(error) for error in exc_info.value.errors())
|
||||
|
||||
def test_caldav_sync_result_updated_negative_invalid(self) -> None:
|
||||
"""Vérifie que updated=-1 lève une ValidationError."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=0, updated=-1, removed=0)
|
||||
assert any("greater than or equal to 0" in str(error) for error in exc_info.value.errors())
|
||||
|
||||
def test_caldav_sync_result_removed_negative_invalid(self) -> None:
|
||||
"""Vérifie que removed=-1 lève une ValidationError."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=0, updated=0, removed=-1)
|
||||
assert any("greater than or equal to 0" in str(error) for error in exc_info.value.errors())
|
||||
|
||||
def test_caldav_sync_result_zero_values_valid(self) -> None:
|
||||
"""Vérifie que les valeurs à zéro sont valides."""
|
||||
instance = CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=0, updated=0, removed=0)
|
||||
assert instance.added == 0
|
||||
assert instance.updated == 0
|
||||
assert instance.removed == 0
|
||||
Reference in New Issue
Block a user